From cd91a703d30655ffed3db0e216537458db6eb325 Mon Sep 17 00:00:00 2001 From: miahsobuj Date: Tue, 29 Sep 2026 23:26:29 +0600 Subject: [PATCH 01/17] feat: scaffold Phase 5 public core and inquiry intake --- .github/workflows/cms.yml | 1 + .github/workflows/identity.yml | 2 + .github/workflows/public-core.yml | 74 ++++++++++++ AI_ASSISTANT.md | 2 + README.md | 2 + docs/DESIGN-SYSTEM.md | 4 +- docs/PAGES.md | 18 +-- docs/PHASE-05-CONTENT-DRAFT.md | 29 +++++ docs/PHASE-05-WORKLOG.md | 21 ++++ docs/PHASES.md | 4 +- docs/openapi/public.json | 47 ++++++++ playwright/public.spec.ts | 23 ++++ scripts/check_openapi.mjs | 3 +- scripts/public_ci.mjs | 64 +++++++++++ src/app/[locale]/[slug]/page.tsx | 34 ++++++ src/app/[locale]/layout.tsx | 8 +- src/app/[locale]/page.tsx | 36 +++--- src/app/[locale]/services/[slug]/page.tsx | 23 ++++ src/app/[locale]/services/page.tsx | 30 +++++ src/app/api/v1/cms/[...path]/route.ts | 5 +- src/app/api/v1/public/inquiries/route.ts | 86 ++++++++++++++ src/app/layout.tsx | 6 +- src/app/robots.ts | 15 +++ src/app/sitemap.ts | 25 +++++ src/components/cms/CmsWorkspace.tsx | 6 +- src/components/public/ContactDetails.tsx | 11 ++ src/components/public/ContactForm.tsx | 40 +++++++ src/components/public/PublicContent.tsx | 26 +++++ src/components/shell/LanguageSwitcher.tsx | 3 +- src/components/shell/SiteHeader.tsx | 8 +- src/db/migrations/0004_public_core/down.sql | 6 + src/db/migrations/0004_public_core/up.sql | 28 +++++ src/db/schema.ts | 14 +++ src/i18n/dictionaries/bn.json | 30 +++++ src/i18n/dictionaries/en.json | 30 +++++ src/lib/public-content.ts | 118 ++++++++++++++++++++ src/lib/public-routes.ts | 21 ++++ src/lib/public-seo.ts | 30 +++++ src/middleware.ts | 2 +- src/styles/tokens.css | 45 ++++++++ 40 files changed, 939 insertions(+), 41 deletions(-) create mode 100644 .github/workflows/public-core.yml create mode 100644 docs/PHASE-05-CONTENT-DRAFT.md create mode 100644 docs/PHASE-05-WORKLOG.md create mode 100644 docs/openapi/public.json create mode 100644 playwright/public.spec.ts create mode 100644 scripts/public_ci.mjs create mode 100644 src/app/[locale]/[slug]/page.tsx create mode 100644 src/app/[locale]/services/[slug]/page.tsx create mode 100644 src/app/[locale]/services/page.tsx create mode 100644 src/app/api/v1/public/inquiries/route.ts create mode 100644 src/app/robots.ts create mode 100644 src/app/sitemap.ts create mode 100644 src/components/public/ContactDetails.tsx create mode 100644 src/components/public/ContactForm.tsx create mode 100644 src/components/public/PublicContent.tsx create mode 100644 src/db/migrations/0004_public_core/down.sql create mode 100644 src/db/migrations/0004_public_core/up.sql create mode 100644 src/lib/public-content.ts create mode 100644 src/lib/public-routes.ts create mode 100644 src/lib/public-seo.ts diff --git a/.github/workflows/cms.yml b/.github/workflows/cms.yml index 8c8e8bf..53eb20b 100644 --- a/.github/workflows/cms.yml +++ b/.github/workflows/cms.yml @@ -47,6 +47,7 @@ jobs: exit 1 fi psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f src/db/migrations/0003_cms/up.sql + psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f src/db/migrations/0004_public_core/up.sql - run: npm run typecheck - run: npm run build - name: Start synthetic CMS web process diff --git a/.github/workflows/identity.yml b/.github/workflows/identity.yml index b74f202..00dbd97 100644 --- a/.github/workflows/identity.yml +++ b/.github/workflows/identity.yml @@ -50,6 +50,8 @@ jobs: exit 1 fi psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f src/db/migrations/0002_identity/up.sql + psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f src/db/migrations/0003_cms/up.sql + psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f src/db/migrations/0004_public_core/up.sql - run: npm run typecheck - run: npm run build - name: Start synthetic web process diff --git a/.github/workflows/public-core.yml b/.github/workflows/public-core.yml new file mode 100644 index 0000000..b092111 --- /dev/null +++ b/.github/workflows/public-core.yml @@ -0,0 +1,74 @@ +name: Public Core +on: + pull_request: + push: + branches: [main] +permissions: + contents: read +jobs: + public-core: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_USER: onskillit + POSTGRES_PASSWORD: onskillit + POSTGRES_DB: onskillit + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + DATABASE_URL: postgres://onskillit:onskillit@localhost:5432/onskillit + PUBLIC_SITE_URL: https://example.test + PUBLIC_INDEXING_ENABLED: 'true' + INQUIRY_RATE_SECRET: synthetic-ci-only-secret-with-thirty-two-characters + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '24' + cache: npm + - run: npm ci + - run: npm run check:api + - name: Apply reviewed migrations + run: | + psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f src/db/migrations/0001_foundation_probe/up.sql + psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f src/db/migrations/0002_identity/up.sql + psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f src/db/migrations/0003_cms/up.sql + psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f src/db/migrations/0004_public_core/up.sql + - name: Verify Phase 5 migration reversal and reapply + run: | + psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f src/db/migrations/0004_public_core/down.sql + if psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -c 'select 1 from public_inquiry limit 0' > /dev/null 2>&1; then + echo 'public_inquiry survived down migration' >&2 + exit 1 + fi + psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f src/db/migrations/0004_public_core/up.sql + - run: npm run typecheck + - run: npm run build + - name: Start synthetic public web process + run: | + npm start > /tmp/onskillit-public.log 2>&1 & + for attempt in $(seq 1 30); do + if curl --fail --silent http://localhost:3000/api/v1/health > /dev/null; then break; fi + sleep 1 + done + - name: Exercise publication and inquiry boundaries + run: node scripts/public_ci.mjs + - name: Install Playwright Chromium + run: npx playwright install --with-deps chromium + - name: Exercise bilingual public pages in browser + run: npx playwright test playwright/public.spec.ts + - name: Upload synthetic public page previews + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: public-page-previews + path: test-results/**/*.png + if-no-files-found: ignore + retention-days: 14 diff --git a/AI_ASSISTANT.md b/AI_ASSISTANT.md index 230406d..c7b55ae 100644 --- a/AI_ASSISTANT.md +++ b/AI_ASSISTANT.md @@ -1,5 +1,7 @@ # OnSkillIT agent working agreement +Current work: PHASE-05 public core is IN PROGRESS on `phase-05/public-core`; PUBLIC CORE VERIFIED is open. See `docs/PHASE-05-WORKLOG.md` and the owner's classified content brief. Production publication remains unapproved. + Status: PHASE-00 through PHASE-04 gates passed; PHASE-04 CMS and publishing core is CI-verified with PUBLISHING CORE VERIFIED passed on synthetic GitHub evidence. The founding partner approved the PHASE-03 plan on 2026-09-29 with mobile-only recovery deferred and disabled, reviewed/audited first-owner bootstrap, and verified-email-only reset. No production deployment, migration or change to `onskillit.com` occurred. The public GitHub repository is the project source of truth; agent chat history is not. At session start: read this file, `README.md`, `docs/PHASES.md`, `docs/PAGES.md`, `docs/TRACEABILITY.md`, `docs/READINESS.md`, `docs/README.md`, and only the task-relevant linked documents. Then discover canonical `soobujmiah/skb` at `ASSISTANT_CONTEXT.md` and `profile/assistant-guidance.md`, followed by task-relevant standards. Verify the SKB Git remote when using a local checkout. Compare dated claims with live repository and source evidence. Follow `standards/policy-resolution.md` for authorization. diff --git a/README.md b/README.md index 0491410..975d62d 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ **Machine-generated operational state:** see [`.repo/STATUS.md`](.repo/STATUS.md) (generated by `tools/repo_knowledge`). For cross-project SKB registry state, see [`onskillit-platform.json`](https://github.com/soobujmiah/skb/blob/main/projects/registry/onskillit-platform.json) in the parent SKB repository (`soobujmiah/skb`). # OnSkillIT Digital Platform — documentation workspace +Current work: PHASE-05 public core is IN PROGRESS on `phase-05/public-core` with PUBLIC CORE VERIFIED open; see `docs/PHASE-05-WORKLOG.md`. + Status: PHASE-00 through PHASE-04 gates passed. PHASE-04 CMS and publishing core passed PUBLISHING CORE VERIFIED on synthetic GitHub evidence. See `docs/PHASE-04-WORKLOG.md` and `docs/CMS-PUBLISHING.md`. No production deployment or data migration has occurred. The public GitHub repository is `soobujmiah/onskillit-platform`. ![OnSkillIT Digital Platform Phase 0 architecture blueprint](docs/assets/onskillit-platform-architecture.svg) diff --git a/docs/DESIGN-SYSTEM.md b/docs/DESIGN-SYSTEM.md index 80015b6..5ec3722 100644 --- a/docs/DESIGN-SYSTEM.md +++ b/docs/DESIGN-SYSTEM.md @@ -12,7 +12,7 @@ Portfolio source (`soobujmiah.github.io/DESIGN_SYSTEM.md`) uses near-black `#050 Separate foundation tokens (color scales, type, spacing, radii, elevation, motion) from semantic tokens (`surface.canvas`, `surface.card`, `text.primary`, `text.secondary`, `border.default`, `action.primary`, `status.success/warning/error/info`, `focus.ring`). Semantic tokens have explicit dark and light values. Dark canvas/primary ink/green anchor derive from portfolio grading. Light theme is warm neutral with green accents, not white-on-green inversion. Accent overuse and low-opacity text must be rejected by contrast review. Create tokens for hover, pressed, disabled, selected, focus, loading and invalid states. Document actual contrast ratios and approvals before code. -Typography: Bangla needs Noto Sans Bengali or a reviewed equivalent with dedicated metrics; Latin UI can evaluate Inter or a distinct face. Use a limited display style for headings, a comfortable text face for learning and a restrained mono treatment for technical labels. Bangla line-height/letter-spacing must be tested separately; never apply wide Latin tracking to Bangla. Digit localization follows language. Use a type scale with body ≥16 CSS px in learning/forms and fluid headings; sizes below are the implementation baseline and must pass device review. +Typography: Bangla uses Hind Siliguri per my 2026-09-29 direction, with dedicated metrics; Latin UI uses Inter for the current shell. Use a limited display style for headings, a comfortable text face for learning and a restrained mono treatment for technical labels. Bangla line-height/letter-spacing must be tested separately; never apply wide Latin tracking to Bangla. Digit localization follows language. Use a type scale with body ≥16 CSS px in learning/forms and fluid headings; sizes below are the implementation baseline and must pass device review. Spacing and layout: 4px base rhythm with semantic spacing steps; constrained prose width; responsive grid for catalog cards; content-first single column on narrow screens. Admin tables may transform to labeled cards or bounded scrollers that preserve row identity/actions. Keep global navigation, search, active course and account context predictable. Sticky UI must not obscure form controls or accessibility focus. @@ -71,7 +71,7 @@ Tokens are semantic and theme switched; components must not embed brand hex valu **Foundation scale:** spacing `0, 4, 8, 12, 16, 24, 32, 48, 64, 96` CSS px; semantic gaps `xs=4`, `sm=8`, `md=16`, `lg=24`, `xl=32`, `section=64` (48 narrow). Grid: 12 columns wide, 8 tablet, 4 mobile; max content width 1200px, prose width 70ch; gutters 16px mobile, 24px tablet, 32px desktop. Breakpoints are content triggers at 640, 768, 1024 and 1440 CSS px, not device labels. Radius `field=8`, `card=12`, `overlay=16`, `pill=9999`; borders 1px normal, 2px focus/selected. Elevation uses border and subtle shadow: level 0 none, level 1 `0 2px 8px rgba(0,0,0,.08)`, level 2 `0 8px 24px rgba(0,0,0,.16)`; dark mode reduces shadow reliance. -**Type scale:** body 16px/1.6 English and 17px/1.7 Bangla, small 14px/1.5, lead 20px/1.5, headings 24/30/40/56px with fluid interpolation for the top two sizes. English UI: self-hosted Inter if license verified; Bangla: self-hosted Noto Sans Bengali if license verified. Both have system sans fallbacks. Bangla receives no forced uppercase or letter spacing; localized digits follow locale except immutable identifiers. Limit long learning prose to 70ch-equivalent and use comfortable media captions. +**Type scale:** body 16px/1.6 English and 17px/1.7 Bangla, small 14px/1.5, lead 20px/1.5, headings 24/30/40/56px with fluid interpolation for the top two sizes. English UI: Inter via `next/font/google`; Bangla: Hind Siliguri via `next/font/google` per my direction. Both have system sans fallbacks. Bangla receives no forced uppercase or letter spacing; localized digits follow locale except immutable identifiers. Limit long learning prose to 70ch-equivalent and use comfortable media captions. **Components:** button variants primary/secondary/quiet/destructive; inputs with persistent label, help and error text; cards with clear title/metadata/action; data tables with sortable labeled headers and mobile row identity; navigation with active and breadcrumb states; dialogs and drawers with focus containment/return; tabs/accordions with keyboard operation; badges never color-only; alerts persistent for consequential errors; toasts only for noncritical confirmations; pagination with current/total status. All have idle, hover, focus-visible, pressed/selected, disabled, busy and relevant invalid/success states. Empty/loading/error/success patterns use the UX contract in UX-CONCEPT-REVIEW. diff --git a/docs/PAGES.md b/docs/PAGES.md index e5e059e..54fc1ae 100644 --- a/docs/PAGES.md +++ b/docs/PAGES.md @@ -10,15 +10,15 @@ A page is a route-level responsibility with its own loading, access, error and n | Page ID | Page name | Route | Type/audience | Purpose | Parent | Module | Auth | Permission | SEO | Static/dynamic | Template | Priority | Implementation phase | Status | Dependencies | |---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| -| PAGE-HOME | Home | / | Public | Introduce verified offers | root | CMS | none | public | index | static | PAGE-HOME | P1 | PHASE-05 | NOT STARTED | CMS, Service | -| PAGE-ABOUT | About | /about | Public | Explain verified organization | root | CMS | none | public | index | static | PAGE-ABOUT | P1 | PHASE-05 | NOT STARTED | Page | -| PAGE-SERVICES | Services | /services | Public | Browse services | root | Catalog | none | public | index | listing | PAGE-SERVICES | P0 | PHASE-05 | NOT STARTED | Service, Category | -| PAGE-SERVICE-DETAIL | Service detail | /services/{slug} | Public | Explain one service | PAGE-SERVICES | Catalog | none | public | index | dynamic | PAGE-SERVICE-DETAIL | P0 | PHASE-05 | NOT STARTED | Service, Media, SEO | -| PAGE-CONTACT | Contact | /contact | Public | Submit inquiry | root | CRM | none | public | index | static | PAGE-CONTACT | P1 | PHASE-05 | NOT STARTED | Inquiry, rate limit | -| PAGE-FAQ | FAQ | /faq | Public | Answer approved questions | root | CMS | none | public | index | static | PAGE-FAQ | P1 | PHASE-05 | NOT STARTED | FAQ, SEO | -| PAGE-PRIVACY | Privacy | /privacy | Public | Publish approved notice | footer | CMS | none | public | index | static | PAGE-PRIVACY | P1 | PHASE-05 | NOT STARTED | Approved legal text | -| PAGE-TERMS | Terms | /terms | Public | Publish approved terms | footer | CMS | none | public | index | static | PAGE-TERMS | P1 | PHASE-05 | NOT STARTED | Approved legal text | -| PAGE-ACCESSIBILITY | Accessibility | /accessibility | Public | Explain access support | footer | CMS | none | public | index | static | PAGE-ACCESSIBILITY | P1 | PHASE-05 | NOT STARTED | Approved support policy | +| PAGE-HOME | Home | / | Public | Introduce verified offers | root | CMS | none | public | index | static | PAGE-HOME | P1 | PHASE-05 | IN PROGRESS | CMS, Service | +| PAGE-ABOUT | About | /about | Public | Explain verified organization | root | CMS | none | public | index | static | PAGE-ABOUT | P1 | PHASE-05 | IN PROGRESS | Page | +| PAGE-SERVICES | Services | /services | Public | Browse services | root | Catalog | none | public | index | listing | PAGE-SERVICES | P0 | PHASE-05 | IN PROGRESS | Service, Category | +| PAGE-SERVICE-DETAIL | Service detail | /services/{slug} | Public | Explain one service | PAGE-SERVICES | Catalog | none | public | index | dynamic | PAGE-SERVICE-DETAIL | P0 | PHASE-05 | IN PROGRESS | Service, Media, SEO | +| PAGE-CONTACT | Contact | /contact | Public | Submit inquiry | root | CRM | none | public | index | static | PAGE-CONTACT | P1 | PHASE-05 | IN PROGRESS | Inquiry, rate limit | +| PAGE-FAQ | FAQ | /faq | Public | Answer approved questions | root | CMS | none | public | index | static | PAGE-FAQ | P1 | PHASE-05 | IN PROGRESS | FAQ, SEO | +| PAGE-PRIVACY | Privacy | /privacy | Public | Publish approved notice | footer | CMS | none | public | index | static | PAGE-PRIVACY | P1 | PHASE-05 | IN PROGRESS | Approved legal text | +| PAGE-TERMS | Terms | /terms | Public | Publish approved terms | footer | CMS | none | public | index | static | PAGE-TERMS | P1 | PHASE-05 | IN PROGRESS | Approved legal text | +| PAGE-ACCESSIBILITY | Accessibility | /accessibility | Public | Explain access support | footer | CMS | none | public | index | static | PAGE-ACCESSIBILITY | P1 | PHASE-05 | IN PROGRESS | Approved support policy | | PAGE-COURSES | Courses | /courses | Public | Browse real courses | root | LMS | none | public | index | listing | PAGE-COURSES | P0 | PHASE-06 | NOT STARTED | Course, Instructor | | PAGE-COURSE-DETAIL | Course detail | /courses/{slug} | Public | Choose a course | PAGE-COURSES | LMS | none | public | index | dynamic | PAGE-COURSE-DETAIL | P0 | PHASE-06 | NOT STARTED | Course, Edition, Media, SEO | | PAGE-PROGRAMS | Programs | /programs | Public | Browse training programs | root | TMS | none | public | index | listing | PAGE-PROGRAMS | P0 | PHASE-07 | NOT STARTED | Program | diff --git a/docs/PHASE-05-CONTENT-DRAFT.md b/docs/PHASE-05-CONTENT-DRAFT.md new file mode 100644 index 0000000..9662d31 --- /dev/null +++ b/docs/PHASE-05-CONTENT-DRAFT.md @@ -0,0 +1,29 @@ +# Phase 5 content intake — draft, not approved for publication + +Source: my 2026-09-29 content brief in the current work session. This is an editorial intake record, not proof that a product, customer, payment integration, legal entity relationship, policy, price, or service level is operational. I explicitly marked the brief **owner review before publish**. No CMS publication or production seed follows from this document. + +## Confirmed public contact details + +I explicitly confirmed these values for exact public display: + +| Field | Confirmed value | +|---|---| +| Email | `onskillitbd@gmail.com` | +| Phone / WhatsApp | `+8801617301184`; `https://wa.me/8801617301184` | +| Address | `40/1 Kalicharan Shaha Road, Gandaria, Dhaka-1204, Bangladesh` | +| Facebook | `https://www.facebook.com/onskillit` | +| YouTube | `https://www.youtube.com/@onskillit` | + +Office hours were marked `[CONFIRM]` and must remain unpublished until approved. + +## Business and service copy awaiting owner review + +The brief proposes the tagline “Software and skills for growing Bangladeshi businesses” / “বাংলাদেশের ব্যবসার জন্য সফটওয়্যার ও দক্ষতা”, a software and Bangla IT training focus, and five service groups: hospital/clinic management, hotel management, ecommerce stores, custom website/web application development, and IT training. It also proposes audience sectors, delivery features, payment methods, named projects, course counts, a consultation flow, and FAQ answers. **None of these is approved for public CMS publication by this intake alone.** The claim that OnSkillIT is a brand of another named organization needs separate legal/brand authority evidence before public use. Do not infer accreditation, product availability, live payment support, ownership, client references, or capacity from the draft. + +Prices, office hours, a one-working-day response promise, subscription cancellation, course refunds, hotel SaaS status, and ecommerce SaaS status were explicitly tagged `[CONFIRM]`; omit them from any publishable copy until I resolve each one. I prohibited testimonials, client logos, user counts and statistics. I requested **Hind Siliguri** for Bangla UI text. + +I said there is **no approved Privacy, Terms, or Accessibility policy**. Any later drafts require legal/operational review. The inquiry form collects name, email, optional phone and message only after a published privacy notice is available; it retains the notice revision as consent evidence. No payment or card-data policy claim is published from this draft. + +## Editorial next step + +Prepare bilingual CMS drafts with source references and a factual checklist. Before publication, obtain partner approval for each service's actual scope and availability, all business/ownership/product/payment claims, FAQ commitments, and complete policy text. Then use the Phase 4 independent review/publish workflow. Contact details above may appear on the noindex contact route before the wider copy is approved. diff --git a/docs/PHASE-05-WORKLOG.md b/docs/PHASE-05-WORKLOG.md new file mode 100644 index 0000000..24afac2 --- /dev/null +++ b/docs/PHASE-05-WORKLOG.md @@ -0,0 +1,21 @@ +# PHASE-05 work log — public business site + +Current phase: **IN PROGRESS**. Gate: **PUBLIC CORE VERIFIED OPEN**. Source work starts from `main` at `74a91a3` on branch `phase-05/public-core`. No Phase 5 page, service, inquiry, or legal copy is published on the production domain by creating this branch. + +| Field | Value | +|---|---| +| Current phase / gate | PHASE-05 / PUBLIC CORE VERIFIED (open) | +| Task | Build the bilingual public core, published-only service catalog, inquiry intake, and SEO baseline | +| Modules | Public frontend, CMS delivery, Service, Inquiry, SEO | +| Page IDs | PAGE-HOME, PAGE-ABOUT, PAGE-SERVICES, PAGE-SERVICE-DETAIL, PAGE-CONTACT, PAGE-FAQ, PAGE-PRIVACY, PAGE-TERMS, PAGE-ACCESSIBILITY | +| Requirements | R05, R09, R13, R14, R16, R19, R20; R17 for CI | +| Dependencies | PHASE-02 EXPERIENCE FOUNDATION VERIFIED and PHASE-04 PUBLISHING CORE VERIFIED passed | +| Acceptance | Published-only bilingual rendering, no invented offerings or legal claims, two-theme responsive pages, semantic navigation, abuse-controlled inquiry, published-only metadata and sitemap, GitHub CI evidence | + +## Publication inputs + +I supplied exact **confirmed contact details** and a broader **draft content brief** on 2026-09-29; its classification and review needs are recorded in [PHASE-05-CONTENT-DRAFT](PHASE-05-CONTENT-DRAFT.md). The brief did not approve business/service claims or any policy for publication. Proposed offerings in `OFFERINGS.md` are not public facts. Public routes show a clear unpublished state and stay `noindex` until their CMS records are approved and published. The inquiry form is disabled until a privacy notice is published. Production indexing and domain cutover remain separate owner gates. + +## Source and verification + +Work in progress. Record exact migration, API, page paths, CI runs, review result, commit and gate decision here before merge. All builds and tests run in GitHub per the founding partner's instruction; local build/test results are not phase evidence. diff --git a/docs/PHASES.md b/docs/PHASES.md index cce14db..1e1cc82 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -1,6 +1,6 @@ # Canonical phase architecture -Status: **canonical baseline accepted by founding-partner instruction, 2026-09-24**. This is the sole phase register. PHASE-00 through PHASE-04 are **COMPLETE**, with their respective gates passed after GitHub evidence and founding-partner instructions. PHASE-03's **IDENTITY VERIFIED** decision followed the 2026-09-29 instruction to complete PHASE-03 and then PHASE-04, the passing [gate-closure CI](https://github.com/soobujmiah/onskillit-platform/actions/runs/36562919856), security procedure review and synthetic page inspection; see [PHASE-03-WORKLOG](PHASE-03-WORKLOG.md). PHASE-04 is **COMPLETE; PUBLISHING CORE VERIFIED passed** after [final CMS CI](https://github.com/soobujmiah/onskillit-platform/actions/runs/36570696266) on `53954c5` and the other passing PR checks. See [PHASE-04-WORKLOG](PHASE-04-WORKLOG.md). PHASE-05 through PHASE-15 are **NOT STARTED**. Phase numbers name milestones, not permission to skip dependencies. V1 runs through PHASE-14; PHASE-15 is controlled post-launch evolution. Page IDs and their single owning phase are authoritative in [PAGES.md](PAGES.md). +Status: **canonical baseline accepted by founding-partner instruction, 2026-09-24**. This is the sole phase register. PHASE-00 through PHASE-04 are **COMPLETE**, with their respective gates passed after GitHub evidence and founding-partner instructions. PHASE-03's **IDENTITY VERIFIED** decision followed the 2026-09-29 instruction to complete PHASE-03 and then PHASE-04, the passing [gate-closure CI](https://github.com/soobujmiah/onskillit-platform/actions/runs/36562919856), security procedure review and synthetic page inspection; see [PHASE-03-WORKLOG](PHASE-03-WORKLOG.md). PHASE-04 is **COMPLETE; PUBLISHING CORE VERIFIED passed** after [final CMS CI](https://github.com/soobujmiah/onskillit-platform/actions/runs/36570696266) on `53954c5` and the other passing PR checks. See [PHASE-04-WORKLOG](PHASE-04-WORKLOG.md). PHASE-05 is **IN PROGRESS** with PUBLIC CORE VERIFIED open; PHASE-06 through PHASE-15 are **NOT STARTED**. Phase numbers name milestones, not permission to skip dependencies. V1 runs through PHASE-14; PHASE-15 is controlled post-launch evolution. Page IDs and their single owning phase are authoritative in [PAGES.md](PAGES.md). ## Master register @@ -11,7 +11,7 @@ Status: **canonical baseline accepted by founding-partner instruction, 2026-09-2 | PHASE-02 | Design and localization shell | Establish shared accessible bilingual and themed UI | Tokens, shells, language and theme infrastructure | 01 | EXPERIENCE FOUNDATION VERIFIED | COMPLETE | | PHASE-03 | Identity, RBAC and audit | Secure all private operations | Accounts, sessions, scoped roles, audit | 01,02 | IDENTITY VERIFIED | COMPLETE | | PHASE-04 | CMS and publishing core | Enable controlled content operations | Typed pages, revisions, media, navigation, SEO fields | 03 | PUBLISHING CORE VERIFIED | COMPLETE | -| PHASE-05 | Public business site | Publish verified business and service content | Public core, services, inquiries, legal and SEO baseline | 02,04 | PUBLIC CORE VERIFIED | NOT STARTED | +| PHASE-05 | Public business site | Publish verified business and service content | Public core, services, inquiries, legal and SEO baseline | 02,04 | PUBLIC CORE VERIFIED | IN PROGRESS | | PHASE-06 | LMS and course catalog | Deliver real learning workflows | Courses, editions, lessons, assessments, progress | 03,04 | LEARNING VERIFIED | NOT STARTED | | PHASE-07 | TMS and training catalog | Manage scheduled delivery | Programs, batches, sessions, attendance, results | 06 | TRAINING VERIFIED | NOT STARTED | | PHASE-08 | Enrollment and finance | Connect learning/training to payment | Enrollment, capacity, invoices, provider adapters, refunds | 06,07,03 | COMMERCE VERIFIED | NOT STARTED | diff --git a/docs/openapi/public.json b/docs/openapi/public.json new file mode 100644 index 0000000..c584214 --- /dev/null +++ b/docs/openapi/public.json @@ -0,0 +1,47 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "OnSkillIT public core API", + "version": "0.1.0", + "description": "PHASE-05 anonymous inquiry intake. Public pages and service catalog render from published CMS records on the server." + }, + "servers": [{ "url": "/api/v1/public" }], + "paths": { + "/inquiries": { + "post": { + "summary": "Submit an inquiry after consenting to the current published privacy notice", + "operationId": "submitInquiry", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["name", "email", "message", "locale", "consent", "consentVersion", "sourcePath"], + "properties": { + "name": { "type": "string", "minLength": 2, "maxLength": 120 }, + "email": { "type": "string", "format": "email", "maxLength": 254 }, + "phone": { "type": "string", "maxLength": 25 }, + "message": { "type": "string", "minLength": 10, "maxLength": 4000 }, + "locale": { "type": "string", "enum": ["en", "bn"] }, + "consent": { "type": "boolean", "const": true }, + "consentVersion": { "type": "string", "format": "uuid" }, + "sourcePath": { "type": "string", "enum": ["/en/contact", "/bn/contact"] }, + "website": { "type": "string", "description": "Honeypot; must be empty" } + } + } + } + } + }, + "responses": { + "202": { "description": "Received without disclosing internal record identifiers" }, + "400": { "description": "Invalid request or consent" }, + "403": { "description": "Origin mismatch" }, + "429": { "description": "Rate limited" }, + "503": { "description": "Intake or current privacy notice unavailable" } + } + } + } + } +} diff --git a/playwright/public.spec.ts b/playwright/public.spec.ts new file mode 100644 index 0000000..8a1593b --- /dev/null +++ b/playwright/public.spec.ts @@ -0,0 +1,23 @@ +import { test, expect } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; + +for (const locale of ["en", "bn"] as const) { + for (const width of [320, 1440]) { + test(`${locale} public core at ${width}px`, async ({ page }, testInfo) => { + await page.setViewportSize({ width, height: 900 }); + if (locale === "bn") await page.context().addCookies([{ name: "theme", value: "dark", url: "http://localhost:3000" }]); + for (const path of ["about", "services", "services/sample-service" + (locale === "bn" ? "-bn" : ""), "contact", "privacy"]) { + await page.goto(`/${locale}/${path}`); + await expect(page.locator("html")).toHaveAttribute("lang", locale); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(0); + const results = await new AxeBuilder({ page }).analyze(); + expect(results.violations).toEqual([]); + await page.screenshot({ path: testInfo.outputPath(`${path.replaceAll("/", "-")}.png`), fullPage: true }); + } + await page.goto(`/${locale}/contact`); + await expect(page.getByRole("link", { name: "onskillitbd@gmail.com" })).toHaveAttribute("href", "mailto:onskillitbd@gmail.com"); + await expect(page.getByRole("link", { name: "+8801617301184" })).toHaveAttribute("href", "tel:+8801617301184"); + }); + } +} diff --git a/scripts/check_openapi.mjs b/scripts/check_openapi.mjs index 583cd51..b6dbb83 100644 --- a/scripts/check_openapi.mjs +++ b/scripts/check_openapi.mjs @@ -2,4 +2,5 @@ import SwaggerParser from "@apidevtools/swagger-parser"; await SwaggerParser.validate("docs/openapi/identity.json"); await SwaggerParser.validate("docs/openapi/cms.json"); -console.log("Identity and CMS OpenAPI contracts validated"); +await SwaggerParser.validate("docs/openapi/public.json"); +console.log("Identity, CMS and public OpenAPI contracts validated"); diff --git a/scripts/public_ci.mjs b/scripts/public_ci.mjs new file mode 100644 index 0000000..1221f0c --- /dev/null +++ b/scripts/public_ci.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import postgres from "postgres"; + +const db = postgres(process.env.DATABASE_URL, { max: 1 }); +const base = "http://localhost:3000"; +const fixture = (slug, locale) => locale === "en" ? { + title: "Synthetic public title", slug, description: "Synthetic approved page description", + seoTitle: "Synthetic search title", seoDescription: "Synthetic search description", + sections: [{ type: "text", heading: "Sample section", body: "Synthetic CI content only", source: "ci-fixture" }], +} : { + title: "কৃত্রিম পাতার শিরোনাম", slug, description: "শুধু পরীক্ষার জন্য কৃত্রিম বিবরণ", + seoTitle: "কৃত্রিম অনুসন্ধান শিরোনাম", seoDescription: "কৃত্রিম অনুসন্ধান বিবরণ", + sections: [{ type: "text", heading: "নমুনা অংশ", body: "শুধু পরীক্ষার বিষয়বস্তু", source: "ci-fixture" }], +}; +async function page(key, kind, author, published = false, category = null) { + const [record] = await db`INSERT INTO cms_page(page_key,kind,created_by) VALUES (${key},${kind},${author}) RETURNING id`; + const [revision] = await db`INSERT INTO cms_revision(page_id,revision_no,en,bn,created_by) + VALUES (${record.id},1,${db.json(fixture(key === "service-sample" ? "sample-service" : key, "en"))}, + ${db.json(fixture(key === "service-sample" ? "sample-service-bn" : key, "bn"))},${author}) RETURNING id`; + if (category) await db`INSERT INTO catalog_service(page_id,category) VALUES (${record.id},${category})`; + if (published) await db`UPDATE cms_page SET state='published',published_revision_id=${revision.id} WHERE id=${record.id}`; + return { id: record.id, revisionId: revision.id }; +} +const get = (path) => fetch(`${base}${path}`, { cache: "no-store" }); +const inquiry = (body) => fetch(`${base}/api/v1/public/inquiries`, { + method: "POST", headers: { "content-type": "application/json", Origin: base }, body: JSON.stringify(body), +}); + +try { + const [author] = await db`INSERT INTO identity_user DEFAULT VALUES RETURNING id`; + const unpublished = await get("/en"); + assert.equal(unpublished.status, 200); + assert.match(await unpublished.text(), /Approved information is being prepared/); + assert.equal((await get("/en/services/sample-service")).status, 404); + assert.match(await (await get("/robots.txt")).text(), /Disallow: \/$/m); + + await page("home", "landing", author.id, false); + await page("about", "page", author.id, true); + await page("contact", "page", author.id, true); + await page("services", "page", author.id, true); + const service = await page("service-sample", "service", author.id, true, "synthetic-category"); + const invalid = { name: "Synthetic Sender", email: "sender@example.test", message: "Synthetic inquiry message", locale: "en", + consent: true, consentVersion: "00000000-0000-4000-8000-000000000000", sourcePath: "/en/contact", website: "" }; + assert.equal((await inquiry(invalid)).status, 503); + const privacy = await page("privacy", "page", author.id, true); + const valid = { ...invalid, consentVersion: privacy.revisionId }; + assert.equal((await inquiry({ ...valid, consent: false })).status, 400); + assert.equal((await inquiry({ ...valid, website: "bot.example" })).status, 400); + assert.equal((await inquiry(valid)).status, 202); + assert.equal((await db`SELECT count(*)::int AS count FROM public_inquiry`)[0].count, 1); + assert.equal((await get("/en/services/sample-service")).status, 200); + assert.equal((await get("/bn/services/sample-service-bn")).status, 200); + assert.equal((await get("/bn/services/sample-service")).status, 404); + assert.doesNotMatch(await (await get("/sitemap.xml")).text(), /sample-service/); + await db`INSERT INTO cms_setting(key,value,updated_by) VALUES ('robots_enabled','true',${author.id})`; + const sitemap = await (await get("/sitemap.xml")).text(); + assert.match(sitemap, /sample-service/); + assert.doesNotMatch(sitemap, /\/en\/?<\/loc>/); + assert.match(await (await get("/en/about")).text(), /Synthetic public title/); + assert.equal((await db`SELECT category FROM catalog_service WHERE page_id=${service.id}`)[0].category, "synthetic-category"); + for (let i = 0; i < 4; i++) assert.equal((await inquiry(valid)).status, 202); + assert.equal((await inquiry(valid)).status, 429); + console.log("Public core integration passed: draft isolation, paired service routes, privacy consent, inquiry rate limit and sitemap"); +} finally { await db.end(); } diff --git a/src/app/[locale]/[slug]/page.tsx b/src/app/[locale]/[slug]/page.tsx new file mode 100644 index 0000000..e3c6781 --- /dev/null +++ b/src/app/[locale]/[slug]/page.tsx @@ -0,0 +1,34 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { PublicContent, UnpublishedPage } from "@/components/public/PublicContent"; +import { getDictionary } from "@/i18n/get-dictionary"; +import { isLocale } from "@/i18n/locales"; +import { publishedPage } from "@/lib/public-content"; +import { publicMetadata } from "@/lib/public-seo"; +import { ContactForm } from "@/components/public/ContactForm"; +import { ContactDetails } from "@/components/public/ContactDetails"; + +const keys = ["about", "contact", "faq", "privacy", "terms", "accessibility"] as const; +type Key = typeof keys[number]; +function isKey(value: string): value is Key { return keys.includes(value as Key); } +export const dynamic = "force-dynamic"; + +export async function generateMetadata({ params }: { params: Promise<{ locale: string; slug: string }> }): Promise { + const { locale, slug } = await params; + if (!isLocale(locale) || !isKey(slug)) return { robots: { index: false } }; + return publicMetadata(await publishedPage(slug, locale), locale, `/${locale}/${slug}`); +} + +export default async function PublicStaticPage({ params }: { params: Promise<{ locale: string; slug: string }> }) { + const { locale, slug } = await params; + if (!isLocale(locale) || !isKey(slug)) notFound(); + const [page, privacy] = await Promise.all([publishedPage(slug, locale), slug === "contact" ? publishedPage("privacy", locale) : Promise.resolve(null)]); + const t = getDictionary(locale).public; + return <> + {page ? : } + {slug === "contact" && } + {slug === "contact" && page && (privacy && (process.env.INQUIRY_RATE_SECRET?.length ?? 0) >= 32 + ? + :

{t.unavailable}

)} + ; +} diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 35e4836..93d6d19 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -5,6 +5,8 @@ import { getDictionary } from "@/i18n/get-dictionary"; import { SkipLink } from "@/components/shell/SkipLink"; import { SiteHeader } from "@/components/shell/SiteHeader"; import { HtmlLangSync } from "@/components/shell/HtmlLangSync"; +import { publishedNavigation } from "@/lib/public-content"; +import Link from "next/link"; export default async function LocaleLayout({ children, @@ -21,15 +23,19 @@ export default async function LocaleLayout({ const cookieStore = await cookies(); const themeCookie = cookieStore.get("theme")?.value; const initialTheme: "light" | "dark" = themeCookie === "dark" ? "dark" : "light"; + const navigation = await publishedNavigation(locale); return ( <> - +
{children}
+
{dictionary.brand}
); } diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx index a0a628e..d4dc56e 100644 --- a/src/app/[locale]/page.tsx +++ b/src/app/[locale]/page.tsx @@ -1,21 +1,23 @@ -import { getDictionary } from "@/i18n/get-dictionary"; -import { isLocale, type Locale } from "@/i18n/locales"; +import type { Metadata } from "next"; import { notFound } from "next/navigation"; +import { getDictionary } from "@/i18n/get-dictionary"; +import { isLocale } from "@/i18n/locales"; +import { publishedPage } from "@/lib/public-content"; +import { publicMetadata } from "@/lib/public-seo"; +import { PublicContent, UnpublishedPage } from "@/components/public/PublicContent"; -export default async function LocaleHomePage({ params }: { params: Promise<{ locale: string }> }) { - const { locale: rawLocale } = await params; - if (!isLocale(rawLocale)) notFound(); - const locale: Locale = rawLocale; - const dictionary = getDictionary(locale); +export const dynamic = "force-dynamic"; - return ( -
-

- {dictionary.placeholder.heading} -

-

- {dictionary.placeholder.body} -

-
- ); +export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise { + const { locale } = await params; + if (!isLocale(locale)) return { robots: { index: false } }; + return publicMetadata(await publishedPage("home", locale), locale, `/${locale}`); +} + +export default async function LocaleHomePage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + if (!isLocale(locale)) notFound(); + const page = await publishedPage("home", locale); + const t = getDictionary(locale).public; + return page ? : ; } diff --git a/src/app/[locale]/services/[slug]/page.tsx b/src/app/[locale]/services/[slug]/page.tsx new file mode 100644 index 0000000..91b92a7 --- /dev/null +++ b/src/app/[locale]/services/[slug]/page.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { PublicContent } from "@/components/public/PublicContent"; +import { isLocale } from "@/i18n/locales"; +import { publishedServiceBySlug } from "@/lib/public-content"; +import { publicMetadata } from "@/lib/public-seo"; + +export const dynamic = "force-dynamic"; + +export async function generateMetadata({ params }: { params: Promise<{ locale: string; slug: string }> }): Promise { + const { locale, slug } = await params; + if (!isLocale(locale)) return { robots: { index: false } }; + const page = await publishedServiceBySlug(slug, locale); + return publicMetadata(page, locale, `/${locale}/services/${slug}`, page ? `/${locale === "en" ? "bn" : "en"}/services/${page.otherSlug}` : undefined); +} + +export default async function ServiceDetailPage({ params }: { params: Promise<{ locale: string; slug: string }> }) { + const { locale, slug } = await params; + if (!isLocale(locale)) notFound(); + const page = await publishedServiceBySlug(slug, locale); + if (!page) notFound(); + return ; +} diff --git a/src/app/[locale]/services/page.tsx b/src/app/[locale]/services/page.tsx new file mode 100644 index 0000000..d60b956 --- /dev/null +++ b/src/app/[locale]/services/page.tsx @@ -0,0 +1,30 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { getDictionary } from "@/i18n/get-dictionary"; +import { isLocale } from "@/i18n/locales"; +import { publishedPage, publishedServices } from "@/lib/public-content"; +import { publicMetadata } from "@/lib/public-seo"; + +export const dynamic = "force-dynamic"; + +export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise { + const { locale } = await params; + if (!isLocale(locale)) return { robots: { index: false } }; + return publicMetadata(await publishedPage("services", locale), locale, `/${locale}/services`); +} + +export default async function ServicesPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + if (!isLocale(locale)) notFound(); + const [page, services] = await Promise.all([publishedPage("services", locale), publishedServices(locale)]); + const t = getDictionary(locale).public; + return
+

OnSkillIT

{page?.content.title ?? t.services}

+ {page &&

{page.content.description}

}
+ {services.length ?
    {services.map((service) =>
  • +

    {service.content.title}

    {service.content.description}

    + {t.serviceLink} +
  • )}
:

{t.noServices}

} +
; +} diff --git a/src/app/api/v1/cms/[...path]/route.ts b/src/app/api/v1/cms/[...path]/route.ts index 72b9b96..c308f0b 100644 --- a/src/app/api/v1/cms/[...path]/route.ts +++ b/src/app/api/v1/cms/[...path]/route.ts @@ -112,10 +112,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const body=await bodyOf(request); if (!body) return bad("INVALID_BODY"); if (part === "pages") { if (!await allowed("pages.write")) return bad("FORBIDDEN",403); - if (!only(body,["page_key","kind","en","bn"]) || typeof body.page_key !== "string" || !KEY.test(body.page_key) || !["page","landing"].includes(String(body.kind)) || !content(body.en,false,"en") || !content(body.bn,false,"bn")) return bad("INVALID_PAGE"); + if (!only(body,["page_key","kind","category","en","bn"]) || typeof body.page_key !== "string" || !KEY.test(body.page_key) || !["page","landing","service"].includes(String(body.kind)) || + (body.kind === "service" && (typeof body.category !== "string" || !KEY.test(body.category))) || + (body.kind !== "service" && body.category !== undefined) || !content(body.en,false,"en") || !content(body.bn,false,"bn")) return bad("INVALID_PAGE"); const en=body.en as LocaleContent, bn=body.bn as LocaleContent; try { const id=await db.begin(async tx=>{ const pages=await tx`INSERT INTO cms_page(page_key,kind,created_by) VALUES (${body.page_key as string},${body.kind as string},${session.userId}) RETURNING id`; await tx`INSERT INTO cms_revision(page_id,revision_no,en,bn,created_by) VALUES (${pages[0].id},1,${tx.json(en)},${tx.json(bn)},${session.userId})`; + if (body.kind === "service") await tx`INSERT INTO catalog_service(page_id,category) VALUES (${pages[0].id},${body.category as string})`; await tx`INSERT INTO identity_audit(actor_user_id,action,outcome,request_id,metadata) VALUES (${session.userId},'cms.page_create','success',${requestId},${tx.json({ page_id: pages[0].id })})`; return pages[0].id as string; }); return json({id},201); } catch (error) { if (typeof error === "object" && error && "code" in error && error.code === "23505") return bad("PAGE_KEY_IN_USE",409); throw error; } diff --git a/src/app/api/v1/public/inquiries/route.ts b/src/app/api/v1/public/inquiries/route.ts new file mode 100644 index 0000000..64ef888 --- /dev/null +++ b/src/app/api/v1/public/inquiries/route.ts @@ -0,0 +1,86 @@ +import { createHmac } from "node:crypto"; +import { NextRequest, NextResponse } from "next/server"; +import { createDbClient, getDatabaseUrl } from "@/db/client"; +import { isLocale } from "@/i18n/locales"; + +export const runtime = "nodejs"; +const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +function fail(code: string, status: number) { + return NextResponse.json({ code }, { status, headers: { "Cache-Control": "no-store" } }); +} + +function text(value: unknown, min: number, max: number): value is string { + return typeof value === "string" && value.trim().length >= min && value.length <= max; +} + +async function limitedBody(request: NextRequest): Promise | null> { + if (!request.headers.get("content-type")?.startsWith("application/json")) return null; + const reader = request.body?.getReader(); + if (!reader) return null; + const chunks: Uint8Array[] = []; + let length = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + length += value.length; + if (length > 10000) { await reader.cancel(); return null; } + chunks.push(value); + } + try { + const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record : null; + } catch { return null; } +} + +export async function POST(request: NextRequest) { + const databaseUrl = getDatabaseUrl(); + const secret = process.env.INQUIRY_RATE_SECRET; + if (!databaseUrl || !secret || secret.length < 32) return fail("UNAVAILABLE", 503); + const origin = request.headers.get("origin"); + if (origin) { + try { if (new URL(origin).host !== request.nextUrl.host) return fail("FORBIDDEN", 403); } + catch { return fail("FORBIDDEN", 403); } + } + const body = await limitedBody(request); + if (!body) return fail("INVALID_REQUEST", 400); + if (body.website) return fail("INVALID_REQUEST", 400); + if (!text(body.name, 2, 120) || !text(body.email, 3, 254) || !emailPattern.test(body.email) || + !text(body.message, 10, 4000) || typeof body.locale !== "string" || !isLocale(body.locale) || body.consent !== true || + (body.phone !== undefined && body.phone !== "" && (typeof body.phone !== "string" || !/^\+?[0-9 ()-]{7,25}$/.test(body.phone))) || + !text(body.consentVersion, 36, 36) || !/^[0-9a-f-]{36}$/i.test(body.consentVersion) || + body.sourcePath !== `/${body.locale}/contact`) return fail("INVALID_REQUEST", 400); + + const email = body.email.trim().toLowerCase(); + const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || request.headers.get("x-real-ip") || "unknown"; + const digest = (value: string) => createHmac("sha256", secret).update(value).digest("hex"); + const db = createDbClient(databaseUrl); + try { + const keys = [ + { key: digest(`public-inquiry:ip:${ip}`), limit: 10, minutes: 60 }, + { key: digest(`public-inquiry:email:${email}`), limit: 5, minutes: 60 }, + ]; + const result = await db.begin(async (tx) => { + for (const rule of keys) { + const rows = await tx<{ count: number }[]>`INSERT INTO identity_rate_limit(key_hash,count,window_start) + VALUES (${rule.key},1,now()) ON CONFLICT (key_hash) DO UPDATE SET + count=CASE WHEN identity_rate_limit.window_start < now() - (${rule.minutes} * interval '1 minute') + THEN 1 ELSE identity_rate_limit.count + 1 END, + window_start=CASE WHEN identity_rate_limit.window_start < now() - (${rule.minutes} * interval '1 minute') + THEN now() ELSE identity_rate_limit.window_start END RETURNING count`; + if (Number(rows[0]?.count ?? 0) > rule.limit) return "RATE_LIMITED"; + } + const privacy = await tx`SELECT p.published_revision_id FROM cms_page p + WHERE p.page_key='privacy' AND p.state='published' AND p.published_revision_id=${body.consentVersion as string} LIMIT 1`; + const contact = await tx`SELECT 1 FROM cms_page WHERE page_key='contact' AND state='published' LIMIT 1`; + if (!privacy.length || !contact.length) return "UNAVAILABLE"; + await tx`INSERT INTO public_inquiry(name,email,phone,message,locale,consent_version,source_path) + VALUES (${(body.name as string).trim()},${email},${typeof body.phone === "string" && body.phone.trim() ? body.phone.trim() : null},${(body.message as string).trim()},${body.locale as string}, + ${body.consentVersion as string},${body.sourcePath as string})`; + return "OK"; + }); + if (result === "RATE_LIMITED") return fail("RATE_LIMITED", 429); + if (result !== "OK") return fail("UNAVAILABLE", 503); + return NextResponse.json({ status: "received" }, { status: 202, headers: { "Cache-Control": "no-store" } }); + } finally { await db.end(); } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 28dceff..866a394 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,10 +1,10 @@ import type { Metadata } from "next"; import { cookies, headers } from "next/headers"; -import { Inter, Noto_Sans_Bengali } from "next/font/google"; +import { Inter, Hind_Siliguri } from "next/font/google"; import "@/styles/tokens.css"; const inter = Inter({ subsets: ["latin"], variable: "--font-en", display: "swap" }); -const notoSansBengali = Noto_Sans_Bengali({ +const hindSiliguri = Hind_Siliguri({ subsets: ["bengali"], weight: ["400", "500", "600", "700"], variable: "--font-bn", @@ -32,7 +32,7 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac const locale = headerStore.get("x-locale") ?? "en"; return ( - + + +
{preview}
+
OnSkillIT + +
+

OnSkillIT

{title}

+

{esc(page["description"])}

{content}
+ +''' + + +def main() -> None: + if COPY["status"] != "editorial_draft" or set(PAGES) != {"home", "about", "services", "contact", "faq", *SERVICES}: + raise SystemExit("Unexpected Phase 5 editorial copy shape") + OUT.mkdir(exist_ok=True) + assets = OUT / "assets" + assets.mkdir(exist_ok=True) + copyfile(ROOT / "src/styles/tokens.css", assets / "tokens.css") + copyfile(ROOT / "scripts/pages-preview.css", assets / "preview.css") + copyfile(ROOT / "scripts/pages-preview.js", assets / "preview.js") + (OUT / ".nojekyll").touch() + (OUT / "robots.txt").write_text("User-agent: *\nDisallow: /\n", encoding="utf-8") + (OUT / "index.html").write_text( + f'OnSkillIT previewEnglish · বাংলা', + encoding="utf-8", + ) + for locale in ("en", "bn"): + for key in PAGES: + relative = path_for(key, locale).removeprefix(BASE) + target = OUT / relative / "index.html" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(render_page(key, locale), encoding="utf-8") + print(f"Generated {2 * len(PAGES)} localized preview pages in {OUT}") + + +if __name__ == "__main__": + main() diff --git a/scripts/pages-preview.css b/scripts/pages-preview.css new file mode 100644 index 0000000..0fe3e11 --- /dev/null +++ b/scripts/pages-preview.css @@ -0,0 +1,14 @@ +@import url('https://fonts.googleapis.com/css2?family=Hind+Siliguri:wght@400;600;700&display=swap'); +html[lang="bn"] body { font-family: 'Hind Siliguri', sans-serif; } +html[lang="en"] body { font-family: Arial, sans-serif; line-height: 1.6; } +.preview-banner { padding: .5rem 1rem; text-align: center; background: var(--action-primary-bg); color: var(--action-primary-text); font-size: .875rem; } +.preview-header { width: min(100%, 82rem); margin: auto; padding: 1rem 1.5rem; display: flex; flex-wrap: wrap; align-items: center; gap: 1rem 2rem; justify-content: space-between; } +.site-nav { gap: .35rem 1rem; } +.site-nav a[aria-current="page"] { font-weight: 800; text-decoration-thickness: 2px; } +.preview-controls { display: flex; align-items: center; gap: 1rem; } +.preview-controls a { color: var(--text-primary); } +.preview-controls button { min-width: 44px; min-height: 44px; border: 1px solid var(--border-default); border-radius: var(--radius-field); background: var(--surface-raised); color: var(--text-primary); font: inherit; cursor: pointer; } +main { padding: var(--space-xl) var(--space-lg) var(--space-section); } +.public-card-grid { margin-bottom: 0; } +.public-contact-details { margin-bottom: 0; } +@media (max-width: 640px) { .preview-header { align-items: flex-start; } .site-nav { order: 3; width: 100%; } main { padding: var(--space-lg) var(--space-md); } } diff --git a/scripts/pages-preview.js b/scripts/pages-preview.js new file mode 100644 index 0000000..2f81046 --- /dev/null +++ b/scripts/pages-preview.js @@ -0,0 +1,11 @@ +(() => { + try { + const stored = localStorage.getItem('onskillit-preview-theme'); + if (stored === 'dark' || stored === 'light') document.documentElement.dataset.theme = stored; + } catch { /* storage may be unavailable */ } + document.getElementById('theme-toggle')?.addEventListener('click', () => { + const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; + document.documentElement.dataset.theme = next; + try { localStorage.setItem('onskillit-preview-theme', next); } catch { /* no persistence */ } + }); +})(); From 9057b958a54f538cea421bb4676883864a1e3483 Mon Sep 17 00:00:00 2001 From: miahsobuj Date: Wed, 30 Sep 2026 03:39:24 +0600 Subject: [PATCH 15/17] ci: pin verified GitHub Pages action revisions --- .github/workflows/pages-preview.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pages-preview.yml b/.github/workflows/pages-preview.yml index 91ce32e..03fb1e9 100644 --- a/.github/workflows/pages-preview.yml +++ b/.github/workflows/pages-preview.yml @@ -35,9 +35,9 @@ jobs: assert ' Date: Wed, 30 Sep 2026 03:41:48 +0600 Subject: [PATCH 16/17] docs: record live GitHub Pages Phase 5 preview --- README.md | 2 ++ docs/PHASE-05-WORKLOG.md | 4 ++-- docs/TRACEABILITY.md | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 975d62d..44a1631 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ Current work: PHASE-05 public core is IN PROGRESS on `phase-05/public-core` with PUBLIC CORE VERIFIED open; see `docs/PHASE-05-WORKLOG.md`. +The [GitHub Pages Phase 5 preview](https://soobujmiah.github.io/onskillit-platform/) presents confirmed English/Bangla business copy for review. It is static and noindex; the server application and external-domain deployment remain separate. + Status: PHASE-00 through PHASE-04 gates passed. PHASE-04 CMS and publishing core passed PUBLISHING CORE VERIFIED on synthetic GitHub evidence. See `docs/PHASE-04-WORKLOG.md` and `docs/CMS-PUBLISHING.md`. No production deployment or data migration has occurred. The public GitHub repository is `soobujmiah/onskillit-platform`. ![OnSkillIT Digital Platform Phase 0 architecture blueprint](docs/assets/onskillit-platform-architecture.svg) diff --git a/docs/PHASE-05-WORKLOG.md b/docs/PHASE-05-WORKLOG.md index 33d468f..d764360 100644 --- a/docs/PHASE-05-WORKLOG.md +++ b/docs/PHASE-05-WORKLOG.md @@ -24,8 +24,8 @@ The first [Public Core run 36605215028](https://github.com/soobujmiah/onskillit- Source and documentation commit `2075065` passed all [PR checks](https://github.com/soobujmiah/onskillit-platform/pull/17/checks), including [Public Core 36628307560](https://github.com/soobujmiah/onskillit-platform/actions/runs/36628307560), identity, CMS, locale/browser, web build, migrations, documentation, repository checks and deploy smoke. GitHub browser artifacts show the owner-copy Home, About, Services, Service Detail, Contact and FAQ at 320 and 1440 pixels in both languages. I inspected representative desktop/mobile screenshots, found a repeated Home tagline, removed it and inspected the corrected final Home artifact. Automated browser checks found no horizontal overflow or axe violations. Nine Phase 5 page templates are **CI-VERIFIED for synthetic source behavior**, not released. No local build or test has been run. After `main` advanced independently, I merged it into this branch and aligned its machine-readable phase register with the accepted [canonical phase register](PHASES.md). -The phase gate remains **OPEN** because legal policies are unapproved, a shareable preview host is unselected, and owner visual/content review is outstanding. Explicit `[CONFIRM]` claims remain excluded; they do not block publication if omitted. [Bilingual policy proposals](PHASE-05-POLICY-REVIEW.md) are outside the CMS and require owner and qualified operational/legal review before publication. No production deployment, data migration or domain change occurred. +The phase gate remains **OPEN** because legal policies are unapproved and owner visual/content review is outstanding. A GitHub Pages review host was selected on 2026-09-30; see below. Explicit `[CONFIRM]` claims remain excluded; they do not block publication if omitted. [Bilingual policy proposals](PHASE-05-POLICY-REVIEW.md) are outside the CMS and require owner and qualified operational/legal review before publication. No production deployment, data migration or domain change occurred. ## GitHub Pages review preview -On 2026-09-30 I clarified that GitHub should host a preview now, while deployment on the external OnSkillIT domain waits until the full project is complete. The existing Next.js application uses server rendering, CMS/database reads, authentication and POST APIs; it cannot be fully hosted by GitHub Pages. I therefore added `scripts/build_pages_preview.py` and a branch-scoped GitHub Actions Pages workflow that render the ten owner-confirmed editorial records as twenty static English/Bangla pages, with navigation, dark/light controls and confirmed contact links. The review site carries `noindex` and `robots.txt` disallow, an explicit preview banner, and no inquiry form, policy text, prices or `[CONFIRM]` claims. It is a content and visual preview, not a deployment of the server app or a production release. This workflow needs a successful GitHub run and live URL check before it can be reported as deployed. +On 2026-09-30 I clarified that GitHub should host a preview now, while deployment on the external OnSkillIT domain waits until the full project is complete. The existing Next.js application uses server rendering, CMS/database reads, authentication and POST APIs; it cannot be fully hosted by GitHub Pages. I therefore added `scripts/build_pages_preview.py` and a branch-scoped GitHub Actions Pages workflow that render the ten owner-confirmed editorial records as twenty static English/Bangla pages, with navigation, dark/light controls and confirmed contact links. The review site carries `noindex` and `robots.txt` disallow, an explicit preview banner, and no inquiry form, policy text, prices or `[CONFIRM]` claims. It is a content and visual preview, not a deployment of the server app or a production release. The first workflow run failed before checkout because I used invalid action revisions; the next run was initially blocked by the Pages environment's `main`-only branch policy. I corrected the pinned revisions, authorized only `phase-05/public-core` in that environment and reran the workflow. [Deployment run 36634726227](https://github.com/soobujmiah/onskillit-platform/actions/runs/36634726227) passed at `9057b95`. The [GitHub Pages preview](https://soobujmiah.github.io/onskillit-platform/) returned HTTP 200 for the root, both locale homepages, a Bangla service detail and the stylesheet; the English home response contains the confirmed tagline and `noindex` meta. No local build or test was run. diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 3f43a36..1050e14 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -1,6 +1,6 @@ # Requirements traceability matrix -PHASE-05 source mapping: PAGE-HOME → `src/app/[locale]/page.tsx`; PAGE-ABOUT, PAGE-CONTACT, PAGE-FAQ, PAGE-PRIVACY, PAGE-TERMS, PAGE-ACCESSIBILITY → `src/app/[locale]/[slug]/page.tsx`; PAGE-SERVICES → `src/app/[locale]/services/page.tsx`; PAGE-SERVICE-DETAIL → `src/app/[locale]/services/[slug]/page.tsx`. R05 maps to `catalog_service` and published CMS reads; R09 maps to `public_inquiry`, `POST /api/v1/public/inquiries` and [public OpenAPI](openapi/public.json); R13 maps to metadata, sitemap and robots; R16/R19/R20 map to the public CSS, paired dictionaries and Hind Siliguri. `scripts/public_ci.mjs`, `playwright/public.spec.ts` and `.github/workflows/public-core.yml` are the GitHub evidence path. All eleven PR checks passed on `da963f4`; [Public Core run 36607281415](https://github.com/soobujmiah/onskillit-platform/actions/runs/36607281415) covers synthetic page and inquiry behavior. Page source is CI-VERIFIED; the PHASE-05 gate remains open for content, host and visual inputs. +PHASE-05 source mapping: PAGE-HOME → `src/app/[locale]/page.tsx`; PAGE-ABOUT, PAGE-CONTACT, PAGE-FAQ, PAGE-PRIVACY, PAGE-TERMS, PAGE-ACCESSIBILITY → `src/app/[locale]/[slug]/page.tsx`; PAGE-SERVICES → `src/app/[locale]/services/page.tsx`; PAGE-SERVICE-DETAIL → `src/app/[locale]/services/[slug]/page.tsx`. R05 maps to `catalog_service` and published CMS reads; R09 maps to `public_inquiry`, `POST /api/v1/public/inquiries` and [public OpenAPI](openapi/public.json); R13 maps to metadata, sitemap and robots; R16/R19/R20 map to the public CSS, paired dictionaries and Hind Siliguri. `scripts/public_ci.mjs`, `playwright/public.spec.ts` and `.github/workflows/public-core.yml` are the GitHub source evidence path. `scripts/build_pages_preview.py` and `.github/workflows/pages-preview.yml` render the owner-confirmed editorial copy as a static, noindex GitHub Pages review site; [deployment run 36634726227](https://github.com/soobujmiah/onskillit-platform/actions/runs/36634726227) and the [live preview](https://soobujmiah.github.io/onskillit-platform/) verify R05/R13/R16/R19/R20 only for this static review surface. The server application, inquiry and policies remain unreleased. The PHASE-05 gate remains open for legal and owner review inputs. PHASE-01 through PHASE-03 are COMPLETE (see their work logs and [HANDOFF](HANDOFF.md)). Their shell infrastructure contributes to R16/R19/R20, but these cross-cutting rows cannot reach `CI-verified` until every owning V1 page is implemented. PHASE-03 source merged in PR #12 and gate closure is in PR #14; IDENTITY VERIFIED passed on synthetic GitHub evidence and the founding partner’s completion instruction. The founding partner's 2026-09-29 decision in [PHASE-03-PLAN](PHASE-03-PLAN.md) defers and disables mobile-only recovery, permits unverified-email registration/sign-in but gates email reset on mailbox verification, and requires reviewed/audited first-owner grant. These are acceptance boundaries for R01, R02, R10 and R15; passing GitHub CI and the gate decision are recorded in PHASE-03-WORKLOG. From 3733ead4b0055db3f9f1c1560dc8016174fd54b8 Mon Sep 17 00:00:00 2001 From: miahsobuj Date: Wed, 30 Sep 2026 04:13:03 +0600 Subject: [PATCH 17/17] docs: align Phase 5 handoff with live GitHub preview --- AI_ASSISTANT.md | 2 +- README.md | 2 +- docs/HANDOFF.md | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/AI_ASSISTANT.md b/AI_ASSISTANT.md index c7b55ae..6895b7e 100644 --- a/AI_ASSISTANT.md +++ b/AI_ASSISTANT.md @@ -1,6 +1,6 @@ # OnSkillIT agent working agreement -Current work: PHASE-05 public core is IN PROGRESS on `phase-05/public-core`; PUBLIC CORE VERIFIED is open. See `docs/PHASE-05-WORKLOG.md` and the owner's classified content brief. Production publication remains unapproved. +Current work: PHASE-05 public core is IN PROGRESS; PUBLIC CORE VERIFIED is open. See `docs/PHASE-05-WORKLOG.md` and the owner's classified content brief. The GitHub Pages editorial preview is live; server and production publication remain pending. Status: PHASE-00 through PHASE-04 gates passed; PHASE-04 CMS and publishing core is CI-verified with PUBLISHING CORE VERIFIED passed on synthetic GitHub evidence. The founding partner approved the PHASE-03 plan on 2026-09-29 with mobile-only recovery deferred and disabled, reviewed/audited first-owner bootstrap, and verified-email-only reset. No production deployment, migration or change to `onskillit.com` occurred. The public GitHub repository is the project source of truth; agent chat history is not. diff --git a/README.md b/README.md index 44a1631..bf8621c 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ **Machine-generated operational state:** see [`.repo/STATUS.md`](.repo/STATUS.md) (generated by `tools/repo_knowledge`). For cross-project SKB registry state, see [`onskillit-platform.json`](https://github.com/soobujmiah/skb/blob/main/projects/registry/onskillit-platform.json) in the parent SKB repository (`soobujmiah/skb`). # OnSkillIT Digital Platform — documentation workspace -Current work: PHASE-05 public core is IN PROGRESS on `phase-05/public-core` with PUBLIC CORE VERIFIED open; see `docs/PHASE-05-WORKLOG.md`. +Current work: PHASE-05 public core is IN PROGRESS with PUBLIC CORE VERIFIED open; see `docs/PHASE-05-WORKLOG.md`. The [GitHub Pages Phase 5 preview](https://soobujmiah.github.io/onskillit-platform/) presents confirmed English/Bangla business copy for review. It is static and noindex; the server application and external-domain deployment remain separate. diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 36aea05..73f3215 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -6,7 +6,7 @@ Updated: 2026-09-30 (Asia/Dhaka). **This file is the canonical cross-agent hando - Public repository: `soobujmiah/onskillit-platform`, default branch `main`; local checkout `/home/sbj/onskillit-platform-docs` has the verified `origin` remote. Use a fresh branch from current `main` for new work. - PHASE-00 through PHASE-03 are **COMPLETE**. PHASE-03 IDENTITY VERIFIED passed after [gate-closure Identity CI](https://github.com/soobujmiah/onskillit-platform/actions/runs/36562919856), all nine PR checks at `5f21544`, synthetic browser screenshots and the founding partner's 2026-09-29 instruction to complete PHASE-03 before PHASE-04. [PR #14](https://github.com/soobujmiah/onskillit-platform/pull/14) merged as `0428cfb`; [post-merge Identity CI](https://github.com/soobujmiah/onskillit-platform/actions/runs/36565770895) and the other `main` checks passed. [PHASE-03-WORKLOG](PHASE-03-WORKLOG.md) has the detailed evidence and boundaries. -- PHASE-04 PUBLISHING CORE VERIFIED **PASSED** on synthetic GitHub evidence. [PR #15](https://github.com/soobujmiah/onskillit-platform/pull/15) merged as `aeb3d696223f4f7870458b14913e357afc41386b` after all eleven final PR checks passed on `65cb2d9`; [post-merge CMS run 36571645321](https://github.com/soobujmiah/onskillit-platform/actions/runs/36571645321) and the other eight `main` workflows passed. The six Phase 4 staff routes are CI-verified. PHASE-05 is IN PROGRESS on [draft PR #17](https://github.com/soobujmiah/onskillit-platform/pull/17), with PUBLIC CORE VERIFIED open; PHASE-06 through PHASE-15 are NOT STARTED. Public business pages belong to PHASE-05. See [PHASES](PHASES.md), [PAGES](PAGES.md), [API-CONTRACT](API-CONTRACT.md), [DATA-MODEL](DATA-MODEL.md), [PHASE-05-WORKLOG](PHASE-05-WORKLOG.md) and [TRACEABILITY](TRACEABILITY.md). +- PHASE-04 PUBLISHING CORE VERIFIED **PASSED** on synthetic GitHub evidence. [PR #15](https://github.com/soobujmiah/onskillit-platform/pull/15) merged as `aeb3d696223f4f7870458b14913e357afc41386b` after all eleven final PR checks passed on `65cb2d9`; [post-merge CMS run 36571645321](https://github.com/soobujmiah/onskillit-platform/actions/runs/36571645321) and the other eight `main` workflows passed. The six Phase 4 staff routes are CI-verified. PHASE-05 source and the [GitHub Pages editorial preview](https://soobujmiah.github.io/onskillit-platform/) are in [PR #17](https://github.com/soobujmiah/onskillit-platform/pull/17); PUBLIC CORE VERIFIED remains open. PHASE-06 through PHASE-15 are NOT STARTED. Public business pages belong to PHASE-05. See [PHASES](PHASES.md), [PAGES](PAGES.md), [API-CONTRACT](API-CONTRACT.md), [DATA-MODEL](DATA-MODEL.md), [PHASE-05-WORKLOG](PHASE-05-WORKLOG.md) and [TRACEABILITY](TRACEABILITY.md). - The owner declined `main` branch protection during PHASE-01; do not enable it without a new instruction. ## Delivered and remaining boundaries @@ -17,5 +17,5 @@ Updated: 2026-09-30 (Asia/Dhaka). **This file is the canonical cross-agent hando ## Exact continuation order -1. Continue PHASE-05 on `phase-05/public-core`. Check draft PR #17 final-head GitHub CI, review the bilingual editorial drafts and publication/security contracts, and complete source and documentation work before deciding the phase gate. The owner confirmed exact contact details and the unmarked business/service brief; no policy text is approved. Arrange a shareable preview only after selecting a host and protected environment configuration. -2. Keep real content claims, external mail, preview hosting and production/domain changes at their respective gates. Never place credentials or private records in this public repository. +1. Continue PHASE-05 after PR #17 source merge. Review the live bilingual static preview and the editorial drafts; the owner confirmed exact contact details and the unmarked business/service brief. Resolve the operational questions and approve complete English/Bangla Privacy, Terms and Accessibility text before CMS publication or enabling inquiries. Keep the Phase 5 gate open until the review and evidence are recorded. +2. GitHub Pages serves the static editorial preview only. A compatible server, PostgreSQL, worker and protected configuration are still needed to run CMS, sign-in and inquiry endpoints. Keep external mail, production/domain changes and real content publication at their respective gates. Never place credentials or private records in this public repository.