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