feat(i18n): add route manifest and use it for language toggle + hreflang; add alias redirects to avoid 404s on mismatched slugs

This commit is contained in:
Ruidy 2025-09-05 11:59:18 -04:00
parent 6cf3d3c853
commit cc09505eca
12 changed files with 356 additions and 10 deletions

View file

@ -1 +1,8 @@
/ /fr/ 301!
# Helpful aliases to avoid 404s from external links
/fr/reviews /fr/avis 301
/en/avis /en/reviews 301
/fr/location-access /fr/acces 301
/en/acces /en/location-access 301
/fr/rates-availability /fr/tarifs-disponibilites 301
/en/tarifs-disponibilites /en/rates-availability 301

71
src/i18n/routes.ts Normal file
View file

@ -0,0 +1,71 @@
export type Locale = 'fr' | 'en';
export type RouteKey =
| 'home'
| 'apartments'
| 'apartment_t2'
| 'apartment_t3'
| 'reviews'
| 'location'
| 'rates'
| 'contact'
| 'thank_you';
export const routes: Record<RouteKey, Record<Locale, string>> = {
home: { fr: '/fr/', en: '/en/' },
apartments: { fr: '/fr/appartements/', en: '/en/apartments/' },
apartment_t2: { fr: '/fr/appartements/t2-corail/', en: '/en/apartments/t2-corail/' },
apartment_t3: { fr: '/fr/appartements/t3-azur/', en: '/en/apartments/t3-azur/' },
reviews: { fr: '/fr/avis/', en: '/en/reviews/' },
location: { fr: '/fr/acces/', en: '/en/location-access/' },
rates: { fr: '/fr/tarifs-disponibilites/', en: '/en/rates-availability/' },
contact: { fr: '/fr/contact/', en: '/en/contact/' },
thank_you: { fr: '/fr/merci/', en: '/en/thank-you/' },
};
const normalize = (p: string) => {
if (!p) return '/';
let out = p.startsWith('/') ? p : `/${p}`;
if (!out.endsWith('/')) out = `${out}/`;
return out;
};
export function keyForPath(pathname: string): RouteKey | null {
const n = normalize(pathname);
for (const key of Object.keys(routes) as RouteKey[]) {
const localizations = routes[key];
if (n === localizations.fr || n === localizations.en) return key;
}
return null;
}
export function siblingPath(pathname: string, locale: Locale): string {
const key = keyForPath(pathname);
if (key) return routes[key][locale];
return routes.home[locale];
}
export function hrefFor(key: RouteKey, locale: Locale): string {
return routes[key][locale];
}
export function navFor(locale: Locale): Array<{ label: string; href: string }> {
return locale === 'en'
? [
{ label: 'Apartments', href: routes.apartments.en },
{ label: 'Reviews', href: routes.reviews.en },
{ label: 'Location & Access', href: routes.location.en },
{ label: 'Rates', href: routes.rates.en },
]
: [
{ label: 'Appartements', href: routes.apartments.fr },
{ label: 'Avis', href: routes.reviews.fr },
{ label: 'Accès', href: routes.location.fr },
{ label: 'Tarifs', href: routes.rates.fr },
];
}
export function ctaLabelFor(locale: Locale): string {
return locale === 'en' ? 'Send a Request' : 'Envoyer une demande';
}

View file

@ -1,11 +1,13 @@
---
import '../styles/global.css';
import { siblingPath, hrefFor, navFor, ctaLabelFor } from '../i18n/routes';
const { title = 'VillaFleurie', lang = 'fr', description = 'Séjours confortables au Gosier pour couples et petites familles' } = Astro.props;
const pathname = Astro.url.pathname;
const toFr = (p: string) => p.startsWith('/en/') ? `/fr/${p.slice(4)}` : (p.startsWith('/fr/') ? p : '/fr/');
const toEn = (p: string) => p.startsWith('/fr/') ? `/en/${p.slice(4)}` : (p.startsWith('/en/') ? p : '/en/');
const altFr = toFr(pathname);
const altEn = toEn(pathname);
const altFr = siblingPath(pathname, 'fr');
const altEn = siblingPath(pathname, 'en');
const nav = navFor(lang);
const ctaLabel = ctaLabelFor(lang);
const homeHref = hrefFor('home', lang);
---
<!doctype html>
<html lang={lang}>
@ -22,16 +24,13 @@ const altEn = toEn(pathname);
<body class="min-h-screen bg-white text-slate-900">
<header class="border-b border-slate-200">
<nav class="mx-auto max-w-6xl flex items-center justify-between p-4">
<a href="/fr/" class="flex items-center gap-3">
<a href={homeHref} class="flex items-center gap-3">
<img src="/assets/images/logo.png" alt="VillaFleurie" class="h-10 w-auto" />
<span class="sr-only">VillaFleurie</span>
</a>
<div class="flex items-center gap-4">
<a href="/fr/appartements/" class="text-sm hover:underline">Appartements</a>
<a href="/fr/avis/" class="text-sm hover:underline">Avis</a>
<a href="/fr/acces/" class="text-sm hover:underline">Accès</a>
<a href="/fr/tarifs-disponibilites/" class="text-sm hover:underline">Tarifs</a>
<a href="/fr/contact/" class="inline-flex items-center rounded-lg bg-brand px-3 py-2 text-white hover:bg-brand-600">Envoyer une demande</a>
{nav.map((n) => <a href={n.href} class="text-sm hover:underline">{n.label}</a>)}
<a href={hrefFor('contact', lang)} class="inline-flex items-center rounded-lg bg-brand px-3 py-2 text-white hover:bg-brand-600">{ctaLabel}</a>
<div class="ml-2 flex items-center gap-2 text-xs">
<a href={altFr} class={"hover:underline " + (lang==='fr' ? 'font-semibold text-brand-600' : '')}>FR</a>
<span class="text-slate-400">|</span>

View file

@ -0,0 +1,21 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro';
---
<BaseLayout title="Apartments" lang="en">
<div class="mx-auto max-w-6xl px-6 py-10">
<h1 class="text-3xl font-semibold">Apartments</h1>
<div class="mt-6 grid md:grid-cols-2 gap-6">
<article class="rounded-lg border border-slate-200 p-4">
<h2 class="text-lg font-semibold">T2 Corail</h2>
<p class="text-sm text-slate-600">45 m² • 23 guests • 1 queen + sofabed • €59/night</p>
<a href="/en/apartments/t2-corail/" class="mt-3 inline-block text-brand-600 underline">Discover</a>
</article>
<article class="rounded-lg border border-slate-200 p-4">
<h2 class="text-lg font-semibold">T3 Azur</h2>
<p class="text-sm text-slate-600">55 m² • up to 4 guests • 2 queen beds • €79/night</p>
<a href="/en/apartments/t3-azur/" class="mt-3 inline-block text-brand-600 underline">Discover</a>
</article>
</div>
</div>
</BaseLayout>

View file

@ -0,0 +1,24 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro';
---
<BaseLayout title="T2 Corail" lang="en">
<div class="mx-auto max-w-6xl px-6 py-10">
<h1 class="text-3xl font-semibold">T2 Corail</h1>
<p class="mt-2 text-slate-700">Ideal for couples, a cozy and warm apartment close to beaches and shops.</p>
<ul class="mt-4 text-sm text-slate-700 grid grid-cols-1 md:grid-cols-2 gap-2">
<li>45 m²</li>
<li>23 guests</li>
<li>1 queen bed + sofabed</li>
<li>Air conditioning, WiFi, equipped kitchen</li>
<li>Crib on demand</li>
<li>Secured parking</li>
<li>Minimum stay: 3 nights</li>
<li>Checkin 14:00 / Checkout 11:00</li>
</ul>
<div class="mt-6 flex gap-3">
<a href="/en/contact/" class="inline-flex items-center rounded-lg bg-brand px-4 py-2 text-white hover:bg-brand-600">Send a Request</a>
<a href="https://airbnb.fr/h/villafleurie-t2" target="_blank" rel="noopener" class="inline-flex items-center rounded-lg border border-slate-200 px-4 py-2">Book on Airbnb (T2)</a>
</div>
</div>
</BaseLayout>

View file

@ -0,0 +1,24 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro';
---
<BaseLayout title="T3 Azur" lang="en">
<div class="mx-auto max-w-6xl px-6 py-10">
<h1 class="text-3xl font-semibold">T3 Azur</h1>
<p class="mt-2 text-slate-700">Perfect for families, a spacious apartment with two queen beds, close to beaches and shops.</p>
<ul class="mt-4 text-sm text-slate-700 grid grid-cols-1 md:grid-cols-2 gap-2">
<li>55 m²</li>
<li>Up to 4 guests</li>
<li>2 queen beds</li>
<li>Air conditioning, WiFi, equipped kitchen</li>
<li>Crib on demand</li>
<li>Secured parking</li>
<li>Minimum stay: 3 nights</li>
<li>Checkin 14:00 / Checkout 11:00</li>
</ul>
<div class="mt-6 flex gap-3">
<a href="/en/contact/" class="inline-flex items-center rounded-lg bg-brand px-4 py-2 text-white hover:bg-brand-600">Send a Request</a>
<a href="https://airbnb.fr/h/villafleurie-t3" target="_blank" rel="noopener" class="inline-flex items-center rounded-lg border border-slate-200 px-4 py-2">Book on Airbnb (T3)</a>
</div>
</div>
</BaseLayout>

View file

@ -0,0 +1,55 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
---
<BaseLayout title="Contact" lang="en">
<div class="mx-auto max-w-6xl px-6 py-10">
<h1 class="text-3xl font-semibold">Send a Request</h1>
<form name="bookingForm" method="POST" data-netlify="true" action="/en/thank-you/" class="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4">
<input type="hidden" name="form-name" value="bookingForm" />
<label class="flex flex-col gap-1">
<span class="text-sm">Full name</span>
<input class="rounded-lg border border-slate-300 p-2" name="full_name" required />
</label>
<label class="flex flex-col gap-1">
<span class="text-sm">Email</span>
<input class="rounded-lg border border-slate-300 p-2" type="email" name="email" required />
</label>
<label class="flex flex-col gap-1">
<span class="text-sm">Arrival</span>
<input class="rounded-lg border border-slate-300 p-2" type="date" name="arrival_date" required />
</label>
<label class="flex flex-col gap-1">
<span class="text-sm">Departure</span>
<input class="rounded-lg border border-slate-300 p-2" type="date" name="departure_date" required />
</label>
<label class="flex flex-col gap-1">
<span class="text-sm">Apartment</span>
<select class="rounded-lg border border-slate-300 p-2" name="apartment" required>
<option value="">Choose…</option>
<option value="t2">T2 Corail</option>
<option value="t3">T3 Azur</option>
</select>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm">Adults</span>
<input class="rounded-lg border border-slate-300 p-2" type="number" min="1" name="guests_adults" required />
</label>
<label class="flex flex-col gap-1">
<span class="text-sm">Children</span>
<input class="rounded-lg border border-slate-300 p-2" type="number" min="0" name="guests_children" />
</label>
<label class="md:col-span-2 flex flex-col gap-1">
<span class="text-sm">Message (optional)</span>
<textarea class="rounded-lg border border-slate-300 p-2" name="message" rows="4"></textarea>
</label>
<label class="md:col-span-2 flex items-start gap-2 text-sm">
<input type="checkbox" required name="consent_privacy" />
<span>I agree to the use of my data to process my request in accordance with the <a class="underline" href="/fr/politiques/confidentialite/">Privacy Policy</a>.</span>
</label>
<div class="md:col-span-2">
<button class="rounded-lg bg-brand px-4 py-2 text-white hover:bg-brand-600">Send</button>
</div>
</form>
</div>
</BaseLayout>

66
src/pages/en/index.astro Normal file
View file

@ -0,0 +1,66 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
const title = 'Home';
---
<BaseLayout title={title} lang="en" description="Comfortable stays in Le Gosier for couples and small families">
<section class="relative">
<img src="/assets/images/villafleurie_t2_salon_1_wl81yXI.jpg" alt="Bright living room" class="w-full h-[50vh] object-cover" />
<div class="absolute inset-0 bg-black/30" />
<div class="absolute inset-0 flex items-center">
<div class="mx-auto max-w-6xl px-6">
<h1 class="text-white text-3xl md:text-5xl font-semibold max-w-3xl">Comfortable stays in Le Gosier for couples and small families</h1>
<p class="text-white/90 mt-3 max-w-2xl">Two bright apartments near the beaches. Send a request or book instantly.</p>
<div class="mt-6 flex gap-3">
<a href="/en/contact/" class="inline-flex items-center rounded-lg bg-brand px-4 py-2 text-white hover:bg-brand-600">Send a Request</a>
<a href="https://www.booking.com/hotel/gp/villafleurie.fr.html" target="_blank" rel="noopener" class="inline-flex items-center rounded-lg border border-slate-200 bg-white/90 px-4 py-2 text-slate-900 hover:bg-white" onclick="plausible('click_booking',{props:{locale:'en',page:'home',position:'hero'}})">Book on Booking.com</a>
<a href="https://airbnb.fr/h/villafleurie-t2" target="_blank" rel="noopener" class="inline-flex items-center rounded-lg border border-slate-200 bg-white/90 px-4 py-2 text-slate-900 hover:bg-white" onclick="plausible('click_airbnb',{props:{locale:'en',page:'home',position:'hero'}})">Book on Airbnb (T2)</a>
</div>
</div>
</div>
</section>
<section class="mx-auto max-w-6xl px-6 py-12">
<h2 class="text-2xl font-semibold">Get to know VillaFleurie</h2>
<p class="mt-3 text-slate-700">Villa is a unique holiday retreat in the beautiful Guadeloupe archipelago, ready to welcome tranquilityseeking guests all year round.</p>
</section>
<section class="mx-auto max-w-6xl px-6 py-8">
<h2 class="text-2xl font-semibold">Facilities</h2>
<ul class="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<li>Air conditioning</li>
<li>Fast WiFi</li>
<li>Equipped kitchen</li>
<li>Crib on demand</li>
<li>Secured parking</li>
<li>Near the beaches</li>
<li>Quiet neighborhood</li>
<li>Familyfriendly</li>
</ul>
</section>
<section class="mx-auto max-w-6xl px-6 py-8">
<h2 class="text-2xl font-semibold">Apartments</h2>
<div class="mt-4 grid md:grid-cols-2 gap-6">
<article class="rounded-lg border border-slate-200 p-4">
<h3 class="text-lg font-semibold">T2 Corail</h3>
<p class="text-sm text-slate-600">45 m² • 23 guests • 1 queen + sofabed • €59/night</p>
<a href="/en/apartments/t2-corail/" class="mt-3 inline-block text-brand-600 underline">Discover</a>
</article>
<article class="rounded-lg border border-slate-200 p-4">
<h3 class="text-lg font-semibold">T3 Azur</h3>
<p class="text-sm text-slate-600">55 m² • up to 4 guests • 2 queen beds • €79/night</p>
<a href="/en/apartments/t3-azur/" class="mt-3 inline-block text-brand-600 underline">Discover</a>
</article>
</div>
</section>
<section class="mx-auto max-w-6xl px-6 py-8">
<h2 class="text-2xl font-semibold">Location & Access</h2>
<p class="text-slate-700">4 rue Gerty Archimède, 97190 Le Gosier · Secured onsite parking</p>
<div class="mt-4 aspect-video w-full">
<iframe class="w-full h-full" style="border:0" loading="lazy" allowfullscreen
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3831.2598078323063!2d-61.48991482394046!3d16.20707768449259!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x8c134f148764f5d5%3A0x981bb218cee8b16c!2sVillaFleurie!5e0!3m2!1sfr!2sde!4v1685258248016!5m2!1sfr!2sde"></iframe>
</div>
</section>
</BaseLayout>

View file

@ -0,0 +1,15 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
---
<BaseLayout title="Location & Access" lang="en">
<div class="mx-auto max-w-6xl px-6 py-10">
<h1 class="text-3xl font-semibold">Location & Access</h1>
<p class="mt-2 text-slate-700">Address: 4 rue Gerty Archimède, 97190 Le Gosier</p>
<p class="text-slate-700">GPS: 16.207078, -61.489915 · Secured onsite parking</p>
<div class="mt-4 aspect-video w-full">
<iframe class="w-full h-full" style="border:0" loading="lazy" allowfullscreen
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3831.2598078323063!2d-61.48991482394046!3d16.20707768449259!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x8c134f148764f5d5%3A0x981bb218cee8b16c!2sVillaFleurie!5e0!3m2!1sfr!2sde!4v1685258248016!5m2!1sfr!2sde"></iframe>
</div>
</div>
</BaseLayout>

View file

@ -0,0 +1,29 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
---
<BaseLayout title="Rates & Availability" lang="en">
<div class="mx-auto max-w-6xl px-6 py-10">
<h1 class="text-3xl font-semibold">Rates & Availability</h1>
<div class="mt-6 grid md:grid-cols-2 gap-6">
<div class="rounded-lg border border-slate-200 p-4">
<h2 class="text-lg font-semibold">T2 Corail</h2>
<p class="mt-1 text-slate-700">€59 / night</p>
</div>
<div class="rounded-lg border border-slate-200 p-4">
<h2 class="text-lg font-semibold">T3 Azur</h2>
<p class="mt-1 text-slate-700">€79 / night</p>
</div>
</div>
<div class="mt-6 text-sm text-slate-700 space-y-2">
<p>Cleaning fee: €20 if the apartment is left dirty; free otherwise.</p>
<p>Security deposit: none.</p>
<p>Late cancellation: 50% within 14 days before arrival. See full cancellation policy (French).</p>
</div>
<div class="mt-6 flex gap-3">
<a href="https://www.booking.com/hotel/gp/villafleurie.fr.html" target="_blank" rel="noopener" class="underline">Book on Booking.com</a>
<a href="https://airbnb.fr/h/villafleurie-t2" target="_blank" rel="noopener" class="underline">Book on Airbnb (T2)</a>
<a href="https://airbnb.fr/h/villafleurie-t3" target="_blank" rel="noopener" class="underline">Book on Airbnb (T3)</a>
</div>
</div>
</BaseLayout>

View file

@ -0,0 +1,20 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
---
<BaseLayout title="Reviews" lang="en">
<div class="mx-auto max-w-6xl px-6 py-10">
<h1 class="text-3xl font-semibold">Reviews</h1>
<p class="mt-2 text-slate-700">Selection of Booking.com reviews (excerpts). Translated into English.</p>
<ul class="mt-6 space-y-6">
<li class="rounded-lg border border-slate-200 p-4">
<p class="font-medium">Michel (FR) — 10/10 · 2 Aug 2025</p>
<p class="text-slate-700 mt-2">“Perfect. Great value for money, very kind hosts, fast WiFi, small garden and parking.”</p>
</li>
<li class="rounded-lg border border-slate-200 p-4">
<p class="font-medium">Caratterina (IT) — 9/10 · 12 Apr 2025</p>
<p class="text-slate-700 mt-2">“Very spacious, comfortable and extremely clean apartment in a quiet area, 15 minutes walk to the center.”</p>
</li>
</ul>
</div>
</BaseLayout>

View file

@ -0,0 +1,15 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
---
<BaseLayout title="Thank You" lang="en">
<div class="mx-auto max-w-6xl px-6 py-10">
<h1 class="text-3xl font-semibold">Thank you for your request</h1>
<p class="mt-2 text-slate-700">Well get back to you shortly. For instant booking:</p>
<div class="mt-4 flex gap-3">
<a href="https://www.booking.com/hotel/gp/villafleurie.fr.html" target="_blank" rel="noopener" class="underline">Booking.com</a>
<a href="https://airbnb.fr/h/villafleurie-t2" target="_blank" rel="noopener" class="underline">Airbnb (T2)</a>
<a href="https://airbnb.fr/h/villafleurie-t3" target="_blank" rel="noopener" class="underline">Airbnb (T3)</a>
</div>
</div>
</BaseLayout>