Compare commits
43 Commits
d44565fae9
...
c2a56571af
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2a56571af | ||
|
|
55634b3b57 | ||
|
|
846004e6d8 | ||
|
|
d4959bd4da | ||
|
|
dda0a3d2df | ||
|
|
885f4d1299 | ||
| 98c39f6844 | |||
| 14d46ceaa6 | |||
| 2e41e216c0 | |||
| bc74fa77d9 | |||
| 3c53a6a33e | |||
| 8e58ee85f0 | |||
| 6d25172a13 | |||
| 4288e5cd44 | |||
| e8c48043ed | |||
| 2602d0c838 | |||
| 3f550de6b2 | |||
|
|
217ab37496 | ||
|
|
c06ae56d88 | ||
|
|
8cdafbe62a | ||
|
|
9344f2702c | ||
|
|
4247a7f83f | ||
|
|
0f042fd384 | ||
|
|
c17d351cd1 | ||
|
|
ec4b01e1b4 | ||
|
|
c83d783ff7 | ||
|
|
62d3045f0c | ||
|
|
0df8d3d592 | ||
|
|
7fe5ac7cd4 | ||
|
|
1bdca917b3 | ||
|
|
8a68be797a | ||
|
|
1a198252b3 | ||
|
|
cee5048d74 | ||
|
|
00c7a62e51 | ||
|
|
3318b34f1e | ||
|
|
0089285373 | ||
|
|
7224bc56c2 | ||
|
|
f079ef6f52 | ||
|
|
a8f7ca31f9 | ||
|
|
1ebfd206ce | ||
|
|
65ce2ef23c | ||
|
|
65663ad6ec | ||
|
|
3480efedd1 |
15
.github/workflows/deploy.yml
vendored
15
.github/workflows/deploy.yml
vendored
@@ -13,6 +13,15 @@ on:
|
||||
description: Branch or SHA to deploy
|
||||
required: false
|
||||
default: main
|
||||
reconcile_api_domains:
|
||||
description: >-
|
||||
Also provision api.<base-domain> nginx vhosts and TLS. Off by default:
|
||||
existing API domains are configured by hand, and re-running the helper
|
||||
writes a second server block for a server_name that already has one.
|
||||
Turn this on only when adding a NEW base domain.
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: deploy-frontend
|
||||
@@ -82,7 +91,13 @@ jobs:
|
||||
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
||||
chmod 644 ~/.ssh/known_hosts
|
||||
|
||||
# Opt-in only. api.<base-domain> vhosts already exist and are hand-managed;
|
||||
# the helper writes its own file per domain, so running it unconditionally
|
||||
# would give nginx two server blocks for one server_name and re-run certbot
|
||||
# against a live API on every single deploy. Frontend releases do not need
|
||||
# this step - it is for standing up a NEW base domain.
|
||||
- name: Reconcile tenant API domains
|
||||
if: ${{ inputs.reconcile_api_domains }}
|
||||
env:
|
||||
HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
USER: ${{ secrets.DEPLOY_USER }}
|
||||
|
||||
373
docs/BRAND-BOOTSTRAP.md
Normal file
373
docs/BRAND-BOOTSTRAP.md
Normal 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`
|
||||
@@ -37,6 +37,21 @@ host; tenant subdomains do not create additional API DNS names.
|
||||
|
||||
nginx root is `/srv/marketplaces/current/frontend`. Activation is a symlink swap, so no request is ever served from a half-written directory, and a rollback is a symlink change rather than a rebuild.
|
||||
|
||||
**On the current production host there is one extra hop.** That server predates
|
||||
`server-setup.sh` and was provisioned by hand, so instead of the catch-all vhost
|
||||
it has per-domain configs (`gorbushka.conf`, `dexarmarket.conf`,
|
||||
`gorbushka-admin.conf`, `gorbushka-landing.conf`) whose `root` is
|
||||
`/var/www/dexarmarket/browser`. That path is itself a symlink:
|
||||
|
||||
```
|
||||
/var/www/dexarmarket/browser -> /srv/marketplaces/current/frontend
|
||||
```
|
||||
|
||||
so the release/`current` model above still holds and the workflow needs no
|
||||
per-host special-casing. Until 2026-08-22 `browser` pointed straight at one
|
||||
pinned release directory with no `current` in between, which is why two
|
||||
successfully-uploaded releases sat unserved.
|
||||
|
||||
---
|
||||
|
||||
## 3. First-time setup
|
||||
@@ -104,18 +119,26 @@ The output is the `DEPLOY_KNOWN_HOSTS` secret. Pinning it means a rebuilt or imp
|
||||
|
||||
### 3.4 Add CI secrets
|
||||
|
||||
Required for every deploy:
|
||||
|
||||
| Secret | Value |
|
||||
|---|---|
|
||||
| `DEPLOY_HOST` | server IP or hostname |
|
||||
| `DEPLOY_USER` | `deploy` |
|
||||
| `DEPLOY_SSH_KEY` | contents of the **private** key file |
|
||||
| `DEPLOY_KNOWN_HOSTS` | output of `ssh-keyscan -H <server-ip>` |
|
||||
|
||||
Required **only** when running the workflow with `reconcile_api_domains` on
|
||||
(§4.6) — a normal release deploy never reads these:
|
||||
|
||||
| Secret | Value |
|
||||
|---|---|
|
||||
| `STOREFRONT_DOMAINS` | space-separated full hosts, e.g. `gorbushka.market store1.example.com` |
|
||||
| `CERTBOT_EMAIL` | operations email used for Let's Encrypt |
|
||||
| `BACKEND_UPSTREAM` | optional; defaults to `https://127.0.0.1:445` |
|
||||
|
||||
Before deploying, point each base domain's shared API hostname at the server.
|
||||
For `gorbushka.market` and `store1.gorbushka.market`, only
|
||||
When that step does run, point each base domain's shared API hostname at the
|
||||
server first. For `gorbushka.market` and `store1.gorbushka.market`, only
|
||||
`api.gorbushka.market` is required. The workflow deduplicates
|
||||
`STOREFRONT_DOMAINS` by base domain and deliberately stops before release
|
||||
activation if DNS, certificate issuance, nginx validation, or the JSON
|
||||
@@ -197,7 +220,24 @@ For a single domain, outside the reconciler:
|
||||
sudo bash add-domain.sh shop.example.com --email ops@example.com --with-www
|
||||
```
|
||||
|
||||
### 4.5 Verify
|
||||
### 4.5 API domains in CD are opt-in
|
||||
|
||||
The deploy workflow's **Reconcile tenant API domains** step is gated behind the
|
||||
`reconcile_api_domains` input and is **off for push-triggered deploys**.
|
||||
|
||||
`configure-api-domain.sh` writes `/etc/nginx/sites-available/api.<domain>` and
|
||||
enables it. The API vhosts on the current production host were created by hand
|
||||
under different filenames (`gorbushka-api.conf`), so running the helper there
|
||||
produces a *second* server block for a `server_name` that already has one, and
|
||||
re-runs certbot against a live API — on every deploy. Shipping frontend files
|
||||
needs none of that.
|
||||
|
||||
Turn it on from the workflow-dispatch form only when standing up a **new** base
|
||||
domain. Before the first such run, reconcile the naming: either delete the
|
||||
hand-made vhost and let the helper own the name, or leave the step off and keep
|
||||
managing API domains manually.
|
||||
|
||||
### 4.6 Verify
|
||||
|
||||
```bash
|
||||
curl -I https://shop.example.com/health
|
||||
@@ -219,6 +259,11 @@ sudo systemctl reload nginx
|
||||
|
||||
Only the last 5 releases are retained. Older ones need a rebuild from the tag.
|
||||
|
||||
The production host reaches releases through `/var/www/dexarmarket/browser ->
|
||||
/srv/marketplaces/current/frontend` (§2), so moving `current` is all a rollback
|
||||
needs there too — do not repoint `browser` at a release directly, or the next
|
||||
deploy's swap will silently stop taking effect.
|
||||
|
||||
---
|
||||
|
||||
## 6. Operational checks
|
||||
|
||||
@@ -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:
|
||||
@@ -46,6 +48,20 @@ Auth lives in `@marketplaces/auth` (published from vitanovaPackages; see `../PAC
|
||||
|
||||
**The critical gap:** the session API has no concept of "admin." The frontend only chooses where to *store* the result. **Every admin endpoint must independently verify authorization server-side** — client-side guards are UI convenience, never security. Admin requests carry `AdminWebSessionID: <sessionId>` (and `Authorization: Bearer <token>` once admin JWTs exist) on paths containing `/admin/`, `/backoffice/`, `/builder/`, `/media/`.
|
||||
|
||||
**Admin credential (login/password) auth — required, not yet built.** `admin.gorbushka.market` can authenticate via Telegram today; login/password is not implemented, so the frontend must not validate or embed admin credentials, and the Ed25519 `/admin-login` page is not production-ready (its challenge/verify endpoints don't exist). Tenant identity comes only from nginx's trusted `X-Storefront-Host` — never from the login body.
|
||||
|
||||
```http
|
||||
POST /api/identity/v1/session { login, password }
|
||||
-> { accessToken (short JWT), refreshToken (rotating opaque), expiresAt,
|
||||
mustChangePassword, user: { id, login, displayName, roles[], tenantId } }
|
||||
POST /api/identity/v1/session/refresh
|
||||
DELETE /api/identity/v1/session
|
||||
POST /api/identity/v1/session/change-password { currentPassword, newPassword }
|
||||
GET /api/identity/v1/session/permissions
|
||||
```
|
||||
|
||||
Errors: `400` malformed; `401 INVALID_CREDENTIALS` (one generic message for unknown login and wrong password); `403 TENANT_DISABLED` / `TENANT_MISMATCH`; `429 RATE_LIMITED` with `Retry-After`. While `mustChangePassword` is true, every non-auth admin endpoint returns `403 PASSWORD_CHANGE_REQUIRED`. Provisioning: random one-time bootstrap password (never `{slug}2026$`), store only an Argon2id hash with a unique salt, never log passwords/refresh tokens/authorization headers/session ids, rate-limit by tenant+login+source IP with backoff, rotate refresh tokens and revoke the full family on reuse, audit login success/failure + password change + refresh reuse + logout + lockout. nginx must preserve `proxy_set_header X-Storefront-Host $storefront_host; proxy_set_header Origin "";` and, for `Origin: https://admin.gorbushka.market`, resolve `$storefront_host` to `gorbushka.market`; API upstream stays `https://127.0.0.1:445`.
|
||||
|
||||
### 1.3 Infrastructure state (dev server `213.21.246.138`, user `seto`)
|
||||
|
||||
| Thing | State |
|
||||
@@ -418,7 +434,8 @@ Write: `POST /companies/{id}/projects`, `/projects/{id}/stores`, `/stores/{id}/p
|
||||
|
||||
Append here whenever a section changes. Newest first.
|
||||
|
||||
- **2026-08-22** — Consolidated the entire `docs/backend/` set into this one file per the single-doc rule. No contract content changed; the former per-phase files are removed.
|
||||
- **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).
|
||||
- **2026-08-17** — Payment chain freeze lifted (Sprint 0.1); FX source decided in-house.
|
||||
|
||||
596
docs/superpowers/plans/2026-08-22-frontend-default-bootstrap.md
Normal file
596
docs/superpowers/plans/2026-08-22-frontend-default-bootstrap.md
Normal 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 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.
|
||||
137
docs/superpowers/specs/2026-08-15-platform-super-admin-design.md
Normal file
137
docs/superpowers/specs/2026-08-15-platform-super-admin-design.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# Platform Super-Admin — Phase 1 Design
|
||||
|
||||
**Status:** Approved
|
||||
**Date:** 2026-08-15
|
||||
**Audience:** Internal admin & risk team ("super puper user")
|
||||
|
||||
## Purpose
|
||||
|
||||
A cross-tenant view for internal admin/risk staff: see every project (store/tenant) on the
|
||||
platform, drill into one, and review its access list, audit log, admin edit history, and
|
||||
purchase history. Read-only in this phase.
|
||||
|
||||
Editing project data / impersonating a store's admin ("edit all", with a per-change "notify
|
||||
this store's admin" toggle) is explicitly **out of scope** for this phase — see
|
||||
[Phase 2](#phase-2-out-of-scope-here) below. Phase 1 exists first because Phase 2's edit and
|
||||
notify plumbing depends on the tenant-context switch this phase builds.
|
||||
|
||||
## Non-goals (Phase 1)
|
||||
|
||||
- No editing of any tenant's data.
|
||||
- No impersonation of a store's admin.
|
||||
- No "notify store admin" mechanism (that's a Phase 2 concern, tied to edit actions that
|
||||
don't exist yet).
|
||||
- No real backend — this repo is frontend-only; the backend contract is specified here for
|
||||
whoever owns that service, not implemented here.
|
||||
|
||||
## Architecture
|
||||
|
||||
- New top-level feature module: `src/app/features/platform-admin/`.
|
||||
- New route tree `/platform-admin/**`, own shell/layout. **Not** nested under any tenant's
|
||||
`/admin/**` — a project is not "logged into" the way a store admin is.
|
||||
- New `platformAdminAuthGuard` (parallel to, but sharing no state with, `adminAuthGuard` in
|
||||
`core/admin-auth/admin-auth.guard.ts`).
|
||||
- `PlatformAuthService` — session/login state for the super-admin, backed by a
|
||||
`PlatformAuthGateway` interface: `login(credentials)`, `logout()`, `session()`.
|
||||
- `PlatformAuthLocalGateway` — dev-only implementation. Reads the expected credential from
|
||||
a **git-ignored** local file (`platform-auth.local-secret.ts`, added to `.gitignore`),
|
||||
never committed, never present in a production build path.
|
||||
- `PlatformAuthApiGateway` — later swap-in once the backend endpoint exists; same
|
||||
interface, no caller changes needed.
|
||||
|
||||
## Data model
|
||||
|
||||
```ts
|
||||
interface PlatformProjectSummary {
|
||||
id: UUID;
|
||||
name: string;
|
||||
slug: string;
|
||||
host: string;
|
||||
status: 'active' | 'suspended';
|
||||
createdAt: number;
|
||||
adminCount: number;
|
||||
lastActivityAt: number | null;
|
||||
}
|
||||
|
||||
interface PlatformProjectAccessEntry {
|
||||
userId: UUID;
|
||||
displayName: string;
|
||||
telegramUsername: string;
|
||||
roleId: string; // maps to existing AdminRole / ROLE_PERMISSIONS
|
||||
}
|
||||
|
||||
type PlatformProjectHistoryEntry =
|
||||
| { kind: 'access'; tenantId: UUID; actorLabel: string; timestamp: number; summary: string }
|
||||
| { kind: 'edit'; tenantId: UUID; actorLabel: string; timestamp: number; summary: string }
|
||||
| { kind: 'purchase'; tenantId: UUID; actorLabel: string; timestamp: number; summary: string };
|
||||
```
|
||||
|
||||
- `PlatformProjectSummary[]` is produced by `PlatformProjectsGateway.list()`, which aggregates
|
||||
the existing `TenantConfig` fixture list plus derived stats. Mock gateway now; real
|
||||
aggregation is a backend concern later.
|
||||
- `PlatformProjectAccessEntry` reuses the existing `AdminRole` / `ROLE_PERMISSIONS` shape from
|
||||
`core/auth/models/permission.model.ts` — no new role system.
|
||||
- `PlatformProjectHistoryEntry` is a discriminated union covering all three history types the
|
||||
user asked for (access/audit, admin edit history, purchase history). Mock gateway simulates
|
||||
aggregation from existing per-tenant sources (e.g. the pattern in
|
||||
`AdminDashboardHistoryService`, `admin-transactions`); real aggregation is a backend concern.
|
||||
- Every super-admin **view** into a project also writes its own `kind: 'access'` entry
|
||||
(`platform.viewedProject`) — the risk team needs to know who looked at what, not just what
|
||||
changed.
|
||||
|
||||
## Components / pages
|
||||
|
||||
- `PlatformProjectsListPageComponent` — table of all projects: name, status, admin count,
|
||||
last activity. Search/filter by status.
|
||||
- `PlatformProjectDetailPageComponent` — project overview stats, then tabs:
|
||||
- **Access** — `PlatformProjectAccessEntry[]` for that tenant.
|
||||
- **Audit Log** — `history` filtered to `kind: 'access'`.
|
||||
- **Edit History** — `history` filtered to `kind: 'edit'`.
|
||||
- **Purchase History** — `history` filtered to `kind: 'purchase'`.
|
||||
- All read-only in this phase.
|
||||
|
||||
## Security
|
||||
|
||||
- `platformAdminAuthGuard` denies unless the session carries `platform.superadmin`. Like the
|
||||
existing `AdminPermissionsService`, the frontend check is defense-in-depth only — real
|
||||
enforcement must happen server-side once the backend endpoint exists. This is called out
|
||||
explicitly so it's never mistaken for the source of truth.
|
||||
- No credential is ever hardcoded in committed source. Dev-only credential lives in a
|
||||
git-ignored local file; production auth goes through the real backend endpoint below.
|
||||
- Session timeout for platform-admin: 15 minutes idle (shorter than regular tenant-admin
|
||||
sessions — higher-privilege session, smaller blast radius if a session is left open).
|
||||
- Every super-admin action (including read-only views) is itself audit-logged.
|
||||
- After implementation, run `/security-audit` on this feature specifically before it ships.
|
||||
|
||||
### Backend contract (for whoever owns that service — not implemented in this repo)
|
||||
|
||||
Add to `BACKEND-API-REFERENCE.md`:
|
||||
|
||||
- `POST /platform-admin/auth` — verifies a hashed credential server-side, returns a session
|
||||
token scoped to `platform.superadmin`. Never a plaintext credential check in a client-shipped
|
||||
artifact.
|
||||
- `GET /platform-admin/projects` — returns `PlatformProjectSummary[]`.
|
||||
- `GET /platform-admin/projects/:id/history` — returns `PlatformProjectHistoryEntry[]` for
|
||||
that tenant, paginated.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests: `platformAdminAuthGuard`, `PlatformProjectsGateway` (mock), history-aggregation
|
||||
mapping logic.
|
||||
- No E2E in this phase — no real backend to exercise end-to-end yet.
|
||||
|
||||
## Phase 2 (out of scope here)
|
||||
|
||||
A separate spec/plan cycle, once Phase 1 ships:
|
||||
|
||||
- Full edit / impersonation: super-admin acts as a tenant's admin across every existing admin
|
||||
module (products, orders, categories, settings, etc.), reusing those modules under a
|
||||
tenant-context switch.
|
||||
- Per-edit-action **"notify this store's admin about this change"** checkbox, **default
|
||||
unchecked**. Uses the existing in-app notification pattern (the one behind
|
||||
`admin-order-watcher.service.ts`'s unread-badge flow) so the affected tenant's admin sees it
|
||||
in their notification feed. Unchecked-by-default matters: some super-admin edits are
|
||||
discreet technical fixes where alerting the store admin would be noise or a reputational
|
||||
concern, not every edit should ping them.
|
||||
- This phase needs the tenant-context switch and audit-logging plumbing this Phase 1 spec
|
||||
establishes, which is why it's sequenced after.
|
||||
@@ -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.
|
||||
@@ -29,8 +29,8 @@ BASE_URL=https://staging.example.com npm run e2e
|
||||
| `currency-switch.spec.ts` | `160 RUB` must not silently become `160 USD` on a currency switch — Track Q Q4, and the regression guard `../docs/backend/BACKEND-INTEGRATION.md` §5 exists to close. Written **before** the checkout money-truth rewrite (F10–F16 in the frontend backlog), specifically so that rewrite has a net under it. |
|
||||
| `smoke.spec.ts` | App boots, storefront renders, no console errors on first paint. |
|
||||
| `admin-dev-bypass.spec.ts` | `?devBypassAdmin=true` actually reaches the admin shell without a Telegram login (Track Q F59). |
|
||||
| `checkout-request-shape.spec.ts` | ⚠️ **Currently failing, known issue, not resolved (2026-08-21).** The checkout request-shape assertions are correct on paper; the customer-session fake this test relies on doesn't work right now for a reason not yet found — see the `fakeCustomerSession` comment in the file. Do not trust a green *or* red run of this specific test as a verdict on checkout correctness until it's root-caused. |
|
||||
| `checkout-idempotent-click.spec.ts` | ⚠️ Same known issue as above (Track Q F62) - fails the same way, for the same unresolved reason. |
|
||||
| `checkout-request-shape.spec.ts` | The amount actually charged must be computed server-side, never sent by the client (`PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md` §5.2). Was red for a real bug, not a harness issue — root-caused 2026-08-21, see the `fakeCustomerSession` comment and `api-headers.interceptor.ts`. |
|
||||
| `checkout-idempotent-click.spec.ts` | Double-clicking checkout sends exactly one checkout-session request (Track Q F62). Same root cause and fix as above. |
|
||||
|
||||
## Adding a test
|
||||
|
||||
|
||||
@@ -20,9 +20,8 @@ test('double-clicking checkout sends exactly one checkout-session request', asyn
|
||||
window.localStorage.setItem('marketplace_cart', JSON.stringify([item]));
|
||||
}, FAKE_ITEM);
|
||||
|
||||
// KNOWN ISSUE, NOT RESOLVED (2026-08-21) - see checkout-request-shape.spec.ts's
|
||||
// fakeCustomerSession comment. This test currently fails the same way:
|
||||
// the session check never fires despite the cookie being present.
|
||||
// Root-caused and fixed 2026-08-21 - see checkout-request-shape.spec.ts's
|
||||
// fakeCustomerSession comment and api-headers.interceptor.ts.
|
||||
await context.addCookies([{ name: 'webSessionID', value: 'e2e-fake-session', url: 'http://localhost:4200' }]);
|
||||
await page.route('**/users/sessions/**', route =>
|
||||
route.fulfill({
|
||||
|
||||
@@ -81,19 +81,14 @@ async function seedCart(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function fakeCustomerSession(page: Page, context: import('@playwright/test').BrowserContext): Promise<void> {
|
||||
// KNOWN ISSUE, NOT RESOLVED (2026-08-21): this test currently fails.
|
||||
// Traced with page.on('request'): the customer-session check
|
||||
// (AuthService.checkSession -> getStoredWebSessionID) never fires at all
|
||||
// once Angular bootstraps on this page, even though the cookie is
|
||||
// confirmed present via context.cookies() and via document.cookie read
|
||||
// from a plain (non-Angular) page on the same origin immediately before.
|
||||
// Switching { domain, path } to { url } here did not fix it - kept anyway
|
||||
// since it is the more correct form regardless. Something in the app's
|
||||
// own bootstrap/DI path is not seeing a cookie that unambiguously exists
|
||||
// in the browser; root cause not yet found. Do not trust a green run of
|
||||
// this specific test until this is root-caused - the checkout REQUEST
|
||||
// SHAPE assertions this test makes are still correct on paper, just
|
||||
// currently unverifiable through this harness.
|
||||
// Root-caused and fixed 2026-08-21 (see api-headers.interceptor.ts):
|
||||
// apiHeadersInterceptor injected AuthService to attach a WebSessionID
|
||||
// header, but AuthService's own constructor makes the exact
|
||||
// GET /users/sessions/:id call this interceptor runs on, which threw
|
||||
// NG0200 (circular dependency) mid-construction on every page load -
|
||||
// swallowed silently, read as "session invalid," cookie cleared
|
||||
// immediately. The { url } cookie form below is unrelated to that bug but
|
||||
// is still the more correct form, so it stays.
|
||||
await context.addCookies([
|
||||
{
|
||||
name: 'webSessionID',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,7 +59,7 @@ server {
|
||||
add_header Access-Control-Allow-Origin \$cors_origin always;
|
||||
add_header Access-Control-Allow-Credentials "true" always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, AdminWebSessionID, X-Requested-With" always;
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, AdminWebSessionID, WebSessionID, Currency, X-Language, X-Region, X-Requested-With" always;
|
||||
add_header Vary "Origin" always;
|
||||
|
||||
if (\$request_method = OPTIONS) { return 204; }
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
<p>{{ 'app.serverError' | translate }}</p>
|
||||
<button class="retry-btn" (click)="retryConnection()">{{ 'app.retryConnection' | translate }}</button>
|
||||
</div>
|
||||
} @else if (isAdminHost && !isAdminRoute()) {
|
||||
<app-telegram-login mode="admin" />
|
||||
} @else if (isAdminRoute()) {
|
||||
<router-outlet></router-outlet>
|
||||
<app-telegram-login mode="admin" />
|
||||
|
||||
@@ -28,6 +28,8 @@ import { TelegramLoginComponent } from './components/telegram-login/telegram-log
|
||||
})
|
||||
export class App implements OnInit {
|
||||
protected title = '';
|
||||
readonly isAdminHost = typeof window !== 'undefined'
|
||||
&& window.location.hostname.toLowerCase().startsWith('admin.');
|
||||
isHomePage = signal(true);
|
||||
isAdminRoute = signal(false);
|
||||
checkingServer = signal(true);
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
<app-icon name="lock" [size]="40" />
|
||||
</div>
|
||||
|
||||
<h2>{{ 'auth.loginRequired' | translate }}</h2>
|
||||
<p class="login-desc">{{ 'auth.loginDescription' | translate }}</p>
|
||||
<h2>{{ (mode === 'admin' ? 'auth.adminLoginRequired' : 'auth.loginRequired') | translate }}</h2>
|
||||
<p class="login-desc">{{ (mode === 'admin' ? 'auth.adminLoginDescription' : 'auth.loginDescription') | translate }}</p>
|
||||
|
||||
@if (status() === 'checking') {
|
||||
<div class="login-status checking">
|
||||
@@ -22,7 +22,7 @@
|
||||
<svg class="tg-icon" width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
||||
</svg>
|
||||
{{ 'auth.loginWithTelegram' | translate }}
|
||||
{{ (mode === 'admin' ? 'auth.adminLoginWithTelegram' : 'auth.loginWithTelegram') | translate }}
|
||||
</button>
|
||||
|
||||
<!-- @if (loginUrl()) {
|
||||
@@ -64,7 +64,7 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<p class="login-note">{{ 'auth.loginNote' | translate }}</p>
|
||||
<p class="login-note">{{ (mode === 'admin' ? 'auth.adminLoginNote' : 'auth.loginNote') | translate }}</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
57
src/app/core/config/config.service.spec.ts
Normal file
57
src/app/core/config/config.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -1154,6 +1154,10 @@ export const en: Translations = {
|
||||
auth: {
|
||||
loginRequired: 'Login required',
|
||||
loginDescription: 'Please log in via Telegram to proceed with your order',
|
||||
adminLoginRequired: 'Admin panel sign-in',
|
||||
adminLoginDescription: 'Sign in through Telegram with an administrator account',
|
||||
adminLoginWithTelegram: 'Sign in as administrator',
|
||||
adminLoginNote: 'The admin panel will open after verification',
|
||||
checking: 'Checking...',
|
||||
loginWithTelegram: 'Log in with Telegram',
|
||||
orScanQr: 'Or scan the QR code',
|
||||
|
||||
@@ -1154,6 +1154,10 @@ export const hy: Translations = {
|
||||
auth: {
|
||||
loginRequired: 'Պահանջվում է մուտք',
|
||||
loginDescription: 'Պատվերի համար մուտք գործեք Telegram-ով',
|
||||
adminLoginRequired: 'Մուտք կառավարման վահանակ',
|
||||
adminLoginDescription: 'Մուտք գործեք Telegram-ով՝ ադմինիստրատորի հաշվով',
|
||||
adminLoginWithTelegram: 'Մուտք գործել որպես ադմինիստրատոր',
|
||||
adminLoginNote: 'Ստուգումից հետո կբացվի կառավարման վահանակը',
|
||||
checking: 'Ստուգում...',
|
||||
loginWithTelegram: 'Մուտք Telegram-ով',
|
||||
orScanQr: 'Կամ սքանավորեք QR կոդը',
|
||||
|
||||
@@ -1154,6 +1154,10 @@ export const ru: Translations = {
|
||||
auth: {
|
||||
loginRequired: 'Требуется авторизация',
|
||||
loginDescription: 'Для оформления заказа войдите через Telegram',
|
||||
adminLoginRequired: 'Вход в панель управления',
|
||||
adminLoginDescription: 'Войдите через Telegram с аккаунтом администратора',
|
||||
adminLoginWithTelegram: 'Войти как администратор',
|
||||
adminLoginNote: 'После подтверждения откроется панель управления',
|
||||
checking: 'Проверка...',
|
||||
loginWithTelegram: 'Войти через Telegram',
|
||||
orScanQr: 'Или отсканируйте QR-код',
|
||||
|
||||
@@ -1153,6 +1153,10 @@ export interface Translations {
|
||||
auth: {
|
||||
loginRequired: string;
|
||||
loginDescription: string;
|
||||
adminLoginRequired: string;
|
||||
adminLoginDescription: string;
|
||||
adminLoginWithTelegram: string;
|
||||
adminLoginNote: string;
|
||||
checking: string;
|
||||
loginWithTelegram: string;
|
||||
orScanQr: string;
|
||||
|
||||
@@ -38,6 +38,21 @@ function getAnonymousSessionId(): string {
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @marketplaces/auth's AuthService checks for a persisted session in its own
|
||||
* constructor (a synchronous HTTP call to GET /users/sessions/:id before the
|
||||
* constructor returns). If this interceptor injects AuthService for that
|
||||
* exact call, Angular sees AuthService requesting itself mid-construction
|
||||
* and throws NG0200 (circular dependency) - silently, since it's swallowed
|
||||
* by TelegramSessionApiService's catchError(() => of(null)), which reads as
|
||||
* "session invalid" and logs the user straight back out on every load.
|
||||
* These endpoints are the identity mechanism itself (the session id is
|
||||
* already the URL/body), so they never needed a WebSessionID header from an
|
||||
* existing session in the first place - skipping AuthService injection here
|
||||
* is correct, not a workaround.
|
||||
*/
|
||||
const AUTH_SESSION_PATH = '/users/sessions';
|
||||
|
||||
export const apiHeadersInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const apiConfig = inject(ApiConfigService);
|
||||
if (!apiConfig.isApiRequest(req.url)) {
|
||||
@@ -46,12 +61,10 @@ export const apiHeadersInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
|
||||
const locationService = inject(LocationService);
|
||||
const languageService = inject(LanguageService);
|
||||
const authService = inject(AuthService);
|
||||
|
||||
const regionId = locationService.regionId();
|
||||
const lang = languageService.currentLanguage();
|
||||
const currency = languageService.currentCurrency();
|
||||
const session = authService.session();
|
||||
|
||||
let headers = req.headers;
|
||||
|
||||
@@ -62,7 +75,12 @@ export const apiHeadersInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
headers = headers.set('X-Language', LANG_HEADER_MAP[lang] ?? lang.toUpperCase());
|
||||
}
|
||||
headers = headers.set('Currency', currency || 'RUB');
|
||||
|
||||
if (!req.url.includes(AUTH_SESSION_PATH)) {
|
||||
const authService = inject(AuthService);
|
||||
const session = authService.session();
|
||||
headers = headers.set('WebSessionID', session?.sessionId || getAnonymousSessionId());
|
||||
}
|
||||
|
||||
return next(req.clone({ headers }));
|
||||
};
|
||||
|
||||
@@ -741,10 +741,16 @@ export const mockDataInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const userSessionMatch = url.match(/\/users\/sessions\/([^/?]+)$/);
|
||||
if (userSessionMatch && req.method === 'GET') {
|
||||
const webSessionID = decodeURIComponent(userSessionMatch[1]);
|
||||
// An id never seen via POST /users/sessions didn't start a fresh QR
|
||||
// login in this session - it's a cookie carried over from an earlier
|
||||
// visit (AuthService.checkSession's one-shot check on page load), which
|
||||
// a real backend would already recognize. Only ids the mock itself put
|
||||
// through the polling flow need the checks>=3 gate below.
|
||||
const isReturningSession = !mockWebSessionChecks.has(webSessionID);
|
||||
const checks = (mockWebSessionChecks.get(webSessionID) ?? 0) + 1;
|
||||
mockWebSessionChecks.set(webSessionID, checks);
|
||||
|
||||
if (checks >= 3) {
|
||||
if (isReturningSession || checks >= 3) {
|
||||
return respond({
|
||||
webSessionID,
|
||||
status: true,
|
||||
|
||||
@@ -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;
|
||||
|
||||
30
src/app/shared/models/config/default-bootstrap.const.spec.ts
Normal file
30
src/app/shared/models/config/default-bootstrap.const.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
226
src/app/shared/models/config/default-bootstrap.const.ts
Normal file
226
src/app/shared/models/config/default-bootstrap.const.ts
Normal 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,
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"generatedAt": "2026-07-03T00:00:00Z",
|
||||
"published": true,
|
||||
"tenant": {
|
||||
"id": "tenant-default-001",
|
||||
"slug": "default",
|
||||
|
||||
Reference in New Issue
Block a user