feat(plugins): seasonality + min-stay (Phase 3.2 + 3.4)
Plugin seasonality : - Migration : Carbet.seasonalConstraints JSONB nullable - lib/seasonality.ts : enum Season (DRY|LOW_WATER|WET), currentSeason() helper Guyane (juil-sept sèche, oct-nov étiage, déc-juin pluies), parseSeasonalConstraints, isCurrentlyOpen, SEASON_META (label/emoji/tone) - Composant <SeasonBanner /> server, gated par plugin, ajouté dans layout au-dessus de tout le contenu — bandeau couleur+emoji+message contextuel Plugin min-stay : - Migration : Carbet.minStayNights, maxStayNights, minCapacity nullable - Composant <StayConstraints /> client, gated par plugin — pill text '2 nuits minimum', '2-7 nuits', 'groupe 4+ recommandé' - Carbet card et fiche enrichies avec les contraintes Tous deux désactivables : sans le toggle, comportement legacy inchangé.
This commit is contained in:
parent
4842a44746
commit
be2391998d
7 changed files with 196 additions and 1 deletions
|
|
@ -0,0 +1,7 @@
|
|||
-- Plugin seasonality + min-stay : champs sur Carbet
|
||||
|
||||
ALTER TABLE "Carbet"
|
||||
ADD COLUMN "seasonalConstraints" JSONB,
|
||||
ADD COLUMN "minStayNights" INTEGER,
|
||||
ADD COLUMN "maxStayNights" INTEGER,
|
||||
ADD COLUMN "minCapacity" INTEGER;
|
||||
|
|
@ -16,6 +16,7 @@ import { CarbetGallery } from "../_components/carbet-gallery";
|
|||
import { ReviewsSection } from "../_components/reviews-section";
|
||||
import { StarRating } from "../_components/star-rating";
|
||||
import { AccessTypeBadge } from "@/components/AccessTypeBadge";
|
||||
import { StayConstraints } from "@/components/StayConstraints";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ slug: string }>;
|
||||
|
|
@ -197,6 +198,18 @@ export default async function PublicCarbetPage({ params }: PageProps) {
|
|||
{formatCoordinate(carbet.longitude)}
|
||||
</dd>
|
||||
</div>
|
||||
{(carbet.minStayNights || carbet.maxStayNights || carbet.minCapacity) ? (
|
||||
<div>
|
||||
<dt className="font-medium text-zinc-500">Séjour</dt>
|
||||
<dd>
|
||||
<StayConstraints
|
||||
minNights={carbet.minStayNights}
|
||||
maxNights={carbet.maxStayNights}
|
||||
minCapacity={carbet.minCapacity}
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<dt className="font-medium text-zinc-500">Capacité</dt>
|
||||
<dd>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Geist, Geist_Mono, Cormorant_Garamond } from "next/font/google";
|
|||
import "./globals.css";
|
||||
import { PluginProvider } from "@/lib/plugins/client";
|
||||
import { getEnabledPluginKeys, syncPluginsFromRegistry } from "@/lib/plugins/server";
|
||||
import { SeasonBanner } from "@/components/SeasonBanner";
|
||||
|
||||
// Le layout interroge la DB Plugin à chaque request → rendu dynamique forcé.
|
||||
// Sans ça, le layout (et donc data-theme + enabledKeys passés au client) est
|
||||
|
|
@ -76,7 +77,10 @@ export default async function RootLayout({
|
|||
data-theme={themeGuyane ? "guyane" : undefined}
|
||||
className="min-h-full flex flex-col font-sans"
|
||||
>
|
||||
<PluginProvider enabledKeys={enabledKeys}>{children}</PluginProvider>
|
||||
<PluginProvider enabledKeys={enabledKeys}>
|
||||
<SeasonBanner />
|
||||
{children}
|
||||
</PluginProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
41
src/components/SeasonBanner.tsx
Normal file
41
src/components/SeasonBanner.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { isPluginEnabled } from "@/lib/plugins/server";
|
||||
import { currentSeason, SEASON_META } from "@/lib/seasonality";
|
||||
|
||||
/**
|
||||
* Bandeau saison — affiché en haut de la home et de /carbets si le plugin
|
||||
* `seasonality` est activé. Server component pur, pas de fetch DB.
|
||||
*/
|
||||
export async function SeasonBanner() {
|
||||
if (!(await isPluginEnabled("seasonality"))) return null;
|
||||
const season = currentSeason();
|
||||
const meta = SEASON_META[season];
|
||||
|
||||
const messages: Record<typeof season, string> = {
|
||||
DRY:
|
||||
"Conditions optimales : fleuves navigables, pistes route en bon état, lever de soleil sur l'eau brûlante.",
|
||||
LOW_WATER:
|
||||
"Étiage en cours : les carbets fleuve uniquement peuvent ne pas être accessibles. Filtre dispo en page recherche.",
|
||||
WET:
|
||||
"Pluies fréquentes : la jungle est dense et vivante, prévoir un véhicule adapté pour les carbets route+fleuve.",
|
||||
};
|
||||
|
||||
const tones = {
|
||||
ok: "bg-[var(--color-karbe-canopy-700)] text-[var(--color-karbe-bone)]",
|
||||
warn: "bg-[var(--color-karbe-laterite-500)] text-[var(--color-karbe-bone)]",
|
||||
info: "bg-[var(--color-karbe-maroni-700)] text-[var(--color-karbe-bone)]",
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<aside className={`${tones[meta.tone]} text-xs sm:text-sm`}>
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-3 px-6 py-2 sm:px-8 lg:px-12">
|
||||
<span className="flex items-center gap-2">
|
||||
<span aria-hidden>{meta.emoji}</span>
|
||||
<span className="font-semibold uppercase tracking-wider">{meta.label}</span>
|
||||
<span className="hidden text-[var(--color-karbe-bone)]/85 sm:inline">
|
||||
· {messages[season]}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
47
src/components/StayConstraints.tsx
Normal file
47
src/components/StayConstraints.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"use client";
|
||||
|
||||
import { useIsPluginEnabled } from "@/lib/plugins/client";
|
||||
|
||||
/**
|
||||
* Composant client qui affiche les contraintes de séjour si le plugin
|
||||
* `min-stay` est activé. Sinon, retourne null (legacy = pas de contraintes).
|
||||
*/
|
||||
export function StayConstraints({
|
||||
minNights,
|
||||
maxNights,
|
||||
minCapacity,
|
||||
className = "",
|
||||
}: {
|
||||
minNights?: number | null;
|
||||
maxNights?: number | null;
|
||||
minCapacity?: number | null;
|
||||
className?: string;
|
||||
}) {
|
||||
const enabled = useIsPluginEnabled("min-stay");
|
||||
if (!enabled) return null;
|
||||
if (!minNights && !maxNights && !minCapacity) return null;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (minNights && maxNights && minNights !== maxNights) {
|
||||
parts.push(`${minNights}–${maxNights} nuits`);
|
||||
} else if (minNights) {
|
||||
parts.push(
|
||||
minNights === 1 ? "1 nuit minimum" : `${minNights} nuits minimum`,
|
||||
);
|
||||
} else if (maxNights) {
|
||||
parts.push(`Max ${maxNights} nuits`);
|
||||
}
|
||||
if (minCapacity && minCapacity > 1) {
|
||||
parts.push(`groupe de ${minCapacity}+ recommandé`);
|
||||
}
|
||||
if (!parts.length) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full bg-[var(--color-karbe-wood-300)]/30 px-2 py-0.5 text-[11px] font-medium text-[var(--color-karbe-wood-700)] ring-1 ring-[var(--color-karbe-wood-500)]/20 ${className}`}
|
||||
title="Contraintes de séjour fixées par le loueur"
|
||||
>
|
||||
🌙 {parts.join(" · ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -26,6 +26,10 @@ export type PublicCarbetDetail = {
|
|||
accessType: AccessType;
|
||||
roadAccessNote: string | null;
|
||||
capacity: number;
|
||||
minStayNights: number | null;
|
||||
maxStayNights: number | null;
|
||||
minCapacity: number | null;
|
||||
seasonalConstraints: unknown;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
ownerId: string;
|
||||
|
|
@ -53,6 +57,10 @@ export const getPublicCarbet = cache(
|
|||
accessType: true,
|
||||
roadAccessNote: true,
|
||||
capacity: true,
|
||||
minStayNights: true,
|
||||
maxStayNights: true,
|
||||
minCapacity: true,
|
||||
seasonalConstraints: true,
|
||||
latitude: true,
|
||||
longitude: true,
|
||||
ownerId: true,
|
||||
|
|
@ -85,6 +93,10 @@ export const getPublicCarbet = cache(
|
|||
accessType: carbet.accessType,
|
||||
roadAccessNote: carbet.roadAccessNote,
|
||||
capacity: carbet.capacity,
|
||||
minStayNights: carbet.minStayNights,
|
||||
maxStayNights: carbet.maxStayNights,
|
||||
minCapacity: carbet.minCapacity,
|
||||
seasonalConstraints: carbet.seasonalConstraints,
|
||||
latitude: carbet.latitude.toString(),
|
||||
longitude: carbet.longitude.toString(),
|
||||
ownerId: carbet.ownerId,
|
||||
|
|
|
|||
71
src/lib/seasonality.ts
Normal file
71
src/lib/seasonality.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* Saisons guyanaises — gated par le plugin `seasonality`.
|
||||
*
|
||||
* Guyane française :
|
||||
* - DRY (juillet-septembre) : saison sèche, conditions idéales
|
||||
* - LOW_WATER (octobre-mi-novembre) : étiage, fleuves bas, certains carbets
|
||||
* fleuve-only peuvent ne pas être accessibles
|
||||
* - WET (décembre-juin) : grande saison des pluies, pistes route
|
||||
* parfois en mauvais état
|
||||
*
|
||||
* Volontairement simplifié — la vraie saisonnalité varie un peu selon le
|
||||
* fleuve. Les contraintes fines vivent dans Carbet.seasonalConstraints.
|
||||
*/
|
||||
|
||||
export type Season = "DRY" | "LOW_WATER" | "WET";
|
||||
|
||||
export function currentSeason(date = new Date()): Season {
|
||||
const month = date.getMonth() + 1; // 1..12
|
||||
if (month >= 7 && month <= 9) return "DRY";
|
||||
if (month === 10 || month === 11) return "LOW_WATER";
|
||||
return "WET";
|
||||
}
|
||||
|
||||
export type SeasonalConstraints = {
|
||||
closedInLowWater?: boolean;
|
||||
closedSeasons?: Season[];
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export function parseSeasonalConstraints(value: unknown): SeasonalConstraints | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const v = value as Record<string, unknown>;
|
||||
const out: SeasonalConstraints = {};
|
||||
if (typeof v.closedInLowWater === "boolean") out.closedInLowWater = v.closedInLowWater;
|
||||
if (Array.isArray(v.closedSeasons)) {
|
||||
out.closedSeasons = v.closedSeasons.filter(
|
||||
(s): s is Season => s === "DRY" || s === "LOW_WATER" || s === "WET",
|
||||
);
|
||||
}
|
||||
if (typeof v.note === "string") out.note = v.note;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function isCurrentlyOpen(
|
||||
constraints: SeasonalConstraints | null,
|
||||
date = new Date(),
|
||||
): boolean {
|
||||
if (!constraints) return true;
|
||||
const s = currentSeason(date);
|
||||
if (constraints.closedInLowWater && s === "LOW_WATER") return false;
|
||||
if (constraints.closedSeasons?.includes(s)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export const SEASON_META: Record<Season, { label: string; emoji: string; tone: "ok" | "warn" | "info" }> = {
|
||||
DRY: {
|
||||
label: "Saison sèche",
|
||||
emoji: "☀️",
|
||||
tone: "ok",
|
||||
},
|
||||
LOW_WATER: {
|
||||
label: "Étiage",
|
||||
emoji: "⚠️",
|
||||
tone: "warn",
|
||||
},
|
||||
WET: {
|
||||
label: "Saison des pluies",
|
||||
emoji: "🌧",
|
||||
tone: "info",
|
||||
},
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue