diff --git a/docs/BRAND-BOOTSTRAP.md b/docs/BRAND-BOOTSTRAP.md new file mode 100644 index 0000000..41a0a2b --- /dev/null +++ b/docs/BRAND-BOOTSTRAP.md @@ -0,0 +1,373 @@ +# Brand bootstrap — full JSON reference + +What one JSON document must contain to turn this codebase into a live, branded marketplace. Frontend is Angular 22, multi-tenant, one bundle for every domain — a brand is 100% config, zero code or rebuild. Source of truth for wire shape: [`bootstrap-config.model.ts`](../src/app/shared/models/config/bootstrap-config.model.ts) and its per-section models in the same folder. Backend contract: [`BACKEND-INTEGRATION.md`](backend/BACKEND-INTEGRATION.md). Deploy/domain/TLS mechanics: [`DEPLOYMENT.md`](DEPLOYMENT.md). + +## How it works + +1. Request arrives at `https://`. +2. nginx forwards the verified `Host` to the API as `X-Storefront-Host`. **Tenant identity comes only from this header — never from a client-supplied field.** +3. SPA calls `GET /bootstrap` (also proxied through `api.`). +4. Backend resolves tenant from the host, returns this JSON. Frontend renders entirely from it — theme, nav, pages, feature flags, locales. +5. One backend, many brands: each `Marketplace` row + its `MarketplaceDomain` rows is a brand. No per-brand deploy. + +Acceptance check used in CI: `curl -fsS https://api./bootstrap | jq -e 'type=="object"'`. + +## Minimal path to a new brand + +1. Backend: create a `Marketplace` row (`docs/backend/BACKEND-INTEGRATION.md` §11) and at least one `MarketplaceDomain` (`type: 'production'`). +2. Point the domain's DNS A record at the server. +3. TLS: either it's a `*.yourapex.com` subdomain (wildcard, zero extra work — [`DEPLOYMENT.md`](DEPLOYMENT.md) §4.1) or a customer's own domain (`add-domain.sh`, §4.4, or the `sync-domains.sh` reconciler, §4.2). +4. `configure-api-domain.sh` for the base domain — creates `api.` (backend proxy, CORS, cert). One API hostname per base domain; subdomains reuse it. +5. Backend returns a populated bootstrap JSON for that `Host`. Nothing to redeploy on the frontend side. +6. Verify: `curl -I https:///health` (nginx, expect 200) and `curl -fsS https://api./bootstrap | jq .` (backend, expect the object below). + +--- + +## Full annotated example + +```json +{ + "schemaVersion": "1.0.0", + "generatedAt": "2026-08-22T00:00:00Z", + + "tenant": { + "id": "tenant-acme-001", + "slug": "acme", + "code": "ACME", + "host": "shop.acme.com", + "name": "Acme Marketplace", + "websiteBaseUrl": "https://shop.acme.com", + "builderBaseUrl": "https://builder.shop.acme.com", + "backofficeBaseUrl": "https://backoffice.shop.acme.com", + "defaultLocale": "en", + "supportedLocales": ["en", "ru"], + "defaultCurrency": "USD", + "supportedCurrencies": ["USD", "EUR"], + "timezone": "America/New_York", + "documentationUrl": "https://docs.shop.acme.com" + }, + + "branding": { + "brandName": "Acme", + "legalName": "Acme Commerce LLC", + "slogan": "Everything, delivered", + "logoUrl": "https://cdn.acme.com/logo.svg", + "logoCompactUrl": "https://cdn.acme.com/logo-compact.svg", + "faviconUrl": "https://cdn.acme.com/favicon.ico", + "appIconUrl": "https://cdn.acme.com/icon-192.png", + "supportEmail": "support@acme.com", + "supportPhone": "+1-555-000-0000" + }, + + "theme": { + "themeId": "acme-light", + "mode": "light", + "palette": { + "primary": "#1a56db", + "secondary": "#7e8a97", + "accent": "#60a5fa", + "success": "#10b981", + "warning": "#f59e0b", + "danger": "#ef4444", + "info": "#3b82f6", + "textPrimary": "#111827", + "textSecondary": "#6b7280", + "backgroundPrimary": "#ffffff", + "backgroundSecondary": "#f9fafb", + "border": "#e5e7eb" + }, + "typography": { + "primaryFontFamily": "Inter, sans-serif", + "headingFontFamily": "Inter, sans-serif", + "baseFontSize": 16 + }, + "spacing": { "unit": 4, "scale": [0, 4, 8, 12, 16, 24, 32, 48] }, + "borderRadiusScale": { "sm": "6px", "md": "10px", "lg": "14px", "xl": "20px" }, + "shadows": { + "sm": "0 2px 8px rgba(0,0,0,0.1)", + "md": "0 4px 12px rgba(0,0,0,0.15)", + "lg": "0 12px 32px rgba(26,86,219,0.2)" + }, + "iconSet": "default" + }, + + "company": { + "companyName": "Acme Commerce LLC", + "registrationNumber": "0000000000", + "taxId": "00-0000000", + "address": { + "country": "USA", + "region": "NY", + "city": "New York", + "street": "5th Ave 1", + "postalCode": "10001" + }, + "contacts": { + "email": "support@acme.com", + "phone": "+1-555-000-0000", + "telegram": "@acme_support", + "website": "https://acme.com" + } + }, + + "featureFlags": { + "wishlist": true, "compare": true, "reviews": true, "blog": false, + "chat": false, "analytics": true, "notifications": true, + "coupons": true, "loyalty": false, "giftCards": false, "invoices": true + }, + + "apiEndpoints": { + "bootstrap": { "path": "/bootstrap", "method": "GET", "timeoutMs": 10000 }, + "website": {}, "builder": {}, "backoffice": {} + }, + + "localization": { + "defaultLocale": "en", + "supportedLocales": ["en", "ru"], + "currencyByLocale": { "en": "USD", "ru": "RUB" }, + "dictionaries": [ + { "locale": "en", "dictionaryUrl": "/assets/i18n/en.json", "version": "1.0.0" }, + { "locale": "ru", "dictionaryUrl": "/assets/i18n/ru.json", "version": "1.0.0" } + ] + }, + + "seo": { + "default": { "title": "Acme", "description": "Everything, delivered", "robots": "index,follow" }, + "byPageKey": { + "home": { "title": "Acme - Home", "description": "Everything, delivered", "canonicalUrl": "https://shop.acme.com/", "robots": "index,follow" } + } + }, + + "permissions": { + "definitions": [ + { "key": "builder.pages.edit", "description": "Edit pages in builder" }, + { "key": "backoffice.products.read", "description": "Read products in backoffice" } + ], + "roles": [ + { "role": "builder_admin", "permissions": ["builder.pages.edit"] }, + { "role": "backoffice_manager", "permissions": ["backoffice.products.read"] } + ] + }, + + "header": { "showLogo": true, "showSearch": true, "showCategories": true, "showCart": true, "sticky": true, "layout": "default" }, + "layout": { "type": "default" }, + + "navigation": { + "header": [ + { "id": "nav-home", "labelKey": "nav.home", "route": "/", "icon": "home", "order": 1 }, + { "id": "nav-search", "labelKey": "nav.search", "route": "/search", "icon": "search", "order": 2 }, + { "id": "nav-cart", "labelKey": "nav.cart", "route": "/cart", "icon": "cart", "order": 3 } + ], + "footer": [ + { "id": "footer-about", "labelKey": "nav.about", "route": "/about-us", "order": 1 }, + { "id": "footer-privacy", "labelKey": "nav.privacy", "route": "/privacy-policy", "order": 2 } + ] + }, + + "footer": { + "paymentIcons": [{ "src": "/assets/images/visa-logo.svg", "alt": "Visa", "width": 40, "height": 28 }], + "copyrightText": { "en": "© 2026 Acme. All rights reserved.", "ru": "© 2026 Acme. Все права защищены." }, + "legalPageKeys": ["about-us", "privacy-policy", "terms-of-service"] + }, + + "catalog": { + "layout": "grid", + "navigationMode": "default", + "defaultSort": "relevance", + "availableSorts": ["relevance", "latest", "price_asc", "price_desc", "rating", "popular", "discount"], + "enabledFilters": ["price", "availability", "rating", "brand", "category"], + "showBreadcrumbs": true, "showCategoryBanner": true, "showRatings": true, + "showDiscounts": true, "showAvailability": true, "suggestionsEnabled": true, "searchHistoryEnabled": true + }, + + "productPage": { + "rating": { "enabled": true }, + "reviews": { "enabled": true, "pageSize": 5, "showSummary": true }, + "questions": { "enabled": true, "pageSize": 5 }, + "tabs": { "enabled": true, "items": ["description", "specifications", "reviews", "questions", "delivery", "warranty"] }, + "relatedProducts": { "enabled": true } + }, + + "userExperience": { + "wishlist": { "enabled": true, "headerBadgeEnabled": true }, + "compare": { "enabled": true, "maxItems": 4, "hideIdenticalDefault": false, "highlightDifferencesDefault": true }, + "recentlyViewed": { "enabled": true, "maxItems": 12, "widgetEnabled": true }, + "share": { "enabled": true }, + "continueBrowsing": { "enabled": true }, + "savedSearches": { "enabled": true, "maxItems": 10 } + }, + + "features": { + "wishlist": true, "compare": true, "reviews": true, "comments": true, + "questions": true, "recommendations": true, "recentlyViewed": true, + "searchHistory": true, "recentlySearched": true, "ratings": true, + "share": true, "brands": true, "manufacturers": true, + "availability": true, "discounts": true, "badges": true + }, + + "widgetRegistry": { "manifestUrl": "https://api.acme.com/widget-manifest.json" }, + + "staticPages": { + "about-us": { + "route": "/about-us", + "title": { "en": "About Us", "ru": "О компании" }, + "html": { "en": "

About Us

...

", "ru": "

О компании

...

" } + } + }, + + "pages": [ + { + "id": "page-home", "key": "home", "title": "Home", + "route": { "path": "/", "exact": true }, + "layout": { "type": "default" }, + "seoKey": "home", "visible": true, + "sections": [ + { + "id": "section-hero", "type": "hero", "order": 1, + "layout": { "strategy": "hero", "columns": 1, "gap": "1.5rem", "align": "stretch" }, + "visibility": { "desktop": true, "tablet": true, "mobile": true }, + "visible": true, + "widgets": [ + { + "id": "widget-hero-main", "type": "hero", "version": "1.0.0", "order": 1, + "padding": "0.5rem 0", + "visibility": { "desktop": true, "tablet": true, "mobile": true }, + "visible": true, + "props": { + "title": { "en": "Welcome to Acme", "ru": "Добро пожаловать в Acme" }, + "subtitle": { "en": "Everything, delivered", "ru": "Всё, с доставкой" }, + "ctaLabel": { "en": "Start Shopping", "ru": "Начать покупки" } + } + } + ] + } + ] + } + ], + + "modules": { "sellerManagement": { "enabled": false } } +} +``` + +--- + +## Field reference + +### `tenant` (required) — [`tenant.model.ts`](../src/app/shared/models/config/tenant.model.ts) + +Identity and locale/currency defaults. `host` must exactly match the domain nginx forwards — mismatches are how tenant leakage bugs happen. `websiteBaseUrl` / `builderBaseUrl` / `backofficeBaseUrl` are the three surfaces this same brand can present (storefront, page builder, admin backoffice) — each gets its own subdomain or host. + +### `branding` (required) — [`branding.model.ts`](../src/app/shared/models/config/branding.model.ts) + +Everything a human sees as "this is the brand": name, logo variants, favicon, support contact. `logoCompactUrl` is used where header space is tight (mobile, collapsed nav). + +### `theme` (required) — [`theme.model.ts`](../src/app/shared/models/config/theme.model.ts) + +Full design-token set: color palette, typography, spacing scale, border radii, shadows. Consumed by [`theme-css-vars.mapper.ts`](../src/app/theme/mappers/theme-css-vars.mapper.ts) → CSS custom properties at runtime. `mode` is `light` or `dark`; ship a matching palette for whichever `themeId` you pick. + +### `company` (required) — [`company.model.ts`](../src/app/shared/models/config/company.model.ts) + +Legal/registration data for invoices, footer legal text, compliance pages. Not user-facing branding — this is the registered entity behind the brand. + +### `featureFlags` (required) — [`feature-flags.model.ts`](../src/app/shared/models/config/feature-flags.model.ts) + +Coarse on/off switches for major product areas (wishlist, blog, chat, loyalty, gift cards, invoices...). Distinct from `features` below — this set gates bigger surfaces. + +### `features` (optional) — [`features-config.model.ts`](../src/app/shared/models/config/features-config.model.ts) + +Finer-grained per-marketplace toggles (comments, recommendations, badges, etc). Omit any key to fall back to `DEFAULT_MARKETPLACE_FEATURES_CONFIG` (all `true`). + +### `apiEndpoints` (required) — [`api-endpoints.model.ts`](../src/app/shared/models/config/api-endpoints.model.ts) + +Per-surface endpoint overrides. `bootstrap` itself is always required; `website`/`builder`/`backoffice` may stay empty objects to use defaults. + +### `localization` (required) — [`localization.model.ts`](../src/app/shared/models/config/localization.model.ts) + +Locale list, default, per-locale currency, and dictionary URLs (`/assets/i18n/.json` or a CDN URL). Every locale in `tenant.supportedLocales` needs an entry here. + +### `seo` (required) — [`seo.model.ts`](../src/app/shared/models/config/seo.model.ts) + +Default meta tags plus per-`pageKey` overrides, consumed by [`seo.service.ts`](../src/app/services/seo.service.ts). + +### `permissions` (required) — [`permissions.model.ts`](../src/app/shared/models/config/permissions.model.ts) + +Role → permission-key map used by frontend guards. The frontend never hardcodes role logic beyond hiding affordances — see `BACKEND-INTEGRATION.md` §4.6; the authoritative check still happens server-side per request. + +### `header` (optional) — [`header-config.model.ts`](../src/app/shared/models/config/header-config.model.ts) + +Which header elements show (`showSearch`, `showCart`, `showRegion`, ...) and `layout` (`default` | `centered`). Omit to use `DEFAULT_HEADER_CONFIG`. + +### `catalog` (optional) — [`catalog-config.model.ts`](../src/app/shared/models/config/catalog-config.model.ts) + +Product-listing behavior: layout, sort options, enabled filters, which badges/breadcrumbs show. + +### `layout` (optional) — [`layout.model.ts`](../src/app/shared/models/config/layout.model.ts) + +Top-level page shell type. + +### `navigation` (required) — [`navigation.model.ts`](../src/app/shared/models/config/navigation.model.ts) + +Header and footer link lists, each entry `{ id, labelKey, route, icon?, order }`. `labelKey` resolves against the locale dictionaries in `localization`. + +### `footer` (optional) — [`footer-config.model.ts`](../src/app/shared/models/config/footer-config.model.ts) + +Payment-method icons, per-locale copyright text, legal page keys to link. + +### `productPage` (optional) — [`product-page-config.model.ts`](../src/app/shared/models/config/product-page-config.model.ts) + +Reviews, questions, tabs, related-products behavior on the PDP. + +### `userExperience` (optional) — [`user-experience-config.model.ts`](../src/app/shared/models/config/user-experience-config.model.ts) + +Wishlist, compare, recently-viewed, share, saved-searches — limits and toggles. + +### `pages` (required) — [`page.model.ts`](../src/app/shared/models/config/page.model.ts) + +The actual page tree. Each page has a route, layout, and a `sections[]` list; each section has `layout` (`hero` | `grid` | `carousel` | ...), responsive `visibility`, and `widgets[]`. Each widget references a `type` + `version` resolved against the widget manifest (see `widgetRegistry`) and carries its own `props` (usually per-locale strings). This is what the page builder edits and what [`section-engine.service.ts`](../src/app/dynamic-renderer/section-engine/section-engine.service.ts) renders. + +### `staticPages` (optional) — [`static-page.model.ts`](../src/app/shared/models/config/static-page.model.ts) + +Simple route → per-locale `{ title, html }` pages (about, privacy, terms, contacts) that don't need the full section/widget builder. + +### `widgetRegistry` (optional) — [`widget-registry.model.ts`](../src/app/shared/models/config/widget-registry.model.ts) + +URL to the widget manifest — the catalog of widget types/versions this brand's `pages[].sections[].widgets[]` are allowed to reference. See [`widget-manifest.service.ts`](../src/app/widgets/registry/widget-manifest.service.ts). + +### `modules` (optional) — [`platform-modules.model.ts`](../src/app/shared/models/config/platform-modules.model.ts) + +Platform-level capability gates that introduce a whole new scope (currently just `sellerManagement`), not a simple toggle. Absent or `undefined` = every module disabled, and existing marketplaces that never send this field behave exactly as before (ADR-011). A disabled module must add zero new routes/menus/API calls. + +### `seller` (optional, backend-resolved only) — [`seller.model.ts`](../src/app/shared/models/config/seller.model.ts) + +Present only when `modules.sellerManagement.enabled` is `true` **and** the request resolves beneath a specific seller. The frontend never decides this itself — same rule as tenant resolution (ADR-001): the backend resolves scope from the verified host/session, never from a client-supplied field. + +--- + +## Required vs optional at a glance + +| Required | Optional (sensible defaults exist) | +|---|---| +| `schemaVersion`, `generatedAt` | `features` | +| `tenant` | `header` | +| `branding` | `catalog` | +| `theme` | `layout` | +| `company` | `footer` | +| `featureFlags` | `productPage` | +| `apiEndpoints` | `userExperience` | +| `localization` | `staticPages` | +| `seo` | `widgetRegistry` | +| `permissions` | `modules` | +| `navigation` | `seller` (backend-resolved, never client-set) | +| `pages` | | + +## Going live — checklist + +- [ ] `Marketplace` row created (§11 of `BACKEND-INTEGRATION.md`), `lifecycleState` progressed to `production_ready` +- [ ] `MarketplaceDomain` row(s) added, `type: 'production'` +- [ ] DNS A record → server IP +- [ ] TLS: wildcard subdomain (no action) or `add-domain.sh` / reconciler for a custom domain +- [ ] `api.` configured (`configure-api-domain.sh`) — CORS echoes the exact storefront origin, never `*` with credentials +- [ ] Backend returns full bootstrap JSON for that `Host` — validate with `curl -fsS https://api./bootstrap | jq .` +- [ ] `curl -I https:///health` → `200` +- [ ] Every locale in `tenant.supportedLocales` has a `localization.dictionaries[]` entry and a `localization.currencyByLocale` entry +- [ ] `navigation.header`/`footer` routes match real routes; `staticPages`/`pages[].route` keys line up with `legalPageKeys` diff --git a/docs/backend/BACKEND-INTEGRATION.md b/docs/backend/BACKEND-INTEGRATION.md index 3a2c533..a492b55 100644 --- a/docs/backend/BACKEND-INTEGRATION.md +++ b/docs/backend/BACKEND-INTEGRATION.md @@ -37,6 +37,8 @@ New endpoints use `/api/v2/...`; legacy endpoints (documented in `../../BACKEND- One deployed bundle serves **every** customer domain; there is no per-tenant build. The chain: `TenantResolverService` reads the browser hostname → `ApiConfigService` uses one API host per base domain (`example.com` and `store1.example.com` both use `api.example.com`) → nginx validates the browser origin and forwards the full storefront hostname as `X-Storefront-Host` → the backend resolves the tenant from that trusted header, **never** from the shared API `Host`, and treats the frontend-supplied hostname as an untrusted hint, deriving real scope from the authenticated session. A tenant must never read another tenant's data — return `403`, not an empty result. Bootstrap carries only what's needed before app start (branding, languages, homepage layout, navigation, enabled widgets, footer pages); never products, orders, cart, or users. +**`published: boolean`** — required top-level field on the bootstrap response (2026-08-22). `true` once the marketplace has a `publishedRevision` (§11); `false` while it has none — every other field may then be anything, including a partial/placeholder row, since the frontend ignores the rest of the body and renders its own built-in all-features-on placeholder instead (`ConfigService`, see [Brand-bootstrap design](../superpowers/specs/2026-08-22-frontend-default-bootstrap-design.md)). Field absent (old backend) is read as `true` for backward compatibility — do not omit it once built. + ### 1.2 Auth — read before writing any endpoint Auth lives in `@marketplaces/auth` (published from vitanovaPackages; see `../PACKAGES-USAGE.md`). Two mechanisms exist client-side: @@ -432,6 +434,7 @@ Write: `POST /companies/{id}/projects`, `/projects/{id}/stores`, `/stores/{id}/p Append here whenever a section changes. Newest first. +- **2026-08-22** — Added `published: boolean` to the bootstrap response contract (§1.1): frontend now renders a built-in generic placeholder (all feature flags on) for any marketplace with no published revision, decided from this one field rather than HTTP status. See [Brand-bootstrap design](../superpowers/specs/2026-08-22-frontend-default-bootstrap-design.md). - **2026-08-22** — Consolidated the entire `docs/backend/` set into this one file per the single-doc rule; folded in the admin credential (login/password) auth handoff (§1.2). No contract content changed; the former per-phase files are removed. - **2026-08-21** — Harvest additions (`FH-*`) folded in across §4–§13, from the parallel-platform review ([ADR-0006](../context/adrs/ADR-0006-harvest-mechanisms-from-the-parallel-platform.md)): atomic reservation, inventory journal, idempotency constraints, session model, origin allowlist, secret envelope, order public token, revision immutability/clone/preview, tenant resolution hardening, server-side content validation, digital code pools, order-manager contour, provider-agnostic identity + VK/Yandex + Telegram migration, host hardening. - **2026-08-18** — RoutingContext + Company/Project/PaymentPoint hierarchy added (partner provisioning); backend ownership answered (separate developer). diff --git a/docs/superpowers/plans/2026-08-22-frontend-default-bootstrap.md b/docs/superpowers/plans/2026-08-22-frontend-default-bootstrap.md new file mode 100644 index 0000000..0de143d --- /dev/null +++ b/docs/superpowers/plans/2026-08-22-frontend-default-bootstrap.md @@ -0,0 +1,596 @@ +# Frontend Default Bootstrap Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** When a marketplace has no published revision, the frontend renders a built-in, all-features-on generic placeholder instead of a broken/empty page — decided from one explicit `published: boolean` field on the `/bootstrap` response, not from HTTP status. + +**Architecture:** Add `published` to `BootstrapConfig`. Add one new frontend-only constant `DEFAULT_BOOTSTRAP: BootstrapConfig`, composed from existing `DEFAULT_HEADER_CONFIG` / `DEFAULT_MARKETPLACE_FEATURES_CONFIG` / `DEFAULT_PLATFORM_MODULES_CONFIG` plus a hardcoded generic shell for the sections with no existing default (`tenant`, `branding`, `theme`, `company`, `featureFlags`, `apiEndpoints`, `localization`, `seo`, `permissions`, `navigation`, `footer`, `pages`, `staticPages`). `ConfigService.loadBootstrap()` swaps its cached snapshot to `DEFAULT_BOOTSTRAP` whenever the fetched response has `published === false`. No provider changes. + +**Tech Stack:** Angular 22, RxJS, Jasmine/Karma (existing `.spec.ts` pattern in this repo). + +## Global Constraints + +- `published` missing/undefined on a response must be treated as `true` (backward compatible — matches the existing pattern for `modules`/ADR-011). +- Fallback is a whole-object swap — no field-level merging with the real response. +- Fallback triggers only on the explicit `published: false` signal, never on HTTP failure (existing `catchError` behavior in `ConfigService` is untouched). +- `DEFAULT_BOOTSTRAP.featureFlags` and `.features` must have every flag `true`. +- Reuse `DEFAULT_HEADER_CONFIG`, `DEFAULT_MARKETPLACE_FEATURES_CONFIG`, `DEFAULT_PLATFORM_MODULES_CONFIG` as-is — do not redefine their values inline. + +--- + +### Task 1: Add `published` to the `BootstrapConfig` contract + +**Files:** +- Modify: `src/app/shared/models/config/bootstrap-config.model.ts` +- Modify: `src/assets/mock/bootstrap/bootstrap.json` (add `"published": true` so the existing mock keeps behaving as "already live") + +**Interfaces:** +- Produces: `BootstrapConfig.published: boolean` — consumed by Task 3 (`ConfigService`). + +- [ ] **Step 1: Add the field to the interface** + +In `src/app/shared/models/config/bootstrap-config.model.ts`, add `published` right after `generatedAt`: + +```ts +export interface BootstrapConfig { + schemaVersion: string; + generatedAt: string; + published: boolean; + tenant: TenantConfig; + branding: BrandingConfig; + theme: ThemeConfig; + company: CompanyConfig; + featureFlags: FeatureFlagsConfig; + features?: MarketplaceFeaturesConfig; + apiEndpoints: ApiEndpointsConfig; + localization: LocalizationConfig; + seo: SeoConfig; + permissions: PermissionsConfig; + header?: HeaderConfig; + catalog?: CatalogConfig; + layout?: PlatformLayoutConfig; + navigation: NavigationConfig; + footer?: FooterConfig; + productPage?: ProductPageConfig; + userExperience?: UserExperienceConfig; + pages: PageConfig[]; + staticPages?: StaticPagesConfig; + widgetRegistry?: WidgetRegistryConfig; + modules?: PlatformModulesConfig; + seller?: SellerConfig; +} +``` + +- [ ] **Step 2: Update the mock fixture** + +In `src/assets/mock/bootstrap/bootstrap.json`, add `"published": true,` as the line right after `"generatedAt": "2026-07-03T00:00:00Z",` (line 3). + +- [ ] **Step 3: Compile check** + +Run: `npx tsc --noEmit -p tsconfig.json` +Expected: no new errors referencing `bootstrap-config.model.ts` or `bootstrap.json` (the mock file isn't type-checked, but any TS consumer that builds a `BootstrapConfig` object literal without `published` will now fail — confirms the field is wired through). + +- [ ] **Step 4: Commit** + +```bash +git add src/app/shared/models/config/bootstrap-config.model.ts src/assets/mock/bootstrap/bootstrap.json +git commit -m "feat: add published field to BootstrapConfig contract" +``` + +--- + +### Task 2: Add the `DEFAULT_BOOTSTRAP` constant + +**Files:** +- Create: `src/app/shared/models/config/default-bootstrap.const.ts` +- Modify: `src/app/shared/models/config/index.ts` (export the new file) +- Test: `src/app/shared/models/config/default-bootstrap.const.spec.ts` + +**Interfaces:** +- Consumes: `BootstrapConfig` (Task 1), `DEFAULT_HEADER_CONFIG` from `./header-config.model`, `DEFAULT_MARKETPLACE_FEATURES_CONFIG` from `./features-config.model`, `DEFAULT_PLATFORM_MODULES_CONFIG` from `./platform-modules.model`. +- Produces: `DEFAULT_BOOTSTRAP: BootstrapConfig` — consumed by Task 3 (`ConfigService`). + +- [ ] **Step 1: Write the failing test** + +Create `src/app/shared/models/config/default-bootstrap.const.spec.ts`: + +```ts +import { DEFAULT_BOOTSTRAP } from './default-bootstrap.const'; + +describe('DEFAULT_BOOTSTRAP', () => { + it('is marked unpublished', () => { + expect(DEFAULT_BOOTSTRAP.published).toBe(false); + }); + + it('has every feature flag turned on', () => { + Object.values(DEFAULT_BOOTSTRAP.featureFlags).forEach(value => { + expect(value).toBe(true); + }); + }); + + it('has every optional MarketplaceFeaturesConfig flag turned on', () => { + expect(DEFAULT_BOOTSTRAP.features).toBeDefined(); + Object.values(DEFAULT_BOOTSTRAP.features!).forEach(value => { + expect(value).toBe(true); + }); + }); + + it('has at least one page with a hero section', () => { + expect(DEFAULT_BOOTSTRAP.pages.length).toBeGreaterThan(0); + const heroSection = DEFAULT_BOOTSTRAP.pages[0].sections.find(s => s.type === 'hero'); + expect(heroSection).toBeDefined(); + }); + + it('has a generic brand name, not a real tenant name', () => { + expect(DEFAULT_BOOTSTRAP.branding.brandName).toBe('Marketplace'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `ng test --include='**/default-bootstrap.const.spec.ts' --watch=false` +Expected: FAIL — `Cannot find module './default-bootstrap.const'` + +- [ ] **Step 3: Write the constant** + +Create `src/app/shared/models/config/default-bootstrap.const.ts`: + +```ts +import { BootstrapConfig } from './bootstrap-config.model'; +import { DEFAULT_HEADER_CONFIG } from './header-config.model'; +import { DEFAULT_MARKETPLACE_FEATURES_CONFIG } from './features-config.model'; +import { DEFAULT_PLATFORM_MODULES_CONFIG } from './platform-modules.model'; + +/** + * Whole-object fallback rendered whenever the backend reports + * `published: false` for the resolved marketplace (no published revision + * yet). Every feature flag is on so it doubles as a full-surface product + * demo. See docs/superpowers/specs/2026-08-22-frontend-default-bootstrap-design.md. + */ +export const DEFAULT_BOOTSTRAP: BootstrapConfig = { + schemaVersion: '1.0.0', + generatedAt: new Date(0).toISOString(), + published: false, + + tenant: { + id: 'tenant-default-unpublished', + slug: 'default', + code: 'DEFAULT', + host: 'default.local', + name: 'Marketplace', + websiteBaseUrl: 'https://marketplace.local', + builderBaseUrl: 'https://builder.marketplace.local', + backofficeBaseUrl: 'https://backoffice.marketplace.local', + defaultLocale: 'en', + supportedLocales: ['en'], + defaultCurrency: 'USD', + supportedCurrencies: ['USD'], + timezone: 'UTC', + }, + + branding: { + brandName: 'Marketplace', + legalName: 'Marketplace', + slogan: 'Your store, coming soon', + logoUrl: '/icons/icon-192x192.png', + logoCompactUrl: '/icons/icon-192x192.png', + faviconUrl: '/favicon.ico', + appIconUrl: '/icons/icon-192x192.png', + supportEmail: 'support@marketplace.local', + }, + + theme: { + themeId: 'default-light', + mode: 'light', + palette: { + primary: '#497671', + secondary: '#a1b4b5', + accent: '#a7ceca', + success: '#10b981', + warning: '#f59e0b', + danger: '#ef4444', + info: '#3b82f6', + textPrimary: '#1e3c38', + textSecondary: '#667a77', + backgroundPrimary: '#ffffff', + backgroundSecondary: '#f5f5f5', + border: '#d3dad9', + }, + typography: { + primaryFontFamily: 'DM Sans, sans-serif', + headingFontFamily: 'DM Sans, sans-serif', + baseFontSize: 16, + }, + spacing: { unit: 4, scale: [0, 4, 8, 12, 16, 24, 32, 48] }, + borderRadiusScale: { sm: '8px', md: '12px', lg: '16px', xl: '22px' }, + shadows: { + sm: '0 2px 8px rgba(0,0,0,0.1)', + md: '0 4px 12px rgba(0,0,0,0.15)', + lg: '0 12px 32px rgba(73,118,113,0.2)', + }, + iconSet: 'default', + }, + + company: { + companyName: 'Marketplace', + address: { country: '', city: '' }, + contacts: { email: 'support@marketplace.local' }, + }, + + featureFlags: { + wishlist: true, + compare: true, + reviews: true, + questions: true, + comments: true, + recommendations: true, + blog: true, + chat: true, + analytics: true, + notifications: true, + coupons: true, + loyalty: true, + giftCards: true, + invoices: true, + }, + features: DEFAULT_MARKETPLACE_FEATURES_CONFIG, + + apiEndpoints: { + bootstrap: { path: '/bootstrap', method: 'GET', timeoutMs: 10000 }, + website: {}, + builder: {}, + backoffice: {}, + }, + + localization: { + defaultLocale: 'en', + supportedLocales: ['en'], + currencyByLocale: { en: 'USD' }, + dictionaries: [{ locale: 'en', dictionaryUrl: '/assets/i18n/en.json', version: '1.0.0' }], + }, + + seo: { + default: { title: 'Marketplace', description: 'Your store, coming soon', robots: 'noindex,nofollow' }, + byPageKey: { + home: { title: 'Marketplace - Home', description: 'Your store, coming soon', robots: 'noindex,nofollow' }, + }, + }, + + permissions: { definitions: [], roles: [] }, + + header: DEFAULT_HEADER_CONFIG, + + navigation: { + header: [ + { id: 'nav-home', labelKey: 'nav.home', route: '/', icon: 'home', order: 1 }, + { id: 'nav-search', labelKey: 'nav.search', route: '/search', icon: 'search', order: 2 }, + { id: 'nav-cart', labelKey: 'nav.cart', route: '/cart', icon: 'cart', order: 3 }, + ], + footer: [ + { id: 'footer-about', labelKey: 'nav.about', route: '/about-us', order: 1 }, + { id: 'footer-contacts', labelKey: 'nav.contacts', route: '/contacts', order: 2 }, + ], + }, + + footer: { + paymentIcons: [], + copyrightText: { en: '© 2026 Marketplace. All rights reserved.' }, + legalPageKeys: ['about-us', 'privacy-policy', 'terms-of-service'], + }, + + staticPages: { + 'about-us': { + route: '/about-us', + title: { en: 'About Us' }, + html: { en: '

About Us

This marketplace has not published its storefront yet.

' }, + }, + 'privacy-policy': { + route: '/privacy-policy', + title: { en: 'Privacy Policy' }, + html: { en: '

Privacy Policy

Placeholder content until publish.

' }, + }, + 'terms-of-service': { + route: '/terms-of-service', + title: { en: 'Terms of Service' }, + html: { en: '

Terms of Service

Placeholder content until publish.

' }, + }, + }, + + pages: [ + { + id: 'page-home', + key: 'home', + title: 'Home', + route: { path: '/', exact: true }, + layout: { type: 'default' }, + seoKey: 'home', + visible: true, + sections: [ + { + id: 'section-hero', + type: 'hero', + order: 1, + layout: { strategy: 'hero', columns: 1, gap: '1.5rem', align: 'stretch' }, + visibility: { desktop: true, tablet: true, mobile: true }, + visible: true, + widgets: [ + { + id: 'widget-hero-main', + type: 'hero', + version: '1.0.0', + order: 1, + padding: '0.5rem 0', + visibility: { desktop: true, tablet: true, mobile: true }, + visible: true, + props: { + title: { en: 'Welcome to Marketplace' }, + subtitle: { en: 'This storefront has not been published yet' }, + ctaLabel: { en: 'Learn more' }, + }, + }, + ], + }, + { + id: 'section-categories', + type: 'categories', + order: 2, + layout: { strategy: 'grid', columns: 1, gap: '1.5rem', align: 'stretch' }, + visibility: { desktop: true, tablet: true, mobile: true }, + visible: true, + widgets: [ + { + id: 'widget-categories-root', + type: 'categories', + version: '1.0.0', + order: 1, + padding: '0.25rem 0', + visibility: { desktop: true, tablet: true, mobile: true }, + visible: true, + props: { title: 'Categories', source: 'root', emptyMessage: 'No categories available' }, + }, + ], + }, + ], + }, + ], + + modules: DEFAULT_PLATFORM_MODULES_CONFIG, +}; +``` + +- [ ] **Step 4: Export it from the barrel file** + +In `src/app/shared/models/config/index.ts`, add one line (alphabetical position, after `catalog-config.model`): + +```ts +export * from './default-bootstrap.const'; +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `ng test --include='**/default-bootstrap.const.spec.ts' --watch=false` +Expected: PASS (5 specs) + +- [ ] **Step 6: Commit** + +```bash +git add src/app/shared/models/config/default-bootstrap.const.ts src/app/shared/models/config/default-bootstrap.const.spec.ts src/app/shared/models/config/index.ts +git commit -m "feat: add DEFAULT_BOOTSTRAP placeholder config" +``` + +--- + +### Task 3: Swap to `DEFAULT_BOOTSTRAP` in `ConfigService` when unpublished + +**Files:** +- Modify: `src/app/core/config/config.service.ts` +- Test: `src/app/core/config/config.service.spec.ts` (new file — none exists today) + +**Interfaces:** +- Consumes: `DEFAULT_BOOTSTRAP` (Task 2), `BootstrapConfig.published` (Task 1), existing `CONFIG_PROVIDER` token / `ConfigProvider.loadBootstrap()`. +- Produces: no new public method — `loadBootstrap()` and `getBootstrapSnapshot()` keep their existing signatures; behavior changes only in which object ends up cached. + +- [ ] **Step 1: Write the failing tests** + +Create `src/app/core/config/config.service.spec.ts`: + +```ts +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { ConfigService } from './config.service'; +import { CONFIG_PROVIDER } from './config-provider.token'; +import { ConfigProvider } from './config-provider.interface'; +import { BootstrapConfig, DEFAULT_BOOTSTRAP } from '../../shared/models/config'; + +function makeRealBootstrap(overrides: Partial = {}): BootstrapConfig { + return { ...DEFAULT_BOOTSTRAP, published: true, tenant: { ...DEFAULT_BOOTSTRAP.tenant, name: 'Acme' }, ...overrides }; +} + +describe('ConfigService', () => { + let provider: jasmine.SpyObj; + + function setup(response: BootstrapConfig): ConfigService { + provider = jasmine.createSpyObj('ConfigProvider', ['loadBootstrap']); + provider.loadBootstrap.and.returnValue(of(response)); + TestBed.configureTestingModule({ + providers: [ConfigService, { provide: CONFIG_PROVIDER, useValue: provider }], + }); + return TestBed.inject(ConfigService); + } + + it('caches the real response when published is true', done => { + const real = makeRealBootstrap(); + const service = setup(real); + + service.loadBootstrap().subscribe(result => { + expect(result.tenant.name).toBe('Acme'); + expect(service.getBootstrapSnapshot()).toEqual(real); + done(); + }); + }); + + it('swaps to DEFAULT_BOOTSTRAP when published is false', done => { + const draft = makeRealBootstrap({ published: false }); + const service = setup(draft); + + service.loadBootstrap().subscribe(result => { + expect(result).toEqual(DEFAULT_BOOTSTRAP); + expect(service.getBootstrapSnapshot()).toEqual(DEFAULT_BOOTSTRAP); + done(); + }); + }); + + it('treats a missing published field as published (backward compatible)', done => { + const legacy = makeRealBootstrap(); + delete (legacy as Partial).published; + const service = setup(legacy); + + service.loadBootstrap().subscribe(result => { + expect(result.tenant.name).toBe('Acme'); + expect(result).not.toEqual(DEFAULT_BOOTSTRAP); + done(); + }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `ng test --include='**/config.service.spec.ts' --watch=false` +Expected: FAIL on the "swaps to DEFAULT_BOOTSTRAP" spec — `result` currently equals `draft` (unpublished, unswapped), not `DEFAULT_BOOTSTRAP`. + +- [ ] **Step 3: Implement the swap** + +Replace the body of `src/app/core/config/config.service.ts` with: + +```ts +import { Injectable, inject, signal } from '@angular/core'; +import { Observable, of, throwError } from 'rxjs'; +import { catchError, map, shareReplay, tap } from 'rxjs/operators'; +import { BootstrapConfig, DEFAULT_BOOTSTRAP } from '../../shared/models/config'; +import { CONFIG_PROVIDER } from './config-provider.token'; + +@Injectable({ providedIn: 'root' }) +export class ConfigService { + private readonly provider = inject(CONFIG_PROVIDER); + + private bootstrapSnapshot: BootstrapConfig | null = null; + private bootstrap$?: Observable; + private readonly revisionState = signal(0); + + readonly bootstrapRevision = this.revisionState.asReadonly(); + + loadBootstrap(forceRefresh: boolean = false): Observable { + if (this.bootstrapSnapshot && !forceRefresh && this.bootstrap$) { + return this.bootstrap$; + } + + if (!this.bootstrap$ || forceRefresh) { + this.bootstrap$ = this.provider.loadBootstrap().pipe( + map(config => (config.published === false ? DEFAULT_BOOTSTRAP : config)), + tap(config => { + this.bootstrapSnapshot = config; + this.revisionState.update(value => value + 1); + }), + shareReplay(1), + catchError(error => { + this.bootstrap$ = undefined; + this.bootstrapSnapshot = null; + return throwError(() => error); + }) + ); + } + + return this.bootstrap$; + } + + getBootstrapSnapshot(): BootstrapConfig | null { + return this.bootstrapSnapshot; + } + + applyBootstrapOverride(next: BootstrapConfig): void { + const cloned = JSON.parse(JSON.stringify(next)) as BootstrapConfig; + this.bootstrapSnapshot = cloned; + this.bootstrap$ = of(cloned); + this.revisionState.update(value => value + 1); + } +} +``` + +The only change from the current file: the `map` operator inserted before `tap`, and the `DEFAULT_BOOTSTRAP` import. `config.published === false` (strict) rather than `!config.published` is deliberate — it makes `undefined`/missing explicitly fall through to "treat as published," matching the backward-compatibility constraint. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `ng test --include='**/config.service.spec.ts' --watch=false` +Expected: PASS (3 specs) + +- [ ] **Step 5: Run the full unit suite to check for regressions** + +Run: `ng test --watch=false` +Expected: PASS, no new failures (existing consumers of `ConfigService` only rely on `loadBootstrap()`/`getBootstrapSnapshot()`, unchanged signatures). + +- [ ] **Step 6: Commit** + +```bash +git add src/app/core/config/config.service.ts src/app/core/config/config.service.spec.ts +git commit -m "feat: fall back to DEFAULT_BOOTSTRAP when marketplace is unpublished" +``` + +--- + +### Task 4: E2E smoke test for the unpublished placeholder + +**Files:** +- Modify: `e2e/smoke.spec.ts` (existing Playwright smoke suite) + +**Interfaces:** +- Consumes: Playwright route interception (`page.route`), `DEFAULT_BOOTSTRAP.branding.brandName` (Task 2) as the assertion target. + +- [ ] **Step 1: Read the existing smoke spec to match its conventions** + +Run: `cat e2e/smoke.spec.ts` (or open the file) — confirm the existing pattern for intercepting `/bootstrap` if one exists, and the base URL fixture used by other specs in this file. + +- [ ] **Step 2: Write the failing test** + +Add to `e2e/smoke.spec.ts`: + +```ts +test('renders the placeholder home page when the marketplace is unpublished', async ({ page }) => { + await page.route('**/bootstrap', route => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ schemaVersion: '1.0.0', generatedAt: new Date().toISOString(), published: false }), + }) + ); + + await page.goto('/'); + + await expect(page.getByText('Welcome to Marketplace')).toBeVisible(); +}); +``` + +- [ ] **Step 3: Run it to verify it fails** + +Run: `npx playwright test e2e/smoke.spec.ts -g "unpublished"` +Expected: FAIL — either the route interception payload is rejected client-side (schema mismatch) or the text isn't found, since Task 1–3 aren't wired in yet if this task runs standalone. If Tasks 1–3 are already merged, this should already pass; if it fails for a reason other than "text not found" (e.g. a network error), fix the intercepted payload shape first, not the app code. + +- [ ] **Step 4: Confirm it passes against the real implementation** + +Run: `npx playwright test e2e/smoke.spec.ts -g "unpublished"` +Expected: PASS, once Tasks 1–3 are committed. + +- [ ] **Step 5: Commit** + +```bash +git add e2e/smoke.spec.ts +git commit -m "test: add e2e smoke test for unpublished-marketplace placeholder" +``` + +--- + +## Self-review notes + +- **Spec coverage:** §1 (backend `published` field) → Task 1. §2 (whole-object swap, `DEFAULT_BOOTSTRAP` composed from existing `DEFAULT_*` constants) → Task 2. §2 (`ConfigService` trigger point) → Task 3. §3 (error handling: missing field = published, HTTP failure unchanged) → covered by Task 3 Step 1 test 3 and by leaving `catchError` untouched. §4 (testing) → Tasks 2–4 cover unit + E2E; schema-shape check is TypeScript compilation itself (Task 2 Step 3 must compile against `BootstrapConfig`). +- **Backend-side resolution logic** (how the backend decides `published` from `MarketplaceRevision.status`) is explicitly out of scope per the spec — not a frontend-repo task. diff --git a/docs/superpowers/specs/2026-08-22-frontend-default-bootstrap-design.md b/docs/superpowers/specs/2026-08-22-frontend-default-bootstrap-design.md new file mode 100644 index 0000000..add4238 --- /dev/null +++ b/docs/superpowers/specs/2026-08-22-frontend-default-bootstrap-design.md @@ -0,0 +1,85 @@ +# Frontend default bootstrap (unpublished-marketplace placeholder) + +**Date:** 2026-08-22 +**Status:** approved (decided by project owner in-session, no further review requested) + +## Problem + +Production `/bootstrap` has no fallback today. A marketplace with no published revision either 404s or returns whatever partial row the backend has — frontend has nothing sane to render. Need a placeholder that shows immediately for any brand before its first publish, with every feature switched on so it doubles as a full product demo. + +## Decision + +Whole-object fallback, decided client-side from one explicit backend signal. + +### 1. Backend contract change + +Add one required top-level field to the `/bootstrap` response: + +```ts +interface BootstrapConfig { + schemaVersion: string; + generatedAt: string; + published: boolean; // NEW — false until MarketplaceRevision.status = 'published' + tenant: TenantConfig; + ... +} +``` + +`published` mirrors whether the marketplace has a `publishedRevision` (see `MarketplaceRevision.status` in `BACKEND-INTEGRATION.md` §11) — not `lifecycleState` directly, since a marketplace can be `live` while a *new* draft revision sits unpublished. Backend still returns full real `tenant`/`branding`/etc when `published: true`; when `false` it may return anything or the last-known real data — frontend ignores every other field in that case (see §2). + +### 2. Frontend: whole-object swap + +New constant, colocated with the other `DEFAULT_*` config constants: + +```ts +// src/app/shared/models/config/default-bootstrap.const.ts +export const DEFAULT_BOOTSTRAP: BootstrapConfig = { + schemaVersion: '1.0.0', + generatedAt: new Date(0).toISOString(), + published: false, + tenant: { /* generic placeholder — brandName 'Marketplace', no real domain */ }, + branding: { brandName: 'Marketplace', ... }, + theme: { /* the existing default-light palette from bootstrap.json */ }, + featureFlags: { wishlist: true, compare: true, reviews: true, blog: true, chat: true, + analytics: true, notifications: true, coupons: true, loyalty: true, + giftCards: true, invoices: true }, // everything ON + features: DEFAULT_MARKETPLACE_FEATURES_CONFIG, // reused, already all-true + header: DEFAULT_HEADER_CONFIG, // reused + modules: DEFAULT_PLATFORM_MODULES_CONFIG, // reused (sellerManagement off — real module gate, not a feature flag) + navigation: { /* hardcoded generic nav */ }, + pages: [ /* hardcoded generic home page, hero+categories+featured, same shape as bootstrap.json */ ], + staticPages: { /* generic about/privacy/terms/contacts */ }, + ... +}; +``` + +`ConfigService.loadBootstrap()` gains one check after the provider emits: + +```ts +tap(config => { + const resolved = config.published ? config : DEFAULT_BOOTSTRAP; + this.bootstrapSnapshot = resolved; + this.revisionState.update(v => v + 1); +}), +``` + +No change to `ApiBootstrapProvider`, `MockBootstrapProvider`, or the `ConfigProvider` interface — the swap is a `ConfigService`-only concern, so it applies uniformly regardless of provider mode. + +### 3. Error handling + +- `published` missing/undefined from an old backend response → treat as `true` (backwards compatible: existing marketplaces that never send the field keep behaving exactly as today, same pattern already used for `modules`/ADR-011). +- Actual HTTP failure (network error, 5xx) stays a hard error — `catchError` behavior unchanged, no fallback. Fallback is only for the *known* "not published yet" case, not for "backend unreachable." (Matches your earlier answer: explicit signal, not HTTP-status-driven.) + +### 4. Testing + +- `ConfigService` unit test: `published: false` response → snapshot equals `DEFAULT_BOOTSTRAP`. +- `ConfigService` unit test: `published: true` → snapshot equals the real response, untouched. +- `ConfigService` unit test: `published` absent → snapshot equals the real response (back-compat). +- `DEFAULT_BOOTSTRAP` itself: a schema-shape test (it must satisfy `BootstrapConfig` — TypeScript already enforces this at compile time, so this is really just "does it compile"). +- One E2E smoke: a marketplace with no revision renders the placeholder home page without erroring. + +## Out of scope (explicitly deferred) + +- Field-level merge (real brand name + placeholder theme) — rejected in favor of simpler whole-object swap. +- Any admin-panel UI for previewing/editing the default — not asked for. +- Backend implementation of `published` resolution logic — backend team's own call once they build the service; this spec only fixes the wire contract. diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 9a2592c..3b58214 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -32,4 +32,22 @@ test.describe('smoke', () => { expect(errors, `console errors on first paint: ${errors.join('\n')}`).toEqual([]); }); + + test('renders the placeholder home page when the marketplace is unpublished', async ({ page }) => { + // This suite runs against the mock-data build (see comment at the top of + // playwright.config.ts), so MockBootstrapProvider fetches this static + // asset rather than a live /bootstrap endpoint - that's the URL to + // intercept here, not the real API path. + await page.route('**/assets/mock/bootstrap/bootstrap.json', route => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ schemaVersion: '1.0.0', generatedAt: new Date().toISOString(), published: false }), + }) + ); + + await page.goto('/'); + + await expect(page.getByText('Welcome to Marketplace')).toBeVisible(); + }); }); diff --git a/src/app/core/config/config.service.spec.ts b/src/app/core/config/config.service.spec.ts new file mode 100644 index 0000000..5f2ba27 --- /dev/null +++ b/src/app/core/config/config.service.spec.ts @@ -0,0 +1,57 @@ +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { ConfigService } from './config.service'; +import { CONFIG_PROVIDER } from './config-provider.token'; +import { ConfigProvider } from './config-provider.interface'; +import { BootstrapConfig, DEFAULT_BOOTSTRAP } from '../../shared/models/config'; + +function makeRealBootstrap(overrides: Partial = {}): BootstrapConfig { + return { ...DEFAULT_BOOTSTRAP, published: true, tenant: { ...DEFAULT_BOOTSTRAP.tenant, name: 'Acme' }, ...overrides }; +} + +describe('ConfigService', () => { + let provider: jasmine.SpyObj; + + function setup(response: BootstrapConfig): ConfigService { + provider = jasmine.createSpyObj('ConfigProvider', ['loadBootstrap']); + provider.loadBootstrap.and.returnValue(of(response)); + TestBed.configureTestingModule({ + providers: [ConfigService, { provide: CONFIG_PROVIDER, useValue: provider }], + }); + return TestBed.inject(ConfigService); + } + + it('caches the real response when published is true', done => { + const real = makeRealBootstrap(); + const service = setup(real); + + service.loadBootstrap().subscribe(result => { + expect(result.tenant.name).toBe('Acme'); + expect(service.getBootstrapSnapshot()).toEqual(real); + done(); + }); + }); + + it('swaps to DEFAULT_BOOTSTRAP when published is false', done => { + const draft = makeRealBootstrap({ published: false }); + const service = setup(draft); + + service.loadBootstrap().subscribe(result => { + expect(result).toEqual(DEFAULT_BOOTSTRAP); + expect(service.getBootstrapSnapshot()).toEqual(DEFAULT_BOOTSTRAP); + done(); + }); + }); + + it('treats a missing published field as published (backward compatible)', done => { + const legacy = makeRealBootstrap(); + delete (legacy as Partial).published; + const service = setup(legacy); + + service.loadBootstrap().subscribe(result => { + expect(result.tenant.name).toBe('Acme'); + expect(result).not.toEqual(DEFAULT_BOOTSTRAP); + done(); + }); + }); +}); diff --git a/src/app/core/config/config.service.ts b/src/app/core/config/config.service.ts index 3c2b6af..84129f6 100644 --- a/src/app/core/config/config.service.ts +++ b/src/app/core/config/config.service.ts @@ -1,7 +1,7 @@ import { Injectable, inject, signal } from '@angular/core'; import { Observable, of, throwError } from 'rxjs'; -import { catchError, shareReplay, tap } from 'rxjs/operators'; -import { BootstrapConfig } from '../../shared/models/config'; +import { catchError, map, shareReplay, tap } from 'rxjs/operators'; +import { BootstrapConfig, DEFAULT_BOOTSTRAP } from '../../shared/models/config'; import { CONFIG_PROVIDER } from './config-provider.token'; @Injectable({ providedIn: 'root' }) @@ -21,6 +21,7 @@ export class ConfigService { if (!this.bootstrap$ || forceRefresh) { this.bootstrap$ = this.provider.loadBootstrap().pipe( + map(config => (config.published === false ? DEFAULT_BOOTSTRAP : config)), tap(config => { this.bootstrapSnapshot = config; this.revisionState.update(value => value + 1); diff --git a/src/app/shared/models/config/bootstrap-config.model.ts b/src/app/shared/models/config/bootstrap-config.model.ts index b1cfcbe..ad7e09e 100644 --- a/src/app/shared/models/config/bootstrap-config.model.ts +++ b/src/app/shared/models/config/bootstrap-config.model.ts @@ -24,6 +24,7 @@ import { WidgetRegistryConfig } from './widget-registry.model'; export interface BootstrapConfig { schemaVersion: string; generatedAt: string; + published: boolean; tenant: TenantConfig; branding: BrandingConfig; theme: ThemeConfig; diff --git a/src/app/shared/models/config/default-bootstrap.const.spec.ts b/src/app/shared/models/config/default-bootstrap.const.spec.ts new file mode 100644 index 0000000..09ddee5 --- /dev/null +++ b/src/app/shared/models/config/default-bootstrap.const.spec.ts @@ -0,0 +1,30 @@ +import { DEFAULT_BOOTSTRAP } from './default-bootstrap.const'; + +describe('DEFAULT_BOOTSTRAP', () => { + it('is marked unpublished', () => { + expect(DEFAULT_BOOTSTRAP.published).toBe(false); + }); + + it('has every feature flag turned on', () => { + Object.values(DEFAULT_BOOTSTRAP.featureFlags).forEach(value => { + expect(value).toBe(true); + }); + }); + + it('has every optional MarketplaceFeaturesConfig flag turned on', () => { + expect(DEFAULT_BOOTSTRAP.features).toBeDefined(); + Object.values(DEFAULT_BOOTSTRAP.features!).forEach(value => { + expect(value).toBe(true); + }); + }); + + it('has at least one page with a hero section', () => { + expect(DEFAULT_BOOTSTRAP.pages.length).toBeGreaterThan(0); + const heroSection = DEFAULT_BOOTSTRAP.pages[0].sections.find(s => s.type === 'hero'); + expect(heroSection).toBeDefined(); + }); + + it('has a generic brand name, not a real tenant name', () => { + expect(DEFAULT_BOOTSTRAP.branding.brandName).toBe('Marketplace'); + }); +}); diff --git a/src/app/shared/models/config/default-bootstrap.const.ts b/src/app/shared/models/config/default-bootstrap.const.ts new file mode 100644 index 0000000..465ba21 --- /dev/null +++ b/src/app/shared/models/config/default-bootstrap.const.ts @@ -0,0 +1,226 @@ +import { BootstrapConfig } from './bootstrap-config.model'; +import { DEFAULT_HEADER_CONFIG } from './header-config.model'; +import { DEFAULT_MARKETPLACE_FEATURES_CONFIG } from './features-config.model'; +import { DEFAULT_PLATFORM_MODULES_CONFIG } from './platform-modules.model'; + +/** + * Whole-object fallback rendered whenever the backend reports + * `published: false` for the resolved marketplace (no published revision + * yet). Every feature flag is on so it doubles as a full-surface product + * demo. See docs/superpowers/specs/2026-08-22-frontend-default-bootstrap-design.md. + */ +export const DEFAULT_BOOTSTRAP: BootstrapConfig = { + schemaVersion: '1.0.0', + generatedAt: new Date(0).toISOString(), + published: false, + + tenant: { + id: 'tenant-default-unpublished', + slug: 'default', + code: 'DEFAULT', + host: 'default.local', + name: 'Marketplace', + websiteBaseUrl: 'https://marketplace.local', + builderBaseUrl: 'https://builder.marketplace.local', + backofficeBaseUrl: 'https://backoffice.marketplace.local', + defaultLocale: 'en', + supportedLocales: ['en'], + defaultCurrency: 'USD', + supportedCurrencies: ['USD'], + timezone: 'UTC', + }, + + branding: { + brandName: 'Marketplace', + legalName: 'Marketplace', + slogan: 'Your store, coming soon', + logoUrl: '/icons/icon-192x192.png', + logoCompactUrl: '/icons/icon-192x192.png', + faviconUrl: '/favicon.ico', + appIconUrl: '/icons/icon-192x192.png', + supportEmail: 'support@marketplace.local', + }, + + theme: { + themeId: 'default-light', + mode: 'light', + palette: { + primary: '#497671', + secondary: '#a1b4b5', + accent: '#a7ceca', + success: '#10b981', + warning: '#f59e0b', + danger: '#ef4444', + info: '#3b82f6', + textPrimary: '#1e3c38', + textSecondary: '#667a77', + backgroundPrimary: '#ffffff', + backgroundSecondary: '#f5f5f5', + border: '#d3dad9', + }, + typography: { + primaryFontFamily: 'DM Sans, sans-serif', + headingFontFamily: 'DM Sans, sans-serif', + baseFontSize: 16, + }, + spacing: { unit: 4, scale: [0, 4, 8, 12, 16, 24, 32, 48] }, + borderRadiusScale: { sm: '8px', md: '12px', lg: '16px', xl: '22px' }, + shadows: { + sm: '0 2px 8px rgba(0,0,0,0.1)', + md: '0 4px 12px rgba(0,0,0,0.15)', + lg: '0 12px 32px rgba(73,118,113,0.2)', + }, + iconSet: 'default', + }, + + company: { + companyName: 'Marketplace', + address: { country: '', city: '' }, + contacts: { email: 'support@marketplace.local' }, + }, + + featureFlags: { + wishlist: true, + compare: true, + reviews: true, + questions: true, + comments: true, + recommendations: true, + blog: true, + chat: true, + analytics: true, + notifications: true, + coupons: true, + loyalty: true, + giftCards: true, + invoices: true, + }, + features: DEFAULT_MARKETPLACE_FEATURES_CONFIG, + + apiEndpoints: { + bootstrap: { path: '/bootstrap', method: 'GET', timeoutMs: 10000 }, + website: {}, + builder: {}, + backoffice: {}, + }, + + localization: { + defaultLocale: 'en', + supportedLocales: ['en'], + currencyByLocale: { en: 'USD' }, + dictionaries: [{ locale: 'en', dictionaryUrl: '/assets/i18n/en.json', version: '1.0.0' }], + }, + + seo: { + default: { title: 'Marketplace', description: 'Your store, coming soon', robots: 'noindex,nofollow' }, + byPageKey: { + home: { title: 'Marketplace - Home', description: 'Your store, coming soon', robots: 'noindex,nofollow' }, + }, + }, + + permissions: { definitions: [], roles: [] }, + + header: DEFAULT_HEADER_CONFIG, + + navigation: { + header: [ + { id: 'nav-home', labelKey: 'nav.home', route: '/', icon: 'home', order: 1 }, + { id: 'nav-search', labelKey: 'nav.search', route: '/search', icon: 'search', order: 2 }, + { id: 'nav-cart', labelKey: 'nav.cart', route: '/cart', icon: 'cart', order: 3 }, + ], + footer: [ + { id: 'footer-about', labelKey: 'nav.about', route: '/about-us', order: 1 }, + { id: 'footer-contacts', labelKey: 'nav.contacts', route: '/contacts', order: 2 }, + ], + }, + + footer: { + paymentIcons: [], + copyrightText: { en: '© 2026 Marketplace. All rights reserved.' }, + legalPageKeys: ['about-us', 'privacy-policy', 'terms-of-service'], + }, + + staticPages: { + 'about-us': { + id: 'about-us', + slug: 'about-us', + route: '/about-us', + title: { en: 'About Us' }, + html: { en: '

About Us

This marketplace has not published its storefront yet.

' }, + }, + 'privacy-policy': { + id: 'privacy-policy', + slug: 'privacy-policy', + route: '/privacy-policy', + title: { en: 'Privacy Policy' }, + html: { en: '

Privacy Policy

Placeholder content until publish.

' }, + }, + 'terms-of-service': { + id: 'terms-of-service', + slug: 'terms-of-service', + route: '/terms-of-service', + title: { en: 'Terms of Service' }, + html: { en: '

Terms of Service

Placeholder content until publish.

' }, + }, + }, + + pages: [ + { + id: 'page-home', + key: 'home', + title: 'Home', + route: { path: '/', exact: true }, + layout: { type: 'default' }, + seoKey: 'home', + visible: true, + sections: [ + { + id: 'section-hero', + type: 'hero', + order: 1, + layout: { strategy: 'hero', columns: 1, gap: '1.5rem', align: 'stretch' }, + visibility: { desktop: true, tablet: true, mobile: true }, + visible: true, + widgets: [ + { + id: 'widget-hero-main', + type: 'hero', + version: '1.0.0', + order: 1, + padding: '0.5rem 0', + visibility: { desktop: true, tablet: true, mobile: true }, + visible: true, + props: { + title: { en: 'Welcome to Marketplace' }, + subtitle: { en: 'This storefront has not been published yet' }, + ctaLabel: { en: 'Learn more' }, + }, + }, + ], + }, + { + id: 'section-categories', + type: 'categories', + order: 2, + layout: { strategy: 'grid', columns: 1, gap: '1.5rem', align: 'stretch' }, + visibility: { desktop: true, tablet: true, mobile: true }, + visible: true, + widgets: [ + { + id: 'widget-categories-root', + type: 'categories', + version: '1.0.0', + order: 1, + padding: '0.25rem 0', + visibility: { desktop: true, tablet: true, mobile: true }, + visible: true, + props: { title: 'Categories', source: 'root', emptyMessage: 'No categories available' }, + }, + ], + }, + ], + }, + ], + + modules: DEFAULT_PLATFORM_MODULES_CONFIG, +}; diff --git a/src/app/shared/models/config/index.ts b/src/app/shared/models/config/index.ts index b94f466..aee4063 100644 --- a/src/app/shared/models/config/index.ts +++ b/src/app/shared/models/config/index.ts @@ -3,6 +3,7 @@ export * from './bootstrap-config.model'; export * from './branding.model'; export * from './catalog-config.model'; export * from './company.model'; +export * from './default-bootstrap.const'; export * from './feature-flags.model'; export * from './features-config.model'; export * from './footer-config.model'; diff --git a/src/assets/mock/bootstrap/bootstrap.json b/src/assets/mock/bootstrap/bootstrap.json index c8596b0..668fdac 100644 --- a/src/assets/mock/bootstrap/bootstrap.json +++ b/src/assets/mock/bootstrap/bootstrap.json @@ -1,6 +1,7 @@ { "schemaVersion": "1.0.0", "generatedAt": "2026-07-03T00:00:00Z", + "published": true, "tenant": { "id": "tenant-default-001", "slug": "default",