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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 22:02:42 +04:00

597 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Frontend Default Bootstrap Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** When a marketplace has no published revision, the frontend renders a built-in, all-features-on generic placeholder instead of a broken/empty page — decided from one explicit `published: boolean` field on the `/bootstrap` response, not from HTTP status.
**Architecture:** Add `published` to `BootstrapConfig`. Add one new frontend-only constant `DEFAULT_BOOTSTRAP: BootstrapConfig`, composed from existing `DEFAULT_HEADER_CONFIG` / `DEFAULT_MARKETPLACE_FEATURES_CONFIG` / `DEFAULT_PLATFORM_MODULES_CONFIG` plus a hardcoded generic shell for the sections with no existing default (`tenant`, `branding`, `theme`, `company`, `featureFlags`, `apiEndpoints`, `localization`, `seo`, `permissions`, `navigation`, `footer`, `pages`, `staticPages`). `ConfigService.loadBootstrap()` swaps its cached snapshot to `DEFAULT_BOOTSTRAP` whenever the fetched response has `published === false`. No provider changes.
**Tech Stack:** Angular 22, RxJS, Jasmine/Karma (existing `.spec.ts` pattern in this repo).
## Global Constraints
- `published` missing/undefined on a response must be treated as `true` (backward compatible — matches the existing pattern for `modules`/ADR-011).
- Fallback is a whole-object swap — no field-level merging with the real response.
- Fallback triggers only on the explicit `published: false` signal, never on HTTP failure (existing `catchError` behavior in `ConfigService` is untouched).
- `DEFAULT_BOOTSTRAP.featureFlags` and `.features` must have every flag `true`.
- Reuse `DEFAULT_HEADER_CONFIG`, `DEFAULT_MARKETPLACE_FEATURES_CONFIG`, `DEFAULT_PLATFORM_MODULES_CONFIG` as-is — do not redefine their values inline.
---
### Task 1: Add `published` to the `BootstrapConfig` contract
**Files:**
- Modify: `src/app/shared/models/config/bootstrap-config.model.ts`
- Modify: `src/assets/mock/bootstrap/bootstrap.json` (add `"published": true` so the existing mock keeps behaving as "already live")
**Interfaces:**
- Produces: `BootstrapConfig.published: boolean` — consumed by Task 3 (`ConfigService`).
- [ ] **Step 1: Add the field to the interface**
In `src/app/shared/models/config/bootstrap-config.model.ts`, add `published` right after `generatedAt`:
```ts
export interface BootstrapConfig {
schemaVersion: string;
generatedAt: string;
published: boolean;
tenant: TenantConfig;
branding: BrandingConfig;
theme: ThemeConfig;
company: CompanyConfig;
featureFlags: FeatureFlagsConfig;
features?: MarketplaceFeaturesConfig;
apiEndpoints: ApiEndpointsConfig;
localization: LocalizationConfig;
seo: SeoConfig;
permissions: PermissionsConfig;
header?: HeaderConfig;
catalog?: CatalogConfig;
layout?: PlatformLayoutConfig;
navigation: NavigationConfig;
footer?: FooterConfig;
productPage?: ProductPageConfig;
userExperience?: UserExperienceConfig;
pages: PageConfig[];
staticPages?: StaticPagesConfig;
widgetRegistry?: WidgetRegistryConfig;
modules?: PlatformModulesConfig;
seller?: SellerConfig;
}
```
- [ ] **Step 2: Update the mock fixture**
In `src/assets/mock/bootstrap/bootstrap.json`, add `"published": true,` as the line right after `"generatedAt": "2026-07-03T00:00:00Z",` (line 3).
- [ ] **Step 3: Compile check**
Run: `npx tsc --noEmit -p tsconfig.json`
Expected: no new errors referencing `bootstrap-config.model.ts` or `bootstrap.json` (the mock file isn't type-checked, but any TS consumer that builds a `BootstrapConfig` object literal without `published` will now fail — confirms the field is wired through).
- [ ] **Step 4: Commit**
```bash
git add src/app/shared/models/config/bootstrap-config.model.ts src/assets/mock/bootstrap/bootstrap.json
git commit -m "feat: add published field to BootstrapConfig contract"
```
---
### Task 2: Add the `DEFAULT_BOOTSTRAP` constant
**Files:**
- Create: `src/app/shared/models/config/default-bootstrap.const.ts`
- Modify: `src/app/shared/models/config/index.ts` (export the new file)
- Test: `src/app/shared/models/config/default-bootstrap.const.spec.ts`
**Interfaces:**
- Consumes: `BootstrapConfig` (Task 1), `DEFAULT_HEADER_CONFIG` from `./header-config.model`, `DEFAULT_MARKETPLACE_FEATURES_CONFIG` from `./features-config.model`, `DEFAULT_PLATFORM_MODULES_CONFIG` from `./platform-modules.model`.
- Produces: `DEFAULT_BOOTSTRAP: BootstrapConfig` — consumed by Task 3 (`ConfigService`).
- [ ] **Step 1: Write the failing test**
Create `src/app/shared/models/config/default-bootstrap.const.spec.ts`:
```ts
import { DEFAULT_BOOTSTRAP } from './default-bootstrap.const';
describe('DEFAULT_BOOTSTRAP', () => {
it('is marked unpublished', () => {
expect(DEFAULT_BOOTSTRAP.published).toBe(false);
});
it('has every feature flag turned on', () => {
Object.values(DEFAULT_BOOTSTRAP.featureFlags).forEach(value => {
expect(value).toBe(true);
});
});
it('has every optional MarketplaceFeaturesConfig flag turned on', () => {
expect(DEFAULT_BOOTSTRAP.features).toBeDefined();
Object.values(DEFAULT_BOOTSTRAP.features!).forEach(value => {
expect(value).toBe(true);
});
});
it('has at least one page with a hero section', () => {
expect(DEFAULT_BOOTSTRAP.pages.length).toBeGreaterThan(0);
const heroSection = DEFAULT_BOOTSTRAP.pages[0].sections.find(s => s.type === 'hero');
expect(heroSection).toBeDefined();
});
it('has a generic brand name, not a real tenant name', () => {
expect(DEFAULT_BOOTSTRAP.branding.brandName).toBe('Marketplace');
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `ng test --include='**/default-bootstrap.const.spec.ts' --watch=false`
Expected: FAIL — `Cannot find module './default-bootstrap.const'`
- [ ] **Step 3: Write the constant**
Create `src/app/shared/models/config/default-bootstrap.const.ts`:
```ts
import { BootstrapConfig } from './bootstrap-config.model';
import { DEFAULT_HEADER_CONFIG } from './header-config.model';
import { DEFAULT_MARKETPLACE_FEATURES_CONFIG } from './features-config.model';
import { DEFAULT_PLATFORM_MODULES_CONFIG } from './platform-modules.model';
/**
* Whole-object fallback rendered whenever the backend reports
* `published: false` for the resolved marketplace (no published revision
* yet). Every feature flag is on so it doubles as a full-surface product
* demo. See docs/superpowers/specs/2026-08-22-frontend-default-bootstrap-design.md.
*/
export const DEFAULT_BOOTSTRAP: BootstrapConfig = {
schemaVersion: '1.0.0',
generatedAt: new Date(0).toISOString(),
published: false,
tenant: {
id: 'tenant-default-unpublished',
slug: 'default',
code: 'DEFAULT',
host: 'default.local',
name: 'Marketplace',
websiteBaseUrl: 'https://marketplace.local',
builderBaseUrl: 'https://builder.marketplace.local',
backofficeBaseUrl: 'https://backoffice.marketplace.local',
defaultLocale: 'en',
supportedLocales: ['en'],
defaultCurrency: 'USD',
supportedCurrencies: ['USD'],
timezone: 'UTC',
},
branding: {
brandName: 'Marketplace',
legalName: 'Marketplace',
slogan: 'Your store, coming soon',
logoUrl: '/icons/icon-192x192.png',
logoCompactUrl: '/icons/icon-192x192.png',
faviconUrl: '/favicon.ico',
appIconUrl: '/icons/icon-192x192.png',
supportEmail: 'support@marketplace.local',
},
theme: {
themeId: 'default-light',
mode: 'light',
palette: {
primary: '#497671',
secondary: '#a1b4b5',
accent: '#a7ceca',
success: '#10b981',
warning: '#f59e0b',
danger: '#ef4444',
info: '#3b82f6',
textPrimary: '#1e3c38',
textSecondary: '#667a77',
backgroundPrimary: '#ffffff',
backgroundSecondary: '#f5f5f5',
border: '#d3dad9',
},
typography: {
primaryFontFamily: 'DM Sans, sans-serif',
headingFontFamily: 'DM Sans, sans-serif',
baseFontSize: 16,
},
spacing: { unit: 4, scale: [0, 4, 8, 12, 16, 24, 32, 48] },
borderRadiusScale: { sm: '8px', md: '12px', lg: '16px', xl: '22px' },
shadows: {
sm: '0 2px 8px rgba(0,0,0,0.1)',
md: '0 4px 12px rgba(0,0,0,0.15)',
lg: '0 12px 32px rgba(73,118,113,0.2)',
},
iconSet: 'default',
},
company: {
companyName: 'Marketplace',
address: { country: '', city: '' },
contacts: { email: 'support@marketplace.local' },
},
featureFlags: {
wishlist: true,
compare: true,
reviews: true,
questions: true,
comments: true,
recommendations: true,
blog: true,
chat: true,
analytics: true,
notifications: true,
coupons: true,
loyalty: true,
giftCards: true,
invoices: true,
},
features: DEFAULT_MARKETPLACE_FEATURES_CONFIG,
apiEndpoints: {
bootstrap: { path: '/bootstrap', method: 'GET', timeoutMs: 10000 },
website: {},
builder: {},
backoffice: {},
},
localization: {
defaultLocale: 'en',
supportedLocales: ['en'],
currencyByLocale: { en: 'USD' },
dictionaries: [{ locale: 'en', dictionaryUrl: '/assets/i18n/en.json', version: '1.0.0' }],
},
seo: {
default: { title: 'Marketplace', description: 'Your store, coming soon', robots: 'noindex,nofollow' },
byPageKey: {
home: { title: 'Marketplace - Home', description: 'Your store, coming soon', robots: 'noindex,nofollow' },
},
},
permissions: { definitions: [], roles: [] },
header: DEFAULT_HEADER_CONFIG,
navigation: {
header: [
{ id: 'nav-home', labelKey: 'nav.home', route: '/', icon: 'home', order: 1 },
{ id: 'nav-search', labelKey: 'nav.search', route: '/search', icon: 'search', order: 2 },
{ id: 'nav-cart', labelKey: 'nav.cart', route: '/cart', icon: 'cart', order: 3 },
],
footer: [
{ id: 'footer-about', labelKey: 'nav.about', route: '/about-us', order: 1 },
{ id: 'footer-contacts', labelKey: 'nav.contacts', route: '/contacts', order: 2 },
],
},
footer: {
paymentIcons: [],
copyrightText: { en: '© 2026 Marketplace. All rights reserved.' },
legalPageKeys: ['about-us', 'privacy-policy', 'terms-of-service'],
},
staticPages: {
'about-us': {
route: '/about-us',
title: { en: 'About Us' },
html: { en: '<h2>About Us</h2><p>This marketplace has not published its storefront yet.</p>' },
},
'privacy-policy': {
route: '/privacy-policy',
title: { en: 'Privacy Policy' },
html: { en: '<h2>Privacy Policy</h2><p>Placeholder content until publish.</p>' },
},
'terms-of-service': {
route: '/terms-of-service',
title: { en: 'Terms of Service' },
html: { en: '<h2>Terms of Service</h2><p>Placeholder content until publish.</p>' },
},
},
pages: [
{
id: 'page-home',
key: 'home',
title: 'Home',
route: { path: '/', exact: true },
layout: { type: 'default' },
seoKey: 'home',
visible: true,
sections: [
{
id: 'section-hero',
type: 'hero',
order: 1,
layout: { strategy: 'hero', columns: 1, gap: '1.5rem', align: 'stretch' },
visibility: { desktop: true, tablet: true, mobile: true },
visible: true,
widgets: [
{
id: 'widget-hero-main',
type: 'hero',
version: '1.0.0',
order: 1,
padding: '0.5rem 0',
visibility: { desktop: true, tablet: true, mobile: true },
visible: true,
props: {
title: { en: 'Welcome to Marketplace' },
subtitle: { en: 'This storefront has not been published yet' },
ctaLabel: { en: 'Learn more' },
},
},
],
},
{
id: 'section-categories',
type: 'categories',
order: 2,
layout: { strategy: 'grid', columns: 1, gap: '1.5rem', align: 'stretch' },
visibility: { desktop: true, tablet: true, mobile: true },
visible: true,
widgets: [
{
id: 'widget-categories-root',
type: 'categories',
version: '1.0.0',
order: 1,
padding: '0.25rem 0',
visibility: { desktop: true, tablet: true, mobile: true },
visible: true,
props: { title: 'Categories', source: 'root', emptyMessage: 'No categories available' },
},
],
},
],
},
],
modules: DEFAULT_PLATFORM_MODULES_CONFIG,
};
```
- [ ] **Step 4: Export it from the barrel file**
In `src/app/shared/models/config/index.ts`, add one line (alphabetical position, after `catalog-config.model`):
```ts
export * from './default-bootstrap.const';
```
- [ ] **Step 5: Run test to verify it passes**
Run: `ng test --include='**/default-bootstrap.const.spec.ts' --watch=false`
Expected: PASS (5 specs)
- [ ] **Step 6: Commit**
```bash
git add src/app/shared/models/config/default-bootstrap.const.ts src/app/shared/models/config/default-bootstrap.const.spec.ts src/app/shared/models/config/index.ts
git commit -m "feat: add DEFAULT_BOOTSTRAP placeholder config"
```
---
### Task 3: Swap to `DEFAULT_BOOTSTRAP` in `ConfigService` when unpublished
**Files:**
- Modify: `src/app/core/config/config.service.ts`
- Test: `src/app/core/config/config.service.spec.ts` (new file — none exists today)
**Interfaces:**
- Consumes: `DEFAULT_BOOTSTRAP` (Task 2), `BootstrapConfig.published` (Task 1), existing `CONFIG_PROVIDER` token / `ConfigProvider.loadBootstrap()`.
- Produces: no new public method — `loadBootstrap()` and `getBootstrapSnapshot()` keep their existing signatures; behavior changes only in which object ends up cached.
- [ ] **Step 1: Write the failing tests**
Create `src/app/core/config/config.service.spec.ts`:
```ts
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { ConfigService } from './config.service';
import { CONFIG_PROVIDER } from './config-provider.token';
import { ConfigProvider } from './config-provider.interface';
import { BootstrapConfig, DEFAULT_BOOTSTRAP } from '../../shared/models/config';
function makeRealBootstrap(overrides: Partial<BootstrapConfig> = {}): BootstrapConfig {
return { ...DEFAULT_BOOTSTRAP, published: true, tenant: { ...DEFAULT_BOOTSTRAP.tenant, name: 'Acme' }, ...overrides };
}
describe('ConfigService', () => {
let provider: jasmine.SpyObj<ConfigProvider>;
function setup(response: BootstrapConfig): ConfigService {
provider = jasmine.createSpyObj<ConfigProvider>('ConfigProvider', ['loadBootstrap']);
provider.loadBootstrap.and.returnValue(of(response));
TestBed.configureTestingModule({
providers: [ConfigService, { provide: CONFIG_PROVIDER, useValue: provider }],
});
return TestBed.inject(ConfigService);
}
it('caches the real response when published is true', done => {
const real = makeRealBootstrap();
const service = setup(real);
service.loadBootstrap().subscribe(result => {
expect(result.tenant.name).toBe('Acme');
expect(service.getBootstrapSnapshot()).toEqual(real);
done();
});
});
it('swaps to DEFAULT_BOOTSTRAP when published is false', done => {
const draft = makeRealBootstrap({ published: false });
const service = setup(draft);
service.loadBootstrap().subscribe(result => {
expect(result).toEqual(DEFAULT_BOOTSTRAP);
expect(service.getBootstrapSnapshot()).toEqual(DEFAULT_BOOTSTRAP);
done();
});
});
it('treats a missing published field as published (backward compatible)', done => {
const legacy = makeRealBootstrap();
delete (legacy as Partial<BootstrapConfig>).published;
const service = setup(legacy);
service.loadBootstrap().subscribe(result => {
expect(result.tenant.name).toBe('Acme');
expect(result).not.toEqual(DEFAULT_BOOTSTRAP);
done();
});
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `ng test --include='**/config.service.spec.ts' --watch=false`
Expected: FAIL on the "swaps to DEFAULT_BOOTSTRAP" spec — `result` currently equals `draft` (unpublished, unswapped), not `DEFAULT_BOOTSTRAP`.
- [ ] **Step 3: Implement the swap**
Replace the body of `src/app/core/config/config.service.ts` with:
```ts
import { Injectable, inject, signal } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { catchError, map, shareReplay, tap } from 'rxjs/operators';
import { BootstrapConfig, DEFAULT_BOOTSTRAP } from '../../shared/models/config';
import { CONFIG_PROVIDER } from './config-provider.token';
@Injectable({ providedIn: 'root' })
export class ConfigService {
private readonly provider = inject(CONFIG_PROVIDER);
private bootstrapSnapshot: BootstrapConfig | null = null;
private bootstrap$?: Observable<BootstrapConfig>;
private readonly revisionState = signal(0);
readonly bootstrapRevision = this.revisionState.asReadonly();
loadBootstrap(forceRefresh: boolean = false): Observable<BootstrapConfig> {
if (this.bootstrapSnapshot && !forceRefresh && this.bootstrap$) {
return this.bootstrap$;
}
if (!this.bootstrap$ || forceRefresh) {
this.bootstrap$ = this.provider.loadBootstrap().pipe(
map(config => (config.published === false ? DEFAULT_BOOTSTRAP : config)),
tap(config => {
this.bootstrapSnapshot = config;
this.revisionState.update(value => value + 1);
}),
shareReplay(1),
catchError(error => {
this.bootstrap$ = undefined;
this.bootstrapSnapshot = null;
return throwError(() => error);
})
);
}
return this.bootstrap$;
}
getBootstrapSnapshot(): BootstrapConfig | null {
return this.bootstrapSnapshot;
}
applyBootstrapOverride(next: BootstrapConfig): void {
const cloned = JSON.parse(JSON.stringify(next)) as BootstrapConfig;
this.bootstrapSnapshot = cloned;
this.bootstrap$ = of(cloned);
this.revisionState.update(value => value + 1);
}
}
```
The only change from the current file: the `map` operator inserted before `tap`, and the `DEFAULT_BOOTSTRAP` import. `config.published === false` (strict) rather than `!config.published` is deliberate — it makes `undefined`/missing explicitly fall through to "treat as published," matching the backward-compatibility constraint.
- [ ] **Step 4: Run tests to verify they pass**
Run: `ng test --include='**/config.service.spec.ts' --watch=false`
Expected: PASS (3 specs)
- [ ] **Step 5: Run the full unit suite to check for regressions**
Run: `ng test --watch=false`
Expected: PASS, no new failures (existing consumers of `ConfigService` only rely on `loadBootstrap()`/`getBootstrapSnapshot()`, unchanged signatures).
- [ ] **Step 6: Commit**
```bash
git add src/app/core/config/config.service.ts src/app/core/config/config.service.spec.ts
git commit -m "feat: fall back to DEFAULT_BOOTSTRAP when marketplace is unpublished"
```
---
### Task 4: E2E smoke test for the unpublished placeholder
**Files:**
- Modify: `e2e/smoke.spec.ts` (existing Playwright smoke suite)
**Interfaces:**
- Consumes: Playwright route interception (`page.route`), `DEFAULT_BOOTSTRAP.branding.brandName` (Task 2) as the assertion target.
- [ ] **Step 1: Read the existing smoke spec to match its conventions**
Run: `cat e2e/smoke.spec.ts` (or open the file) — confirm the existing pattern for intercepting `/bootstrap` if one exists, and the base URL fixture used by other specs in this file.
- [ ] **Step 2: Write the failing test**
Add to `e2e/smoke.spec.ts`:
```ts
test('renders the placeholder home page when the marketplace is unpublished', async ({ page }) => {
await page.route('**/bootstrap', route =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ schemaVersion: '1.0.0', generatedAt: new Date().toISOString(), published: false }),
})
);
await page.goto('/');
await expect(page.getByText('Welcome to Marketplace')).toBeVisible();
});
```
- [ ] **Step 3: Run it to verify it fails**
Run: `npx playwright test e2e/smoke.spec.ts -g "unpublished"`
Expected: FAIL — either the route interception payload is rejected client-side (schema mismatch) or the text isn't found, since Task 13 aren't wired in yet if this task runs standalone. If Tasks 13 are already merged, this should already pass; if it fails for a reason other than "text not found" (e.g. a network error), fix the intercepted payload shape first, not the app code.
- [ ] **Step 4: Confirm it passes against the real implementation**
Run: `npx playwright test e2e/smoke.spec.ts -g "unpublished"`
Expected: PASS, once Tasks 13 are committed.
- [ ] **Step 5: Commit**
```bash
git add e2e/smoke.spec.ts
git commit -m "test: add e2e smoke test for unpublished-marketplace placeholder"
```
---
## Self-review notes
- **Spec coverage:** §1 (backend `published` field) → Task 1. §2 (whole-object swap, `DEFAULT_BOOTSTRAP` composed from existing `DEFAULT_*` constants) → Task 2. §2 (`ConfigService` trigger point) → Task 3. §3 (error handling: missing field = published, HTTP failure unchanged) → covered by Task 3 Step 1 test 3 and by leaving `catchError` untouched. §4 (testing) → Tasks 24 cover unit + E2E; schema-shape check is TypeScript compilation itself (Task 2 Step 3 must compile against `BootstrapConfig`).
- **Backend-side resolution logic** (how the backend decides `published` from `MarketplaceRevision.status`) is explicitly out of scope per the spec — not a frontend-repo task.