karbe/src/lib/carbet-public.ts
Karbé Architect c2df6722f2 feat(carbets): public search + carbet detail page (SSR/SEO)
Implémente SYS-5 : la marketplace publique pour découvrir les carbets
fluviaux publiés par les hôtes.

- /carbets : page de recherche server-side avec filtres GET
  (fleuve, dates de séjour, capacité min.), grille de résultats
  avec photo de couverture, fleuve, capacité, durée pirogue
- /carbets/[slug] : fiche carbet SSR
  - generateMetadata (title/description + OpenGraph/Twitter cards)
  - galerie médias (photo couverture + vignettes vidéo/photo)
  - description, équipements (catalogue), accès, coords GPS,
    capacité, prénom de l'hôte
- robots.ts + sitemap.xml (incluant les carbets publiés)
- metadataBase / title.template au niveau du root layout, OG par
  défaut Karbé
- Lien "Découvrir les carbets" sur la home
- Helpers partagés : lib/carbet-search.ts (parse filters + query),
  lib/carbet-public.ts (fetch SSR mémoïsé via React cache),
  lib/format.ts (durée pirogue, troncature, coords)
- Nouvelle variable d'env NEXT_PUBLIC_SITE_URL (canonical/OG/sitemap)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-29 22:24:25 +00:00

85 lines
2.3 KiB
TypeScript

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")),
};
},
);