15 Commits
Author SHA1 Message Date
IllusionandClaude Opus 5.5 709a978da5 feat(web): add Cloudflare Turnstile to the public forms
Render the widget on the contact, event registration and both application
forms, and verify the token with Siteverify in each action before anything
is written to the CMS. Verification fails closed. The check is off unless
both TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY are set.

Allow challenges.cloudflare.com in script-src and frame-src.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 12:05:51 +02:00
Illusion 0b2c8d581f Revert "fix(web): nonce JSON-LD scripts"
This reverts commit 9119599e45.
2026-10-16 00:28:51 +02:00
Illusion 274ca6f5ca fix(web): nonce Google tag loader 2026-10-16 00:27:16 +02:00
Illusion 9119599e45 fix(web): nonce JSON-LD scripts 2026-10-16 00:21:33 +02:00
Illusion e6069248b0 fix(web): nonce router stream scripts 2026-10-16 00:02:23 +02:00
Illusion 4afc510fcc fix(web): add nonce-based CSP
Generate a per-response nonce for SSR scripts instead of permitting arbitrary inline scripts. Allow only required analytics and configured CMS media origins.
2026-10-15 23:44:56 +02:00
Illusion a5539d18d7 fix: extend the border 2026-10-08 14:45:34 +02:00
Illusion 2cecb76921 change favicon 2026-10-08 14:27:33 +02:00
teledrisi 1a3a67f1f2 small text fixes 2026-10-07 20:27:06 +02:00
Illusion 9ba0c59eff fix: close navmenu on navigation 2026-10-07 18:16:29 +02:00
Illusion 2d06447811 chore(cms): update generated schema types 2026-10-07 11:36:55 +02:00
Illusion 2b3ffcf19e feat(web): restyle partner sections 2026-10-07 11:33:10 +02:00
Illusion 847a7d733d fix(cms): render partner mail templates 2026-10-07 10:16:02 +02:00
Illusion 47d2a7fc3e fix(cms): hardcode styled email templates 2026-10-07 09:33:39 +02:00
Illusion dcf4c85128 remove mail templates from CMS 2026-10-07 09:33:08 +02:00
38 changed files with 964 additions and 677 deletions
+5
View File
@@ -109,6 +109,11 @@ DOCKER_STRAPI_URL=http://cms:1337
# Settings -> API Tokens, then paste the value here (it is shown once).
STRAPI_TOKEN=
# Cloudflare Turnstile on the public forms. Both keys or neither: with either
# one empty, the forms are not checked. See apps/web/.env.example.
TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY=
# ---------------------------------------------------------------------------
# CMS RUNTIME
+1
View File
@@ -929,6 +929,7 @@ Whichever port you pick, `SITE_URL` stays the public origin (`https://sharper.ly
| `SITE_URL` | web | `https://sharper.ly` — canonical URLs, `hreflang`, JSON-LD are built from it |
| `STRAPI_URL` | web | the CMS origin, e.g. `https://cms.sharper.ly`. It is also prefixed onto media URLs, so it must be resolvable **by the browser**, not only from the server |
| `STRAPI_TOKEN` | web | only needed for draft/protected content; published content is public |
| `TURNSTILE_SITE_KEY` / `TURNSTILE_SECRET_KEY` | web | Cloudflare Turnstile on the public forms. Set both or neither — with either empty, no widget renders and submissions are not checked |
| `PUBLIC_URL` | cms | `https://cms.sharper.ly` — admin redirects and generated links |
| `PARTNER_REVIEW_NOTIFICATION_EMAILS` | cms | Optional comma-separated addresses for partner draft notices; defaults to active Super Admins |
| `IS_PROXIED` | cms | `true` behind nginx / a load balancer, so `X-Forwarded-*` is trusted |
+4 -4
View File
@@ -158,10 +158,10 @@ DEFAULT_REPLY_TO=support@yourdomain.com
# confirmation, event-reminder and application-acknowledgement e-mails
# (src/lib/event-mail.ts, src/lib/application-mail.ts).
#
# The subject and body of those e-mails are editable in the CMS (the
# email-settings single type); the SHARP-ER / EU / partner logo header needs
# PUBLIC_URL (above) to build absolute image URLs - without it the mail falls
# back to a text wordmark.
# Their bilingual copy is hardcoded in src/lib/email-template-defaults.ts.
# Every message uses the styled HTML layout in src/lib/event-mail.ts. The
# SHARP-ER / EU / partner logos need PUBLIC_URL (above) to build absolute image
# URLs - without it the mail falls back to a text wordmark.
# Production only - switching the upload provider to S3. Uncomment these after
# adding @strapi/provider-upload-aws-s3 and editing the upload block in
@@ -1,31 +0,0 @@
{
"kind": "singleType",
"collectionName": "email_settings",
"info": {
"singularName": "email-settings",
"pluralName": "email-settings-list",
"displayName": "Email Settings",
"description": "The outbound email templates (registration confirmation, waitlist, reminder, application acknowledgement). Markdown bodies become HTML at send time. The values seeded here on first boot are also the defaults the mails fall back to when the row is missing. Read by the mail jobs only; not exposed over the public API."
},
"options": {
"draftAndPublish": false
},
"pluginOptions": {
"i18n": {
"localized": true
}
},
"attributes": {
"templates": {
"type": "component",
"repeatable": true,
"component": "email.template",
"required": true,
"pluginOptions": {
"i18n": {
"localized": true
}
}
}
}
}
@@ -202,7 +202,7 @@ const sanitizeSubmission: Core.MiddlewareFactory =
});
// Send the confirmation only after the transaction above has committed:
// the mail helper reads the event, template and branding back from the
// the mail helper reads the event and template back from the
// database, and running those reads inside the transaction would bind them
// to a transaction that is about to be committed, which knex rejects
// ("Transaction query already complete"). Dispatched without awaiting so a
@@ -5,8 +5,8 @@
* transaction opened by the sanitize-submission middleware, and that middleware
* dispatches the confirmation e-mail *after* the transaction has committed.
* It must not be dispatched from here: a send fired inside the request
* transaction would bind the mail helper's own reads (event, template,
* branding) to a transaction that is about to be committed, which knex
* transaction would bind the mail helper's own reads (event and template)
* to a transaction that is about to be committed, which knex
* rejects with "Transaction query already complete".
*/
-58
View File
@@ -1,58 +0,0 @@
import type { Core } from '@strapi/strapi';
import {
DEFAULT_EMAIL_TEMPLATES,
EMAIL_TEMPLATE_KEYS,
type EmailTemplateKey,
} from '../lib/email-template-defaults';
import { LOG_PREFIX } from './constants';
const EMAIL_SETTINGS_UID = 'api::email-settings.email-settings';
/**
* The `email.template` component payload for one locale of one template.
* `enabled` stays on so the shipped wording is the live configuration, keeping
* code defaults and CMS state in agreement on a fresh database.
*/
const templateRow = (key: EmailTemplateKey, locale: 'en' | 'ar') => ({
key,
enabled: true,
subject: DEFAULT_EMAIL_TEMPLATES[key][locale].subject,
body: DEFAULT_EMAIL_TEMPLATES[key][locale].body,
});
/**
* Create the `email-settings` single type if the database does not have one
* yet, so the four email templates exist and are editable in the admin panel
* after the very first boot — carrying the shipped default copy. Never
* overwrites an existing row, so editor customisation survives every restart.
*/
export const ensureEmailSettings = async (strapi: Core.Strapi): Promise<void> => {
const existing = await strapi
.documents(EMAIL_SETTINGS_UID)
.findFirst({});
if (existing) {
return;
}
const templatesFor = (locale: 'en' | 'ar') =>
EMAIL_TEMPLATE_KEYS.map((key) => templateRow(key, locale));
/* Arabic first — it is the default locale and the one a single type is
created on — then the English translation is written onto the same
document, which is how Strapi 5 models one document in two locales. */
const created = await strapi.documents(EMAIL_SETTINGS_UID).create({
locale: 'ar',
data: { templates: templatesFor('ar') },
});
await strapi.documents(EMAIL_SETTINGS_UID).update({
documentId: created.documentId,
locale: 'en',
data: { templates: templatesFor('en') },
});
strapi.log.info(
`${LOG_PREFIX} created email-settings single type with the four default email templates.`
);
};
-3
View File
@@ -2,7 +2,6 @@ import type { Core } from '@strapi/strapi';
import { revokePublicAuthActions } from './auth-permissions';
import { LOG_PREFIX } from './constants';
import { ensureEmailSettings } from './email-settings';
import { ensureLocales } from './locales';
import { ensurePublicPermissions } from './public-permissions';
import { ensurePartnerReviewPermissions } from './partner-review-permissions';
@@ -11,7 +10,6 @@ import { ensurePartnerRelationLabels } from './partner-relation-labels';
import { ensureReminderSettings } from './reminder-settings';
export { revokePublicAuthActions } from './auth-permissions';
export { ensureEmailSettings } from './email-settings';
export { ensureLocales } from './locales';
export { ensurePublicPermissions } from './public-permissions';
export { ensureReminderSettings } from './reminder-settings';
@@ -34,7 +32,6 @@ export const runBootstrap = async ({ strapi }: { strapi: Core.Strapi }): Promise
await ensurePublicPermissions(strapi);
await revokePublicAuthActions(strapi);
await ensureReminderSettings(strapi);
await ensureEmailSettings(strapi);
await ensurePartnerReviewPermissions(strapi);
await ensurePartnerReviewLayouts(strapi);
await ensurePartnerRelationLabels(strapi);
@@ -1,35 +0,0 @@
{
"collectionName": "components_email_templates",
"info": {
"displayName": "Email Template",
"icon": "envelop",
"description": "One outbound email template: a subject plus a markdown body. The body is converted to HTML at send time. Placeholders: {event}, {date} and {call}. A placeholder whose value is missing collapses together with the word before it, so \"for {call}\" (and \"على {call}\") reads cleanly when there is no call."
},
"options": {},
"attributes": {
"key": {
"type": "enumeration",
"enum": [
"confirmation",
"waitlist",
"event-reminder",
"application-acknowledgement"
],
"required": true
},
"enabled": {
"type": "boolean",
"default": true,
"description": "Off reverts this email to the shipped default copy."
},
"subject": {
"type": "string",
"required": true,
"maxLength": 250
},
"body": {
"type": "richtext",
"required": true
}
}
}
+7 -15
View File
@@ -11,22 +11,18 @@
* retried by nothing today but is visible in the admin panel as a null.
*
* The (Get Involved) section form can submit an application with no
* opportunity relation. The mail then drops the call name and stays a plain
* acknowledgement, so the promise holds for both entry points.
* opportunity relation. The mail then drops the call name and uses the
* no-call acknowledgement, so the promise holds for both entry points.
*
* COPY IS CMS-DRIVEN like the event mails: subject and body come from the
* `email-settings` single type's `application-acknowledgement` template, whose
* shipped default is the approved wording. The call name fills the `{call}`
* Copy is hardcoded in `email-template-defaults.ts`, and this message uses the
* same styled HTML layout as the event emails. The call name fills the `{call}`
* placeholder; when there is no call name the placeholder and its preceding
* word collapse, leaving the no-call variant automatically.
*/
import type { Core } from '@strapi/strapi';
import {
readEmailBranding,
resolveEmailTemplate,
} from './email-templates';
import { DEFAULT_EMAIL_TEMPLATES } from './email-template-defaults';
import { buildBilingualMail, trySendMail } from './event-mail';
const OPPORTUNITY_UID = 'api::opportunity.opportunity';
@@ -88,11 +84,8 @@ export async function sendApplicationAcknowledgement({
opportunityReference: OpportunityReference;
}): Promise<void> {
try {
const [opportunity, template, branding] = await Promise.all([
fetchBilingualOpportunity(strapi, opportunityReference),
resolveEmailTemplate(strapi, 'application-acknowledgement'),
readEmailBranding(strapi),
]);
const opportunity = await fetchBilingualOpportunity(strapi, opportunityReference);
const template = DEFAULT_EMAIL_TEMPLATES['application-acknowledgement'];
const callEn = opportunity.en?.title || undefined;
const callAr = opportunity.ar?.title || opportunity.en?.title || undefined;
@@ -106,7 +99,6 @@ export async function sendApplicationAcknowledgement({
ar: { call: callAr ?? '' },
en: { call: callEn ?? '' },
},
branding,
});
const ok = await trySendMail(strapi, { to, ...mail });
+40 -11
View File
@@ -1,12 +1,9 @@
/**
* email-template-defaults — the shipped subject and body for every email the
* CMS sends.
* email-template-defaults — the hardcoded subject and body for every email
* the CMS sends.
*
* These are the "default for each email template": first-boot writes them into
* the `email-settings` single type (src/bootstrap/email-settings.ts) so editors
* start from a working, editable baseline, and they remain the fallback in code
* when the single type is missing, unreadable or a template is switched off —
* which is what keeps the mails sending even if nothing was ever configured.
* This is the source of truth for outbound email copy. Changing a template
* requires a code change and review; the CMS does not store editable copies.
*
* The copy is the wording the mail modules hard-coded before this file existed,
* which itself mirrors the approved web copy (apps/web i18n messages and
@@ -24,6 +21,9 @@ export const EMAIL_TEMPLATE_KEYS = [
'waitlist',
'event-reminder',
'application-acknowledgement',
'partner-review-submitted',
'partner-review-decision',
'partner-review-published',
] as const;
export type EmailTemplateKey = (typeof EMAIL_TEMPLATE_KEYS)[number];
@@ -37,8 +37,8 @@ export type LocalisedEmailTemplate = {
/**
* The two approved acknowledgement wordings, kept for their exact strings:
* the with-call variant is the shipped default and the no-call variant is what
* the placeholder collapse reproduces when there is no opportunity.
* the no-call variant is what placeholder collapse produces when there is no
* opportunity.
*/
const ACKNOWLEDGEMENT_WITH_CALL = {
ar: 'شكرًا لك — استلمنا طلبك على {call}. سيراجع فريقنا طلبك ويتواصل معك عبر البريد الإلكتروني.',
@@ -46,8 +46,7 @@ const ACKNOWLEDGEMENT_WITH_CALL = {
} as const;
/**
* The plain acknowledgement (no call), reproduced verbatim so editors can
* compare against the collapsed form when the placeholder is empty.
* The plain acknowledgement (no call), reproduced verbatim for reference.
*/
const ACKNOWLEDGEMENT_WITHOUT_CALL = {
ar: 'شكرًا لك — استلمنا طلبك. سيراجع فريقنا طلبك ويتواصل معك عبر البريد الإلكتروني.',
@@ -99,4 +98,34 @@ export const DEFAULT_EMAIL_TEMPLATES: Readonly<
body: ACKNOWLEDGEMENT_WITH_CALL.en,
},
},
'partner-review-submitted': {
ar: {
subject: 'مسودة الشريك جاهزة للمراجعة',
body: 'قدّم أحد المساهمين من الشركاء {label}.\n\nيمكنك مراجعته في مدير المحتوى في Strapi. إنها مسودة ولن تكون متاحة للجمهور حتى ينشرها محرر.',
},
en: {
subject: '[SHARP-ER] Partner draft ready for review',
body: 'A partner contributor submitted {label}.\n\nReview it in Strapi Content Manager. It is a draft and is not public until an Editor publishes it.',
},
},
'partner-review-decision': {
ar: {
subject: 'تحديث بشأن مراجعة {label}',
body: 'تمت مراجعة {label}، والقرار هو: {decision}.\n\nملاحظات المراجع:\n{feedback}\n\nيرجى تحديث مسودتك في مدير المحتوى في Strapi وحفظها لإعادة تقديمها.',
},
en: {
subject: '[SHARP-ER] Review update: {label}',
body: 'Your {label} has been marked {decision}.\n\nReviewer feedback:\n{feedback}\n\nPlease update your draft in Strapi Content Manager and save it to resubmit.',
},
},
'partner-review-published': {
ar: {
subject: 'تم نشر محتواك',
body: 'تمت الموافقة على {label} ونشره على موقع SHARP-ER.',
},
en: {
subject: '[SHARP-ER] Your content has been published',
body: 'Your {label} has been approved and published on the SHARP-ER website.',
},
},
};
+2 -269
View File
@@ -1,275 +1,8 @@
/**
* email-templates — resolve the CMS-configured copy and branding the outbound
* mails are built from.
*
* The `email-settings` single type (created at boot with the shipped defaults)
* holds one subject + markdown body per template key, per locale. These
* resolvers turn that editable state into exactly the values an email needs:
*
* resolveEmailTemplate the subject/body pair for a key, per locale, each
* locale falling back to its own code default when
* the row is missing, switched off or unreadable.
* readEmailBranding the logos for the mail: the SHARP-ER project logo,
* the EU badge and the consortium partner logos,
* preferring the media configured on `site-settings`
* and falling back to the brand assets bundled with
* the CMS (public/branding), so the mail brands itself
* the same way the site does even before anyone
* uploads media. Logo images need an absolute URL, so
* they resolve against `server.url` (PUBLIC_URL, with
* the local server as the development fallback); with
* neither available the mail falls back to the site
* name as a text wordmark.
* fillPlaceholders the {event} / {date} / {call} substitution, shared
* by both mail modules. An empty value removes the
* placeholder together with the word before it, so
* "for {call}" / "على {call}" collapse cleanly when
* there is no call (see the component description).
* email-templates — shared placeholder interpolation for hardcoded outbound
* email templates. Subjects and bodies live in email-template-defaults.ts.
*/
import type { Core } from '@strapi/strapi';
import {
DEFAULT_EMAIL_TEMPLATES,
type EmailTemplateKey,
type LocalisedEmailTemplate,
} from './email-template-defaults';
const EMAIL_SETTINGS_UID = 'api::email-settings.email-settings';
const SITE_SETTINGS_UID = 'api::site-settings.site-settings';
const LOG_PREFIX = '[email-templates]';
/* ---------------------------------------------------------------------------
TEMPLATES
------------------------------------------------------------------------ */
type MailTemplateSettings = {
templates?: unknown;
};
/**
* Find the row for `key` on a `email-settings` document read for one locale.
* Returns `undefined` when there is no such row, it is switched off, or its
* fields are unusable — the caller then falls back to that locale's default.
*/
function templateRowFor(
settings: unknown,
key: EmailTemplateKey
): LocalisedEmailTemplate | undefined {
if (!settings || typeof settings !== 'object') return undefined;
const templates = (settings as MailTemplateSettings).templates;
if (!Array.isArray(templates)) return undefined;
for (const row of templates) {
if (!row || typeof row !== 'object') continue;
const candidate = row as {
key?: unknown;
enabled?: unknown;
subject?: unknown;
body?: unknown;
};
if (candidate.key !== key) continue;
if (candidate.enabled === false) return undefined;
if (typeof candidate.subject === 'string' && typeof candidate.body === 'string') {
return { subject: candidate.subject, body: candidate.body };
}
return undefined;
}
return undefined;
}
/** The per-locale subject/body for one template key, resolved from the CMS. */
export type ResolvedEmailTemplate = {
ar: LocalisedEmailTemplate;
en: LocalisedEmailTemplate;
};
/**
* The subject and body for one template key, per locale. Each locale prefers
* its own CMS row and falls back to that locale's shipped default when the
* row is missing, disabled or the single type is unreadable — so a broken or
* empty `email-settings` never stops a mail from going out.
*/
export async function resolveEmailTemplate(
strapi: Core.Strapi,
key: EmailTemplateKey
): Promise<ResolvedEmailTemplate> {
const defaults = DEFAULT_EMAIL_TEMPLATES[key];
const resolved: ResolvedEmailTemplate = {
ar: { subject: defaults.ar.subject, body: defaults.ar.body },
en: { subject: defaults.en.subject, body: defaults.en.body },
};
try {
const [arSettings, enSettings] = await Promise.all([
strapi.documents(EMAIL_SETTINGS_UID).findFirst({ locale: 'ar' }),
strapi.documents(EMAIL_SETTINGS_UID).findFirst({ locale: 'en' }),
]);
const arRow = templateRowFor(arSettings, key);
const enRow = templateRowFor(enSettings, key);
if (arRow) resolved.ar = arRow;
if (enRow) resolved.en = enRow;
} catch (error) {
strapi.log.warn(
`${LOG_PREFIX} could not read email-settings for "${key}", using defaults: ${String(error)}`
);
}
return resolved;
}
/* ---------------------------------------------------------------------------
BRANDING (SHARP-ER + EU + consortium logos)
------------------------------------------------------------------------ */
/**
* The `site-settings` media shapes, read with a loose internal eye so the
* population details of the findFirst populate above cannot break the mail.
*/
type RawMedia = { url?: unknown } | null | undefined;
type RawPartner = { name?: unknown; logo?: RawMedia } | undefined;
/** A partner that will appear in the mail's logo block. */
export type EmailPartner = { name: string; logo: string | null };
/** Everything the mail chrome needs, resolved to absolute image URLs. */
export type EmailBranding = {
siteName: string;
projectLogo: string | null;
euBadge: string | null;
partners: EmailPartner[];
};
const EMPTY_BRANDING: EmailBranding = {
siteName: 'SHARP-ER',
projectLogo: null,
euBadge: null,
partners: [],
};
/**
* The brand assets shipped with the CMS, copied from the site's own
* `apps/web/public/assets` into `public/branding`. They are the fallback when
* `site-settings` has no logo uploaded yet, so every mail still carries the
* SHARP-ER wordmark, the EU emblem and the consortium logos (Acted, UEC, SBE —
* always together, never one partner alone). `site-settings` media still wins
* when an editor uploads it.
*/
const BUNDLED_BRANDING = {
projectLogo: '/branding/sharp-er-wordmark.png',
euBadge: '/branding/eu-emblem.png',
partners: [
{ name: 'Acted', logo: '/branding/partner-acted.png' },
{ name: 'United Efforts Consultancy (UEC)', logo: '/branding/partner-uec.png' },
{ name: 'Stichting Bureau Ensemble (SBE)', logo: '/branding/partner-sbe.png' },
],
} as const;
/**
* The absolute origin the brand images are resolved against: `server.url`
* (PUBLIC_URL) when configured, the local server in development, and `null`
* otherwise. Mail clients need absolute URLs or the logos do not load; on
* production behind a custom domain, leaving PUBLIC_URL unset drops the
* images and degrades the mail to the plain text wordmark.
*/
const emailBase = (strapi: Core.Strapi): string | null => {
const configured = strapi.config.get('server.url') as string | undefined;
if (configured && configured.trim().length > 0) return configured;
if ((process.env.NODE_ENV ?? 'development') !== 'production') {
return `http://localhost:${strapi.config.get('server.port') ?? 1337}`;
}
return null;
};
/** Resolve a media URL to an absolute one, or null when that is impossible. */
const absoluteMediaUrl = (base: string | undefined, url: unknown): string | null => {
if (typeof url !== 'string' || url.length === 0) return null;
try {
return new URL(url, base).href;
} catch {
return null;
}
};
/**
* Read the logos for the mail chrome: the SHARP-ER project logo, the EU
* funding badge and the consortium partner logos (Acted, UEC, SBE). It prefers
* the `site-settings` media and falls back to the brand assets bundled with the
* CMS (public/branding) when nothing is uploaded, so the mail only degrades to
* a text wordmark when there is no absolute base URL to resolve images
* against. The badge and the partner logos always render as one unit — EU
* visibility is contractual and a partner logo never appears on its own.
*/
export async function readEmailBranding(strapi: Core.Strapi): Promise<EmailBranding> {
let siteName = EMPTY_BRANDING.siteName;
try {
const settings = await strapi.documents(SITE_SETTINGS_UID).findFirst({
populate: {
logo: true,
euBadge: true,
consortiumLogos: { populate: { logo: true } },
},
});
if (settings && typeof settings.siteName === 'string') {
siteName = settings.siteName;
}
const base = emailBase(strapi);
if (!base) {
strapi.log.warn(
`${LOG_PREFIX} no absolute base URL (PUBLIC_URL) and not in development; e-mails will use the text wordmark and omit logos.`
);
return { siteName, projectLogo: null, euBadge: null, partners: [] };
}
const mediaUrl = (file: RawMedia): string | null =>
absoluteMediaUrl(base, file?.url);
const bundledUrl = (assetPath: string): string | null =>
absoluteMediaUrl(base, assetPath);
const toPartner = (partner: unknown): EmailPartner => {
const p = partner as RawPartner | undefined;
return {
name: typeof p?.name === 'string' ? p.name : '',
logo: mediaUrl(p?.logo),
};
};
const uploadedPartners: EmailPartner[] = Array.isArray(settings?.consortiumLogos)
? (settings.consortiumLogos as unknown[]).map(toPartner)
: [];
const partners: EmailPartner[] =
uploadedPartners.length > 0
? uploadedPartners
: BUNDLED_BRANDING.partners.map((partner) => ({
name: partner.name,
logo: bundledUrl(partner.logo),
}));
return {
siteName,
projectLogo: mediaUrl(settings?.logo) ?? bundledUrl(BUNDLED_BRANDING.projectLogo),
euBadge: mediaUrl(settings?.euBadge) ?? bundledUrl(BUNDLED_BRANDING.euBadge),
partners,
};
} catch (error) {
strapi.log.warn(
`${LOG_PREFIX} could not read site-settings branding, emails will use the text wordmark: ${String(error)}`
);
return EMPTY_BRANDING;
}
}
/* ---------------------------------------------------------------------------
PLACEHOLDERS
------------------------------------------------------------------------ */
/** Literal values for {event} / {date} / {call}, per locale, unescaped. */
export type PlaceholderValues = Readonly<Record<string, string>>;
+62 -67
View File
@@ -14,25 +14,16 @@
* The Arabic block always comes first and is marked dir="rtl"; the English
* block follows inside the same message. Subjects are bilingual too.
*
* COPY IS CMS-DRIVEN. The subject and body for each mail come from the
* `email-settings` single type — markdown bodies, converted to HTML at send
* time (see lib/markdown.ts) — with the shipped approved copy as the fallback
* (see lib/email-template-defaults.ts). This module owns the chrome around
* that copy: the event lookups, the {event} / {date} placeholders, the venue
* or join-online line, and the logo header/footer (SHARP-ER logo, EU badge
* and the consortium partner logos, read from `site-settings`).
* COPY IS HARDCODED in lib/email-template-defaults.ts. This module owns the
* shared styled HTML layout, event lookups, the {event} / {date} placeholders,
* venue or join-online line, and EU/partner branding.
*/
import type { Core } from '@strapi/strapi';
import { buildEventCalendar } from './event-calendar';
import {
readEmailBranding,
resolveEmailTemplate,
fillPlaceholders,
type EmailBranding,
type PlaceholderValues,
} from './email-templates';
import { DEFAULT_EMAIL_TEMPLATES } from './email-template-defaults';
import { fillPlaceholders, type PlaceholderValues } from './email-templates';
import { mdToHtml, mdToText, escapeHtml } from './markdown';
const EVENT_UID = 'api::event.event';
@@ -316,46 +307,41 @@ const FOOTER = {
const BASE_FONT =
"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;";
const EMAIL_LOGOS = {
project: 'https://cms.sharper.ly/branding/sharp-er-wordmark.png',
eu: 'https://cms.sharper.ly/branding/eu-emblem.png',
partners: [
{ name: 'Acted', url: 'https://cms.sharper.ly/branding/partner-acted.png' },
{
name: 'United Efforts Consultancy (UEC)',
url: 'https://cms.sharper.ly/branding/partner-uec.png',
},
{
name: 'Stichting Bureau Ensemble (SBE)',
url: 'https://cms.sharper.ly/branding/partner-sbe.png',
},
],
} as const;
/**
* The logo chrome shared by every mail: the SHARP-ER project logo at the top
* (the EU badge beside it), and the consortium partner logos under the footer
* text. EU visibility is contractual, so the badge and the partner logos are
* rendered as one unit straight from `site-settings`. Without an absolute
* image URL the header degrades to the site name as a text wordmark.
* linked to the public assets on the project website.
*/
const renderBrandHeader = (branding?: EmailBranding): string => {
if (!branding) return '';
const logo = branding.projectLogo
? `<img src="${branding.projectLogo}" alt="${escapeHtml(branding.siteName)}" style="height:40px; width:auto; display:block;" />`
: `<strong style="font-size:18px; color:#222;">${escapeHtml(branding.siteName)}</strong>`;
const badge = branding.euBadge
? `<img src="${branding.euBadge}" alt="${escapeHtml('Funded by the European Union')}" style="height:36px; width:auto; display:block;" />`
: '';
if (!badge) {
return `<div style="margin-bottom:28px; ${BASE_FONT}">${logo}</div>`;
}
return `
const renderBrandHeader = (): string => `
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:28px; ${BASE_FONT}">
<tr>
<td style="padding:0; vertical-align:middle;">${logo}</td>
<td align="right" style="padding:0; vertical-align:middle;">${badge}</td>
<td style="padding:0; vertical-align:middle;"><img src="${EMAIL_LOGOS.project}" alt="SHARP-ER" style="height:40px; width:auto; display:block;" /></td>
<td align="right" style="padding:0; vertical-align:middle;"><img src="${EMAIL_LOGOS.eu}" alt="${escapeHtml('Funded by the European Union')}" style="height:36px; width:auto; display:block;" /></td>
</tr>
</table>`;
};
const renderPartnerLogos = (branding?: EmailBranding): string => {
const logos = (branding?.partners ?? []).filter((partner) => partner.logo);
if (logos.length === 0) return '';
const images = logos
const renderPartnerLogos = (): string => {
const images = EMAIL_LOGOS.partners
.map(
(partner) =>
`<img src="${partner.logo}" alt="${escapeHtml(partner.name)}" title="${escapeHtml(partner.name)}" style="height:28px; width:auto; max-width:160px; margin:0 16px 8px 0; display:inline-block; vertical-align:middle;" />`
`<img src="${partner.url}" alt="${escapeHtml(partner.name)}" title="${escapeHtml(partner.name)}" style="height:28px; width:auto; max-width:160px; margin:0 16px 8px 0; display:inline-block; vertical-align:middle;" />`
)
.join('');
@@ -388,7 +374,6 @@ export function buildBilingualMail(opts: {
/** Literal {event} / {date} / {call} values, per locale, unescaped. */
vars?: { ar: PlaceholderValues; en: PlaceholderValues };
locationLine?: { ar: string; en: string };
branding?: EmailBranding;
jsonLd?: JsonLd;
}): { subject: string; text: string; html: string } {
const {
@@ -398,7 +383,6 @@ export function buildBilingualMail(opts: {
bodyEn,
vars,
locationLine,
branding,
jsonLd,
} = opts;
@@ -410,27 +394,46 @@ export function buildBilingualMail(opts: {
Object.entries(values).map(([name, value]) => [name, escapeHtml(value)])
);
const html = `
const subject = `${fillPlaceholders(subjectAr, varsAr)} | ${fillPlaceholders(subjectEn, varsEn)}`;
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${escapeHtml(subject)}</title>
${renderJsonLd(jsonLd)}
${renderBrandHeader(branding)}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="${BASE_FONT}"><tr><td style="padding:0; line-height:1.6; color:#222;">
<div dir="rtl" lang="ar">
</head>
<body style="margin:0; padding:0; background-color:#f2f5f3; ${BASE_FONT}">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" bgcolor="#f2f5f3" style="width:100%; border-collapse:collapse; background-color:#f2f5f3;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" bgcolor="#ffffff" style="width:100%; max-width:640px; border:1px solid #dfe6e1; border-top:4px solid #356b57; border-radius:8px; background-color:#ffffff; border-collapse:separate; border-spacing:0;">
<tr><td style="padding:28px 32px 24px; ${BASE_FONT}">
${renderBrandHeader()}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="${BASE_FONT}"><tr><td style="padding:0; line-height:1.7; color:#25312b; font-size:16px;">
<div dir="rtl" lang="ar" style="direction:rtl; text-align:right;">
${fillPlaceholders(mdToHtml(bodyAr), escaped(varsAr))}
${locationHtml(locationLine?.ar)}
</div>
<hr style="border:none; border-top:1px solid #ddd; margin:24px 0;" />
<div dir="ltr" lang="en">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:24px 0;"><tr><td style="border-top:1px solid #e2e8e4; font-size:0; line-height:0;">&nbsp;</td></tr></table>
<div dir="ltr" lang="en" style="direction:ltr; text-align:left;">
${fillPlaceholders(mdToHtml(bodyEn), escaped(varsEn))}
${locationHtml(locationLine?.en)}
</div>
</td></tr></table>
<p style="margin-top:32px; font-size:12px; line-height:1.5; color:#666; ${BASE_FONT}">
<p style="margin:32px 0 0; font-size:12px; line-height:1.5; color:#66716b; ${BASE_FONT}">
${FOOTER.ar}
</p>
<p style="font-size:12px; line-height:1.5; color:#666; ${BASE_FONT}">
<p style="margin:8px 0 0; font-size:12px; line-height:1.5; color:#66716b; ${BASE_FONT}">
${FOOTER.en}
</p>
${renderPartnerLogos(branding)}`;
${renderPartnerLogos()}
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
const text = [
fillPlaceholders(mdToText(bodyAr), varsAr),
@@ -446,7 +449,7 @@ export function buildBilingualMail(opts: {
.join('\n');
return {
subject: `${fillPlaceholders(subjectAr, varsAr)} | ${fillPlaceholders(subjectEn, varsEn)}`,
subject,
text,
html,
};
@@ -498,7 +501,7 @@ type MailOptions = {
to: string;
subject: string;
text: string;
html?: string;
html: string;
attachments?: MailAttachment[];
};
@@ -562,11 +565,8 @@ export async function sendRegistrationConfirmation({
try {
const isWaitlist = state === 'waitlisted';
const [event, template, branding] = await Promise.all([
fetchBilingualEvent(strapi, eventReference),
resolveEmailTemplate(strapi, isWaitlist ? 'waitlist' : 'confirmation'),
readEmailBranding(strapi),
]);
const event = await fetchBilingualEvent(strapi, eventReference);
const template = DEFAULT_EMAIL_TEMPLATES[isWaitlist ? 'waitlist' : 'confirmation'];
if (!event.en && !event.ar) {
strapi.log.warn(
@@ -601,7 +601,6 @@ export async function sendRegistrationConfirmation({
en: { event: titleEn, date: dateEn },
},
locationLine: locationLineFor(event),
branding,
jsonLd: buildEventReservationJsonLd(event, state, reservation),
});
@@ -634,11 +633,8 @@ export async function sendEventReminderMail({
reservation: ReservationIdentity;
}): Promise<boolean> {
try {
const [event, template, branding] = await Promise.all([
fetchBilingualEvent(strapi, eventReference),
resolveEmailTemplate(strapi, 'event-reminder'),
readEmailBranding(strapi),
]);
const event = await fetchBilingualEvent(strapi, eventReference);
const template = DEFAULT_EMAIL_TEMPLATES['event-reminder'];
if (!event.en && !event.ar) {
strapi.log.warn(
@@ -669,7 +665,6 @@ export async function sendEventReminderMail({
en: { event: titleEn, date: dateEn },
},
locationLine: locationLineFor(event),
branding,
jsonLd: buildEventReservationJsonLd(event, 'confirmed', reservation),
});
@@ -1,6 +1,8 @@
import type { Core } from '@strapi/strapi';
import { trySendMail } from './event-mail';
import { DEFAULT_EMAIL_TEMPLATES, type EmailTemplateKey } from './email-template-defaults';
import { buildBilingualMail, trySendMail } from './event-mail';
import type { PlaceholderValues } from './email-templates';
const PARTNER_CONTENT = new Set([
'api::resource.resource',
@@ -12,6 +14,10 @@ const PARTNER_CONTENT = new Set([
const NOTIFIABLE_ACTIONS = new Set(['create', 'update', 'publish']);
const REVIEW_RETURN_STATUSES = new Set(['changes-requested', 'rejected']);
const LOG_PREFIX = '[partner-review-mail]';
type PartnerReviewTemplateKey = Extract<
EmailTemplateKey,
'partner-review-submitted' | 'partner-review-decision' | 'partner-review-published'
>;
type ReviewDocument = {
documentId?: string;
@@ -83,27 +89,50 @@ const getSubmitter = async (strapi: Core.Strapi, id: number): Promise<AdminUser
select: ['id', 'email', 'isActive'],
})) as AdminUser | null;
const entryLabel = (uid: string, document?: ReviewDocument | null): string => {
const ENTRY_TYPE_LABELS: Record<string, { ar: string; en: string }> = {
resource: { ar: 'مورد', en: 'resource' },
'news-article': { ar: 'مقال إخباري', en: 'news article' },
activity: { ar: 'نشاط', en: 'activity' },
event: { ar: 'فعالية', en: 'event' },
opportunity: { ar: 'فرصة', en: 'opportunity' },
};
const entryLabel = (uid: string, document: ReviewDocument | null | undefined, locale: 'ar' | 'en'): string => {
const title = document?.title?.trim() || document?.slug?.trim();
const type = uid.split('.')[1]?.replace(/-/g, ' ') ?? 'content';
return title ? `${type}: ${title}` : `a ${type} entry`;
const typeSlug = uid.split('.')[1] ?? 'content';
const type = ENTRY_TYPE_LABELS[typeSlug]?.[locale] ?? (locale === 'ar' ? 'محتوى' : typeSlug.replace(/-/g, ' '));
if (title) return `${type}: ${title}`;
return locale === 'ar' ? `عنصر من نوع ${type}` : `a ${type} entry`;
};
const buildReviewMail = (
key: PartnerReviewTemplateKey,
vars: { ar: PlaceholderValues; en: PlaceholderValues }
): ReturnType<typeof buildBilingualMail> => {
const template = DEFAULT_EMAIL_TEMPLATES[key];
return buildBilingualMail({
subjectAr: template.ar.subject,
subjectEn: template.en.subject,
bodyAr: template.ar.body,
bodyEn: template.en.body,
vars,
});
};
const sendReviewNotification = async (
strapi: Core.Strapi,
to: string[],
subject: string,
body: string
mail: ReturnType<typeof buildBilingualMail>
): Promise<void> => {
if (to.length === 0) {
strapi.log.warn(`${LOG_PREFIX} no recipient configured for: ${subject}`);
strapi.log.warn(`${LOG_PREFIX} no recipient configured for: ${mail.subject}`);
return;
}
await Promise.all(
to.map((address) =>
trySendMail(strapi, { to: address, subject, text: body }).then((sent) => {
if (sent) strapi.log.info(`${LOG_PREFIX} sent "${subject}" to ${address}.`);
trySendMail(strapi, { to: address, ...mail }).then((sent) => {
if (sent) strapi.log.info(`${LOG_PREFIX} sent "${mail.subject}" to ${address}.`);
})
)
);
@@ -130,7 +159,8 @@ const notifyAfterSave = async ({
const submitterId = after.submittedByAdminUserId;
if (typeof submitterId !== 'number') return;
const label = entryLabel(uid, after);
const labelAr = entryLabel(uid, after, 'ar');
const labelEn = entryLabel(uid, after, 'en');
if (
(action === 'create' && after.reviewStatus === 'pending') ||
@@ -140,8 +170,10 @@ const notifyAfterSave = async ({
await sendReviewNotification(
strapi,
recipients,
`[SHARP-ER] Partner draft ready for review`,
`A partner contributor submitted ${label}.\n\nReview it in Strapi Content Manager. It is a draft and is not public until an Editor publishes it.`
buildReviewMail('partner-review-submitted', {
ar: { label: labelAr },
en: { label: labelEn },
})
);
return;
}
@@ -158,13 +190,24 @@ const notifyAfterSave = async ({
return;
}
const decision = after.reviewStatus === 'rejected' ? 'rejected' : 'changes requested';
const feedback = after.reviewFeedback?.trim() || '(No review feedback was provided.)';
const decisionEn = after.reviewStatus === 'rejected' ? 'rejected' : 'changes requested';
const decisionAr = after.reviewStatus === 'rejected' ? 'الرفض' : 'طلب إجراء تغييرات';
const feedback = after.reviewFeedback?.trim();
await sendReviewNotification(
strapi,
[submitter.email],
`[SHARP-ER] Review update: ${label}`,
`Your ${label} has been marked ${decision}.\n\nReviewer feedback:\n${feedback}\n\nPlease update your draft in Strapi Content Manager and save it to resubmit.`
buildReviewMail('partner-review-decision', {
ar: {
label: labelAr,
decision: decisionAr,
feedback: feedback || '(لم تُقدَّم ملاحظات للمراجعة.)',
},
en: {
label: labelEn,
decision: decisionEn,
feedback: feedback || '(No review feedback was provided.)',
},
})
);
return;
}
@@ -179,8 +222,10 @@ const notifyAfterSave = async ({
await sendReviewNotification(
strapi,
[submitter.email],
`[SHARP-ER] Your content has been published`,
`Your ${label} has been approved and published on the SHARP-ER website.`
buildReviewMail('partner-review-published', {
ar: { label: labelAr },
en: { label: labelEn },
})
);
}
};
+1 -29
View File
@@ -110,33 +110,6 @@ export interface BlocksVideoEmbed extends Struct.ComponentSchema {
};
}
export interface EmailTemplate extends Struct.ComponentSchema {
collectionName: 'components_email_templates';
info: {
description: 'One outbound email template: a subject plus a markdown body. The body is converted to HTML at send time. Placeholders: {event}, {date} and {call}. A placeholder whose value is missing collapses together with the word before it, so "for {call}" (and "\u0639\u0644\u0649 {call}") reads cleanly when there is no call.';
displayName: 'Email Template';
icon: 'envelop';
};
attributes: {
body: Schema.Attribute.RichText & Schema.Attribute.Required;
enabled: Schema.Attribute.Boolean & Schema.Attribute.DefaultTo<true>;
key: Schema.Attribute.Enumeration<
[
'confirmation',
'waitlist',
'event-reminder',
'application-acknowledgement',
]
> &
Schema.Attribute.Required;
subject: Schema.Attribute.String &
Schema.Attribute.Required &
Schema.Attribute.SetMinMaxLength<{
maxLength: 250;
}>;
};
}
export interface NavigationFooterColumn extends Struct.ComponentSchema {
collectionName: 'components_navigation_footer_columns';
info: {
@@ -222,7 +195,7 @@ export interface PageLegacyMetric extends Struct.ComponentSchema {
export interface PageLegacyStage extends Struct.ComponentSchema {
collectionName: 'components_page_legacy_stages';
info: {
description: 'One SHARP programme phase with copy, figures, areas of work, and its own image gallery.';
description: 'One SHARP programme phase with its copy, figures, areas of work, and image gallery. The website displays up to five selected images.';
displayName: 'Legacy Phase';
icon: 'landmark';
};
@@ -393,7 +366,6 @@ declare module '@strapi/strapi' {
'blocks.quote': BlocksQuote;
'blocks.rich-text': BlocksRichText;
'blocks.video-embed': BlocksVideoEmbed;
'email.template': EmailTemplate;
'navigation.footer-column': NavigationFooterColumn;
'navigation.nav-group': NavigationNavGroup;
'page.band-intro': PageBandIntro;
+167 -41
View File
@@ -632,6 +632,23 @@ export interface ApiActivityActivity extends Struct.CollectionTypeSchema {
>;
publishedAt: Schema.Attribute.DateTime;
regions: Schema.Attribute.Relation<'oneToMany', 'api::region.region'>;
reviewFeedback: Schema.Attribute.Text &
Schema.Attribute.Private &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: false;
};
}>;
reviewStatus: Schema.Attribute.Enumeration<
['pending', 'approved', 'changes-requested', 'rejected']
> &
Schema.Attribute.Private &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: false;
};
}> &
Schema.Attribute.DefaultTo<'pending'>;
seo: Schema.Attribute.Component<'shared.seo', false> &
Schema.Attribute.SetPluginOptions<{
i18n: {
@@ -645,6 +662,13 @@ export interface ApiActivityActivity extends Struct.CollectionTypeSchema {
localized: true;
};
}>;
submittedByAdminUserId: Schema.Attribute.Integer &
Schema.Attribute.Private &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: false;
};
}>;
summary: Schema.Attribute.Text &
Schema.Attribute.SetPluginOptions<{
i18n: {
@@ -852,45 +876,6 @@ export interface ApiContactSubmissionContactSubmission
};
}
export interface ApiEmailSettingsEmailSettings extends Struct.SingleTypeSchema {
collectionName: 'email_settings';
info: {
description: 'The outbound email templates (registration confirmation, waitlist, reminder, application acknowledgement). Markdown bodies become HTML at send time. The values seeded here on first boot are also the defaults the mails fall back to when the row is missing. Read by the mail jobs only; not exposed over the public API.';
displayName: 'Email Settings';
pluralName: 'email-settings-list';
singularName: 'email-settings';
};
options: {
draftAndPublish: false;
};
pluginOptions: {
i18n: {
localized: true;
};
};
attributes: {
createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
locale: Schema.Attribute.String;
localizations: Schema.Attribute.Relation<
'oneToMany',
'api::email-settings.email-settings'
>;
publishedAt: Schema.Attribute.DateTime;
templates: Schema.Attribute.Component<'email.template', true> &
Schema.Attribute.Required &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: true;
};
}>;
updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
};
}
export interface ApiEventRegistrationEventRegistration
extends Struct.CollectionTypeSchema {
collectionName: 'event_registrations';
@@ -1069,6 +1054,23 @@ export interface ApiEventEvent extends Struct.CollectionTypeSchema {
'oneToMany',
'api::resource.resource'
>;
reviewFeedback: Schema.Attribute.Text &
Schema.Attribute.Private &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: false;
};
}>;
reviewStatus: Schema.Attribute.Enumeration<
['pending', 'approved', 'changes-requested', 'rejected']
> &
Schema.Attribute.Private &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: false;
};
}> &
Schema.Attribute.DefaultTo<'pending'>;
seo: Schema.Attribute.Component<'shared.seo', false> &
Schema.Attribute.SetPluginOptions<{
i18n: {
@@ -1089,6 +1091,13 @@ export interface ApiEventEvent extends Struct.CollectionTypeSchema {
localized: false;
};
}>;
submittedByAdminUserId: Schema.Attribute.Integer &
Schema.Attribute.Private &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: false;
};
}>;
timezone: Schema.Attribute.String &
Schema.Attribute.SetPluginOptions<{
i18n: {
@@ -1396,7 +1405,7 @@ export interface ApiKnowledgeItemKnowledgeItem
export interface ApiLegacyPageLegacyPage extends Struct.SingleTypeSchema {
collectionName: 'legacy_pages';
info: {
description: 'SHARP I and SHARP II legacy content, hero media, and phase galleries.';
description: 'SHARP I and SHARP II programme history, achievements, metrics, and featured images.';
displayName: 'Legacy Page';
pluralName: 'legacy-pages';
singularName: 'legacy-page';
@@ -1578,6 +1587,23 @@ export interface ApiNewsArticleNewsArticle extends Struct.CollectionTypeSchema {
}>;
publishedAt: Schema.Attribute.DateTime;
regions: Schema.Attribute.Relation<'oneToMany', 'api::region.region'>;
reviewFeedback: Schema.Attribute.Text &
Schema.Attribute.Private &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: false;
};
}>;
reviewStatus: Schema.Attribute.Enumeration<
['pending', 'approved', 'changes-requested', 'rejected']
> &
Schema.Attribute.Private &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: false;
};
}> &
Schema.Attribute.DefaultTo<'pending'>;
seo: Schema.Attribute.Component<'shared.seo', false> &
Schema.Attribute.SetPluginOptions<{
i18n: {
@@ -1591,6 +1617,13 @@ export interface ApiNewsArticleNewsArticle extends Struct.CollectionTypeSchema {
localized: true;
};
}>;
submittedByAdminUserId: Schema.Attribute.Integer &
Schema.Attribute.Private &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: false;
};
}>;
title: Schema.Attribute.String &
Schema.Attribute.Required &
Schema.Attribute.SetPluginOptions<{
@@ -1706,6 +1739,23 @@ export interface ApiOpportunityOpportunity extends Struct.CollectionTypeSchema {
}> &
Schema.Attribute.DefaultTo<'call-eoi'>;
publishedAt: Schema.Attribute.DateTime;
reviewFeedback: Schema.Attribute.Text &
Schema.Attribute.Private &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: false;
};
}>;
reviewStatus: Schema.Attribute.Enumeration<
['pending', 'approved', 'changes-requested', 'rejected']
> &
Schema.Attribute.Private &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: false;
};
}> &
Schema.Attribute.DefaultTo<'pending'>;
seo: Schema.Attribute.Component<'shared.seo', false> &
Schema.Attribute.SetPluginOptions<{
i18n: {
@@ -1719,6 +1769,13 @@ export interface ApiOpportunityOpportunity extends Struct.CollectionTypeSchema {
localized: true;
};
}>;
submittedByAdminUserId: Schema.Attribute.Integer &
Schema.Attribute.Private &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: false;
};
}>;
summary: Schema.Attribute.Text &
Schema.Attribute.SetPluginOptions<{
i18n: {
@@ -1745,6 +1802,51 @@ export interface ApiOpportunityOpportunity extends Struct.CollectionTypeSchema {
};
}
export interface ApiPartnerAccessPartnerAccess
extends Struct.CollectionTypeSchema {
collectionName: 'partner_accesses';
info: {
description: 'Links one Strapi admin user to the partner organisation they can submit content for.';
displayName: 'Partner Access';
pluralName: 'partner-accesses';
singularName: 'partner-access';
};
options: {
draftAndPublish: false;
};
pluginOptions: {
i18n: {
localized: false;
};
};
attributes: {
adminUserId: Schema.Attribute.Integer &
Schema.Attribute.Required &
Schema.Attribute.Unique &
Schema.Attribute.SetMinMax<
{
min: 1;
},
number
>;
createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
locale: Schema.Attribute.String & Schema.Attribute.Private;
localizations: Schema.Attribute.Relation<
'oneToMany',
'api::partner-access.partner-access'
> &
Schema.Attribute.Private;
partner: Schema.Attribute.Relation<'manyToOne', 'api::partner.partner'> &
Schema.Attribute.Required;
publishedAt: Schema.Attribute.DateTime;
updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
};
}
export interface ApiPartnerPartner extends Struct.CollectionTypeSchema {
collectionName: 'partners';
info: {
@@ -2056,6 +2158,23 @@ export interface ApiResourceResource extends Struct.CollectionTypeSchema {
};
}> &
Schema.Attribute.DefaultTo<'project-report'>;
reviewFeedback: Schema.Attribute.Text &
Schema.Attribute.Private &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: false;
};
}>;
reviewStatus: Schema.Attribute.Enumeration<
['pending', 'approved', 'changes-requested', 'rejected']
> &
Schema.Attribute.Private &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: false;
};
}> &
Schema.Attribute.DefaultTo<'pending'>;
seo: Schema.Attribute.Component<'shared.seo', false> &
Schema.Attribute.SetPluginOptions<{
i18n: {
@@ -2069,6 +2188,13 @@ export interface ApiResourceResource extends Struct.CollectionTypeSchema {
localized: true;
};
}>;
submittedByAdminUserId: Schema.Attribute.Integer &
Schema.Attribute.Private &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: false;
};
}>;
supersedes: Schema.Attribute.Relation<'oneToOne', 'api::resource.resource'>;
thumbnail: Schema.Attribute.Media<'images'> &
Schema.Attribute.SetPluginOptions<{
@@ -2869,7 +2995,6 @@ declare module '@strapi/strapi' {
'api::application.application': ApiApplicationApplication;
'api::contact-page.contact-page': ApiContactPageContactPage;
'api::contact-submission.contact-submission': ApiContactSubmissionContactSubmission;
'api::email-settings.email-settings': ApiEmailSettingsEmailSettings;
'api::event-registration.event-registration': ApiEventRegistrationEventRegistration;
'api::event.event': ApiEventEvent;
'api::faq.faq': ApiFaqFaq;
@@ -2878,6 +3003,7 @@ declare module '@strapi/strapi' {
'api::legacy-page.legacy-page': ApiLegacyPageLegacyPage;
'api::news-article.news-article': ApiNewsArticleNewsArticle;
'api::opportunity.opportunity': ApiOpportunityOpportunity;
'api::partner-access.partner-access': ApiPartnerAccessPartnerAccess;
'api::partner.partner': ApiPartnerPartner;
'api::region.region': ApiRegionRegion;
'api::reminder-settings.reminder-settings': ApiReminderSettingsReminderSettings;
+12
View File
@@ -45,3 +45,15 @@ STRAPI_URL=http://localhost:1337
# Optional Strapi API token. Only needed to read draft or otherwise protected
# content; published content is public. Settings -> API Tokens in the admin.
STRAPI_TOKEN=
# --- Bot protection --------------------------------------------------------
# Cloudflare Turnstile on the public forms (contact, event registration and
# both application forms). Both keys or neither: with either one empty, no
# widget renders and submissions are not checked. The site key is public and
# reaches the browser; the secret key never does.
# Cloudflare dashboard -> Turnstile -> your widget. For local testing,
# Cloudflare's always-pass pair is 1x00000000000000000000AA (site) and
# 1x0000000000000000000000000000000AA (secret).
TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY=
@@ -153,6 +153,8 @@ export function DesktopNav({
useEffect(() => clearCloseTimer, [clearCloseTimer]);
useEffect(() => closeGroup(), [pathname, closeGroup]);
const columnLinks = useCallback((groupId: string): HTMLElement[] => {
const nav = navRef.current;
if (!nav) return [];
@@ -490,6 +492,7 @@ export function DesktopNav({
<Link
to={child.href}
data-nav-sublink=""
onClick={closeGroup}
aria-current={
isCurrentPath(child.href, pathname) ? "page" : undefined
}
@@ -520,6 +523,7 @@ export function DesktopNav({
isCurrentPath(item.href, pathname) ? "page" : undefined
}
onFocus={closeGroup}
onClick={closeGroup}
className={cx(
"body-sm-bold whitespace-nowrap pb-[2px] no-underline",
/* Same rest/hover/current treatment as a group trigger. */
@@ -0,0 +1,187 @@
/* ============================================================================
TURNSTILE - Cloudflare's bot check on the public forms
----------------------------------------------------------------------------
Rendered INSIDE a <Form>: the widget injects a hidden `cf-turnstile-response`
input into its container, so the token travels with the other fields and
the route's action checks it with `verifyTurnstile` (~/lib/turnstile.server).
The site key is read off the root loader. Without one the component renders
nothing and the server skips the check, so an unconfigured deploy keeps
working exactly as before.
EXPLICIT RENDERING, NOT THE `cf-turnstile` CLASS
Implicit rendering scans the DOM once, when api.js loads - a form reached by
a client-side navigation would never get a widget. Rendering from an effect
ties the widget to the component instead, and `remove` on unmount keeps
Cloudflare's iframes from piling up across navigations.
THE SCRIPT AND THE CSP
api.js is appended from here, once per page, the first time a form mounts -
so pages without a form never load it. The CSP (entry.server.tsx) admits it
through 'strict-dynamic', because the bundle that appends it is nonced; the
widget's iframe needs `frame-src https://challenges.cloudflare.com`.
SCRIPTING OFF
The check cannot run without JavaScript, so the forms that otherwise work
as plain posts cannot pass it. The <noscript> line says so up front instead
of letting the reader fill everything in first.
========================================================================= */
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouteLoaderData } from "react-router";
import { createTranslator } from "~/i18n/t";
import type { AppTheme } from "~/lib/theme";
import type { Locale } from "../LanguageSwitcher";
/** Cloudflare requires this exact URL - api.js must not be proxied or cached. */
const SCRIPT_URL =
"https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
/**
* Which form a token was issued for. Reported per action in the Cloudflare
* dashboard. Up to 32 characters of [A-Za-z0-9_-].
*/
export type TurnstileAction =
| "contact"
| "event-registration"
| "opportunity-application"
| "get-involved";
/** The slice of the root loader this component reads. */
interface RootLoaderData {
theme?: AppTheme;
turnstileSiteKey?: string | null;
}
/** The slice of `window.turnstile` used here. */
interface TurnstileApi {
render(
container: HTMLElement,
options: {
sitekey: string;
action: string;
theme: AppTheme;
language: Locale;
size: "flexible";
},
): string | undefined;
reset(widgetId: string): void;
remove(widgetId: string): void;
}
declare global {
interface Window {
turnstile?: TurnstileApi;
}
}
let scriptLoad: Promise<TurnstileApi> | undefined;
/** Appends api.js once and resolves with the API. A failed load may retry. */
function loadTurnstile(): Promise<TurnstileApi> {
if (window.turnstile) return Promise.resolve(window.turnstile);
scriptLoad ??= new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = SCRIPT_URL;
script.async = true;
script.onload = () =>
window.turnstile
? resolve(window.turnstile)
: reject(new Error("api.js loaded without defining window.turnstile"));
script.onerror = () => {
script.remove();
scriptLoad = undefined;
reject(new Error("api.js could not be loaded"));
};
document.head.append(script);
});
return scriptLoad;
}
export interface TurnstileProps {
/** Which form this is. */
action: TurnstileAction;
/** Reading locale, for the widget's own language and the fallback line. */
locale: Locale;
/**
* Anything that changes once per submission - pass the route's action data.
* Siteverify accepts a token only once, so every new result asks the widget
* for a fresh one.
*/
resetKey?: unknown;
className?: string;
}
export function Turnstile({ action, locale, resetKey, className }: TurnstileProps) {
const rootData = useRouteLoaderData<RootLoaderData>("root");
const t = useMemo(() => createTranslator(locale), [locale]);
const siteKey = rootData?.turnstileSiteKey ?? null;
const theme: AppTheme = rootData?.theme ?? "dark";
const containerRef = useRef<HTMLDivElement>(null);
const widgetIdRef = useRef<string | undefined>(undefined);
const [unavailable, setUnavailable] = useState(false);
useEffect(() => {
if (!siteKey) return;
let cancelled = false;
loadTurnstile().then(
(turnstile) => {
if (cancelled || !containerRef.current) return;
widgetIdRef.current = turnstile.render(containerRef.current, {
sitekey: siteKey,
action,
theme,
language: locale,
size: "flexible",
});
},
() => {
if (!cancelled) setUnavailable(true);
},
);
return () => {
cancelled = true;
if (widgetIdRef.current) window.turnstile?.remove(widgetIdRef.current);
widgetIdRef.current = undefined;
};
}, [siteKey, action, theme, locale]);
useEffect(() => {
if (widgetIdRef.current) window.turnstile?.reset(widgetIdRef.current);
}, [resetKey]);
if (!siteKey) return null;
const fallback = (
<p className="body-sm text-[var(--text-danger)]">
{t("form.verificationUnavailable")}
</p>
);
return (
<div className={className}>
{/* Cloudflare owns everything inside this element; React renders no
children into it, so the two never fight over the same nodes. */}
{unavailable ? fallback : <div ref={containerRef} />}
<noscript>{fallback}</noscript>
</div>
);
}
export default Turnstile;
/* ---------------------------------------------------------------------------
USAGE
---------------------------------------------------------------------------
// Inside the <Form>, just above the submit button. In the action, call
// `verifyTurnstile(form)` from ~/lib/turnstile.server after validation.
<Turnstile action="contact" locale={locale} resetKey={actionData} />
------------------------------------------------------------------------ */
@@ -0,0 +1,2 @@
export { Turnstile, default } from "./Turnstile";
export type { TurnstileAction, TurnstileProps } from "./Turnstile";
+38 -1
View File
@@ -20,6 +20,7 @@ import type { RenderToPipeableStreamOptions } from "react-dom/server";
import { renderToPipeableStream } from "react-dom/server";
import { DEFAULT_LOCALE, getLocaleFromPathname } from "~/i18n/config";
import { getServerEnv } from "~/lib/env.server";
export const streamTimeout = 5_000;
@@ -32,6 +33,36 @@ function contentLanguage(request: Request): string {
}
}
function readCspNonce(routerContext: EntryContext): string | undefined {
const rootData = routerContext.staticHandlerContext.loaderData["root"] as
| { nonce?: unknown }
| undefined;
return typeof rootData?.nonce === "string" ? rootData.nonce : undefined;
}
function contentSecurityPolicy(nonce: string): string {
const { STRAPI_URL, isProduction } = getServerEnv();
const mediaOrigin = new URL(STRAPI_URL).origin;
const directives = [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://www.googletagmanager.com https://challenges.cloudflare.com`,
"script-src-attr 'none'",
// The Turnstile widget on the public forms renders in Cloudflare's iframe.
"frame-src https://challenges.cloudflare.com",
"style-src 'self' 'unsafe-inline'",
`img-src 'self' ${mediaOrigin} https://www.google-analytics.com data: blob:`,
`media-src 'self' ${mediaOrigin} data: blob:`,
"font-src 'self' data:",
"connect-src 'self' https://www.googletagmanager.com https://www.google-analytics.com https://region1.google-analytics.com",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
...(isProduction ? ["upgrade-insecure-requests"] : []),
];
return directives.join("; ");
}
export default function handleRequest(
request: Request,
responseStatusCode: number,
@@ -39,6 +70,11 @@ export default function handleRequest(
routerContext: EntryContext,
loadContext: RouterContextProvider,
) {
const nonce = readCspNonce(routerContext);
if (nonce) {
responseHeaders.set("Content-Security-Policy", contentSecurityPolicy(nonce));
}
// https://httpwg.org/specs/rfc9110.html#HEAD
if (request.method.toUpperCase() === "HEAD") {
return new Response(null, {
@@ -66,8 +102,9 @@ export default function handleRequest(
);
const { pipe, abort } = renderToPipeableStream(
<ServerRouter context={routerContext} url={request.url} />,
<ServerRouter context={routerContext} url={request.url} nonce={nonce} />,
{
nonce,
[readyOption]() {
shellRendered = true;
const body = new PassThrough({
+3 -1
View File
@@ -130,7 +130,7 @@
"joinWaitlist": "الانضمام إلى قائمة الانتظار",
"confirmationTitle": "تم تسجيلك بنجاح",
"confirmationBody": "شكرًا لك — تم تأكيد مكانك في {event} بتاريخ {date}. سنرسل لك تذكيرًا وأي تفاصيل إضافية على البريد الإلكتروني الذي قدّمته.",
"noEvents": "لا توجد فعاليات لعرضها بعد."
"noEvents": "سيتم الإعلان عن الفعاليات القادمة قريباً."
},
"news": {
"tabProject": "أخبار SHARP-ER",
@@ -168,6 +168,8 @@
"errorEmail": "أدخل بريدًا إلكترونيًا صالحًا.",
"errorConsent": "يرجى تأكيد الموافقة لإتمام التسجيل.",
"errorGeneric": "حدث خطأ ما. يرجى المحاولة مرة أخرى.",
"errorVerification": "تعذّر إكمال التحقق الأمني. يرجى المحاولة مرة أخرى.",
"verificationUnavailable": "تعذّر تحميل التحقق الأمني. يرجى التأكد من تفعيل JavaScript ثم إعادة تحميل الصفحة.",
"successTitle": "شكرًا لك",
"successBody": "تم إرسال رسالتك. سنعاود التواصل معك في أقرب وقت ممكن."
},
+2
View File
@@ -168,6 +168,8 @@
"errorEmail": "Enter a valid email address.",
"errorConsent": "Please confirm this to complete your registration.",
"errorGeneric": "Something went wrong. Please try again.",
"errorVerification": "The security check could not be completed. Please try again.",
"verificationUnavailable": "The security check could not load. Please make sure JavaScript is enabled, then reload the page.",
"successTitle": "Thank you",
"successBody": "Your message has been sent. We will get back to you as soon as we can."
},
+23
View File
@@ -20,6 +20,13 @@ export interface ServerEnv {
readonly STRAPI_TOKEN?: string;
/** Public origin of this site, no trailing slash. Used for canonical URLs. */
readonly SITE_URL: string;
/**
* Cloudflare Turnstile keys. Set together or not at all: the check on the
* public forms is on only when BOTH are present, so a half-configured deploy
* leaves the forms unprotected rather than rejecting every submission.
*/
readonly TURNSTILE_SITE_KEY?: string;
readonly TURNSTILE_SECRET_KEY?: string;
readonly isProduction: boolean;
readonly isDevelopment: boolean;
}
@@ -92,14 +99,30 @@ export function getServerEnv(): ServerEnv {
issues,
);
const token = source.STRAPI_TOKEN?.trim();
const turnstileSiteKey = source.TURNSTILE_SITE_KEY?.trim();
const turnstileSecretKey = source.TURNSTILE_SECRET_KEY?.trim();
if (issues.length > 0) throw new EnvError(issues);
/* Not an EnvError: a missing bot check is no reason to take every page
down. It is said out loud once, because the forms are now unguarded. */
if (Boolean(turnstileSiteKey) !== Boolean(turnstileSecretKey)) {
console.warn(
"[env] Only one of TURNSTILE_SITE_KEY / TURNSTILE_SECRET_KEY is set; Turnstile is off.",
);
}
cached = {
NODE_ENV,
STRAPI_URL,
SITE_URL,
...(token ? { STRAPI_TOKEN: token } : {}),
...(turnstileSiteKey && turnstileSecretKey
? {
TURNSTILE_SITE_KEY: turnstileSiteKey,
TURNSTILE_SECRET_KEY: turnstileSecretKey,
}
: {}),
isProduction: NODE_ENV === "production",
isDevelopment: NODE_ENV === "development",
};
+81
View File
@@ -0,0 +1,81 @@
/* ============================================================================
CLOUDFLARE TURNSTILE - SERVER-SIDE VERIFICATION. SERVER ONLY.
----------------------------------------------------------------------------
The widget (~/components/ui/Turnstile) only PRODUCES a token; nothing is
proven until Siteverify accepts it. Every public form action calls
`verifyTurnstile` after its own field validation and before anything is
written to the CMS.
FAILS CLOSED
A missing token, a rejected one and an unreachable Siteverify all answer
`false`. A bot cannot make Cloudflare unreachable, so failing open would only
ever help an attacker; the reader gets a "try again" either way.
SINGLE USE
Siteverify accepts a token exactly once, so each form resets its widget
after every action result (`resetKey`) - a corrected resubmit would
otherwise carry a spent token and fail with `timeout-or-duplicate`.
OFF WHEN UNCONFIGURED
With TURNSTILE_SITE_KEY / TURNSTILE_SECRET_KEY unset (see env.server.ts) no
widget renders and every submission passes, so local development and a
deploy that has not been given keys yet behave exactly as before.
========================================================================= */
import { getServerEnv } from "./env.server";
const SITEVERIFY_URL =
"https://challenges.cloudflare.com/turnstile/v0/siteverify";
/** The hidden input the widget injects into its form. Cloudflare's default. */
const RESPONSE_FIELD = "cf-turnstile-response";
/** Cloudflare's documented ceiling - anything longer is not a token. */
const MAX_TOKEN_LENGTH = 2048;
/** Error codes that mean the KEY is wrong, not the visitor. */
const SECRET_ERRORS = new Set(["missing-input-secret", "invalid-input-secret"]);
interface SiteverifyResult {
readonly success?: unknown;
readonly "error-codes"?: unknown;
}
/** True when Turnstile is off, or Siteverify accepted the form's token. */
export async function verifyTurnstile(form: FormData): Promise<boolean> {
const { TURNSTILE_SECRET_KEY } = getServerEnv();
if (!TURNSTILE_SECRET_KEY) return true;
const token = form.get(RESPONSE_FIELD);
if (
typeof token !== "string" ||
token.length === 0 ||
token.length > MAX_TOKEN_LENGTH
) {
return false;
}
try {
const response = await fetch(SITEVERIFY_URL, {
method: "POST",
body: new URLSearchParams({ secret: TURNSTILE_SECRET_KEY, response: token }),
signal: AbortSignal.timeout(10_000),
});
const result = (await response.json()) as SiteverifyResult;
if (result.success === true) return true;
/* A visitor who fails the check is routine and is not logged. A key
Cloudflare refuses fails EVERY submission, so that one is. */
const codes = Array.isArray(result["error-codes"]) ? result["error-codes"] : [];
if (codes.some((code) => SECRET_ERRORS.has(code))) {
console.error(
`[turnstile] Siteverify rejected TURNSTILE_SECRET_KEY: ${codes.join(", ")}`,
);
}
return false;
} catch (error) {
console.error("[turnstile] Siteverify could not be reached", error);
return false;
}
}
+15 -3
View File
@@ -36,7 +36,7 @@ import { useLocale, useRouteHandleValue } from "~/i18n";
import { DEFAULT_LOCALE, dirFor, type Locale } from "~/i18n/config";
import { localizedPath } from "~/i18n/paths";
import { createTranslator, getMessages } from "~/i18n/t";
import { getSiteUrl } from "~/lib/env.server";
import { getServerEnv, getSiteUrl } from "~/lib/env.server";
import {
readCanonicalPath,
readFooterSurface,
@@ -70,6 +70,11 @@ const FONT_PRELOADS: Readonly<Record<Locale, readonly string[]>> = {
],
};
function createCspNonce(): string {
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
return btoa(String.fromCharCode(...bytes));
}
/**
* Resolved once per request. Every route can read the origin and the locale
* off this loader via `matches[0].data` in its own `meta`.
@@ -88,6 +93,10 @@ export function loader({ request }: Route.LoaderArgs) {
guesses, so there is no flash-of-wrong-theme and no hydration mismatch.
`:root` is dark, so the absence of a cookie is a dark first visit. */
theme: readTheme(request.headers.get("Cookie")),
nonce: createCspNonce(),
/* Public by design - the widget needs it in the browser. Null when
Turnstile is not configured, and then no form renders the widget. */
turnstileSiteKey: getServerEnv().TURNSTILE_SITE_KEY ?? null,
};
}
@@ -157,6 +166,7 @@ export function Layout({ children }: { children: ReactNode }) {
surface in either theme. */
const rootData = useRouteLoaderData<typeof loader>("root");
const theme: AppTheme = rootData?.theme ?? DEFAULT_THEME;
const nonce = rootData?.nonce;
const navSurface = useRouteHandleValue(readNavSurface) ?? theme;
const footerSurface = useRouteHandleValue(readFooterSurface) ?? theme;
@@ -204,10 +214,12 @@ export function Layout({ children }: { children: ReactNode }) {
<Meta />
<Links />
<script
nonce={nonce}
async
src="https://www.googletagmanager.com/gtag/js?id=G-TJT6NM2DMT"
/>
<script
nonce={nonce}
dangerouslySetInnerHTML={{
__html: `window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
@@ -243,8 +255,8 @@ gtag('config', 'G-TJT6NM2DMT');`,
linkGroups={footerLinkGroups}
/>
<ScrollRestoration />
<Scripts />
<ScrollRestoration nonce={nonce} />
<Scripts nonce={nonce} />
</body>
</html>
);
@@ -35,13 +35,13 @@ const EN = {
heading: "Upcoming Events",
/** Draft 4, EN standfirst. */
intro:
"Register for a SUQ dialogue, join a training, or track what’s coming next across Libya.",
"Stay informed about upcoming dialogue sessions, training opportunities, and networking events across Libya. Use the calendar to find registration details, thematic focus, and event materials.",
metaDescription:
"Dialogues, trainings and Civil Society Law Sessions across Libya. Register for a SUQ dialogue, join a training, or track what’s coming next.",
listLabel: "Events",
statusFilterLabel: "Status",
emptyHint:
"Dialogues, trainings and sessions are published here as soon as they are scheduled.",
"Upcoming events will be announced soon. Dialogues, trainings, and sessions will be published here as soon as they are scheduled.",
unavailable:
"The events calendar cannot be loaded right now. Please try again in a moment.",
formatHybrid: "In person and online",
@@ -86,7 +86,7 @@ const EN = {
heading: "Get Involved",
/** Draft 8, EN standfirst. */
intro:
"Whether you’re a civil society organisation or an individual, here’s how to take part in SHARP-ER.",
"SHARP-ER connects people and organisations with opportunities to learn, participate, collaborate, and lead. Whether you’re an individual or a civil society organisation, explore current calls for applications, training programmes, and partnership opportunities below.",
metaDescription:
"Calls for expression of interest, SHARP network membership and upcoming sessions — how civil society organisations and individuals take part in SHARP-ER.",
pathwaysHeading: "Ways to take part",
@@ -233,12 +233,12 @@ const AR: RouteCopy = {
},
heading: "الفعاليات القادمة",
intro:
"سجّل في حوار SUQ، أو انضم إلى تدريب، أو تابع ما هو قادم في مختلف أنحاء ليبيا.",
"ابقَ على اطلاع بجلسات الحوار القادمة، وفرص التدريب، وفعاليات التشبيك في جميع أنحاء ليبيا. استخدم التقويم لمعرفة تفاصيل التسجيل، ومحاور الفعاليات، والمواد المرفقة بها.",
metaDescription:
"حوارات وتدريبات وجلسات قانون المجتمع المدني في مختلف أنحاء ليبيا. سجّل في حوار SUQ، أو انضم إلى تدريب، أو تابع ما هو قادم.",
listLabel: "الفعاليات",
statusFilterLabel: "الحالة",
emptyHint: "تُنشر هنا الحوارات والتدريبات والجلسات فور تحديد مواعيدها.",
emptyHint: "ستُنشر هنا الحوارات والتدريبات والجلسات فور تحديد مواعيدها.",
unavailable:
"تعذّر تحميل تقويم الفعاليات في الوقت الحالي. يرجى المحاولة مرة أخرى بعد قليل.",
formatHybrid: "حضوري وعبر الإنترنت",
@@ -275,7 +275,7 @@ const AR: RouteCopy = {
opportunities: {
heading: "شارك معنا",
intro:
"سواء كنتم منظمة مجتمع مدني أو فردًا، إليكم كيفية المشاركة في SHARP-ER.",
"يربط برنامج شارب-إي آر (SHARP-ER) الأفراد والمنظمات بفرص التعلم والمشاركة والتعاون والقيادة. سواء كنتم أفراداً أو منظمات مجتمع مدني، استكشفوا دعوات التقديم الحالية، والبرامج التدريبية، وفرص الشراكة أدناه.",
metaDescription:
"دعوات للتعبير عن الاهتمام، وعضوية شبكة SHARP، وجلسات قادمة — كيف تشارك منظمات المجتمع المدني والأفراد في SHARP-ER.",
pathwaysHeading: "طرق المشاركة",
@@ -7,7 +7,8 @@
Both forms work without JavaScript: they are plain <Form method="post">
submissions, so validation has to be complete on the server. There is no
client mirror of these rules to drift out of sync.
client mirror of these rules to drift out of sync. (The Cloudflare
Turnstile check, when configured, is the one part that needs scripting.)
========================================================================= */
import type { TranslationKey } from "~/i18n";
+6 -6
View File
@@ -38,7 +38,7 @@ export const RESOURCES_COPY: Localised<SectionCopy> = {
en: {
heading: "Reports, Research, and Publications",
intro:
"Findings, articles, and official documents produced through SHARP-ER’s research and dialogue work.",
"Explore SHARP-ER publications and practical resources for civil society organisations, communities, public institutions, researchers, and practitioners. This library provides accessible training guides, dialogue reports, policy briefs, and community awareness materials designed to be used in your own work.",
emptyTitle: "No publications have been added yet.",
emptyBody:
"Reports, research and official documents will appear here as they are published.",
@@ -48,7 +48,7 @@ export const RESOURCES_COPY: Localised<SectionCopy> = {
ar: {
heading: "تقارير وأبحاث ومنشورات",
intro:
"نتائج ومقالات ووثائق رسمية صادرة عن أعمال البحث والحوار في مشروع SHARP-ER.",
"اكتشفوا منشورات وموارد شارب-إي آر (SHARP-ER) العملية الموجهة إلى منظمات المجتمع المدني، والمجتمعات المحلية، والمؤسسات العامة، والباحثين والممارسين. توفر هذه المكتبة أدلة تدريبية، وتقارير حوارية، وموجزات سياسات، ومواد توعوية يسهل الوصول إليها واستخدامها في عملكم اليومي.",
emptyTitle: "لم تُضف أي منشورات بعد.",
emptyBody: "ستظهر هنا التقارير والأبحاث والوثائق الرسمية فور نشرها.",
metaDescription:
@@ -64,7 +64,7 @@ export const KNOWLEDGE_COPY: Localised<SectionCopy> = {
en: {
heading: "Learn From the SHARP Network",
intro:
"Training materials, dialogue guides, and lessons from civil society organisations putting SHARP-ER’s approach into practice.",
"The SHARP-ER Knowledge Hub is a space for learning, evidence, and exchange. It brings together insights from dialogue, research, training, and local initiatives to help you understand what works and why. Use the Hub to find practical tools, compare regional experiences, and inform policy. Contributions from civil society, researchers, and participants are always welcome as this space grows.",
emptyTitle: "The knowledge hub is being prepared.",
emptyBody:
"Training modules, dialogue guides, case studies and FAQs will appear here as they are published.",
@@ -74,7 +74,7 @@ export const KNOWLEDGE_COPY: Localised<SectionCopy> = {
ar: {
heading: "تعلّم من شبكة SHARP",
intro:
"مواد تدريبية، وأدلة حوار، ودروس مستفادة من منظمات المجتمع المدني التي تطبّق نهج SHARP-ER على أرض الواقع.",
"يمثل مركز المعرفة لبرنامج شارب-إي آر (SHARP-ER) مساحة للتعلم وتبادل الأدلة والخبرات. يجمع المركز الرؤى المستخلصة من الحوارات والبحوث والتدريبات والمبادرات المحلية لمساعدتكم على فهم ما ينجح ولماذا. استخدموا المركز للوصول إلى أدوات عملية، ومقارنة التجارب الإقليمية، والاستفادة من الأدلة في تطوير السياسات. نرحب دائماً بمساهمات المجتمع المدني والباحثين والمشاركين مع استمرار توسع هذا الفضاء.",
emptyTitle: "يجري تجهيز مركز المعرفة.",
emptyBody:
"ستظهر هنا الوحدات التدريبية وأدلة الحوار ودراسات الحالة والأسئلة الشائعة فور نشرها.",
@@ -142,7 +142,7 @@ export const NEWS_COPY: Localised<NewsCopy> = {
en: {
heading: "News from SHARP-ER and the Network",
intro:
"Milestones from the project team, and updates from the civil society organisations we work with.",
"Follow the latest SHARP-ER milestones, partner stories, and community-led initiatives. Discover how ideas move from discussion to action: whether it’s a new partnership, a local concern brought to the table, or a locally led solution.",
projectBlurb:
"Official updates on milestones, campaign launches, publications, and events.",
networkBlurb:
@@ -156,7 +156,7 @@ export const NEWS_COPY: Localised<NewsCopy> = {
ar: {
heading: "أخبار من SHARP-ER ومن الشبكة",
intro:
"محطات رئيسية من فريق المشروع، ومستجدات من منظمات المجتمع المدني التي نعمل معها.",
"تابع أحدث إنجازات برنامج شارب-إي آر (SHARP-ER)، وقصص الشركاء، والمبادرات التي تقودها المجتمعات المحلية. اكتشف كيف تتحول الأفكار من مجرد نقاشات إلى واقع ملموس، سواء كان ذلك عبر شراكة جديدة، أو طرح قضية مجتمعية للنقاش، أو ابتكار حلول محلية.",
projectBlurb:
"تحديثات رسمية حول المحطات الرئيسية، وإطلاق الحملات، والمنشورات، والفعاليات.",
networkBlurb:
+23 -20
View File
@@ -10,8 +10,8 @@
Facts a four-cell seam grid: the run, the coverage, the lead
partner and the funder.
Nationwide the approved body prose beside the three focus areas.
Consortium the three members as role / name / deliverable panels, under
a header row that carries the EU funding line.
Consortium unboxed rows for the three members.
The header carries the EU funding line.
Legacy the SHARP I and II panel with the accent start-edge, and the
call to action into What We Do.
@@ -157,7 +157,7 @@ const ACCENT_EDGE =
interface ConsortiumMember {
id: string;
name: string;
/** The role chip - `consortium.role` in the CMS, or the bundled copy's. */
/** The role label - `consortium.role` in the CMS, or the bundled copy's. */
role: string | null;
description: string;
website: string | null;
@@ -301,33 +301,36 @@ export default function About({ loaderData }: Route.ComponentProps) {
</p>
</div>
<ul className="grid list-none grid-cols-1 gap-lg md:grid-cols-3">
<ul className="list-none divide-y divide-[var(--border-subtle)] border-y border-[var(--border-subtle)]">
{consortium.map((member) => (
<li key={member.id}>
<InfoPanel
/* On the sunken band the draft lifts these cards to the
raised dark it uses for a page band (#1d1d1b), one step
lighter than the panel shade InfoPanel defaults to. The
`!` is what makes the override beat the component's own
`bg-*` utility, which sorts after this one. */
className="sh-lift sh-reveal sh-reveal--in hover:border-[var(--border-accent)]"
title={member.name}
{...(member.role
? { eyebrow: member.role, eyebrowVariant: "chip" as const }
: {})}
<li
key={member.id}
className="sh-reveal sh-reveal--in grid grid-cols-1 gap-md py-lg md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] md:gap-xl"
>
<span className="block">{member.description}</span>
<div className="flex flex-col gap-xs">
{member.role ? (
<p className="label-md uppercase text-[var(--text-accent)]">
{member.role}
</p>
) : null}
<h3 className="heading-h3 text-[var(--text-primary)]">
{member.website ? (
<a
href={member.website}
target="_blank"
rel="noopener noreferrer"
className="mt-xs inline-block body-sm-bold text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
className="text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
>
{member.name}
</a>
) : null}
</InfoPanel>
) : (
member.name
)}
</h3>
</div>
<p className="body-md text-[var(--text-secondary)]">
{member.description}
</p>
</li>
))}
</ul>
+2 -2
View File
@@ -231,7 +231,7 @@ export const ACTIVITIES_INDEX_COPY: Readonly<
en: {
eyebrow: "Activities and Initiatives",
headline: "What’s Happening Across the SHARP-ER Network",
lede: "From regional network meetings to nationwide dialogues — here’s where the project stands today.",
lede: "From regional network meetings to nationwide dialogues — here’s where the project stands today. Our activities focus on inclusive dialogue, civil-society capacity building, community-led initiatives, and digital awareness across Libya.",
streamsNavLabel: "Activity streams",
figuresHeading: "SHARP-ER in figures",
emptyTitle: "No activities have been published yet.",
@@ -245,7 +245,7 @@ export const ACTIVITIES_INDEX_COPY: Readonly<
ar: {
eyebrow: "الأنشطة والمبادرات",
headline: "ماذا يحدث عبر شبكة SHARP-ER",
lede: "من اجتماعات الشبكة الإقليمية إلى الحوارات الوطنية، إليكم أين وصل المشروع اليوم.",
lede: "من اجتماعات الشبكات الإقليمية إلى الحوارات الوطنية — إليك ما وصل إليه المشروع اليوم. تركز أنشطتنا على الحوار الشامل، وبناء قدرات المجتمع المدني، والمبادرات التي تقودها المجتمعات المحلية، والتوعية الرقمية في جميع أنحاء ليبيا.",
streamsNavLabel: "محاور الأنشطة",
figuresHeading: "SHARP-ER بالأرقام",
emptyTitle: "لم يتم نشر أي أنشطة حتى الآن.",
@@ -27,6 +27,7 @@ import {
import { ArrowIcon, Button } from "~/components/ui/Button";
import { Input, Textarea } from "~/components/ui/Input";
import { Turnstile } from "~/components/ui/Turnstile";
import {
interpolate,
localizedPath,
@@ -38,6 +39,7 @@ import {
import { safeHref } from "~/lib/safe-href";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import { isStrapiError, requireEntity } from "~/lib/strapi";
import { verifyTurnstile } from "~/lib/turnstile.server";
import type { Route } from "./+types/applications-and-opportunities.$slug";
import { getRouteCopy } from "./_events-opportunities/copy";
@@ -98,6 +100,8 @@ type ActionResult =
readonly values: ApplicationValues;
/** True when validation passed but the CMS refused the write. */
readonly failed: boolean;
/** True when validation passed but the Turnstile check did not. */
readonly verificationFailed: boolean;
};
export async function action({ params, request }: Route.ActionArgs) {
@@ -121,11 +125,30 @@ export async function action({ params, request }: Route.ActionArgs) {
if (!valid) {
return data(
{ ok: false, errors, values, failed: false } satisfies ActionResult,
{
ok: false,
errors,
values,
failed: false,
verificationFailed: false,
} satisfies ActionResult,
{ status: 400 },
);
}
if (!(await verifyTurnstile(form))) {
return data(
{
ok: false,
errors: {},
values,
failed: false,
verificationFailed: true,
} satisfies ActionResult,
{ status: 403 },
);
}
try {
await submitApplication({
values,
@@ -138,7 +161,13 @@ export async function action({ params, request }: Route.ActionArgs) {
`[opportunities] application for "${slug}" was rejected: ${error.status} ${error.message}`,
);
return data(
{ ok: false, errors: {}, values, failed: true } satisfies ActionResult,
{
ok: false,
errors: {},
values,
failed: true,
verificationFailed: false,
} satisfies ActionResult,
{ status: 502 },
);
}
@@ -492,6 +521,10 @@ function ApplicationSection({
</Notice>
) : null}
{result && !result.ok && result.verificationFailed ? (
<Notice tone="danger" role="alert" title={t("form.errorVerification")} />
) : null}
<Form method="post" action={detailPath} className="flex flex-col gap-md">
<div className="grid gap-md sm:grid-cols-2">
<Input
@@ -544,6 +577,12 @@ function ApplicationSection({
/>
</div>
<Turnstile
action="opportunity-application"
locale={locale}
resetKey={result}
/>
<div className="flex flex-wrap items-center gap-sm">
<Button
type="submit"
@@ -37,7 +37,8 @@
an enhancement only: all three steps are in the server-rendered HTML, no
field is hidden behind a step, and the whole thing submits and reports its
errors with JavaScript switched off. `useNavigation()` only swaps the button
into its loading state.
into its loading state. The one exception is the Cloudflare Turnstile check,
which needs scripting whenever it is configured (~/components/ui/Turnstile).
WHAT THE DRAFT PROMISES AND THIS PAGE DOES NOT (CLAUDE.md rule 5 - the
wording lives in ./_events-opportunities/copy.ts, where each omission is
@@ -65,6 +66,7 @@ import { data, Form, Link, useActionData, useNavigation } from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
import { CardLink, CardShell } from "~/components/ui/Card";
import { Input, Textarea } from "~/components/ui/Input";
import { Turnstile } from "~/components/ui/Turnstile";
import {
interpolate,
localizedPath,
@@ -76,6 +78,7 @@ import {
} from "~/i18n";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import { isStrapiError } from "~/lib/strapi";
import { verifyTurnstile } from "~/lib/turnstile.server";
import type { Route } from "./+types/applications-and-opportunities";
import { Container } from "./_page-kit/sections";
@@ -192,6 +195,8 @@ type ActionResult =
readonly values: PathwayApplicationValues;
/** True when validation passed but the CMS refused the write. */
readonly failed: boolean;
/** True when validation passed but the Turnstile check did not. */
readonly verificationFailed: boolean;
};
export async function action({ params, request }: Route.ActionArgs) {
@@ -219,11 +224,30 @@ export async function action({ params, request }: Route.ActionArgs) {
if (!valid) {
return data(
{ ok: false, errors, values, failed: false } satisfies ActionResult,
{
ok: false,
errors,
values,
failed: false,
verificationFailed: false,
} satisfies ActionResult,
{ status: 400 },
);
}
if (!(await verifyTurnstile(form))) {
return data(
{
ok: false,
errors: {},
values,
failed: false,
verificationFailed: true,
} satisfies ActionResult,
{ status: 403 },
);
}
try {
await submitPathwayApplication({
values,
@@ -235,7 +259,13 @@ export async function action({ params, request }: Route.ActionArgs) {
`[opportunities] application from /${locale} was rejected: ${error.status} ${error.message}`,
);
return data(
{ ok: false, errors: {}, values, failed: true } satisfies ActionResult,
{
ok: false,
errors: {},
values,
failed: true,
verificationFailed: false,
} satisfies ActionResult,
{ status: 502 },
);
}
@@ -678,13 +708,15 @@ function ApplicationForm({
result: ActionResult | undefined;
route: ApplicationRoute;
}) {
const { t } = useTranslation();
const { locale, t } = useTranslation();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
const errors = result && !result.ok ? result.errors : {};
const values = result && !result.ok ? result.values : EMPTY_VALUES;
const hasFailed = result !== undefined && !result.ok && result.failed;
const hasFailedVerification =
result !== undefined && !result.ok && result.verificationFailed;
/* This form has no consent checkbox - only the event registration does - so
the "consent" code cannot occur here; it is narrowed away rather than
@@ -723,7 +755,7 @@ function ApplicationForm({
className="flex flex-col gap-lg"
noValidate
>
{invalidFields.length > 0 || hasFailed ? (
{invalidFields.length > 0 || hasFailed || hasFailedVerification ? (
<div
ref={summaryRef}
tabIndex={-1}
@@ -731,7 +763,11 @@ function ApplicationForm({
className="flex flex-col gap-xs rounded-card border border-border-danger bg-surface-danger-subtle p-md"
>
<p className="body-md-bold text-text-danger">
{hasFailed ? t("form.errorGeneric") : copy.apply.errorSummaryTitle}
{hasFailed
? t("form.errorGeneric")
: hasFailedVerification
? t("form.errorVerification")
: copy.apply.errorSummaryTitle}
</p>
{hasFailed ? (
@@ -887,6 +923,8 @@ function ApplicationForm({
</div>
</div>
<Turnstile action="get-involved" locale={locale} resetKey={result} />
<div className="flex flex-wrap items-center justify-between gap-md border-t border-border-subtle pt-lg">
<p className="body-sm max-w-[26rem] text-text-tertiary">
{copy.apply.acknowledgement}
+20
View File
@@ -10,6 +10,8 @@
Client-side JS is an enhancement only - the page works as a plain HTML form
post, and `useNavigation()` merely swaps the button into its loading state.
The one exception is the Cloudflare Turnstile check, which needs scripting
whenever it is configured (see ~/components/ui/Turnstile).
Every message the reader can see is localised: field labels and the generic
errors come from the i18n catalogue, the page prose from the approved draft.
@@ -41,9 +43,11 @@ import { data, Form, Link, useLocation, useNavigation, useRouteLoaderData } from
import { ArrowIcon, Button } from "~/components/ui/Button";
import { EuBadge } from "~/components/ui/EuBadge";
import { Input, Textarea } from "~/components/ui/Input";
import { Turnstile } from "~/components/ui/Turnstile";
import { createTranslator, sectionPath, type Locale } from "~/i18n";
import { getServerEnv } from "~/lib/env.server";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import { verifyTurnstile } from "~/lib/turnstile.server";
import type { Route } from "./+types/contact";
import type { CmsContactPage } from "./_page-kit/cms-types";
@@ -274,6 +278,20 @@ export async function action({ params, request }: Route.ActionArgs) {
);
}
if (!(await verifyTurnstile(form))) {
const t = createTranslator(locale);
return data(
{
status: "failed" as const,
errors: {} as ContactErrors,
values,
formError: t("form.errorVerification"),
},
{ status: 403 },
);
}
try {
await createContactSubmission(
values,
@@ -548,6 +566,8 @@ export default function Contact({
{...(errors.message ? { error: errors.message } : {})}
/>
<Turnstile action="contact" locale={locale} resetKey={actionData} />
<Button
type="submit"
size="lg"
+43 -4
View File
@@ -39,6 +39,7 @@ import {
import { ArrowIcon, Button } from "~/components/ui/Button";
import { Input } from "~/components/ui/Input";
import { Turnstile } from "~/components/ui/Turnstile";
import {
formatDate,
interpolate,
@@ -51,6 +52,7 @@ import {
import { safeHref } from "~/lib/safe-href";
import { buildBreadcrumbJsonLd, buildEventJsonLd, buildMeta } from "~/lib/seo";
import { isStrapiError, requireEntity } from "~/lib/strapi";
import { verifyTurnstile } from "~/lib/turnstile.server";
import type { Route } from "./+types/events-calendar.$slug";
import { getRouteCopy } from "./_events-opportunities/copy";
@@ -161,6 +163,8 @@ type ActionResult =
readonly values: RegistrationValues;
/** True when validation passed but the CMS refused the write. */
readonly failed: boolean;
/** True when validation passed but the Turnstile check did not. */
readonly verificationFailed: boolean;
};
export async function action({ params, request }: Route.ActionArgs) {
@@ -185,11 +189,30 @@ export async function action({ params, request }: Route.ActionArgs) {
if (!valid) {
return data(
{ ok: false, errors, values, failed: false } satisfies ActionResult,
{
ok: false,
errors,
values,
failed: false,
verificationFailed: false,
} satisfies ActionResult,
{ status: 400 },
);
}
if (!(await verifyTurnstile(form))) {
return data(
{
ok: false,
errors: {},
values,
failed: false,
verificationFailed: true,
} satisfies ActionResult,
{ status: 403 },
);
}
let registrationState: "confirmed" | "waitlisted";
try {
/* Only the fields the CMS whitelist accepts are sent - see
@@ -215,7 +238,13 @@ export async function action({ params, request }: Route.ActionArgs) {
`[events] registration for "${slug}" was rejected: ${error.status} ${error.message}`,
);
return data(
{ ok: false, errors: {}, values, failed: true } satisfies ActionResult,
{
ok: false,
errors: {},
values,
failed: true,
verificationFailed: false,
} satisfies ActionResult,
{ status: 502 },
);
}
@@ -630,8 +659,12 @@ function RegistrationSection({
</Notice>
) : null}
{/* A plain POST to this same URL: no fetcher, no client validation, and
therefore nothing that stops working with scripting off. */}
{result && !result.ok && result.verificationFailed ? (
<Notice tone="danger" role="alert" title={t("form.errorVerification")} />
) : null}
{/* A plain POST to this same URL: no fetcher and no client validation.
Only the Turnstile check, when configured, needs scripting. */}
<Form method="post" action={detailPath} className="flex flex-col gap-md">
<div className="grid gap-md sm:grid-cols-2">
<Input
@@ -693,6 +726,12 @@ function RegistrationSection({
/>
</div>
<Turnstile
action="event-registration"
locale={locale}
resetKey={result}
/>
<div className="flex flex-wrap items-center gap-sm">
<Button
type="submit"
+23 -12
View File
@@ -141,10 +141,9 @@ const PARTNER_PAGE_SIZE = 36;
/**
* Below this many partners the wall stays a grid.
*
* The marquee loops by carrying the tiles twice and sliding exactly half the
* track, which only reads as a loop while the half is wider than the frame it
* slides through. Eight 178px tiles clear a desktop container; fewer would
* drift a short row across an empty band, so the grid keeps them still.
* The loop slides exactly half the track; fewer than eight wider tiles would
* leave a short drift instead of filling a desktop frame, so the grid keeps
* them still.
*/
const PARTNER_MARQUEE_MIN = 8;
/** The draft's newsroom band is a straight three-up row. */
@@ -487,10 +486,21 @@ function PartnerTile({
src={partner.logo.src}
alt={decorative ? "" : partner.name}
loading="lazy"
className="max-h-[3.5rem] w-auto max-w-full object-contain"
className={cx(
"w-auto max-w-full object-contain",
fixedWidth
? "h-[6.75rem] transition-transform duration-[var(--motion-duration-base)] group-hover:scale-105"
: "max-h-[3.5rem]",
)}
/>
) : (
<span className="body-sm-bold text-center text-[var(--text-secondary)]">
<span
className={cx(
"body-sm-bold text-center text-[var(--text-secondary)]",
fixedWidth &&
"transition-colors duration-[var(--motion-duration-base)] group-hover:text-[var(--text-accent)]",
)}
>
{partner.name}
</span>
);
@@ -499,17 +509,16 @@ function PartnerTile({
<li
{...(decorative ? { "aria-hidden": true } : {})}
className={cx(
"flex h-[5.5rem] items-center justify-center",
"rounded-control border border-[var(--border-subtle)] bg-[var(--surface-card)] p-md",
"transition-colors duration-[var(--motion-duration-base)]",
"hover:border-[var(--border-accent)]",
"flex items-center justify-center",
fixedWidth
? "group me-lg h-[7.5rem] w-[12.5rem] shrink-0 ps-lg pe-lg"
: "h-[5.5rem] rounded-control border border-[var(--border-subtle)] bg-[var(--surface-card)] p-md transition-colors duration-[var(--motion-duration-base)] hover:border-[var(--border-accent)] sh-reveal sh-reveal--in",
/* A still grid reveals tile by tile; a travelling track must not, or
each tile would ramp its own opacity while it moves.
The track's spacing is a margin on the tile rather than a `gap` on
the row: the loop slides exactly half the track, so each half has to
carry its own trailing space or the seam lands half a gap short. */
fixedWidth ? "me-md w-[11.125rem] shrink-0" : "sh-reveal sh-reveal--in",
decorative && "sh-marquee__clone",
)}
>
@@ -647,7 +656,8 @@ reveal
className="bg-[var(--surface-sunken)]"
>
<div className="flex flex-col gap-2xl">
<div className="sh-reveal flex flex-col gap-md border-b border-[var(--border-default)] pb-lg md:max-w-[52rem]">
<div className="border-b border-[var(--border-default)]">
<div className="sh-reveal flex flex-col gap-md pb-lg md:max-w-[52rem]">
<p className="label-md uppercase text-[var(--text-accent)]">
{legacyCopy.homeEyebrow}
</p>
@@ -658,6 +668,7 @@ reveal
size="display"
/>
</div>
</div>
<div className="grid grid-cols-1 gap-lg lg:grid-cols-2">
{[
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 31 KiB

+3
View File
@@ -129,6 +129,9 @@ services:
STRAPI_TOKEN: ${STRAPI_TOKEN:-}
# Canonical URLs, hreflang alternates and JSON-LD are built from this.
SITE_URL: ${SITE_URL:-http://localhost:3000}
# Cloudflare Turnstile on the public forms - off unless both are set.
TURNSTILE_SITE_KEY: ${TURNSTILE_SITE_KEY:-}
TURNSTILE_SECRET_KEY: ${TURNSTILE_SECRET_KEY:-}
volumes:
# Panton and the Arabic faces are licensed and deliberately excluded from
# the image (.dockerignore). Mounting the host folder read-only lets a