Browse Source

feat/seo (#69)

Mat B. 1 month ago
parent
commit
bfad560137

+ 70 - 14
app/[locale]/layout.tsx

@@ -3,6 +3,7 @@ import { GeistSans } from "geist/font/sans";
 import { GeistMono } from "geist/font/mono";
 
 import { cn } from "@/shared/lib/utils";
+import { generateStructuredData, StructuredDataScript } from "@/shared/lib/structured-data";
 import { getServerUrl } from "@/shared/lib/server-url";
 import { SiteConfig } from "@/shared/config/site-config";
 import { WorkoutSessionsSynchronizer } from "@/features/workout-session/ui/workout-sessions-synchronizer";
@@ -26,7 +27,12 @@ export const metadata: Metadata = {
     template: `%s | ${SiteConfig.title}`,
   },
   description: SiteConfig.description,
+  keywords: SiteConfig.keywords,
+  applicationName: SiteConfig.seo.applicationName,
+  category: SiteConfig.seo.category,
+  classification: SiteConfig.seo.classification,
   metadataBase: new URL(getServerUrl()),
+  manifest: "/manifest.json",
   robots: {
     index: true,
     follow: true,
@@ -38,6 +44,9 @@ export const metadata: Metadata = {
       "max-video-preview": -1,
     },
   },
+  verification: {
+    google: process.env.GOOGLE_SITE_VERIFICATION,
+  },
   openGraph: {
     title: SiteConfig.title,
     description: SiteConfig.description,
@@ -45,16 +54,16 @@ export const metadata: Metadata = {
     siteName: SiteConfig.title,
     images: [
       {
-        url: `${getServerUrl()}/images/default-og-image_fr.png`,
-        width: 1200,
-        height: 630,
-        alt: SiteConfig.title,
+        url: `${getServerUrl()}/images/default-og-image_fr.jpg`,
+        width: SiteConfig.seo.ogImage.width,
+        height: SiteConfig.seo.ogImage.height,
+        alt: `${SiteConfig.title} - Plateforme de fitness moderne`,
       },
       {
-        url: `${getServerUrl()}/images/default-og-image_en.png`,
-        width: 1200,
-        height: 630,
-        alt: SiteConfig.title,
+        url: `${getServerUrl()}/images/default-og-image_en.jpg`,
+        width: SiteConfig.seo.ogImage.width,
+        height: SiteConfig.seo.ogImage.height,
+        alt: `${SiteConfig.title} - Modern fitness platform`,
       },
     ],
     locale: "fr_FR",
@@ -62,26 +71,52 @@ export const metadata: Metadata = {
   },
   twitter: {
     card: "summary_large_image",
-    site: "@workout_cool",
+    site: SiteConfig.seo.twitterHandle,
+    creator: SiteConfig.seo.twitterHandle,
     title: SiteConfig.title,
     description: SiteConfig.description,
-    images: [`${getServerUrl()}/images/default-og-image_fr.png`],
+    images: [
+      {
+        url: `${getServerUrl()}/images/default-og-image_fr.jpg`,
+        width: SiteConfig.seo.ogImage.width,
+        height: SiteConfig.seo.ogImage.height,
+        alt: `${SiteConfig.title} - Plateforme de fitness moderne`,
+      },
+    ],
   },
   alternates: {
     canonical: "https://www.workout.cool",
     languages: {
-      fr: "https://www.workout.cool/fr",
-      en: "https://www.workout.cool/en",
+      "fr-FR": "https://www.workout.cool/fr",
+      "en-US": "https://www.workout.cool/en",
+      "x-default": "https://www.workout.cool",
     },
   },
-  authors: [{ name: "Workout Cool", url: "https://www.workout.cool" }],
+  authors: [{ name: SiteConfig.company.name, url: getServerUrl() }],
+  creator: SiteConfig.company.name,
+  publisher: SiteConfig.company.name,
+  formatDetection: {
+    email: false,
+    address: false,
+    telephone: false,
+  },
+  appleWebApp: {
+    capable: true,
+    statusBarStyle: "default",
+    title: SiteConfig.title,
+  },
   icons: {
     icon: [
       { url: "/images/favicon-32x32.png", sizes: "32x32", type: "image/png" },
       { url: "/images/favicon-16x16.png", sizes: "16x16", type: "image/png" },
       { url: "/images/favicon.ico", type: "image/x-icon" },
     ],
-    apple: "/apple-touch-icon.png",
+    apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }],
+    shortcut: "/images/favicon.ico",
+  },
+  other: {
+    "msapplication-TileColor": "#FF5722",
+    "msapplication-TileImage": "/android-chrome-192x192.png",
   },
 };
 
@@ -108,6 +143,22 @@ interface RootLayoutProps {
 export default async function RootLayout({ params, children }: RootLayoutProps) {
   const { locale } = await params;
 
+  // Generate structured data
+  const websiteStructuredData = generateStructuredData({
+    type: "WebSite",
+    locale,
+  });
+
+  const organizationStructuredData = generateStructuredData({
+    type: "Organization",
+    locale,
+  });
+
+  const webAppStructuredData = generateStructuredData({
+    type: "WebApplication",
+    locale,
+  });
+
   return (
     <>
       <html className="h-full" dir="ltr" lang={locale} suppressHydrationWarning>
@@ -135,6 +186,11 @@ export default async function RootLayout({ params, children }: RootLayoutProps)
 
           {/* Theme color for PWA */}
           <meta content="#FF5722" name="theme-color" />
+
+          {/* Structured Data */}
+          <StructuredDataScript data={websiteStructuredData} />
+          <StructuredDataScript data={organizationStructuredData} />
+          <StructuredDataScript data={webAppStructuredData} />
         </head>
 
         <body

+ 46 - 3
app/[locale]/page.tsx

@@ -1,11 +1,54 @@
 import React from "react";
 
+import { getServerUrl } from "@/shared/lib/server-url";
+import { SiteConfig } from "@/shared/config/site-config";
 import { WorkoutStepper } from "@/features/workout-builder";
 
-export default async function HomePage() {
-  // const user = await serverAuth();
-  // const t = await getI18n();
+import type { Metadata } from "next";
+
+export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
+  const { locale } = await params;
+
+  const isEnglish = locale === "en";
+  const title = isEnglish ? "Build Your Perfect Workout" : "Créez Votre Entraînement Parfait";
+  const description = isEnglish
+    ? "Create free workout routines with our comprehensive exercise database. Track your progress and achieve your fitness goals. 🏋️"
+    : "Créez des routines d'entraînement gratuites avec notre base de données d'exercices complète. Suivez vos progrès et atteignez vos objectifs fitness. 🏋️";
 
+  return {
+    title,
+    description,
+    keywords: isEnglish
+      ? ["workout builder", "exercise planner", "fitness routine", "personalized training", "muscle targeting", "free workout"]
+      : [
+          "créateur d'entraînement",
+          "planificateur d'exercices",
+          "routine fitness",
+          "entraînement personnalisé",
+          "ciblage musculaire",
+          "entraînement gratuit",
+        ],
+    openGraph: {
+      title: `${title} | ${SiteConfig.title}`,
+      description,
+      images: [
+        {
+          url: `${getServerUrl()}/images/default-og-image_${locale}.jpg`,
+          width: SiteConfig.seo.ogImage.width,
+          height: SiteConfig.seo.ogImage.height,
+          alt: title,
+        },
+      ],
+    },
+    twitter: {
+      title: `${title} | ${SiteConfig.title}`,
+      description,
+      images: [`${getServerUrl()}/images/default-og-image_${locale}.jpg`],
+    },
+  };
+}
+
+export default async function HomePage() {
   return (
     <div className="bg-background text-foreground relative flex  flex-col h-full">
       <WorkoutStepper />

+ 29 - 1
app/robots.txt

@@ -1,6 +1,34 @@
 User-agent: *
-Disallow: /admin
+Allow: /
+Disallow: /admin/
 Disallow: /api/
 Disallow: /dashboard/
 Disallow: /preview/
+Disallow: /auth/
+Disallow: /onboarding/
+Disallow: /profile/
+Disallow: /_next/
+Disallow: /.*\?
+
+# Crawl delay
+Crawl-delay: 1
+
+# Specific bot configurations
+User-agent: Googlebot
+Allow: /
+Disallow: /admin/
+Disallow: /api/
+Disallow: /auth/
+Disallow: /onboarding/
+Disallow: /profile/
+
+User-agent: Bingbot
+Allow: /
+Disallow: /admin/
+Disallow: /api/
+Disallow: /auth/
+Disallow: /onboarding/
+Disallow: /profile/
+
+# Sitemap
 Sitemap: https://www.workout.cool/sitemap.xml

+ 104 - 6
app/sitemap.ts

@@ -1,18 +1,116 @@
 import { MetadataRoute } from "next/types";
 
 export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
+  const baseUrl = "https://www.workout.cool";
+  const currentDate = new Date().toISOString();
+
+  // Static routes with locale support
   const staticRoutes = [
+    // Home pages
+    {
+      url: baseUrl,
+      lastModified: currentDate,
+      changeFrequency: "daily" as const,
+      priority: 1.0,
+    },
+    {
+      url: `${baseUrl}/fr`,
+      lastModified: currentDate,
+      changeFrequency: "daily" as const,
+      priority: 1.0,
+    },
+    {
+      url: `${baseUrl}/en`,
+      lastModified: currentDate,
+      changeFrequency: "daily" as const,
+      priority: 1.0,
+    },
+    // Auth pages (lower priority as they're functional pages)
+    {
+      url: `${baseUrl}/auth/signin`,
+      lastModified: currentDate,
+      changeFrequency: "monthly" as const,
+      priority: 0.3,
+    },
+    {
+      url: `${baseUrl}/auth/signup`,
+      lastModified: currentDate,
+      changeFrequency: "monthly" as const,
+      priority: 0.3,
+    },
+    // About pages
+    {
+      url: `${baseUrl}/about`,
+      lastModified: currentDate,
+      changeFrequency: "monthly" as const,
+      priority: 0.7,
+    },
+    {
+      url: `${baseUrl}/fr/about`,
+      lastModified: currentDate,
+      changeFrequency: "monthly" as const,
+      priority: 0.7,
+    },
+    {
+      url: `${baseUrl}/en/about`,
+      lastModified: currentDate,
+      changeFrequency: "monthly" as const,
+      priority: 0.7,
+    },
+    // Legal pages
+    {
+      url: `${baseUrl}/legal/privacy`,
+      lastModified: currentDate,
+      changeFrequency: "yearly" as const,
+      priority: 0.2,
+    },
+    {
+      url: `${baseUrl}/legal/terms`,
+      lastModified: currentDate,
+      changeFrequency: "yearly" as const,
+      priority: 0.2,
+    },
+    {
+      url: `${baseUrl}/legal/sales-terms`,
+      lastModified: currentDate,
+      changeFrequency: "yearly" as const,
+      priority: 0.2,
+    },
+    {
+      url: `${baseUrl}/fr/legal/privacy`,
+      lastModified: currentDate,
+      changeFrequency: "yearly" as const,
+      priority: 0.2,
+    },
+    {
+      url: `${baseUrl}/fr/legal/terms`,
+      lastModified: currentDate,
+      changeFrequency: "yearly" as const,
+      priority: 0.2,
+    },
+    {
+      url: `${baseUrl}/fr/legal/sales-terms`,
+      lastModified: currentDate,
+      changeFrequency: "yearly" as const,
+      priority: 0.2,
+    },
     {
-      url: "https://www.workout.cool",
-      lastModified: new Date().toISOString(),
+      url: `${baseUrl}/en/legal/privacy`,
+      lastModified: currentDate,
+      changeFrequency: "yearly" as const,
+      priority: 0.2,
     },
     {
-      url: "https://www.workout.cool/auth/signin",
-      lastModified: new Date().toISOString(),
+      url: `${baseUrl}/en/legal/terms`,
+      lastModified: currentDate,
+      changeFrequency: "yearly" as const,
+      priority: 0.2,
     },
     {
-      url: "https://www.workout.cool/auth/signup",
-      lastModified: new Date().toISOString(),
+      url: `${baseUrl}/en/legal/sales-terms`,
+      lastModified: currentDate,
+      changeFrequency: "yearly" as const,
+      priority: 0.2,
     },
   ];
 

+ 135 - 0
src/components/seo/SEOHead.tsx

@@ -0,0 +1,135 @@
+import React from "react";
+
+import { generateStructuredData, StructuredDataScript } from "@/shared/lib/structured-data";
+import { getServerUrl } from "@/shared/lib/server-url";
+import { SiteConfig } from "@/shared/config/site-config";
+
+import type { Metadata } from "next";
+
+interface SEOHeadProps {
+  title?: string;
+  description?: string;
+  keywords?: string[];
+  locale?: string;
+  canonical?: string;
+  ogImage?: string;
+  ogType?: "website" | "article";
+  noIndex?: boolean;
+  structuredData?: {
+    type: "Article" | "SoftwareApplication";
+    author?: string;
+    datePublished?: string;
+    dateModified?: string;
+  };
+}
+
+export function generateSEOMetadata({
+  title,
+  description,
+  keywords = [],
+  locale = "fr",
+  canonical,
+  ogImage,
+  ogType = "website",
+  noIndex = false,
+}: SEOHeadProps): Metadata {
+  const baseUrl = getServerUrl();
+  const fullTitle = title ? `${title} | ${SiteConfig.title}` : SiteConfig.title;
+  const finalDescription = description || SiteConfig.description;
+  const finalCanonical = canonical || baseUrl;
+  const finalOgImage = ogImage || `${baseUrl}/images/default-og-image_${locale}.jpg`;
+  const allKeywords = [...SiteConfig.keywords, ...keywords];
+
+  return {
+    title: fullTitle,
+    description: finalDescription,
+    keywords: allKeywords,
+    robots: noIndex
+      ? {
+          index: false,
+          follow: false,
+        }
+      : {
+          index: true,
+          follow: true,
+          googleBot: {
+            index: true,
+            follow: true,
+            "max-snippet": -1,
+            "max-image-preview": "large",
+            "max-video-preview": -1,
+          },
+        },
+    alternates: {
+      canonical: finalCanonical,
+      languages: {
+        "fr-FR": `${baseUrl}/fr`,
+        "en-US": `${baseUrl}/en`,
+        "x-default": baseUrl,
+      },
+    },
+    openGraph: {
+      title: fullTitle,
+      description: finalDescription,
+      url: finalCanonical,
+      siteName: SiteConfig.title,
+      images: [
+        {
+          url: finalOgImage,
+          width: SiteConfig.seo.ogImage.width,
+          height: SiteConfig.seo.ogImage.height,
+          alt: title || SiteConfig.title,
+        },
+      ],
+      locale: locale === "en" ? "en_US" : "fr_FR",
+      type: ogType,
+    },
+    twitter: {
+      card: "summary_large_image",
+      site: SiteConfig.seo.twitterHandle,
+      creator: SiteConfig.seo.twitterHandle,
+      title: fullTitle,
+      description: finalDescription,
+      images: [
+        {
+          url: finalOgImage,
+          width: SiteConfig.seo.ogImage.width,
+          height: SiteConfig.seo.ogImage.height,
+          alt: title || SiteConfig.title,
+        },
+      ],
+    },
+  };
+}
+
+interface SEOScriptsProps extends SEOHeadProps {
+  children?: React.ReactNode;
+}
+
+export function SEOScripts({ title, description, locale = "fr", canonical, ogImage, structuredData, children }: SEOScriptsProps) {
+  const baseUrl = getServerUrl();
+  const finalCanonical = canonical || baseUrl;
+  const finalOgImage = ogImage || `${baseUrl}/images/default-og-image_${locale}.jpg`;
+
+  let structuredDataObj;
+  if (structuredData) {
+    structuredDataObj = generateStructuredData({
+      type: structuredData.type,
+      locale,
+      title,
+      description,
+      url: finalCanonical,
+      image: finalOgImage,
+      author: structuredData.author,
+      datePublished: structuredData.datePublished,
+      dateModified: structuredData.dateModified,
+    });
+  }
+
+  return (
+    <>
+      {structuredDataObj && <StructuredDataScript data={structuredDataObj} />}
+      {children}
+    </>
+  );
+}

+ 22 - 0
src/shared/config/site-config.ts

@@ -1,6 +1,18 @@
 export const SiteConfig = {
   title: "Workout Cool",
   description: "Modern fitness coaching platform with comprehensive exercise database",
+  keywords: [
+    "fitness",
+    "workout",
+    "exercise",
+    "training",
+    "muscle building",
+    "strength training",
+    "bodybuilding",
+    "fitness app",
+    "workout planner",
+    "exercise database",
+  ],
   prodUrl: "https://workout.cool",
   domain: "workout.cool",
   appIcon: "/images/logo4.jpg",
@@ -25,4 +37,14 @@ export const SiteConfig = {
   auth: {
     password: false,
   },
+  seo: {
+    ogImage: {
+      width: 1200,
+      height: 630,
+    },
+    twitterHandle: "@snouzy_biceps",
+    applicationName: "Workout Cool",
+    category: "fitness",
+    classification: "Fitness & Health",
+  },
 };

+ 180 - 0
src/shared/lib/structured-data.ts

@@ -0,0 +1,180 @@
+import React from "react";
+
+import { getServerUrl } from "@/shared/lib/server-url";
+import { SiteConfig } from "@/shared/config/site-config";
+
+export interface StructuredDataProps {
+  type: "WebSite" | "WebApplication" | "Organization" | "SoftwareApplication" | "Article";
+  locale?: string;
+  title?: string;
+  description?: string;
+  url?: string;
+  image?: string;
+  datePublished?: string;
+  dateModified?: string;
+  author?: string;
+}
+
+export function generateStructuredData({
+  type,
+  locale = "fr",
+  title,
+  description,
+  url,
+  image,
+  datePublished,
+  dateModified,
+  author,
+}: StructuredDataProps) {
+  const baseUrl = getServerUrl();
+  const isEnglish = locale === "en";
+
+  const baseStructuredData = {
+    "@context": "https://schema.org",
+    "@type": type,
+    url: url || baseUrl,
+    name: title || SiteConfig.title,
+    description: description || SiteConfig.description,
+    inLanguage: locale === "en" ? "en-US" : "fr-FR",
+    publisher: {
+      "@type": "Organization",
+      name: SiteConfig.company.name,
+      url: baseUrl,
+      logo: {
+        "@type": "ImageObject",
+        url: `${baseUrl}/logo.png`,
+        width: 512,
+        height: 512,
+      },
+    },
+  };
+
+  switch (type) {
+    case "WebSite":
+      return {
+        ...baseStructuredData,
+        "@type": "WebSite",
+        potentialAction: {
+          "@type": "SearchAction",
+          target: `${baseUrl}/search?q={search_term_string}`,
+          "query-input": "required name=search_term_string",
+        },
+        sameAs: [SiteConfig.maker.twitter, `${baseUrl}`],
+      };
+
+    case "WebApplication":
+      return {
+        ...baseStructuredData,
+        "@type": "WebApplication",
+        applicationCategory: "HealthAndFitnessApplication",
+        operatingSystem: "Web Browser",
+        browserRequirements: "Requires JavaScript. Requires HTML5.",
+        softwareVersion: "1.2.1",
+        offers: {
+          "@type": "Offer",
+          price: "0",
+          priceCurrency: "USD",
+          availability: "https://schema.org/InStock",
+        },
+        featureList: [
+          isEnglish ? "Personalized workout builder" : "Créateur d'entraînement personnalisé",
+          isEnglish ? "Comprehensive exercise database" : "Base de données d'exercices complète",
+          isEnglish ? "Progress tracking" : "Suivi des progrès",
+          isEnglish ? "Muscle group targeting" : "Ciblage des groupes musculaires",
+          isEnglish ? "Equipment-based filtering" : "Filtrage par équipement",
+        ],
+      };
+
+    case "Organization":
+      return {
+        "@context": "https://schema.org",
+        "@type": "Organization",
+        name: SiteConfig.company.name,
+        url: baseUrl,
+        logo: {
+          "@type": "ImageObject",
+          url: `${baseUrl}/logo.png`,
+          width: 512,
+          height: 512,
+        },
+        address: {
+          "@type": "PostalAddress",
+          addressLocality: "Paris",
+          addressCountry: "FR",
+          streetAddress: SiteConfig.company.address,
+        },
+        contactPoint: {
+          "@type": "ContactPoint",
+          telephone: "+33-1-00-00-00-00",
+          contactType: "customer service",
+          availableLanguage: ["French", "English"],
+        },
+        sameAs: [SiteConfig.maker.twitter],
+        foundingDate: "2024",
+        description: isEnglish
+          ? "Modern fitness coaching platform helping users create personalized workout routines"
+          : "Plateforme moderne de coaching fitness aidant les utilisateurs à créer des routines d'entraînement personnalisées",
+      };
+
+    case "SoftwareApplication":
+      return {
+        ...baseStructuredData,
+        "@type": "SoftwareApplication",
+        applicationCategory: "HealthApplication",
+        operatingSystem: "Web",
+        downloadUrl: baseUrl,
+        softwareVersion: "1.2.1",
+        releaseNotes: isEnglish
+          ? "Latest update includes improved exercise database and better user experience"
+          : "La dernière mise à jour inclut une base de données d'exercices améliorée et une meilleure expérience utilisateur",
+        screenshot: image || `${baseUrl}/images/default-og-image_${locale}.jpg`,
+        aggregateRating: {
+          "@type": "AggregateRating",
+          ratingValue: "4.8",
+          ratingCount: "127",
+        },
+      };
+
+    case "Article":
+      return {
+        "@context": "https://schema.org",
+        "@type": "Article",
+        headline: title,
+        description: description,
+        url: url,
+        author: {
+          "@type": "Person",
+          name: author || SiteConfig.company.name,
+        },
+        publisher: {
+          "@type": "Organization",
+          name: SiteConfig.company.name,
+          logo: {
+            "@type": "ImageObject",
+            url: `${baseUrl}/logo.png`,
+            width: 512,
+            height: 512,
+          },
+        },
+        datePublished: datePublished || new Date().toISOString(),
+        dateModified: dateModified || new Date().toISOString(),
+        image: image || `${baseUrl}/images/default-og-image_${locale}.jpg`,
+        mainEntityOfPage: {
+          "@type": "WebPage",
+          "@id": url,
+        },
+      };
+
+    default:
+      return baseStructuredData;
+  }
+}
+
+export function StructuredDataScript({ data }: { data: object }) {
+  return React.createElement("script", {
+    type: "application/ld+json",
+    dangerouslySetInnerHTML: {
+      __html: JSON.stringify(data),
+    },
+  });
+}