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>
This commit is contained in:
parent
3567eb975b
commit
c2df6722f2
13 changed files with 827 additions and 1 deletions
85
src/lib/carbet-public.ts
Normal file
85
src/lib/carbet-public.ts
Normal 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
158
src/lib/carbet-search.ts
Normal 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
27
src/lib/format.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue