22 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
81 changed files with 4713 additions and 536 deletions
+9
View File
@@ -84,6 +84,10 @@ CMS_PORT=1337
# in production (e.g. https://cms.sharper.ly). # in production (e.g. https://cms.sharper.ly).
CMS_PUBLIC_URL=http://localhost:1337 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. # 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). # Settings -> API Tokens, then paste the value here (it is shown once).
STRAPI_TOKEN= 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 # CMS RUNTIME
+43 -2
View File
@@ -62,7 +62,7 @@ sharp-er/
│ ├── web/ React Router 8 front end (SSR) │ ├── web/ React Router 8 front end (SSR)
│ │ ├── app/ │ │ ├── app/
│ │ │ ├── root.tsx HTML shell: <html lang/dir>, canonical + hreflang, nav, footer │ │ │ ├── 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) │ │ │ ├── routes/ One module per page (+ shared _page-kit / _shared helpers)
│ │ │ ├── components/ui/ Button, Input, Card, Navigation, Footer, LanguageSwitcher │ │ │ ├── components/ui/ Button, Input, Card, Navigation, Footer, LanguageSwitcher
│ │ │ ├── styles/ Design tokens — primitives.css, semantic.css, typography.css │ │ │ ├── styles/ Design tokens — primitives.css, semantic.css, typography.css
@@ -266,6 +266,35 @@ every boot; it
You never have to click through the Users & Permissions screens after adding a content type; You never have to click through the Users & Permissions screens after adding a content type;
restart the CMS and the grant is there. 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 ### 6. Create the first admin user
Strapi has no seeded administrator. On first run, open 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 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 start` | runs both production builds |
| `pnpm seed` | writes the approved content into the CMS — see [Seeding content](#seeding-content) | | `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` | | `pnpm typecheck` | `tsc` in both applications, preceded in the web app by `react-router typegen` |
**`apps/web`** — run with `pnpm --filter web <script>`: **`apps/web`** — run with `pnpm --filter web <script>`:
@@ -512,6 +542,7 @@ and never opens a port of its own.
```bash ```bash
pnpm seed # the approved content 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 # the same, plus fixtures to look at
pnpm seed -- --demo --clean # remove those fixtures again 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 streams, the launch article, and the About, What We Do, Contact and Home
pages. 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 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 no project email, no Facebook URL and no consortium addresses, because the
source writes all of those as bracketed placeholders. Two fields are judgement 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 calls and say so at the line: every activity's `activityStatus`, which the
@@ -890,7 +929,9 @@ 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 | | `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_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 | | `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 | | `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 | | `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 | | `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 | | `DATABASE_*` | cms | managed PostgreSQL; set `DATABASE_SSL=true` for a hosted database |
+11 -2
View File
@@ -19,7 +19,7 @@ PORT=1337
# Public URL of the API, used to build absolute media/admin URLs. # 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 # Leave commented out in local development; set it behind a proxy or a custom
# domain, e.g. PUBLIC_URL=https://cms.sharper.ly # 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 # true when Strapi sits behind nginx / a load balancer, so X-Forwarded-* headers
# are trusted for the client IP and protocol. # are trusted for the client IP and protocol.
IS_PROXIED=false IS_PROXIED=false
@@ -149,10 +149,19 @@ SMTP_HOST=smtp.example.com
SMTP_PORT=587 SMTP_PORT=587
SMTP_USERNAME=your-username SMTP_USERNAME=your-username
SMTP_PASSWORD=your-password 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_FROM=no-reply@yourdomain.com
DEFAULT_REPLY_TO=support@yourdomain.com DEFAULT_REPLY_TO=support@yourdomain.com
# The values above are also the from/reply-to for the registration # The values above are also the from/reply-to for the registration
# confirmation and event-reminder e-mails (src/lib/event-mail.ts). # 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 # Production only - switching the upload provider to S3. Uncomment these after
# adding @strapi/provider-upload-aws-s3 and editing the upload block in # adding @strapi/provider-upload-aws-s3 and editing the upload block in
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", "relation": "oneToMany",
"target": "api::partner.partner" "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": { "regions": {
"type": "relation", "type": "relation",
"relation": "oneToMany", "relation": "oneToMany",
@@ -28,6 +28,8 @@
import type { Core } from '@strapi/strapi'; import type { Core } from '@strapi/strapi';
import { errors } from '@strapi/utils'; import { errors } from '@strapi/utils';
import { sendRegistrationConfirmation } from '../../../lib/event-mail';
const CLIENT_FIELDS = [ const CLIENT_FIELDS = [
'event', 'event',
'fullName', 'fullName',
@@ -143,7 +145,9 @@ const resolveRegistrationState = async (
const taken = capacity === null ? 0 : await countConfirmed(strapi, event.documentId); const taken = capacity === null ? 0 : await countConfirmed(strapi, event.documentId);
const isFull = const isFull =
event.registrationStatus === 'full' || (capacity !== null && taken >= capacity); event.registrationStatus === 'full' ||
event.registrationStatus === 'waitlist' ||
(capacity !== null && taken >= capacity);
if (!isFull) { if (!isFull) {
return 'confirmed'; return 'confirmed';
@@ -189,15 +193,51 @@ const sanitizeSubmission: Core.MiddlewareFactory =
.forUpdate() .forUpdate()
.first(); .first();
data.registrationState = await resolveRegistrationState(strapi, { data.registrationState = await resolveRegistrationState(strapi, event.documentId);
documentId: event.documentId,
});
data.submittedAt = new Date().toISOString(); data.submittedAt = new Date().toISOString();
data.reminderSentAt = null; data.reminderSentAt = null;
request.body = { data }; request.body = { data };
await next(); 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; export default sanitizeSubmission;
@@ -1,64 +1,15 @@
/** /**
* event-registration service * event-registration service
* *
* `create` is overridden to fire the confirmation e-mail *after* the row is * Plain default `create` service. The registration row is written inside the
* written. The send is deliberately not awaited: a slow or failing SMTP call * transaction opened by the sanitize-submission middleware, and that middleware
* must never turn the registration POST into a 502. The mail helper swallows * dispatches the confirmation e-mail *after* the transaction has committed.
* and logs its own errors, and the state derivation stays in the * It must not be dispatched from here: a send fired inside the request
* sanitize-submission middleware - nothing here recomputes confirmed/waitlisted. * 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'; import { factories } from '@strapi/strapi';
import { sendRegistrationConfirmation } from '../../../lib/event-mail'; export default factories.createCoreService('api::event-registration.event-registration');
const REGISTRATION_UID = 'api::event-registration.event-registration';
const LOG_PREFIX = '[event-registration]';
/**
* The `event` value a client may have sent is a documentId (what the REST API
* uses for relations), or a legacy numeric id. Mirrors the middleware's reader
* so the confirmation mail points at the event the registrant chose.
*/
const readEventReference = (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): { email?: unknown; registrationState?: unknown } =>
(typeof result === 'object' && result !== null ? result : {}) as {
email?: unknown;
registrationState?: unknown;
};
export default factories.createCoreService(REGISTRATION_UID, ({ strapi }) => ({
async create(params: { data?: { event?: unknown } } & Record<string, unknown>) {
const result = await super.create(params);
const { email, registrationState } = readCreatedResult(result);
if (typeof email === 'string' && email.length > 0) {
const eventReference = readEventReference(params.data?.event);
void sendRegistrationConfirmation({
strapi,
to: email,
state: registrationState === 'waitlisted' ? 'waitlisted' : 'confirmed',
eventReference,
});
} else {
strapi.log.warn(
`${LOG_PREFIX} no email on the created registration; confirmation e-mail skipped.`,
);
}
return result;
},
}));
@@ -143,6 +143,35 @@
"relation": "oneToMany", "relation": "oneToMany",
"target": "api::partner.partner" "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": { "registrationStatus": {
"type": "enumeration", "type": "enumeration",
"enum": [ "enum": [
@@ -199,19 +228,6 @@
} }
} }
}, },
"gallery": {
"type": "media",
"multiple": true,
"required": false,
"allowedTypes": [
"images"
],
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"recapUrl": { "recapUrl": {
"type": "string", "type": "string",
"pluginOptions": { "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", "relation": "oneToOne",
"target": "api::partner.partner" "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": { "publishDate": {
"type": "date", "type": "date",
"required": true, "required": true,
@@ -149,6 +149,35 @@
"relation": "oneToMany", "relation": "oneToMany",
"target": "api::partner.partner" "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": { "coverImage": {
"type": "media", "type": "media",
"multiple": false, "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
}
}
}
@@ -140,6 +140,35 @@
"relation": "oneToMany", "relation": "oneToMany",
"target": "api::partner.partner" "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": { "topics": {
"type": "relation", "type": "relation",
"relation": "oneToMany", "relation": "oneToMany",
+6
View File
@@ -4,6 +4,9 @@ import { revokePublicAuthActions } from './auth-permissions';
import { LOG_PREFIX } from './constants'; import { LOG_PREFIX } from './constants';
import { ensureLocales } from './locales'; import { ensureLocales } from './locales';
import { ensurePublicPermissions } from './public-permissions'; 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'; import { ensureReminderSettings } from './reminder-settings';
export { revokePublicAuthActions } from './auth-permissions'; export { revokePublicAuthActions } from './auth-permissions';
@@ -29,6 +32,9 @@ export const runBootstrap = async ({ strapi }: { strapi: Core.Strapi }): Promise
await ensurePublicPermissions(strapi); await ensurePublicPermissions(strapi);
await revokePublicAuthActions(strapi); await revokePublicAuthActions(strapi);
await ensureReminderSettings(strapi); await ensureReminderSettings(strapi);
await ensurePartnerReviewPermissions(strapi);
await ensurePartnerReviewLayouts(strapi);
await ensurePartnerRelationLabels(strapi);
strapi.log.debug(`${LOG_PREFIX} bootstrap complete.`); 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: [] },
});
}
}
};
@@ -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"]
}
}
}
+6 -1
View File
@@ -2,6 +2,8 @@ import type { Core } from '@strapi/strapi';
import { runBootstrap } from './bootstrap'; import { runBootstrap } from './bootstrap';
import { runEventReminders } from './lib/event-reminders'; 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'; import { runWaitlistPromotion } from './lib/waitlist-promotion';
export default { export default {
@@ -11,7 +13,10 @@ export default {
* *
* This gives you an opportunity to extend code. * 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 * An asynchronous bootstrap function that runs before
+20 -35
View File
@@ -11,15 +11,18 @@
* retried by nothing today but is visible in the admin panel as a null. * 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 * The (Get Involved) section form can submit an application with no
* opportunity relation. The mail then drops the call name and stays a plain * opportunity relation. The mail then drops the call name and uses the
* acknowledgement, so the promise holds for both entry points. * no-call acknowledgement, so the promise holds for both entry points.
* *
* Copy is derived from the approved web copy; the footer is the same * Copy is hardcoded in `email-template-defaults.ts`, and this message uses the
* EU-visibility block the event mails use. Nothing here is invented. * 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 type { Core } from '@strapi/strapi';
import { DEFAULT_EMAIL_TEMPLATES } from './email-template-defaults';
import { buildBilingualMail, trySendMail } from './event-mail'; import { buildBilingualMail, trySendMail } from './event-mail';
const OPPORTUNITY_UID = 'api::opportunity.opportunity'; const OPPORTUNITY_UID = 'api::opportunity.opportunity';
@@ -33,31 +36,6 @@ type OpportunityReference = { documentId?: string; id?: number } | undefined;
/** An opportunity looked up in both locales; missing locales are allowed. */ /** An opportunity looked up in both locales; missing locales are allowed. */
type BilingualOpportunity = { en?: { title?: string | null }; ar?: { title?: string | null } }; type BilingualOpportunity = { en?: { title?: string | null }; ar?: { title?: string | null } };
const ACKNOWLEDGEMENT_SUBJECT = {
ar: 'تم استلام الطلب',
en: 'Application received',
} as const;
/*
* The approved wording this mirrors, from
* apps/web/app/routes/_events-opportunities/copy.ts:
* applicationReceivedBody en: "Thank you for your application. Our team
* will review it and follow up by email."
* ar: "شكرًا لتقديم طلبك. سيراجعه فريقنا ويتواصل
* معك عبر البريد الإلكتروني."
* receivedBody ar: "شكرًا لتقديمك على {route}..." (the "على"
* preposition is kept for the call name).
*/
const ACKNOWLEDGEMENT_BODY_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;
const ACKNOWLEDGEMENT_BODY = {
ar: 'شكرًا لك — استلمنا طلبك. سيراجع فريقنا طلبك ويتواصل معك عبر البريد الإلكتروني.',
en: 'Thank you — we have received your application. Our team will review it and follow up by email.',
} as const;
/** /**
* Fetch an opportunity in both locales. Only published entries are ever * Fetch an opportunity in both locales. Only published entries are ever
* fetched; either a documentId or a legacy numeric id is accepted, mirroring * fetched; either a documentId or a legacy numeric id is accepted, mirroring
@@ -89,6 +67,10 @@ export async function fetchBilingualOpportunity(
* `acknowledgementSentAt` when the provider accepted it. Best-effort and * `acknowledgementSentAt` when the provider accepted it. Best-effort and
* never awaited by the request path: a slow or failing SMTP call must not make * never awaited by the request path: a slow or failing SMTP call must not make
* the application POST fail. * 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({ export async function sendApplicationAcknowledgement({
strapi, strapi,
@@ -103,17 +85,20 @@ export async function sendApplicationAcknowledgement({
}): Promise<void> { }): Promise<void> {
try { try {
const opportunity = await fetchBilingualOpportunity(strapi, opportunityReference); const opportunity = await fetchBilingualOpportunity(strapi, opportunityReference);
const template = DEFAULT_EMAIL_TEMPLATES['application-acknowledgement'];
const callEn = opportunity.en?.title || undefined; const callEn = opportunity.en?.title || undefined;
const callAr = opportunity.ar?.title || opportunity.en?.title || undefined; const callAr = opportunity.ar?.title || opportunity.en?.title || undefined;
const mail = buildBilingualMail({ const mail = buildBilingualMail({
subjectAr: ACKNOWLEDGEMENT_SUBJECT.ar, subjectAr: template.ar.subject,
subjectEn: ACKNOWLEDGEMENT_SUBJECT.en, subjectEn: template.en.subject,
bodyAr: (callAr ? ACKNOWLEDGEMENT_BODY_WITH_CALL.ar : ACKNOWLEDGEMENT_BODY.ar) bodyAr: template.ar.body,
.replace(/\{call\}/g, callAr ?? ''), bodyEn: template.en.body,
bodyEn: (callEn ? ACKNOWLEDGEMENT_BODY_WITH_CALL.en : ACKNOWLEDGEMENT_BODY.en) vars: {
.replace(/\{call\}/g, callEn ?? ''), ar: { call: callAr ?? '' },
en: { call: callEn ?? '' },
},
}); });
const ok = await trySendMail(strapi, { to, ...mail }); const ok = await trySendMail(strapi, { to, ...mail });
+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}`;
}
+401 -145
View File
@@ -14,13 +14,18 @@
* The Arabic block always comes first and is marked dir="rtl"; the English * The Arabic block always comes first and is marked dir="rtl"; the English
* block follows inside the same message. Subjects are bilingual too. * block follows inside the same message. Subjects are bilingual too.
* *
* Copy mirrors the approved web copy in apps/web (i18n/messages and * COPY IS HARDCODED in lib/email-template-defaults.ts. This module owns the
* _events-opportunities/copy.ts); anything invented lives in the seed, not * shared styled HTML layout, event lookups, the {event} / {date} placeholders,
* here. EU visibility uses the approved wording from the seed sources. * venue or join-online line, and EU/partner branding.
*/ */
import type { Core } from '@strapi/strapi'; 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'; const EVENT_UID = 'api::event.event';
export type RegistrationState = 'confirmed' | 'waitlisted'; export type RegistrationState = 'confirmed' | 'waitlisted';
@@ -31,12 +36,15 @@ type Locale = 'en' | 'ar';
type EventRow = { type EventRow = {
documentId: string; documentId: string;
title?: string | null; title?: string | null;
description?: string | null;
/** Strapi returns datetime attributes as ISO strings (or Dates); accept both. */ /** Strapi returns datetime attributes as ISO strings (or Dates); accept both. */
startDateTime?: string | Date | null; startDateTime?: string | Date | null;
endDateTime?: string | Date | null;
timezone?: string | null; timezone?: string | null;
format?: 'in-person' | 'online' | 'hybrid' | null; format?: 'in-person' | 'online' | 'hybrid' | null;
venue?: string | null; venue?: string | null;
onlineUrl?: string | null; onlineUrl?: string | null;
region?: { name?: string | null } | null;
}; };
/** An event looked up in both locales; missing locales are allowed. */ /** An event looked up in both locales; missing locales are allowed. */
@@ -52,6 +60,174 @@ const EMAIL_TAG = {
const DEFAULT_TIMEZONE = 'Africa/Tripoli'; 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 * Date plus time of day, mirroring the web's `formatDateTime`: Arabic uses the
* latn numbering system, so digits never switch to Eastern Arabic numerals. * latn numbering system, so digits never switch to Eastern Arabic numerals.
@@ -73,43 +249,9 @@ function formatEventDateTime(
} }
/* --------------------------------------------------------------------------- /* ---------------------------------------------------------------------------
CONFIRMATION / WAITLIST COPY (approved web copy) LOCATION LINE
------------------------------------------------------------------------ */ ------------------------------------------------------------------------ */
const CONFIRMATION_SUBJECT = {
ar: 'تم تأكيد تسجيلك',
en: 'Registration confirmed',
} as const;
const WAITLIST_SUBJECT = {
ar: 'تمت إضافتك إلى قائمة الانتظار',
en: "You're on the waitlist",
} as const;
const CONFIRMATION_BODY = {
ar: 'شكرًا لك — تم تأكيد مكانك في {event} بتاريخ {date}. سنرسل لك تذكيرًا وأي تفاصيل إضافية على البريد الإلكتروني الذي قدّمته.',
en: 'Thank you — your spot for {event} on {date} is confirmed. We’ll send a reminder and any joining details to the email you provided.',
} as const;
const WAITLIST_BODY = {
ar: 'شكرًا لك — اكتمل العدد في {event} بتاريخ {date}، لذلك أضفناك إلى قائمة الانتظار. سنراسلك عبر البريد الإلكتروني في حال توفّر مكان.',
en: '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.',
} as const;
/* ---------------------------------------------------------------------------
REMINDER COPY
------------------------------------------------------------------------ */
const REMINDER_SUBJECT_PREFIX = {
ar: 'تذكير',
en: 'Reminder',
} as const;
const REMINDER_BODY = {
ar: 'هذا تذكير بأنك مسجّل في {event} بتاريخ {date}. تبدأ الفعالية في الوقت المُشار إليه أدناه.',
en: 'This is a reminder that you are registered for {event} on {date}. A summary is below.',
} as const;
const LOCATION_LABEL = { const LOCATION_LABEL = {
ar: 'الموقع', ar: 'الموقع',
en: 'Location', en: 'Location',
@@ -120,6 +262,35 @@ const JOIN_LABEL = {
en: 'Join online', en: 'Join online',
} as const; } 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) EU VISIBILITY FOOTER (approved wording)
------------------------------------------------------------------------ */ ------------------------------------------------------------------------ */
@@ -136,57 +307,139 @@ const FOOTER = {
const BASE_FONT = const BASE_FONT =
"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;"; "font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;";
function escapeHtml(value: string): string { const EMAIL_LOGOS = {
return value project: 'https://cms.sharper.ly/branding/sharp-er-wordmark.png',
.replace(/&/g, '&amp;') eu: 'https://cms.sharper.ly/branding/eu-emblem.png',
.replace(/</g, '&lt;') partners: [
.replace(/>/g, '&gt;') { name: 'Acted', url: 'https://cms.sharper.ly/branding/partner-acted.png' },
.replace(/"/g, '&quot;') {
.replace(/'/g, '&#39;'); 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;
function wrapParagraphs(text: string): string { /**
return text * The logo chrome shared by every mail: the SHARP-ER project logo at the top
.trim() * (the EU badge beside it), and the consortium partner logos under the footer
.split(/\n\n+/) * text. EU visibility is contractual, so the badge and the partner logos are
.map((paragraph) => `<p>${escapeHtml(paragraph.trim())}</p>`) * 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(''); .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 * One bilingual message: the Arabic block first (rtl), then English, then the
* EU-visibility footer. Inline styles keep the layout working in mail clients * EU-visibility footer and partner logos. Inline styles keep the layout
* that strip <head> style sheets. * 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: { export function buildBilingualMail(opts: {
subjectAr: string; subjectAr: string;
subjectEn: string; subjectEn: string;
bodyAr: string; bodyAr: string;
bodyEn: string; bodyEn: string;
/** Literal {event} / {date} / {call} values, per locale, unescaped. */
vars?: { ar: PlaceholderValues; en: PlaceholderValues };
locationLine?: { ar: string; en: string }; locationLine?: { ar: string; en: string };
jsonLd?: JsonLd;
}): { subject: string; text: string; html: string } { }): { subject: string; text: string; html: string } {
const { subjectAr, subjectEn, bodyAr, bodyEn, locationLine } = opts; const {
subjectAr,
subjectEn,
bodyAr,
bodyEn,
vars,
locationLine,
jsonLd,
} = opts;
const html = ` const varsAr = vars?.ar ?? {};
<div dir="rtl" lang="ar" style="${BASE_FONT} line-height:1.6; color:#222;"> const varsEn = vars?.en ?? {};
${wrapParagraphs(bodyAr)}
</div> const escaped = (values: PlaceholderValues): PlaceholderValues =>
<hr style="border:none; border-top:1px solid #ddd; margin:24px 0;" /> Object.fromEntries(
<div dir="ltr" lang="en" style="${BASE_FONT} line-height:1.6; color:#222;"> Object.entries(values).map(([name, value]) => [name, escapeHtml(value)])
${wrapParagraphs(bodyEn)} );
</div>
<p style="margin-top:32px; font-size:12px; line-height:1.5; color:#666; ${BASE_FONT}"> const subject = `${fillPlaceholders(subjectAr, varsAr)} | ${fillPlaceholders(subjectEn, varsEn)}`;
${FOOTER.ar}
</p> const html = `<!doctype html>
<p style="font-size:12px; line-height:1.5; color:#666; ${BASE_FONT}"> <html lang="en">
${FOOTER.en} <head>
</p>`; <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 = [ const text = [
bodyAr, fillPlaceholders(mdToText(bodyAr), varsAr),
locationLine?.ar ?? '', locationLine?.ar ?? '',
'', '',
bodyEn, fillPlaceholders(mdToText(bodyEn), varsEn),
locationLine?.en ?? '', locationLine?.en ?? '',
'', '',
FOOTER.ar, FOOTER.ar,
@@ -196,7 +449,7 @@ export function buildBilingualMail(opts: {
.join('\n'); .join('\n');
return { return {
subject: `${subjectAr} | ${subjectEn}`, subject,
text, text,
html, html,
}; };
@@ -225,8 +478,16 @@ export async function fetchBilingualEvent(
if (!filters) return {}; if (!filters) return {};
const [en, ar] = await Promise.all([ const [en, ar] = await Promise.all([
strapi.documents(EVENT_UID).findFirst({ filters, locale: 'en' }), strapi.documents(EVENT_UID).findFirst({
strapi.documents(EVENT_UID).findFirst({ filters, locale: 'ar' }), 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 }; return { en: en ?? undefined, ar: ar ?? undefined };
@@ -240,7 +501,8 @@ type MailOptions = {
to: string; to: string;
subject: string; subject: string;
text: string; text: string;
html?: string; html: string;
attachments?: MailAttachment[];
}; };
type EmailServiceShape = { send: (options: MailOptions) => Promise<unknown> }; type EmailServiceShape = { send: (options: MailOptions) => Promise<unknown> };
@@ -278,35 +540,6 @@ export async function trySendMail(
CONFIRMATION CONFIRMATION
------------------------------------------------------------------------ */ ------------------------------------------------------------------------ */
/**
* 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;
}
/** /**
* Build and send the confirmation (or waitlist) e-mail for a registration. * Build and send the confirmation (or waitlist) e-mail for a registration.
* Best-effort: logs failures, never throws into the request path. * Best-effort: logs failures, never throws into the request path.
@@ -321,14 +554,19 @@ export async function sendRegistrationConfirmation({
to, to,
state, state,
eventReference, eventReference,
reservation,
}: { }: {
strapi: Core.Strapi; strapi: Core.Strapi;
to: string; to: string;
state: RegistrationState; state: RegistrationState;
eventReference: EventReference; eventReference: EventReference;
reservation: ReservationIdentity;
}): Promise<boolean> { }): Promise<boolean> {
try { try {
const isWaitlist = state === 'waitlisted';
const event = await fetchBilingualEvent(strapi, eventReference); const event = await fetchBilingualEvent(strapi, eventReference);
const template = DEFAULT_EMAIL_TEMPLATES[isWaitlist ? 'waitlist' : 'confirmation'];
if (!event.en && !event.ar) { if (!event.en && !event.ar) {
strapi.log.warn( strapi.log.warn(
@@ -353,23 +591,25 @@ export async function sendRegistrationConfirmation({
timeZone, timeZone,
); );
const isWaitlist = state === 'waitlisted';
const subjectAr = isWaitlist ? WAITLIST_SUBJECT.ar : CONFIRMATION_SUBJECT.ar;
const subjectEn = isWaitlist ? WAITLIST_SUBJECT.en : CONFIRMATION_SUBJECT.en;
const bodyAr = isWaitlist ? WAITLIST_BODY.ar : CONFIRMATION_BODY.ar;
const bodyEn = isWaitlist ? WAITLIST_BODY.en : CONFIRMATION_BODY.en;
const mail = buildBilingualMail({ const mail = buildBilingualMail({
subjectAr, subjectAr: template.ar.subject,
subjectEn, subjectEn: template.en.subject,
bodyAr: bodyAr bodyAr: template.ar.body,
.replace(/\{event\}/g, titleAr) bodyEn: template.en.body,
.replace(/\{date\}/g, dateAr), vars: {
bodyEn: bodyEn.replace(/\{event\}/g, titleEn).replace(/\{date\}/g, dateEn), ar: { event: titleAr, date: dateAr },
en: { event: titleEn, date: dateEn },
},
locationLine: locationLineFor(event), locationLine: locationLineFor(event),
jsonLd: buildEventReservationJsonLd(event, state, reservation),
}); });
return trySendMail(strapi, { to, ...mail }); const attachment = buildEventCalendarAttachment(event, state);
return trySendMail(strapi, {
to,
...mail,
...(attachment ? { attachments: [attachment] } : {}),
});
} catch (error) { } catch (error) {
strapi.log.error(`[event-mail] confirmation failed: ${String(error)}`); strapi.log.error(`[event-mail] confirmation failed: ${String(error)}`);
return false; return false;
@@ -385,41 +625,57 @@ export async function sendEventReminderMail({
strapi, strapi,
to, to,
eventReference, eventReference,
reservation,
}: { }: {
strapi: Core.Strapi; strapi: Core.Strapi;
to: string; to: string;
eventReference: EventReference; eventReference: EventReference;
reservation: ReservationIdentity;
}): Promise<boolean> { }): Promise<boolean> {
const event = await fetchBilingualEvent(strapi, eventReference); try {
const event = await fetchBilingualEvent(strapi, eventReference);
const template = DEFAULT_EMAIL_TEMPLATES['event-reminder'];
if (!event.en && !event.ar) { if (!event.en && !event.ar) {
strapi.log.warn( strapi.log.warn(
'[event-mail] reminder skipped: no published event for the registration.', '[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; 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: `${REMINDER_SUBJECT_PREFIX.ar}: ${titleAr}`,
subjectEn: `${REMINDER_SUBJECT_PREFIX.en}: ${titleEn}`,
bodyAr: REMINDER_BODY.ar
.replace(/\{event\}/g, titleAr)
.replace(/\{date\}/g, dateAr),
bodyEn: REMINDER_BODY.en.replace(/\{event\}/g, titleEn).replace(/\{date\}/g, dateEn),
locationLine: locationLineFor(event),
});
return trySendMail(strapi, { to, ...mail });
} }
+7
View File
@@ -126,6 +126,13 @@ export async function runEventReminders({
strapi, strapi,
to: registration.email as string, to: registration.email as string,
eventReference: reference, eventReference: reference,
reservation: {
id: registration.documentId as string,
name:
typeof registration.fullName === 'string'
? registration.fullName
: undefined,
},
}); });
if (!ok) { if (!ok) {
+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;
});
};
+5
View File
@@ -110,6 +110,7 @@ async function promoteEvent(
if (slots <= 0) break; if (slots <= 0) break;
const email = (registration as { email?: unknown }).email; const email = (registration as { email?: unknown }).email;
const fullName = (registration as { fullName?: unknown }).fullName;
if (typeof email !== 'string' || email.length === 0) { if (typeof email !== 'string' || email.length === 0) {
strapi.log.warn( strapi.log.warn(
`${LOG_PREFIX} registration ${registration.documentId} has no email; left waitlisted.`, `${LOG_PREFIX} registration ${registration.documentId} has no email; left waitlisted.`,
@@ -122,6 +123,10 @@ async function promoteEvent(
to: email, to: email,
state: 'confirmed', state: 'confirmed',
eventReference: { documentId: eventDocumentId }, eventReference: { documentId: eventDocumentId },
reservation: {
id: registration.documentId as string,
name: typeof fullName === 'string' ? fullName : undefined,
},
}); });
if (!ok) { if (!ok) {
+189 -7
View File
@@ -1,9 +1,10 @@
/* ============================================================================ /* ============================================================================
SEED - APPROVED CONTENT SEED - APPROVED CONTENT
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
Everything in this file is transcribed from docs/, in both languages, and Everything in this file is transcribed from docs/ or the supplied project
nothing in it is invented. That is the whole point of keeping it apart from content suggestions, in both languages, and nothing in it is invented. That
demo.ts: this content is safe to publish. is the whole point of keeping it apart from demo.ts: this content is safe to
publish.
SOURCES SOURCES
docs/SHARPER_Website_Draft_Section_EN_AR.docx sections 1-9, EN and AR 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/SHARPER Home Page .docx the launch article, EN/AR
docs/UEC_SHARPER_Website_TOR V3.docx the consortium and the docs/UEC_SHARPER_Website_TOR V3.docx the consortium and the
project's purpose 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 WHAT docs/ DOES NOT SUPPLY, AND IS THEREFORE NOT HERE
* Figures. Every number in the draft is bracketed and marked "TBD ... must * 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> = { export const HOME_PAGE: Localised<SeedData> = {
en: { en: {
hero: { 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', headline: 'Advancing Justice and Accountability, Led by Libyan Communities',
subheading: 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.', '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. */ /** The role chip and its line on the About page's consortium cards. */
export const CONSORTIUM_ROLES: Readonly< export const CONSORTIUM_ROLES: Readonly<
Record<string, Localised<{ role: string; roleDescription: string }>> 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: 'مفتوح الآن · دعوات تمويل للمنظمات الليبية' } },
};
+74 -12
View File
@@ -4,6 +4,7 @@
Run it with `pnpm seed` from the repository root, or from apps/cms: Run it with `pnpm seed` from the repository root, or from apps/cms:
pnpm seed the approved content only (approved.ts) 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 the same, plus the invented fixtures (demo.ts)
pnpm seed --demo --clean remove the invented fixtures, leave the rest 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. documentId, and a relation can only be set once its target exists.
WHAT IT DOES NOT TOUCH WHAT IT DOES NOT TOUCH
* Media. The seed uploads nothing. Where a page already has a picture - * Media. The seed uploads nothing. Existing home and Legacy hero/gallery
the home hero, most of all - the existing file is read back and written media are read back and written out again with the copy, because a
out again with the new copy, because a component is replaced wholesale component is replaced wholesale on write and omitted media is removed.
on write and anything left out of the payload is gone.
* Submissions. `application`, `event-registration` and `contact-submission` * Submissions. `application`, `event-registration` and `contact-submission`
hold personal data. Nothing writes to them here, not even in demo mode. hold personal data. Nothing writes to them here, not even in demo mode.
* Locales and permissions. Those are reconciled at boot by src/bootstrap. * Locales and permissions. Those are reconciled at boot by src/bootstrap.
@@ -35,6 +35,7 @@ import {
CONSORTIUM_ROLES, CONSORTIUM_ROLES,
CONTACT_PAGE, CONTACT_PAGE,
HOME_PAGE, HOME_PAGE,
LEGACY_PAGE,
LAUNCH_ARTICLE, LAUNCH_ARTICLE,
SITE_SETTINGS, SITE_SETTINGS,
TOPICS, TOPICS,
@@ -43,7 +44,6 @@ import {
} from './approved'; } from './approved';
import { import {
DEMO_FAQS, DEMO_FAQS,
DEMO_HOME_PAGE,
DEMO_PARTNERS, DEMO_PARTNERS,
DEMO_PARTNER_SLUGS, DEMO_PARTNER_SLUGS,
DEMO_PREFIX, DEMO_PREFIX,
@@ -59,6 +59,7 @@ import {
deleteBySlugPrefix, deleteBySlugPrefix,
deleteWhere, deleteWhere,
mediaId, mediaId,
mediaIds,
readExisting, readExisting,
upsert, upsert,
type Localised, type Localised,
@@ -71,12 +72,15 @@ export interface SeedOptions {
readonly demo: boolean; readonly demo: boolean;
/** With `demo`, remove those fixtures instead of writing them. */ /** With `demo`, remove those fixtures instead of writing them. */
readonly clean: boolean; readonly clean: boolean;
/** Write only the bilingual Legacy single type, preserving its media. */
readonly legacyOnly: boolean;
} }
const UID = { const UID = {
siteSettings: 'api::site-settings.site-settings', siteSettings: 'api::site-settings.site-settings',
homePage: 'api::home-page.home-page', homePage: 'api::home-page.home-page',
aboutPage: 'api::about-page.about-page', aboutPage: 'api::about-page.about-page',
legacyPage: 'api::legacy-page.legacy-page',
whatWeDoPage: 'api::what-we-do-page.what-we-do-page', whatWeDoPage: 'api::what-we-do-page.what-we-do-page',
contactPage: 'api::contact-page.contact-page', contactPage: 'api::contact-page.contact-page',
partner: 'api::partner.partner', partner: 'api::partner.partner',
@@ -218,6 +222,8 @@ async function seedApproved(strapi: Core.Strapi): Promise<{
}); });
log(strapi, 'about page'); log(strapi, 'about page');
await seedLegacyPage(strapi);
await upsert(strapi, UID.whatWeDoPage, WHAT_WE_DO_PAGE); await upsert(strapi, UID.whatWeDoPage, WHAT_WE_DO_PAGE);
log(strapi, '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 }; 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 THE DEMO SET
------------------------------------------------------------------------ */ ------------------------------------------------------------------------ */
@@ -326,11 +383,10 @@ async function seedDemo(
} }
log(strapi, `${knowledge.length} demo knowledge items`); log(strapi, `${knowledge.length} demo knowledge items`);
/* Finally the home page, which the demo set only adds to: the status pill, /* Finally the home page: the demo set adds twelve organisations to the
the network logo wall (twelve organisations, which is what turns the wall network logo wall and two network stories beside the real launch article.
into the design's marquee), and two network stories beside the real launch The hero is rewritten in full, media included, because writing a component
article. The hero is rewritten in full, media included, because writing a replaces it. */
component replaces it. */
const existingHome = await readExisting(strapi, UID.homePage, ['hero.media']); const existingHome = await readExisting(strapi, UID.homePage, ['hero.media']);
const heroMedia = mediaId(existingHome?.hero, 'media'); const heroMedia = mediaId(existingHome?.hero, 'media');
@@ -344,7 +400,6 @@ async function seedDemo(
...HOME_PAGE[locale], ...HOME_PAGE[locale],
hero: { hero: {
...(HOME_PAGE[locale].hero as SeedData), ...(HOME_PAGE[locale].hero as SeedData),
...(DEMO_HOME_PAGE[locale].hero as SeedData),
...(heroMedia ? { media: heroMedia } : {}), ...(heroMedia ? { media: heroMedia } : {}),
}, },
featuredNews: featured, 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 /* 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 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 existingHome = await readExisting(strapi, UID.homePage, ['hero.media']);
const heroMedia = mediaId(existingHome?.hero, 'media'); const heroMedia = mediaId(existingHome?.hero, 'media');
@@ -420,6 +475,12 @@ export async function runSeed(strapi: Core.Strapi, options: SeedOptions): Promis
return; 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); const context = await seedApproved(strapi);
if (options.demo) { if (options.demo) {
@@ -435,6 +496,7 @@ export function parseSeedOptions(argv: readonly string[]): SeedOptions {
return { return {
demo: argv.includes('--demo'), demo: argv.includes('--demo'),
clean: argv.includes('--clean'), clean: argv.includes('--clean'),
legacyOnly: argv.includes('--legacy-only'),
}; };
} }
+15 -2
View File
@@ -218,11 +218,12 @@ export async function readExisting(
strapi: Core.Strapi, strapi: Core.Strapi,
uid: SeedUid, uid: SeedUid,
populate: string[], populate: string[],
find?: SeedData find?: SeedData,
locale: SeedLocale = 'ar'
): Promise<SeedDocument | null> { ): Promise<SeedDocument | null> {
return documents(strapi, uid).findFirst({ return documents(strapi, uid).findFirst({
...(find ? { filters: find } : {}), ...(find ? { filters: find } : {}),
locale: 'ar', locale,
...draftFilter(strapi, uid), ...draftFilter(strapi, uid),
populate, populate,
}); });
@@ -236,3 +237,15 @@ export function mediaId(component: unknown, field: string): number | undefined {
const id = (media as Record<string, unknown>).id; const id = (media as Record<string, unknown>).id;
return typeof id === 'number' ? id : undefined; 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 { export interface PageWorkstream extends Struct.ComponentSchema {
collectionName: 'components_page_workstreams'; collectionName: 'components_page_workstreams';
info: { info: {
@@ -327,6 +370,9 @@ declare module '@strapi/strapi' {
'navigation.nav-group': NavigationNavGroup; 'navigation.nav-group': NavigationNavGroup;
'page.band-intro': PageBandIntro; 'page.band-intro': PageBandIntro;
'page.consortium-member': PageConsortiumMember; 'page.consortium-member': PageConsortiumMember;
'page.legacy-focus-area': PageLegacyFocusArea;
'page.legacy-metric': PageLegacyMetric;
'page.legacy-stage': PageLegacyStage;
'page.workstream': PageWorkstream; 'page.workstream': PageWorkstream;
'shared.accordion-item': SharedAccordionItem; 'shared.accordion-item': SharedAccordionItem;
'shared.address': SharedAddress; 'shared.address': SharedAddress;
+253 -6
View File
@@ -632,6 +632,23 @@ export interface ApiActivityActivity extends Struct.CollectionTypeSchema {
>; >;
publishedAt: Schema.Attribute.DateTime; publishedAt: Schema.Attribute.DateTime;
regions: Schema.Attribute.Relation<'oneToMany', 'api::region.region'>; 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> & seo: Schema.Attribute.Component<'shared.seo', false> &
Schema.Attribute.SetPluginOptions<{ Schema.Attribute.SetPluginOptions<{
i18n: { i18n: {
@@ -645,6 +662,13 @@ export interface ApiActivityActivity extends Struct.CollectionTypeSchema {
localized: true; localized: true;
}; };
}>; }>;
submittedByAdminUserId: Schema.Attribute.Integer &
Schema.Attribute.Private &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: false;
};
}>;
summary: Schema.Attribute.Text & summary: Schema.Attribute.Text &
Schema.Attribute.SetPluginOptions<{ Schema.Attribute.SetPluginOptions<{
i18n: { i18n: {
@@ -990,12 +1014,6 @@ export interface ApiEventEvent extends Struct.CollectionTypeSchema {
}; };
}> & }> &
Schema.Attribute.DefaultTo<'in-person'>; Schema.Attribute.DefaultTo<'in-person'>;
gallery: Schema.Attribute.Media<'images', true> &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: true;
};
}>;
implementingPartners: Schema.Attribute.Relation< implementingPartners: Schema.Attribute.Relation<
'oneToMany', 'oneToMany',
'api::partner.partner' 'api::partner.partner'
@@ -1036,6 +1054,23 @@ export interface ApiEventEvent extends Struct.CollectionTypeSchema {
'oneToMany', 'oneToMany',
'api::resource.resource' '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> & seo: Schema.Attribute.Component<'shared.seo', false> &
Schema.Attribute.SetPluginOptions<{ Schema.Attribute.SetPluginOptions<{
i18n: { i18n: {
@@ -1056,6 +1091,13 @@ export interface ApiEventEvent extends Struct.CollectionTypeSchema {
localized: false; localized: false;
}; };
}>; }>;
submittedByAdminUserId: Schema.Attribute.Integer &
Schema.Attribute.Private &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: false;
};
}>;
timezone: Schema.Attribute.String & timezone: Schema.Attribute.String &
Schema.Attribute.SetPluginOptions<{ Schema.Attribute.SetPluginOptions<{
i18n: { 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 { export interface ApiNewsArticleNewsArticle extends Struct.CollectionTypeSchema {
collectionName: 'news_articles'; collectionName: 'news_articles';
info: { info: {
@@ -1459,6 +1587,23 @@ export interface ApiNewsArticleNewsArticle extends Struct.CollectionTypeSchema {
}>; }>;
publishedAt: Schema.Attribute.DateTime; publishedAt: Schema.Attribute.DateTime;
regions: Schema.Attribute.Relation<'oneToMany', 'api::region.region'>; 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> & seo: Schema.Attribute.Component<'shared.seo', false> &
Schema.Attribute.SetPluginOptions<{ Schema.Attribute.SetPluginOptions<{
i18n: { i18n: {
@@ -1472,6 +1617,13 @@ export interface ApiNewsArticleNewsArticle extends Struct.CollectionTypeSchema {
localized: true; localized: true;
}; };
}>; }>;
submittedByAdminUserId: Schema.Attribute.Integer &
Schema.Attribute.Private &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: false;
};
}>;
title: Schema.Attribute.String & title: Schema.Attribute.String &
Schema.Attribute.Required & Schema.Attribute.Required &
Schema.Attribute.SetPluginOptions<{ Schema.Attribute.SetPluginOptions<{
@@ -1587,6 +1739,23 @@ export interface ApiOpportunityOpportunity extends Struct.CollectionTypeSchema {
}> & }> &
Schema.Attribute.DefaultTo<'call-eoi'>; Schema.Attribute.DefaultTo<'call-eoi'>;
publishedAt: Schema.Attribute.DateTime; 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> & seo: Schema.Attribute.Component<'shared.seo', false> &
Schema.Attribute.SetPluginOptions<{ Schema.Attribute.SetPluginOptions<{
i18n: { i18n: {
@@ -1600,6 +1769,13 @@ export interface ApiOpportunityOpportunity extends Struct.CollectionTypeSchema {
localized: true; localized: true;
}; };
}>; }>;
submittedByAdminUserId: Schema.Attribute.Integer &
Schema.Attribute.Private &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: false;
};
}>;
summary: Schema.Attribute.Text & summary: Schema.Attribute.Text &
Schema.Attribute.SetPluginOptions<{ Schema.Attribute.SetPluginOptions<{
i18n: { 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 { export interface ApiPartnerPartner extends Struct.CollectionTypeSchema {
collectionName: 'partners'; collectionName: 'partners';
info: { info: {
@@ -1937,6 +2158,23 @@ export interface ApiResourceResource extends Struct.CollectionTypeSchema {
}; };
}> & }> &
Schema.Attribute.DefaultTo<'project-report'>; 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> & seo: Schema.Attribute.Component<'shared.seo', false> &
Schema.Attribute.SetPluginOptions<{ Schema.Attribute.SetPluginOptions<{
i18n: { i18n: {
@@ -1950,6 +2188,13 @@ export interface ApiResourceResource extends Struct.CollectionTypeSchema {
localized: true; localized: true;
}; };
}>; }>;
submittedByAdminUserId: Schema.Attribute.Integer &
Schema.Attribute.Private &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: false;
};
}>;
supersedes: Schema.Attribute.Relation<'oneToOne', 'api::resource.resource'>; supersedes: Schema.Attribute.Relation<'oneToOne', 'api::resource.resource'>;
thumbnail: Schema.Attribute.Media<'images'> & thumbnail: Schema.Attribute.Media<'images'> &
Schema.Attribute.SetPluginOptions<{ Schema.Attribute.SetPluginOptions<{
@@ -2755,8 +3000,10 @@ declare module '@strapi/strapi' {
'api::faq.faq': ApiFaqFaq; 'api::faq.faq': ApiFaqFaq;
'api::home-page.home-page': ApiHomePageHomePage; 'api::home-page.home-page': ApiHomePageHomePage;
'api::knowledge-item.knowledge-item': ApiKnowledgeItemKnowledgeItem; 'api::knowledge-item.knowledge-item': ApiKnowledgeItemKnowledgeItem;
'api::legacy-page.legacy-page': ApiLegacyPageLegacyPage;
'api::news-article.news-article': ApiNewsArticleNewsArticle; 'api::news-article.news-article': ApiNewsArticleNewsArticle;
'api::opportunity.opportunity': ApiOpportunityOpportunity; 'api::opportunity.opportunity': ApiOpportunityOpportunity;
'api::partner-access.partner-access': ApiPartnerAccessPartnerAccess;
'api::partner.partner': ApiPartnerPartner; 'api::partner.partner': ApiPartnerPartner;
'api::region.region': ApiRegionRegion; 'api::region.region': ApiRegionRegion;
'api::reminder-settings.reminder-settings': ApiReminderSettingsReminderSettings; 'api::reminder-settings.reminder-settings': ApiReminderSettingsReminderSettings;
+12
View File
@@ -45,3 +45,15 @@ STRAPI_URL=http://localhost:1337
# Optional Strapi API token. Only needed to read draft or otherwise protected # Optional Strapi API token. Only needed to read draft or otherwise protected
# content; published content is public. Settings -> API Tokens in the admin. # content; published content is public. Settings -> API Tokens in the admin.
STRAPI_TOKEN= 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/ app/
├── root.tsx HTML shell: <html lang/dir>, canonical + hreflang, <Navigation>, <Footer> ├── 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/ ├── routes/
│ ├── *.tsx One module per page, serving BOTH locales │ ├── *.tsx One module per page, serving BOTH locales
│ ├── _page-kit/ Section/hero/prose primitives, markdown renderer, CMS helpers │ ├── _page-kit/ Section/hero/prose primitives, markdown renderer, CMS helpers
@@ -153,6 +153,8 @@ export function DesktopNav({
useEffect(() => clearCloseTimer, [clearCloseTimer]); useEffect(() => clearCloseTimer, [clearCloseTimer]);
useEffect(() => closeGroup(), [pathname, closeGroup]);
const columnLinks = useCallback((groupId: string): HTMLElement[] => { const columnLinks = useCallback((groupId: string): HTMLElement[] => {
const nav = navRef.current; const nav = navRef.current;
if (!nav) return []; if (!nav) return [];
@@ -490,6 +492,7 @@ export function DesktopNav({
<Link <Link
to={child.href} to={child.href}
data-nav-sublink="" data-nav-sublink=""
onClick={closeGroup}
aria-current={ aria-current={
isCurrentPath(child.href, pathname) ? "page" : undefined isCurrentPath(child.href, pathname) ? "page" : undefined
} }
@@ -520,6 +523,7 @@ export function DesktopNav({
isCurrentPath(item.href, pathname) ? "page" : undefined isCurrentPath(item.href, pathname) ? "page" : undefined
} }
onFocus={closeGroup} onFocus={closeGroup}
onClick={closeGroup}
className={cx( className={cx(
"body-sm-bold whitespace-nowrap pb-[2px] no-underline", "body-sm-bold whitespace-nowrap pb-[2px] no-underline",
/* Same rest/hover/current treatment as a group trigger. */ /* Same rest/hover/current treatment as a group trigger. */
@@ -1,7 +1,7 @@
/* ============================================================================ /* ============================================================================
DEFAULT INFORMATION ARCHITECTURE 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 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 Strapi site-settings single type; this module is the typed fallback that
renders when Strapi has not been wired yet. renders when Strapi has not been wired yet.
@@ -30,6 +30,7 @@ import type { NavItem, NavigationStrings, NavigationStringsByLocale } from "./ty
export const NAV_PATHS = { export const NAV_PATHS = {
home: "/", home: "/",
about: "/about", about: "/about",
legacy: "/legacy",
whatWeDo: "/what-we-do", whatWeDo: "/what-we-do",
activities: "/activities-and-initiatives", activities: "/activities-and-initiatives",
events: "/events-calendar", events: "/events-calendar",
@@ -53,6 +54,7 @@ const LABELS: Record<Locale, Record<LabelKey, string>> = {
home: "Home", home: "Home",
aboutAndStrategy: "About and Strategy", aboutAndStrategy: "About and Strategy",
about: "About", about: "About",
legacy: "SHARP Legacy",
whatWeDo: "What We Do", whatWeDo: "What We Do",
activities: "Activities and Initiatives", activities: "Activities and Initiatives",
engagementAndOpportunities: "Engagement and Opportunities", engagementAndOpportunities: "Engagement and Opportunities",
@@ -68,6 +70,7 @@ const LABELS: Record<Locale, Record<LabelKey, string>> = {
home: "الرئيسية", home: "الرئيسية",
aboutAndStrategy: "عن المشروع والاستراتيجية", aboutAndStrategy: "عن المشروع والاستراتيجية",
about: "عن المشروع", about: "عن المشروع",
legacy: "إرث شارب",
whatWeDo: "ماذا نفعل", whatWeDo: "ماذا نفعل",
activities: "الأنشطة والمبادرات", activities: "الأنشطة والمبادرات",
engagementAndOpportunities: "المشاركة والفرص", engagementAndOpportunities: "المشاركة والفرص",
@@ -96,6 +99,7 @@ export function getDefaultNavItems(locale: Locale): NavItem[] {
label: label.aboutAndStrategy, label: label.aboutAndStrategy,
children: [ children: [
{ id: "about", label: label.about, href: href("about") }, { 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: "what-we-do", label: label.whatWeDo, href: href("whatWeDo") },
{ id: "activities", label: label.activities, href: href("activities") }, { 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 { renderToPipeableStream } from "react-dom/server";
import { DEFAULT_LOCALE, getLocaleFromPathname } from "~/i18n/config"; import { DEFAULT_LOCALE, getLocaleFromPathname } from "~/i18n/config";
import { getServerEnv } from "~/lib/env.server";
export const streamTimeout = 5_000; 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( export default function handleRequest(
request: Request, request: Request,
responseStatusCode: number, responseStatusCode: number,
@@ -39,6 +70,11 @@ export default function handleRequest(
routerContext: EntryContext, routerContext: EntryContext,
loadContext: RouterContextProvider, loadContext: RouterContextProvider,
) { ) {
const nonce = readCspNonce(routerContext);
if (nonce) {
responseHeaders.set("Content-Security-Policy", contentSecurityPolicy(nonce));
}
// https://httpwg.org/specs/rfc9110.html#HEAD // https://httpwg.org/specs/rfc9110.html#HEAD
if (request.method.toUpperCase() === "HEAD") { if (request.method.toUpperCase() === "HEAD") {
return new Response(null, { return new Response(null, {
@@ -66,8 +102,9 @@ export default function handleRequest(
); );
const { pipe, abort } = renderToPipeableStream( const { pipe, abort } = renderToPipeableStream(
<ServerRouter context={routerContext} url={request.url} />, <ServerRouter context={routerContext} url={request.url} nonce={nonce} />,
{ {
nonce,
[readyOption]() { [readyOption]() {
shellRendered = true; shellRendered = true;
const body = new PassThrough({ const body = new PassThrough({
+1 -1
View File
@@ -56,7 +56,7 @@ export interface Translation {
readonly messages: Messages; readonly messages: Messages;
/** `/{locale}` prefixed URL for an unprefixed path. */ /** `/{locale}` prefixed URL for an unprefixed path. */
readonly localizedPath: (path: string) => string; 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; readonly sectionPath: (key: RouteSlugKey) => string;
} }
+12 -2
View File
@@ -24,6 +24,7 @@
"home": "الرئيسية", "home": "الرئيسية",
"aboutAndStrategy": "عن المشروع والاستراتيجية", "aboutAndStrategy": "عن المشروع والاستراتيجية",
"about": "عن المشروع", "about": "عن المشروع",
"legacy": "إرث شارب",
"whatWeDo": "ماذا نفعل", "whatWeDo": "ماذا نفعل",
"activities": "الأنشطة والمبادرات", "activities": "الأنشطة والمبادرات",
"engagementAndOpportunities": "المشاركة والفرص", "engagementAndOpportunities": "المشاركة والفرص",
@@ -42,6 +43,12 @@
"en": "English", "en": "English",
"ar": "العربية" "ar": "العربية"
}, },
"legacy": {
"fullRecord": "اقرأ التفاصيل الكاملة لبرنامج {programme}",
"previousImage": "الصورة السابقة من {programme}",
"nextImage": "الصورة التالية من {programme}",
"imageCount": "الصورة {current} من {total}"
},
"actions": { "actions": {
"readMore": "اقرأ المزيد", "readMore": "اقرأ المزيد",
"learnMore": "اعرف المزيد", "learnMore": "اعرف المزيد",
@@ -67,7 +74,8 @@
"applyToJoin": "قدّم طلب الانضمام", "applyToJoin": "قدّم طلب الانضمام",
"viewUpcomingSessions": "اطّلع على الجلسات القادمة", "viewUpcomingSessions": "اطّلع على الجلسات القادمة",
"submitYourUpdate": "شاركونا مستجداتكم", "submitYourUpdate": "شاركونا مستجداتكم",
"addToCalendar": "أضف إلى التقويم", "addToGoogleCalendar": "أضف إلى تقويم Google",
"downloadIcsFile": "تنزيل ملف .ics",
"tryAgain": "حاول مرة أخرى" "tryAgain": "حاول مرة أخرى"
}, },
"filters": { "filters": {
@@ -122,7 +130,7 @@
"joinWaitlist": "الانضمام إلى قائمة الانتظار", "joinWaitlist": "الانضمام إلى قائمة الانتظار",
"confirmationTitle": "تم تسجيلك بنجاح", "confirmationTitle": "تم تسجيلك بنجاح",
"confirmationBody": "شكرًا لك — تم تأكيد مكانك في {event} بتاريخ {date}. سنرسل لك تذكيرًا وأي تفاصيل إضافية على البريد الإلكتروني الذي قدّمته.", "confirmationBody": "شكرًا لك — تم تأكيد مكانك في {event} بتاريخ {date}. سنرسل لك تذكيرًا وأي تفاصيل إضافية على البريد الإلكتروني الذي قدّمته.",
"noEvents": "لا توجد فعاليات لعرضها بعد." "noEvents": "سيتم الإعلان عن الفعاليات القادمة قريباً."
}, },
"news": { "news": {
"tabProject": "أخبار SHARP-ER", "tabProject": "أخبار SHARP-ER",
@@ -160,6 +168,8 @@
"errorEmail": "أدخل بريدًا إلكترونيًا صالحًا.", "errorEmail": "أدخل بريدًا إلكترونيًا صالحًا.",
"errorConsent": "يرجى تأكيد الموافقة لإتمام التسجيل.", "errorConsent": "يرجى تأكيد الموافقة لإتمام التسجيل.",
"errorGeneric": "حدث خطأ ما. يرجى المحاولة مرة أخرى.", "errorGeneric": "حدث خطأ ما. يرجى المحاولة مرة أخرى.",
"errorVerification": "تعذّر إكمال التحقق الأمني. يرجى المحاولة مرة أخرى.",
"verificationUnavailable": "تعذّر تحميل التحقق الأمني. يرجى التأكد من تفعيل JavaScript ثم إعادة تحميل الصفحة.",
"successTitle": "شكرًا لك", "successTitle": "شكرًا لك",
"successBody": "تم إرسال رسالتك. سنعاود التواصل معك في أقرب وقت ممكن." "successBody": "تم إرسال رسالتك. سنعاود التواصل معك في أقرب وقت ممكن."
}, },
+11 -1
View File
@@ -24,6 +24,7 @@
"home": "Home", "home": "Home",
"aboutAndStrategy": "About and Strategy", "aboutAndStrategy": "About and Strategy",
"about": "About", "about": "About",
"legacy": "SHARP Legacy",
"whatWeDo": "What We Do", "whatWeDo": "What We Do",
"activities": "Activities and Initiatives", "activities": "Activities and Initiatives",
"engagementAndOpportunities": "Engagement and Opportunities", "engagementAndOpportunities": "Engagement and Opportunities",
@@ -42,6 +43,12 @@
"en": "English", "en": "English",
"ar": "العربية" "ar": "العربية"
}, },
"legacy": {
"fullRecord": "Read the full {programme} record",
"previousImage": "Previous {programme} image",
"nextImage": "Next {programme} image",
"imageCount": "Image {current} of {total}"
},
"actions": { "actions": {
"readMore": "Read more", "readMore": "Read more",
"learnMore": "Learn more", "learnMore": "Learn more",
@@ -67,7 +74,8 @@
"applyToJoin": "Apply to join", "applyToJoin": "Apply to join",
"viewUpcomingSessions": "View upcoming sessions", "viewUpcomingSessions": "View upcoming sessions",
"submitYourUpdate": "Submit your update", "submitYourUpdate": "Submit your update",
"addToCalendar": "Add to calendar", "addToGoogleCalendar": "Add to Google Calendar",
"downloadIcsFile": "Download .ics file",
"tryAgain": "Try again" "tryAgain": "Try again"
}, },
"filters": { "filters": {
@@ -160,6 +168,8 @@
"errorEmail": "Enter a valid email address.", "errorEmail": "Enter a valid email address.",
"errorConsent": "Please confirm this to complete your registration.", "errorConsent": "Please confirm this to complete your registration.",
"errorGeneric": "Something went wrong. Please try again.", "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", "successTitle": "Thank you",
"successBody": "Your message has been sent. We will get back to you as soon as we can." "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); 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 { export function sectionPath(key: RouteSlugKey, locale: Locale): string {
return localizePath(sectionSlugPath(key), locale); return localizePath(sectionSlugPath(key), locale);
} }
+23
View File
@@ -20,6 +20,13 @@ export interface ServerEnv {
readonly STRAPI_TOKEN?: string; readonly STRAPI_TOKEN?: string;
/** Public origin of this site, no trailing slash. Used for canonical URLs. */ /** Public origin of this site, no trailing slash. Used for canonical URLs. */
readonly SITE_URL: string; 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 isProduction: boolean;
readonly isDevelopment: boolean; readonly isDevelopment: boolean;
} }
@@ -92,14 +99,30 @@ export function getServerEnv(): ServerEnv {
issues, issues,
); );
const token = source.STRAPI_TOKEN?.trim(); 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); 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 = { cached = {
NODE_ENV, NODE_ENV,
STRAPI_URL, STRAPI_URL,
SITE_URL, SITE_URL,
...(token ? { STRAPI_TOKEN: token } : {}), ...(token ? { STRAPI_TOKEN: token } : {}),
...(turnstileSiteKey && turnstileSecretKey
? {
TURNSTILE_SITE_KEY: turnstileSiteKey,
TURNSTILE_SECRET_KEY: turnstileSecretKey,
}
: {}),
isProduction: NODE_ENV === "production", isProduction: NODE_ENV === "production",
isDevelopment: NODE_ENV === "development", isDevelopment: NODE_ENV === "development",
}; };
+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`, 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 which the React Router dev plugin evaluates in Node while building the route
@@ -23,6 +23,7 @@
export const ROUTE_SLUGS = { export const ROUTE_SLUGS = {
home: "", home: "",
about: "about", about: "about",
legacy: "legacy",
whatWeDo: "what-we-do", whatWeDo: "what-we-do",
activities: "activities-and-initiatives", activities: "activities-and-initiatives",
events: "events-calendar", events: "events-calendar",
@@ -41,7 +42,7 @@ export type RouteSlugKey = keyof typeof ROUTE_SLUGS;
*/ */
export type RouteSlugs = 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[]; 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 = [ export const ROUTE_SECTION_GROUPS = [
{ {
id: "about-and-strategy", id: "about-and-strategy",
children: ["about", "whatWeDo", "activities"], children: ["about", "legacy", "whatWeDo", "activities"],
}, },
{ {
id: "engagement-and-opportunities", id: "engagement-and-opportunities",
+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 { DEFAULT_LOCALE, dirFor, type Locale } from "~/i18n/config";
import { localizedPath } from "~/i18n/paths"; import { localizedPath } from "~/i18n/paths";
import { createTranslator, getMessages } from "~/i18n/t"; import { createTranslator, getMessages } from "~/i18n/t";
import { getSiteUrl } from "~/lib/env.server"; import { getServerEnv, getSiteUrl } from "~/lib/env.server";
import { import {
readCanonicalPath, readCanonicalPath,
readFooterSurface, 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 * Resolved once per request. Every route can read the origin and the locale
* off this loader via `matches[0].data` in its own `meta`. * 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. 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. */ `:root` is dark, so the absence of a cookie is a dark first visit. */
theme: readTheme(request.headers.get("Cookie")), 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. */ surface in either theme. */
const rootData = useRouteLoaderData<typeof loader>("root"); const rootData = useRouteLoaderData<typeof loader>("root");
const theme: AppTheme = rootData?.theme ?? DEFAULT_THEME; const theme: AppTheme = rootData?.theme ?? DEFAULT_THEME;
const nonce = rootData?.nonce;
const navSurface = useRouteHandleValue(readNavSurface) ?? theme; const navSurface = useRouteHandleValue(readNavSurface) ?? theme;
const footerSurface = useRouteHandleValue(readFooterSurface) ?? theme; const footerSurface = useRouteHandleValue(readFooterSurface) ?? theme;
@@ -203,6 +213,20 @@ export function Layout({ children }: { children: ReactNode }) {
))} ))}
<Meta /> <Meta />
<Links /> <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> </head>
<body className="flex min-h-dvh flex-col"> <body className="flex min-h-dvh flex-col">
<a <a
@@ -231,8 +255,8 @@ export function Layout({ children }: { children: ReactNode }) {
linkGroups={footerLinkGroups} linkGroups={footerLinkGroups}
/> />
<ScrollRestoration /> <ScrollRestoration nonce={nonce} />
<Scripts /> <Scripts nonce={nonce} />
</body> </body>
</html> </html>
); );
+5 -4
View File
@@ -5,7 +5,7 @@
/ -> 302 to /en or /ar (Accept-Language) / -> 302 to /en or /ar (Accept-Language)
/:lang -> home /: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/<section>/<slug> -> one entry of a CMS-backed section
/:lang/events-calendar/<slug>.ics -> that event as an RFC 5545 file /:lang/events-calendar/<slug>.ics -> that event as an RFC 5545 file
anything else -> 404 anything else -> 404
@@ -43,7 +43,7 @@
ROUTE IDS ROUTE IDS
--------- ---------
"root" | "routes/locale-redirect" | "locale" | "routes/not-found", one per "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" - "events", "applications", "news", "contact", "resources", "knowledgeHub" -
and one per detail route, the section key plus "Detail" ("newsDetail", and one per detail route, the section key plus "Detail" ("newsDetail",
"eventsDetail", ...), plus "eventsIcs". So `useRouteLoaderData("locale")` "eventsDetail", ...), plus "eventsIcs". So `useRouteLoaderData("locale")`
@@ -76,6 +76,7 @@ import { ROUTE_SLUGS, type RouteSlugKey } from "./lib/route-slugs";
const SECTION_MODULES = { const SECTION_MODULES = {
home: "routes/home.tsx", home: "routes/home.tsx",
about: "routes/about.tsx", about: "routes/about.tsx",
legacy: "routes/legacy.tsx",
whatWeDo: "routes/what-we-do.tsx", whatWeDo: "routes/what-we-do.tsx",
activities: "routes/activities-and-initiatives.tsx", activities: "routes/activities-and-initiatives.tsx",
events: "routes/events-calendar.tsx", events: "routes/events-calendar.tsx",
@@ -89,7 +90,7 @@ const SECTION_MODULES = {
/** /**
* Section -> the module that renders ONE entry of it, at * Section -> the module that renders ONE entry of it, at
* `<section slug>/:slug`. Only the six CMS-backed sections have one; "about", * `<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. * drill into.
*/ */
const DETAIL_MODULES = { const DETAIL_MODULES = {
@@ -117,7 +118,7 @@ const RESOURCE_ROUTES = [
const detailKeys = Object.keys(DETAIL_MODULES) as (keyof typeof DETAIL_MODULES)[]; 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 * the .ics resource route. Flat on purpose - see "LISTING AND DETAIL ARE
* SIBLINGS" above. * SIBLINGS" above.
*/ */
@@ -35,13 +35,13 @@ const EN = {
heading: "Upcoming Events", heading: "Upcoming Events",
/** Draft 4, EN standfirst. */ /** Draft 4, EN standfirst. */
intro: 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: metaDescription:
"Dialogues, trainings and Civil Society Law Sessions across Libya. Register for a SUQ dialogue, join a training, or track what’s coming next.", "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", listLabel: "Events",
statusFilterLabel: "Status", statusFilterLabel: "Status",
emptyHint: 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: unavailable:
"The events calendar cannot be loaded right now. Please try again in a moment.", "The events calendar cannot be loaded right now. Please try again in a moment.",
formatHybrid: "In person and online", formatHybrid: "In person and online",
@@ -65,7 +65,8 @@ const EN = {
eventCompleted: "This event has already taken place.", eventCompleted: "This event has already taken place.",
joinOnlineLabel: "Joining link", joinOnlineLabel: "Joining link",
addToCalendarHint: 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).", timeZoneNote: "All times are shown in Libya time (UTC+2).",
/** /**
* `event-registration.consentGdpr` is REQUIRED in the CMS, so the reader * `event-registration.consentGdpr` is REQUIRED in the CMS, so the reader
@@ -85,7 +86,7 @@ const EN = {
heading: "Get Involved", heading: "Get Involved",
/** Draft 8, EN standfirst. */ /** Draft 8, EN standfirst. */
intro: 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: metaDescription:
"Calls for expression of interest, SHARP network membership and upcoming sessions — how civil society organisations and individuals take part in SHARP-ER.", "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", pathwaysHeading: "Ways to take part",
@@ -232,12 +233,12 @@ const AR: RouteCopy = {
}, },
heading: "الفعاليات القادمة", heading: "الفعاليات القادمة",
intro: intro:
"سجّل في حوار SUQ، أو انضم إلى تدريب، أو تابع ما هو قادم في مختلف أنحاء ليبيا.", "ابقَ على اطلاع بجلسات الحوار القادمة، وفرص التدريب، وفعاليات التشبيك في جميع أنحاء ليبيا. استخدم التقويم لمعرفة تفاصيل التسجيل، ومحاور الفعاليات، والمواد المرفقة بها.",
metaDescription: metaDescription:
"حوارات وتدريبات وجلسات قانون المجتمع المدني في مختلف أنحاء ليبيا. سجّل في حوار SUQ، أو انضم إلى تدريب، أو تابع ما هو قادم.", "حوارات وتدريبات وجلسات قانون المجتمع المدني في مختلف أنحاء ليبيا. سجّل في حوار SUQ، أو انضم إلى تدريب، أو تابع ما هو قادم.",
listLabel: "الفعاليات", listLabel: "الفعاليات",
statusFilterLabel: "الحالة", statusFilterLabel: "الحالة",
emptyHint: "تُنشر هنا الحوارات والتدريبات والجلسات فور تحديد مواعيدها.", emptyHint: "ستُنشر هنا الحوارات والتدريبات والجلسات فور تحديد مواعيدها.",
unavailable: unavailable:
"تعذّر تحميل تقويم الفعاليات في الوقت الحالي. يرجى المحاولة مرة أخرى بعد قليل.", "تعذّر تحميل تقويم الفعاليات في الوقت الحالي. يرجى المحاولة مرة أخرى بعد قليل.",
formatHybrid: "حضوري وعبر الإنترنت", formatHybrid: "حضوري وعبر الإنترنت",
@@ -259,7 +260,9 @@ const AR: RouteCopy = {
"اكتمل العدد في هذه الفعالية. انضم إلى قائمة الانتظار وسنتواصل معك في حال توفّر مكان.", "اكتمل العدد في هذه الفعالية. انضم إلى قائمة الانتظار وسنتواصل معك في حال توفّر مكان.",
eventCompleted: "انتهت هذه الفعالية.", eventCompleted: "انتهت هذه الفعالية.",
joinOnlineLabel: "رابط الانضمام", joinOnlineLabel: "رابط الانضمام",
addToCalendarHint: "يُنزّل ملف .ics يمكن فتحه في أي تطبيق تقويم.", addToCalendarHint:
"ملف .ics متوافق مع تطبيق التقويم على أجهزة Apple ومع تطبيقات " +
"التقويم الأخرى.",
timeZoneNote: "جميع الأوقات معروضة بتوقيت ليبيا (UTC+2).", timeZoneNote: "جميع الأوقات معروضة بتوقيت ليبيا (UTC+2).",
consentLabel: consentLabel:
"أوافق على أن يحتفظ مشروع SHARP-ER بالبيانات أعلاه لإدارة تسجيلي في هذه الفعالية.", "أوافق على أن يحتفظ مشروع SHARP-ER بالبيانات أعلاه لإدارة تسجيلي في هذه الفعالية.",
@@ -272,7 +275,7 @@ const AR: RouteCopy = {
opportunities: { opportunities: {
heading: "شارك معنا", heading: "شارك معنا",
intro: intro:
"سواء كنتم منظمة مجتمع مدني أو فردًا، إليكم كيفية المشاركة في SHARP-ER.", "يربط برنامج شارب-إي آر (SHARP-ER) الأفراد والمنظمات بفرص التعلم والمشاركة والتعاون والقيادة. سواء كنتم أفراداً أو منظمات مجتمع مدني، استكشفوا دعوات التقديم الحالية، والبرامج التدريبية، وفرص الشراكة أدناه.",
metaDescription: metaDescription:
"دعوات للتعبير عن الاهتمام، وعضوية شبكة SHARP، وجلسات قادمة — كيف تشارك منظمات المجتمع المدني والأفراد في SHARP-ER.", "دعوات للتعبير عن الاهتمام، وعضوية شبكة SHARP، وجلسات قادمة — كيف تشارك منظمات المجتمع المدني والأفراد في SHARP-ER.",
pathwaysHeading: "طرق المشاركة", pathwaysHeading: "طرق المشاركة",
@@ -7,7 +7,8 @@
Both forms work without JavaScript: they are plain <Form method="post"> Both forms work without JavaScript: they are plain <Form method="post">
submissions, so validation has to be complete on the server. There is no 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"; import type { TranslationKey } from "~/i18n";
@@ -1,7 +1,7 @@
/* ============================================================================ /* ============================================================================
iCALENDAR (RFC 5545) 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: against the spec rather than against one client's tolerance:
* CRLF line endings, and content lines folded at 75 OCTETS - folded on * CRLF line endings, and content lines folded at 75 OCTETS - folded on
@@ -206,6 +206,46 @@ export interface CmsAboutPage extends StrapiEntity {
readonly seo?: CmsSeo | null; 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 */ /** api::what-we-do-page.what-we-do-page */
export interface CmsWhatWeDoPage extends StrapiEntity { export interface CmsWhatWeDoPage extends StrapiEntity {
readonly hero?: CmsHero | null; 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, overridden by the matching Strapi field the moment an editor fills it in,
and the pages read `cms ?? draft`. and the pages read `cms ?? draft`.
WHAT IS DELIBERATELY ABSENT HISTORICAL FIGURES
Figures. The draft marks every number "TBD" and says they must not be The general SHARP-ER home draft marks current-programme figures "TBD";
published until confirmed, so there is no statistic anywhere in this file. this file adds only the SHARP I and SHARP II historical figures from the
Figma hides the home page's figures band outright (frame 253:4835 is supplied legacy content.
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.
========================================================================= */ ========================================================================= */
import type { Locale } from "~/i18n"; import type { Locale } from "~/i18n";
@@ -104,6 +101,44 @@ export interface HomeCopy {
readonly partnersEmpty: string; 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 { export interface AboutCopy {
/** Draft eyebrow over the hero headline - "About the project". */ /** Draft eyebrow over the hero headline - "About the project". */
readonly heroEyebrow: string; readonly heroEyebrow: string;
@@ -170,8 +205,6 @@ export interface ContactCopy {
readonly websiteHref: string; readonly websiteHref: string;
readonly websiteText: string; readonly websiteText: string;
readonly contactPending: string; readonly contactPending: string;
readonly consortiumHeading: string;
readonly consortiumEmpty: string;
readonly sendAnother: string; readonly sendAnother: string;
readonly errorSummaryTitle: string; readonly errorSummaryTitle: string;
readonly errorTooLong: string; readonly errorTooLong: string;
@@ -184,6 +217,7 @@ export interface ContactCopy {
export interface PageCopy { export interface PageCopy {
readonly home: HomeCopy; readonly home: HomeCopy;
readonly about: AboutCopy; readonly about: AboutCopy;
readonly legacy: LegacyCopy;
readonly whatWeDo: WhatWeDoCopy; readonly whatWeDo: WhatWeDoCopy;
readonly contact: ContactCopy; readonly contact: ContactCopy;
} }
@@ -241,6 +275,121 @@ const EN: PageCopy = {
"Partner organisations will be listed here as they join the SHARP-ER network.", "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: { about: {
heroEyebrow: "About the project", heroEyebrow: "About the project",
heroHeadline: "Strengthening the Rule of Law, Together", heroHeadline: "Strengthening the Rule of Law, Together",
@@ -357,9 +506,6 @@ const EN: PageCopy = {
websiteText: "sharper.ly", websiteText: "sharper.ly",
contactPending: 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.", "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", sendAnother: "Send another message",
errorSummaryTitle: "Please check the form", errorSummaryTitle: "Please check the form",
errorTooLong: "This answer is too long.", errorTooLong: "This answer is too long.",
@@ -423,6 +569,122 @@ const AR: PageCopy = {
"ستُدرج المنظمات الشريكة هنا فور انضمامها إلى شبكة SHARP-ER.", "ستُدرج المنظمات الشريكة هنا فور انضمامها إلى شبكة 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: { about: {
heroEyebrow: "عن المشروع", heroEyebrow: "عن المشروع",
heroHeadline: "نحو تعزيز سيادة القانون، معًا", heroHeadline: "نحو تعزيز سيادة القانون، معًا",
@@ -544,9 +806,6 @@ const AR: PageCopy = {
websiteText: "sharper.ly", websiteText: "sharper.ly",
contactPending: contactPending:
"سيُنشر هنا البريد الإلكتروني العام للمشروع فور تأكيده. وفي غضون ذلك، استخدم النموذج وستصل رسالتك إلى الفريق.", "سيُنشر هنا البريد الإلكتروني العام للمشروع فور تأكيده. وفي غضون ذلك، استخدم النموذج وستصل رسالتك إلى الفريق.",
consortiumHeading: "جهات اتصال الائتلاف",
consortiumEmpty:
"ستُنشر هنا بيانات التواصل الخاصة بـ Acted ليبيا، وUnited Efforts Consultancy، وStichting Bureau Ensemble فور تأكيدها.",
sendAnother: "إرسال رسالة أخرى", sendAnother: "إرسال رسالة أخرى",
errorSummaryTitle: "يرجى مراجعة النموذج", errorSummaryTitle: "يرجى مراجعة النموذج",
errorTooLong: "هذه الإجابة طويلة جدًا.", 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,
};
}
@@ -256,12 +256,6 @@ export interface PageHeroProps {
* footer for EU visibility. * footer for EU visibility.
*/ */
funderBadge?: ReactNode; 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. */ /** Buttons, rendered inline-end of the headline on wide viewports. */
actions?: ReactNode; actions?: ReactNode;
/** /**
@@ -378,7 +372,6 @@ export function PageHero({
lead, lead,
wordmark, wordmark,
funderBadge, funderBadge,
statusPill,
actions, actions,
leadWithActions = false, leadWithActions = false,
ambient, ambient,
@@ -454,8 +447,6 @@ export function PageHero({
</div> </div>
) : null} ) : null}
{statusPill ? <div className={rise(2)}>{statusPill}</div> : null}
{leadWithActions ? ( {leadWithActions ? (
<> <>
{/* Headline across the top ... */} {/* Headline across the top ... */}
+6 -6
View File
@@ -38,7 +38,7 @@ export const RESOURCES_COPY: Localised<SectionCopy> = {
en: { en: {
heading: "Reports, Research, and Publications", heading: "Reports, Research, and Publications",
intro: 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.", emptyTitle: "No publications have been added yet.",
emptyBody: emptyBody:
"Reports, research and official documents will appear here as they are published.", "Reports, research and official documents will appear here as they are published.",
@@ -48,7 +48,7 @@ export const RESOURCES_COPY: Localised<SectionCopy> = {
ar: { ar: {
heading: "تقارير وأبحاث ومنشورات", heading: "تقارير وأبحاث ومنشورات",
intro: intro:
"نتائج ومقالات ووثائق رسمية صادرة عن أعمال البحث والحوار في مشروع SHARP-ER.", "اكتشفوا منشورات وموارد شارب-إي آر (SHARP-ER) العملية الموجهة إلى منظمات المجتمع المدني، والمجتمعات المحلية، والمؤسسات العامة، والباحثين والممارسين. توفر هذه المكتبة أدلة تدريبية، وتقارير حوارية، وموجزات سياسات، ومواد توعوية يسهل الوصول إليها واستخدامها في عملكم اليومي.",
emptyTitle: "لم تُضف أي منشورات بعد.", emptyTitle: "لم تُضف أي منشورات بعد.",
emptyBody: "ستظهر هنا التقارير والأبحاث والوثائق الرسمية فور نشرها.", emptyBody: "ستظهر هنا التقارير والأبحاث والوثائق الرسمية فور نشرها.",
metaDescription: metaDescription:
@@ -64,7 +64,7 @@ export const KNOWLEDGE_COPY: Localised<SectionCopy> = {
en: { en: {
heading: "Learn From the SHARP Network", heading: "Learn From the SHARP Network",
intro: 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.", emptyTitle: "The knowledge hub is being prepared.",
emptyBody: emptyBody:
"Training modules, dialogue guides, case studies and FAQs will appear here as they are published.", "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: { ar: {
heading: "تعلّم من شبكة SHARP", heading: "تعلّم من شبكة SHARP",
intro: intro:
"مواد تدريبية، وأدلة حوار، ودروس مستفادة من منظمات المجتمع المدني التي تطبّق نهج SHARP-ER على أرض الواقع.", "يمثل مركز المعرفة لبرنامج شارب-إي آر (SHARP-ER) مساحة للتعلم وتبادل الأدلة والخبرات. يجمع المركز الرؤى المستخلصة من الحوارات والبحوث والتدريبات والمبادرات المحلية لمساعدتكم على فهم ما ينجح ولماذا. استخدموا المركز للوصول إلى أدوات عملية، ومقارنة التجارب الإقليمية، والاستفادة من الأدلة في تطوير السياسات. نرحب دائماً بمساهمات المجتمع المدني والباحثين والمشاركين مع استمرار توسع هذا الفضاء.",
emptyTitle: "يجري تجهيز مركز المعرفة.", emptyTitle: "يجري تجهيز مركز المعرفة.",
emptyBody: emptyBody:
"ستظهر هنا الوحدات التدريبية وأدلة الحوار ودراسات الحالة والأسئلة الشائعة فور نشرها.", "ستظهر هنا الوحدات التدريبية وأدلة الحوار ودراسات الحالة والأسئلة الشائعة فور نشرها.",
@@ -142,7 +142,7 @@ export const NEWS_COPY: Localised<NewsCopy> = {
en: { en: {
heading: "News from SHARP-ER and the Network", heading: "News from SHARP-ER and the Network",
intro: 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: projectBlurb:
"Official updates on milestones, campaign launches, publications, and events.", "Official updates on milestones, campaign launches, publications, and events.",
networkBlurb: networkBlurb:
@@ -156,7 +156,7 @@ export const NEWS_COPY: Localised<NewsCopy> = {
ar: { ar: {
heading: "أخبار من SHARP-ER ومن الشبكة", heading: "أخبار من SHARP-ER ومن الشبكة",
intro: intro:
"محطات رئيسية من فريق المشروع، ومستجدات من منظمات المجتمع المدني التي نعمل معها.", "تابع أحدث إنجازات برنامج شارب-إي آر (SHARP-ER)، وقصص الشركاء، والمبادرات التي تقودها المجتمعات المحلية. اكتشف كيف تتحول الأفكار من مجرد نقاشات إلى واقع ملموس، سواء كان ذلك عبر شراكة جديدة، أو طرح قضية مجتمعية للنقاش، أو ابتكار حلول محلية.",
projectBlurb: projectBlurb:
"تحديثات رسمية حول المحطات الرئيسية، وإطلاق الحملات، والمنشورات، والفعاليات.", "تحديثات رسمية حول المحطات الرئيسية، وإطلاق الحملات، والمنشورات، والفعاليات.",
networkBlurb: networkBlurb:
+33 -60
View File
@@ -10,8 +10,8 @@
Facts a four-cell seam grid: the run, the coverage, the lead Facts a four-cell seam grid: the run, the coverage, the lead
partner and the funder. partner and the funder.
Nationwide the approved body prose beside the three focus areas. Nationwide the approved body prose beside the three focus areas.
Consortium the three members as role / name / deliverable panels, under Consortium unboxed rows for the three members.
a header row that carries the EU funding line. The header carries the EU funding line.
Legacy the SHARP I and II panel with the accent start-edge, and the Legacy the SHARP I and II panel with the accent start-edge, and the
call to action into What We Do. call to action into What We Do.
@@ -157,7 +157,7 @@ const ACCENT_EDGE =
interface ConsortiumMember { interface ConsortiumMember {
id: string; id: string;
name: 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; role: string | null;
description: string; description: string;
website: string | null; website: string | null;
@@ -301,33 +301,36 @@ export default function About({ loaderData }: Route.ComponentProps) {
</p> </p>
</div> </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) => ( {consortium.map((member) => (
<li key={member.id}> <li
<InfoPanel key={member.id}
/* On the sunken band the draft lifts these cards to the 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"
raised dark it uses for a page band (#1d1d1b), one step >
lighter than the panel shade InfoPanel defaults to. The <div className="flex flex-col gap-xs">
`!` is what makes the override beat the component's own {member.role ? (
`bg-*` utility, which sorts after this one. */ <p className="label-md uppercase text-[var(--text-accent)]">
className="sh-lift sh-reveal sh-reveal--in hover:border-[var(--border-accent)]" {member.role}
title={member.name} </p>
{...(member.role
? { eyebrow: member.role, eyebrowVariant: "chip" as const }
: {})}
>
<span className="block">{member.description}</span>
{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)]"
>
{member.name}
</a>
) : null} ) : null}
</InfoPanel> <h3 className="heading-h3 text-[var(--text-primary)]">
{member.website ? (
<a
href={member.website}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
>
{member.name}
</a>
) : (
member.name
)}
</h3>
</div>
<p className="body-md text-[var(--text-secondary)]">
{member.description}
</p>
</li> </li>
))} ))}
</ul> </ul>
@@ -361,43 +364,13 @@ export default function About({ loaderData }: Route.ComponentProps) {
<div className="flex flex-wrap items-center gap-md"> <div className="flex flex-wrap items-center gap-md">
<Button <Button
as={Link} as={Link}
to={sectionPath("whatWeDo", locale)} to={sectionPath("legacy", locale)}
prefetch="intent" prefetch="intent"
size="lg" size="lg"
iconEnd={<ArrowIcon />} iconEnd={<ArrowIcon />}
> >
{t("actions.learnWhatWeDo")} {copy.legacyLinkLabel}
</Button> </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>
</div> </div>
</PageSection> </PageSection>
+2 -2
View File
@@ -231,7 +231,7 @@ export const ACTIVITIES_INDEX_COPY: Readonly<
en: { en: {
eyebrow: "Activities and Initiatives", eyebrow: "Activities and Initiatives",
headline: "What’s Happening Across the SHARP-ER Network", 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", streamsNavLabel: "Activity streams",
figuresHeading: "SHARP-ER in figures", figuresHeading: "SHARP-ER in figures",
emptyTitle: "No activities have been published yet.", emptyTitle: "No activities have been published yet.",
@@ -245,7 +245,7 @@ export const ACTIVITIES_INDEX_COPY: Readonly<
ar: { ar: {
eyebrow: "الأنشطة والمبادرات", eyebrow: "الأنشطة والمبادرات",
headline: "ماذا يحدث عبر شبكة SHARP-ER", headline: "ماذا يحدث عبر شبكة SHARP-ER",
lede: "من اجتماعات الشبكة الإقليمية إلى الحوارات الوطنية، إليكم أين وصل المشروع اليوم.", lede: "من اجتماعات الشبكات الإقليمية إلى الحوارات الوطنية — إليك ما وصل إليه المشروع اليوم. تركز أنشطتنا على الحوار الشامل، وبناء قدرات المجتمع المدني، والمبادرات التي تقودها المجتمعات المحلية، والتوعية الرقمية في جميع أنحاء ليبيا.",
streamsNavLabel: "محاور الأنشطة", streamsNavLabel: "محاور الأنشطة",
figuresHeading: "SHARP-ER بالأرقام", figuresHeading: "SHARP-ER بالأرقام",
emptyTitle: "لم يتم نشر أي أنشطة حتى الآن.", emptyTitle: "لم يتم نشر أي أنشطة حتى الآن.",
@@ -27,6 +27,7 @@ import {
import { ArrowIcon, Button } from "~/components/ui/Button"; import { ArrowIcon, Button } from "~/components/ui/Button";
import { Input, Textarea } from "~/components/ui/Input"; import { Input, Textarea } from "~/components/ui/Input";
import { Turnstile } from "~/components/ui/Turnstile";
import { import {
interpolate, interpolate,
localizedPath, localizedPath,
@@ -38,6 +39,7 @@ import {
import { safeHref } from "~/lib/safe-href"; import { safeHref } from "~/lib/safe-href";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo"; import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import { isStrapiError, requireEntity } from "~/lib/strapi"; import { isStrapiError, requireEntity } from "~/lib/strapi";
import { verifyTurnstile } from "~/lib/turnstile.server";
import type { Route } from "./+types/applications-and-opportunities.$slug"; import type { Route } from "./+types/applications-and-opportunities.$slug";
import { getRouteCopy } from "./_events-opportunities/copy"; import { getRouteCopy } from "./_events-opportunities/copy";
@@ -98,6 +100,8 @@ type ActionResult =
readonly values: ApplicationValues; readonly values: ApplicationValues;
/** True when validation passed but the CMS refused the write. */ /** True when validation passed but the CMS refused the write. */
readonly failed: boolean; readonly failed: boolean;
/** True when validation passed but the Turnstile check did not. */
readonly verificationFailed: boolean;
}; };
export async function action({ params, request }: Route.ActionArgs) { export async function action({ params, request }: Route.ActionArgs) {
@@ -121,11 +125,30 @@ export async function action({ params, request }: Route.ActionArgs) {
if (!valid) { if (!valid) {
return data( return data(
{ ok: false, errors, values, failed: false } satisfies ActionResult, {
ok: false,
errors,
values,
failed: false,
verificationFailed: false,
} satisfies ActionResult,
{ status: 400 }, { status: 400 },
); );
} }
if (!(await verifyTurnstile(form))) {
return data(
{
ok: false,
errors: {},
values,
failed: false,
verificationFailed: true,
} satisfies ActionResult,
{ status: 403 },
);
}
try { try {
await submitApplication({ await submitApplication({
values, values,
@@ -138,7 +161,13 @@ export async function action({ params, request }: Route.ActionArgs) {
`[opportunities] application for "${slug}" was rejected: ${error.status} ${error.message}`, `[opportunities] application for "${slug}" was rejected: ${error.status} ${error.message}`,
); );
return data( return data(
{ ok: false, errors: {}, values, failed: true } satisfies ActionResult, {
ok: false,
errors: {},
values,
failed: true,
verificationFailed: false,
} satisfies ActionResult,
{ status: 502 }, { status: 502 },
); );
} }
@@ -492,6 +521,10 @@ function ApplicationSection({
</Notice> </Notice>
) : null} ) : 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"> <Form method="post" action={detailPath} className="flex flex-col gap-md">
<div className="grid gap-md sm:grid-cols-2"> <div className="grid gap-md sm:grid-cols-2">
<Input <Input
@@ -544,6 +577,12 @@ function ApplicationSection({
/> />
</div> </div>
<Turnstile
action="opportunity-application"
locale={locale}
resetKey={result}
/>
<div className="flex flex-wrap items-center gap-sm"> <div className="flex flex-wrap items-center gap-sm">
<Button <Button
type="submit" type="submit"
@@ -37,7 +37,8 @@
an enhancement only: all three steps are in the server-rendered HTML, no 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 field is hidden behind a step, and the whole thing submits and reports its
errors with JavaScript switched off. `useNavigation()` only swaps the button 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 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 wording lives in ./_events-opportunities/copy.ts, where each omission is
@@ -65,6 +66,7 @@ import { data, Form, Link, useActionData, useNavigation } from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button"; import { ArrowIcon, Button } from "~/components/ui/Button";
import { CardLink, CardShell } from "~/components/ui/Card"; import { CardLink, CardShell } from "~/components/ui/Card";
import { Input, Textarea } from "~/components/ui/Input"; import { Input, Textarea } from "~/components/ui/Input";
import { Turnstile } from "~/components/ui/Turnstile";
import { import {
interpolate, interpolate,
localizedPath, localizedPath,
@@ -76,6 +78,7 @@ import {
} from "~/i18n"; } from "~/i18n";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo"; import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import { isStrapiError } from "~/lib/strapi"; import { isStrapiError } from "~/lib/strapi";
import { verifyTurnstile } from "~/lib/turnstile.server";
import type { Route } from "./+types/applications-and-opportunities"; import type { Route } from "./+types/applications-and-opportunities";
import { Container } from "./_page-kit/sections"; import { Container } from "./_page-kit/sections";
@@ -192,6 +195,8 @@ type ActionResult =
readonly values: PathwayApplicationValues; readonly values: PathwayApplicationValues;
/** True when validation passed but the CMS refused the write. */ /** True when validation passed but the CMS refused the write. */
readonly failed: boolean; readonly failed: boolean;
/** True when validation passed but the Turnstile check did not. */
readonly verificationFailed: boolean;
}; };
export async function action({ params, request }: Route.ActionArgs) { export async function action({ params, request }: Route.ActionArgs) {
@@ -219,11 +224,30 @@ export async function action({ params, request }: Route.ActionArgs) {
if (!valid) { if (!valid) {
return data( return data(
{ ok: false, errors, values, failed: false } satisfies ActionResult, {
ok: false,
errors,
values,
failed: false,
verificationFailed: false,
} satisfies ActionResult,
{ status: 400 }, { status: 400 },
); );
} }
if (!(await verifyTurnstile(form))) {
return data(
{
ok: false,
errors: {},
values,
failed: false,
verificationFailed: true,
} satisfies ActionResult,
{ status: 403 },
);
}
try { try {
await submitPathwayApplication({ await submitPathwayApplication({
values, values,
@@ -235,7 +259,13 @@ export async function action({ params, request }: Route.ActionArgs) {
`[opportunities] application from /${locale} was rejected: ${error.status} ${error.message}`, `[opportunities] application from /${locale} was rejected: ${error.status} ${error.message}`,
); );
return data( return data(
{ ok: false, errors: {}, values, failed: true } satisfies ActionResult, {
ok: false,
errors: {},
values,
failed: true,
verificationFailed: false,
} satisfies ActionResult,
{ status: 502 }, { status: 502 },
); );
} }
@@ -678,13 +708,15 @@ function ApplicationForm({
result: ActionResult | undefined; result: ActionResult | undefined;
route: ApplicationRoute; route: ApplicationRoute;
}) { }) {
const { t } = useTranslation(); const { locale, t } = useTranslation();
const navigation = useNavigation(); const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting"; const isSubmitting = navigation.state === "submitting";
const errors = result && !result.ok ? result.errors : {}; const errors = result && !result.ok ? result.errors : {};
const values = result && !result.ok ? result.values : EMPTY_VALUES; const values = result && !result.ok ? result.values : EMPTY_VALUES;
const hasFailed = result !== undefined && !result.ok && result.failed; 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 /* This form has no consent checkbox - only the event registration does - so
the "consent" code cannot occur here; it is narrowed away rather than the "consent" code cannot occur here; it is narrowed away rather than
@@ -723,7 +755,7 @@ function ApplicationForm({
className="flex flex-col gap-lg" className="flex flex-col gap-lg"
noValidate noValidate
> >
{invalidFields.length > 0 || hasFailed ? ( {invalidFields.length > 0 || hasFailed || hasFailedVerification ? (
<div <div
ref={summaryRef} ref={summaryRef}
tabIndex={-1} tabIndex={-1}
@@ -731,7 +763,11 @@ function ApplicationForm({
className="flex flex-col gap-xs rounded-card border border-border-danger bg-surface-danger-subtle p-md" 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"> <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> </p>
{hasFailed ? ( {hasFailed ? (
@@ -887,6 +923,8 @@ function ApplicationForm({
</div> </div>
</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"> <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"> <p className="body-sm max-w-[26rem] text-text-tertiary">
{copy.apply.acknowledgement} {copy.apply.acknowledgement}
+21 -58
View File
@@ -10,6 +10,8 @@
Client-side JS is an enhancement only - the page works as a plain HTML form 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. 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 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. errors come from the i18n catalogue, the page prose from the approved draft.
@@ -41,19 +43,19 @@ import { data, Form, Link, useLocation, useNavigation, useRouteLoaderData } from
import { ArrowIcon, Button } from "~/components/ui/Button"; import { ArrowIcon, Button } from "~/components/ui/Button";
import { EuBadge } from "~/components/ui/EuBadge"; import { EuBadge } from "~/components/ui/EuBadge";
import { Input, Textarea } from "~/components/ui/Input"; import { Input, Textarea } from "~/components/ui/Input";
import { Turnstile } from "~/components/ui/Turnstile";
import { createTranslator, sectionPath, type Locale } from "~/i18n"; import { createTranslator, sectionPath, type Locale } from "~/i18n";
import { getServerEnv } from "~/lib/env.server"; import { getServerEnv } from "~/lib/env.server";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo"; import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import { verifyTurnstile } from "~/lib/turnstile.server";
import type { Route } from "./+types/contact"; import type { Route } from "./+types/contact";
import { Accordion, type AccordionItem } from "./_page-kit/accordion";
import type { CmsContactPage } from "./_page-kit/cms-types"; import type { CmsContactPage } from "./_page-kit/cms-types";
import { list, loadSingle, toImage } from "./_page-kit/cms.server"; import { list, loadSingle, toImage } from "./_page-kit/cms.server";
import { pageCopy } from "./_page-kit/copy"; import { pageCopy } from "./_page-kit/copy";
import { routeLocale } from "./_page-kit/locale"; import { routeLocale } from "./_page-kit/locale";
import { RichText } from "./_page-kit/rich-text"; import { RichText } from "./_page-kit/rich-text";
import { import {
EmptyState,
PageHero, PageHero,
PageSection, PageSection,
Prose, Prose,
@@ -74,7 +76,6 @@ export async function loader({ params }: Route.LoaderArgs) {
populate: { populate: {
hero: { populate: { media: true, ctas: true } }, hero: { populate: { media: true, ctas: true } },
socialLinks: true, socialLinks: true,
consortiumContacts: true,
seo: { populate: { ogImage: true } }, seo: { populate: { ogImage: true } },
}, },
}); });
@@ -95,15 +96,6 @@ export async function loader({ params }: Route.LoaderArgs) {
href: link.href, href: link.href,
isExternal: link.isExternal ?? true, 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: { seo: {
title: page?.seo?.metaTitle ?? null, title: page?.seo?.metaTitle ?? null,
description: page?.seo?.metaDescription ?? null, description: page?.seo?.metaDescription ?? null,
@@ -286,6 +278,20 @@ export async function action({ params, request }: Route.ActionArgs) {
); );
} }
if (!(await verifyTurnstile(form))) {
const t = createTranslator(locale);
return data(
{
status: "failed" as const,
errors: {} as ContactErrors,
values,
formError: t("form.errorVerification"),
},
{ status: 403 },
);
}
try { try {
await createContactSubmission( await createContactSubmission(
values, values,
@@ -362,8 +368,7 @@ export default function Contact({
loaderData, loaderData,
actionData, actionData,
}: Route.ComponentProps) { }: Route.ComponentProps) {
const { locale, hero, intro, generalEmail, socialLinks, consortiumContacts } = const { locale, hero, intro, generalEmail, socialLinks } = loaderData;
loaderData;
const t = createTranslator(locale); const t = createTranslator(locale);
const copy = pageCopy(locale).contact; const copy = pageCopy(locale).contact;
@@ -390,32 +395,6 @@ export default function Contact({
(message): message is string => typeof message === "string", (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 /* 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 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 below the fold, so focus is moved onto the summary itself - it is
@@ -587,6 +566,8 @@ export default function Contact({
{...(errors.message ? { error: errors.message } : {})} {...(errors.message ? { error: errors.message } : {})}
/> />
<Turnstile action="contact" locale={locale} resetKey={actionData} />
<Button <Button
type="submit" type="submit"
size="lg" size="lg"
@@ -664,24 +645,6 @@ export default function Contact({
</dl> </dl>
</div> </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 {/* EU VISIBILITY - the funding note the draft prints at the foot of
this column. The emblem and the statement come from the shared this column. The emblem and the statement come from the shared
badge, so the wording and the artwork stay contractual. */} badge, so the wording and the artwork stay contractual. */}
+106 -7
View File
@@ -13,8 +13,8 @@
refuses to submit without it. It is never a hidden input: consent that the refuses to submit without it. It is never a hidden input: consent that the
reader did not give is not consent. 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. /{locale}/events-calendar/{slug}.ics, which streams a real RFC 5545 file.
ROUTE ROUTE
Registered in app/routes.ts as "eventsDetail", a SIBLING of the listing route Registered in app/routes.ts as "eventsDetail", a SIBLING of the listing route
@@ -39,6 +39,7 @@ import {
import { ArrowIcon, Button } from "~/components/ui/Button"; import { ArrowIcon, Button } from "~/components/ui/Button";
import { Input } from "~/components/ui/Input"; import { Input } from "~/components/ui/Input";
import { Turnstile } from "~/components/ui/Turnstile";
import { import {
formatDate, formatDate,
interpolate, interpolate,
@@ -51,6 +52,7 @@ import {
import { safeHref } from "~/lib/safe-href"; import { safeHref } from "~/lib/safe-href";
import { buildBreadcrumbJsonLd, buildEventJsonLd, buildMeta } from "~/lib/seo"; import { buildBreadcrumbJsonLd, buildEventJsonLd, buildMeta } from "~/lib/seo";
import { isStrapiError, requireEntity } from "~/lib/strapi"; import { isStrapiError, requireEntity } from "~/lib/strapi";
import { verifyTurnstile } from "~/lib/turnstile.server";
import type { Route } from "./+types/events-calendar.$slug"; import type { Route } from "./+types/events-calendar.$slug";
import { getRouteCopy } from "./_events-opportunities/copy"; import { getRouteCopy } from "./_events-opportunities/copy";
@@ -62,6 +64,7 @@ import {
formatEventWhen, formatEventWhen,
isRegistrationOpen, isRegistrationOpen,
isWaitlisted, isWaitlisted,
type EventDetailData,
} from "./_events-opportunities/events"; } from "./_events-opportunities/events";
import { fetchEvent } from "./_events-opportunities/events.server"; import { fetchEvent } from "./_events-opportunities/events.server";
import { import {
@@ -98,6 +101,47 @@ import { RichText } from "./_shared/RichText";
*/ */
export const handle = SELF_ALTERNATES_HANDLE; 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) { export async function loader({ params }: Route.LoaderArgs) {
const locale = requireLocale(params.lang); const locale = requireLocale(params.lang);
const slug = params.slug ?? ""; const slug = params.slug ?? "";
@@ -119,6 +163,8 @@ type ActionResult =
readonly values: RegistrationValues; readonly values: RegistrationValues;
/** True when validation passed but the CMS refused the write. */ /** True when validation passed but the CMS refused the write. */
readonly failed: boolean; readonly failed: boolean;
/** True when validation passed but the Turnstile check did not. */
readonly verificationFailed: boolean;
}; };
export async function action({ params, request }: Route.ActionArgs) { export async function action({ params, request }: Route.ActionArgs) {
@@ -143,11 +189,30 @@ export async function action({ params, request }: Route.ActionArgs) {
if (!valid) { if (!valid) {
return data( return data(
{ ok: false, errors, values, failed: false } satisfies ActionResult, {
ok: false,
errors,
values,
failed: false,
verificationFailed: false,
} satisfies ActionResult,
{ status: 400 }, { status: 400 },
); );
} }
if (!(await verifyTurnstile(form))) {
return data(
{
ok: false,
errors: {},
values,
failed: false,
verificationFailed: true,
} satisfies ActionResult,
{ status: 403 },
);
}
let registrationState: "confirmed" | "waitlisted"; let registrationState: "confirmed" | "waitlisted";
try { try {
/* Only the fields the CMS whitelist accepts are sent - see /* Only the fields the CMS whitelist accepts are sent - see
@@ -173,7 +238,13 @@ export async function action({ params, request }: Route.ActionArgs) {
`[events] registration for "${slug}" was rejected: ${error.status} ${error.message}`, `[events] registration for "${slug}" was rejected: ${error.status} ${error.message}`,
); );
return data( return data(
{ ok: false, errors: {}, values, failed: true } satisfies ActionResult, {
ok: false,
errors: {},
values,
failed: true,
verificationFailed: false,
} satisfies ActionResult,
{ status: 502 }, { status: 502 },
); );
} }
@@ -429,6 +500,24 @@ export default function EventDetail() {
<DetailList heading={copy.eventDetail.detailsHeading} items={details} /> <DetailList heading={copy.eventDetail.detailsHeading} items={details} />
<div className="flex flex-col gap-xs"> <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 <Button
as="a" as="a"
href={`${detailPath}.ics`} href={`${detailPath}.ics`}
@@ -436,7 +525,7 @@ export default function EventDetail() {
variant="secondary" variant="secondary"
fullWidth fullWidth
> >
{t("actions.addToCalendar")} {t("actions.downloadIcsFile")}
</Button> </Button>
<p className="body-sm text-text-tertiary"> <p className="body-sm text-text-tertiary">
{copy.eventDetail.addToCalendarHint} {copy.eventDetail.addToCalendarHint}
@@ -570,8 +659,12 @@ function RegistrationSection({
</Notice> </Notice>
) : null} ) : null}
{/* A plain POST to this same URL: no fetcher, no client validation, and {result && !result.ok && result.verificationFailed ? (
therefore nothing that stops working with scripting off. */} <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"> <Form method="post" action={detailPath} className="flex flex-col gap-md">
<div className="grid gap-md sm:grid-cols-2"> <div className="grid gap-md sm:grid-cols-2">
<Input <Input
@@ -633,6 +726,12 @@ function RegistrationSection({
/> />
</div> </div>
<Turnstile
action="event-registration"
locale={locale}
resetKey={result}
/>
<div className="flex flex-wrap items-center gap-sm"> <div className="flex flex-wrap items-center gap-sm">
<Button <Button
type="submit" type="submit"
+30 -12
View File
@@ -50,7 +50,7 @@ import {
useTranslation, useTranslation,
type Locale, type Locale,
} from "~/i18n"; } from "~/i18n";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo"; import { buildBreadcrumbJsonLd, buildEventJsonLd, buildMeta } from "~/lib/seo";
import type { Route } from "./+types/events-calendar"; import type { Route } from "./+types/events-calendar";
import { 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 root = readRootSeo(matches);
const copy = getRouteCopy(root.locale); const copy = getRouteCopy(root.locale);
const search = new URLSearchParams(location.search); 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({ return buildMeta({
locale: root.locale, locale: root.locale,
@@ -204,16 +219,19 @@ export function meta({ location, matches }: Route.MetaArgs) {
search.has("page") || search.has("page") ||
search.has("view") || search.has("view") ||
search.has("month"), search.has("month"),
jsonLd: buildBreadcrumbJsonLd(root.siteUrl, [ jsonLd: [
{ buildBreadcrumbJsonLd(root.siteUrl, [
name: translate(root.locale, "nav.sections.home"), {
pathname: localizedPath("/", root.locale), name: translate(root.locale, "nav.sections.home"),
}, pathname: localizedPath("/", root.locale),
{ },
name: translate(root.locale, "nav.sections.events"), {
pathname: sectionPath("events", root.locale), name: translate(root.locale, "nav.sections.events"),
}, pathname: sectionPath("events", root.locale),
]), },
]),
...eventJsonLd,
],
}); });
} }
+118 -33
View File
@@ -6,7 +6,7 @@
alternating the two dark surfaces the way the draft alternates #0B0B0A and alternating the two dark surfaces the way the draft alternates #0B0B0A and
#1D1D1B: #1D1D1B:
Hero EU badge, an optional status pill, the headline, the lead, Hero EU badge, the headline, the lead,
the CTA pair, the wide picture and the brand stripe. Two the CTA pair, the wide picture and the brand stripe. Two
slow washes drift behind it. slow washes drift behind it.
Approach "A nationwide project, delivered locally": what the project Approach "A nationwide project, delivered locally": what the project
@@ -17,7 +17,7 @@
a link through to What We Do. a link through to What We Do.
Newsroom three cards from the newsroom, newest first. Newsroom three cards from the newsroom, newest first.
FAQs a sticky heading column beside the accordion. FAQs a sticky heading column beside the accordion.
Consortium the three members, then the civil society network logos. Consortium the three members, then the civil society network logos.
WHAT THE DRAFT SHIPS THAT THIS PAGE DOES NOT WHAT THE DRAFT SHIPS THAT THIS PAGE DOES NOT
-------------------------------------------- --------------------------------------------
@@ -73,6 +73,7 @@ import {
isImageGalleryBlock, isImageGalleryBlock,
type CmsFaq, type CmsFaq,
type CmsHomePage, type CmsHomePage,
type CmsLegacyPage,
type CmsNewsArticle, type CmsNewsArticle,
type CmsPartner, type CmsPartner,
} from "./_page-kit/cms-types"; } from "./_page-kit/cms-types";
@@ -86,6 +87,7 @@ import {
type ResolvedPartner, type ResolvedPartner,
} from "./_page-kit/cms.server"; } from "./_page-kit/cms.server";
import { pageCopy } from "./_page-kit/copy"; import { pageCopy } from "./_page-kit/copy";
import { resolveLegacyStage } from "./_page-kit/legacy-cms.server";
import { routeLocale } from "./_page-kit/locale"; import { routeLocale } from "./_page-kit/locale";
import { import {
cx, cx,
@@ -139,10 +141,9 @@ const PARTNER_PAGE_SIZE = 36;
/** /**
* Below this many partners the wall stays a grid. * Below this many partners the wall stays a grid.
* *
* The marquee loops by carrying the tiles twice and sliding exactly half the * The loop slides exactly half the track; fewer than eight wider tiles would
* track, which only reads as a loop while the half is wider than the frame it * leave a short drift instead of filling a desktop frame, so the grid keeps
* slides through. Eight 178px tiles clear a desktop container; fewer would * them still.
* drift a short row across an empty band, so the grid keeps them still.
*/ */
const PARTNER_MARQUEE_MIN = 8; const PARTNER_MARQUEE_MIN = 8;
/** The draft's newsroom band is a straight three-up row. */ /** The draft's newsroom band is a straight three-up row. */
@@ -181,6 +182,13 @@ function toNewsCard(article: CmsNewsArticle, locale: Locale): NewsCard {
export async function loader({ params }: Route.LoaderArgs) { export async function loader({ params }: Route.LoaderArgs) {
const locale = routeLocale(params); const locale = routeLocale(params);
const legacyPagePromise = loadSingle<CmsLegacyPage>("legacy-page", {
locale,
populate: {
stageOne: { populate: { metrics: true, focusAreas: true } },
stageTwo: { populate: { metrics: true, focusAreas: true } },
},
});
const page = await loadSingle<CmsHomePage>("home-page", { const page = await loadSingle<CmsHomePage>("home-page", {
locale, locale,
@@ -194,6 +202,8 @@ export async function loader({ params }: Route.LoaderArgs) {
seo: { populate: { ogImage: true } }, seo: { populate: { ogImage: true } },
}, },
}); });
const legacyPage = await legacyPagePromise;
const legacyFallback = pageCopy(locale).legacy;
/* The curated relation wins; otherwise show every partner the CMS knows, so /* The curated relation wins; otherwise show every partner the CMS knows, so
the wall fills in as soon as partners exist - before anyone remembers to the wall fills in as soon as partners exist - before anyone remembers to
@@ -243,8 +253,15 @@ export async function loader({ params }: Route.LoaderArgs) {
return { return {
locale, locale,
legacy: {
homeEyebrow: legacyPage?.homeEyebrow ?? legacyFallback.homeEyebrow,
homeHeading: legacyPage?.homeHeading ?? legacyFallback.homeHeading,
homeLead: legacyPage?.homeLead ?? legacyFallback.homeLead,
homeLinkLabel: legacyFallback.homeLinkLabel,
stageOne: resolveLegacyStage(legacyPage?.stageOne, legacyFallback.stageOne),
stageTwo: resolveLegacyStage(legacyPage?.stageTwo, legacyFallback.stageTwo),
},
hero: { hero: {
eyebrow: page?.hero?.eyebrow ?? null,
headline: page?.hero?.headline ?? null, headline: page?.hero?.headline ?? null,
subheading: page?.hero?.subheading ?? null, subheading: page?.hero?.subheading ?? null,
media: toImage(page?.hero?.media, "large") ?? null, media: toImage(page?.hero?.media, "large") ?? null,
@@ -334,24 +351,6 @@ function HeroCta({ href, label, variant }: HeroCtaProps) {
); );
} }
/**
* The hero's status line. The draft hard-codes "Now live · Expression of
* Interest open"; here the words are the CMS hero eyebrow, so the pill only
* ever announces a state an editor has actually written - and the hero simply
* has no pill until then. The dot pulses; the text does not move.
*/
function StatusPill({ label }: { label: string }) {
return (
<span className="inline-flex items-center gap-xs rounded-pill border border-[var(--border-subtle)] bg-[var(--surface-raised)] py-2xs ps-sm pe-md">
<span
aria-hidden="true"
className="sh-pulse size-[7px] shrink-0 rounded-pill bg-[var(--surface-accent)]"
/>
<span className="label-md uppercase text-[var(--text-secondary)]">{label}</span>
</span>
);
}
/** /**
* The approach band's pictures. The CMS alt text doubles as the visible * The approach band's pictures. The CMS alt text doubles as the visible
* caption - so the picture is described once, in the caption, and the <img> * caption - so the picture is described once, in the caption, and the <img>
@@ -487,10 +486,21 @@ function PartnerTile({
src={partner.logo.src} src={partner.logo.src}
alt={decorative ? "" : partner.name} alt={decorative ? "" : partner.name}
loading="lazy" loading="lazy"
className="max-h-[3.5rem] w-auto max-w-full object-contain" className={cx(
"w-auto max-w-full object-contain",
fixedWidth
? "h-[6.75rem] transition-transform duration-[var(--motion-duration-base)] group-hover:scale-105"
: "max-h-[3.5rem]",
)}
/> />
) : ( ) : (
<span className="body-sm-bold text-center text-[var(--text-secondary)]"> <span
className={cx(
"body-sm-bold text-center text-[var(--text-secondary)]",
fixedWidth &&
"transition-colors duration-[var(--motion-duration-base)] group-hover:text-[var(--text-accent)]",
)}
>
{partner.name} {partner.name}
</span> </span>
); );
@@ -499,17 +509,16 @@ function PartnerTile({
<li <li
{...(decorative ? { "aria-hidden": true } : {})} {...(decorative ? { "aria-hidden": true } : {})}
className={cx( className={cx(
"flex h-[5.5rem] items-center justify-center", "flex items-center justify-center",
"rounded-control border border-[var(--border-subtle)] bg-[var(--surface-card)] p-md", fixedWidth
"transition-colors duration-[var(--motion-duration-base)]", ? "group me-lg h-[7.5rem] w-[12.5rem] shrink-0 ps-lg pe-lg"
"hover:border-[var(--border-accent)]", : "h-[5.5rem] rounded-control border border-[var(--border-subtle)] bg-[var(--surface-card)] p-md transition-colors duration-[var(--motion-duration-base)] hover:border-[var(--border-accent)] sh-reveal sh-reveal--in",
/* A still grid reveals tile by tile; a travelling track must not, or /* A still grid reveals tile by tile; a travelling track must not, or
each tile would ramp its own opacity while it moves. each tile would ramp its own opacity while it moves.
The track's spacing is a margin on the tile rather than a `gap` on The track's spacing is a margin on the tile rather than a `gap` on
the row: the loop slides exactly half the track, so each half has to the row: the loop slides exactly half the track, so each half has to
carry its own trailing space or the seam lands half a gap short. */ carry its own trailing space or the seam lands half a gap short. */
fixedWidth ? "me-md w-[11.125rem] shrink-0" : "sh-reveal sh-reveal--in",
decorative && "sh-marquee__clone", decorative && "sh-marquee__clone",
)} )}
> >
@@ -546,6 +555,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
const { locale, hero, approach, gallery, partners, news, faqs } = loaderData; const { locale, hero, approach, gallery, partners, news, faqs } = loaderData;
const t = createTranslator(locale); const t = createTranslator(locale);
const copy = pageCopy(locale).home; const copy = pageCopy(locale).home;
const legacyCopy = loaderData.legacy;
/* The hero badge sits on the page's own surface, so it follows the theme /* The hero badge sits on the page's own surface, so it follows the theme
exactly as the footer's badge does: same root cookie, same emblem. */ exactly as the footer's badge does: same root cookie, same emblem. */
const root = useRouteLoaderData<{ theme?: AppTheme }>("root"); const root = useRouteLoaderData<{ theme?: AppTheme }>("root");
@@ -603,7 +613,6 @@ export default function Home({ loaderData }: Route.ComponentProps) {
title={headline} title={headline}
lead={""} lead={""}
leadWithActions leadWithActions
{...(hero.eyebrow ? { statusPill: <StatusPill label={hero.eyebrow} /> } : {})}
ambient={[ ambient={[
{ top: -220, from: "start", offset: "8%", width: 780, height: 620, durationSeconds: 18 }, { top: -220, from: "start", offset: "8%", width: 780, height: 620, durationSeconds: 18 },
{ {
@@ -641,6 +650,82 @@ reveal
))} ))}
/> />
{/* THE TWO EARLIER SHARP PHASES */}
<PageSection
aria-labelledby="home-legacy-heading"
className="bg-[var(--surface-sunken)]"
>
<div className="flex flex-col gap-2xl">
<div className="border-b border-[var(--border-default)]">
<div className="sh-reveal flex flex-col gap-md pb-lg md:max-w-[52rem]">
<p className="label-md uppercase text-[var(--text-accent)]">
{legacyCopy.homeEyebrow}
</p>
<SectionHeader
id="home-legacy-heading"
title={legacyCopy.homeHeading}
lead={legacyCopy.homeLead}
size="display"
/>
</div>
</div>
<div className="grid grid-cols-1 gap-lg lg:grid-cols-2">
{[
{
stage: legacyCopy.stageOne,
anchor: "sharp-i",
},
{
stage: legacyCopy.stageTwo,
anchor: "sharp-ii",
},
].map(({ stage, anchor }) => (
<article
key={stage.name}
className="sh-lift flex h-full flex-col overflow-hidden rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)]"
>
<div className="flex flex-1 flex-col gap-lg p-lg md:p-xl">
<div className="flex flex-wrap items-baseline justify-between gap-sm">
<h3 className="heading-h2 text-[var(--text-primary)]">
{stage.name}
</h3>
<span className="label-md text-[var(--text-accent)]">
{stage.years}
</span>
</div>
<p className="body-md text-[var(--text-secondary)]">
{stage.summary}
</p>
<dl className="grid grid-cols-3 gap-md border-y border-[var(--border-subtle)] py-md">
{stage.metrics.slice(0, 3).map((metric) => (
<div key={metric.label} className="flex flex-col gap-2xs">
<dt className="label-md text-[var(--text-secondary)]">
{metric.label}
</dt>
<dd className="heading-h2 text-[var(--text-accent)]">
{metric.value}
</dd>
</div>
))}
</dl>
<Button
as={Link}
to={`${sectionPath("legacy", locale)}#${anchor}`}
prefetch="intent"
variant="ghost"
className="self-start"
iconEnd={<ArrowIcon />}
>
{legacyCopy.homeLinkLabel}
</Button>
</div>
</article>
))}
</div>
</div>
</PageSection>
{/* WHAT THE PROJECT IS, AND WHO DELIVERS IT */} {/* WHAT THE PROJECT IS, AND WHO DELIVERS IT */}
{/* `bleed` drops the 80rem Container so the band's content - the three {/* `bleed` drops the 80rem Container so the band's content - the three
pictures especially - runs the full width of the viewport, the way pictures especially - runs the full width of the viewport, the way
+458
View File
@@ -0,0 +1,458 @@
/* ==========================================================================
SHARP LEGACY - /en/legacy and /ar/legacy
--------------------------------------------------------------------------
The full programme record stays available from the CMS. The page introduces
each phase with a short story, its own gallery, and three highlights.
========================================================================== */
import { useRef, useState } from "react";
import { Link } from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
import { createTranslator, sectionPath, type Translator } from "~/i18n";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import type { Route } from "./+types/legacy";
import type { CmsLegacyPage } from "./_page-kit/cms-types";
import { loadSingle, toImage } from "./_page-kit/cms.server";
import type { LegacyGalleryImage, LegacyStageCopy } from "./_page-kit/copy";
import { pageCopy } from "./_page-kit/copy";
import { resolveLegacyStage } from "./_page-kit/legacy-cms.server";
import { routeLocale } from "./_page-kit/locale";
import { cx, PageHero, PageSection, SectionHeader } from "./_page-kit/sections";
import { RichText } from "./_page-kit/rich-text";
import "./_page-kit/motion.css";
const STAGES = [
{
id: "sharp-i",
band: true,
},
{
id: "sharp-ii",
band: false,
},
] as const;
/** Keep the short page narrative in step with the editor's CMS description. */
function storyPreview(markdown: string) {
const [introduction = "", ...rawChapters] = markdown
.trim()
.replace(/\r\n?/g, "\n")
.split(/\n(?=###\s)/);
const chapters = rawChapters.flatMap((raw) => {
const match = /^###\s+([^\n]+)\n([\s\S]*)$/.exec(raw.trim());
return match
? [{ heading: match[1]!, body: match[2]!.trim() }]
: [];
});
const featuredIndexes = new Set(
chapters.length > 3 ? [1, 2, chapters.length - 1] : chapters.map((_, index) => index),
);
const firstSentences = (value: string, count: number) =>
value.trim().split(/(?<=[.!?؟])\s+/u).slice(0, count).join(" ");
return {
introduction: firstSentences(introduction, 2),
highlights: chapters
.filter((_, index) => featuredIndexes.has(index))
.map(({ heading, body }) => ({
heading,
preview: firstSentences(body.split(/\n\s*\n/)[0] ?? body, 2),
})),
};
}
export function loader({ params }: Route.LoaderArgs) {
const locale = routeLocale(params);
return loadSingle<CmsLegacyPage>("legacy-page", {
locale,
populate: {
hero: { populate: { media: true, ctas: true } },
stageOne: { populate: { metrics: true, focusAreas: true, images: true } },
stageTwo: { populate: { metrics: true, focusAreas: true, images: true } },
seo: { populate: { ogImage: true } },
},
}).then((page) => {
const fallback = pageCopy(locale).legacy;
return {
locale,
hero: {
eyebrow: page?.hero?.eyebrow ?? null,
title: page?.hero?.headline ?? null,
lead: page?.hero?.subheading ?? null,
media: toImage(page?.hero?.media, "large") ?? null,
},
stageOne: resolveLegacyStage(page?.stageOne, fallback.stageOne),
stageTwo: resolveLegacyStage(page?.stageTwo, fallback.stageTwo),
closingHeading: page?.closingHeading ?? null,
closingBody: page?.closingBody ?? null,
seo: {
title: page?.seo?.metaTitle ?? null,
description: page?.seo?.metaDescription ?? null,
image: toImage(page?.seo?.ogImage, "large") ?? null,
noIndex: page?.seo?.noIndex ?? false,
},
};
});
}
export function meta({ loaderData, matches }: Route.MetaArgs) {
const root = matches[0].loaderData;
const locale = loaderData?.locale ?? root.locale;
const t = createTranslator(locale);
const copy = pageCopy(locale).legacy;
const seo = loaderData?.seo;
return buildMeta({
locale,
pathname: root.pathname,
siteUrl: root.siteUrl,
title: seo?.title ?? t("nav.sections.legacy"),
description: seo?.description ?? loaderData?.hero.lead ?? copy.heroLead,
...(seo?.image
? { image: { url: seo.image.src, alt: seo.image.alt } }
: {}),
noIndex: seo?.noIndex ?? false,
jsonLd: buildBreadcrumbJsonLd(root.siteUrl, [
{ name: t("nav.sections.home"), pathname: sectionPath("home", locale) },
{ name: t("nav.sections.about"), pathname: sectionPath("about", locale) },
{ name: t("nav.sections.legacy"), pathname: sectionPath("legacy", locale) },
]),
});
}
function LegacyImageGallery({
images,
stageName,
heading,
empty,
t,
}: {
images: readonly LegacyGalleryImage[];
stageName: string;
heading: string;
empty: string;
t: Translator;
}) {
const trackRef = useRef<HTMLUListElement>(null);
const [activeIndex, setActiveIndex] = useState(0);
const scrollToImage = (index: number) => {
trackRef.current?.children.item(index)?.scrollIntoView({
behavior: "auto",
block: "nearest",
inline: "start",
});
setActiveIndex(index);
};
const updateActiveImage = () => {
const track = trackRef.current;
if (!track) return;
const bounds = track.getBoundingClientRect();
const center = (bounds.left + bounds.right) / 2;
let closestIndex = 0;
let closestDistance = Number.POSITIVE_INFINITY;
Array.from(track.children).forEach((slide, index) => {
const rect = slide.getBoundingClientRect();
const distance = Math.abs((rect.left + rect.right) / 2 - center);
if (distance < closestDistance) {
closestIndex = index;
closestDistance = distance;
}
});
setActiveIndex((current) => current === closestIndex ? current : closestIndex);
};
if (images.length === 0) {
return (
<div className="relative flex aspect-[4/3] flex-col justify-end overflow-hidden rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)] p-xl">
<span aria-hidden="true" className="absolute -end-md -top-xl display-sm text-[var(--text-accent)] opacity-10">
{stageName}
</span>
<p className="relative max-w-[20rem] body-md text-[var(--text-secondary)]">
{empty}
</p>
</div>
);
}
if (images.length === 1) {
const image = images[0]!;
return (
<figure className="flex flex-col gap-sm">
<img
src={image.src}
alt={image.alt}
loading="lazy"
decoding="async"
className="aspect-[4/3] w-full rounded-card border border-[var(--border-subtle)] object-cover shadow-[var(--elevation-card)]"
/>
{image.caption ? (
<figcaption className="body-sm text-[var(--text-secondary)]">
{image.caption}
</figcaption>
) : null}
</figure>
);
}
return (
<div role="region" aria-label={heading} className="flex min-w-0 flex-col gap-sm">
<ul
ref={trackRef}
tabIndex={0}
onScroll={updateActiveImage}
className="flex snap-x snap-mandatory overflow-x-auto overscroll-x-contain rounded-card motion-safe:scroll-smooth focus-visible:outline-[color:var(--focus-ring-color)] focus-visible:outline-[length:var(--focus-ring-width)] focus-visible:outline-offset-[var(--focus-ring-offset)]"
>
{images.map((image, index) => (
<li key={`${image.src}-${index}`} className="w-full shrink-0 snap-start">
<figure className="flex flex-col gap-sm">
<img
src={image.src}
alt={image.alt}
loading="lazy"
decoding="async"
className="aspect-[4/3] w-full rounded-card border border-[var(--border-subtle)] object-cover shadow-[var(--elevation-card)]"
/>
{image.caption ? (
<figcaption className="body-sm text-[var(--text-secondary)]">
{image.caption}
</figcaption>
) : null}
</figure>
</li>
))}
</ul>
<div className="flex items-center justify-between gap-sm">
<p aria-live="polite" className="label-md text-[var(--text-secondary)]">
{t("legacy.imageCount", { current: activeIndex + 1, total: images.length })}
</p>
<div className="flex items-center gap-xs">
<button
type="button"
aria-label={t("legacy.previousImage", { programme: stageName })}
disabled={activeIndex === 0}
onClick={() => scrollToImage(activeIndex - 1)}
className="flex size-11 items-center justify-center rounded-control border border-[var(--border-default)] text-[var(--text-primary)] transition-colors hover:border-[var(--border-accent)] hover:text-[var(--text-accent)] disabled:cursor-not-allowed disabled:opacity-40 focus-visible:outline-[color:var(--focus-ring-color)] focus-visible:outline-[length:var(--focus-ring-width)] focus-visible:outline-offset-[var(--focus-ring-offset)]"
>
<ArrowIcon direction="backward" />
</button>
<button
type="button"
aria-label={t("legacy.nextImage", { programme: stageName })}
disabled={activeIndex === images.length - 1}
onClick={() => scrollToImage(activeIndex + 1)}
className="flex size-11 items-center justify-center rounded-control border border-[var(--border-default)] text-[var(--text-primary)] transition-colors hover:border-[var(--border-accent)] hover:text-[var(--text-accent)] disabled:cursor-not-allowed disabled:opacity-40 focus-visible:outline-[color:var(--focus-ring-color)] focus-visible:outline-[length:var(--focus-ring-width)] focus-visible:outline-offset-[var(--focus-ring-offset)]"
>
<ArrowIcon direction="forward" />
</button>
</div>
</div>
<div aria-hidden="true" className="flex gap-xs">
{images.map((image, index) => (
<span
key={`${image.src}-${index}`}
className={cx(
"h-1 flex-1 rounded-chip transition-colors",
index === activeIndex ? "bg-[var(--border-accent)]" : "bg-[var(--border-subtle)]",
)}
/>
))}
</div>
</div>
);
}
function StageSection({
stage,
id,
band,
t,
}: {
stage: LegacyStageCopy;
id: string;
band: boolean;
t: Translator;
}) {
const story = storyPreview(stage.description);
return (
<PageSection
id={id}
aria-labelledby={`${id}-heading`}
{...(band ? { band: true } : {})}
>
<div className="flex flex-col gap-2xl">
<div className="grid items-center gap-xl lg:grid-cols-[minmax(0,0.88fr)_minmax(0,1.12fr)] lg:gap-2xl">
<div className="flex min-w-0 flex-col gap-lg">
<SectionHeader
id={`${id}-heading`}
eyebrow={stage.years}
title={stage.name}
lead={stage.summary}
size="display"
/>
{story.introduction ? (
<p className="body-lg text-[var(--text-secondary)]">
{story.introduction}
</p>
) : null}
<dl className="grid grid-cols-3 gap-sm border-t border-[var(--border-default)] pt-lg">
{stage.metrics.slice(0, 3).map((metric) => (
<div key={metric.label} className="flex min-w-0 flex-col gap-xs">
<dt className="order-2 label-md text-[var(--text-secondary)]">
{metric.label}
</dt>
<dd className="order-1 heading-h2 text-[var(--text-accent)]">
{metric.value}
</dd>
</div>
))}
</dl>
</div>
<div className={cx("flex min-w-0 flex-col gap-sm", id === "sharp-ii" && "lg:order-first")}>
<h3 className="label-md uppercase text-[var(--text-accent)]">
{stage.imageHeading}
</h3>
<LegacyImageGallery
images={stage.images}
stageName={stage.name}
heading={stage.imageHeading}
empty={stage.imageEmpty}
t={t}
/>
</div>
</div>
{story.highlights.length > 0 ? (
<div className="grid gap-md md:grid-cols-3">
{story.highlights.map((highlight) => (
<article
key={highlight.heading}
className="flex flex-col gap-sm border-s-2 border-[var(--border-accent)] bg-[var(--surface-card)] p-lg"
>
<h3 className="heading-h3 text-[var(--text-primary)]">
{highlight.heading}
</h3>
<p className="body-md text-[var(--text-secondary)]">
{highlight.preview}
</p>
</article>
))}
</div>
) : null}
<details className="group rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)]">
<summary className="flex cursor-pointer list-none items-center justify-between gap-md p-lg body-md-bold text-[var(--text-primary)] focus-visible:outline-[color:var(--focus-ring-color)] focus-visible:outline-[length:var(--focus-ring-width)] focus-visible:outline-offset-[var(--focus-ring-offset)] [&::-webkit-details-marker]:hidden">
{t("legacy.fullRecord", { programme: stage.name })}
<span aria-hidden="true" className="heading-h3 text-[var(--text-accent)] group-open:rotate-45">
+
</span>
</summary>
<div className="flex flex-col gap-xl border-t border-[var(--border-subtle)] px-lg py-xl">
<RichText markdown={stage.description} className="w-full" />
{stage.focusAreas.length > 0 ? (
<div className="flex flex-col gap-sm">
<h3 className="heading-h3 text-[var(--text-primary)]">
{stage.focusHeading}
</h3>
<ul className="flex flex-wrap gap-sm">
{stage.focusAreas.map((area) => (
<li
key={area}
className="rounded-chip border border-[var(--border-subtle)] px-md py-xs body-sm text-[var(--text-secondary)]"
>
{area}
</li>
))}
</ul>
</div>
) : null}
</div>
</details>
</div>
</PageSection>
);
}
export default function Legacy({ loaderData }: Route.ComponentProps) {
const locale = loaderData.locale;
const t = createTranslator(locale);
const copy = pageCopy(locale).legacy;
const stageEntries = [
{ ...STAGES[0], stage: loaderData.stageOne },
{ ...STAGES[1], stage: loaderData.stageTwo },
] as const;
return (
<>
<PageHero
eyebrow={loaderData.hero.eyebrow ?? copy.heroEyebrow}
title={loaderData.hero.title ?? copy.heroTitle}
lead={loaderData.hero.lead ?? copy.heroLead}
{...(loaderData.hero.media ? { media: loaderData.hero.media } : {})}
ambient={[
{
top: -150,
from: "start",
offset: "5%",
width: 560,
height: 470,
},
{
top: 100,
from: "end",
offset: "-80px",
width: 460,
height: 420,
tone: "danger",
reverse: true,
},
]}
/>
{stageEntries.map(({ stage, ...config }) => (
<StageSection
key={`${config.id}-${locale}`}
stage={stage}
t={t}
{...config}
/>
))}
<PageSection
aria-labelledby="legacy-closing-heading"
className="bg-[var(--surface-sunken)]"
>
<div className="mx-auto flex max-w-[var(--layout-prose-max)] flex-col items-start gap-lg">
<SectionHeader
id="legacy-closing-heading"
title={loaderData.closingHeading ?? copy.closingHeading}
/>
<p className="body-lg text-[var(--text-secondary)]">
{(loaderData.closingBody ?? copy.closingParagraphs.join(" "))
.trim()
.split(/(?<=[.!?؟])\s+/u)
.slice(0, 2)
.join(" ")}
</p>
<Button
as={Link}
to={sectionPath("about", locale)}
prefetch="intent"
variant="primary"
iconEnd={<ArrowIcon />}
>
{t("actions.discoverSharper")}
</Button>
</div>
</PageSection>
</>
);
}
@@ -0,0 +1,148 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="800"
height="859.0954"
fill="none"
version="1.1"
id="svg10"
sodipodi:docname="Calendar2026.svg"
inkscape:version="1.4.2 (f4327f4, 2025-05-13)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview10"
pagecolor="#505050"
bordercolor="#ffffff"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
inkscape:deskcolor="#505050"
inkscape:zoom="4.3177083"
inkscape:cx="76.313631"
inkscape:cy="87.893848"
inkscape:window-width="1920"
inkscape:window-height="1009"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="svg10" />
<path
fill="#bbe2ff"
d="M 64.743253,150.86554 C 64.743253,67.543758 132.28701,0 215.60879,0 h 368.78242 c 83.32178,0 150.86554,67.543758 150.86554,150.86554 v 159.24695 c 0,83.32178 -67.54376,150.86554 -150.86554,150.86554 H 215.60879 c -83.32178,0 -150.865537,-67.54376 -150.865537,-150.86554 z"
id="path1"
style="stroke-width:5.23839" />
<path
fill="#3c90ff"
d="M 1.1859076,216.8273 C -9.5475474,135.25514 53.952176,62.86064 136.22104,62.86064 h 527.55792 c 82.2741,0 145.76859,72.3945 135.03513,153.96666 l -32.12702,244.15073 32.12702,244.15072 c 10.73346,81.57216 -52.76103,153.96666 -135.03513,153.96666 H 136.22104 c -82.274102,0 -145.7685874,-72.3945 -135.0351324,-153.96666 L 33.312933,460.97803 Z"
id="path2"
style="stroke-width:5.23839" />
<mask
id="a"
width="154"
height="152"
x="19"
y="20"
maskUnits="userSpaceOnUse">
<path
fill="#3c90ff"
d="M 19.867,49.392 C 17.818,33.82 29.94,20 45.645,20 h 100.71 c 15.706,0 27.827,13.82 25.778,29.392 L 166,96 l 6.133,46.608 C 174.182,158.18 162.061,172 146.355,172 H 45.645 C 29.939,172 17.818,158.18 19.867,142.608 L 26,96 Z"
id="path3" />
</mask>
<g
mask="url(#a)"
id="g4"
transform="matrix(5.2383867,0,0,5.2383867,-102.88512,-41.907093)">
<path
fill="url(#b)"
d="M 0,0 H 166 V 76 H 0 Z"
transform="matrix(1,0,0,-1,13,172)"
id="path4"
style="fill:url(#b)" />
</g>
<mask
id="c"
width="154"
height="152"
x="19"
y="20"
maskUnits="userSpaceOnUse">
<path
fill="#3186ff"
d="M 19.867,49.392 C 17.818,33.82 29.94,20 45.645,20 h 100.71 c 15.706,0 27.827,13.82 25.778,29.392 L 166,96 l 6.133,46.608 C 174.182,158.18 162.061,172 146.355,172 H 45.645 C 29.939,172 17.818,158.18 19.867,142.608 L 26,96 Z"
id="path5" />
</mask>
<g
mask="url(#c)"
id="g6"
transform="matrix(5.2383867,0,0,5.2383867,-102.88512,-41.907093)">
<path
fill="url(#d)"
d="M 32,27.2 C 32,16.596 40.596,8 51.2,8 h 89.6 C 151.404,8 160,16.596 160,27.2 V 96 H 32 Z"
filter="url(#e)"
id="path6"
style="fill:url(#d)" />
</g>
<path
fill="#ffffff"
d="m 291.84303,656.55843 q -32.90754,0 -56.45409,-10.70202 -23.54655,-10.70203 -39.86412,-28.62779 -16.05566,-18.19815 -22.74508,-35.58436 -6.68942,-17.3862 -5.34839,-21.13689 a 10.84346,10.84346 0 0 1 5.34839,-5.88794 l 29.70165,-11.77066 q 3.74021,-1.8701 7.4909,-0.53431 3.74021,1.06863 8.82668,12.30497 5.35363,11.23634 14.98178,23.8137 a 74.908929,74.908929 0 0 0 23.54655,19.52871 q 13.65124,6.95658 33.70902,6.95658 32.37323,0 51.37286,-18.72724 19.26155,-18.72723 19.26155,-47.62217 0,-31.3046 -20.33542,-48.16173 Q 321.00513,473.283 287.55803,473.283 H 259.4698 a 9.9529347,9.9529347 0 0 1 -6.95657,-2.67158 q -2.67158,-2.94397 -2.67682,-6.68942 v -28.62778 q 0,-4.01785 2.67158,-6.68942 a 9.5338637,9.5338637 0 0 1 6.96181,-2.94398 h 24.34279 q 29.96881,0 48.16172,-16.32281 18.19292,-16.32281 18.19292,-42.27378 0,-25.67857 -16.32281,-41.46707 -16.32282,-15.7885 -44.94536,-15.7885 -16.05566,0 -27.82631,5.35364 a 60.241447,60.241447 0 0 0 -20.33542,14.98178 118.91138,118.91138 0 0 0 -14.71463,19.8011 q -6.14986,10.16771 -9.90055,11.23634 -3.7402,0.80148 -7.22373,-1.33579 l -28.09347,-13.64599 q -3.47829,-1.87535 -4.54692,-5.88795 -1.06863,-4.0126 6.42226,-18.72723 7.75805,-14.98179 23.54131,-30.50313 a 110.00612,110.00612 0 0 1 36.92539,-24.08086 q 21.13689,-8.56476 49.23036,-8.55953 52.17433,0 82.67222,27.55392 30.50312,27.29199 30.50312,72.24259 0,31.03744 -14.98178,53.77728 -14.71987,22.7346 -41.73947,32.11131 v 1.06863 q 32.64039,9.62815 51.36762,35.31196 18.99963,25.42189 18.99439,60.73386 0,50.57138 -35.3172,82.94461 -35.30673,32.37323 -92.03846,32.37323 z m 268.46732,-6.1551 q -4.54692,0 -8.03045,-3.47829 a 11.78637,11.78637 0 0 1 -3.20589,-8.29237 V 341.11326 l -60.19954,43.34241 q -3.21637,2.40966 -7.49613,1.60819 a 10.267238,10.267238 0 0 1 -6.41702,-4.0126 l -17.39145,-24.62042 a 10.372006,10.372006 0 0 1 -1.87534,-7.4909 q 0.80147,-4.27452 4.27976,-6.68418 l 106.75308,-76.25519 q 1.34103,-1.06863 2.94398,-1.60295 1.60818,-0.80147 3.74544,-0.80147 h 22.47792 q 4.54692,0 7.22374,3.21113 2.94397,2.9335 2.94397,7.49089 v 363.3345 q 0,4.81932 -3.47829,8.29237 a 10.476773,10.476773 0 0 1 -8.03045,3.47829 z"
id="path7"
style="stroke-width:5.23839" />
<defs
id="defs10">
<linearGradient
id="b"
x1="83"
x2="83"
y1="76"
gradientUnits="userSpaceOnUse">
<stop
stop-color="#4fa0ff"
id="stop7" />
<stop
offset="1"
stop-color="#3186ff"
id="stop8" />
</linearGradient>
<linearGradient
id="d"
x1="89.059998"
x2="89.059998"
y1="21.75"
y2="96.389999"
gradientUnits="userSpaceOnUse">
<stop
stop-color="#a9a8ff"
id="stop9" />
<stop
offset=".8"
stop-color="#3c90ff"
id="stop10" />
</linearGradient>
<filter
id="e"
width="152"
height="112"
x="20"
y="-4"
color-interpolation-filters="sRGB"
filterUnits="userSpaceOnUse">
<feFlood
flood-opacity="0"
result="BackgroundImageFix"
id="feFlood10" />
<feBlend
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
id="feBlend10"
mode="normal" />
<feGaussianBlur
result="effect1_foregroundBlur_37330_7673"
stdDeviation="6"
id="feGaussianBlur10" />
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 31 KiB

+4
View File
@@ -74,6 +74,7 @@ services:
# together with CMS_PORT, and set it to the public origin in production. # together with CMS_PORT, and set it to the public origin in production.
PUBLIC_URL: ${CMS_PUBLIC_URL:-http://localhost:1337} PUBLIC_URL: ${CMS_PUBLIC_URL:-http://localhost:1337}
LOG_LEVEL: ${LOG_LEVEL:-info} LOG_LEVEL: ${LOG_LEVEL:-info}
PARTNER_REVIEW_NOTIFICATION_EMAILS: ${PARTNER_REVIEW_NOTIFICATION_EMAILS:-}
# Inside the compose network the database is reachable by service name on # Inside the compose network the database is reachable by service name on
# its own port, never through the published one. # its own port, never through the published one.
@@ -128,6 +129,9 @@ services:
STRAPI_TOKEN: ${STRAPI_TOKEN:-} STRAPI_TOKEN: ${STRAPI_TOKEN:-}
# Canonical URLs, hreflang alternates and JSON-LD are built from this. # Canonical URLs, hreflang alternates and JSON-LD are built from this.
SITE_URL: ${SITE_URL:-http://localhost:3000} SITE_URL: ${SITE_URL:-http://localhost:3000}
# Cloudflare Turnstile on the public forms - off unless both are set.
TURNSTILE_SITE_KEY: ${TURNSTILE_SITE_KEY:-}
TURNSTILE_SECRET_KEY: ${TURNSTILE_SECRET_KEY:-}
volumes: volumes:
# Panton and the Arabic faces are licensed and deliberately excluded from # Panton and the Arabic faces are licensed and deliberately excluded from
# the image (.dockerignore). Mounting the host folder read-only lets a # the image (.dockerignore). Mounting the host folder read-only lets a