diff --git a/.env.example b/.env.example index 5e08691..7190dcf 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,10 @@ DATABASE_URL="postgresql://user:password@localhost:5432/karbe?schema=public" NEXTAUTH_SECRET="changeme" AUTH_SECRET="changeme" +# URL publique du site, utilisée pour résoudre les URLs canoniques et +# OpenGraph (SEO). En développement, laissez la valeur par défaut. +NEXT_PUBLIC_SITE_URL="http://localhost:3000" + # Stockage objet des médias (S3 ou MinIO). Compatible AWS S3 et MinIO. # Pour MinIO en local : renseignez S3_ENDPOINT (ex: http://localhost:9000) # et laissez S3_FORCE_PATH_STYLE à "true". diff --git a/src/app/carbets/[slug]/page.tsx b/src/app/carbets/[slug]/page.tsx new file mode 100644 index 0000000..cf0ed79 --- /dev/null +++ b/src/app/carbets/[slug]/page.tsx @@ -0,0 +1,171 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; + +import { getPublicCarbet } from "@/lib/carbet-public"; +import { + formatCoordinate, + formatPirogueDuration, + truncate, +} from "@/lib/format"; +import { MediaType } from "@/generated/prisma/enums"; + +import { CarbetGallery } from "../_components/carbet-gallery"; + +type PageProps = { + params: Promise<{ slug: string }>; +}; + +export async function generateMetadata({ + params, +}: PageProps): Promise { + const { slug } = await params; + const carbet = await getPublicCarbet(slug); + + if (!carbet) { + return { + title: "Carbet introuvable", + robots: { index: false, follow: false }, + }; + } + + const description = truncate(carbet.description, 200); + const coverPhoto = carbet.media.find((m) => m.type === MediaType.PHOTO); + const ogImages = coverPhoto + ? [{ url: coverPhoto.url, alt: `Photo de ${carbet.title}` }] + : undefined; + const canonical = `/carbets/${carbet.slug}`; + + return { + title: `${carbet.title} — Carbet sur ${carbet.river}`, + description, + alternates: { canonical }, + openGraph: { + type: "website", + title: `${carbet.title} — Carbet sur le fleuve ${carbet.river}`, + description, + url: canonical, + siteName: "Karbé", + locale: "fr_FR", + images: ogImages, + }, + twitter: { + card: ogImages ? "summary_large_image" : "summary", + title: carbet.title, + description, + images: ogImages?.map((img) => img.url), + }, + }; +} + +export default async function PublicCarbetPage({ params }: PageProps) { + const { slug } = await params; + const carbet = await getPublicCarbet(slug); + + if (!carbet) { + notFound(); + } + + return ( +
+ + ← Tous les carbets + + +
+

+ Fleuve {carbet.river} +

+

+ {carbet.title} +

+

+ Accueil par {carbet.ownerFirstName} · {carbet.capacity} voyageur + {carbet.capacity > 1 ? "s" : ""} · Pirogue{" "} + {formatPirogueDuration(carbet.pirogueDurationMin)} depuis{" "} + {carbet.embarkPoint} +

+
+ +
+ +
+ +
+
+
+

+ À propos de ce carbet +

+

+ {carbet.description} +

+
+ + {carbet.amenities.length > 0 ? ( +
+

+ Équipements +

+
    + {carbet.amenities.map((amenity) => ( +
  • + + ● + + {amenity.label} +
  • + ))} +
+
+ ) : null} +
+ + +
+
+ ); +} diff --git a/src/app/carbets/_components/carbet-card.tsx b/src/app/carbets/_components/carbet-card.tsx new file mode 100644 index 0000000..0667ccf --- /dev/null +++ b/src/app/carbets/_components/carbet-card.tsx @@ -0,0 +1,47 @@ +import Link from "next/link"; + +import type { CarbetSearchResult } from "@/lib/carbet-search"; +import { formatPirogueDuration, truncate } from "@/lib/format"; + +export function CarbetCard({ carbet }: { carbet: CarbetSearchResult }) { + const href = `/carbets/${carbet.slug}`; + return ( +
+ + {carbet.coverUrl ? ( + // Use a plain here — uploaded media URLs come from MinIO/S3 and + // don't go through next/image's optimizer in this environment. + // eslint-disable-next-line @next/next/no-img-element + {`Photo + ) : ( +
+ Pas de photo +
+ )} + +
+

+ + {carbet.title} + +

+

+ Fleuve {carbet.river} · {carbet.capacity} voyageur + {carbet.capacity > 1 ? "s" : ""} +

+

+ {truncate(carbet.description, 180)} +

+

+ Pirogue {formatPirogueDuration(carbet.pirogueDurationMin)} depuis{" "} + {carbet.embarkPoint} +

+
+
+ ); +} diff --git a/src/app/carbets/_components/carbet-gallery.tsx b/src/app/carbets/_components/carbet-gallery.tsx new file mode 100644 index 0000000..807adda --- /dev/null +++ b/src/app/carbets/_components/carbet-gallery.tsx @@ -0,0 +1,73 @@ +import type { PublicCarbetMedia } from "@/lib/carbet-public"; +import { MediaType } from "@/generated/prisma/enums"; + +type Props = { + title: string; + media: PublicCarbetMedia[]; +}; + +// SSR-friendly gallery: shows a cover (photo or video) plus a strip of +// secondary media. No client component — all native HTML controls. +export function CarbetGallery({ title, media }: Props) { + if (media.length === 0) { + return ( +
+ Pas encore de média pour ce carbet. +
+ ); + } + + const [cover, ...rest] = media; + + return ( +
+
+ {cover.type === MediaType.VIDEO ? ( +
+ + {rest.length > 0 ? ( + + ) : null} +
+ ); +} diff --git a/src/app/carbets/_components/search-filters.tsx b/src/app/carbets/_components/search-filters.tsx new file mode 100644 index 0000000..3b77f08 --- /dev/null +++ b/src/app/carbets/_components/search-filters.tsx @@ -0,0 +1,90 @@ +import type { CarbetSearchFilters } from "@/lib/carbet-search"; + +type SearchFiltersProps = { + filters: CarbetSearchFilters; + rivers: string[]; +}; + +function toDateInput(date: Date | undefined): string { + if (!date) return ""; + // The Date was built from a YYYY-MM-DD UTC string, so toISOString() gives us + // back the same calendar day regardless of the server timezone. + return date.toISOString().slice(0, 10); +} + +export function SearchFilters({ filters, rivers }: SearchFiltersProps) { + return ( +
+ + + + + + + + +
+ + Réinitialiser + + +
+
+ ); +} diff --git a/src/app/carbets/page.tsx b/src/app/carbets/page.tsx new file mode 100644 index 0000000..a49ed1b --- /dev/null +++ b/src/app/carbets/page.tsx @@ -0,0 +1,87 @@ +import type { Metadata } from "next"; + +import { + listPublishedRivers, + parseSearchFilters, + searchCarbets, + type RawSearchParams, +} from "@/lib/carbet-search"; + +import { CarbetCard } from "./_components/carbet-card"; +import { SearchFilters } from "./_components/search-filters"; + +export const metadata: Metadata = { + title: "Rechercher un carbet", + description: + "Explorez les carbets fluviaux de Guyane disponibles sur Karbé : filtrez par fleuve, dates de séjour et capacité d'accueil.", + alternates: { canonical: "/carbets" }, + openGraph: { + title: "Rechercher un carbet — Karbé", + description: + "Trouvez un carbet authentique le long des fleuves de Guyane. Filtrez par fleuve, dates et capacité.", + type: "website", + }, +}; + +export default async function CarbetsSearchPage({ + searchParams, +}: { + searchParams: Promise; +}) { + const raw = await searchParams; + const filters = parseSearchFilters(raw); + + const [results, rivers] = await Promise.all([ + searchCarbets(filters), + listPublishedRivers(), + ]); + + const hasActiveFilters = Boolean( + filters.river || + filters.startDate || + filters.endDate || + filters.capacity, + ); + + return ( +
+
+

+ Carbets fluviaux de Guyane +

+

+ Sélectionnez votre fleuve, vos dates et le nombre de voyageurs pour + découvrir les carbets disponibles, depuis le Maroni jusqu'à + l'Oyapock. +

+
+ + + +
+

Résultats de la recherche

+ {results.length === 0 ? ( +

+ {hasActiveFilters + ? "Aucun carbet ne correspond à votre recherche. Essayez d'élargir les filtres." + : "Aucun carbet publié pour le moment. Revenez bientôt !"} +

+ ) : ( + <> +

+ {results.length} carbet{results.length > 1 ? "s" : ""} trouvé + {results.length > 1 ? "s" : ""}. +

+
    + {results.map((carbet) => ( +
  • + +
  • + ))} +
+ + )} +
+
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index fb56576..a671161 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -12,10 +12,24 @@ const geistMono = Geist_Mono({ subsets: ["latin"], }); +const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000"; + export const metadata: Metadata = { - title: "Karbé — carbets fluviaux de Guyane", + metadataBase: new URL(siteUrl), + title: { + default: "Karbé — carbets fluviaux de Guyane", + template: "%s | Karbé", + }, description: "Karbé, la marketplace de location de carbets fluviaux de Guyane.", + openGraph: { + type: "website", + siteName: "Karbé", + locale: "fr_FR", + title: "Karbé — carbets fluviaux de Guyane", + description: + "La marketplace pour louer des carbets fluviaux le long des fleuves de Guyane.", + }, }; export default function RootLayout({ diff --git a/src/app/page.tsx b/src/app/page.tsx index 44be437..d709fb4 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,3 +1,5 @@ +import Link from "next/link"; + export default function Home() { return (
@@ -10,6 +12,20 @@ export default function Home() { Connecter voyageurs et hôtes pour des séjours authentiques au cœur de la forêt amazonienne.

+
+ + Découvrir les carbets + + + Espace hôte + +
); diff --git a/src/app/robots.ts b/src/app/robots.ts new file mode 100644 index 0000000..f53e553 --- /dev/null +++ b/src/app/robots.ts @@ -0,0 +1,16 @@ +import type { MetadataRoute } from "next"; + +const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000"; + +export default function robots(): MetadataRoute.Robots { + return { + rules: [ + { + userAgent: "*", + allow: "/", + disallow: ["/admin", "/espace-hote", "/api/", "/connexion"], + }, + ], + sitemap: `${siteUrl.replace(/\/+$/, "")}/sitemap.xml`, + }; +} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts new file mode 100644 index 0000000..78d6edf --- /dev/null +++ b/src/app/sitemap.ts @@ -0,0 +1,38 @@ +import type { MetadataRoute } from "next"; + +import { prisma } from "@/lib/prisma"; +import { CarbetStatus } from "@/generated/prisma/enums"; + +const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000"; + +function abs(path: string): string { + return `${siteUrl.replace(/\/+$/, "")}${path}`; +} + +export default async function sitemap(): Promise { + const staticRoutes: MetadataRoute.Sitemap = [ + { url: abs("/"), changeFrequency: "weekly", priority: 1 }, + { url: abs("/carbets"), changeFrequency: "daily", priority: 0.9 }, + { url: abs("/cgv"), changeFrequency: "yearly", priority: 0.2 }, + { url: abs("/mentions-legales"), changeFrequency: "yearly", priority: 0.2 }, + { + url: abs("/politique-de-confidentialite"), + changeFrequency: "yearly", + priority: 0.2, + }, + ]; + + const carbets = await prisma.carbet.findMany({ + where: { status: CarbetStatus.PUBLISHED }, + select: { slug: true, updatedAt: true }, + }); + + const carbetRoutes: MetadataRoute.Sitemap = carbets.map((carbet) => ({ + url: abs(`/carbets/${carbet.slug}`), + lastModified: carbet.updatedAt, + changeFrequency: "weekly", + priority: 0.7, + })); + + return [...staticRoutes, ...carbetRoutes]; +} diff --git a/src/lib/carbet-public.ts b/src/lib/carbet-public.ts new file mode 100644 index 0000000..5735b6c --- /dev/null +++ b/src/lib/carbet-public.ts @@ -0,0 +1,85 @@ +import { cache } from "react"; + +import { prisma } from "@/lib/prisma"; +import { amenityLabel } from "@/lib/amenities"; +import { CarbetStatus, MediaType } from "@/generated/prisma/enums"; + +export type PublicCarbetMedia = { + id: string; + type: MediaType; + url: string; +}; + +export type PublicCarbetDetail = { + id: string; + slug: string; + title: string; + description: string; + river: string; + embarkPoint: string; + pirogueDurationMin: number; + capacity: number; + latitude: string; + longitude: string; + ownerFirstName: string; + media: PublicCarbetMedia[]; + amenities: { key: string; label: string }[]; +}; + +// Memoized within a single request so generateMetadata() and the page itself +// only hit the database once per render. +export const getPublicCarbet = cache( + async (slug: string): Promise => { + const carbet = await prisma.carbet.findFirst({ + where: { slug, status: CarbetStatus.PUBLISHED }, + select: { + id: true, + slug: true, + title: true, + description: true, + river: true, + embarkPoint: true, + pirogueDurationMin: true, + capacity: true, + latitude: true, + longitude: true, + owner: { select: { firstName: true } }, + media: { + orderBy: { sortOrder: "asc" }, + select: { id: true, type: true, s3Url: true }, + }, + amenities: { + select: { amenity: { select: { key: true, label: true } } }, + }, + }, + }); + + if (!carbet) return null; + + return { + id: carbet.id, + slug: carbet.slug, + title: carbet.title, + description: carbet.description, + river: carbet.river, + embarkPoint: carbet.embarkPoint, + pirogueDurationMin: carbet.pirogueDurationMin, + capacity: carbet.capacity, + latitude: carbet.latitude.toString(), + longitude: carbet.longitude.toString(), + ownerFirstName: carbet.owner.firstName, + media: carbet.media.map((m) => ({ + id: m.id, + type: m.type, + url: m.s3Url, + })), + amenities: carbet.amenities + .map((entry) => ({ + key: entry.amenity.key, + // Prefer the catalogue label so renames roll out without a backfill. + label: amenityLabel(entry.amenity.key) || entry.amenity.label, + })) + .sort((a, b) => a.label.localeCompare(b.label, "fr")), + }; + }, +); diff --git a/src/lib/carbet-search.ts b/src/lib/carbet-search.ts new file mode 100644 index 0000000..d6c2fd8 --- /dev/null +++ b/src/lib/carbet-search.ts @@ -0,0 +1,158 @@ +import { prisma } from "@/lib/prisma"; +import { Prisma } from "@/generated/prisma/client"; +import { + AvailabilityBlockReason, + AvailabilityScope, + CarbetStatus, +} from "@/generated/prisma/enums"; + +export type CarbetSearchFilters = { + river?: string; + startDate?: Date; + endDate?: Date; + capacity?: number; +}; + +export type RawSearchParams = { + [key: string]: string | string[] | undefined; +}; + +function pickString(value: string | string[] | undefined): string | undefined { + if (Array.isArray(value)) return value[0]; + return value; +} + +// Parse and normalize raw URLSearchParams into a typed filter set. +// Invalid / partial inputs are dropped so the search page degrades gracefully. +export function parseSearchFilters( + searchParams: RawSearchParams, +): CarbetSearchFilters { + const filters: CarbetSearchFilters = {}; + + const river = pickString(searchParams.river)?.trim(); + if (river) { + filters.river = river; + } + + const startRaw = pickString(searchParams.startDate); + const endRaw = pickString(searchParams.endDate); + const start = startRaw ? new Date(`${startRaw}T00:00:00.000Z`) : undefined; + const end = endRaw ? new Date(`${endRaw}T23:59:59.999Z`) : undefined; + const startValid = start && !Number.isNaN(start.getTime()); + const endValid = end && !Number.isNaN(end.getTime()); + + // Only honour a date range if both bounds parse and start <= end. + if (startValid && endValid && start! <= end!) { + filters.startDate = start; + filters.endDate = end; + } else if (startValid && !endRaw) { + filters.startDate = start; + } else if (endValid && !startRaw) { + filters.endDate = end; + } + + const capacityRaw = pickString(searchParams.capacity); + if (capacityRaw) { + const capacity = Number(capacityRaw); + if (Number.isInteger(capacity) && capacity > 0 && capacity <= 100) { + filters.capacity = capacity; + } + } + + return filters; +} + +export type CarbetSearchResult = { + id: string; + slug: string; + title: string; + river: string; + embarkPoint: string; + pirogueDurationMin: number; + capacity: number; + description: string; + coverUrl: string | null; + mediaCount: number; +}; + +// Build the Prisma where-clause for a public carbet search. A carbet is only +// considered if it is PUBLISHED and (when dates are given) has at least one +// PUBLIC + available slot that covers the requested range. +function buildWhere(filters: CarbetSearchFilters): Prisma.CarbetWhereInput { + const where: Prisma.CarbetWhereInput = { + status: CarbetStatus.PUBLISHED, + }; + + if (filters.river) { + where.river = { contains: filters.river, mode: "insensitive" }; + } + + if (filters.capacity) { + where.capacity = { gte: filters.capacity }; + } + + if (filters.startDate && filters.endDate) { + where.availabilities = { + some: { + scope: AvailabilityScope.PUBLIC, + isAvailable: true, + blockReason: AvailabilityBlockReason.NONE, + startDate: { lte: filters.startDate }, + endDate: { gte: filters.endDate }, + }, + }; + } + + return where; +} + +export async function searchCarbets( + filters: CarbetSearchFilters, + limit = 30, +): Promise { + const carbets = await prisma.carbet.findMany({ + where: buildWhere(filters), + orderBy: [{ updatedAt: "desc" }], + take: limit, + select: { + id: true, + slug: true, + title: true, + river: true, + embarkPoint: true, + pirogueDurationMin: true, + capacity: true, + description: true, + media: { + orderBy: { sortOrder: "asc" }, + take: 1, + select: { s3Url: true }, + }, + _count: { select: { media: true } }, + }, + }); + + return carbets.map((carbet) => ({ + id: carbet.id, + slug: carbet.slug, + title: carbet.title, + river: carbet.river, + embarkPoint: carbet.embarkPoint, + pirogueDurationMin: carbet.pirogueDurationMin, + capacity: carbet.capacity, + description: carbet.description, + coverUrl: carbet.media[0]?.s3Url ?? null, + mediaCount: carbet._count.media, + })); +} + +// Distinct list of rivers across the published catalogue, for filter UI hints. +export async function listPublishedRivers(): Promise { + const rows = await prisma.carbet.findMany({ + where: { status: CarbetStatus.PUBLISHED }, + distinct: ["river"], + orderBy: { river: "asc" }, + select: { river: true }, + }); + return rows.map((row) => row.river); +} diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..5770802 --- /dev/null +++ b/src/lib/format.ts @@ -0,0 +1,27 @@ +// Format a pirogue trip duration (minutes) into a human readable French label +// such as "45 min" or "1 h 20". +export function formatPirogueDuration(minutes: number): string { + if (minutes < 60) return `${minutes} min`; + const hours = Math.floor(minutes / 60); + const rest = minutes % 60; + if (rest === 0) return `${hours} h`; + return `${hours} h ${String(rest).padStart(2, "0")}`; +} + +// Trim a long description for use in cards or meta descriptions. +export function truncate(text: string, max: number): string { + if (text.length <= max) return text; + const slice = text.slice(0, max - 1); + const lastSpace = slice.lastIndexOf(" "); + const cut = lastSpace > max * 0.6 ? slice.slice(0, lastSpace) : slice; + return `${cut.trim()}…`; +} + +// Format a decimal coordinate (Prisma Decimal | number | string) for display. +export function formatCoordinate( + value: number | string | { toString(): string }, +): string { + const num = typeof value === "number" ? value : Number(value.toString()); + if (Number.isNaN(num)) return "—"; + return num.toFixed(5); +}