Compare commits
12 Commits
2e09369345
...
23060261c7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23060261c7 | ||
|
|
be167d110e | ||
|
|
34f79b0303 | ||
|
|
5d585ff8ff | ||
|
|
0e16eecda6 | ||
|
|
a4f44dbb58 | ||
|
|
e3a70f5e65 | ||
|
|
475d75781f | ||
|
|
6ac52b1c50 | ||
|
|
5f23c6e5aa | ||
|
|
580d228484 | ||
|
|
b19fd77a60 |
@@ -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),
|
||||||
|
|||||||
@@ -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>
|
||||||
19
src/app/components/vk-id-login/vk-id-login.component.scss
Normal file
19
src/app/components/vk-id-login/vk-id-login.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/app/components/vk-id-login/vk-id-login.component.ts
Normal file
37
src/app/components/vk-id-login/vk-id-login.component.ts
Normal 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/app/core/analytics/models/analytics-event.model.ts
Normal file
11
src/app/core/analytics/models/analytics-event.model.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
17
src/app/core/analytics/services/analytics-local.gateway.ts
Normal file
17
src/app/core/analytics/services/analytics-local.gateway.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/app/core/analytics/services/analytics.service.ts
Normal file
25
src/app/core/analytics/services/analytics.service.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/app/core/cart/models/server-cart.model.ts
Normal file
35
src/app/core/cart/models/server-cart.model.ts
Normal 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;
|
||||||
|
}
|
||||||
11
src/app/core/cart/services/server-cart-gateway.interface.ts
Normal file
11
src/app/core/cart/services/server-cart-gateway.interface.ts
Normal 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>;
|
||||||
|
}
|
||||||
9
src/app/core/cart/services/server-cart-gateway.token.ts
Normal file
9
src/app/core/cart/services/server-cart-gateway.token.ts
Normal 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),
|
||||||
|
});
|
||||||
66
src/app/core/cart/services/server-cart-local.gateway.ts
Normal file
66
src/app/core/cart/services/server-cart-local.gateway.ts
Normal 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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/app/core/content-modules/models/mall-content.model.ts
Normal file
58
src/app/core/content-modules/models/mall-content.model.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
@@ -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() });
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/app/core/finance/models/reconciliation.model.ts
Normal file
38
src/app/core/finance/models/reconciliation.model.ts
Normal 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';
|
||||||
|
}
|
||||||
10
src/app/core/finance/services/finance-gateway.interface.ts
Normal file
10
src/app/core/finance/services/finance-gateway.interface.ts
Normal 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[]>;
|
||||||
|
}
|
||||||
9
src/app/core/finance/services/finance-gateway.token.ts
Normal file
9
src/app/core/finance/services/finance-gateway.token.ts
Normal 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),
|
||||||
|
});
|
||||||
32
src/app/core/finance/services/finance-local.gateway.ts
Normal file
32
src/app/core/finance/services/finance-local.gateway.ts
Normal 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([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/app/core/identity/models/customer-identity.model.ts
Normal file
28
src/app/core/identity/models/customer-identity.model.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
9
src/app/core/identity/services/vk-id-gateway.token.ts
Normal file
9
src/app/core/identity/services/vk-id-gateway.token.ts
Normal 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),
|
||||||
|
});
|
||||||
28
src/app/core/identity/services/vk-id-local.gateway.ts
Normal file
28
src/app/core/identity/services/vk-id-local.gateway.ts
Normal 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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/app/core/integrations/models/connector.model.ts
Normal file
21
src/app/core/integrations/models/connector.model.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/app/core/offers/models/offer.model.ts
Normal file
30
src/app/core/offers/models/offer.model.ts
Normal 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;
|
||||||
|
}
|
||||||
10
src/app/core/offers/services/offer-gateway.interface.ts
Normal file
10
src/app/core/offers/services/offer-gateway.interface.ts
Normal 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[] }>;
|
||||||
|
}
|
||||||
9
src/app/core/offers/services/offer-gateway.token.ts
Normal file
9
src/app/core/offers/services/offer-gateway.token.ts
Normal 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),
|
||||||
|
});
|
||||||
58
src/app/core/offers/services/offer-local.gateway.ts
Normal file
58
src/app/core/offers/services/offer-local.gateway.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/app/core/permissions/guards/permission.guard.ts
Normal file
20
src/app/core/permissions/guards/permission.guard.ts
Normal 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))
|
||||||
|
);
|
||||||
|
}
|
||||||
19
src/app/core/permissions/models/permission.model.ts
Normal file
19
src/app/core/permissions/models/permission.model.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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[]>;
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/app/core/pricing/models/fx-quote.model.ts
Normal file
14
src/app/core/pricing/models/fx-quote.model.ts
Normal 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();
|
||||||
|
}
|
||||||
47
src/app/core/pricing/models/money.model.ts
Normal file
47
src/app/core/pricing/models/money.model.ts
Normal 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 };
|
||||||
|
}
|
||||||
30
src/app/core/pricing/models/price-snapshot.model.ts
Normal file
30
src/app/core/pricing/models/price-snapshot.model.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
9
src/app/core/pricing/services/fx-quote-gateway.token.ts
Normal file
9
src/app/core/pricing/services/fx-quote-gateway.token.ts
Normal 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),
|
||||||
|
});
|
||||||
32
src/app/core/pricing/services/fx-quote-local.gateway.ts
Normal file
32
src/app/core/pricing/services/fx-quote-local.gateway.ts
Normal 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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/app/core/sellers/models/seller-user.model.ts
Normal file
12
src/app/core/sellers/models/seller-user.model.ts
Normal 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';
|
||||||
|
}
|
||||||
@@ -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[]>;
|
||||||
|
}
|
||||||
9
src/app/core/sellers/services/seller-gateway.token.ts
Normal file
9
src/app/core/sellers/services/seller-gateway.token.ts
Normal 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),
|
||||||
|
});
|
||||||
22
src/app/core/sellers/services/seller-local.gateway.ts
Normal file
22
src/app/core/sellers/services/seller-local.gateway.ts
Normal 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([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/app/features/admin/audit/facade/admin-audit.facade.ts
Normal file
20
src/app/features/admin/audit/facade/admin-audit.facade.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<div class="audit-page">
|
||||||
|
<header>
|
||||||
|
<h1>Audit & 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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<div class="finance-page">
|
||||||
|
<header>
|
||||||
|
<h1>Payments & 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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
@@ -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)); })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'] },
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: 'Հաշվետվություններ',
|
||||||
|
|||||||
@@ -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: 'Отчёты',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user