Carbets publics — recherche + fiche SSR (SYS-5) (#8)

Implémente SYS-5 : page /carbets (recherche SSR + filtres river/dates/capacité) et /carbets/[slug] (fiche SSR avec generateMetadata OG/Twitter, galerie, équipements, accès), plus SEO sitewide (robots.ts, sitemap.xml, metadataBase, title.template).

Reviewed-by: Karbé Architect <karbe-architect@cosmolan.fr>
This commit is contained in:
tarzzan 2026-05-29 22:28:29 +00:00
commit ba0494611b
13 changed files with 827 additions and 1 deletions

View file

@ -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".

View file

@ -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<Metadata> {
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 (
<main className="mx-auto w-full max-w-5xl flex-1 px-6 py-10">
<Link
href="/carbets"
className="text-sm text-zinc-600 hover:text-zinc-900"
>
Tous les carbets
</Link>
<header className="mt-3">
<p className="text-sm font-medium uppercase tracking-wide text-emerald-700">
Fleuve {carbet.river}
</p>
<h1 className="mt-1 text-3xl font-semibold text-zinc-900 sm:text-4xl">
{carbet.title}
</h1>
<p className="mt-2 text-sm text-zinc-600">
Accueil par {carbet.ownerFirstName} · {carbet.capacity} voyageur
{carbet.capacity > 1 ? "s" : ""} · Pirogue{" "}
{formatPirogueDuration(carbet.pirogueDurationMin)} depuis{" "}
{carbet.embarkPoint}
</p>
</header>
<section className="mt-6">
<CarbetGallery title={carbet.title} media={carbet.media} />
</section>
<div className="mt-10 grid gap-10 lg:grid-cols-3">
<div className="lg:col-span-2">
<section>
<h2 className="text-xl font-semibold text-zinc-900">
À propos de ce carbet
</h2>
<p className="mt-3 whitespace-pre-line text-zinc-700">
{carbet.description}
</p>
</section>
{carbet.amenities.length > 0 ? (
<section className="mt-10">
<h2 className="text-xl font-semibold text-zinc-900">
Équipements
</h2>
<ul className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
{carbet.amenities.map((amenity) => (
<li
key={amenity.key}
className="flex items-center gap-2 rounded-md border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700"
>
<span aria-hidden className="text-emerald-600">
</span>
{amenity.label}
</li>
))}
</ul>
</section>
) : null}
</div>
<aside className="space-y-4 rounded-lg border border-zinc-200 bg-white p-5">
<div>
<h2 className="text-base font-semibold text-zinc-900">
Accès au carbet
</h2>
<dl className="mt-3 space-y-2 text-sm text-zinc-700">
<div>
<dt className="font-medium text-zinc-500">
Point d&apos;embarquement
</dt>
<dd>{carbet.embarkPoint}</dd>
</div>
<div>
<dt className="font-medium text-zinc-500">Trajet pirogue</dt>
<dd>{formatPirogueDuration(carbet.pirogueDurationMin)}</dd>
</div>
<div>
<dt className="font-medium text-zinc-500">Coordonnées GPS</dt>
<dd>
{formatCoordinate(carbet.latitude)},{" "}
{formatCoordinate(carbet.longitude)}
</dd>
</div>
<div>
<dt className="font-medium text-zinc-500">Capacité</dt>
<dd>
{carbet.capacity} voyageur
{carbet.capacity > 1 ? "s" : ""}
</dd>
</div>
</dl>
</div>
<p className="rounded-md bg-emerald-50 px-3 py-2 text-xs text-emerald-800">
La réservation en ligne arrive bientôt. En attendant, contactez
l&apos;équipe Karbé pour organiser votre séjour.
</p>
</aside>
</div>
</main>
);
}

View file

@ -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 (
<article className="group flex flex-col overflow-hidden rounded-lg border border-zinc-200 bg-white shadow-sm transition hover:border-emerald-300 hover:shadow-md">
<Link href={href} className="relative block aspect-[4/3] bg-zinc-100">
{carbet.coverUrl ? (
// Use a plain <img> 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
<img
src={carbet.coverUrl}
alt={`Photo de ${carbet.title}`}
loading="lazy"
className="h-full w-full object-cover transition group-hover:scale-[1.02]"
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-zinc-400">
Pas de photo
</div>
)}
</Link>
<div className="flex flex-1 flex-col p-4">
<h3 className="text-lg font-semibold text-zinc-900">
<Link href={href} className="hover:text-emerald-700">
{carbet.title}
</Link>
</h3>
<p className="mt-1 text-sm text-zinc-600">
Fleuve {carbet.river} · {carbet.capacity} voyageur
{carbet.capacity > 1 ? "s" : ""}
</p>
<p className="mt-2 line-clamp-3 text-sm text-zinc-600">
{truncate(carbet.description, 180)}
</p>
<p className="mt-3 text-xs text-zinc-500">
Pirogue {formatPirogueDuration(carbet.pirogueDurationMin)} depuis{" "}
{carbet.embarkPoint}
</p>
</div>
</article>
);
}

View file

@ -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 (
<div className="flex aspect-[16/9] w-full items-center justify-center rounded-lg bg-zinc-100 text-sm text-zinc-400">
Pas encore de média pour ce carbet.
</div>
);
}
const [cover, ...rest] = media;
return (
<div className="space-y-3">
<figure className="overflow-hidden rounded-lg bg-zinc-100">
{cover.type === MediaType.VIDEO ? (
<video
src={cover.url}
controls
playsInline
preload="metadata"
className="aspect-[16/9] w-full bg-black object-contain"
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={cover.url}
alt={`Photo principale de ${title}`}
className="aspect-[16/9] w-full object-cover"
/>
)}
</figure>
{rest.length > 0 ? (
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{rest.map((item) => (
<li
key={item.id}
className="overflow-hidden rounded-md bg-zinc-100"
>
{item.type === MediaType.VIDEO ? (
<video
src={item.url}
preload="metadata"
controls
playsInline
className="aspect-square w-full bg-black object-contain"
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={item.url}
alt={`Média de ${title}`}
loading="lazy"
className="aspect-square w-full object-cover"
/>
)}
</li>
))}
</ul>
) : null}
</div>
);
}

View file

@ -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 (
<form
method="get"
action="/carbets"
className="grid gap-4 rounded-lg border border-zinc-200 bg-white p-5 sm:grid-cols-2 lg:grid-cols-5"
>
<label className="flex flex-col gap-1 text-sm lg:col-span-2">
<span className="font-medium text-zinc-700">Fleuve ou rivière</span>
<input
type="text"
name="river"
defaultValue={filters.river ?? ""}
placeholder="Maroni, Approuague, Oyapock…"
list="known-rivers"
className="rounded-md border border-zinc-300 px-3 py-2 text-zinc-900 focus:border-emerald-500 focus:outline-none"
/>
{rivers.length > 0 ? (
<datalist id="known-rivers">
{rivers.map((river) => (
<option key={river} value={river} />
))}
</datalist>
) : null}
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-zinc-700">Arrivée</span>
<input
type="date"
name="startDate"
defaultValue={toDateInput(filters.startDate)}
className="rounded-md border border-zinc-300 px-3 py-2 text-zinc-900 focus:border-emerald-500 focus:outline-none"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-zinc-700">Départ</span>
<input
type="date"
name="endDate"
defaultValue={toDateInput(filters.endDate)}
className="rounded-md border border-zinc-300 px-3 py-2 text-zinc-900 focus:border-emerald-500 focus:outline-none"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-zinc-700">Voyageurs</span>
<input
type="number"
name="capacity"
min={1}
max={100}
defaultValue={filters.capacity ?? ""}
placeholder="Nombre min."
className="rounded-md border border-zinc-300 px-3 py-2 text-zinc-900 focus:border-emerald-500 focus:outline-none"
/>
</label>
<div className="flex items-end gap-2 sm:col-span-2 lg:col-span-5 lg:justify-end">
<a
href="/carbets"
className="rounded-md border border-zinc-300 px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-50"
>
Réinitialiser
</a>
<button
type="submit"
className="rounded-md bg-emerald-600 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-700"
>
Rechercher
</button>
</div>
</form>
);
}

87
src/app/carbets/page.tsx Normal file
View file

@ -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<RawSearchParams>;
}) {
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 (
<main className="mx-auto w-full max-w-6xl flex-1 px-6 py-10">
<header className="mb-6">
<h1 className="text-3xl font-semibold text-zinc-900 sm:text-4xl">
Carbets fluviaux de Guyane
</h1>
<p className="mt-2 max-w-2xl text-base text-zinc-600">
Sélectionnez votre fleuve, vos dates et le nombre de voyageurs pour
découvrir les carbets disponibles, depuis le Maroni jusqu&apos;à
l&apos;Oyapock.
</p>
</header>
<SearchFilters filters={filters} rivers={rivers} />
<section className="mt-8" aria-live="polite">
<h2 className="sr-only">Résultats de la recherche</h2>
{results.length === 0 ? (
<p className="rounded-md border border-dashed border-zinc-300 px-6 py-12 text-center text-sm text-zinc-500">
{hasActiveFilters
? "Aucun carbet ne correspond à votre recherche. Essayez d'élargir les filtres."
: "Aucun carbet publié pour le moment. Revenez bientôt !"}
</p>
) : (
<>
<p className="mb-4 text-sm text-zinc-600">
{results.length} carbet{results.length > 1 ? "s" : ""} trouvé
{results.length > 1 ? "s" : ""}.
</p>
<ul className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{results.map((carbet) => (
<li key={carbet.id}>
<CarbetCard carbet={carbet} />
</li>
))}
</ul>
</>
)}
</section>
</main>
);
}

View file

@ -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({

View file

@ -1,3 +1,5 @@
import Link from "next/link";
export default function Home() {
return (
<div className="flex flex-1 items-center justify-center bg-zinc-50 px-6 dark:bg-black">
@ -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.
</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<Link
href="/carbets"
className="rounded-md bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-emerald-700"
>
Découvrir les carbets
</Link>
<Link
href="/espace-hote"
className="rounded-md border border-zinc-300 px-5 py-2.5 text-sm font-medium text-zinc-700 hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-900"
>
Espace hôte
</Link>
</div>
</main>
</div>
);

16
src/app/robots.ts Normal file
View file

@ -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`,
};
}

38
src/app/sitemap.ts Normal file
View file

@ -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<MetadataRoute.Sitemap> {
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];
}

85
src/lib/carbet-public.ts Normal file
View file

@ -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<PublicCarbetDetail | null> => {
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")),
};
},
);

158
src/lib/carbet-search.ts Normal file
View file

@ -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<CarbetSearchResult[]> {
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<string[]> {
const rows = await prisma.carbet.findMany({
where: { status: CarbetStatus.PUBLISHED },
distinct: ["river"],
orderBy: { river: "asc" },
select: { river: true },
});
return rows.map((row) => row.river);
}

27
src/lib/format.ts Normal file
View file

@ -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);
}