feat: Phase 6 frontend - server-cart core, mock-gateway backed (scoped)

core/cart against docs/backend/PHASE-6-CART-CHECKOUT-CONTRACT.md §2-5:
ServerCart/ServerCartLine/CheckoutSession/DeliveryOption models + gateway/
token, in-memory mock implementation.

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-08-17 23:54:51 +04:00
parent 475d75781f
commit e3a70f5e65
4 changed files with 121 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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