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..f7883ff 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,11 @@ Next.js 15 App Router, Tailwind, next-intl (en/fr/de/nl/tr/ar — ar is RTL; control plane is en-only, outside `[locale]`). Design system: craft/handmade — see [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`. +Master plan: `rumi-workspace/docs/plans/SOFRA-SAAS-PLAN.md`; partner flexibility: +`rumi-workspace/docs/plans/SOFRA-PARTNER-FLEXIBILITY-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..b5848b6 --- /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 })}

+ +
+ + +
+
+