From 17b2a5d8355802a324f0f0ebbcbb0aead0a630f1 Mon Sep 17 00:00:00 2001 From: James Pattinson Date: Sat, 20 Jun 2026 13:36:19 -0400 Subject: [PATCH] And now with files --- README.md | 1 + docker-compose.yml | 1 + src/components/BannerRotator.astro | 78 ++++++++++++++ src/components/DroneFlightRequestForm.astro | 25 ++--- src/components/FacebookWidget.astro | 103 ++++++++++++++++++ src/components/FuelPricesWidget.astro | 79 ++++++++++++-- src/components/PprRequestForm.astro | 4 +- src/layouts/BaseLayout.astro | 23 ++--- src/lib/directus.ts | 93 +++++++++++++++-- src/lib/events.ts | 24 +++++ src/lib/fallback-data.ts | 25 +++++ src/lib/site.ts | 1 + src/pages/about/cafe.astro | 100 ++++++++++++++++++ src/pages/about/index.astro | 10 +- src/pages/events/index.astro | 3 +- src/pages/index.astro | 109 +++++++------------- src/pages/pilot-info.astro | 20 +--- src/styles/global.css | 93 ++++++++++++----- 18 files changed, 631 insertions(+), 161 deletions(-) create mode 100644 src/components/BannerRotator.astro create mode 100644 src/components/FacebookWidget.astro create mode 100644 src/lib/events.ts create mode 100644 src/pages/about/cafe.astro diff --git a/README.md b/README.md index 59a89a5..8e23862 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Production-ready airfield website stack built with Astro, Directus, PostgreSQL, - All deploy-time variables live in `.env`. - `PUBLIC_PPR_API_BASE` controls the browser-side PPR API base URL; PPR and drone request endpoints are derived from it at build/dev-server startup. +- `DIRECTUS_HOMEPAGE_BANNER_FOLDER` names the Directus file folder used for rotating homepage banner images. If the folder is missing or empty, the site falls back to `/images/banner.png`. - The frontend service bind-mounts the project into `/app`, keeps `node_modules` and `.astro` in named volumes, and serves the site with `astro dev` on the published frontend port. - Layout and page structure are controlled entirely by Astro. - Frontend source edits should appear without rebuilding the container image. diff --git a/docker-compose.yml b/docker-compose.yml index 254c1bd..ae531c8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -74,6 +74,7 @@ services: PUBLIC_PPR_API_BASE: ${PUBLIC_PPR_API_BASE} DIRECTUS_URL: ${DIRECTUS_URL} DIRECTUS_PUBLIC_URL: ${DIRECTUS_PUBLIC_URL} + DIRECTUS_HOMEPAGE_BANNER_FOLDER: ${DIRECTUS_HOMEPAGE_BANNER_FOLDER} DIRECTUS_PORT: ${DIRECTUS_PORT} DIRECTUS_ADMIN_TOKEN: ${DIRECTUS_ADMIN_TOKEN} depends_on: diff --git a/src/components/BannerRotator.astro b/src/components/BannerRotator.astro new file mode 100644 index 0000000..8d66b92 --- /dev/null +++ b/src/components/BannerRotator.astro @@ -0,0 +1,78 @@ +--- +import type { HomepageBannerImage } from '../lib/fallback-data'; + +type Props = { + images: HomepageBannerImage[]; +}; + +const { images } = Astro.props; +const slides = images.length > 0 ? images : [{ src: '/images/banner.png', alt: 'Swansea Airport banner' }]; +--- + + + +{slides.length > 1 && ( + +)} + + diff --git a/src/components/DroneFlightRequestForm.astro b/src/components/DroneFlightRequestForm.astro index 5557282..f1f10a7 100644 --- a/src/components/DroneFlightRequestForm.astro +++ b/src/components/DroneFlightRequestForm.astro @@ -13,7 +13,9 @@ const frzGeoJsonEndpoint = `${pprApiBase}/drone-requests/frz`; Tell us when and where you plan to fly. Pick the operating location on the map or enter it manually, then click Submit. We will review your request and contact you if we need any further information.

- +

+ If your flight is outside the FRZ, you do not need to submit a request, but it would still be appreciated if it is in the vicinity. Please follow the local drone guidance and fly safely. +

@@ -30,6 +32,16 @@ const frzGeoJsonEndpoint = `${pprApiBase}/drone-requests/frz`; +
+ + +
+ +
+ + +
+
@@ -93,17 +105,6 @@ const frzGeoJsonEndpoint = `${pprApiBase}/drone-requests/frz`;
- -
- - -
- -
- - -
-
diff --git a/src/components/FacebookWidget.astro b/src/components/FacebookWidget.astro new file mode 100644 index 0000000..ce8b60c --- /dev/null +++ b/src/components/FacebookWidget.astro @@ -0,0 +1,103 @@ +--- +import SectionHeading from './SectionHeading.astro'; + +const facebookUrl = 'https://www.facebook.com/swanseaairportofficial'; +--- + + + + diff --git a/src/components/FuelPricesWidget.astro b/src/components/FuelPricesWidget.astro index 50a6706..c00b0d6 100644 --- a/src/components/FuelPricesWidget.astro +++ b/src/components/FuelPricesWidget.astro @@ -12,15 +12,27 @@ function formatFuelPrice(value: unknown): string { const numeric = Number(value); return Number.isFinite(numeric) ? numeric.toFixed(2) : '0.00'; } + +function formatVatLabel(value: unknown): string { + return value === true || value === 1 || value === '1' || value === 'true' ? 'Inc VAT' : 'Ex VAT'; +} ---
- +
{fuelPrices.map((fuel) => (
-

{fuel.fuel_type}

-

{fuel.currency} {formatFuelPrice(fuel.price_per_litre)} / litre

+
+

{fuel.fuel_type}

+
+

+ {fuel.currency} {formatFuelPrice(fuel.price_per_litre)} + / litre + {formatVatLabel(fuel.inc_vat)} +

+
+
{fuel.notes &&

{fuel.notes}

}
))} @@ -34,22 +46,40 @@ function formatFuelPrice(value: unknown): string { .fuel-card { border: 1px solid rgba(11, 79, 122, 0.18); - background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(232, 243, 253, 0.94)); + background: + linear-gradient(90deg, rgba(11, 79, 122, 0.11), transparent 38%), + linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(232, 243, 253, 0.94)); } - .fuel-type-pill { - width: fit-content; - margin: 0 0 0.7rem; - padding: 0.35rem 0.85rem; - font-size: 0.83rem; - letter-spacing: 0.08em; + .fuel-row { + display: grid; + grid-template-columns: minmax(7rem, 0.75fr) minmax(0, 1.25fr); + gap: 1rem; + align-items: center; + } + + .fuel-type { + margin: 0; + font-size: clamp(1.45rem, 2.5vw, 2rem); + line-height: 1; text-transform: uppercase; font-weight: 800; - background: linear-gradient(180deg, rgba(11, 79, 122, 0.17), rgba(29, 118, 184, 0.15)); color: var(--brand); + overflow-wrap: anywhere; + } + + .fuel-price-group { + display: grid; + justify-items: end; + text-align: right; } .fuel-price { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + align-items: baseline; + gap: 0.42rem; margin: 0; font-size: clamp(1.7rem, 3vw, 2.25rem); line-height: 1.1; @@ -61,4 +91,31 @@ function formatFuelPrice(value: unknown): string { font-weight: 600; color: var(--muted); } + + .vat-label { + display: inline-flex; + align-items: center; + padding: 0.28rem 0.7rem; + border-radius: 999px; + background: rgba(11, 79, 122, 0.1); + color: var(--brand); + font-size: 0.82rem; + font-weight: 800; + } + + @media (max-width: 560px) { + .fuel-row { + grid-template-columns: 1fr; + gap: 0.75rem; + } + + .fuel-price-group { + justify-items: start; + text-align: left; + } + + .fuel-price { + justify-content: flex-start; + } + } diff --git a/src/components/PprRequestForm.astro b/src/components/PprRequestForm.astro index 3143e7d..8678209 100644 --- a/src/components/PprRequestForm.astro +++ b/src/components/PprRequestForm.astro @@ -12,8 +12,8 @@ const pprApiBase = configuredApiBase.replace(/\/$/, ''); the airport will contact you if additional information is required.

- This form is under test. If you have any issues, email - james.pattinson@sasalliance.org. + If you have any issues, email + james.pattinson@sasalliance.org - our resident IT nerd!

diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 072c65e..6236021 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -1,5 +1,5 @@ --- -import { homepageHighlights, site } from '../lib/site'; +import { site } from '../lib/site'; import '../styles/global.css'; type Props = { @@ -77,22 +77,19 @@ const pageTitle = title ? `${title} ยท ${site.name}` : site.name; diff --git a/src/lib/directus.ts b/src/lib/directus.ts index 6352b25..e6aa74a 100644 --- a/src/lib/directus.ts +++ b/src/lib/directus.ts @@ -1,14 +1,17 @@ import { + fallbackCafePageImages, fallbackContacts, fallbackDocuments, fallbackEvents, fallbackFuelPrices, + fallbackHomepageBannerImages, fallbackNews, fallbackNotices, type ContactItem, type DocumentItem, type EventItem, type FuelPrice, + type HomepageBannerImage, type NewsItem, type Notice, } from './fallback-data'; @@ -19,7 +22,7 @@ const defaultSortByCollection: Partial> = { news: '-publish_date', events: '-start_datetime', notices: '-priority', - fuel_prices: '-last_updated', + fuel_prices: 'fuel_type', documents: '-uploaded_at', contacts: 'order', }; @@ -29,7 +32,30 @@ declare const process: { }; const directusUrl = process.env.DIRECTUS_URL ?? 'http://directus:8055'; +const directusPublicUrl = process.env.DIRECTUS_PUBLIC_URL && !process.env.DIRECTUS_PUBLIC_URL.includes('example.com') + ? process.env.DIRECTUS_PUBLIC_URL + : directusUrl; const directusToken = process.env.DIRECTUS_ADMIN_TOKEN; +const homepageBannerFolder = process.env.DIRECTUS_HOMEPAGE_BANNER_FOLDER ?? 'homepage-banners'; +const cafePageFolder = process.env.DIRECTUS_CAFE_PAGE_FOLDER ?? 'cafe-page'; + +type DirectusFolder = { + id: string; + name: string; +}; + +type DirectusFile = { + id: string; + title?: string; + description?: string; + filename_download?: string; + type?: string; +}; + +function directusHeaders(): Record | undefined { + if (!directusToken) return undefined; + return { Authorization: `Bearer ${directusToken}` }; +} async function readCollection(collection: CollectionName): Promise { const endpoint = new URL(`/items/${collection}`, directusUrl); @@ -40,13 +66,8 @@ async function readCollection(collection: CollectionName): Promise { } try { - const headers: Record = {}; - if (directusToken) { - headers['Authorization'] = `Bearer ${directusToken}`; - } - const response = await fetch(endpoint, { - headers: Object.keys(headers).length > 0 ? headers : undefined, + headers: directusHeaders(), }); if (!response.ok) { throw new Error(`Directus responded with ${response.status}`); @@ -59,6 +80,64 @@ async function readCollection(collection: CollectionName): Promise { } } +async function readDirectusEndpoint(endpoint: URL): Promise { + let response = await fetch(endpoint, { + headers: directusHeaders(), + }); + + if (response.status === 403 && directusToken) { + response = await fetch(endpoint); + } + + if (!response.ok) { + throw new Error(`Directus responded with ${response.status}`); + } + + const payload = (await response.json()) as { data?: T[] }; + return payload.data ?? []; +} + +function resolveDirectusAssetUrl(fileId: string): string { + return new URL(`/assets/${fileId}`, directusPublicUrl).toString(); +} + +async function findFolderByName(name: string): Promise { + const endpoint = new URL('/folders', directusUrl); + endpoint.searchParams.set('limit', '1'); + endpoint.searchParams.set('filter[name][_eq]', name); + endpoint.searchParams.set('fields', 'id,name'); + + const folders = await readDirectusEndpoint(endpoint); + return folders[0] ?? null; +} + +async function getImagesFromFolder(folderName: string, fallbackImages: HomepageBannerImage[]): Promise { + try { + const folder = await findFolderByName(folderName); + if (!folder) return fallbackImages; + + const endpoint = new URL('/files', directusUrl); + endpoint.searchParams.set('limit', '20'); + endpoint.searchParams.set('sort', '-uploaded_on'); + endpoint.searchParams.set('filter[folder][_eq]', folder.id); + endpoint.searchParams.set('filter[type][_starts_with]', 'image/'); + endpoint.searchParams.set('fields', 'id,title,description,filename_download,type'); + + const files = await readDirectusEndpoint(endpoint); + const images = files.map((file) => ({ + src: resolveDirectusAssetUrl(file.id), + alt: file.description || file.title || file.filename_download || 'Swansea Airport', + })); + + return images.length > 0 ? images : fallbackImages; + } catch { + return fallbackImages; + } +} + +export const getHomepageBannerImages = () => getImagesFromFolder(homepageBannerFolder, fallbackHomepageBannerImages); +export const getCafePageImages = () => getImagesFromFolder(cafePageFolder, fallbackCafePageImages); + function fallbackFor(collection: CollectionName) { switch (collection) { case 'news': diff --git a/src/lib/events.ts b/src/lib/events.ts new file mode 100644 index 0000000..5752b85 --- /dev/null +++ b/src/lib/events.ts @@ -0,0 +1,24 @@ +import type { EventItem } from './fallback-data'; + +function getEventEndTime(event: EventItem): number { + const value = event.end_datetime || event.start_datetime; + const timestamp = new Date(value).getTime(); + return Number.isFinite(timestamp) ? timestamp : 0; +} + +function getEventStartTime(event: EventItem): number { + const timestamp = new Date(event.start_datetime).getTime(); + return Number.isFinite(timestamp) ? timestamp : 0; +} + +export function isUpcomingEvent(event: EventItem, now = new Date()): boolean { + return getEventEndTime(event) >= now.getTime(); +} + +export function sortEventsByStartDate(events: EventItem[]): EventItem[] { + return [...events].sort((left, right) => getEventStartTime(left) - getEventStartTime(right)); +} + +export function getUpcomingEvents(events: EventItem[], now = new Date()): EventItem[] { + return sortEventsByStartDate(events.filter((event) => isUpcomingEvent(event, now))); +} diff --git a/src/lib/fallback-data.ts b/src/lib/fallback-data.ts index d981983..1bc88d6 100644 --- a/src/lib/fallback-data.ts +++ b/src/lib/fallback-data.ts @@ -12,6 +12,7 @@ export type FuelPrice = { fuel_type: string; price_per_litre: number; currency: string; + inc_vat?: boolean; last_updated: string; notes?: string; }; @@ -58,6 +59,11 @@ export type ContactItem = { order?: number; }; +export type HomepageBannerImage = { + src: string; + alt: string; +}; + export const fallbackNotices: Notice[] = [ { title: 'Welcome to Swansea Airport', @@ -73,11 +79,23 @@ export const fallbackFuelPrices: FuelPrice[] = [ fuel_type: 'AVGAS', price_per_litre: 2.35, currency: 'GBP', + inc_vat: true, last_updated: '2026-05-11', notes: 'Placeholder rate for the initial scaffold.', }, ]; +export const fallbackCafePageImages: HomepageBannerImage[] = [ + { + src: '/images/cessna.jpg', + alt: 'Aircraft on the apron at Swansea Airport', + }, + { + src: '/images/banner.png', + alt: 'Swansea Airport', + }, +]; + export const fallbackEvents: EventItem[] = [ { title: 'Airfield open day', @@ -122,3 +140,10 @@ export const fallbackContacts: ContactItem[] = [ order: 1, }, ]; + +export const fallbackHomepageBannerImages: HomepageBannerImage[] = [ + { + src: '/images/banner.png', + alt: 'Swansea Airport banner', + }, +]; diff --git a/src/lib/site.ts b/src/lib/site.ts index dbd040a..33ccc77 100644 --- a/src/lib/site.ts +++ b/src/lib/site.ts @@ -20,6 +20,7 @@ export const site = { label: 'About', href: '/about/', children: [ + { label: 'Cafe', href: '/about/cafe/' }, { label: 'History', href: '/about/history/' }, { label: 'Drones', href: '/about/drones/' }, { label: 'Fees and Charges', href: '/about/fees-and-charges/' }, diff --git a/src/pages/about/cafe.astro b/src/pages/about/cafe.astro new file mode 100644 index 0000000..01c3d24 --- /dev/null +++ b/src/pages/about/cafe.astro @@ -0,0 +1,100 @@ +--- +import BannerRotator from '../../components/BannerRotator.astro'; +import BaseLayout from '../../layouts/BaseLayout.astro'; +import { getCafePageImages } from '../../lib/directus'; + +const cafeImages = await getCafePageImages(); +--- + + +
+
+

The Whirlybird Cafe

+

+ The cafe at Swansea Airport is a relaxed, welcoming spot for pilots, visitors, walkers, + families, and anyone passing through the airfield. It is friendly, informal, and dog + friendly, making it an easy place to stop for a drink, a bite to eat, and a view of the + airfield. +

+

+ Whether you are meeting friends, watching the aircraft, or taking a break while exploring + Fairwood Common and the Gower, the cafe offers a comfortable place to pause and enjoy the + airport atmosphere. +

+

+ On Thursday to Sunday evenings, the cafe transforms into Thai Bach Express, serving Thai + food for delivery and dining in. + Visit Thai Bach. +

+ +
+ + +
+
+ + diff --git a/src/pages/about/index.astro b/src/pages/about/index.astro index 6b35b04..7c1c852 100644 --- a/src/pages/about/index.astro +++ b/src/pages/about/index.astro @@ -4,14 +4,12 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
-

About

-

About

-

- This section provides information for local communities and visitors. Choose a topic - below to view placeholder content that will be replaced with final approved copy. -

+
+

Cafe

+

A friendly, dog-friendly cafe for visitors, walkers, pilots, and local families.

+

History

The story of Swansea Airport, formerly RAF Fairwood Common.

diff --git a/src/pages/events/index.astro b/src/pages/events/index.astro index 4e979d3..f6034d9 100644 --- a/src/pages/events/index.astro +++ b/src/pages/events/index.astro @@ -2,8 +2,9 @@ import BaseLayout from '../../layouts/BaseLayout.astro'; import EventsList from '../../components/EventsList.astro'; import { getEvents } from '../../lib/directus'; +import { getUpcomingEvents } from '../../lib/events'; -const events = (await getEvents()).sort((left, right) => new Date(left.start_datetime).getTime() - new Date(right.start_datetime).getTime()); +const events = getUpcomingEvents(await getEvents()); --- diff --git a/src/pages/index.astro b/src/pages/index.astro index 7eb429f..d7f7002 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,20 +1,24 @@ --- import BaseLayout from '../layouts/BaseLayout.astro'; +import BannerRotator from '../components/BannerRotator.astro'; import NoticeBanner from '../components/NoticeBanner.astro'; import FuelPricesWidget from '../components/FuelPricesWidget.astro'; import EventsList from '../components/EventsList.astro'; +import FacebookWidget from '../components/FacebookWidget.astro'; import NewsFeed from '../components/NewsFeed.astro'; -import { homepageHighlights, site } from '../lib/site'; -import { getEvents, getFuelPrices, getNews, getNotices } from '../lib/directus'; +import { getUpcomingEvents } from '../lib/events'; +import { homepageHighlights } from '../lib/site'; +import { getEvents, getFuelPrices, getHomepageBannerImages, getNews, getNotices } from '../lib/directus'; -const [notices, fuelPrices, events, news] = await Promise.all([ +const [notices, fuelPrices, events, news, bannerImages] = await Promise.all([ getNotices(), getFuelPrices(), getEvents(), getNews(), + getHomepageBannerImages(), ]); -const featuredEvents = events.filter((event) => event.is_featured).slice(0, 3); +const featuredEvents = getUpcomingEvents(events).filter((event) => event.is_featured).slice(0, 3); const latestNews = news.slice(0, 3); const businessPromos = [ @@ -50,9 +54,7 @@ const businessPromos = [ --- - +
@@ -71,35 +73,22 @@ const businessPromos = [
+
-
-
-

Flying from Swansea

-

A practical base for training, touring, and quick access

-

- Swansea Airport is set up for straightforward arrivals and departures, with a compact layout, a clear operating rhythm, and enough room to keep the focus on flying rather than logistics. -

-

- Operational notices, fuel pricing, events, and visitor guidance are presented together so people can scan what they need without digging through the page. -

-
- -
- A Cessna aircraft on the apron at Swansea Airport -
-
- + +

Experience, training, and adventure on your doorstep

-
+ +
+
+

Flying from Swansea

+

Run by passionate volunteers

+

+ Swansea Airport is mainly run by volunteers who are passionate about aviation and the local area. The team is dedicated to providing a safe, welcoming, and enjoyable experience for pilots, visitors, and aviation enthusiasts alike. +

+

+ We are always looking for new volunteers to join our team. Whether you have experience in aviation or simply a love for flying, there are many ways to get involved and help support the airport. +

+
+
+ +
+ A Cessna aircraft on the apron at Swansea Airport +
+
+
-
-
-
-

Operational information

-

Essential details for today

-
-
- -
-
-

Today at the airfield

-
-
- {site.openingHours} - Opening hours -
-
- {site.licensedHours} - Licensed hours -
-
-
- -
-

Runway overview

-
    - {site.runwayFacts.map((fact) => ( -
  • {fact}
  • - ))} -
-
-
-
- - + -
-
- {homepageHighlights.map((item) => ( -
-

{item.title}

-

{item.body}

-
- ))} -
-
+
diff --git a/src/pages/pilot-info.astro b/src/pages/pilot-info.astro index b57933e..64a9a4a 100644 --- a/src/pages/pilot-info.astro +++ b/src/pages/pilot-info.astro @@ -56,6 +56,10 @@ When the Tower is unavailable, this will be NOTAMed and blind calls to Swansea T and there is to be no dead-side flight within the ATZ.

All joins are to be downwind or base leg joins only.

+

+ Caution: Pilots should be aware of possible windshear on short final for + Runway 10, especially when surface winds are above 10 kt. +

When already on frequency, parachuting will be notified by the Air/Ground service using the following message: @@ -126,20 +130,6 @@ When the Tower is unavailable, this will be NOTAMed and blind calls to Swansea T -

Arrival essentials

-
-
-

PPR

-

Pre-landing fogging is presented prominently here and can be linked to the relevant Directus content or booking workflow.

-
-
-

Book out

-

Departure procedures and any required outbound reporting remain in the same controlled page structure.

-
-
-

Fuel and services

-

Fuel prices are shown on the homepage and can be reused here with the same data source.

-
-
+
diff --git a/src/styles/global.css b/src/styles/global.css index 3ae63a7..8fb35e8 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -89,6 +89,8 @@ img { } .navshell { + position: relative; + z-index: 30; border-bottom: 1px solid var(--line); background: rgba(255, 255, 255, 0.84); } @@ -290,22 +292,6 @@ main { padding-block: 1.5rem 4rem; } -.banner-image { - width: 100%; - display: block; - overflow: hidden; - margin: 0; - background: rgba(255, 255, 255, 0.4); -} - -.banner-image img { - width: 100%; - height: auto; - display: block; - max-height: 12rem; - object-fit: cover; -} - section { padding-block: 1rem; } @@ -439,30 +425,76 @@ section { } .business-card { + position: relative; + isolation: isolate; display: grid; - grid-template-columns: 6.5rem minmax(0, 1fr); - gap: 1rem; + grid-template-columns: minmax(8rem, 10rem) minmax(0, 1fr); + min-height: 9.25rem; + gap: 1.2rem; align-items: center; - padding: 1rem; + padding: 1.1rem; border: 1px solid var(--line); border-radius: var(--radius); background: rgba(255, 255, 255, 0.9); text-decoration: none; box-shadow: var(--shadow); + overflow: hidden; + transition: + border-color 180ms ease, + box-shadow 180ms ease, + transform 180ms ease; +} + +.business-card::before { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + background: + linear-gradient(135deg, rgba(29, 118, 184, 0.15), transparent 38%), + linear-gradient(315deg, rgba(246, 181, 56, 0.2), transparent 34%); + opacity: 0; + transition: opacity 180ms ease; } .business-card:hover { color: inherit; - transform: translateY(-1px); + border-color: rgba(29, 118, 184, 0.28); + box-shadow: 0 22px 48px rgba(16, 34, 51, 0.16); + transform: translateY(-3px); +} + +.business-card:hover::before { + opacity: 1; +} + +.business-logo-frame { + display: grid; + place-items: center; + min-height: 7rem; + padding: 0.85rem; + border: 1px solid rgba(29, 118, 184, 0.14); + border-radius: 0.9rem; + background: + radial-gradient(circle at 30% 18%, rgba(255, 255, 255, 0.92), transparent 38%), + linear-gradient(145deg, #ffffff 0%, #f4f9fd 100%); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.9), + 0 12px 28px rgba(16, 34, 51, 0.1); } .business-logo { width: 100%; - height: 100%; - max-height: 5rem; + height: 6.4rem; object-fit: contain; border-radius: 0; box-shadow: none; + filter: drop-shadow(0 8px 12px rgba(16, 34, 51, 0.12)); + transition: transform 180ms ease; +} + +.business-card:hover .business-logo { + transform: scale(1.08); } .business-copy { @@ -701,6 +733,11 @@ section { color: inherit; } +.footer-link { + font-weight: 800; + text-decoration-color: rgba(245, 250, 255, 0.42); +} + .footer-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -799,7 +836,17 @@ section { } .business-card { - grid-template-columns: 5.5rem minmax(0, 1fr); + grid-template-columns: minmax(6.8rem, 8rem) minmax(0, 1fr); + min-height: 8.5rem; + } + + .business-logo-frame { + min-height: 6rem; + padding: 0.7rem; + } + + .business-logo { + height: 5.4rem; } .topbar-inner,