27 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
teledrisi 1c0912378a partner review posts and connection 2026-10-06 15:36:11 +02:00
teledrisi 98fae066bc added seeder for legacy apge and added the legacy page 2026-10-05 17:32:06 +02:00
Illusion a5c531be00 feat(events): added "Add to Google Calendar" button; and clarify .ics download 2026-10-02 12:49:11 +02:00
Illusion 5310272715 feat(events): add calendar email support
Help registrants add events to their calendars and keep waitlisted places distinct from confirmed attendance.
2026-09-27 15:57:43 +02:00
Illusion 8e502b4a01 feat: google analytics 2026-09-27 12:18:31 +02:00
Illusion 60dd92e4b0 feat: email templates in CMS 2026-09-24 14:20:24 +02:00
Illusion fb097d22d9 fix: remove event gallery 2026-09-24 12:12:05 +02:00
Illusion eba419bfc2 fix: FIFO queue for waitlisting; send email for application form; EU logo in contact page in light mode 2026-09-24 11:51:24 +02:00
Illusion e5bce55bf7 feat: event registration emails and reminders (reminders untested) 2026-09-24 00:55:47 +02:00
Illusion 1879a206ad feat: homepage animations; Arabic as default locale 2026-09-24 00:22:40 +02:00
Illusion 2ff5cf47cf more changes
- Restore the SHARP-ER in the homepage hero section
- Match the design for the 3-image section in the homepage
2026-09-23 23:36:36 +02:00
Illusion cc2b426119 more style changes
- Removed the sheen over the homepage hero image
- Animated the SHARP-ER logo in the navbar
- More padding on the bottom of the hero section in the homepage
2026-09-23 23:22:57 +02:00
107 changed files with 6448 additions and 632 deletions
+9
View File
@@ -84,6 +84,10 @@ CMS_PORT=1337
# in production (e.g. https://cms.sharper.ly).
CMS_PUBLIC_URL=http://localhost:1337
# Optional comma-separated recipients for new partner draft notifications.
# When empty, notifications go to all active Strapi Super Admin accounts.
PARTNER_REVIEW_NOTIFICATION_EMAILS=
# ---------------------------------------------------------------------------
# WEB APP - Docker mode. In manual mode these live in apps/web/.env.
@@ -105,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 -1
View File
@@ -25,7 +25,7 @@ apps/web React Router 8 (framework mode, ssr: true) · React 19 · Vite 8 · T
Route ids: root · locale · home · about · whatWeDo · activities · events ·
applications · news · contact · resources · knowledgeHub (+ *Detail)
apps/cms Strapi 5.53 (TypeScript) · PostgreSQL 16 · i18n (en default, ar) · draft & publish
apps/cms Strapi 5.53 (TypeScript) · PostgreSQL 16 · i18n (ar default, en) · draft & publish
src/api/** content types (schema.json is the contract, commit it)
src/bootstrap/** runs on every boot: creates locales, grants Public permissions
(find/findOne on content; create-only on submission types)
+48 -3
View File
@@ -62,7 +62,7 @@ sharp-er/
│ ├── web/ React Router 8 front end (SSR)
│ │ ├── app/
│ │ │ ├── root.tsx HTML shell: <html lang/dir>, canonical + hreflang, nav, footer
│ │ │ ├── routes.ts Route config: "/" redirect, ":lang" layout, the ten sections
│ │ │ ├── routes.ts Route config: "/" redirect, ":lang" layout, the eleven sections
│ │ │ ├── routes/ One module per page (+ shared _page-kit / _shared helpers)
│ │ │ ├── components/ui/ Button, Input, Card, Navigation, Footer, LanguageSwitcher
│ │ │ ├── styles/ Design tokens — primitives.css, semantic.css, typography.css
@@ -258,7 +258,7 @@ The first `pnpm dev:cms` takes a minute: Strapi creates its tables, then the boo
reconciliation in `apps/cms/src/bootstrap/` runs. That step is idempotent and executes on
every boot; it
- creates the `en` (default) and `ar` locales if they are missing, and
- creates the `ar` (default) and `en` locales if they are missing, and
- grants the **Public** role `find` / `findOne` on the content types the site reads, and
`create` — and only `create` — on the three submission types (event registrations,
applications, contact messages), so nobody can read other people's personal data back out.
@@ -266,6 +266,35 @@ every boot; it
You never have to click through the Users & Permissions screens after adding a content type;
restart the CMS and the grant is there.
### Partner content review
Partner contributors use Strapi's **Author** admin role. To onboard one, create the admin
account, then as Super Admin create a **Partner Access** entry in Content Manager with the
admin user ID (from the user detail URL) and the linked Partner organisation. Each admin
account has one assignment; multiple accounts can be assigned to the same partner. Partner
Access has no public Content API and is reserved for Super Admins.
Authors can create and edit their own resource, news article, activity, event, or opportunity
drafts. The CMS enforces their partner assignment on every save, regardless of what the
browser submits, and removes the organisation selector from their edit fields. Authors cannot
publish or delete. An **Editor** reviews the draft in the side panel below Preview. Set its
status to **Approved for publishing** before publishing, or choose **Changes requested** or
**Rejected** and add feedback. The Publish action stays disabled until approval is saved, and
the server rejects publish requests for these content types unless their review status is approved.
The partner author can read the status and feedback in the CMS; both fields are excluded from the public API.
Resubmitting resets the status to **Pending** and clears old feedback.
The CMS also emails reviewers when a partner draft is first submitted or resubmitted, and emails
the submitting admin when the reviewer requests changes, rejects the draft, or publishes it.
Reviewer mail goes to all active Super Admins by default; set the optional comma-separated
`PARTNER_REVIEW_NOTIFICATION_EMAILS` to use a dedicated inbox. These notices use the configured
SMTP provider. Email failures are logged and do not undo a saved draft or review decision.
Strapi's built-in Author role limits authors to entries they created. Different users assigned
to the same partner therefore share the organisation assignment, but not each other's drafts.
Use Editor for central review; partner-wide collaboration would need a separate custom access
policy.
### 6. Create the first admin user
Strapi has no seeded administrator. On first run, open
@@ -470,6 +499,7 @@ docker run --rm -v sharper_cms-uploads:/data -v "$PWD":/backup alpine \
| `pnpm build` | builds the CMS, then the web app (that order: the web build has no CMS dependency, but a broken CMS build should fail first) |
| `pnpm start` | runs both production builds |
| `pnpm seed` | writes the approved content into the CMS — see [Seeding content](#seeding-content) |
| `pnpm seed -- --legacy-only` | writes SHARP I, SHARP II, and the home section below the hero through the bilingual Legacy page; preserves its uploaded media |
| `pnpm typecheck` | `tsc` in both applications, preceded in the web app by `react-router typegen` |
**`apps/web`** — run with `pnpm --filter web <script>`:
@@ -512,6 +542,7 @@ and never opens a port of its own.
```bash
pnpm seed # the approved content
pnpm seed -- --legacy-only # SHARP I/II and home Legacy section only
pnpm seed -- --demo # the same, plus fixtures to look at
pnpm seed -- --demo --clean # remove those fixtures again
```
@@ -532,8 +563,16 @@ settings, the three consortium members, the topic taxonomy, the five activity
streams, the launch article, and the About, What We Do, Contact and Home
pages.
The Legacy single type also holds the SHARP I and SHARP II records and the
Legacy section shown below the home hero. Its programme facts and figures come
from `SHARP I Programme SUMMARY.docx` and `SHARP II Programme SUMMARY.docx`;
the English text is a concise paraphrase and the Arabic text is a translation.
Run `pnpm seed -- --legacy-only` to update only that single type. The seed
preserves the hero and up to five gallery images selected in each locale.
What `docs/` does not supply is not written. There are no statistics anywhere
(the source marks every number "TBD — must not be published until confirmed"),
(outside the sourced Legacy page; the `docs/` source marks other numbers
"TBD — must not be published until confirmed"),
no project email, no Facebook URL and no consortium addresses, because the
source writes all of those as bracketed placeholders. Two fields are judgement
calls and say so at the line: every activity's `activityStatus`, which the
@@ -890,12 +929,18 @@ 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 |
| `CORS_ORIGINS` | cms | `https://sharper.ly,https://www.sharper.ly` — an empty list blocks every cross-origin browser call, which is the safe default |
| `DATABASE_*` | cms | managed PostgreSQL; set `DATABASE_SSL=true` for a hosted database |
| `APP_KEYS` and the other five secrets | cms | **different values from staging**, generated with `./scripts/generate-secrets.sh` |
For per-visitor submission limits, the edge proxy must overwrite `X-Forwarded-For` with
the verified client address (not append to a value supplied by the client). The web server
relays that header to Strapi, which trusts it only when `IS_PROXIED=true`.
Uploaded media is written to `apps/cms/public/uploads` by the default provider, so give the
CMS a persistent volume — or switch to S3, which `apps/cms/config/plugins.ts` documents step
by step (remember to add the bucket host to `img-src` / `media-src` in
+14 -1
View File
@@ -19,13 +19,15 @@ PORT=1337
# Public URL of the API, used to build absolute media/admin URLs.
# Leave commented out in local development; set it behind a proxy or a custom
# domain, e.g. PUBLIC_URL=https://cms.sharper.ly
# PUBLIC_URL=
PUBLIC_URL=https://cms.sharper.ly
# true when Strapi sits behind nginx / a load balancer, so X-Forwarded-* headers
# are trusted for the client IP and protocol.
IS_PROXIED=false
# Log verbosity: fatal | error | warn | info | http | debug | trace
LOG_LEVEL=info
# Enable the Strapi cron runner (config/server.ts -> cron.enabled).
# This also turns on the hourly event-reminder e-mails (see src/lib/event-mail).
# Needs SMTP below to be real - with no provider configured the job logs errors.
CRON_ENABLED=false
# Include relations in webhook payloads. Off by default: heavier payloads.
WEBHOOKS_POPULATE_RELATIONS=false
@@ -147,8 +149,19 @@ SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USERNAME=your-username
SMTP_PASSWORD=your-password
# Optional comma-separated reviewer recipients. If empty, partner draft
# notifications are sent to all active Strapi Super Admin accounts.
PARTNER_REVIEW_NOTIFICATION_EMAILS=
DEFAULT_FROM=no-reply@yourdomain.com
DEFAULT_REPLY_TO=support@yourdomain.com
# The values above are also the from/reply-to for the registration
# confirmation, event-reminder and application-acknowledgement e-mails
# (src/lib/event-mail.ts, src/lib/application-mail.ts).
#
# 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
+4 -4
View File
@@ -37,7 +37,7 @@ const deniedTypes = [
/**
* The locales the site ships with.
*
* `en` is the default (LTR) and `ar` is the right-to-left translation. The web
* `ar` is the default (RTL) and `en` is the left-to-right translation. The web
* app mirrors these codes in its URL prefixes (`/en/...`, `/ar/...`), so the
* codes here are part of the public contract - do not rename them.
*
@@ -47,8 +47,8 @@ const deniedTypes = [
* here plus a restart.
*/
const locales = [
{ code: 'en', name: 'English (en)', isDefault: true },
{ code: 'ar', name: 'Arabic (ar)' },
{ code: 'ar', name: 'Arabic (ar)', isDefault: true },
{ code: 'en', name: 'English (en)' },
];
const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Plugin => ({
@@ -59,7 +59,7 @@ const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Plugin =>
i18n: {
enabled: true,
config: {
defaultLocale: 'en',
defaultLocale: 'ar',
locales,
},
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

+252
View File
@@ -0,0 +1,252 @@
import * as React from 'react';
import { Badge, Box, Button, Flex, Textarea, Typography } from '@strapi/design-system';
import {
unstable_useContentManagerContext as useContentManagerContext,
unstable_useDocumentActions as useDocumentActions,
type StrapiApp,
useAuth,
} from '@strapi/strapi/admin';
import { useLocation } from 'react-router-dom';
const PARTNER_CONTENT_TYPES = new Set([
'api::resource.resource',
'api::news-article.news-article',
'api::activity.activity',
'api::event.event',
'api::opportunity.opportunity',
]);
type ReviewValues = {
reviewStatus?: string;
reviewFeedback?: string;
};
type ContentFormValues = ReviewValues & Record<string, unknown>;
type ReviewForm = {
values?: ContentFormValues;
initialValues?: ContentFormValues;
modified?: boolean;
isSubmitting?: boolean;
resetForm?: (newInitialValues?: ContentFormValues) => void;
onChange?: (event: { target: { name: string; value: string; type: string } }) => void;
};
const STATUS_PRESENTATION: Record<string, { label: string; variant: 'warning' | 'success' | 'danger' }> = {
pending: { label: 'Pending review', variant: 'warning' },
approved: { label: 'Approved', variant: 'success' },
'changes-requested': { label: 'Changes requested', variant: 'warning' },
rejected: { label: 'Rejected', variant: 'danger' },
};
const ReviewPanelContent = ({ model }: { model: string }) => {
const { form: rawForm, isCreatingEntry, id, collectionType } = useContentManagerContext();
const { update, isLoading: isSavingReview } = useDocumentActions();
const location = useLocation();
const canReview = useAuth('PartnerReviewPanel', ({ user }) =>
user?.roles?.some(({ code }) => code === 'strapi-editor' || code === 'strapi-super-admin') ?? false
);
const form = rawForm as ReviewForm;
if (!PARTNER_CONTENT_TYPES.has(model)) return null;
const values = form.values ?? {};
const status = STATUS_PRESENTATION[values.reviewStatus ?? ''] ? values.reviewStatus! : 'pending';
const statusPresentation = STATUS_PRESENTATION[status];
const feedback = values.reviewFeedback ?? '';
const initialValues = form.initialValues ?? {};
const withoutReviewFields = (formValues: ContentFormValues) =>
Object.fromEntries(
Object.keys(formValues)
.filter((field) => field !== 'reviewStatus' && field !== 'reviewFeedback')
.sort()
.map((field) => [field, formValues[field]])
);
const savedContentValues = withoutReviewFields(initialValues);
const currentContentValues = withoutReviewFields(values);
const hasUnsavedContent = JSON.stringify(savedContentValues) !== JSON.stringify(currentContentValues);
const [isApproving, setIsApproving] = React.useState(false);
const onChange = (name: string, value: string, type: string) =>
form.onChange?.({ target: { name, value, type } });
const approveAndSave = async () => {
if (!id || hasUnsavedContent || isApproving || isSavingReview) return;
setIsApproving(true);
try {
const locale = new URLSearchParams(location.search).get('plugins[i18n][locale]');
const result = await update(
{
collectionType,
model,
documentId: id,
...(locale ? { params: { locale } } : {}),
},
{ reviewStatus: 'approved', reviewFeedback: feedback }
);
if (!result || 'error' in result) return;
form.resetForm?.({
...values,
reviewStatus: 'approved',
reviewFeedback: feedback,
});
} finally {
setIsApproving(false);
}
};
const reviewChangesPending =
status !== (form.initialValues?.reviewStatus ?? 'pending') ||
feedback !== (form.initialValues?.reviewFeedback ?? '');
return (
<Box
background="neutral0"
borderColor="neutral200"
borderStyle="solid"
borderWidth="1px"
hasRadius
padding={4}
width="100%"
>
<Flex direction="column" alignItems="stretch" gap={4}>
<Flex alignItems="center" justifyContent="space-between" gap={2}>
<Typography variant="delta" fontWeight="semiBold" textColor="neutral800">
Partner submission
</Typography>
<Badge variant={statusPresentation.variant}>{statusPresentation.label}</Badge>
</Flex>
<Typography variant="pi" textColor="neutral600" lineHeight={1.5}>
Review the partner’s draft, leave a note if needed, then choose an outcome below.
</Typography>
<Flex direction="column" alignItems="stretch" gap={2}>
<Typography variant="pi" fontWeight="semiBold" textColor="neutral800" tag="label" htmlFor="partner-review-feedback">
Reviewer note
</Typography>
<Textarea
id="partner-review-feedback"
aria-label="Reviewer note"
value={feedback}
disabled={!canReview || isCreatingEntry}
onChange={(event) => onChange('reviewFeedback', event.currentTarget.value, 'textarea')}
rows={4}
placeholder="Share feedback with the partner. A note is required when requesting changes or rejecting."
resize="vertical"
/>
</Flex>
<Flex direction="column" alignItems="stretch" gap={2}>
<Button
fullWidth
size="M"
variant="success"
type="button"
loading={isApproving || isSavingReview}
disabled={!canReview || isCreatingEntry || !id || hasUnsavedContent || form.isSubmitting || isSavingReview}
onClick={() => void approveAndSave().catch(() => undefined)}
>
Approve for publishing
</Button>
<Button
fullWidth
size="M"
variant="secondary"
type="button"
disabled={!canReview || isCreatingEntry || form.isSubmitting || feedback.trim().length === 0}
onClick={() => onChange('reviewStatus', 'changes-requested', 'select')}
>
Request changes
</Button>
<Button
fullWidth
size="S"
variant="danger-light"
type="button"
disabled={!canReview || isCreatingEntry || form.isSubmitting || feedback.trim().length === 0}
onClick={() => onChange('reviewStatus', 'rejected', 'select')}
>
Reject submission
</Button>
</Flex>
{isCreatingEntry ? (
<Typography variant="pi" textColor="neutral600">
Save this entry before reviewing it.
</Typography>
) : !canReview ? (
<Typography variant="pi" textColor="neutral600">
An editor will review this submission.
</Typography>
) : reviewChangesPending || hasUnsavedContent ? (
<Box
background="warning100"
borderRadius="4px"
padding={3}
role="status"
>
<Typography variant="pi" textColor="warning700">
{hasUnsavedContent
? 'Save content edits before approving so the review applies to the saved version.'
: status === 'approved'
? 'Approval is saved immediately and unlocks publishing.'
: 'Save this entry to send your review decision. Publishing stays locked until approval is saved.'}
</Typography>
</Box>
) : null}
</Flex>
</Box>
);
};
const PartnerReviewSidePanel = ({ model }: { model: string }) => ({
title: 'Partner review',
content: <ReviewPanelContent model={model} />,
});
type ActionProps = { model: string; [key: string]: unknown };
type ActionDescription = { disabled?: boolean; [key: string]: unknown } | null | undefined;
type DocumentAction = ((props: ActionProps) => ActionDescription) & {
type?: string;
position?: string | string[];
};
type ContentManagerApis = {
addEditViewSidePanel: (panels: Array<(props: { model: string }) => { title: string; content: React.ReactNode }>) => void;
addDocumentAction: (reducer: (actions: DocumentAction[]) => DocumentAction[]) => void;
};
export default {
bootstrap(app: StrapiApp) {
const contentManager = app.getPlugin('content-manager');
const apis = contentManager.apis as unknown as ContentManagerApis;
apis.addEditViewSidePanel((panels) => [...panels, PartnerReviewSidePanel]);
apis.addDocumentAction((actions) =>
actions.map((action) => {
if (action.type !== 'publish') return action;
const ApprovalGatedPublish: DocumentAction = (props) => {
const { form: rawForm } = useContentManagerContext();
const form = rawForm as ReviewForm;
const base = action(props);
if (base == null) return base;
const requiresApproval = PARTNER_CONTENT_TYPES.has(props.model);
const approvalSaved = form.initialValues?.reviewStatus === 'approved';
const approved = form.values?.reviewStatus === 'approved';
const hasUnsavedReview = approved && !approvalSaved;
return {
...base,
disabled:
Boolean(base.disabled) ||
(requiresApproval && (!approved || hasUnsavedReview || form.modified === true)),
};
};
ApprovalGatedPublish.type = action.type;
ApprovalGatedPublish.position = action.position;
return ApprovalGatedPublish;
})
);
},
};
@@ -93,6 +93,35 @@
"relation": "oneToMany",
"target": "api::partner.partner"
},
"reviewFeedback": {
"type": "text",
"private": true,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"reviewStatus": {
"type": "enumeration",
"enum": ["pending", "approved", "changes-requested", "rejected"],
"default": "pending",
"private": true,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"submittedByAdminUserId": {
"type": "integer",
"private": true,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"regions": {
"type": "relation",
"relation": "oneToMany",
@@ -13,7 +13,7 @@
"attributes": {
"opportunity": {
"type": "relation",
"relation": "oneToOne",
"relation": "manyToOne",
"target": "api::opportunity.opportunity"
},
"applicantName": {
@@ -1,7 +1,68 @@
/**
* application service
*
* `create` is overridden to fire the acknowledgement e-mail *after* the row is
* written, mirroring the event-registration service. The send is deliberately
* not awaited: a slow or failing SMTP call must never turn the application
* POST into a 502. The mail helper stamps `acknowledgementSentAt` only when
* the provider accepted the mail, and swallows and logs its own errors.
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::application.application');
import { sendApplicationAcknowledgement } from '../../../lib/application-mail';
const APPLICATIONS_UID = 'api::application.application';
const LOG_PREFIX = '[application]';
/**
* The `opportunity` value a client may have sent is a documentId (what the
* REST API uses for relations), or a legacy numeric id. The Get Involved form
* sends none at all - an application is not always filed against a call.
*/
const readOpportunityReference = (value: unknown): { documentId?: string; id?: number } | undefined => {
if (typeof value === 'string' && value.length > 0) {
return { documentId: value };
}
if (typeof value === 'number' && Number.isInteger(value)) {
return { id: value };
}
return undefined;
};
const readCreatedResult = (result: unknown): { documentId?: unknown; email?: unknown } =>
(typeof result === 'object' && result !== null ? result : {}) as {
documentId?: unknown;
email?: unknown;
};
export default factories.createCoreService(APPLICATIONS_UID, ({ strapi }) => ({
async create(params: { data?: { opportunity?: unknown } } & Record<string, unknown>) {
const result = await super.create(params);
const { documentId, email } = readCreatedResult(result);
if (typeof documentId === 'string' && documentId.length > 0) {
if (typeof email === 'string' && email.length > 0) {
void sendApplicationAcknowledgement({
strapi,
to: email,
applicationDocumentId: documentId,
opportunityReference: readOpportunityReference(params.data?.opportunity),
});
} else {
strapi.log.warn(
`${LOG_PREFIX} no email on the created application; acknowledgement skipped.`,
);
}
} else {
strapi.log.warn(
`${LOG_PREFIX} no document id on the created application; acknowledgement skipped.`,
);
}
return result;
},
}));
@@ -13,7 +13,7 @@
"attributes": {
"event": {
"type": "relation",
"relation": "oneToOne",
"relation": "manyToOne",
"target": "api::event.event"
},
"fullName": {
@@ -21,15 +21,15 @@
* `curl`, and the count it depends on - how many places are already taken - is
* not something a client can be trusted to report.
*
* The count is read and the row is written without a transaction, so two
* registrations arriving in the same instant can both see the last free place.
* Overbooking by the number of concurrent requests is the accepted trade-off
* for a public form; staff see the overflow in the admin panel.
* A transaction locks the event row while the confirmed count and registration
* write run, so concurrent requests cannot claim the same final place.
*/
import type { Core } from '@strapi/strapi';
import { errors } from '@strapi/utils';
import { sendRegistrationConfirmation } from '../../../lib/event-mail';
const CLIENT_FIELDS = [
'event',
'fullName',
@@ -50,6 +50,7 @@ type EventCapacity = {
capacity: number | null;
waitlistEnabled: boolean | null;
registrationStatus: string | null;
registrationDeadline: string | Date | null;
};
/**
@@ -89,9 +90,9 @@ const findEvent = async (
? { documentId: reference.documentId }
: { id: reference.id };
return (await strapi.db.query(EVENT_UID).findOne({
where,
select: ['documentId', 'capacity', 'waitlistEnabled', 'registrationStatus'],
return (await strapi.documents(EVENT_UID).findFirst({
filters: where,
status: 'published',
})) as EventCapacity | null;
};
@@ -121,13 +122,22 @@ const resolveRegistrationState = async (
const event = await findEvent(strapi, readEventReference(eventValue));
if (!event) {
// Either no event was given or the id does not resolve. There is no
// capacity to weigh, and nothing to confirm a place on.
return 'waitlisted';
throw new errors.ApplicationError('A valid published event is required.');
}
if (event.registrationStatus === 'closed') {
throw new errors.ApplicationError('Registration for this event is closed.');
if (
event.registrationStatus !== 'open' &&
event.registrationStatus !== 'full' &&
event.registrationStatus !== 'waitlist'
) {
throw new errors.ApplicationError('Registration for this event is not open.');
}
if (event.registrationDeadline) {
const deadline = new Date(event.registrationDeadline).getTime();
if (!Number.isFinite(deadline) || Date.now() >= deadline) {
throw new errors.ApplicationError('The registration deadline has passed.');
}
}
const capacity =
@@ -135,7 +145,9 @@ const resolveRegistrationState = async (
const taken = capacity === null ? 0 : await countConfirmed(strapi, event.documentId);
const isFull =
event.registrationStatus === 'full' || (capacity !== null && taken >= capacity);
event.registrationStatus === 'full' ||
event.registrationStatus === 'waitlist' ||
(capacity !== null && taken >= capacity);
if (!isFull) {
return 'confirmed';
@@ -168,13 +180,64 @@ const sanitizeSubmission: Core.MiddlewareFactory =
}
}
data.registrationState = await resolveRegistrationState(strapi, data.event);
await strapi.db.transaction(async ({ trx }) => {
const event = await findEvent(strapi, readEventReference(data.event));
if (!event) {
throw new errors.ApplicationError('A valid published event is required.');
}
await trx('events')
.select('id')
.where({ document_id: event.documentId })
.orderBy('id')
.forUpdate()
.first();
data.registrationState = await resolveRegistrationState(strapi, event.documentId);
data.submittedAt = new Date().toISOString();
data.reminderSentAt = null;
request.body = { data };
await next();
});
// Send the confirmation only after the transaction above has committed:
// 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
// slow SMTP call can never turn the registration POST into a 502.
if (typeof data.email === 'string' && data.email.length > 0) {
const response = ctx.body as
| {
data?: { documentId?: unknown; id?: unknown };
documentId?: unknown;
id?: unknown;
}
| undefined;
const created = response?.data ?? response;
const reservationId =
typeof created?.documentId === 'string'
? created.documentId
: typeof created?.id === 'number'
? String(created.id)
: undefined;
void sendRegistrationConfirmation({
strapi,
to: data.email,
state: data.registrationState === 'waitlisted' ? 'waitlisted' : 'confirmed',
eventReference: readEventReference(data.event),
reservation: {
id: reservationId,
name: typeof data.fullName === 'string' ? data.fullName : undefined,
},
});
} else {
strapi.log.warn(
'no email on the registration request; confirmation e-mail skipped.',
);
}
};
export default sanitizeSubmission;
@@ -1,5 +1,13 @@
/**
* event-registration service
*
* Plain default `create` service. The registration row is written inside the
* 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 and template)
* to a transaction that is about to be committed, which knex
* rejects with "Transaction query already complete".
*/
import { factories } from '@strapi/strapi';
@@ -143,6 +143,35 @@
"relation": "oneToMany",
"target": "api::partner.partner"
},
"reviewFeedback": {
"type": "text",
"private": true,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"reviewStatus": {
"type": "enumeration",
"enum": ["pending", "approved", "changes-requested", "rejected"],
"default": "pending",
"private": true,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"submittedByAdminUserId": {
"type": "integer",
"private": true,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"registrationStatus": {
"type": "enumeration",
"enum": [
@@ -199,19 +228,6 @@
}
}
},
"gallery": {
"type": "media",
"multiple": true,
"required": false,
"allowedTypes": [
"images"
],
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"recapUrl": {
"type": "string",
"pluginOptions": {
@@ -0,0 +1,49 @@
{
"kind": "singleType",
"collectionName": "legacy_pages",
"info": {
"singularName": "legacy-page",
"pluralName": "legacy-pages",
"displayName": "Legacy Page",
"description": "SHARP I and SHARP II programme history, achievements, metrics, and featured images."
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {
"i18n": {
"localized": true
}
},
"attributes": {
"homeEyebrow": { "type": "string", "pluginOptions": { "i18n": { "localized": true } } },
"homeHeading": { "type": "string", "pluginOptions": { "i18n": { "localized": true } } },
"homeLead": { "type": "text", "pluginOptions": { "i18n": { "localized": true } } },
"hero": {
"type": "component",
"repeatable": false,
"component": "shared.hero",
"pluginOptions": { "i18n": { "localized": true } }
},
"stageOne": {
"type": "component",
"repeatable": false,
"component": "page.legacy-stage",
"pluginOptions": { "i18n": { "localized": true } }
},
"stageTwo": {
"type": "component",
"repeatable": false,
"component": "page.legacy-stage",
"pluginOptions": { "i18n": { "localized": true } }
},
"closingHeading": { "type": "string", "pluginOptions": { "i18n": { "localized": true } } },
"closingBody": { "type": "richtext", "pluginOptions": { "i18n": { "localized": true } } },
"seo": {
"type": "component",
"repeatable": false,
"component": "shared.seo",
"pluginOptions": { "i18n": { "localized": true } }
}
}
}
@@ -0,0 +1,7 @@
/**
* legacy-page controller
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::legacy-page.legacy-page');
@@ -0,0 +1,7 @@
/**
* legacy-page router
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::legacy-page.legacy-page');
@@ -0,0 +1,7 @@
/**
* legacy-page service
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::legacy-page.legacy-page');
@@ -90,6 +90,35 @@
"relation": "oneToOne",
"target": "api::partner.partner"
},
"reviewFeedback": {
"type": "text",
"private": true,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"reviewStatus": {
"type": "enumeration",
"enum": ["pending", "approved", "changes-requested", "rejected"],
"default": "pending",
"private": true,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"submittedByAdminUserId": {
"type": "integer",
"private": true,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"publishDate": {
"type": "date",
"required": true,
@@ -149,6 +149,35 @@
"relation": "oneToMany",
"target": "api::partner.partner"
},
"reviewFeedback": {
"type": "text",
"private": true,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"reviewStatus": {
"type": "enumeration",
"enum": ["pending", "approved", "changes-requested", "rejected"],
"default": "pending",
"private": true,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"submittedByAdminUserId": {
"type": "integer",
"private": true,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"coverImage": {
"type": "media",
"multiple": false,
@@ -0,0 +1,32 @@
{
"kind": "collectionType",
"collectionName": "partner_accesses",
"info": {
"singularName": "partner-access",
"pluralName": "partner-accesses",
"displayName": "Partner Access",
"description": "Links one Strapi admin user to the partner organisation they can submit content for."
},
"options": {
"draftAndPublish": false
},
"pluginOptions": {
"i18n": {
"localized": false
}
},
"attributes": {
"adminUserId": {
"type": "integer",
"required": true,
"unique": true,
"min": 1
},
"partner": {
"type": "relation",
"relation": "manyToOne",
"target": "api::partner.partner",
"required": true
}
}
}
@@ -0,0 +1,26 @@
{
"kind": "singleType",
"collectionName": "reminder_settings",
"info": {
"singularName": "reminder-settings",
"pluralName": "reminder-settings-list",
"displayName": "Reminder Settings",
"description": "Operational settings for the event reminder emails: whether they are enabled and how long before an event starts the reminder is sent. Read by the cron job only; not exposed over the public API."
},
"options": {
"draftAndPublish": false
},
"attributes": {
"enabled": {
"type": "boolean",
"required": true,
"default": true
},
"leadHours": {
"type": "integer",
"required": true,
"default": 48,
"min": 0
}
}
}
@@ -140,6 +140,35 @@
"relation": "oneToMany",
"target": "api::partner.partner"
},
"reviewFeedback": {
"type": "text",
"private": true,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"reviewStatus": {
"type": "enumeration",
"enum": ["pending", "approved", "changes-requested", "rejected"],
"default": "pending",
"private": true,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"submittedByAdminUserId": {
"type": "integer",
"private": true,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"topics": {
"type": "relation",
"relation": "oneToMany",
+2 -2
View File
@@ -22,8 +22,8 @@ export type LocaleDefinition = {
* is that config file.
*/
export const FALLBACK_LOCALES: LocaleDefinition[] = [
{ code: 'en', name: 'English (en)', isDefault: true },
{ code: 'ar', name: 'Arabic (ar)' },
{ code: 'ar', name: 'Arabic (ar)', isDefault: true },
{ code: 'en', name: 'English (en)' },
];
/** `type` of the users-permissions role that unauthenticated requests use. */
+9
View File
@@ -4,10 +4,15 @@ import { revokePublicAuthActions } from './auth-permissions';
import { LOG_PREFIX } from './constants';
import { ensureLocales } from './locales';
import { ensurePublicPermissions } from './public-permissions';
import { ensurePartnerReviewPermissions } from './partner-review-permissions';
import { ensurePartnerReviewLayouts } from './partner-review-layouts';
import { ensurePartnerRelationLabels } from './partner-relation-labels';
import { ensureReminderSettings } from './reminder-settings';
export { revokePublicAuthActions } from './auth-permissions';
export { ensureLocales } from './locales';
export { ensurePublicPermissions } from './public-permissions';
export { ensureReminderSettings } from './reminder-settings';
/**
* Boot-time reconciliation, run from `src/index.ts`.
@@ -26,6 +31,10 @@ export const runBootstrap = async ({ strapi }: { strapi: Core.Strapi }): Promise
await ensureLocales(strapi);
await ensurePublicPermissions(strapi);
await revokePublicAuthActions(strapi);
await ensureReminderSettings(strapi);
await ensurePartnerReviewPermissions(strapi);
await ensurePartnerReviewLayouts(strapi);
await ensurePartnerRelationLabels(strapi);
strapi.log.debug(`${LOG_PREFIX} bootstrap complete.`);
};
@@ -0,0 +1,70 @@
import type { Core } from '@strapi/strapi';
import { LOG_PREFIX } from './constants';
const PARTNER_RELATIONS: Record<string, string[]> = {
'api::partner.partner': [],
'api::partner-access.partner-access': ['partner'],
'api::resource.resource': ['partnerOrganisations'],
'api::news-article.news-article': ['authorOrganisation'],
'api::activity.activity': ['implementingPartners'],
'api::event.event': ['implementingPartners'],
'api::opportunity.opportunity': ['implementingPartners'],
};
type ContentManagerConfiguration = {
settings: Record<string, unknown>;
metadatas: Record<string, { edit?: Record<string, unknown>; [key: string]: unknown }>;
[key: string]: unknown;
};
type ContentManagerContentTypesService = {
findContentType(uid: string): Record<string, unknown> | undefined;
findConfiguration(contentType: Record<string, unknown>): Promise<ContentManagerConfiguration>;
updateConfiguration(
contentType: Record<string, unknown>,
configuration: ContentManagerConfiguration
): Promise<unknown>;
};
/** Always show Partner relation values by their organisation name in Content Manager. */
export const ensurePartnerRelationLabels = async (strapi: Core.Strapi): Promise<void> => {
const manager = strapi
.plugin('content-manager')
.service<ContentManagerContentTypesService>('content-types');
for (const [uid, fields] of Object.entries(PARTNER_RELATIONS)) {
const contentType = manager.findContentType(uid);
if (!contentType) continue;
const config = await manager.findConfiguration(contentType);
const metadatas = { ...config.metadatas };
let metadataChanged = false;
for (const field of fields) {
const metadata = metadatas[field] ?? {};
if (metadata.edit?.mainField === 'name') continue;
metadatas[field] = {
...metadata,
edit: { ...metadata.edit, mainField: 'name' },
};
metadataChanged = true;
}
const settingsChanged =
uid === 'api::partner.partner' && config.settings.mainField !== 'name';
if (!metadataChanged && !settingsChanged) continue;
await manager.updateConfiguration(contentType, {
...config,
settings:
uid === 'api::partner.partner'
? { ...config.settings, mainField: 'name' }
: config.settings,
metadatas,
});
}
strapi.log.debug(`${LOG_PREFIX} partner relations use organisation names in Content Manager.`);
};
@@ -0,0 +1,68 @@
import type { Core } from '@strapi/strapi';
import { LOG_PREFIX } from './constants';
const PARTNER_REVIEW_TYPES = [
'api::resource.resource',
'api::news-article.news-article',
'api::activity.activity',
'api::event.event',
'api::opportunity.opportunity',
];
const REVIEW_FIELDS = ['reviewStatus', 'reviewFeedback', 'submittedByAdminUserId'];
type EditLayoutItem = { name: string; size?: number };
type ContentManagerConfiguration = {
uid?: string;
settings: Record<string, unknown>;
metadatas: Record<string, { edit?: Record<string, unknown>; [key: string]: unknown }>;
layouts: { edit: EditLayoutItem[][]; [key: string]: unknown };
[key: string]: unknown;
};
type ContentManagerContentTypesService = {
findContentType(uid: string): Record<string, unknown> | undefined;
findConfiguration(contentType: Record<string, unknown>): Promise<ContentManagerConfiguration>;
updateConfiguration(
contentType: Record<string, unknown>,
configuration: ContentManagerConfiguration
): Promise<unknown>;
};
/** Keep workflow fields in the custom review panel, out of the main content form. */
export const ensurePartnerReviewLayouts = async (strapi: Core.Strapi): Promise<void> => {
const manager = strapi
.plugin('content-manager')
.service<ContentManagerContentTypesService>('content-types');
for (const uid of PARTNER_REVIEW_TYPES) {
const contentType = manager.findContentType(uid);
if (!contentType) continue;
const config = await manager.findConfiguration(contentType);
const editLayout = (config.layouts.edit ?? [])
.map((row) => row.filter(({ name }) => !REVIEW_FIELDS.includes(name)))
.filter((row) => row.length > 0);
const metadatas = { ...config.metadatas };
const metadataChanged = REVIEW_FIELDS.some(
(field) => config.metadatas[field]?.edit?.visible !== false
);
for (const field of REVIEW_FIELDS) {
const metadata = metadatas[field] ?? {};
metadatas[field] = {
...metadata,
edit: { ...metadata.edit, visible: false },
};
}
if (JSON.stringify(editLayout) !== JSON.stringify(config.layouts.edit) || metadataChanged) {
await manager.updateConfiguration(contentType, {
...config,
metadatas,
layouts: { ...config.layouts, edit: editLayout },
});
}
}
strapi.log.debug(`${LOG_PREFIX} partner review fields moved into the review panel.`);
};
@@ -0,0 +1,205 @@
import type { Core } from '@strapi/strapi';
import { LOG_PREFIX } from './constants';
const REVIEW_FIELDS: Record<string, { partnerField: string; actions: string[] }> = {
'api::resource.resource': { partnerField: 'partnerOrganisations', actions: ['create', 'read', 'update'] },
'api::news-article.news-article': { partnerField: 'authorOrganisation', actions: ['create', 'read', 'update'] },
'api::activity.activity': { partnerField: 'implementingPartners', actions: ['create', 'read', 'update'] },
'api::event.event': { partnerField: 'implementingPartners', actions: ['create', 'read', 'update'] },
'api::opportunity.opportunity': { partnerField: 'implementingPartners', actions: ['create', 'read', 'update'] },
};
const REVIEWER_FIELDS = ['reviewFeedback', 'reviewStatus'];
const INTERNAL_FIELDS = ['submittedByAdminUserId'];
type Permission = {
id: number;
action: string;
subject: string;
properties?: { fields?: string[]; [key: string]: unknown };
conditions?: unknown[];
};
type AdminRole = { id: number; code: string };
type MappedAdminUser = { id: number; roles?: AdminRole[] };
const PARTNER_READ_ACTION = 'plugin::content-manager.explorer.read';
const PARTNER_WRITE_ACTIONS = [
'plugin::content-manager.explorer.create',
'plugin::content-manager.explorer.update',
'plugin::content-manager.explorer.delete',
'plugin::content-manager.explorer.publish',
];
const PARTNER_PUBLIC_FIELDS = [
'name',
'slug',
'logo',
'logoDark',
'description',
'website',
'partnerRole',
'country',
];
const getMappedPartnerRoleIds = async (strapi: Core.Strapi): Promise<Set<number>> => {
const assignments = (await strapi.db.query('api::partner-access.partner-access').findMany({
select: ['adminUserId'],
})) as Array<{ adminUserId?: number }>;
const userIds = [...new Set(assignments.map(({ adminUserId }) => adminUserId).filter(Boolean))];
if (userIds.length === 0) return new Set();
const users = (await strapi.db.query('admin::user').findMany({
where: { id: { $in: userIds } },
select: ['id'],
populate: { roles: { select: ['id', 'code'] } },
})) as MappedAdminUser[];
return new Set(users.flatMap(({ roles = [] }) => roles.map(({ id }) => id)));
};
const withFields = (
properties: Permission['properties'],
allFields: string[],
transform: (fields: string[]) => string[]
) => {
// In Strapi, an omitted field list means "all fields". Materialize that
// set before filtering so a permission with defaults cannot become empty.
const fields = Array.isArray(properties?.fields) ? properties.fields : allFields;
return { ...properties, fields: transform(fields) };
};
/** Keep partner assignment and reviewer feedback field access aligned with the workflow. */
export const ensurePartnerReviewPermissions = async (strapi: Core.Strapi): Promise<void> => {
const mappedRoleIds = await getMappedPartnerRoleIds(strapi);
const allRoles = (await strapi.db.query('admin::role').findMany({
select: ['id', 'code'],
})) as AdminRole[];
const roles = allRoles.filter(
({ id, code }) => code === 'strapi-author' || code === 'strapi-editor' || mappedRoleIds.has(id)
);
for (const role of roles as Array<{ id: number; code: string }>) {
const permissions = (await strapi.db.query('admin::permission').findMany({
where: { role: { id: role.id }, subject: { $in: Object.keys(REVIEW_FIELDS) } },
select: ['id', 'action', 'subject', 'properties'],
})) as Permission[];
for (const permission of permissions) {
const config = REVIEW_FIELDS[permission.subject];
if (!config || !config.actions.some((action) => permission.action.endsWith(`.${action}`))) continue;
const action = config.actions.find((candidate) => permission.action.endsWith(`.${candidate}`));
if (!action) continue;
const contentTypes = strapi.contentTypes as unknown as Record<
string,
{ attributes?: Record<string, unknown> }
>;
const allFields = Object.keys(contentTypes[permission.subject]?.attributes ?? {});
const isMappedPartnerAuthor =
mappedRoleIds.has(role.id) &&
role.code !== 'strapi-editor' &&
role.code !== 'strapi-super-admin';
const isPartnerAuthorRole = role.code === 'strapi-author' || isMappedPartnerAuthor;
const properties = withFields(permission.properties, allFields, (fields) => {
const filtered = fields.filter((field) => {
if (INTERNAL_FIELDS.includes(field)) return false;
if (isPartnerAuthorRole && action !== 'read') {
return field !== config.partnerField && !REVIEWER_FIELDS.includes(field);
}
return true;
});
if (role.code === 'strapi-editor' || (isPartnerAuthorRole && action === 'read')) {
for (const field of REVIEWER_FIELDS) {
if (!filtered.includes(field)) filtered.push(field);
}
}
return filtered;
});
if (JSON.stringify(permission.properties ?? {}) !== JSON.stringify(properties)) {
await strapi.db.query('admin::permission').update({
where: { id: permission.id },
data: { properties },
});
}
}
// Partner assignments are an administrative control. Only Super Admins
// should be able to create or change them through Content Manager.
if (
role.code === 'strapi-author' ||
role.code === 'strapi-editor' ||
mappedRoleIds.has(role.id)
) {
await strapi.db.query('admin::permission').deleteMany({
where: { role: { id: role.id }, subject: 'api::partner-access.partner-access' },
});
}
}
await ensureMappedPartnerReadAccess(strapi, allRoles, mappedRoleIds);
strapi.log.debug(`${LOG_PREFIX} partner review role fields reconciled.`);
};
/** Partner authors need to read the public organisation name in relations, but never edit it. */
const ensureMappedPartnerReadAccess = async (
strapi: Core.Strapi,
roles: AdminRole[],
mappedRoleIds: Set<number>
): Promise<void> => {
const protectedRoles = roles.filter(
({ id, code }) =>
mappedRoleIds.has(id) && code !== 'strapi-super-admin' && code !== 'strapi-editor'
);
if (protectedRoles.length === 0) return;
for (const role of protectedRoles) {
const permissions = (await strapi.db.query('admin::permission').findMany({
where: { role: { id: role.id }, subject: 'api::partner.partner' },
select: ['id', 'action', 'properties', 'conditions'],
})) as Permission[];
const readPermission = permissions.find(({ action }) => action === PARTNER_READ_ACTION);
const writePermissions = permissions.filter(({ action }) =>
PARTNER_WRITE_ACTIONS.includes(action)
);
if (writePermissions.length > 0) {
await strapi.db.query('admin::permission').deleteMany({
where: { id: { $in: writePermissions.map(({ id }) => id) } },
});
}
const properties = {
...(readPermission?.properties ?? {}),
fields: PARTNER_PUBLIC_FIELDS,
locales: ['en', 'ar'],
};
if (!readPermission) {
await strapi.db.query('admin::permission').create({
data: {
action: PARTNER_READ_ACTION,
actionParameters: {},
subject: 'api::partner.partner',
properties,
conditions: [],
role: role.id,
},
});
continue;
}
if (
JSON.stringify(readPermission.properties ?? {}) !== JSON.stringify(properties) ||
(readPermission.conditions?.length ?? 0) > 0
) {
await strapi.db.query('admin::permission').update({
where: { id: readPermission.id },
data: { properties, conditions: [] },
});
}
}
};
+19 -16
View File
@@ -35,11 +35,9 @@ const desiredActionsFor = (controllerName: string): string[] =>
* Grant the Public role read access to the public content (collection types and
* single types) and create access to the three submission content types.
*
* Only missing permissions are inserted, and permissions granted by hand in the
* admin panel are never removed - so this is safe to run on every boot. A read
* grant on a submission type is reported as a warning rather than deleted, so
* that an unexpected grant is visible without the boot silently undoing an
* administrator's decision.
* Missing public-content permissions are inserted. Submission types are kept
* create-only: any other public grant is revoked on boot because those entries
* contain personal data.
*/
export const ensurePublicPermissions = async (strapi: Core.Strapi): Promise<void> => {
if (!strapi.plugins['users-permissions']) {
@@ -63,7 +61,7 @@ export const ensurePublicPermissions = async (strapi: Core.Strapi): Promise<void
.getActions();
const desiredActions = new Set<string>();
const forbiddenReadActions = new Set<string>();
const forbiddenSubmissionActions = new Set<string>();
for (const [namespace, { controllers }] of Object.entries(actionMap)) {
// Only the project's own APIs; plugin permissions are managed by Strapi.
@@ -80,11 +78,8 @@ export const ensurePublicPermissions = async (strapi: Core.Strapi): Promise<void
if (wantedActions.includes(action)) {
desiredActions.add(permission);
} else if (
PUBLIC_SUBMISSION_CONTENT_TYPES.includes(controllerName) &&
PUBLIC_READ_ACTIONS.includes(action)
) {
forbiddenReadActions.add(permission);
} else if (PUBLIC_SUBMISSION_CONTENT_TYPES.includes(controllerName)) {
forbiddenSubmissionActions.add(permission);
}
}
}
@@ -94,7 +89,7 @@ export const ensurePublicPermissions = async (strapi: Core.Strapi): Promise<void
.query('plugin::users-permissions.permission')
.findMany({
where: { role: { id: publicRole.id } },
select: ['action'],
select: ['id', 'action'],
});
const existingActions = new Set(existingPermissions.map(({ action }) => action));
@@ -112,12 +107,20 @@ export const ensurePublicPermissions = async (strapi: Core.Strapi): Promise<void
);
}
const leakedActions = [...forbiddenReadActions].filter((action) => existingActions.has(action));
const forbiddenPermissions = existingPermissions.filter(({ action }) =>
forbiddenSubmissionActions.has(action)
);
if (forbiddenPermissions.length > 0) {
await strapi.db.query('plugin::users-permissions.permission').deleteMany({
where: { id: { $in: forbiddenPermissions.map(({ id }) => id) } },
});
if (leakedActions.length > 0) {
strapi.log.warn(
`${LOG_PREFIX} the Public role can read submission data: ${leakedActions.join(', ')}. ` +
'Revoke these in Settings > Roles > Public unless this is intentional.'
`${LOG_PREFIX} revoked forbidden Public submission permission(s): ${forbiddenPermissions
.map(({ action }) => action)
.sort()
.join(', ')}.`
);
}
};
@@ -0,0 +1,40 @@
import type { Core } from '@strapi/strapi';
import { LOG_PREFIX } from './constants';
const REMINDER_SETTINGS_UID = 'api::reminder-settings.reminder-settings';
/**
* The values the reminder cron job falls back to when no `reminder-settings`
* single type exists yet (first boot on an empty database). The admin panel
* owns these values from then on; this is only the bootstrap default.
*/
export const DEFAULT_REMINDER_SETTINGS = {
enabled: true,
leadHours: 48,
} as const;
/**
* Create the `reminder-settings` single type if the database does not have one
* yet, so the values the reminder cron job depends on exist and are editable in
* the admin panel after the very first boot. Never overwrites an existing row.
*/
export const ensureReminderSettings = async (strapi: Core.Strapi): Promise<void> => {
const existing = await strapi
.documents(REMINDER_SETTINGS_UID)
.findFirst({});
if (existing) {
return;
}
await strapi.documents(REMINDER_SETTINGS_UID).create({
data: { ...DEFAULT_REMINDER_SETTINGS },
});
strapi.log.info(
`${LOG_PREFIX} created reminder-settings single type with defaults ` +
`(enabled=${DEFAULT_REMINDER_SETTINGS.enabled}, ` +
`leadHours=${DEFAULT_REMINDER_SETTINGS.leadHours}).`
);
};
@@ -0,0 +1,11 @@
{
"collectionName": "components_page_legacy_focus_areas",
"info": {
"displayName": "Legacy Focus Area",
"icon": "bulletList"
},
"options": {},
"attributes": {
"name": { "type": "string", "required": true }
}
}
@@ -0,0 +1,12 @@
{
"collectionName": "components_page_legacy_metrics",
"info": {
"displayName": "Legacy Metric",
"icon": "chartBubble"
},
"options": {},
"attributes": {
"value": { "type": "string", "required": true },
"label": { "type": "string", "required": true }
}
}
@@ -0,0 +1,26 @@
{
"collectionName": "components_page_legacy_stages",
"info": {
"displayName": "Legacy Phase",
"icon": "landmark",
"description": "One SHARP programme phase with its copy, figures, areas of work, and image gallery. The website displays up to five selected images."
},
"options": {},
"attributes": {
"name": { "type": "string", "required": true },
"years": { "type": "string" },
"summary": { "type": "text" },
"description": { "type": "richtext" },
"metrics": { "type": "component", "repeatable": true, "component": "page.legacy-metric" },
"focusHeading": { "type": "string" },
"focusAreas": { "type": "component", "repeatable": true, "component": "page.legacy-focus-area" },
"galleryHeading": { "type": "string" },
"images": {
"type": "media",
"multiple": true,
"required": false,
"description": "Select up to five images for this programme. The gallery shows the first five in this order.",
"allowedTypes": ["images"]
}
}
}
+26 -1
View File
@@ -1,6 +1,10 @@
import type { Core } from '@strapi/strapi';
import { runBootstrap } from './bootstrap';
import { runEventReminders } from './lib/event-reminders';
import { registerPartnerContentAccess } from './lib/partner-content-access';
import { registerPartnerReviewNotifications } from './lib/partner-review-notifications';
import { runWaitlistPromotion } from './lib/waitlist-promotion';
export default {
/**
@@ -9,7 +13,10 @@ export default {
*
* This gives you an opportunity to extend code.
*/
register(/* { strapi }: { strapi: Core.Strapi } */) {},
register({ strapi }: { strapi: Core.Strapi }) {
registerPartnerReviewNotifications(strapi);
registerPartnerContentAccess(strapi);
},
/**
* An asynchronous bootstrap function that runs before
@@ -21,5 +28,23 @@ export default {
*/
async bootstrap({ strapi }: { strapi: Core.Strapi }) {
await runBootstrap({ strapi });
// The cron provider always runs its own `start()`, so `strapi.cron.add()`
// here schedules immediately. Respect the explicit opt-out in
// `server.cron.enabled` (env `CRON_ENABLED`): without SMTP configured the
// hourly job would just fail and log, so it stays inert by default.
if (strapi.config.get('server.cron.enabled', false)) {
strapi.cron.add({
'send-event-reminders': {
task: runEventReminders,
options: { rule: '0 * * * *', tz: 'Africa/Tripoli' },
},
'run-waitlist-promotion': {
task: runWaitlistPromotion,
options: { rule: '0 * * * *', tz: 'Africa/Tripoli' },
},
});
strapi.log.info('[index] event reminder + waitlist promotion cron jobs registered.');
}
},
};
+119
View File
@@ -0,0 +1,119 @@
/**
* application-mail — bilingual acknowledgement e-mail for applications.
*
* The web copy promises every application is "acknowledged automatically by
* email" (apps/web/app/routes/_events-opportunities/copy.ts:
* `apply.acknowledgement` / `apply.receivedBody`), and the content type models
* that with `application.acknowledgementSentAt`. This module is the half that
* delivers: fired fire-and-forget from the application service's `create`
* override, mirroring the event-registration confirmation, and stamping
* `acknowledgementSentAt` ONLY after the mail was accepted, so a failure is
* 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 uses the
* no-call acknowledgement, so the promise holds for both entry points.
*
* 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 { DEFAULT_EMAIL_TEMPLATES } from './email-template-defaults';
import { buildBilingualMail, trySendMail } from './event-mail';
const OPPORTUNITY_UID = 'api::opportunity.opportunity';
const APPLICATION_UID = 'api::application.application';
const LOG_PREFIX = '[application-mail]';
/** Identifier a public form may have sent for the opportunity relation. */
type OpportunityReference = { documentId?: string; id?: number } | undefined;
/** An opportunity looked up in both locales; missing locales are allowed. */
type BilingualOpportunity = { en?: { title?: string | null }; ar?: { title?: string | null } };
/**
* Fetch an opportunity in both locales. Only published entries are ever
* fetched; either a documentId or a legacy numeric id is accepted, mirroring
* the event-registration middleware. Returns an empty record when there is no
* usable reference (the Get Involved form sends none).
*/
export async function fetchBilingualOpportunity(
strapi: Core.Strapi,
reference: OpportunityReference,
): Promise<BilingualOpportunity> {
const filters = reference?.documentId
? { documentId: reference.documentId }
: reference?.id !== undefined
? { id: reference.id }
: null;
if (!filters) return {};
const [en, ar] = await Promise.all([
strapi.documents(OPPORTUNITY_UID).findFirst({ filters, locale: 'en' }),
strapi.documents(OPPORTUNITY_UID).findFirst({ filters, locale: 'ar' }),
]);
return { en: en ?? undefined, ar: ar ?? undefined };
}
/**
* Build and send the acknowledgement for an application, and stamp
* `acknowledgementSentAt` when the provider accepted it. Best-effort and
* never awaited by the request path: a slow or failing SMTP call must not make
* the application POST fail.
*
* The `{call}` placeholder is filled from the opportunity's title in each
* locale; an empty title collapses "for {call}" / "على {call}" out of the
* body, which is how a no-opportunity application still reads cleanly.
*/
export async function sendApplicationAcknowledgement({
strapi,
to,
applicationDocumentId,
opportunityReference,
}: {
strapi: Core.Strapi;
to: string;
applicationDocumentId: string;
opportunityReference: OpportunityReference;
}): Promise<void> {
try {
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;
const mail = buildBilingualMail({
subjectAr: template.ar.subject,
subjectEn: template.en.subject,
bodyAr: template.ar.body,
bodyEn: template.en.body,
vars: {
ar: { call: callAr ?? '' },
en: { call: callEn ?? '' },
},
});
const ok = await trySendMail(strapi, { to, ...mail });
if (!ok) {
return;
}
await strapi
.documents(APPLICATION_UID)
.update({
documentId: applicationDocumentId,
data: { acknowledgementSentAt: new Date().toISOString() },
});
} catch (error) {
strapi.log.error(`${LOG_PREFIX} acknowledgement failed: ${String(error)}`);
}
}
+131
View File
@@ -0,0 +1,131 @@
/**
* email-template-defaults — the hardcoded subject and body for every email
* the CMS sends.
*
* 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
* _events-opportunities/copy.ts). Nothing here is invented.
*
* BODIES ARE MARKDOWN. Plain paragraphs convert unchanged; the `{event}`,
* `{date}` and `{call}` placeholders are filled after conversion. A placeholder
* whose value is empty collapses together with the word before it, so the call
* clause of the acknowledgement ("for {call}" / "على {call}") reads cleanly even
* when the submission carries no opportunity.
*/
export const EMAIL_TEMPLATE_KEYS = [
'confirmation',
'waitlist',
'event-reminder',
'application-acknowledgement',
'partner-review-submitted',
'partner-review-decision',
'partner-review-published',
] as const;
export type EmailTemplateKey = (typeof EMAIL_TEMPLATE_KEYS)[number];
/** One locale's worth of a template. */
export type LocalisedEmailTemplate = {
subject: string;
/** Markdown; converted to HTML at send time. */
body: string;
};
/**
* The two approved acknowledgement wordings, kept for their exact strings:
* the no-call variant is what placeholder collapse produces when there is no
* opportunity.
*/
const ACKNOWLEDGEMENT_WITH_CALL = {
ar: 'شكرًا لك — استلمنا طلبك على {call}. سيراجع فريقنا طلبك ويتواصل معك عبر البريد الإلكتروني.',
en: 'Thank you — we have received your application for {call}. Our team will review it and follow up by email.',
} as const;
/**
* The plain acknowledgement (no call), reproduced verbatim for reference.
*/
const ACKNOWLEDGEMENT_WITHOUT_CALL = {
ar: 'شكرًا لك — استلمنا طلبك. سيراجع فريقنا طلبك ويتواصل معك عبر البريد الإلكتروني.',
en: 'Thank you — we have received your application. Our team will review it and follow up by email.',
} as const;
export const ACKNOWLEDGEMENT_WITHOUT_CALL_COPY = ACKNOWLEDGEMENT_WITHOUT_CALL;
export const DEFAULT_EMAIL_TEMPLATES: Readonly<
Record<EmailTemplateKey, { ar: LocalisedEmailTemplate; en: LocalisedEmailTemplate }>
> = {
confirmation: {
ar: {
subject: 'تم تأكيد تسجيلك',
body: 'شكرًا لك — تم تأكيد مكانك في {event} بتاريخ {date}. سنرسل لك تذكيرًا وأي تفاصيل إضافية على البريد الإلكتروني الذي قدّمته.',
},
en: {
subject: 'Registration confirmed',
body: 'Thank you — your spot for {event} on {date} is confirmed. We’ll send a reminder and any joining details to the email you provided.',
},
},
waitlist: {
ar: {
subject: 'تمت إضافتك إلى قائمة الانتظار',
body: 'شكرًا لك — اكتمل العدد في {event} بتاريخ {date}، لذلك أضفناك إلى قائمة الانتظار. سنراسلك عبر البريد الإلكتروني في حال توفّر مكان.',
},
en: {
subject: "You're on the waitlist",
body: 'Thank you — {event} on {date} is fully booked, so we have added you to the waitlist. We will email you if a place becomes available.',
},
},
'event-reminder': {
ar: {
subject: 'تذكير: {event}',
body: 'هذا تذكير بأنك مسجّل في {event} بتاريخ {date}. تبدأ الفعالية في الوقت المُشار إليه أدناه.',
},
en: {
subject: 'Reminder: {event}',
body: 'This is a reminder that you are registered for {event} on {date}. A summary is below.',
},
},
'application-acknowledgement': {
ar: {
subject: 'تم استلام الطلب',
body: ACKNOWLEDGEMENT_WITH_CALL.ar,
},
en: {
subject: 'Application received',
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.',
},
},
};
+28
View File
@@ -0,0 +1,28 @@
/**
* email-templates — shared placeholder interpolation for hardcoded outbound
* email templates. Subjects and bodies live in email-template-defaults.ts.
*/
/** Literal values for {event} / {date} / {call}, per locale, unescaped. */
export type PlaceholderValues = Readonly<Record<string, string>>;
/**
* Fill `{name}` placeholders in a template string. A value of `''` removes
* the placeholder together with the word directly in front of it, so "for
* {call}" (or "على {call}") collapses to nothing instead of leaving a
* dangling connector. Non-empty values are spliced with `split`/`join`, so a
* value containing `$` or backslashes is inserted literally.
*/
export function fillPlaceholders(value: string, vars: PlaceholderValues): string {
let out = value;
for (const name of Object.keys(vars)) {
const replacement = vars[name];
const token = `{${name}}`;
if (replacement === '') {
out = out.replace(new RegExp(`\\S+\\s*\\{${name}\\}`, 'g'), '');
} else {
out = out.split(token).join(replacement);
}
}
return out;
}
+111
View File
@@ -0,0 +1,111 @@
const CRLF = '\r\n';
const MAX_OCTETS = 75;
const CONTROL_CHARS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
export type CalendarEvent = {
uid: string;
start: string | Date;
end?: string | Date | null;
allDay?: boolean;
summary: string;
description?: string;
location?: string;
url?: string;
status?: 'CONFIRMED' | 'TENTATIVE' | 'CANCELLED';
};
function escapeText(value: string): string {
return value
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\r\n|\r|\n/g, '\\n')
.replace(CONTROL_CHARS, '');
}
function foldLine(line: string): string {
if (Buffer.byteLength(line, 'utf8') <= MAX_OCTETS) return line;
const folded: string[] = [];
let current = '';
let octets = 0;
for (const character of line) {
const size = Buffer.byteLength(character, 'utf8');
if (octets + size > MAX_OCTETS) {
folded.push(current);
current = ' ';
octets = 1;
}
current += character;
octets += size;
}
folded.push(current);
return folded.join(CRLF);
}
function property(name: string, value: string): string {
return foldLine(`${name}:${value}`);
}
function parseDate(value: string | Date | null | undefined): Date | undefined {
if (!value) return undefined;
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? undefined : date;
}
function toUtcStamp(date: Date): string {
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
}
function toDateValue(date: Date): string {
return date.toISOString().slice(0, 10).replace(/-/g, '');
}
export function buildEventCalendar(event: CalendarEvent): string {
const start = parseDate(event.start);
const end = parseDate(event.end);
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
property('PRODID', '-//SHARP-ER//Events Calendar//EN'),
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
property('X-WR-CALNAME', 'SHARP-ER'),
];
if (start) {
lines.push('BEGIN:VEVENT');
lines.push(property('UID', escapeText(event.uid)));
lines.push(property('DTSTAMP', toUtcStamp(new Date())));
if (event.allDay) {
lines.push(`DTSTART;VALUE=DATE:${toDateValue(start)}`);
const exclusiveEnd = new Date((end ?? start).getTime() + 86_400_000);
lines.push(`DTEND;VALUE=DATE:${toDateValue(exclusiveEnd)}`);
} else {
lines.push(property('DTSTART', toUtcStamp(start)));
if (end && end.getTime() > start.getTime()) {
lines.push(property('DTEND', toUtcStamp(end)));
}
}
lines.push(property('SUMMARY', escapeText(event.summary)));
if (event.description) {
lines.push(property('DESCRIPTION', escapeText(event.description)));
}
if (event.location) {
lines.push(property('LOCATION', escapeText(event.location)));
}
if (event.url && /^https?:\/\//i.test(event.url)) {
lines.push(property('URL', event.url));
}
lines.push(property('STATUS', event.status ?? 'CONFIRMED'));
lines.push('END:VEVENT');
}
lines.push('END:VCALENDAR');
return `${lines.join(CRLF)}${CRLF}`;
}
+681
View File
@@ -0,0 +1,681 @@
/**
* event-mail — bilingual e-mail building and sending for event registrations.
*
* The CMS sends two kinds of e-mails, both bilingual with Arabic first:
*
* confirmation the moment a registration is created (confirmed or
* waitlisted) — fire-and-forget from the write path, never
* awaited, so a slow or failing SMTP call can never make the
* registration request fail.
* reminder sent by the cron job to confirmed registrations who have not
* been reminded yet, for events starting within the configured
* lead time.
*
* 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 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 { 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';
export type RegistrationState = 'confirmed' | 'waitlisted';
type Locale = 'en' | 'ar';
/** Non-localised fields are shared by both locale rows of an event. */
type EventRow = {
documentId: string;
title?: string | null;
description?: string | null;
/** Strapi returns datetime attributes as ISO strings (or Dates); accept both. */
startDateTime?: string | Date | null;
endDateTime?: string | Date | null;
timezone?: string | null;
format?: 'in-person' | 'online' | 'hybrid' | null;
venue?: string | null;
onlineUrl?: string | null;
region?: { name?: string | null } | null;
};
/** An event looked up in both locales; missing locales are allowed. */
type BilingualEvent = { en?: EventRow; ar?: EventRow };
/** Identifier a public form may have sent for the event relation. */
type EventReference = { documentId?: string; id?: number } | undefined;
const EMAIL_TAG = {
en: 'en-GB-u-nu-latn',
ar: 'ar-LY-u-nu-latn',
} as const;
const DEFAULT_TIMEZONE = 'Africa/Tripoli';
type ReservationIdentity = {
id?: string;
name?: string;
};
type MailAttachment = {
filename: string;
content: string;
contentType: string;
};
type JsonLd = Readonly<Record<string, unknown>>;
function validWebUrl(value: string | null | undefined): string | undefined {
const candidate = value?.trim();
if (!candidate) return undefined;
try {
const url = new URL(candidate);
return url.protocol === 'http:' || url.protocol === 'https:'
? url.toString()
: undefined;
} catch {
return undefined;
}
}
function isoDateTime(value: string | Date | null | undefined): string | undefined {
if (!value) return undefined;
const date = typeof value === 'string' ? new Date(value) : value;
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
}
function eventLocationForJsonLd(event: BilingualEvent): JsonLd | undefined {
const venue = event.en?.venue || event.ar?.venue;
const region = event.en?.region?.name || event.ar?.region?.name;
const format = event.en?.format || event.ar?.format;
const onlineUrl = validWebUrl(event.en?.onlineUrl || event.ar?.onlineUrl);
if ((format === 'online' || (!venue && !region)) && onlineUrl) {
return { '@type': 'VirtualLocation', url: onlineUrl };
}
if (!venue && !region) return undefined;
return {
'@type': 'Place',
name: venue || region,
address: {
'@type': 'PostalAddress',
...(region ? { addressLocality: region } : {}),
addressCountry: 'LY',
},
};
}
function buildEventReservationJsonLd(
event: BilingualEvent,
state: RegistrationState,
reservation: ReservationIdentity,
): JsonLd | undefined {
const name = event.en?.title || event.ar?.title;
const startDate = isoDateTime(
event.en?.startDateTime ?? event.ar?.startDateTime,
);
const reservationNumber = reservation.id?.trim();
const attendeeName = reservation.name?.trim();
const location = eventLocationForJsonLd(event);
if (!name || !startDate || !reservationNumber || !attendeeName || !location) {
return undefined;
}
const format = event.en?.format || event.ar?.format;
const endDate = isoDateTime(event.en?.endDateTime ?? event.ar?.endDateTime);
const description = event.en?.description || event.ar?.description;
return {
'@context': 'https://schema.org',
'@type': 'EventReservation',
reservationNumber,
reservationStatus:
state === 'confirmed'
? 'http://schema.org/Confirmed'
: 'https://schema.org/ReservationPending',
underName: { '@type': 'Person', name: attendeeName },
reservationFor: {
'@type': 'Event',
name,
startDate,
...(endDate ? { endDate } : {}),
...(description ? { description } : {}),
location,
eventAttendanceMode:
format === 'online'
? 'https://schema.org/OnlineEventAttendanceMode'
: format === 'hybrid'
? 'https://schema.org/MixedEventAttendanceMode'
: 'https://schema.org/OfflineEventAttendanceMode',
eventStatus: 'https://schema.org/EventScheduled',
},
};
}
function eventCalendarFilename(title: string): string {
const safeTitle = title
.replace(/[<>:"/\\|?*\u0000-\u001f\u007f-\u009f]/g, '-')
.replace(/\s+/g, ' ')
.replace(/^[. ]+|[. ]+$/g, '')
.trim();
const limitedTitle = Array.from(safeTitle).slice(0, 120).join('').replace(/[. ]+$/g, '');
return `${limitedTitle || 'event'}.ics`;
}
function buildEventCalendarAttachment(
event: BilingualEvent,
state: RegistrationState,
): MailAttachment | undefined {
const eventId = event.en?.documentId || event.ar?.documentId;
const start = event.en?.startDateTime ?? event.ar?.startDateTime;
const summary = event.en?.title || event.ar?.title;
if (!eventId || !start || !summary || !isoDateTime(start)) return undefined;
const onlineUrl = validWebUrl(event.en?.onlineUrl || event.ar?.onlineUrl);
const descriptions = [
event.en?.description || event.ar?.description,
event.ar?.description && event.ar.description !== event.en?.description
? event.ar.description
: undefined,
].filter((value): value is string => Boolean(value));
const location =
event.en?.venue ||
event.ar?.venue ||
event.en?.region?.name ||
event.ar?.region?.name ||
onlineUrl;
return {
filename: eventCalendarFilename(summary),
content: buildEventCalendar({
uid: `${eventId}@sharper.ly`,
start,
end: event.en?.endDateTime ?? event.ar?.endDateTime,
allDay: typeof start === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(start),
summary,
...(descriptions.length > 0 ? { description: descriptions.join('\n\n') } : {}),
...(location ? { location } : {}),
...(onlineUrl ? { url: onlineUrl } : {}),
status: state === 'confirmed' ? 'CONFIRMED' : 'TENTATIVE',
}),
contentType: 'text/calendar; charset=utf-8; method=PUBLISH',
};
}
function renderJsonLd(jsonLd?: JsonLd): string {
if (!jsonLd) return '';
const serialized = JSON.stringify(jsonLd);
if (!serialized) return '';
const safeJson = serialized
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026');
return `<script type="application/ld+json">${safeJson}</script>`;
}
/**
* Date plus time of day, mirroring the web's `formatDateTime`: Arabic uses the
* latn numbering system, so digits never switch to Eastern Arabic numerals.
*/
function formatEventDateTime(
value: string | Date | null | undefined,
locale: Locale,
timeZone: string,
): string {
if (!value) return '';
const date = typeof value === 'string' ? new Date(value) : value;
if (Number.isNaN(date.getTime())) return '';
return new Intl.DateTimeFormat(EMAIL_TAG[locale], {
dateStyle: 'long',
timeStyle: 'short',
timeZone,
}).format(date);
}
/* ---------------------------------------------------------------------------
LOCATION LINE
------------------------------------------------------------------------ */
const LOCATION_LABEL = {
ar: 'الموقع',
en: 'Location',
} as const;
const JOIN_LABEL = {
ar: 'الانضمام عبر الإنترنت',
en: 'Join online',
} as const;
/**
* Extra line with the venue or the online link, when the event has one. Each
* locale's line uses that locale's row, falling back to the other row when a
* localized field is empty on one side.
*/
function locationLineFor(event: BilingualEvent): { ar: string; en: string } | undefined {
const format = event.en?.format;
const onlineUrl = event.en?.onlineUrl || event.ar?.onlineUrl;
const venue = {
en: event.en?.venue || event.ar?.venue || '',
ar: event.ar?.venue || event.en?.venue || '',
};
const isOnline = format === 'online' || (format === 'hybrid' && !venue.en);
if (isOnline && onlineUrl) {
return {
ar: `${JOIN_LABEL.ar}: ${onlineUrl}`,
en: `${JOIN_LABEL.en}: ${onlineUrl}`,
};
}
if (venue.en || venue.ar) {
return {
ar: `${LOCATION_LABEL.ar}: ${venue.ar}`,
en: `${LOCATION_LABEL.en}: ${venue.en}`,
};
}
return undefined;
}
/* ---------------------------------------------------------------------------
EU VISIBILITY FOOTER (approved wording)
------------------------------------------------------------------------ */
const FOOTER = {
ar: 'SHARP-ER — العمل المشترك لتعزيز سيادة القانون، مسار الخروج — يُنفَّذ من قِبل منظمة Acted بالشراكة مع United Efforts Consultancy (UEC) وStichting Bureau Ensemble (SBE)، وبتمويل من الاتحاد الأوروبي.',
en: 'SHARP-ER — Shared Action on Rule of Law Progress, Exit Route — is implemented by Acted, in consortium with United Efforts Consultancy (UEC) and Stichting Bureau Ensemble (SBE), and funded by the European Union.',
} as const;
/* ---------------------------------------------------------------------------
MARKUP
------------------------------------------------------------------------ */
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
* linked to the public assets on the project website.
*/
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;"><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 = (): string => {
const images = EMAIL_LOGOS.partners
.map(
(partner) =>
`<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('');
return `
<div style="margin-top:24px; padding-top:16px; border-top:1px solid #ddd; ${BASE_FONT}">
${images}
</div>`;
};
const locationHtml = (line?: string): string =>
line
? `<p style="margin:12px 0 0; font-size:14px; color:#444; ${BASE_FONT}">${escapeHtml(line)}</p>`
: '';
/**
* One bilingual message: the Arabic block first (rtl), then English, then the
* EU-visibility footer and partner logos. Inline styles keep the layout
* working in mail clients that strip <head> style sheets.
*
* The markdown bodies are converted to HTML here, and the `{event}` /
* `{date}` / `{call}` placeholders are filled AFTER conversion — with
* HTML-escaped values in the HTML part and the literal values in the plain
* part — so nothing a title or URL contains can be read as markdown.
*/
export function buildBilingualMail(opts: {
subjectAr: string;
subjectEn: string;
bodyAr: string;
bodyEn: string;
/** Literal {event} / {date} / {call} values, per locale, unescaped. */
vars?: { ar: PlaceholderValues; en: PlaceholderValues };
locationLine?: { ar: string; en: string };
jsonLd?: JsonLd;
}): { subject: string; text: string; html: string } {
const {
subjectAr,
subjectEn,
bodyAr,
bodyEn,
vars,
locationLine,
jsonLd,
} = opts;
const varsAr = vars?.ar ?? {};
const varsEn = vars?.en ?? {};
const escaped = (values: PlaceholderValues): PlaceholderValues =>
Object.fromEntries(
Object.entries(values).map(([name, value]) => [name, escapeHtml(value)])
);
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)}
</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>
<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:32px 0 0; font-size:12px; line-height:1.5; color:#66716b; ${BASE_FONT}">
${FOOTER.ar}
</p>
<p style="margin:8px 0 0; font-size:12px; line-height:1.5; color:#66716b; ${BASE_FONT}">
${FOOTER.en}
</p>
${renderPartnerLogos()}
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
const text = [
fillPlaceholders(mdToText(bodyAr), varsAr),
locationLine?.ar ?? '',
'',
fillPlaceholders(mdToText(bodyEn), varsEn),
locationLine?.en ?? '',
'',
FOOTER.ar,
FOOTER.en,
]
.filter((line) => line.length > 0)
.join('\n');
return {
subject,
text,
html,
};
}
/* ---------------------------------------------------------------------------
EVENT LOOKUP
------------------------------------------------------------------------ */
/**
* Fetch an event in both locales. Only published events are ever fetched: the
* documents API defaults to `status: published` when none is given. Either a
* documentId (what the REST API uses for relations) or a legacy numeric id is
* accepted, mirroring the sanitize-submission middleware.
*/
export async function fetchBilingualEvent(
strapi: Core.Strapi,
reference: EventReference,
): Promise<BilingualEvent> {
const filters = reference?.documentId
? { documentId: reference.documentId }
: reference?.id !== undefined
? { id: reference.id }
: null;
if (!filters) return {};
const [en, ar] = await Promise.all([
strapi.documents(EVENT_UID).findFirst({
filters,
locale: 'en',
populate: { region: { fields: ['name'] } },
}),
strapi.documents(EVENT_UID).findFirst({
filters,
locale: 'ar',
populate: { region: { fields: ['name'] } },
}),
]);
return { en: en ?? undefined, ar: ar ?? undefined };
}
/* ---------------------------------------------------------------------------
SENDING
------------------------------------------------------------------------ */
type MailOptions = {
to: string;
subject: string;
text: string;
html: string;
attachments?: MailAttachment[];
};
type EmailServiceShape = { send: (options: MailOptions) => Promise<unknown> };
/**
* Send one e-mail through the nodemailer provider. Throws on failure — callers
* decide whether that should surface (reminders: caught per registration) or
* be swallowed (confirmation: never awaited).
*/
export async function sendMail(
strapi: Core.Strapi,
options: MailOptions,
): Promise<void> {
const service = strapi.plugin('email').service<EmailServiceShape>('email');
await service.send(options);
}
/** A guarded send that logs instead of throwing. Returns true when it worked. */
export async function trySendMail(
strapi: Core.Strapi,
options: MailOptions,
): Promise<boolean> {
try {
await sendMail(strapi, options);
return true;
} catch (error) {
strapi.log.error(
`[event-mail] failed to send to ${options.to}: ${String(error)}`,
);
return false;
}
}
/* ---------------------------------------------------------------------------
CONFIRMATION
------------------------------------------------------------------------ */
/**
* Build and send the confirmation (or waitlist) e-mail for a registration.
* Best-effort: logs failures, never throws into the request path.
*
* Returns true when the mail was accepted by the provider. The waitlist
* promotion cron uses this to only flip a registration to `confirmed` AFTER
* the "your place is now confirmed" mail went out (at-least-once, like the
* reminder job).
*/
export async function sendRegistrationConfirmation({
strapi,
to,
state,
eventReference,
reservation,
}: {
strapi: Core.Strapi;
to: string;
state: RegistrationState;
eventReference: EventReference;
reservation: ReservationIdentity;
}): Promise<boolean> {
try {
const isWaitlist = state === 'waitlisted';
const event = await fetchBilingualEvent(strapi, eventReference);
const template = DEFAULT_EMAIL_TEMPLATES[isWaitlist ? 'waitlist' : 'confirmation'];
if (!event.en && !event.ar) {
strapi.log.warn(
'[event-mail] confirmation skipped: no published event for the registration.',
);
return false;
}
const timeZone = event.en?.timezone || event.ar?.timezone || DEFAULT_TIMEZONE;
const titleEn = event.en?.title || event.ar?.title || '';
const titleAr = event.ar?.title || event.en?.title || '';
const dateEn = formatEventDateTime(
event.en?.startDateTime,
'en',
timeZone,
);
const dateAr = formatEventDateTime(
event.ar?.startDateTime ?? event.en?.startDateTime,
'ar',
timeZone,
);
const mail = buildBilingualMail({
subjectAr: template.ar.subject,
subjectEn: template.en.subject,
bodyAr: template.ar.body,
bodyEn: template.en.body,
vars: {
ar: { event: titleAr, date: dateAr },
en: { event: titleEn, date: dateEn },
},
locationLine: locationLineFor(event),
jsonLd: buildEventReservationJsonLd(event, state, reservation),
});
const attachment = buildEventCalendarAttachment(event, state);
return trySendMail(strapi, {
to,
...mail,
...(attachment ? { attachments: [attachment] } : {}),
});
} catch (error) {
strapi.log.error(`[event-mail] confirmation failed: ${String(error)}`);
return false;
}
}
/* ---------------------------------------------------------------------------
REMINDER
------------------------------------------------------------------------ */
/** Build and send the reminder e-mail for a single confirmed registration. */
export async function sendEventReminderMail({
strapi,
to,
eventReference,
reservation,
}: {
strapi: Core.Strapi;
to: string;
eventReference: EventReference;
reservation: ReservationIdentity;
}): Promise<boolean> {
try {
const event = await fetchBilingualEvent(strapi, eventReference);
const template = DEFAULT_EMAIL_TEMPLATES['event-reminder'];
if (!event.en && !event.ar) {
strapi.log.warn(
'[event-mail] reminder skipped: no published event for the registration.',
);
return false;
}
const timeZone = event.en?.timezone || event.ar?.timezone || DEFAULT_TIMEZONE;
const titleEn = event.en?.title || event.ar?.title || '';
const titleAr = event.ar?.title || event.en?.title || '';
const dateEn = formatEventDateTime(event.en?.startDateTime, 'en', timeZone);
const dateAr = formatEventDateTime(
event.ar?.startDateTime ?? event.en?.startDateTime,
'ar',
timeZone,
);
const mail = buildBilingualMail({
subjectAr: template.ar.subject,
subjectEn: template.en.subject,
bodyAr: template.ar.body,
bodyEn: template.en.body,
vars: {
ar: { event: titleAr, date: dateAr },
en: { event: titleEn, date: dateEn },
},
locationLine: locationLineFor(event),
jsonLd: buildEventReservationJsonLd(event, 'confirmed', reservation),
});
const attachment = buildEventCalendarAttachment(event, 'confirmed');
return trySendMail(strapi, {
to,
...mail,
...(attachment ? { attachments: [attachment] } : {}),
});
} catch (error) {
strapi.log.error(`[event-mail] reminder failed: ${String(error)}`);
return false;
}
}
+166
View File
@@ -0,0 +1,166 @@
/**
* event-reminders — the hourly cron task that sends reminder e-mails to
* confirmed registrations whose event starts within the configured lead time.
*
* Semantics are at-least-once: `reminderSentAt` is written ONLY after the
* e-mail sent successfully, so a failure on one run is retried on the next.
* One failing registration never stops the batch — every registration is
* wrapped in its own try/catch.
*
* Registered from src/index.ts only when `server.cron.enabled` is true.
*/
import type { Core } from '@strapi/strapi';
import { sendEventReminderMail } from './event-mail';
const REGISTRATION_UID = 'api::event-registration.event-registration';
const REMINDER_SETTINGS_UID = 'api::reminder-settings.reminder-settings';
const LOG_PREFIX = '[event-reminders]';
const DEFAULT_LEAD_HOURS = 48;
const DEFAULT_REMINDER_ENABLED = true;
const PAGE_SIZE = 50;
type ReminderSettings = {
enabled: boolean;
leadHours: number;
};
/**
* Read the dashboard-controlled settings, falling back to the seed defaults
* when the single type is missing or its values are empty.
*/
async function readReminderSettings(
strapi: Core.Strapi,
): Promise<ReminderSettings> {
try {
const settings = await strapi
.documents(REMINDER_SETTINGS_UID)
.findFirst({});
if (!settings) {
return { enabled: DEFAULT_REMINDER_ENABLED, leadHours: DEFAULT_LEAD_HOURS };
}
const leadHours =
typeof settings.leadHours === 'number' && settings.leadHours >= 0
? settings.leadHours
: DEFAULT_LEAD_HOURS;
return {
enabled: settings.enabled !== false,
leadHours,
};
} catch (error) {
strapi.log.warn(
`${LOG_PREFIX} could not read reminder settings, using defaults: ${String(error)}`,
);
return { enabled: DEFAULT_REMINDER_ENABLED, leadHours: DEFAULT_LEAD_HOURS };
}
}
export async function runEventReminders({
strapi,
}: {
strapi: Core.Strapi;
}): Promise<void> {
const settings = await readReminderSettings(strapi);
if (!settings.enabled) {
strapi.log.debug(`${LOG_PREFIX} disabled in reminder settings, skipping.`);
return;
}
const now = new Date();
const horizon = new Date(now.getTime() + settings.leadHours * 3_600_000);
strapi.log.info(
`${LOG_PREFIX} running for events starting between ${now.toISOString()} and ${horizon.toISOString()}.`,
);
let page = 1;
let sent = 0;
let failed = 0;
while (true) {
const registrations = await strapi
.documents(REGISTRATION_UID)
.findMany({
filters: {
registrationState: { $eq: 'confirmed' },
reminderSentAt: { $null: true },
event: {
startDateTime: {
$gte: now.toISOString(),
$lte: horizon.toISOString(),
},
},
},
populate: { event: true },
pagination: { page, pageSize: PAGE_SIZE },
});
for (const registration of registrations) {
try {
const event = registration.event as
| { documentId?: string; id?: number }
| null
| undefined;
const reference = event?.documentId
? { documentId: event.documentId }
: event?.id !== undefined
? { id: event.id }
: undefined;
if (!event || !reference) {
strapi.log.warn(
`${LOG_PREFIX} registration ${registration.documentId} has no event, skipping.`,
);
continue;
}
const ok = await sendEventReminderMail({
strapi,
to: registration.email as string,
eventReference: reference,
reservation: {
id: registration.documentId as string,
name:
typeof registration.fullName === 'string'
? registration.fullName
: undefined,
},
});
if (!ok) {
failed += 1;
continue;
}
await strapi
.documents(REGISTRATION_UID)
.update({
documentId: registration.documentId as string,
data: { reminderSentAt: new Date().toISOString() },
});
sent += 1;
} catch (error) {
failed += 1;
strapi.log.error(
`${LOG_PREFIX} failed for registration ${registration.documentId}: ${String(error)}`,
);
}
}
if (registrations.length < PAGE_SIZE) break;
page += 1;
}
strapi.log.info(
`${LOG_PREFIX} finished: ${sent} reminder(s) sent, ${failed} failed.`,
);
}
+254
View File
@@ -0,0 +1,254 @@
/**
* markdown — a tiny markdown-to-HTML renderer for email bodies.
*
* Email templates are markdown in the CMS (components/email/template.json).
* This converts the subset the editors actually use — headings, paragraphs,
* bullet and numbered lists, blockquotes, plus inline bold / italic / code /
* links — into the inline-styled HTML email clients expect.
*
* There is deliberately NO generic HTML passthrough and no markdown
* dependency: the source is HTML-escaped FIRST, so anything that is not
* recognised converts to visible text rather than markup (the same position
* the web app's rich-text renderer takes). Links are the one exception — an
* `href` is emitted only when its scheme is http, https or mailto; anything
* else renders as its label, as plain text.
*
* `mdToText` renders the same blocks as plain text, so the message's plain
* part stays in step with the HTML part.
*/
const ESCAPE = /[&<>"']/g;
const ESCAPE_MAP: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
/** Escape text so the rest of this module can treat it as inert data. */
export function escapeHtml(value: string): string {
return value.replace(ESCAPE, (ch) => ESCAPE_MAP[ch]!);
}
/* ---------------------------------------------------------------------------
INLINE
------------------------------------------------------------------------ */
/** `**bold**`, `*italic*`, `` `code` ``, `[label](/href)`. */
const INLINE_PATTERN =
/(\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|`[^`\n]+`|\[[^\]\n]+\]\([^)\s]+\))/g;
const LINK_PATTERN = /^\[([^\]\n]+)\]\(([^)\s]+)\)$/;
/** Schemes an email may link to. Everything else renders as its label. */
const SAFE_LINK = /^(?:https?:|mailto:)/i;
/**
* Render the inline markup in one (already HTML-escaped) line into an HTML
* fragment. `href` values are not re-escaped here because the source was
* escaped up-front.
*/
function inlineToHtml(text: string): string {
return text
.split(INLINE_PATTERN)
.filter((part) => part !== '' && part !== undefined)
.map((part) => {
if (part.startsWith('**') && part.endsWith('**') && part.length > 4) {
return `<strong>${part.slice(2, -2)}</strong>`;
}
if (part.startsWith('__') && part.endsWith('__') && part.length > 4) {
return `<strong>${part.slice(2, -2)}</strong>`;
}
if (part.startsWith('*') && part.endsWith('*') && part.length > 2) {
return `<em>${part.slice(1, -1)}</em>`;
}
if (part.startsWith('`') && part.endsWith('`') && part.length > 2) {
return `<code style="font-family:Menlo,Consolas,monospace; font-size:0.9em; background:#f4f4f4; padding:1px 4px; border-radius:3px;">${part.slice(1, -1)}</code>`;
}
const link = LINK_PATTERN.exec(part);
if (link) {
const [, label, target] = link;
if (!SAFE_LINK.test(target)) return label;
return `<a href="${target}" style="color:#008764; text-decoration:underline;">${label}</a>`;
}
return part;
})
.join('');
}
/** Strip inline markup instead of turning it into tags (for the text part). */
function inlineToText(text: string): string {
return text
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/__([^_]+)__/g, '$1')
.replace(/\*([^*\n]+)\*/g, '$1')
.replace(/`([^`\n]+)`/g, '$1')
.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, '$1');
}
/* ---------------------------------------------------------------------------
BLOCKS
------------------------------------------------------------------------ */
type Block =
| { kind: 'heading'; level: 2 | 3 | 4; text: string }
| { kind: 'paragraph'; text: string }
| { kind: 'quote'; text: string }
| { kind: 'list'; ordered: boolean; items: string[] };
const HEADING = /^(#{1,6})\s+(.*)$/;
const BULLET = /^[-*+]\s+(.*)$/;
const NUMBERED = /^\d+[.)]\s+(.*)$/;
const QUOTE = /^>\s?(.*)$/;
/**
* Group the source's lines into blocks, mirroring the web app's
* `parseMarkdown` (apps/web/app/routes/_page-kit/rich-text.tsx).
*/
export function parseMarkdown(source: string): Block[] {
const blocks: Block[] = [];
let paragraph: string[] = [];
let quote: string[] = [];
let listItems: string[] = [];
let listOrdered = false;
const flushParagraph = () => {
if (paragraph.length > 0) {
blocks.push({ kind: 'paragraph', text: paragraph.join(' ') });
paragraph = [];
}
};
const flushQuote = () => {
if (quote.length > 0) {
blocks.push({ kind: 'quote', text: quote.join(' ') });
quote = [];
}
};
const flushList = () => {
if (listItems.length > 0) {
blocks.push({ kind: 'list', ordered: listOrdered, items: listItems });
listItems = [];
}
};
const flushAll = () => {
flushParagraph();
flushQuote();
flushList();
};
for (const rawLine of source.replace(/\r\n?/g, '\n').split('\n')) {
const line = rawLine.trim();
if (line === '') {
flushAll();
continue;
}
const heading = HEADING.exec(line);
if (heading) {
flushAll();
// The mail owns no h1, so CMS headings start at h2 and never pass h4.
const level = Math.min(Math.max(heading[1]!.length + 1, 2), 4) as 2 | 3 | 4;
blocks.push({ kind: 'heading', level, text: heading[2]! });
continue;
}
const quoted = QUOTE.exec(line);
if (quoted) {
flushParagraph();
flushList();
quote.push(quoted[1]!);
continue;
}
const bullet = BULLET.exec(line);
const numbered = NUMBERED.exec(line);
if (bullet ?? numbered) {
flushParagraph();
flushQuote();
const ordered = numbered !== null;
if (listItems.length > 0 && ordered !== listOrdered) flushList();
listOrdered = ordered;
listItems.push((numbered?.[1] ?? bullet?.[1])!);
continue;
}
flushQuote();
flushList();
paragraph.push(line);
}
flushAll();
return blocks;
}
/* ---------------------------------------------------------------------------
RENDERERS
------------------------------------------------------------------------ */
/**
* Convert an email template's markdown body to HTML. The input is escaped
* before parsing, so unrecognised syntax (or any raw HTML) surfaces as text.
*/
export function mdToHtml(source: string): string {
const blocks = parseMarkdown(source);
return blocks
.map((block) => {
switch (block.kind) {
case 'heading': {
const tag = `h${block.level}`;
const size =
block.level === 2 ? '20px' : block.level === 3 ? '17px' : '15px';
return `<${tag} style="margin:0 0 8px; font-size:${size}; line-height:1.35; color:#222;">${inlineToHtml(block.text)}</${tag}>`;
}
case 'quote': {
const text = inlineToHtml(block.text);
return `<blockquote style="margin:0 0 12px; padding:0 0 0 12px; border-left:2px solid #008764; color:#444;">${text}</blockquote>`;
}
case 'list': {
const tag = block.ordered ? 'ol' : 'ul';
const listStyle = block.ordered ? 'decimal' : 'disc';
const items = block.items
.map((item) => `<li style="margin:0 0 4px;">${inlineToHtml(item)}</li>`)
.join('');
return `<${tag} style="margin:0 0 12px; padding:0 0 0 18px; list-style:${listStyle};">${items}</${tag}>`;
}
default:
return `<p style="margin:0 0 12px; line-height:1.6;">${inlineToHtml(block.text)}</p>`;
}
})
.join('');
}
/**
* Convert an email template's markdown body to plain text, for the message's
* `text` part. Paragraphs are separated by one blank line, list items by "- "
* / "N. ", blockquotes just drop their marker.
*/
export function mdToText(source: string): string {
const blocks = parseMarkdown(source);
return blocks
.map((block) => {
switch (block.kind) {
case 'heading':
return block.text;
case 'quote':
return inlineToText(block.text);
case 'list':
return block.items
.map((item, index) =>
block.ordered
? `${index + 1}. ${inlineToText(item)}`
: `- ${inlineToText(item)}`
)
.join('\n');
default:
return inlineToText(block.text);
}
})
.join('\n\n');
}
@@ -0,0 +1,96 @@
import { errors } from '@strapi/utils';
import type { Core } from '@strapi/strapi';
const PARTNER_CONTENT: Record<string, { field: string }> = {
'api::resource.resource': { field: 'partnerOrganisations' },
'api::news-article.news-article': { field: 'authorOrganisation' },
'api::activity.activity': { field: 'implementingPartners' },
'api::event.event': { field: 'implementingPartners' },
'api::opportunity.opportunity': { field: 'implementingPartners' },
};
const MUTATING_ACTIONS = new Set(['create', 'update', 'publish', 'unpublish', 'delete', 'clone']);
type AdminUser = { id: number };
type PartnerAccess = { partner?: { documentId?: string } | null };
type AdminRole = { code: string };
/** Enforce partner ownership at the Document Service boundary, including Admin UI saves. */
export const registerPartnerContentAccess = (strapi: Core.Strapi): void => {
strapi.documents.use(async (context, next) => {
const partnerContent = PARTNER_CONTENT[context.uid];
if (!partnerContent || !MUTATING_ACTIONS.has(context.action)) return next();
const ctx = strapi.requestContext.get();
// Trusted internal jobs and seeders do not have an HTTP request context.
if (!ctx) return next();
const user = ctx.state?.user as AdminUser | undefined;
const isContentManager = ctx.request.path.startsWith('/content-manager/');
if (!user || !isContentManager) {
throw new errors.ForbiddenError('Only assigned CMS users can submit partner content.');
}
if (context.action === 'publish') {
const publishParams = context.params as { documentId?: string; locale?: string };
if (publishParams.documentId) {
const draft = (await strapi.documents(context.uid as never).findOne({
documentId: publishParams.documentId,
...(publishParams.locale ? { locale: publishParams.locale } : {}),
status: 'draft',
})) as { submittedByAdminUserId?: number | null; reviewStatus?: string | null } | null;
if (draft && draft.reviewStatus !== 'approved') {
throw new errors.ForbiddenError('This draft must be marked Approved before it can be published.');
}
}
}
const mapping = (await strapi.db.query('api::partner-access.partner-access').findOne({
where: { adminUserId: user.id },
populate: { partner: true },
select: ['id'],
})) as PartnerAccess | null;
const admin = (await strapi.db.query('admin::user').findOne({
where: { id: user.id },
populate: { roles: { select: ['code'] } },
select: ['id'],
})) as (AdminUser & { roles?: AdminRole[] }) | null;
const roleCodes = new Set(admin?.roles?.map(({ code }) => code) ?? []);
// A mapped user is scoped even if their role changes. Super Admin remains
// the break-glass administrator; Editors can review only when unassigned.
if (mapping) {
if (roleCodes.has('strapi-super-admin')) return next();
if (context.action !== 'create' && context.action !== 'update') {
throw new errors.ForbiddenError('Partner users can save drafts but cannot publish or delete content.');
}
const partnerDocumentId = mapping.partner?.documentId;
if (!partnerDocumentId) {
throw new errors.ForbiddenError('Your CMS account has no valid partner assignment. Contact an administrator.');
}
const data = (context.params.data ?? {}) as Record<string, unknown>;
context.params.data = data as typeof context.params.data;
data[partnerContent.field] = { set: [partnerDocumentId] };
// Feedback is private reviewer metadata; partner users cannot submit or
// erase it via a crafted Admin request.
data.reviewFeedback = null;
data.reviewStatus = 'pending';
data.submittedByAdminUserId = user.id;
return next();
}
if (roleCodes.has('strapi-super-admin') || roleCodes.has('strapi-editor')) return next();
// Authors without an assignment fail closed rather than creating content
// with a partner chosen by the browser.
if (roleCodes.has('strapi-author')) {
throw new errors.ForbiddenError('Your CMS account is not assigned to a partner. Contact an administrator.');
}
throw new errors.ForbiddenError('This CMS account cannot submit partner content.');
});
};
@@ -0,0 +1,267 @@
import type { Core } from '@strapi/strapi';
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',
'api::news-article.news-article',
'api::activity.activity',
'api::event.event',
'api::opportunity.opportunity',
]);
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;
title?: string;
slug?: string;
reviewStatus?: string | null;
reviewFeedback?: string | null;
submittedByAdminUserId?: number | null;
};
type AdminUser = {
id: number;
email?: string | null;
isActive?: boolean;
roles?: Array<{ code?: string }>;
};
const readResultDocumentId = (value: unknown): string | undefined => {
if (!value || typeof value !== 'object') return undefined;
const documentId = (value as { documentId?: unknown }).documentId;
return typeof documentId === 'string' && documentId.length > 0 ? documentId : undefined;
};
const readDocument = async (
strapi: Core.Strapi,
uid: string,
documentId: string,
locale: string | undefined,
status: 'draft' | 'published' = 'draft'
): Promise<ReviewDocument | null> => {
try {
return (await strapi.documents(uid as never).findOne({
documentId,
...(locale ? { locale } : {}),
status,
})) as ReviewDocument | null;
} catch (error) {
strapi.log.warn(`${LOG_PREFIX} could not read ${uid} ${documentId}: ${String(error)}`);
return null;
}
};
const getReviewerRecipients = async (strapi: Core.Strapi): Promise<string[]> => {
const configured = (process.env.PARTNER_REVIEW_NOTIFICATION_EMAILS ?? '')
.split(',')
.map((address) => address.trim())
.filter(Boolean);
if (configured.length > 0) return [...new Set(configured)];
const admins = (await strapi.db.query('admin::user').findMany({
select: ['id', 'email', 'isActive'],
populate: { roles: { select: ['code'] } },
})) as AdminUser[];
return [
...new Set(
admins
.filter(
(admin) => admin.isActive !== false && admin.roles?.some((role) => role.code === 'strapi-super-admin')
)
.map((admin) => admin.email?.trim())
.filter((email): email is string => Boolean(email))
),
];
};
const getSubmitter = async (strapi: Core.Strapi, id: number): Promise<AdminUser | null> =>
(await strapi.db.query('admin::user').findOne({
where: { id },
select: ['id', 'email', 'isActive'],
})) as AdminUser | null;
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 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[],
mail: ReturnType<typeof buildBilingualMail>
): Promise<void> => {
if (to.length === 0) {
strapi.log.warn(`${LOG_PREFIX} no recipient configured for: ${mail.subject}`);
return;
}
await Promise.all(
to.map((address) =>
trySendMail(strapi, { to: address, ...mail }).then((sent) => {
if (sent) strapi.log.info(`${LOG_PREFIX} sent "${mail.subject}" to ${address}.`);
})
)
);
};
const notifyAfterSave = async ({
strapi,
uid,
action,
documentId,
locale,
before,
}: {
strapi: Core.Strapi;
uid: string;
action: string;
documentId: string;
locale?: string;
before: ReviewDocument | null;
}): Promise<void> => {
const after = await readDocument(strapi, uid, documentId, locale, action === 'publish' ? 'published' : 'draft');
if (!after) return;
const submitterId = after.submittedByAdminUserId;
if (typeof submitterId !== 'number') return;
const labelAr = entryLabel(uid, after, 'ar');
const labelEn = entryLabel(uid, after, 'en');
if (
(action === 'create' && after.reviewStatus === 'pending') ||
(action === 'update' && before?.reviewStatus !== 'pending' && after.reviewStatus === 'pending')
) {
const recipients = await getReviewerRecipients(strapi);
await sendReviewNotification(
strapi,
recipients,
buildReviewMail('partner-review-submitted', {
ar: { label: labelAr },
en: { label: labelEn },
})
);
return;
}
if (
action === 'update' &&
after.reviewStatus &&
REVIEW_RETURN_STATUSES.has(after.reviewStatus) &&
(before?.reviewStatus !== after.reviewStatus || before?.reviewFeedback !== after.reviewFeedback)
) {
const submitter = await getSubmitter(strapi, submitterId);
if (!submitter?.email || submitter.isActive === false) {
strapi.log.warn(`${LOG_PREFIX} no active partner author email for admin user ${submitterId}.`);
return;
}
const decisionEn = after.reviewStatus === 'rejected' ? 'rejected' : 'changes requested';
const decisionAr = after.reviewStatus === 'rejected' ? 'الرفض' : 'طلب إجراء تغييرات';
const feedback = after.reviewFeedback?.trim();
await sendReviewNotification(
strapi,
[submitter.email],
buildReviewMail('partner-review-decision', {
ar: {
label: labelAr,
decision: decisionAr,
feedback: feedback || '(لم تُقدَّم ملاحظات للمراجعة.)',
},
en: {
label: labelEn,
decision: decisionEn,
feedback: feedback || '(No review feedback was provided.)',
},
})
);
return;
}
if (action === 'publish') {
const submitter = await getSubmitter(strapi, submitterId);
if (!submitter?.email || submitter.isActive === false) {
strapi.log.warn(`${LOG_PREFIX} no active partner author email for admin user ${submitterId}.`);
return;
}
await sendReviewNotification(
strapi,
[submitter.email],
buildReviewMail('partner-review-published', {
ar: { label: labelAr },
en: { label: labelEn },
})
);
}
};
/** Notify reviewers and partner authors after persisted workflow transitions. */
export const registerPartnerReviewNotifications = (strapi: Core.Strapi): void => {
strapi.documents.use(async (context, next) => {
if (!PARTNER_CONTENT.has(context.uid) || !NOTIFIABLE_ACTIONS.has(context.action)) return next();
const requestContext = strapi.requestContext.get();
if (!requestContext?.state?.user || !requestContext.request.path.startsWith('/content-manager/')) {
return next();
}
const params = context.params as { documentId?: string; locale?: string };
const before =
params.documentId && (context.action === 'update' || context.action === 'publish')
? await readDocument(strapi, context.uid, params.documentId, params.locale)
: null;
const result = await next();
const documentId = params.documentId ?? readResultDocumentId(result);
if (!documentId) return result;
// Run after Strapi has persisted the draft or review decision. Delivery
// failures are logged and never roll back a successful content save.
void notifyAfterSave({
strapi,
uid: context.uid,
action: context.action,
documentId,
locale: params.locale,
before,
}).catch((error) => {
strapi.log.error(`${LOG_PREFIX} notification failed for ${context.uid} ${documentId}: ${String(error)}`);
});
return result;
});
};
+208
View File
@@ -0,0 +1,208 @@
/**
* waitlist-promotion — the cron task that honours the "we will email you if a
* place becomes available" promise.
*
* Registrations are stamped `confirmed` OR `waitlisted` at signup
* (event-registration/middlewares/sanitize-submission.ts). Nothing ever moved
* a `waitlisted` row to `confirmed` when a seat freed up, so the waitlist was
* a queue nobody drained. This job does it, first-come-first-served:
*
* 1. find every event that still has `waitlisted` registrations;
* 2. for each, lock the event row (the same serialisation the registration
* middleware uses, so a concurrent POST cannot race the count) and
* compute how many seats are left below capacity;
* 3. promote the OLDEST waitlisted registrations (`submittedAt` asc) into
* those seats, sending the standard confirmation mail first and flipping
* the row to `confirmed` ONLY after the mail was accepted.
*
* Semantics are at-least-once, like the reminder job: a row whose mail failed
* stays `waitlisted` and is retried on the next run. A waitlisted row with no
* email cannot be told anything, so it is left as-is for staff to deal with.
*
* Registered from src/index.ts only when `server.cron.enabled` is true.
*/
import type { Core } from '@strapi/strapi';
import { sendRegistrationConfirmation } from './event-mail';
const EVENT_UID = 'api::event.event';
const REGISTRATION_UID = 'api::event-registration.event-registration';
const LOG_PREFIX = '[waitlist-promotion]';
const PAGE_SIZE = 50;
/** The subset of the event that decides how many seats can be promoted into. */
type EventCapacity = {
documentId: string;
capacity: number | null;
};
/** How many places on this event are already taken, mirroring the middleware. */
const countConfirmed = async (strapi: Core.Strapi, documentId: string): Promise<number> =>
strapi.db.query(REGISTRATION_UID).count({
where: {
event: { documentId },
registrationState: 'confirmed',
},
});
/**
* Promote the oldest waiting registrants of ONE event into its free seats.
*
* The lock mirrors `sanitize-submission`: registering and promoting both
* serialize on the event row, so a concurrent request cannot steal a seat the
* promotion just counted. Count and updates run inside the same transaction.
*/
async function promoteEvent(
strapi: Core.Strapi,
eventDocumentId: string,
): Promise<{ promoted: number; failed: number }> {
return strapi.db.transaction(async ({ trx }) => {
await trx('events')
.select('id')
.where({ document_id: eventDocumentId })
.orderBy('id')
.forUpdate()
.first();
const event = (await strapi.documents(EVENT_UID).findFirst({
filters: { documentId: eventDocumentId },
status: 'published',
})) as EventCapacity | null;
if (!event) {
return { promoted: 0, failed: 0 };
}
const capacity =
typeof event.capacity === 'number' && event.capacity > 0 ? event.capacity : null;
// No finite capacity means there is never a seat to free up.
if (capacity === null) {
return { promoted: 0, failed: 0 };
}
const taken = await countConfirmed(strapi, eventDocumentId);
let slots = capacity - taken;
if (slots <= 0) {
return { promoted: 0, failed: 0 };
}
// Oldest waiters first; the page size is a safety bound, the loop stops
// at `slots` anyway.
const registrations = await strapi
.documents(REGISTRATION_UID)
.findMany({
filters: {
event: { documentId: eventDocumentId },
registrationState: { $eq: 'waitlisted' },
},
sort: 'submittedAt:asc',
pagination: { page: 1, pageSize: PAGE_SIZE },
});
let promoted = 0;
let failed = 0;
for (const registration of registrations) {
if (slots <= 0) break;
const email = (registration as { email?: unknown }).email;
const fullName = (registration as { fullName?: unknown }).fullName;
if (typeof email !== 'string' || email.length === 0) {
strapi.log.warn(
`${LOG_PREFIX} registration ${registration.documentId} has no email; left waitlisted.`,
);
continue;
}
const ok = await sendRegistrationConfirmation({
strapi,
to: email,
state: 'confirmed',
eventReference: { documentId: eventDocumentId },
reservation: {
id: registration.documentId as string,
name: typeof fullName === 'string' ? fullName : undefined,
},
});
if (!ok) {
failed += 1;
continue;
}
await strapi
.documents(REGISTRATION_UID)
.update({
documentId: registration.documentId as string,
data: { registrationState: 'confirmed' },
});
promoted += 1;
slots -= 1;
}
return { promoted, failed };
});
}
/**
* Run one pass over every event that still has waitlisted registrations.
* One failing event never stops the batch; totals are logged.
*/
export async function runWaitlistPromotion({
strapi,
}: {
strapi: Core.Strapi;
}): Promise<void> {
const eventIds = new Set<string>();
let page = 1;
while (true) {
const registrations = await strapi
.documents(REGISTRATION_UID)
.findMany({
filters: { registrationState: { $eq: 'waitlisted' } },
populate: { event: true },
pagination: { page, pageSize: PAGE_SIZE },
});
for (const registration of registrations) {
const event = (registration as { event?: { documentId?: unknown } }).event;
if (typeof event?.documentId === 'string' && event.documentId.length > 0) {
eventIds.add(event.documentId);
}
}
if (registrations.length < PAGE_SIZE) break;
page += 1;
}
if (eventIds.size === 0) {
strapi.log.info(`${LOG_PREFIX} no events with a waitlist, skipping.`);
return;
}
let promotedTotal = 0;
let failedTotal = 0;
for (const eventId of eventIds) {
try {
const { promoted, failed } = await promoteEvent(strapi, eventId);
promotedTotal += promoted;
failedTotal += failed;
} catch (error) {
failedTotal += 1;
strapi.log.error(
`${LOG_PREFIX} failed for event ${eventId}: ${String(error)}`,
);
}
}
strapi.log.info(
`${LOG_PREFIX} finished: ${promotedTotal} waitlisted registration(s) promoted, ${failedTotal} mail(s) failed (retried next run).`,
);
}
+189 -7
View File
@@ -1,9 +1,10 @@
/* ============================================================================
SEED - APPROVED CONTENT
----------------------------------------------------------------------------
Everything in this file is transcribed from docs/, in both languages, and
nothing in it is invented. That is the whole point of keeping it apart from
demo.ts: this content is safe to publish.
Everything in this file is transcribed from docs/ or the supplied project
content suggestions, in both languages, and nothing in it is invented. That
is the whole point of keeping it apart from demo.ts: this content is safe to
publish.
SOURCES
docs/SHARPER_Website_Draft_Section_EN_AR.docx sections 1-9, EN and AR
@@ -13,6 +14,9 @@
docs/SHARPER Home Page .docx the launch article, EN/AR
docs/UEC_SHARPER_Website_TOR V3.docx the consortium and the
project's purpose
SHARP-ER Website Content suggestions submitted by Isam Saidi, supplied by
the project team: SHARP I/II
legacy text and historical figures
WHAT docs/ DOES NOT SUPPLY, AND IS THEREFORE NOT HERE
* Figures. Every number in the draft is bracketed and marked "TBD ... must
@@ -424,10 +428,6 @@ export const LAUNCH_ARTICLE = {
export const HOME_PAGE: Localised<SeedData> = {
en: {
hero: {
/* No `eyebrow`. The design draws a status pill reading "Now open -
Funding calls for Libyan organisations"; whether a call is open on any
given day is a fact about the project, not about the design, so the
pill stays unwritten until an editor writes it. */
headline: 'Advancing Justice and Accountability, Led by Libyan Communities',
subheading:
'SHARP-ER — Shared Action on Rule of Law Progress, Exit Route — works with civil society organisations, municipalities, and community leaders across Libya to strengthen access to justice, accountability, and local governance. Funded by the European Union, running to December 2027.',
@@ -547,6 +547,188 @@ export const ABOUT_PAGE: Localised<SeedData> = {
},
};
/**
* Facts and figures below come from "SHARP I Programme SUMMARY.docx" and
* "SHARP II Programme SUMMARY.docx". English page copy paraphrases the source;
* Arabic page copy translates it. UI headings are editorial labels, not quotes.
* The home Legacy section is stored here with the two programme records.
*/
export const LEGACY_PAGE: Localised<SeedData> = {
en: {
homeEyebrow: 'SHARP I and SHARP II',
homeHeading: 'Dialogue and local action in Libya, 2019–2024',
homeLead:
'Two EU-funded programmes supported rule of law dialogue, civil society capacity, and local action across Libya.',
hero: {
eyebrow: 'SHARP I and SHARP II',
headline: 'Dialogue and local action across Libya',
subheading:
'From November 2019 to August 2024, two EU-funded programmes supported rule of law dialogue, civil society capacity, and locally led action.',
},
stageOne: {
name: 'SHARP I',
years: 'November 2019 – August 2022',
summary:
'Built dialogue and local capacity through research, learning, and community-led action.',
description:
`The EU-funded SHARP I programme was implemented by Institute Clingendael and Bureau Ensemble with Libyan civil society organisations, local authorities, and national institutions. From November 2019 to August 2022, it worked to strengthen Libya’s rule of law through dialogue, research, and local action. Budget: €1.996 million.\n\n### Objectives\n- Strengthen Libyan institutions’ ability to respond to rule of law challenges.\n- Build the organisational capabilities of Libyan change agents.\n- Foster dialogue and information-sharing between stakeholders.\n\n### Dialogue and learning\nThe programme held 102 Search, Understand, and Question (SUQ) dialogues with 1,583 participants; 48% of participants were women. Topics included human rights, transitional justice, and local governance. A further 46 Training & Learning sessions covered facilitation, project management, and monitoring.\n\n### Community action\nTwenty-one sub-contract actions addressed conflict mediation, access to justice, women’s rights, labour rights, and civil-society and media engagement. They produced 62 workshops, 6 research papers, 24 training sessions, and 1,555 media outreach activities.\n\n### Results and lasting value\n87% of SUQ participants considered the discussions relevant, and 75% believed SHARP could contribute to rule of law change. Projects strengthened CSO reporting skills and partnerships with local authorities. Libyan-led initiatives built community trust; the programme adapted to pandemic and security constraints with virtual activities and local dialogue, helping CSOs develop lasting networks.\n\n### What followed\nThe EU approved SHARP II to build on these lessons: deepen dialogue, focus on four to six rule of law themes while retaining flexibility, strengthen CSO project design and evaluation, and support durable government–CSO networks.`,
metrics: [
{ value: '102', label: 'SUQ dialogues' },
{ value: '1,583', label: 'Dialogue participants' },
{ value: '48%', label: 'Women participants' },
{ value: '46', label: 'Learning sessions' },
{ value: '21', label: 'Sub-contract actions' },
{ value: '62', label: 'Workshops delivered' },
{ value: '6', label: 'Research papers' },
{ value: '24', label: 'Training sessions' },
{ value: '1,555', label: 'Media outreach activities' },
{ value: '87%', label: 'Found dialogues relevant' },
{ value: '75%', label: 'Saw potential for change' },
],
focusHeading: 'Areas of work',
focusAreas: [
{ name: 'Human rights' },
{ name: 'Transitional justice' },
{ name: 'Local governance' },
{ name: 'Conflict mediation' },
{ name: 'Access to justice' },
{ name: 'Women’s rights' },
{ name: 'Labour rights' },
{ name: 'Civil society and media' },
],
galleryHeading: 'SHARP I in the field',
},
stageTwo: {
name: 'SHARP II',
years: 'September 2022 – August 2024',
summary:
'Expanded a nationwide civil-society network and turned inclusive dialogue into community action.',
description:
`Funded by the European Union and implemented by Bureau Ensemble with Libyan partners, SHARP II built on SHARP I to strengthen the rule of law, stability, and participatory governance. From September 2022 to August 2024, the programme had a budget of €1.426 million.\n\n### Priorities\nSHARP II aimed to grow a national network of rule of law dialogue groups; build social cohesion and consensus locally, regionally, and nationally; organise inclusive dialogues on rule of law and positive citizenship; and support small community actions.\n\n### Dialogue and network\n157 SUQ dialogues engaged 2,554 participants, 44% of them women. Discussions covered access to justice, women’s empowerment, local governance, and the media’s role in the rule of law. The network grew to 64 active CSOs from 27, reaching every region, including marginalised areas in the south.\n\n### Skills and community action\nSix Training & Learning sessions covered facilitation, project management, and monitoring. Advanced training reached 100 participants, with 24 certified as trainers. All 34 approved sub-contracts were implemented, addressing legal awareness, reconciliation, women’s rights, gender-based violence, municipal capacity, ethical journalism, and misinformation. Activities reached 1,487 trainees and 1,697 workshop participants; awareness campaigns benefited 2,896 people.\n\n### Policy, communication, and impact\nRegional and national dialogues informed recommendations on faster court processes and legal aid, municipal empowerment under Local Governance Law 59, and media transparency and hate speech. Libyan-led monitoring supported adaptive delivery, while 1,555 media outreach activities amplified the work. CSOs led 100% of activities; ministries and municipalities partnered with them, and young people, displaced people, and persons with disabilities took part.\n\n### A sustainable exit\nThe March–August 2024 exit phase shaped three pillars for continued impact: Access to Justice (legal aid, mediation, and policy reform), TAZIZ (municipal capacity and citizen engagement), and ADWA (ethical journalism and countering misinformation). United Efforts was established and registered as a Libyan legal entity to help sustain the work.`,
metrics: [
{ value: '157', label: 'SUQ dialogues' },
{ value: '2,554', label: 'Dialogue participants' },
{ value: '64', label: 'Active CSOs' },
{ value: '44%', label: 'Women participants' },
{ value: '34', label: 'Sub-contracts' },
{ value: '100', label: 'Advanced training participants' },
{ value: '24', label: 'Certified trainers' },
{ value: '1,487', label: 'Trainees' },
{ value: '1,697', label: 'Workshop participants' },
{ value: '2,896', label: 'Awareness campaign beneficiaries' },
{ value: '1,555', label: 'Media outreach activities' },
],
focusHeading: 'Areas of work',
focusAreas: [
{ name: 'Access to justice' },
{ name: 'Women’s empowerment' },
{ name: 'Local governance' },
{ name: 'Media and rule of law' },
{ name: 'Social cohesion' },
{ name: 'Rule of law' },
{ name: 'Positive citizenship' },
],
galleryHeading: 'SHARP II in the field',
},
closingHeading: 'SHARP II’s exit phase',
closingBody: [
'From March to August 2024, the SHARP II exit phase focused on access to justice, local governance through TAZIZ, and media through ADWA.',
'United Efforts was established and registered as a Libyan legal entity. The SHARP II summary identifies a stable nationwide civil society network and a roadmap for future EU–Libya collaboration through SHARP-ER.',
].join('\n\n'),
seo: {
metaTitle: 'SHARP Legacy',
metaDescription:
'Discover the experience, achievements, and networks built through SHARP I and SHARP II in Libya.',
},
},
ar: {
homeEyebrow: 'شارب الأول وشارب الثاني',
homeHeading: 'الحوار والعمل المحلي في ليبيا، 2019–2024',
homeLead:
'دعم برنامجان ممولان من الاتحاد الأوروبي الحوار حول سيادة القانون، وقدرات المجتمع المدني، والعمل المحلي في مختلف أنحاء ليبيا.',
hero: {
eyebrow: 'شارب الأول وشارب الثاني',
headline: 'الحوار والعمل المحلي في مختلف أنحاء ليبيا',
subheading:
'من نوفمبر 2019 إلى أغسطس 2024، دعم برنامجان ممولان من الاتحاد الأوروبي الحوار حول سيادة القانون، وقدرات المجتمع المدني، والعمل بقيادة محلية.',
},
stageOne: {
name: 'شارب الأول',
years: 'نوفمبر 2019 – أغسطس 2022',
summary: 'عزّز الحوار والقدرات المحلية من خلال البحث والتعلّم والعمل المجتمعي.',
description:
`نفّذ معهد كلينغندايل ومؤسسة Bureau Ensemble برنامج شارب الأول بتمويل من الاتحاد الأوروبي، وبالتعاون مع منظمات المجتمع المدني والسلطات المحلية والمؤسسات الوطنية الليبية. عمل البرنامج من نوفمبر 2019 إلى أغسطس 2022 على دعم سيادة القانون في ليبيا عبر الحوار والبحث والعمل المحلي، بميزانية بلغت 1.996 مليون يورو.\n\n### الأهداف\n- تعزيز قدرة المؤسسات الليبية على الاستجابة لتحديات سيادة القانون.\n- بناء القدرات التنظيمية للفاعلين الليبيين الساعين إلى التغيير.\n- تشجيع الحوار وتبادل المعلومات بين أصحاب المصلحة.\n\n### الحوار والتعلّم\nعُقدت 102 جلسة «ابحث، افهم، واسأل» (SUQ)، بمشاركة 1,583 شخصاً، وكانت نسبة النساء 48%. وتناولت الجلسات حقوق الإنسان والعدالة الانتقالية والحوكمة المحلية. كما عُقدت 46 جلسة للتدريب والتعلّم حول التيسير وإدارة المشاريع والرصد.\n\n### العمل المجتمعي\nنُفّذ 21 مشروعاً فرعياً تناولت الوساطة في النزاعات والوصول إلى العدالة وحقوق النساء وحقوق العمال ومشاركة المجتمع المدني والإعلام. وأسفرت عن 62 ورشة عمل و6 أوراق بحثية و24 جلسة تدريبية و1,555 نشاطاً للتواصل الإعلامي.\n\n### النتائج والأثر المستدام\nرأى 87% من المشاركين أن جلسات الحوار ذات صلة، واعتقد 75% أن شارب قادر على الإسهام في تغيير واقع سيادة القانون. وعززت المشاريع مهارات إعداد التقارير لدى منظمات المجتمع المدني وشراكاتها مع السلطات المحلية. كما دعمت المبادرات بقيادة ليبية الثقة المجتمعية، وتكيّف البرنامج مع قيود الجائحة والأوضاع الأمنية عبر الأنشطة الافتراضية والحوار المحلي، بما ساعد المنظمات على بناء شبكات طويلة الأمد.\n\n### ما تلا ذلك\nوافق الاتحاد الأوروبي على شارب الثاني للاستفادة من هذه الخبرات: تعميق الحوار، والتركيز على أربعة إلى ستة موضوعات مع الحفاظ على المرونة، وتعزيز مهارات تصميم المشاريع وتقييمها لدى منظمات المجتمع المدني، ودعم شبكات مستدامة بين الحكومة والمجتمع المدني.`,
metrics: [
{ value: '102', label: 'جلسة حوار SUQ' },
{ value: '1,583', label: 'مشاركاً في جلسات الحوار' },
{ value: '48%', label: 'مشاركة النساء' },
{ value: '46', label: 'جلسة للتعلّم' },
{ value: '21', label: 'عملاً بعقد فرعي' },
{ value: '62', label: 'ورشة عمل' },
{ value: '6', label: 'أوراق بحثية' },
{ value: '24', label: 'جلسة تدريبية' },
{ value: '1,555', label: 'نشاطاً للتواصل الإعلامي' },
{ value: '87%', label: 'وجدوا الحوارات ذات صلة' },
{ value: '75%', label: 'رأوا إمكانية إحداث تغيير' },
],
focusHeading: 'مجالات العمل',
focusAreas: [
{ name: 'حقوق الإنسان' },
{ name: 'العدالة الانتقالية' },
{ name: 'الحوكمة المحلية' },
{ name: 'الوساطة في النزاعات' },
{ name: 'الوصول إلى العدالة' },
{ name: 'حقوق النساء' },
{ name: 'حقوق العمال' },
{ name: 'المجتمع المدني والإعلام' },
],
galleryHeading: 'شارب الأول ميدانياً',
},
stageTwo: {
name: 'شارب الثاني',
years: 'سبتمبر 2022 – أغسطس 2024',
summary:
'وسّع شبكة المجتمع المدني الوطنية وحوّل الحوار الشامل إلى عمل مجتمعي.',
description:
`بتمويل من الاتحاد الأوروبي وتنفيذ Bureau Ensemble بالشراكة مع فاعلين ليبيين، بنى شارب الثاني على إنجازات شارب الأول لتعزيز سيادة القانون والاستقرار والحوكمة التشاركية. امتد البرنامج من سبتمبر 2022 إلى أغسطس 2024 بميزانية 1.426 مليون يورو.\n\n### الأولويات\nعمل شارب الثاني على توسيع شبكة وطنية لمجموعات الحوار حول سيادة القانون، وتعزيز التماسك الاجتماعي والتوافق محلياً وإقليمياً ووطنياً، وتنظيم حوارات شاملة حول سيادة القانون والمواطنة الإيجابية، ودعم مبادرات مجتمعية صغيرة.\n\n### الحوار وتوسيع الشبكة\nعُقدت 157 جلسة حوار SUQ بمشاركة 2,554 شخصاً، وكانت نسبة النساء 44%. وتناولت الحوارات الوصول إلى العدالة وتمكين المرأة والحوكمة المحلية ودور الإعلام في سيادة القانون. ونمت الشبكة إلى 64 منظمة مجتمع مدني ناشطة بعد أن كانت 27، وامتدت إلى جميع المناطق بما فيها المناطق المهمشة في الجنوب.\n\n### بناء القدرات والعمل المجتمعي\nعُقدت 6 جلسات للتدريب والتعلّم حول التيسير وإدارة المشاريع والرصد. وشمل التدريب المتقدم 100 مشارك، حصل 24 منهم على اعتماد مدربين. ونُفّذت العقود الفرعية الـ34 المعتمدة، وتناولت التوعية القانونية والمصالحة وحقوق المرأة ومكافحة العنف القائم على النوع الاجتماعي وبناء قدرات البلديات والصحافة الأخلاقية ومواجهة المعلومات المضللة. ووصلت الأنشطة إلى 1,487 متدرباً و1,697 مشاركاً في الورش؛ واستفاد 2,896 شخصاً من حملات التوعية.\n\n### السياسات والتواصل والأثر\nأسهمت الحوارات الإقليمية والوطنية في صياغة توصيات لتسريع إجراءات المحاكم وتوفير مكاتب للمساعدة القانونية، وتمكين البلديات بموجب قانون الحكم المحلي رقم 59، ودعم الشفافية الإعلامية ومكافحة خطاب الكراهية. وأسهم الرصد بقيادة ليبية في تكييف التنفيذ، كما عززت 1,555 نشاطاً إعلامياً أثر البرنامج. وقادت منظمات المجتمع المدني 100% من الأنشطة، وتعاونت معها الوزارات والبلديات، وشارك الشباب والنازحون والأشخاص ذوو الإعاقة.\n\n### خطة خروج مستدامة\nصممت مرحلة الخروج، من مارس إلى أغسطس 2024، ثلاثة مشاريع لضمان الأثر على المدى الطويل: الوصول إلى العدالة عبر المساعدة القانونية والتدريب على الوساطة والإصلاحات؛ و«تعزيز» (TAZIZ) لبناء قدرات البلديات ومشاركة المواطنين؛ و«أضواء» (ADWA) للصحافة الأخلاقية ومواجهة المعلومات المضللة. كما أُسست وسُجلت «United Efforts» ككيان قانوني ليبي لضمان استمرارية العمل.`,
metrics: [
{ value: '157', label: 'جلسة حوار SUQ' },
{ value: '2,554', label: 'مشاركاً في الحوار' },
{ value: '64', label: 'منظمة مجتمع مدني ناشطة' },
{ value: '44%', label: 'مشاركة النساء' },
{ value: '34', label: 'عقداً فرعياً' },
{ value: '100', label: 'مشاركاً في التدريب المتقدم' },
{ value: '24', label: 'مدرباً معتمداً' },
{ value: '1,487', label: 'متدرباً' },
{ value: '1,697', label: 'مشاركاً في الورش' },
{ value: '2,896', label: 'مستفيداً من حملات التوعية' },
{ value: '1,555', label: 'نشاطاً للتواصل الإعلامي' },
],
focusHeading: 'مجالات العمل',
focusAreas: [
{ name: 'الوصول إلى العدالة' },
{ name: 'تمكين المرأة' },
{ name: 'الحوكمة المحلية' },
{ name: 'الإعلام وسيادة القانون' },
{ name: 'التماسك الاجتماعي' },
{ name: 'سيادة القانون' },
{ name: 'المواطنة الإيجابية' },
],
galleryHeading: 'شارب الثاني ميدانياً',
},
closingHeading: 'مرحلة خروج شارب الثاني',
closingBody: [
'من مارس إلى أغسطس 2024، ركزت مرحلة خروج شارب الثاني على الوصول إلى العدالة، والحوكمة المحلية عبر «تعزيز» (TAZIZ)، والإعلام عبر «أضواء» (ADWA).',
'أُسست «United Efforts» وسُجلت ككيان قانوني ليبي. ويشير ملخص شارب الثاني إلى شبكة وطنية مستقرة من منظمات المجتمع المدني وخارطة طريق لتعاون الاتحاد الأوروبي وليبيا مستقبلاً عبر شارب-إي آر.',
].join('\n\n'),
seo: {
metaTitle: 'إرث شارب',
metaDescription:
'اكتشفوا الخبرات والإنجازات والشبكات التي بُنيت من خلال مشروعي شارب الأول والثاني في ليبيا.',
},
},
};
/** The role chip and its line on the About page's consortium cards. */
export const CONSORTIUM_ROLES: Readonly<
Record<string, Localised<{ role: string; roleDescription: string }>>
-12
View File
@@ -911,15 +911,3 @@ export const demoKnowledge = (now: Date): readonly DemoEntry[] => {
),
];
};
/**
* The home page fields the demo set overrides.
*
* The status pill is the one piece of approved-looking copy the demo writes,
* and it is true of the demo data: `demo-call-for-expression-of-interest-
* round-two` really is open on a demo-seeded site.
*/
export const DEMO_HOME_PAGE: Localised<SeedData> = {
en: { hero: { eyebrow: 'Now open · Funding calls for Libyan organisations' } },
ar: { hero: { eyebrow: 'مفتوح الآن · دعوات تمويل للمنظمات الليبية' } },
};
+75 -13
View File
@@ -4,6 +4,7 @@
Run it with `pnpm seed` from the repository root, or from apps/cms:
pnpm seed the approved content only (approved.ts)
pnpm seed -- --legacy-only only create/update the Legacy single type
pnpm seed --demo the same, plus the invented fixtures (demo.ts)
pnpm seed --demo --clean remove the invented fixtures, leave the rest
@@ -17,10 +18,9 @@
documentId, and a relation can only be set once its target exists.
WHAT IT DOES NOT TOUCH
* Media. The seed uploads nothing. Where a page already has a picture -
the home hero, most of all - the existing file is read back and written
out again with the new copy, because a component is replaced wholesale
on write and anything left out of the payload is gone.
* Media. The seed uploads nothing. Existing home and Legacy hero/gallery
media are read back and written out again with the copy, because a
component is replaced wholesale on write and omitted media is removed.
* Submissions. `application`, `event-registration` and `contact-submission`
hold personal data. Nothing writes to them here, not even in demo mode.
* Locales and permissions. Those are reconciled at boot by src/bootstrap.
@@ -35,6 +35,7 @@ import {
CONSORTIUM_ROLES,
CONTACT_PAGE,
HOME_PAGE,
LEGACY_PAGE,
LAUNCH_ARTICLE,
SITE_SETTINGS,
TOPICS,
@@ -43,7 +44,6 @@ import {
} from './approved';
import {
DEMO_FAQS,
DEMO_HOME_PAGE,
DEMO_PARTNERS,
DEMO_PARTNER_SLUGS,
DEMO_PREFIX,
@@ -59,6 +59,7 @@ import {
deleteBySlugPrefix,
deleteWhere,
mediaId,
mediaIds,
readExisting,
upsert,
type Localised,
@@ -71,12 +72,15 @@ export interface SeedOptions {
readonly demo: boolean;
/** With `demo`, remove those fixtures instead of writing them. */
readonly clean: boolean;
/** Write only the bilingual Legacy single type, preserving its media. */
readonly legacyOnly: boolean;
}
const UID = {
siteSettings: 'api::site-settings.site-settings',
homePage: 'api::home-page.home-page',
aboutPage: 'api::about-page.about-page',
legacyPage: 'api::legacy-page.legacy-page',
whatWeDoPage: 'api::what-we-do-page.what-we-do-page',
contactPage: 'api::contact-page.contact-page',
partner: 'api::partner.partner',
@@ -218,6 +222,8 @@ async function seedApproved(strapi: Core.Strapi): Promise<{
});
log(strapi, 'about page');
await seedLegacyPage(strapi);
await upsert(strapi, UID.whatWeDoPage, WHAT_WE_DO_PAGE);
log(strapi, 'what we do page');
@@ -227,6 +233,57 @@ async function seedApproved(strapi: Core.Strapi): Promise<{
return { partners, topics, launchArticleId: launch.documentId };
}
/** Seed only the Legacy single type, leaving every other CMS record untouched. */
async function seedLegacyPage(strapi: Core.Strapi): Promise<void> {
/* A component is replaced wholesale on update. Carry editor-selected hero
and phase-gallery media across reseeds so the approved copy never drops it. */
const populate = [
'hero.media',
'stageOne.images',
'stageTwo.images',
];
const existingArabic = await readExisting(strapi, UID.legacyPage, populate);
const existingEnglish = await readExisting(strapi, UID.legacyPage, populate, undefined, 'en');
const existingFor = (locale: 'en' | 'ar') =>
locale === 'en' ? existingEnglish ?? existingArabic : existingArabic ?? existingEnglish;
const mediaFor = (locale: 'en' | 'ar') => {
const existing = existingFor(locale);
return {
hero: mediaId(existing?.hero, 'media'),
one: mediaIds(existing?.stageOne, 'images'),
two: mediaIds(existing?.stageTwo, 'images'),
};
};
const enMedia = mediaFor('en');
const arMedia = mediaFor('ar');
const withGallery = (stage: unknown, images: number[]): SeedData => ({
...(stage as SeedData),
...(images.length > 0 ? { images } : {}),
});
await upsert(strapi, UID.legacyPage, {
en: {
...LEGACY_PAGE.en,
hero: {
...(LEGACY_PAGE.en.hero as SeedData),
...(enMedia.hero ? { media: enMedia.hero } : {}),
},
stageOne: withGallery(LEGACY_PAGE.en.stageOne, enMedia.one),
stageTwo: withGallery(LEGACY_PAGE.en.stageTwo, enMedia.two),
},
ar: {
...LEGACY_PAGE.ar,
hero: {
...(LEGACY_PAGE.ar.hero as SeedData),
...(arMedia.hero ? { media: arMedia.hero } : {}),
},
stageOne: withGallery(LEGACY_PAGE.ar.stageOne, arMedia.one),
stageTwo: withGallery(LEGACY_PAGE.ar.stageTwo, arMedia.two),
},
});
log(strapi, 'legacy page');
}
/* ---------------------------------------------------------------------------
THE DEMO SET
------------------------------------------------------------------------ */
@@ -247,7 +304,7 @@ async function seedDemo(
log(strapi, `${DEMO_PARTNERS.length} demo network partners`);
for (const item of DEMO_FAQS) {
await upsert(strapi, UID.faq, item, { question: { $eq: item.en.question } });
await upsert(strapi, UID.faq, item, { question: { $eq: item.ar.question } });
}
log(strapi, `${DEMO_FAQS.length} demo FAQs`);
@@ -326,11 +383,10 @@ async function seedDemo(
}
log(strapi, `${knowledge.length} demo knowledge items`);
/* Finally the home page, which the demo set only adds to: the status pill,
the network logo wall (twelve organisations, which is what turns the wall
into the design's marquee), and two network stories beside the real launch
article. The hero is rewritten in full, media included, because writing a
component replaces it. */
/* Finally the home page: the demo set adds twelve organisations to the
network logo wall and two network stories beside the real launch article.
The hero is rewritten in full, media included, because writing a component
replaces it. */
const existingHome = await readExisting(strapi, UID.homePage, ['hero.media']);
const heroMedia = mediaId(existingHome?.hero, 'media');
@@ -344,7 +400,6 @@ async function seedDemo(
...HOME_PAGE[locale],
hero: {
...(HOME_PAGE[locale].hero as SeedData),
...(DEMO_HOME_PAGE[locale].hero as SeedData),
...(heroMedia ? { media: heroMedia } : {}),
},
featuredNews: featured,
@@ -376,7 +431,7 @@ async function cleanDemo(strapi: Core.Strapi): Promise<void> {
/* The home page is approved content with demo fields written onto it, so it
is put back rather than deleted. Deleting the partners above already
emptied the wall; this takes the status pill off again. */
emptied the wall. */
const existingHome = await readExisting(strapi, UID.homePage, ['hero.media']);
const heroMedia = mediaId(existingHome?.hero, 'media');
@@ -420,6 +475,12 @@ export async function runSeed(strapi: Core.Strapi, options: SeedOptions): Promis
return;
}
if (options.legacyOnly) {
if (options.demo) throw new Error('[seed] --legacy-only cannot be combined with --demo.');
await seedLegacyPage(strapi);
return;
}
const context = await seedApproved(strapi);
if (options.demo) {
@@ -435,6 +496,7 @@ export function parseSeedOptions(argv: readonly string[]): SeedOptions {
return {
demo: argv.includes('--demo'),
clean: argv.includes('--clean'),
legacyOnly: argv.includes('--legacy-only'),
};
}
+26 -12
View File
@@ -7,12 +7,13 @@
explicit `--demo --clean`.
THE SHAPE OF A WRITE
A seed entry is always a pair - the English document and its Arabic
A seed entry is always a pair - the Arabic document and its English
translation - because every localised content type on this site ships in
both languages (CLAUDE.md rule 7). `upsert` writes the English document
first, because that is the one carrying the identity (the slug), then
writes the Arabic locale onto the SAME document, which is how Strapi 5
models a translation: one `documentId`, one row per locale.
both languages (CLAUDE.md rule 7) and Arabic is the CMS's default locale.
`upsert` writes the Arabic document first, because that is the one
carrying the identity (the slug), then writes the English locale onto the
SAME document, which is how Strapi 5 models a translation: one
`documentId`, one row per locale.
DRAFT AND PUBLISH
Content is written as a draft and then published explicitly. Publishing
@@ -108,7 +109,7 @@ export async function upsert(
seed ends up with duplicates. */
const existing = await service.findFirst({
...(find ? { filters: find } : {}),
locale: 'en',
locale: 'ar',
...draftFilter(strapi, uid),
});
@@ -117,16 +118,16 @@ export async function upsert(
if (existing) {
documentId = existing.documentId;
await service.update({ documentId, locale: 'en', data: data.en });
await service.update({ documentId, locale: 'ar', data: data.ar });
} else {
const document = await service.create({ locale: 'en', data: data.en });
const document = await service.create({ locale: 'ar', data: data.ar });
documentId = document.documentId;
created = true;
}
/* An update against a locale the document does not have yet creates that
translation - there is no separate "add a locale" call. */
await service.update({ documentId, locale: 'ar', data: data.ar });
await service.update({ documentId, locale: 'en', data: data.en });
if (usesDraft(strapi, uid)) {
await service.publish({ documentId, locale: '*' });
@@ -144,7 +145,7 @@ export async function upsert(
* 1. `delete({ documentId })` with no locale removes the DEFAULT locale
* only, not the document. It takes `locale: '*'` to remove the lot.
* 2. `findMany({ locale })` answers for one locale, so a document whose
* English half has already gone is invisible to a search in English.
* base half has already gone is invisible to a search in that locale.
* Both locales are searched and the documentIds pooled, which also
* sweeps up orphans an earlier, less careful run left.
*/
@@ -217,11 +218,12 @@ export async function readExisting(
strapi: Core.Strapi,
uid: SeedUid,
populate: string[],
find?: SeedData
find?: SeedData,
locale: SeedLocale = 'ar'
): Promise<SeedDocument | null> {
return documents(strapi, uid).findFirst({
...(find ? { filters: find } : {}),
locale: 'en',
locale,
...draftFilter(strapi, uid),
populate,
});
@@ -235,3 +237,15 @@ export function mediaId(component: unknown, field: string): number | undefined {
const id = (media as Record<string, unknown>).id;
return typeof id === 'number' ? id : undefined;
}
/** The ids of populated media entries nested in a component. */
export function mediaIds(component: unknown, field: string): number[] {
if (!component || typeof component !== 'object') return [];
const media = (component as Record<string, unknown>)[field];
if (!Array.isArray(media)) return [];
return media.flatMap((item: unknown) => {
if (!item || typeof item !== 'object') return [];
const id = (item as Record<string, unknown>).id;
return typeof id === 'number' ? [id] : [];
});
}
+46
View File
@@ -169,6 +169,49 @@ export interface PageConsortiumMember extends Struct.ComponentSchema {
};
}
export interface PageLegacyFocusArea extends Struct.ComponentSchema {
collectionName: 'components_page_legacy_focus_areas';
info: {
displayName: 'Legacy Focus Area';
icon: 'bulletList';
};
attributes: {
name: Schema.Attribute.String & Schema.Attribute.Required;
};
}
export interface PageLegacyMetric extends Struct.ComponentSchema {
collectionName: 'components_page_legacy_metrics';
info: {
displayName: 'Legacy Metric';
icon: 'chartBubble';
};
attributes: {
label: Schema.Attribute.String & Schema.Attribute.Required;
value: Schema.Attribute.String & Schema.Attribute.Required;
};
}
export interface PageLegacyStage extends Struct.ComponentSchema {
collectionName: 'components_page_legacy_stages';
info: {
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';
};
attributes: {
description: Schema.Attribute.RichText;
focusAreas: Schema.Attribute.Component<'page.legacy-focus-area', true>;
focusHeading: Schema.Attribute.String;
galleryHeading: Schema.Attribute.String;
images: Schema.Attribute.Media<'images', true>;
metrics: Schema.Attribute.Component<'page.legacy-metric', true>;
name: Schema.Attribute.String & Schema.Attribute.Required;
summary: Schema.Attribute.Text;
years: Schema.Attribute.String;
};
}
export interface PageWorkstream extends Struct.ComponentSchema {
collectionName: 'components_page_workstreams';
info: {
@@ -327,6 +370,9 @@ declare module '@strapi/strapi' {
'navigation.nav-group': NavigationNavGroup;
'page.band-intro': PageBandIntro;
'page.consortium-member': PageConsortiumMember;
'page.legacy-focus-area': PageLegacyFocusArea;
'page.legacy-metric': PageLegacyMetric;
'page.legacy-stage': PageLegacyStage;
'page.workstream': PageWorkstream;
'shared.accordion-item': SharedAccordionItem;
'shared.address': SharedAddress;
+297 -8
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: {
@@ -707,7 +731,7 @@ export interface ApiApplicationApplication extends Struct.CollectionTypeSchema {
> &
Schema.Attribute.Private;
opportunity: Schema.Attribute.Relation<
'oneToOne',
'manyToOne',
'api::opportunity.opportunity'
>;
organisation: Schema.Attribute.String &
@@ -871,7 +895,7 @@ export interface ApiEventRegistrationEventRegistration
Schema.Attribute.Private;
dietaryOrAccess: Schema.Attribute.Text;
email: Schema.Attribute.Email & Schema.Attribute.Required;
event: Schema.Attribute.Relation<'oneToOne', 'api::event.event'>;
event: Schema.Attribute.Relation<'manyToOne', 'api::event.event'>;
fullName: Schema.Attribute.String &
Schema.Attribute.Required &
Schema.Attribute.SetMinMaxLength<{
@@ -990,12 +1014,6 @@ export interface ApiEventEvent extends Struct.CollectionTypeSchema {
};
}> &
Schema.Attribute.DefaultTo<'in-person'>;
gallery: Schema.Attribute.Media<'images', true> &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: true;
};
}>;
implementingPartners: Schema.Attribute.Relation<
'oneToMany',
'api::partner.partner'
@@ -1036,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: {
@@ -1056,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: {
@@ -1360,6 +1402,92 @@ export interface ApiKnowledgeItemKnowledgeItem
};
}
export interface ApiLegacyPageLegacyPage extends Struct.SingleTypeSchema {
collectionName: 'legacy_pages';
info: {
description: 'SHARP I and SHARP II programme history, achievements, metrics, and featured images.';
displayName: 'Legacy Page';
pluralName: 'legacy-pages';
singularName: 'legacy-page';
};
options: {
draftAndPublish: true;
};
pluginOptions: {
i18n: {
localized: true;
};
};
attributes: {
closingBody: Schema.Attribute.RichText &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: true;
};
}>;
closingHeading: Schema.Attribute.String &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: true;
};
}>;
createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
hero: Schema.Attribute.Component<'shared.hero', false> &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: true;
};
}>;
homeEyebrow: Schema.Attribute.String &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: true;
};
}>;
homeHeading: Schema.Attribute.String &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: true;
};
}>;
homeLead: Schema.Attribute.Text &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: true;
};
}>;
locale: Schema.Attribute.String;
localizations: Schema.Attribute.Relation<
'oneToMany',
'api::legacy-page.legacy-page'
>;
publishedAt: Schema.Attribute.DateTime;
seo: Schema.Attribute.Component<'shared.seo', false> &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: true;
};
}>;
stageOne: Schema.Attribute.Component<'page.legacy-stage', false> &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: true;
};
}>;
stageTwo: Schema.Attribute.Component<'page.legacy-stage', false> &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: true;
};
}>;
updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
};
}
export interface ApiNewsArticleNewsArticle extends Struct.CollectionTypeSchema {
collectionName: 'news_articles';
info: {
@@ -1459,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: {
@@ -1472,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<{
@@ -1587,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: {
@@ -1600,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: {
@@ -1626,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: {
@@ -1761,6 +1982,47 @@ export interface ApiRegionRegion extends Struct.CollectionTypeSchema {
};
}
export interface ApiReminderSettingsReminderSettings
extends Struct.SingleTypeSchema {
collectionName: 'reminder_settings';
info: {
description: 'Operational settings for the event reminder emails: whether they are enabled and how long before an event starts the reminder is sent. Read by the cron job only; not exposed over the public API.';
displayName: 'Reminder Settings';
pluralName: 'reminder-settings-list';
singularName: 'reminder-settings';
};
options: {
draftAndPublish: false;
};
attributes: {
createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
enabled: Schema.Attribute.Boolean &
Schema.Attribute.Required &
Schema.Attribute.DefaultTo<true>;
leadHours: Schema.Attribute.Integer &
Schema.Attribute.Required &
Schema.Attribute.SetMinMax<
{
min: 0;
},
number
> &
Schema.Attribute.DefaultTo<48>;
locale: Schema.Attribute.String & Schema.Attribute.Private;
localizations: Schema.Attribute.Relation<
'oneToMany',
'api::reminder-settings.reminder-settings'
> &
Schema.Attribute.Private;
publishedAt: Schema.Attribute.DateTime;
updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
};
}
export interface ApiResourceResource extends Struct.CollectionTypeSchema {
collectionName: 'resources';
info: {
@@ -1896,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: {
@@ -1909,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<{
@@ -2714,10 +3000,13 @@ declare module '@strapi/strapi' {
'api::faq.faq': ApiFaqFaq;
'api::home-page.home-page': ApiHomePageHomePage;
'api::knowledge-item.knowledge-item': ApiKnowledgeItemKnowledgeItem;
'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;
'api::resource.resource': ApiResourceResource;
'api::site-settings.site-settings': ApiSiteSettingsSiteSettings;
'api::topic.topic': ApiTopicTopic;
+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=
+1 -1
View File
@@ -28,7 +28,7 @@ the canonical URL is derived from the request).
```
app/
├── root.tsx HTML shell: <html lang/dir>, canonical + hreflang, <Navigation>, <Footer>
├── routes.ts Route config: "/" redirect, the ":lang" layout, the ten sections
├── routes.ts Route config: "/" redirect, the ":lang" layout, the eleven sections
├── routes/
│ ├── *.tsx One module per page, serving BOTH locales
│ ├── _page-kit/ Section/hero/prose primitives, markdown renderer, CMS helpers
@@ -16,7 +16,7 @@ export type Locale = (typeof LOCALES)[number];
export type Direction = "ltr" | "rtl";
export const DEFAULT_LOCALE: Locale = "en";
export const DEFAULT_LOCALE: Locale = "ar";
/** Locale -> the value that belongs in <html dir>. */
const DIRECTION_BY_LOCALE: Record<Locale, Direction> = {
@@ -42,11 +42,17 @@ export interface BrandProps {
/** Accessible name of the link; the images are decorative. */
label: string;
surface: NavSurface;
/**
* Run the "-ER" reveal and its breathing loop. Off on the home page, whose
* hero plays the same reveal once - a second, looping copy beside it is
* jarring. Anywhere else the two animations run as designed.
*/
animate?: boolean;
className?: string;
}
/** The SHARP-ER wordmark, linking home. Sits at the inline start of the bar. */
export function Brand({ href, label, surface, className }: BrandProps) {
export function Brand({ href, label, surface, animate = true, className }: BrandProps) {
const { base, suffix } = BRAND_LOCKUP[surface];
return (
@@ -75,10 +81,12 @@ export function Brand({ href, label, surface, className }: BrandProps) {
/>
{/* The clipping box starts at the P's right edge, so the mark inside
it can slide out from behind the P (Navigation.css). */}
it can slide out from behind the P (Navigation.css). Without
`animate` the box and the reveal classes are dropped and the full
lockup simply sits there. */}
<span
style={{ flex: `${suffix.drawnWidth} 1 0%` }}
className="sh-brand-suffix block min-w-0"
className={cx("block min-w-0", animate && "sh-brand-suffix")}
>
<img
src={suffix.src}
@@ -88,7 +96,7 @@ export function Brand({ href, label, surface, className }: BrandProps) {
loading="eager"
decoding="async"
draggable={false}
className="sh-brand-suffix__mark block h-auto w-full"
className={cx("block h-auto w-full", animate && "sh-brand-suffix__mark")}
/>
</span>
</span>
@@ -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. */
@@ -268,7 +268,12 @@ export function MobileNav({
{/* Mirrors the compact bar it replaces (20:220): the same 64px row and
the same gutter, so the wordmark does not shift when it opens. */}
<div className="flex h-[64px] shrink-0 items-center justify-between gap-md px-[var(--layout-gutter)]">
<Brand href={homeHref} label={strings.homeLinkLabel} surface={surface} />
<Brand
href={homeHref}
label={strings.homeLinkLabel}
surface={surface}
animate={!isCurrentPath(homeHref, pathname)}
/>
<button
ref={closeRef}
type="button"
@@ -29,20 +29,33 @@
last, slowest part of the decelerating slide. The translate is physical
(translateX) on purpose: the lockup row is pinned to dir="ltr" because a
Latin wordmark never mirrors, so the P is always left of the suffix, in
Arabic too. Pure CSS, so it runs from the server-rendered HTML without
waiting for hydration, and the `both` fill keeps the suffix hidden through
the delay instead of flashing in first. It replays whenever the navigation
mounts again, e.g. on a client-side navigation back to a page. Under
prefers-reduced-motion the animation is removed outright - app.css only
collapses the duration and would leave the delay holding the suffix
hidden - so the full name is simply there from the start. */
Arabic too.
After that one-time reveal the mark keeps breathing: after a long rest
(~26s of stillness per cycle) it slides back out from behind the P and
returns, so the wordmark quietly draws the eye without the glyphs moving
while they are visible. On the home page none of this runs (Brand.tsx
drops the classes when the lockup links the page you are on): the hero
there plays the same reveal once, and a second, looping copy right above
it would be jarring. The two
animations are sequenced by delay - the reveal (once, `both` fill, so the
suffix stays hidden through its delay instead of flashing in) hands over
to the loop, whose later position in the list wins from its start, and
whose own delay is when the revealed lockup is simply at rest. Pure CSS,
so it runs from the server-rendered HTML without waiting for hydration.
Under prefers-reduced-motion both animations are removed outright -
app.css only collapses the duration and would leave the delay holding the
suffix hidden - so the full name is simply there from the start. */
.sh-brand-suffix {
overflow: hidden;
}
.sh-brand-suffix__mark {
animation: sh-brand-suffix-in var(--motion-duration-reveal)
var(--motion-ease-entrance) var(--motion-delay-reveal) both;
animation:
sh-brand-suffix-in var(--motion-duration-reveal)
var(--motion-ease-entrance) var(--motion-delay-reveal) both,
sh-brand-breathe calc(var(--motion-duration-reveal) * 37.5)
ease-in-out calc(var(--motion-duration-reveal) * 5) infinite;
}
@media (prefers-reduced-motion: reduce) {
@@ -68,6 +81,53 @@
}
}
/* One 30s breath: dwell at rest (~26s - the stillness is what stretches, the
gestures stay at a human pace), slide back out behind the P (the reverse of
the reveal - solid while it pulls away, fading as it leaves), pass on
hidden, then play the reveal again, and hold. Looped, with the cycle ending
where it starts so the seam is invisible. Each slide is ONE eased interval
of roughly one reveal duration - the opacity stop inside the out-slide is
opacity-only, so it never splits the transform's easing and the glyphs
cannot stall or hitch mid-gesture. */
@keyframes sh-brand-breathe {
/* settled */
0%, 87% {
transform: translateX(0);
opacity: 1;
animation-timing-function: var(--motion-ease-standard);
}
/* pull back solid - opacity only, so the 87→90 transform still runs one
eased interval and the slide never hitches */
88.5% {
opacity: 1;
}
/* gone, behind the P */
90% {
transform: translateX(-100%);
opacity: 0;
}
/* pass, hidden, then the reveal again - one eased slide back in */
94% {
transform: translateX(-100%);
opacity: 0;
animation-timing-function: var(--motion-ease-entrance);
}
97% {
transform: translateX(0);
opacity: 1;
}
/* settled until the cycle hands over (0% is settled too) */
100% {
transform: translateX(0);
opacity: 1;
}
}
@keyframes sh-nav-menu-in {
from {
opacity: 0;
@@ -3,6 +3,7 @@ import { useMemo } from "react";
import {
buildLocalizedHref,
getDirection,
isCurrentPath,
LanguageToggle,
type Locale,
} from "../LanguageSwitcher";
@@ -114,6 +115,9 @@ export function Navigation({
href={resolvedHomeHref}
label={strings.homeLinkLabel}
surface={surface}
/* The home page's own hero plays the reveal once; the brand beside
it stays still there instead of running a second copy. */
animate={!isCurrentPath(resolvedHomeHref, pathname)}
/>
<DesktopNav
@@ -1,7 +1,7 @@
/* ============================================================================
DEFAULT INFORMATION ARCHITECTURE
----------------------------------------------------------------------------
The ten top-level destinations from the Figma navigation (frame 20:236) and
The top-level destinations from the Figma navigation (frame 20:236) and
ToR 3.2. The app shell normally feeds `items` to <Navigation /> from the
Strapi site-settings single type; this module is the typed fallback that
renders when Strapi has not been wired yet.
@@ -30,6 +30,7 @@ import type { NavItem, NavigationStrings, NavigationStringsByLocale } from "./ty
export const NAV_PATHS = {
home: "/",
about: "/about",
legacy: "/legacy",
whatWeDo: "/what-we-do",
activities: "/activities-and-initiatives",
events: "/events-calendar",
@@ -53,6 +54,7 @@ const LABELS: Record<Locale, Record<LabelKey, string>> = {
home: "Home",
aboutAndStrategy: "About and Strategy",
about: "About",
legacy: "SHARP Legacy",
whatWeDo: "What We Do",
activities: "Activities and Initiatives",
engagementAndOpportunities: "Engagement and Opportunities",
@@ -68,6 +70,7 @@ const LABELS: Record<Locale, Record<LabelKey, string>> = {
home: "الرئيسية",
aboutAndStrategy: "عن المشروع والاستراتيجية",
about: "عن المشروع",
legacy: "إرث شارب",
whatWeDo: "ماذا نفعل",
activities: "الأنشطة والمبادرات",
engagementAndOpportunities: "المشاركة والفرص",
@@ -96,6 +99,7 @@ export function getDefaultNavItems(locale: Locale): NavItem[] {
label: label.aboutAndStrategy,
children: [
{ id: "about", label: label.about, href: href("about") },
{ id: "legacy", label: label.legacy, href: href("legacy") },
{ id: "what-we-do", label: label.whatWeDo, href: href("whatWeDo") },
{ id: "activities", label: label.activities, href: href("activities") },
],
@@ -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({
+1 -1
View File
@@ -56,7 +56,7 @@ export interface Translation {
readonly messages: Messages;
/** `/{locale}` prefixed URL for an unprefixed path. */
readonly localizedPath: (path: string) => string;
/** The locale-prefixed URL of one of the ten sections. */
/** The locale-prefixed URL of one of the eleven sections. */
readonly sectionPath: (key: RouteSlugKey) => string;
}
+12 -2
View File
@@ -24,6 +24,7 @@
"home": "الرئيسية",
"aboutAndStrategy": "عن المشروع والاستراتيجية",
"about": "عن المشروع",
"legacy": "إرث شارب",
"whatWeDo": "ماذا نفعل",
"activities": "الأنشطة والمبادرات",
"engagementAndOpportunities": "المشاركة والفرص",
@@ -42,6 +43,12 @@
"en": "English",
"ar": "العربية"
},
"legacy": {
"fullRecord": "اقرأ التفاصيل الكاملة لبرنامج {programme}",
"previousImage": "الصورة السابقة من {programme}",
"nextImage": "الصورة التالية من {programme}",
"imageCount": "الصورة {current} من {total}"
},
"actions": {
"readMore": "اقرأ المزيد",
"learnMore": "اعرف المزيد",
@@ -67,7 +74,8 @@
"applyToJoin": "قدّم طلب الانضمام",
"viewUpcomingSessions": "اطّلع على الجلسات القادمة",
"submitYourUpdate": "شاركونا مستجداتكم",
"addToCalendar": "أضف إلى التقويم",
"addToGoogleCalendar": "أضف إلى تقويم Google",
"downloadIcsFile": "تنزيل ملف .ics",
"tryAgain": "حاول مرة أخرى"
},
"filters": {
@@ -122,7 +130,7 @@
"joinWaitlist": "الانضمام إلى قائمة الانتظار",
"confirmationTitle": "تم تسجيلك بنجاح",
"confirmationBody": "شكرًا لك — تم تأكيد مكانك في {event} بتاريخ {date}. سنرسل لك تذكيرًا وأي تفاصيل إضافية على البريد الإلكتروني الذي قدّمته.",
"noEvents": "لا توجد فعاليات لعرضها بعد."
"noEvents": "سيتم الإعلان عن الفعاليات القادمة قريباً."
},
"news": {
"tabProject": "أخبار SHARP-ER",
@@ -160,6 +168,8 @@
"errorEmail": "أدخل بريدًا إلكترونيًا صالحًا.",
"errorConsent": "يرجى تأكيد الموافقة لإتمام التسجيل.",
"errorGeneric": "حدث خطأ ما. يرجى المحاولة مرة أخرى.",
"errorVerification": "تعذّر إكمال التحقق الأمني. يرجى المحاولة مرة أخرى.",
"verificationUnavailable": "تعذّر تحميل التحقق الأمني. يرجى التأكد من تفعيل JavaScript ثم إعادة تحميل الصفحة.",
"successTitle": "شكرًا لك",
"successBody": "تم إرسال رسالتك. سنعاود التواصل معك في أقرب وقت ممكن."
},
+11 -1
View File
@@ -24,6 +24,7 @@
"home": "Home",
"aboutAndStrategy": "About and Strategy",
"about": "About",
"legacy": "SHARP Legacy",
"whatWeDo": "What We Do",
"activities": "Activities and Initiatives",
"engagementAndOpportunities": "Engagement and Opportunities",
@@ -42,6 +43,12 @@
"en": "English",
"ar": "العربية"
},
"legacy": {
"fullRecord": "Read the full {programme} record",
"previousImage": "Previous {programme} image",
"nextImage": "Next {programme} image",
"imageCount": "Image {current} of {total}"
},
"actions": {
"readMore": "Read more",
"learnMore": "Learn more",
@@ -67,7 +74,8 @@
"applyToJoin": "Apply to join",
"viewUpcomingSessions": "View upcoming sessions",
"submitYourUpdate": "Submit your update",
"addToCalendar": "Add to calendar",
"addToGoogleCalendar": "Add to Google Calendar",
"downloadIcsFile": "Download .ics file",
"tryAgain": "Try again"
},
"filters": {
@@ -160,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."
},
+1 -1
View File
@@ -60,7 +60,7 @@ export function alternatePath(
return buildLocalizedHref(pathname, targetLocale, options);
}
/** The locale-prefixed URL of one of the ten sections. */
/** The locale-prefixed URL of one of the eleven sections. */
export function sectionPath(key: RouteSlugKey, locale: Locale): string {
return localizePath(sectionSlugPath(key), locale);
}
+10 -10
View File
@@ -5,8 +5,8 @@
plus {placeholder} interpolation. No i18next, no ICU MessageFormat - the
catalogues hold UI chrome only, page prose comes from Strapi.
English is the reference catalogue: `Messages` is derived from en.json, and
ar.json is typechecked against it, so a key added on one side and forgotten
Arabic is the reference catalogue: `Messages` is derived from ar.json, and
en.json is typechecked against it, so a key added on one side and forgotten
on the other is a compile error rather than a blank label in production.
========================================================================= */
@@ -15,8 +15,8 @@ import ar from "./messages/ar.json";
import { DEFAULT_LOCALE, type Locale } from "./config";
/** The shape of every catalogue. Derived from the English source of truth. */
export type Messages = typeof en;
/** The shape of every catalogue. Derived from the Arabic source of truth. */
export type Messages = typeof ar;
/** Anything that may be interpolated into a message. */
export type TranslationParams = Readonly<
@@ -44,13 +44,13 @@ export type Translator = (
params?: TranslationParams,
) => string;
/* The `Messages` annotation is the parity check: ar.json must supply every
key en.json declares, with a string in every leaf position. */
const arabicMessages: Messages = ar;
/* The `Messages` annotation is the parity check: en.json must supply every
key ar.json declares, with a string in every leaf position. */
const englishMessages: Messages = en;
const CATALOGUES: Readonly<Record<Locale, Messages>> = {
en,
ar: arabicMessages,
en: englishMessages,
ar,
};
/** The whole catalogue for a locale - handy for spreading into a component. */
@@ -90,7 +90,7 @@ export function interpolate(
}
/**
* Translate one key. Falls back to the English string when a locale is
* Translate one key. Falls back to the default-locale string when a locale is
* missing it, and finally to the key itself so a page never renders "".
*/
export function translate(
+25 -2
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",
};
@@ -120,8 +143,8 @@ export function resetServerEnv(): void {
* the incoming request is trusted, honouring the standard forwarding headers.
*/
export function getSiteUrl(request: Request): string {
const { SITE_URL } = getServerEnv();
if (process.env.SITE_URL?.trim()) return SITE_URL;
const env = getServerEnv();
if (process.env.SITE_URL?.trim() || env.isProduction) return env.SITE_URL;
const url = new URL(request.url);
const forwardedHost =
+4 -3
View File
@@ -1,5 +1,5 @@
/* ============================================================================
ROUTE SLUGS - the single source of truth for the site's ten sections
ROUTE SLUGS - the single source of truth for the site's eleven sections
----------------------------------------------------------------------------
This module has ZERO imports on purpose: it is loaded by `app/routes.ts`,
which the React Router dev plugin evaluates in Node while building the route
@@ -23,6 +23,7 @@
export const ROUTE_SLUGS = {
home: "",
about: "about",
legacy: "legacy",
whatWeDo: "what-we-do",
activities: "activities-and-initiatives",
events: "events-calendar",
@@ -41,7 +42,7 @@ export type RouteSlugKey = keyof typeof ROUTE_SLUGS;
*/
export type RouteSlugs = typeof ROUTE_SLUGS;
/** The ten section keys in navigation order. */
/** The section keys in navigation order. */
export const ROUTE_SLUG_KEYS = Object.keys(ROUTE_SLUGS) as readonly RouteSlugKey[];
/**
@@ -57,7 +58,7 @@ export function sectionSlugPath(key: RouteSlugKey): string {
export const ROUTE_SECTION_GROUPS = [
{
id: "about-and-strategy",
children: ["about", "whatWeDo", "activities"],
children: ["about", "legacy", "whatWeDo", "activities"],
},
{
id: "engagement-and-opportunities",
+5 -4
View File
@@ -14,6 +14,7 @@
import type { MetaDescriptor } from "react-router";
import {
DEFAULT_LOCALE,
INTL_LOCALE,
OG_LOCALE,
SUPPORTED_LOCALES,
@@ -103,8 +104,8 @@ export interface LocaleAlternate {
/**
* The hreflang set for a page: one entry per locale plus `x-default`, which
* points at the English URL because English is the default locale that bare
* "/" negotiates towards.
* points at the default-locale URL because that is what a bare "/" resolves
* to when no locale can be negotiated.
*/
export function alternateUrls(
siteUrl: string,
@@ -121,9 +122,9 @@ export function alternateUrls(
return [
...alternates,
{
locale: "en",
locale: DEFAULT_LOCALE,
hrefLang: "x-default",
href: canonicalUrl(siteUrl, localizedPath(unprefixed, "en")),
href: canonicalUrl(siteUrl, localizedPath(unprefixed, DEFAULT_LOCALE)),
},
];
}
+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;
}
}
+27 -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;
@@ -203,6 +213,20 @@ 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);}
gtag('js', new Date());
gtag('config', 'G-TJT6NM2DMT');`,
}}
/>
</head>
<body className="flex min-h-dvh flex-col">
<a
@@ -231,8 +255,8 @@ export function Layout({ children }: { children: ReactNode }) {
linkGroups={footerLinkGroups}
/>
<ScrollRestoration />
<Scripts />
<ScrollRestoration nonce={nonce} />
<Scripts nonce={nonce} />
</body>
</html>
);
+5 -4
View File
@@ -5,7 +5,7 @@
/ -> 302 to /en or /ar (Accept-Language)
/:lang -> home
/:lang/<section> -> one of the ten top-level sections
/:lang/<section> -> one of the eleven top-level sections
/:lang/<section>/<slug> -> one entry of a CMS-backed section
/:lang/events-calendar/<slug>.ics -> that event as an RFC 5545 file
anything else -> 404
@@ -43,7 +43,7 @@
ROUTE IDS
---------
"root" | "routes/locale-redirect" | "locale" | "routes/not-found", one per
section named after its key: "home", "about", "whatWeDo", "activities",
section named after its key: "home", "about", "legacy", "whatWeDo", "activities",
"events", "applications", "news", "contact", "resources", "knowledgeHub" -
and one per detail route, the section key plus "Detail" ("newsDetail",
"eventsDetail", ...), plus "eventsIcs". So `useRouteLoaderData("locale")`
@@ -76,6 +76,7 @@ import { ROUTE_SLUGS, type RouteSlugKey } from "./lib/route-slugs";
const SECTION_MODULES = {
home: "routes/home.tsx",
about: "routes/about.tsx",
legacy: "routes/legacy.tsx",
whatWeDo: "routes/what-we-do.tsx",
activities: "routes/activities-and-initiatives.tsx",
events: "routes/events-calendar.tsx",
@@ -89,7 +90,7 @@ const SECTION_MODULES = {
/**
* Section -> the module that renders ONE entry of it, at
* `<section slug>/:slug`. Only the six CMS-backed sections have one; "about",
* "what-we-do", "contact" and the home page are single pages with nothing to
* "legacy", "what-we-do", "contact" and the home page are single pages with nothing to
* drill into.
*/
const DETAIL_MODULES = {
@@ -117,7 +118,7 @@ const RESOURCE_ROUTES = [
const detailKeys = Object.keys(DETAIL_MODULES) as (keyof typeof DETAIL_MODULES)[];
/**
* Every page under ":lang": the ten sections, then the six detail routes, then
* Every page under ":lang": the eleven sections, then the six detail routes, then
* the .ics resource route. Flat on purpose - see "LISTING AND DETAIL ARE
* SIBLINGS" above.
*/
@@ -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",
@@ -65,7 +65,8 @@ const EN = {
eventCompleted: "This event has already taken place.",
joinOnlineLabel: "Joining link",
addToCalendarHint:
"Downloads an .ics file you can open in any calendar application.",
"The .ics file is compatible with Calendar on Apple devices and " +
"other calendar apps.",
timeZoneNote: "All times are shown in Libya time (UTC+2).",
/**
* `event-registration.consentGdpr` is REQUIRED in the CMS, so the reader
@@ -85,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",
@@ -114,9 +115,8 @@ const EN = {
uploaded here (see `documentsNote`), so no limit is quoted at all.
* "an automatic acknowledgment with your reference number" - there is no
reference-number field anywhere in the CMS, so no number is promised.
The acknowledgement itself IS modelled, as
`application.acknowledgementSentAt`, so it is the only half that
survives.
The acknowledgement itself IS sent: the CMS mails it on submission
and stamps `application.acknowledgementSentAt` when it went out.
* "follows up within four weeks" - that response time appears in no
approved document; §8 of the draft marks it "[X weeks] - TBD". The
follow-up is stated without a period attached, exactly as the per-call
@@ -168,7 +168,7 @@ const EN = {
*/
documentsNote:
"Documents are not uploaded through this form. Describe any supporting material in the summary above.",
/** `application.acknowledgementSentAt` - modelled, so it can be said. */
/** `application.acknowledgementSentAt` - sent, not just modelled. */
acknowledgement: "Your submission is acknowledged automatically by email.",
submit: "Submit application",
errorSummaryTitle: "Please check the form",
@@ -233,12 +233,12 @@ const AR: RouteCopy = {
},
heading: "الفعاليات القادمة",
intro:
"سجّل في حوار SUQ، أو انضم إلى تدريب، أو تابع ما هو قادم في مختلف أنحاء ليبيا.",
"ابقَ على اطلاع بجلسات الحوار القادمة، وفرص التدريب، وفعاليات التشبيك في جميع أنحاء ليبيا. استخدم التقويم لمعرفة تفاصيل التسجيل، ومحاور الفعاليات، والمواد المرفقة بها.",
metaDescription:
"حوارات وتدريبات وجلسات قانون المجتمع المدني في مختلف أنحاء ليبيا. سجّل في حوار SUQ، أو انضم إلى تدريب، أو تابع ما هو قادم.",
listLabel: "الفعاليات",
statusFilterLabel: "الحالة",
emptyHint: "تُنشر هنا الحوارات والتدريبات والجلسات فور تحديد مواعيدها.",
emptyHint: "ستُنشر هنا الحوارات والتدريبات والجلسات فور تحديد مواعيدها.",
unavailable:
"تعذّر تحميل تقويم الفعاليات في الوقت الحالي. يرجى المحاولة مرة أخرى بعد قليل.",
formatHybrid: "حضوري وعبر الإنترنت",
@@ -260,7 +260,9 @@ const AR: RouteCopy = {
"اكتمل العدد في هذه الفعالية. انضم إلى قائمة الانتظار وسنتواصل معك في حال توفّر مكان.",
eventCompleted: "انتهت هذه الفعالية.",
joinOnlineLabel: "رابط الانضمام",
addToCalendarHint: "يُنزّل ملف .ics يمكن فتحه في أي تطبيق تقويم.",
addToCalendarHint:
"ملف .ics متوافق مع تطبيق التقويم على أجهزة Apple ومع تطبيقات " +
"التقويم الأخرى.",
timeZoneNote: "جميع الأوقات معروضة بتوقيت ليبيا (UTC+2).",
consentLabel:
"أوافق على أن يحتفظ مشروع SHARP-ER بالبيانات أعلاه لإدارة تسجيلي في هذه الفعالية.",
@@ -273,7 +275,7 @@ const AR: RouteCopy = {
opportunities: {
heading: "شارك معنا",
intro:
"سواء كنتم منظمة مجتمع مدني أو فردًا، إليكم كيفية المشاركة في SHARP-ER.",
"يربط برنامج شارب-إي آر (SHARP-ER) الأفراد والمنظمات بفرص التعلم والمشاركة والتعاون والقيادة. سواء كنتم أفراداً أو منظمات مجتمع مدني، استكشفوا دعوات التقديم الحالية، والبرامج التدريبية، وفرص الشراكة أدناه.",
metaDescription:
"دعوات للتعبير عن الاهتمام، وعضوية شبكة SHARP، وجلسات قادمة — كيف تشارك منظمات المجتمع المدني والأفراد في SHARP-ER.",
pathwaysHeading: "طرق المشاركة",
@@ -147,6 +147,7 @@ function toCard(entry: StrapiEvent, now: Date): EventCardData {
registrationStatus:
oneOf(entry.registrationStatus, EVENT_REGISTRATION_STATUSES) ??
"not-required",
registrationDeadline: text(entry.registrationDeadline),
timeStatus: deriveTimeStatus({ start, end, allDay }, now),
recapUrl: text(entry.recapUrl),
image: toImage(entry.coverImage, "medium"),
@@ -114,6 +114,7 @@ export interface EventCardData {
/** Name of the related region entry, not a free-text string. */
readonly region?: string;
readonly registrationStatus: EventRegistrationStatus;
readonly registrationDeadline?: string;
readonly timeStatus: EventTimeStatus;
/** Set on a past event that has a write-up - drives the "Read recap" label. */
readonly recapUrl?: string;
@@ -126,7 +127,6 @@ export interface EventDetailData extends EventCardData {
/** The long body. There is no external registration portal field in the CMS. */
readonly description?: CmsRichText;
readonly onlineUrl?: string;
readonly registrationDeadline?: string;
readonly venue?: string;
/** Editorial: the CMS offers a waitlist once the event is full. */
readonly waitlistEnabled: boolean;
@@ -330,8 +330,16 @@ export function collectRegions(
* reader simply turns up, so there is nothing to submit; "closed" speaks for
* itself; "full" and "waitlist" still take names for the waiting list.
*/
export function isRegistrationOpen(event: EventCardData): boolean {
export function isRegistrationOpen(
event: EventCardData,
now = Date.now(),
): boolean {
const deadline = event.registrationDeadline
? Date.parse(event.registrationDeadline)
: undefined;
return (
(deadline === undefined || (Number.isFinite(deadline) && now < deadline)) &&
event.timeStatus !== "completed" &&
(event.registrationStatus === "open" ||
event.registrationStatus === "full" ||
@@ -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";
@@ -1,7 +1,7 @@
/* ============================================================================
iCALENDAR (RFC 5545)
----------------------------------------------------------------------------
Builds the file behind "Add to calendar". Pure, dependency-free and written
Builds the file behind "Download .ics file". Pure, dependency-free and written
against the spec rather than against one client's tolerance:
* CRLF line endings, and content lines folded at 75 OCTETS - folded on
@@ -19,6 +19,7 @@ const DEFAULT_TIMEOUT_MS = 10_000;
export interface StrapiWriteInit {
readonly timeoutMs?: number;
readonly signal?: AbortSignal;
readonly forwardedFor?: string;
}
interface StrapiErrorEnvelope {
@@ -49,6 +50,7 @@ export async function createStrapiEntry<T>(
accept: "application/json",
};
if (env.STRAPI_TOKEN) headers.authorization = `Bearer ${env.STRAPI_TOKEN}`;
if (init.forwardedFor) headers["x-forwarded-for"] = init.forwardedFor;
const timeoutMs = init.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const timeoutSignal = AbortSignal.timeout(timeoutMs);
@@ -68,12 +68,17 @@ export interface RegistrationSubmission {
readonly values: RegistrationValues;
/** documentId of the event being registered for. */
readonly eventId: string;
readonly forwardedFor?: string;
}
export async function submitRegistration(
input: RegistrationSubmission,
): Promise<void> {
await createStrapiEntry(EVENT_REGISTRATIONS_RESOURCE, {
): Promise<"confirmed" | "waitlisted"> {
const created = await createStrapiEntry<{
readonly registrationState?: unknown;
}>(
EVENT_REGISTRATIONS_RESOURCE,
{
event: input.eventId,
fullName: input.values.fullName,
email: input.values.email,
@@ -81,13 +86,25 @@ export async function submitRegistration(
organisation: optional(input.values.organisation),
// Required in the schema: without it every POST is a 400.
consentGdpr: input.values.consentGdpr,
});
},
{ forwardedFor: input.forwardedFor },
);
if (
created?.registrationState !== "confirmed" &&
created?.registrationState !== "waitlisted"
) {
throw new Error("Strapi returned an invalid registration state.");
}
return created.registrationState;
}
export interface ApplicationSubmission {
readonly values: ApplicationValues;
/** documentId of the opportunity being applied to. */
readonly opportunityId: string;
readonly forwardedFor?: string;
}
/** The key the free-text answer is filed under inside `answers` (json). */
@@ -96,7 +113,9 @@ export const APPLICATION_MESSAGE_KEY = "message";
export async function submitApplication(
input: ApplicationSubmission,
): Promise<void> {
await createStrapiEntry(APPLICATIONS_RESOURCE, {
await createStrapiEntry(
APPLICATIONS_RESOURCE,
{
opportunity: input.opportunityId,
// `fullName` is the registration's field name; an application's is
// `applicantName`.
@@ -106,7 +125,9 @@ export async function submitApplication(
// `answers` is a json field - the form's one free-text question goes in
// it, keyed, so more questions can be added without another migration.
answers: { [APPLICATION_MESSAGE_KEY]: input.values.message },
});
},
{ forwardedFor: input.forwardedFor },
);
}
/* ---------------------------------------------------------------------------
@@ -134,6 +155,7 @@ export const APPLICATION_ANSWER_KEYS = {
export interface PathwayApplicationSubmission {
readonly values: PathwayApplicationValues;
readonly forwardedFor?: string;
/**
* documentId of the opportunity this application belongs to, when one is
* known. The Get Involved form is NOT tied to a single call - it is the
@@ -159,7 +181,9 @@ export async function submitPathwayApplication(
): Promise<void> {
const { values } = input;
await createStrapiEntry(APPLICATIONS_RESOURCE, {
await createStrapiEntry(
APPLICATIONS_RESOURCE,
{
...(input.opportunityId ? { opportunity: input.opportunityId } : {}),
applicantName: values.fullName,
email: values.email,
@@ -176,5 +200,7 @@ export async function submitPathwayApplication(
: {}),
[APPLICATION_ANSWER_KEYS.summary]: values.summary,
},
});
},
{ forwardedFor: input.forwardedFor },
);
}
+9 -1
View File
@@ -40,6 +40,8 @@ export interface AccordionProps {
*/
defaultOpenIndex?: number | null;
className?: string;
/** Extra classes for each question row, e.g. a scroll-driven reveal. */
itemClassName?: string;
}
/**
@@ -75,7 +77,12 @@ function ToggleMark({ open }: { open: boolean }) {
);
}
export function Accordion({ items, defaultOpenIndex = 0, className }: AccordionProps) {
export function Accordion({
items,
defaultOpenIndex = 0,
className,
itemClassName,
}: AccordionProps) {
const baseId = useId();
const initial =
defaultOpenIndex === null ? null : (items[defaultOpenIndex]?.id ?? null);
@@ -97,6 +104,7 @@ export function Accordion({ items, defaultOpenIndex = 0, className }: AccordionP
"border-b border-[var(--border-default)]",
"transition-colors duration-[var(--motion-duration-base)]",
"hover:bg-[var(--surface-muted)]",
itemClassName,
)}
>
<button
@@ -206,6 +206,46 @@ export interface CmsAboutPage extends StrapiEntity {
readonly seo?: CmsSeo | null;
}
/** page.legacy-metric */
export interface CmsLegacyMetric {
readonly id?: number;
readonly value: string;
readonly label: string;
}
/** page.legacy-focus-area */
export interface CmsLegacyFocusArea {
readonly id?: number;
readonly name: string;
}
/** page.legacy-stage */
export interface CmsLegacyStage {
readonly id?: number;
readonly name?: string | null;
readonly years?: string | null;
readonly summary?: string | null;
readonly description?: string | null;
readonly metrics?: readonly CmsLegacyMetric[] | null;
readonly focusHeading?: string | null;
readonly focusAreas?: readonly CmsLegacyFocusArea[] | null;
readonly galleryHeading?: string | null;
readonly images?: readonly StrapiMedia[] | null;
}
/** api::legacy-page.legacy-page */
export interface CmsLegacyPage extends StrapiEntity {
readonly homeEyebrow?: string | null;
readonly homeHeading?: string | null;
readonly homeLead?: string | null;
readonly hero?: CmsHero | null;
readonly stageOne?: CmsLegacyStage | null;
readonly stageTwo?: CmsLegacyStage | null;
readonly closingHeading?: string | null;
readonly closingBody?: string | null;
readonly seo?: CmsSeo | null;
}
/** api::what-we-do-page.what-we-do-page */
export interface CmsWhatWeDoPage extends StrapiEntity {
readonly hero?: CmsHero | null;
+274 -15
View File
@@ -31,13 +31,10 @@
overridden by the matching Strapi field the moment an editor fills it in,
and the pages read `cms ?? draft`.
WHAT IS DELIBERATELY ABSENT
Figures. The draft marks every number "TBD" and says they must not be
published until confirmed, so there is no statistic anywhere in this file.
Figma hides the home page's figures band outright (frame 253:4835 is
hidden="true") and the third design drop deletes it, so the band is gone
from the page and the `stats` component is gone from the CMS. There is
nowhere left for a figure to be typed in, which is the point.
HISTORICAL FIGURES
The general SHARP-ER home draft marks current-programme figures "TBD";
this file adds only the SHARP I and SHARP II historical figures from the
supplied legacy content.
========================================================================= */
import type { Locale } from "~/i18n";
@@ -104,6 +101,44 @@ export interface HomeCopy {
readonly partnersEmpty: string;
}
export interface LegacyMetric {
readonly value: string;
readonly label: string;
}
export interface LegacyGalleryImage {
readonly src: string;
readonly alt: string;
readonly caption?: string;
}
export interface LegacyStageCopy {
readonly name: string;
readonly years: string;
readonly summary: string;
readonly description: string;
readonly metrics: readonly LegacyMetric[];
readonly focusHeading: string;
readonly focusAreas: readonly string[];
readonly imageHeading: string;
readonly imageEmpty: string;
readonly images: readonly LegacyGalleryImage[];
}
export interface LegacyCopy {
readonly homeEyebrow: string;
readonly homeHeading: string;
readonly homeLead: string;
readonly homeLinkLabel: string;
readonly heroEyebrow: string;
readonly heroTitle: string;
readonly heroLead: string;
readonly stageOne: LegacyStageCopy;
readonly stageTwo: LegacyStageCopy;
readonly closingHeading: string;
readonly closingParagraphs: readonly string[];
}
export interface AboutCopy {
/** Draft eyebrow over the hero headline - "About the project". */
readonly heroEyebrow: string;
@@ -170,8 +205,6 @@ export interface ContactCopy {
readonly websiteHref: string;
readonly websiteText: string;
readonly contactPending: string;
readonly consortiumHeading: string;
readonly consortiumEmpty: string;
readonly sendAnother: string;
readonly errorSummaryTitle: string;
readonly errorTooLong: string;
@@ -184,6 +217,7 @@ export interface ContactCopy {
export interface PageCopy {
readonly home: HomeCopy;
readonly about: AboutCopy;
readonly legacy: LegacyCopy;
readonly whatWeDo: WhatWeDoCopy;
readonly contact: ContactCopy;
}
@@ -241,6 +275,121 @@ const EN: PageCopy = {
"Partner organisations will be listed here as they join the SHARP-ER network.",
},
legacy: {
homeEyebrow: "SHARP I and SHARP II",
homeHeading: "Dialogue and local action in Libya, 2019–2024",
homeLead:
"Two EU-funded programmes supported rule of law dialogue, civil society capacity, and local action across Libya.",
homeLinkLabel: "Explore this phase",
heroEyebrow: "SHARP I and SHARP II",
heroTitle: "Dialogue and local action across Libya",
heroLead:
"From November 2019 to August 2024, two EU-funded programmes supported rule of law dialogue, civil society capacity, and locally led action.",
stageOne: {
name: "SHARP I",
years: "November 2019 – August 2022",
summary:
"Built dialogue and local capacity through research, learning, and community-led action.",
description: `The EU-funded SHARP I programme was implemented by Institute Clingendael and Bureau Ensemble with Libyan civil society organisations, local authorities, and national institutions. From November 2019 to August 2022, it worked to strengthen Libya’s rule of law through dialogue, research, and local action. Budget: €1.996 million.
### Objectives
- Strengthen Libyan institutions’ ability to respond to rule of law challenges.
- Build the organisational capabilities of Libyan change agents.
- Foster dialogue and information-sharing between stakeholders.
### Dialogue and learning
The programme held 102 Search, Understand, and Question (SUQ) dialogues with 1,583 participants; 48% of participants were women. Topics included human rights, transitional justice, and local governance. A further 46 Training & Learning sessions covered facilitation, project management, and monitoring.
### Community action
Twenty-one sub-contract actions addressed conflict mediation, access to justice, women’s rights, labour rights, and civil-society and media engagement. They produced 62 workshops, 6 research papers, 24 training sessions, and 1,555 media outreach activities.
### Results and lasting value
87% of SUQ participants considered the discussions relevant, and 75% believed SHARP could contribute to rule of law change. Projects strengthened CSO reporting skills and partnerships with local authorities. Libyan-led initiatives built community trust; the programme adapted to pandemic and security constraints with virtual activities and local dialogue, helping CSOs develop lasting networks.
### What followed
The EU approved SHARP II to build on these lessons: deepen dialogue, focus on four to six rule of law themes while retaining flexibility, strengthen CSO project design and evaluation, and support durable government–CSO networks.`,
metrics: [
{ value: "102", label: "SUQ dialogues" },
{ value: "1,583", label: "Dialogue participants" },
{ value: "48%", label: "Women participants" },
{ value: "46", label: "Learning sessions" },
{ value: "21", label: "Sub-contract actions" },
{ value: "62", label: "Workshops delivered" },
{ value: "6", label: "Research papers" },
{ value: "24", label: "Training sessions" },
{ value: "1,555", label: "Media outreach activities" },
{ value: "87%", label: "Found dialogues relevant" },
{ value: "75%", label: "Saw potential for change" },
],
focusHeading: "Areas of work",
focusAreas: [
"Human rights",
"Transitional justice",
"Local governance",
"Conflict mediation",
"Access to justice",
"Women’s rights",
"Labour rights",
"Civil society and media",
],
imageHeading: "SHARP I in the field",
imageEmpty: "Dialogue and local action across Libya.",
images: [],
},
stageTwo: {
name: "SHARP II",
years: "September 2022 – August 2024",
summary:
"Expanded a nationwide civil-society network and turned inclusive dialogue into community action.",
description: `Funded by the European Union and implemented by Bureau Ensemble with Libyan partners, SHARP II built on SHARP I to strengthen the rule of law, stability, and participatory governance. From September 2022 to August 2024, the programme had a budget of €1.426 million.
### Priorities
SHARP II aimed to grow a national network of rule of law dialogue groups; build social cohesion and consensus locally, regionally, and nationally; organise inclusive dialogues on rule of law and positive citizenship; and support small community actions.
### Dialogue and network
157 SUQ dialogues engaged 2,554 participants, 44% of them women. Discussions covered access to justice, women’s empowerment, local governance, and the media’s role in the rule of law. The network grew to 64 active CSOs from 27, reaching every region, including marginalised areas in the south.
### Skills and community action
Six Training & Learning sessions covered facilitation, project management, and monitoring. Advanced training reached 100 participants, with 24 certified as trainers. All 34 approved sub-contracts were implemented, addressing legal awareness, reconciliation, women’s rights, gender-based violence, municipal capacity, ethical journalism, and misinformation. Activities reached 1,487 trainees and 1,697 workshop participants; awareness campaigns benefited 2,896 people.
### Policy, communication, and impact
Regional and national dialogues informed recommendations on faster court processes and legal aid, municipal empowerment under Local Governance Law 59, and media transparency and hate speech. Libyan-led monitoring supported adaptive delivery, while 1,555 media outreach activities amplified the work. CSOs led 100% of activities; ministries and municipalities partnered with them, and young people, displaced people, and persons with disabilities took part.
### A sustainable exit
The March–August 2024 exit phase shaped three pillars for continued impact: Access to Justice (legal aid, mediation, and policy reform), TAZIZ (municipal capacity and citizen engagement), and ADWA (ethical journalism and countering misinformation). United Efforts was established and registered as a Libyan legal entity to help sustain the work.`,
metrics: [
{ value: "157", label: "SUQ dialogues" },
{ value: "2,554", label: "Dialogue participants" },
{ value: "64", label: "Active CSOs" },
{ value: "44%", label: "Women participants" },
{ value: "34", label: "Sub-contracts" },
{ value: "100", label: "Advanced training participants" },
{ value: "24", label: "Certified trainers" },
{ value: "1,487", label: "Trainees" },
{ value: "1,697", label: "Workshop participants" },
{ value: "2,896", label: "Awareness campaign beneficiaries" },
{ value: "1,555", label: "Media outreach activities" },
],
focusHeading: "Areas of work",
focusAreas: [
"Access to justice",
"Women’s empowerment",
"Local governance",
"Media and rule of law",
"Social cohesion",
"Positive citizenship",
],
imageHeading: "SHARP II in the field",
imageEmpty: "A nationwide civil-society network in action.",
images: [],
},
closingHeading: "SHARP II’s exit phase",
closingParagraphs: [
"From March to August 2024, the SHARP II exit phase focused on access to justice, local governance through TAZIZ, and media through ADWA.",
"United Efforts was established and registered as a Libyan legal entity. The SHARP II summary identifies a stable nationwide civil society network and a roadmap for future EU–Libya collaboration through SHARP-ER.",
],
},
about: {
heroEyebrow: "About the project",
heroHeadline: "Strengthening the Rule of Law, Together",
@@ -357,9 +506,6 @@ const EN: PageCopy = {
websiteText: "sharper.ly",
contactPending:
"The project’s general enquiries address will be published here once it is confirmed. In the meantime, use the form and your message will reach the team.",
consortiumHeading: "Consortium contacts",
consortiumEmpty:
"Contact details for Acted Libya, United Efforts Consultancy and Stichting Bureau Ensemble will be published here once they are confirmed.",
sendAnother: "Send another message",
errorSummaryTitle: "Please check the form",
errorTooLong: "This answer is too long.",
@@ -423,6 +569,122 @@ const AR: PageCopy = {
"ستُدرج المنظمات الشريكة هنا فور انضمامها إلى شبكة SHARP-ER.",
},
legacy: {
homeEyebrow: "شارب الأول وشارب الثاني",
homeHeading: "الحوار والعمل المحلي في ليبيا، 2019–2024",
homeLead:
"دعم برنامجان ممولان من الاتحاد الأوروبي الحوار حول سيادة القانون، وقدرات المجتمع المدني، والعمل المحلي في مختلف أنحاء ليبيا.",
homeLinkLabel: "اكتشفوا هذه المرحلة",
heroEyebrow: "شارب الأول وشارب الثاني",
heroTitle: "الحوار والعمل المحلي في مختلف أنحاء ليبيا",
heroLead:
"من نوفمبر 2019 إلى أغسطس 2024، دعم برنامجان ممولان من الاتحاد الأوروبي الحوار حول سيادة القانون، وقدرات المجتمع المدني، والعمل بقيادة محلية.",
stageOne: {
name: "شارب الأول",
years: "نوفمبر 2019 – أغسطس 2022",
summary:
"عزّز الحوار والقدرات المحلية من خلال البحث والتعلّم والعمل المجتمعي.",
description: `نفّذ معهد كلينغندايل ومؤسسة Bureau Ensemble برنامج شارب الأول بتمويل من الاتحاد الأوروبي، وبالتعاون مع منظمات المجتمع المدني والسلطات المحلية والمؤسسات الوطنية الليبية. عمل البرنامج من نوفمبر 2019 إلى أغسطس 2022 على دعم سيادة القانون في ليبيا عبر الحوار والبحث والعمل المحلي، بميزانية بلغت 1.996 مليون يورو.
### الأهداف
- تعزيز قدرة المؤسسات الليبية على الاستجابة لتحديات سيادة القانون.
- بناء القدرات التنظيمية للفاعلين الليبيين الساعين إلى التغيير.
- تشجيع الحوار وتبادل المعلومات بين أصحاب المصلحة.
### الحوار والتعلّم
عُقدت 102 جلسة «ابحث، افهم، واسأل» (SUQ)، بمشاركة 1,583 شخصاً، وكانت نسبة النساء 48%. وتناولت الجلسات حقوق الإنسان والعدالة الانتقالية والحوكمة المحلية. كما عُقدت 46 جلسة للتدريب والتعلّم حول التيسير وإدارة المشاريع والرصد.
### العمل المجتمعي
نُفّذ 21 مشروعاً فرعياً تناولت الوساطة في النزاعات والوصول إلى العدالة وحقوق النساء وحقوق العمال ومشاركة المجتمع المدني والإعلام. وأسفرت عن 62 ورشة عمل و6 أوراق بحثية و24 جلسة تدريبية و1,555 نشاطاً للتواصل الإعلامي.
### النتائج والأثر المستدام
رأى 87% من المشاركين أن جلسات الحوار ذات صلة، واعتقد 75% أن شارب قادر على الإسهام في تغيير واقع سيادة القانون. وعززت المشاريع مهارات إعداد التقارير لدى منظمات المجتمع المدني وشراكاتها مع السلطات المحلية. كما دعمت المبادرات بقيادة ليبية الثقة المجتمعية، وتكيّف البرنامج مع قيود الجائحة والأوضاع الأمنية عبر الأنشطة الافتراضية والحوار المحلي، بما ساعد المنظمات على بناء شبكات طويلة الأمد.
### ما تلا ذلك
وافق الاتحاد الأوروبي على شارب الثاني للاستفادة من هذه الخبرات: تعميق الحوار، والتركيز على أربعة إلى ستة موضوعات مع الحفاظ على المرونة، وتعزيز مهارات تصميم المشاريع وتقييمها لدى منظمات المجتمع المدني، ودعم شبكات مستدامة بين الحكومة والمجتمع المدني.`,
metrics: [
{ value: "102", label: "جلسة حوار SUQ" },
{ value: "1,583", label: "مشاركاً في الحوار" },
{ value: "48%", label: "مشاركة النساء" },
{ value: "46", label: "جلسة للتعلّم" },
{ value: "21", label: "عملاً بعقد فرعي" },
{ value: "62", label: "ورشة عمل" },
{ value: "6", label: "أوراق بحثية" },
{ value: "24", label: "جلسة تدريبية" },
{ value: "1,555", label: "نشاطاً للتواصل الإعلامي" },
{ value: "87%", label: "وجدوا الحوارات ذات صلة" },
{ value: "75%", label: "رأوا إمكانية إحداث تغيير" },
],
focusHeading: "مجالات العمل",
focusAreas: [
"حقوق الإنسان",
"العدالة الانتقالية",
"الحوكمة المحلية",
"الوساطة في النزاعات",
"الوصول إلى العدالة",
"حقوق النساء",
"حقوق العمال",
"المجتمع المدني والإعلام",
],
imageHeading: "شارب الأول ميدانياً",
imageEmpty: "حوار وعمل محلي في مختلف أنحاء ليبيا.",
images: [],
},
stageTwo: {
name: "شارب الثاني",
years: "سبتمبر 2022 – أغسطس 2024",
summary:
"وسّع شبكة المجتمع المدني الوطنية وحوّل الحوار الشامل إلى عمل مجتمعي.",
description: `بتمويل من الاتحاد الأوروبي وتنفيذ Bureau Ensemble بالشراكة مع فاعلين ليبيين، بنى شارب الثاني على إنجازات شارب الأول لتعزيز سيادة القانون والاستقرار والحوكمة التشاركية. امتد البرنامج من سبتمبر 2022 إلى أغسطس 2024 بميزانية 1.426 مليون يورو.
### الأولويات
عمل شارب الثاني على توسيع شبكة وطنية لمجموعات الحوار حول سيادة القانون، وتعزيز التماسك الاجتماعي والتوافق محلياً وإقليمياً ووطنياً، وتنظيم حوارات شاملة حول سيادة القانون والمواطنة الإيجابية، ودعم مبادرات مجتمعية صغيرة.
### الحوار وتوسيع الشبكة
عُقدت 157 جلسة حوار SUQ بمشاركة 2,554 شخصاً، وكانت نسبة النساء 44%. وتناولت الحوارات الوصول إلى العدالة وتمكين المرأة والحوكمة المحلية ودور الإعلام في سيادة القانون. ونمت الشبكة إلى 64 منظمة مجتمع مدني ناشطة بعد أن كانت 27، وامتدت إلى جميع المناطق بما فيها المناطق المهمشة في الجنوب.
### بناء القدرات والعمل المجتمعي
عُقدت 6 جلسات للتدريب والتعلّم حول التيسير وإدارة المشاريع والرصد. وشمل التدريب المتقدم 100 مشارك، حصل 24 منهم على اعتماد مدربين. ونُفّذت العقود الفرعية الـ34 المعتمدة، وتناولت التوعية القانونية والمصالحة وحقوق المرأة ومكافحة العنف القائم على النوع الاجتماعي وبناء قدرات البلديات والصحافة الأخلاقية ومواجهة المعلومات المضللة. ووصلت الأنشطة إلى 1,487 متدرباً و1,697 مشاركاً في الورش؛ واستفاد 2,896 شخصاً من حملات التوعية.
### السياسات والتواصل والأثر
أسهمت الحوارات الإقليمية والوطنية في صياغة توصيات لتسريع إجراءات المحاكم وتوفير مكاتب للمساعدة القانونية، وتمكين البلديات بموجب قانون الحكم المحلي رقم 59، ودعم الشفافية الإعلامية ومكافحة خطاب الكراهية. وأسهم الرصد بقيادة ليبية في تكييف التنفيذ، كما عززت 1,555 نشاطاً إعلامياً أثر البرنامج. وقادت منظمات المجتمع المدني 100% من الأنشطة، وتعاونت معها الوزارات والبلديات، وشارك الشباب والنازحون والأشخاص ذوو الإعاقة.
### خطة خروج مستدامة
صممت مرحلة الخروج، من مارس إلى أغسطس 2024، ثلاثة مشاريع لضمان الأثر على المدى الطويل: الوصول إلى العدالة عبر المساعدة القانونية والتدريب على الوساطة والإصلاحات؛ و«تعزيز» (TAZIZ) لبناء قدرات البلديات ومشاركة المواطنين؛ و«أضواء» (ADWA) للصحافة الأخلاقية ومواجهة المعلومات المضللة. كما أُسست وسُجلت «United Efforts» ككيان قانوني ليبي لضمان استمرارية العمل.`,
metrics: [
{ value: "157", label: "جلسة حوار SUQ" },
{ value: "2,554", label: "مشاركاً في الحوار" },
{ value: "64", label: "منظمة مجتمع مدني ناشطة" },
{ value: "44%", label: "مشاركة النساء" },
{ value: "34", label: "عقداً فرعياً" },
{ value: "100", label: "مشاركاً في التدريب المتقدم" },
{ value: "24", label: "مدرباً معتمداً" },
{ value: "1,487", label: "متدرباً" },
{ value: "1,697", label: "مشاركاً في الورش" },
{ value: "2,896", label: "مستفيداً من حملات التوعية" },
{ value: "1,555", label: "نشاطاً للتواصل الإعلامي" },
],
focusHeading: "مجالات العمل",
focusAreas: [
"الوصول إلى العدالة",
"تمكين المرأة",
"الحوكمة المحلية",
"الإعلام وسيادة القانون",
"التماسك الاجتماعي",
"سيادة القانون",
"المواطنة الإيجابية",
],
imageHeading: "شارب الثاني ميدانياً",
imageEmpty: "شبكة وطنية من منظمات المجتمع المدني تتحرك على الأرض.",
images: [],
},
closingHeading: "مرحلة خروج شارب الثاني",
closingParagraphs: [
"من مارس إلى أغسطس 2024، ركزت مرحلة خروج شارب الثاني على الوصول إلى العدالة، والحوكمة المحلية عبر «تعزيز» (TAZIZ)، والإعلام عبر «أضواء» (ADWA).",
"أُسست «United Efforts» وسُجلت ككيان قانوني ليبي. ويشير ملخص شارب الثاني إلى شبكة وطنية مستقرة من منظمات المجتمع المدني وخارطة طريق لتعاون الاتحاد الأوروبي وليبيا مستقبلاً عبر شارب-إي آر.",
],
},
about: {
heroEyebrow: "عن المشروع",
heroHeadline: "نحو تعزيز سيادة القانون، معًا",
@@ -544,9 +806,6 @@ const AR: PageCopy = {
websiteText: "sharper.ly",
contactPending:
"سيُنشر هنا البريد الإلكتروني العام للمشروع فور تأكيده. وفي غضون ذلك، استخدم النموذج وستصل رسالتك إلى الفريق.",
consortiumHeading: "جهات اتصال الائتلاف",
consortiumEmpty:
"ستُنشر هنا بيانات التواصل الخاصة بـ Acted ليبيا، وUnited Efforts Consultancy، وStichting Bureau Ensemble فور تأكيدها.",
sendAnother: "إرسال رسالة أخرى",
errorSummaryTitle: "يرجى مراجعة النموذج",
errorTooLong: "هذه الإجابة طويلة جدًا.",
@@ -0,0 +1,45 @@
/* Server-side mapping from the editable Strapi phase component to page copy. */
import type { CmsLegacyStage } from "./cms-types";
import { list, toImage } from "./cms.server";
import type { LegacyGalleryImage, LegacyStageCopy } from "./copy";
const textOr = (value: string | null | undefined, fallback: string): string =>
value?.trim() ? value : fallback;
export function resolveLegacyStage(
source: CmsLegacyStage | null | undefined,
fallback: LegacyStageCopy,
): LegacyStageCopy {
const metrics = list(source?.metrics)
.filter((metric) => metric.value.trim() !== "" && metric.label.trim() !== "")
.map(({ value, label }) => ({ value, label }));
const focusAreas = list(source?.focusAreas)
.map((area) => area.name.trim())
.filter(Boolean);
const images: LegacyGalleryImage[] = list(source?.images)
.slice(0, 5)
.flatMap((media) => {
const image = toImage(media, "large");
return image
? [{
src: image.src,
alt: image.alt,
...(media.caption ? { caption: media.caption } : {}),
}]
: [];
});
return {
...fallback,
name: textOr(source?.name, fallback.name),
years: textOr(source?.years, fallback.years),
summary: textOr(source?.summary, fallback.summary),
description: textOr(source?.description, fallback.description),
metrics: metrics.length > 0 ? metrics : fallback.metrics,
focusHeading: textOr(source?.focusHeading, fallback.focusHeading),
focusAreas: focusAreas.length > 0 ? focusAreas : fallback.focusAreas,
imageHeading: textOr(source?.galleryHeading, fallback.imageHeading),
images,
};
}
+110 -15
View File
@@ -203,22 +203,14 @@
}
}
/* A picture pushing in a little while the frame around it is hovered. The
frame clips, so the picture grows into its own box and not over the page.
Paired with .sh-lift the tile rises and its picture closes in, which is
how the draft hovers the gallery. */
.sh-zoom {
overflow: hidden;
}
.sh-zoom img {
transition: transform var(--motion-duration-slow)
var(--motion-ease-standard);
}
/* A gallery tile that grows as a WHOLE frame instead of pushing the picture
inside it - the draft hovers the frame, not the image. It keeps `.sh-lift`'s
rise and adds a uniform scale to it. Written after `.sh-lift:hover` so the
compound transform wins at equal specificity; `.sh-lift`'s transition
already covers `transform`, so the two move together. */
@media (hover: hover) {
.sh-zoom:hover img {
transform: scale(1.09);
.sh-lift--grow:hover {
transform: translateY(-5px) scale(1.04);
}
}
@@ -276,6 +268,39 @@
animation-range: entry 0% entry 80%;
}
/* The edge tiles of a row land in from their own side of the line - the
reading start and the reading end - instead of rising, so a row drags
into place edge-first while its middle rises. `translateX` has no
logical keyword, so the RTL block swaps the two names: the drift always
comes off the start edge, whichever script is on screen. */
.sh-reveal--from-start {
animation-name: sh-rise-from-start;
}
.sh-reveal--from-end {
animation-name: sh-rise-from-end;
}
[dir="rtl"] .sh-reveal--from-start {
animation-name: sh-rise-from-end;
}
[dir="rtl"] .sh-reveal--from-end {
animation-name: sh-rise-from-start;
}
/* A beat between the tiles of a row: each item after the first holds at
its hidden state until it is a little deeper in the viewport, so a row
cascades start-to-end instead of landing as one. Only the start of the
range shifts; `.sh-reveal--in` and the edge slides keep their ends. */
.sh-reveal--step-1 {
animation-range-start: entry 12%;
}
.sh-reveal--step-2 {
animation-range-start: entry 24%;
}
/* The hero picture drifting as the page scrolls past it. It is scaled up
before it travels, so the drift can never uncover an edge of the
frame. Chromium-only, like every timeline here: elsewhere the picture
@@ -314,6 +339,49 @@
transform-origin: right center;
}
}
/* THE SAME REVEAL, WITHOUT A TIMELINE. `_page-kit/reveal.ts` tags a
`.sh-reveal` element `.sh-reveal-js` (hidden) in browsers that lack
scroll timelines, then `.sh-reveal-js--in` plays `sh-rise` once it
scrolls into view. Both classes are inert without the helper, so
content is always readable. */
.sh-reveal-js {
opacity: 0;
}
.sh-reveal-js--in {
animation: sh-rise var(--motion-duration-reveal)
var(--motion-ease-entrance) both;
}
/* The edge tiles keep their drift here too: the reader who never sees a
scroll timeline still gets the same from-start / from-end arrival. The
RTL mirrors swap the names, as the Chromium block above does. */
.sh-reveal--from-start.sh-reveal-js--in {
animation-name: sh-rise-from-start;
}
.sh-reveal--from-end.sh-reveal-js--in {
animation-name: sh-rise-from-end;
}
[dir="rtl"] .sh-reveal--from-start.sh-reveal-js--in {
animation-name: sh-rise-from-end;
}
[dir="rtl"] .sh-reveal--from-end.sh-reveal-js--in {
animation-name: sh-rise-from-start;
}
/* The same beat, expressed in time where there is no timeline. `both`
already answers for the hidden hold during the delay. */
.sh-reveal--step-1.sh-reveal-js--in {
animation-delay: var(--motion-duration-fast);
}
.sh-reveal--step-2.sh-reveal-js--in {
animation-delay: calc(var(--motion-duration-fast) * 2);
}
}
}
@@ -341,6 +409,33 @@
}
}
/* The same reveal turned sideways. `--from-start` is picked up by the RTL
block as the mirror of `--from-end`, so both names exist and the swap is
legible there instead of shipping one keyframe and praying. */
@keyframes sh-rise-from-start {
from {
opacity: 0;
transform: translateX(-20px) scale(0.985);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes sh-rise-from-end {
from {
opacity: 0;
transform: translateX(20px) scale(0.985);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes sh-bar-grow {
from {
transform: scaleX(0);
+45
View File
@@ -0,0 +1,45 @@
/* Reveal-on-scroll for browsers without scroll-driven timelines.
----------------------------------------------------------------------------
motion.css handles `.sh-reveal` with `animation-timeline: view()`, which is
Chromium-only; elsewhere every band sits at rest, fully visible. This helper
gives those browsers the same `.sh-rise` reveal: it tags each `.sh-reveal`
element `.sh-reveal-js` (hidden), then flips it to `.sh-reveal-js--in` (the
animation runs) once it scrolls into view.
It is a progressive enhancement and is strictly opt-in per page:
- Chromium already animates via CSS, so this returns early and never
fights the scroll-driven version.
- Reduced-motion visitors are left alone (the CSS never hides them).
- With JS off, no classes are ever added, so content stays visible.
Renders nothing on the server; call from a `useEffect` in the page. */
const SELECTOR = ".sh-reveal";
export function enableReveals(): void {
if (typeof window === "undefined") return;
if (!("IntersectionObserver" in window)) return;
if ("CSS" in window && CSS.supports("animation-timeline: view()")) {
/* Chromium already plays the reveal; nothing to add. */
return;
}
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const element = entry.target;
element.classList.add("sh-reveal-js--in");
element.classList.remove("sh-reveal-js");
observer.unobserve(element);
}
},
{ rootMargin: "0px 0px -8% 0px", threshold: 0.05 },
);
for (const element of document.querySelectorAll<HTMLElement>(SELECTOR)) {
element.classList.add("sh-reveal-js");
observer.observe(element);
}
}
@@ -256,12 +256,6 @@ export interface PageHeroProps {
* footer for EU visibility.
*/
funderBadge?: ReactNode;
/**
* A short status line drawn as a pill above the headline - the redesign's
* "Now live" chip. Only rendered when the caller passes one, so it can never
* announce a state the CMS has not authored.
*/
statusPill?: ReactNode;
/** Buttons, rendered inline-end of the headline on wide viewports. */
actions?: ReactNode;
/**
@@ -378,7 +372,6 @@ export function PageHero({
lead,
wordmark,
funderBadge,
statusPill,
actions,
leadWithActions = false,
ambient,
@@ -454,8 +447,6 @@ export function PageHero({
</div>
) : null}
{statusPill ? <div className={rise(2)}>{statusPill}</div> : null}
{leadWithActions ? (
<>
{/* Headline across the top ... */}
@@ -583,12 +574,6 @@ export function PageHero({
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-[var(--overlay-picture-tint)]"
/>
{/* A highlight travelling across the picture - the draft's
`sheen`. Decorative, and it rides on top of both washes. */}
<span
aria-hidden="true"
className="sh-sheen pointer-events-none absolute inset-0 bg-[linear-gradient(100deg,transparent_35%,var(--overlay-veil)_50%,transparent_65%)]"
/>
</div>
{/* The colour band (478:16360, redrawn in the drafts' home
+7 -4
View File
@@ -20,7 +20,7 @@
2. The route's `meta` appends `detailAlternateDescriptors(...)` to whatever
`buildMeta` produced, which emits <link rel="canonical"> plus one
<link rel="alternate" hreflang> per locale that actually has a slug, and
x-default pointing at English when English exists.
x-default pointing at the default locale when it exists.
3. The route declares `export const handle = SELF_ALTERNATES_HANDLE`, which
tells the app shell that this page emits its own pair and that the
shell's prefix-swapped set must be suppressed for it. The shell reads it
@@ -33,7 +33,7 @@
import type { MetaDescriptor } from "react-router";
import { SUPPORTED_LOCALES, type Locale } from "~/i18n/config";
import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type Locale } from "~/i18n/config";
import { sectionPath, type RouteSlugKey } from "~/i18n/paths";
import { canonicalUrl } from "~/lib/seo";
@@ -160,8 +160,11 @@ export function detailAlternateDescriptors({
});
}
// x-default is the English URL, because bare "/" negotiates towards English.
const fallback = paths.find((entry) => entry.locale === "en");
// x-default is the default-locale URL, because bare "/" negotiates towards
// the default locale. It must point at an existing page, so a detail page
// with no translation yet falls back to whichever locale does exist.
const fallback =
paths.find((entry) => entry.locale === DEFAULT_LOCALE) ?? paths[0];
if (fallback) {
descriptors.push({
tagName: "link",
+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:
+25 -52
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>
@@ -361,43 +364,13 @@ export default function About({ loaderData }: Route.ComponentProps) {
<div className="flex flex-wrap items-center gap-md">
<Button
as={Link}
to={sectionPath("whatWeDo", locale)}
to={sectionPath("legacy", locale)}
prefetch="intent"
size="lg"
iconEnd={<ArrowIcon />}
>
{t("actions.learnWhatWeDo")}
{copy.legacyLinkLabel}
</Button>
{/* The draft marks the legacy page as "[link: …]" with no target
yet, so this second button appears only once the CMS gives it
a real one. */}
{legacy.link ? (
legacy.link.isExternal ? (
<Button
as="a"
href={legacy.link.href}
target="_blank"
rel="noopener noreferrer"
variant="secondary"
size="lg"
iconEnd={<ArrowIcon />}
>
{legacy.link.label}
</Button>
) : (
<Button
as={Link}
to={legacy.link.href}
prefetch="intent"
variant="secondary"
size="lg"
iconEnd={<ArrowIcon />}
>
{legacy.link.label}
</Button>
)
) : null}
</div>
</div>
</PageSection>
+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,20 +125,49 @@ 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, opportunityId: opportunity.documentId });
await submitApplication({
values,
opportunityId: opportunity.documentId,
forwardedFor: request.headers.get("x-forwarded-for") ?? undefined,
});
} catch (error) {
if (!isStrapiError(error)) throw error;
console.error(
`[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 },
);
}
@@ -488,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
@@ -540,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
@@ -46,8 +47,7 @@
uploaded here at all (see `documentsNote`);
* "an automatic acknowledgment with your reference number" - no
reference-number field exists anywhere in the CMS. The acknowledgement
is modelled (`application.acknowledgementSentAt`) and survives; the
number does not;
IS sent (the CMS mails it on submission) and the number does not;
* "follows up within four weeks" - a response time that appears in no
approved document. §8 of the draft marks it "[X weeks] - TBD".
@@ -66,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,
@@ -77,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";
@@ -193,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) {
@@ -220,20 +224,48 @@ 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 });
await submitPathwayApplication({
values,
forwardedFor: request.headers.get("x-forwarded-for") ?? undefined,
});
} catch (error) {
if (!isStrapiError(error)) throw error;
console.error(
`[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 },
);
}
@@ -676,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
@@ -721,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}
@@ -729,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 ? (
@@ -885,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}
+36 -62
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.
@@ -36,30 +38,31 @@
import { useEffect, useRef, type ReactNode } from "react";
import { SITE_FACEBOOK_URL } from "~/lib/site-links";
import { data, Form, Link, useLocation, useNavigation } from "react-router";
import { data, Form, Link, useLocation, useNavigation, useRouteLoaderData } from "react-router";
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 { Accordion, type AccordionItem } from "./_page-kit/accordion";
import type { CmsContactPage } from "./_page-kit/cms-types";
import { list, loadSingle, toImage } from "./_page-kit/cms.server";
import { pageCopy } from "./_page-kit/copy";
import { routeLocale } from "./_page-kit/locale";
import { RichText } from "./_page-kit/rich-text";
import {
EmptyState,
PageHero,
PageSection,
Prose,
} from "./_page-kit/sections";
import "./_page-kit/motion.css";
import { DEFAULT_THEME, type AppTheme } from "~/lib/theme";
/* ---------------------------------------------------------------------------
LOADER
@@ -73,7 +76,6 @@ export async function loader({ params }: Route.LoaderArgs) {
populate: {
hero: { populate: { media: true, ctas: true } },
socialLinks: true,
consortiumContacts: true,
seo: { populate: { ogImage: true } },
},
});
@@ -94,15 +96,6 @@ export async function loader({ params }: Route.LoaderArgs) {
href: link.href,
isExternal: link.isExternal ?? true,
})),
consortiumContacts: list(page?.consortiumContacts).map((address) => ({
id: String(address.id ?? address.organisation),
organisation: address.organisation,
addressLine: address.addressLine ?? null,
city: address.city ?? null,
country: address.country ?? null,
email: address.email ?? null,
phone: address.phone ?? null,
})),
seo: {
title: page?.seo?.metaTitle ?? null,
description: page?.seo?.metaDescription ?? null,
@@ -230,7 +223,10 @@ function validate(values: ContactValues, locale: Locale): ContactErrors {
* though the browser decides triage state, and the value would be silently
* discarded anyway - so the body below is the whitelist and only it.
*/
async function createContactSubmission(values: ContactValues): Promise<void> {
async function createContactSubmission(
values: ContactValues,
forwardedFor?: string,
): Promise<void> {
const env = getServerEnv();
const url = `${env.STRAPI_URL.replace(/\/+$/, "")}/api/contact-submissions`;
@@ -240,6 +236,7 @@ async function createContactSubmission(values: ContactValues): Promise<void> {
"content-type": "application/json",
accept: "application/json",
...(env.STRAPI_TOKEN ? { authorization: `Bearer ${env.STRAPI_TOKEN}` } : {}),
...(forwardedFor ? { "x-forwarded-for": forwardedFor } : {}),
},
body: JSON.stringify({
data: {
@@ -281,8 +278,25 @@ 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);
await createContactSubmission(
values,
request.headers.get("x-forwarded-for") ?? undefined,
);
} catch (error) {
console.error("[contact] submission failed", error);
const t = createTranslator(locale);
@@ -354,8 +368,7 @@ export default function Contact({
loaderData,
actionData,
}: Route.ComponentProps) {
const { locale, hero, intro, generalEmail, socialLinks, consortiumContacts } =
loaderData;
const { locale, hero, intro, generalEmail, socialLinks } = loaderData;
const t = createTranslator(locale);
const copy = pageCopy(locale).contact;
@@ -370,6 +383,9 @@ export default function Contact({
const { pathname } = useLocation();
const isSubmitting = navigation.state === "submitting";
const root = useRouteLoaderData<{ theme?: AppTheme }>("root");
const theme: AppTheme = root?.theme ?? DEFAULT_THEME;
const errors = actionData?.errors ?? {};
const values = actionData?.values ?? EMPTY_VALUES;
const formError =
@@ -379,32 +395,6 @@ export default function Contact({
(message): message is string => typeof message === "string",
);
/* The draft draws the consortium addresses as a disclosure list, which the
approved draft also calls for ("optional accordion"). The answers are
Markdown because that is what <Accordion /> renders; every value in them
comes from the CMS, and the whole list is replaced by the localised empty
state while the addresses are still "TBD". */
const consortiumItems: readonly AccordionItem[] = consortiumContacts.map(
(contact) => {
const place = [contact.city, contact.country].filter(Boolean).join(", ");
return {
id: contact.id,
question: contact.organisation,
answer: [
contact.addressLine,
place === "" ? null : place,
contact.email ? `[${contact.email}](mailto:${contact.email})` : null,
contact.phone
? `[${contact.phone}](tel:${contact.phone.replace(/\s+/g, "")})`
: null,
]
.filter((line): line is string => typeof line === "string")
.join("\n\n"),
};
},
);
/* A failed submit re-renders the page with the summary at the top of the
form. Screen-reader users are left wherever they were, and keyboard users
below the fold, so focus is moved onto the summary itself - it is
@@ -576,6 +566,8 @@ export default function Contact({
{...(errors.message ? { error: errors.message } : {})}
/>
<Turnstile action="contact" locale={locale} resetKey={actionData} />
<Button
type="submit"
size="lg"
@@ -653,24 +645,6 @@ export default function Contact({
</dl>
</div>
{/* CONSORTIUM CONTACTS - "TBD" in the draft, so the CMS owns them.
The draft labels the list with an accent eyebrow rather than a
section heading, so the heading takes the label role. */}
<div className="sh-reveal flex flex-col gap-sm">
<h2
id="contact-consortium-heading"
className="label-md uppercase text-[var(--text-accent)]"
>
{copy.consortiumHeading}
</h2>
{consortiumItems.length > 0 ? (
<Accordion items={consortiumItems} defaultOpenIndex={null} />
) : (
<EmptyState message={copy.consortiumEmpty} />
)}
</div>
{/* EU VISIBILITY - the funding note the draft prints at the foot of
this column. The emblem and the statement come from the shared
badge, so the wording and the artwork stay contractual. */}
@@ -681,7 +655,7 @@ export default function Contact({
size="sm"
statement={t("site.euStatement")}
locale={locale}
surface="dark"
surface={theme}
/>
</div>
</div>
+116 -11
View File
@@ -13,7 +13,7 @@
refuses to submit without it. It is never a hidden input: consent that the
reader did not give is not consent.
"Add to calendar" points at the sibling resource route,
The .ics download points at the sibling resource route,
/{locale}/events-calendar/{slug}.ics, which streams a real RFC 5545 file.
ROUTE
@@ -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";
@@ -62,6 +64,7 @@ import {
formatEventWhen,
isRegistrationOpen,
isWaitlisted,
type EventDetailData,
} from "./_events-opportunities/events";
import { fetchEvent } from "./_events-opportunities/events.server";
import {
@@ -98,6 +101,47 @@ import { RichText } from "./_shared/RichText";
*/
export const handle = SELF_ALTERNATES_HANDLE;
function addCalendarDay(date: Date): Date {
const next = new Date(date);
next.setUTCDate(next.getUTCDate() + 1);
return next;
}
function toGoogleCalendarDate(date: Date): string {
return date.toISOString().slice(0, 10).replace(/-/g, "");
}
function toGoogleCalendarDateTime(date: Date): string {
return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
}
function buildGoogleCalendarHref(event: EventDetailData): string {
const start = new Date(event.start);
const parsedEnd = event.end ? new Date(event.end) : undefined;
const end =
parsedEnd && !Number.isNaN(parsedEnd.getTime()) ? parsedEnd : undefined;
const dates = event.allDay
? `${toGoogleCalendarDate(start)}/${toGoogleCalendarDate(
addCalendarDay(end ?? start),
)}`
: `${toGoogleCalendarDateTime(start)}/${toGoogleCalendarDateTime(
end && end.getTime() > start.getTime() ? end : start,
)}`;
const params = new URLSearchParams({
action: "TEMPLATE",
text: event.title,
dates,
});
const details = [event.summary, event.onlineUrl].filter(Boolean).join("\n\n");
const location = event.place ?? event.venue;
if (details) params.set("details", details);
if (location) params.set("location", location);
return `https://calendar.google.com/calendar/render?${params.toString()}`;
}
export async function loader({ params }: Route.LoaderArgs) {
const locale = requireLocale(params.lang);
const slug = params.slug ?? "";
@@ -119,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) {
@@ -128,8 +174,6 @@ export async function action({ params, request }: Route.ActionArgs) {
const { event } = requireEntity(await fetchEvent(slug, locale));
const form = await request.formData();
const waitlist = isWaitlisted(event);
/* A form nobody can submit through the page must not be submittable through
a POST either - registration state is checked again on the server. */
if (!isRegistrationOpen(event)) {
@@ -138,18 +182,38 @@ export async function action({ params, request }: Route.ActionArgs) {
/* A bot that fills every field gets the acknowledgement and writes nothing. */
if (isHoneypotFilled(form)) {
return { ok: true, waitlist } satisfies ActionResult;
return { ok: true, waitlist: isWaitlisted(event) } satisfies ActionResult;
}
const { values, errors, valid } = validateRegistration(form);
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
./_events-opportunities/submissions.server.ts. `registrationState` is
@@ -159,7 +223,11 @@ export async function action({ params, request }: Route.ActionArgs) {
`waitlist` flag below is therefore presentational - it decides which
sentence the reader sees, and it is read from the same event fields the
middleware weighs, so the two agree. */
await submitRegistration({ values, eventId: event.documentId });
registrationState = await submitRegistration({
values,
eventId: event.documentId,
forwardedFor: request.headers.get("x-forwarded-for") ?? undefined,
});
} catch (error) {
if (!isStrapiError(error)) throw error;
/* A 4xx here is the CMS refusing the registration on its own terms - the
@@ -170,12 +238,21 @@ 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 },
);
}
return { ok: true, waitlist } satisfies ActionResult;
return {
ok: true,
waitlist: registrationState === "waitlisted",
} satisfies ActionResult;
}
/* ---------------------------------------------------------------------------
@@ -423,6 +500,24 @@ export default function EventDetail() {
<DetailList heading={copy.eventDetail.detailsHeading} items={details} />
<div className="flex flex-col gap-xs">
<Button
as="a"
href={buildGoogleCalendarHref(event)}
target="_blank"
rel="noopener noreferrer"
variant="secondary"
iconStart={
<img
src="/assets/icons/google-calendar.svg"
alt=""
aria-hidden="true"
height={20}
/>
}
fullWidth
>
{t("actions.addToGoogleCalendar")}
</Button>
<Button
as="a"
href={`${detailPath}.ics`}
@@ -430,7 +525,7 @@ export default function EventDetail() {
variant="secondary"
fullWidth
>
{t("actions.addToCalendar")}
{t("actions.downloadIcsFile")}
</Button>
<p className="body-sm text-text-tertiary">
{copy.eventDetail.addToCalendarHint}
@@ -564,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
@@ -627,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"
+21 -3
View File
@@ -50,7 +50,7 @@ import {
useTranslation,
type Locale,
} from "~/i18n";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import { buildBreadcrumbJsonLd, buildEventJsonLd, buildMeta } from "~/lib/seo";
import type { Route } from "./+types/events-calendar";
import {
@@ -185,10 +185,25 @@ export async function loader({ params, request }: Route.LoaderArgs) {
};
}
export function meta({ location, matches }: Route.MetaArgs) {
export function meta({ location, matches, loaderData }: Route.MetaArgs) {
const root = readRootSeo(matches);
const copy = getRouteCopy(root.locale);
const search = new URLSearchParams(location.search);
const eventJsonLd = (loaderData?.events ?? []).map((event) =>
buildEventJsonLd({
siteUrl: root.siteUrl,
locale: root.locale,
name: event.title,
pathname: `${sectionPath("events", root.locale)}/${encodeURIComponent(event.slug)}`,
startDate: toIsoDate(event.start) ?? event.start,
...(event.end ? { endDate: toIsoDate(event.end) ?? event.end } : {}),
...(event.summary ? { description: event.summary } : {}),
...(event.image ? { image: event.image.src } : {}),
...(event.place ? { locationName: event.place } : {}),
...(event.region ? { addressLocality: event.region } : {}),
isOnline: event.format === "online" || event.format === "hybrid",
}),
);
return buildMeta({
locale: root.locale,
@@ -204,7 +219,8 @@ export function meta({ location, matches }: Route.MetaArgs) {
search.has("page") ||
search.has("view") ||
search.has("month"),
jsonLd: buildBreadcrumbJsonLd(root.siteUrl, [
jsonLd: [
buildBreadcrumbJsonLd(root.siteUrl, [
{
name: translate(root.locale, "nav.sections.home"),
pathname: localizedPath("/", root.locale),
@@ -214,6 +230,8 @@ export function meta({ location, matches }: Route.MetaArgs) {
pathname: sectionPath("events", root.locale),
},
]),
...eventJsonLd,
],
});
}

Some files were not shown because too many files have changed in this diff Show More