From 18800e9a5144adf85451a82a50a1a18e1a63bc9c Mon Sep 17 00:00:00 2001 From: Fabrice Rochette <45168870+mjfelis@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:58:46 -0500 Subject: [PATCH] feat(site): one legal entity per domain mobiera.com keeps Mobiera SAS (Colombia); mobiera.io now shows Mobiera Norte SA (Panama): name, Mercantile Registry folio, address, city, data-protection law, canonical origin. The proxy picks the site from the request headers and rewrites to the internal path ///..., so every page stays prerendered, once per site and language. app/lib/entity.ts is the single definition of both entities. Message files name the entity through [[tokens]] resolved per site when messages load. Footer, contact and privacy pages, structured data, canonical and hreflang links, sitemap, robots.txt, the RSS feed, the social image and the contact emails follow the domain. The response carries x-mobiera-site for verification. The social image becomes a route (/og-image) because the file convention published the internal path. --- README.md | 34 +++- app/{ => [site]}/[locale]/[...rest]/page.tsx | 0 .../certification/CertifiedIntegrators.tsx | 0 .../[locale]/certification/page.tsx | 0 .../[locale]/company/careers/page.tsx | 0 app/{ => [site]}/[locale]/company/page.tsx | 0 app/{ => [site]}/[locale]/contact/page.tsx | 9 +- .../[locale]/cookies/CookiePreferences.tsx | 0 app/{ => [site]}/[locale]/cookies/page.tsx | 0 app/{ => [site]}/[locale]/layout.tsx | 19 +- .../[locale]/news/[slug]/page.tsx | 9 +- app/{ => [site]}/[locale]/news/page.tsx | 0 app/{ => [site]}/[locale]/not-found.tsx | 0 .../[locale]/og-image/route.tsx} | 13 +- app/{ => [site]}/[locale]/page.tsx | 0 app/{ => [site]}/[locale]/privacy/page.tsx | 8 +- .../[locale]/telecom/ai-agents/page.tsx | 0 .../[locale]/telecom/aircast/page.tsx | 0 app/{ => [site]}/[locale]/telecom/page.tsx | 0 app/{ => [site]}/[locale]/trust/page.tsx | 0 .../[locale]/trust/services/page.tsx | 0 .../[locale]/trust/verana/page.tsx | 0 app/api/contact/route.ts | 20 +- app/components/Footer.tsx | 8 +- app/components/JsonLd.tsx | 18 +- app/components/Nav.tsx | 3 +- app/lib/email-layout.ts | 19 +- app/lib/entity.test.ts | 39 ++++ app/lib/entity.ts | 175 ++++++++++++++++++ app/lib/seo.ts | 16 +- app/lib/site.ts | 11 +- app/news/feed.xml/route.ts | 6 +- app/robots.ts | 8 +- app/sitemap.ts | 12 +- docs/configuration.md | 18 +- i18n/navigation.ts | 3 +- i18n/request.ts | 14 +- i18n/use-pathname.ts | 19 ++ messages/en/company.json | 56 ++++-- messages/en/contact.json | 2 +- messages/en/content.json | 152 +++++++++++---- messages/en/cookies.json | 2 +- messages/en/email.json | 2 +- messages/en/home.json | 2 +- messages/en/privacy.json | 6 +- messages/en/trust.json | 2 +- messages/es/company.json | 56 ++++-- messages/es/contact.json | 2 +- messages/es/content.json | 152 +++++++++++---- messages/es/cookies.json | 2 +- messages/es/email.json | 2 +- messages/es/home.json | 2 +- messages/es/privacy.json | 6 +- messages/es/trust.json | 2 +- proxy.ts | 29 ++- spec/facts.yaml | 16 ++ 56 files changed, 789 insertions(+), 185 deletions(-) rename app/{ => [site]}/[locale]/[...rest]/page.tsx (100%) rename app/{ => [site]}/[locale]/certification/CertifiedIntegrators.tsx (100%) rename app/{ => [site]}/[locale]/certification/page.tsx (100%) rename app/{ => [site]}/[locale]/company/careers/page.tsx (100%) rename app/{ => [site]}/[locale]/company/page.tsx (100%) rename app/{ => [site]}/[locale]/contact/page.tsx (88%) rename app/{ => [site]}/[locale]/cookies/CookiePreferences.tsx (100%) rename app/{ => [site]}/[locale]/cookies/page.tsx (100%) rename app/{ => [site]}/[locale]/layout.tsx (83%) rename app/{ => [site]}/[locale]/news/[slug]/page.tsx (83%) rename app/{ => [site]}/[locale]/news/page.tsx (100%) rename app/{ => [site]}/[locale]/not-found.tsx (100%) rename app/{[locale]/opengraph-image.tsx => [site]/[locale]/og-image/route.tsx} (75%) rename app/{ => [site]}/[locale]/page.tsx (100%) rename app/{ => [site]}/[locale]/privacy/page.tsx (82%) rename app/{ => [site]}/[locale]/telecom/ai-agents/page.tsx (100%) rename app/{ => [site]}/[locale]/telecom/aircast/page.tsx (100%) rename app/{ => [site]}/[locale]/telecom/page.tsx (100%) rename app/{ => [site]}/[locale]/trust/page.tsx (100%) rename app/{ => [site]}/[locale]/trust/services/page.tsx (100%) rename app/{ => [site]}/[locale]/trust/verana/page.tsx (100%) create mode 100644 app/lib/entity.test.ts create mode 100644 app/lib/entity.ts create mode 100644 i18n/use-pathname.ts diff --git a/README.md b/README.md index 2e6b2e0..28d1dcb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # mobiera.io -Source for [mobiera.io](https://mobiera.io), the Mobiera SAS website: +Source for the Mobiera website, served as [mobiera.io](https://mobiera.io) (Mobiera Norte SA, Panama) and [mobiera.com](https://mobiera.com) (Mobiera SAS, Colombia): operator-grade software for mobile networks (Aircast, AI One) and verifiable trust on Verana (trust services, integrator certification for Latin America). @@ -23,6 +23,38 @@ They render at `/news/` and in the RSS feed at `/news/feed.xml`. Design: direction A ("Signal") from `spec/design/`. Tokens live in `app/globals.css`; the logo is the existing Mobiera mark and wordmark. +## Two domains, two legal entities + +The same site answers on two domains, and the domain decides which company of +the group the visitor deals with: + +| Domain | Legal entity | Registration | Address | +| --- | --- | --- | --- | +| mobiera.com | Mobiera SAS | NIT 900662462-4 | Cra 13A 86A-42, Bogotá D.C., Colombia | +| mobiera.io | Mobiera Norte SA | Registro Mercantil, folio número 812363 | World Trade Center 200-B, Suite 214, Calle 53 Este, Marbella, Panama City, Panama | + +Everything that differs lives in `app/lib/entity.ts`: name, registration, +address, city, data-protection law, canonical origin. Nothing else is +duplicated. + +- `proxy.ts` picks the site from the request (`x-mobiera-site`, then any + forwarded-host header, then `Host`; a header naming mobiera.com wins) and + rewrites to the internal path `///...`. Pages stay + prerendered, once per site and language. Visitors never see the site + segment. The response header `x-mobiera-site` tells which one was served: + `curl -sI https://mobiera.com/ | grep -i x-mobiera-site`. +- Message files name the entity with tokens, resolved per site when messages + load: `[[legalName]]`, `[[legalRegistration]]`, `[[legalAddress]]`, + `[[legalCity]]`, `[[siteDomain]]`, `[[historyName]]`, `[[dataLaw]]`, + `[[rightsDays]]`, `[[noteBogota]]`, `[[notePanama]]`. Never write a company + name, registration or address in a message file. +- Server components read the entity with `getEntity(locale)`. Canonical URLs, + hreflang, the sitemap, robots.txt, the RSS feed, the structured data, the + social image and the contact emails all follow the domain. +- mobiera.com reaches the app through a reverse proxy. If that proxy hides its + name from the app, make it send `X-Mobiera-Site: com`. +- Unknown hosts (localhost) get `DEFAULT_SITE` (`io` unless set to `com`). + ## Languages English at the bare paths, Latin American Spanish under `/es`. `proxy.ts` diff --git a/app/[locale]/[...rest]/page.tsx b/app/[site]/[locale]/[...rest]/page.tsx similarity index 100% rename from app/[locale]/[...rest]/page.tsx rename to app/[site]/[locale]/[...rest]/page.tsx diff --git a/app/[locale]/certification/CertifiedIntegrators.tsx b/app/[site]/[locale]/certification/CertifiedIntegrators.tsx similarity index 100% rename from app/[locale]/certification/CertifiedIntegrators.tsx rename to app/[site]/[locale]/certification/CertifiedIntegrators.tsx diff --git a/app/[locale]/certification/page.tsx b/app/[site]/[locale]/certification/page.tsx similarity index 100% rename from app/[locale]/certification/page.tsx rename to app/[site]/[locale]/certification/page.tsx diff --git a/app/[locale]/company/careers/page.tsx b/app/[site]/[locale]/company/careers/page.tsx similarity index 100% rename from app/[locale]/company/careers/page.tsx rename to app/[site]/[locale]/company/careers/page.tsx diff --git a/app/[locale]/company/page.tsx b/app/[site]/[locale]/company/page.tsx similarity index 100% rename from app/[locale]/company/page.tsx rename to app/[site]/[locale]/company/page.tsx diff --git a/app/[locale]/contact/page.tsx b/app/[site]/[locale]/contact/page.tsx similarity index 88% rename from app/[locale]/contact/page.tsx rename to app/[site]/[locale]/contact/page.tsx index 87da5f8..08665c5 100644 --- a/app/[locale]/contact/page.tsx +++ b/app/[site]/[locale]/contact/page.tsx @@ -4,7 +4,8 @@ import { faBuilding, faLink, faRoute } from "@fortawesome/free-solid-svg-icons"; import ContactForm from "@/app/components/ContactForm"; import { Card, CardTitle, PageHero, Section } from "@/app/components/ui"; import { alternates, pageLocale, type PageParams } from "@/app/lib/seo"; -import { LEGAL, LINKS } from "@/app/lib/site"; +import { getEntity } from "@/app/lib/entity"; +import { LINKS } from "@/app/lib/site"; export async function generateMetadata({ params }: { params: Promise }): Promise { const locale = await pageLocale(params); @@ -15,6 +16,7 @@ export async function generateMetadata({ params }: { params: Promise export default async function Page({ params }: { params: Promise }) { const locale = await pageLocale(params); setRequestLocale(locale); + const entity = getEntity(locale); const t = await getTranslations("contact"); const bullets = t.raw("routed.bullets") as string[]; return ( @@ -45,8 +47,9 @@ export default async function Page({ params }: { params: Promise }) {t("who.title")}
- {LEGAL.name}, NIT {LEGAL.nit} - {LEGAL.address} + {entity.name} + {entity.registration} + {entity.address}
diff --git a/app/[locale]/cookies/CookiePreferences.tsx b/app/[site]/[locale]/cookies/CookiePreferences.tsx similarity index 100% rename from app/[locale]/cookies/CookiePreferences.tsx rename to app/[site]/[locale]/cookies/CookiePreferences.tsx diff --git a/app/[locale]/cookies/page.tsx b/app/[site]/[locale]/cookies/page.tsx similarity index 100% rename from app/[locale]/cookies/page.tsx rename to app/[site]/[locale]/cookies/page.tsx diff --git a/app/[locale]/layout.tsx b/app/[site]/[locale]/layout.tsx similarity index 83% rename from app/[locale]/layout.tsx rename to app/[site]/[locale]/layout.tsx index 95a4a29..d9e4133 100644 --- a/app/[locale]/layout.tsx +++ b/app/[site]/[locale]/layout.tsx @@ -4,15 +4,16 @@ import { NextIntlClientProvider } from "next-intl"; import { getTranslations, setRequestLocale } from "next-intl/server"; import "@fortawesome/fontawesome-svg-core/styles.css"; import { config as faConfig } from "@fortawesome/fontawesome-svg-core"; -import "../globals.css"; +import "../../globals.css"; faConfig.autoAddCss = false; import Nav from "@/app/components/Nav"; import Footer from "@/app/components/Footer"; import CookieConsent from "@/app/components/CookieConsent"; import Analytics from "@/app/components/Analytics"; -import { SITE_NAME, SITE_URL } from "@/app/lib/site"; -import { alternates, pageLocale, type PageParams } from "@/app/lib/seo"; +import { SITES, getEntity, type Site } from "@/app/lib/entity"; +import { SITE_NAME } from "@/app/lib/site"; +import { alternates, localizedPath, pageLocale, type PageParams } from "@/app/lib/seo"; import { HTML_LANG, OG_LOCALE, routing, type Locale } from "@/i18n/routing"; const sora = Sora({ subsets: ["latin"], weight: ["500", "600", "700"], variable: "--font-sora", display: "swap" }); @@ -22,24 +23,26 @@ const plexMono = IBM_Plex_Mono({ subsets: ["latin"], weight: ["400", "500"], var const inter = Inter({ subsets: ["latin"], weight: ["600"], variable: "--font-inter", display: "swap" }); const spaceGrotesk = Space_Grotesk({ subsets: ["latin"], weight: ["700"], variable: "--font-space-grotesk", display: "swap" }); -export function generateStaticParams(): { locale: Locale }[] { - return routing.locales.map((locale) => ({ locale })); +export function generateStaticParams(): { site: Site; locale: Locale }[] { + return SITES.flatMap((site) => routing.locales.map((locale) => ({ site, locale }))); } export async function generateMetadata({ params }: { params: Promise }): Promise { const locale = await pageLocale(params); const t = await getTranslations({ locale, namespace: "common.meta" }); const title = `${SITE_NAME}: ${t("tagline")}`; + const { url } = getEntity(locale); + const image = { url: localizedPath(locale, "/og-image"), width: 1200, height: 630, alt: title, type: "image/png" }; return { - metadataBase: new URL(SITE_URL), + metadataBase: new URL(url), title: { default: title, template: `%s | ${SITE_NAME}` }, description: t("description"), applicationName: SITE_NAME, keywords: t("keywords").split(",").map((k) => k.trim()), alternates: alternates(locale, "/"), robots: { index: true, follow: true, googleBot: { index: true, follow: true, "max-image-preview": "large", "max-snippet": -1 } }, - openGraph: { type: "website", siteName: SITE_NAME, url: SITE_URL, title, description: t("description"), locale: OG_LOCALE[locale] }, - twitter: { card: "summary_large_image", title, description: t("description") }, + openGraph: { type: "website", siteName: SITE_NAME, url, title, description: t("description"), locale: OG_LOCALE[locale], images: [image] }, + twitter: { card: "summary_large_image", title, description: t("description"), images: [image.url] }, icons: { icon: [{ url: "/images/favicon/favicon-32x32.png", sizes: "32x32", type: "image/png" }, { url: "/images/favicon/favicon-16x16.png", sizes: "16x16", type: "image/png" }], apple: "/apple-touch-icon.png", diff --git a/app/[locale]/news/[slug]/page.tsx b/app/[site]/[locale]/news/[slug]/page.tsx similarity index 83% rename from app/[locale]/news/[slug]/page.tsx rename to app/[site]/[locale]/news/[slug]/page.tsx index 4a61503..331a0d6 100644 --- a/app/[locale]/news/[slug]/page.tsx +++ b/app/[site]/[locale]/news/[slug]/page.tsx @@ -5,7 +5,8 @@ import ReactMarkdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import { Eyebrow } from "@/app/components/ui"; import { formatDate, getAllNews, getNewsBySlug } from "@/app/lib/news"; -import { alternates, pageLocale, type PageParams } from "@/app/lib/seo"; +import { SITES, type Site } from "@/app/lib/entity"; +import { alternates, localizedPath, pageLocale, type PageParams } from "@/app/lib/seo"; import { Link } from "@/i18n/navigation"; import { routing, type Locale } from "@/i18n/routing"; @@ -17,8 +18,8 @@ const components: Components = { a: ({ href = "", children }) => (href.startsWith("/") ? {children} : {children}), }; -export function generateStaticParams(): { locale: Locale; slug: string }[] { - return routing.locales.flatMap((locale) => getAllNews().map((n) => ({ locale, slug: n.slug }))); +export function generateStaticParams(): { site: Site; locale: Locale; slug: string }[] { + return SITES.flatMap((site) => routing.locales.flatMap((locale) => getAllNews().map((n) => ({ site, locale, slug: n.slug })))); } export async function generateMetadata({ params }: { params: Promise }): Promise { @@ -26,7 +27,7 @@ export async function generateMetadata({ params }: { params: Promise }): const { slug } = await params; const item = getNewsBySlug(slug, locale); if (!item) return {}; - return { title: item.title, description: item.summary, alternates: alternates(locale, `/news/${item.slug}`), openGraph: { type: "article", title: item.title, description: item.summary, publishedTime: item.date } }; + return { title: item.title, description: item.summary, alternates: alternates(locale, `/news/${item.slug}`), openGraph: { type: "article", title: item.title, description: item.summary, publishedTime: item.date, images: [localizedPath(locale, "/og-image")] } }; } export default async function Page({ params }: { params: Promise }) { diff --git a/app/[locale]/news/page.tsx b/app/[site]/[locale]/news/page.tsx similarity index 100% rename from app/[locale]/news/page.tsx rename to app/[site]/[locale]/news/page.tsx diff --git a/app/[locale]/not-found.tsx b/app/[site]/[locale]/not-found.tsx similarity index 100% rename from app/[locale]/not-found.tsx rename to app/[site]/[locale]/not-found.tsx diff --git a/app/[locale]/opengraph-image.tsx b/app/[site]/[locale]/og-image/route.tsx similarity index 75% rename from app/[locale]/opengraph-image.tsx rename to app/[site]/[locale]/og-image/route.tsx index 919305c..3b7e710 100644 --- a/app/[locale]/opengraph-image.tsx +++ b/app/[site]/[locale]/og-image/route.tsx @@ -1,12 +1,15 @@ import { ImageResponse } from "next/og"; import { getTranslations } from "next-intl/server"; +import { getEntity } from "@/app/lib/entity"; import { pageLocale, type PageParams } from "@/app/lib/seo"; -export const alt = "Mobiera: Trust networks and Verifiable Credentials, built on Verana."; -export const size = { width: 1200, height: 630 }; -export const contentType = "image/png"; +// Social preview image, per site and language. A route instead of the +// opengraph-image file convention: that one publishes the internal +// /// path, which does not exist for visitors. The layout +// links it as /og-image (or /es/og-image). +const size = { width: 1200, height: 630 }; -export default async function OpenGraphImage({ params }: { params: Promise }) { +export async function GET(_req: Request, { params }: { params: Promise }) { const locale = await pageLocale(params); const t = await getTranslations({ locale, namespace: "common" }); return new ImageResponse( @@ -22,7 +25,7 @@ export default async function OpenGraphImage({ params }: { params: Promise
{t("og.since")} - mobiera.io + {getEntity(locale).domain}
), diff --git a/app/[locale]/page.tsx b/app/[site]/[locale]/page.tsx similarity index 100% rename from app/[locale]/page.tsx rename to app/[site]/[locale]/page.tsx diff --git a/app/[locale]/privacy/page.tsx b/app/[site]/[locale]/privacy/page.tsx similarity index 82% rename from app/[locale]/privacy/page.tsx rename to app/[site]/[locale]/privacy/page.tsx index c444ef9..5188ebf 100644 --- a/app/[locale]/privacy/page.tsx +++ b/app/[site]/[locale]/privacy/page.tsx @@ -3,7 +3,7 @@ import { getTranslations, setRequestLocale } from "next-intl/server"; import { Link } from "@/i18n/navigation"; import { PageHero } from "@/app/components/ui"; import { alternates, pageLocale, type PageParams } from "@/app/lib/seo"; -import { LEGAL } from "@/app/lib/site"; +import { PRIVACY_EMAIL } from "@/app/lib/entity"; export async function generateMetadata({ params }: { params: Promise }): Promise { const locale = await pageLocale(params); @@ -15,14 +15,14 @@ export default async function Page({ params }: { params: Promise }) const locale = await pageLocale(params); setRequestLocale(locale); const t = await getTranslations("privacy"); - const mail = (chunks: React.ReactNode) => {chunks}; + const mail = (chunks: React.ReactNode) => {chunks}; const b = (chunks: React.ReactNode) => {chunks}; return ( <>

{t("who.title")}

-

{t.rich("who.body", { name: LEGAL.name, nit: LEGAL.nit, address: LEGAL.address, email: LEGAL.privacyEmail, mail })}

+

{t.rich("who.body", { email: PRIVACY_EMAIL, mail })}

{t("collect.title")}

{t.rich("collect.form", { b })}

{t.rich("collect.analytics", { b, link: (chunks) => {chunks} })}

@@ -33,7 +33,7 @@ export default async function Page({ params }: { params: Promise })

{t("processors.title")}

{t("processors.body")}

{t("rights.title")}

-

{t.rich("rights.body", { email: LEGAL.privacyEmail, mail })}

+

{t.rich("rights.body", { email: PRIVACY_EMAIL, mail })}

{t("changes.title")}

{t("changes.body")}

diff --git a/app/[locale]/telecom/ai-agents/page.tsx b/app/[site]/[locale]/telecom/ai-agents/page.tsx similarity index 100% rename from app/[locale]/telecom/ai-agents/page.tsx rename to app/[site]/[locale]/telecom/ai-agents/page.tsx diff --git a/app/[locale]/telecom/aircast/page.tsx b/app/[site]/[locale]/telecom/aircast/page.tsx similarity index 100% rename from app/[locale]/telecom/aircast/page.tsx rename to app/[site]/[locale]/telecom/aircast/page.tsx diff --git a/app/[locale]/telecom/page.tsx b/app/[site]/[locale]/telecom/page.tsx similarity index 100% rename from app/[locale]/telecom/page.tsx rename to app/[site]/[locale]/telecom/page.tsx diff --git a/app/[locale]/trust/page.tsx b/app/[site]/[locale]/trust/page.tsx similarity index 100% rename from app/[locale]/trust/page.tsx rename to app/[site]/[locale]/trust/page.tsx diff --git a/app/[locale]/trust/services/page.tsx b/app/[site]/[locale]/trust/services/page.tsx similarity index 100% rename from app/[locale]/trust/services/page.tsx rename to app/[site]/[locale]/trust/services/page.tsx diff --git a/app/[locale]/trust/verana/page.tsx b/app/[site]/[locale]/trust/verana/page.tsx similarity index 100% rename from app/[locale]/trust/verana/page.tsx rename to app/[site]/[locale]/trust/verana/page.tsx diff --git a/app/api/contact/route.ts b/app/api/contact/route.ts index 3e9dbc5..fb34d18 100644 --- a/app/api/contact/route.ts +++ b/app/api/contact/route.ts @@ -3,6 +3,7 @@ import { hasLocale } from "next-intl"; import { getTranslations } from "next-intl/server"; import { sendEmail, escapeHtml } from "@/app/lib/email"; import { emailLayout } from "@/app/lib/email-layout"; +import { entityFor, setRequestSite, siteFromHeaders, type Entity } from "@/app/lib/entity"; import { alertOps } from "@/app/lib/ops-alert"; import { contactRecipients, smtpServer } from "@/app/lib/smtp"; import { CONTACT_TOPICS, topicLabel, type ContactTopic } from "@/app/lib/site"; @@ -47,7 +48,7 @@ function inquiryHtml(rows: [string, string][], message: string): string { type Cv = { filename: string; content: Buffer; contentType: string }; /** Acknowledgement to the visitor, in the visitor's language. */ -async function acknowledgementEmail(locale: Locale, name: string, topic: ContactTopic): Promise<{ subject: string; html: string }> { +async function acknowledgementEmail(entity: Entity, locale: Locale, name: string, topic: ContactTopic): Promise<{ subject: string; html: string }> { const t = await getTranslations({ locale, namespace: "email" }); const tc = await getTranslations({ locale, namespace: "contact" }); const bodyHtml = ` @@ -56,11 +57,15 @@ async function acknowledgementEmail(locale: Locale, name: string, topic: Contact

${t("ack.signoff")}

`; return { subject: t("ack.subject"), - html: emailLayout({ heading: t("ack.heading", { name: escapeHtml(name) }), bodyHtml, lang: HTML_LANG[locale], footer: t("layout.footer") }), + html: emailLayout({ heading: t("ack.heading", { name: escapeHtml(name) }), bodyHtml, site: entity, lang: HTML_LANG[locale], footer: t("layout.footer") }), }; } export async function POST(req: NextRequest) { + // The domain the form was served from decides the legal entity named in + // the emails; registering it also resolves the [[tokens]] in the messages. + const site = siteFromHeaders(req.headers); + setRequestSite(site); let data: Record = {}; let cv: Cv | undefined; const contentType = req.headers.get("content-type") ?? ""; @@ -124,6 +129,7 @@ export async function POST(req: NextRequest) { const label = topicLabel(topic); // Language of the form the visitor used; only known locales, default English. const locale: Locale = hasLocale(routing.locales, data.locale) ? data.locale : routing.defaultLocale; + const entity = entityFor(site, locale); // Without SMTP (local dev) do not fail the user: log and report success. if (!smtpServer()) { @@ -137,11 +143,13 @@ export async function POST(req: NextRequest) { await sendEmail({ to, replyTo: `${name} <${email}>`, - subject: `[mobiera.io contact] ${label}: ${name}`, + subject: `[${entity.domain} contact] ${label}: ${name}`, html: emailLayout({ heading: `New inquiry: ${label}`, + site: entity, + footer: `${entity.name}, ${entity.city}. Sent from the contact form on ${entity.domain}.`, bodyHtml: inquiryHtml( - [["Topic", label], ["Name", name], ["Email", email], ["Organization", organization], ["Profile", profile], ["Language", HTML_LANG[locale]], ["CV", cv ? cv.filename : ""], ["Consent", new Date().toISOString()]], + [["Topic", label], ["Name", name], ["Email", email], ["Organization", organization], ["Profile", profile], ["Language", HTML_LANG[locale]], ["Site", entity.domain], ["CV", cv ? cv.filename : ""], ["Consent", new Date().toISOString()]], message, ), }), @@ -150,14 +158,14 @@ export async function POST(req: NextRequest) { console.info(`[contact] ${label} inquiry emailed to ${to.length} recipient(s)`); } catch (err) { console.error("[contact] delivery failed", err, { topic, name, email, organization }); - await alertOps(`Contact form (mobiera.io): email delivery failed for ${name} <${email}> (${label}). ${String(err).slice(0, 300)}`); + await alertOps(`Contact form (${entity.domain}): email delivery failed for ${name} <${email}> (${label}). ${String(err).slice(0, 300)}`); return NextResponse.json({ ok: false, error: "delivery" }, { status: 502 }); } // Acknowledgement to the visitor. Best effort: the inquiry is already // delivered, so a failure here is logged and does not fail the request. try { - const ack = await acknowledgementEmail(locale, name, topicValue); + const ack = await acknowledgementEmail(entity, locale, name, topicValue); await sendEmail({ to: email, subject: ack.subject, html: ack.html }); } catch (err) { console.error("[contact] acknowledgement failed", err, { email, locale }); diff --git a/app/components/Footer.tsx b/app/components/Footer.tsx index c508025..47020d6 100644 --- a/app/components/Footer.tsx +++ b/app/components/Footer.tsx @@ -1,9 +1,11 @@ -import { getTranslations } from "next-intl/server"; +import { getLocale, getTranslations } from "next-intl/server"; import { Link } from "@/i18n/navigation"; -import { LEGAL, LINKS } from "@/app/lib/site"; +import { FOUNDED, getEntity } from "@/app/lib/entity"; +import { LINKS } from "@/app/lib/site"; export default async function Footer() { const t = await getTranslations("common.footer"); + const entity = getEntity(await getLocale()); const year = new Date().getFullYear(); return (