Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion app/[locale]/case/rumi/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
161 changes: 161 additions & 0 deletions app/[locale]/compare/alternatives/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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 (
<>
<JsonLdScript data={[webPage, faqPage]} />
<Header />
<main>
<article className="mx-auto max-w-6xl px-6 py-craft-section-mobile md:py-craft-section">
<SectionLabel>{t("label")}</SectionLabel>
<h1 className="mt-4 max-w-4xl font-display font-bold text-5xl md:text-6xl">{t("title")}</h1>
<p className="mt-3 max-w-3xl text-muted-foreground leading-relaxed">{t("subtitle")}</p>
<p className="mt-4 font-mono text-xs text-primary">
<time dateTime={DISCOVERY_CONTENT_LAST_CHECKED}>{t("checked", { date: checkedDate })}</time>
</p>
<div className="mt-8 max-w-3xl space-y-4 text-muted-foreground leading-relaxed">
<p>{t("intro")}</p>
<p>{t("scopeNote")}</p>
</div>

<section className="mt-10" aria-labelledby="alternatives-table">
<h2 id="alternatives-table" className="font-hand text-3xl font-bold">{t("table.title")}</h2>
<p className="mt-2 text-sm text-muted-foreground">{t("table.note", { date: checkedDate })}</p>
<section aria-label={t("table.caption")} className="mt-4 overflow-x-auto hand-drawn-border">
<table className="w-full min-w-[900px] border-collapse text-start text-sm">
<caption className="sr-only">{t("table.caption")}</caption>
<thead className="bg-muted/50">
<tr>
{["provider", "fit", "published", "verify"].map((key) => (
<th key={key} scope="col" className="p-4 font-label font-bold">{t("table.columns." + key)}</th>
))}
</tr>
</thead>
<tbody>
{ALTERNATIVE_PROVIDER_KEYS.map((key) => (
<tr key={key} className="border-t border-border align-top">
<th scope="row" className="p-4 font-label font-bold">{t("providers." + key + ".name")}</th>
<td className="p-4 text-muted-foreground leading-relaxed">{t("providers." + key + ".fit")}</td>
<td className="p-4 text-muted-foreground leading-relaxed">
{t("providers." + key + ".published")}
<ul className="mt-3 space-y-1 text-xs">
{ALTERNATIVE_PROVIDER_SOURCE_KEYS[key].map((sourceKey) => {
const source = sourceFor(sourceKey);
return source ? (
<li key={sourceKey}>
<a href={source.url} target="_blank" rel="noopener noreferrer" className="underline decoration-primary/60 underline-offset-4 hover:text-primary">
{t("sources.items." + sourceKey)} ↗
</a>
</li>
) : null;
})}
</ul>
</td>
<td className="p-4 text-muted-foreground leading-relaxed">{t("providers." + key + ".verify")}</td>
</tr>
))}
</tbody>
</table>
</section>
</section>

<section className="mt-12">
<h2 className="font-hand text-3xl font-bold">{t("transition.title")}</h2>
<p className="mt-2 max-w-3xl text-muted-foreground leading-relaxed">{t("transition.intro")}</p>
<ol className="mt-4 grid gap-4 md:grid-cols-2">
{["inventory", "export", "pilot", "cutover"].map((key, index) => (
<li key={key} className="hand-drawn-border bg-card p-5">
<span className="font-mono text-xs text-primary">{index + 1}</span>
<p className="mt-2 text-muted-foreground leading-relaxed">{t("transition.steps." + key)}</p>
</li>
))}
</ol>
</section>

<section className="mt-12 space-y-7">
{ALTERNATIVE_FAQ_KEYS.map((key) => (
<div key={key}>
<h2 className="font-hand text-2xl font-bold">{t("faq." + key + ".q")}</h2>
<p className="mt-2 max-w-3xl text-muted-foreground leading-relaxed">{t("faq." + key + ".a")}</p>
</div>
))}
</section>

<section className="mt-12">
<h2 className="font-hand text-3xl font-bold">{t("sources.title")}</h2>
<p className="mt-2 text-sm text-muted-foreground">{t("sources.note", { date: checkedDate })}</p>
<ul className="mt-3 space-y-2 text-sm">
{ALTERNATIVES_SOURCES.map(({ key, url }) => (
<li key={key}><a href={url} target="_blank" rel="noopener noreferrer" className="underline decoration-primary/60 underline-offset-4 hover:text-primary">{t("sources.items." + key)} ↗</a></li>
))}
</ul>
</section>

<nav aria-label={t("related.label")} className="mt-12 flex flex-wrap gap-x-6 gap-y-3">
<Link href="/compare/gloriafood" className="underline decoration-primary/60 underline-offset-4">{t("related.gloriafood")}</Link>
<Link href="/guides/qr-menu-switzerland" className="underline decoration-primary/60 underline-offset-4">{t("related.switzerland")}</Link>
<Link href="/guides/qr-menu-geneva" className="underline decoration-primary/60 underline-offset-4">{t("related.geneva")}</Link>
</nav>
</article>
</main>
<Footer />
</>
);
}
35 changes: 34 additions & 1 deletion app/[locale]/compare/gloriafood/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import CompareTable from "@/components/CompareTable";
import {
COMPARE_LAST_CHECKED,
COMPARE_QA_KEYS,
COMPARE_TRANSITION_KEYS,
GLORIAFOOD_SERVICE_URL,
COMPARE_SOURCES,
FIT_GF_KEYS,
FIT_SOFRA_KEYS,
Expand Down Expand Up @@ -80,6 +82,37 @@ export default async function CompareGloriaFoodPage({
<p>{t("intro2")}</p>
</div>

<section
aria-labelledby="gloriafood-service-update"
className="mt-8 hand-drawn-border bg-muted/40 p-6"
>
<h2
id="gloriafood-service-update"
className="font-hand text-3xl font-bold"
>
{t("service.title")}
</h2>
<p className="mt-3 max-w-3xl text-muted-foreground leading-relaxed">
{t("service.body")}{" "}
<a
href={GLORIAFOOD_SERVICE_URL}
target="_blank"
rel="noopener noreferrer"
className="underline decoration-primary/60 underline-offset-4 hover:text-primary transition-colors"
>
{t("service.link")}
</a>
</p>
<h3 className="mt-6 font-label font-bold">
{t("service.transitionTitle")}
</h3>
<ol className="mt-3 list-decimal space-y-2 ps-5 text-muted-foreground leading-relaxed">
{COMPARE_TRANSITION_KEYS.map((key) => (
<li key={key}>{t("service.steps." + key)}</li>
))}
</ol>
</section>

<CompareTable />
<p className="mt-4 text-xs text-muted-foreground leading-relaxed">
{t("disclaimer")}
Expand Down Expand Up @@ -140,7 +173,7 @@ export default async function CompareGloriaFoodPage({
<h2 className="font-hand text-3xl font-bold">{t("ctaTitle")}</h2>
<p className="mt-2 text-muted-foreground">{t("ctaBody")}</p>
<div className="mt-6 flex flex-wrap gap-4">
<Link href="/#waitlist" className="btn-primary">
<Link href="/signup" className="btn-primary">
{t("ctaButton")}
</Link>
<Link href="/case/rumi" className="btn-secondary">
Expand Down
49 changes: 49 additions & 0 deletions app/[locale]/guides/qr-menu-geneva/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { Metadata } from "next";
import { setRequestLocale, getTranslations } from "next-intl/server";
import DiscoveryGuidePage from "@/components/DiscoveryGuidePage";
import { DISCOVERY_CONTENT_LAST_CHECKED, GENEVA_SOURCES, GENEVA_STEP_KEYS } from "@/components/discovery-guide-data";
import { marketingPageMetadata } from "@/lib/seo";

export async function generateMetadata({ params }: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "discovery.geneva" });
return marketingPageMetadata({
locale,
path: "/guides/qr-menu-geneva",
title: t("meta.title"),
description: t("meta.description"),
});
}

export default async function GenevaQrGuide({ params }: Readonly<{
params: Promise<{ locale: string }>;
}>) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations({ locale, namespace: "discovery.geneva" });
const checkedDate = new Intl.DateTimeFormat(locale, { dateStyle: "long", timeZone: "UTC" }).format(new Date(DISCOVERY_CONTENT_LAST_CHECKED));
return <DiscoveryGuidePage
locale={locale}
path="/guides/qr-menu-geneva"
metaTitle={t("meta.title")}
metaDescription={t("meta.description")}
label={t("label")}
title={t("title")}
subtitle={t("subtitle")}
checked={t("checked", { date: checkedDate })}
intro={t("intro")}
stepsTitle={t("stepsTitle")}
steps={GENEVA_STEP_KEYS.map((key) => ({ key, title: t("steps." + key + ".title"), body: t("steps." + key + ".body") }))}
fieldCheck={{ title: t("fieldCheck.title"), body: t("fieldCheck.body"), link: { href: "/case/rumi", label: t("fieldCheck.link") } }}
sourcesTitle={t("sources.title")}
sourcesNote={t("sources.note", { date: checkedDate })}
sources={GENEVA_SOURCES.map(({ key, url }) => ({ key, url, label: t("sources.items." + key) }))}
relatedLabel={t("related.label")}
related={[
{ href: "/compare/alternatives", label: t("related.alternatives") },
{ href: "/guides/qr-menu-switzerland", label: t("related.switzerland") },
]}
/>;
}
49 changes: 49 additions & 0 deletions app/[locale]/guides/qr-menu-switzerland/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { Metadata } from "next";
import { setRequestLocale, getTranslations } from "next-intl/server";
import DiscoveryGuidePage from "@/components/DiscoveryGuidePage";
import { DISCOVERY_CONTENT_LAST_CHECKED, SWITZERLAND_SOURCES, SWITZERLAND_STEP_KEYS } from "@/components/discovery-guide-data";
import { marketingPageMetadata } from "@/lib/seo";

export async function generateMetadata({ params }: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "discovery.switzerland" });
return marketingPageMetadata({
locale,
path: "/guides/qr-menu-switzerland",
title: t("meta.title"),
description: t("meta.description"),
});
}

export default async function SwitzerlandQrGuide({ params }: Readonly<{
params: Promise<{ locale: string }>;
}>) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations({ locale, namespace: "discovery.switzerland" });
const checkedDate = new Intl.DateTimeFormat(locale, { dateStyle: "long", timeZone: "UTC" }).format(new Date(DISCOVERY_CONTENT_LAST_CHECKED));
return <DiscoveryGuidePage
locale={locale}
path="/guides/qr-menu-switzerland"
metaTitle={t("meta.title")}
metaDescription={t("meta.description")}
label={t("label")}
title={t("title")}
subtitle={t("subtitle")}
checked={t("checked", { date: checkedDate })}
intro={t("intro")}
stepsTitle={t("stepsTitle")}
steps={SWITZERLAND_STEP_KEYS.map((key) => ({ key, title: t("steps." + key + ".title"), body: t("steps." + key + ".body") }))}
fieldCheck={{ title: t("fieldCheck.title"), body: t("fieldCheck.body") }}
sourcesTitle={t("sources.title")}
sourcesNote={t("sources.note", { date: checkedDate })}
sources={SWITZERLAND_SOURCES.map(({ key, url }) => ({ key, url, label: t("sources.items." + key) }))}
relatedLabel={t("related.label")}
related={[
{ href: "/compare/alternatives", label: t("related.alternatives") },
{ href: "/guides/qr-menu-geneva", label: t("related.geneva") },
]}
/>;
}
4 changes: 2 additions & 2 deletions app/[locale]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { NextIntlClientProvider, hasLocale } from "next-intl";
import { setRequestLocale, getTranslations } from "next-intl/server";
import "../globals.css";
import { routing } from "@/i18n/routing";
import { SITE_URL, pageAlternates, OG_IMAGE } from "@/lib/seo";
import { IS_CANONICAL_SITE, SITE_URL, pageAlternates, OG_IMAGE } from "@/lib/seo";
import { fontClassNames, themeInitScript } from "@/lib/fonts";
import ThemeSync from "@/components/ThemeSync";

Expand Down Expand Up @@ -40,7 +40,7 @@ export async function generateMetadata({
description: t("description"),
images: [OG_IMAGE],
},
robots: { index: true, follow: true },
robots: { index: IS_CANONICAL_SITE, follow: true },
};
}

Expand Down
Loading
Loading