feat: landing
This commit is contained in:
parent
1ba875b667
commit
a85507f7dc
|
|
@ -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
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KubernetesApiProvider">{}</component>
|
||||
</project>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
|
|
@ -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 à l’application 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
202
src/app/page.tsx
202
src/app/page.tsx
|
|
@ -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.
|
||||
</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"
|
||||
>
|
||||
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"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</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="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="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"
|
||||
>
|
||||
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"
|
||||
>
|
||||
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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
export const dynamic = "force-dynamic";
|
||||
|
||||
export { default, generateMetadata } from "../privacy/page";
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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`,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
|
@ -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'é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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export const dynamic = "force-dynamic";
|
||||
|
||||
export { default, generateMetadata } from "../terms/page";
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export const dynamic = "force-dynamic";
|
||||
|
||||
export { default, generateMetadata } from "../terms/page";
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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 n’est pas publié ou l’API 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 d’apparaî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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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;
|
||||
Loading…
Reference in New Issue