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
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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).

Expand All @@ -23,6 +23,38 @@ They render at `/news/<slug>` 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 `/<site>/<locale>/...`. 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`
Expand Down
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -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<PageParams> }): Promise<Metadata> {
const locale = await pageLocale(params);
Expand All @@ -15,6 +16,7 @@ export async function generateMetadata({ params }: { params: Promise<PageParams>
export default async function Page({ params }: { params: Promise<PageParams> }) {
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 (
Expand Down Expand Up @@ -45,8 +47,9 @@ export default async function Page({ params }: { params: Promise<PageParams> })
<Card>
<CardTitle icon={faBuilding}>{t("who.title")}</CardTitle>
<address className="not-italic text-muted">
<strong className="text-ink block">{LEGAL.name}, NIT {LEGAL.nit}</strong>
{LEGAL.address}
<strong className="text-ink block">{entity.name}</strong>
<span className="block">{entity.registration}</span>
{entity.address}
</address>
</Card>
</div>
Expand Down
File renamed without changes.
19 changes: 11 additions & 8 deletions app/[locale]/layout.tsx → app/[site]/[locale]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand All @@ -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<PageParams> }): Promise<Metadata> {
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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -17,16 +18,16 @@ const components: Components = {
a: ({ href = "", children }) => (href.startsWith("/") ? <Link href={href}>{children}</Link> : <a href={href} rel="noopener">{children}</a>),
};

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<Params> }): Promise<Metadata> {
const locale = await pageLocale(params);
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<Params> }) {
Expand Down
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -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
// /<site>/<locale>/ 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<PageParams> }) {
export async function GET(_req: Request, { params }: { params: Promise<PageParams> }) {
const locale = await pageLocale(params);
const t = await getTranslations({ locale, namespace: "common" });
return new ImageResponse(
Expand All @@ -22,7 +25,7 @@ export default async function OpenGraphImage({ params }: { params: Promise<PageP
</div>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 22, color: "#50E2AA", letterSpacing: 2 }}>
<span>{t("og.since")}</span>
<span>mobiera.io</span>
<span>{getEntity(locale).domain}</span>
</div>
</div>
),
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -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<PageParams> }): Promise<Metadata> {
const locale = await pageLocale(params);
Expand All @@ -15,14 +15,14 @@ export default async function Page({ params }: { params: Promise<PageParams> })
const locale = await pageLocale(params);
setRequestLocale(locale);
const t = await getTranslations("privacy");
const mail = (chunks: React.ReactNode) => <a href={`mailto:${LEGAL.privacyEmail}`}>{chunks}</a>;
const mail = (chunks: React.ReactNode) => <a href={`mailto:${PRIVACY_EMAIL}`}>{chunks}</a>;
const b = (chunks: React.ReactNode) => <strong>{chunks}</strong>;
return (
<>
<PageHero eyebrow={t("eyebrow")} title={t("title")} lead={t("lead")} ground={false} />
<div className="container-x prose pb-20">
<h2>{t("who.title")}</h2>
<p>{t.rich("who.body", { name: LEGAL.name, nit: LEGAL.nit, address: LEGAL.address, email: LEGAL.privacyEmail, mail })}</p>
<p>{t.rich("who.body", { email: PRIVACY_EMAIL, mail })}</p>
<h2>{t("collect.title")}</h2>
<p>{t.rich("collect.form", { b })}</p>
<p>{t.rich("collect.analytics", { b, link: (chunks) => <Link href="/cookies">{chunks}</Link> })}</p>
Expand All @@ -33,7 +33,7 @@ export default async function Page({ params }: { params: Promise<PageParams> })
<h2>{t("processors.title")}</h2>
<p>{t("processors.body")}</p>
<h2>{t("rights.title")}</h2>
<p>{t.rich("rights.body", { email: LEGAL.privacyEmail, mail })}</p>
<p>{t.rich("rights.body", { email: PRIVACY_EMAIL, mail })}</p>
<h2>{t("changes.title")}</h2>
<p>{t("changes.body")}</p>
</div>
Expand Down
File renamed without changes.
File renamed without changes.
20 changes: 14 additions & 6 deletions app/api/contact/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 = `
Expand All @@ -56,11 +57,15 @@ async function acknowledgementEmail(locale: Locale, name: string, topic: Contact
<p style="margin:0;">${t("ack.signoff")}</p>`;
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<string, string> = {};
let cv: Cv | undefined;
const contentType = req.headers.get("content-type") ?? "";
Expand Down Expand Up @@ -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()) {
Expand All @@ -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,
),
}),
Expand All @@ -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 });
Expand Down
8 changes: 5 additions & 3 deletions app/components/Footer.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<footer className="site-footer">
Expand Down Expand Up @@ -46,7 +48,7 @@ export default async function Footer() {
</div>
</div>
<div className="fbottom">
<span>© {LEGAL.founded}-{year} {LEGAL.name}, {LEGAL.city}</span>
<span>© {FOUNDED}-{year} {entity.name}, {entity.city}</span>
<span className="flex flex-wrap gap-x-3">
<Link href="/privacy">{t("privacy")}</Link>
<Link href="/cookies">{t("cookies")}</Link>
Expand Down
18 changes: 10 additions & 8 deletions app/components/JsonLd.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
import { getTranslations } from "next-intl/server";
import { LEGAL, LINKS, SITE_NAME, SITE_URL } from "@/app/lib/site";
import { getLocale, getTranslations } from "next-intl/server";
import { FOUNDED, getEntity } from "@/app/lib/entity";
import { LINKS, SITE_NAME } from "@/app/lib/site";

/** Organization structured data, rendered on the home page only. */
export default async function JsonLd() {
const t = await getTranslations("common");
const entity = getEntity(await getLocale());
const data = {
"@context": "https://schema.org",
"@type": "Organization",
"@id": `${SITE_URL}/#organization`,
"@id": `${entity.url}/#organization`,
name: SITE_NAME,
legalName: LEGAL.name,
url: SITE_URL,
logo: `${SITE_URL}/images/favicon/android-chrome-512x512.png`,
legalName: entity.name,
url: entity.url,
logo: `${entity.url}/images/favicon/android-chrome-512x512.png`,
description: t("meta.description"),
foundingDate: String(LEGAL.founded),
address: { "@type": "PostalAddress", streetAddress: "Cra 13A 86A-42", addressLocality: "Bogotá D.C.", addressCountry: "CO" },
foundingDate: String(FOUNDED),
address: { "@type": "PostalAddress", ...entity.postal },
sameAs: [LINKS.linkedin, LINKS.github],
memberOf: { "@type": "Organization", name: "Verana Foundation", url: LINKS.foundation },
};
Expand Down
3 changes: 2 additions & 1 deletion app/components/Nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

import { useEffect, useState } from "react";
import { useLocale, useTranslations } from "next-intl";
import { Link, usePathname } from "@/i18n/navigation";
import { Link } from "@/i18n/navigation";
import { usePathname } from "@/i18n/use-pathname";
import Logo from "./Logo";
import { NAV } from "@/app/lib/site";

Expand Down
Loading
Loading