first commit

This commit is contained in:
2025-11-23 08:02:54 +01:00
commit afd3881015
52 changed files with 9280 additions and 0 deletions

15
.dockerignore Normal file
View File

@ -0,0 +1,15 @@
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.env
.env.local
.env*.local
.next
.git
.gitignore
*.md
.vscode
.idea

4
.eslintrc.json Normal file
View File

@ -0,0 +1,4 @@
{
"extends": "next/core-web-vitals"
}

37
.gitignore vendored Normal file
View File

@ -0,0 +1,37 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

43
Dockerfile Normal file
View File

@ -0,0 +1,43 @@
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED 1
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
CMD ["node", "server.js"]

134
README.md Normal file
View File

@ -0,0 +1,134 @@
# Compagnon du lagon - Pension Marama
Application web PWA (Progressive Web App) destinée aux clients d'une pension de famille en Polynésie. Cette application remplace le livret d'accueil papier et offre une expérience numérique optimisée pour les connexions internet faibles.
## Technologies
- **Framework**: Next.js 14 (App Router)
- **Langage**: TypeScript
- **Styling**: Tailwind CSS
- **Icons**: Lucide-React
- **Components**: Shadcn/UI
## Fonctionnalités
### Onglet Accueil
- Header personnalisé avec numéro de bungalow
- Card WiFi avec copie du mot de passe
- Widget météo (placeholder)
- Section "Le mot du gérant"
### Onglet Explorer
- Liste de catégories (Plages, Restaurants/Roulottes, Epiceries, Activités)
- Liste de lieux recommandés par catégorie
- Intégration Google Maps pour chaque lieu
### Onglet Infos Pratiques
- FAQ en accordéon (Check-out, Petit-déjeuner, Climatisation, Numéros d'urgence)
- Lexique tahitien (5 mots essentiels)
## Installation
### Développement local
```bash
# Installer les dépendances
npm install
# Copier le fichier d'environnement
cp env.example .env.local
# Modifier les variables dans .env.local selon vos besoins
# Lancer le serveur de développement
npm run dev
```
L'application sera accessible sur [http://localhost:3000](http://localhost:3000)
### Docker
#### Build et run
```bash
# Build de l'image
docker-compose build
# Lancer le conteneur
docker-compose up -d
# Voir les logs
docker-compose logs -f
# Arrêter le conteneur
docker-compose down
```
#### Variables d'environnement Docker
Créez un fichier `.env` à la racine du projet avec vos variables :
```env
NEXT_PUBLIC_BUNGALOW_NUMBER=1
NEXT_PUBLIC_WIFI_NAME=Lagon-WiFi
NEXT_PUBLIC_WIFI_PASSWORD=motdepasse123
NEXT_PUBLIC_GERANT_MESSAGE=Bienvenue dans notre pension de famille !
```
## Configuration
### Variables d'environnement
- `NEXT_PUBLIC_BUNGALOW_NUMBER`: Numéro du bungalow (dynamique selon le client)
- `NEXT_PUBLIC_WIFI_NAME`: Nom du réseau WiFi
- `NEXT_PUBLIC_WIFI_PASSWORD`: Mot de passe WiFi
- `NEXT_PUBLIC_GERANT_MESSAGE`: Message personnalisé du gérant
## Structure du projet
```
├── app/
│ ├── accueil/ # Page d'accueil
│ ├── explorer/ # Page explorer
│ ├── infos/ # Page infos pratiques
│ ├── api/ # Routes API
│ └── layout.tsx # Layout principal
├── components/
│ ├── accueil/ # Composants page accueil
│ ├── explorer/ # Composants page explorer
│ ├── infos/ # Composants page infos
│ ├── layout/ # Composants layout
│ └── ui/ # Composants Shadcn/UI
├── lib/ # Utilitaires et configuration
└── public/ # Assets statiques et PWA
```
## PWA
L'application est configurée comme PWA avec :
- Manifest.json pour l'installation
- Service Worker pour le cache offline
- Optimisations pour connexions faibles
## Design System
- **Couleur primaire**: #0E7490 (Bleu lagon)
- **Couleur secondaire**: #ECFCCB (Vert citron pâle)
- **Background**: #FAFAFA (Blanc cassé)
- **Typographie**: Inter, minimum 16px
- **Bordures**: Très arrondies (rounded-xl, rounded-2xl)
## Production
```bash
# Build de production
npm run build
# Lancer en production
npm start
```
## Licence
Propriétaire - Tous droits réservés

40
app/accueil/page.tsx Normal file
View File

@ -0,0 +1,40 @@
import dynamic from "next/dynamic";
import Layout from "@/components/layout/Layout";
import { config } from "@/lib/config";
import WifiCard from "@/components/accueil/WifiCard";
const WeatherWidget = dynamic(() => import("@/components/accueil/WeatherWidget"), {
loading: () => <div className="h-32 bg-gray-100 rounded-2xl animate-pulse" />,
ssr: false,
});
export default function AccueilPage() {
return (
<Layout>
<div className="px-4 py-6 space-y-6">
<header className="text-center py-4">
<h1 className="text-2xl font-bold text-primary mb-2">
Ia Ora Na
</h1>
<p className="text-lg text-gray-700">
Bienvenue au Bungalow {config.bungalowNumber}
</p>
</header>
<WifiCard />
<WeatherWidget />
<section className="bg-secondary rounded-2xl p-6">
<h2 className="text-xl font-semibold text-primary mb-3">
Le mot du gérant
</h2>
<p className="text-gray-700 leading-relaxed">
{config.gerantMessage}
</p>
</section>
</div>
</Layout>
);
}

View File

@ -0,0 +1,67 @@
import { NextResponse } from "next/server";
export interface Excursion {
id: string;
name: string;
type: "tour-lagon" | "plongee" | "4x4";
description: string;
duration: string;
price: number;
available: boolean;
}
const excursions: Excursion[] = [
{
id: "1",
name: "Tour du Lagon de Fakarava",
type: "tour-lagon",
description: "Découvrez les merveilles du lagon de Fakarava avec arrêts snorkeling aux raies et requins. Visite des motus et des spots de plongée exceptionnels.",
duration: "4 heures",
price: 12000,
available: true,
},
{
id: "2",
name: "Plongée à la Passe Sud (Tumakohua)",
type: "plongee",
description: "Expérience unique de plongée à la Passe Sud de Fakarava, réputée pour ses raies mantas et sa faune exceptionnelle. Accessible uniquement par bateau.",
duration: "Journée complète",
price: 15000,
available: true,
},
{
id: "3",
name: "Excursion en vélo vers le Sud",
type: "4x4",
description: "Exploration de l'atoll de Fakarava en vélo le long de la route principale. Découvrez les villages et les points de vue sur le lagon.",
duration: "3 heures",
price: 8000,
available: true,
},
];
export async function GET() {
return NextResponse.json(excursions);
}
export async function POST(request: Request) {
try {
const body = await request.json();
const { excursionId, name, email, phone, date, participants } = body;
// Ici, en production, vous sauvegarderiez la réservation dans une base de données
// Pour l'instant, on simule juste une réponse de succès
return NextResponse.json({
success: true,
message: "Réservation enregistrée avec succès",
reservationId: `RES-${Date.now()}`,
});
} catch (error) {
return NextResponse.json(
{ success: false, message: "Erreur lors de la réservation" },
{ status: 400 }
);
}
}

198
app/api/infos/route.ts Normal file
View File

@ -0,0 +1,198 @@
import { NextResponse } from "next/server";
export interface FAQItem {
id: string;
question: string;
answer: string;
category?: string;
icon?: string;
}
export interface LexiqueItem {
id: string;
mot: string;
traduction: string;
description: string;
}
const faq: FAQItem[] = [
// Pension - Informations générales
{
id: "pension-1",
question: "Heures de Check-out",
answer: "Le check-out se fait avant 11h00. Merci de libérer votre bungalow à l'heure prévue pour permettre la préparation pour les prochains clients.",
category: "Pension",
icon: "🏠",
},
{
id: "pension-2",
question: "Petit-déjeuner",
answer: "Le petit-déjeuner est servi de 7h30 à 9h30 dans la salle commune. Il comprend des fruits frais, du pain, des confitures locales et des boissons chaudes.",
category: "Pension",
icon: "🍽️",
},
{
id: "pension-3",
question: "Comment utiliser la climatisation",
answer: "La télécommande de la climatisation se trouve sur la table de chevet. Appuyez sur le bouton ON/OFF pour l'activer. La température recommandée est de 24°C pour un confort optimal et une consommation raisonnable.",
category: "Pension",
icon: "❄️",
},
// 💰 Argent, Banque & Paiement
{
id: "argent-1",
question: "Où trouver un distributeur ?",
answer: "Un seul distributeur automatique de billets (DAB) est disponible à Fakarava. Il est situé à l'entrée du Bureau de Poste (OPT) dans le village de Rotoava.",
category: "💰 Argent, Banque & Paiement",
icon: "💰",
},
{
id: "argent-2",
question: "Faut-il prévoir du liquide ?",
answer: "OUI, le liquide (XPF) est ESSENTIEL. De nombreux snacks, petites pensions et activités n'acceptent pas la carte bancaire. Prévoyez de retirer assez à Tahiti ou Papeete avant d'arriver, ou dès votre arrivée à Rotoava.",
category: "💰 Argent, Banque & Paiement",
icon: "💰",
},
{
id: "argent-3",
question: "Les cartes bancaires sont-elles acceptées ?",
answer: "Les hôtels, les grands prestataires de plongée et les magasins principaux acceptent la carte. Les petites roulottes et certains taxis exigent du liquide.",
category: "💰 Argent, Banque & Paiement",
icon: "💰",
},
// 🌐 Communications & Internet
{
id: "internet-1",
question: "Comment avoir du Wifi ?",
answer: "Le Wifi est disponible exclusivement à la pension. Le code est accessible sur la page d'accueil de cette application (onglet Accueil). Attention : le débit est plus lent que sur les grandes îles.",
category: "🌐 Communications & Internet",
icon: "🌐",
},
{
id: "internet-2",
question: "Où acheter une carte SIM ?",
answer: "Vous pouvez acheter une carte SIM locale (Vini ou Vodafone) au Bureau de Poste (OPT) de Rotoava, si vous souhaitez avoir la 4G sur l'atoll (coûteux).",
category: "🌐 Communications & Internet",
icon: "🌐",
},
{
id: "internet-3",
question: "Y a-t-il une couverture mobile ?",
answer: "La couverture 4G est bonne et stable uniquement dans la zone de Rotoava (au Nord) et sur la route principale. Elle est quasi inexistante vers le Sud de l'atoll.",
category: "🌐 Communications & Internet",
icon: "🌐",
},
// 🚲 Transport & Déplacements
{
id: "transport-1",
question: "Quel est le meilleur moyen de se déplacer ?",
answer: "Le vélo est le moyen de transport standard et le plus agréable. Le Nord de l'atoll (Rotoava) est plat et idéal pour le vélo.",
category: "🚲 Transport & Déplacements",
icon: "🚲",
},
{
id: "transport-2",
question: "Comment aller à la Passe Sud (Tumakohua) ?",
answer: "La Passe Sud est à environ 60 km et est accessible uniquement par bateau. Les excursions sont organisées par les prestataires de plongée. Il est impossible d'y aller en voiture ou vélo depuis le Nord.",
category: "🚲 Transport & Déplacements",
icon: "🚲",
},
{
id: "transport-3",
question: "Où louer un vélo ou un scooter ?",
answer: "Des vélos sont disponibles à la location directement à la pension. Contactez le gérant pour plus d'informations.",
category: "🚲 Transport & Déplacements",
icon: "🚲",
},
// 🩹 Santé & Urgences
{
id: "sante-1",
question: "Où trouver un médecin ?",
answer: "Le dispensaire se trouve dans le village de Rotoava. En cas d'urgence grave, contactez le gérant immédiatement.",
category: "🩹 Santé & Urgences",
icon: "🩹",
},
{
id: "sante-2",
question: "Y a-t-il une pharmacie ?",
answer: "Il n'y a pas de pharmacie à Fakarava. Seule l'infirmerie du dispensaire dispose d'une petite réserve de médicaments de base.",
category: "🩹 Santé & Urgences",
icon: "🩹",
},
{
id: "sante-3",
question: "Numéros d'urgence",
answer: "Gérant : Contactez le gérant via l'application ou le numéro fourni à l'arrivée. Dispensaire : Situé à Rotoava. SAMU : 15. Police : 17.",
category: "🩹 Santé & Urgences",
icon: "🩹",
},
// 🌿 Consignes Éco-Lagon
{
id: "eco-1",
question: "Comment protéger le lagon ?",
answer: "Fakarava est une Réserve de Biosphère UNESCO ! Nous vous demandons de ne jamais toucher les coraux (morts ou vivants), de ne pas nourrir les poissons, et de toujours emporter vos déchets avec vous.",
category: "🌿 Consignes Éco-Lagon",
icon: "🌿",
},
{
id: "eco-2",
question: "Consommation d'eau",
answer: "L'eau potable est précieuse ici. Merci de limiter la durée de vos douches et de toujours fermer les robinets après usage.",
category: "🌿 Consignes Éco-Lagon",
icon: "🌿",
},
];
const lexique: LexiqueItem[] = [
{
id: "1",
mot: "Ia Ora Na",
traduction: "Bonjour / Salut",
description: "Salutation traditionnelle tahitienne utilisée pour dire bonjour.",
},
{
id: "2",
mot: "Mauruuru",
traduction: "Merci",
description: "Expression de gratitude en tahitien.",
},
{
id: "3",
mot: "Maeva",
traduction: "Bienvenue",
description: "Mot utilisé pour souhaiter la bienvenue aux visiteurs.",
},
{
id: "4",
mot: "Aita",
traduction: "Non",
description: "Négation en tahitien.",
},
{
id: "5",
mot: "E",
traduction: "Oui",
description: "Affirmation en tahitien.",
},
];
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const type = searchParams.get("type");
if (type === "faq") {
return NextResponse.json(faq);
}
if (type === "lexique") {
return NextResponse.json(lexique);
}
return NextResponse.json({ faq, lexique });
}

View File

@ -0,0 +1,76 @@
import { NextResponse } from "next/server";
export interface Notification {
id: string;
title: string;
message: string;
type: "whale" | "weather" | "excursion" | "info";
timestamp: string;
read: boolean;
}
// Notifications stockées (en production, utiliser une base de données)
let notifications: Notification[] = [
{
id: "1",
title: "Observation de baleines",
message: "Les baleines ont été vues au nord de l'île ce matin !",
type: "whale",
timestamp: new Date().toISOString(),
read: false,
},
];
export async function GET() {
return NextResponse.json(notifications);
}
export async function POST(request: Request) {
try {
const body = await request.json();
const { title, message, type } = body;
const notification: Notification = {
id: `notif-${Date.now()}`,
title,
message,
type: type || "info",
timestamp: new Date().toISOString(),
read: false,
};
notifications.unshift(notification);
// Limiter à 50 notifications
if (notifications.length > 50) {
notifications = notifications.slice(0, 50);
}
return NextResponse.json({ success: true, notification });
} catch (error) {
return NextResponse.json(
{ success: false, message: "Erreur lors de la création de la notification" },
{ status: 400 }
);
}
}
export async function PATCH(request: Request) {
try {
const body = await request.json();
const { id, read } = body;
const notification = notifications.find((n) => n.id === id);
if (notification) {
notification.read = read;
}
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json(
{ success: false, message: "Erreur lors de la mise à jour" },
{ status: 400 }
);
}
}

85
app/api/places/route.ts Normal file
View File

@ -0,0 +1,85 @@
import { NextResponse } from "next/server";
import { FAKARAVA_SPOTS } from "@/lib/data/fakarava-spots";
export interface Place {
id: string;
name: string;
category: string;
description: string;
image: string;
location: {
lat: number;
lng: number;
address: string;
};
type?: string;
keywords?: string[];
contact?: string;
gmapLink?: string;
conseil?: string;
horaires?: string;
}
// Coordonnées approximatives de Fakarava (Rotoava)
const FAKARAVA_CENTER = {
lat: -16.3167,
lng: -145.6167,
};
// Convertir les spots de Fakarava en format Place
const convertFakaravaSpots = (): Place[] => {
return FAKARAVA_SPOTS.map((spot, index) => {
// Mapper la catégorie "Restauration" vers "restaurants"
const categoryMap: Record<string, string> = {
"Restauration": "restaurants",
"Plages": "plages",
"Epiceries": "epiceries",
"Activités": "activites",
};
// Déterminer l'image par défaut selon la catégorie
const getDefaultImage = (category: string) => {
if (category === "plages") return "/placeholder-beach.jpg";
if (category === "epiceries") return "/placeholder-store.jpg";
return "/placeholder-restaurant.jpg";
};
return {
id: `fakarava-${index + 1}`,
name: spot.name,
category: categoryMap[spot.category] || spot.category.toLowerCase(),
type: spot.type,
description: spot.description,
keywords: spot.keywords,
contact: spot.contact,
conseil: spot.conseil,
horaires: spot.horaires,
image: getDefaultImage(categoryMap[spot.category] || spot.category.toLowerCase()),
location: {
// Coordonnées approximatives basées sur le PK
lat: FAKARAVA_CENTER.lat + (index * 0.01),
lng: FAKARAVA_CENTER.lng + (index * 0.01),
address: spot.location,
},
gmapLink: spot.gmapLink,
};
});
};
const places: Place[] = [
...convertFakaravaSpots(),
// Ajoutez ici d'autres lieux spécifiques à Fakarava
];
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const category = searchParams.get("category");
let filteredPlaces = places;
if (category && category !== "all") {
filteredPlaces = places.filter((place) => place.category === category);
}
return NextResponse.json(filteredPlaces);
}

View File

@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
export interface SunTimes {
date: string;
sunrise: string;
sunset: string;
}
// Calcul approximatif du lever/coucher du soleil pour Fakarava
// Coordonnées de Fakarava (Rotoava) : -16.3167, -145.6167
// En production, utiliser une API comme https://sunrise-sunset.org/api
const calculateSunTimes = (date: Date, lat: number = -16.3167, lng: number = -145.6167): SunTimes => {
// Calcul simplifié (formule approximative)
const dayOfYear = Math.floor((date.getTime() - new Date(date.getFullYear(), 0, 0).getTime()) / 86400000);
const declination = 23.45 * Math.sin((360 * (284 + dayOfYear) / 365) * Math.PI / 180);
const hourAngle = Math.acos(-Math.tan(lat * Math.PI / 180) * Math.tan(declination * Math.PI / 180)) * 180 / Math.PI;
const sunriseHour = 12 - hourAngle / 15 - (lng / 15);
const sunsetHour = 12 + hourAngle / 15 - (lng / 15);
const formatTime = (hour: number) => {
const h = Math.floor(hour);
const m = Math.floor((hour - h) * 60);
return `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}`;
};
return {
date: date.toISOString().split("T")[0],
sunrise: formatTime(sunriseHour),
sunset: formatTime(sunsetHour),
};
};
export async function GET() {
const today = new Date();
const sunTimes = calculateSunTimes(today);
return NextResponse.json(sunTimes);
}

44
app/api/tides/route.ts Normal file
View File

@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
export interface TideData {
date: string;
highTide: { time: string; height: number };
lowTide: { time: string; height: number };
}
// Données de marées pour les 7 prochains jours
// En production, utiliser une API réelle comme https://www.tide-forecast.com/api
const generateTideData = (): TideData[] => {
const tides: TideData[] = [];
const today = new Date();
for (let i = 0; i < 7; i++) {
const date = new Date(today);
date.setDate(today.getDate() + i);
// Simulation de données de marées (à remplacer par une vraie API)
const dayOffset = i;
const highTideHour = (6 + dayOffset * 0.8) % 24;
const lowTideHour = (12 + dayOffset * 0.8) % 24;
tides.push({
date: date.toISOString().split("T")[0],
highTide: {
time: `${Math.floor(highTideHour).toString().padStart(2, "0")}:${Math.floor((highTideHour % 1) * 60).toString().padStart(2, "0")}`,
height: 1.2 + Math.random() * 0.3,
},
lowTide: {
time: `${Math.floor(lowTideHour).toString().padStart(2, "0")}:${Math.floor((lowTideHour % 1) * 60).toString().padStart(2, "0")}`,
height: 0.3 + Math.random() * 0.2,
},
});
}
return tides;
};
export async function GET() {
const tides = generateTideData();
return NextResponse.json(tides);
}

39
app/explorer/page.tsx Normal file
View File

@ -0,0 +1,39 @@
"use client";
import { useState } from "react";
import dynamic from "next/dynamic";
import Layout from "@/components/layout/Layout";
import CategoryList from "@/components/explorer/CategoryList";
const PlaceList = dynamic(() => import("@/components/explorer/PlaceList"), {
loading: () => (
<div className="flex items-center justify-center py-12">
<p className="text-gray-600">Chargement...</p>
</div>
),
});
export default function ExplorerPage() {
const [selectedCategory, setSelectedCategory] = useState("all");
return (
<Layout>
<div className="py-6">
<header className="px-4 mb-6">
<h1 className="text-2xl font-bold text-primary mb-2">Explorer</h1>
<p className="text-gray-600">
Découvrez les meilleurs endroits de Fakarava
</p>
</header>
<CategoryList
selectedCategory={selectedCategory}
onCategoryChange={setSelectedCategory}
/>
<PlaceList category={selectedCategory} />
</div>
</Layout>
);
}

19
app/globals.css Normal file
View File

@ -0,0 +1,19 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-background;
font-size: 16px;
min-height: 100vh;
color: #1f2937;
}
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}

44
app/infos/page.tsx Normal file
View File

@ -0,0 +1,44 @@
import dynamic from "next/dynamic";
import Layout from "@/components/layout/Layout";
import ContactSection from "@/components/infos/ContactSection";
const FAQAccordion = dynamic(() => import("@/components/infos/FAQAccordion"), {
loading: () => <div className="h-64 bg-gray-100 rounded-2xl animate-pulse" />,
});
const LexiqueSection = dynamic(() => import("@/components/infos/LexiqueSection"), {
loading: () => <div className="h-48 bg-gray-100 rounded-2xl animate-pulse" />,
});
export default function InfosPage() {
return (
<Layout>
<div className="px-4 py-6 space-y-8">
<header>
<h1 className="text-2xl font-bold text-primary mb-2">
Infos Pratiques
</h1>
<p className="text-gray-600">
Tout ce que vous devez savoir pour votre séjour
</p>
</header>
<section>
<h2 className="text-xl font-semibold text-primary mb-4">
Questions Fréquentes
</h2>
<FAQAccordion />
</section>
<section>
<LexiqueSection />
</section>
<section>
<ContactSection />
</section>
</div>
</Layout>
);
}

44
app/layout.tsx Normal file
View File

@ -0,0 +1,44 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import PWARegister from "@/components/PWARegister";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Compagnon du lagon - Pension Marama",
description: "Votre guide numérique pour votre séjour à Fakarava",
manifest: "/manifest.json",
themeColor: "#0E7490",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "Compagnon du lagon - Pension Marama",
},
viewport: {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="fr">
<head>
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/icon-192x192.png" />
</head>
<body className={inter.className}>
{children}
<PWARegister />
</body>
</html>
);
}

44
app/mana-tracker/page.tsx Normal file
View File

@ -0,0 +1,44 @@
import dynamic from "next/dynamic";
import Layout from "@/components/layout/Layout";
import TideWidget from "@/components/mana-tracker/TideWidget";
import SunTimesWidget from "@/components/mana-tracker/SunTimesWidget";
const ExcursionBooking = dynamic(
() => import("@/components/mana-tracker/ExcursionBooking"),
{
loading: () => <div className="h-64 bg-gray-100 rounded-2xl animate-pulse" />,
}
);
const PushNotificationManager = dynamic(
() => import("@/components/mana-tracker/PushNotificationManager"),
{
loading: () => <div className="h-48 bg-gray-100 rounded-2xl animate-pulse" />,
}
);
export default function ManaTrackerPage() {
return (
<Layout>
<div className="px-4 py-6 space-y-6">
<header>
<h1 className="text-2xl font-bold text-primary mb-2">
Mana Tracker
</h1>
<p className="text-gray-600">
Activités & Météo - Tout dépend de la mer et du soleil
</p>
</header>
<TideWidget />
<SunTimesWidget />
<ExcursionBooking />
<PushNotificationManager />
</div>
</Layout>
);
}

20
app/offline/page.tsx Normal file
View File

@ -0,0 +1,20 @@
import Layout from "@/components/layout/Layout";
export default function OfflinePage() {
return (
<Layout>
<div className="flex flex-col items-center justify-center min-h-[60vh] px-4 text-center">
<h1 className="text-2xl font-bold text-primary mb-4">
Mode hors ligne
</h1>
<p className="text-gray-600 mb-6">
Vous êtes actuellement hors ligne. Certaines fonctionnalités peuvent être limitées.
</p>
<p className="text-sm text-gray-500">
Les données mises en cache restent disponibles.
</p>
</div>
</Layout>
);
}

6
app/page.tsx Normal file
View File

@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/accueil");
}

View File

@ -0,0 +1,26 @@
"use client";
import { useEffect } from "react";
export default function PWARegister() {
useEffect(() => {
if (
typeof window !== "undefined" &&
"serviceWorker" in navigator
) {
window.addEventListener("load", () => {
navigator.serviceWorker
.register("/sw.js")
.then((registration) => {
console.log("Service Worker registered:", registration);
})
.catch((error) => {
console.log("Service Worker registration failed:", error);
});
});
}
}, []);
return null;
}

View File

@ -0,0 +1,35 @@
import { Cloud, Sun } from "lucide-react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
export default function WeatherWidget() {
return (
<Card className="bg-gradient-to-br from-primary/10 to-secondary">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sun className="h-6 w-6 text-primary" />
Météo
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="text-3xl font-bold text-primary">28°C</p>
<p className="text-gray-600 mt-1">Ensoleillé</p>
</div>
<div className="text-6xl">
<Sun className="h-16 w-16 text-yellow-400" />
</div>
</div>
<div className="mt-4 flex gap-4 text-sm text-gray-600">
<div>
<span className="font-semibold">Vent:</span> 15 km/h
</div>
<div>
<span className="font-semibold">Humidité:</span> 75%
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,56 @@
"use client";
import { useState } from "react";
import { Wifi, Copy, Check } from "lucide-react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { config } from "@/lib/config";
export default function WifiCard() {
const [copied, setCopied] = useState(false);
const handleCopyPassword = async () => {
try {
await navigator.clipboard.writeText(config.wifiPassword);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Erreur lors de la copie:", err);
}
};
return (
<Card className="bg-white">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Wifi className="h-6 w-6 text-primary" />
Connexion WiFi
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-sm text-gray-600 mb-1">Nom du réseau</p>
<p className="text-lg font-semibold text-primary">{config.wifiName}</p>
</div>
<Button
onClick={handleCopyPassword}
className="w-full h-14 text-lg"
size="lg"
>
{copied ? (
<>
<Check className="mr-2 h-5 w-5" />
Mot de passe copié !
</>
) : (
<>
<Copy className="mr-2 h-5 w-5" />
Copier le mot de passe
</>
)}
</Button>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,54 @@
"use client";
import { useState } from "react";
import { Waves, Utensils, ShoppingBag, Activity } from "lucide-react";
import { cn } from "@/lib/utils";
export interface Category {
id: string;
name: string;
icon: React.ComponentType<{ className?: string }>;
}
const categories: Category[] = [
{ id: "all", name: "Tout", icon: Activity },
{ id: "plages", name: "Plages", icon: Waves },
{ id: "restaurants", name: "Restaurants / Roulottes", icon: Utensils },
{ id: "epiceries", name: "Epiceries", icon: ShoppingBag },
{ id: "activites", name: "Activités", icon: Activity },
];
interface CategoryListProps {
selectedCategory: string;
onCategoryChange: (categoryId: string) => void;
}
export default function CategoryList({
selectedCategory,
onCategoryChange,
}: CategoryListProps) {
return (
<div className="flex gap-3 overflow-x-auto pb-2 px-4 scrollbar-hide">
{categories.map((category) => {
const Icon = category.icon;
const isSelected = selectedCategory === category.id;
return (
<button
key={category.id}
onClick={() => onCategoryChange(category.id)}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-xl whitespace-nowrap transition-colors",
isSelected
? "bg-primary text-white"
: "bg-white text-gray-700 border border-gray-200 hover:bg-secondary"
)}
>
<Icon className="h-5 w-5" />
<span className="font-medium">{category.name}</span>
</button>
);
})}
</div>
);
}

View File

@ -0,0 +1,94 @@
"use client";
import { MapPin, ExternalLink } from "lucide-react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Place } from "@/app/api/places/route";
interface PlaceCardProps {
place: Place;
}
export default function PlaceCard({ place }: PlaceCardProps) {
const handleOpenMaps = () => {
let url: string;
if (place.gmapLink && place.gmapLink !== "LIEN_GOOGLE_MAPS_A_INSERER") {
url = place.gmapLink;
} else {
url = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(place.location.address)}`;
}
window.open(url, "_blank");
};
return (
<Card className="overflow-hidden">
<div className="relative h-48 bg-gradient-to-br from-primary/20 to-secondary">
<div className="absolute inset-0 flex items-center justify-center">
<MapPin className="h-16 w-16 text-primary/30" />
</div>
</div>
<CardHeader>
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<CardTitle>{place.name}</CardTitle>
{place.type && (
<p className="text-sm text-gray-500 mt-1">{place.type}</p>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-gray-700 leading-relaxed">{place.description}</p>
{place.keywords && place.keywords.length > 0 && (
<div className="flex flex-wrap gap-2">
{place.keywords.map((keyword, index) => (
<span
key={index}
className="px-2 py-1 bg-secondary text-primary text-xs font-medium rounded-lg"
>
{keyword}
</span>
))}
</div>
)}
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-gray-600">
<MapPin className="h-4 w-4" />
<span>{place.location.address}</span>
</div>
{place.contact && (
<div className="flex items-center gap-2 text-sm text-gray-600">
<span className="font-medium">Contact:</span>
<a
href={`tel:${place.contact.replace(/\s/g, "")}`}
className="text-primary hover:underline"
>
{place.contact}
</a>
</div>
)}
{place.horaires && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3">
<p className="text-xs font-semibold text-blue-900 mb-1">Horaires</p>
<p className="text-sm text-blue-800">{place.horaires}</p>
</div>
)}
{place.conseil && (
<div className="bg-secondary border border-primary/20 rounded-xl p-3">
<p className="text-xs font-semibold text-primary mb-1">💡 Conseil pratique</p>
<p className="text-sm text-gray-700">{place.conseil}</p>
</div>
)}
</div>
<Button onClick={handleOpenMaps} className="w-full" variant="outline">
<ExternalLink className="mr-2 h-4 w-4" />
Y aller
</Button>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,58 @@
"use client";
import { useEffect, useState } from "react";
import PlaceCard from "./PlaceCard";
import { Place } from "@/app/api/places/route";
interface PlaceListProps {
category: string;
}
export default function PlaceList({ category }: PlaceListProps) {
const [places, setPlaces] = useState<Place[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchPlaces = async () => {
setLoading(true);
try {
const response = await fetch(
`/api/places${category !== "all" ? `?category=${category}` : ""}`
);
const data = await response.json();
setPlaces(data);
} catch (error) {
console.error("Erreur lors du chargement des lieux:", error);
} finally {
setLoading(false);
}
};
fetchPlaces();
}, [category]);
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<p className="text-gray-600">Chargement...</p>
</div>
);
}
if (places.length === 0) {
return (
<div className="flex items-center justify-center py-12">
<p className="text-gray-600">Aucun lieu trouvé dans cette catégorie.</p>
</div>
);
}
return (
<div className="space-y-6 px-4 pb-6">
{places.map((place) => (
<PlaceCard key={place.id} place={place} />
))}
</div>
);
}

View File

@ -0,0 +1,96 @@
"use client";
import { Mail, Phone, MapPin, Clock } from "lucide-react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { config } from "@/lib/config";
export default function ContactSection() {
const contact = config.contact;
return (
<Card className="bg-secondary">
<CardHeader>
<CardTitle>Nous contacter</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{contact.phone && (
<div className="flex items-start gap-3">
<div className="bg-primary/10 p-2 rounded-xl">
<Phone className="h-5 w-5 text-primary" />
</div>
<div className="flex-1">
<p className="text-sm text-gray-600 mb-1">Téléphone</p>
<a
href={`tel:${contact.phone.replace(/\s/g, "")}`}
className="text-primary font-semibold hover:underline"
>
{contact.phone}
</a>
</div>
</div>
)}
{contact.whatsapp && (
<div className="flex items-start gap-3">
<div className="bg-green-100 p-2 rounded-xl">
<Phone className="h-5 w-5 text-green-600" />
</div>
<div className="flex-1">
<p className="text-sm text-gray-600 mb-1">WhatsApp</p>
<a
href={`https://wa.me/${contact.whatsapp.replace(/[^\d]/g, "")}`}
target="_blank"
rel="noopener noreferrer"
className="text-green-600 font-semibold hover:underline"
>
{contact.whatsapp}
</a>
</div>
</div>
)}
{contact.email && (
<div className="flex items-start gap-3">
<div className="bg-primary/10 p-2 rounded-xl">
<Mail className="h-5 w-5 text-primary" />
</div>
<div className="flex-1">
<p className="text-sm text-gray-600 mb-1">Email</p>
<a
href={`mailto:${contact.email}`}
className="text-primary font-semibold hover:underline break-all"
>
{contact.email}
</a>
</div>
</div>
)}
{contact.address && (
<div className="flex items-start gap-3">
<div className="bg-primary/10 p-2 rounded-xl">
<MapPin className="h-5 w-5 text-primary" />
</div>
<div className="flex-1">
<p className="text-sm text-gray-600 mb-1">Adresse</p>
<p className="text-gray-700 font-medium">{contact.address}</p>
</div>
</div>
)}
{contact.hours && (
<div className="flex items-start gap-3">
<div className="bg-primary/10 p-2 rounded-xl">
<Clock className="h-5 w-5 text-primary" />
</div>
<div className="flex-1">
<p className="text-sm text-gray-600 mb-1">Horaires</p>
<p className="text-gray-700 font-medium">{contact.hours}</p>
</div>
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,80 @@
"use client";
import { useEffect, useState, useMemo } from "react";
import {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from "@/components/ui/accordion";
import { FAQItem } from "@/app/api/infos/route";
export default function FAQAccordion() {
const [faqItems, setFaqItems] = useState<FAQItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchFAQ = async () => {
try {
const response = await fetch("/api/infos?type=faq");
const data = await response.json();
setFaqItems(data);
} catch (error) {
console.error("Erreur lors du chargement de la FAQ:", error);
} finally {
setLoading(false);
}
};
fetchFAQ();
}, []);
// Grouper les FAQ par catégorie
const faqByCategory = useMemo(() => {
const grouped: Record<string, FAQItem[]> = {};
faqItems.forEach((item) => {
const category = item.category || "Autres";
if (!grouped[category]) {
grouped[category] = [];
}
grouped[category].push(item);
});
return grouped;
}, [faqItems]);
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<p className="text-gray-600">Chargement...</p>
</div>
);
}
return (
<div className="space-y-6">
{Object.entries(faqByCategory).map(([category, items]) => (
<div key={category} className="space-y-3">
<h3 className="text-lg font-semibold text-primary flex items-center gap-2">
{items[0]?.icon && <span>{items[0].icon}</span>}
<span>{category}</span>
</h3>
<Accordion type="single" collapsible className="w-full">
{items.map((item) => (
<AccordionItem key={item.id} value={item.id}>
<AccordionTrigger className="text-left">
{item.question}
</AccordionTrigger>
<AccordionContent>
<p className="text-gray-700 leading-relaxed whitespace-pre-line">
{item.answer}
</p>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,63 @@
"use client";
import { useEffect, useState } from "react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { LexiqueItem } from "@/app/api/infos/route";
export default function LexiqueSection() {
const [lexiqueItems, setLexiqueItems] = useState<LexiqueItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchLexique = async () => {
try {
const response = await fetch("/api/infos?type=lexique");
const data = await response.json();
setLexiqueItems(data);
} catch (error) {
console.error("Erreur lors du chargement du lexique:", error);
} finally {
setLoading(false);
}
};
fetchLexique();
}, []);
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<p className="text-gray-600">Chargement...</p>
</div>
);
}
return (
<Card className="bg-secondary">
<CardHeader>
<CardTitle>Lexique Tahitien</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{lexiqueItems.map((item) => (
<div
key={item.id}
className="border-b border-primary/20 pb-4 last:border-0 last:pb-0"
>
<div className="flex items-start justify-between mb-2">
<h3 className="text-lg font-semibold text-primary">
{item.mot}
</h3>
<span className="text-sm font-medium text-gray-600">
{item.traduction}
</span>
</div>
<p className="text-sm text-gray-700">{item.description}</p>
</div>
))}
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,11 @@
import TabNavigation from "./TabNavigation";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-background pb-16">
{children}
<TabNavigation />
</div>
);
}

View File

@ -0,0 +1,60 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Home, MapPin, Info, Waves } from "lucide-react";
import { cn } from "@/lib/utils";
const tabs = [
{
name: "Accueil",
href: "/accueil",
icon: Home,
},
{
name: "Explorer",
href: "/explorer",
icon: MapPin,
},
{
name: "Mana",
href: "/mana-tracker",
icon: Waves,
},
{
name: "Infos",
href: "/infos",
icon: Info,
},
];
export default function TabNavigation() {
const pathname = usePathname();
return (
<nav className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-gray-200 shadow-lg">
<div className="flex items-center justify-around h-16 px-2">
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = pathname === tab.href;
return (
<Link
key={tab.href}
href={tab.href}
className={cn(
"flex flex-col items-center justify-center gap-1 flex-1 h-full rounded-xl transition-colors",
isActive
? "text-primary bg-secondary"
: "text-gray-500 hover:text-primary hover:bg-gray-50"
)}
>
<Icon className="h-6 w-6" />
<span className="text-xs font-medium">{tab.name}</span>
</Link>
);
})}
</div>
</nav>
);
}

View File

@ -0,0 +1,259 @@
"use client";
import { useState, useEffect } from "react";
import { Calendar, Users, CheckCircle } from "lucide-react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Excursion } from "@/app/api/excursions/route";
export default function ExcursionBooking() {
const [excursions, setExcursions] = useState<Excursion[]>([]);
const [loading, setLoading] = useState(true);
const [selectedExcursion, setSelectedExcursion] = useState<Excursion | null>(null);
const [formData, setFormData] = useState({
name: "",
email: "",
phone: "",
date: "",
participants: 1,
});
const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
useEffect(() => {
const fetchExcursions = async () => {
try {
const response = await fetch("/api/excursions");
const data = await response.json();
setExcursions(data);
} catch (error) {
console.error("Erreur lors du chargement des excursions:", error);
} finally {
setLoading(false);
}
};
fetchExcursions();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedExcursion) return;
setSubmitting(true);
try {
const response = await fetch("/api/excursions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
excursionId: selectedExcursion.id,
...formData,
}),
});
const result = await response.json();
if (result.success) {
setSuccess(true);
setFormData({ name: "", email: "", phone: "", date: "", participants: 1 });
setTimeout(() => {
setSuccess(false);
setSelectedExcursion(null);
}, 3000);
}
} catch (error) {
console.error("Erreur lors de la réservation:", error);
} finally {
setSubmitting(false);
}
};
const getExcursionTypeLabel = (type: string) => {
switch (type) {
case "tour-lagon":
return "Tour Lagon";
case "plongee":
return "Plongée";
case "4x4":
return "4x4";
default:
return type;
}
};
if (loading) {
return (
<Card>
<CardContent className="p-6">
<div className="animate-pulse">Chargement des excursions...</div>
</CardContent>
</Card>
);
}
if (success) {
return (
<Card className="bg-secondary">
<CardContent className="p-6 text-center">
<CheckCircle className="h-12 w-12 text-primary mx-auto mb-4" />
<h3 className="text-xl font-semibold text-primary mb-2">
Réservation confirmée !
</h3>
<p className="text-gray-700">
Votre demande de réservation a é enregistrée. Nous vous contacterons bientôt.
</p>
</CardContent>
</Card>
);
}
if (selectedExcursion) {
return (
<Card>
<CardHeader>
<CardTitle>Réserver : {selectedExcursion.name}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nom complet
</label>
<input
type="text"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
type="email"
required
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Téléphone
</label>
<input
type="tel"
required
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Date
</label>
<input
type="date"
required
min={new Date().toISOString().split("T")[0]}
value={formData.date}
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Participants
</label>
<input
type="number"
min="1"
max="10"
required
value={formData.participants}
onChange={(e) =>
setFormData({ ...formData, participants: parseInt(e.target.value) })
}
className="w-full px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
</div>
<div className="bg-secondary rounded-xl p-4">
<p className="text-sm text-gray-600 mb-1">Total</p>
<p className="text-2xl font-bold text-primary">
{selectedExcursion.price * formData.participants} XPF
</p>
</div>
<div className="flex gap-3">
<Button
type="button"
variant="outline"
onClick={() => setSelectedExcursion(null)}
className="flex-1"
>
Annuler
</Button>
<Button type="submit" disabled={submitting} className="flex-1">
{submitting ? "Envoi..." : "Réserver"}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Réservation d'excursions</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{excursions.map((excursion) => (
<div
key={excursion.id}
className="border border-gray-200 rounded-xl p-4 hover:border-primary transition-colors"
>
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<h3 className="font-semibold text-lg text-primary mb-1">
{excursion.name}
</h3>
<span className="inline-block px-3 py-1 bg-secondary text-primary text-xs font-medium rounded-lg mb-2">
{getExcursionTypeLabel(excursion.type)}
</span>
<p className="text-sm text-gray-600 mb-2">{excursion.description}</p>
<div className="flex items-center gap-4 text-xs text-gray-500">
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{excursion.duration}
</span>
</div>
</div>
<div className="text-right ml-4">
<p className="text-2xl font-bold text-primary">
{excursion.price.toLocaleString()} XPF
</p>
<p className="text-xs text-gray-500">par personne</p>
</div>
</div>
<Button
onClick={() => setSelectedExcursion(excursion)}
disabled={!excursion.available}
className="w-full mt-3"
variant={excursion.available ? "default" : "outline"}
>
{excursion.available ? "Réserver" : "Indisponible"}
</Button>
</div>
))}
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,231 @@
"use client";
import { useEffect, useState } from "react";
import { Bell, BellOff, X } from "lucide-react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Notification } from "@/app/api/notifications/route";
export default function PushNotificationManager() {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [permission, setPermission] = useState<NotificationPermission>("default");
const [loading, setLoading] = useState(true);
useEffect(() => {
if (typeof window !== "undefined" && "Notification" in window && window.Notification) {
setPermission(window.Notification.permission);
}
fetchNotifications();
}, []);
const fetchNotifications = async () => {
try {
const response = await fetch("/api/notifications");
const data = await response.json();
setNotifications(data);
} catch (error) {
console.error("Erreur lors du chargement des notifications:", error);
} finally {
setLoading(false);
}
};
const requestPermission = async () => {
if (typeof window === "undefined" || !("Notification" in window) || !window.Notification) {
alert("Votre navigateur ne supporte pas les notifications");
return;
}
const permission = await window.Notification.requestPermission();
setPermission(permission);
if (permission === "granted") {
// Enregistrer le service worker pour les notifications push
if ("serviceWorker" in navigator) {
try {
const registration = await navigator.serviceWorker.ready;
// Vérifier périodiquement les nouvelles notifications
setInterval(async () => {
try {
const response = await fetch("/api/notifications");
const newNotifications = await response.json();
const unread = newNotifications.filter((n: Notification) => !n.read);
// Afficher une notification pour chaque nouvelle alerte non lue
if (typeof window !== "undefined" && window.Notification) {
unread.forEach((notification: Notification) => {
if (window.Notification.permission === "granted") {
new window.Notification(notification.title, {
body: notification.message,
icon: "/icon-192x192.png",
badge: "/icon-192x192.png",
tag: notification.id,
requireInteraction: notification.type === "whale",
});
}
});
}
} catch (error) {
console.error("Erreur lors de la vérification des notifications:", error);
}
}, 60000); // Vérifier toutes les minutes
} catch (error) {
console.error("Erreur lors de l'enregistrement du service worker:", error);
}
}
}
};
const showTestNotification = () => {
if (permission === "granted" && typeof window !== "undefined" && window.Notification) {
new window.Notification("Test de notification", {
body: "Les notifications fonctionnent correctement !",
icon: "/icon-192x192.png",
badge: "/icon-192x192.png",
});
}
};
const markAsRead = async (id: string) => {
try {
await fetch("/api/notifications", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, read: true }),
});
setNotifications(
notifications.map((n) => (n.id === id ? { ...n, read: true } : n))
);
} catch (error) {
console.error("Erreur lors de la mise à jour:", error);
}
};
const getNotificationIcon = (type: string) => {
switch (type) {
case "whale":
return "🐋";
case "weather":
return "🌦️";
case "excursion":
return "🚤";
default:
return "";
}
};
if (loading) {
return (
<Card>
<CardContent className="p-6">
<div className="animate-pulse">Chargement...</div>
</CardContent>
</Card>
);
}
const unreadCount = notifications.filter((n) => !n.read).length;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
<Bell className="h-6 w-6 text-primary" />
Notifications
</span>
{unreadCount > 0 && (
<span className="bg-primary text-white text-xs font-bold px-2 py-1 rounded-full">
{unreadCount}
</span>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{permission === "default" && (
<div className="bg-secondary rounded-xl p-4">
<p className="text-sm text-gray-700 mb-3">
Activez les notifications pour recevoir des alertes importantes (baleines, météo, etc.)
</p>
<Button onClick={requestPermission} className="w-full">
<Bell className="mr-2 h-4 w-4" />
Activer les notifications
</Button>
</div>
)}
{permission === "denied" && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4">
<p className="text-sm text-red-700">
Les notifications sont désactivées. Veuillez les activer dans les paramètres de votre navigateur.
</p>
</div>
)}
{permission === "granted" && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm text-gray-600">Notifications activées</p>
<Button
onClick={showTestNotification}
variant="outline"
size="sm"
>
Tester
</Button>
</div>
</div>
)}
<div className="space-y-2 max-h-64 overflow-y-auto">
{notifications.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-4">
Aucune notification pour le moment
</p>
) : (
notifications.map((notification) => (
<div
key={notification.id}
className={`border rounded-xl p-3 ${
notification.read
? "bg-gray-50 border-gray-200"
: "bg-secondary border-primary/30"
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">
{getNotificationIcon(notification.type)}
</span>
<h4 className="font-semibold text-sm">{notification.title}</h4>
{!notification.read && (
<span className="bg-primary text-white text-xs px-1.5 py-0.5 rounded-full">
Nouveau
</span>
)}
</div>
<p className="text-sm text-gray-700">{notification.message}</p>
<p className="text-xs text-gray-500 mt-1">
{new Date(notification.timestamp).toLocaleString("fr-FR")}
</p>
</div>
{!notification.read && (
<button
onClick={() => markAsRead(notification.id)}
className="text-gray-400 hover:text-gray-600"
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
))
)}
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,80 @@
"use client";
import { useEffect, useState } from "react";
import { Sun, Sunrise, Sunset } from "lucide-react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { SunTimes } from "@/app/api/sun-times/route";
export default function SunTimesWidget() {
const [sunTimes, setSunTimes] = useState<SunTimes | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchSunTimes = async () => {
try {
const response = await fetch("/api/sun-times");
const data = await response.json();
setSunTimes(data);
} catch (error) {
console.error("Erreur lors du chargement des heures du soleil:", error);
} finally {
setLoading(false);
}
};
fetchSunTimes();
}, []);
if (loading) {
return (
<Card>
<CardContent className="p-6">
<div className="animate-pulse">Chargement...</div>
</CardContent>
</Card>
);
}
if (!sunTimes) {
return null;
}
const getCurrentTime = () => {
const now = new Date();
return `${now.getHours().toString().padStart(2, "0")}:${now.getMinutes().toString().padStart(2, "0")}`;
};
const currentTime = getCurrentTime();
const isDay = currentTime >= sunTimes.sunrise && currentTime < sunTimes.sunset;
return (
<Card className="bg-gradient-to-br from-yellow-50 to-orange-50">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sun className="h-6 w-6 text-yellow-500" />
Lever / Coucher du Soleil
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div className="bg-white rounded-xl p-4 text-center">
<Sunrise className="h-8 w-8 text-yellow-500 mx-auto mb-2" />
<p className="text-xs text-gray-600 mb-1">Lever</p>
<p className="text-2xl font-bold text-primary">{sunTimes.sunrise}</p>
</div>
<div className="bg-white rounded-xl p-4 text-center">
<Sunset className="h-8 w-8 text-orange-500 mx-auto mb-2" />
<p className="text-xs text-gray-600 mb-1">Coucher</p>
<p className="text-2xl font-bold text-primary">{sunTimes.sunset}</p>
</div>
</div>
<div className="mt-4 text-center">
<p className="text-sm text-gray-600">
{isDay ? "☀️ Soleil actuellement visible" : "🌙 Nuit"}
</p>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,100 @@
"use client";
import { useEffect, useState } from "react";
import { Waves, TrendingUp, TrendingDown } from "lucide-react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { TideData } from "@/app/api/tides/route";
export default function TideWidget() {
const [tides, setTides] = useState<TideData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchTides = async () => {
try {
const response = await fetch("/api/tides");
const data = await response.json();
setTides(data);
} catch (error) {
console.error("Erreur lors du chargement des marées:", error);
} finally {
setLoading(false);
}
};
fetchTides();
}, []);
if (loading) {
return (
<Card>
<CardContent className="p-6">
<div className="animate-pulse">Chargement des marées...</div>
</CardContent>
</Card>
);
}
const todayTide = tides[0];
const tomorrowTide = tides[1];
if (!todayTide) {
return null;
}
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString("fr-FR", { weekday: "short", day: "numeric", month: "short" });
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Waves className="h-6 w-6 text-primary" />
Marées
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-sm text-gray-600 mb-2">Aujourd'hui - {formatDate(todayTide.date)}</p>
<div className="grid grid-cols-2 gap-4">
<div className="bg-secondary rounded-xl p-3">
<div className="flex items-center gap-2 mb-1">
<TrendingUp className="h-4 w-4 text-primary" />
<span className="text-sm font-semibold text-primary">Haute mer</span>
</div>
<p className="text-lg font-bold">{todayTide.highTide.time}</p>
<p className="text-xs text-gray-600">{todayTide.highTide.height.toFixed(1)}m</p>
</div>
<div className="bg-secondary rounded-xl p-3">
<div className="flex items-center gap-2 mb-1">
<TrendingDown className="h-4 w-4 text-primary" />
<span className="text-sm font-semibold text-primary">Basse mer</span>
</div>
<p className="text-lg font-bold">{todayTide.lowTide.time}</p>
<p className="text-xs text-gray-600">{todayTide.lowTide.height.toFixed(1)}m</p>
</div>
</div>
</div>
{tomorrowTide && (
<div className="pt-4 border-t border-gray-200">
<p className="text-sm text-gray-600 mb-2">Demain - {formatDate(tomorrowTide.date)}</p>
<div className="grid grid-cols-2 gap-4">
<div className="bg-gray-50 rounded-xl p-3">
<p className="text-xs text-gray-600 mb-1">Haute mer</p>
<p className="text-base font-semibold">{tomorrowTide.highTide.time}</p>
</div>
<div className="bg-gray-50 rounded-xl p-3">
<p className="text-xs text-gray-600 mb-1">Basse mer</p>
<p className="text-base font-semibold">{tomorrowTide.lowTide.time}</p>
</div>
</div>
</div>
)}
</CardContent>
</Card>
);
}

202
components/ui/accordion.tsx Normal file
View File

@ -0,0 +1,202 @@
"use client";
import * as React from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
interface AccordionContextValue {
value: string[];
onValueChange: (value: string[]) => void;
}
const AccordionContext = React.createContext<AccordionContextValue | undefined>(
undefined
);
interface AccordionProps {
type?: "single" | "multiple";
defaultValue?: string | string[];
value?: string | string[];
onValueChange?: (value: string | string[]) => void;
children: React.ReactNode;
className?: string;
}
const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
({ type = "single", defaultValue, value, onValueChange, children, className }, ref) => {
const [internalValue, setInternalValue] = React.useState<string[]>(
defaultValue
? Array.isArray(defaultValue)
? defaultValue
: [defaultValue]
: []
);
const controlledValue = value
? Array.isArray(value)
? value
: [value]
: undefined;
const currentValue = controlledValue ?? internalValue;
const handleValueChange = React.useCallback(
(newValue: string[]) => {
if (!controlledValue) {
setInternalValue(newValue);
}
if (onValueChange) {
onValueChange(type === "single" ? newValue[0] || "" : newValue);
}
},
[controlledValue, onValueChange, type]
);
const contextValue = React.useMemo(
() => ({
value: currentValue,
onValueChange: handleValueChange,
}),
[currentValue, handleValueChange]
);
return (
<AccordionContext.Provider value={contextValue}>
<div ref={ref} className={cn("space-y-2", className)}>
{children}
</div>
</AccordionContext.Provider>
);
}
);
Accordion.displayName = "Accordion";
interface AccordionItemProps {
value: string;
children: React.ReactNode;
className?: string;
}
const AccordionItem = React.forwardRef<HTMLDivElement, AccordionItemProps>(
({ value, children, className }, ref) => {
return (
<div
ref={ref}
className={cn("rounded-xl border border-gray-200 overflow-hidden", className)}
data-value={value}
>
{children}
</div>
);
}
);
AccordionItem.displayName = "AccordionItem";
interface AccordionTriggerProps {
children: React.ReactNode;
className?: string;
}
const AccordionTrigger = React.forwardRef<
HTMLButtonElement,
AccordionTriggerProps
>(({ children, className }, ref) => {
const context = React.useContext(AccordionContext);
if (!context) {
throw new Error("AccordionTrigger must be used within Accordion");
}
const item = React.useContext(ItemContext);
if (!item) {
throw new Error("AccordionTrigger must be used within AccordionItem");
}
const isOpen = context.value.includes(item.value);
const handleClick = () => {
const newValue = isOpen
? context.value.filter((v) => v !== item.value)
: [...context.value, item.value];
context.onValueChange(newValue);
};
return (
<button
ref={ref}
type="button"
onClick={handleClick}
className={cn(
"flex w-full items-center justify-between p-4 text-left font-medium text-primary transition-all hover:bg-secondary [&[data-state=open]>svg]:rotate-180",
className
)}
data-state={isOpen ? "open" : "closed"}
>
{children}
<ChevronDown className="h-5 w-5 shrink-0 transition-transform duration-200" />
</button>
);
});
AccordionTrigger.displayName = "AccordionTrigger";
interface AccordionContentProps {
children: React.ReactNode;
className?: string;
}
const ItemContext = React.createContext<{ value: string } | undefined>(
undefined
);
const AccordionContent = React.forwardRef<
HTMLDivElement,
AccordionContentProps
>(({ children, className }, ref) => {
const context = React.useContext(AccordionContext);
if (!context) {
throw new Error("AccordionContent must be used within Accordion");
}
const item = React.useContext(ItemContext);
if (!item) {
throw new Error("AccordionContent must be used within AccordionItem");
}
const isOpen = context.value.includes(item.value);
return (
<div
ref={ref}
className={cn(
"overflow-hidden transition-all",
isOpen ? "max-h-[1000px] opacity-100" : "max-h-0 opacity-0"
)}
>
<div className={cn("p-4 pt-0 text-gray-700", className)}>
{children}
</div>
</div>
);
});
AccordionContent.displayName = "AccordionContent";
const AccordionItemWithContext = React.forwardRef<
HTMLDivElement,
AccordionItemProps
>(({ value, children, ...props }, ref) => {
return (
<ItemContext.Provider value={{ value }}>
<AccordionItem ref={ref} value={value} {...props}>
{children}
</AccordionItem>
</ItemContext.Provider>
);
});
AccordionItemWithContext.displayName = "AccordionItem";
export {
Accordion,
AccordionItemWithContext as AccordionItem,
AccordionTrigger,
AccordionContent,
};

46
components/ui/button.tsx Normal file
View File

@ -0,0 +1,46 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-xl text-base font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline: "border-2 border-primary text-primary hover:bg-primary hover:text-white",
ghost: "hover:bg-secondary hover:text-secondary-foreground",
},
size: {
default: "h-12 px-6 py-3",
sm: "h-10 px-4",
lg: "h-14 px-8 text-lg",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

76
components/ui/card.tsx Normal file
View File

@ -0,0 +1,76 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-2xl border border-gray-200 bg-white shadow-sm",
className
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("text-xl font-semibold leading-none tracking-tight text-primary", className)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-gray-600", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

23
docker-compose.yml Normal file
View File

@ -0,0 +1,23 @@
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- NEXT_PUBLIC_BUNGALOW_NUMBER=${NEXT_PUBLIC_BUNGALOW_NUMBER:-1}
- NEXT_PUBLIC_WIFI_NAME=${NEXT_PUBLIC_WIFI_NAME:-Lagon-WiFi}
- NEXT_PUBLIC_WIFI_PASSWORD=${NEXT_PUBLIC_WIFI_PASSWORD:-motdepasse123}
- NEXT_PUBLIC_GERANT_MESSAGE=${NEXT_PUBLIC_GERANT_MESSAGE:-Bienvenue dans notre pension de famille !}
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s

12
env.example Normal file
View File

@ -0,0 +1,12 @@
NEXT_PUBLIC_BUNGALOW_NUMBER=1
NEXT_PUBLIC_WIFI_NAME=Lagon-WiFi
NEXT_PUBLIC_WIFI_PASSWORD=motdepasse123
NEXT_PUBLIC_GERANT_MESSAGE=Bienvenue dans notre pension de famille !
# Informations de contact
NEXT_PUBLIC_CONTACT_EMAIL=contact@fakarava-pension.com
NEXT_PUBLIC_CONTACT_PHONE=+689 XX XX XX XX
NEXT_PUBLIC_CONTACT_WHATSAPP=+689 XX XX XX XX
NEXT_PUBLIC_CONTACT_ADDRESS=Rotoava, Fakarava
NEXT_PUBLIC_CONTACT_HOURS=Disponible 24/7 pour les urgences

14
lib/config.ts Normal file
View File

@ -0,0 +1,14 @@
export const config = {
bungalowNumber: process.env.NEXT_PUBLIC_BUNGALOW_NUMBER || "1",
wifiName: process.env.NEXT_PUBLIC_WIFI_NAME || "Lagon-WiFi",
wifiPassword: process.env.NEXT_PUBLIC_WIFI_PASSWORD || "motdepasse123",
gerantMessage: process.env.NEXT_PUBLIC_GERANT_MESSAGE || "Bienvenue dans notre pension de famille !",
contact: {
email: process.env.NEXT_PUBLIC_CONTACT_EMAIL || undefined,
phone: process.env.NEXT_PUBLIC_CONTACT_PHONE || undefined,
whatsapp: process.env.NEXT_PUBLIC_CONTACT_WHATSAPP || undefined,
address: process.env.NEXT_PUBLIC_CONTACT_ADDRESS || "Rotoava, Fakarava",
hours: process.env.NEXT_PUBLIC_CONTACT_HOURS || "Disponible 24/7 pour les urgences",
},
};

124
lib/data/fakarava-spots.ts Normal file
View File

@ -0,0 +1,124 @@
export interface FakaravaSpot {
category: string;
name: string;
location: string;
type: string;
description: string;
keywords: string[];
contact?: string;
gmapLink: string;
conseil?: string;
horaires?: string;
}
export const FAKARAVA_SPOTS: FakaravaSpot[] = [
// Restauration
{
category: "Restauration",
name: "Snack du Requin Dormeur",
location: "PK 2, Rotoava",
type: "Snack / Plage",
description: "LE lieu pour déjeuner les pieds dans l'eau. Poisson cru légendaire. Le cadre est inoubliable.",
keywords: ["Lagon", "Poisson Cru", "Ambiance", "Incontournable"],
contact: "+689 40 93 40 15",
gmapLink: "LIEN_GOOGLE_MAPS_A_INSERER",
},
{
category: "Restauration",
name: "Snack Kori Kori",
location: "PK 4, Rotoava",
type: "Snack / Déjeuner",
description: "Super vue sur le lagon, parfait pour un déjeuner simple et bon. Leur Fish Burger est très apprécié.",
keywords: ["Décontracté", "Vue", "Fish Burger", "Poisson Grillé"],
contact: "+689 40 98 43 97",
gmapLink: "LIEN_GOOGLE_MAPS_A_INSERER",
},
// Plages et Spots
{
category: "Plages",
name: "Plage du PK 9 (Cocotier Couché)",
location: "PK 9, Rotoava",
type: "Plage",
description: "La plage la plus photogénique du Nord de Fakarava. Vous y trouverez le célèbre cocotier penché, parfait pour une photo \"carte postale\". Idéal pour le farniente et la baignade tranquille.",
keywords: ["Photogénique", "Cocotier", "Baignade", "Farniente"],
gmapLink: "LIEN_GOOGLE_MAPS_A_INSERER",
conseil: "Accessible à vélo (environ 9 km depuis Rotoava). Prévoyez de l'eau car elle est isolée.",
},
{
category: "Plages",
name: "L'Ancien Phare de Topaka",
location: "Après le PK 9, Rotoava",
type: "Point de vue",
description: "Un peu plus loin après le PK 9, le phare n'est pas une plage en soi, mais le lieu offre une vue unique sur l'Océan Pacifique (côté \"océan\" et non lagon). Ambiance de \"bout du monde\".",
keywords: ["Phare", "Vue panoramique", "Océan", "Point de vue"],
gmapLink: "LIEN_GOOGLE_MAPS_A_INSERER",
conseil: "S'y rendre à vélo pour une belle balade (non recommandé pour la baignade).",
},
{
category: "Plages",
name: "La Passe Nord (Garuae)",
location: "Passe Nord, Fakarava",
type: "Spot de snorkeling",
description: "L'une des plus grandes passes de Polynésie. On n'y va pas pour la plage, mais pour l'activité de snorkeling en dérivante qui y est spectaculaire (à faire avec un club). Le paysage est impressionnant.",
keywords: ["Passe", "Snorkeling", "Dérivante", "Spectaculaire"],
gmapLink: "LIEN_GOOGLE_MAPS_A_INSERER",
conseil: "Zone très courante, attention si vous nagez seul.",
},
{
category: "Plages",
name: "Les Sables Roses (Passe Sud)",
location: "Pointe Sud, Fakarava",
type: "Plage exceptionnelle",
description: "L'excursion la plus célèbre de Fakarava. Il s'agit d'une plage unique de sable aux nuances rosées, à la pointe Sud de l'atoll.",
keywords: ["Sable rose", "Exceptionnel", "Passe Sud", "Excursion"],
gmapLink: "LIEN_GOOGLE_MAPS_A_INSERER",
conseil: "ATTENTION : Nécessite une excursion bateau à la journée (environ 2 heures de trajet). Coût élevé, à réserver.",
},
{
category: "Plages",
name: "Plage de la Pension (Votre Plage)",
location: "Pension Marama, Rotoava",
type: "Plage privée",
description: "N'hésitez pas à mettre en avant votre propre plage et ponton ! Idéal pour un snorkeling facile et sécurisé.",
keywords: ["Plage privée", "Ponton", "Snorkeling", "Sécurisé"],
gmapLink: "LIEN_GOOGLE_MAPS_A_INSERER",
conseil: "Parfait pour la baignade et le snorkeling en toute tranquillité, juste devant votre bungalow.",
},
// Épiceries
{
category: "Epiceries",
name: "Magasin Rotoava",
location: "Cœur du village de Rotoava",
type: "Épicerie principale",
description: "L'épicerie principale du village, idéale pour le ravitaillement. Bien achalandée avec les produits essentiels.",
keywords: ["Épicerie", "Ravitaillement", "Village", "Principal"],
gmapLink: "LIEN_GOOGLE_MAPS_A_INSERER",
horaires: "Lundi au Samedi : 7h00 - 11h30 et 15h00 - 17h30. (Fermé les jours fériés.)",
conseil: "Idéal pour le ravitaillement principal. Pensez à y aller avant midi !",
},
{
category: "Epiceries",
name: "Snack Chez Elda",
location: "Rotoava",
type: "Snack / Dépannage",
description: "Souvent mentionné pour sa cuisine, il est aussi un point de vente pour quelques nécessités.",
keywords: ["Snack", "Dépannage", "Cuisine", "Nécessités"],
gmapLink: "LIEN_GOOGLE_MAPS_A_INSERER",
horaires: "Horaires variables.",
conseil: "Peut dépanner en cas de besoin en dehors des heures de pointe.",
},
{
category: "Epiceries",
name: "Autres Petites Épiceries",
location: "Le long de la route principale",
type: "Épiceries locales",
description: "Les autres magasins sont plus petits et moins achalandés, mais pratiques si vous êtes loin du centre.",
keywords: ["Petites épiceries", "Locales", "Dépannage"],
gmapLink: "LIEN_GOOGLE_MAPS_A_INSERER",
horaires: "Variables, souvent ouvertes tôt le matin.",
conseil: "Les autres magasins sont plus petits et moins achalandés, mais pratiques si vous êtes loin du centre.",
},
];

7
lib/utils.ts Normal file
View File

@ -0,0 +1,7 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

18
next.config.js Normal file
View File

@ -0,0 +1,18 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
output: "standalone",
compress: true,
poweredByHeader: false,
images: {
formats: ["image/avif", "image/webp"],
minimumCacheTTL: 60,
},
experimental: {
optimizePackageImports: ["lucide-react"],
},
};
module.exports = nextConfig;

6162
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
package.json Normal file
View File

@ -0,0 +1,32 @@
{
"name": "compagnon-de-lagon",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"next": "^14.2.33",
"lucide-react": "^0.460.0",
"clsx": "^2.1.1",
"tailwind-merge": "^2.5.5",
"class-variance-authority": "^0.7.1"
},
"devDependencies": {
"typescript": "^5.6.3",
"@types/node": "^22.10.1",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"eslint": "^8.57.1",
"eslint-config-next": "^14.2.33"
}
}

10
postcss.config.mjs Normal file
View File

@ -0,0 +1,10 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;

14
public/ICONS_README.md Normal file
View File

@ -0,0 +1,14 @@
# Icônes PWA requises
Pour que la PWA fonctionne correctement, vous devez ajouter les icônes suivantes dans le dossier `public/` :
- `icon-192x192.png` : Icône 192x192 pixels pour Android
- `icon-512x512.png` : Icône 512x512 pixels pour Android et iOS
Ces icônes doivent être au format PNG et suivre les guidelines PWA :
- Fond transparent ou couleur de thème (#0E7490)
- Design simple et reconnaissable même à petite taille
- Format carré avec contenu centré
Vous pouvez utiliser un outil comme [PWA Asset Generator](https://github.com/onderceylan/pwa-asset-generator) pour générer toutes les tailles nécessaires à partir d'une seule image source.

1
public/favicon.ico Normal file
View File

@ -0,0 +1 @@
placeholder

25
public/manifest.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "Compagnon du lagon - Pension Marama",
"short_name": "Pension Marama",
"description": "Votre guide numérique pour votre séjour à Fakarava",
"start_url": "/",
"display": "standalone",
"background_color": "#FAFAFA",
"theme_color": "#0E7490",
"orientation": "portrait",
"icons": [
{
"src": "/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}

147
public/sw.js Normal file
View File

@ -0,0 +1,147 @@
const CACHE_NAME = "compagnon-lagon-v1";
const urlsToCache = [
"/",
"/accueil",
"/explorer",
"/mana-tracker",
"/infos",
"/manifest.json",
];
const API_CACHE_NAME = "compagnon-lagon-api-v1";
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(urlsToCache);
})
);
});
self.addEventListener("fetch", (event) => {
const { request } = event;
const url = new URL(request.url);
// Cache strategy pour les API routes
if (url.pathname.startsWith("/api/")) {
event.respondWith(
caches.open(API_CACHE_NAME).then((cache) => {
return cache.match(request).then((cachedResponse) => {
if (cachedResponse) {
// Retourner le cache et mettre à jour en arrière-plan
fetch(request)
.then((response) => {
if (response && response.status === 200) {
cache.put(request, response.clone());
}
})
.catch(() => {});
return cachedResponse;
}
// Pas de cache, faire la requête
return fetch(request)
.then((response) => {
if (response && response.status === 200) {
cache.put(request, response.clone());
}
return response;
})
.catch(() => {
// En cas d'erreur réseau, retourner une réponse par défaut
return new Response(
JSON.stringify({ error: "Offline" }),
{
status: 503,
headers: { "Content-Type": "application/json" },
}
);
});
});
})
);
return;
}
// Cache strategy pour les pages statiques
event.respondWith(
caches.match(request).then((response) => {
if (response) {
return response;
}
return fetch(request)
.then((response) => {
if (!response || response.status !== 200 || response.type !== "basic") {
return response;
}
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(request, responseToCache);
});
return response;
})
.catch(() => {
// Retourner une page offline si disponible
return caches.match("/offline");
});
})
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter(
(cacheName) =>
cacheName !== CACHE_NAME && cacheName !== API_CACHE_NAME
)
.map((cacheName) => caches.delete(cacheName))
);
})
);
});
// Gestion des notifications push
self.addEventListener("push", (event) => {
const data = event.data ? event.data.json() : {};
const title = data.title || "Compagnon du lagon - Pension Marama";
const options = {
body: data.message || "Nouvelle notification",
icon: "/icon-192x192.png",
badge: "/icon-192x192.png",
tag: data.id || "notification",
requireInteraction: data.important || false,
data: data,
};
event.waitUntil(self.registration.showNotification(title, options));
});
// Gestion du clic sur une notification
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const data = event.notification.data;
const urlToOpen = data.url || "/mana-tracker";
event.waitUntil(
clients
.matchAll({
type: "window",
includeUncontrolled: true,
})
.then((clientList) => {
for (let i = 0; i < clientList.length; i++) {
const client = clientList[i];
if (client.url === urlToOpen && "focus" in client) {
return client.focus();
}
}
if (clients.openWindow) {
return clients.openWindow(urlToOpen);
}
})
);
});

38
tailwind.config.ts Normal file
View File

@ -0,0 +1,38 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
primary: {
DEFAULT: "#0E7490",
foreground: "#FFFFFF",
},
secondary: {
DEFAULT: "#ECFCCB",
foreground: "#0E7490",
},
background: "#FAFAFA",
foreground: "#1f2937",
border: "#e5e7eb",
},
borderRadius: {
xl: "1rem",
"2xl": "1.5rem",
},
fontFamily: {
sans: ["Inter", "system-ui", "sans-serif"],
},
},
},
plugins: [],
};
export default config;

27
tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}