Compare commits
20
Commits
60dd92e4b0
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
709a978da5
|
||
|
|
0b2c8d581f | ||
|
|
274ca6f5ca | ||
|
|
9119599e45 | ||
|
|
e6069248b0 | ||
|
|
4afc510fcc | ||
|
|
a5539d18d7
|
||
|
|
2cecb76921
|
||
|
|
1a3a67f1f2 | ||
|
|
9ba0c59eff
|
||
|
|
2d06447811
|
||
|
|
2b3ffcf19e
|
||
|
|
847a7d733d
|
||
|
|
47d2a7fc3e
|
||
|
|
dcf4c85128
|
||
|
|
1c0912378a | ||
|
|
98fae066bc | ||
|
|
a5c531be00
|
||
|
|
5310272715
|
||
|
|
8e502b4a01
|
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -149,16 +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, event-reminder and application-acknowledgement e-mails
|
# confirmation, event-reminder and application-acknowledgement e-mails
|
||||||
# (src/lib/event-mail.ts, src/lib/application-mail.ts).
|
# (src/lib/event-mail.ts, src/lib/application-mail.ts).
|
||||||
#
|
#
|
||||||
# The subject and body of those e-mails are editable in the CMS (the
|
# Their bilingual copy is hardcoded in src/lib/email-template-defaults.ts.
|
||||||
# email-settings single type); the SHARP-ER / EU / partner logo header needs
|
# Every message uses the styled HTML layout in src/lib/event-mail.ts. The
|
||||||
# PUBLIC_URL (above) to build absolute image URLs - without it the mail falls
|
# SHARP-ER / EU / partner logos need PUBLIC_URL (above) to build absolute image
|
||||||
# back to a text wordmark.
|
# 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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"kind": "singleType",
|
|
||||||
"collectionName": "email_settings",
|
|
||||||
"info": {
|
|
||||||
"singularName": "email-settings",
|
|
||||||
"pluralName": "email-settings-list",
|
|
||||||
"displayName": "Email Settings",
|
|
||||||
"description": "The outbound email templates (registration confirmation, waitlist, reminder, application acknowledgement). Markdown bodies become HTML at send time. The values seeded here on first boot are also the defaults the mails fall back to when the row is missing. Read by the mail jobs only; not exposed over the public API."
|
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"draftAndPublish": false
|
|
||||||
},
|
|
||||||
"pluginOptions": {
|
|
||||||
"i18n": {
|
|
||||||
"localized": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"attributes": {
|
|
||||||
"templates": {
|
|
||||||
"type": "component",
|
|
||||||
"repeatable": true,
|
|
||||||
"component": "email.template",
|
|
||||||
"required": true,
|
|
||||||
"pluginOptions": {
|
|
||||||
"i18n": {
|
|
||||||
"localized": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -145,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';
|
||||||
@@ -200,17 +202,36 @@ const sanitizeSubmission: Core.MiddlewareFactory =
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Send the confirmation only after the transaction above has committed:
|
// Send the confirmation only after the transaction above has committed:
|
||||||
// the mail helper reads the event, template and branding back from the
|
// the mail helper reads the event and template back from the
|
||||||
// database, and running those reads inside the transaction would bind them
|
// database, and running those reads inside the transaction would bind them
|
||||||
// to a transaction that is about to be committed, which knex rejects
|
// to a transaction that is about to be committed, which knex rejects
|
||||||
// ("Transaction query already complete"). Dispatched without awaiting so a
|
// ("Transaction query already complete"). Dispatched without awaiting so a
|
||||||
// slow SMTP call can never turn the registration POST into a 502.
|
// slow SMTP call can never turn the registration POST into a 502.
|
||||||
if (typeof data.email === 'string' && data.email.length > 0) {
|
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({
|
void sendRegistrationConfirmation({
|
||||||
strapi,
|
strapi,
|
||||||
to: data.email,
|
to: data.email,
|
||||||
state: data.registrationState === 'waitlisted' ? 'waitlisted' : 'confirmed',
|
state: data.registrationState === 'waitlisted' ? 'waitlisted' : 'confirmed',
|
||||||
eventReference: readEventReference(data.event),
|
eventReference: readEventReference(data.event),
|
||||||
|
reservation: {
|
||||||
|
id: reservationId,
|
||||||
|
name: typeof data.fullName === 'string' ? data.fullName : undefined,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
strapi.log.warn(
|
strapi.log.warn(
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
* transaction opened by the sanitize-submission middleware, and that middleware
|
* transaction opened by the sanitize-submission middleware, and that middleware
|
||||||
* dispatches the confirmation e-mail *after* the transaction has committed.
|
* dispatches the confirmation e-mail *after* the transaction has committed.
|
||||||
* It must not be dispatched from here: a send fired inside the request
|
* It must not be dispatched from here: a send fired inside the request
|
||||||
* transaction would bind the mail helper's own reads (event, template,
|
* transaction would bind the mail helper's own reads (event and template)
|
||||||
* branding) to a transaction that is about to be committed, which knex
|
* to a transaction that is about to be committed, which knex
|
||||||
* rejects with "Transaction query already complete".
|
* rejects with "Transaction query already complete".
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
import type { Core } from '@strapi/strapi';
|
|
||||||
|
|
||||||
import {
|
|
||||||
DEFAULT_EMAIL_TEMPLATES,
|
|
||||||
EMAIL_TEMPLATE_KEYS,
|
|
||||||
type EmailTemplateKey,
|
|
||||||
} from '../lib/email-template-defaults';
|
|
||||||
import { LOG_PREFIX } from './constants';
|
|
||||||
|
|
||||||
const EMAIL_SETTINGS_UID = 'api::email-settings.email-settings';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The `email.template` component payload for one locale of one template.
|
|
||||||
* `enabled` stays on so the shipped wording is the live configuration, keeping
|
|
||||||
* code defaults and CMS state in agreement on a fresh database.
|
|
||||||
*/
|
|
||||||
const templateRow = (key: EmailTemplateKey, locale: 'en' | 'ar') => ({
|
|
||||||
key,
|
|
||||||
enabled: true,
|
|
||||||
subject: DEFAULT_EMAIL_TEMPLATES[key][locale].subject,
|
|
||||||
body: DEFAULT_EMAIL_TEMPLATES[key][locale].body,
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create the `email-settings` single type if the database does not have one
|
|
||||||
* yet, so the four email templates exist and are editable in the admin panel
|
|
||||||
* after the very first boot — carrying the shipped default copy. Never
|
|
||||||
* overwrites an existing row, so editor customisation survives every restart.
|
|
||||||
*/
|
|
||||||
export const ensureEmailSettings = async (strapi: Core.Strapi): Promise<void> => {
|
|
||||||
const existing = await strapi
|
|
||||||
.documents(EMAIL_SETTINGS_UID)
|
|
||||||
.findFirst({});
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const templatesFor = (locale: 'en' | 'ar') =>
|
|
||||||
EMAIL_TEMPLATE_KEYS.map((key) => templateRow(key, locale));
|
|
||||||
|
|
||||||
/* Arabic first — it is the default locale and the one a single type is
|
|
||||||
created on — then the English translation is written onto the same
|
|
||||||
document, which is how Strapi 5 models one document in two locales. */
|
|
||||||
const created = await strapi.documents(EMAIL_SETTINGS_UID).create({
|
|
||||||
locale: 'ar',
|
|
||||||
data: { templates: templatesFor('ar') },
|
|
||||||
});
|
|
||||||
await strapi.documents(EMAIL_SETTINGS_UID).update({
|
|
||||||
documentId: created.documentId,
|
|
||||||
locale: 'en',
|
|
||||||
data: { templates: templatesFor('en') },
|
|
||||||
});
|
|
||||||
|
|
||||||
strapi.log.info(
|
|
||||||
`${LOG_PREFIX} created email-settings single type with the four default email templates.`
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -2,13 +2,14 @@ import type { Core } from '@strapi/strapi';
|
|||||||
|
|
||||||
import { revokePublicAuthActions } from './auth-permissions';
|
import { revokePublicAuthActions } from './auth-permissions';
|
||||||
import { LOG_PREFIX } from './constants';
|
import { LOG_PREFIX } from './constants';
|
||||||
import { ensureEmailSettings } from './email-settings';
|
|
||||||
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';
|
||||||
export { ensureEmailSettings } from './email-settings';
|
|
||||||
export { ensureLocales } from './locales';
|
export { ensureLocales } from './locales';
|
||||||
export { ensurePublicPermissions } from './public-permissions';
|
export { ensurePublicPermissions } from './public-permissions';
|
||||||
export { ensureReminderSettings } from './reminder-settings';
|
export { ensureReminderSettings } from './reminder-settings';
|
||||||
@@ -31,7 +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 ensureEmailSettings(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: [] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"collectionName": "components_email_templates",
|
|
||||||
"info": {
|
|
||||||
"displayName": "Email Template",
|
|
||||||
"icon": "envelop",
|
|
||||||
"description": "One outbound email template: a subject plus a markdown body. The body is converted to HTML at send time. Placeholders: {event}, {date} and {call}. A placeholder whose value is missing collapses together with the word before it, so \"for {call}\" (and \"على {call}\") reads cleanly when there is no call."
|
|
||||||
},
|
|
||||||
"options": {},
|
|
||||||
"attributes": {
|
|
||||||
"key": {
|
|
||||||
"type": "enumeration",
|
|
||||||
"enum": [
|
|
||||||
"confirmation",
|
|
||||||
"waitlist",
|
|
||||||
"event-reminder",
|
|
||||||
"application-acknowledgement"
|
|
||||||
],
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"enabled": {
|
|
||||||
"type": "boolean",
|
|
||||||
"default": true,
|
|
||||||
"description": "Off reverts this email to the shipped default copy."
|
|
||||||
},
|
|
||||||
"subject": {
|
|
||||||
"type": "string",
|
|
||||||
"required": true,
|
|
||||||
"maxLength": 250
|
|
||||||
},
|
|
||||||
"body": {
|
|
||||||
"type": "richtext",
|
|
||||||
"required": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -11,22 +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 CMS-DRIVEN like the event mails: subject and body come from the
|
* Copy is hardcoded in `email-template-defaults.ts`, and this message uses the
|
||||||
* `email-settings` single type's `application-acknowledgement` template, whose
|
* same styled HTML layout as the event emails. The call name fills the `{call}`
|
||||||
* shipped default is the approved wording. The call name fills the `{call}`
|
|
||||||
* placeholder; when there is no call name the placeholder and its preceding
|
* placeholder; when there is no call name the placeholder and its preceding
|
||||||
* word collapse, leaving the no-call variant automatically.
|
* word collapse, leaving the no-call variant automatically.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Core } from '@strapi/strapi';
|
import type { Core } from '@strapi/strapi';
|
||||||
|
|
||||||
import {
|
import { DEFAULT_EMAIL_TEMPLATES } from './email-template-defaults';
|
||||||
readEmailBranding,
|
|
||||||
resolveEmailTemplate,
|
|
||||||
} from './email-templates';
|
|
||||||
import { buildBilingualMail, trySendMail } from './event-mail';
|
import { buildBilingualMail, trySendMail } from './event-mail';
|
||||||
|
|
||||||
const OPPORTUNITY_UID = 'api::opportunity.opportunity';
|
const OPPORTUNITY_UID = 'api::opportunity.opportunity';
|
||||||
@@ -88,11 +84,8 @@ export async function sendApplicationAcknowledgement({
|
|||||||
opportunityReference: OpportunityReference;
|
opportunityReference: OpportunityReference;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const [opportunity, template, branding] = await Promise.all([
|
const opportunity = await fetchBilingualOpportunity(strapi, opportunityReference);
|
||||||
fetchBilingualOpportunity(strapi, opportunityReference),
|
const template = DEFAULT_EMAIL_TEMPLATES['application-acknowledgement'];
|
||||||
resolveEmailTemplate(strapi, 'application-acknowledgement'),
|
|
||||||
readEmailBranding(strapi),
|
|
||||||
]);
|
|
||||||
|
|
||||||
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;
|
||||||
@@ -106,7 +99,6 @@ export async function sendApplicationAcknowledgement({
|
|||||||
ar: { call: callAr ?? '' },
|
ar: { call: callAr ?? '' },
|
||||||
en: { call: callEn ?? '' },
|
en: { call: callEn ?? '' },
|
||||||
},
|
},
|
||||||
branding,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const ok = await trySendMail(strapi, { to, ...mail });
|
const ok = await trySendMail(strapi, { to, ...mail });
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* email-template-defaults — the shipped subject and body for every email the
|
* email-template-defaults — the hardcoded subject and body for every email
|
||||||
* CMS sends.
|
* the CMS sends.
|
||||||
*
|
*
|
||||||
* These are the "default for each email template": first-boot writes them into
|
* This is the source of truth for outbound email copy. Changing a template
|
||||||
* the `email-settings` single type (src/bootstrap/email-settings.ts) so editors
|
* requires a code change and review; the CMS does not store editable copies.
|
||||||
* start from a working, editable baseline, and they remain the fallback in code
|
|
||||||
* when the single type is missing, unreadable or a template is switched off —
|
|
||||||
* which is what keeps the mails sending even if nothing was ever configured.
|
|
||||||
*
|
*
|
||||||
* The copy is the wording the mail modules hard-coded before this file existed,
|
* 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
|
* which itself mirrors the approved web copy (apps/web i18n messages and
|
||||||
@@ -24,6 +21,9 @@ export const EMAIL_TEMPLATE_KEYS = [
|
|||||||
'waitlist',
|
'waitlist',
|
||||||
'event-reminder',
|
'event-reminder',
|
||||||
'application-acknowledgement',
|
'application-acknowledgement',
|
||||||
|
'partner-review-submitted',
|
||||||
|
'partner-review-decision',
|
||||||
|
'partner-review-published',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type EmailTemplateKey = (typeof EMAIL_TEMPLATE_KEYS)[number];
|
export type EmailTemplateKey = (typeof EMAIL_TEMPLATE_KEYS)[number];
|
||||||
@@ -37,8 +37,8 @@ export type LocalisedEmailTemplate = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The two approved acknowledgement wordings, kept for their exact strings:
|
* The two approved acknowledgement wordings, kept for their exact strings:
|
||||||
* the with-call variant is the shipped default and the no-call variant is what
|
* the no-call variant is what placeholder collapse produces when there is no
|
||||||
* the placeholder collapse reproduces when there is no opportunity.
|
* opportunity.
|
||||||
*/
|
*/
|
||||||
const ACKNOWLEDGEMENT_WITH_CALL = {
|
const ACKNOWLEDGEMENT_WITH_CALL = {
|
||||||
ar: 'شكرًا لك — استلمنا طلبك على {call}. سيراجع فريقنا طلبك ويتواصل معك عبر البريد الإلكتروني.',
|
ar: 'شكرًا لك — استلمنا طلبك على {call}. سيراجع فريقنا طلبك ويتواصل معك عبر البريد الإلكتروني.',
|
||||||
@@ -46,8 +46,7 @@ const ACKNOWLEDGEMENT_WITH_CALL = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The plain acknowledgement (no call), reproduced verbatim so editors can
|
* The plain acknowledgement (no call), reproduced verbatim for reference.
|
||||||
* compare against the collapsed form when the placeholder is empty.
|
|
||||||
*/
|
*/
|
||||||
const ACKNOWLEDGEMENT_WITHOUT_CALL = {
|
const ACKNOWLEDGEMENT_WITHOUT_CALL = {
|
||||||
ar: 'شكرًا لك — استلمنا طلبك. سيراجع فريقنا طلبك ويتواصل معك عبر البريد الإلكتروني.',
|
ar: 'شكرًا لك — استلمنا طلبك. سيراجع فريقنا طلبك ويتواصل معك عبر البريد الإلكتروني.',
|
||||||
@@ -99,4 +98,34 @@ export const DEFAULT_EMAIL_TEMPLATES: Readonly<
|
|||||||
body: ACKNOWLEDGEMENT_WITH_CALL.en,
|
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.',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -1,275 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* email-templates — resolve the CMS-configured copy and branding the outbound
|
* email-templates — shared placeholder interpolation for hardcoded outbound
|
||||||
* mails are built from.
|
* email templates. Subjects and bodies live in email-template-defaults.ts.
|
||||||
*
|
|
||||||
* The `email-settings` single type (created at boot with the shipped defaults)
|
|
||||||
* holds one subject + markdown body per template key, per locale. These
|
|
||||||
* resolvers turn that editable state into exactly the values an email needs:
|
|
||||||
*
|
|
||||||
* resolveEmailTemplate the subject/body pair for a key, per locale, each
|
|
||||||
* locale falling back to its own code default when
|
|
||||||
* the row is missing, switched off or unreadable.
|
|
||||||
* readEmailBranding the logos for the mail: the SHARP-ER project logo,
|
|
||||||
* the EU badge and the consortium partner logos,
|
|
||||||
* preferring the media configured on `site-settings`
|
|
||||||
* and falling back to the brand assets bundled with
|
|
||||||
* the CMS (public/branding), so the mail brands itself
|
|
||||||
* the same way the site does even before anyone
|
|
||||||
* uploads media. Logo images need an absolute URL, so
|
|
||||||
* they resolve against `server.url` (PUBLIC_URL, with
|
|
||||||
* the local server as the development fallback); with
|
|
||||||
* neither available the mail falls back to the site
|
|
||||||
* name as a text wordmark.
|
|
||||||
* fillPlaceholders the {event} / {date} / {call} substitution, shared
|
|
||||||
* by both mail modules. An empty value removes the
|
|
||||||
* placeholder together with the word before it, so
|
|
||||||
* "for {call}" / "على {call}" collapse cleanly when
|
|
||||||
* there is no call (see the component description).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Core } from '@strapi/strapi';
|
|
||||||
|
|
||||||
import {
|
|
||||||
DEFAULT_EMAIL_TEMPLATES,
|
|
||||||
type EmailTemplateKey,
|
|
||||||
type LocalisedEmailTemplate,
|
|
||||||
} from './email-template-defaults';
|
|
||||||
|
|
||||||
const EMAIL_SETTINGS_UID = 'api::email-settings.email-settings';
|
|
||||||
const SITE_SETTINGS_UID = 'api::site-settings.site-settings';
|
|
||||||
|
|
||||||
const LOG_PREFIX = '[email-templates]';
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
|
||||||
TEMPLATES
|
|
||||||
------------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
type MailTemplateSettings = {
|
|
||||||
templates?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the row for `key` on a `email-settings` document read for one locale.
|
|
||||||
* Returns `undefined` when there is no such row, it is switched off, or its
|
|
||||||
* fields are unusable — the caller then falls back to that locale's default.
|
|
||||||
*/
|
|
||||||
function templateRowFor(
|
|
||||||
settings: unknown,
|
|
||||||
key: EmailTemplateKey
|
|
||||||
): LocalisedEmailTemplate | undefined {
|
|
||||||
if (!settings || typeof settings !== 'object') return undefined;
|
|
||||||
|
|
||||||
const templates = (settings as MailTemplateSettings).templates;
|
|
||||||
if (!Array.isArray(templates)) return undefined;
|
|
||||||
|
|
||||||
for (const row of templates) {
|
|
||||||
if (!row || typeof row !== 'object') continue;
|
|
||||||
const candidate = row as {
|
|
||||||
key?: unknown;
|
|
||||||
enabled?: unknown;
|
|
||||||
subject?: unknown;
|
|
||||||
body?: unknown;
|
|
||||||
};
|
|
||||||
if (candidate.key !== key) continue;
|
|
||||||
if (candidate.enabled === false) return undefined;
|
|
||||||
if (typeof candidate.subject === 'string' && typeof candidate.body === 'string') {
|
|
||||||
return { subject: candidate.subject, body: candidate.body };
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The per-locale subject/body for one template key, resolved from the CMS. */
|
|
||||||
export type ResolvedEmailTemplate = {
|
|
||||||
ar: LocalisedEmailTemplate;
|
|
||||||
en: LocalisedEmailTemplate;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The subject and body for one template key, per locale. Each locale prefers
|
|
||||||
* its own CMS row and falls back to that locale's shipped default when the
|
|
||||||
* row is missing, disabled or the single type is unreadable — so a broken or
|
|
||||||
* empty `email-settings` never stops a mail from going out.
|
|
||||||
*/
|
|
||||||
export async function resolveEmailTemplate(
|
|
||||||
strapi: Core.Strapi,
|
|
||||||
key: EmailTemplateKey
|
|
||||||
): Promise<ResolvedEmailTemplate> {
|
|
||||||
const defaults = DEFAULT_EMAIL_TEMPLATES[key];
|
|
||||||
const resolved: ResolvedEmailTemplate = {
|
|
||||||
ar: { subject: defaults.ar.subject, body: defaults.ar.body },
|
|
||||||
en: { subject: defaults.en.subject, body: defaults.en.body },
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [arSettings, enSettings] = await Promise.all([
|
|
||||||
strapi.documents(EMAIL_SETTINGS_UID).findFirst({ locale: 'ar' }),
|
|
||||||
strapi.documents(EMAIL_SETTINGS_UID).findFirst({ locale: 'en' }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const arRow = templateRowFor(arSettings, key);
|
|
||||||
const enRow = templateRowFor(enSettings, key);
|
|
||||||
if (arRow) resolved.ar = arRow;
|
|
||||||
if (enRow) resolved.en = enRow;
|
|
||||||
} catch (error) {
|
|
||||||
strapi.log.warn(
|
|
||||||
`${LOG_PREFIX} could not read email-settings for "${key}", using defaults: ${String(error)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
|
||||||
BRANDING (SHARP-ER + EU + consortium logos)
|
|
||||||
------------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The `site-settings` media shapes, read with a loose internal eye so the
|
|
||||||
* population details of the findFirst populate above cannot break the mail.
|
|
||||||
*/
|
|
||||||
type RawMedia = { url?: unknown } | null | undefined;
|
|
||||||
type RawPartner = { name?: unknown; logo?: RawMedia } | undefined;
|
|
||||||
|
|
||||||
/** A partner that will appear in the mail's logo block. */
|
|
||||||
export type EmailPartner = { name: string; logo: string | null };
|
|
||||||
|
|
||||||
/** Everything the mail chrome needs, resolved to absolute image URLs. */
|
|
||||||
export type EmailBranding = {
|
|
||||||
siteName: string;
|
|
||||||
projectLogo: string | null;
|
|
||||||
euBadge: string | null;
|
|
||||||
partners: EmailPartner[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const EMPTY_BRANDING: EmailBranding = {
|
|
||||||
siteName: 'SHARP-ER',
|
|
||||||
projectLogo: null,
|
|
||||||
euBadge: null,
|
|
||||||
partners: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The brand assets shipped with the CMS, copied from the site's own
|
|
||||||
* `apps/web/public/assets` into `public/branding`. They are the fallback when
|
|
||||||
* `site-settings` has no logo uploaded yet, so every mail still carries the
|
|
||||||
* SHARP-ER wordmark, the EU emblem and the consortium logos (Acted, UEC, SBE —
|
|
||||||
* always together, never one partner alone). `site-settings` media still wins
|
|
||||||
* when an editor uploads it.
|
|
||||||
*/
|
|
||||||
const BUNDLED_BRANDING = {
|
|
||||||
projectLogo: '/branding/sharp-er-wordmark.png',
|
|
||||||
euBadge: '/branding/eu-emblem.png',
|
|
||||||
partners: [
|
|
||||||
{ name: 'Acted', logo: '/branding/partner-acted.png' },
|
|
||||||
{ name: 'United Efforts Consultancy (UEC)', logo: '/branding/partner-uec.png' },
|
|
||||||
{ name: 'Stichting Bureau Ensemble (SBE)', logo: '/branding/partner-sbe.png' },
|
|
||||||
],
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The absolute origin the brand images are resolved against: `server.url`
|
|
||||||
* (PUBLIC_URL) when configured, the local server in development, and `null`
|
|
||||||
* otherwise. Mail clients need absolute URLs or the logos do not load; on
|
|
||||||
* production behind a custom domain, leaving PUBLIC_URL unset drops the
|
|
||||||
* images and degrades the mail to the plain text wordmark.
|
|
||||||
*/
|
|
||||||
const emailBase = (strapi: Core.Strapi): string | null => {
|
|
||||||
const configured = strapi.config.get('server.url') as string | undefined;
|
|
||||||
if (configured && configured.trim().length > 0) return configured;
|
|
||||||
if ((process.env.NODE_ENV ?? 'development') !== 'production') {
|
|
||||||
return `http://localhost:${strapi.config.get('server.port') ?? 1337}`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Resolve a media URL to an absolute one, or null when that is impossible. */
|
|
||||||
const absoluteMediaUrl = (base: string | undefined, url: unknown): string | null => {
|
|
||||||
if (typeof url !== 'string' || url.length === 0) return null;
|
|
||||||
try {
|
|
||||||
return new URL(url, base).href;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the logos for the mail chrome: the SHARP-ER project logo, the EU
|
|
||||||
* funding badge and the consortium partner logos (Acted, UEC, SBE). It prefers
|
|
||||||
* the `site-settings` media and falls back to the brand assets bundled with the
|
|
||||||
* CMS (public/branding) when nothing is uploaded, so the mail only degrades to
|
|
||||||
* a text wordmark when there is no absolute base URL to resolve images
|
|
||||||
* against. The badge and the partner logos always render as one unit — EU
|
|
||||||
* visibility is contractual and a partner logo never appears on its own.
|
|
||||||
*/
|
|
||||||
export async function readEmailBranding(strapi: Core.Strapi): Promise<EmailBranding> {
|
|
||||||
let siteName = EMPTY_BRANDING.siteName;
|
|
||||||
try {
|
|
||||||
const settings = await strapi.documents(SITE_SETTINGS_UID).findFirst({
|
|
||||||
populate: {
|
|
||||||
logo: true,
|
|
||||||
euBadge: true,
|
|
||||||
consortiumLogos: { populate: { logo: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (settings && typeof settings.siteName === 'string') {
|
|
||||||
siteName = settings.siteName;
|
|
||||||
}
|
|
||||||
|
|
||||||
const base = emailBase(strapi);
|
|
||||||
if (!base) {
|
|
||||||
strapi.log.warn(
|
|
||||||
`${LOG_PREFIX} no absolute base URL (PUBLIC_URL) and not in development; e-mails will use the text wordmark and omit logos.`
|
|
||||||
);
|
|
||||||
return { siteName, projectLogo: null, euBadge: null, partners: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const mediaUrl = (file: RawMedia): string | null =>
|
|
||||||
absoluteMediaUrl(base, file?.url);
|
|
||||||
const bundledUrl = (assetPath: string): string | null =>
|
|
||||||
absoluteMediaUrl(base, assetPath);
|
|
||||||
|
|
||||||
const toPartner = (partner: unknown): EmailPartner => {
|
|
||||||
const p = partner as RawPartner | undefined;
|
|
||||||
return {
|
|
||||||
name: typeof p?.name === 'string' ? p.name : '',
|
|
||||||
logo: mediaUrl(p?.logo),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const uploadedPartners: EmailPartner[] = Array.isArray(settings?.consortiumLogos)
|
|
||||||
? (settings.consortiumLogos as unknown[]).map(toPartner)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const partners: EmailPartner[] =
|
|
||||||
uploadedPartners.length > 0
|
|
||||||
? uploadedPartners
|
|
||||||
: BUNDLED_BRANDING.partners.map((partner) => ({
|
|
||||||
name: partner.name,
|
|
||||||
logo: bundledUrl(partner.logo),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
siteName,
|
|
||||||
projectLogo: mediaUrl(settings?.logo) ?? bundledUrl(BUNDLED_BRANDING.projectLogo),
|
|
||||||
euBadge: mediaUrl(settings?.euBadge) ?? bundledUrl(BUNDLED_BRANDING.euBadge),
|
|
||||||
partners,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
strapi.log.warn(
|
|
||||||
`${LOG_PREFIX} could not read site-settings branding, emails will use the text wordmark: ${String(error)}`
|
|
||||||
);
|
|
||||||
return EMPTY_BRANDING;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
|
||||||
PLACEHOLDERS
|
|
||||||
------------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
/** Literal values for {event} / {date} / {call}, per locale, unescaped. */
|
/** Literal values for {event} / {date} / {call}, per locale, unescaped. */
|
||||||
export type PlaceholderValues = Readonly<Record<string, string>>;
|
export type PlaceholderValues = Readonly<Record<string, string>>;
|
||||||
|
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
+285
-82
@@ -14,24 +14,16 @@
|
|||||||
* 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 IS CMS-DRIVEN. The subject and body for each mail come from the
|
* COPY IS HARDCODED in lib/email-template-defaults.ts. This module owns the
|
||||||
* `email-settings` single type — markdown bodies, converted to HTML at send
|
* shared styled HTML layout, event lookups, the {event} / {date} placeholders,
|
||||||
* time (see lib/markdown.ts) — with the shipped approved copy as the fallback
|
* venue or join-online line, and EU/partner branding.
|
||||||
* (see lib/email-template-defaults.ts). This module owns the chrome around
|
|
||||||
* that copy: the event lookups, the {event} / {date} placeholders, the venue
|
|
||||||
* or join-online line, and the logo header/footer (SHARP-ER logo, EU badge
|
|
||||||
* and the consortium partner logos, read from `site-settings`).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Core } from '@strapi/strapi';
|
import type { Core } from '@strapi/strapi';
|
||||||
|
|
||||||
import {
|
import { buildEventCalendar } from './event-calendar';
|
||||||
readEmailBranding,
|
import { DEFAULT_EMAIL_TEMPLATES } from './email-template-defaults';
|
||||||
resolveEmailTemplate,
|
import { fillPlaceholders, type PlaceholderValues } from './email-templates';
|
||||||
fillPlaceholders,
|
|
||||||
type EmailBranding,
|
|
||||||
type PlaceholderValues,
|
|
||||||
} from './email-templates';
|
|
||||||
import { mdToHtml, mdToText, escapeHtml } from './markdown';
|
import { mdToHtml, mdToText, escapeHtml } from './markdown';
|
||||||
|
|
||||||
const EVENT_UID = 'api::event.event';
|
const EVENT_UID = 'api::event.event';
|
||||||
@@ -44,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. */
|
||||||
@@ -65,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.
|
||||||
@@ -144,46 +307,41 @@ 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;";
|
||||||
|
|
||||||
|
const EMAIL_LOGOS = {
|
||||||
|
project: 'https://cms.sharper.ly/branding/sharp-er-wordmark.png',
|
||||||
|
eu: 'https://cms.sharper.ly/branding/eu-emblem.png',
|
||||||
|
partners: [
|
||||||
|
{ name: 'Acted', url: 'https://cms.sharper.ly/branding/partner-acted.png' },
|
||||||
|
{
|
||||||
|
name: 'United Efforts Consultancy (UEC)',
|
||||||
|
url: 'https://cms.sharper.ly/branding/partner-uec.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Stichting Bureau Ensemble (SBE)',
|
||||||
|
url: 'https://cms.sharper.ly/branding/partner-sbe.png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The logo chrome shared by every mail: the SHARP-ER project logo at the top
|
* The logo chrome shared by every mail: the SHARP-ER project logo at the top
|
||||||
* (the EU badge beside it), and the consortium partner logos under the footer
|
* (the EU badge beside it), and the consortium partner logos under the footer
|
||||||
* text. EU visibility is contractual, so the badge and the partner logos are
|
* text. EU visibility is contractual, so the badge and the partner logos are
|
||||||
* rendered as one unit straight from `site-settings`. Without an absolute
|
* linked to the public assets on the project website.
|
||||||
* image URL the header degrades to the site name as a text wordmark.
|
|
||||||
*/
|
*/
|
||||||
|
const renderBrandHeader = (): string => `
|
||||||
const renderBrandHeader = (branding?: EmailBranding): string => {
|
|
||||||
if (!branding) return '';
|
|
||||||
|
|
||||||
const logo = branding.projectLogo
|
|
||||||
? `<img src="${branding.projectLogo}" alt="${escapeHtml(branding.siteName)}" style="height:40px; width:auto; display:block;" />`
|
|
||||||
: `<strong style="font-size:18px; color:#222;">${escapeHtml(branding.siteName)}</strong>`;
|
|
||||||
|
|
||||||
const badge = branding.euBadge
|
|
||||||
? `<img src="${branding.euBadge}" alt="${escapeHtml('Funded by the European Union')}" style="height:36px; width:auto; display:block;" />`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
if (!badge) {
|
|
||||||
return `<div style="margin-bottom:28px; ${BASE_FONT}">${logo}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `
|
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:28px; ${BASE_FONT}">
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:28px; ${BASE_FONT}">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:0; vertical-align:middle;">${logo}</td>
|
<td style="padding:0; vertical-align:middle;"><img src="${EMAIL_LOGOS.project}" alt="SHARP-ER" style="height:40px; width:auto; display:block;" /></td>
|
||||||
<td align="right" style="padding:0; vertical-align:middle;">${badge}</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>
|
</tr>
|
||||||
</table>`;
|
</table>`;
|
||||||
};
|
|
||||||
|
|
||||||
const renderPartnerLogos = (branding?: EmailBranding): string => {
|
const renderPartnerLogos = (): string => {
|
||||||
const logos = (branding?.partners ?? []).filter((partner) => partner.logo);
|
const images = EMAIL_LOGOS.partners
|
||||||
if (logos.length === 0) return '';
|
|
||||||
|
|
||||||
const images = logos
|
|
||||||
.map(
|
.map(
|
||||||
(partner) =>
|
(partner) =>
|
||||||
`<img src="${partner.logo}" alt="${escapeHtml(partner.name)}" title="${escapeHtml(partner.name)}" style="height:28px; width:auto; max-width:160px; margin:0 16px 8px 0; display:inline-block; vertical-align:middle;" />`
|
`<img src="${partner.url}" alt="${escapeHtml(partner.name)}" title="${escapeHtml(partner.name)}" style="height:28px; width:auto; max-width:160px; margin:0 16px 8px 0; display:inline-block; vertical-align:middle;" />`
|
||||||
)
|
)
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
@@ -216,9 +374,17 @@ export function buildBilingualMail(opts: {
|
|||||||
/** Literal {event} / {date} / {call} values, per locale, unescaped. */
|
/** Literal {event} / {date} / {call} values, per locale, unescaped. */
|
||||||
vars?: { ar: PlaceholderValues; en: PlaceholderValues };
|
vars?: { ar: PlaceholderValues; en: PlaceholderValues };
|
||||||
locationLine?: { ar: string; en: string };
|
locationLine?: { ar: string; en: string };
|
||||||
branding?: EmailBranding;
|
jsonLd?: JsonLd;
|
||||||
}): { subject: string; text: string; html: string } {
|
}): { subject: string; text: string; html: string } {
|
||||||
const { subjectAr, subjectEn, bodyAr, bodyEn, vars, locationLine, branding } = opts;
|
const {
|
||||||
|
subjectAr,
|
||||||
|
subjectEn,
|
||||||
|
bodyAr,
|
||||||
|
bodyEn,
|
||||||
|
vars,
|
||||||
|
locationLine,
|
||||||
|
jsonLd,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
const varsAr = vars?.ar ?? {};
|
const varsAr = vars?.ar ?? {};
|
||||||
const varsEn = vars?.en ?? {};
|
const varsEn = vars?.en ?? {};
|
||||||
@@ -228,26 +394,46 @@ export function buildBilingualMail(opts: {
|
|||||||
Object.entries(values).map(([name, value]) => [name, escapeHtml(value)])
|
Object.entries(values).map(([name, value]) => [name, escapeHtml(value)])
|
||||||
);
|
);
|
||||||
|
|
||||||
const html = `
|
const subject = `${fillPlaceholders(subjectAr, varsAr)} | ${fillPlaceholders(subjectEn, varsEn)}`;
|
||||||
${renderBrandHeader(branding)}
|
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="${BASE_FONT}"><tr><td style="padding:0; line-height:1.6; color:#222;">
|
const html = `<!doctype html>
|
||||||
<div dir="rtl" lang="ar">
|
<html lang="en">
|
||||||
${fillPlaceholders(mdToHtml(bodyAr), escaped(varsAr))}
|
<head>
|
||||||
${locationHtml(locationLine?.ar)}
|
<meta charset="utf-8" />
|
||||||
</div>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<hr style="border:none; border-top:1px solid #ddd; margin:24px 0;" />
|
<title>${escapeHtml(subject)}</title>
|
||||||
<div dir="ltr" lang="en">
|
${renderJsonLd(jsonLd)}
|
||||||
${fillPlaceholders(mdToHtml(bodyEn), escaped(varsEn))}
|
</head>
|
||||||
${locationHtml(locationLine?.en)}
|
<body style="margin:0; padding:0; background-color:#f2f5f3; ${BASE_FONT}">
|
||||||
</div>
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" bgcolor="#f2f5f3" style="width:100%; border-collapse:collapse; background-color:#f2f5f3;">
|
||||||
</td></tr></table>
|
<tr><td align="center" style="padding:32px 12px;">
|
||||||
<p style="margin-top:32px; font-size:12px; line-height:1.5; color:#666; ${BASE_FONT}">
|
<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;">
|
||||||
${FOOTER.ar}
|
<tr><td style="padding:28px 32px 24px; ${BASE_FONT}">
|
||||||
</p>
|
${renderBrandHeader()}
|
||||||
<p style="font-size:12px; line-height:1.5; color:#666; ${BASE_FONT}">
|
<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;">
|
||||||
${FOOTER.en}
|
<div dir="rtl" lang="ar" style="direction:rtl; text-align:right;">
|
||||||
</p>
|
${fillPlaceholders(mdToHtml(bodyAr), escaped(varsAr))}
|
||||||
${renderPartnerLogos(branding)}`;
|
${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;"> </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 = [
|
||||||
fillPlaceholders(mdToText(bodyAr), varsAr),
|
fillPlaceholders(mdToText(bodyAr), varsAr),
|
||||||
@@ -263,7 +449,7 @@ export function buildBilingualMail(opts: {
|
|||||||
.join('\n');
|
.join('\n');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subject: `${fillPlaceholders(subjectAr, varsAr)} | ${fillPlaceholders(subjectEn, varsEn)}`,
|
subject,
|
||||||
text,
|
text,
|
||||||
html,
|
html,
|
||||||
};
|
};
|
||||||
@@ -292,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 };
|
||||||
@@ -307,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> };
|
||||||
@@ -359,20 +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 isWaitlist = state === 'waitlisted';
|
||||||
|
|
||||||
const [event, template, branding] = await Promise.all([
|
const event = await fetchBilingualEvent(strapi, eventReference);
|
||||||
fetchBilingualEvent(strapi, eventReference),
|
const template = DEFAULT_EMAIL_TEMPLATES[isWaitlist ? 'waitlist' : 'confirmation'];
|
||||||
resolveEmailTemplate(strapi, isWaitlist ? 'waitlist' : 'confirmation'),
|
|
||||||
readEmailBranding(strapi),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!event.en && !event.ar) {
|
if (!event.en && !event.ar) {
|
||||||
strapi.log.warn(
|
strapi.log.warn(
|
||||||
@@ -407,10 +601,15 @@ export async function sendRegistrationConfirmation({
|
|||||||
en: { event: titleEn, date: dateEn },
|
en: { event: titleEn, date: dateEn },
|
||||||
},
|
},
|
||||||
locationLine: locationLineFor(event),
|
locationLine: locationLineFor(event),
|
||||||
branding,
|
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;
|
||||||
@@ -426,17 +625,16 @@ 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> {
|
||||||
try {
|
try {
|
||||||
const [event, template, branding] = await Promise.all([
|
const event = await fetchBilingualEvent(strapi, eventReference);
|
||||||
fetchBilingualEvent(strapi, eventReference),
|
const template = DEFAULT_EMAIL_TEMPLATES['event-reminder'];
|
||||||
resolveEmailTemplate(strapi, 'event-reminder'),
|
|
||||||
readEmailBranding(strapi),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!event.en && !event.ar) {
|
if (!event.en && !event.ar) {
|
||||||
strapi.log.warn(
|
strapi.log.warn(
|
||||||
@@ -467,10 +665,15 @@ export async function sendEventReminderMail({
|
|||||||
en: { event: titleEn, date: dateEn },
|
en: { event: titleEn, date: dateEn },
|
||||||
},
|
},
|
||||||
locationLine: locationLineFor(event),
|
locationLine: locationLineFor(event),
|
||||||
branding,
|
jsonLd: buildEventReservationJsonLd(event, 'confirmed', reservation),
|
||||||
});
|
});
|
||||||
|
|
||||||
return trySendMail(strapi, { to, ...mail });
|
const attachment = buildEventCalendarAttachment(event, 'confirmed');
|
||||||
|
return trySendMail(strapi, {
|
||||||
|
to,
|
||||||
|
...mail,
|
||||||
|
...(attachment ? { attachments: [attachment] } : {}),
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
strapi.log.error(`[event-mail] reminder failed: ${String(error)}`);
|
strapi.log.error(`[event-mail] reminder failed: ${String(error)}`);
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 }>>
|
||||||
|
|||||||
@@ -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
@@ -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'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
-28
@@ -110,33 +110,6 @@ export interface BlocksVideoEmbed extends Struct.ComponentSchema {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmailTemplate extends Struct.ComponentSchema {
|
|
||||||
collectionName: 'components_email_templates';
|
|
||||||
info: {
|
|
||||||
description: 'One outbound email template: a subject plus a markdown body. The body is converted to HTML at send time. Placeholders: {event}, {date} and {call}. A placeholder whose value is missing collapses together with the word before it, so "for {call}" (and "\u0639\u0644\u0649 {call}") reads cleanly when there is no call.';
|
|
||||||
displayName: 'Email Template';
|
|
||||||
icon: 'envelop';
|
|
||||||
};
|
|
||||||
attributes: {
|
|
||||||
body: Schema.Attribute.RichText & Schema.Attribute.Required;
|
|
||||||
enabled: Schema.Attribute.Boolean & Schema.Attribute.DefaultTo<true>;
|
|
||||||
key: Schema.Attribute.Enumeration<
|
|
||||||
[
|
|
||||||
'confirmation',
|
|
||||||
'waitlist',
|
|
||||||
'event-reminder',
|
|
||||||
'application-acknowledgement',
|
|
||||||
]
|
|
||||||
> &
|
|
||||||
Schema.Attribute.Required;
|
|
||||||
subject: Schema.Attribute.String &
|
|
||||||
Schema.Attribute.Required &
|
|
||||||
Schema.Attribute.SetMinMaxLength<{
|
|
||||||
maxLength: 250;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NavigationFooterColumn extends Struct.ComponentSchema {
|
export interface NavigationFooterColumn extends Struct.ComponentSchema {
|
||||||
collectionName: 'components_navigation_footer_columns';
|
collectionName: 'components_navigation_footer_columns';
|
||||||
info: {
|
info: {
|
||||||
@@ -196,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: {
|
||||||
@@ -350,11 +366,13 @@ declare module '@strapi/strapi' {
|
|||||||
'blocks.quote': BlocksQuote;
|
'blocks.quote': BlocksQuote;
|
||||||
'blocks.rich-text': BlocksRichText;
|
'blocks.rich-text': BlocksRichText;
|
||||||
'blocks.video-embed': BlocksVideoEmbed;
|
'blocks.video-embed': BlocksVideoEmbed;
|
||||||
'email.template': EmailTemplate;
|
|
||||||
'navigation.footer-column': NavigationFooterColumn;
|
'navigation.footer-column': NavigationFooterColumn;
|
||||||
'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
-40
@@ -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: {
|
||||||
@@ -852,45 +876,6 @@ export interface ApiContactSubmissionContactSubmission
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiEmailSettingsEmailSettings extends Struct.SingleTypeSchema {
|
|
||||||
collectionName: 'email_settings';
|
|
||||||
info: {
|
|
||||||
description: 'The outbound email templates (registration confirmation, waitlist, reminder, application acknowledgement). Markdown bodies become HTML at send time. The values seeded here on first boot are also the defaults the mails fall back to when the row is missing. Read by the mail jobs only; not exposed over the public API.';
|
|
||||||
displayName: 'Email Settings';
|
|
||||||
pluralName: 'email-settings-list';
|
|
||||||
singularName: 'email-settings';
|
|
||||||
};
|
|
||||||
options: {
|
|
||||||
draftAndPublish: false;
|
|
||||||
};
|
|
||||||
pluginOptions: {
|
|
||||||
i18n: {
|
|
||||||
localized: true;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
attributes: {
|
|
||||||
createdAt: Schema.Attribute.DateTime;
|
|
||||||
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
|
||||||
Schema.Attribute.Private;
|
|
||||||
locale: Schema.Attribute.String;
|
|
||||||
localizations: Schema.Attribute.Relation<
|
|
||||||
'oneToMany',
|
|
||||||
'api::email-settings.email-settings'
|
|
||||||
>;
|
|
||||||
publishedAt: Schema.Attribute.DateTime;
|
|
||||||
templates: Schema.Attribute.Component<'email.template', true> &
|
|
||||||
Schema.Attribute.Required &
|
|
||||||
Schema.Attribute.SetPluginOptions<{
|
|
||||||
i18n: {
|
|
||||||
localized: true;
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
updatedAt: Schema.Attribute.DateTime;
|
|
||||||
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
|
||||||
Schema.Attribute.Private;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiEventRegistrationEventRegistration
|
export interface ApiEventRegistrationEventRegistration
|
||||||
extends Struct.CollectionTypeSchema {
|
extends Struct.CollectionTypeSchema {
|
||||||
collectionName: 'event_registrations';
|
collectionName: 'event_registrations';
|
||||||
@@ -1069,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: {
|
||||||
@@ -1089,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: {
|
||||||
@@ -1393,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: {
|
||||||
@@ -1492,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: {
|
||||||
@@ -1505,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<{
|
||||||
@@ -1620,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: {
|
||||||
@@ -1633,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: {
|
||||||
@@ -1659,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: {
|
||||||
@@ -1970,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: {
|
||||||
@@ -1983,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<{
|
||||||
@@ -2783,14 +2995,15 @@ declare module '@strapi/strapi' {
|
|||||||
'api::application.application': ApiApplicationApplication;
|
'api::application.application': ApiApplicationApplication;
|
||||||
'api::contact-page.contact-page': ApiContactPageContactPage;
|
'api::contact-page.contact-page': ApiContactPageContactPage;
|
||||||
'api::contact-submission.contact-submission': ApiContactSubmissionContactSubmission;
|
'api::contact-submission.contact-submission': ApiContactSubmissionContactSubmission;
|
||||||
'api::email-settings.email-settings': ApiEmailSettingsEmailSettings;
|
|
||||||
'api::event-registration.event-registration': ApiEventRegistrationEventRegistration;
|
'api::event-registration.event-registration': ApiEventRegistrationEventRegistration;
|
||||||
'api::event.event': ApiEventEvent;
|
'api::event.event': ApiEventEvent;
|
||||||
'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;
|
||||||
|
|||||||
@@ -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
@@ -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";
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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": "تم إرسال رسالتك. سنعاود التواصل معك في أقرب وقت ممكن."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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,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;
|
||||||
|
|||||||
@@ -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 ... */}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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. */}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 |
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user