Drone and PPR and operational updates
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
PUBLIC_SITE_URL=https://swansea-airport.wales
|
||||
PUBLIC_PPR_API_BASE=https://ppr.swansea-airport.wales/api/v1
|
||||
DIRECTUS_URL=http://directus:8055
|
||||
FRONTEND_PORT=8080
|
||||
DIRECTUS_PORT=8055
|
||||
|
||||
@@ -18,6 +18,7 @@ Production-ready airfield website stack built with Astro, Directus, PostgreSQL,
|
||||
## Notes
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
+2
-1
@@ -71,6 +71,7 @@ services:
|
||||
command: ["sh", "-c", "npm install && npm run dev -- --host 0.0.0.0 --port 80"]
|
||||
environment:
|
||||
PUBLIC_SITE_URL: ${PUBLIC_SITE_URL}
|
||||
PUBLIC_PPR_API_BASE: ${PUBLIC_PPR_API_BASE}
|
||||
DIRECTUS_URL: ${DIRECTUS_URL}
|
||||
DIRECTUS_PUBLIC_URL: ${DIRECTUS_PUBLIC_URL}
|
||||
DIRECTUS_PORT: ${DIRECTUS_PORT}
|
||||
@@ -99,4 +100,4 @@ volumes:
|
||||
networks:
|
||||
default:
|
||||
webapps:
|
||||
external: true
|
||||
external: true
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 649 KiB |
@@ -0,0 +1,724 @@
|
||||
---
|
||||
const configuredApiBase = import.meta.env.PUBLIC_PPR_API_BASE ?? 'https://ppr.swansea-airport.wales/api/v1';
|
||||
const pprApiBase = configuredApiBase.replace(/\/$/, '');
|
||||
const droneRequestEndpoint = `${pprApiBase}/drone-requests/public`;
|
||||
const frzGeoJsonEndpoint = `${pprApiBase}/drone-requests/frz`;
|
||||
---
|
||||
|
||||
<section class="drone-shell surface" aria-labelledby="drone-heading">
|
||||
<div class="drone-head">
|
||||
<p class="eyebrow">Drone flight request</p>
|
||||
<h2 id="drone-heading" class="section-title">Request a flight within the FRZ</h2>
|
||||
<p class="section-copy">
|
||||
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.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<form id="drone-form" class="drone-form">
|
||||
<div class="drone-grid">
|
||||
<div class="drone-field">
|
||||
<label for="operator-name">Operator Name <span aria-hidden="true">*</span></label>
|
||||
<input type="text" id="operator-name" name="operator_name" autocomplete="organization" required />
|
||||
</div>
|
||||
|
||||
<div class="drone-field">
|
||||
<label for="operator-id">Operator ID <span aria-hidden="true">*</span></label>
|
||||
<input type="text" id="operator-id" name="operator_id" autocomplete="off" required />
|
||||
</div>
|
||||
|
||||
<div class="drone-field">
|
||||
<label for="flight-date">Date of Flight <span aria-hidden="true">*</span></label>
|
||||
<input type="date" id="flight-date" name="flight_date" required />
|
||||
</div>
|
||||
|
||||
<div class="drone-field">
|
||||
<label for="takeoff-time">Estimated Take Off Time <span aria-hidden="true">*</span></label>
|
||||
<select id="takeoff-time" name="estimated_takeoff_time" required>
|
||||
<option value="">Select Time</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="drone-field">
|
||||
<label for="completion-time">Estimated Completion Time <span aria-hidden="true">*</span></label>
|
||||
<select id="completion-time" name="estimated_completion_time" required>
|
||||
<option value="">Select Time</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="drone-field">
|
||||
<label for="max-elevation">Maximum Elevation, feet AMSL <span aria-hidden="true">*</span></label>
|
||||
<input type="number" id="max-elevation" name="maximum_elevation_ft_amsl" min="0" step="1" inputmode="numeric" required />
|
||||
</div>
|
||||
|
||||
<div class="drone-field drone-full">
|
||||
<label for="location-description">Location <span aria-hidden="true">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
id="location-description"
|
||||
name="location_description"
|
||||
placeholder="Site name, address, grid reference, or map-selected coordinates"
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="drone-map-panel drone-full">
|
||||
<div class="drone-map-toolbar">
|
||||
<div>
|
||||
<p class="drone-map-title">Operating Location</p>
|
||||
<p id="map-status" class="drone-map-status">Click the map to drop the requested flight location.</p>
|
||||
</div>
|
||||
<button type="button" class="button secondary" id="clear-location">Clear Location</button>
|
||||
</div>
|
||||
<div id="drone-map" class="drone-map" aria-label="Clickable map for selecting drone operating location"></div>
|
||||
<div class="drone-field drone-map-location">
|
||||
<label for="map-location-summary">Selected Map Coordinates</label>
|
||||
<input
|
||||
type="text"
|
||||
id="map-location-summary"
|
||||
placeholder="No map location selected"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
<div class="drone-map-legend" aria-label="Map overlay legend">
|
||||
<span><i class="legend-swatch frz"></i>Flight restriction zone</span>
|
||||
<span><i class="legend-swatch selected"></i>Selected location</span>
|
||||
</div>
|
||||
<input type="hidden" id="location-lat" name="location_latitude" />
|
||||
<input type="hidden" id="location-lng" name="location_longitude" />
|
||||
<input type="hidden" id="location-inside-frz" name="location_inside_frz" />
|
||||
</div>
|
||||
|
||||
|
||||
<div class="drone-field">
|
||||
<label for="flyer-name">Flyer Name <span aria-hidden="true">*</span></label>
|
||||
<input type="text" id="flyer-name" name="flyer_name" autocomplete="name" required />
|
||||
</div>
|
||||
|
||||
<div class="drone-field">
|
||||
<label for="flyer-id">Flyer ID <span aria-hidden="true">*</span></label>
|
||||
<input type="text" id="flyer-id" name="flyer_id" autocomplete="off" required />
|
||||
</div>
|
||||
|
||||
<div class="drone-field">
|
||||
<label for="email">Email Address <span aria-hidden="true">*</span></label>
|
||||
<input type="email" id="email" name="email" autocomplete="email" required />
|
||||
</div>
|
||||
|
||||
<div class="drone-field">
|
||||
<label for="phone">Phone Number <span aria-hidden="true">*</span></label>
|
||||
<input type="tel" id="phone" name="phone" autocomplete="tel" required />
|
||||
</div>
|
||||
|
||||
<div class="drone-field drone-full">
|
||||
<label for="notes">Operational Notes</label>
|
||||
<textarea id="notes" name="notes" rows="4" placeholder="Purpose of flight, aircraft type, contact on site, or other useful details..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drone-actions">
|
||||
<button type="submit" class="button primary" id="submit-btn">Submit Drone Request</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="drone-loading" id="loading" role="status" aria-live="polite">
|
||||
<span class="drone-spinner" aria-hidden="true"></span>
|
||||
Submitting your drone request...
|
||||
</div>
|
||||
|
||||
<div class="drone-success notice" id="success-message" role="status" aria-live="polite">
|
||||
<h3>Drone Request Submitted.</h3>
|
||||
<p>Your drone request has been submitted. We will review it and contact you if we need any further information.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="notification" class="drone-notification" role="status" aria-live="polite"></div>
|
||||
|
||||
<script define:vars={{ droneRequestEndpoint, frzGeoJsonEndpoint }}>
|
||||
(() => {
|
||||
const DRONE_REQUEST_ENDPOINT = droneRequestEndpoint;
|
||||
const FRZ_GEOJSON_ENDPOINT = frzGeoJsonEndpoint;
|
||||
const LEAFLET_CSS = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
||||
const LEAFLET_JS = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
|
||||
const ARP = { lat: 51.6053, lng: -4.0678 };
|
||||
|
||||
let map;
|
||||
let selectedMarker;
|
||||
let frzGeoJson;
|
||||
|
||||
const get = (id) => document.getElementById(id);
|
||||
|
||||
function loadStylesheet(href) {
|
||||
if (document.querySelector(`link[href="${href}"]`)) return;
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = href;
|
||||
document.head.append(link);
|
||||
}
|
||||
|
||||
function loadScript(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (window.L) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const existingScript = document.querySelector(`script[src="${src}"]`);
|
||||
if (existingScript) {
|
||||
existingScript.addEventListener('load', resolve, { once: true });
|
||||
existingScript.addEventListener('error', reject, { once: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.async = true;
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.append(script);
|
||||
});
|
||||
}
|
||||
|
||||
function initializeTimeDropdowns() {
|
||||
['takeoff-time', 'completion-time'].forEach((selectId) => {
|
||||
const select = get(selectId);
|
||||
select.replaceChildren(new Option('Select Time', ''));
|
||||
|
||||
for (let hour = 0; hour < 24; hour += 1) {
|
||||
for (let minute = 0; minute < 60; minute += 15) {
|
||||
const value = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||
select.append(new Option(value, value));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatTime(date) {
|
||||
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function setDefaultDateTime() {
|
||||
const takeoff = new Date();
|
||||
takeoff.setHours(takeoff.getHours() + 1, 0, 0, 0);
|
||||
|
||||
const completion = new Date(takeoff);
|
||||
completion.setHours(completion.getHours() + 1);
|
||||
|
||||
get('flight-date').value = formatDate(takeoff);
|
||||
get('takeoff-time').value = formatTime(takeoff);
|
||||
get('completion-time').value = formatTime(completion);
|
||||
}
|
||||
|
||||
function setMapStatus(message) {
|
||||
get('map-status').textContent = message;
|
||||
}
|
||||
|
||||
function isPointInRing(latlng, ring) {
|
||||
if (!Array.isArray(ring) || ring.length < 3) return false;
|
||||
|
||||
const x = latlng.lng;
|
||||
const y = latlng.lat;
|
||||
let inside = false;
|
||||
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
|
||||
const xi = ring[i][0];
|
||||
const yi = ring[i][1];
|
||||
const xj = ring[j][0];
|
||||
const yj = ring[j][1];
|
||||
const intersects = ((yi > y) !== (yj > y))
|
||||
&& (x < ((xj - xi) * (y - yi)) / (yj - yi) + xi);
|
||||
|
||||
if (intersects) inside = !inside;
|
||||
}
|
||||
|
||||
return inside;
|
||||
}
|
||||
|
||||
function isPointInPolygon(latlng, polygon) {
|
||||
if (!polygon.length || !isPointInRing(latlng, polygon[0])) return false;
|
||||
return !polygon.slice(1).some((hole) => isPointInRing(latlng, hole));
|
||||
}
|
||||
|
||||
function isPointInGeometry(latlng, geometry) {
|
||||
if (!geometry) return false;
|
||||
|
||||
if (geometry.type === 'Polygon') {
|
||||
return isPointInPolygon(latlng, geometry.coordinates);
|
||||
}
|
||||
|
||||
if (geometry.type === 'MultiPolygon') {
|
||||
return geometry.coordinates.some((polygon) => isPointInPolygon(latlng, polygon));
|
||||
}
|
||||
|
||||
if (geometry.type === 'GeometryCollection') {
|
||||
return Array.isArray(geometry.geometries)
|
||||
&& geometry.geometries.some((item) => isPointInGeometry(latlng, item));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPointInGeoJson(latlng, geoJson) {
|
||||
if (!geoJson) return null;
|
||||
|
||||
if (geoJson.type === 'FeatureCollection') {
|
||||
return Array.isArray(geoJson.features)
|
||||
&& geoJson.features.some((feature) => isPointInGeometry(latlng, feature.geometry));
|
||||
}
|
||||
|
||||
if (geoJson.type === 'Feature') {
|
||||
return isPointInGeometry(latlng, geoJson.geometry);
|
||||
}
|
||||
|
||||
return isPointInGeometry(latlng, geoJson);
|
||||
}
|
||||
|
||||
function selectLocation(latlng) {
|
||||
const latitude = latlng.lat.toFixed(6);
|
||||
const longitude = latlng.lng.toFixed(6);
|
||||
const insideFrz = isPointInGeoJson(latlng, frzGeoJson);
|
||||
const status = insideFrz === null
|
||||
? 'FRZ status unavailable'
|
||||
: `${insideFrz ? 'inside' : 'outside'} the flight restriction zone`;
|
||||
|
||||
if (selectedMarker) {
|
||||
selectedMarker.setLatLng(latlng);
|
||||
} else {
|
||||
selectedMarker = window.L.marker(latlng, { draggable: true }).addTo(map);
|
||||
selectedMarker.on('dragend', (event) => selectLocation(event.target.getLatLng()));
|
||||
}
|
||||
|
||||
selectedMarker.bindPopup(`Selected location<br>${latitude}, ${longitude}<br>${status}`).openPopup();
|
||||
get('location-lat').value = latitude;
|
||||
get('location-lng').value = longitude;
|
||||
get('location-inside-frz').value = insideFrz === null ? '' : insideFrz ? 'yes' : 'no';
|
||||
get('location-description').value = `${latitude}, ${longitude}`;
|
||||
get('map-location-summary').value = `${latitude}, ${longitude} (${status})`;
|
||||
setMapStatus(`Selected ${latitude}, ${longitude} - ${status}.`);
|
||||
}
|
||||
|
||||
function clearLocation() {
|
||||
if (selectedMarker) {
|
||||
selectedMarker.remove();
|
||||
selectedMarker = null;
|
||||
}
|
||||
|
||||
get('location-lat').value = '';
|
||||
get('location-lng').value = '';
|
||||
get('location-inside-frz').value = '';
|
||||
get('location-description').value = '';
|
||||
get('map-location-summary').value = '';
|
||||
setMapStatus('Click the map to drop the requested flight location.');
|
||||
}
|
||||
|
||||
async function loadFrzGeoJsonOverlay() {
|
||||
setMapStatus('Loading flight restriction zone...');
|
||||
|
||||
try {
|
||||
const response = await fetch(FRZ_GEOJSON_ENDPOINT);
|
||||
if (!response.ok) {
|
||||
throw new Error(`FRZ request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
frzGeoJson = await response.json();
|
||||
const frzLayer = window.L.geoJSON(frzGeoJson, {
|
||||
interactive: false,
|
||||
style: {
|
||||
color: '#6c2bd9',
|
||||
fillColor: '#6c2bd9',
|
||||
fillOpacity: 0.12,
|
||||
weight: 2,
|
||||
},
|
||||
pointToLayer: (_feature, latlng) => window.L.circleMarker(latlng, {
|
||||
radius: 5,
|
||||
color: '#6c2bd9',
|
||||
fillColor: '#ffffff',
|
||||
fillOpacity: 1,
|
||||
weight: 2,
|
||||
}),
|
||||
}).addTo(map);
|
||||
|
||||
const bounds = frzLayer.getBounds();
|
||||
if (bounds.isValid()) {
|
||||
map.fitBounds(bounds.pad(0.12));
|
||||
}
|
||||
|
||||
setMapStatus('Click the map to drop the requested flight location.');
|
||||
} catch (error) {
|
||||
console.error('FRZ GeoJSON failed to load:', error);
|
||||
frzGeoJson = null;
|
||||
map.setView([ARP.lat, ARP.lng], 13);
|
||||
setMapStatus('FRZ overlay could not be loaded. Enter the location manually or click the map to capture coordinates.');
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeMap() {
|
||||
loadStylesheet(LEAFLET_CSS);
|
||||
await loadScript(LEAFLET_JS);
|
||||
|
||||
map = window.L.map('drone-map', {
|
||||
scrollWheelZoom: false,
|
||||
}).setView([ARP.lat, ARP.lng], 13);
|
||||
|
||||
window.L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
}).addTo(map);
|
||||
|
||||
window.L.circleMarker([ARP.lat, ARP.lng], {
|
||||
radius: 5,
|
||||
color: '#102233',
|
||||
fillColor: '#ffffff',
|
||||
fillOpacity: 1,
|
||||
weight: 2,
|
||||
}).bindPopup('Swansea Airport ARP').addTo(map);
|
||||
|
||||
await loadFrzGeoJsonOverlay();
|
||||
map.on('click', (event) => selectLocation(event.latlng));
|
||||
get('clear-location').addEventListener('click', clearLocation);
|
||||
}
|
||||
|
||||
function showNotification(message, isError = false) {
|
||||
const notification = get('notification');
|
||||
notification.textContent = message;
|
||||
notification.className = `drone-notification${isError ? ' error' : ''}`;
|
||||
requestAnimationFrame(() => notification.classList.add('show'));
|
||||
window.setTimeout(() => notification.classList.remove('show'), 5000);
|
||||
}
|
||||
|
||||
function buildDroneRequestData(form) {
|
||||
const formData = new FormData(form);
|
||||
const data = {};
|
||||
|
||||
formData.forEach((value, key) => {
|
||||
const fieldValue = String(value).trim();
|
||||
if (!fieldValue) return;
|
||||
|
||||
if (key === 'maximum_elevation_ft_amsl') {
|
||||
data[key] = Number.parseInt(fieldValue, 10);
|
||||
} else if (key === 'location_latitude' || key === 'location_longitude') {
|
||||
data[key] = Number.parseFloat(fieldValue);
|
||||
} else {
|
||||
data[key] = fieldValue;
|
||||
}
|
||||
});
|
||||
|
||||
if (formData.get('flight_date') && formData.get('estimated_takeoff_time')) {
|
||||
data.estimated_takeoff_at = new Date(`${formData.get('flight_date')}T${formData.get('estimated_takeoff_time')}`).toISOString();
|
||||
}
|
||||
|
||||
if (formData.get('flight_date') && formData.get('estimated_completion_time')) {
|
||||
data.estimated_completion_at = new Date(`${formData.get('flight_date')}T${formData.get('estimated_completion_time')}`).toISOString();
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const form = event.currentTarget;
|
||||
const submitButton = get('submit-btn');
|
||||
const payload = buildDroneRequestData(form);
|
||||
|
||||
get('loading').style.display = 'flex';
|
||||
submitButton.disabled = true;
|
||||
submitButton.textContent = 'Submitting...';
|
||||
|
||||
try {
|
||||
const response = await fetch(DRONE_REQUEST_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `Submission failed: ${response.status}`);
|
||||
}
|
||||
|
||||
form.style.display = 'none';
|
||||
get('success-message').style.display = 'block';
|
||||
showNotification('Drone request submitted successfully!');
|
||||
} catch (error) {
|
||||
console.error('Error submitting drone request:', error);
|
||||
showNotification(`Error submitting drone request: ${error.message}`, true);
|
||||
} finally {
|
||||
get('loading').style.display = 'none';
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = 'Submit Drone Request';
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initializeTimeDropdowns();
|
||||
setDefaultDateTime();
|
||||
get('drone-form').addEventListener('submit', handleSubmit);
|
||||
initializeMap().catch((error) => {
|
||||
console.error('Leaflet map failed to load:', error);
|
||||
setMapStatus('Map could not be loaded. Enter the location manually.');
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.drone-shell {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
margin-block: 1.5rem 0;
|
||||
}
|
||||
|
||||
.drone-head {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.drone-note {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.drone-form {
|
||||
display: grid;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
|
||||
.drone-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.drone-field {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.drone-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.drone-field label {
|
||||
color: var(--text);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.drone-field label span {
|
||||
color: var(--critical);
|
||||
}
|
||||
|
||||
.drone-field input,
|
||||
.drone-field select,
|
||||
.drone-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;
|
||||
}
|
||||
|
||||
.drone-field textarea {
|
||||
min-height: 7.5rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.drone-field input:focus,
|
||||
.drone-field select:focus,
|
||||
.drone-field textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand-2);
|
||||
box-shadow: 0 0 0 0.2rem rgba(29, 118, 184, 0.16);
|
||||
}
|
||||
|
||||
.drone-map-panel {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid rgba(16, 34, 51, 0.12);
|
||||
border-radius: 0.8rem;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.drone-map-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.drone-map-title {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.drone-map-status {
|
||||
margin: 0.1rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.drone-map {
|
||||
width: 100%;
|
||||
min-height: 31rem;
|
||||
border: 1px solid rgba(16, 34, 51, 0.14);
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
background: #dcecff;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.drone-map-location input {
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.drone-map-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.65rem 1rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.drone-map-legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.legend-swatch {
|
||||
width: 0.8rem;
|
||||
height: 0.8rem;
|
||||
border-radius: 0.2rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.legend-swatch.frz {
|
||||
border: 2px solid #6c2bd9;
|
||||
background: rgba(108, 43, 217, 0.12);
|
||||
}
|
||||
|
||||
.legend-swatch.rpz {
|
||||
border: 2px solid #7c3aed;
|
||||
background: rgba(124, 58, 237, 0.18);
|
||||
}
|
||||
|
||||
.legend-swatch.selected {
|
||||
border-radius: 50%;
|
||||
border: 2px solid #102233;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.drone-actions {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
padding-top: 0.4rem;
|
||||
}
|
||||
|
||||
.drone-actions .button,
|
||||
.drone-map-toolbar .button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.drone-actions .button {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.drone-actions .button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.drone-loading {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
color: var(--brand);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.drone-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: drone-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.drone-success {
|
||||
display: none;
|
||||
border-left-color: #257b4c;
|
||||
}
|
||||
|
||||
.drone-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;
|
||||
}
|
||||
|
||||
.drone-notification.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.drone-notification.error {
|
||||
background: var(--critical);
|
||||
}
|
||||
|
||||
@keyframes drone-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.drone-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.drone-map-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.drone-map {
|
||||
min-height: 25rem;
|
||||
}
|
||||
|
||||
.drone-actions,
|
||||
.drone-actions .button,
|
||||
.drone-map-toolbar .button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,669 @@
|
||||
---
|
||||
const configuredApiBase = import.meta.env.PUBLIC_PPR_API_BASE ?? 'https://ppr.swansea-airport.wales/api/v1';
|
||||
const pprApiBase = configuredApiBase.replace(/\/$/, '');
|
||||
---
|
||||
|
||||
<section class="ppr-shell surface" aria-labelledby="ppr-heading">
|
||||
<div class="ppr-head">
|
||||
<p class="eyebrow">Prior permission request</p>
|
||||
<h2 id="ppr-heading" class="section-title">Submit a PPR request</h2>
|
||||
<p class="section-copy">
|
||||
Complete the details below for flights into Swansea Airport. Requests are accepted by default;
|
||||
the airport will contact you if additional information is required.
|
||||
</p>
|
||||
<p class="ppr-note">
|
||||
This form is under test. If you have any issues, email
|
||||
<a href="mailto:james.pattinson@sasalliance.org">james.pattinson@sasalliance.org</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form id="ppr-form" class="ppr-form">
|
||||
<div class="ppr-grid">
|
||||
<div class="ppr-field">
|
||||
<label for="ac_reg">Aircraft Registration <span aria-hidden="true">*</span></label>
|
||||
<input type="text" id="ac_reg" name="ac_reg" autocomplete="off" required />
|
||||
<div id="aircraft-lookup-results" class="ppr-lookup" aria-live="polite"></div>
|
||||
</div>
|
||||
|
||||
<div class="ppr-field">
|
||||
<label for="ac_type">Aircraft Type <span aria-hidden="true">*</span></label>
|
||||
<input type="text" id="ac_type" name="ac_type" autocomplete="off" required />
|
||||
</div>
|
||||
|
||||
<div class="ppr-field">
|
||||
<label for="ac_call">Callsign</label>
|
||||
<input type="text" id="ac_call" name="ac_call" placeholder="If different from registration" />
|
||||
</div>
|
||||
|
||||
<div class="ppr-field">
|
||||
<label for="captain">Captain/Pilot Name <span aria-hidden="true">*</span></label>
|
||||
<input type="text" id="captain" name="captain" autocomplete="name" required />
|
||||
</div>
|
||||
|
||||
<div class="ppr-field">
|
||||
<label for="in_from">Arriving From <span aria-hidden="true">*</span></label>
|
||||
<input type="text" id="in_from" name="in_from" placeholder="ICAO code or airport name" autocomplete="off" required />
|
||||
<div id="arrival-airport-lookup-results" class="ppr-lookup" aria-live="polite"></div>
|
||||
</div>
|
||||
|
||||
<div class="ppr-field">
|
||||
<label for="eta-date">Estimated Time of Arrival (Local Time) <span aria-hidden="true">*</span></label>
|
||||
<div class="ppr-date-time">
|
||||
<input type="date" id="eta-date" name="eta-date" required />
|
||||
<select id="eta-time" name="eta-time" required>
|
||||
<option value="">Select Time</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ppr-field">
|
||||
<label for="pob_in">Persons on Board (Arrival) <span aria-hidden="true">*</span></label>
|
||||
<input type="number" id="pob_in" name="pob_in" min="1" inputmode="numeric" required />
|
||||
</div>
|
||||
|
||||
<div class="ppr-field">
|
||||
<label for="fuel">Fuel Required</label>
|
||||
<select id="fuel" name="fuel">
|
||||
<option value="">None</option>
|
||||
<option value="100LL">100LL</option>
|
||||
<option value="JET A1">JET A1</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="ppr-field">
|
||||
<label for="out_to">Departing To</label>
|
||||
<input type="text" id="out_to" name="out_to" placeholder="ICAO code or airport name" autocomplete="off" />
|
||||
<div id="departure-airport-lookup-results" class="ppr-lookup" aria-live="polite"></div>
|
||||
</div>
|
||||
|
||||
<div class="ppr-field">
|
||||
<label for="etd-date">Estimated Time of Departure (Local Time)</label>
|
||||
<div class="ppr-date-time">
|
||||
<input type="date" id="etd-date" name="etd-date" />
|
||||
<select id="etd-time" name="etd-time">
|
||||
<option value="">Select Time</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ppr-field">
|
||||
<label for="pob_out">Persons on Board (Departure)</label>
|
||||
<input type="number" id="pob_out" name="pob_out" min="1" inputmode="numeric" />
|
||||
</div>
|
||||
|
||||
<div class="ppr-field">
|
||||
<label for="email">Email Address</label>
|
||||
<input type="email" id="email" name="email" autocomplete="email" />
|
||||
</div>
|
||||
|
||||
<div class="ppr-field">
|
||||
<label for="phone">Phone Number</label>
|
||||
<input type="tel" id="phone" name="phone" autocomplete="tel" />
|
||||
</div>
|
||||
|
||||
<div class="ppr-field ppr-full">
|
||||
<label for="notes">Additional Notes</label>
|
||||
<textarea id="notes" name="notes" rows="4" placeholder="Any special requirements, handling instructions, or additional information..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ppr-actions">
|
||||
<button type="submit" class="button primary" id="submit-btn">Submit PPR Request</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="ppr-loading" id="loading" role="status" aria-live="polite">
|
||||
<span class="ppr-spinner" aria-hidden="true"></span>
|
||||
Submitting your PPR request...
|
||||
</div>
|
||||
|
||||
<div class="ppr-success notice" id="success-message" role="status" aria-live="polite">
|
||||
<h3>PPR Request Submitted.</h3>
|
||||
<p>Your PPR request has been submitted. You will receive confirmation via email if provided.</p>
|
||||
<p><strong>Please note:</strong> PPR requests are accepted by default. We will contact you if additional information is required. Remember to check NOTAMs before your flight.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="notification" class="ppr-notification" role="status" aria-live="polite"></div>
|
||||
|
||||
<script define:vars={{ pprApiBase }}>
|
||||
(() => {
|
||||
const API_BASE = pprApiBase;
|
||||
let etdManuallyEdited = false;
|
||||
let aircraftLookupTimeout;
|
||||
let arrivalAirportLookupTimeout;
|
||||
let departureAirportLookupTimeout;
|
||||
|
||||
const get = (id) => document.getElementById(id);
|
||||
|
||||
function setLookupStatus(results, message, className) {
|
||||
results.replaceChildren();
|
||||
if (!message) return;
|
||||
const status = document.createElement('span');
|
||||
status.className = className;
|
||||
status.textContent = message;
|
||||
results.append(status);
|
||||
}
|
||||
|
||||
function createOptionList(items, renderItem) {
|
||||
const list = document.createElement('div');
|
||||
list.className = 'ppr-option-list';
|
||||
items.forEach((item) => list.append(renderItem(item)));
|
||||
return list;
|
||||
}
|
||||
|
||||
function createAircraftOption(aircraft, fallbackRegistration) {
|
||||
const registration = aircraft.registration || fallbackRegistration.toUpperCase();
|
||||
const typeCode = aircraft.type_code || '';
|
||||
const option = document.createElement('button');
|
||||
option.type = 'button';
|
||||
option.className = 'ppr-option';
|
||||
option.addEventListener('mousedown', (event) => event.preventDefault());
|
||||
option.addEventListener('click', () => selectAircraft(registration, typeCode));
|
||||
|
||||
const code = document.createElement('span');
|
||||
code.className = 'ppr-option-code';
|
||||
code.textContent = registration;
|
||||
|
||||
const details = document.createElement('span');
|
||||
details.className = 'ppr-option-detail';
|
||||
details.textContent = `${typeCode || 'Unknown'}${aircraft.model ? ` (${aircraft.model})` : ''}`;
|
||||
|
||||
option.append(code, details);
|
||||
return option;
|
||||
}
|
||||
|
||||
function createAirportOption(fieldId, airport) {
|
||||
const option = document.createElement('button');
|
||||
option.type = 'button';
|
||||
option.className = 'ppr-option';
|
||||
option.addEventListener('mousedown', (event) => event.preventDefault());
|
||||
option.addEventListener('click', () => selectAirport(fieldId, airport.icao));
|
||||
|
||||
const code = document.createElement('span');
|
||||
code.className = 'ppr-option-code';
|
||||
code.textContent = `${airport.icao}/${airport.iata || ''}`;
|
||||
|
||||
const details = document.createElement('span');
|
||||
details.className = 'ppr-option-detail';
|
||||
details.textContent = `${airport.name}, ${airport.country}`;
|
||||
|
||||
option.append(code, details);
|
||||
return option;
|
||||
}
|
||||
|
||||
function selectAircraft(registration, typeCode) {
|
||||
get('ac_reg').value = registration;
|
||||
get('ac_type').value = typeCode;
|
||||
get('aircraft-lookup-results').replaceChildren();
|
||||
get('ac_reg').blur();
|
||||
}
|
||||
|
||||
function selectAirport(fieldId, icaoCode) {
|
||||
get(fieldId).value = icaoCode;
|
||||
const resultsId = fieldId === 'in_from' ? 'arrival-airport-lookup-results' : 'departure-airport-lookup-results';
|
||||
get(resultsId).replaceChildren();
|
||||
get(fieldId).blur();
|
||||
}
|
||||
|
||||
function initializeTimeDropdowns() {
|
||||
['eta-time', 'etd-time'].forEach((selectId) => {
|
||||
const select = get(selectId);
|
||||
select.replaceChildren(new Option('Select Time', ''));
|
||||
|
||||
for (let hour = 0; hour < 24; hour += 1) {
|
||||
for (let minute = 0; minute < 60; minute += 15) {
|
||||
const timeString = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||
select.append(new Option(timeString, timeString));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatTime(date) {
|
||||
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function updateETDFromETA() {
|
||||
if (etdManuallyEdited) return;
|
||||
|
||||
const etaDate = get('eta-date').value;
|
||||
const etaTime = get('eta-time').value;
|
||||
if (!etaDate || !etaTime) return;
|
||||
|
||||
const etd = new Date(new Date(`${etaDate}T${etaTime}`).getTime() + 2 * 60 * 60 * 1000);
|
||||
get('etd-date').value = formatDate(etd);
|
||||
get('etd-time').value = formatTime(etd);
|
||||
}
|
||||
|
||||
function setDefaultDateTime() {
|
||||
const nextHour = new Date();
|
||||
nextHour.setHours(nextHour.getHours() + 1, 0, 0, 0);
|
||||
|
||||
const etd = new Date(nextHour);
|
||||
etd.setHours(nextHour.getHours() + 2);
|
||||
|
||||
get('eta-date').value = formatDate(nextHour);
|
||||
get('eta-time').value = formatTime(nextHour);
|
||||
get('etd-date').value = formatDate(etd);
|
||||
get('etd-time').value = formatTime(etd);
|
||||
}
|
||||
|
||||
function showNotification(message, isError = false) {
|
||||
const notification = get('notification');
|
||||
notification.textContent = message;
|
||||
notification.className = `ppr-notification${isError ? ' error' : ''}`;
|
||||
|
||||
requestAnimationFrame(() => notification.classList.add('show'));
|
||||
window.setTimeout(() => notification.classList.remove('show'), 5000);
|
||||
}
|
||||
|
||||
async function handleAircraftLookup(registration) {
|
||||
clearTimeout(aircraftLookupTimeout);
|
||||
const results = get('aircraft-lookup-results');
|
||||
|
||||
if (!registration || registration.length < 4) {
|
||||
results.replaceChildren();
|
||||
return;
|
||||
}
|
||||
|
||||
setLookupStatus(results, 'Searching...', 'ppr-lookup-searching');
|
||||
aircraftLookupTimeout = window.setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/aircraft/public/lookup/${registration.toUpperCase()}`);
|
||||
if (!response.ok) {
|
||||
results.replaceChildren();
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data || data.length === 0) {
|
||||
setLookupStatus(results, 'No aircraft found with this registration', 'ppr-lookup-muted');
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.length === 1) {
|
||||
const aircraft = data[0];
|
||||
get('ac_reg').value = aircraft.registration || registration.toUpperCase();
|
||||
if (!get('ac_type').value) get('ac_type').value = aircraft.type_code || '';
|
||||
}
|
||||
|
||||
if (data.length <= 10) {
|
||||
results.replaceChildren(createOptionList(data, (item) => createAircraftOption(item, registration)));
|
||||
return;
|
||||
}
|
||||
|
||||
setLookupStatus(results, 'Too many matches, please be more specific', 'ppr-lookup-muted');
|
||||
} catch (error) {
|
||||
console.error('Aircraft lookup error:', error);
|
||||
results.replaceChildren();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
async function handleAirportLookup(query, fieldId, resultsId, timeoutSetter) {
|
||||
const results = get(resultsId);
|
||||
if (!query || query.length < 2) {
|
||||
results.replaceChildren();
|
||||
return;
|
||||
}
|
||||
|
||||
setLookupStatus(results, 'Searching...', 'ppr-lookup-searching');
|
||||
timeoutSetter(window.setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/airport/public/lookup/${query.toUpperCase()}`);
|
||||
if (!response.ok) {
|
||||
results.replaceChildren();
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data || data.length === 0) {
|
||||
setLookupStatus(results, 'No airport found', 'ppr-lookup-muted');
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.length <= 10) {
|
||||
results.replaceChildren(createOptionList(data, (airport) => createAirportOption(fieldId, airport)));
|
||||
return;
|
||||
}
|
||||
|
||||
setLookupStatus(results, 'Too many matches, please be more specific', 'ppr-lookup-muted');
|
||||
} catch (error) {
|
||||
console.error('Airport lookup error:', error);
|
||||
results.replaceChildren();
|
||||
}
|
||||
}, 500));
|
||||
}
|
||||
|
||||
function buildPprData(form) {
|
||||
const formData = new FormData(form);
|
||||
const pprData = {};
|
||||
|
||||
formData.forEach((value, key) => {
|
||||
const fieldValue = String(value).trim();
|
||||
if (!fieldValue) return;
|
||||
|
||||
if (key === 'pob_in' || key === 'pob_out') {
|
||||
pprData[key] = Number.parseInt(fieldValue, 10);
|
||||
} else if (key === 'eta-date' && formData.get('eta-time')) {
|
||||
pprData.eta = new Date(`${fieldValue}T${formData.get('eta-time')}`).toISOString();
|
||||
} else if (key === 'etd-date' && formData.get('etd-time')) {
|
||||
pprData.etd = new Date(`${fieldValue}T${formData.get('etd-time')}`).toISOString();
|
||||
} else if (key !== 'eta-time' && key !== 'etd-time') {
|
||||
pprData[key] = fieldValue;
|
||||
}
|
||||
});
|
||||
|
||||
return pprData;
|
||||
}
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const form = event.currentTarget;
|
||||
const submitButton = get('submit-btn');
|
||||
get('loading').style.display = 'flex';
|
||||
submitButton.disabled = true;
|
||||
submitButton.textContent = 'Submitting...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/pprs/public`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(buildPprData(form)),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `Submission failed: ${response.status}`);
|
||||
}
|
||||
|
||||
form.style.display = 'none';
|
||||
get('success-message').style.display = 'block';
|
||||
showNotification('PPR request submitted successfully!');
|
||||
} catch (error) {
|
||||
console.error('Error submitting PPR:', error);
|
||||
showNotification(`Error submitting PPR: ${error.message}`, true);
|
||||
} finally {
|
||||
get('loading').style.display = 'none';
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = 'Submit PPR Request';
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initializeTimeDropdowns();
|
||||
setDefaultDateTime();
|
||||
|
||||
get('ac_reg').addEventListener('input', (event) => handleAircraftLookup(event.target.value));
|
||||
get('ac_reg').addEventListener('blur', () => window.setTimeout(() => get('aircraft-lookup-results').replaceChildren(), 150));
|
||||
|
||||
get('in_from').addEventListener('input', (event) => {
|
||||
clearTimeout(arrivalAirportLookupTimeout);
|
||||
handleAirportLookup(event.target.value, 'in_from', 'arrival-airport-lookup-results', (timeout) => {
|
||||
arrivalAirportLookupTimeout = timeout;
|
||||
});
|
||||
});
|
||||
get('in_from').addEventListener('blur', () => window.setTimeout(() => get('arrival-airport-lookup-results').replaceChildren(), 150));
|
||||
|
||||
get('out_to').addEventListener('input', (event) => {
|
||||
clearTimeout(departureAirportLookupTimeout);
|
||||
handleAirportLookup(event.target.value, 'out_to', 'departure-airport-lookup-results', (timeout) => {
|
||||
departureAirportLookupTimeout = timeout;
|
||||
});
|
||||
});
|
||||
get('out_to').addEventListener('blur', () => window.setTimeout(() => get('departure-airport-lookup-results').replaceChildren(), 150));
|
||||
|
||||
get('eta-date').addEventListener('change', updateETDFromETA);
|
||||
get('eta-time').addEventListener('change', updateETDFromETA);
|
||||
get('etd-date').addEventListener('change', () => {
|
||||
etdManuallyEdited = true;
|
||||
});
|
||||
get('etd-time').addEventListener('change', () => {
|
||||
etdManuallyEdited = true;
|
||||
});
|
||||
get('ppr-form').addEventListener('submit', handleSubmit);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.ppr-shell {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
margin-block: 1.5rem 0;
|
||||
}
|
||||
|
||||
.ppr-head {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.ppr-note {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.ppr-form {
|
||||
display: grid;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
|
||||
.ppr-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.ppr-field {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.ppr-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.ppr-field label {
|
||||
color: var(--text);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.ppr-field label span {
|
||||
color: var(--critical);
|
||||
}
|
||||
|
||||
.ppr-field input,
|
||||
.ppr-field select,
|
||||
.ppr-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;
|
||||
}
|
||||
|
||||
.ppr-field textarea {
|
||||
min-height: 7.5rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.ppr-field input:focus,
|
||||
.ppr-field select:focus,
|
||||
.ppr-field textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand-2);
|
||||
box-shadow: 0 0 0 0.2rem rgba(29, 118, 184, 0.16);
|
||||
}
|
||||
|
||||
.ppr-date-time {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(8rem, 0.72fr);
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.ppr-lookup {
|
||||
color: var(--muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.ppr-lookup-searching {
|
||||
color: var(--brand-2);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ppr-lookup-muted {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.ppr-option-list {
|
||||
display: grid;
|
||||
max-height: 14rem;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.7rem;
|
||||
background: var(--panel-strong);
|
||||
box-shadow: 0 0.7rem 1.8rem rgba(16, 34, 51, 0.08);
|
||||
}
|
||||
|
||||
.ppr-option {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.7rem 0.8rem;
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba(16, 34, 51, 0.08);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ppr-option:hover,
|
||||
.ppr-option:focus {
|
||||
outline: none;
|
||||
background: var(--brand-soft);
|
||||
}
|
||||
|
||||
.ppr-option:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.ppr-option-code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.ppr-option-detail {
|
||||
color: var(--muted);
|
||||
font-size: 0.86rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ppr-actions {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
padding-top: 0.4rem;
|
||||
}
|
||||
|
||||
.ppr-actions .button {
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ppr-actions .button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ppr-loading {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
color: var(--brand);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.ppr-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: ppr-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.ppr-success {
|
||||
display: none;
|
||||
border-left-color: #257b4c;
|
||||
}
|
||||
|
||||
.ppr-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;
|
||||
}
|
||||
|
||||
.ppr-notification.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.ppr-notification.error {
|
||||
background: var(--critical);
|
||||
}
|
||||
|
||||
@keyframes ppr-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.ppr-grid,
|
||||
.ppr-date-time {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ppr-actions,
|
||||
.ppr-actions .button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ppr-option {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.ppr-option-detail {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+1
-1
@@ -22,11 +22,11 @@ export const site = {
|
||||
children: [
|
||||
{ label: 'History', href: '/about/history/' },
|
||||
{ label: 'Drones', href: '/about/drones/' },
|
||||
{ label: 'Fees and Charges', href: '/about/fees-and-charges/' },
|
||||
{ label: 'Noise', href: '/about/noise/' },
|
||||
{ label: 'Volunteering', href: '/about/volunteering/' },
|
||||
],
|
||||
},
|
||||
{ label: 'Procedures', href: '/procedures-safety-noise-abatement/' },
|
||||
{ label: 'Events', href: '/events/' },
|
||||
{ label: 'News', href: '/news/' },
|
||||
{ label: 'Documents', href: '/documents/' },
|
||||
|
||||
@@ -23,6 +23,10 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
much notice as possible via email, with the following information:
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a class="button primary" href="/drone-flight-request/">Request a Drone Flight</a>
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li>Date of flight</li>
|
||||
<li>Estimated take-off time</li>
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
|
||||
const gaCharges = [
|
||||
['Microlight', '£10', 'Free of charge', '£10', '£20'],
|
||||
['Standard GA Single', '£15', 'Free of charge', '£15', '£30'],
|
||||
['GA Light Twin', '£25', 'Free of charge', '£15', '£30'],
|
||||
['2500kg - 3000kg', '£60', 'Free of charge', '£30', 'Price on request'],
|
||||
['3001kg - 4000kg', '£80', '5 hours free, £5 after', '£45', 'Price on request'],
|
||||
['4001kg - 5000kg', '£180', '5 hours free, £10 after', '£60', 'Price on request'],
|
||||
];
|
||||
|
||||
const touchAndGoCharges = [
|
||||
['Microlight', '£5', '£10'],
|
||||
['Standard GA Single', '£10', '£30'],
|
||||
['GA Light Twin', '£15', '£50'],
|
||||
['2500kg - 3000kg', '£30', '£120'],
|
||||
['3001kg - 4000kg', '£40', '£160'],
|
||||
['4001kg - 5000kg', '£80', '£360'],
|
||||
];
|
||||
|
||||
const businessCharges = [
|
||||
['4001kg - 5000kg', '£200', '5 hours free, £15 after', '£80'],
|
||||
['5001kg - 6000kg', '£220', '5 hours free, £15 after', '£80'],
|
||||
['6001kg - 7000kg', '£240', '5 hours free, £15 after', '£80'],
|
||||
['7001kg - 8000kg', '£265', '4 hours free, £15 after', '£80'],
|
||||
['8001kg - 9000kg', '£290', '4 hours free, £20 after', '£100'],
|
||||
['9001kg - 10000kg', '£330', '4 hours free, £20 after', '£100'],
|
||||
['10001kg - 11000kg', '£360', '£20', '£120'],
|
||||
['11001kg - 12000kg', '£390', '£20', '£120'],
|
||||
['12001kg - 13000kg', '£420', '£25', '£140'],
|
||||
['13001kg - 14000kg', '£450', '£25', '£140'],
|
||||
['14001kg - 15000kg', '£480', '£25', '£140'],
|
||||
['15001kg - 16000kg', '£560', '£30', '£180'],
|
||||
['16001kg - 17000kg', '£640', '£30', '£180'],
|
||||
['17001kg - 18000kg', '£740', '£30', '£180'],
|
||||
];
|
||||
|
||||
const additionalCharges = [
|
||||
['Handling charges', 'Up to £60, price on request', 'Includes marshalling, catering, hotel and transport organisation, and use of crew room if available.'],
|
||||
['Out of hours', '£130', 'Per hour required.'],
|
||||
['Passenger loading', '£5 per passenger', 'Aircraft up to 5000kg.'],
|
||||
['Passenger loading', '£8 per passenger', 'Aircraft between 5001kg and 10000kg.'],
|
||||
['Passenger loading', '£10 per passenger', 'Aircraft 10001kg and above.'],
|
||||
['Porterage charges', 'On request', 'Assistance with baggage or cargo from or to the aircraft.'],
|
||||
['Runway closure', '£50', 'At management discretion following incident or accident.'],
|
||||
['Drones', '£25', 'Commercial drones need 2 days notice before flight and a permit.'],
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout title="Fees and Charges" description="Swansea Airport landing, parking, handling, and related charges.">
|
||||
<section class="container fees-page">
|
||||
<p class="eyebrow">About</p>
|
||||
<h1 class="section-title">Fees and Charges</h1>
|
||||
<p class="section-copy">Prices are based on maximum take-off weight (MTOW) unless otherwise stated.</p>
|
||||
|
||||
<section class="fee-section">
|
||||
<h2>General Aviation</h2>
|
||||
<div class="fee-table-wrap">
|
||||
<table class="fee-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Type</th>
|
||||
<th scope="col">Landing fee</th>
|
||||
<th scope="col">Daytime parking</th>
|
||||
<th scope="col">Overnight parking outside</th>
|
||||
<th scope="col">Overnight parking hangar</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{gaCharges.map((row) => (
|
||||
<tr>
|
||||
{row.map((cell) => <td>{cell}</td>)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="fee-section">
|
||||
<h2>Touch and Go's</h2>
|
||||
<div class="fee-table-wrap compact">
|
||||
<table class="fee-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Type</th>
|
||||
<th scope="col">Single</th>
|
||||
<th scope="col">Unlimited</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{touchAndGoCharges.map((row) => (
|
||||
<tr>
|
||||
{row.map((cell) => <td>{cell}</td>)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="fee-section">
|
||||
<h2>GA Notes</h2>
|
||||
<ul>
|
||||
<li>Hangarage parking is subject to availability.</li>
|
||||
<li>Daytime parking is 0900-1700 local.</li>
|
||||
<li>Overnight parking is 1700-0900 local.</li>
|
||||
<li>GA aircraft departing after hours will not be subject to additional fees.</li>
|
||||
<li>Handling is free of charge up to 2500kg.</li>
|
||||
<li>Aircraft above 2500kg in the GA category may be charged up to £60 for handling.</li>
|
||||
<li>Other prices are by request.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="fee-section">
|
||||
<h2>Business and Corporate Aviation</h2>
|
||||
<div class="fee-table-wrap">
|
||||
<table class="fee-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">MTOW</th>
|
||||
<th scope="col">Landing fee</th>
|
||||
<th scope="col">Daytime parking</th>
|
||||
<th scope="col">Overnight parking</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{businessCharges.map((row) => (
|
||||
<tr>
|
||||
{row.map((cell) => <td>{cell}</td>)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<ul>
|
||||
<li>Hangarage parking is subject to availability.</li>
|
||||
<li>Daytime parking is 0900-1700 local.</li>
|
||||
<li>Overnight parking is 1700-0900 local.</li>
|
||||
<li>Aircraft departing out of hours will be charged £130 per hour.</li>
|
||||
<li>Aircraft may be charged for handling services.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="fee-section">
|
||||
<h2>Handling and Additional Charges</h2>
|
||||
<div class="fee-table-wrap">
|
||||
<table class="fee-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Charge</th>
|
||||
<th scope="col">Price</th>
|
||||
<th scope="col">Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{additionalCharges.map((row) => (
|
||||
<tr>
|
||||
{row.map((cell) => <td>{cell}</td>)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.fees-page {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.fee-section {
|
||||
padding-block: 0.6rem;
|
||||
}
|
||||
|
||||
.fee-section h2 {
|
||||
margin: 0 0 0.8rem;
|
||||
font-family: 'Manrope', system-ui, sans-serif;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.fee-table-wrap {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
margin-block: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.fee-table {
|
||||
width: 100%;
|
||||
min-width: 44rem;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
box-shadow: 0 12px 24px rgba(16, 34, 51, 0.08);
|
||||
}
|
||||
|
||||
.fee-table-wrap.compact .fee-table {
|
||||
min-width: 28rem;
|
||||
}
|
||||
|
||||
.fee-table thead {
|
||||
background: linear-gradient(180deg, rgba(11, 79, 122, 0.14), rgba(29, 118, 184, 0.09));
|
||||
}
|
||||
|
||||
.fee-table th,
|
||||
.fee-table td {
|
||||
padding: 0.72rem 0.85rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.fee-table th {
|
||||
color: var(--brand);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fee-table tbody tr:nth-child(even) {
|
||||
background: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
|
||||
.fee-table tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.fee-section ul {
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
</style>
|
||||
@@ -20,6 +20,10 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
<h3><a href="/about/drones/">Drones</a></h3>
|
||||
<p>Guidance and local operating expectations for drone use near the airport.</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h3><a href="/about/fees-and-charges/">Fees and Charges</a></h3>
|
||||
<p>Landing, parking, handling, passenger loading, drone, and related operational charges.</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h3><a href="/about/noise/">Noise</a></h3>
|
||||
<p>Information about noise awareness, reporting, and community engagement.</p>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import DroneFlightRequestForm from '../components/DroneFlightRequestForm.astro';
|
||||
---
|
||||
|
||||
<BaseLayout title="Drone Flight Request" description="Request a drone flight within the Swansea Airport FRZ.">
|
||||
<div class="container prose">
|
||||
<DroneFlightRequestForm />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -66,7 +66,7 @@ const businessPromos = [
|
||||
</p>
|
||||
<div class="cta-row">
|
||||
<a class="button primary" href="/pilot-info/">Pilot Info</a>
|
||||
<a class="button secondary" href="/procedures-safety-noise-abatement/">Procedures and safety</a>
|
||||
<a class="button primary" href="/ppr/">Request PPR</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,13 +24,72 @@ const fuelPrices = await getFuelPrices();
|
||||
<BaseLayout title="Pilot Info" description="Essential information for pilots planning a visit to Swansea Airport.">
|
||||
<div class="container prose">
|
||||
<p class="eyebrow">Pilot info</p>
|
||||
<h1 class="section-title">Operating hours</h1>
|
||||
<p><strong>Airport open:</strong> Monday to Sunday, 0900-1800 local time.</p>
|
||||
<p><strong>Licensed with fire cover:</strong> Friday, Saturday, and Sunday only. RFFS Cat A1.</p>
|
||||
|
||||
<h1 class="section-title">Communications</h1>
|
||||
<p>
|
||||
Swansea Airport operates a Air / Ground Communication Service, callsign Swansea Radio on 119.705. This service is staffed by volunteers and may not be operational at all times.
|
||||
Swansea Airport operates a Air / Ground Communication Service, callsign SWANSEA RADIO on 119.705. This service is staffed by volunteers and may not be operational at all times.
|
||||
</p>
|
||||
<p>
|
||||
When the Tower is unavailable, this will be NOTAMed and blind calls to Swansea Traffic shall be made on the Air / Ground frequency
|
||||
When the Tower is unavailable, this will be NOTAMed and blind calls to Swansea Traffic shall be made on the Air / Ground frequency.
|
||||
</p>
|
||||
<p>Please submit your PPR request before flying into Swansea.</p>
|
||||
|
||||
|
||||
<center><a class="button primary" href="/ppr/">Request PPR</a></center>
|
||||
<h2>Procedures</h2>
|
||||
<p>
|
||||
<img src="/images/SDMAP.png" alt="Swansea Airport procedures map" loading="lazy" />
|
||||
</p>
|
||||
|
||||
<h3>Arrivals</h3>
|
||||
<p>
|
||||
Make your initial arrival call 10 NM from the ATZ. From the east, this is above Port Talbot.
|
||||
From the north, this is above Ammanford/Tycroes. From the west, this is just off the Gower
|
||||
coast approaching Rhossili. From the south, this is over the sea abeam Porthcawl.
|
||||
</p>
|
||||
<p>
|
||||
The Air/Ground service will advise if parachuting is in progress, as well as providing the
|
||||
usual joining information. If parachuting is in progress, <strong>no overhead joins are permitted</strong>
|
||||
and there is to be <strong>no dead-side flight within the ATZ</strong>.
|
||||
</p>
|
||||
<p>All joins are to be downwind or base leg joins only.</p>
|
||||
<p>
|
||||
When already on frequency, parachuting will be notified by the Air/Ground service using the
|
||||
following message:
|
||||
</p>
|
||||
<blockquote>
|
||||
ALL STATIONS, THIS IS SWANSEA RADIO. PARACHUTING HAS COMMENCED ON THE NORTHERN/SOUTHERN PLA.
|
||||
NO OVERHEAD JOINS, NO DEAD SIDE FLIGHT.
|
||||
</blockquote>
|
||||
<p>
|
||||
When this is called, <strong>no overhead joins or dead-side flight is permitted in the ATZ</strong>.
|
||||
</p>
|
||||
|
||||
<h3>Departures</h3>
|
||||
<p>
|
||||
<strong>All helicopters are to request start.</strong> This is to protect parachuting operations.
|
||||
If parachuting descent is already taking place, pilots will be advised to start only after the
|
||||
parachuting descent has completed.
|
||||
</p>
|
||||
<p>When parachuting is in progress, all departing aircraft should:</p>
|
||||
<ul>
|
||||
<li>Climb straight ahead until clear of the ATZ.</li>
|
||||
<li>Climb on the crosswind until clear of the ATZ.</li>
|
||||
</ul>
|
||||
<p>
|
||||
<strong>Helicopters are to adhere to the circuit patterns when parachuting is taking place.</strong>
|
||||
</p>
|
||||
<p>
|
||||
<strong>
|
||||
Be advised that these procedures form part of the rules of the aerodrome. Permission to use
|
||||
the aerodrome will be revoked if they are not adhered to.
|
||||
</strong>
|
||||
</p>
|
||||
|
||||
|
||||
|
||||
<h2>Runway Information</h2>
|
||||
<div class="runway-table-wrap">
|
||||
@@ -64,11 +123,6 @@ When the Tower is unavailable, this will be NOTAMed and blind calls to Swansea T
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<p>Circuits at 1000ft agl. Overhead joins and/or dead-side flying not permitted when Parachuting is active.
|
||||
|
||||
|
||||
|
||||
</p>
|
||||
|
||||
<FuelPricesWidget fuelPrices={fuelPrices} />
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import PprRequestForm from '../components/PprRequestForm.astro';
|
||||
---
|
||||
|
||||
<BaseLayout title="Request PPR" description="Submit a prior permission request for flights into Swansea Airport.">
|
||||
<div class="container prose">
|
||||
<PprRequestForm />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
---
|
||||
|
||||
<BaseLayout title="Procedures, Safety and Noise Abatement" description="Operational procedures, safety notes, and noise-sensitive guidance.">
|
||||
<div class="container prose">
|
||||
<p class="eyebrow">Operations</p>
|
||||
<h1 class="section-title">Procedures, safety, and noise abatement</h1>
|
||||
<p>
|
||||
This page is intentionally text-led and easy to scan. It is controlled by Astro so the structure stays stable even as the content evolves.
|
||||
</p>
|
||||
|
||||
<h2>Safety priorities</h2>
|
||||
<div class="cards-grid">
|
||||
<article class="card">
|
||||
<h3>Brief before flight</h3>
|
||||
<p>Surface the checklist items pilots need most, without burying them under visual clutter.</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h3>Check current notices</h3>
|
||||
<p>Operational notices should be reviewed before taxi, because the homepage is fed by the same notices collection.</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h3>Respect local noise guidance</h3>
|
||||
<p>Noise abatement text can be expanded in Directus while the page structure stays fixed in code.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
Reference in New Issue
Block a user