diff --git a/CLAUDE.md b/CLAUDE.md index b370093..29a2d5d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,7 @@ apps/web React Router 8 (framework mode, ssr: true) · React 19 · Vite 8 · T Route ids: root · locale · home · about · whatWeDo · activities · events · applications · news · contact · resources · knowledgeHub (+ *Detail) -apps/cms Strapi 5.53 (TypeScript) · PostgreSQL 16 · i18n (en default, ar) · draft & publish +apps/cms Strapi 5.53 (TypeScript) · PostgreSQL 16 · i18n (ar default, en) · draft & publish src/api/** content types (schema.json is the contract, commit it) src/bootstrap/** runs on every boot: creates locales, grants Public permissions (find/findOne on content; create-only on submission types) diff --git a/README.md b/README.md index 4093664..7be48df 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,7 @@ The first `pnpm dev:cms` takes a minute: Strapi creates its tables, then the boo reconciliation in `apps/cms/src/bootstrap/` runs. That step is idempotent and executes on every boot; it -- creates the `en` (default) and `ar` locales if they are missing, and +- creates the `ar` (default) and `en` locales if they are missing, and - grants the **Public** role `find` / `findOne` on the content types the site reads, and `create` — and only `create` — on the three submission types (event registrations, applications, contact messages), so nobody can read other people's personal data back out. diff --git a/apps/cms/config/plugins.ts b/apps/cms/config/plugins.ts index 53bdf1a..8511809 100644 --- a/apps/cms/config/plugins.ts +++ b/apps/cms/config/plugins.ts @@ -37,7 +37,7 @@ const deniedTypes = [ /** * The locales the site ships with. * - * `en` is the default (LTR) and `ar` is the right-to-left translation. The web + * `ar` is the default (RTL) and `en` is the left-to-right translation. The web * app mirrors these codes in its URL prefixes (`/en/...`, `/ar/...`), so the * codes here are part of the public contract - do not rename them. * @@ -47,8 +47,8 @@ const deniedTypes = [ * here plus a restart. */ const locales = [ - { code: 'en', name: 'English (en)', isDefault: true }, - { code: 'ar', name: 'Arabic (ar)' }, + { code: 'ar', name: 'Arabic (ar)', isDefault: true }, + { code: 'en', name: 'English (en)' }, ]; const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Plugin => ({ @@ -59,7 +59,7 @@ const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Plugin => i18n: { enabled: true, config: { - defaultLocale: 'en', + defaultLocale: 'ar', locales, }, }, diff --git a/apps/cms/src/bootstrap/constants.ts b/apps/cms/src/bootstrap/constants.ts index db01ed7..03841d4 100644 --- a/apps/cms/src/bootstrap/constants.ts +++ b/apps/cms/src/bootstrap/constants.ts @@ -22,8 +22,8 @@ export type LocaleDefinition = { * is that config file. */ export const FALLBACK_LOCALES: LocaleDefinition[] = [ - { code: 'en', name: 'English (en)', isDefault: true }, - { code: 'ar', name: 'Arabic (ar)' }, + { code: 'ar', name: 'Arabic (ar)', isDefault: true }, + { code: 'en', name: 'English (en)' }, ]; /** `type` of the users-permissions role that unauthenticated requests use. */ diff --git a/apps/cms/src/seed/index.ts b/apps/cms/src/seed/index.ts index 17b93e3..46c1918 100644 --- a/apps/cms/src/seed/index.ts +++ b/apps/cms/src/seed/index.ts @@ -247,7 +247,7 @@ async function seedDemo( log(strapi, `${DEMO_PARTNERS.length} demo network partners`); for (const item of DEMO_FAQS) { - await upsert(strapi, UID.faq, item, { question: { $eq: item.en.question } }); + await upsert(strapi, UID.faq, item, { question: { $eq: item.ar.question } }); } log(strapi, `${DEMO_FAQS.length} demo FAQs`); diff --git a/apps/cms/src/seed/upsert.ts b/apps/cms/src/seed/upsert.ts index d0b7f4e..daa9d7f 100644 --- a/apps/cms/src/seed/upsert.ts +++ b/apps/cms/src/seed/upsert.ts @@ -7,12 +7,13 @@ explicit `--demo --clean`. THE SHAPE OF A WRITE - A seed entry is always a pair - the English document and its Arabic + A seed entry is always a pair - the Arabic document and its English translation - because every localised content type on this site ships in - both languages (CLAUDE.md rule 7). `upsert` writes the English document - first, because that is the one carrying the identity (the slug), then - writes the Arabic locale onto the SAME document, which is how Strapi 5 - models a translation: one `documentId`, one row per locale. + both languages (CLAUDE.md rule 7) and Arabic is the CMS's default locale. + `upsert` writes the Arabic document first, because that is the one + carrying the identity (the slug), then writes the English locale onto the + SAME document, which is how Strapi 5 models a translation: one + `documentId`, one row per locale. DRAFT AND PUBLISH Content is written as a draft and then published explicitly. Publishing @@ -108,7 +109,7 @@ export async function upsert( seed ends up with duplicates. */ const existing = await service.findFirst({ ...(find ? { filters: find } : {}), - locale: 'en', + locale: 'ar', ...draftFilter(strapi, uid), }); @@ -117,16 +118,16 @@ export async function upsert( if (existing) { documentId = existing.documentId; - await service.update({ documentId, locale: 'en', data: data.en }); + await service.update({ documentId, locale: 'ar', data: data.ar }); } else { - const document = await service.create({ locale: 'en', data: data.en }); + const document = await service.create({ locale: 'ar', data: data.ar }); documentId = document.documentId; created = true; } /* An update against a locale the document does not have yet creates that translation - there is no separate "add a locale" call. */ - await service.update({ documentId, locale: 'ar', data: data.ar }); + await service.update({ documentId, locale: 'en', data: data.en }); if (usesDraft(strapi, uid)) { await service.publish({ documentId, locale: '*' }); @@ -144,7 +145,7 @@ export async function upsert( * 1. `delete({ documentId })` with no locale removes the DEFAULT locale * only, not the document. It takes `locale: '*'` to remove the lot. * 2. `findMany({ locale })` answers for one locale, so a document whose - * English half has already gone is invisible to a search in English. + * base half has already gone is invisible to a search in that locale. * Both locales are searched and the documentIds pooled, which also * sweeps up orphans an earlier, less careful run left. */ @@ -221,7 +222,7 @@ export async function readExisting( ): Promise { return documents(strapi, uid).findFirst({ ...(find ? { filters: find } : {}), - locale: 'en', + locale: 'ar', ...draftFilter(strapi, uid), populate, }); diff --git a/apps/web/app/components/ui/LanguageSwitcher/locale-path.ts b/apps/web/app/components/ui/LanguageSwitcher/locale-path.ts index 8ee0ce4..765499a 100644 --- a/apps/web/app/components/ui/LanguageSwitcher/locale-path.ts +++ b/apps/web/app/components/ui/LanguageSwitcher/locale-path.ts @@ -16,7 +16,7 @@ export type Locale = (typeof LOCALES)[number]; export type Direction = "ltr" | "rtl"; -export const DEFAULT_LOCALE: Locale = "en"; +export const DEFAULT_LOCALE: Locale = "ar"; /** Locale -> the value that belongs in . */ const DIRECTION_BY_LOCALE: Record = { diff --git a/apps/web/app/i18n/t.ts b/apps/web/app/i18n/t.ts index 00fbc39..0c8df48 100644 --- a/apps/web/app/i18n/t.ts +++ b/apps/web/app/i18n/t.ts @@ -5,8 +5,8 @@ plus {placeholder} interpolation. No i18next, no ICU MessageFormat - the catalogues hold UI chrome only, page prose comes from Strapi. - English is the reference catalogue: `Messages` is derived from en.json, and - ar.json is typechecked against it, so a key added on one side and forgotten + Arabic is the reference catalogue: `Messages` is derived from ar.json, and + en.json is typechecked against it, so a key added on one side and forgotten on the other is a compile error rather than a blank label in production. ========================================================================= */ @@ -15,8 +15,8 @@ import ar from "./messages/ar.json"; import { DEFAULT_LOCALE, type Locale } from "./config"; -/** The shape of every catalogue. Derived from the English source of truth. */ -export type Messages = typeof en; +/** The shape of every catalogue. Derived from the Arabic source of truth. */ +export type Messages = typeof ar; /** Anything that may be interpolated into a message. */ export type TranslationParams = Readonly< @@ -44,13 +44,13 @@ export type Translator = ( params?: TranslationParams, ) => string; -/* The `Messages` annotation is the parity check: ar.json must supply every - key en.json declares, with a string in every leaf position. */ -const arabicMessages: Messages = ar; +/* The `Messages` annotation is the parity check: en.json must supply every + key ar.json declares, with a string in every leaf position. */ +const englishMessages: Messages = en; const CATALOGUES: Readonly> = { - en, - ar: arabicMessages, + en: englishMessages, + ar, }; /** The whole catalogue for a locale - handy for spreading into a component. */ @@ -90,7 +90,7 @@ export function interpolate( } /** - * Translate one key. Falls back to the English string when a locale is + * Translate one key. Falls back to the default-locale string when a locale is * missing it, and finally to the key itself so a page never renders "". */ export function translate( diff --git a/apps/web/app/lib/seo.ts b/apps/web/app/lib/seo.ts index 285690e..127da16 100644 --- a/apps/web/app/lib/seo.ts +++ b/apps/web/app/lib/seo.ts @@ -14,6 +14,7 @@ import type { MetaDescriptor } from "react-router"; import { + DEFAULT_LOCALE, INTL_LOCALE, OG_LOCALE, SUPPORTED_LOCALES, @@ -103,8 +104,8 @@ export interface LocaleAlternate { /** * The hreflang set for a page: one entry per locale plus `x-default`, which - * points at the English URL because English is the default locale that bare - * "/" negotiates towards. + * points at the default-locale URL because that is what a bare "/" resolves + * to when no locale can be negotiated. */ export function alternateUrls( siteUrl: string, @@ -121,9 +122,9 @@ export function alternateUrls( return [ ...alternates, { - locale: "en", + locale: DEFAULT_LOCALE, hrefLang: "x-default", - href: canonicalUrl(siteUrl, localizedPath(unprefixed, "en")), + href: canonicalUrl(siteUrl, localizedPath(unprefixed, DEFAULT_LOCALE)), }, ]; } diff --git a/apps/web/app/routes/_page-kit/accordion.tsx b/apps/web/app/routes/_page-kit/accordion.tsx index 32fa00e..13e9006 100644 --- a/apps/web/app/routes/_page-kit/accordion.tsx +++ b/apps/web/app/routes/_page-kit/accordion.tsx @@ -40,6 +40,8 @@ export interface AccordionProps { */ defaultOpenIndex?: number | null; className?: string; + /** Extra classes for each question row, e.g. a scroll-driven reveal. */ + itemClassName?: string; } /** @@ -75,7 +77,12 @@ function ToggleMark({ open }: { open: boolean }) { ); } -export function Accordion({ items, defaultOpenIndex = 0, className }: AccordionProps) { +export function Accordion({ + items, + defaultOpenIndex = 0, + className, + itemClassName, +}: AccordionProps) { const baseId = useId(); const initial = defaultOpenIndex === null ? null : (items[defaultOpenIndex]?.id ?? null); @@ -97,6 +104,7 @@ export function Accordion({ items, defaultOpenIndex = 0, className }: AccordionP "border-b border-[var(--border-default)]", "transition-colors duration-[var(--motion-duration-base)]", "hover:bg-[var(--surface-muted)]", + itemClassName, )} >