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>
This commit is contained in:
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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user