From d3e94f81f6c6a6cd19cf0c84ae5accff2e7fc7ef Mon Sep 17 00:00:00 2001 From: mahmutkaya Date: Wed, 30 Sep 2026 01:52:49 +0200 Subject: [PATCH 1/6] feat(seo): add public discovery and crawl contracts --- .github/workflows/ci.yml | 10 + README.md | 5 +- app/[locale]/case/rumi/page.tsx | 2 +- app/[locale]/compare/alternatives/page.tsx | 161 +++++++ app/[locale]/compare/gloriafood/page.tsx | 35 +- app/[locale]/guides/qr-menu-geneva/page.tsx | 86 ++++ .../guides/qr-menu-switzerland/page.tsx | 85 ++++ app/[locale]/layout.tsx | 4 +- app/robots.ts | 27 +- app/sitemap.ts | 48 +-- components/Footer.tsx | 9 + components/JsonLd.tsx | 14 +- components/LocaleSwitcher.tsx | 38 +- components/MenuBoard.tsx | 6 + components/changelog-data.ts | 1 + components/compare-gloriafood-data.ts | 11 +- components/discovery-guide-data.ts | 30 ++ lib/marketing-routes.ts | 36 ++ lib/seo.ts | 23 +- messages/ar.json | 370 ++++++++++++---- messages/de.json | 368 ++++++++++++---- messages/en.json | 364 ++++++++++++---- messages/fr.json | 370 ++++++++++++---- messages/nl.json | 368 ++++++++++++---- messages/tr.json | 368 ++++++++++++---- package.json | 1 + scripts/verify-marketing-crawl-output.mjs | 396 ++++++++++++++++++ tests/unit/robots.test.ts | 36 ++ tests/unit/seo.test.ts | 85 +++- 29 files changed, 2762 insertions(+), 595 deletions(-) create mode 100644 app/[locale]/compare/alternatives/page.tsx create mode 100644 app/[locale]/guides/qr-menu-geneva/page.tsx create mode 100644 app/[locale]/guides/qr-menu-switzerland/page.tsx create mode 100644 components/discovery-guide-data.ts create mode 100644 lib/marketing-routes.ts create mode 100644 scripts/verify-marketing-crawl-output.mjs create mode 100644 tests/unit/robots.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6137a06..1071b0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,6 +163,16 @@ jobs: - run: npm run build env: NEXT_PUBLIC_SITE_URL: https://sofrapiwas.com + - run: npm run verify:seo-output + env: + SEO_EXPECTED_ORIGIN: https://sofrapiwas.com + - name: build non-canonical output (staging) + run: node_modules/.bin/next build + env: + NEXT_PUBLIC_SITE_URL: https://staging.sofrapiwas.com + - run: npm run verify:seo-output + env: + SEO_EXPECTED_ORIGIN: https://staging.sofrapiwas.com prisma_drift: name: prisma (migrations apply + schema drift) diff --git a/README.md b/README.md index c60eac2..6aeb985 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,10 @@ control plane is en-only, outside `[locale]`). Design system: craft/handmade — [docs/design-tokens.md](docs/design-tokens.md). Architecture decisions: [docs/adr/](docs/adr/) (ADR-001–011). Master plan: `rumi-workspace/docs/plans/SOFRA-SAAS-PLAN.md`; partner program: -`rumi-workspace/docs/plans/SOFRA-PARTNER-PLAN.md`; AEO: `rumi-workspace/docs/plans/SOFRA-AEO-PLAN.md`. +`rumi-workspace/docs/plans/SOFRA-PARTNER-PLAN.md`; AEO/discovery operations: +`rumi-workspace/docs/runbooks/public-search-discovery.md`. The marketing route +inventory lives in `lib/marketing-routes.ts`; canonical and staging crawl output +contracts live in `scripts/verify-marketing-crawl-output.mjs`. ## Develop diff --git a/app/[locale]/case/rumi/page.tsx b/app/[locale]/case/rumi/page.tsx index 7d9e87c..e8352f2 100644 --- a/app/[locale]/case/rumi/page.tsx +++ b/app/[locale]/case/rumi/page.tsx @@ -14,7 +14,7 @@ import { SITE_URL, marketingPageMetadata } from "@/lib/seo"; const RUMI_URL = "https://www.rumirestaurant.ch"; const LIVE_SINCE = "2026-06-29"; const PUBLISHED = "2026-07-10"; -const RUN_KEYS = ["ordering", "boards", "reservations", "loyalty", "printing"] as const; +const RUN_KEYS = ["ordering", "reservations"] as const; export async function generateMetadata({ params, diff --git a/app/[locale]/compare/alternatives/page.tsx b/app/[locale]/compare/alternatives/page.tsx new file mode 100644 index 0000000..372231d --- /dev/null +++ b/app/[locale]/compare/alternatives/page.tsx @@ -0,0 +1,161 @@ +import type { Metadata } from "next"; +import { setRequestLocale, getTranslations } from "next-intl/server"; +import { Link } from "@/i18n/navigation"; +import Header from "@/components/Header"; +import Footer from "@/components/Footer"; +import SectionLabel from "@/components/SectionLabel"; +import JsonLdScript from "@/components/JsonLdScript"; +import { + ALTERNATIVE_FAQ_KEYS, + ALTERNATIVE_PROVIDER_KEYS, + ALTERNATIVE_PROVIDER_SOURCE_KEYS, + ALTERNATIVES_SOURCES, + DISCOVERY_CONTENT_LAST_CHECKED, +} from "@/components/discovery-guide-data"; +import { SITE_URL, marketingPageMetadata } from "@/lib/seo"; + +export async function generateMetadata({ params }: { + params: Promise<{ locale: string }>; +}): Promise { + const { locale } = await params; + const t = await getTranslations({ locale, namespace: "discovery.alternatives" }); + return marketingPageMetadata({ + locale, + path: "/compare/alternatives", + title: t("meta.title"), + description: t("meta.description"), + }); +} + +export default async function AlternativesPage({ params }: Readonly<{ + params: Promise<{ locale: string }>; +}>) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getTranslations({ locale, namespace: "discovery.alternatives" }); + const checkedDate = new Intl.DateTimeFormat(locale, { + dateStyle: "long", + timeZone: "UTC", + }).format(new Date(DISCOVERY_CONTENT_LAST_CHECKED)); + const webPage = { + "@context": "https://schema.org", + "@type": "WebPage", + "@id": SITE_URL + "/" + locale + "/compare/alternatives", + name: t("meta.title"), + description: t("meta.description"), + inLanguage: locale, + dateModified: DISCOVERY_CONTENT_LAST_CHECKED, + isPartOf: { "@id": SITE_URL + "/#website" }, + }; + const faqPage = { + "@context": "https://schema.org", + "@type": "FAQPage", + mainEntity: ALTERNATIVE_FAQ_KEYS.map((key) => ({ + "@type": "Question", + name: t("faq." + key + ".q"), + acceptedAnswer: { "@type": "Answer", text: t("faq." + key + ".a") }, + })), + }; + const sourceFor = (key: string) => ALTERNATIVES_SOURCES.find((source) => source.key === key); + + return ( + <> + +
+
+
+ {t("label")} +

{t("title")}

+

{t("subtitle")}

+

+ +

+
+

{t("intro")}

+

{t("scopeNote")}

+
+ +
+

{t("table.title")}

+

{t("table.note", { date: checkedDate })}

+
+ + + + + {["provider", "fit", "published", "verify"].map((key) => ( + + ))} + + + + {ALTERNATIVE_PROVIDER_KEYS.map((key) => ( + + + + + + + ))} + +
{t("table.caption")}
{t("table.columns." + key)}
{t("providers." + key + ".name")}{t("providers." + key + ".fit")} + {t("providers." + key + ".published")} + + {t("providers." + key + ".verify")}
+
+
+ +
+

{t("transition.title")}

+

{t("transition.intro")}

+
    + {["inventory", "export", "pilot", "cutover"].map((key, index) => ( +
  1. + {index + 1} +

    {t("transition.steps." + key)}

    +
  2. + ))} +
+
+ +
+ {ALTERNATIVE_FAQ_KEYS.map((key) => ( +
+

{t("faq." + key + ".q")}

+

{t("faq." + key + ".a")}

+
+ ))} +
+ +
+

{t("sources.title")}

+

{t("sources.note", { date: checkedDate })}

+ +
+ + +
+
+