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
90 changes: 24 additions & 66 deletions app/(site)/[lang]/(adaptive)/contact/page.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,34 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { Suspense } from "react";
import { siInstagram } from "simple-icons";

import { FormSkeleton } from "@/components/forms/FormSkeleton";
import { pageGutter } from "@/components/layout/nav/rail";
import { BrandIcon } from "@/components/ui/BrandIcon";
import { PageTitle } from "@/components/ui/PageTitle";
import { PageHeader } from "@/components/ui/PageHeader";
import { PageShell } from "@/components/ui/PageShell";
import { Skeleton } from "@/components/ui/Skeleton";
import { CONTACT_SETTINGS_QUERY } from "@/content/siteSettings";
import { cn } from "@/lib/cn";
import { emailForDisplay } from "@/lib/email/display";
import { ContactLinks, getSiteSettings } from "@/content/siteSettings";
import { countryList } from "@/lib/forms/countries";
import { defaultLocale, isLocale, languageAlternates, type Locale } from "@/lib/i18n/config";
import { getLocale, type Locale } from "@/lib/i18n/config";
import { getDictionary } from "@/lib/i18n/dictionaries";
import { instagramHandle } from "@/lib/instagram";
import { cachedSanity, getDynamicFetchOptions } from "@/sanity/lib/live";
import { getPageText } from "@/sanity/lib/pageText";
import { pageMetadata, routes } from "@/lib/routes";

import { ContactForm } from "./ContactForm";

export async function generateMetadata({ params }: PageProps<"/[lang]/contact">): Promise<Metadata> {
const { lang } = await params;
if (!isLocale(lang)) return {};
const [{ contact }, text] = await Promise.all([getDictionary(lang), getPageText(lang)]);
return { title: contact.title, description: text.contactIntro ?? undefined, alternates: languageAlternates(lang, "/contact") };
const lang = await getLocale(params);
const [{ contact }, { contactIntro }] = await Promise.all([getDictionary(lang), getSiteSettings(lang, { draft: false })]);
return pageMetadata(lang, routes.contact(), { title: contact.title, description: contactIntro ?? undefined });
}

export default async function ContactPage({ params }: PageProps<"/[lang]/contact">) {
const { lang } = await params;
if (!isLocale(lang)) notFound();
const [{ contact, forms, common }, text] = await Promise.all([getDictionary(lang), getPageText(lang)]);
const lang = await getLocale(params);
const [{ contact, forms, common }, { contactTagline, contactIntro }] = await Promise.all([
getDictionary(lang),
getSiteSettings(lang, { draft: false }),
]);
return (
<main className={cn("mx-auto grid max-w-6xl gap-12 pb-32 lg:grid-cols-[3fr_2fr]", pageGutter)}>
<PageShell className="grid gap-12 lg:grid-cols-[3fr_2fr]">
<div>
<header className="mb-10">
<PageTitle title={contact.title} tagline={text.contactTagline} />
{text.contactIntro && <p className="mt-4 max-w-2xl text-lg text-fg-muted">{text.contactIntro}</p>}
</header>
<PageHeader title={contact.title} tagline={contactTagline} intro={contactIntro} className="mb-10" />
{/* ContactForm reads the ?type= query, which needs a boundary to keep the page static. */}
<Suspense fallback={<FormSkeleton label={common.loading} fields={3} />}>
<ContactForm lang={lang} dict={{ contact, forms }} countries={countryList(lang)} />
Expand All @@ -46,57 +37,24 @@ export default async function ContactPage({ params }: PageProps<"/[lang]/contact
<Suspense fallback={<DirectContactSkeleton />}>
<DirectContact lang={lang} />
</Suspense>
</main>
</PageShell>
);
}

async function DirectContact({ lang }: { lang: Locale }) {
const { contact } = await getDictionary(lang);
const { perspective, variant } = await getDynamicFetchOptions();
const { data } = await cachedSanity({
query: CONTACT_SETTINGS_QUERY,
params: { lang, fallbackLang: defaultLocale },
perspective,
variant,
stega: false,
});
const settings = data;
if (!settings?.contactEmail && !settings?.instagram) return null;
const email = settings.contactEmail ? emailForDisplay(settings.contactEmail) : null;
const [{ contact: labels }, { contact }] = await Promise.all([getDictionary(lang), getSiteSettings(lang)]);
if (!contact.email && !contact.instagram) return null;

return (
<aside aria-labelledby="direct-contact" className="lg:pt-28">
<h2 id="direct-contact" className="font-display text-2xl">
{contact.directHeading}
{labels.directHeading}
</h2>
<dl className="mt-4 space-y-4">
{email && (
<div>
<dt className="text-sm text-fg-muted">{contact.emailLabel}</dt>
<dd>
<a href={`mailto:${email}`} className="link-inline">
{email}
</a>
</dd>
</div>
)}
{settings.instagram && (
<div>
<dt className="text-sm text-fg-muted">{contact.instagramLabel}</dt>
<dd>
<a
href={settings.instagram}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 link-inline"
>
<BrandIcon icon={siInstagram} />
{instagramHandle(settings.instagram)}
</a>
</dd>
</div>
)}
</dl>
<ContactLinks
contact={contact}
labels={{ email: labels.emailLabel, instagram: labels.instagramLabel }}
variant="definitions"
/>
</aside>
);
}
Expand Down
147 changes: 24 additions & 123 deletions app/(site)/[lang]/(adaptive)/privacy/page.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { type ReactNode, Suspense } from "react";
import { Suspense } from "react";

import { pageGutter } from "@/components/layout/nav/rail";
import { PageShell } from "@/components/ui/PageShell";
import { PageTitle } from "@/components/ui/PageTitle";
import { CONTACT_SETTINGS_QUERY } from "@/content/siteSettings";
import { cn } from "@/lib/cn";
import { emailForDisplay } from "@/lib/email/display";
import { defaultLocale, isLocale, languageAlternates, type Locale, localizedPath } from "@/lib/i18n/config";
import { ContactLinks, getSiteSettings } from "@/content/siteSettings";
import { BulletList, PolicySection as Section, StorageGroup, WithLink } from "@/features/privacy";
import { formatLongDate } from "@/lib/dates";
import { dateLocale, getLocale, type Locale } from "@/lib/i18n/config";
import { getDictionary } from "@/lib/i18n/dictionaries";
import { format } from "@/lib/i18n/format";
import { cachedSanity, getDynamicFetchOptions } from "@/sanity/lib/live";
import { href, pageMetadata, routes } from "@/lib/routes";

/** Where a complaint to Ireland's data protection authority is made. */
const DPC_URL = "https://www.dataprotection.ie";
Expand All @@ -19,21 +18,19 @@ const DPC_URL = "https://www.dataprotection.ie";
const UPDATED = "2026-11-08";

export async function generateMetadata({ params }: PageProps<"/[lang]/privacy">): Promise<Metadata> {
const { lang } = await params;
if (!isLocale(lang)) return {};
const lang = await getLocale(params);
const { privacy } = await getDictionary(lang);
return { title: privacy.title, alternates: languageAlternates(lang, "/privacy") };
return pageMetadata(lang, routes.privacy(), { title: privacy.title });
}

export default async function PrivacyPage({ params }: PageProps<"/[lang]/privacy">) {
const { lang } = await params;
if (!isLocale(lang)) notFound();
const lang = await getLocale(params);
const { privacy } = await getDictionary(lang);
const date = new Date(`${UPDATED}T00:00:00Z`).toLocaleDateString(lang, { dateStyle: "long", timeZone: "UTC" });
const date = formatLongDate(UPDATED, dateLocale[lang]);
const { who, collect, why, providers, storage, retention, transfers, rights } = privacy;
// A reading column: about 65 characters a line.
return (
<main className={cn("mx-auto max-w-prose pb-16", pageGutter)}>
<PageShell className="max-w-prose pb-16">
<header className="mb-12">
<PageTitle title={privacy.title} />
<p className="mt-3 text-sm text-fg-muted">{format(privacy.updated, { date })}</p>
Expand Down Expand Up @@ -115,120 +112,24 @@ export default async function PrivacyPage({ params }: PageProps<"/[lang]/privacy

<Section heading={privacy.contactHeading}>
<p>{privacy.contactBody}</p>
<ul className="space-y-2">
<li>
<a href={localizedPath(lang, "/contact")} className="link-inline">
{privacy.contactForm}
</a>
</li>
{/* Reads draft mode, so it streams in behind the rest of the page. */}
<Suspense>
<Email lang={lang} />
</Suspense>
</ul>
<p>
<a href={href(lang, routes.contact())} className="link-inline">
{privacy.contactForm}
</a>
</p>
{/* Reads draft mode, so it streams in behind the rest of the page. */}
<Suspense>
<Email lang={lang} />
</Suspense>
</Section>
</div>
</main>
);
}

/** `text` with `placeholder` replaced by an external link, so a translation can put the link wherever its grammar wants it. */
function WithLink({
text,
placeholder,
href,
label,
newTab,
}: {
text: string;
placeholder: string;
href: string;
label: string;
newTab: string;
}) {
const [before, after = ""] = text.split(placeholder);
return (
<>
{before}
<a href={href} target="_blank" rel="noopener noreferrer" className="link-inline">
{label}
<span className="sr-only"> ({newTab})</span>
</a>
{after}
</>
);
}

/** A titled block of running text. */
function Section({ heading, children }: { heading: string; children: ReactNode }) {
const id = slug(heading);
return (
<section aria-labelledby={id}>
<h2 id={id} className="font-display text-2xl">
{heading}
</h2>
<div className="mt-3 space-y-3 leading-relaxed text-fg/90">{children}</div>
</section>
</PageShell>
);
}

function BulletList({ items }: { items: readonly string[] }) {
return (
<ul className="list-disc space-y-2 pl-5 marker:text-fg-muted">
{items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
);
}

type StorageEntry = { name: string; type: string; lasts: string; description: string };

/** What a site stores in the browser, one entry per cookie or key: its name as code, then what it is for. */
function StorageGroup({ title, items }: { title: string; items: readonly StorageEntry[] }) {
return (
<div className="pt-2">
<h3 className="font-medium text-fg">{title}</h3>
<dl className="mt-3 divide-y divide-line border-y border-line">
{items.map((item) => (
<div key={item.name} className="py-4">
<dt className="flex flex-wrap items-baseline gap-x-4 gap-y-1">
<code className="rounded-md bg-bg-raised px-2 py-0.5 font-mono text-sm text-fg">{item.name}</code>
<span className="text-sm text-fg-muted">
{item.type} · {item.lasts}
</span>
</dt>
<dd className="mt-2">{item.description}</dd>
</div>
))}
</dl>
</div>
);
}

/** A heading as an id: "Who handles it for us" → "who-handles-it-for-us". */
const slug = (heading: string) =>
`privacy-${heading
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, "-")
.replace(/^-|-$/g, "")}`;

async function Email({ lang }: { lang: Locale }) {
const { perspective, variant } = await getDynamicFetchOptions();
const { data } = await cachedSanity({
query: CONTACT_SETTINGS_QUERY,
params: { lang, fallbackLang: defaultLocale },
perspective,
variant,
stega: false,
});
if (!data?.contactEmail) return null;
const email = emailForDisplay(data.contactEmail);
const [{ contact: links }, { contact }] = await Promise.all([getSiteSettings(lang), getDictionary(lang)]);
return (
<li>
<a href={`mailto:${email}`} className="link-inline">
{email}
</a>
</li>
<ContactLinks contact={links} labels={{ email: contact.emailLabel, instagram: contact.instagramLabel }} variant="list" />
);
}
9 changes: 1 addition & 8 deletions content/siteSettings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,4 @@ export type { Team } from "./components/TeamFigure";
export { TeamFigure, TeamPhoto } from "./components/TeamFigure";
export type { SiteContact, SiteSettings } from "./loaders";
export { getSiteSettings } from "./loaders";
export {
ABOUT_QUERY,
CONTACT_SETTINGS_QUERY,
HOME_STILLS_QUERY,
PAGE_TEXT_QUERY,
SITE_SETTINGS_QUERY,
SUBMISSION_SETTINGS_QUERY,
} from "./queries";
export { ABOUT_QUERY, HOME_STILLS_QUERY, PAGE_TEXT_QUERY, SITE_SETTINGS_QUERY, SUBMISSION_SETTINGS_QUERY } from "./queries";
9 changes: 0 additions & 9 deletions content/siteSettings/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,6 @@ import { defineQuery } from "next-sanity";

import { IMAGE_FIELDS } from "@/sanity/lib/groq";

/** @deprecated Read `getSiteSettings` instead; kept until the pages that use this move over. */
export const CONTACT_SETTINGS_QUERY = defineQuery(`{
"cur": *[_type == "siteSettings" && language == $lang][0]{ contactEmail, instagram },
"fb": *[_type == "siteSettings" && language == $fallbackLang][0]{ contactEmail, instagram }
}{
"contactEmail": coalesce(cur.contactEmail, fb.contactEmail),
"instagram": coalesce(cur.instagram, fb.instagram)
}`);

/** Recipient addresses for form notifications, falling back to the default language's settings. */
export const SUBMISSION_SETTINGS_QUERY = defineQuery(`
coalesce(
Expand Down
31 changes: 31 additions & 0 deletions features/privacy/components/PolicySection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { ReactNode } from "react";

/** A titled block of running text. */
export function PolicySection({ heading, children }: { heading: string; children: ReactNode }) {
const id = slug(heading);
return (
<section aria-labelledby={id}>
<h2 id={id} className="font-display text-2xl">
{heading}
</h2>
<div className="mt-3 space-y-3 leading-relaxed text-fg/90">{children}</div>
</section>
);
}

export function BulletList({ items }: { items: readonly string[] }) {
return (
<ul className="list-disc space-y-2 pl-5 marker:text-fg-muted">
{items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
);
}

/** A heading as an id: "Who handles it for us" → "who-handles-it-for-us". */
const slug = (heading: string) =>
`privacy-${heading
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, "-")
.replace(/^-|-$/g, "")}`;
23 changes: 23 additions & 0 deletions features/privacy/components/StorageGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
type StorageEntry = { name: string; type: string; lasts: string; description: string };

/** What a site stores in the browser, one entry per cookie or key: its name as code, then what it is for. */
export function StorageGroup({ title, items }: { title: string; items: readonly StorageEntry[] }) {
return (
<div className="pt-2">
<h3 className="font-medium text-fg">{title}</h3>
<dl className="mt-3 divide-y divide-line border-y border-line">
{items.map((item) => (
<div key={item.name} className="py-4">
<dt className="flex flex-wrap items-baseline gap-x-4 gap-y-1">
<code className="rounded-md bg-bg-raised px-2 py-0.5 font-mono text-sm text-fg">{item.name}</code>
<span className="text-sm text-fg-muted">
{item.type} · {item.lasts}
</span>
</dt>
<dd className="mt-2">{item.description}</dd>
</div>
))}
</dl>
</div>
);
}
Loading
Loading