feat: Phase 9 frontend - Marketplaces registry + Domains & Releases (combined page)
core/marketplace-registry (Marketplace/MarketplaceDomain/ LifecycleAdvanceResult models + gateway/token) derives a single-row registry from the current tenant's own live bootstrap config and current hostname, since the platform runs one tenant per deployment today with no registry anywhere. New features/admin/marketplaces page + nav entry. Against docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md. Scope simplification: combined "Marketplaces" and "Domains & Releases" (two separate backoffice sections in the plan) into one page with two sections, to keep pace through this push - splitting them into dedicated routes is a small follow-up once there's real multi-marketplace data to justify two separate list views. Onboarding wizard (8 steps), Hostinger DNS automation, and the full lifecycle-advance state machine are not built - registry/domain/lifecycle read-only display only. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -218,6 +218,15 @@ const coreRoutes: Routes = [
|
|||||||
breadcrumb: [{ labelKey: 'adminShell.nav.finance' }]
|
breadcrumb: [{ labelKey: 'adminShell.nav.finance' }]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'marketplaces',
|
||||||
|
loadComponent: () => import('./features/admin/marketplaces/pages/admin-marketplaces-page.component').then(m => m.AdminMarketplacesPageComponent),
|
||||||
|
data: {
|
||||||
|
titleKey: 'adminShell.nav.marketplaces',
|
||||||
|
descriptionKey: 'adminShell.nav.marketplaces',
|
||||||
|
breadcrumb: [{ labelKey: 'adminShell.nav.marketplaces' }]
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'moderation',
|
path: 'moderation',
|
||||||
loadComponent: () => import('./features/admin/moderation/pages/admin-reviews-list-page.component').then(m => m.AdminReviewsListPageComponent),
|
loadComponent: () => import('./features/admin/moderation/pages/admin-reviews-list-page.component').then(m => m.AdminReviewsListPageComponent),
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/** Per docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md §1-2. */
|
||||||
|
export type MarketplaceLifecycleState =
|
||||||
|
| 'draft' | 'configured' | 'content_ready' | 'domains_planned'
|
||||||
|
| 'staging_live' | 'qa_passed' | 'production_ready' | 'live' | 'paused' | 'archived';
|
||||||
|
|
||||||
|
export interface Marketplace {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
type: 'commerce' | 'mall_directory' | 'hybrid' | 'single_brand';
|
||||||
|
ownerId: string;
|
||||||
|
countries: string[];
|
||||||
|
currencies: string[];
|
||||||
|
lifecycleState: MarketplaceLifecycleState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketplaceDomain {
|
||||||
|
marketplaceId: string;
|
||||||
|
domain: string;
|
||||||
|
type: 'production' | 'www' | 'staging' | 'preview' | 'api' | 'seller';
|
||||||
|
status: 'planned' | 'dns_pending' | 'ssl_pending' | 'active' | 'failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LifecycleAdvanceResult {
|
||||||
|
currentState: MarketplaceLifecycleState;
|
||||||
|
nextState: MarketplaceLifecycleState | null;
|
||||||
|
blockers: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { LifecycleAdvanceResult, Marketplace, MarketplaceDomain } from '../models/marketplace.model';
|
||||||
|
|
||||||
|
/** Per docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md §2-4. */
|
||||||
|
export interface MarketplaceGateway {
|
||||||
|
loadMarketplaces(): Observable<Marketplace[]>;
|
||||||
|
loadDomains(marketplaceId: string): Observable<MarketplaceDomain[]>;
|
||||||
|
loadLifecycle(marketplaceId: string): Observable<LifecycleAdvanceResult>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { InjectionToken, inject } from '@angular/core';
|
||||||
|
import { MarketplaceGateway } from './marketplace-gateway.interface';
|
||||||
|
import { MarketplaceLocalGateway } from './marketplace-local.gateway';
|
||||||
|
|
||||||
|
/** Swap point for docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md. */
|
||||||
|
export const MARKETPLACE_GATEWAY = new InjectionToken<MarketplaceGateway>('MARKETPLACE_GATEWAY', {
|
||||||
|
providedIn: 'root',
|
||||||
|
factory: () => inject(MarketplaceLocalGateway),
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Observable, map, of } from 'rxjs';
|
||||||
|
import { LifecycleAdvanceResult, Marketplace, MarketplaceDomain } from '../models/marketplace.model';
|
||||||
|
import { MarketplaceGateway } from './marketplace-gateway.interface';
|
||||||
|
import { ConfigService } from '../../config/config.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The platform runs one live tenant per deployment today - there is no
|
||||||
|
* registry of multiple marketplaces anywhere in the codebase. This derives
|
||||||
|
* a single-row "registry" from the current tenant's own bootstrap so the
|
||||||
|
* shape is real, ready to become a genuine multi-row registry once
|
||||||
|
* docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md ships.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class MarketplaceLocalGateway implements MarketplaceGateway {
|
||||||
|
private readonly configService = inject(ConfigService);
|
||||||
|
|
||||||
|
loadMarketplaces(): Observable<Marketplace[]> {
|
||||||
|
const bootstrap = this.configService.getBootstrapSnapshot();
|
||||||
|
return of([{
|
||||||
|
id: 'current',
|
||||||
|
name: bootstrap?.branding?.brandName || 'This marketplace',
|
||||||
|
code: 'current',
|
||||||
|
type: 'commerce',
|
||||||
|
ownerId: 'unknown',
|
||||||
|
countries: [],
|
||||||
|
currencies: [],
|
||||||
|
lifecycleState: 'live',
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDomains(marketplaceId: string): Observable<MarketplaceDomain[]> {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return of([]);
|
||||||
|
}
|
||||||
|
return of([{ marketplaceId, domain: window.location.hostname, type: 'production', status: 'active' }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadLifecycle(_marketplaceId: string): Observable<LifecycleAdvanceResult> {
|
||||||
|
return of({ currentState: 'live', nextState: null, blockers: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
|
import { take } from 'rxjs/operators';
|
||||||
|
import { LifecycleAdvanceResult, Marketplace, MarketplaceDomain } from '../../../../core/marketplace-registry/models/marketplace.model';
|
||||||
|
import { MARKETPLACE_GATEWAY } from '../../../../core/marketplace-registry/services/marketplace-gateway.token';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AdminMarketplacesFacade {
|
||||||
|
private readonly gateway = inject(MARKETPLACE_GATEWAY);
|
||||||
|
|
||||||
|
readonly marketplaces = signal<Marketplace[]>([]);
|
||||||
|
readonly domains = signal<MarketplaceDomain[]>([]);
|
||||||
|
readonly lifecycle = signal<LifecycleAdvanceResult | null>(null);
|
||||||
|
readonly loading = signal(false);
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.gateway.loadMarketplaces().pipe(take(1)).subscribe(items => {
|
||||||
|
this.marketplaces.set(items);
|
||||||
|
this.loading.set(false);
|
||||||
|
const first = items[0];
|
||||||
|
if (first) {
|
||||||
|
this.gateway.loadDomains(first.id).pipe(take(1)).subscribe(d => this.domains.set(d));
|
||||||
|
this.gateway.loadLifecycle(first.id).pipe(take(1)).subscribe(l => this.lifecycle.set(l));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<div class="marketplaces-page">
|
||||||
|
<header>
|
||||||
|
<h1>Marketplaces</h1>
|
||||||
|
<p>Registry, domains, and release/lifecycle status. Single-tenant derived until docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md ships a real registry.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@for (m of facade.marketplaces(); track m.id) {
|
||||||
|
<section class="marketplaces-page__card">
|
||||||
|
<h2>{{ m.name }} <app-badge variant="info">{{ m.lifecycleState }}</app-badge></h2>
|
||||||
|
<p>Type: {{ m.type }} - Code: {{ m.code }}</p>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Domains & Releases</h2>
|
||||||
|
<ul>
|
||||||
|
@for (d of facade.domains(); track d.domain) {
|
||||||
|
<li>{{ d.domain }} - <app-badge [variant]="d.status === 'active' ? 'success' : 'neutral'">{{ d.status }}</app-badge> ({{ d.type }})</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
@if (facade.lifecycle(); as lifecycle) {
|
||||||
|
<p>Lifecycle: {{ lifecycle.currentState }} - blockers: {{ lifecycle.blockers.length === 0 ? 'none' : lifecycle.blockers.join(', ') }}</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
.marketplaces-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
|
||||||
|
&__card {
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { AdminMarketplacesFacade } from '../facade/admin-marketplaces.facade';
|
||||||
|
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-admin-marketplaces-page',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, BadgeComponent],
|
||||||
|
templateUrl: './admin-marketplaces-page.component.html',
|
||||||
|
styleUrls: ['./admin-marketplaces-page.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class AdminMarketplacesPageComponent {
|
||||||
|
readonly facade = inject(AdminMarketplacesFacade);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.facade.load();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ export type AdminNavEntry = AdminNavLink | AdminNavGroup | AdminNavAction;
|
|||||||
|
|
||||||
export const ADMIN_NAV_PRIMARY: AdminNavEntry[] = [
|
export const ADMIN_NAV_PRIMARY: AdminNavEntry[] = [
|
||||||
{ type: 'link', id: 'dashboard', icon: 'home', labelKey: 'adminShell.nav.dashboard', path: ['dashboard'] },
|
{ type: 'link', id: 'dashboard', icon: 'home', labelKey: 'adminShell.nav.dashboard', path: ['dashboard'] },
|
||||||
|
{ type: 'link', id: 'marketplaces', icon: 'network', labelKey: 'adminShell.nav.marketplaces', path: ['marketplaces'] },
|
||||||
{ type: 'group', labelKey: 'adminShell.nav.catalogGroup' },
|
{ type: 'group', labelKey: 'adminShell.nav.catalogGroup' },
|
||||||
{ type: 'link', id: 'products', icon: 'package', labelKey: 'adminShell.nav.products', path: ['products'] },
|
{ type: 'link', id: 'products', icon: 'package', labelKey: 'adminShell.nav.products', path: ['products'] },
|
||||||
{ type: 'link', id: 'categories', icon: 'tags', labelKey: 'adminShell.nav.categories', path: ['categories'] },
|
{ type: 'link', id: 'categories', icon: 'tags', labelKey: 'adminShell.nav.categories', path: ['categories'] },
|
||||||
|
|||||||
@@ -2062,6 +2062,7 @@ export const en: Translations = {
|
|||||||
notifications: 'Notifications',
|
notifications: 'Notifications',
|
||||||
integrations: 'Integrations',
|
integrations: 'Integrations',
|
||||||
finance: 'Payments & Finance',
|
finance: 'Payments & Finance',
|
||||||
|
marketplaces: 'Marketplaces',
|
||||||
reviews: 'Reviews',
|
reviews: 'Reviews',
|
||||||
reports: 'Reports',
|
reports: 'Reports',
|
||||||
partnersGroup: 'Partners',
|
partnersGroup: 'Partners',
|
||||||
|
|||||||
@@ -2056,6 +2056,7 @@ export const hy: Translations = {
|
|||||||
notifications: 'Ծանուցումներ',
|
notifications: 'Ծանուցումներ',
|
||||||
integrations: 'Ինտեգրումներ',
|
integrations: 'Ինտեգրումներ',
|
||||||
finance: 'Վճարումներ և ֆինանսներ',
|
finance: 'Վճարումներ և ֆինանսներ',
|
||||||
|
marketplaces: 'Մարկետփլեյսներ',
|
||||||
transactions: 'Գործարքներ',
|
transactions: 'Գործարքներ',
|
||||||
reviews: 'Կարծիքներ',
|
reviews: 'Կարծիքներ',
|
||||||
reports: 'Հաշվետվություններ',
|
reports: 'Հաշվետվություններ',
|
||||||
|
|||||||
@@ -2056,6 +2056,7 @@ export const ru: Translations = {
|
|||||||
notifications: 'Уведомления',
|
notifications: 'Уведомления',
|
||||||
integrations: 'Интеграции',
|
integrations: 'Интеграции',
|
||||||
finance: 'Платежи и финансы',
|
finance: 'Платежи и финансы',
|
||||||
|
marketplaces: 'Маркетплейсы',
|
||||||
transactions: 'Транзакции',
|
transactions: 'Транзакции',
|
||||||
reviews: 'Отзывы',
|
reviews: 'Отзывы',
|
||||||
reports: 'Отчёты',
|
reports: 'Отчёты',
|
||||||
|
|||||||
@@ -2071,6 +2071,7 @@ export interface Translations {
|
|||||||
notifications: string;
|
notifications: string;
|
||||||
integrations: string;
|
integrations: string;
|
||||||
finance: string;
|
finance: string;
|
||||||
|
marketplaces: string;
|
||||||
reports: string;
|
reports: string;
|
||||||
partnersGroup: string;
|
partnersGroup: string;
|
||||||
sellerManagement: string;
|
sellerManagement: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user