prepare-for-prod #2

Merged
jamesp merged 2 commits from prepare-for-prod into main 2026-06-21 17:41:26 -04:00
9 changed files with 533 additions and 35 deletions
Showing only changes of commit 2fcdb5c033 - Show all commits
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

+314
View File
@@ -0,0 +1,314 @@
---
const configuredApiBase = import.meta.env.PUBLIC_PPR_API_BASE ?? 'https://ppr.swansea-airport.wales/api/v1';
const pprApiBase = configuredApiBase.replace(/\/$/, '');
const contactRequestEndpoint = `${pprApiBase}/contact-requests/public`;
---
<section class="contact-shell surface" aria-labelledby="contact-form-heading">
<div class="contact-head">
<p class="eyebrow">General enquiries</p>
<h2 id="contact-form-heading" class="section-title">Send us a message</h2>
<p class="section-copy">
Use this form for general airport, business, visiting, and community enquiries. For flight
requests, please use the dedicated PPR or drone request forms.
</p>
</div>
<form id="contact-form" class="contact-form">
<div class="contact-honeypot" aria-hidden="true">
<label for="website">Website</label>
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off" />
</div>
<div class="contact-grid">
<div class="contact-field">
<label for="contact-name">Name <span aria-hidden="true">*</span></label>
<input type="text" id="contact-name" name="name" autocomplete="name" required />
</div>
<div class="contact-field">
<label for="contact-email">Email Address <span aria-hidden="true">*</span></label>
<input type="email" id="contact-email" name="email" autocomplete="email" required />
</div>
<div class="contact-field">
<label for="contact-phone">Phone Number</label>
<input type="tel" id="contact-phone" name="phone" autocomplete="tel" />
</div>
<div class="contact-field">
<label for="contact-enquiry-type">Enquiry Type <span aria-hidden="true">*</span></label>
<select id="contact-enquiry-type" name="enquiry_type" required>
<option value="">Select enquiry type</option>
<option value="general">General enquiry</option>
<option value="aviation_business">Aviation business / basing</option>
<option value="pilot">Pilot or visiting aircraft</option>
<option value="events">Events and visits</option>
<option value="community">Community or local resident</option>
</select>
</div>
<div class="contact-field contact-full">
<label for="contact-subject">Subject <span aria-hidden="true">*</span></label>
<input type="text" id="contact-subject" name="subject" required />
</div>
<div class="contact-field contact-full">
<label for="contact-message">Message <span aria-hidden="true">*</span></label>
<textarea id="contact-message" name="message" rows="6" required></textarea>
</div>
</div>
<div class="contact-actions">
<button type="submit" class="button primary" id="contact-submit-btn">Send Message</button>
</div>
</form>
<div class="contact-loading" id="contact-loading" role="status" aria-live="polite">
<span class="contact-spinner" aria-hidden="true"></span>
Sending your message...
</div>
<div class="contact-success notice" id="contact-success-message" role="status" aria-live="polite">
<h3>Message sent.</h3>
<p>Thanks for getting in touch. The airport team will review your message and respond where needed.</p>
</div>
</section>
<div id="contact-notification" class="contact-notification" role="status" aria-live="polite"></div>
<script define:vars={{ contactRequestEndpoint }}>
(() => {
const CONTACT_REQUEST_ENDPOINT = contactRequestEndpoint;
const get = (id) => document.getElementById(id);
function showNotification(message, isError = false) {
const notification = get('contact-notification');
notification.textContent = message;
notification.classList.toggle('error', isError);
notification.classList.add('show');
window.setTimeout(() => notification.classList.remove('show'), 5000);
}
function buildContactRequestData(form) {
const formData = new FormData(form);
return {
name: String(formData.get('name') || '').trim(),
email: String(formData.get('email') || '').trim(),
phone: String(formData.get('phone') || '').trim(),
enquiry_type: String(formData.get('enquiry_type') || '').trim(),
subject: String(formData.get('subject') || '').trim(),
message: String(formData.get('message') || '').trim(),
source_page: window.location.pathname,
};
}
async function handleSubmit(event) {
event.preventDefault();
const form = event.currentTarget;
const submitButton = get('contact-submit-btn');
const loading = get('contact-loading');
const successMessage = get('contact-success-message');
if (form.website?.value) {
form.reset();
successMessage.style.display = 'block';
form.style.display = 'none';
return;
}
submitButton.disabled = true;
submitButton.textContent = 'Sending...';
loading.style.display = 'flex';
try {
const response = await fetch(CONTACT_REQUEST_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(buildContactRequestData(form)),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || `Request failed with status ${response.status}`);
}
form.style.display = 'none';
successMessage.style.display = 'block';
showNotification('Message sent successfully.');
} catch (error) {
console.error('Error submitting contact request:', error);
showNotification(`Error sending message: ${error.message}`, true);
} finally {
loading.style.display = 'none';
submitButton.disabled = false;
submitButton.textContent = 'Send Message';
}
}
document.addEventListener('DOMContentLoaded', () => {
get('contact-form').addEventListener('submit', handleSubmit);
});
})();
</script>
<style>
.contact-shell {
display: grid;
gap: 1.25rem;
margin-block: 1.5rem 0;
}
.contact-head,
.contact-form {
display: grid;
gap: 1.2rem;
}
.contact-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.contact-field {
display: grid;
gap: 0.4rem;
align-content: start;
}
.contact-full {
grid-column: 1 / -1;
}
.contact-field label {
color: var(--text);
font-size: 0.92rem;
font-weight: 800;
}
.contact-field label span {
color: var(--critical);
}
.contact-field input,
.contact-field select,
.contact-field textarea {
width: 100%;
min-height: 2.85rem;
border: 1px solid rgba(16, 34, 51, 0.16);
border-radius: 0.7rem;
background: rgba(255, 255, 255, 0.88);
color: var(--text);
font: inherit;
font-size: 1rem;
padding: 0.7rem 0.82rem;
}
.contact-field textarea {
min-height: 9rem;
resize: vertical;
}
.contact-field input:focus,
.contact-field select:focus,
.contact-field textarea:focus {
outline: none;
border-color: var(--brand-2);
box-shadow: 0 0 0 0.2rem rgba(29, 118, 184, 0.16);
}
.contact-actions {
display: flex;
justify-content: flex-start;
padding-top: 0.4rem;
}
.contact-actions .button {
border: 0;
cursor: pointer;
}
.contact-actions .button:disabled {
cursor: wait;
opacity: 0.7;
}
.contact-loading {
display: none;
align-items: center;
gap: 0.7rem;
color: var(--brand);
font-weight: 800;
}
.contact-spinner {
width: 1.45rem;
height: 1.45rem;
border: 3px solid rgba(29, 118, 184, 0.18);
border-top-color: var(--brand-2);
border-radius: 50%;
animation: contact-spin 0.8s linear infinite;
}
.contact-success {
display: none;
border-left-color: #257b4c;
}
.contact-notification {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 60;
max-width: min(24rem, calc(100vw - 2rem));
padding: 0.85rem 1rem;
border-radius: 0.8rem;
background: #257b4c;
color: white;
box-shadow: var(--shadow);
font-weight: 800;
opacity: 0;
pointer-events: none;
transform: translateY(-0.65rem);
transition: opacity 0.18s ease, transform 0.18s ease;
}
.contact-notification.show {
opacity: 1;
transform: translateY(0);
}
.contact-notification.error {
background: var(--critical);
}
.contact-honeypot {
position: absolute;
left: -100vw;
width: 1px;
height: 1px;
overflow: hidden;
}
@keyframes contact-spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 720px) {
.contact-grid {
grid-template-columns: 1fr;
}
.contact-actions,
.contact-actions .button {
width: 100%;
}
}
</style>
+2 -2
View File
@@ -10,11 +10,11 @@ type Props = {
description?: string; description?: string;
}; };
const { events, title = 'Upcoming events', description = 'A quick scan list for pilots, visitors, and local supporters.' } = Astro.props as Props; const { events, title = 'Upcoming events', } = Astro.props as Props;
--- ---
<section> <section>
<SectionHeading eyebrow="Events" title={title} description={description} /> <SectionHeading eyebrow="Events" title={title} />
<div class="stack"> <div class="stack">
{events.length > 0 ? ( {events.length > 0 ? (
events.map((event) => { events.map((event) => {
+1 -1
View File
@@ -84,7 +84,7 @@ const pageTitle = title ? `${title} · ${site.name}` : site.name;
<p class="eyebrow">Phone</p> <p class="eyebrow">Phone</p>
<p><a href={`tel:${site.phone.replace(/\s+/g, '')}`}>{site.phone}</a></p> <p><a href={`tel:${site.phone.replace(/\s+/g, '')}`}>{site.phone}</a></p>
<p class="eyebrow">Email</p> <p class="eyebrow">Email</p>
<p><a href="mailto:info@swansea-airport.wales">info@swansea-airport.wales</a></p> <p><a href="mailto:tower@swansea-airport.wales">tower@swansea-airport.wales</a></p>
</section> </section>
<section> <section>
<p class="eyebrow">Contact</p> <p class="eyebrow">Contact</p>
+1 -1
View File
@@ -138,7 +138,7 @@ export const fallbackContacts: ContactItem[] = [
{ {
name: 'Airport office', name: 'Airport office',
role: 'General enquiries', role: 'General enquiries',
email: 'info@swansea-airport.wales', email: 'tower@swansea-airport.wales',
phone: '01792 687 042', phone: '01792 687 042',
is_public: true, is_public: true,
order: 1, order: 1,
+122 -12
View File
@@ -3,47 +3,157 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
--- ---
<BaseLayout title="Drones" description="Drone operating guidance and coordination process for Swansea Airport."> <BaseLayout title="Drones" description="Drone operating guidance and coordination process for Swansea Airport.">
<section class="container prose"> <script is:inline>
<p class="eyebrow">About</p> document.documentElement.classList.add('js');
<h1 class="section-title">Drones</h1> </script>
<p> <section class="container prose drones-page">
<figure class="drone-banner" data-drone-banner>
<img src="/images/avata2.jpg" alt="Drone aerial view over Swansea Airport" />
</figure>
<p class="eyebrow reveal-item" data-reveal>About</p>
<h1 class="section-title reveal-item" data-reveal>Drones</h1>
<p class="reveal-item" data-reveal>
Firstly, please be assured that Swansea Airport likes to help our drone flying colleagues Firstly, please be assured that Swansea Airport likes to help our drone flying colleagues
where we possibly can. where we possibly can.
</p> </p>
<p> <p class="reveal-item" data-reveal>
You are likely aware that Swansea Airport has a Flight Restriction Zone (FRZ) and two Runway Protection Zones (RPZs) that are in place to protect the public and aircraft from the risk of an accident. You are likely aware that Swansea Airport has a Flight Restriction Zone (FRZ) and two Runway Protection Zones (RPZs) that are in place to protect the public and aircraft from the risk of an accident.
</p> </p>
<p> <p class="reveal-item" data-reveal>
<img src="/images/FRZ.png" alt="Swansea Airport Flight Restriction Zone and Runway Protection Zones map" loading="lazy" /> <img src="/images/FRZ.png" alt="Swansea Airport Flight Restriction Zone and Runway Protection Zones map" loading="lazy" />
</p> </p>
<p> <p class="reveal-item" data-reveal>
It is a requirement to obtain permission from Swansea Airport before flying a drone within the FRZ or RPZs. This is to ensure that we can coordinate your flight with any other aircraft that may be operating in the area, and to ensure that you are aware of any operational considerations that may affect your flight. It is a requirement to obtain permission from Swansea Airport before flying a drone within the FRZ or RPZs. This is to ensure that we can coordinate your flight with any other aircraft that may be operating in the area, and to ensure that you are aware of any operational considerations that may affect your flight.
</p> </p>
<p> <p class="reveal-item" data-reveal>
<center><a class="button primary" href="/drone-flight-request/">Request a Drone Flight</a></center> <center><a class="button primary" href="/drone-flight-request/">Request a Drone Flight</a></center>
</p> </p>
<p> <p class="reveal-item" data-reveal>
We will then reply with any operational considerations and, if required, approve or decline a We will then reply with any operational considerations and, if required, approve or decline a
flight if it is within the FRZ or RPZs. We are not in the habit of declining, so please do submit your request, and we will do our best to accommodate you. flight if it is within the FRZ or RPZs. We are not in the habit of declining, so please do submit your request, and we will do our best to accommodate you.
</p> </p>
<p> <p class="reveal-item" data-reveal>
After this, all we ask is that on the day, you call us in tower on After this, all we ask is that on the day, you call us in tower on
<a href="tel:01792687042">01792 687042</a> twenty minutes prior to launch, so we can give <a href="tel:01792687042">01792 687042</a> twenty minutes prior to launch, so we can give
you any operational updates and also broadcast a warning on our frequency to any aircraft. you any operational updates and also broadcast a warning on our frequency to any aircraft.
</p> </p>
<h2>Parachuting</h2> <h2 class="reveal-item" data-reveal>Parachuting</h2>
<p> <p class="reveal-item" data-reveal>
Parachuting tends to take place Friday to Sunday from Spring to Winter. This can involve free Parachuting tends to take place Friday to Sunday from Spring to Winter. This can involve free
fall descents from 15,000' AMSL. We recommend avoiding these times due to the requirement to fall descents from 15,000' AMSL. We recommend avoiding these times due to the requirement to
ensure the safety of descending skydivers. ensure the safety of descending skydivers.
</p> </p>
</section> </section>
</BaseLayout> </BaseLayout>
<style>
.drone-banner {
--drone-parallax: 0px;
margin: 0 0 1.5rem;
overflow: hidden;
border-radius: var(--radius);
box-shadow: var(--shadow);
aspect-ratio: 21 / 9;
background: var(--line);
}
.drone-banner img {
display: block;
width: 100%;
height: calc(100% + 6rem);
margin-top: -3rem;
object-fit: cover;
transform: translate3d(0, var(--drone-parallax), 0) scale(1.03);
will-change: transform;
}
:global(.js) .reveal-item {
opacity: 0;
transform: translateY(1.4rem);
transition:
opacity 680ms ease,
transform 680ms ease;
}
:global(.js) .reveal-item.is-visible {
opacity: 1;
transform: translateY(0);
}
@media (max-width: 640px) {
.drone-banner {
aspect-ratio: 16 / 9;
}
}
@media (prefers-reduced-motion: reduce) {
.drone-banner img,
:global(.js) .reveal-item {
transform: none;
transition: none;
}
:global(.js) .reveal-item {
opacity: 1;
}
}
</style>
<script>
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const banner = document.querySelector('[data-drone-banner]');
const revealItems = Array.from(document.querySelectorAll('[data-reveal]'));
if (!motionQuery.matches) {
let ticking = false;
const updateBanner = () => {
if (!banner) return;
const rect = banner.getBoundingClientRect();
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const progress = (viewportHeight - rect.top) / (viewportHeight + rect.height);
const clampedProgress = Math.min(Math.max(progress, 0), 1);
const offset = (clampedProgress - 0.5) * 64;
banner.style.setProperty('--drone-parallax', `${offset}px`);
ticking = false;
};
const requestBannerUpdate = () => {
if (ticking) return;
ticking = true;
window.requestAnimationFrame(updateBanner);
};
updateBanner();
window.addEventListener('scroll', requestBannerUpdate, { passive: true });
window.addEventListener('resize', requestBannerUpdate);
const revealObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
entry.target.classList.add('is-visible');
revealObserver.unobserve(entry.target);
});
},
{ threshold: 0.16, rootMargin: '0px 0px -8% 0px' }
);
revealItems.forEach((item) => revealObserver.observe(item));
} else {
revealItems.forEach((item) => item.classList.add('is-visible'));
}
</script>
+3 -6
View File
@@ -1,5 +1,6 @@
--- ---
import BaseLayout from '../layouts/BaseLayout.astro'; import BaseLayout from '../layouts/BaseLayout.astro';
import ContactForm from '../components/ContactForm.astro';
import ContactList from '../components/ContactList.astro'; import ContactList from '../components/ContactList.astro';
import { getContacts } from '../lib/directus'; import { getContacts } from '../lib/directus';
import { site } from '../lib/site'; import { site } from '../lib/site';
@@ -9,13 +10,9 @@ const contacts = await getContacts();
<BaseLayout title="Contact" description="How to reach Swansea Airport and its public contacts."> <BaseLayout title="Contact" description="How to reach Swansea Airport and its public contacts.">
<div class="container stack"> <div class="container stack">
<section class="prose">
<p class="eyebrow">Contact</p>
<h1 class="section-title">Reach the airport team</h1>
<p>{site.address}</p>
<p><a href={`tel:${site.phone.replace(/\s+/g, '')}`}>{site.phone}</a></p>
</section>
<ContactForm />
<ContactList contacts={contacts} /> <ContactList contacts={contacts} />
</div> </div>
</BaseLayout> </BaseLayout>
+90 -13
View File
@@ -1,17 +1,14 @@
--- ---
import BaseLayout from '../layouts/BaseLayout.astro'; import BaseLayout from '../layouts/BaseLayout.astro';
import BannerRotator from '../components/BannerRotator.astro'; import BannerRotator from '../components/BannerRotator.astro';
import NoticeBanner from '../components/NoticeBanner.astro';
import FuelPricesWidget from '../components/FuelPricesWidget.astro'; import FuelPricesWidget from '../components/FuelPricesWidget.astro';
import EventsList from '../components/EventsList.astro'; import EventsList from '../components/EventsList.astro';
import FacebookWidget from '../components/FacebookWidget.astro'; import FacebookWidget from '../components/FacebookWidget.astro';
import NewsFeed from '../components/NewsFeed.astro'; import NewsFeed from '../components/NewsFeed.astro';
import { getUpcomingEvents } from '../lib/events'; import { getUpcomingEvents } from '../lib/events';
import { homepageHighlights } from '../lib/site'; import { getEvents, getFuelPrices, getHomepageBannerImages, getNews } from '../lib/directus';
import { getEvents, getFuelPrices, getHomepageBannerImages, getNews, getNotices } from '../lib/directus';
const [notices, fuelPrices, events, news, bannerImages] = await Promise.all([ const [fuelPrices, events, news, bannerImages] = await Promise.all([
getNotices(),
getFuelPrices(), getFuelPrices(),
getEvents(), getEvents(),
getNews(), getNews(),
@@ -75,10 +72,11 @@ const businessPromos = [
</div> </div>
</div> </div>
</section> </section>
<div class="container stack">
<FuelPricesWidget fuelPrices={fuelPrices} /> <div class="container stack">
</div> <FuelPricesWidget fuelPrices={fuelPrices} />
</div>
<section class="container business-promo"> <section class="container business-promo">
<div class="section-head"> <div class="section-head">
<div> <div>
@@ -101,6 +99,30 @@ const businessPromos = [
</div> </div>
</section> </section>
<section class="aviation-base">
<div class="container aviation-base-inner">
<figure class="aviation-base-image">
<img src="/images/runway.jpeg" alt="Runway view at Swansea Airport" loading="lazy" />
</figure>
<div class="aviation-base-copy">
<p class="eyebrow">Aviation businesses</p>
<h2 class="section-title">Base your next chapter at Swansea Airport</h2>
<p>
With the new operators having secured a long-term lease, Swansea Airport is entering a
more stable period for investment, growth, and aviation activity.
</p>
<p>
We are keen to hear from interested parties with aviation businesses who want to explore
basing themselves at Swansea, from flight training and maintenance to aircraft services,
experiences, hangarage, and complementary airfield operations.
</p>
<div class="aviation-base-actions">
<a class="button primary" href="/contact/">Start a conversation</a>
<a class="button secondary" href="/about/">Learn about the airport</a>
</div>
</div>
</div>
</section>
<section class="container story-flow"> <section class="container story-flow">
<article class="story-copy"> <article class="story-copy">
@@ -123,12 +145,67 @@ const businessPromos = [
</section> </section>
<div class="container stack"> <div class="container stack">
<NoticeBanner notices={notices} /> <EventsList events={featuredEvents} title="Upcoming events" />
<EventsList events={featuredEvents} title="Upcoming events" description="Featured events are surfaced here first for quick scanning." />
<NewsFeed news={latestNews} /> <NewsFeed news={latestNews} />
<FacebookWidget /> <FacebookWidget />
</div> </div>
</BaseLayout> </BaseLayout>
<style>
.aviation-base {
margin: 1.5rem 0;
padding: 2.5rem 0;
border-block: 1px solid var(--line);
background:
linear-gradient(135deg, rgba(11, 79, 122, 0.09), transparent 42%),
linear-gradient(315deg, rgba(246, 181, 56, 0.16), transparent 36%);
}
.aviation-base-inner {
display: grid;
grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr);
gap: clamp(1.5rem, 4vw, 3rem);
align-items: center;
}
.aviation-base-image {
margin: 0;
overflow: hidden;
border-radius: 0;
background: transparent;
}
.aviation-base-image img {
width: 100%;
min-height: clamp(18rem, 28vw, 25rem);
height: 100%;
object-fit: cover;
object-position: center;
}
.aviation-base .section-title {
max-width: 18ch;
}
.aviation-base-copy {
max-width: 58ch;
}
.aviation-base-copy p:last-child {
margin-bottom: 0;
}
.aviation-base-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: flex-start;
margin-top: 1.2rem;
}
@media (max-width: 860px) {
.aviation-base-inner {
grid-template-columns: 1fr;
}
}
</style>