diff --git a/.changeset/add-sitemap-options.md b/.changeset/add-sitemap-options.md new file mode 100644 index 000000000..02e9118bd --- /dev/null +++ b/.changeset/add-sitemap-options.md @@ -0,0 +1,10 @@ +--- +"@onruntime/next-sitemap": minor +--- + +Add new configuration options for sitemap generation: + +- `exclude`: Filter out routes using glob patterns or a function +- `priority`: Automatic depth-based priority calculation (or custom function) +- `changeFreq`: Set change frequency per route +- `additionalSitemaps`: Include custom sitemaps in the sitemap index diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 000000000..e422b8917 --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "master", + "updateInternalDependencies": "patch", + "ignore": ["@onruntime/web"] +} diff --git a/CLAUDE.md b/CLAUDE.md index c1e9471d3..543917870 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,12 +30,13 @@ pnpm --filter @onruntime/web build ### Web App Structure (`apps/web/src/`) -- **app/**: Next.js App Router pages and API routes +- **app/**: Next.js App Router pages and API routes (under `[locale]/`) - **components/**: React components (ui/, layout/, marketing/) - **services/**: External API clients with lazy initialization - **constants/**: Static data (projects, agencies, services, team members) - **content/**: MDX content (glossary, legal pages) - **lib/**: Utilities and helpers +- **locales/**: Translation files (see `locales/README.md` for conventions) - **types/**: TypeScript type definitions ### Key Patterns @@ -63,6 +64,17 @@ export const joinClient = { **API Routes**: Use `unstable_cache` from Next.js for caching external API responses. +**Translations**: Uses `@onruntime/translations` package. See `apps/web/src/locales/README.md` for full conventions. +```typescript +// Server Components +import { getTranslation } from "@/lib/translations.server"; +const { t } = await getTranslation("layout/footer"); + +// Client Components +import { useTranslation } from "@onruntime/translations/react"; +const { t } = useTranslation("layout/footer"); +``` + ## Environment Variables Required for runtime (optional for build): diff --git a/apps/web/env.ts b/apps/web/env.ts index b3a34e9c8..80ac42f0e 100644 --- a/apps/web/env.ts +++ b/apps/web/env.ts @@ -9,11 +9,10 @@ export const env = createEnv({ RESEND_API_KEY: z.string().optional(), }, client: { - NEXT_PUBLIC_APP_URL: z.string().optional(), + NEXT_PUBLIC_APP_URL: z.string().optional().default("https://onruntime.com"), }, runtimeEnv: { - NEXT_PUBLIC_APP_URL: - process.env.NEXT_PUBLIC_APP_URL || "https://onruntime.com", + NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, NODE_ENV: process.env.NODE_ENV, PORT: process.env.PORT, JOIN_API_KEY: process.env.JOIN_API_KEY, diff --git a/apps/web/next-sitemap.config.js b/apps/web/next-sitemap.config.js deleted file mode 100644 index 8124c1d4e..000000000 --- a/apps/web/next-sitemap.config.js +++ /dev/null @@ -1,54 +0,0 @@ -/** @type {import('next-sitemap').IConfig} */ -const config = { - siteUrl: process.env.NEXT_PUBLIC_APP_URL || "https://onruntime.com", - generateRobotsTxt: true, - robotsTxtOptions: { - policies: - process.env.VERCEL_ENV === "production" - ? [{ userAgent: "*", allow: "/" }] - : [{ userAgent: "*", disallow: "/" }], - }, - transform: async (config, path) => { - const locales = ["fr"]; - const defaultLocale = "fr"; - - const pathParts = path.split("/").filter(Boolean); - const hasLocale = locales.includes(pathParts[0]); - const pathDepth = hasLocale ? pathParts.length - 1 : pathParts.length; - - const calculatedPriority = Number( - Math.max(0.1, Math.min(1.0, 1.0 - pathDepth * 0.2)).toFixed(1), - ); - - const alternateRefs = locales.map((locale) => { - const pathParts = path.split("/"); - const hasLocale = locales.includes(pathParts[1]); - const pathWithoutLocale = hasLocale - ? "/" + pathParts.slice(2).join("/") - : path; - - if (locale === defaultLocale) { - return { - hreflang: locale, - href: `${process.env.NEXT_PUBLIC_APP_URL}${pathWithoutLocale}`, - hrefIsAbsolute: true, - }; - } else { - return { - hreflang: locale, - href: `${process.env.NEXT_PUBLIC_APP_URL}/${locale}${pathWithoutLocale}`, - hrefIsAbsolute: true, - }; - } - }); - - return { - loc: path, - lastmod: config.autoLastmod ? new Date().toISOString() : undefined, - priority: path === "/" ? 1.0 : calculatedPriority, - alternateRefs, - }; - }, -}; - -module.exports = config; diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 9288db978..3a60e3450 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -16,6 +16,14 @@ const nextConfig: NextConfig = { }); return config; }, + async rewrites() { + return [ + { + source: "/sitemap-:id.xml", + destination: "/sitemap.xml/:id", + }, + ]; + }, async redirects() { return [ { diff --git a/apps/web/package.json b/apps/web/package.json index 132714d92..212ae8580 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,7 +5,6 @@ "scripts": { "dev": "next dev --turbopack", "build": "next build", - "postbuild": "next-sitemap", "start": "next start", "type-check": "tsc --noEmit" }, @@ -15,8 +14,11 @@ "@mdx-js/loader": "^3.1.0", "@mdx-js/react": "^3.1.0", "@next/mdx": "^16.1.1", + "@onruntime/next-sitemap": "workspace:*", + "@onruntime/translations": "workspace:*", "@radix-ui/react-accordion": "^1.2.3", "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-navigation-menu": "^1.2.3", "@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-tabs": "^1.1.2", @@ -35,13 +37,13 @@ "next-mdx-remote": "^5.0.0", "next-mdx-remote-client": "^2.1.1", "next-seo": "^6.6.0", - "next-sitemap": "^4.2.3", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", "react-wrap-balancer": "^1.1.1", "reflect-metadata": "^0.2.2", "resend": "^4.1.2", + "server-only": "^0.0.1", "tailwind-merge": "^3.0.0", "tailwindcss-animate": "^1.0.7", "usehooks-ts": "^3.1.1", diff --git a/apps/web/src/app/(landing)/customer/page.ts b/apps/web/src/app/(landing)/customer/page.ts deleted file mode 100644 index 94e9a5265..000000000 --- a/apps/web/src/app/(landing)/customer/page.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { constructMetadata } from "@/lib/utils/metadata"; -import CustomerLanding from "@/screens/marketing/landing/customer"; - -export const metadata = constructMetadata({ - title: "Solutions digitales professionnelles pour votre entreprise", - description: "Votre partenaire digital pour le développement web, mobile et design UI/UX. Notre agence crée des solutions sur mesure qui répondent à vos objectifs commerciaux.", -}); - -export default CustomerLanding; diff --git a/apps/web/src/app/(landing)/page.ts b/apps/web/src/app/(landing)/page.ts deleted file mode 100644 index bbc00f056..000000000 --- a/apps/web/src/app/(landing)/page.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { constructMetadata } from "@/lib/utils/metadata"; -import VisitorLanding from "@/screens/marketing/landing/visitor"; - -export const metadata = constructMetadata({ - title: "Agence web de développement et design UI/UX à Paris et Rouen", - description: "Agence digitale spécialisée en développement web, mobile et design UI/UX. Notre équipe d'experts transforme vos idées en solutions digitales performantes.", -}); - -export default VisitorLanding; diff --git a/apps/web/src/app/[locale]/(landing)/customer/page.ts b/apps/web/src/app/[locale]/(landing)/customer/page.ts new file mode 100644 index 000000000..4ffed3831 --- /dev/null +++ b/apps/web/src/app/[locale]/(landing)/customer/page.ts @@ -0,0 +1,13 @@ +import { constructMetadata } from "@/lib/utils/metadata.server"; +import { getTranslation } from "@/lib/translations.server"; +import CustomerLanding from "@/screens/marketing/landing/customer"; + +export async function generateMetadata() { + const { t } = await getTranslation("app/landing/customer/page"); + return constructMetadata({ + title: t("metadata.title"), + description: t("metadata.description"), + }); +} + +export default CustomerLanding; diff --git a/apps/web/src/app/(landing)/layout.tsx b/apps/web/src/app/[locale]/(landing)/layout.tsx similarity index 100% rename from apps/web/src/app/(landing)/layout.tsx rename to apps/web/src/app/[locale]/(landing)/layout.tsx diff --git a/apps/web/src/app/[locale]/(landing)/page.ts b/apps/web/src/app/[locale]/(landing)/page.ts new file mode 100644 index 000000000..855ddf188 --- /dev/null +++ b/apps/web/src/app/[locale]/(landing)/page.ts @@ -0,0 +1,13 @@ +import { constructMetadata } from "@/lib/utils/metadata.server"; +import { getTranslation } from "@/lib/translations.server"; +import VisitorLanding from "@/screens/marketing/landing/visitor"; + +export async function generateMetadata() { + const { t } = await getTranslation("app/landing/page"); + return constructMetadata({ + title: t("metadata.title"), + description: t("metadata.description"), + }); +} + +export default VisitorLanding; diff --git a/apps/web/src/app/(legal)/company/page.tsx b/apps/web/src/app/[locale]/(legal)/company/page.tsx similarity index 56% rename from apps/web/src/app/(legal)/company/page.tsx rename to apps/web/src/app/[locale]/(legal)/company/page.tsx index 57ae1ef7a..f8fec2d00 100644 --- a/apps/web/src/app/(legal)/company/page.tsx +++ b/apps/web/src/app/[locale]/(legal)/company/page.tsx @@ -1,20 +1,26 @@ import LegalPage from "@/components/marketing/legal/page" import { getPageContent } from "@/lib/mdx" -import { constructMetadata } from "@/lib/utils/metadata" +import { constructMetadata } from "@/lib/utils/metadata.server" import type { Metadata } from "next" const contentPath = "legal/company" -export async function generateMetadata(): Promise { - const { frontmatter } = await getPageContent(contentPath) +type PageProps = { + params: Promise<{ locale: string }> +} + +export async function generateMetadata({ params }: PageProps): Promise { + const { locale } = await params + const { frontmatter } = await getPageContent(contentPath, locale) return constructMetadata({ title: `${frontmatter.title}`, description: frontmatter.description, }) } -const CompanyPage = async () => { - const { frontmatter, content } = await getPageContent(contentPath) +const CompanyPage = async ({ params }: PageProps) => { + const { locale } = await params + const { frontmatter, content } = await getPageContent(contentPath, locale) return } diff --git a/apps/web/src/app/(legal)/privacy/page.tsx b/apps/web/src/app/[locale]/(legal)/privacy/page.tsx similarity index 53% rename from apps/web/src/app/(legal)/privacy/page.tsx rename to apps/web/src/app/[locale]/(legal)/privacy/page.tsx index 704963347..a1f50bb33 100644 --- a/apps/web/src/app/(legal)/privacy/page.tsx +++ b/apps/web/src/app/[locale]/(legal)/privacy/page.tsx @@ -1,22 +1,28 @@ import LegalPage from "@/components/marketing/legal/page" import { getPageContent } from "@/lib/mdx" -import { constructMetadata } from "@/lib/utils/metadata" +import { constructMetadata } from "@/lib/utils/metadata.server" import type { Metadata } from "next" const contentPath = "legal/privacy" -export async function generateMetadata(): Promise { - const { frontmatter } = await getPageContent(contentPath) +type PageProps = { + params: Promise<{ locale: string }> +} + +export async function generateMetadata({ params }: PageProps): Promise { + const { locale } = await params + const { frontmatter } = await getPageContent(contentPath, locale) return constructMetadata({ title: `${frontmatter.title}`, description: frontmatter.description, }) } -const PrivacyPage = async () => { - const { frontmatter, content } = await getPageContent(contentPath) +const PrivacyPage = async ({ params }: PageProps) => { + const { locale } = await params + const { frontmatter, content } = await getPageContent(contentPath, locale) return } -export default PrivacyPage \ No newline at end of file +export default PrivacyPage diff --git a/apps/web/src/app/(legal)/terms/page.tsx b/apps/web/src/app/[locale]/(legal)/terms/page.tsx similarity index 53% rename from apps/web/src/app/(legal)/terms/page.tsx rename to apps/web/src/app/[locale]/(legal)/terms/page.tsx index c34b0668e..82c50b5e4 100644 --- a/apps/web/src/app/(legal)/terms/page.tsx +++ b/apps/web/src/app/[locale]/(legal)/terms/page.tsx @@ -1,22 +1,28 @@ import LegalPage from "@/components/marketing/legal/page" import { getPageContent } from "@/lib/mdx" -import { constructMetadata } from "@/lib/utils/metadata" +import { constructMetadata } from "@/lib/utils/metadata.server" import type { Metadata } from "next" const contentPath = "legal/terms" -export async function generateMetadata(): Promise { - const { frontmatter } = await getPageContent(contentPath) +type PageProps = { + params: Promise<{ locale: string }> +} + +export async function generateMetadata({ params }: PageProps): Promise { + const { locale } = await params + const { frontmatter } = await getPageContent(contentPath, locale) return constructMetadata({ title: `${frontmatter.title}`, description: frontmatter.description, }) } -const TermsPage = async () => { - const { frontmatter, content } = await getPageContent(contentPath) +const TermsPage = async ({ params }: PageProps) => { + const { locale } = await params + const { frontmatter, content } = await getPageContent(contentPath, locale) return } -export default TermsPage \ No newline at end of file +export default TermsPage diff --git a/apps/web/src/app/[...not_found]/page.ts b/apps/web/src/app/[locale]/[...not_found]/page.ts similarity index 100% rename from apps/web/src/app/[...not_found]/page.ts rename to apps/web/src/app/[locale]/[...not_found]/page.ts diff --git a/apps/web/src/app/agency/[city]/page.tsx b/apps/web/src/app/[locale]/agency/[city]/page.tsx similarity index 61% rename from apps/web/src/app/agency/[city]/page.tsx rename to apps/web/src/app/[locale]/agency/[city]/page.tsx index 9333f53ab..924dda604 100644 --- a/apps/web/src/app/agency/[city]/page.tsx +++ b/apps/web/src/app/[locale]/agency/[city]/page.tsx @@ -1,8 +1,9 @@ -import React from 'react'; import Image from 'next/image'; import { notFound } from 'next/navigation'; import { Metadata } from 'next'; -import { constructMetadata } from '@/lib/utils/metadata'; +import { Link } from '@onruntime/translations/next'; +import { constructMetadata } from '@/lib/utils/metadata.server'; +import { getTranslation } from '@/lib/translations.server'; import CityHeroSection from '@/components/marketing/agency/city-hero-section'; import LocalExpertise from '@/components/marketing/agency/local-expertise'; import LocalPortfolio from '@/components/marketing/agency/local-portfolio'; @@ -11,7 +12,8 @@ import { getAgencyById } from '@/constants/agencies'; import { LocalBusinessSchema } from '@/components/json-ld/localbusiness-schema'; import { BreadcrumbSchema } from '@/components/json-ld/breadcrumb-schema'; import { FAQPageSchema } from '@/components/json-ld/faqpage-schema'; -import FAQSection from '@/components/marketing/services/faq-section'; +import { ORGANIZATION_DATA } from '@/components/json-ld/constants'; +import AgencyFAQSection from '@/components/marketing/agency/agency-faq-section'; import Routes from '@/constants/routes'; type AgencyPageProps = { @@ -23,23 +25,24 @@ export async function generateMetadata({ }: { params: Promise<{ city: string }>; }): Promise { - const { city } = await params; const cityLower = city.toLowerCase(); - const agency = getAgencyById(cityLower); - + const { t } = await getTranslation('app/agency/[city]/page'); + if (!agency) { return constructMetadata({ - title: "Expertise locale non disponible", - description: "Nous n'avons pas encore d'expertise spécifique pour cette ville.", - noIndex: true + title: t('metadata.not-found.title'), + description: t('metadata.not-found.description'), + noIndex: true, }); } + const { t: tAgency } = await getTranslation(`constants/agencies/${agency.id}`); + return constructMetadata({ - title: agency.title, - description: agency.description, + title: tAgency('title'), + description: tAgency('description'), }); } @@ -53,57 +56,60 @@ export default async function CityPage({ params }: AgencyPageProps) { notFound(); } + const { t } = await getTranslation('app/agency/[city]/page'); + const { t: tAgency } = await getTranslation(`constants/agencies/${agency.id}`); + const faqItems = [ { - questionName: `Comment choisir la bonne agence web à ${agency.name} ?`, - acceptedAnswerText: `Pour choisir la bonne agence web à ${agency.name}, évaluez leur connaissance du marché local, leur portfolio dans votre secteur d'activité, et leur capacité à comprendre vos objectifs spécifiques. L'expertise dans les défis numériques propres à la région ${agency.region} est également un facteur clé.` + questionName: t('faq.questions.choose-agency.question', { city: agency.name }), + acceptedAnswerText: t('faq.questions.choose-agency.answer', { city: agency.name, region: agency.region }) }, { - questionName: `Quels sont les coûts d'un projet web à ${agency.name} ?`, - acceptedAnswerText: `Les coûts d'un projet web à ${agency.name} varient selon la complexité, les fonctionnalités et les objectifs. Pour un site vitrine professionnel, comptez entre 3 000€ et 8 000€. Pour une application web ou e-commerce, les tarifs débutent généralement à 10 000€. Demandez un devis personnalisé pour une estimation précise.` + questionName: t('faq.questions.project-costs.question', { city: agency.name }), + acceptedAnswerText: t('faq.questions.project-costs.answer', { city: agency.name }) }, { - questionName: `Combien de temps faut-il pour développer un site web ou une application pour mon entreprise à ${agency.name} ?`, - acceptedAnswerText: `Les délais de développement pour une entreprise à ${agency.name} dépendent de la complexité du projet. Un site vitrine peut être réalisé en 4-8 semaines, tandis qu'une application sur mesure ou un e-commerce nécessite généralement 2-4 mois. Notre méthodologie agile permet des livraisons progressives pour voir rapidement les avancées.` + questionName: t('faq.questions.development-time.question', { city: agency.name }), + acceptedAnswerText: t('faq.questions.development-time.answer', { city: agency.name }) }, { - questionName: `Pouvez-vous travailler efficacement avec mon entreprise à ${agency.name} à distance ?`, - acceptedAnswerText: `Absolument. Nous collaborons efficacement avec les entreprises de ${agency.name} grâce à notre méthodologie éprouvée de travail à distance. Visioconférences régulières, outils collaboratifs performants et notre connaissance approfondie du marché local de ${agency.region} nous permettent d'assurer un suivi aussi efficace qu'avec une équipe sur place.` + questionName: t('faq.questions.remote-work.question', { city: agency.name }), + acceptedAnswerText: t('faq.questions.remote-work.answer', { city: agency.name, region: agency.region }) } ]; - + return (
- - - - - + - +
@@ -111,16 +117,16 @@ export default async function CityPage({ params }: AgencyPageProps) {

- Défis numériques des entreprises à {agency.name} + {t('challenges.title', { city: agency.name })}

- Les entreprises de {agency.name} font face à des défis spécifiques en matière de transformation digitale. Notre expertise nous permet d'y répondre efficacement. + {t('challenges.description', { city: agency.name })}

- +
-

Enjeux du marché {agency.region}

+

{t('challenges.market-issues', { region: agency.region })}

    {agency.localChallenges.map((challenge, index) => (
  • @@ -134,14 +140,14 @@ export default async function CityPage({ params }: AgencyPageProps) {
-

{challenge}

+

{tAgency(`local-challenges.${challenge}`)}

))}
- +
-

Nos solutions adaptées

+

{t('challenges.our-solutions')}

{agency.focusedServices.slice(0, 3).map((service, index) => { const Icon = service.icon; return ( @@ -152,55 +158,48 @@ export default async function CityPage({ params }: AgencyPageProps) {
)}
-

{service.name}

-

{service.description}

+

{tAgency(`focused-services.${service.key}.name`)}

+

{tAgency(`focused-services.${service.key}.description`)}

); })} - - Découvrir tous nos services + {t('challenges.discover-all-services')} - +
- + - ({ - question: faq.questionName, - answer: faq.acceptedAnswerText - }))} - /> + {agency.testimonials && agency.testimonials.length > 0 && (

- Ce que disent nos clients à {agency.name} + {t('testimonials.title', { city: agency.name })}

- Découvrez les retours d'entreprises locales avec lesquelles nous avons collaboré. + {t('testimonials.description')}

{agency.testimonials.map((testimonial, index) => (
-

"{testimonial.text}"

+

"{tAgency(`testimonials.${testimonial.key}.text`)}"

- {testimonial.imageUrl ? ( - {testimonial.name} + {testimonial.author.imageUrl ? ( + {testimonial.author.name} ) : ( @@ -209,8 +208,8 @@ export default async function CityPage({ params }: AgencyPageProps) { )}
-

{testimonial.name}

-

{testimonial.role} - {testimonial.company}

+

{testimonial.author.name}

+

{testimonial.author.role} - {testimonial.author.company}

@@ -218,7 +217,7 @@ export default async function CityPage({ params }: AgencyPageProps) {
)} - +
@@ -226,10 +225,10 @@ export default async function CityPage({ params }: AgencyPageProps) { } export async function generateStaticParams() { - + const { default: agencies } = await import('@/constants/agencies'); - + return agencies.map((agency) => ({ city: agency.id, })); -} \ No newline at end of file +} diff --git a/apps/web/src/app/agency/page.tsx b/apps/web/src/app/[locale]/agency/page.tsx similarity index 62% rename from apps/web/src/app/agency/page.tsx rename to apps/web/src/app/[locale]/agency/page.tsx index da712936e..8e8258a57 100644 --- a/apps/web/src/app/agency/page.tsx +++ b/apps/web/src/app/[locale]/agency/page.tsx @@ -1,54 +1,60 @@ -import React from 'react'; -import Link from 'next/link'; +import { Link } from '@onruntime/translations/next'; import { Button } from "@/components/ui/button"; import DotPattern from "@/components/ui/dot-pattern"; import { cn } from "@/lib/utils"; import { ArrowRight, Globe, Laptop, Users } from "lucide-react"; import Routes from "@/constants/routes"; -import { constructMetadata } from '@/lib/utils/metadata'; +import { constructMetadata } from '@/lib/utils/metadata.server'; import FranceMap from '@/components/marketing/agency/france-map'; +import AgencyCard from '@/components/marketing/agency/agency-card'; import { getMajorAgencies } from '@/constants/agencies'; import { OrganizationSchema } from '@/components/json-ld/organization-schema'; +import { ORGANIZATION_DATA } from '@/components/json-ld/constants'; +import { getTranslation } from '@/lib/translations.server'; -export const metadata = constructMetadata({ - title: "Expertise web locale | Développement digital dans les grandes villes françaises", - description: "Nous accompagnons les entreprises locales partout en France avec notre expertise des marchés numériques régionaux. Solutions digitales adaptées à chaque région.", -}); +export async function generateMetadata() { + const { t } = await getTranslation('app/agency/page'); + return constructMetadata({ + title: t('metadata.title'), + description: t('metadata.description'), + }); +} + +export default async function AgencyLandingPage() { + const { t } = await getTranslation('app/agency/page'); -export default function AgencyLandingPage() { - const majorAgencies = getMajorAgencies(10); - + return (
- - +
- +

- Votre partenaire digital partout en France + {t('hero.title')}

- +

- Nous développons des solutions digitales innovantes adaptées aux spécificités de chaque marché régional français. Notre équipe maîtrise les enjeux numériques locaux pour vous accompagner efficacement, où que vous soyez en France. + {t('hero.description')}

- +
@@ -73,10 +79,10 @@ export default function AgencyLandingPage() {

- Une expertise adaptée à chaque territoire + {t('expertise.title')}

- Notre équipe analyse et comprend les écosystèmes numériques locaux pour vous proposer des solutions parfaitement adaptées aux spécificités économiques et culturelles de votre région. + {t('expertise.description')}

@@ -85,9 +91,9 @@ export default function AgencyLandingPage() {
-

Compréhension des marchés locaux

+

{t('expertise.local-markets.title')}

- Nous étudions et maîtrisons les spécificités et les tendances des écosystèmes numériques régionaux. + {t('expertise.local-markets.description')}

@@ -95,9 +101,9 @@ export default function AgencyLandingPage() {
-

Travail à distance efficace

+

{t('expertise.remote-work.title')}

- Notre équipe collabore à distance avec votre entreprise, où que vous soyez en France, avec la même efficacité qu'en présentiel. + {t('expertise.remote-work.description')}

@@ -105,9 +111,9 @@ export default function AgencyLandingPage() {
-

Solutions personnalisées

+

{t('expertise.custom-solutions.title')}

- Des stratégies digitales sur mesure qui s'adaptent à votre contexte local et aux particularités de votre marché régional. + {t('expertise.custom-solutions.description')}

@@ -116,41 +122,16 @@ export default function AgencyLandingPage() {

- Notre expertise dans les grandes villes françaises + {t('cities.title')}

- Découvrez notre connaissance approfondie des marchés locaux et comment elle peut bénéficier à votre entreprise. + {t('cities.description')}

{majorAgencies.map((agency) => ( - -

- {agency.name} -

-

- Région: {agency.region} -

-
- {agency.strengths.slice(0, 3).map((strength, index) => ( - - {strength.title} - - ))} -
-
- Découvrir notre expertise - -
- + ))}
@@ -158,39 +139,39 @@ export default function AgencyLandingPage() {

- Pourquoi travailler avec nous pour votre projet local ? + {t('why-us.title')}

- Notre connaissance des marchés régionaux présente de nombreux avantages pour votre entreprise. + {t('why-us.description')}

-

Expertise des écosystèmes locaux

+

{t('why-us.local-ecosystems.title')}

- Une équipe qui comprend les dynamiques de marché propres à votre région, permettant une stratégie digitale parfaitement adaptée. + {t('why-us.local-ecosystems.description')}

- +
-

Agilité à distance

+

{t('why-us.remote-agility.title')}

- Collaboration efficace par visioconférence, messagerie et outils collaboratifs pour une expérience de travail fluide et productive. + {t('why-us.remote-agility.description')}

- +
-

Stratégies localisées

+

{t('why-us.localized-strategies.title')}

- Des solutions digitales qui tiennent compte des spécificités culturelles et économiques de votre territoire. + {t('why-us.localized-strategies.description')}

- +
-

Analyse de marché régional

+

{t('why-us.regional-analysis.title')}

- Étude des tendances, de la concurrence et des opportunités propres à votre secteur géographique pour une stratégie digitale optimale. + {t('why-us.regional-analysis.description')}

@@ -199,46 +180,46 @@ export default function AgencyLandingPage() {

- Nos services pour toutes les régions de France + {t('services.title')}

- Des solutions digitales complètes adaptées aux spécificités de chaque marché local. + {t('services.description')}

-

Design UI/UX

+

{t('services.design.title')}

- Interfaces adaptées aux usages locaux + {t('services.design.description')}

- +
-

Intégration

+

{t('services.integration.title')}

- Solutions CMS et e-commerce régionales + {t('services.integration.description')}

- +
-

Front-end

+

{t('services.frontend.title')}

- Applications adaptées à vos utilisateurs + {t('services.frontend.description')}

- +
-

Back-end

+

{t('services.backend.title')}

- Infrastructures robustes et évolutives + {t('services.backend.description')}

@@ -247,7 +228,7 @@ export default function AgencyLandingPage() {
@@ -258,21 +239,21 @@ export default function AgencyLandingPage() {

- Un projet digital adapté à votre marché local ? + {t('cta.title')}

- Contactez-nous pour discuter de votre projet. Notre expertise des marchés locaux nous permet de vous accompagner efficacement, où que vous soyez en France. + {t('cta.description')}

@@ -281,4 +262,4 @@ export default function AgencyLandingPage() {
); -} \ No newline at end of file +} diff --git a/apps/web/src/app/[locale]/careers/[id]/page.tsx b/apps/web/src/app/[locale]/careers/[id]/page.tsx new file mode 100644 index 000000000..11f11a22a --- /dev/null +++ b/apps/web/src/app/[locale]/careers/[id]/page.tsx @@ -0,0 +1,64 @@ +import { notFound } from "next/navigation"; +import { constructMetadata } from "@/lib/utils/metadata.server"; +import { getTranslation } from "@/lib/translations.server"; +import JobDetailPage from "@/screens/marketing/careers/job-details"; +import { JobPosting } from "@/types/job"; +import { Metadata } from "next"; +import { env } from "env"; + +async function getJobById(id: string, locale: string): Promise { + try { + const baseUrl = env.NEXT_PUBLIC_APP_URL; + + const res = await fetch(`${baseUrl}/api/careers/${id}?locale=${locale}`, { + cache: "no-cache", + }); + + if (!res.ok) { + return null; + } + + const data = await res.json(); + return data.job; + } catch (error) { + console.error("Error fetching job:", error); + return null; + } +} + +export async function generateMetadata(props: { + params: Promise<{ id: string; locale: string }>; +}): Promise { + const params = await props.params; + const { id, locale } = params; + const job = await getJobById(id, locale); + const { t } = await getTranslation("app/careers/[id]/page"); + + if (!job) { + return constructMetadata({ + title: t("metadata.not-found.title"), + description: t("metadata.not-found.description"), + }); + } + + return constructMetadata({ + title: t("metadata.title", { jobTitle: job.title }), + description: + job.shortDescription || + t("metadata.description", { jobTitle: job.title }), + }); +} + +export default async function Page(props: { + params: Promise<{ id: string; locale: string }>; +}) { + const params = await props.params; + const { id, locale } = params; + const job = await getJobById(id, locale); + + if (!job) { + notFound(); + } + + return ; +} diff --git a/apps/web/src/app/[locale]/careers/page.tsx b/apps/web/src/app/[locale]/careers/page.tsx new file mode 100644 index 000000000..c6380ed07 --- /dev/null +++ b/apps/web/src/app/[locale]/careers/page.tsx @@ -0,0 +1,14 @@ +import { constructMetadata } from "@/lib/utils/metadata.server"; +import { getTranslation } from "@/lib/translations.server"; +import CareersPage from "@/screens/marketing/careers"; + +export async function generateMetadata() { + const { t } = await getTranslation("app/careers/page"); + + return constructMetadata({ + title: t("metadata.title"), + description: t("metadata.description"), + }); +} + +export default CareersPage; diff --git a/apps/web/src/app/[locale]/contact/page.tsx b/apps/web/src/app/[locale]/contact/page.tsx new file mode 100644 index 000000000..9027f713c --- /dev/null +++ b/apps/web/src/app/[locale]/contact/page.tsx @@ -0,0 +1,13 @@ +import { constructMetadata } from "@/lib/utils/metadata.server"; +import { getTranslation } from "@/lib/translations.server"; +import ContactPage from "@/screens/marketing/contact"; + +export async function generateMetadata() { + const { t } = await getTranslation("app/contact/page"); + return constructMetadata({ + title: t("metadata.title"), + description: t("metadata.description"), + }); +} + +export default ContactPage; diff --git a/apps/web/src/app/glossary/[letter]/[term]/page.tsx b/apps/web/src/app/[locale]/glossary/[letter]/[term]/page.tsx similarity index 70% rename from apps/web/src/app/glossary/[letter]/[term]/page.tsx rename to apps/web/src/app/[locale]/glossary/[letter]/[term]/page.tsx index 9e6f6098c..e8ff1ffae 100644 --- a/apps/web/src/app/glossary/[letter]/[term]/page.tsx +++ b/apps/web/src/app/[locale]/glossary/[letter]/[term]/page.tsx @@ -1,7 +1,7 @@ -import React from 'react'; import { notFound } from 'next/navigation'; import { getGlossaryEntry, getRelatedEntries } from "@/lib/glossary"; -import { constructMetadata } from "@/lib/utils/metadata"; +import { constructMetadata } from "@/lib/utils/metadata.server"; +import { getTranslation } from "@/lib/translations.server"; import GlossaryEntryPage from '@/components/glossary/entry-page'; interface TermPageProps { @@ -11,21 +11,22 @@ interface TermPageProps { }>; } -export async function generateMetadata({ - params +export async function generateMetadata({ + params }: TermPageProps) { + const { t } = await getTranslation('app/glossary/page'); const { letter, term } = await params; const entry = await getGlossaryEntry(letter, term); - + if (!entry) { return constructMetadata({ - title: "Terme non trouvé | Glossaire", - description: "Ce terme n'existe pas dans notre glossaire.", + title: t('term.metadata.not-found.title'), + description: t('term.metadata.not-found.description'), }); } return constructMetadata({ - title: `${entry.term} | Glossaire`, + title: `${entry.term} | ${t('metadata.title')}`, description: entry.shortDescription, }); } diff --git a/apps/web/src/app/glossary/[letter]/page.tsx b/apps/web/src/app/[locale]/glossary/[letter]/page.tsx similarity index 63% rename from apps/web/src/app/glossary/[letter]/page.tsx rename to apps/web/src/app/[locale]/glossary/[letter]/page.tsx index 0b0fe0a4b..e4e71daee 100644 --- a/apps/web/src/app/glossary/[letter]/page.tsx +++ b/apps/web/src/app/[locale]/glossary/[letter]/page.tsx @@ -1,7 +1,8 @@ import React from 'react'; import { notFound } from 'next/navigation'; import { getEntriesByLetter } from "@/lib/glossary"; -import { constructMetadata } from "@/lib/utils/metadata"; +import { constructMetadata } from "@/lib/utils/metadata.server"; +import { getTranslation } from "@/lib/translations.server"; import GlossaryLetterPage from '@/components/glossary/letter-page'; interface LetterPageProps { @@ -10,22 +11,23 @@ interface LetterPageProps { }>; } -export async function generateMetadata({ - params +export async function generateMetadata({ + params }: LetterPageProps) { + const { t } = await getTranslation('app/glossary/page'); const { letter } = await params; const sanitizedLetter = letter.toLowerCase(); - + if (!sanitizedLetter.match(/^[a-z]$/)) { return constructMetadata({ - title: "Lettre non valide | Glossaire", - description: "Cette lettre n'est pas valide.", + title: t('letter.metadata.invalid.title'), + description: t('letter.metadata.invalid.description'), }); } - + return constructMetadata({ - title: `Termes commençant par ${sanitizedLetter.toUpperCase()} | Glossaire`, - description: `Découvrez tous les termes du glossaire commençant par la lettre ${sanitizedLetter.toUpperCase()}.`, + title: t('letter.metadata.title', { letter: sanitizedLetter.toUpperCase() }), + description: t('letter.metadata.description', { letter: sanitizedLetter.toUpperCase() }), }); } diff --git a/apps/web/src/app/glossary/page.tsx b/apps/web/src/app/[locale]/glossary/page.tsx similarity index 84% rename from apps/web/src/app/glossary/page.tsx rename to apps/web/src/app/[locale]/glossary/page.tsx index 7bc7bf55e..976ff4bc9 100644 --- a/apps/web/src/app/glossary/page.tsx +++ b/apps/web/src/app/[locale]/glossary/page.tsx @@ -1,27 +1,31 @@ -import React from 'react'; -import Link from 'next/link'; +import { Link } from '@onruntime/translations/next'; import { Button } from "@/components/ui/button"; import DotPattern from "@/components/ui/dot-pattern"; import { cn } from "@/lib/utils"; import { getAllGlossaryEntries } from "@/lib/glossary"; -import { constructMetadata } from "@/lib/utils/metadata"; +import { constructMetadata } from "@/lib/utils/metadata.server"; import { GlossaryAlphabetNav } from "@/components/glossary/alphabet-nav"; import { GlossarySearch } from "@/components/glossary/search"; import { Badge } from "@/components/ui/badge"; import { Tag as TagIcon } from "lucide-react"; +import { getTranslation } from "@/lib/translations.server"; -export const metadata = constructMetadata({ - title: "Glossaire du Développement et Design Web", - description: "Consultez notre glossaire complet des termes techniques en développement web, design UI/UX et gestion de projet digital.", -}); +export async function generateMetadata() { + const { t } = await getTranslation('app/glossary/page'); + return constructMetadata({ + title: t('metadata.title'), + description: t('metadata.description'), + }); +} export default async function GlossaryPage() { + const { t } = await getTranslation('app/glossary/page'); const entries = await getAllGlossaryEntries(); - - // Extraction des tags uniques pour le filtrage + + // Extract unique tags for filtering const uniqueTags = [...new Set(entries.flatMap(entry => entry.tags || []))].sort(); - - // Regroupement par lettre pour l'affichage + + // Group entries by letter for display const entriesByLetter = entries.reduce((acc, entry) => { const letter = entry.letter.toLowerCase(); if (!acc[letter]) { @@ -31,7 +35,7 @@ export default async function GlossaryPage() { return acc; }, {} as Record); - // Récupérer les lettres pour lesquelles nous avons des entrées + // Get letters for which we have entries const availableLetters = Object.keys(entriesByLetter).sort(); return ( @@ -40,12 +44,11 @@ export default async function GlossaryPage() { {/* Hero Section */}

- Glossaire + {t('hero.title')}

- +

- Explorez notre glossaire complet des termes techniques en développement web, - design UI/UX et gestion de projet digital. + {t('hero.description')}

@@ -133,10 +136,10 @@ export default async function GlossaryPage() { {availableLetters.length === 0 && (

- Aucun terme n'a encore été ajouté au glossaire. + {t('empty.title')}

- Revenez bientôt pour découvrir notre glossaire complet. + {t('empty.subtitle')}

)} diff --git a/apps/web/src/app/glossary/tag/[tag]/page.tsx b/apps/web/src/app/[locale]/glossary/tag/[tag]/page.tsx similarity index 65% rename from apps/web/src/app/glossary/tag/[tag]/page.tsx rename to apps/web/src/app/[locale]/glossary/tag/[tag]/page.tsx index 272acbc4c..b2eb17e12 100644 --- a/apps/web/src/app/glossary/tag/[tag]/page.tsx +++ b/apps/web/src/app/[locale]/glossary/tag/[tag]/page.tsx @@ -1,7 +1,7 @@ -import React from 'react'; import { notFound } from 'next/navigation'; import { getEntriesByTag } from "@/lib/glossary"; -import { constructMetadata } from "@/lib/utils/metadata"; +import { constructMetadata } from "@/lib/utils/metadata.server"; +import { getTranslation } from "@/lib/translations.server"; import GlossaryTagPage from '@/components/glossary/tag-page'; interface TagPageProps { @@ -10,15 +10,16 @@ interface TagPageProps { }>; } -export async function generateMetadata({ - params +export async function generateMetadata({ + params }: TagPageProps) { + const { t } = await getTranslation('app/glossary/page'); const { tag } = await params; const decodedTag = decodeURIComponent(tag); - + return constructMetadata({ - title: `Termes liés à ${decodedTag} | Glossaire`, - description: `Découvrez tous les termes du glossaire liés à la catégorie ${decodedTag}.`, + title: t('tag.metadata.title', { tag: decodedTag }), + description: t('tag.metadata.description', { tag: decodedTag }), }); } diff --git a/apps/web/src/app/glossary/tags/page.tsx b/apps/web/src/app/[locale]/glossary/tags/page.tsx similarity index 67% rename from apps/web/src/app/glossary/tags/page.tsx rename to apps/web/src/app/[locale]/glossary/tags/page.tsx index 0000ad2fe..58ab7e4f4 100644 --- a/apps/web/src/app/glossary/tags/page.tsx +++ b/apps/web/src/app/[locale]/glossary/tags/page.tsx @@ -1,18 +1,23 @@ -import React from 'react'; -import Link from 'next/link'; +import { Link } from '@onruntime/translations/next'; import { Button } from "@/components/ui/button"; import { ChevronLeft, Tag as TagIcon } from "lucide-react"; import { getAllTags } from "@/lib/glossary"; -import { constructMetadata } from "@/lib/utils/metadata"; +import { constructMetadata } from "@/lib/utils/metadata.server"; +import { getTranslation } from "@/lib/translations.server"; -export const metadata = constructMetadata({ - title: "Tags du glossaire", - description: "Explorez les différentes catégories de termes disponibles dans notre glossaire technique.", -}); +export async function generateMetadata() { + const { t } = await getTranslation('app/glossary/page'); + return constructMetadata({ + title: t('tags.metadata.title'), + description: t('tags.metadata.description'), + }); +} export default async function TagsPage() { + const { t } = await getTranslation('app/glossary/page'); + const { t: tComponents } = await getTranslation('components/glossary'); const tags = await getAllTags(); - + return (
@@ -21,24 +26,24 @@ export default async function TagsPage() {
- + {/* Header */}
-

Catégories du glossaire

+

{t('tags.title')}

- Explorez les {tags.length} catégories de termes disponibles dans notre glossaire technique. + {t('tags.description', { count: tags.length })}

- + {/* Tags grid */}
{tags.map(tag => ( - @@ -47,12 +52,12 @@ export default async function TagsPage() { ))}
- + {/* Empty state */} {tags.length === 0 && (

- Aucune catégorie n'est disponible pour le moment. + {t('tags.empty')}

)} diff --git a/apps/web/src/app/[locale]/layout.tsx b/apps/web/src/app/[locale]/layout.tsx new file mode 100644 index 000000000..c42f7bb25 --- /dev/null +++ b/apps/web/src/app/[locale]/layout.tsx @@ -0,0 +1,106 @@ +import "reflect-metadata"; +import "@fontsource/cal-sans"; +import "@/styles/reset.css"; +import "@/styles/globals.css"; + +import type { ReactNode } from "react"; +import Script from "next/script"; +import { Figtree } from "next/font/google"; +import { Provider } from "react-wrap-balancer"; + +import { cn } from "@/lib/utils/cn"; +import { locales } from "@/lib/translations"; +import Navbar from "@/components/marketing/navbar"; +import Footer from "@/components/layout/footer/footer"; +import { Toaster } from "@/components/ui/toaster"; +import { OrganizationSchema } from "@/components/json-ld/organization-schema"; + +import { Providers } from "./providers"; + +export const figtree = Figtree({ + subsets: ["latin"], + variable: "--font-figtree", +}); + +export async function generateStaticParams() { + return locales.map((locale) => ({ locale })); +} + +type LayoutProps = { + children: ReactNode; + params: Promise<{ locale: string }>; +}; + +export default async function LocaleLayout({ children, params }: LayoutProps) { + const { locale } = await params; + + return ( + + + + + + + + + {children} + +