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

21 KiB
Raw Blame History

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:

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
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:

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:

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):

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
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:

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:

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
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:

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