feat: landing

This commit is contained in:
Leon Morival 2026-05-24 21:29:44 +02:00
parent 1ba875b667
commit a85507f7dc
24 changed files with 919 additions and 68 deletions

10
.idea/.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

4
.idea/misc.xml Normal file
View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KubernetesApiProvider">{}</component>
</project>

BIN
public/bowli-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -0,0 +1,64 @@
import type { Metadata } from "next";
import { SimplePageShell } from "@/components/simple-page-shell";
import { siteConfig } from "@/content/site";
export const metadata: Metadata = {
title: "Suppression de compte",
description: `Demander la suppression d'un compte ${siteConfig.name}.`,
alternates: {
canonical: "/delete-account",
},
};
export default function DeleteAccountPage() {
return (
<SimplePageShell
description="Cette page permet de lancer une demande de suppression de compte même sans accès à lapplication mobile."
eyebrow="Données utilisateur"
title="Supprimer un compte Bowli"
>
<div className="space-y-7 text-base leading-8 text-[var(--body)]">
<section>
<h2 className="text-lg font-semibold text-[var(--ink)]">
{"Depuis l'application"}
</h2>
<p className="mt-2">
Ouvre Bowli, va dans Profil, Réglages, puis choisis Supprimer mon
compte. Cette action supprime le compte et les données associées
dans les bases principales.
</p>
</section>
<section>
<h2 className="text-lg font-semibold text-[var(--ink)]">
Depuis le web
</h2>
<p className="mt-2">
{
"Si tu n'as plus accès à l'application, envoie une demande depuis l'adresse email liée au compte. L'équipe vérifiera la demande avant suppression."
}
</p>
<a
className="mt-4 inline-flex min-h-12 items-center justify-center rounded-md bg-[var(--brand)] px-5 text-sm font-semibold text-white transition-colors hover:bg-[var(--brand-ink)]"
href={`mailto:${siteConfig.supportEmail}?subject=Suppression%20de%20compte%20Bowli&body=Bonjour%2C%0A%0AJe%20souhaite%20supprimer%20mon%20compte%20Bowli.%0AEmail%20du%20compte%20%3A%20%0A%0AMerci.`}
>
Demander la suppression
</a>
</section>
<section className="rounded-lg border border-[var(--border)] bg-[var(--background)] p-5">
<h2 className="text-lg font-semibold text-[var(--ink)]">
Données concernées
</h2>
<p className="mt-2">
{
"La suppression concerne le compte, les repas, les photos, les préférences, les tokens d'accès, les notifications et les données de suivi associées. Certaines informations peuvent être conservées temporairement si la loi, la sécurité ou la prévention des abus l'exige."
}
</p>
</section>
</div>
</SimplePageShell>
);
}

View File

@ -1,8 +1,24 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
--background: #f7faf5;
--surface: #ffffff;
--foreground: #172016;
--ink: #172016;
--body: #2d3a2f;
--muted: #5f6f64;
--soft-text: #7a887d;
--border: #dce7dc;
--border-strong: #b8c8ba;
--brand: #007f3a;
--brand-ink: #0a4525;
--brand-soft: #eaf6e8;
--orange: #ff6b35;
--orange-ink: #833313;
--orange-soft: #fff0e8;
--blue: #3b82f6;
--blue-ink: #1e4f9f;
--blue-soft: #eaf2ff;
}
@theme inline {
@ -12,15 +28,17 @@
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
* {
box-sizing: border-box;
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
}
a {
color: inherit;
text-decoration: none;
}

View File

@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { siteConfig } from "@/content/site";
import "./globals.css";
const geistSans = Geist({
@ -13,8 +14,25 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
metadataBase: new URL(siteConfig.siteUrl),
title: {
default: `${siteConfig.name} - Suivi repas et sport`,
template: `%s | ${siteConfig.name}`,
},
description: siteConfig.description,
applicationName: siteConfig.name,
icons: {
icon: "/favicon.png",
apple: "/bowli-icon.png",
},
openGraph: {
title: siteConfig.name,
description: siteConfig.description,
siteName: siteConfig.name,
images: ["/bowli-icon.png"],
locale: "fr_FR",
type: "website",
},
};
export default function RootLayout({
@ -24,7 +42,7 @@ export default function RootLayout({
}>) {
return (
<html
lang="en"
lang="fr"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>

View File

@ -0,0 +1,57 @@
import type { Metadata } from "next";
import { LegalDocumentUnavailable } from "@/components/legal-document-unavailable";
import { LegalDocumentView } from "@/components/legal-document-view";
import {
fetchLegalDocument,
getLegalDocumentDescription,
getLocalizedTitle,
} from "@/content/legal-documents";
import { siteConfig } from "@/content/site";
type LegalPageProps = {
params: Promise<{ slug: string }>;
};
export const dynamic = "force-dynamic";
export async function generateMetadata({
params,
}: LegalPageProps): Promise<Metadata> {
const { slug } = await params;
const document = await fetchLegalDocument(slug);
if (!document) {
return {
title: "Document légal",
};
}
const title = getLocalizedTitle(document);
const description = getLegalDocumentDescription(document);
return {
title,
description,
alternates: {
canonical: `/legal/${document.slug}`,
},
openGraph: {
title: `${title} - ${siteConfig.name}`,
description,
type: "article",
publishedTime: document.publishedAt ?? undefined,
},
};
}
export default async function LegalPage({ params }: LegalPageProps) {
const { slug } = await params;
const document = await fetchLegalDocument(slug);
if (!document) {
return <LegalDocumentUnavailable title="Document légal" />;
}
return <LegalDocumentView document={document} />;
}

View File

@ -1,65 +1,153 @@
import Image from "next/image";
import Link from "next/link";
import { SiteFooter } from "@/components/site-footer";
import { SiteHeader } from "@/components/site-header";
import { siteConfig } from "@/content/site";
const features = [
{
title: "Journal repas",
body: "Ajoute tes repas, photos, calories et macros dans un historique lisible.",
accent: "bg-[var(--brand-soft)] text-[var(--brand-ink)]",
},
{
title: "Objectifs nutritionnels",
body: "Suis tes habitudes avec des indicateurs simples pour garder le cap.",
accent: "bg-[var(--blue-soft)] text-[var(--blue-ink)]",
},
{
title: "Sport connecté",
body: "Importe tes activités Strava pour rapprocher nutrition et entraînement.",
accent: "bg-[var(--orange-soft)] text-[var(--orange-ink)]",
},
] as const;
const previewMeals = [
{ label: "Petit-déj", kcal: "420 kcal", color: "bg-[var(--orange)]" },
{ label: "Déjeuner", kcal: "680 kcal", color: "bg-[var(--brand)]" },
{ label: "Séance", kcal: "Strava", color: "bg-[var(--blue)]" },
] as const;
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
<div className="flex min-h-screen flex-col bg-[var(--background)]">
<SiteHeader />
<main className="flex-1">
<section className="mx-auto grid w-full max-w-6xl gap-10 px-5 py-12 sm:px-6 sm:py-16 lg:grid-cols-[1fr_420px] lg:items-center">
<div>
<p className="text-sm font-semibold uppercase tracking-normal text-[var(--brand)]">
{siteConfig.formerName} devient {siteConfig.name}
</p>
<h1 className="mt-4 max-w-3xl text-5xl font-semibold leading-none text-[var(--ink)] sm:text-6xl">
{siteConfig.name}
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
<p className="mt-5 max-w-2xl text-lg leading-8 text-[var(--muted)]">
{siteConfig.tagline} Suis tes repas, tes objectifs et tes
activités sans transformer ton journal alimentaire en tableau de
bord compliqué.
</p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<Link
className="inline-flex min-h-12 items-center justify-center rounded-md bg-[var(--brand)] px-5 text-sm font-semibold text-white transition-colors hover:bg-[var(--brand-ink)]"
href="/privacy"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
Voir la confidentialité
</Link>
<Link
className="inline-flex min-h-12 items-center justify-center rounded-md border border-[var(--border-strong)] px-5 text-sm font-semibold text-[var(--ink)] transition-colors hover:border-[var(--ink)]"
href="/support"
>
Learning
</a>{" "}
center.
Contacter le support
</Link>
</div>
</div>
<div className="relative mx-auto w-full max-w-sm">
<div className="rounded-lg border border-[var(--border)] bg-[var(--surface)] p-5 shadow-[0_24px_80px_rgba(24,45,25,0.12)]">
<div className="flex items-center gap-4 border-b border-[var(--border)] pb-5">
<Image
alt="Icône Bowli"
className="h-16 w-16 rounded-lg"
height={64}
priority
src="/bowli-icon.png"
width={64}
/>
<div>
<p className="text-sm text-[var(--muted)]">
{"Aujourd'hui"}
</p>
<p className="text-xl font-semibold text-[var(--ink)]">
1 850 kcal
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
<div className="mt-5 space-y-3">
{previewMeals.map((item) => (
<div
className="flex items-center justify-between rounded-lg border border-[var(--border)] bg-[var(--background)] px-4 py-3"
key={item.label}
>
<div className="flex items-center gap-3">
<span
aria-hidden="true"
className={`h-3 w-3 rounded-full ${item.color}`}
/>
<span className="font-medium text-[var(--ink)]">
{item.label}
</span>
</div>
<span className="text-sm font-semibold text-[var(--muted)]">
{item.kcal}
</span>
</div>
))}
</div>
</div>
</div>
</section>
<section
className="border-y border-[var(--border)] bg-[var(--surface)]"
id="features"
>
<div className="mx-auto w-full max-w-6xl px-5 py-12 sm:px-6">
<div className="max-w-2xl">
<p className="text-sm font-semibold uppercase tracking-normal text-[var(--brand)]">
Application mobile
</p>
<h2 className="mt-3 text-3xl font-semibold text-[var(--ink)]">
Le nécessaire pour suivre sans friction.
</h2>
</div>
<div className="mt-8 grid gap-4 md:grid-cols-3">
{features.map((feature) => (
<article
className="rounded-lg border border-[var(--border)] bg-[var(--background)] p-5"
key={feature.title}
>
<span
className={`inline-flex rounded-md px-3 py-1 text-xs font-semibold ${feature.accent}`}
>
{feature.title}
</span>
<p className="mt-4 text-sm leading-6 text-[var(--muted)]">
{feature.body}
</p>
</article>
))}
</div>
</div>
</section>
</main>
<SiteFooter />
</div>
);
}

View File

@ -0,0 +1,3 @@
export const dynamic = "force-dynamic";
export { default, generateMetadata } from "../privacy/page";

36
src/app/privacy/page.tsx Normal file
View File

@ -0,0 +1,36 @@
import type { Metadata } from "next";
import { LegalDocumentUnavailable } from "@/components/legal-document-unavailable";
import { LegalDocumentView } from "@/components/legal-document-view";
import {
fetchLegalDocument,
getLegalDocumentDescription,
getLocalizedTitle,
legalDocumentSlugs,
} from "@/content/legal-documents";
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
const document = await fetchLegalDocument(legalDocumentSlugs.privacy);
return {
title: document ? getLocalizedTitle(document) : "Politique de confidentialité",
description: document
? getLegalDocumentDescription(document)
: "Politique de confidentialité Bowli.",
alternates: {
canonical: "/privacy",
},
};
}
export default async function PrivacyPage() {
const document = await fetchLegalDocument(legalDocumentSlugs.privacy);
if (!document) {
return <LegalDocumentUnavailable title="Politique de confidentialité" />;
}
return <LegalDocumentView document={document} />;
}

13
src/app/robots.ts Normal file
View File

@ -0,0 +1,13 @@
import type { MetadataRoute } from "next";
import { siteConfig } from "@/content/site";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
},
sitemap: `${siteConfig.siteUrl}/sitemap.xml`,
};
}

18
src/app/sitemap.ts Normal file
View File

@ -0,0 +1,18 @@
import type { MetadataRoute } from "next";
import { siteConfig, storeLinks } from "@/content/site";
export default function sitemap(): MetadataRoute.Sitemap {
const updatedAt = new Date(siteConfig.legalPublishedAt);
return [
{
url: siteConfig.siteUrl,
lastModified: updatedAt,
},
...storeLinks.map((link) => ({
url: `${siteConfig.siteUrl}${link.href}`,
lastModified: updatedAt,
})),
];
}

56
src/app/support/page.tsx Normal file
View File

@ -0,0 +1,56 @@
import type { Metadata } from "next";
import { SimplePageShell } from "@/components/simple-page-shell";
import { siteConfig } from "@/content/site";
export const metadata: Metadata = {
title: "Support",
description: `Contacter le support ${siteConfig.name}.`,
alternates: {
canonical: "/support",
},
};
export default function SupportPage() {
return (
<SimplePageShell
description="Utilise cette page comme support URL dans App Store Connect et Google Play. Elle donne un point de contact clair pour les utilisateurs."
eyebrow="Support"
title={`Support ${siteConfig.name}`}
>
<div className="space-y-6 text-base leading-8 text-[var(--body)]">
<p>
Pour une question sur ton compte, un bug, une demande liée aux données
personnelles ou un problème de connexion, contacte l&apos;équipe Bowli par
email.
</p>
<a
className="inline-flex min-h-12 items-center justify-center rounded-md bg-[var(--brand)] px-5 text-sm font-semibold text-white transition-colors hover:bg-[var(--brand-ink)]"
href={`mailto:${siteConfig.supportEmail}?subject=Support%20Bowli`}
>
{siteConfig.supportEmail}
</a>
<div className="rounded-lg border border-[var(--border)] bg-[var(--background)] p-5">
<h2 className="text-lg font-semibold text-[var(--ink)]">
Informations utiles
</h2>
<ul className="mt-3 list-disc space-y-2 pl-5 text-sm leading-6 text-[var(--muted)]">
<li>
{
"Indique l'adresse email liée à ton compte si la demande concerne ton profil."
}
</li>
<li>
{
"Ajoute le modèle de téléphone et la version de l'application si la demande concerne un bug."
}
</li>
<li>Pour les demandes de suppression, utilise aussi la page dédiée.</li>
</ul>
</div>
</div>
</SimplePageShell>
);
}

View File

@ -0,0 +1,3 @@
export const dynamic = "force-dynamic";
export { default, generateMetadata } from "../terms/page";

View File

@ -0,0 +1,3 @@
export const dynamic = "force-dynamic";
export { default, generateMetadata } from "../terms/page";

36
src/app/terms/page.tsx Normal file
View File

@ -0,0 +1,36 @@
import type { Metadata } from "next";
import { LegalDocumentUnavailable } from "@/components/legal-document-unavailable";
import { LegalDocumentView } from "@/components/legal-document-view";
import {
fetchLegalDocument,
getLegalDocumentDescription,
getLocalizedTitle,
legalDocumentSlugs,
} from "@/content/legal-documents";
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
const document = await fetchLegalDocument(legalDocumentSlugs.terms);
return {
title: document ? getLocalizedTitle(document) : "Conditions d'utilisation",
description: document
? getLegalDocumentDescription(document)
: "Conditions d'utilisation Bowli.",
alternates: {
canonical: "/terms",
},
};
}
export default async function TermsPage() {
const document = await fetchLegalDocument(legalDocumentSlugs.terms);
if (!document) {
return <LegalDocumentUnavailable title="Conditions d'utilisation" />;
}
return <LegalDocumentView document={document} />;
}

View File

@ -0,0 +1,34 @@
import { SimplePageShell } from "@/components/simple-page-shell";
import { siteConfig } from "@/content/site";
type LegalDocumentUnavailableProps = {
title: string;
};
export function LegalDocumentUnavailable({
title,
}: LegalDocumentUnavailableProps) {
return (
<SimplePageShell
description="Le document nest pas publié ou lAPI est momentanément indisponible."
eyebrow="Document légal"
title={title}
>
<div className="space-y-4 text-base leading-8 text-[var(--body)]">
<p>
Le contenu légal demandé doit être publié depuis le dashboard Bowli
avant dapparaître sur cette page.
</p>
<p>
Contact :{" "}
<a
className="font-medium text-[var(--brand)] hover:text-[var(--brand-ink)]"
href={`mailto:${siteConfig.supportEmail}`}
>
{siteConfig.supportEmail}
</a>
</p>
</div>
</SimplePageShell>
);
}

View File

@ -0,0 +1,90 @@
import Link from "next/link";
import {
getLegalDocumentDescription,
getLocalizedContent,
getLocalizedTitle,
getPublishedAtLabel,
type LegalDocument,
type LegalLocale,
} from "@/content/legal-documents";
import { siteConfig } from "@/content/site";
type LegalDocumentViewProps = {
document: LegalDocument;
locale?: LegalLocale;
};
export function LegalDocumentView({
document,
locale = "fr",
}: LegalDocumentViewProps) {
const title = getLocalizedTitle(document, locale);
const description = getLegalDocumentDescription(document);
const content = getLocalizedContent(document, locale);
return (
<div className="flex min-h-screen flex-col bg-[var(--background)]">
<main className="mx-auto w-full max-w-4xl flex-1 px-5 py-10 sm:px-6 sm:py-14">
<Link
className="text-sm font-medium text-[var(--brand)] transition-colors hover:text-[var(--brand-ink)]"
href="/"
>
{"Retour à l'accueil"}
</Link>
<article className="mt-8 overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--surface)]">
<header className="border-b border-[var(--border)] px-5 py-8 sm:px-8">
<p className="text-sm font-semibold uppercase tracking-normal text-[var(--brand)]">
Version {document.version}
</p>
<h1 className="mt-3 text-3xl font-semibold leading-tight text-[var(--ink)] sm:text-4xl">
{title}
</h1>
<p className="mt-4 text-base leading-7 text-[var(--muted)]">
{description}
</p>
<p className="mt-5 text-sm text-[var(--soft-text)]">
Publié le {getPublishedAtLabel(document)}
</p>
</header>
<div className="px-5 py-8 sm:px-8">
<div className="space-y-4 text-base leading-8 text-[var(--body)]">
{content.intro.map((paragraph) => (
<p key={paragraph}>{paragraph}</p>
))}
</div>
<div className="mt-10 divide-y divide-[var(--border)]">
{content.sections.map((section) => (
<section className="py-7 first:pt-0" key={section.title}>
<h2 className="text-xl font-semibold text-[var(--ink)]">
{section.title}
</h2>
<div className="mt-3 space-y-3 text-base leading-8 text-[var(--body)]">
{section.body.map((paragraph) => (
<p key={paragraph}>{paragraph}</p>
))}
</div>
</section>
))}
</div>
</div>
</article>
<div className="mt-8 rounded-lg border border-[var(--border)] bg-[var(--surface)] px-5 py-5 text-sm leading-6 text-[var(--muted)] sm:px-6">
<p>
Contact :{" "}
<a
className="font-medium text-[var(--brand)] hover:text-[var(--brand-ink)]"
href={`mailto:${siteConfig.supportEmail}`}
>
{siteConfig.supportEmail}
</a>
</p>
</div>
</main>
</div>
);
}

View File

@ -0,0 +1,43 @@
import Link from "next/link";
import type { ReactNode } from "react";
type SimplePageShellProps = {
eyebrow: string;
title: string;
description: string;
children: ReactNode;
};
export function SimplePageShell({
eyebrow,
title,
description,
children,
}: SimplePageShellProps) {
return (
<div className="flex min-h-screen flex-col bg-[var(--background)]">
<main className="mx-auto w-full max-w-4xl flex-1 px-5 py-10 sm:px-6 sm:py-14">
<Link
className="text-sm font-medium text-[var(--brand)] transition-colors hover:text-[var(--brand-ink)]"
href="/"
>
{"Retour à l'accueil"}
</Link>
<section className="mt-8 rounded-lg border border-[var(--border)] bg-[var(--surface)] px-5 py-8 sm:px-8">
<p className="text-sm font-semibold uppercase tracking-normal text-[var(--brand)]">
{eyebrow}
</p>
<h1 className="mt-3 text-3xl font-semibold leading-tight text-[var(--ink)] sm:text-4xl">
{title}
</h1>
<p className="mt-4 text-base leading-7 text-[var(--muted)]">
{description}
</p>
<div className="mt-8">{children}</div>
</section>
</main>
</div>
);
}

View File

@ -0,0 +1,31 @@
import Link from "next/link";
import { siteConfig, storeLinks } from "@/content/site";
export function SiteFooter() {
return (
<footer className="border-t border-[var(--border)] bg-[var(--surface)]">
<div className="mx-auto grid w-full max-w-6xl gap-8 px-5 py-10 text-sm text-[var(--muted)] sm:px-6 md:grid-cols-[1fr_2fr]">
<div>
<p className="font-semibold text-[var(--ink)]">{siteConfig.name}</p>
<p className="mt-2 max-w-sm">{siteConfig.description}</p>
</div>
<nav
aria-label="Liens publics"
className="grid gap-3 sm:grid-cols-2 md:justify-self-end"
>
{storeLinks.map((link) => (
<Link
className="transition-colors hover:text-[var(--ink)]"
href={link.href}
key={link.href}
>
{link.label}
</Link>
))}
</nav>
</div>
</footer>
);
}

View File

@ -0,0 +1,51 @@
import Image from "next/image";
import Link from "next/link";
import { siteConfig } from "@/content/site";
const navItems = [
{ href: "/#features", label: "Fonctionnalités" },
{ href: "/privacy", label: "Confidentialité" },
{ href: "/support", label: "Support" },
] as const;
export function SiteHeader() {
return (
<header className="border-b border-[var(--border)] bg-[var(--surface)]/95">
<div className="mx-auto flex min-h-16 w-full max-w-6xl items-center justify-between gap-4 px-5 sm:px-6">
<Link
aria-label="Accueil Bowli"
className="flex items-center gap-3"
href="/"
>
<Image
alt=""
className="h-9 w-9 rounded-lg"
height={36}
priority
src="/bowli-icon.png"
width={36}
/>
<span className="text-lg font-semibold text-[var(--ink)]">
{siteConfig.name}
</span>
</Link>
<nav
aria-label="Navigation principale"
className="hidden items-center gap-6 text-sm font-medium text-[var(--muted)] sm:flex"
>
{navItems.map((item) => (
<Link
className="transition-colors hover:text-[var(--ink)]"
href={item.href}
key={item.href}
>
{item.label}
</Link>
))}
</nav>
</div>
</header>
);
}

View File

@ -0,0 +1,140 @@
export type LegalLocale = "fr";
export type LegalDocumentType = "privacy_policy" | "terms";
export type LegalSection = {
title: string;
body: string[];
};
export type LegalContent = Record<
LegalLocale,
{
intro: string[];
sections: LegalSection[];
}
>;
export type LegalDocument = {
id: number;
slug: string;
type: LegalDocumentType;
typeLabel: string;
version: number;
title: Record<LegalLocale, string>;
content: LegalContent;
isActive: boolean;
publishedAt: string | null;
createdAt: string;
updatedAt: string;
};
type LegalDocumentApiResponse = {
data: LegalDocument;
};
const defaultApiUrl = "http://localhost:8003/api";
export const legalDocumentSlugs = {
privacy: "privacy-policy",
terms: "terms-of-service",
} as const;
const legalDocumentSlugAliases: Record<string, string> = {
privacy: legalDocumentSlugs.privacy,
"privacy-policy": legalDocumentSlugs.privacy,
confidentialite: legalDocumentSlugs.privacy,
"politique-de-confidentialite": legalDocumentSlugs.privacy,
terms: legalDocumentSlugs.terms,
"terms-of-service": legalDocumentSlugs.terms,
"terms-and-conditions": legalDocumentSlugs.terms,
cgu: legalDocumentSlugs.terms,
};
export function getLegalDocumentsApiUrl() {
return (
process.env.LEGAL_DOCUMENTS_API_URL ??
process.env.NEXT_PUBLIC_API_URL ??
defaultApiUrl
).replace(/\/+$/, "");
}
export function resolveLegalDocumentSlug(slug: string) {
return legalDocumentSlugAliases[slug] ?? slug;
}
export async function fetchLegalDocument(
slug: string
): Promise<LegalDocument | null> {
const resolvedSlug = resolveLegalDocumentSlug(slug);
try {
const response = await fetch(
`${getLegalDocumentsApiUrl()}/legal-documents/${encodeURIComponent(resolvedSlug)}`,
{
headers: {
Accept: "application/json",
},
next: {
revalidate: 60,
},
}
);
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`Legal document request failed: ${response.status}`);
}
const payload = (await response.json()) as LegalDocumentApiResponse;
return payload.data;
} catch (error) {
console.error(error);
return null;
}
}
export function getLocalizedTitle(
document: LegalDocument,
locale: LegalLocale = "fr"
) {
return (
document.title[locale] ??
Object.values(document.title)[0] ??
document.slug
);
}
export function getLocalizedContent(
document: LegalDocument,
locale: LegalLocale = "fr"
) {
return (
document.content[locale] ??
Object.values(document.content)[0] ?? {
intro: [],
sections: [],
}
);
}
export function getLegalDocumentDescription(document: LegalDocument) {
const content = getLocalizedContent(document);
return content.intro[0] ?? getLocalizedTitle(document);
}
export function getPublishedAtLabel(document: LegalDocument) {
if (!document.publishedAt) {
return "Non publié";
}
return new Intl.DateTimeFormat("fr-FR", {
dateStyle: "long",
}).format(new Date(document.publishedAt));
}

35
src/content/site.ts Normal file
View File

@ -0,0 +1,35 @@
export const siteConfig = {
name: "Bowli",
formerName: "Daily Meal",
appIdentifier: "com.meal.daily",
tagline: "Le carnet repas et sport qui reste simple.",
description:
"Bowli aide à suivre ses repas, ses objectifs nutritionnels et ses activités sportives depuis une application mobile.",
supportEmail: process.env.NEXT_PUBLIC_SUPPORT_EMAIL ?? "support@bowli.app",
siteUrl: process.env.NEXT_PUBLIC_SITE_URL ?? "https://bowli.app",
legalPublishedAt: "2026-05-24T00:00:00+02:00",
legalUpdatedLabel: "24 mai 2026",
} as const;
export const storeLinks = [
{
href: "/privacy",
label: "Politique de confidentialité",
description: "URL publique à renseigner dans App Store Connect et Play Console.",
},
{
href: "/terms",
label: "Conditions d'utilisation",
description: "Conditions applicables à la création et à l'utilisation d'un compte Bowli.",
},
{
href: "/support",
label: "Support",
description: "Point de contact public pour les fiches Apple et Google.",
},
{
href: "/delete-account",
label: "Suppression de compte",
description: "URL web dédiée à la demande de suppression de compte Google Play.",
},
] as const;