diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -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 diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..90dee70 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + {} + \ No newline at end of file diff --git a/public/bowli-icon.png b/public/bowli-icon.png new file mode 100644 index 0000000..618aa05 Binary files /dev/null and b/public/bowli-icon.png differ diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..f1b6fb5 Binary files /dev/null and b/public/favicon.png differ diff --git a/src/app/delete-account/page.tsx b/src/app/delete-account/page.tsx new file mode 100644 index 0000000..9279cb5 --- /dev/null +++ b/src/app/delete-account/page.tsx @@ -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 ( + +
+
+

+ {"Depuis l'application"} +

+

+ 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. +

+
+ +
+

+ Depuis le web +

+

+ { + "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." + } +

+ + + Demander la suppression + +
+ +
+

+ Données concernées +

+

+ { + "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." + } +

+
+
+
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index a2dc41e..a77f880 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 976eb90..5ac3fe9 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 ( {children} diff --git a/src/app/legal/[slug]/page.tsx b/src/app/legal/[slug]/page.tsx new file mode 100644 index 0000000..fdb569e --- /dev/null +++ b/src/app/legal/[slug]/page.tsx @@ -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 { + 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 ; + } + + return ; +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 3f36f7c..00cc9f7 100644 --- a/src/app/page.tsx +++ b/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 ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
- +
+ + +
+
+
+

+ {siteConfig.formerName} devient {siteConfig.name} +

+

+ {siteConfig.name} +

+

+ {siteConfig.tagline} Suis tes repas, tes objectifs et tes + activités sans transformer ton journal alimentaire en tableau de + bord compliqué. +

+ +
+ + Voir la confidentialité + + + Contacter le support + +
+
+ +
+
+
+ Icône Bowli +
+

+ {"Aujourd'hui"} +

+

+ 1 850 kcal +

+
+
+ +
+ {previewMeals.map((item) => ( +
+
+
+ + {item.kcal} + +
+ ))} +
+
+
+
+ +
+
+
+

+ Application mobile +

+

+ Le nécessaire pour suivre sans friction. +

+
+ +
+ {features.map((feature) => ( +
+ + {feature.title} + +

+ {feature.body} +

+
+ ))} +
+
+
+
+ +
); } diff --git a/src/app/privacy-policy/page.tsx b/src/app/privacy-policy/page.tsx new file mode 100644 index 0000000..23f01f2 --- /dev/null +++ b/src/app/privacy-policy/page.tsx @@ -0,0 +1,3 @@ +export const dynamic = "force-dynamic"; + +export { default, generateMetadata } from "../privacy/page"; diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx new file mode 100644 index 0000000..3106767 --- /dev/null +++ b/src/app/privacy/page.tsx @@ -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 { + 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 ; + } + + return ; +} diff --git a/src/app/robots.ts b/src/app/robots.ts new file mode 100644 index 0000000..3c6be69 --- /dev/null +++ b/src/app/robots.ts @@ -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`, + }; +} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts new file mode 100644 index 0000000..6f493f1 --- /dev/null +++ b/src/app/sitemap.ts @@ -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, + })), + ]; +} diff --git a/src/app/support/page.tsx b/src/app/support/page.tsx new file mode 100644 index 0000000..7993571 --- /dev/null +++ b/src/app/support/page.tsx @@ -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 ( + +
+

+ 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. +

+ + + {siteConfig.supportEmail} + + +
+

+ Informations utiles +

+
    +
  • + { + "Indique l'adresse email liée à ton compte si la demande concerne ton profil." + } +
  • +
  • + { + "Ajoute le modèle de téléphone et la version de l'application si la demande concerne un bug." + } +
  • +
  • Pour les demandes de suppression, utilise aussi la page dédiée.
  • +
+
+
+
+ ); +} diff --git a/src/app/terms-and-conditions/page.tsx b/src/app/terms-and-conditions/page.tsx new file mode 100644 index 0000000..e7b07cc --- /dev/null +++ b/src/app/terms-and-conditions/page.tsx @@ -0,0 +1,3 @@ +export const dynamic = "force-dynamic"; + +export { default, generateMetadata } from "../terms/page"; diff --git a/src/app/terms-of-service/page.tsx b/src/app/terms-of-service/page.tsx new file mode 100644 index 0000000..e7b07cc --- /dev/null +++ b/src/app/terms-of-service/page.tsx @@ -0,0 +1,3 @@ +export const dynamic = "force-dynamic"; + +export { default, generateMetadata } from "../terms/page"; diff --git a/src/app/terms/page.tsx b/src/app/terms/page.tsx new file mode 100644 index 0000000..566ae00 --- /dev/null +++ b/src/app/terms/page.tsx @@ -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 { + 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 ; + } + + return ; +} diff --git a/src/components/legal-document-unavailable.tsx b/src/components/legal-document-unavailable.tsx new file mode 100644 index 0000000..9f47540 --- /dev/null +++ b/src/components/legal-document-unavailable.tsx @@ -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 ( + +
+

+ Le contenu légal demandé doit être publié depuis le dashboard Bowli + avant d’apparaître sur cette page. +

+

+ Contact :{" "} + + {siteConfig.supportEmail} + +

+
+
+ ); +} diff --git a/src/components/legal-document-view.tsx b/src/components/legal-document-view.tsx new file mode 100644 index 0000000..e0c3b58 --- /dev/null +++ b/src/components/legal-document-view.tsx @@ -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 ( +
+
+ + {"Retour à l'accueil"} + + +
+
+

+ Version {document.version} +

+

+ {title} +

+

+ {description} +

+

+ Publié le {getPublishedAtLabel(document)} +

+
+ +
+
+ {content.intro.map((paragraph) => ( +

{paragraph}

+ ))} +
+ +
+ {content.sections.map((section) => ( +
+

+ {section.title} +

+
+ {section.body.map((paragraph) => ( +

{paragraph}

+ ))} +
+
+ ))} +
+
+
+ +
+

+ Contact :{" "} + + {siteConfig.supportEmail} + +

+
+
+
+ ); +} diff --git a/src/components/simple-page-shell.tsx b/src/components/simple-page-shell.tsx new file mode 100644 index 0000000..693b0e9 --- /dev/null +++ b/src/components/simple-page-shell.tsx @@ -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 ( +
+
+ + {"Retour à l'accueil"} + + +
+

+ {eyebrow} +

+

+ {title} +

+

+ {description} +

+ +
{children}
+
+
+
+ ); +} diff --git a/src/components/site-footer.tsx b/src/components/site-footer.tsx new file mode 100644 index 0000000..decd1c6 --- /dev/null +++ b/src/components/site-footer.tsx @@ -0,0 +1,31 @@ +import Link from "next/link"; + +import { siteConfig, storeLinks } from "@/content/site"; + +export function SiteFooter() { + return ( +
+
+
+

{siteConfig.name}

+

{siteConfig.description}

+
+ + +
+
+ ); +} diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx new file mode 100644 index 0000000..e46e9b3 --- /dev/null +++ b/src/components/site-header.tsx @@ -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 ( +
+
+ + + + {siteConfig.name} + + + + +
+
+ ); +} diff --git a/src/content/legal-documents.ts b/src/content/legal-documents.ts new file mode 100644 index 0000000..b145f53 --- /dev/null +++ b/src/content/legal-documents.ts @@ -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; + 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 = { + 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 { + 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)); +} diff --git a/src/content/site.ts b/src/content/site.ts new file mode 100644 index 0000000..783bcdf --- /dev/null +++ b/src/content/site.ts @@ -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;