1 Commits

Author SHA1 Message Date
sdarbinyan
c2a56571af feat(bootstrap): fall back to built-in placeholder when marketplace unpublished
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled
Adds published: boolean to the bootstrap wire contract. ConfigService
swaps to a new DEFAULT_BOOTSTRAP constant (all feature flags on, generic
branding/theme/pages) whenever the backend reports published: false, so
an unpublished marketplace renders a working demo instead of a blank or
broken page. Missing published field stays backward compatible (treated
as true). Documents the brand bootstrap wire shape for backend/ops use.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 22:02:42 +04:00
12 changed files with 1394 additions and 2 deletions

373
docs/BRAND-BOOTSTRAP.md Normal file
View File

@@ -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://<any-domain>`.
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.<base-domain>`).
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.<domain>/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.<domain>` (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://<domain>/health` (nginx, expect 200) and `curl -fsS https://api.<domain>/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": "<h2>About Us</h2><p>...</p>", "ru": "<h2>О компании</h2><p>...</p>" }
}
},
"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/<locale>.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.<base-domain>` 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.<domain>/bootstrap | jq .`
- [ ] `curl -I https://<domain>/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`

View File

@@ -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).

View File

@@ -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: '<h2>About Us</h2><p>This marketplace has not published its storefront yet.</p>' },
},
'privacy-policy': {
route: '/privacy-policy',
title: { en: 'Privacy Policy' },
html: { en: '<h2>Privacy Policy</h2><p>Placeholder content until publish.</p>' },
},
'terms-of-service': {
route: '/terms-of-service',
title: { en: 'Terms of Service' },
html: { en: '<h2>Terms of Service</h2><p>Placeholder content until publish.</p>' },
},
},
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> = {}): BootstrapConfig {
return { ...DEFAULT_BOOTSTRAP, published: true, tenant: { ...DEFAULT_BOOTSTRAP.tenant, name: 'Acme' }, ...overrides };
}
describe('ConfigService', () => {
let provider: jasmine.SpyObj<ConfigProvider>;
function setup(response: BootstrapConfig): ConfigService {
provider = jasmine.createSpyObj<ConfigProvider>('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<BootstrapConfig>).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<BootstrapConfig>;
private readonly revisionState = signal(0);
readonly bootstrapRevision = this.revisionState.asReadonly();
loadBootstrap(forceRefresh: boolean = false): Observable<BootstrapConfig> {
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 13 aren't wired in yet if this task runs standalone. If Tasks 13 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 13 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 24 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.

View File

@@ -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.

View File

@@ -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();
});
});

View File

@@ -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> = {}): BootstrapConfig {
return { ...DEFAULT_BOOTSTRAP, published: true, tenant: { ...DEFAULT_BOOTSTRAP.tenant, name: 'Acme' }, ...overrides };
}
describe('ConfigService', () => {
let provider: jasmine.SpyObj<ConfigProvider>;
function setup(response: BootstrapConfig): ConfigService {
provider = jasmine.createSpyObj<ConfigProvider>('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<BootstrapConfig>).published;
const service = setup(legacy);
service.loadBootstrap().subscribe(result => {
expect(result.tenant.name).toBe('Acme');
expect(result).not.toEqual(DEFAULT_BOOTSTRAP);
done();
});
});
});

View File

@@ -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);

View File

@@ -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;

View File

@@ -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');
});
});

View File

@@ -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: '<h2>About Us</h2><p>This marketplace has not published its storefront yet.</p>' },
},
'privacy-policy': {
id: 'privacy-policy',
slug: 'privacy-policy',
route: '/privacy-policy',
title: { en: 'Privacy Policy' },
html: { en: '<h2>Privacy Policy</h2><p>Placeholder content until publish.</p>' },
},
'terms-of-service': {
id: 'terms-of-service',
slug: 'terms-of-service',
route: '/terms-of-service',
title: { en: 'Terms of Service' },
html: { en: '<h2>Terms of Service</h2><p>Placeholder content until publish.</p>' },
},
},
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,
};

View File

@@ -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';

View File

@@ -1,6 +1,7 @@
{
"schemaVersion": "1.0.0",
"generatedAt": "2026-07-03T00:00:00Z",
"published": true,
"tenant": {
"id": "tenant-default-001",
"slug": "default",