feat: deep SEO optimization — metadata, OG tags, sitemap, structured data, GA4
All checks were successful
Build & Push / build-and-push (push) Successful in 1m28s
All checks were successful
Build & Push / build-and-push (push) Successful in 1m28s
- Add metadataBase and localized generateMetadata to all pages (EN/FR) - Add Open Graph and Twitter card defaults with branded OG image - Add canonical URLs and hreflang alternates on every page - Create robots.ts (allow all, block /admin/ and /api/) - Create sitemap.ts with all routes x 2 locales and hreflang - Add Organization, WebSite, and CreativeWork JSON-LD structured data - Add GA4 (G-LD348WF8EM) with Consent Mode v2 (default denied for EEA) - Add llms.txt for AI discoverability - Add production nginx config for letsbe.biz Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,3 +24,6 @@ NEXT_PUBLIC_CALCOM_URL=https://cal.letsbe.biz
|
||||
|
||||
# ── Site URL ──
|
||||
NEXT_PUBLIC_SITE_URL=https://staging.letsbe.biz
|
||||
|
||||
# ── Google Analytics ──
|
||||
NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
|
||||
|
||||
151
docs/superpowers/specs/2026-04-07-seo-optimization-design.md
Normal file
151
docs/superpowers/specs/2026-04-07-seo-optimization-design.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# SEO Optimization — Design Spec
|
||||
|
||||
**Date:** 2026-04-07
|
||||
**Domain:** letsbe.biz
|
||||
**Stack:** Next.js 16 App Router, next-intl (en/fr, `as-needed` prefix), Payload CMS
|
||||
|
||||
## Current State
|
||||
|
||||
The site has a single `metadata` export in the layout with a generic title/description. No robots.txt, no sitemap, no OG tags, no hreflang, no structured data, no analytics. The services page has a static English-only metadata export. Case study pages and the homepage have no metadata at all.
|
||||
|
||||
## 1. Metadata Foundation
|
||||
|
||||
### metadataBase
|
||||
Add `metadataBase: new URL('https://letsbe.biz')` to `src/app/(frontend)/[locale]/layout.tsx`. Required for OG images and canonical URLs to resolve as absolute URLs.
|
||||
|
||||
### i18n SEO keys
|
||||
Add a `meta` section to both `en.json` and `fr.json`:
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"home": { "title": "...", "description": "..." },
|
||||
"about": { "title": "...", "description": "..." },
|
||||
"services": { "title": "...", "description": "..." },
|
||||
"work": {
|
||||
"monaco-ocean": { "title": "...", "description": "..." },
|
||||
"port-nimara": { "title": "...", "description": "..." },
|
||||
"port-amador": { "title": "...", "description": "..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Per-page generateMetadata
|
||||
Convert every page to use `generateMetadata({ params })`:
|
||||
- Read locale from params
|
||||
- Call `getTranslations({ locale, namespace: 'meta' })` for localized title/description
|
||||
- Set `alternates.canonical` (e.g., `https://letsbe.biz/about` for en, `https://letsbe.biz/fr/about` for fr)
|
||||
- Set `alternates.languages` for hreflang:
|
||||
- `en` → unprefixed path
|
||||
- `fr` → `/fr/` prefixed path
|
||||
- `x-default` → unprefixed (English)
|
||||
|
||||
### Layout metadata
|
||||
Convert the layout's static `metadata` to `generateMetadata()` so it can set locale-aware defaults, `metadataBase`, and default OG/Twitter config.
|
||||
|
||||
## 2. Social Sharing (Open Graph / Twitter)
|
||||
|
||||
### Default OG in layout
|
||||
```ts
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
siteName: 'LetsBe.',
|
||||
locale: locale === 'fr' ? 'fr_FR' : 'en_US',
|
||||
images: [{ url: '/images/og-default.png', width: 1200, height: 630 }],
|
||||
}
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
}
|
||||
```
|
||||
|
||||
### OG image
|
||||
Create a 1200x630 OG default image using sharp — the logo centered on a branded background (the site's surface color with the blue accent). Place at `public/images/og-default.png`.
|
||||
|
||||
### Per-page OG
|
||||
Case study pages override with their hero image. Other pages use the default.
|
||||
|
||||
## 3. Crawl Infrastructure
|
||||
|
||||
### robots.ts
|
||||
`src/app/robots.ts`:
|
||||
- Allow all user agents
|
||||
- Disallow `/admin/`, `/api/`
|
||||
- Sitemap: `https://letsbe.biz/sitemap.xml`
|
||||
|
||||
### sitemap.ts
|
||||
`src/app/sitemap.ts`:
|
||||
- Static routes: `/`, `/about`, `/services`
|
||||
- Dynamic routes: `/work/monaco-ocean`, `/work/port-nimara`, `/work/port-amador`
|
||||
- Each entry includes both locale variants in `alternates.languages`
|
||||
- `lastModified` set to build date
|
||||
- `changeFrequency` and `priority` set appropriately
|
||||
|
||||
## 4. Structured Data (JSON-LD)
|
||||
|
||||
Inject via `<script type="application/ld+json">` in layout and page components.
|
||||
|
||||
### Organization (layout)
|
||||
```json
|
||||
{
|
||||
"@type": "Organization",
|
||||
"name": "LetsBe.",
|
||||
"url": "https://letsbe.biz",
|
||||
"logo": "https://letsbe.biz/images/letsbe-logo-short.png",
|
||||
"sameAs": ["linkedin-url", "x-url"]
|
||||
}
|
||||
```
|
||||
|
||||
### WebSite (homepage)
|
||||
```json
|
||||
{
|
||||
"@type": "WebSite",
|
||||
"name": "LetsBe.",
|
||||
"url": "https://letsbe.biz"
|
||||
}
|
||||
```
|
||||
|
||||
### Service (services page)
|
||||
One `Service` entry per pillar (Design, Software, Infrastructure).
|
||||
|
||||
### CreativeWork (case study pages)
|
||||
Per-project with title, description, image, creator → Organization.
|
||||
|
||||
## 5. Google Analytics + Consent Mode
|
||||
|
||||
### GA4 integration
|
||||
- Measurement ID: `G-LD348WF8EM`
|
||||
- Use Next.js `@next/third-parties/google` GoogleAnalytics component (or `next/script` if the package isn't available)
|
||||
- Place in the layout, render only in production (`process.env.NODE_ENV === 'production'`)
|
||||
|
||||
### Consent Mode v2
|
||||
Set default consent state before GA loads:
|
||||
```js
|
||||
gtag('consent', 'default', {
|
||||
analytics_storage: 'denied',
|
||||
ad_storage: 'denied',
|
||||
ad_user_data: 'denied',
|
||||
ad_personalization: 'denied',
|
||||
wait_for_update: 500,
|
||||
});
|
||||
```
|
||||
This lets GA4 run in cookieless mode (modeled conversions, basic pageviews) without requiring a cookie banner. A full consent banner can be added later to unlock full measurement.
|
||||
|
||||
### Environment variable
|
||||
`NEXT_PUBLIC_GA_ID=G-LD348WF8EM` in `.env` / `.env.production`.
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `src/app/(frontend)/[locale]/layout.tsx` | Convert to generateMetadata, add OG, add JSON-LD Organization |
|
||||
| `src/app/(frontend)/[locale]/page.tsx` | Add generateMetadata, add JSON-LD WebSite |
|
||||
| `src/app/(frontend)/[locale]/about/page.tsx` | Add generateMetadata |
|
||||
| `src/app/(frontend)/[locale]/services/page.tsx` | Replace static metadata with generateMetadata, add JSON-LD Service |
|
||||
| `src/app/(frontend)/[locale]/work/[slug]/page.tsx` | Add generateMetadata, add JSON-LD CreativeWork |
|
||||
| `src/i18n/messages/en.json` | Add `meta` section |
|
||||
| `src/i18n/messages/fr.json` | Add `meta` section |
|
||||
| `src/app/robots.ts` | Create |
|
||||
| `src/app/sitemap.ts` | Create |
|
||||
| `public/images/og-default.png` | Create (generated via sharp) |
|
||||
| `src/components/analytics/GoogleAnalytics.tsx` | Create (GA4 + consent mode) |
|
||||
| `.env.production` | Add `NEXT_PUBLIC_GA_ID` |
|
||||
60
nginx/letsbe.biz.conf
Normal file
60
nginx/letsbe.biz.conf
Normal file
@@ -0,0 +1,60 @@
|
||||
# Redirect www → non-www
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name www.letsbe.biz;
|
||||
|
||||
# Certbot will upgrade this to https after cert is issued
|
||||
return 301 http://letsbe.biz$request_uri;
|
||||
}
|
||||
|
||||
# Main site
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name letsbe.biz;
|
||||
|
||||
# Certbot will add SSL config after:
|
||||
# sudo certbot --nginx -d letsbe.biz -d www.letsbe.biz
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:6974;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
# Static assets — long cache
|
||||
location /_next/static/ {
|
||||
proxy_pass http://127.0.0.1:6974;
|
||||
proxy_cache_valid 200 365d;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
# Public assets
|
||||
location /images/ {
|
||||
proxy_pass http://127.0.0.1:6974;
|
||||
proxy_cache_valid 200 30d;
|
||||
add_header Cache-Control "public, max-age=2592000";
|
||||
}
|
||||
|
||||
# Payload media uploads
|
||||
location /media/ {
|
||||
proxy_pass http://127.0.0.1:6974;
|
||||
proxy_cache_valid 200 30d;
|
||||
add_header Cache-Control "public, max-age=2592000";
|
||||
}
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
client_max_body_size 50M;
|
||||
}
|
||||
BIN
public/images/og-default.png
Normal file
BIN
public/images/og-default.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
31
public/llms.txt
Normal file
31
public/llms.txt
Normal file
@@ -0,0 +1,31 @@
|
||||
# LetsBe.
|
||||
|
||||
> Bespoke digital studio — custom web design, software, AI integration, and private infrastructure.
|
||||
|
||||
LetsBe. is a digital studio founded by Matt Ciaccio, serving clients worldwide from the Côte d'Azur, France. We design and build custom websites, purpose-built software, and manage private digital infrastructure — all under one roof.
|
||||
|
||||
## Services
|
||||
|
||||
- **Web Design & Development**: Custom websites and web applications designed from scratch, built for performance and SEO.
|
||||
- **Software & Platforms**: CRMs, management tools, dashboards, booking systems, and API integrations built around your workflow.
|
||||
- **Hosting & Infrastructure**: Dedicated servers, email, cloud storage, security, and monitoring — fully owned by the client.
|
||||
- **AI Integration**: Intelligent features, automation, and data intelligence woven into websites and software.
|
||||
|
||||
## Selected Work
|
||||
|
||||
- [Monaco Ocean Protection Challenge](/work/monaco-ocean): AI-powered judging and analytics platform for a Mediterranean conservation event.
|
||||
- [Port Nimara](/work/port-nimara): Custom website and CRM for marina lead management and operations.
|
||||
- [Port Amador](/work/port-amador): Website and private digital infrastructure for a premium marina.
|
||||
|
||||
## Key Pages
|
||||
|
||||
- [Home](https://letsbe.biz)
|
||||
- [Services](https://letsbe.biz/services)
|
||||
- [About](https://letsbe.biz/about)
|
||||
- [Start a Project](https://letsbe.biz/#configure)
|
||||
|
||||
## Contact
|
||||
|
||||
- Email: hello@letsbe.biz
|
||||
- LinkedIn: https://www.linkedin.com/company/letsbe-digital
|
||||
- X: https://x.com/letsbe_digital
|
||||
@@ -1,11 +1,40 @@
|
||||
import Image from 'next/image';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { Shield, PenTool, Users } from 'lucide-react';
|
||||
import ScrollReveal from '@/components/ui/ScrollReveal';
|
||||
import Button from '@/components/ui/Button';
|
||||
import CornerBracket from '@/components/icons/CornerBracket';
|
||||
import { routing } from '@/i18n/routing';
|
||||
|
||||
const BASE_URL = 'https://letsbe.biz';
|
||||
|
||||
// ─── Metadata ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ locale: string }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'meta.about' });
|
||||
|
||||
const path = locale === 'en' ? '/about' : `/${locale}/about`;
|
||||
|
||||
return {
|
||||
title: t('title'),
|
||||
description: t('description'),
|
||||
alternates: {
|
||||
canonical: `${BASE_URL}${path}`,
|
||||
languages: {
|
||||
'en': `${BASE_URL}/about`,
|
||||
'fr': `${BASE_URL}/fr/about`,
|
||||
'x-default': `${BASE_URL}/about`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Static Generation ────────────────────────────────────────────────────────
|
||||
|
||||
export function generateStaticParams() {
|
||||
|
||||
@@ -1,28 +1,79 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Script from 'next/script'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import { getMessages, setRequestLocale } from 'next-intl/server'
|
||||
import { getMessages, getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { routing } from '@/i18n/routing'
|
||||
import Nav from '@/components/layout/Nav'
|
||||
import Footer from '@/components/layout/Footer'
|
||||
import GoogleAnalytics from '@/components/analytics/GoogleAnalytics'
|
||||
import '@/styles/globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'LetsBe. | Web Design, AI & Digital Infrastructure Studio',
|
||||
description:
|
||||
'Custom web design, purpose-built software, AI integration, and private infrastructure — designed, built, and managed by one dedicated team.',
|
||||
}
|
||||
const BASE_URL = 'https://letsbe.biz'
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode
|
||||
params: Promise<{ locale: string }>
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: 'meta' })
|
||||
|
||||
return {
|
||||
metadataBase: new URL(BASE_URL),
|
||||
title: {
|
||||
default: t('home.title'),
|
||||
template: '%s',
|
||||
},
|
||||
description: t('home.description'),
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
siteName: t('siteName'),
|
||||
locale: locale === 'fr' ? 'fr_FR' : 'en_US',
|
||||
images: [{ url: '/images/og-default.png', width: 1200, height: 630 }],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
alternates: {
|
||||
canonical: BASE_URL,
|
||||
languages: {
|
||||
'en': BASE_URL,
|
||||
'fr': `${BASE_URL}/fr`,
|
||||
'x-default': BASE_URL,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return routing.locales.map((locale) => ({ locale }))
|
||||
}
|
||||
|
||||
// ─── JSON-LD: Organization ───────────────────────────────────────────────────
|
||||
|
||||
const organizationJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: 'LetsBe.',
|
||||
url: BASE_URL,
|
||||
logo: `${BASE_URL}/images/letsbe-logo-short.png`,
|
||||
sameAs: [
|
||||
'https://www.linkedin.com/company/letsbe-digital',
|
||||
'https://x.com/letsbe_digital',
|
||||
],
|
||||
contactPoint: {
|
||||
'@type': 'ContactPoint',
|
||||
email: 'hello@letsbe.biz',
|
||||
contactType: 'customer service',
|
||||
},
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({ children, params }: Props) {
|
||||
const { locale } = await params
|
||||
|
||||
@@ -38,6 +89,10 @@ export default async function LocaleLayout({ children, params }: Props) {
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationJsonLd) }}
|
||||
/>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<Script
|
||||
src="//unpkg.com/react-grab/dist/index.global.js"
|
||||
@@ -47,6 +102,7 @@ export default async function LocaleLayout({ children, params }: Props) {
|
||||
)}
|
||||
</head>
|
||||
<body className="font-sans text-on-surface bg-surface antialiased">
|
||||
<GoogleAnalytics />
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<Nav />
|
||||
{children}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { setRequestLocale } from 'next-intl/server'
|
||||
import type { Metadata } from 'next'
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import Hero from '@/components/sections/Hero'
|
||||
import TrustBar from '@/components/sections/TrustBar'
|
||||
import ServicesOverview from '@/components/sections/ServicesOverview'
|
||||
@@ -9,16 +10,49 @@ import Configurator from '@/components/sections/Configurator'
|
||||
import Discovery from '@/components/sections/Discovery'
|
||||
import CTABanner from '@/components/sections/CTABanner'
|
||||
|
||||
const BASE_URL = 'https://letsbe.biz'
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ locale: string }>
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: 'meta.home' })
|
||||
|
||||
const path = locale === 'en' ? '' : `/${locale}`
|
||||
|
||||
return {
|
||||
title: t('title'),
|
||||
description: t('description'),
|
||||
alternates: {
|
||||
canonical: `${BASE_URL}${path}`,
|
||||
languages: {
|
||||
'en': BASE_URL,
|
||||
'fr': `${BASE_URL}/fr`,
|
||||
'x-default': BASE_URL,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const websiteJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: 'LetsBe.',
|
||||
url: BASE_URL,
|
||||
}
|
||||
|
||||
export default async function HomePage({ params }: Props) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
|
||||
return (
|
||||
<main>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteJsonLd) }}
|
||||
/>
|
||||
<Hero />
|
||||
<TrustBar />
|
||||
<ServicesOverview />
|
||||
|
||||
@@ -1,19 +1,39 @@
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import ServicesHero from '@/components/sections/services/ServicesHero';
|
||||
import ServicePillar from '@/components/sections/services/ServicePillar';
|
||||
import AILayer from '@/components/sections/services/AILayer';
|
||||
import ServicesCTA from '@/components/sections/services/ServicesCTA';
|
||||
// Icon names passed as strings — resolved in client component
|
||||
|
||||
const BASE_URL = 'https://letsbe.biz';
|
||||
|
||||
// ─── Metadata ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Services | LetsBe. — Bespoke Digital Studio',
|
||||
description:
|
||||
'Custom web design, purpose-built software, AI automation, and private infrastructure — three pillars of digital excellence under one roof.',
|
||||
type PageProps = {
|
||||
params: Promise<{ locale: string }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'meta.services' });
|
||||
|
||||
const path = locale === 'en' ? '/services' : `/${locale}/services`;
|
||||
|
||||
return {
|
||||
title: t('title'),
|
||||
description: t('description'),
|
||||
alternates: {
|
||||
canonical: `${BASE_URL}${path}`,
|
||||
languages: {
|
||||
'en': `${BASE_URL}/services`,
|
||||
'fr': `${BASE_URL}/fr/services`,
|
||||
'x-default': `${BASE_URL}/services`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Service data ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const SERVICE_PILLARS = [
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import Image from 'next/image';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { routing } from '@/i18n/routing';
|
||||
import ScrollReveal from '@/components/ui/ScrollReveal';
|
||||
import Button from '@/components/ui/Button';
|
||||
import CornerBracket from '@/components/icons/CornerBracket';
|
||||
import Chip from '@/components/ui/Chip';
|
||||
|
||||
const BASE_URL = 'https://letsbe.biz';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Project {
|
||||
@@ -69,6 +72,38 @@ const PROJECTS: Record<string, Project> = {
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Metadata ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale, slug } = await params;
|
||||
const project = PROJECTS[slug];
|
||||
if (!project) return {};
|
||||
|
||||
const t = await getTranslations({ locale, namespace: 'meta.work' });
|
||||
|
||||
const path = locale === 'en' ? `/work/${slug}` : `/${locale}/work/${slug}`;
|
||||
|
||||
return {
|
||||
title: t(`${slug}.title` as any),
|
||||
description: t(`${slug}.description` as any),
|
||||
openGraph: {
|
||||
images: [{ url: project.image }],
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${BASE_URL}${path}`,
|
||||
languages: {
|
||||
'en': `${BASE_URL}/work/${slug}`,
|
||||
'fr': `${BASE_URL}/fr/work/${slug}`,
|
||||
'x-default': `${BASE_URL}/work/${slug}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Static Generation ────────────────────────────────────────────────────────
|
||||
|
||||
export function generateStaticParams() {
|
||||
@@ -124,8 +159,25 @@ export default async function CaseStudyPage({ params }: Props) {
|
||||
const project = PROJECTS[slug];
|
||||
if (!project) notFound();
|
||||
|
||||
const caseStudyJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CreativeWork',
|
||||
name: project.title,
|
||||
description: project.description,
|
||||
image: `${BASE_URL}${project.image}`,
|
||||
creator: {
|
||||
'@type': 'Organization',
|
||||
name: 'LetsBe.',
|
||||
url: BASE_URL,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<main>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(caseStudyJsonLd) }}
|
||||
/>
|
||||
|
||||
{/* ── Hero ── */}
|
||||
<section className="relative min-h-[420px] md:min-h-[480px] flex items-end overflow-hidden">
|
||||
|
||||
14
src/app/robots.ts
Normal file
14
src/app/robots.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { MetadataRoute } from 'next'
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: ['/admin/', '/api/'],
|
||||
},
|
||||
],
|
||||
sitemap: 'https://letsbe.biz/sitemap.xml',
|
||||
}
|
||||
}
|
||||
38
src/app/sitemap.ts
Normal file
38
src/app/sitemap.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { MetadataRoute } from 'next'
|
||||
|
||||
const BASE_URL = 'https://letsbe.biz'
|
||||
|
||||
const STATIC_ROUTES = ['', '/about', '/services']
|
||||
const PROJECT_SLUGS = ['monaco-ocean', 'port-nimara', 'port-amador']
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const now = new Date()
|
||||
|
||||
const staticEntries: MetadataRoute.Sitemap = STATIC_ROUTES.map((route) => ({
|
||||
url: `${BASE_URL}${route}`,
|
||||
lastModified: now,
|
||||
changeFrequency: route === '' ? 'weekly' : 'monthly',
|
||||
priority: route === '' ? 1.0 : 0.8,
|
||||
alternates: {
|
||||
languages: {
|
||||
en: `${BASE_URL}${route}`,
|
||||
fr: `${BASE_URL}/fr${route}`,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const projectEntries: MetadataRoute.Sitemap = PROJECT_SLUGS.map((slug) => ({
|
||||
url: `${BASE_URL}/work/${slug}`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'monthly' as const,
|
||||
priority: 0.7,
|
||||
alternates: {
|
||||
languages: {
|
||||
en: `${BASE_URL}/work/${slug}`,
|
||||
fr: `${BASE_URL}/fr/work/${slug}`,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
return [...staticEntries, ...projectEntries]
|
||||
}
|
||||
40
src/components/analytics/GoogleAnalytics.tsx
Normal file
40
src/components/analytics/GoogleAnalytics.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import Script from 'next/script'
|
||||
|
||||
const GA_ID = process.env.NEXT_PUBLIC_GA_ID
|
||||
|
||||
export default function GoogleAnalytics() {
|
||||
if (!GA_ID || process.env.NODE_ENV !== 'production') return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Consent Mode v2 — default to denied for EEA compliance */}
|
||||
<Script id="gtag-consent" strategy="beforeInteractive">
|
||||
{`
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('consent', 'default', {
|
||||
analytics_storage: 'denied',
|
||||
ad_storage: 'denied',
|
||||
ad_user_data: 'denied',
|
||||
ad_personalization: 'denied',
|
||||
wait_for_update: 500,
|
||||
});
|
||||
`}
|
||||
</Script>
|
||||
|
||||
{/* Google tag (gtag.js) */}
|
||||
<Script
|
||||
src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`}
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
<Script id="gtag-init" strategy="afterInteractive">
|
||||
{`
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', '${GA_ID}');
|
||||
`}
|
||||
</Script>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,33 @@
|
||||
{
|
||||
"meta": {
|
||||
"siteName": "LetsBe.",
|
||||
"home": {
|
||||
"title": "LetsBe. | Custom Web Design, Software & Digital Infrastructure",
|
||||
"description": "Bespoke websites, purpose-built software, AI integration, and private infrastructure — designed, built, and managed by one dedicated team."
|
||||
},
|
||||
"about": {
|
||||
"title": "About LetsBe. | Our Story & Approach",
|
||||
"description": "An American-founded digital studio building custom websites, software, and platforms for businesses that care about quality."
|
||||
},
|
||||
"services": {
|
||||
"title": "Services | LetsBe. — Web Design, Software & Infrastructure",
|
||||
"description": "Custom web design, purpose-built software, AI automation, and private infrastructure — three pillars of digital excellence under one roof."
|
||||
},
|
||||
"work": {
|
||||
"monaco-ocean": {
|
||||
"title": "Monaco Ocean Protection Challenge | LetsBe.",
|
||||
"description": "AI-powered judging and analytics platform for one of the Mediterranean's leading conservation events."
|
||||
},
|
||||
"port-nimara": {
|
||||
"title": "Port Nimara — Maritime Digital Hub | LetsBe.",
|
||||
"description": "Custom website and full CRM for lead management, berth assignment, and marina operations."
|
||||
},
|
||||
"port-amador": {
|
||||
"title": "Port Amador — Premium Nautical Experience | LetsBe.",
|
||||
"description": "Website and private digital infrastructure for a premium marina — cloud storage, email, and file management."
|
||||
}
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
"services": "Services",
|
||||
"configure": "Get Started",
|
||||
|
||||
@@ -1,4 +1,33 @@
|
||||
{
|
||||
"meta": {
|
||||
"siteName": "LetsBe.",
|
||||
"home": {
|
||||
"title": "LetsBe. | Design Web Sur Mesure, Logiciels & Infrastructure Digitale",
|
||||
"description": "Sites web sur mesure, logiciels dédiés, intégration IA et infrastructure privée — conçus, développés et gérés par une seule équipe."
|
||||
},
|
||||
"about": {
|
||||
"title": "À Propos de LetsBe. | Notre Histoire & Approche",
|
||||
"description": "Un studio digital fondé aux États-Unis, créant des sites web, logiciels et plateformes sur mesure pour les entreprises exigeantes."
|
||||
},
|
||||
"services": {
|
||||
"title": "Services | LetsBe. — Design Web, Logiciels & Infrastructure",
|
||||
"description": "Design web sur mesure, logiciels dédiés, automatisation IA et infrastructure privée — trois piliers d'excellence digitale sous un même toit."
|
||||
},
|
||||
"work": {
|
||||
"monaco-ocean": {
|
||||
"title": "Monaco Ocean Protection Challenge | LetsBe.",
|
||||
"description": "Plateforme de jugement et d'analyse propulsée par l'IA pour l'un des événements de conservation majeurs de la Méditerranée."
|
||||
},
|
||||
"port-nimara": {
|
||||
"title": "Port Nimara — Hub Digital Maritime | LetsBe.",
|
||||
"description": "Site web sur mesure et CRM complet pour la gestion des prospects, l'attribution des places et les opérations de la marina."
|
||||
},
|
||||
"port-amador": {
|
||||
"title": "Port Amador — Expérience Nautique Premium | LetsBe.",
|
||||
"description": "Site web et infrastructure digitale privée pour une marina premium — stockage cloud, email et gestion de fichiers."
|
||||
}
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
"services": "Services",
|
||||
"configure": "Démarrer",
|
||||
|
||||
Reference in New Issue
Block a user