first commit
This commit is contained in:
15
.dockerignore
Normal file
15
.dockerignore
Normal 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
4
.eslintrc.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals"
|
||||||
|
}
|
||||||
|
|
||||||
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal 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
43
Dockerfile
Normal 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
134
README.md
Normal 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
40
app/accueil/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
67
app/api/excursions/route.ts
Normal file
67
app/api/excursions/route.ts
Normal 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
198
app/api/infos/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
|
||||||
76
app/api/notifications/route.ts
Normal file
76
app/api/notifications/route.ts
Normal 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
85
app/api/places/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
40
app/api/sun-times/route.ts
Normal file
40
app/api/sun-times/route.ts
Normal 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
44
app/api/tides/route.ts
Normal 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
39
app/explorer/page.tsx
Normal 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
19
app/globals.css
Normal 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
44
app/infos/page.tsx
Normal 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
44
app/layout.tsx
Normal 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
44
app/mana-tracker/page.tsx
Normal 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
20
app/offline/page.tsx
Normal 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
6
app/page.tsx
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
redirect("/accueil");
|
||||||
|
}
|
||||||
|
|
||||||
26
components/PWARegister.tsx
Normal file
26
components/PWARegister.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
35
components/accueil/WeatherWidget.tsx
Normal file
35
components/accueil/WeatherWidget.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
56
components/accueil/WifiCard.tsx
Normal file
56
components/accueil/WifiCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
54
components/explorer/CategoryList.tsx
Normal file
54
components/explorer/CategoryList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
94
components/explorer/PlaceCard.tsx
Normal file
94
components/explorer/PlaceCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
58
components/explorer/PlaceList.tsx
Normal file
58
components/explorer/PlaceList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
96
components/infos/ContactSection.tsx
Normal file
96
components/infos/ContactSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
80
components/infos/FAQAccordion.tsx
Normal file
80
components/infos/FAQAccordion.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
63
components/infos/LexiqueSection.tsx
Normal file
63
components/infos/LexiqueSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
11
components/layout/Layout.tsx
Normal file
11
components/layout/Layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
60
components/layout/TabNavigation.tsx
Normal file
60
components/layout/TabNavigation.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
259
components/mana-tracker/ExcursionBooking.tsx
Normal file
259
components/mana-tracker/ExcursionBooking.tsx
Normal 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 été 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
231
components/mana-tracker/PushNotificationManager.tsx
Normal file
231
components/mana-tracker/PushNotificationManager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
80
components/mana-tracker/SunTimesWidget.tsx
Normal file
80
components/mana-tracker/SunTimesWidget.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
100
components/mana-tracker/TideWidget.tsx
Normal file
100
components/mana-tracker/TideWidget.tsx
Normal 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
202
components/ui/accordion.tsx
Normal 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
46
components/ui/button.tsx
Normal 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
76
components/ui/card.tsx
Normal 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
23
docker-compose.yml
Normal 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
12
env.example
Normal 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
14
lib/config.ts
Normal 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
124
lib/data/fakarava-spots.ts
Normal 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
7
lib/utils.ts
Normal 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
18
next.config.js
Normal 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
6162
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
package.json
Normal file
32
package.json
Normal 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
10
postcss.config.mjs
Normal 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
14
public/ICONS_README.md
Normal 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
1
public/favicon.ico
Normal file
@ -0,0 +1 @@
|
|||||||
|
placeholder
|
||||||
25
public/manifest.json
Normal file
25
public/manifest.json
Normal 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
147
public/sw.js
Normal 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
38
tailwind.config.ts
Normal 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
27
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user