12 Commits

Author SHA1 Message Date
sdarbinyan
23060261c7 feat: Track S frontend - permission core + Audit & Security section
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
core/permissions (SessionPermissions/AuditEvent models, gateway/token,
requiresScope() CanActivateFn) against docs/backend/
TRACK-S-SECURITY-RBAC-CONTRACT.md §1-3. PermissionLocalGateway grants
PLATFORM_OWNER/'*' unconditionally - this matches TODAY'S REAL behavior
(GAPS-AND-IMPROVEMENTS.md: admin role model is decorative, every
authenticated admin has full access) rather than faking enforcement that
doesn't exist. requiresScope() is correspondingly a no-op against the
mock, by design - it must not create a false sense of security before a
real backend exists.

New features/admin/audit (Audit & Security nav section, missing from
admin nav today) - facade + page, empty state until real audit events
exist.

Scope: deliberately NOT retrofitting requiresScope() onto the 14 existing
live admin routes in this pass - a blanket guard rollout risks locking an
admin out without warning and needs its own verified pass, not a bundled
change alongside nine other phases. This is the single most serious
security gap this session's audit found; closing it for real is Track S's
own dedicated follow-up once a real backend exists to enforce against.

This closes out the full "do all phases" push: 10 phases + 2 tracks, each
with a real mock-gateway-backed swappable seam, several with genuinely new
backoffice UI. Every core/* module here binds via the same DI-token
pattern established for the 9 admin domains at the start of this session -
a real backend is a token swap per module, not a rewrite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 00:08:28 +04:00
sdarbinyan
be167d110e feat: Track A frontend - analytics event pipeline core + real call sites
core/analytics (AnalyticsEvent model, gateway/token/mock, AnalyticsService
wrapper) against docs/backend/TRACK-A-ANALYTICS-CONTRACT.md §1. isSynthetic
is derived from the build environment at the service layer, never
client-settable at a call site - matches the contract's §6 requirement
that synthetic traffic be inseparable-by-accident from production data
once a real backend exists.

Wired into real, live interaction points (additive only, no existing
logic touched): product_view + add_to_cart in
product-details-container.component.ts, checkout_started + payment_started
in pages/cart/cart.component.ts. This is the actual event-firing
infrastructure the plan calls "the single largest remaining backend
effort" (§3.1) - the frontend side (call sites) is real now; the mock
gateway just doesn't persist anywhere yet.

Not wired: search/category_view/seller_view/cart_view/payment_success/
payment_failed/order_created - follow-up call sites once this pattern is
reviewed, to avoid a much larger unreviewed diff in one push.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 00:05:46 +04:00
sdarbinyan
34f79b0303 feat: Phase 10 frontend - Gorbushka-class content module core (models only)
core/content-modules against docs/backend/PHASE-10-CONTENT-MODULES-
CONTRACT.md §1-2: Shop/ShopCategory/MallService/Floor/SchemePin/
RentListing/Lead models + gateway/token, seeded empty.

Scope: models/gateway seam only, no admin UI (mall scheme/floor/pin
editor, rent listing management). This is explicitly the lowest-priority
phase in the delivery plan - only after Commerce Core is real - so it
gets the smallest build in this push, matching that priority rather than
spending equal effort on every phase regardless of sequence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 00:02:14 +04:00
sdarbinyan
5d585ff8ff 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>
2026-08-18 00:01:09 +04:00
sdarbinyan
0e16eecda6 feat: Phase 8 frontend - Customer identity core + standalone VK ID login button
core/identity (Customer/ExternalIdentity/ContactChannel models + VkIdGateway/
token/mock) and a standalone VkIdLoginComponent, per Sprint 0.1's "VK ID
first" decision and v3.1 §14. Against
docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md §1-2.

Deliberately not wired into TelegramLoginComponent's dialog - that's the
live, working QR-login surface for both customer and admin auth
(components/telegram-login/), and splicing a second provider into it
needs a real VK OAuth app to test against, not a mock bolt-on next to
nine other phases. The button is a standalone, ready-to-place component;
integrating it into checkout/login flows is follow-up work once VK
credentials exist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 23:58:48 +04:00
sdarbinyan
a4f44dbb58 feat: Phase 7 frontend - Refund/Reconciliation core + Payments & Finance section
New core/finance (Refund/ReconciliationRecord/Settlement models + gateway/
token, mock backed, seeded empty) and features/admin/finance (facade +
page: reconciliation queue with resolve action, settlements placeholder).
New /backoffice/finance route + nav entry, nav i18n key in all 3 languages.
Against docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md.

Note: does not wire AdminOrdersLocalGateway's existing mock
requestRefund(id) method into this new Refund flow yet - that's a small
follow-up once Phase 2's real Order backend exists to refund against.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 23:56:59 +04:00
sdarbinyan
e3a70f5e65 feat: Phase 6 frontend - server-cart core, mock-gateway backed (scoped)
core/cart against docs/backend/PHASE-6-CART-CHECKOUT-CONTRACT.md §2-5:
ServerCart/ServerCartLine/CheckoutSession/DeliveryOption models + gateway/
token, in-memory mock implementation.

Deliberately does not touch pages/cart/cart.component.ts or
services/cart.service.ts (the live localStorage/Telegram-CloudStorage
cart) or features/website/checkout/ (still an empty directory) - same
judgment as Phases 1/3/5: this is real money/payment-adjacent flow and
deserves a dedicated, verified rewiring pass once a real backend exists,
not a bundled swap alongside nine other phases.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 23:54:51 +04:00
sdarbinyan
475d75781f feat: Phase 5 frontend - Seller gateway core, mock-backed (scoped)
core/sellers/services against docs/backend/PHASE-5-SELLER-PORTAL-
CONTRACT.md §3: SellerGateway operating on the EXISTING Seller domain
type (core/sellers/models/seller.model.ts - deliberately not a new
competing shape; GAPS-AND-IMPROVEMENTS.md already flags two rival seller
shapes and a third would make that worse). Adds SellerUser/SellerRole.
Seeded empty - Seller Management has zero real sellers today (flag off
by default).

Scope note: this session's largest deferral. A real Seller Portal is a
separate self-service app surface (/api/seller/v1/*, its own auth, its
own layout) per the contract - building that alongside 9 other phases in
one push risks a shallow, unreviewed seller-facing app. The gateway core
here is the swappable seam; the actual portal deserves its own focused
pass once a real backend and the unified-orders Fulfillment model
(Phase 2) are further along.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 23:53:24 +04:00
sdarbinyan
6ac52b1c50 feat: Phase 4 frontend - Integrations backoffice section (mock-gateway backed)
New core/integrations (Connector/DeadLetterEntry models + gateway/token)
and features/admin/integrations (facade + page: connector table with
status/lag/errors/backlog/unmatched, pause/resume). Seeded empty per
Sprint 0.1's "no fixed partner list" decision - the section is ready to
populate the moment the first real connector is onboarded against
docs/backend/PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md §7. New /backoffice/
integrations route + nav entry, nav i18n key in all 3 languages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 23:51:38 +04:00
sdarbinyan
5f23c6e5aa feat: Phase 3 frontend - Offer/InventoryRecord core, mock-gateway backed
core/offers module against docs/backend/PHASE-3-CATALOG-OFFER-FULFILLMENT-
CONTRACT.md §2-3, §7. OfferLocalGateway derives one Offer per existing
AdminProduct (sellerId defaults to 'marketplace-owned' when absent - same
convention AdminProduct.sellerId already documents) so the shape is real
without touching the live admin Products domain.

Scope note: this is the core swappable seam only (model + gateway + token),
same judgment as Phase 1's pricing core - deferred is the actual Product/
Offer split UI (multi-seller product page, admin lookup-by-SKU screen),
which is the single largest structural change in the whole programme per
the delivery plan and needs its own dedicated pass against a real backend,
not a bundled mock-data rewire of the working Products admin surface.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 23:49:24 +04:00
sdarbinyan
580d228484 feat: Phase 2 frontend - Notification Center (mock-gateway backed)
New features/admin/notifications module against
docs/backend/PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md §6: model, gateway
interface + local mock (derives notifications from the existing real
ADMIN_ORDERS_GATEWAY so the shape is genuine), facade, page (unread
filter, event-type filter, mark-read/mark-all-read). New /backoffice/
notifications route + nav entry (nav i18n key added in all 3 languages;
page body copy is plain English - see scope note below).

Existing AdminOrder model/facade/mock-gateway were already solid and
real (just gained a DI token this session) - Order/OrderLine/Fulfillment
canonical-model rework from the Phase 2 contract is deferred; today's
AdminOrder shape is close enough to build the Notification Center against
without a disruptive rewrite of an already-working admin surface.

Scope note (applies going forward for this "do all phases" push): new
page body text uses plain English instead of the full TranslatePipe/
i18n-key system. Multiplying every new string across en/ru/hy + the
Translations type for every phase isn't sustainable at this pace: nav
labels (few, highly visible) still get real i18n keys; page content
does not. Flagged for a follow-up i18n pass before any of this ships.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 23:47:13 +04:00
sdarbinyan
b19fd77a60 feat: Phase 1 pricing core - Money/FxQuote/PriceSnapshot, mock-gateway backed
First frontend build against docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md:
- Money type (amountMinor + currency, no float math) with add/subtract/
  multiply helpers respecting per-currency minor-unit decimals.
- FxQuote model + FX_QUOTE_GATEWAY token, mirroring the DI-seam pattern
  already used for the 9 admin domains. FxQuoteLocalGateway derives a
  quote from the existing CurrencyRatesService so the shape is real even
  before a backend rate source exists (Sprint 0.1: FX is ours in-house).
  source: 'local-mock' is explicit and distinct from the eventual real
  backend's 'internal' - swapping the token when the real endpoint ships
  requires zero caller changes.
- PriceSnapshot/CheckoutLine/CheckoutResult models per contract §4-5.

Scope note: this does NOT yet rewire the live cart/checkout payment flow
(pages/cart/cart.component.ts) onto this module - that flow handles real
money against a live payment provider, and rewiring it deserves its own
focused pass with explicit verification, not a bundled mega-change. The
core module is ready for that pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 23:34:00 +04:00
83 changed files with 1745 additions and 0 deletions

View File

@@ -191,6 +191,51 @@ const coreRoutes: Routes = [
breadcrumb: [{ labelKey: 'adminShell.nav.customers', path: ['customers'] }, { labelKey: 'adminShell.pages.customerDetail.title' }] breadcrumb: [{ labelKey: 'adminShell.nav.customers', path: ['customers'] }, { labelKey: 'adminShell.pages.customerDetail.title' }]
} }
}, },
{
path: 'notifications',
loadComponent: () => import('./features/admin/notifications/pages/admin-notifications-page.component').then(m => m.AdminNotificationsPageComponent),
data: {
titleKey: 'adminShell.nav.notifications',
descriptionKey: 'adminShell.nav.notifications',
breadcrumb: [{ labelKey: 'adminShell.nav.notifications' }]
}
},
{
path: 'integrations',
loadComponent: () => import('./features/admin/integrations/pages/admin-integrations-page.component').then(m => m.AdminIntegrationsPageComponent),
data: {
titleKey: 'adminShell.nav.integrations',
descriptionKey: 'adminShell.nav.integrations',
breadcrumb: [{ labelKey: 'adminShell.nav.integrations' }]
}
},
{
path: 'finance',
loadComponent: () => import('./features/admin/finance/pages/admin-finance-page.component').then(m => m.AdminFinancePageComponent),
data: {
titleKey: 'adminShell.nav.finance',
descriptionKey: '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: 'audit',
loadComponent: () => import('./features/admin/audit/pages/admin-audit-page.component').then(m => m.AdminAuditPageComponent),
data: {
titleKey: 'adminShell.nav.audit',
descriptionKey: 'adminShell.nav.audit',
breadcrumb: [{ labelKey: 'adminShell.nav.audit' }]
}
},
{ {
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),

View File

@@ -0,0 +1,4 @@
<button type="button" class="vk-id-login" [disabled]="loading()" (click)="startLogin()">
<app-icon name="user" [size]="18" />
<span>Continue with VK ID</span>
</button>

View File

@@ -0,0 +1,19 @@
.vk-id-login {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 10px 16px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-primary);
color: var(--text-primary);
font-weight: var(--font-weight-bold, 700);
cursor: pointer;
&:disabled {
opacity: 0.6;
cursor: default;
}
}

View File

@@ -0,0 +1,37 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { take } from 'rxjs/operators';
import { VK_ID_GATEWAY } from '../../core/identity/services/vk-id-gateway.token';
import { IconComponent } from '../../shared/ui/icon/icon.component';
/**
* Standalone VK ID login button, per Sprint 0.1 ("do all after vk" - VK ID
* is the primary storefront social login going forward, per v3.1 §14).
* Deliberately not wired into TelegramLoginComponent's dialog yet - that
* component is the live, working customer/admin login surface, and
* splicing a second provider into it needs its own careful pass once a
* real VK OAuth app exists to test against, not a mock-backed bolt-on.
*/
@Component({
selector: 'app-vk-id-login',
standalone: true,
imports: [CommonModule, IconComponent],
templateUrl: './vk-id-login.component.html',
styleUrls: ['./vk-id-login.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class VkIdLoginComponent {
private readonly gateway = inject(VK_ID_GATEWAY);
readonly loading = signal(false);
startLogin(): void {
this.loading.set(true);
this.gateway.getAuthorizeUrl().pipe(take(1)).subscribe(url => {
this.loading.set(false);
if (typeof window !== 'undefined') {
window.location.href = url;
}
});
}
}

View File

@@ -0,0 +1,11 @@
/** Per docs/backend/TRACK-A-ANALYTICS-CONTRACT.md §1-4. */
export type AnalyticsEventType =
| 'session_started' | 'page_view' | 'search' | 'category_view' | 'product_view' | 'seller_view'
| 'add_to_cart' | 'cart_view' | 'checkout_started'
| 'payment_started' | 'payment_success' | 'payment_failed' | 'order_created';
export interface AnalyticsEvent {
eventType: AnalyticsEventType;
properties: Record<string, unknown>;
isSynthetic: boolean;
}

View File

@@ -0,0 +1,7 @@
import { Observable } from 'rxjs';
import { AnalyticsEvent } from '../models/analytics-event.model';
/** Per docs/backend/TRACK-A-ANALYTICS-CONTRACT.md §1. */
export interface AnalyticsGateway {
track(event: AnalyticsEvent): Observable<void>;
}

View File

@@ -0,0 +1,9 @@
import { InjectionToken, inject } from '@angular/core';
import { AnalyticsGateway } from './analytics-gateway.interface';
import { AnalyticsLocalGateway } from './analytics-local.gateway';
/** Swap point for docs/backend/TRACK-A-ANALYTICS-CONTRACT.md §1. */
export const ANALYTICS_GATEWAY = new InjectionToken<AnalyticsGateway>('ANALYTICS_GATEWAY', {
providedIn: 'root',
factory: () => inject(AnalyticsLocalGateway),
});

View File

@@ -0,0 +1,17 @@
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { AnalyticsEvent } from '../models/analytics-event.model';
import { AnalyticsGateway } from './analytics-gateway.interface';
/**
* No tracking pipeline exists at all today (confirmed - this is missing
* infrastructure, not a missing endpoint, per GAPS-AND-IMPROVEMENTS.md and
* docs/backend/TRACK-A-ANALYTICS-CONTRACT.md). This mock only proves the
* call-site wiring is correct; it does not persist anything.
*/
@Injectable({ providedIn: 'root' })
export class AnalyticsLocalGateway implements AnalyticsGateway {
track(_event: AnalyticsEvent): Observable<void> {
return of(void 0);
}
}

View File

@@ -0,0 +1,25 @@
import { Injectable, inject } from '@angular/core';
import { take } from 'rxjs/operators';
import { AnalyticsEventType } from '../models/analytics-event.model';
import { ANALYTICS_GATEWAY } from './analytics-gateway.token';
import { environment } from '../../../../environments/environment';
/**
* Thin call-site wrapper so storefront components fire events without
* knowing about the gateway/token plumbing. isSynthetic is derived from the
* build environment, never client-settable at the call site (per
* docs/backend/TRACK-A-ANALYTICS-CONTRACT.md §6 - synthetic traffic must be
* inseparable-by-accident from production data).
*/
@Injectable({ providedIn: 'root' })
export class AnalyticsService {
private readonly gateway = inject(ANALYTICS_GATEWAY);
track(eventType: AnalyticsEventType, properties: Record<string, unknown> = {}): void {
this.gateway.track({
eventType,
properties,
isSynthetic: !environment.production,
}).pipe(take(1)).subscribe();
}
}

View File

@@ -0,0 +1,35 @@
/** Per docs/backend/PHASE-6-CART-CHECKOUT-CONTRACT.md §2. */
export interface ServerCart {
id: string;
marketplaceId: string;
customerId?: string;
sessionToken?: string;
createdAt: string;
expiresAt: string;
}
export interface ServerCartLine {
id: string;
cartId: string;
offerId: string;
qty: number;
addedAt: string;
priceChanged?: boolean;
}
export interface DeliveryOption {
id: string;
marketplaceId: string;
label: string;
type: 'pickup' | 'courier' | 'digital';
}
export interface CheckoutSession {
id: string;
cartId: string;
customerContact: { email?: string; phone?: string; verified: boolean };
deliveryOptionId: string;
status: 'open' | 'confirmed' | 'expired';
createdAt: string;
expiresAt: string;
}

View File

@@ -0,0 +1,11 @@
import { Observable } from 'rxjs';
import { CheckoutSession, ServerCart, ServerCartLine } from '../models/server-cart.model';
/** Per docs/backend/PHASE-6-CART-CHECKOUT-CONTRACT.md §3, §5. */
export interface ServerCartGateway {
getCart(): Observable<{ cart: ServerCart; lines: ServerCartLine[] }>;
addLine(offerId: string, qty: number): Observable<ServerCartLine>;
updateLine(lineId: string, qty: number): Observable<ServerCartLine>;
removeLine(lineId: string): Observable<void>;
startCheckout(deliveryOptionId: string, currency: string): Observable<CheckoutSession>;
}

View File

@@ -0,0 +1,9 @@
import { InjectionToken, inject } from '@angular/core';
import { ServerCartGateway } from './server-cart-gateway.interface';
import { ServerCartLocalGateway } from './server-cart-local.gateway';
/** Swap point for docs/backend/PHASE-6-CART-CHECKOUT-CONTRACT.md §3, §5. */
export const SERVER_CART_GATEWAY = new InjectionToken<ServerCartGateway>('SERVER_CART_GATEWAY', {
providedIn: 'root',
factory: () => inject(ServerCartLocalGateway),
});

View File

@@ -0,0 +1,66 @@
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { CheckoutSession, ServerCart, ServerCartLine } from '../models/server-cart.model';
import { ServerCartGateway } from './server-cart-gateway.interface';
const CART_TTL_MS = 30 * 24 * 60 * 60 * 1000;
/**
* In-memory stand-in for the server cart. The LIVE cart today is
* localStorage/Telegram-CloudStorage backed (pages/cart/cart.component.ts,
* services/cart.service.ts) and deliberately untouched by this module - see
* docs/backend/PHASE-6-CART-CHECKOUT-CONTRACT.md for why swapping that live
* payment-adjacent flow needs its own dedicated, verified pass rather than
* a bundled mock-data rewire.
*/
@Injectable({ providedIn: 'root' })
export class ServerCartLocalGateway implements ServerCartGateway {
private cart: ServerCart = {
id: 'cart_local',
marketplaceId: 'default',
sessionToken: 'local-session',
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + CART_TTL_MS).toISOString(),
};
private lines: ServerCartLine[] = [];
getCart(): Observable<{ cart: ServerCart; lines: ServerCartLine[] }> {
return of({ cart: this.cart, lines: this.lines });
}
addLine(offerId: string, qty: number): Observable<ServerCartLine> {
const existing = this.lines.find(l => l.offerId === offerId);
if (existing) {
existing.qty += qty;
return of(existing);
}
const line: ServerCartLine = { id: `line_${Date.now()}`, cartId: this.cart.id, offerId, qty, addedAt: new Date().toISOString() };
this.lines.push(line);
return of(line);
}
updateLine(lineId: string, qty: number): Observable<ServerCartLine> {
const line = this.lines.find(l => l.id === lineId);
if (line) {
line.qty = qty;
}
return of(line as ServerCartLine);
}
removeLine(lineId: string): Observable<void> {
this.lines = this.lines.filter(l => l.id !== lineId);
return of(void 0);
}
startCheckout(deliveryOptionId: string, _currency: string): Observable<CheckoutSession> {
return of({
id: `chk_${Date.now()}`,
cartId: this.cart.id,
customerContact: { verified: false },
deliveryOptionId,
status: 'open',
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
});
}
}

View File

@@ -0,0 +1,58 @@
/** Per docs/backend/PHASE-10-CONTENT-MODULES-CONTRACT.md §1. Gorbushka-class (mall_directory) tenants. */
export interface Shop {
id: string;
marketplaceId: string;
shopCategoryId: string;
name: string;
floorId?: string;
status: 'draft' | 'published';
}
export interface ShopCategory {
id: string;
marketplaceId: string;
title: string;
}
export interface MallService {
id: string;
marketplaceId: string;
title: string;
description: string;
status: 'draft' | 'published';
}
export interface Floor {
id: string;
marketplaceId: string;
order: number;
label: string;
}
export interface SchemePin {
id: string;
marketplaceId: string;
floorId: string;
shopId?: string;
x: number;
y: number;
}
export interface RentListing {
id: string;
marketplaceId: string;
title: string;
areaSqm: number;
floorId?: string;
status: 'available' | 'leased';
}
export interface Lead {
id: string;
marketplaceId: string;
rentListingId?: string;
contactName: string;
contactPhone: string;
message?: string;
createdAt: string;
}

View File

@@ -0,0 +1,12 @@
import { Observable } from 'rxjs';
import { Floor, Lead, RentListing, SchemePin, Shop, ShopCategory } from '../models/mall-content.model';
/** Per docs/backend/PHASE-10-CONTENT-MODULES-CONTRACT.md §2. */
export interface MallContentGateway {
loadShops(): Observable<Shop[]>;
loadShopCategories(): Observable<ShopCategory[]>;
loadFloors(): Observable<Floor[]>;
loadSchemePins(floorId: string): Observable<SchemePin[]>;
loadRentListings(): Observable<RentListing[]>;
submitLead(lead: Omit<Lead, 'id' | 'createdAt'>): Observable<Lead>;
}

View File

@@ -0,0 +1,9 @@
import { InjectionToken, inject } from '@angular/core';
import { MallContentGateway } from './mall-content-gateway.interface';
import { MallContentLocalGateway } from './mall-content-local.gateway';
/** Swap point for docs/backend/PHASE-10-CONTENT-MODULES-CONTRACT.md. */
export const MALL_CONTENT_GATEWAY = new InjectionToken<MallContentGateway>('MALL_CONTENT_GATEWAY', {
providedIn: 'root',
factory: () => inject(MallContentLocalGateway),
});

View File

@@ -0,0 +1,23 @@
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { Floor, Lead, RentListing, SchemePin, Shop, ShopCategory } from '../models/mall-content.model';
import { MallContentGateway } from './mall-content-gateway.interface';
/**
* Lowest-priority phase per the delivery plan - only after Commerce Core is
* real. Seeded empty; the current Gorbushka frontend/archive remains UX
* reference only per ADR-0001, production data routes through the shared
* platform once docs/backend/PHASE-10-CONTENT-MODULES-CONTRACT.md ships.
*/
@Injectable({ providedIn: 'root' })
export class MallContentLocalGateway implements MallContentGateway {
loadShops(): Observable<Shop[]> { return of([]); }
loadShopCategories(): Observable<ShopCategory[]> { return of([]); }
loadFloors(): Observable<Floor[]> { return of([]); }
loadSchemePins(_floorId: string): Observable<SchemePin[]> { return of([]); }
loadRentListings(): Observable<RentListing[]> { return of([]); }
submitLead(lead: Omit<Lead, 'id' | 'createdAt'>): Observable<Lead> {
return of({ ...lead, id: `lead_${Date.now()}`, createdAt: new Date().toISOString() });
}
}

View File

@@ -0,0 +1,38 @@
import { Money } from '../../pricing/models/money.model';
/** Per docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md §1-3. */
export interface Refund {
id: string;
orderId: string;
orderLineIds: string[];
amount: Money;
reason: string;
actor: string;
status: 'requested' | 'approved' | 'processing' | 'completed' | 'failed';
requestedAt: string;
completedAt?: string;
}
export interface ReconciliationRecord {
id: string;
orderId: string;
providerPaymentId?: string;
internalAmount: Money;
providerAmount?: Money;
matchStrategy: 'provider_payment_id' | 'merchant_reference' | 'amount_currency_fallback';
result: 'matched' | 'unmatched' | 'duplicate' | 'amount_mismatch' | 'status_mismatch';
resolvedBy?: string;
resolvedAt?: string;
}
export interface Settlement {
id: string;
sellerId: string;
periodStart: string;
periodEnd: string;
grossAmount: Money;
commission: Money;
refunds: Money;
netPayout: Money;
status: 'pending' | 'paid';
}

View File

@@ -0,0 +1,10 @@
import { Observable } from 'rxjs';
import { ReconciliationRecord, Refund, Settlement } from '../models/reconciliation.model';
/** Per docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md §1-3. */
export interface FinanceGateway {
loadRefunds(orderId?: string): Observable<Refund[]>;
loadReconciliationQueue(): Observable<ReconciliationRecord[]>;
resolveReconciliation(id: string, note: string): Observable<void>;
loadSettlements(sellerId?: string): Observable<Settlement[]>;
}

View File

@@ -0,0 +1,9 @@
import { InjectionToken, inject } from '@angular/core';
import { FinanceGateway } from './finance-gateway.interface';
import { FinanceLocalGateway } from './finance-local.gateway';
/** Swap point for docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md. */
export const FINANCE_GATEWAY = new InjectionToken<FinanceGateway>('FINANCE_GATEWAY', {
providedIn: 'root',
factory: () => inject(FinanceLocalGateway),
});

View File

@@ -0,0 +1,32 @@
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { ReconciliationRecord, Refund, Settlement } from '../models/reconciliation.model';
import { FinanceGateway } from './finance-gateway.interface';
/**
* Seeded empty. AdminOrdersLocalGateway's requestRefund(id) exists as a mock
* method but nothing reads it into a real Refund/reconciliation flow yet -
* this gateway is the real target once docs/backend/
* PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md ships.
*/
@Injectable({ providedIn: 'root' })
export class FinanceLocalGateway implements FinanceGateway {
private reconciliation: ReconciliationRecord[] = [];
loadRefunds(_orderId?: string): Observable<Refund[]> {
return of([]);
}
loadReconciliationQueue(): Observable<ReconciliationRecord[]> {
return of(this.reconciliation);
}
resolveReconciliation(id: string, _note: string): Observable<void> {
this.reconciliation = this.reconciliation.filter(r => r.id !== id);
return of(void 0);
}
loadSettlements(_sellerId?: string): Observable<Settlement[]> {
return of([]);
}
}

View File

@@ -0,0 +1,28 @@
/** Per docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md §1. */
export interface Customer {
id: string;
marketplaceId: string;
name?: string;
email?: string;
phone?: string;
status: 'active' | 'suspended';
createdAt: string;
}
export type ExternalIdentityProvider = 'vk_id' | 'telegram' | 'max';
export interface ExternalIdentity {
customerId: string;
provider: ExternalIdentityProvider;
providerUserId: string;
verifiedAt: string;
lastUsedAt: string;
}
export interface ContactChannel {
customerId: string;
provider: 'telegram' | 'vk' | 'max';
chatId: string;
verified: boolean;
notificationsEnabled: boolean;
}

View File

@@ -0,0 +1,8 @@
import { Observable } from 'rxjs';
import { Customer } from '../models/customer-identity.model';
/** Per docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md §2. OAuth completion is backend-side; this is the client-facing surface only. */
export interface VkIdGateway {
getAuthorizeUrl(): Observable<string>;
completeCallback(code: string, codeVerifier: string): Observable<Customer>;
}

View File

@@ -0,0 +1,9 @@
import { InjectionToken, inject } from '@angular/core';
import { VkIdGateway } from './vk-id-gateway.interface';
import { VkIdLocalGateway } from './vk-id-local.gateway';
/** Swap point for docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md §2. */
export const VK_ID_GATEWAY = new InjectionToken<VkIdGateway>('VK_ID_GATEWAY', {
providedIn: 'root',
factory: () => inject(VkIdLocalGateway),
});

View File

@@ -0,0 +1,28 @@
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { Customer } from '../models/customer-identity.model';
import { VkIdGateway } from './vk-id-gateway.interface';
/**
* No real VK OAuth app is configured yet - this mock exists so the
* VkIdLoginButtonComponent has something to call and the flow shape is
* provable end-to-end before a real client id/secret exist. Swap
* VK_ID_GATEWAY once docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md §2
* ships; the real backend completes OAuth server-side, this interface never
* exposes a client secret regardless of implementation.
*/
@Injectable({ providedIn: 'root' })
export class VkIdLocalGateway implements VkIdGateway {
getAuthorizeUrl(): Observable<string> {
return of('about:blank#vk-id-not-configured');
}
completeCallback(_code: string, _codeVerifier: string): Observable<Customer> {
return of({
id: 'customer_vk_mock',
marketplaceId: 'default',
status: 'active',
createdAt: new Date().toISOString(),
});
}
}

View File

@@ -0,0 +1,21 @@
/** Per docs/backend/PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md §2. */
export interface Connector {
id: string;
marketplaceId: string;
provider: string;
authType: 'webhook_signed' | 'api_key' | 'oauth2';
status: 'active' | 'paused' | 'error';
lastSuccessAt?: string;
lagSeconds?: number;
errorCount: number;
backlogCount: number;
unmatchedCount: number;
}
export interface DeadLetterEntry {
id: string;
connectorId: string;
reason: string;
retryCount: number;
lastAttemptAt: string;
}

View File

@@ -0,0 +1,10 @@
import { Observable } from 'rxjs';
import { Connector, DeadLetterEntry } from '../models/connector.model';
export interface ConnectorGateway {
loadConnectors(): Observable<Connector[]>;
loadDeadLetter(connectorId: string): Observable<DeadLetterEntry[]>;
replay(deadLetterId: string): Observable<void>;
pause(connectorId: string): Observable<void>;
resume(connectorId: string): Observable<void>;
}

View File

@@ -0,0 +1,9 @@
import { InjectionToken, inject } from '@angular/core';
import { ConnectorGateway } from './connector-gateway.interface';
import { ConnectorLocalGateway } from './connector-local.gateway';
/** Swap point for docs/backend/PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md §7. */
export const CONNECTOR_GATEWAY = new InjectionToken<ConnectorGateway>('CONNECTOR_GATEWAY', {
providedIn: 'root',
factory: () => inject(ConnectorLocalGateway),
});

View File

@@ -0,0 +1,37 @@
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { Connector, DeadLetterEntry } from '../models/connector.model';
import { ConnectorGateway } from './connector-gateway.interface';
/**
* No connector exists in real life yet (Sprint 0.1: no fixed partner list -
* connectors onboard as partners arrive). Seeded with zero rows, ready to
* light up as soon as an admin onboards the first real connector via
* docs/backend/PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md §7.
*/
@Injectable({ providedIn: 'root' })
export class ConnectorLocalGateway implements ConnectorGateway {
private connectors: Connector[] = [];
loadConnectors(): Observable<Connector[]> {
return of(this.connectors);
}
loadDeadLetter(_connectorId: string): Observable<DeadLetterEntry[]> {
return of([]);
}
replay(_deadLetterId: string): Observable<void> {
return of(void 0);
}
pause(connectorId: string): Observable<void> {
this.connectors = this.connectors.map(c => c.id === connectorId ? { ...c, status: 'paused' } : c);
return of(void 0);
}
resume(connectorId: string): Observable<void> {
this.connectors = this.connectors.map(c => c.id === connectorId ? { ...c, status: 'active' } : c);
return of(void 0);
}
}

View File

@@ -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[];
}

View File

@@ -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>;
}

View File

@@ -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),
});

View File

@@ -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: [] });
}
}

View File

@@ -0,0 +1,30 @@
import { Money } from '../../pricing/models/money.model';
/** Per docs/backend/PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md §2-3. */
export interface Offer {
id: string;
marketplaceId: string;
sellerId: string;
variantId: string;
sellerSku: string;
price: Money;
stockPolicy: 'track' | 'no_track' | 'preorder';
status: 'draft' | 'moderation' | 'published' | 'paused' | 'archived';
publishedAt?: string;
executabilityChecked: boolean;
}
export interface InventoryRecord {
offerId: string;
available: number;
reserved: number;
sold: number;
warehouse?: string;
source: 'manual' | 'feed_sync' | 'connector';
}
export interface OfferLookupQuery {
sku?: string;
sellerSku?: string;
externalId?: string;
}

View File

@@ -0,0 +1,10 @@
import { Observable } from 'rxjs';
import { InventoryRecord, Offer, OfferLookupQuery } from '../models/offer.model';
/** Per docs/backend/PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md §7. */
export interface OfferGateway {
loadOffers(sellerId?: string): Observable<Offer[]>;
lookup(query: OfferLookupQuery): Observable<Offer[]>;
loadInventory(offerId: string): Observable<InventoryRecord | null>;
publish(offerId: string): Observable<{ ok: true } | { ok: false; errors: string[] }>;
}

View File

@@ -0,0 +1,9 @@
import { InjectionToken, inject } from '@angular/core';
import { OfferGateway } from './offer-gateway.interface';
import { OfferLocalGateway } from './offer-local.gateway';
/** Swap point for docs/backend/PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md §7. */
export const OFFER_GATEWAY = new InjectionToken<OfferGateway>('OFFER_GATEWAY', {
providedIn: 'root',
factory: () => inject(OfferLocalGateway),
});

View File

@@ -0,0 +1,58 @@
import { Injectable, inject } from '@angular/core';
import { Observable, map, of } from 'rxjs';
import { InventoryRecord, Offer, OfferLookupQuery } from '../models/offer.model';
import { OfferGateway } from './offer-gateway.interface';
import { ADMIN_PRODUCTS_GATEWAY } from '../../../features/admin/products/services/admin-products-gateway.token';
import { fromMajor } from '../../pricing/models/money.model';
/**
* Derives Offers from the existing (mock) Products gateway - one offer per
* product, sellerId defaulted to 'marketplace-owned' when absent, exactly
* matching AdminProduct.sellerId's existing "absent means marketplace-owned"
* convention. Real Offer/Product split (docs/backend/
* PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md) replaces this once a real
* backend exists - swap OFFER_GATEWAY, no caller changes needed.
*/
@Injectable({ providedIn: 'root' })
export class OfferLocalGateway implements OfferGateway {
private readonly productsGateway = inject(ADMIN_PRODUCTS_GATEWAY);
loadOffers(sellerId?: string): Observable<Offer[]> {
return this.productsGateway.loadProducts({ search: '', page: 1, pageSize: 100 } as any).pipe(
map((result: any) => (result.items ?? [])
.map((p: any): Offer => this.toOffer(p))
.filter((o: Offer) => !sellerId || o.sellerId === sellerId))
);
}
lookup(query: OfferLookupQuery): Observable<Offer[]> {
return this.loadOffers().pipe(
map(offers => offers.filter(o =>
(!query.sku || o.sellerSku === query.sku) &&
(!query.sellerSku || o.sellerSku === query.sellerSku)
))
);
}
loadInventory(offerId: string): Observable<InventoryRecord | null> {
return of({ offerId, available: 42, reserved: 0, sold: 0, source: 'manual' });
}
publish(_offerId: string): Observable<{ ok: true } | { ok: false; errors: string[] }> {
return of({ ok: true });
}
private toOffer(product: any): Offer {
return {
id: `offer_${product.id}`,
marketplaceId: 'default',
sellerId: product.sellerId ?? 'marketplace-owned',
variantId: product.id,
sellerSku: product.sku,
price: fromMajor(product.price ?? 0, product.currency ?? 'RUB'),
stockPolicy: 'track',
status: product.visible ? 'published' : 'draft',
executabilityChecked: !!product.visible,
};
}
}

View File

@@ -0,0 +1,20 @@
import { inject } from '@angular/core';
import { CanActivateFn } from '@angular/router';
import { map } from 'rxjs/operators';
import { PERMISSION_GATEWAY } from '../services/permission-gateway.token';
/**
* Route guard shape for docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md §2.
* Against PermissionLocalGateway (grants everything - see that file's doc
* comment) this is currently a no-op, by design: it must not create a false
* sense of enforcement before a real backend exists. Attach via route data
* (`data: { requiredScope: '...' }`) once real enforcement is needed;
* wiring this onto the 14 existing live admin routes is deliberately not
* done in this pass - that needs its own verified rollout, not a blanket
* retrofit that could lock an admin out without warning.
*/
export function requiresScope(scope: string): CanActivateFn {
return () => inject(PERMISSION_GATEWAY).getSessionPermissions().pipe(
map(permissions => permissions.scopes.includes('*') || permissions.scopes.includes(scope))
);
}

View File

@@ -0,0 +1,19 @@
/** Per docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md §1-2. */
export type PlatformRole = 'PLATFORM_OWNER' | 'TECH_ADMIN' | 'SECURITY_ADMIN' | 'DOMAIN_MANAGER' | 'VIEWER';
export type MarketplaceRole = 'MARKETPLACE_ADMIN' | 'CONTENT_MANAGER' | 'CATALOG_MANAGER' | 'ORDER_MANAGER' | 'FINANCE_MANAGER' | 'SUPPORT_MANAGER' | 'VIEWER';
export interface SessionPermissions {
role: PlatformRole | MarketplaceRole;
scopes: string[];
marketplaceIds: string[];
}
export interface AuditEvent {
id: string;
actor: string;
action: string;
entityType: string;
entityId: string;
reason?: string;
occurredAt: string;
}

View File

@@ -0,0 +1,8 @@
import { Observable } from 'rxjs';
import { AuditEvent, SessionPermissions } from '../models/permission.model';
/** Per docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md §2-3. */
export interface PermissionGateway {
getSessionPermissions(): Observable<SessionPermissions>;
loadAuditLog(): Observable<AuditEvent[]>;
}

View File

@@ -0,0 +1,9 @@
import { InjectionToken, inject } from '@angular/core';
import { PermissionGateway } from './permission-gateway.interface';
import { PermissionLocalGateway } from './permission-local.gateway';
/** Swap point for docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md §2. */
export const PERMISSION_GATEWAY = new InjectionToken<PermissionGateway>('PERMISSION_GATEWAY', {
providedIn: 'root',
factory: () => inject(PermissionLocalGateway),
});

View File

@@ -0,0 +1,25 @@
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { AuditEvent, SessionPermissions } from '../models/permission.model';
import { PermissionGateway } from './permission-gateway.interface';
/**
* Grants full PLATFORM_OWNER access unconditionally. This matches today's
* REAL behaviour (GAPS-AND-IMPROVEMENTS.md: "the admin role model is
* decorative - anyone who passes admin authentication has full access
* regardless of assigned role") rather than pretending enforcement exists
* when it doesn't. Swapping PERMISSION_GATEWAY for a real backend per
* docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md is what actually turns
* enforcement on - PermissionGuard below is inert against this mock by
* design, not a false sense of security.
*/
@Injectable({ providedIn: 'root' })
export class PermissionLocalGateway implements PermissionGateway {
getSessionPermissions(): Observable<SessionPermissions> {
return of({ role: 'PLATFORM_OWNER', scopes: ['*'], marketplaceIds: ['*'] });
}
loadAuditLog(): Observable<AuditEvent[]> {
return of([]);
}
}

View File

@@ -0,0 +1,14 @@
/** Per docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §3. */
export interface FxQuote {
quoteId: string;
base: string;
quote: string;
rate: number;
source: string;
observedAt: string;
expiresAt: string;
}
export function isFxQuoteExpired(fxQuote: FxQuote, now: Date = new Date()): boolean {
return now.getTime() >= new Date(fxQuote.expiresAt).getTime();
}

View File

@@ -0,0 +1,47 @@
/** Minor-unit money, per docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §2. No float for money math. */
export interface Money {
amountMinor: number;
currency: string;
}
const MINOR_UNIT_DECIMALS: Record<string, number> = {
RUB: 2,
USD: 2,
EUR: 2,
AMD: 2,
};
export function decimalsFor(currency: string): number {
return MINOR_UNIT_DECIMALS[currency] ?? 2;
}
export function toMajor(money: Money): number {
return money.amountMinor / Math.pow(10, decimalsFor(money.currency));
}
export function fromMajor(amount: number, currency: string): Money {
const decimals = decimalsFor(currency);
return { amountMinor: Math.round(amount * Math.pow(10, decimals)), currency };
}
export function addMoney(a: Money, b: Money): Money {
if (a.currency !== b.currency) {
throw new Error(`Cannot add Money of different currencies: ${a.currency} vs ${b.currency}`);
}
return { amountMinor: a.amountMinor + b.amountMinor, currency: a.currency };
}
export function subtractMoney(a: Money, b: Money): Money {
if (a.currency !== b.currency) {
throw new Error(`Cannot subtract Money of different currencies: ${a.currency} vs ${b.currency}`);
}
return { amountMinor: a.amountMinor - b.amountMinor, currency: a.currency };
}
export function multiplyMoney(money: Money, factor: number): Money {
return { amountMinor: Math.round(money.amountMinor * factor), currency: money.currency };
}
export function zeroMoney(currency: string): Money {
return { amountMinor: 0, currency };
}

View File

@@ -0,0 +1,30 @@
import { Money } from './money.model';
/** Per docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §4. Immutable once created. */
export interface PriceSnapshot {
id: string;
offerId: string;
amount: Money;
displayAmount: Money;
fxQuoteId: string | null;
capturedAt: string;
}
export interface CheckoutLine {
offerId: string;
qty: number;
unitPrice: Money;
lineTotal: Money;
priceSnapshotId: string;
}
export interface CheckoutResult {
checkoutSessionId: string;
lines: CheckoutLine[];
subtotal: Money;
discount: Money;
delivery: Money;
total: Money;
fxQuoteId: string | null;
expiresAt: string;
}

View File

@@ -0,0 +1,7 @@
import { Observable } from 'rxjs';
import { FxQuote } from '../models/fx-quote.model';
/** Per docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §3.1. */
export interface FxQuoteGateway {
getQuote(base: string, quote: string): Observable<FxQuote>;
}

View File

@@ -0,0 +1,9 @@
import { InjectionToken, inject } from '@angular/core';
import { FxQuoteGateway } from './fx-quote-gateway.interface';
import { FxQuoteLocalGateway } from './fx-quote-local.gateway';
/** Swap point for the real FX backend from docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §3.1. */
export const FX_QUOTE_GATEWAY = new InjectionToken<FxQuoteGateway>('FX_QUOTE_GATEWAY', {
providedIn: 'root',
factory: () => inject(FxQuoteLocalGateway),
});

View File

@@ -0,0 +1,32 @@
import { Injectable, inject } from '@angular/core';
import { Observable, of } from 'rxjs';
import { FxQuote } from '../models/fx-quote.model';
import { FxQuoteGateway } from './fx-quote-gateway.interface';
import { CurrencyRatesService } from '../../../services/currency-rates.service';
const QUOTE_TTL_MS = 5 * 60 * 1000;
/**
* Mock FX source until a real backend rate service exists (Sprint 0.1:
* FX is computed in-house, "internal" is the normal source value, not just
* a fallback). Derives a quote from CurrencyRatesService's existing
* admin-editable rates so the shape is real even though the source isn't.
*/
@Injectable({ providedIn: 'root' })
export class FxQuoteLocalGateway implements FxQuoteGateway {
private readonly currencyRates = inject(CurrencyRatesService);
getQuote(base: string, quote: string): Observable<FxQuote> {
const rate = this.currencyRates.convert(1, base, quote);
const now = new Date();
return of({
quoteId: `fxq_local_${base}_${quote}_${now.getTime()}`,
base,
quote,
rate,
source: 'local-mock',
observedAt: now.toISOString(),
expiresAt: new Date(now.getTime() + QUOTE_TTL_MS).toISOString(),
});
}
}

View File

@@ -0,0 +1,12 @@
import { UUID } from '../../../shared/types/primitive.types';
/** Per docs/backend/PHASE-5-SELLER-PORTAL-CONTRACT.md §2, §4. */
export type SellerRole = 'SELLER_OWNER' | 'SELLER_CATALOG_MANAGER' | 'SELLER_ORDER_MANAGER' | 'SELLER_FINANCE_VIEWER' | 'SELLER_VIEWER';
export interface SellerUser {
id: UUID;
sellerId: UUID;
role: SellerRole;
email: string;
status: 'active' | 'invited' | 'suspended';
}

View File

@@ -0,0 +1,9 @@
import { Observable } from 'rxjs';
import { Seller } from '../models/seller.model';
import { SellerUser } from '../models/seller-user.model';
/** Per docs/backend/PHASE-5-SELLER-PORTAL-CONTRACT.md §3. Operates on the existing Seller domain type. */
export interface SellerGateway {
loadSellers(): Observable<Seller[]>;
loadTeam(sellerId: string): Observable<SellerUser[]>;
}

View File

@@ -0,0 +1,9 @@
import { InjectionToken, inject } from '@angular/core';
import { SellerGateway } from './seller-gateway.interface';
import { SellerLocalGateway } from './seller-local.gateway';
/** Swap point for docs/backend/PHASE-5-SELLER-PORTAL-CONTRACT.md §3. */
export const SELLER_GATEWAY = new InjectionToken<SellerGateway>('SELLER_GATEWAY', {
providedIn: 'root',
factory: () => inject(SellerLocalGateway),
});

View File

@@ -0,0 +1,22 @@
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { Seller } from '../models/seller.model';
import { SellerUser } from '../models/seller-user.model';
import { SellerGateway } from './seller-gateway.interface';
/**
* No seller has ever onboarded (Seller Management is flag-off by default,
* zero backend today per GAPS-AND-IMPROVEMENTS.md). Seeded empty - ready to
* populate the moment docs/backend/PHASE-5-SELLER-PORTAL-CONTRACT.md ships
* and the flag flips on for the first tenant.
*/
@Injectable({ providedIn: 'root' })
export class SellerLocalGateway implements SellerGateway {
loadSellers(): Observable<Seller[]> {
return of([]);
}
loadTeam(_sellerId: string): Observable<SellerUser[]> {
return of([]);
}
}

View File

@@ -0,0 +1,20 @@
import { Injectable, inject, signal } from '@angular/core';
import { take } from 'rxjs/operators';
import { AuditEvent } from '../../../../core/permissions/models/permission.model';
import { PERMISSION_GATEWAY } from '../../../../core/permissions/services/permission-gateway.token';
@Injectable({ providedIn: 'root' })
export class AdminAuditFacade {
private readonly gateway = inject(PERMISSION_GATEWAY);
readonly events = signal<AuditEvent[]>([]);
readonly loading = signal(false);
load(): void {
this.loading.set(true);
this.gateway.loadAuditLog().pipe(take(1)).subscribe(items => {
this.events.set(items);
this.loading.set(false);
});
}
}

View File

@@ -0,0 +1,16 @@
<div class="audit-page">
<header>
<h1>Audit &amp; Security</h1>
<p>Role changes, sensitive actions, login/security events, exports. Real role model does not exist yet - see docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md. Today every authenticated admin has full access regardless of role.</p>
</header>
@if (facade.events().length === 0 && !facade.loading()) {
<app-empty-state title="No audit events yet" description="Nothing to show until a real backend logs actions here." />
} @else {
<ul>
@for (e of facade.events(); track e.id) {
<li>{{ e.occurredAt }} - {{ e.actor }} - {{ e.action }} ({{ e.entityType }}:{{ e.entityId }})</li>
}
</ul>
}
</div>

View File

@@ -0,0 +1,14 @@
.audit-page {
display: flex;
flex-direction: column;
gap: 16px;
ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
}

View File

@@ -0,0 +1,20 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AdminAuditFacade } from '../facade/admin-audit.facade';
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
@Component({
selector: 'app-admin-audit-page',
standalone: true,
imports: [CommonModule, EmptyStateComponent],
templateUrl: './admin-audit-page.component.html',
styleUrls: ['./admin-audit-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AdminAuditPageComponent {
readonly facade = inject(AdminAuditFacade);
constructor() {
this.facade.load();
}
}

View File

@@ -0,0 +1,26 @@
import { Injectable, inject, signal } from '@angular/core';
import { take } from 'rxjs/operators';
import { ReconciliationRecord, Settlement } from '../../../../core/finance/models/reconciliation.model';
import { FINANCE_GATEWAY } from '../../../../core/finance/services/finance-gateway.token';
@Injectable({ providedIn: 'root' })
export class AdminFinanceFacade {
private readonly gateway = inject(FINANCE_GATEWAY);
readonly reconciliation = signal<ReconciliationRecord[]>([]);
readonly settlements = signal<Settlement[]>([]);
readonly loading = signal(false);
load(): void {
this.loading.set(true);
this.gateway.loadReconciliationQueue().pipe(take(1)).subscribe(items => {
this.reconciliation.set(items);
this.loading.set(false);
});
this.gateway.loadSettlements().pipe(take(1)).subscribe(items => this.settlements.set(items));
}
resolve(id: string, note: string): void {
this.gateway.resolveReconciliation(id, note).pipe(take(1)).subscribe(() => this.load());
}
}

View File

@@ -0,0 +1,29 @@
<div class="finance-page">
<header>
<h1>Payments &amp; Finance</h1>
<p>Reconciliation queue and settlements. Mock-gateway backed until docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md ships.</p>
</header>
<section>
<h2>Reconciliation queue</h2>
@if (facade.reconciliation().length === 0 && !facade.loading()) {
<app-empty-state title="Nothing to reconcile" description="Unmatched payments will appear here." />
} @else {
<ul>
@for (r of facade.reconciliation(); track r.id) {
<li>
{{ r.orderId }} - {{ r.result }}
<app-button variant="secondary" (click)="facade.resolve(r.id, 'resolved from backoffice')">Resolve</app-button>
</li>
}
</ul>
}
</section>
<section>
<h2>Settlements</h2>
@if (facade.settlements().length === 0) {
<app-empty-state title="No settlements yet" description="Seller payout periods will appear here." />
}
</section>
</div>

View File

@@ -0,0 +1,29 @@
.finance-page {
display: flex;
flex-direction: column;
gap: 24px;
section {
display: flex;
flex-direction: column;
gap: 12px;
}
ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
li {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
}
}
}

View File

@@ -0,0 +1,21 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AdminFinanceFacade } from '../facade/admin-finance.facade';
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
@Component({
selector: 'app-admin-finance-page',
standalone: true,
imports: [CommonModule, EmptyStateComponent, ButtonComponent],
templateUrl: './admin-finance-page.component.html',
styleUrls: ['./admin-finance-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AdminFinancePageComponent {
readonly facade = inject(AdminFinanceFacade);
constructor() {
this.facade.load();
}
}

View File

@@ -0,0 +1,28 @@
import { Injectable, inject, signal } from '@angular/core';
import { take } from 'rxjs/operators';
import { Connector } from '../../../../core/integrations/models/connector.model';
import { CONNECTOR_GATEWAY } from '../../../../core/integrations/services/connector-gateway.token';
@Injectable({ providedIn: 'root' })
export class AdminIntegrationsFacade {
private readonly gateway = inject(CONNECTOR_GATEWAY);
readonly connectors = signal<Connector[]>([]);
readonly loading = signal(false);
load(): void {
this.loading.set(true);
this.gateway.loadConnectors().pipe(take(1)).subscribe(items => {
this.connectors.set(items);
this.loading.set(false);
});
}
pause(id: string): void {
this.gateway.pause(id).pipe(take(1)).subscribe(() => this.load());
}
resume(id: string): void {
this.gateway.resume(id).pipe(take(1)).subscribe(() => this.load());
}
}

View File

@@ -0,0 +1,38 @@
<div class="integrations-page">
<header>
<h1>Integrations</h1>
<p>External marketplace connectors, payment providers, FX sources, messaging. No fixed partner list - connectors onboard as partners arrive (docs/backend/PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md).</p>
</header>
@if (facade.connectors().length === 0 && !facade.loading()) {
<app-empty-state title="No connectors yet" description="Onboard the first partner connector to see it here." />
} @else {
<table class="integrations-page__table">
<thead>
<tr>
<th>Provider</th><th>Status</th><th>Last success</th><th>Lag</th><th>Errors</th><th>Backlog</th><th>Unmatched</th><th></th>
</tr>
</thead>
<tbody>
@for (c of facade.connectors(); track c.id) {
<tr>
<td>{{ c.provider }}</td>
<td><app-badge [variant]="c.status === 'active' ? 'success' : c.status === 'error' ? 'danger' : 'neutral'">{{ c.status }}</app-badge></td>
<td>{{ c.lastSuccessAt || '-' }}</td>
<td>{{ c.lagSeconds ?? '-' }}s</td>
<td>{{ c.errorCount }}</td>
<td>{{ c.backlogCount }}</td>
<td>{{ c.unmatchedCount }}</td>
<td>
@if (c.status === 'active') {
<app-button variant="secondary" (click)="facade.pause(c.id)">Pause</app-button>
} @else {
<app-button variant="secondary" (click)="facade.resume(c.id)">Resume</app-button>
}
</td>
</tr>
}
</tbody>
</table>
}
</div>

View File

@@ -0,0 +1,16 @@
.integrations-page {
display: flex;
flex-direction: column;
gap: 16px;
&__table {
width: 100%;
border-collapse: collapse;
th, td {
text-align: left;
padding: 8px 12px;
border-bottom: 1px solid var(--border-color);
}
}
}

View File

@@ -0,0 +1,22 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AdminIntegrationsFacade } from '../facade/admin-integrations.facade';
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
@Component({
selector: 'app-admin-integrations-page',
standalone: true,
imports: [CommonModule, BadgeComponent, ButtonComponent, EmptyStateComponent],
templateUrl: './admin-integrations-page.component.html',
styleUrls: ['./admin-integrations-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AdminIntegrationsPageComponent {
readonly facade = inject(AdminIntegrationsFacade);
constructor() {
this.facade.load();
}
}

View File

@@ -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));
}
});
}
}

View File

@@ -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 &amp; 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>

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,36 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { take } from 'rxjs/operators';
import { AdminNotification, AdminNotificationFilters } from '../models/admin-notification.model';
import { ADMIN_NOTIFICATIONS_GATEWAY } from '../services/admin-notifications-gateway.token';
@Injectable({ providedIn: 'root' })
export class AdminNotificationsFacade {
private readonly gateway = inject(ADMIN_NOTIFICATIONS_GATEWAY);
readonly filters = signal<AdminNotificationFilters>({ marketplaceId: '', eventType: 'all', unreadOnly: false });
readonly notifications = signal<AdminNotification[]>([]);
readonly loading = signal(false);
readonly unreadCount = computed(() => this.notifications().filter(n => !n.read).length);
load(): void {
this.loading.set(true);
this.gateway.loadNotifications(this.filters()).pipe(take(1)).subscribe(items => {
this.notifications.set(items);
this.loading.set(false);
});
}
updateFilters(patch: Partial<AdminNotificationFilters>): void {
this.filters.update(current => ({ ...current, ...patch }));
this.load();
}
markRead(id: string): void {
this.gateway.markRead(id).pipe(take(1)).subscribe(() => this.load());
}
markAllRead(): void {
this.gateway.markAllRead().pipe(take(1)).subscribe(() => this.load());
}
}

View File

@@ -0,0 +1,22 @@
/** Per docs/backend/PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md §6. */
export type AdminNotificationSeverity = 'info' | 'warning' | 'critical';
export type AdminNotificationEntityType = 'order' | 'payment' | 'offer' | 'connector' | 'refund';
export interface AdminNotification {
id: string;
marketplaceId: string;
entityType: AdminNotificationEntityType;
entityId: string;
severity: AdminNotificationSeverity;
eventType: string;
title: string;
read: boolean;
deepLink: string[];
createdAt: string;
}
export interface AdminNotificationFilters {
marketplaceId: string;
eventType: string;
unreadOnly: boolean;
}

View File

@@ -0,0 +1,38 @@
<div class="notifications-page">
<header class="notifications-page__header">
<div>
<h1>Notifications</h1>
<p>Mock-gateway backed until docs/backend/PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md ships.</p>
</div>
<app-button variant="secondary" (click)="facade.markAllRead()">Mark all read</app-button>
</header>
<div class="notifications-page__filters">
<label>
<input type="checkbox" [ngModel]="facade.filters().unreadOnly" (ngModelChange)="facade.updateFilters({ unreadOnly: $event })" />
Unread only
</label>
<select [ngModel]="facade.filters().eventType" (ngModelChange)="facade.updateFilters({ eventType: $event })">
@for (type of eventTypes; track type) {
<option [value]="type">{{ type }}</option>
}
</select>
</div>
@if (facade.notifications().length === 0 && !facade.loading()) {
<app-empty-state title="No notifications" description="Nothing matches the current filter." />
} @else {
<ul class="notifications-page__list">
@for (n of facade.notifications(); track n.id) {
<li class="notifications-page__item" [class.notifications-page__item--unread]="!n.read">
<app-badge [variant]="n.severity === 'critical' ? 'danger' : n.severity === 'warning' ? 'warning' : 'info'">{{ n.severity }}</app-badge>
<a [routerLink]="['/', currentLang(), 'backoffice', ...n.deepLink]">{{ n.title }}</a>
<span class="notifications-page__time">{{ n.createdAt | date: 'short' }}</span>
@if (!n.read) {
<button type="button" (click)="facade.markRead(n.id)">Mark read</button>
}
</li>
}
</ul>
}
</div>

View File

@@ -0,0 +1,46 @@
.notifications-page {
display: flex;
flex-direction: column;
gap: 16px;
&__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
&__filters {
display: flex;
gap: 16px;
align-items: center;
}
&__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
&__item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
&--unread {
background: var(--bg-secondary);
font-weight: var(--font-weight-bold, 700);
}
}
&__time {
margin-left: auto;
color: var(--text-secondary);
font-weight: normal;
}
}

View File

@@ -0,0 +1,29 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { AdminNotificationsFacade } from '../facade/admin-notifications.facade';
import { LanguageService } from '../../../../services/language.service';
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
@Component({
selector: 'app-admin-notifications-page',
standalone: true,
imports: [CommonModule, FormsModule, RouterLink, BadgeComponent, ButtonComponent, EmptyStateComponent],
templateUrl: './admin-notifications-page.component.html',
styleUrls: ['./admin-notifications-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AdminNotificationsPageComponent {
readonly facade = inject(AdminNotificationsFacade);
private readonly languageService = inject(LanguageService);
readonly currentLang = this.languageService.currentLanguage;
readonly eventTypes = ['all', 'order.created', 'order.paid'] as const;
constructor() {
this.facade.load();
}
}

View File

@@ -0,0 +1,8 @@
import { Observable } from 'rxjs';
import { AdminNotification, AdminNotificationFilters } from '../models/admin-notification.model';
export interface AdminNotificationsGateway {
loadNotifications(filters: AdminNotificationFilters): Observable<AdminNotification[]>;
markRead(id: string): Observable<void>;
markAllRead(): Observable<void>;
}

View File

@@ -0,0 +1,9 @@
import { InjectionToken, inject } from '@angular/core';
import { AdminNotificationsGateway } from './admin-notifications-gateway.interface';
import { AdminNotificationsLocalGateway } from './admin-notifications-local.gateway';
/** Swap point for docs/backend/PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md §6. */
export const ADMIN_NOTIFICATIONS_GATEWAY = new InjectionToken<AdminNotificationsGateway>('ADMIN_NOTIFICATIONS_GATEWAY', {
providedIn: 'root',
factory: () => inject(AdminNotificationsLocalGateway),
});

View File

@@ -0,0 +1,50 @@
import { Injectable, inject } from '@angular/core';
import { Observable, map, of } from 'rxjs';
import { AdminNotification, AdminNotificationFilters } from '../models/admin-notification.model';
import { AdminNotificationsGateway } from './admin-notifications-gateway.interface';
import { ADMIN_ORDERS_GATEWAY } from '../../orders/services/admin-orders-gateway.token';
/**
* Derives notifications from the existing (mock) Orders gateway so the shape
* is real even before a platform event bus exists. Once
* docs/backend/PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md §6 ships, swap the
* token - no caller changes needed.
*/
@Injectable({ providedIn: 'root' })
export class AdminNotificationsLocalGateway implements AdminNotificationsGateway {
private readonly ordersGateway = inject(ADMIN_ORDERS_GATEWAY);
private readonly readIds = new Set<string>();
loadNotifications(filters: AdminNotificationFilters): Observable<AdminNotification[]> {
return this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 20 }).pipe(
map(result => result.items
.map((order): AdminNotification => ({
id: `notif_${order.id}`,
marketplaceId: 'default',
entityType: 'order',
entityId: order.id,
severity: order.status === 'cancelled' ? 'warning' : 'info',
eventType: order.payment.status === 'paid' ? 'order.paid' : 'order.created',
title: `Order ${order.orderNumber} - ${order.payment.status === 'paid' ? 'paid' : 'created'}`,
read: this.readIds.has(order.id),
deepLink: ['orders', order.id],
createdAt: order.createdAt,
}))
.filter(n => !filters.unreadOnly || !n.read)
.filter(n => !filters.eventType || filters.eventType === 'all' || n.eventType === filters.eventType)
)
);
}
markRead(id: string): Observable<void> {
const orderId = id.replace(/^notif_/, '');
this.readIds.add(orderId);
return of(void 0);
}
markAllRead(): Observable<void> {
return this.loadNotifications({ marketplaceId: '', eventType: '', unreadOnly: false }).pipe(
map(items => { items.forEach(n => this.readIds.add(n.entityId)); })
);
}
}

View File

@@ -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'] },
@@ -38,6 +39,10 @@ export const ADMIN_NAV_PRIMARY: AdminNavEntry[] = [
{ type: 'link', id: 'customers', icon: 'user', labelKey: 'adminShell.nav.customers', path: ['customers'] }, { type: 'link', id: 'customers', icon: 'user', labelKey: 'adminShell.nav.customers', path: ['customers'] },
{ type: 'link', id: 'transactions', icon: 'creditCard', labelKey: 'adminShell.nav.transactions', path: ['transactions'] }, { type: 'link', id: 'transactions', icon: 'creditCard', labelKey: 'adminShell.nav.transactions', path: ['transactions'] },
{ type: 'link', id: 'moderation', icon: 'star', labelKey: 'adminShell.nav.moderation', path: ['moderation'] }, { type: 'link', id: 'moderation', icon: 'star', labelKey: 'adminShell.nav.moderation', path: ['moderation'] },
{ type: 'link', id: 'notifications', icon: 'bell', labelKey: 'adminShell.nav.notifications', path: ['notifications'] },
{ type: 'link', id: 'integrations', icon: 'network', labelKey: 'adminShell.nav.integrations', path: ['integrations'] },
{ type: 'link', id: 'finance', icon: 'creditCard', labelKey: 'adminShell.nav.finance', path: ['finance'] },
{ type: 'link', id: 'audit', icon: 'shield', labelKey: 'adminShell.nav.audit', path: ['audit'] },
{ type: 'link', id: 'reports', icon: 'chartBar', labelKey: 'adminShell.nav.reports', path: ['reports'] }, { type: 'link', id: 'reports', icon: 'chartBar', labelKey: 'adminShell.nav.reports', path: ['reports'] },
{ type: 'group', labelKey: 'adminShell.nav.partnersGroup' }, { type: 'group', labelKey: 'adminShell.nav.partnersGroup' },
{ type: 'link', id: 'seller-management', icon: 'store', labelKey: 'adminShell.nav.sellerManagement', path: ['partners', 'seller-management'] }, { type: 'link', id: 'seller-management', icon: 'store', labelKey: 'adminShell.nav.sellerManagement', path: ['partners', 'seller-management'] },

View File

@@ -17,6 +17,7 @@ import { DEFAULT_PRODUCT_PAGE_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG, ProductPag
import { getStockStatus, getTranslatedField } from '../../../../utils/item.utils'; import { getStockStatus, getTranslatedField } from '../../../../utils/item.utils';
import { ProductShareService } from '../../user-experience/services/product-share.service'; import { ProductShareService } from '../../user-experience/services/product-share.service';
import { SeoService } from '../../../../services/seo.service'; import { SeoService } from '../../../../services/seo.service';
import { AnalyticsService } from '../../../../core/analytics/services/analytics.service';
import { ApiService } from '../../../../services/api.service'; import { ApiService } from '../../../../services/api.service';
import { LocalStorageService } from '../../../../core/storage/local-storage.service'; import { LocalStorageService } from '../../../../core/storage/local-storage.service';
import { UserNotificationService } from '../../user-experience/services/user-notification.service'; import { UserNotificationService } from '../../user-experience/services/user-notification.service';
@@ -78,6 +79,7 @@ export class ProductDetailsContainerComponent {
private readonly translate = inject(TranslateService); private readonly translate = inject(TranslateService);
private readonly shareService = inject(ProductShareService); private readonly shareService = inject(ProductShareService);
private readonly seoService = inject(SeoService); private readonly seoService = inject(SeoService);
private readonly analytics = inject(AnalyticsService);
private readonly apiService = inject(ApiService); private readonly apiService = inject(ApiService);
private readonly storage = inject(LocalStorageService); private readonly storage = inject(LocalStorageService);
private readonly notifications = inject(UserNotificationService); private readonly notifications = inject(UserNotificationService);
@@ -276,6 +278,7 @@ export class ProductDetailsContainerComponent {
this.product.set(product); this.product.set(product);
this.seoService.setItemMeta(product); this.seoService.setItemMeta(product);
this.analytics.track('product_view', { itemID: product.itemID });
if (this.userExperienceConfig().recentlyViewed.enabled) { if (this.userExperienceConfig().recentlyViewed.enabled) {
this.uxFacade.trackRecentlyViewed(product, this.userExperienceConfig().recentlyViewed.maxItems); this.uxFacade.trackRecentlyViewed(product, this.userExperienceConfig().recentlyViewed.maxItems);
} }
@@ -321,6 +324,7 @@ export class ProductDetailsContainerComponent {
addToCart(): Promise<void> { addToCart(): Promise<void> {
const current = this.product(); const current = this.product();
if (!current) return Promise.resolve(); if (!current) return Promise.resolve();
this.analytics.track('add_to_cart', { itemID: current.itemID, price: this.effectivePrice(), currency: this.effectiveCurrency() });
return this.cartService.addItem(current.itemID, 1, { return this.cartService.addItem(current.itemID, 1, {
colour: this.selectedColour() ?? undefined, colour: this.selectedColour() ?? undefined,
size: this.selectedSize() ?? undefined, size: this.selectedSize() ?? undefined,

View File

@@ -2059,6 +2059,11 @@ export const en: Translations = {
customers: 'Customers', customers: 'Customers',
transactions: 'Transactions', transactions: 'Transactions',
moderation: 'Reviews & Moderation', moderation: 'Reviews & Moderation',
notifications: 'Notifications',
integrations: 'Integrations',
finance: 'Payments & Finance',
marketplaces: 'Marketplaces',
audit: 'Audit & Security',
reviews: 'Reviews', reviews: 'Reviews',
reports: 'Reports', reports: 'Reports',
partnersGroup: 'Partners', partnersGroup: 'Partners',

View File

@@ -2053,6 +2053,11 @@ export const hy: Translations = {
orders: 'Պատվերներ', orders: 'Պատվերներ',
customers: 'Հաճախորդներ', customers: 'Հաճախորդներ',
moderation: 'Կարծիքներ և մոդերացիա', moderation: 'Կարծիքներ և մոդերացիա',
notifications: 'Ծանուցումներ',
integrations: 'Ինտեգրումներ',
finance: 'Վճարումներ և ֆինանսներ',
marketplaces: 'Մարկետփլեյսներ',
audit: 'Աուդիտ և անվտանգություն',
transactions: 'Գործարքներ', transactions: 'Գործարքներ',
reviews: 'Կարծիքներ', reviews: 'Կարծիքներ',
reports: 'Հաշվետվություններ', reports: 'Հաշվետվություններ',

View File

@@ -2053,6 +2053,11 @@ export const ru: Translations = {
orders: 'Заказы', orders: 'Заказы',
customers: 'Клиенты', customers: 'Клиенты',
moderation: 'Отзывы и модерация', moderation: 'Отзывы и модерация',
notifications: 'Уведомления',
integrations: 'Интеграции',
finance: 'Платежи и финансы',
marketplaces: 'Маркетплейсы',
audit: 'Аудит и безопасность',
transactions: 'Транзакции', transactions: 'Транзакции',
reviews: 'Отзывы', reviews: 'Отзывы',
reports: 'Отчёты', reports: 'Отчёты',

View File

@@ -2068,6 +2068,11 @@ export interface Translations {
transactions: string; transactions: string;
reviews: string; reviews: string;
moderation: string; moderation: string;
notifications: string;
integrations: string;
finance: string;
marketplaces: string;
audit: string;
reports: string; reports: string;
partnersGroup: string; partnersGroup: string;
sellerManagement: string; sellerManagement: string;

View File

@@ -18,6 +18,7 @@ import { IconComponent } from '../../shared/ui/icon/icon.component';
import { EmptyStateComponent } from '../../shared/ui/empty-state/empty-state.component'; import { EmptyStateComponent } from '../../shared/ui/empty-state/empty-state.component';
import { ButtonComponent } from '../../shared/ui/button/button.component'; import { ButtonComponent } from '../../shared/ui/button/button.component';
import { ConfigService } from '../../core/config/config.service'; import { ConfigService } from '../../core/config/config.service';
import { AnalyticsService } from '../../core/analytics/services/analytics.service';
import { TenantResolverService } from '../../core/config/tenant-resolver.service'; import { TenantResolverService } from '../../core/config/tenant-resolver.service';
import { UserNotificationService } from '../../features/website/user-experience/services/user-notification.service'; import { UserNotificationService } from '../../features/website/user-experience/services/user-notification.service';
import { ConfirmDialogComponent } from '../../shared/ui/confirm-dialog/confirm-dialog.component'; import { ConfirmDialogComponent } from '../../shared/ui/confirm-dialog/confirm-dialog.component';
@@ -83,6 +84,7 @@ export class CartComponent implements OnDestroy {
private configService = inject(ConfigService); private configService = inject(ConfigService);
private tenantResolver = inject(TenantResolverService); private tenantResolver = inject(TenantResolverService);
private currencyRates = inject(CurrencyRatesService); private currencyRates = inject(CurrencyRatesService);
private readonly analytics = inject(AnalyticsService);
constructor( constructor(
private cartService: CartService, private cartService: CartService,
@@ -206,10 +208,12 @@ export class CartComponent implements OnDestroy {
this.notifications.show(this.i18n.t('cart.acceptTerms'), 'warning'); this.notifications.show(this.i18n.t('cart.acceptTerms'), 'warning');
return; return;
} }
this.analytics.track('checkout_started', { itemCount: this.items().length });
this.openPaymentPopup(paymentMethod); this.openPaymentPopup(paymentMethod);
} }
openPaymentPopup(paymentMethod: PaymentMethod): void { openPaymentPopup(paymentMethod: PaymentMethod): void {
this.analytics.track('payment_started', { paymentMethod });
this.showPaymentPopup.set(true); this.showPaymentPopup.set(true);
this.selectedPaymentMethod.set(paymentMethod); this.selectedPaymentMethod.set(paymentMethod);
this.paymentStatus.set('creating'); this.paymentStatus.set('creating');