chore(retrait cluster): synchro copie du nœud M3U96a + README « URL live hors fonction » (2026-09-04)
46 changed files +5,101 −0
added
.gitignore
+15 −0
@@ -0,0 +1,15 @@ | |||||
| 1 | +# Author: Simon-Pierre Boucher | ||||
| 2 | +# Contact: contact@spboucher.ai | ||||
| 3 | +# Project: Hilmacorp.ai — Web Platform | ||||
| 4 | +# File: .gitignore | ||||
| 5 | +# Description: Files and directories excluded from version control | ||||
| 6 | + | ||||
| 7 | +/node_modules | ||||
| 8 | +/.next | ||||
| 9 | +/out | ||||
| 10 | +.env | ||||
| 11 | +.env.local | ||||
| 12 | +.DS_Store | ||||
| 13 | +*.tsbuildinfo | ||||
| 14 | +next-env.d.ts | ||||
| 15 | +/data | ||||
added
CLAUDE.md
+124 −0
@@ -0,0 +1,124 @@ | |||||
| 1 | +# CLAUDE.md — Plateforme Web Hilmacorp.ai | ||||
| 2 | + | ||||
| 3 | +## Objectif du projet | ||||
| 4 | + | ||||
| 5 | +Construire le site web officiel de **Hilmacorp.ai**, une entreprise d'intelligence artificielle fondée par **Simon-Pierre Boucher** et **Massamba Mbaye**. Le site doit être moderne, professionnel, rapide et bilingue (français / anglais), et présenter l'entreprise, ses services, ses fondateurs et sa vision. | ||||
| 6 | + | ||||
| 7 | +--- | ||||
| 8 | + | ||||
| 9 | +## ⚠️ RÈGLE OBLIGATOIRE — En-tête de chaque fichier | ||||
| 10 | + | ||||
| 11 | +**Chaque fichier créé dans ce projet (code, config, style, composant, script, etc.) DOIT commencer par l'en-tête suivant**, adapté à la syntaxe de commentaire du langage : | ||||
| 12 | + | ||||
| 13 | +``` | ||||
| 14 | +/** | ||||
| 15 | + * Author: Simon-Pierre Boucher | ||||
| 16 | + * Contact: contact@spboucher.ai | ||||
| 17 | + * Project: Hilmacorp.ai — Web Platform | ||||
| 18 | + * File: <chemin/nom-du-fichier> | ||||
| 19 | + * Description: <courte description du rôle du fichier> | ||||
| 20 | + */ | ||||
| 21 | +``` | ||||
| 22 | + | ||||
| 23 | +Exemples selon le type de fichier : | ||||
| 24 | + | ||||
| 25 | +- **JS / TS / TSX / CSS** : bloc `/** ... */` | ||||
| 26 | +- **HTML** : `<!-- Author: Simon-Pierre Boucher | Contact: contact@spboucher.ai | ... -->` | ||||
| 27 | +- **Python / Shell / YAML** : lignes commençant par `#` | ||||
| 28 | +- **Markdown** : bloc de commentaire HTML `<!-- ... -->` en tête de fichier | ||||
| 29 | +- **JSON** : exception — pas de commentaire possible; ajouter si pertinent un champ `"_author": "Simon-Pierre Boucher <contact@spboucher.ai>"` quand le schéma le permet (ex. `package.json` → champ `author`). | ||||
| 30 | + | ||||
| 31 | +**Ne jamais créer ou modifier un fichier sans vérifier la présence de cet en-tête.** | ||||
| 32 | + | ||||
| 33 | +--- | ||||
| 34 | + | ||||
| 35 | +## Stack technique recommandé | ||||
| 36 | + | ||||
| 37 | +- **Framework** : Next.js 14+ (App Router) avec TypeScript | ||||
| 38 | +- **Styles** : Tailwind CSS | ||||
| 39 | +- **Composants** : composants React réutilisables, design system léger | ||||
| 40 | +- **i18n** : support français / anglais (next-intl ou équivalent) | ||||
| 41 | +- **Déploiement cible** : Vercel ou infrastructure privée (le code doit rester portable) | ||||
| 42 | +- **SEO** : métadonnées complètes, Open Graph, sitemap, robots.txt | ||||
| 43 | +- **Formulaire de contact** : API route avec validation, envoi vers `contact@spboucher.ai` | ||||
| 44 | + | ||||
| 45 | +--- | ||||
| 46 | + | ||||
| 47 | +## Structure du site (pages) | ||||
| 48 | + | ||||
| 49 | +1. **Accueil (`/`)** | ||||
| 50 | + - Hero : « Hilmacorp.ai builds intelligent systems for organizations that want to work, analyze, decide, and innovate differently. » | ||||
| 51 | + - Présentation courte de l'entreprise | ||||
| 52 | + - Aperçu des services principaux avec liens | ||||
| 53 | + - Appel à l'action (contact / démonstration) | ||||
| 54 | + | ||||
| 55 | +2. **À propos (`/about`)** | ||||
| 56 | + - Mission : rendre l'IA avancée utile, accessible et opérationnelle pour les organisations de toutes tailles | ||||
| 57 | + - Approche (processus itératif en 8 étapes : identifier le problème → évaluer données/infrastructure → concevoir l'architecture → prototype → tests → mesure de la valeur → déploiement contrôlé → amélioration continue) | ||||
| 58 | + - Vision de l'entreprise | ||||
| 59 | + | ||||
| 60 | +3. **Fondateurs (`/founders`)** | ||||
| 61 | + - **Simon-Pierre Boucher** : recherche en IA, grands modèles de langage (LLM), agents IA, analyse de données, modélisation financière, développement logiciel, systèmes IA avancés, modèles open-weight, infrastructure locale, systèmes multimodaux, workflows agentiques. | ||||
| 62 | + - **Massamba Mbaye** : leadership, développement stratégique, opérations d'affaires, partenariats, déploiement commercial de technologies innovantes, lien entre capacités techniques et besoins organisationnels. | ||||
| 63 | + | ||||
| 64 | +4. **Services (`/services`)** — une page index + sous-pages ou sections pour : | ||||
| 65 | + - Agents IA et automatisation intelligente | ||||
| 66 | + - Grands modèles de langage (approche indépendante des fournisseurs, routage multi-modèles) | ||||
| 67 | + - IA privée et locale (déploiements open-weight, Apple Silicon, GPU, architectures hybrides) | ||||
| 68 | + - RAG — Retrieval-Augmented Generation (citations, contrôles d'accès, journaux d'audit) | ||||
| 69 | + - Analyse de données et recherche (finance, immobilier, veille concurrentielle, prévision…) | ||||
| 70 | + - IA multimodale (texte, images, PDF, tableaux, audio, vidéo, données structurées) | ||||
| 71 | + - Stratégie et consultation IA (évaluations d'opportunités, feuilles de route, POC, formation) | ||||
| 72 | + - Développement logiciel sur mesure (plateformes, APIs, tableaux de bord, intégrations) | ||||
| 73 | + | ||||
| 74 | +5. **Sécurité et déploiement responsable (`/security`)** | ||||
| 75 | + - Contrôles d'accès par rôles, authentification sécurisée, chiffrement, hébergement privé de modèles, workflows d'approbation humaine, monitoring, journalisation, validation des sorties. | ||||
| 76 | + | ||||
| 77 | +6. **Industries (`/industries`)** | ||||
| 78 | + - Services financiers, immobilier, éducation, recherche académique, services professionnels, administration publique, technologie, consultation, assurance, juridique et conformité, médias, commerce de détail. | ||||
| 79 | + | ||||
| 80 | +7. **Contact (`/contact`)** | ||||
| 81 | + - Formulaire (nom, organisation, courriel, message) | ||||
| 82 | + - Courriel de contact affiché : **contact@spboucher.ai** | ||||
| 83 | + | ||||
| 84 | +--- | ||||
| 85 | + | ||||
| 86 | +## Directives de design | ||||
| 87 | + | ||||
| 88 | +- Esthétique sobre, technologique et crédible : fond sombre ou clair épuré, typographie soignée, beaucoup d'espace blanc. | ||||
| 89 | +- Éviter les templates génériques; le design doit paraître intentionnel et distinctif. | ||||
| 90 | +- Palette suggérée : tons neutres profonds + un accent (bleu profond ou vert technologique). | ||||
| 91 | +- Responsive obligatoire (mobile-first). | ||||
| 92 | +- Accessibilité : contrastes AA minimum, navigation clavier, balises sémantiques. | ||||
| 93 | +- Performance : images optimisées, chargement paresseux, score Lighthouse ≥ 90. | ||||
| 94 | + | ||||
| 95 | +--- | ||||
| 96 | + | ||||
| 97 | +## Contenu | ||||
| 98 | + | ||||
| 99 | +- Tout le contenu textuel provient du document de présentation Hilmacorp.ai (mission, services, approche, vision). Reprendre ce contenu fidèlement, en versions française et anglaise. | ||||
| 100 | +- Ton : professionnel, clair, orienté résultats. Pas de jargon marketing vide. | ||||
| 101 | + | ||||
| 102 | +--- | ||||
| 103 | + | ||||
| 104 | +## Conventions de développement | ||||
| 105 | + | ||||
| 106 | +- TypeScript strict activé. | ||||
| 107 | +- Composants dans `components/`, pages dans `app/`, contenus i18n dans `messages/` ou `locales/`. | ||||
| 108 | +- Un composant = un fichier. Nommage PascalCase pour les composants. | ||||
| 109 | +- Commits atomiques avec messages clairs. | ||||
| 110 | +- Tests de base pour les API routes (formulaire de contact). | ||||
| 111 | +- Aucune clé API ou secret dans le code — utiliser les variables d'environnement (`.env.example` fourni). | ||||
| 112 | + | ||||
| 113 | +--- | ||||
| 114 | + | ||||
| 115 | +## Checklist avant livraison | ||||
| 116 | + | ||||
| 117 | +- [ ] En-tête **Simon-Pierre Boucher / contact@spboucher.ai** présent dans chaque fichier | ||||
| 118 | +- [ ] Site bilingue FR/EN fonctionnel | ||||
| 119 | +- [ ] Toutes les pages listées ci-dessus créées | ||||
| 120 | +- [ ] Formulaire de contact fonctionnel avec validation | ||||
| 121 | +- [ ] SEO et métadonnées configurés | ||||
| 122 | +- [ ] Responsive vérifié (mobile, tablette, desktop) | ||||
| 123 | +- [ ] Lighthouse ≥ 90 (performance, accessibilité, SEO) | ||||
| 124 | +- [ ] `README.md` avec instructions d'installation et de déploiement | ||||
added
README.md
+72 −0
@@ -0,0 +1,72 @@ | |||||
| 1 | +> ⚠️ **URL live hors fonction pour le moment.** L'application a été retirée du cluster MacLustr le 2026-09-04 (processus arrêtés, copie du nœud supprimée). Ce dépôt spbgit est désormais la seule source de vérité du projet. Ancienne URL : https://www.hilmacorp.ai | ||||
| 2 | + | ||||
| 3 | +<!-- | ||||
| 4 | +Author: Simon-Pierre Boucher | ||||
| 5 | +Contact: contact@spboucher.ai | ||||
| 6 | +Project: Hilmacorp.ai — Web Platform | ||||
| 7 | +File: README.md | ||||
| 8 | +Description: Installation, development, testing, and deployment instructions | ||||
| 9 | +--> | ||||
| 10 | + | ||||
| 11 | +# Hilmacorp.ai — Web Platform | ||||
| 12 | + | ||||
| 13 | +Official bilingual (FR/EN) corporate website of **Hilmacorp.ai**, an artificial-intelligence | ||||
| 14 | +company founded by **Simon-Pierre Boucher** and **Massamba Mbaye**. | ||||
| 15 | + | ||||
| 16 | +## Stack | ||||
| 17 | + | ||||
| 18 | +- **Next.js 15** (App Router) + **TypeScript** (strict) | ||||
| 19 | +- **Tailwind CSS v4** | ||||
| 20 | +- Custom lightweight i18n (`/fr` and `/en` locale segments, dictionaries in `messages/`) | ||||
| 21 | +- Contact form API route with server-side validation (`/api/contact`) | ||||
| 22 | +- Full SEO: per-page metadata, Open Graph, `sitemap.xml`, `robots.txt` | ||||
| 23 | + | ||||
| 24 | +## Project layout | ||||
| 25 | + | ||||
| 26 | +``` | ||||
| 27 | +app/ | ||||
| 28 | + [locale]/ Pages (home, about, founders, services, security, industries, contact) | ||||
| 29 | + api/contact/ Contact form API route | ||||
| 30 | + robots.ts sitemap.ts SEO metadata routes | ||||
| 31 | +components/ Reusable React components (PascalCase, one component per file) | ||||
| 32 | +lib/ i18n loader + contact validation (pure, tested) | ||||
| 33 | +messages/ fr.json / en.json content dictionaries | ||||
| 34 | +tests/ node:test unit tests for the API validation | ||||
| 35 | +``` | ||||
| 36 | + | ||||
| 37 | +## Installation | ||||
| 38 | + | ||||
| 39 | +```bash | ||||
| 40 | +npm install | ||||
| 41 | +cp .env.example .env # then fill in values as needed | ||||
| 42 | +``` | ||||
| 43 | + | ||||
| 44 | +## Development | ||||
| 45 | + | ||||
| 46 | +```bash | ||||
| 47 | +npm run dev # http://localhost:3000 → redirects to /fr or /en | ||||
| 48 | +``` | ||||
| 49 | + | ||||
| 50 | +## Tests | ||||
| 51 | + | ||||
| 52 | +```bash | ||||
| 53 | +npm test # node --test (requires Node ≥ 22 for native TS type stripping) | ||||
| 54 | +``` | ||||
| 55 | + | ||||
| 56 | +## Production build & start | ||||
| 57 | + | ||||
| 58 | +```bash | ||||
| 59 | +npm run build | ||||
| 60 | +npm run start -- -p 8070 | ||||
| 61 | +``` | ||||
| 62 | + | ||||
| 63 | +## Environment variables | ||||
| 64 | + | ||||
| 65 | +See `.env.example`. No secrets are committed. If `RESEND_API_KEY` is unset, contact | ||||
| 66 | +messages are validated and appended to `data/contact-messages.jsonl` on the server. | ||||
| 67 | + | ||||
| 68 | +## Deployment | ||||
| 69 | + | ||||
| 70 | +The site is portable: Vercel, any Node host, or private infrastructure. | ||||
| 71 | +Current production deployment: MacLustr node **M3U96a**, PM2 (`hilmacorp` + `hilmacorp-ngrok`), | ||||
| 72 | +exposed at **https://www.hilmacorp.ai** via ngrok on port 8070. | ||||
added
app/[locale]/[...rest]/page.tsx
+13 −0
@@ -0,0 +1,13 @@ | |||||
| 1 | +/** | ||||
| 2 | + * Author: Simon-Pierre Boucher | ||||
| 3 | + * Contact: contact@spboucher.ai | ||||
| 4 | + * Project: Hilmacorp.ai — Web Platform | ||||
| 5 | + * File: app/[locale]/[...rest]/page.tsx | ||||
| 6 | + * Description: Catch-all route delegating unknown paths to the locale-aware 404 page | ||||
| 7 | + */ | ||||
| 8 | + | ||||
| 9 | +import { notFound } from "next/navigation"; | ||||
| 10 | + | ||||
| 11 | +export default function CatchAll() { | ||||
| 12 | + notFound(); | ||||
| 13 | +} | ||||
added
app/[locale]/about/page.tsx
+104 −0
@@ -0,0 +1,104 @@ | |||||
| 1 | +/** | ||||
| 2 | + * Author: Simon-Pierre Boucher | ||||
| 3 | + * Contact: contact@spboucher.ai | ||||
| 4 | + * Project: Hilmacorp.ai — Web Platform | ||||
| 5 | + * File: app/[locale]/about/page.tsx | ||||
| 6 | + * Description: About page — mission, iterative eight-step approach, principles, and company vision | ||||
| 7 | + */ | ||||
| 8 | + | ||||
| 9 | +import type { Metadata } from "next"; | ||||
| 10 | +import Container from "@/components/Container"; | ||||
| 11 | +import SectionHeading from "@/components/SectionHeading"; | ||||
| 12 | +import { getMessages, isLocale, type Locale } from "@/lib/i18n"; | ||||
| 13 | + | ||||
| 14 | +export async function generateMetadata({ | ||||
| 15 | + params, | ||||
| 16 | +}: { | ||||
| 17 | + params: Promise<{ locale: string }>; | ||||
| 18 | +}): Promise<Metadata> { | ||||
| 19 | + const { locale } = await params; | ||||
| 20 | + if (!isLocale(locale)) return {}; | ||||
| 21 | + const { meta } = getMessages(locale); | ||||
| 22 | + return { title: { absolute: meta.about.title }, description: meta.about.description }; | ||||
| 23 | +} | ||||
| 24 | + | ||||
| 25 | +export default async function AboutPage({ | ||||
| 26 | + params, | ||||
| 27 | +}: { | ||||
| 28 | + params: Promise<{ locale: string }>; | ||||
| 29 | +}) { | ||||
| 30 | + const { locale } = await params; | ||||
| 31 | + const { about } = getMessages(locale as Locale); | ||||
| 32 | + | ||||
| 33 | + return ( | ||||
| 34 | + <> | ||||
| 35 | + <section className="relative overflow-hidden border-b border-line"> | ||||
| 36 | + <div className="grid-backdrop absolute inset-0" aria-hidden="true" /> | ||||
| 37 | + <Container className="relative py-20 sm:py-28"> | ||||
| 38 | + <SectionHeading as="h1" title={about.title} lead={about.lead} /> | ||||
| 39 | + </Container> | ||||
| 40 | + </section> | ||||
| 41 | + | ||||
| 42 | + {/* Mission */} | ||||
| 43 | + <section className="py-20 sm:py-24"> | ||||
| 44 | + <Container className="grid gap-10 lg:grid-cols-[1fr_2fr]"> | ||||
| 45 | + <h2 className="font-display text-2xl font-semibold tracking-tight text-accent"> | ||||
| 46 | + {about.missionTitle} | ||||
| 47 | + </h2> | ||||
| 48 | + <p className="text-lg leading-relaxed text-ink">{about.missionBody}</p> | ||||
| 49 | + </Container> | ||||
| 50 | + </section> | ||||
| 51 | + | ||||
| 52 | + {/* Approach — 8 steps */} | ||||
| 53 | + <section className="border-t border-line bg-paper-2 py-20 sm:py-24"> | ||||
| 54 | + <Container> | ||||
| 55 | + <SectionHeading title={about.approachTitle} lead={about.approachLead} /> | ||||
| 56 | + <ol className="mt-12 grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> | ||||
| 57 | + {about.steps.map((step, index) => ( | ||||
| 58 | + <li | ||||
| 59 | + key={step.title} | ||||
| 60 | + className="rounded-2xl border border-line bg-card p-6" | ||||
| 61 | + > | ||||
| 62 | + <p className="font-mono text-sm text-accent"> | ||||
| 63 | + {String(index + 1).padStart(2, "0")} | ||||
| 64 | + </p> | ||||
| 65 | + <h3 className="mt-3 text-base font-semibold text-ink">{step.title}</h3> | ||||
| 66 | + <p className="mt-2 text-sm leading-relaxed text-ink-faint">{step.body}</p> | ||||
| 67 | + </li> | ||||
| 68 | + ))} | ||||
| 69 | + </ol> | ||||
| 70 | + </Container> | ||||
| 71 | + </section> | ||||
| 72 | + | ||||
| 73 | + {/* Principles */} | ||||
| 74 | + <section className="py-20 sm:py-24"> | ||||
| 75 | + <Container> | ||||
| 76 | + <SectionHeading title={about.principlesTitle} lead={about.principlesLead} /> | ||||
| 77 | + <ul className="mt-12 grid gap-x-12 gap-y-10 sm:grid-cols-2"> | ||||
| 78 | + {about.principles.map((principle) => ( | ||||
| 79 | + <li key={principle.title} className="border-l-2 border-accent pl-6"> | ||||
| 80 | + <h3 className="font-display text-xl font-semibold text-ink"> | ||||
| 81 | + {principle.title} | ||||
| 82 | + </h3> | ||||
| 83 | + <p className="mt-2 leading-relaxed text-ink-soft">{principle.body}</p> | ||||
| 84 | + </li> | ||||
| 85 | + ))} | ||||
| 86 | + </ul> | ||||
| 87 | + </Container> | ||||
| 88 | + </section> | ||||
| 89 | + | ||||
| 90 | + {/* Vision */} | ||||
| 91 | + <section className="border-t border-line bg-ink py-20 sm:py-24"> | ||||
| 92 | + <Container className="grid gap-10 lg:grid-cols-[1fr_2fr]"> | ||||
| 93 | + <h2 className="font-display text-2xl font-semibold tracking-tight text-paper"> | ||||
| 94 | + {about.visionTitle} | ||||
| 95 | + </h2> | ||||
| 96 | + <div className="space-y-5"> | ||||
| 97 | + <p className="text-lg leading-relaxed text-paper/80">{about.visionBody1}</p> | ||||
| 98 | + <p className="text-lg leading-relaxed text-paper/80">{about.visionBody2}</p> | ||||
| 99 | + </div> | ||||
| 100 | + </Container> | ||||
| 101 | + </section> | ||||
| 102 | + </> | ||||
| 103 | + ); | ||||
| 104 | +} | ||||
added
app/[locale]/apps/page.tsx
+168 −0
@@ -0,0 +1,168 @@ | |||||
| 1 | +/** | ||||
| 2 | + * Author: Simon-Pierre Boucher | ||||
| 3 | + * Contact: contact@spboucher.ai | ||||
| 4 | + * Project: Hilmacorp.ai — Web Platform | ||||
| 5 | + * File: app/[locale]/apps/page.tsx | ||||
| 6 | + * Description: Apps page — web platforms (AI Risk Index, LLM Index) and the Zyquo family | ||||
| 7 | + * of native macOS applications with direct DMG downloads | ||||
| 8 | + */ | ||||
| 9 | + | ||||
| 10 | +import type { Metadata } from "next"; | ||||
| 11 | +import Container from "@/components/Container"; | ||||
| 12 | +import SectionHeading from "@/components/SectionHeading"; | ||||
| 13 | +import { getMessages, isLocale, type Locale } from "@/lib/i18n"; | ||||
| 14 | + | ||||
| 15 | +export async function generateMetadata({ | ||||
| 16 | + params, | ||||
| 17 | +}: { | ||||
| 18 | + params: Promise<{ locale: string }>; | ||||
| 19 | +}): Promise<Metadata> { | ||||
| 20 | + const { locale } = await params; | ||||
| 21 | + if (!isLocale(locale)) return {}; | ||||
| 22 | + const { meta } = getMessages(locale); | ||||
| 23 | + return { title: { absolute: meta.apps.title }, description: meta.apps.description }; | ||||
| 24 | +} | ||||
| 25 | + | ||||
| 26 | +function CheckIcon() { | ||||
| 27 | + return ( | ||||
| 28 | + <svg | ||||
| 29 | + width="16" | ||||
| 30 | + height="16" | ||||
| 31 | + viewBox="0 0 16 16" | ||||
| 32 | + fill="none" | ||||
| 33 | + aria-hidden="true" | ||||
| 34 | + className="mt-0.5 shrink-0" | ||||
| 35 | + > | ||||
| 36 | + <path | ||||
| 37 | + d="M3 8.5l3.2 3L13 4.5" | ||||
| 38 | + stroke="#0b6b4a" | ||||
| 39 | + strokeWidth="1.8" | ||||
| 40 | + strokeLinecap="round" | ||||
| 41 | + strokeLinejoin="round" | ||||
| 42 | + /> | ||||
| 43 | + </svg> | ||||
| 44 | + ); | ||||
| 45 | +} | ||||
| 46 | + | ||||
| 47 | +export default async function AppsPage({ | ||||
| 48 | + params, | ||||
| 49 | +}: { | ||||
| 50 | + params: Promise<{ locale: string }>; | ||||
| 51 | +}) { | ||||
| 52 | + const { locale } = await params; | ||||
| 53 | + const { apps } = getMessages(locale as Locale); | ||||
| 54 | + | ||||
| 55 | + return ( | ||||
| 56 | + <> | ||||
| 57 | + <section className="relative overflow-hidden border-b border-line"> | ||||
| 58 | + <div className="grid-backdrop absolute inset-0" aria-hidden="true" /> | ||||
| 59 | + <Container className="relative py-20 sm:py-28"> | ||||
| 60 | + <SectionHeading as="h1" title={apps.title} lead={apps.lead} /> | ||||
| 61 | + </Container> | ||||
| 62 | + </section> | ||||
| 63 | + | ||||
| 64 | + {/* Web platforms */} | ||||
| 65 | + <section className="py-16 sm:py-20"> | ||||
| 66 | + <Container> | ||||
| 67 | + <SectionHeading title={apps.webTitle} lead={apps.webLead} /> | ||||
| 68 | + <div className="mt-10 grid gap-6 lg:grid-cols-2"> | ||||
| 69 | + {apps.web.map((app) => ( | ||||
| 70 | + <article | ||||
| 71 | + key={app.id} | ||||
| 72 | + className="flex flex-col rounded-3xl border border-line bg-card p-8 shadow-sm sm:p-10" | ||||
| 73 | + > | ||||
| 74 | + <p className="font-mono text-xs uppercase tracking-[0.2em] text-ink-faint"> | ||||
| 75 | + {app.urlLabel} | ||||
| 76 | + </p> | ||||
| 77 | + <h3 className="mt-3 font-display text-2xl font-semibold tracking-tight text-ink"> | ||||
| 78 | + {app.name} | ||||
| 79 | + </h3> | ||||
| 80 | + <p className="mt-2 font-medium text-accent">{app.tagline}</p> | ||||
| 81 | + <p className="mt-4 leading-relaxed text-ink-soft">{app.description}</p> | ||||
| 82 | + <ul className="mt-6 grid gap-2.5"> | ||||
| 83 | + {app.points.map((point) => ( | ||||
| 84 | + <li key={point} className="flex items-start gap-2.5 text-sm text-ink"> | ||||
| 85 | + <CheckIcon /> | ||||
| 86 | + {point} | ||||
| 87 | + </li> | ||||
| 88 | + ))} | ||||
| 89 | + </ul> | ||||
| 90 | + <div className="mt-8 flex-1" /> | ||||
| 91 | + <a | ||||
| 92 | + href={app.url} | ||||
| 93 | + target="_blank" | ||||
| 94 | + rel="noopener noreferrer" | ||||
| 95 | + className="inline-flex w-fit items-center gap-2 rounded-full bg-ink px-6 py-2.5 text-sm font-medium text-paper transition-colors hover:bg-accent-strong" | ||||
| 96 | + > | ||||
| 97 | + {apps.visit} | ||||
| 98 | + <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true"> | ||||
| 99 | + <path | ||||
| 100 | + d="M4 10L10 4M10 4H5.5M10 4v4.5" | ||||
| 101 | + stroke="currentColor" | ||||
| 102 | + strokeWidth="1.6" | ||||
| 103 | + strokeLinecap="round" | ||||
| 104 | + strokeLinejoin="round" | ||||
| 105 | + /> | ||||
| 106 | + </svg> | ||||
| 107 | + </a> | ||||
| 108 | + </article> | ||||
| 109 | + ))} | ||||
| 110 | + </div> | ||||
| 111 | + </Container> | ||||
| 112 | + </section> | ||||
| 113 | + | ||||
| 114 | + {/* macOS apps */} | ||||
| 115 | + <section className="border-t border-line bg-paper-2 py-16 sm:py-20"> | ||||
| 116 | + <Container> | ||||
| 117 | + <SectionHeading title={apps.macTitle} lead={apps.macLead} /> | ||||
| 118 | + <div className="mt-10 grid gap-6 lg:grid-cols-2"> | ||||
| 119 | + {apps.mac.map((app) => ( | ||||
| 120 | + <article | ||||
| 121 | + key={app.id} | ||||
| 122 | + className="flex flex-col rounded-3xl border border-line bg-card p-8 shadow-sm sm:p-10" | ||||
| 123 | + > | ||||
| 124 | + <p className="font-mono text-xs uppercase tracking-[0.2em] text-ink-faint"> | ||||
| 125 | + {app.requires} | ||||
| 126 | + </p> | ||||
| 127 | + <h3 className="mt-3 font-display text-2xl font-semibold tracking-tight text-ink"> | ||||
| 128 | + {app.name} | ||||
| 129 | + </h3> | ||||
| 130 | + <p className="mt-2 font-medium text-accent">{app.tagline}</p> | ||||
| 131 | + <p className="mt-4 leading-relaxed text-ink-soft">{app.description}</p> | ||||
| 132 | + <ul className="mt-6 grid gap-2.5"> | ||||
| 133 | + {app.points.map((point) => ( | ||||
| 134 | + <li key={point} className="flex items-start gap-2.5 text-sm text-ink"> | ||||
| 135 | + <CheckIcon /> | ||||
| 136 | + {point} | ||||
| 137 | + </li> | ||||
| 138 | + ))} | ||||
| 139 | + </ul> | ||||
| 140 | + <div className="mt-8 flex-1" /> | ||||
| 141 | + <div className="flex flex-wrap items-center gap-4"> | ||||
| 142 | + <a | ||||
| 143 | + href={`/downloads/${app.file}`} | ||||
| 144 | + download | ||||
| 145 | + className="inline-flex items-center gap-2 rounded-full bg-ink px-6 py-2.5 text-sm font-medium text-paper transition-colors hover:bg-accent-strong" | ||||
| 146 | + > | ||||
| 147 | + <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true"> | ||||
| 148 | + <path | ||||
| 149 | + d="M7 1.5v8m0 0L3.8 6.3M7 9.5l3.2-3.2M2 12.5h10" | ||||
| 150 | + stroke="currentColor" | ||||
| 151 | + strokeWidth="1.6" | ||||
| 152 | + strokeLinecap="round" | ||||
| 153 | + strokeLinejoin="round" | ||||
| 154 | + /> | ||||
| 155 | + </svg> | ||||
| 156 | + {apps.download} | ||||
| 157 | + </a> | ||||
| 158 | + <span className="font-mono text-xs text-ink-faint">{app.size}</span> | ||||
| 159 | + </div> | ||||
| 160 | + </article> | ||||
| 161 | + ))} | ||||
| 162 | + </div> | ||||
| 163 | + <p className="mt-10 text-sm text-ink-faint">{apps.macNote}</p> | ||||
| 164 | + </Container> | ||||
| 165 | + </section> | ||||
| 166 | + </> | ||||
| 167 | + ); | ||||
| 168 | +} | ||||
added
app/[locale]/contact/page.tsx
+78 −0
@@ -0,0 +1,78 @@ | |||||
| 1 | +/** | ||||
| 2 | + * Author: Simon-Pierre Boucher | ||||
| 3 | + * Contact: contact@spboucher.ai | ||||
| 4 | + * Project: Hilmacorp.ai — Web Platform | ||||
| 5 | + * File: app/[locale]/contact/page.tsx | ||||
| 6 | + * Description: Contact page — practical information cards and validated contact form | ||||
| 7 | + */ | ||||
| 8 | + | ||||
| 9 | +import type { Metadata } from "next"; | ||||
| 10 | +import Container from "@/components/Container"; | ||||
| 11 | +import SectionHeading from "@/components/SectionHeading"; | ||||
| 12 | +import ContactForm from "@/components/ContactForm"; | ||||
| 13 | +import { getMessages, isLocale, type Locale } from "@/lib/i18n"; | ||||
| 14 | + | ||||
| 15 | +export async function generateMetadata({ | ||||
| 16 | + params, | ||||
| 17 | +}: { | ||||
| 18 | + params: Promise<{ locale: string }>; | ||||
| 19 | +}): Promise<Metadata> { | ||||
| 20 | + const { locale } = await params; | ||||
| 21 | + if (!isLocale(locale)) return {}; | ||||
| 22 | + const { meta } = getMessages(locale); | ||||
| 23 | + return { title: { absolute: meta.contact.title }, description: meta.contact.description }; | ||||
| 24 | +} | ||||
| 25 | + | ||||
| 26 | +export default async function ContactPage({ | ||||
| 27 | + params, | ||||
| 28 | +}: { | ||||
| 29 | + params: Promise<{ locale: string }>; | ||||
| 30 | +}) { | ||||
| 31 | + const { locale } = await params; | ||||
| 32 | + const { contact } = getMessages(locale as Locale); | ||||
| 33 | + | ||||
| 34 | + const infoCards = [ | ||||
| 35 | + { | ||||
| 36 | + title: contact.infoEmailTitle, | ||||
| 37 | + body: ( | ||||
| 38 | + <a | ||||
| 39 | + href="mailto:contact@spboucher.ai" | ||||
| 40 | + className="font-medium text-accent hover:text-accent-strong" | ||||
| 41 | + > | ||||
| 42 | + contact@spboucher.ai | ||||
| 43 | + </a> | ||||
| 44 | + ), | ||||
| 45 | + }, | ||||
| 46 | + { title: contact.infoResponseTitle, body: contact.infoResponse }, | ||||
| 47 | + { title: contact.infoLanguagesTitle, body: contact.infoLanguages }, | ||||
| 48 | + ]; | ||||
| 49 | + | ||||
| 50 | + return ( | ||||
| 51 | + <> | ||||
| 52 | + <section className="relative overflow-hidden border-b border-line"> | ||||
| 53 | + <div className="grid-backdrop absolute inset-0" aria-hidden="true" /> | ||||
| 54 | + <Container className="relative py-20 sm:py-28"> | ||||
| 55 | + <SectionHeading as="h1" title={contact.title} lead={contact.lead} /> | ||||
| 56 | + </Container> | ||||
| 57 | + </section> | ||||
| 58 | + | ||||
| 59 | + <section className="py-16 sm:py-20"> | ||||
| 60 | + <Container className="grid gap-12 lg:grid-cols-[1fr_2fr]"> | ||||
| 61 | + <div className="flex flex-col gap-4"> | ||||
| 62 | + {infoCards.map((card) => ( | ||||
| 63 | + <div key={card.title} className="rounded-2xl border border-line bg-card p-6"> | ||||
| 64 | + <h2 className="font-mono text-xs uppercase tracking-[0.25em] text-ink-faint"> | ||||
| 65 | + {card.title} | ||||
| 66 | + </h2> | ||||
| 67 | + <p className="mt-2.5 text-sm leading-relaxed text-ink-soft">{card.body}</p> | ||||
| 68 | + </div> | ||||
| 69 | + ))} | ||||
| 70 | + </div> | ||||
| 71 | + <div className="rounded-3xl border border-line bg-card p-8 shadow-sm sm:p-10"> | ||||
| 72 | + <ContactForm strings={contact.form} /> | ||||
| 73 | + </div> | ||||
| 74 | + </Container> | ||||
| 75 | + </section> | ||||
| 76 | + </> | ||||
| 77 | + ); | ||||
| 78 | +} | ||||
added
app/[locale]/founders/page.tsx
+104 −0
@@ -0,0 +1,104 @@ | |||||
| 1 | +/** | ||||
| 2 | + * Author: Simon-Pierre Boucher | ||||
| 3 | + * Contact: contact@spboucher.ai | ||||
| 4 | + * Project: Hilmacorp.ai — Web Platform | ||||
| 5 | + * File: app/[locale]/founders/page.tsx | ||||
| 6 | + * Description: Founders page — profiles of Simon-Pierre Boucher and Massamba Mbaye | ||||
| 7 | + */ | ||||
| 8 | + | ||||
| 9 | +import type { Metadata } from "next"; | ||||
| 10 | +import Container from "@/components/Container"; | ||||
| 11 | +import SectionHeading from "@/components/SectionHeading"; | ||||
| 12 | +import { getMessages, isLocale, type Locale } from "@/lib/i18n"; | ||||
| 13 | + | ||||
| 14 | +export async function generateMetadata({ | ||||
| 15 | + params, | ||||
| 16 | +}: { | ||||
| 17 | + params: Promise<{ locale: string }>; | ||||
| 18 | +}): Promise<Metadata> { | ||||
| 19 | + const { locale } = await params; | ||||
| 20 | + if (!isLocale(locale)) return {}; | ||||
| 21 | + const { meta } = getMessages(locale); | ||||
| 22 | + return { title: { absolute: meta.founders.title }, description: meta.founders.description }; | ||||
| 23 | +} | ||||
| 24 | + | ||||
| 25 | +function FounderCard({ | ||||
| 26 | + name, | ||||
| 27 | + role, | ||||
| 28 | + bio, | ||||
| 29 | + tags, | ||||
| 30 | + initials, | ||||
| 31 | +}: { | ||||
| 32 | + name: string; | ||||
| 33 | + role: string; | ||||
| 34 | + bio: string; | ||||
| 35 | + tags: string[]; | ||||
| 36 | + initials: string; | ||||
| 37 | +}) { | ||||
| 38 | + return ( | ||||
| 39 | + <article className="rounded-3xl border border-line bg-card p-8 shadow-sm sm:p-10"> | ||||
| 40 | + <div className="flex items-center gap-5"> | ||||
| 41 | + <span | ||||
| 42 | + aria-hidden="true" | ||||
| 43 | + className="flex h-16 w-16 shrink-0 items-center justify-center rounded-2xl bg-ink font-mono text-lg font-semibold text-paper" | ||||
| 44 | + > | ||||
| 45 | + {initials} | ||||
| 46 | + </span> | ||||
| 47 | + <div> | ||||
| 48 | + <h2 className="font-display text-2xl font-semibold text-ink">{name}</h2> | ||||
| 49 | + <p className="mt-1 text-sm font-medium text-accent">{role}</p> | ||||
| 50 | + </div> | ||||
| 51 | + </div> | ||||
| 52 | + <p className="mt-6 leading-relaxed text-ink-soft">{bio}</p> | ||||
| 53 | + <ul className="mt-6 flex flex-wrap gap-2"> | ||||
| 54 | + {tags.map((tag) => ( | ||||
| 55 | + <li | ||||
| 56 | + key={tag} | ||||
| 57 | + className="rounded-full bg-accent-soft px-3 py-1 text-xs font-medium text-accent-strong" | ||||
| 58 | + > | ||||
| 59 | + {tag} | ||||
| 60 | + </li> | ||||
| 61 | + ))} | ||||
| 62 | + </ul> | ||||
| 63 | + </article> | ||||
| 64 | + ); | ||||
| 65 | +} | ||||
| 66 | + | ||||
| 67 | +export default async function FoundersPage({ | ||||
| 68 | + params, | ||||
| 69 | +}: { | ||||
| 70 | + params: Promise<{ locale: string }>; | ||||
| 71 | +}) { | ||||
| 72 | + const { locale } = await params; | ||||
| 73 | + const { founders } = getMessages(locale as Locale); | ||||
| 74 | + | ||||
| 75 | + return ( | ||||
| 76 | + <> | ||||
| 77 | + <section className="relative overflow-hidden border-b border-line"> | ||||
| 78 | + <div className="grid-backdrop absolute inset-0" aria-hidden="true" /> | ||||
| 79 | + <Container className="relative py-20 sm:py-28"> | ||||
| 80 | + <SectionHeading as="h1" title={founders.title} lead={founders.lead} /> | ||||
| 81 | + </Container> | ||||
| 82 | + </section> | ||||
| 83 | + | ||||
| 84 | + <section className="py-20 sm:py-24"> | ||||
| 85 | + <Container className="grid gap-8 lg:grid-cols-2"> | ||||
| 86 | + <FounderCard | ||||
| 87 | + name={founders.spb.name} | ||||
| 88 | + role={founders.spb.role} | ||||
| 89 | + bio={founders.spb.bio} | ||||
| 90 | + tags={founders.spb.tags} | ||||
| 91 | + initials="SPB" | ||||
| 92 | + /> | ||||
| 93 | + <FounderCard | ||||
| 94 | + name={founders.mm.name} | ||||
| 95 | + role={founders.mm.role} | ||||
| 96 | + bio={founders.mm.bio} | ||||
| 97 | + tags={founders.mm.tags} | ||||
| 98 | + initials="MM" | ||||
| 99 | + /> | ||||
| 100 | + </Container> | ||||
| 101 | + </section> | ||||
| 102 | + </> | ||||
| 103 | + ); | ||||
| 104 | +} | ||||
added
app/[locale]/industries/page.tsx
+61 −0
@@ -0,0 +1,61 @@ | |||||
| 1 | +/** | ||||
| 2 | + * Author: Simon-Pierre Boucher | ||||
| 3 | + * Contact: contact@spboucher.ai | ||||
| 4 | + * Project: Hilmacorp.ai — Web Platform | ||||
| 5 | + * File: app/[locale]/industries/page.tsx | ||||
| 6 | + * Description: Industries page — the twelve sectors served by Hilmacorp.ai | ||||
| 7 | + */ | ||||
| 8 | + | ||||
| 9 | +import type { Metadata } from "next"; | ||||
| 10 | +import Container from "@/components/Container"; | ||||
| 11 | +import SectionHeading from "@/components/SectionHeading"; | ||||
| 12 | +import { getMessages, isLocale, type Locale } from "@/lib/i18n"; | ||||
| 13 | + | ||||
| 14 | +export async function generateMetadata({ | ||||
| 15 | + params, | ||||
| 16 | +}: { | ||||
| 17 | + params: Promise<{ locale: string }>; | ||||
| 18 | +}): Promise<Metadata> { | ||||
| 19 | + const { locale } = await params; | ||||
| 20 | + if (!isLocale(locale)) return {}; | ||||
| 21 | + const { meta } = getMessages(locale); | ||||
| 22 | + return { title: { absolute: meta.industries.title }, description: meta.industries.description }; | ||||
| 23 | +} | ||||
| 24 | + | ||||
| 25 | +export default async function IndustriesPage({ | ||||
| 26 | + params, | ||||
| 27 | +}: { | ||||
| 28 | + params: Promise<{ locale: string }>; | ||||
| 29 | +}) { | ||||
| 30 | + const { locale } = await params; | ||||
| 31 | + const { industries } = getMessages(locale as Locale); | ||||
| 32 | + | ||||
| 33 | + return ( | ||||
| 34 | + <> | ||||
| 35 | + <section className="relative overflow-hidden border-b border-line"> | ||||
| 36 | + <div className="grid-backdrop absolute inset-0" aria-hidden="true" /> | ||||
| 37 | + <Container className="relative py-20 sm:py-28"> | ||||
| 38 | + <SectionHeading as="h1" title={industries.title} lead={industries.lead} /> | ||||
| 39 | + </Container> | ||||
| 40 | + </section> | ||||
| 41 | + | ||||
| 42 | + <section className="py-16 sm:py-20"> | ||||
| 43 | + <Container> | ||||
| 44 | + <ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> | ||||
| 45 | + {industries.items.map((industry) => ( | ||||
| 46 | + <li | ||||
| 47 | + key={industry.title} | ||||
| 48 | + className="rounded-2xl border border-line bg-card p-7 shadow-sm transition-colors hover:border-accent" | ||||
| 49 | + > | ||||
| 50 | + <h2 className="font-display text-lg font-semibold text-ink"> | ||||
| 51 | + {industry.title} | ||||
| 52 | + </h2> | ||||
| 53 | + <p className="mt-2 text-sm leading-relaxed text-ink-faint">{industry.body}</p> | ||||
| 54 | + </li> | ||||
| 55 | + ))} | ||||
| 56 | + </ul> | ||||
| 57 | + </Container> | ||||
| 58 | + </section> | ||||
| 59 | + </> | ||||
| 60 | + ); | ||||
| 61 | +} | ||||
added
app/[locale]/layout.tsx
+84 −0
@@ -0,0 +1,84 @@ | |||||
| 1 | +/** | ||||
| 2 | + * Author: Simon-Pierre Boucher | ||||
| 3 | + * Contact: contact@spboucher.ai | ||||
| 4 | + * Project: Hilmacorp.ai — Web Platform | ||||
| 5 | + * File: app/[locale]/layout.tsx | ||||
| 6 | + * Description: Locale-scoped root layout — HTML shell, fonts, global metadata, header and footer | ||||
| 7 | + */ | ||||
| 8 | + | ||||
| 9 | +import type { Metadata } from "next"; | ||||
| 10 | +import { Inter, Fraunces } from "next/font/google"; | ||||
| 11 | +import { notFound } from "next/navigation"; | ||||
| 12 | +import Header from "@/components/Header"; | ||||
| 13 | +import Footer from "@/components/Footer"; | ||||
| 14 | +import CookieConsent from "@/components/CookieConsent"; | ||||
| 15 | +import { getMessages, isLocale, locales, type Locale } from "@/lib/i18n"; | ||||
| 16 | +import "@/app/globals.css"; | ||||
| 17 | + | ||||
| 18 | +const inter = Inter({ subsets: ["latin"], variable: "--font-inter", display: "swap" }); | ||||
| 19 | +const fraunces = Fraunces({ subsets: ["latin"], variable: "--font-fraunces", display: "swap" }); | ||||
| 20 | + | ||||
| 21 | +const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://www.hilmacorp.ai"; | ||||
| 22 | + | ||||
| 23 | +export function generateStaticParams() { | ||||
| 24 | + return locales.map((locale) => ({ locale })); | ||||
| 25 | +} | ||||
| 26 | + | ||||
| 27 | +export async function generateMetadata({ | ||||
| 28 | + params, | ||||
| 29 | +}: { | ||||
| 30 | + params: Promise<{ locale: string }>; | ||||
| 31 | +}): Promise<Metadata> { | ||||
| 32 | + const { locale } = await params; | ||||
| 33 | + if (!isLocale(locale)) return {}; | ||||
| 34 | + const messages = getMessages(locale); | ||||
| 35 | + return { | ||||
| 36 | + metadataBase: new URL(SITE_URL), | ||||
| 37 | + title: { | ||||
| 38 | + default: messages.meta.home.title, | ||||
| 39 | + template: `%s — ${messages.meta.siteName}`, | ||||
| 40 | + }, | ||||
| 41 | + description: messages.meta.home.description, | ||||
| 42 | + alternates: { | ||||
| 43 | + languages: { fr: "/fr", en: "/en" }, | ||||
| 44 | + }, | ||||
| 45 | + openGraph: { | ||||
| 46 | + type: "website", | ||||
| 47 | + siteName: messages.meta.siteName, | ||||
| 48 | + locale: locale === "fr" ? "fr_CA" : "en_US", | ||||
| 49 | + url: `${SITE_URL}/${locale}`, | ||||
| 50 | + title: messages.meta.home.title, | ||||
| 51 | + description: messages.meta.home.description, | ||||
| 52 | + }, | ||||
| 53 | + twitter: { | ||||
| 54 | + card: "summary", | ||||
| 55 | + title: messages.meta.home.title, | ||||
| 56 | + description: messages.meta.home.description, | ||||
| 57 | + }, | ||||
| 58 | + robots: { index: true, follow: true }, | ||||
| 59 | + }; | ||||
| 60 | +} | ||||
| 61 | + | ||||
| 62 | +export default async function LocaleLayout({ | ||||
| 63 | + children, | ||||
| 64 | + params, | ||||
| 65 | +}: { | ||||
| 66 | + children: React.ReactNode; | ||||
| 67 | + params: Promise<{ locale: string }>; | ||||
| 68 | +}) { | ||||
| 69 | + const { locale } = await params; | ||||
| 70 | + if (!isLocale(locale)) notFound(); | ||||
| 71 | + const typedLocale = locale as Locale; | ||||
| 72 | + const messages = getMessages(typedLocale); | ||||
| 73 | + | ||||
| 74 | + return ( | ||||
| 75 | + <html lang={typedLocale} className={`${inter.variable} ${fraunces.variable}`}> | ||||
| 76 | + <body className="flex min-h-screen flex-col font-sans"> | ||||
| 77 | + <Header locale={typedLocale} nav={messages.nav} /> | ||||
| 78 | + <main className="flex-1">{children}</main> | ||||
| 79 | + <Footer locale={typedLocale} messages={messages} /> | ||||
| 80 | + <CookieConsent locale={typedLocale} strings={messages.cookies} /> | ||||
| 81 | + </body> | ||||
| 82 | + </html> | ||||
| 83 | + ); | ||||
| 84 | +} | ||||
added
app/[locale]/not-found.tsx
+38 −0
@@ -0,0 +1,38 @@ | |||||
| 1 | +/** | ||||
| 2 | + * Author: Simon-Pierre Boucher | ||||
| 3 | + * Contact: contact@spboucher.ai | ||||
| 4 | + * Project: Hilmacorp.ai — Web Platform | ||||
| 5 | + * File: app/[locale]/not-found.tsx | ||||
| 6 | + * Description: Locale-aware 404 page (light theme) | ||||
| 7 | + */ | ||||
| 8 | + | ||||
| 9 | +import Link from "next/link"; | ||||
| 10 | +import Container from "@/components/Container"; | ||||
| 11 | + | ||||
| 12 | +export default function NotFound() { | ||||
| 13 | + return ( | ||||
| 14 | + <Container className="py-32 text-center"> | ||||
| 15 | + <p className="font-mono text-sm uppercase tracking-[0.3em] text-accent">404</p> | ||||
| 16 | + <h1 className="mt-4 font-display text-3xl font-semibold text-ink"> | ||||
| 17 | + Page not found / Page introuvable | ||||
| 18 | + </h1> | ||||
| 19 | + <p className="mt-4 text-ink-soft"> | ||||
| 20 | + The page you are looking for does not exist. / La page que vous cherchez n'existe pas. | ||||
| 21 | + </p> | ||||
| 22 | + <div className="mt-8 flex justify-center gap-4"> | ||||
| 23 | + <Link | ||||
| 24 | + href="/en" | ||||
| 25 | + className="rounded-full border border-line-strong px-5 py-2 text-sm text-ink hover:border-accent hover:text-accent" | ||||
| 26 | + > | ||||
| 27 | + English home | ||||
| 28 | + </Link> | ||||
| 29 | + <Link | ||||
| 30 | + href="/fr" | ||||
| 31 | + className="rounded-full border border-line-strong px-5 py-2 text-sm text-ink hover:border-accent hover:text-accent" | ||||
| 32 | + > | ||||
| 33 | + Accueil français | ||||
| 34 | + </Link> | ||||
| 35 | + </div> | ||||
| 36 | + </Container> | ||||
| 37 | + ); | ||||
| 38 | +} | ||||
added
app/[locale]/page.tsx
+203 −0
@@ -0,0 +1,203 @@ | |||||
| 1 | +/** | ||||
| 2 | + * Author: Simon-Pierre Boucher | ||||
| 3 | + * Contact: contact@spboucher.ai | ||||
| 4 | + * Project: Hilmacorp.ai — Web Platform | ||||
| 5 | + * File: app/[locale]/page.tsx | ||||
| 6 | + * Description: Home page — hero, introduction, commitments, services overview, method strip, call to action | ||||
| 7 | + */ | ||||
| 8 | + | ||||
| 9 | +import type { Metadata } from "next"; | ||||
| 10 | +import Link from "next/link"; | ||||
| 11 | +import Container from "@/components/Container"; | ||||
| 12 | +import SectionHeading from "@/components/SectionHeading"; | ||||
| 13 | +import { getMessages, isLocale, type Locale } from "@/lib/i18n"; | ||||
| 14 | + | ||||
| 15 | +export async function generateMetadata({ | ||||
| 16 | + params, | ||||
| 17 | +}: { | ||||
| 18 | + params: Promise<{ locale: string }>; | ||||
| 19 | +}): Promise<Metadata> { | ||||
| 20 | + const { locale } = await params; | ||||
| 21 | + if (!isLocale(locale)) return {}; | ||||
| 22 | + const { meta } = getMessages(locale); | ||||
| 23 | + return { title: { absolute: meta.home.title }, description: meta.home.description }; | ||||
| 24 | +} | ||||
| 25 | + | ||||
| 26 | +export default async function HomePage({ | ||||
| 27 | + params, | ||||
| 28 | +}: { | ||||
| 29 | + params: Promise<{ locale: string }>; | ||||
| 30 | +}) { | ||||
| 31 | + const { locale } = await params; | ||||
| 32 | + const typedLocale = locale as Locale; | ||||
| 33 | + const messages = getMessages(typedLocale); | ||||
| 34 | + const { home, services } = messages; | ||||
| 35 | + | ||||
| 36 | + return ( | ||||
| 37 | + <> | ||||
| 38 | + {/* Hero */} | ||||
| 39 | + <section className="relative overflow-hidden border-b border-line"> | ||||
| 40 | + <div className="grid-backdrop absolute inset-0" aria-hidden="true" /> | ||||
| 41 | + <Container className="relative py-24 sm:py-36"> | ||||
| 42 | + <p className="mb-5 font-mono text-xs uppercase tracking-[0.3em] text-accent"> | ||||
| 43 | + {home.heroKicker} | ||||
| 44 | + </p> | ||||
| 45 | + <h1 className="max-w-4xl font-display text-4xl font-semibold leading-[1.08] tracking-tight text-ink sm:text-6xl"> | ||||
| 46 | + {home.heroTitle} | ||||
| 47 | + </h1> | ||||
| 48 | + <p className="mt-8 max-w-2xl text-lg leading-relaxed text-ink-soft"> | ||||
| 49 | + {home.heroLead} | ||||
| 50 | + </p> | ||||
| 51 | + <div className="mt-10 flex flex-wrap gap-4"> | ||||
| 52 | + <Link | ||||
| 53 | + href={`/${typedLocale}/contact`} | ||||
| 54 | + className="rounded-full bg-ink px-7 py-3 text-sm font-medium text-paper transition-colors hover:bg-accent-strong" | ||||
| 55 | + > | ||||
| 56 | + {home.heroCtaPrimary} | ||||
| 57 | + </Link> | ||||
| 58 | + <Link | ||||
| 59 | + href={`/${typedLocale}/services`} | ||||
| 60 | + className="rounded-full border border-line-strong px-7 py-3 text-sm font-medium text-ink transition-colors hover:border-accent hover:text-accent" | ||||
| 61 | + > | ||||
| 62 | + {home.heroCtaSecondary} | ||||
| 63 | + </Link> | ||||
| 64 | + </div> | ||||
| 65 | + </Container> | ||||
| 66 | + </section> | ||||
| 67 | + | ||||
| 68 | + {/* Stats band */} | ||||
| 69 | + <section className="border-b border-line bg-paper-2"> | ||||
| 70 | + <Container> | ||||
| 71 | + <dl className="grid grid-cols-1 divide-y divide-line sm:grid-cols-3 sm:divide-x sm:divide-y-0"> | ||||
| 72 | + {[ | ||||
| 73 | + { value: "8", label: home.statsProjects }, | ||||
| 74 | + { value: "2", label: home.statsLanguages }, | ||||
| 75 | + { value: "8", label: home.statsSteps }, | ||||
| 76 | + ].map((stat) => ( | ||||
| 77 | + <div key={stat.label} className="px-2 py-9 text-center"> | ||||
| 78 | + <dd className="font-display text-4xl font-semibold text-accent"> | ||||
| 79 | + {stat.value} | ||||
| 80 | + </dd> | ||||
| 81 | + <dt className="mt-1 text-sm text-ink-faint">{stat.label}</dt> | ||||
| 82 | + </div> | ||||
| 83 | + ))} | ||||
| 84 | + </dl> | ||||
| 85 | + </Container> | ||||
| 86 | + </section> | ||||
| 87 | + | ||||
| 88 | + {/* Introduction */} | ||||
| 89 | + <section className="py-20 sm:py-28"> | ||||
| 90 | + <Container> | ||||
| 91 | + <SectionHeading title={home.introTitle} /> | ||||
| 92 | + <div className="mt-8 grid gap-8 lg:grid-cols-2"> | ||||
| 93 | + <p className="leading-relaxed text-ink-soft">{home.introBody1}</p> | ||||
| 94 | + <p className="leading-relaxed text-ink-soft">{home.introBody2}</p> | ||||
| 95 | + </div> | ||||
| 96 | + <div className="mt-10 flex flex-wrap gap-6"> | ||||
| 97 | + <Link | ||||
| 98 | + href={`/${typedLocale}/about`} | ||||
| 99 | + className="text-sm font-medium text-accent hover:text-accent-strong" | ||||
| 100 | + > | ||||
| 101 | + {home.introLinkAbout} → | ||||
| 102 | + </Link> | ||||
| 103 | + <Link | ||||
| 104 | + href={`/${typedLocale}/founders`} | ||||
| 105 | + className="text-sm font-medium text-accent hover:text-accent-strong" | ||||
| 106 | + > | ||||
| 107 | + {home.introLinkFounders} → | ||||
| 108 | + </Link> | ||||
| 109 | + </div> | ||||
| 110 | + </Container> | ||||
| 111 | + </section> | ||||
| 112 | + | ||||
| 113 | + {/* Commitments */} | ||||
| 114 | + <section className="border-t border-line bg-paper-2 py-20 sm:py-28"> | ||||
| 115 | + <Container> | ||||
| 116 | + <SectionHeading title={home.whyTitle} lead={home.whyLead} /> | ||||
| 117 | + <ul className="mt-12 grid gap-4 sm:grid-cols-2"> | ||||
| 118 | + {home.why.map((item, index) => ( | ||||
| 119 | + <li | ||||
| 120 | + key={item.title} | ||||
| 121 | + className="rounded-2xl border border-line bg-card p-8" | ||||
| 122 | + > | ||||
| 123 | + <p className="font-mono text-sm text-accent"> | ||||
| 124 | + {String(index + 1).padStart(2, "0")} | ||||
| 125 | + </p> | ||||
| 126 | + <h3 className="mt-3 font-display text-xl font-semibold text-ink"> | ||||
| 127 | + {item.title} | ||||
| 128 | + </h3> | ||||
| 129 | + <p className="mt-3 leading-relaxed text-ink-soft">{item.body}</p> | ||||
| 130 | + </li> | ||||
| 131 | + ))} | ||||
| 132 | + </ul> | ||||
| 133 | + </Container> | ||||
| 134 | + </section> | ||||
| 135 | + | ||||
| 136 | + {/* Services overview */} | ||||
| 137 | + <section className="py-20 sm:py-28"> | ||||
| 138 | + <Container> | ||||
| 139 | + <SectionHeading title={home.servicesTitle} lead={home.servicesLead} /> | ||||
| 140 | + <ul className="mt-12 grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> | ||||
| 141 | + {services.items.map((service, index) => ( | ||||
| 142 | + <li key={service.id}> | ||||
| 143 | + <Link | ||||
| 144 | + href={`/${typedLocale}/services#${service.id}`} | ||||
| 145 | + className="group block h-full rounded-2xl border border-line bg-card p-6 transition-colors hover:border-accent" | ||||
| 146 | + > | ||||
| 147 | + <p className="font-mono text-xs text-ink-faint"> | ||||
| 148 | + {String(index + 1).padStart(2, "0")} | ||||
| 149 | + </p> | ||||
| 150 | + <h3 className="mt-3 text-base font-semibold text-ink group-hover:text-accent"> | ||||
| 151 | + {service.title} | ||||
| 152 | + </h3> | ||||
| 153 | + </Link> | ||||
| 154 | + </li> | ||||
| 155 | + ))} | ||||
| 156 | + </ul> | ||||
| 157 | + <div className="mt-10"> | ||||
| 158 | + <Link | ||||
| 159 | + href={`/${typedLocale}/services`} | ||||
| 160 | + className="text-sm font-medium text-accent hover:text-accent-strong" | ||||
| 161 | + > | ||||
| 162 | + {home.servicesCtaAll} → | ||||
| 163 | + </Link> | ||||
| 164 | + </div> | ||||
| 165 | + </Container> | ||||
| 166 | + </section> | ||||
| 167 | + | ||||
| 168 | + {/* Method strip */} | ||||
| 169 | + <section className="border-t border-line bg-ink py-16 sm:py-20"> | ||||
| 170 | + <Container className="flex flex-col items-start gap-6 sm:flex-row sm:items-center sm:justify-between"> | ||||
| 171 | + <div className="max-w-2xl"> | ||||
| 172 | + <h2 className="font-display text-2xl font-semibold tracking-tight text-paper sm:text-3xl"> | ||||
| 173 | + {home.processTitle} | ||||
| 174 | + </h2> | ||||
| 175 | + <p className="mt-3 leading-relaxed text-paper/70">{home.processBody}</p> | ||||
| 176 | + </div> | ||||
| 177 | + <Link | ||||
| 178 | + href={`/${typedLocale}/about`} | ||||
| 179 | + className="shrink-0 rounded-full border border-paper/30 px-6 py-2.5 text-sm font-medium text-paper transition-colors hover:border-paper hover:bg-paper hover:text-ink" | ||||
| 180 | + > | ||||
| 181 | + {home.processLink} | ||||
| 182 | + </Link> | ||||
| 183 | + </Container> | ||||
| 184 | + </section> | ||||
| 185 | + | ||||
| 186 | + {/* Call to action */} | ||||
| 187 | + <section className="py-20 sm:py-28"> | ||||
| 188 | + <Container className="text-center"> | ||||
| 189 | + <h2 className="font-display text-3xl font-semibold tracking-tight text-ink sm:text-4xl"> | ||||
| 190 | + {home.ctaTitle} | ||||
| 191 | + </h2> | ||||
| 192 | + <p className="mx-auto mt-5 max-w-2xl leading-relaxed text-ink-soft">{home.ctaBody}</p> | ||||
| 193 | + <Link | ||||
| 194 | + href={`/${typedLocale}/contact`} | ||||
| 195 | + className="mt-9 inline-block rounded-full bg-ink px-8 py-3.5 text-sm font-medium text-paper transition-colors hover:bg-accent-strong" | ||||
| 196 | + > | ||||
| 197 | + {home.ctaButton} | ||||
| 198 | + </Link> | ||||
| 199 | + </Container> | ||||
| 200 | + </section> | ||||
| 201 | + </> | ||||
| 202 | + ); | ||||
| 203 | +} | ||||
added
app/[locale]/privacy/page.tsx
+77 −0
@@ -0,0 +1,77 @@ | |||||
| 1 | +/** | ||||
| 2 | + * Author: Simon-Pierre Boucher | ||||
| 3 | + * Contact: contact@spboucher.ai | ||||
| 4 | + * Project: Hilmacorp.ai — Web Platform | ||||
| 5 | + * File: app/[locale]/privacy/page.tsx | ||||
| 6 | + * Description: Privacy Policy page — data practices plus the cookie inventory and preferences access | ||||
| 7 | + */ | ||||
| 8 | + | ||||
| 9 | +import type { Metadata } from "next"; | ||||
| 10 | +import LegalPage from "@/components/LegalPage"; | ||||
| 11 | +import ManageCookiesButton from "@/components/ManageCookiesButton"; | ||||
| 12 | +import { getMessages, isLocale, type Locale } from "@/lib/i18n"; | ||||
| 13 | + | ||||
| 14 | +export async function generateMetadata({ | ||||
| 15 | + params, | ||||
| 16 | +}: { | ||||
| 17 | + params: Promise<{ locale: string }>; | ||||
| 18 | +}): Promise<Metadata> { | ||||
| 19 | + const { locale } = await params; | ||||
| 20 | + if (!isLocale(locale)) return {}; | ||||
| 21 | + const { meta } = getMessages(locale); | ||||
| 22 | + return { title: { absolute: meta.privacy.title }, description: meta.privacy.description }; | ||||
| 23 | +} | ||||
| 24 | + | ||||
| 25 | +export default async function PrivacyPage({ | ||||
| 26 | + params, | ||||
| 27 | +}: { | ||||
| 28 | + params: Promise<{ locale: string }>; | ||||
| 29 | +}) { | ||||
| 30 | + const { locale } = await params; | ||||
| 31 | + const { privacy } = getMessages(locale as Locale); | ||||
| 32 | + const cookies = privacy.cookiesSection; | ||||
| 33 | + | ||||
| 34 | + return ( | ||||
| 35 | + <LegalPage | ||||
| 36 | + title={privacy.title} | ||||
| 37 | + lead={privacy.lead} | ||||
| 38 | + lastUpdated={privacy.lastUpdated} | ||||
| 39 | + sections={privacy.sections} | ||||
| 40 | + > | ||||
| 41 | + <section> | ||||
| 42 | + <h2 className="font-display text-xl font-semibold text-ink">{cookies.title}</h2> | ||||
| 43 | + <p className="mt-3 leading-relaxed text-ink-soft">{cookies.intro}</p> | ||||
| 44 | + <div className="mt-6 overflow-x-auto rounded-2xl border border-line"> | ||||
| 45 | + <table className="w-full border-collapse bg-card text-left text-sm"> | ||||
| 46 | + <thead> | ||||
| 47 | + <tr className="border-b border-line bg-paper-2"> | ||||
| 48 | + <th scope="col" className="px-5 py-3 font-semibold text-ink"> | ||||
| 49 | + {cookies.tableName} | ||||
| 50 | + </th> | ||||
| 51 | + <th scope="col" className="px-5 py-3 font-semibold text-ink"> | ||||
| 52 | + {cookies.tablePurpose} | ||||
| 53 | + </th> | ||||
| 54 | + <th scope="col" className="px-5 py-3 font-semibold text-ink"> | ||||
| 55 | + {cookies.tableDuration} | ||||
| 56 | + </th> | ||||
| 57 | + </tr> | ||||
| 58 | + </thead> | ||||
| 59 | + <tbody> | ||||
| 60 | + {cookies.rows.map((row) => ( | ||||
| 61 | + <tr key={row.name} className="border-b border-line last:border-b-0"> | ||||
| 62 | + <td className="px-5 py-3 font-mono text-xs text-ink">{row.name}</td> | ||||
| 63 | + <td className="px-5 py-3 text-ink-soft">{row.purpose}</td> | ||||
| 64 | + <td className="px-5 py-3 text-ink-soft">{row.duration}</td> | ||||
| 65 | + </tr> | ||||
| 66 | + ))} | ||||
| 67 | + </tbody> | ||||
| 68 | + </table> | ||||
| 69 | + </div> | ||||
| 70 | + <ManageCookiesButton | ||||
| 71 | + label={cookies.manage} | ||||
| 72 | + className="mt-6 rounded-full border border-line-strong px-6 py-2.5 text-sm font-medium text-ink transition-colors hover:border-accent hover:text-accent" | ||||
| 73 | + /> | ||||
| 74 | + </section> | ||||
| 75 | + </LegalPage> | ||||
| 76 | + ); | ||||
| 77 | +} | ||||
added
app/[locale]/security/page.tsx
+79 −0
@@ -0,0 +1,79 @@ | |||||
| 1 | +/** | ||||
| 2 | + * Author: Simon-Pierre Boucher | ||||
| 3 | + * Contact: contact@spboucher.ai | ||||
| 4 | + * Project: Hilmacorp.ai — Web Platform | ||||
| 5 | + * File: app/[locale]/security/page.tsx | ||||
| 6 | + * Description: Security & responsible deployment page — governance controls and practices | ||||
| 7 | + */ | ||||
| 8 | + | ||||
| 9 | +import type { Metadata } from "next"; | ||||
| 10 | +import Container from "@/components/Container"; | ||||
| 11 | +import SectionHeading from "@/components/SectionHeading"; | ||||
| 12 | +import { getMessages, isLocale, type Locale } from "@/lib/i18n"; | ||||
| 13 | + | ||||
| 14 | +export async function generateMetadata({ | ||||
| 15 | + params, | ||||
| 16 | +}: { | ||||
| 17 | + params: Promise<{ locale: string }>; | ||||
| 18 | +}): Promise<Metadata> { | ||||
| 19 | + const { locale } = await params; | ||||
| 20 | + if (!isLocale(locale)) return {}; | ||||
| 21 | + const { meta } = getMessages(locale); | ||||
| 22 | + return { title: { absolute: meta.security.title }, description: meta.security.description }; | ||||
| 23 | +} | ||||
| 24 | + | ||||
| 25 | +export default async function SecurityPage({ | ||||
| 26 | + params, | ||||
| 27 | +}: { | ||||
| 28 | + params: Promise<{ locale: string }>; | ||||
| 29 | +}) { | ||||
| 30 | + const { locale } = await params; | ||||
| 31 | + const { security } = getMessages(locale as Locale); | ||||
| 32 | + | ||||
| 33 | + return ( | ||||
| 34 | + <> | ||||
| 35 | + <section className="relative overflow-hidden border-b border-line"> | ||||
| 36 | + <div className="grid-backdrop absolute inset-0" aria-hidden="true" /> | ||||
| 37 | + <Container className="relative py-20 sm:py-28"> | ||||
| 38 | + <SectionHeading as="h1" title={security.title} lead={security.lead} /> | ||||
| 39 | + </Container> | ||||
| 40 | + </section> | ||||
| 41 | + | ||||
| 42 | + <section className="py-16 sm:py-20"> | ||||
| 43 | + <Container> | ||||
| 44 | + <ul className="grid gap-4 sm:grid-cols-2"> | ||||
| 45 | + {security.items.map((item) => ( | ||||
| 46 | + <li | ||||
| 47 | + key={item.title} | ||||
| 48 | + className="rounded-2xl border border-line bg-card p-7 shadow-sm" | ||||
| 49 | + > | ||||
| 50 | + <div className="flex items-start gap-4"> | ||||
| 51 | + <span | ||||
| 52 | + aria-hidden="true" | ||||
| 53 | + className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-accent-soft" | ||||
| 54 | + > | ||||
| 55 | + <svg width="16" height="16" viewBox="0 0 16 16" fill="none"> | ||||
| 56 | + <path | ||||
| 57 | + d="M8 1.5l5 2v4c0 3.2-2.1 5.6-5 7-2.9-1.4-5-3.8-5-7v-4l5-2z" | ||||
| 58 | + stroke="#0b6b4a" | ||||
| 59 | + strokeWidth="1.5" | ||||
| 60 | + strokeLinejoin="round" | ||||
| 61 | + /> | ||||
| 62 | + </svg> | ||||
| 63 | + </span> | ||||
| 64 | + <div> | ||||
| 65 | + <h2 className="text-base font-semibold text-ink">{item.title}</h2> | ||||
| 66 | + <p className="mt-2 text-sm leading-relaxed text-ink-faint">{item.body}</p> | ||||
| 67 | + </div> | ||||
| 68 | + </div> | ||||
| 69 | + </li> | ||||
| 70 | + ))} | ||||
| 71 | + </ul> | ||||
| 72 | + <p className="mx-auto mt-14 max-w-3xl border-l-2 border-accent pl-6 font-display text-xl leading-relaxed text-ink"> | ||||
| 73 | + {security.closing} | ||||
| 74 | + </p> | ||||
| 75 | + </Container> | ||||
| 76 | + </section> | ||||
| 77 | + </> | ||||
| 78 | + ); | ||||
| 79 | +} | ||||
added
app/[locale]/services/page.tsx
+119 −0
@@ -0,0 +1,119 @@ | |||||
| 1 | +/** | ||||
| 2 | + * Author: Simon-Pierre Boucher | ||||
| 3 | + * Contact: contact@spboucher.ai | ||||
| 4 | + * Project: Hilmacorp.ai — Web Platform | ||||
| 5 | + * File: app/[locale]/services/page.tsx | ||||
| 6 | + * Description: Services page — the eight practice areas as anchored sections, plus engagement model | ||||
| 7 | + */ | ||||
| 8 | + | ||||
| 9 | +import type { Metadata } from "next"; | ||||
| 10 | +import Link from "next/link"; | ||||
| 11 | +import Container from "@/components/Container"; | ||||
| 12 | +import SectionHeading from "@/components/SectionHeading"; | ||||
| 13 | +import { getMessages, isLocale, type Locale } from "@/lib/i18n"; | ||||
| 14 | + | ||||
| 15 | +export async function generateMetadata({ | ||||
| 16 | + params, | ||||
| 17 | +}: { | ||||
| 18 | + params: Promise<{ locale: string }>; | ||||
| 19 | +}): Promise<Metadata> { | ||||
| 20 | + const { locale } = await params; | ||||
| 21 | + if (!isLocale(locale)) return {}; | ||||
| 22 | + const { meta } = getMessages(locale); | ||||
| 23 | + return { title: { absolute: meta.services.title }, description: meta.services.description }; | ||||
| 24 | +} | ||||
| 25 | + | ||||
| 26 | +export default async function ServicesPage({ | ||||
| 27 | + params, | ||||
| 28 | +}: { | ||||
| 29 | + params: Promise<{ locale: string }>; | ||||
| 30 | +}) { | ||||
| 31 | + const { locale } = await params; | ||||
| 32 | + const typedLocale = locale as Locale; | ||||
| 33 | + const { services, nav } = getMessages(typedLocale); | ||||
| 34 | + | ||||
| 35 | + return ( | ||||
| 36 | + <> | ||||
| 37 | + <section className="relative overflow-hidden border-b border-line"> | ||||
| 38 | + <div className="grid-backdrop absolute inset-0" aria-hidden="true" /> | ||||
| 39 | + <Container className="relative py-20 sm:py-28"> | ||||
| 40 | + <SectionHeading as="h1" title={services.title} lead={services.lead} /> | ||||
| 41 | + <nav aria-label="Services index" className="mt-10 flex flex-wrap gap-2"> | ||||
| 42 | + {services.items.map((service) => ( | ||||
| 43 | + <a | ||||
| 44 | + key={service.id} | ||||
| 45 | + href={`#${service.id}`} | ||||
| 46 | + className="rounded-full border border-line-strong bg-card px-4 py-1.5 text-sm text-ink-soft transition-colors hover:border-accent hover:text-accent" | ||||
| 47 | + > | ||||
| 48 | + {service.title} | ||||
| 49 | + </a> | ||||
| 50 | + ))} | ||||
| 51 | + </nav> | ||||
| 52 | + </Container> | ||||
| 53 | + </section> | ||||
| 54 | + | ||||
| 55 | + <section className="py-16 sm:py-20"> | ||||
| 56 | + <Container> | ||||
| 57 | + <div className="grid gap-6 lg:grid-cols-2"> | ||||
| 58 | + {services.items.map((service, index) => ( | ||||
| 59 | + <article | ||||
| 60 | + key={service.id} | ||||
| 61 | + id={service.id} | ||||
| 62 | + className="scroll-mt-24 rounded-3xl border border-line bg-card p-8 shadow-sm sm:p-10" | ||||
| 63 | + > | ||||
| 64 | + <p className="font-mono text-sm text-accent"> | ||||
| 65 | + {String(index + 1).padStart(2, "0")} | ||||
| 66 | + </p> | ||||
| 67 | + <h2 className="mt-3 font-display text-2xl font-semibold tracking-tight text-ink"> | ||||
| 68 | + {service.title} | ||||
| 69 | + </h2> | ||||
| 70 | + <p className="mt-4 leading-relaxed text-ink-soft">{service.description}</p> | ||||
| 71 | + <ul className="mt-6 grid gap-2.5"> | ||||
| 72 | + {service.points.map((point) => ( | ||||
| 73 | + <li key={point} className="flex items-start gap-2.5 text-sm text-ink"> | ||||
| 74 | + <svg | ||||
| 75 | + width="16" | ||||
| 76 | + height="16" | ||||
| 77 | + viewBox="0 0 16 16" | ||||
| 78 | + fill="none" | ||||
| 79 | + aria-hidden="true" | ||||
| 80 | + className="mt-0.5 shrink-0" | ||||
| 81 | + > | ||||
| 82 | + <path | ||||
| 83 | + d="M3 8.5l3.2 3L13 4.5" | ||||
| 84 | + stroke="#0b6b4a" | ||||
| 85 | + strokeWidth="1.8" | ||||
| 86 | + strokeLinecap="round" | ||||
| 87 | + strokeLinejoin="round" | ||||
| 88 | + /> | ||||
| 89 | + </svg> | ||||
| 90 | + {point} | ||||
| 91 | + </li> | ||||
| 92 | + ))} | ||||
| 93 | + </ul> | ||||
| 94 | + </article> | ||||
| 95 | + ))} | ||||
| 96 | + </div> | ||||
| 97 | + </Container> | ||||
| 98 | + </section> | ||||
| 99 | + | ||||
| 100 | + {/* Engagement model */} | ||||
| 101 | + <section className="border-t border-line bg-ink py-16 sm:py-20"> | ||||
| 102 | + <Container className="text-center"> | ||||
| 103 | + <h2 className="font-display text-2xl font-semibold tracking-tight text-paper sm:text-3xl"> | ||||
| 104 | + {services.engagementTitle} | ||||
| 105 | + </h2> | ||||
| 106 | + <p className="mx-auto mt-4 max-w-3xl leading-relaxed text-paper/70"> | ||||
| 107 | + {services.engagementBody} | ||||
| 108 | + </p> | ||||
| 109 | + <Link | ||||
| 110 | + href={`/${typedLocale}/contact`} | ||||
| 111 | + className="mt-8 inline-block rounded-full bg-paper px-8 py-3.5 text-sm font-medium text-ink transition-colors hover:bg-accent-soft" | ||||
| 112 | + > | ||||
| 113 | + {nav.cta} | ||||
| 114 | + </Link> | ||||
| 115 | + </Container> | ||||
| 116 | + </section> | ||||
| 117 | + </> | ||||
| 118 | + ); | ||||
| 119 | +} | ||||
added
app/[locale]/terms/page.tsx
+40 −0
@@ -0,0 +1,40 @@ | |||||
| 1 | +/** | ||||
| 2 | + * Author: Simon-Pierre Boucher | ||||
| 3 | + * Contact: contact@spboucher.ai | ||||
| 4 | + * Project: Hilmacorp.ai — Web Platform | ||||
| 5 | + * File: app/[locale]/terms/page.tsx | ||||
| 6 | + * Description: Terms of Service page (bilingual legal document) | ||||
| 7 | + */ | ||||
| 8 | + | ||||
| 9 | +import type { Metadata } from "next"; | ||||
| 10 | +import LegalPage from "@/components/LegalPage"; | ||||
| 11 | +import { getMessages, isLocale, type Locale } from "@/lib/i18n"; | ||||
| 12 | + | ||||
| 13 | +export async function generateMetadata({ | ||||
| 14 | + params, | ||||
| 15 | +}: { | ||||
| 16 | + params: Promise<{ locale: string }>; | ||||
| 17 | +}): Promise<Metadata> { | ||||
| 18 | + const { locale } = await params; | ||||
| 19 | + if (!isLocale(locale)) return {}; | ||||
| 20 | + const { meta } = getMessages(locale); | ||||
| 21 | + return { title: { absolute: meta.terms.title }, description: meta.terms.description }; | ||||
| 22 | +} | ||||
| 23 | + | ||||
| 24 | +export default async function TermsPage({ | ||||
| 25 | + params, | ||||
| 26 | +}: { | ||||
| 27 | + params: Promise<{ locale: string }>; | ||||
| 28 | +}) { | ||||
| 29 | + const { locale } = await params; | ||||
| 30 | + const { terms } = getMessages(locale as Locale); | ||||
| 31 | + | ||||
| 32 | + return ( | ||||
| 33 | + <LegalPage | ||||
| 34 | + title={terms.title} | ||||
| 35 | + lead={terms.lead} | ||||
| 36 | + lastUpdated={terms.lastUpdated} | ||||
| 37 | + sections={terms.sections} | ||||
| 38 | + /> | ||||
| 39 | + ); | ||||
| 40 | +} | ||||
added
app/api/contact/route.ts
+77 −0
@@ -0,0 +1,77 @@ | |||||
| 1 | +/** | ||||
| 2 | + * Author: Simon-Pierre Boucher | ||||
| 3 | + * Contact: contact@spboucher.ai | ||||
| 4 | + * Project: Hilmacorp.ai — Web Platform | ||||
| 5 | + * File: app/api/contact/route.ts | ||||
| 6 | + * Description: Contact form API route — validates input, then delivers to contact@spboucher.ai | ||||
| 7 | + * via Resend when RESEND_API_KEY is set, otherwise persists to data/contact-messages.jsonl | ||||
| 8 | + */ | ||||
| 9 | + | ||||
| 10 | +import { NextResponse } from "next/server"; | ||||
| 11 | +import { appendFile, mkdir } from "node:fs/promises"; | ||||
| 12 | +import path from "node:path"; | ||||
| 13 | +import { validateContact } from "@/lib/validate"; | ||||
| 14 | + | ||||
| 15 | +export const runtime = "nodejs"; | ||||
| 16 | + | ||||
| 17 | +const CONTACT_TO = process.env.CONTACT_TO ?? "contact@spboucher.ai"; | ||||
| 18 | + | ||||
| 19 | +async function deliverByEmail(data: { | ||||
| 20 | + name: string; | ||||
| 21 | + organization: string; | ||||
| 22 | + email: string; | ||||
| 23 | + message: string; | ||||
| 24 | +}): Promise<boolean> { | ||||
| 25 | + const apiKey = process.env.RESEND_API_KEY; | ||||
| 26 | + if (!apiKey) return false; | ||||
| 27 | + const response = await fetch("https://api.resend.com/emails", { | ||||
| 28 | + method: "POST", | ||||
| 29 | + headers: { | ||||
| 30 | + Authorization: `Bearer ${apiKey}`, | ||||
| 31 | + "Content-Type": "application/json", | ||||
| 32 | + }, | ||||
| 33 | + body: JSON.stringify({ | ||||
| 34 | + from: process.env.CONTACT_FROM ?? "Hilmacorp.ai <onboarding@resend.dev>", | ||||
| 35 | + to: [CONTACT_TO], | ||||
| 36 | + reply_to: data.email, | ||||
| 37 | + subject: `[hilmacorp.ai] Contact — ${data.name}`, | ||||
| 38 | + text: `Name: ${data.name}\nOrganization: ${data.organization || "—"}\nEmail: ${data.email}\n\n${data.message}`, | ||||
| 39 | + }), | ||||
| 40 | + }); | ||||
| 41 | + return response.ok; | ||||
| 42 | +} | ||||
| 43 | + | ||||
| 44 | +async function persistLocally(data: object): Promise<void> { | ||||
| 45 | + const dir = path.join(process.cwd(), "data"); | ||||
| 46 | + await mkdir(dir, { recursive: true }); | ||||
| 47 | + const record = { receivedAt: new Date().toISOString(), to: CONTACT_TO, ...data }; | ||||
| 48 | + await appendFile(path.join(dir, "contact-messages.jsonl"), JSON.stringify(record) + "\n", "utf8"); | ||||
| 49 | +} | ||||
| 50 | + | ||||
| 51 | +export async function POST(request: Request) { | ||||
| 52 | + let body: unknown; | ||||
| 53 | + try { | ||||
| 54 | + body = await request.json(); | ||||
| 55 | + } catch { | ||||
| 56 | + return NextResponse.json({ ok: false, errors: { body: "invalid_json" } }, { status: 400 }); | ||||
| 57 | + } | ||||
| 58 | + | ||||
| 59 | + const result = validateContact(body); | ||||
| 60 | + if (!result.ok || !result.data) { | ||||
| 61 | + return NextResponse.json({ ok: false, errors: result.errors }, { status: 422 }); | ||||
| 62 | + } | ||||
| 63 | + | ||||
| 64 | + try { | ||||
| 65 | + const emailed = await deliverByEmail(result.data); | ||||
| 66 | + if (!emailed) await persistLocally(result.data); | ||||
| 67 | + return NextResponse.json({ ok: true }); | ||||
| 68 | + } catch (error) { | ||||
| 69 | + console.error("[contact] delivery failed:", error); | ||||
| 70 | + try { | ||||
| 71 | + await persistLocally(result.data); | ||||
| 72 | + return NextResponse.json({ ok: true }); | ||||
| 73 | + } catch { | ||||
| 74 | + return NextResponse.json({ ok: false, errors: { server: "delivery_failed" } }, { status: 500 }); | ||||
| 75 | + } | ||||
| 76 | + } | ||||
| 77 | +} | ||||
added
app/globals.css
+59 −0
@@ -0,0 +1,59 @@ | |||||
| 1 | +/** | ||||
| 2 | + * Author: Simon-Pierre Boucher | ||||
| 3 | + * Contact: contact@spboucher.ai | ||||
| 4 | + * Project: Hilmacorp.ai — Web Platform | ||||
| 5 | + * File: app/globals.css | ||||
| 6 | + * Description: Global styles — Tailwind v4 entry point, light editorial design tokens, base rules | ||||
| 7 | + */ | ||||
| 8 | + | ||||
| 9 | +@import "tailwindcss"; | ||||
| 10 | + | ||||
| 11 | +@theme { | ||||
| 12 | + /* Light editorial palette: warm paper base, deep green-black ink, deep emerald accent */ | ||||
| 13 | + --color-paper: #faf9f6; | ||||
| 14 | + --color-paper-2: #f2f0ea; | ||||
| 15 | + --color-card: #ffffff; | ||||
| 16 | + --color-line: #e7e3da; | ||||
| 17 | + --color-line-strong: #d3cec1; | ||||
| 18 | + --color-ink: #191f1c; | ||||
| 19 | + --color-ink-soft: #414c46; | ||||
| 20 | + --color-ink-faint: #6d7871; | ||||
| 21 | + --color-accent: #0b6b4a; | ||||
| 22 | + --color-accent-strong: #095c40; | ||||
| 23 | + --color-accent-soft: #e6f2ec; | ||||
| 24 | + | ||||
| 25 | + --font-sans: var(--font-inter), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; | ||||
| 26 | + --font-display: var(--font-fraunces), ui-serif, Georgia, serif; | ||||
| 27 | + --font-mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, monospace; | ||||
| 28 | +} | ||||
| 29 | + | ||||
| 30 | +html { | ||||
| 31 | + scroll-behavior: smooth; | ||||
| 32 | +} | ||||
| 33 | + | ||||
| 34 | +body { | ||||
| 35 | + background-color: var(--color-paper); | ||||
| 36 | + color: var(--color-ink); | ||||
| 37 | + -webkit-font-smoothing: antialiased; | ||||
| 38 | + text-rendering: optimizeLegibility; | ||||
| 39 | +} | ||||
| 40 | + | ||||
| 41 | +::selection { | ||||
| 42 | + background-color: var(--color-accent); | ||||
| 43 | + color: var(--color-paper); | ||||
| 44 | +} | ||||
| 45 | + | ||||
| 46 | +/* Subtle engineering-grid backdrop used by hero sections (light variant) */ | ||||
| 47 | +.grid-backdrop { | ||||
| 48 | + background-image: | ||||
| 49 | + linear-gradient(to right, rgb(25 31 28 / 0.045) 1px, transparent 1px), | ||||
| 50 | + linear-gradient(to bottom, rgb(25 31 28 / 0.045) 1px, transparent 1px); | ||||
| 51 | + background-size: 44px 44px; | ||||
| 52 | + mask-image: radial-gradient(ellipse 90% 75% at 50% 0%, black 35%, transparent 100%); | ||||
| 53 | +} | ||||
| 54 | + | ||||
| 55 | +/* Focus visibility for keyboard navigation (accessibility) */ | ||||
| 56 | +:focus-visible { | ||||
| 57 | + outline: 2px solid var(--color-accent); | ||||
| 58 | + outline-offset: 2px; | ||||
| 59 | +} | ||||
added
app/icon.svg
+5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added
app/robots.ts
+18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added
app/sitemap.ts
+28 −0
Large diff (28 lines) is not loaded by default to keep the page fast.Load diff
added
components/ContactForm.tsx
+154 −0
Large diff (154 lines) is not loaded by default to keep the page fast.Load diff
added
components/Container.tsx
+23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added
components/CookieConsent.tsx
+97 −0
Large diff (97 lines) is not loaded by default to keep the page fast.Load diff
added
components/Footer.tsx
+106 −0
Large diff (106 lines) is not loaded by default to keep the page fast.Load diff
added
components/Header.tsx
+138 −0
Large diff (138 lines) is not loaded by default to keep the page fast.Load diff
added
components/LegalPage.tsx
+64 −0
Large diff (64 lines) is not loaded by default to keep the page fast.Load diff
added
components/LocaleSwitcher.tsx
+37 −0
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
added
components/Logo.tsx
+28 −0
Large diff (28 lines) is not loaded by default to keep the page fast.Load diff
added
components/ManageCookiesButton.tsx
+29 −0
Large diff (29 lines) is not loaded by default to keep the page fast.Load diff
added
components/SectionHeading.tsx
+41 −0
Large diff (41 lines) is not loaded by default to keep the page fast.Load diff
added
lib/i18n.ts
+30 −0
Large diff (30 lines) is not loaded by default to keep the page fast.Load diff
added
lib/validate.ts
+47 −0
Large diff (47 lines) is not loaded by default to keep the page fast.Load diff
added
messages/en.json
+394 −0
Large diff (394 lines) is not loaded by default to keep the page fast.Load diff
added
messages/fr.json
+394 −0
Large diff (394 lines) is not loaded by default to keep the page fast.Load diff
added
middleware.ts
+41 −0
Large diff (41 lines) is not loaded by default to keep the page fast.Load diff
added
next.config.mjs
+15 −0
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added
package-lock.json
+1735 −0
Large diff (1,735 lines) is not loaded by default to keep the page fast.Load diff
added
package.json
+27 −0
Large diff (27 lines) is not loaded by default to keep the page fast.Load diff
added
postcss.config.mjs
+15 −0
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added
public/downloads/ZyquoAgent.dmg
+0 −0
Binary file not shown.
added
public/downloads/ZyquoCloud.dmg
+0 −0
Binary file not shown.
added
public/downloads/ZyquoLocal.dmg
+0 −0
Binary file not shown.
added
public/downloads/ZyquoRouter.dmg
+0 −0
Binary file not shown.
added
tests/contact-validation.test.mjs
+94 −0
Large diff (94 lines) is not loaded by default to keep the page fast.Load diff
added
tsconfig.json
+26 −0
Large diff (27 lines) is not loaded by default to keep the page fast.Load diff