From afd38810159380cf66b161091c016126d20015a4 Mon Sep 17 00:00:00 2001 From: syoul Date: Sun, 23 Nov 2025 08:02:54 +0100 Subject: [PATCH] first commit --- .dockerignore | 15 + .eslintrc.json | 4 + .gitignore | 37 + Dockerfile | 43 + README.md | 134 + app/accueil/page.tsx | 40 + app/api/excursions/route.ts | 67 + app/api/infos/route.ts | 198 + app/api/notifications/route.ts | 76 + app/api/places/route.ts | 85 + app/api/sun-times/route.ts | 40 + app/api/tides/route.ts | 44 + app/explorer/page.tsx | 39 + app/globals.css | 19 + app/infos/page.tsx | 44 + app/layout.tsx | 44 + app/mana-tracker/page.tsx | 44 + app/offline/page.tsx | 20 + app/page.tsx | 6 + components/PWARegister.tsx | 26 + components/accueil/WeatherWidget.tsx | 35 + components/accueil/WifiCard.tsx | 56 + components/explorer/CategoryList.tsx | 54 + components/explorer/PlaceCard.tsx | 94 + components/explorer/PlaceList.tsx | 58 + components/infos/ContactSection.tsx | 96 + components/infos/FAQAccordion.tsx | 80 + components/infos/LexiqueSection.tsx | 63 + components/layout/Layout.tsx | 11 + components/layout/TabNavigation.tsx | 60 + components/mana-tracker/ExcursionBooking.tsx | 259 + .../mana-tracker/PushNotificationManager.tsx | 231 + components/mana-tracker/SunTimesWidget.tsx | 80 + components/mana-tracker/TideWidget.tsx | 100 + components/ui/accordion.tsx | 202 + components/ui/button.tsx | 46 + components/ui/card.tsx | 76 + docker-compose.yml | 23 + env.example | 12 + lib/config.ts | 14 + lib/data/fakarava-spots.ts | 124 + lib/utils.ts | 7 + next.config.js | 18 + package-lock.json | 6162 +++++++++++++++++ package.json | 32 + postcss.config.mjs | 10 + public/ICONS_README.md | 14 + public/favicon.ico | 1 + public/manifest.json | 25 + public/sw.js | 147 + tailwind.config.ts | 38 + tsconfig.json | 27 + 52 files changed, 9280 insertions(+) create mode 100644 .dockerignore create mode 100644 .eslintrc.json create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/accueil/page.tsx create mode 100644 app/api/excursions/route.ts create mode 100644 app/api/infos/route.ts create mode 100644 app/api/notifications/route.ts create mode 100644 app/api/places/route.ts create mode 100644 app/api/sun-times/route.ts create mode 100644 app/api/tides/route.ts create mode 100644 app/explorer/page.tsx create mode 100644 app/globals.css create mode 100644 app/infos/page.tsx create mode 100644 app/layout.tsx create mode 100644 app/mana-tracker/page.tsx create mode 100644 app/offline/page.tsx create mode 100644 app/page.tsx create mode 100644 components/PWARegister.tsx create mode 100644 components/accueil/WeatherWidget.tsx create mode 100644 components/accueil/WifiCard.tsx create mode 100644 components/explorer/CategoryList.tsx create mode 100644 components/explorer/PlaceCard.tsx create mode 100644 components/explorer/PlaceList.tsx create mode 100644 components/infos/ContactSection.tsx create mode 100644 components/infos/FAQAccordion.tsx create mode 100644 components/infos/LexiqueSection.tsx create mode 100644 components/layout/Layout.tsx create mode 100644 components/layout/TabNavigation.tsx create mode 100644 components/mana-tracker/ExcursionBooking.tsx create mode 100644 components/mana-tracker/PushNotificationManager.tsx create mode 100644 components/mana-tracker/SunTimesWidget.tsx create mode 100644 components/mana-tracker/TideWidget.tsx create mode 100644 components/ui/accordion.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/card.tsx create mode 100644 docker-compose.yml create mode 100644 env.example create mode 100644 lib/config.ts create mode 100644 lib/data/fakarava-spots.ts create mode 100644 lib/utils.ts create mode 100644 next.config.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 public/ICONS_README.md create mode 100644 public/favicon.ico create mode 100644 public/manifest.json create mode 100644 public/sw.js create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..daf40bf --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +Dockerfile +.dockerignore +node_modules +npm-debug.log +README.md +.env +.env.local +.env*.local +.next +.git +.gitignore +*.md +.vscode +.idea + diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..f18272b --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,4 @@ +{ + "extends": "next/core-web-vitals" +} + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8ac666e --- /dev/null +++ b/.gitignore @@ -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 + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0a1fc27 --- /dev/null +++ b/Dockerfile @@ -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"] + diff --git a/README.md b/README.md new file mode 100644 index 0000000..6ec405b --- /dev/null +++ b/README.md @@ -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 + diff --git a/app/accueil/page.tsx b/app/accueil/page.tsx new file mode 100644 index 0000000..2c5e41c --- /dev/null +++ b/app/accueil/page.tsx @@ -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: () =>
, + ssr: false, +}); + +export default function AccueilPage() { + return ( + +
+
+

+ Ia Ora Na +

+

+ Bienvenue au Bungalow {config.bungalowNumber} +

+
+ + + + + +
+

+ Le mot du gérant +

+

+ {config.gerantMessage} +

+
+
+
+ ); +} + diff --git a/app/api/excursions/route.ts b/app/api/excursions/route.ts new file mode 100644 index 0000000..c1b489f --- /dev/null +++ b/app/api/excursions/route.ts @@ -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 } + ); + } +} + diff --git a/app/api/infos/route.ts b/app/api/infos/route.ts new file mode 100644 index 0000000..1b72e5b --- /dev/null +++ b/app/api/infos/route.ts @@ -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 }); +} + diff --git a/app/api/notifications/route.ts b/app/api/notifications/route.ts new file mode 100644 index 0000000..38e1c6f --- /dev/null +++ b/app/api/notifications/route.ts @@ -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 } + ); + } +} + diff --git a/app/api/places/route.ts b/app/api/places/route.ts new file mode 100644 index 0000000..3b6dd89 --- /dev/null +++ b/app/api/places/route.ts @@ -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 = { + "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); +} + diff --git a/app/api/sun-times/route.ts b/app/api/sun-times/route.ts new file mode 100644 index 0000000..f2e9b59 --- /dev/null +++ b/app/api/sun-times/route.ts @@ -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); +} + diff --git a/app/api/tides/route.ts b/app/api/tides/route.ts new file mode 100644 index 0000000..7dd2c04 --- /dev/null +++ b/app/api/tides/route.ts @@ -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); +} + diff --git a/app/explorer/page.tsx b/app/explorer/page.tsx new file mode 100644 index 0000000..7c40a53 --- /dev/null +++ b/app/explorer/page.tsx @@ -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: () => ( +
+

Chargement...

+
+ ), +}); + +export default function ExplorerPage() { + const [selectedCategory, setSelectedCategory] = useState("all"); + + return ( + +
+
+

Explorer

+

+ Découvrez les meilleurs endroits de Fakarava +

+
+ + + + +
+
+ ); +} + diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..db426f6 --- /dev/null +++ b/app/globals.css @@ -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; + } +} + diff --git a/app/infos/page.tsx b/app/infos/page.tsx new file mode 100644 index 0000000..3fa8de2 --- /dev/null +++ b/app/infos/page.tsx @@ -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: () =>
, +}); + +const LexiqueSection = dynamic(() => import("@/components/infos/LexiqueSection"), { + loading: () =>
, +}); + +export default function InfosPage() { + return ( + +
+
+

+ Infos Pratiques +

+

+ Tout ce que vous devez savoir pour votre séjour +

+
+ +
+

+ Questions Fréquentes +

+ +
+ +
+ +
+ +
+ +
+
+
+ ); +} + diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..283f242 --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + + + + + + {children} + + + + ); +} + diff --git a/app/mana-tracker/page.tsx b/app/mana-tracker/page.tsx new file mode 100644 index 0000000..5925496 --- /dev/null +++ b/app/mana-tracker/page.tsx @@ -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: () =>
, + } +); + +const PushNotificationManager = dynamic( + () => import("@/components/mana-tracker/PushNotificationManager"), + { + loading: () =>
, + } +); + +export default function ManaTrackerPage() { + return ( + +
+
+

+ Mana Tracker +

+

+ Activités & Météo - Tout dépend de la mer et du soleil +

+
+ + + + + + + + +
+
+ ); +} + diff --git a/app/offline/page.tsx b/app/offline/page.tsx new file mode 100644 index 0000000..37bba2b --- /dev/null +++ b/app/offline/page.tsx @@ -0,0 +1,20 @@ +import Layout from "@/components/layout/Layout"; + +export default function OfflinePage() { + return ( + +
+

+ Mode hors ligne +

+

+ Vous êtes actuellement hors ligne. Certaines fonctionnalités peuvent être limitées. +

+

+ Les données mises en cache restent disponibles. +

+
+
+ ); +} + diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..28a86cb --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation"; + +export default function Home() { + redirect("/accueil"); +} + diff --git a/components/PWARegister.tsx b/components/PWARegister.tsx new file mode 100644 index 0000000..8d37fc4 --- /dev/null +++ b/components/PWARegister.tsx @@ -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; +} + diff --git a/components/accueil/WeatherWidget.tsx b/components/accueil/WeatherWidget.tsx new file mode 100644 index 0000000..3962cb4 --- /dev/null +++ b/components/accueil/WeatherWidget.tsx @@ -0,0 +1,35 @@ +import { Cloud, Sun } from "lucide-react"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; + +export default function WeatherWidget() { + return ( + + + + + Météo + + + +
+
+

28°C

+

Ensoleillé

+
+
+ +
+
+
+
+ Vent: 15 km/h +
+
+ Humidité: 75% +
+
+
+
+ ); +} + diff --git a/components/accueil/WifiCard.tsx b/components/accueil/WifiCard.tsx new file mode 100644 index 0000000..edcfa8a --- /dev/null +++ b/components/accueil/WifiCard.tsx @@ -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 ( + + + + + Connexion WiFi + + + +
+

Nom du réseau

+

{config.wifiName}

+
+ +
+
+ ); +} + diff --git a/components/explorer/CategoryList.tsx b/components/explorer/CategoryList.tsx new file mode 100644 index 0000000..6090a3a --- /dev/null +++ b/components/explorer/CategoryList.tsx @@ -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 ( +
+ {categories.map((category) => { + const Icon = category.icon; + const isSelected = selectedCategory === category.id; + return ( + + ); + })} +
+ ); +} + diff --git a/components/explorer/PlaceCard.tsx b/components/explorer/PlaceCard.tsx new file mode 100644 index 0000000..1cfc4c0 --- /dev/null +++ b/components/explorer/PlaceCard.tsx @@ -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 ( + +
+
+ +
+
+ +
+
+ {place.name} + {place.type && ( +

{place.type}

+ )} +
+
+
+ +

{place.description}

+ + {place.keywords && place.keywords.length > 0 && ( +
+ {place.keywords.map((keyword, index) => ( + + {keyword} + + ))} +
+ )} + +
+
+ + {place.location.address} +
+ {place.contact && ( +
+ Contact: + + {place.contact} + +
+ )} + {place.horaires && ( +
+

Horaires

+

{place.horaires}

+
+ )} + {place.conseil && ( +
+

💡 Conseil pratique

+

{place.conseil}

+
+ )} +
+ + +
+
+ ); +} + diff --git a/components/explorer/PlaceList.tsx b/components/explorer/PlaceList.tsx new file mode 100644 index 0000000..8c5eee6 --- /dev/null +++ b/components/explorer/PlaceList.tsx @@ -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([]); + 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 ( +
+

Chargement...

+
+ ); + } + + if (places.length === 0) { + return ( +
+

Aucun lieu trouvé dans cette catégorie.

+
+ ); + } + + return ( +
+ {places.map((place) => ( + + ))} +
+ ); +} + diff --git a/components/infos/ContactSection.tsx b/components/infos/ContactSection.tsx new file mode 100644 index 0000000..e317cd3 --- /dev/null +++ b/components/infos/ContactSection.tsx @@ -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 ( + + + Nous contacter + + + {contact.phone && ( +
+
+ +
+
+

Téléphone

+ + {contact.phone} + +
+
+ )} + + {contact.whatsapp && ( +
+
+ +
+ +
+ )} + + {contact.email && ( +
+
+ +
+ +
+ )} + + {contact.address && ( +
+
+ +
+
+

Adresse

+

{contact.address}

+
+
+ )} + + {contact.hours && ( +
+
+ +
+
+

Horaires

+

{contact.hours}

+
+
+ )} +
+
+ ); +} + diff --git a/components/infos/FAQAccordion.tsx b/components/infos/FAQAccordion.tsx new file mode 100644 index 0000000..1e715c1 --- /dev/null +++ b/components/infos/FAQAccordion.tsx @@ -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([]); + 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 = {}; + faqItems.forEach((item) => { + const category = item.category || "Autres"; + if (!grouped[category]) { + grouped[category] = []; + } + grouped[category].push(item); + }); + return grouped; + }, [faqItems]); + + if (loading) { + return ( +
+

Chargement...

+
+ ); + } + + return ( +
+ {Object.entries(faqByCategory).map(([category, items]) => ( +
+

+ {items[0]?.icon && {items[0].icon}} + {category} +

+ + {items.map((item) => ( + + + {item.question} + + +

+ {item.answer} +

+
+
+ ))} +
+
+ ))} +
+ ); +} + diff --git a/components/infos/LexiqueSection.tsx b/components/infos/LexiqueSection.tsx new file mode 100644 index 0000000..511cbb2 --- /dev/null +++ b/components/infos/LexiqueSection.tsx @@ -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([]); + 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 ( +
+

Chargement...

+
+ ); + } + + return ( + + + Lexique Tahitien + + +
+ {lexiqueItems.map((item) => ( +
+
+

+ {item.mot} +

+ + {item.traduction} + +
+

{item.description}

+
+ ))} +
+
+
+ ); +} + diff --git a/components/layout/Layout.tsx b/components/layout/Layout.tsx new file mode 100644 index 0000000..47ea130 --- /dev/null +++ b/components/layout/Layout.tsx @@ -0,0 +1,11 @@ +import TabNavigation from "./TabNavigation"; + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( +
+ {children} + +
+ ); +} + diff --git a/components/layout/TabNavigation.tsx b/components/layout/TabNavigation.tsx new file mode 100644 index 0000000..4cbb11c --- /dev/null +++ b/components/layout/TabNavigation.tsx @@ -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 ( + + ); +} + diff --git a/components/mana-tracker/ExcursionBooking.tsx b/components/mana-tracker/ExcursionBooking.tsx new file mode 100644 index 0000000..f832ab0 --- /dev/null +++ b/components/mana-tracker/ExcursionBooking.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [selectedExcursion, setSelectedExcursion] = useState(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 ( + + +
Chargement des excursions...
+
+
+ ); + } + + if (success) { + return ( + + + +

+ Réservation confirmée ! +

+

+ Votre demande de réservation a été enregistrée. Nous vous contacterons bientôt. +

+
+
+ ); + } + + if (selectedExcursion) { + return ( + + + Réserver : {selectedExcursion.name} + + +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+
+ + 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" + /> +
+
+ + + 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" + /> +
+
+
+

Total

+

+ {selectedExcursion.price * formData.participants} XPF +

+
+
+ + +
+
+
+
+ ); + } + + return ( + + + Réservation d'excursions + + +
+ {excursions.map((excursion) => ( +
+
+
+

+ {excursion.name} +

+ + {getExcursionTypeLabel(excursion.type)} + +

{excursion.description}

+
+ + + {excursion.duration} + +
+
+
+

+ {excursion.price.toLocaleString()} XPF +

+

par personne

+
+
+ +
+ ))} +
+
+
+ ); +} + diff --git a/components/mana-tracker/PushNotificationManager.tsx b/components/mana-tracker/PushNotificationManager.tsx new file mode 100644 index 0000000..4002512 --- /dev/null +++ b/components/mana-tracker/PushNotificationManager.tsx @@ -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([]); + const [permission, setPermission] = useState("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 ( + + +
Chargement...
+
+
+ ); + } + + const unreadCount = notifications.filter((n) => !n.read).length; + + return ( + + + + + + Notifications + + {unreadCount > 0 && ( + + {unreadCount} + + )} + + + + {permission === "default" && ( +
+

+ Activez les notifications pour recevoir des alertes importantes (baleines, météo, etc.) +

+ +
+ )} + + {permission === "denied" && ( +
+

+ Les notifications sont désactivées. Veuillez les activer dans les paramètres de votre navigateur. +

+
+ )} + + {permission === "granted" && ( +
+
+

Notifications activées

+ +
+
+ )} + +
+ {notifications.length === 0 ? ( +

+ Aucune notification pour le moment +

+ ) : ( + notifications.map((notification) => ( +
+
+
+
+ + {getNotificationIcon(notification.type)} + +

{notification.title}

+ {!notification.read && ( + + Nouveau + + )} +
+

{notification.message}

+

+ {new Date(notification.timestamp).toLocaleString("fr-FR")} +

+
+ {!notification.read && ( + + )} +
+
+ )) + )} +
+
+
+ ); +} + diff --git a/components/mana-tracker/SunTimesWidget.tsx b/components/mana-tracker/SunTimesWidget.tsx new file mode 100644 index 0000000..5cf0a87 --- /dev/null +++ b/components/mana-tracker/SunTimesWidget.tsx @@ -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(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 ( + + +
Chargement...
+
+
+ ); + } + + 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 ( + + + + + Lever / Coucher du Soleil + + + +
+
+ +

Lever

+

{sunTimes.sunrise}

+
+
+ +

Coucher

+

{sunTimes.sunset}

+
+
+
+

+ {isDay ? "☀️ Soleil actuellement visible" : "🌙 Nuit"} +

+
+
+
+ ); +} + diff --git a/components/mana-tracker/TideWidget.tsx b/components/mana-tracker/TideWidget.tsx new file mode 100644 index 0000000..b72dae7 --- /dev/null +++ b/components/mana-tracker/TideWidget.tsx @@ -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([]); + 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 ( + + +
Chargement des marées...
+
+
+ ); + } + + 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 ( + + + + + Marées + + + +
+

Aujourd'hui - {formatDate(todayTide.date)}

+
+
+
+ + Haute mer +
+

{todayTide.highTide.time}

+

{todayTide.highTide.height.toFixed(1)}m

+
+
+
+ + Basse mer +
+

{todayTide.lowTide.time}

+

{todayTide.lowTide.height.toFixed(1)}m

+
+
+
+ + {tomorrowTide && ( +
+

Demain - {formatDate(tomorrowTide.date)}

+
+
+

Haute mer

+

{tomorrowTide.highTide.time}

+
+
+

Basse mer

+

{tomorrowTide.lowTide.time}

+
+
+
+ )} +
+
+ ); +} + diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx new file mode 100644 index 0000000..849ba2f --- /dev/null +++ b/components/ui/accordion.tsx @@ -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( + 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( + ({ type = "single", defaultValue, value, onValueChange, children, className }, ref) => { + const [internalValue, setInternalValue] = React.useState( + 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 ( + +
+ {children} +
+
+ ); + } +); +Accordion.displayName = "Accordion"; + +interface AccordionItemProps { + value: string; + children: React.ReactNode; + className?: string; +} + +const AccordionItem = React.forwardRef( + ({ value, children, className }, ref) => { + return ( +
+ {children} +
+ ); + } +); +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 ( + + ); +}); +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 ( +
+
+ {children} +
+
+ ); +}); +AccordionContent.displayName = "AccordionContent"; + +const AccordionItemWithContext = React.forwardRef< + HTMLDivElement, + AccordionItemProps +>(({ value, children, ...props }, ref) => { + return ( + + + {children} + + + ); +}); +AccordionItemWithContext.displayName = "AccordionItem"; + +export { + Accordion, + AccordionItemWithContext as AccordionItem, + AccordionTrigger, + AccordionContent, +}; + diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..adadc87 --- /dev/null +++ b/components/ui/button.tsx @@ -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, + VariantProps {} + +const Button = React.forwardRef( + ({ className, variant, size, ...props }, ref) => { + return ( +