This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:23 +07:00
parent d9f159a8c3
commit cda5d21d0d
53 changed files with 2711 additions and 162 deletions
+9 -4
View File
@@ -158,7 +158,7 @@ sport-store/
│ │
├── docs/ ├── docs/
│ ├── architecture.md Boundaries, conventions, risks — read this first │ ├── architecture.md Boundaries, conventions, risks — read this first
│ └── adr/ 22 decision records │ └── adr/ 23 decision records
│ │
├── docker-compose.yml Backing services; `--profile full` runs everything ├── docker-compose.yml Backing services; `--profile full` runs everything
├── turbo.json pnpm-workspace.yaml package.json ├── turbo.json pnpm-workspace.yaml package.json
@@ -254,10 +254,10 @@ locale-in-path would buy nothing.
| **M5** ✅ | Cart (Redis), guest checkout, orders with stock reservation and an admin order lifecycle | | **M5** ✅ | Cart (Redis), guest checkout, orders with stock reservation and an admin order lifecycle |
| **M6** ✅ | Search: PostgreSQL full-text + trigram, diacritic-folded, ranked, with type-ahead and refinable results | | **M6** ✅ | Search: PostgreSQL full-text + trigram, diacritic-folded, ranked, with type-ahead and refinable results |
| **M7** ◐ | Discounts end-to-end: one engine, one admin screen, stacking, windows, limits, redemptions. Reviews and CMS remain | | **M7** ◐ | Discounts end-to-end: one engine, one admin screen, stacking, windows, limits, redemptions. Reviews and CMS remain |
| **M8** | Customer account | | **M8** ✅ | Customer accounts: registration, profile, address book, order history, wishlist; guest orders adopted on sign-up |
| **M9** | Payments (VNPay, MoMo, ZaloPay, COD), shipping, notifications | | **M9** | Payments (VNPay, MoMo, ZaloPay, COD), shipping, notifications |
**Recommended next step: M8 (customer accounts).** The store can **Recommended next step: M9 (payments) — or M10 (shipping), since payments are deferred.** The store can
now be browsed, searched, filled into a bag and checked out, and every order moves stock through a now be browsed, searched, filled into a bag and checked out, and every order moves stock through a
ledger. What it still cannot do is take money — which is the one gap between this and a shop that ledger. What it still cannot do is take money — which is the one gap between this and a shop that
trades. trades.
@@ -270,7 +270,7 @@ Everything below was run, not assumed:
- `pnpm lint` · `pnpm typecheck` · `pnpm test` · `pnpm build` — 27/27 Turborepo tasks pass; - `pnpm lint` · `pnpm typecheck` · `pnpm test` · `pnpm build` — 27/27 Turborepo tasks pass;
`pnpm format:check` clean `pnpm format:check` clean
- 10 migrations, 43 tables; seed loads 36 permissions, 6 roles, 3 brands, 8 categories, - 11 migrations, 44 tables; seed loads 36 permissions, 6 roles, 3 brands, 8 categories,
3 collections, 12 products, **155 variants**, 64 uploaded images and 3 dev accounts 3 collections, 12 products, **155 variants**, 64 uploaded images and 3 dev accounts
- **78 tests** — RBAC guards, password hashing, translation fallback, `Accept-Language`, the - **78 tests** — RBAC guards, password hashing, translation fallback, `Accept-Language`, the
variant matrix planner, the HTTP client's fetch receiver and retry recursion, the inventory variant matrix planner, the HTTP client's fetch receiver and retry recursion, the inventory
@@ -312,6 +312,11 @@ through Chrome with the console and network panel open:
at 09:00, not shifted; a discount scheduled for next week reads "Scheduled" and stays out of at 09:00, not shifted; a discount scheduled for next week reads "Scheduled" and stays out of
the bag; switching one off in admin drops it from a shopper's bag on the next load; retiring the bag; switching one off in admin drops it from a shopper's bag on the next load; retiring
one soft-deletes it, leaving redemptions and the audit trail intact one soft-deletes it, leaving redemptions and the audit trail intact
- **Accounts (M8):** registering with an email that had guest orders adopts them into the new
account's history; a signed-in checkout attaches the order to the customer while a guest
checkout — and one carrying a malformed token — still succeeds unattached; one customer
cannot read another's order by id, address book or wishlist; an admin token is refused on
account routes; the session survives a page reload with the access token held only in memory
- **Content (M7):** a post published in one language still lists on the other locale's journal - **Content (M7):** a post published in one language still lists on the other locale's journal
rather than vanishing; a body containing `<script>` and `<img onerror>` renders as visible text rather than vanishing; a body containing `<script>` and `<img onerror>` renders as visible text
and executes nothing; a Markdown link to `/men` keeps its locale prefix; a published page and executes nothing; a Markdown link to `/men` keeps its locale prefix; a published page
@@ -0,0 +1,23 @@
-- NOTE: Prisma's diff wanted to DROP INDEX "search_documents_document_idx" and
-- ALTER "search_documents"."document" DROP DEFAULT here. Removed on purpose —
-- see the M6 migration: `document` is a GENERATED tsvector column Prisma cannot
-- express, so it proposes undoing it in every migration. Applying it destroys
-- full-text search.
-- CreateTable
CREATE TABLE "wishlist_items" (
"customer_id" UUID NOT NULL,
"product_id" UUID NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "wishlist_items_pkey" PRIMARY KEY ("customer_id","product_id")
);
-- CreateIndex
CREATE INDEX "wishlist_items_customer_id_created_at_idx" ON "wishlist_items"("customer_id", "created_at");
-- AddForeignKey
ALTER TABLE "wishlist_items" ADD CONSTRAINT "wishlist_items_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wishlist_items" ADD CONSTRAINT "wishlist_items_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+26 -1
View File
@@ -210,9 +210,10 @@ model Customer {
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
addresses Address[] addresses Address[]
orders Order[] orders Order[]
wishlist WishlistItem[]
@@map("customers") @@map("customers")
} }
@@ -525,6 +526,7 @@ model Product {
searchDocuments SearchDocument[] searchDocuments SearchDocument[]
reviews Review[] reviews Review[]
wishlistedBy WishlistItem[]
discounts DiscountProduct[] discounts DiscountProduct[]
brand Brand? @relation(fields: [brandId], references: [id], onDelete: SetNull) brand Brand? @relation(fields: [brandId], references: [id], onDelete: SetNull)
primaryCategory Category? @relation(fields: [primaryCategoryId], references: [id], onDelete: SetNull) primaryCategory Category? @relation(fields: [primaryCategoryId], references: [id], onDelete: SetNull)
@@ -1346,3 +1348,26 @@ model ContentEntryTranslation {
@@unique([locale, slug]) @@unique([locale, slug])
@@map("content_entry_translations") @@map("content_entry_translations")
} }
/// One saved product, per customer.
///
/// Deliberately keyed on the *product*, not a variant. A shopper saving a
/// jacket is saying "this one, later" — not "this one in black, size M". Saving
/// a variant would mean a wishlist entry silently dies when a colourway is
/// discontinued, and would make the same jacket appear five times.
model WishlistItem {
customerId String @map("customer_id") @db.Uuid
productId String @map("product_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
/// The composite key IS the uniqueness rule: adding a product twice is a
/// no-op upsert rather than a duplicate row or an error the client must
/// handle. Double-tapping a heart icon is not a failure.
@@id([customerId, productId])
@@index([customerId, createdAt])
@@map("wishlist_items")
}
+21 -1
View File
@@ -21,7 +21,12 @@ import {
type SessionSummary, type SessionSummary,
type TokenAudience, type TokenAudience,
} from '@sport/types'; } from '@sport/types';
import { loginSchema, type LoginInput } from '@sport/validation'; import {
loginSchema,
registerSchema,
type LoginInput,
type RegisterInput,
} from '@sport/validation';
import { CurrentActor } from '@/common/decorators/current-actor.decorator'; import { CurrentActor } from '@/common/decorators/current-actor.decorator';
import { Public } from '@/common/decorators/public.decorator'; import { Public } from '@/common/decorators/public.decorator';
@@ -61,6 +66,21 @@ export class AuthController {
return this.handleLogin(body, TOKEN_AUDIENCES.STOREFRONT, request, response); return this.handleLogin(body, TOKEN_AUDIENCES.STOREFRONT, request, response);
} }
@Public()
@Post('register')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create a shopper account and sign in' })
async register(
@Body(new ZodValidationPipe(registerSchema)) body: RegisterInput,
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
): Promise<LoginResult> {
const { result, session } = await this.authService.register(body, contextOf(request));
// Storefront audience only — there is no admin equivalent of this route.
this.writeSession(response, TOKEN_AUDIENCES.STOREFRONT, session);
return result;
}
@Public() @Public()
@Post('admin/login') @Post('admin/login')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
+2 -1
View File
@@ -2,6 +2,7 @@ import { Global, Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { CustomersModule } from '@/modules/customers/customers.module';
import { UsersModule } from '@/modules/users/users.module'; import { UsersModule } from '@/modules/users/users.module';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
@@ -24,7 +25,7 @@ import { TokenService } from './token.service';
*/ */
@Global() @Global()
@Module({ @Module({
imports: [JwtModule.register({}), UsersModule], imports: [JwtModule.register({}), UsersModule, CustomersModule],
controllers: [AuthController], controllers: [AuthController],
providers: [ providers: [
AuthService, AuthService,
+67 -1
View File
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { import {
API_ERROR_CODES, API_ERROR_CODES,
SYSTEM_ROLES,
TOKEN_AUDIENCES, TOKEN_AUDIENCES,
isBackOfficeUser, isBackOfficeUser,
type CurrentUser, type CurrentUser,
@@ -11,10 +12,11 @@ import {
type TokenAudience, type TokenAudience,
type UserType, type UserType,
} from '@sport/types'; } from '@sport/types';
import type { LoginInput } from '@sport/validation'; import type { LoginInput, RegisterInput } from '@sport/validation';
import { AppException } from '@/common/errors/app.exception'; import { AppException } from '@/common/errors/app.exception';
import { PasswordService } from '@/common/security/password.service'; import { PasswordService } from '@/common/security/password.service';
import { CustomersService } from '@/modules/customers/public';
import { UsersService, type AuthUserRow } from '@/modules/users/public'; import { UsersService, type AuthUserRow } from '@/modules/users/public';
import { AuthRepository } from './auth.repository'; import { AuthRepository } from './auth.repository';
@@ -43,6 +45,7 @@ export class AuthService {
private readonly tokenService: TokenService, private readonly tokenService: TokenService,
private readonly repository: AuthRepository, private readonly repository: AuthRepository,
private readonly throttle: LoginThrottleService, private readonly throttle: LoginThrottleService,
private readonly customersService: CustomersService,
) {} ) {}
/** /**
@@ -53,6 +56,69 @@ export class AuthService {
* the login form into a user-enumeration oracle, and the timing is equalised * the login form into a user-enumeration oracle, and the timing is equalised
* for the same reason. * for the same reason.
*/ */
/**
* Creates a shopper account and signs them straight in.
*
* Registration is a storefront-only action: there is deliberately no way to
* self-register a staff account, so the type is pinned to CUSTOMER here
* rather than taken from the request. The CUSTOMER role is resolved by key
* because roles are data — a SUPER_ADMIN can edit them, so the id is not a
* stable reference and must never be hard-coded.
*
* Signing in immediately rather than bouncing to a login form: the password
* was typed seconds ago, and making someone retype it to reach the account
* they just created is friction with no security benefit.
*/
async register(
input: RegisterInput,
context: RequestContext,
): Promise<{ result: LoginResult; session: IssuedSession }> {
const roleId = await this.usersService.findRoleIdByKey(SYSTEM_ROLES.CUSTOMER);
if (!roleId) {
// A seeding failure, not a client error — fail loudly rather than create
// an account with no permissions that breaks mysteriously later.
throw new Error('The customer role is missing; the database is not seeded.');
}
const passwordHash = await this.passwordService.hash(input.password);
/**
* Throws 409 if the email is taken, and deliberately says so.
*
* Softening this to a generic message protects nothing — the login form
* already reveals which addresses exist — while leaving an honest person
* stuck on a form that will not accept them and will not say why.
*/
const created = await this.usersService.createCustomer(
{
email: input.email,
firstName: input.firstName,
lastName: input.lastName,
phone: input.phone,
roleIds: [roleId],
},
passwordHash,
);
await this.customersService.provision(created.id, input.email, input.acceptsMarketing);
const user = await this.usersService.findForAuthByEmail(input.email);
if (!user) throw new Error('Account vanished immediately after creation.');
const session = await this.startSession(user, TOKEN_AUDIENCES.STOREFRONT, context);
this.logger.log(`Registered customer ${created.id}`);
return {
result: {
user: this.usersService.toCurrentUser(user),
accessToken: session.accessToken,
accessTokenExpiresAt: session.accessTokenExpiresAt.toISOString(),
},
session,
};
}
async login( async login(
input: LoginInput, input: LoginInput,
audience: TokenAudience, audience: TokenAudience,
@@ -38,13 +38,32 @@ export class AccessTokenGuard implements CanActivate {
context.getClass(), context.getClass(),
]); ]);
if (isPublic) {
return true;
}
const request = context.switchToHttp().getRequest<Request>(); const request = context.switchToHttp().getRequest<Request>();
const token = extractBearerToken(request); const token = extractBearerToken(request);
/**
* A public route still *recognises* a caller who brought a token.
*
* Checkout is the reason: it must work for guests, so it cannot require
* authentication, but an order placed by a signed-in shopper has to end up
* attached to their account. Without this, the only ways to do that are to
* trust a customer id from the request body — which is an
* account-takeover primitive — or to re-verify the token by hand in the
* controller, duplicating exactly the logic below.
*
* The route stays public in every case: a missing, malformed or expired
* token leaves `request.actor` undefined and the request proceeds. Nothing
* here can turn a public route into a protected one, or vice versa.
*/
if (isPublic) {
if (token) {
const claims = await this.verifyQuietly(token);
if (claims) request.actor = toActor(claims);
}
return true;
}
if (!token) { if (!token) {
throw AppException.unauthenticated(); throw AppException.unauthenticated();
} }
@@ -74,17 +93,31 @@ export class AccessTokenGuard implements CanActivate {
throw AppException.forbidden('This credential cannot be used here.'); throw AppException.forbidden('This credential cannot be used here.');
} }
const actor: AuthenticatedActor = { request.actor = toActor(claims);
userId: claims.sub,
userType: claims.type,
audience: claims.aud,
permissions: claims.permissions ?? [],
sessionId: claims.sid,
};
request.actor = actor;
return true; return true;
} }
/** Verifies a token for the optional path, where failure simply means "guest". */
private async verifyQuietly(token: string): Promise<AccessTokenClaims | null> {
try {
return await this.jwtService.verifyAsync<AccessTokenClaims>(token, {
secret: this.config.auth.accessSecret,
issuer: this.config.auth.issuer,
});
} catch {
return null;
}
}
}
function toActor(claims: AccessTokenClaims): AuthenticatedActor {
return {
userId: claims.sub,
userType: claims.type,
audience: claims.aud,
permissions: claims.permissions ?? [],
sessionId: claims.sid,
};
} }
function extractBearerToken(request: Request): string | null { function extractBearerToken(request: Request): string | null {
@@ -13,7 +13,7 @@ import {
import { ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import type { Cart, Locale, Order } from '@sport/types'; import { TOKEN_AUDIENCES, type Cart, type Locale, type Order } from '@sport/types';
import { placeOrderSchema, type PlaceOrderInput } from '@sport/validation'; import { placeOrderSchema, type PlaceOrderInput } from '@sport/validation';
import { Public } from '@/common/decorators/public.decorator'; import { Public } from '@/common/decorators/public.decorator';
@@ -23,13 +23,17 @@ import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
import { APP_CONFIG } from '@/config/app-config.module'; import { APP_CONFIG } from '@/config/app-config.module';
import type { AppConfig } from '@/config/configuration'; import type { AppConfig } from '@/config/configuration';
import { clearCartCookie, resolveCartToken, setCartCookie } from '@/modules/carts/public'; import { clearCartCookie, resolveCartToken, setCartCookie } from '@/modules/carts/public';
import { CustomersService } from '@/modules/customers/public';
import { OrdersService } from '@/modules/orders/public'; import { OrdersService } from '@/modules/orders/public';
import { CheckoutService } from './checkout.service'; import { CheckoutService } from './checkout.service';
/** /**
* Public because guest checkout is the default. Customer accounts arrive in M8 * Public because guest checkout is the default.
* and will attach an order to a customer, not gate the ability to place one. *
* An account attaches an order to a customer; it never gates the ability to
* place one. A signed-in shopper is recognised through the optional branch of
* AccessTokenGuard, so the same endpoint serves both without a second route.
*/ */
@ApiTags('checkout') @ApiTags('checkout')
@Public() @Public()
@@ -38,6 +42,7 @@ export class CheckoutController {
constructor( constructor(
private readonly service: CheckoutService, private readonly service: CheckoutService,
private readonly orders: OrdersService, private readonly orders: OrdersService,
private readonly customers: CustomersService,
@Inject(APP_CONFIG) private readonly config: AppConfig, @Inject(APP_CONFIG) private readonly config: AppConfig,
) {} ) {}
@@ -81,7 +86,21 @@ export class CheckoutController {
} }
const { token } = resolveCartToken(request); const { token } = resolveCartToken(request);
const order = await this.service.placeOrder(token, key, body, locale);
/**
* A signed-in shopper's order is attached to their account; a guest's is
* not. The actor is optional here because checkout must work without an
* account — see the public branch of AccessTokenGuard. The audience check
* matters: an admin token must never file an order into a customer's
* history, and `resolveCustomerId` would return null for staff anyway.
*/
const actor = request.actor;
const customerId =
actor && actor.audience === TOKEN_AUDIENCES.STOREFRONT
? await this.customers.resolveCustomerId(actor.userId)
: null;
const order = await this.service.placeOrder(token, key, body, locale, customerId);
// The bag is gone, so the cookie naming it should go too — otherwise the // The bag is gone, so the cookie naming it should go too — otherwise the
// next visit reads an empty cart under a stale token forever. // next visit reads an empty cart under a stale token forever.
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CartsModule } from '@/modules/carts/carts.module'; import { CartsModule } from '@/modules/carts/carts.module';
import { CustomersModule } from '@/modules/customers/customers.module';
import { OrdersModule } from '@/modules/orders/orders.module'; import { OrdersModule } from '@/modules/orders/orders.module';
import { PromotionsModule } from '@/modules/promotions/promotions.module'; import { PromotionsModule } from '@/modules/promotions/promotions.module';
@@ -15,7 +16,7 @@ import { CheckoutService } from './checkout.service';
* being smeared across the two sides. Payment providers (M9) attach here. * being smeared across the two sides. Payment providers (M9) attach here.
*/ */
@Module({ @Module({
imports: [CartsModule, OrdersModule, PromotionsModule], imports: [CartsModule, OrdersModule, PromotionsModule, CustomersModule],
controllers: [CheckoutController], controllers: [CheckoutController],
providers: [CheckoutService], providers: [CheckoutService],
exports: [CheckoutService], exports: [CheckoutService],
@@ -70,6 +70,14 @@ export class CheckoutService {
idempotencyKey: string, idempotencyKey: string,
input: PlaceOrderInput, input: PlaceOrderInput,
locale: Locale, locale: Locale,
/**
* Set when a signed-in shopper checks out; null for a guest.
*
* Resolved from the session by the controller, never from the request body
* — a client-supplied customer id would let anyone file an order into
* somebody else's history.
*/
customerId: string | null = null,
): Promise<Order> { ): Promise<Order> {
const key = CACHE_KEYS.idempotency('checkout', idempotencyKey); const key = CACHE_KEYS.idempotency('checkout', idempotencyKey);
const claimed = await this.redis.setIfAbsent(key, {}, CACHE_TTL.idempotency); const claimed = await this.redis.setIfAbsent(key, {}, CACHE_TTL.idempotency);
@@ -86,7 +94,7 @@ export class CheckoutService {
} }
try { try {
return await this.place(cartToken, key, input, locale); return await this.place(cartToken, key, input, locale, customerId);
} catch (error) { } catch (error) {
// Release, so the shopper can fix whatever went wrong and try again. // Release, so the shopper can fix whatever went wrong and try again.
await this.redis.delete(key); await this.redis.delete(key);
@@ -99,6 +107,7 @@ export class CheckoutService {
idempotencyCacheKey: string, idempotencyCacheKey: string,
input: PlaceOrderInput, input: PlaceOrderInput,
locale: Locale, locale: Locale,
customerId: string | null,
): Promise<Order> { ): Promise<Order> {
const cart = await this.carts.resolveForCheckout(cartToken, locale); const cart = await this.carts.resolveForCheckout(cartToken, locale);
@@ -166,6 +175,7 @@ export class CheckoutService {
*/ */
const order = await tx.order.create({ const order = await tx.order.create({
data: { data: {
customerId,
email: input.email, email: input.email,
phone: input.shippingAddress.phone, phone: input.shippingAddress.phone,
@@ -0,0 +1,115 @@
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
TOKEN_AUDIENCES,
type AuthenticatedActor,
type CustomerAddress,
type CustomerProfile,
} from '@sport/types';
import {
customerAddressSchema,
updateProfileSchema,
wishlistItemSchema,
type CustomerAddressInput,
type UpdateProfileInput,
type WishlistItemInput,
} from '@sport/validation';
import { CurrentActor } from '@/common/decorators/current-actor.decorator';
import { RequireAudience } from '@/common/decorators/require-permissions.decorator';
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
import { CustomersService } from './customers.service';
/**
* The signed-in shopper's own account.
*
* Every route here is scoped to `actor.userId` and takes no customer id from
* the client — there is deliberately no `GET /customers/:id`. That is what
* makes horizontal privilege escalation impossible by construction rather than
* by remembering to check ownership in each handler.
*
* `RequireAudience(STOREFRONT)` keeps admin tokens out: a back-office token is
* for back-office endpoints, and reusing it here would blur which surface an
* action came from in the audit trail.
*/
@ApiTags('account')
@ApiBearerAuth()
@RequireAudience(TOKEN_AUDIENCES.STOREFRONT)
@Controller('account')
export class CustomersController {
constructor(private readonly service: CustomersService) {}
@Get('profile')
@ApiOperation({ summary: 'The signed-in shopper’s profile' })
getProfile(@CurrentActor() actor: AuthenticatedActor): Promise<CustomerProfile> {
return this.service.getProfile(actor.userId);
}
@Patch('profile')
@ApiOperation({ summary: 'Update name, phone, birthday or marketing consent' })
updateProfile(
@Body(new ZodValidationPipe(updateProfileSchema)) body: UpdateProfileInput,
@CurrentActor() actor: AuthenticatedActor,
): Promise<CustomerProfile> {
return this.service.updateProfile(actor.userId, body);
}
@Get('addresses')
@ApiOperation({ summary: 'Address book, defaults first' })
listAddresses(@CurrentActor() actor: AuthenticatedActor): Promise<CustomerAddress[]> {
return this.service.listAddresses(actor.userId);
}
@Post('addresses')
@ApiOperation({ summary: 'Save an address' })
createAddress(
@Body(new ZodValidationPipe(customerAddressSchema)) body: CustomerAddressInput,
@CurrentActor() actor: AuthenticatedActor,
): Promise<CustomerAddress> {
return this.service.createAddress(actor.userId, body);
}
@Patch('addresses/:id')
@ApiOperation({ summary: 'Update a saved address' })
updateAddress(
@Param('id') id: string,
@Body(new ZodValidationPipe(customerAddressSchema)) body: CustomerAddressInput,
@CurrentActor() actor: AuthenticatedActor,
): Promise<CustomerAddress> {
return this.service.updateAddress(actor.userId, id, body);
}
@Delete('addresses/:id')
@ApiOperation({ summary: 'Remove a saved address' })
deleteAddress(@Param('id') id: string, @CurrentActor() actor: AuthenticatedActor): Promise<void> {
return this.service.deleteAddress(actor.userId, id);
}
@Get('wishlist')
@ApiOperation({ summary: 'Saved product ids, newest first' })
listWishlist(@CurrentActor() actor: AuthenticatedActor): Promise<string[]> {
return this.service.listWishlistProductIds(actor.userId);
}
@Post('wishlist')
@ApiOperation({ summary: 'Save a product. Idempotent.' })
async addToWishlist(
@Body(new ZodValidationPipe(wishlistItemSchema)) body: WishlistItemInput,
@CurrentActor() actor: AuthenticatedActor,
): Promise<{ ok: true }> {
await this.service.addToWishlist(actor.userId, body.productId);
return { ok: true };
}
@Delete('wishlist/:productId')
@ApiOperation({ summary: 'Remove a saved product' })
async removeFromWishlist(
@Param('productId') productId: string,
@CurrentActor() actor: AuthenticatedActor,
): Promise<{ ok: true }> {
await this.service.removeFromWishlist(actor.userId, productId);
return { ok: true };
}
}
@@ -1,19 +1,22 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CustomersController } from './customers.controller';
import { CustomersRepository } from './customers.repository';
import { CustomersService } from './customers.service';
/** /**
* CustomersModule — boundary declared, implementation pending. * CustomersModule — owns `customers`, `addresses` and `wishlist_items`.
* *
* Owns (exclusively): `customers`, `addresses` * Shopper profiles are separate from `users` so customer PII can later live
* under a stricter access policy without touching staff accounts. The split
* costs one join and buys the ability to treat the two differently.
* *
* Shopper profiles and address book. Separate from `users` so customer PII can later live under a stricter access policy without touching staff accounts. * Exported for AuthModule, which provisions a customer record during
* * registration — the only place another module writes here.
* Anatomy once implemented (see ../README.md):
* customers.module.ts wiring only
* customers.controller.ts HTTP surface, no logic
* customers.service.ts business rules
* customers.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
*/ */
@Module({}) @Module({
controllers: [CustomersController],
providers: [CustomersService, CustomersRepository],
exports: [CustomersService],
})
export class CustomersModule {} export class CustomersModule {}
@@ -0,0 +1,171 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
/** The only file in this module that touches Prisma. */
@Injectable()
export class CustomersRepository {
constructor(private readonly prisma: PrismaService) {}
// ---- Profile -------------------------------------------------------------
findByUserId(userId: string) {
return this.prisma.customer.findUnique({
where: { userId },
select: {
id: true,
acceptsMarketing: true,
dateOfBirth: true,
user: { select: { email: true, firstName: true, lastName: true, phone: true } },
},
});
}
/**
* Creates the customer profile that hangs off a user account.
*
* Separate from `users` on purpose (see the module doc): shopper PII can
* later move under a stricter access policy without touching staff accounts.
*/
create(userId: string, acceptsMarketing: boolean) {
return this.prisma.customer.create({
data: { userId, acceptsMarketing },
select: { id: true },
});
}
async updateProfile(
customerId: string,
userId: string,
data: {
firstName?: string;
lastName?: string;
phone?: string | null;
acceptsMarketing?: boolean;
dateOfBirth?: Date | null;
},
): Promise<void> {
// Name and phone live on `users`, marketing consent and birthday on
// `customers`. One transaction so a half-applied profile is impossible.
await this.prisma.$transaction([
this.prisma.user.update({
where: { id: userId },
data: {
...(data.firstName === undefined ? {} : { firstName: data.firstName }),
...(data.lastName === undefined ? {} : { lastName: data.lastName }),
...(data.phone === undefined ? {} : { phone: data.phone }),
},
}),
this.prisma.customer.update({
where: { id: customerId },
data: {
...(data.acceptsMarketing === undefined
? {}
: { acceptsMarketing: data.acceptsMarketing }),
...(data.dateOfBirth === undefined ? {} : { dateOfBirth: data.dateOfBirth }),
},
}),
]);
}
// ---- Addresses -----------------------------------------------------------
findAddresses(customerId: string) {
return this.prisma.address.findMany({
where: { customerId },
// Defaults first, then newest. An address book exists to be picked from,
// and the one they always use should not be third in the list.
orderBy: [{ isDefaultShipping: 'desc' }, { createdAt: 'desc' }],
});
}
findAddress(customerId: string, addressId: string) {
// Scoped by customer, always: an address id alone must never be enough to
// read somebody else's address.
return this.prisma.address.findFirst({ where: { id: addressId, customerId } });
}
createAddress(data: Prisma.AddressUncheckedCreateInput) {
return this.prisma.address.create({ data });
}
updateAddress(addressId: string, data: Prisma.AddressUncheckedUpdateInput) {
return this.prisma.address.update({ where: { id: addressId }, data });
}
deleteAddress(addressId: string) {
return this.prisma.address.delete({ where: { id: addressId } });
}
/**
* Clears the default flag on every other address.
*
* "Default" is a property of the set, not of the row, and the database cannot
* express "at most one true per customer" without a partial unique index that
* would then reject the intermediate state of a swap. So it is maintained
* here, in the same transaction as the write that sets it.
*/
clearDefaults(
tx: Prisma.TransactionClient,
customerId: string,
exceptId: string | null,
kind: 'shipping' | 'billing',
) {
return tx.address.updateMany({
where: { customerId, ...(exceptId ? { id: { not: exceptId } } : {}) },
data: kind === 'shipping' ? { isDefaultShipping: false } : { isDefaultBilling: false },
});
}
transaction<T>(fn: (tx: Prisma.TransactionClient) => Promise<T>): Promise<T> {
return this.prisma.$transaction(fn);
}
countAddresses(customerId: string): Promise<number> {
return this.prisma.address.count({ where: { customerId } });
}
// ---- Wishlist ------------------------------------------------------------
findWishlistProductIds(customerId: string) {
return this.prisma.wishlistItem.findMany({
where: { customerId },
orderBy: { createdAt: 'desc' },
select: { productId: true },
});
}
/** Idempotent: double-tapping a heart is not an error. */
addToWishlist(customerId: string, productId: string) {
return this.prisma.wishlistItem.upsert({
where: { customerId_productId: { customerId, productId } },
create: { customerId, productId },
update: {},
select: { productId: true },
});
}
removeFromWishlist(customerId: string, productId: string) {
return this.prisma.wishlistItem.deleteMany({ where: { customerId, productId } });
}
// ---- Guest order adoption ------------------------------------------------
/**
* Attaches past guest orders placed with this email to the new account.
*
* Matched on email because that is the only link a guest checkout leaves
* behind. Safe *only* because it runs at registration, where the email has
* just been proven to be reachable by whoever set the password — the same
* assumption the whole password-reset flow rests on.
*/
adoptGuestOrders(customerId: string, email: string): Promise<number> {
return this.prisma.order
.updateMany({
where: { customerId: null, email: { equals: email, mode: 'insensitive' } },
data: { customerId },
})
.then((result) => result.count);
}
}
@@ -0,0 +1,282 @@
import { Injectable, Logger } from '@nestjs/common';
import type { CustomerAddress, CustomerProfile } from '@sport/types';
import type { CustomerAddressInput, UpdateProfileInput } from '@sport/validation';
import { AppException } from '@/common/errors/app.exception';
import { CustomersRepository } from './customers.repository';
/** Hard cap on the address book. Not a business rule — an abuse ceiling. */
const MAX_ADDRESSES = 20;
@Injectable()
export class CustomersService {
private readonly logger = new Logger(CustomersService.name);
constructor(private readonly repository: CustomersRepository) {}
/**
* Creates the customer record for a newly registered user, and adopts any
* guest orders placed with the same email.
*
* Called by AuthService during registration — the one moment where matching
* orders on email is safe, because the password was just set by whoever
* controls that address.
*/
async provision(userId: string, email: string, acceptsMarketing: boolean): Promise<string> {
const customer = await this.repository.create(userId, acceptsMarketing);
const adopted = await this.repository.adoptGuestOrders(customer.id, email);
if (adopted > 0) {
this.logger.log(`Adopted ${adopted} guest order(s) into customer ${customer.id}`);
}
return customer.id;
}
/**
* The customer id behind a signed-in user, or null for a staff account.
*
* The one thing other modules need from here. Orders and checkout scope their
* queries by `customerId`, and resolving it via this module keeps the
* `customers` table owned by exactly one place.
*/
async resolveCustomerId(userId: string): Promise<string | null> {
const row = await this.repository.findByUserId(userId);
return row?.id ?? null;
}
// ---- Profile -------------------------------------------------------------
async getProfile(userId: string): Promise<CustomerProfile> {
const row = await this.requireCustomer(userId);
return {
id: row.id,
email: row.user.email,
// Nullable on `users` because staff accounts may be created without
// them; a shopper always supplies both at registration, so an empty
// string here means a legacy row rather than a normal state.
firstName: row.user.firstName ?? '',
lastName: row.user.lastName ?? '',
phone: row.user.phone,
acceptsMarketing: row.acceptsMarketing,
// Date only. `toISOString()` would append a UTC time to a birthday and
// shift it a day for anyone east of Greenwich — including every customer
// this store has.
dateOfBirth: row.dateOfBirth ? row.dateOfBirth.toISOString().slice(0, 10) : null,
};
}
async updateProfile(userId: string, input: UpdateProfileInput): Promise<CustomerProfile> {
const row = await this.requireCustomer(userId);
await this.repository.updateProfile(row.id, userId, {
firstName: input.firstName,
lastName: input.lastName,
phone: input.phone === undefined ? undefined : (input.phone ?? null),
acceptsMarketing: input.acceptsMarketing,
dateOfBirth:
input.dateOfBirth === undefined
? undefined
: input.dateOfBirth
? new Date(input.dateOfBirth)
: null,
});
return this.getProfile(userId);
}
// ---- Addresses -----------------------------------------------------------
async listAddresses(userId: string): Promise<CustomerAddress[]> {
const row = await this.requireCustomer(userId);
const addresses = await this.repository.findAddresses(row.id);
return addresses.map(toCustomerAddress);
}
async createAddress(userId: string, input: CustomerAddressInput): Promise<CustomerAddress> {
const row = await this.requireCustomer(userId);
const existing = await this.repository.countAddresses(row.id);
if (existing >= MAX_ADDRESSES) {
throw AppException.conflict(`You can save at most ${MAX_ADDRESSES} addresses.`);
}
// The first address is the default whether or not they asked — an address
// book with no default makes checkout prefill nothing.
const isFirst = existing === 0;
const created = await this.repository.transaction(async (tx) => {
const address = await tx.address.create({
data: {
customerId: row.id,
...toAddressData(input),
isDefaultShipping: input.isDefaultShipping || isFirst,
isDefaultBilling: input.isDefaultBilling || isFirst,
},
});
if (address.isDefaultShipping) {
await this.repository.clearDefaults(tx, row.id, address.id, 'shipping');
}
if (address.isDefaultBilling) {
await this.repository.clearDefaults(tx, row.id, address.id, 'billing');
}
return address;
});
return toCustomerAddress(created);
}
async updateAddress(
userId: string,
addressId: string,
input: CustomerAddressInput,
): Promise<CustomerAddress> {
const row = await this.requireCustomer(userId);
const existing = await this.repository.findAddress(row.id, addressId);
if (!existing) throw AppException.notFound('Address');
const updated = await this.repository.transaction(async (tx) => {
const address = await tx.address.update({
where: { id: addressId },
data: {
...toAddressData(input),
isDefaultShipping: input.isDefaultShipping,
isDefaultBilling: input.isDefaultBilling,
},
});
if (address.isDefaultShipping) {
await this.repository.clearDefaults(tx, row.id, address.id, 'shipping');
}
if (address.isDefaultBilling) {
await this.repository.clearDefaults(tx, row.id, address.id, 'billing');
}
return address;
});
return toCustomerAddress(updated);
}
async deleteAddress(userId: string, addressId: string): Promise<void> {
const row = await this.requireCustomer(userId);
const existing = await this.repository.findAddress(row.id, addressId);
if (!existing) throw AppException.notFound('Address');
await this.repository.deleteAddress(addressId);
/**
* Promote another address if the default was just removed.
*
* Without this, deleting the default leaves a book full of addresses and
* nothing prefilled at checkout — which reads as the address book being
* broken rather than as a consequence of the delete.
*/
if (existing.isDefaultShipping || existing.isDefaultBilling) {
const remaining = await this.repository.findAddresses(row.id);
const next = remaining[0];
if (next) {
await this.repository.updateAddress(next.id, {
isDefaultShipping: next.isDefaultShipping || existing.isDefaultShipping,
isDefaultBilling: next.isDefaultBilling || existing.isDefaultBilling,
});
}
}
}
/** The address checkout should prefill, if any. */
async defaultShippingAddress(userId: string): Promise<CustomerAddress | null> {
const addresses = await this.listAddresses(userId);
return addresses.find((address) => address.isDefaultShipping) ?? addresses[0] ?? null;
}
// ---- Wishlist ------------------------------------------------------------
async listWishlistProductIds(userId: string): Promise<string[]> {
const row = await this.requireCustomer(userId);
const items = await this.repository.findWishlistProductIds(row.id);
return items.map((item) => item.productId);
}
async addToWishlist(userId: string, productId: string): Promise<void> {
const row = await this.requireCustomer(userId);
await this.repository.addToWishlist(row.id, productId);
}
async removeFromWishlist(userId: string, productId: string): Promise<void> {
const row = await this.requireCustomer(userId);
await this.repository.removeFromWishlist(row.id, productId);
}
// ---- internals -----------------------------------------------------------
/**
* A staff account has no customer profile.
*
* The token audience already keeps admins off these routes, so reaching here
* without a profile means a genuinely inconsistent account rather than an
* authorisation failure — 404 is the honest answer.
*/
private async requireCustomer(userId: string) {
const row = await this.repository.findByUserId(userId);
if (!row) throw AppException.notFound('Customer profile');
return row;
}
}
type AddressRow = {
id: string;
fullName: string;
phone: string;
line1: string;
line2: string | null;
ward: string | null;
district: string | null;
province: string;
countryCode: string;
postalCode: string | null;
isDefaultShipping: boolean;
isDefaultBilling: boolean;
};
function toCustomerAddress(row: AddressRow): CustomerAddress {
return {
id: row.id,
fullName: row.fullName,
phone: row.phone,
line1: row.line1,
line2: row.line2,
ward: row.ward,
district: row.district,
province: row.province,
countryCode: row.countryCode,
postalCode: row.postalCode,
isDefaultShipping: row.isDefaultShipping,
isDefaultBilling: row.isDefaultBilling,
};
}
function toAddressData(input: CustomerAddressInput) {
return {
fullName: input.fullName,
phone: input.phone,
line1: input.line1,
line2: input.line2 ?? null,
ward: input.ward ?? null,
district: input.district ?? null,
province: input.province,
countryCode: input.countryCode,
postalCode: input.postalCode ?? null,
};
}
@@ -1,10 +1,7 @@
/** /**
* Public surface of CustomersModule. * Public surface of CustomersModule.
* *
* This barrel is the ONLY thing other modules may import from here. Everything * AuthModule uses `provision()` during registration; CheckoutModule reads the
* else — repository, DTOs, internal services — is private, and the ESLint * default shipping address to prefill. Everything else stays private.
* boundary rule in @sport/eslint-config/nest enforces it.
*
* Keep it narrow: each export is a promise to the rest of the codebase.
*/ */
export {}; export { CustomersService } from '../customers.service';
@@ -0,0 +1,64 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
TOKEN_AUDIENCES,
type AuthenticatedActor,
type OffsetPaginated,
type Order,
type OrderListItem,
} from '@sport/types';
import { orderListQuerySchema, type OrderListQuery } from '@sport/validation';
import { CurrentActor } from '@/common/decorators/current-actor.decorator';
import { RequireAudience } from '@/common/decorators/require-permissions.decorator';
import { AppException } from '@/common/errors/app.exception';
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
import { CustomersService } from '@/modules/customers/public';
import { OrdersService } from './orders.service';
/**
* A shopper's own order history.
*
* Separate from `OrdersAdminController` because the authorisation model is
* different in kind, not in degree: the admin surface is permission-gated and
* can read any order; this one is session-scoped and can only ever read the
* caller's. Putting both on one controller would mean one forgotten guard is a
* full order-history leak.
*/
@ApiTags('account/orders')
@ApiBearerAuth()
@RequireAudience(TOKEN_AUDIENCES.STOREFRONT)
@Controller('account/orders')
export class MyOrdersController {
constructor(
private readonly orders: OrdersService,
private readonly customers: CustomersService,
) {}
@Get()
@ApiOperation({ summary: 'Your orders, newest first' })
async list(
@Query(new ZodValidationPipe(orderListQuerySchema)) query: OrderListQuery,
@CurrentActor() actor: AuthenticatedActor,
): Promise<OffsetPaginated<OrderListItem>> {
return this.orders.listForCustomer(await this.requireCustomerId(actor), query);
}
@Get(':id')
@ApiOperation({ summary: 'One of your orders' })
async getById(
@Param('id') id: string,
@CurrentActor() actor: AuthenticatedActor,
): Promise<Order> {
return this.orders.getForCustomer(await this.requireCustomerId(actor), id);
}
private async requireCustomerId(actor: AuthenticatedActor): Promise<string> {
const customerId = await this.customers.resolveCustomerId(actor.userId);
if (!customerId) throw AppException.notFound('Customer profile');
return customerId;
}
}
+4 -2
View File
@@ -1,7 +1,9 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CustomersModule } from '@/modules/customers/customers.module';
import { PromotionsModule } from '@/modules/promotions/promotions.module'; import { PromotionsModule } from '@/modules/promotions/promotions.module';
import { MyOrdersController } from './my-orders.controller';
import { OrdersAdminController } from './orders.controller'; import { OrdersAdminController } from './orders.controller';
import { OrdersMapper } from './orders.mapper'; import { OrdersMapper } from './orders.mapper';
import { OrdersRepository } from './orders.repository'; import { OrdersRepository } from './orders.repository';
@@ -18,8 +20,8 @@ import { OrdersService } from './orders.service';
* EXTRACTION CANDIDATE. * EXTRACTION CANDIDATE.
*/ */
@Module({ @Module({
imports: [PromotionsModule], imports: [PromotionsModule, CustomersModule],
controllers: [OrdersAdminController], controllers: [OrdersAdminController, MyOrdersController],
providers: [OrdersService, OrdersRepository, OrdersMapper], providers: [OrdersService, OrdersRepository, OrdersMapper],
exports: [OrdersService], exports: [OrdersService],
}) })
@@ -18,6 +18,9 @@ const lineSelect = {
const detailSelect = { const detailSelect = {
id: true, id: true,
number: true, number: true,
// Needed to prove ownership on the account routes; never mapped into the
// response, which is why it does not appear on the Order type.
customerId: true,
status: true, status: true,
paymentStatus: true, paymentStatus: true,
fulfillmentStatus: true, fulfillmentStatus: true,
@@ -66,6 +66,46 @@ export class OrdersService {
return this.mapper.toOrder(row); return this.mapper.toOrder(row);
} }
/**
* One customer's own orders.
*
* `customerId` comes from the session, never from the request, so there is no
* parameter an attacker could point at somebody else's history. Guest orders
* placed before the account existed appear here too — registration adopts
* them by email (see CustomersService.provision).
*/
async listForCustomer(
customerId: string,
query: OrderListQuery,
): Promise<OffsetPaginated<OrderListItem>> {
const [rows, totalItems] = await this.repository.list(
{ customerId },
(query.page - 1) * query.perPage,
query.perPage,
);
const totalPages = Math.max(1, Math.ceil(totalItems / query.perPage));
return {
items: rows.map((row) => this.mapper.toListItem(row)),
pageInfo: {
page: query.page,
perPage: query.perPage,
totalItems,
totalPages,
hasNextPage: query.page < totalPages,
},
};
}
/** One of the customer's own orders. Scoped, so a stray id 404s. */
async getForCustomer(customerId: string, orderId: string): Promise<Order> {
const row = await this.repository.findById(orderId);
if (!row || row.customerId !== customerId) throw AppException.notFound('Order');
return this.mapper.toOrder(row);
}
async list(query: OrderListQuery): Promise<OffsetPaginated<OrderListItem>> { async list(query: OrderListQuery): Promise<OffsetPaginated<OrderListItem>> {
const where: Prisma.OrderWhereInput = { const where: Prisma.OrderWhereInput = {
...(query.status ? { status: query.status } : {}), ...(query.status ? { status: query.status } : {}),
@@ -131,6 +131,56 @@ export class UsersService {
* granted access that was never granted — the worst possible failure mode for * granted access that was never granted — the worst possible failure mode for
* a permissions screen. * a permissions screen.
*/ */
/**
* Creates a shopper account.
*
* Separate from `create()` rather than widening its input type: `create()`
* backs the admin's "add a user" screen, whose schema only allows STAFF,
* ADMIN and SUPER_ADMIN. Letting CUSTOMER through there would make it
* possible to mint shopper accounts from the back office by accident, and
* would put self-registration and staff provisioning on the same code path.
*/
async createCustomer(
input: {
email: string;
firstName: string;
lastName: string;
phone?: string | null;
roleIds: readonly string[];
},
passwordHash: string,
): Promise<{ id: string }> {
const existing = await this.repository.findForAuthByEmail(input.email);
if (existing) {
throw AppException.conflict('An account with that email already exists.');
}
const user = await this.repository.create({
email: input.email,
passwordHash,
type: 'CUSTOMER',
status: 'ACTIVE',
firstName: input.firstName,
lastName: input.lastName,
phone: input.phone ?? null,
roles: { createMany: { data: input.roleIds.map((roleId) => ({ roleId })) } },
});
return { id: user.id };
}
/**
* Resolves a seeded role by its key.
*
* Registration needs the CUSTOMER role id and must not hard-code a uuid —
* roles are data (rows a SUPER_ADMIN can edit), so the key is the stable
* reference and the id is not.
*/
async findRoleIdByKey(key: string): Promise<string | null> {
const role = await this.rolesRepository.findByKey(key);
return role?.id ?? null;
}
private async assertRolesExist(roleIds: readonly string[]): Promise<void> { private async assertRolesExist(roleIds: readonly string[]): Promise<void> {
if (roleIds.length === 0) return; if (roleIds.length === 0) return;
@@ -1,23 +1,20 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server'; import { getTranslations, setRequestLocale } from 'next-intl/server';
import { PageScaffold } from '@/components/layout/page-scaffold'; import { AccountAddresses } from '@/features/account/account-panels';
type PageProps = { params: Promise<{ locale: string }> }; type PageProps = { params: Promise<{ locale: string }> };
export async function generateMetadata({ params }: PageProps): Promise<Metadata> { export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale } = await params; const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'placeholder.addresses' }); const t = await getTranslations({ locale, namespace: 'account' });
return { title: t('title') }; // Account pages are per-person and behind a session; never indexed.
return { title: t('nav.addresses'), robots: { index: false, follow: false } };
} }
export default async function AddressesPage({ params }: PageProps) { export default async function Page({ params }: PageProps) {
const { locale } = await params; const { locale } = await params;
setRequestLocale(locale); setRequestLocale(locale);
const t = await getTranslations('placeholder.addresses'); return <AccountAddresses />;
return (
<PageScaffold title={t('title')} description={t('body')} milestone="M8 — customer account" />
);
} }
@@ -1,62 +1,11 @@
import { getTranslations, setRequestLocale } from 'next-intl/server'; import type { ReactNode } from 'react';
import type { Locale } from '@sport/types'; import { AccountShell } from '@/features/account/account-shell';
import { SiteHeader } from '@/components/commerce/site-header';
import { SiteFooter } from '@/components/layout/site-footer';
import { Link } from '@/i18n/navigation';
import { fetchNavigation } from '@/lib/catalog';
import { routes } from '@/lib/routes';
/**
* Everything under /account requires a signed-in customer. Enforcement will be
* layered: the proxy checks for a session cookie, this layout verifies the
* token server-side, and the API re-checks on every request. Only the last one
* is real security; the first two are UX.
*/
export default async function AccountLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('account');
const navigation = await fetchNavigation(locale as Locale);
const items = [
{ href: routes.accountProfile(), label: t('profile') },
{ href: routes.accountOrders(), label: t('orders') },
{ href: routes.accountAddresses(), label: t('addresses') },
{ href: routes.accountWishlist(), label: t('wishlist') },
];
export default function AccountLayout({ children }: { children: ReactNode }) {
return ( return (
<div className="flex min-h-screen flex-col"> <div className="max-w-page px-gutter mx-auto py-12">
<SiteHeader navigation={navigation} locale={locale as Locale} /> <AccountShell>{children}</AccountShell>
<div className="max-w-page px-gutter mx-auto flex w-full flex-1 gap-12 py-16">
<aside className="hidden w-56 shrink-0 md:block">
<h2 className="text-ink-400 text-xs font-semibold uppercase tracking-widest">
{t('title')}
</h2>
<nav className="mt-4 space-y-1">
{items.map((item) => (
<Link
key={item.href}
href={item.href}
className="text-ink-600 hover:text-ink-950 block py-1.5 text-sm"
>
{item.label}
</Link>
))}
</nav>
</aside>
<div className="min-w-0 flex-1">{children}</div>
</div>
<SiteFooter navigation={navigation} />
</div> </div>
); );
} }
@@ -1,23 +1,20 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server'; import { getTranslations, setRequestLocale } from 'next-intl/server';
import { PageScaffold } from '@/components/layout/page-scaffold'; import { AccountOrders } from '@/features/account/account-panels';
type PageProps = { params: Promise<{ locale: string }> }; type PageProps = { params: Promise<{ locale: string }> };
export async function generateMetadata({ params }: PageProps): Promise<Metadata> { export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale } = await params; const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'placeholder.orders' }); const t = await getTranslations({ locale, namespace: 'account' });
return { title: t('title') }; // Account pages are per-person and behind a session; never indexed.
return { title: t('nav.orders'), robots: { index: false, follow: false } };
} }
export default async function OrdersPage({ params }: PageProps) { export default async function Page({ params }: PageProps) {
const { locale } = await params; const { locale } = await params;
setRequestLocale(locale); setRequestLocale(locale);
const t = await getTranslations('placeholder.orders'); return <AccountOrders />;
return (
<PageScaffold title={t('title')} description={t('body')} milestone="M8 — customer account" />
);
} }
@@ -1,23 +1,20 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server'; import { getTranslations, setRequestLocale } from 'next-intl/server';
import { PageScaffold } from '@/components/layout/page-scaffold'; import { AccountOverview } from '@/features/account/account-panels';
type PageProps = { params: Promise<{ locale: string }> }; type PageProps = { params: Promise<{ locale: string }> };
export async function generateMetadata({ params }: PageProps): Promise<Metadata> { export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale } = await params; const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'placeholder.account' }); const t = await getTranslations({ locale, namespace: 'account' });
return { title: t('title') }; // Account pages are per-person and behind a session; never indexed.
return { title: t('nav.overview'), robots: { index: false, follow: false } };
} }
export default async function AccountPage({ params }: PageProps) { export default async function Page({ params }: PageProps) {
const { locale } = await params; const { locale } = await params;
setRequestLocale(locale); setRequestLocale(locale);
const t = await getTranslations('placeholder.account'); return <AccountOverview />;
return (
<PageScaffold title={t('title')} description={t('body')} milestone="M8 — customer account" />
);
} }
@@ -1,23 +1,20 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server'; import { getTranslations, setRequestLocale } from 'next-intl/server';
import { PageScaffold } from '@/components/layout/page-scaffold'; import { AccountProfile } from '@/features/account/account-panels';
type PageProps = { params: Promise<{ locale: string }> }; type PageProps = { params: Promise<{ locale: string }> };
export async function generateMetadata({ params }: PageProps): Promise<Metadata> { export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale } = await params; const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'placeholder.profile' }); const t = await getTranslations({ locale, namespace: 'account' });
return { title: t('title') }; // Account pages are per-person and behind a session; never indexed.
return { title: t('nav.profile'), robots: { index: false, follow: false } };
} }
export default async function ProfilePage({ params }: PageProps) { export default async function Page({ params }: PageProps) {
const { locale } = await params; const { locale } = await params;
setRequestLocale(locale); setRequestLocale(locale);
const t = await getTranslations('placeholder.profile'); return <AccountProfile />;
return (
<PageScaffold title={t('title')} description={t('body')} milestone="M8 — customer account" />
);
} }
@@ -1,23 +1,20 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server'; import { getTranslations, setRequestLocale } from 'next-intl/server';
import { PageScaffold } from '@/components/layout/page-scaffold'; import { AccountWishlist } from '@/features/account/account-panels';
type PageProps = { params: Promise<{ locale: string }> }; type PageProps = { params: Promise<{ locale: string }> };
export async function generateMetadata({ params }: PageProps): Promise<Metadata> { export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale } = await params; const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'placeholder.wishlist' }); const t = await getTranslations({ locale, namespace: 'account' });
return { title: t('title') }; // Account pages are per-person and behind a session; never indexed.
return { title: t('nav.wishlist'), robots: { index: false, follow: false } };
} }
export default async function WishlistPage({ params }: PageProps) { export default async function Page({ params }: PageProps) {
const { locale } = await params; const { locale } = await params;
setRequestLocale(locale); setRequestLocale(locale);
const t = await getTranslations('placeholder.wishlist'); return <AccountWishlist />;
return (
<PageScaffold title={t('title')} description={t('body')} milestone="M8 — customer account" />
);
} }
@@ -0,0 +1,36 @@
import { setRequestLocale } from 'next-intl/server';
import type { Locale } from '@sport/types';
import { SiteHeader } from '@/components/commerce/site-header';
import { SiteFooter } from '@/components/layout/site-footer';
import { fetchNavigation } from '@/lib/catalog';
/**
* Full site chrome around sign-in and the account area.
*
* Deliberately the shop layout rather than checkout's stripped-back one: an
* account page is browsing, not a funnel. Someone checking an old order is one
* click from wanting the catalog, and a bare page with no way back to the store
* is a dead end.
*/
export default async function AccountGroupLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const navigation = await fetchNavigation(locale as Locale);
return (
<div className="flex min-h-screen flex-col">
<SiteHeader navigation={navigation} locale={locale as Locale} />
<main className="flex-1">{children}</main>
<SiteFooter navigation={navigation} />
</div>
);
}
@@ -0,0 +1,29 @@
import type { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Suspense } from 'react';
import { AuthForm } from '@/features/account/auth-form';
type PageProps = { params: Promise<{ locale: string }> };
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'auth' });
// Never indexed: a sign-in form is not a landing page.
return { title: t('signInTitle'), robots: { index: false, follow: true } };
}
export default async function Page({ params }: PageProps) {
const { locale } = await params;
setRequestLocale(locale);
return (
<div className="px-gutter mx-auto max-w-md py-16">
{/* AuthForm reads ?next= via useSearchParams, which needs a Suspense
boundary so the rest of the page can still be prerendered. */}
<Suspense fallback={null}>
<AuthForm mode="signIn" />
</Suspense>
</div>
);
}
@@ -0,0 +1,29 @@
import type { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Suspense } from 'react';
import { AuthForm } from '@/features/account/auth-form';
type PageProps = { params: Promise<{ locale: string }> };
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'auth' });
// Never indexed: a sign-in form is not a landing page.
return { title: t('registerTitle'), robots: { index: false, follow: true } };
}
export default async function Page({ params }: PageProps) {
const { locale } = await params;
setRequestLocale(locale);
return (
<div className="px-gutter mx-auto max-w-md py-16">
{/* AuthForm reads ?next= via useSearchParams, which needs a Suspense
boundary so the rest of the page can still be prerendered. */}
<Suspense fallback={null}>
<AuthForm mode="register" />
</Suspense>
</div>
);
}
+4 -1
View File
@@ -5,6 +5,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
import { LOCALE_TAGS, type Locale } from '@sport/types'; import { LOCALE_TAGS, type Locale } from '@sport/types';
import { CustomerProvider } from '@/features/account/customer-provider';
import { CartProvider } from '@/features/cart/cart-provider'; import { CartProvider } from '@/features/cart/cart-provider';
import { routing } from '@/i18n/routing'; import { routing } from '@/i18n/routing';
@@ -70,7 +71,9 @@ export default async function LocaleLayout({
<NextIntlClientProvider> <NextIntlClientProvider>
{/* Above both route groups: the bag survives moving from browsing {/* Above both route groups: the bag survives moving from browsing
into checkout, which is the one transition it must not lose. */} into checkout, which is the one transition it must not lose. */}
<CartProvider locale={locale as Locale}>{children}</CartProvider> <CustomerProvider>
<CartProvider locale={locale as Locale}>{children}</CartProvider>
</CustomerProvider>
</NextIntlClientProvider> </NextIntlClientProvider>
</body> </body>
</html> </html>
@@ -3,12 +3,13 @@
import { Loader2, Lock } from 'lucide-react'; import { Loader2, Lock } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import { useFormatter, useTranslations } from 'next-intl'; import { useFormatter, useTranslations } from 'next-intl';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { isApiClientError } from '@sport/api-client'; import { isApiClientError } from '@sport/api-client';
import type { Locale } from '@sport/types'; import type { Locale } from '@sport/types';
import { Button, Input } from '@sport/ui'; import { Button, Input } from '@sport/ui';
import { useCustomer } from '@/features/account/customer-provider';
import { useCart } from '@/features/cart/cart-provider'; import { useCart } from '@/features/cart/cart-provider';
import { Link, useRouter } from '@/i18n/navigation'; import { Link, useRouter } from '@/i18n/navigation';
import { browserApi } from '@/lib/api'; import { browserApi } from '@/lib/api';
@@ -54,6 +55,55 @@ export function CheckoutForm({ locale }: { locale: Locale }) {
const { cart, loading, refresh } = useCart(); const { cart, loading, refresh } = useCart();
const [fields, setFields] = useState<Fields>(EMPTY); const [fields, setFields] = useState<Fields>(EMPTY);
const { customer, status } = useCustomer();
/**
* Prefill from the account, once, and never over typing already done.
*
* The `previous.line1 ? previous : ...` guard is the important part: the
* address book request is in flight while the shopper is already filling the
* form, and a prefill that lands late and overwrites what they typed is worse
* than no prefill at all.
*/
useEffect(() => {
if (status !== 'authenticated' || !customer) return;
let cancelled = false;
browserApi.account
.listAddresses()
.then((addresses) => {
if (cancelled) return;
const preferred = addresses.find((address) => address.isDefaultShipping) ?? addresses[0];
setFields((previous) => {
if (previous.line1 || previous.fullName) return previous;
return {
...previous,
email: previous.email || customer.email,
...(preferred
? {
fullName: preferred.fullName,
phone: preferred.phone,
line1: preferred.line1,
ward: preferred.ward ?? '',
district: preferred.district ?? '',
province: preferred.province,
}
: {}),
};
});
})
.catch(() => {
// An address book that will not load still leaves a usable form.
});
return () => {
cancelled = true;
};
}, [status, customer]);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -6,6 +6,7 @@ import { useMemo, useState } from 'react';
import { VARIANT_AVAILABILITY, type StorefrontProduct, type StorefrontVariant } from '@sport/types'; import { VARIANT_AVAILABILITY, type StorefrontProduct, type StorefrontVariant } from '@sport/types';
import { cn } from '@sport/ui'; import { cn } from '@sport/ui';
import { WishlistButton } from '@/features/account/wishlist-button';
import { discountPercent, formatMoney } from '@/lib/format'; import { discountPercent, formatMoney } from '@/lib/format';
import { AddToBag } from './add-to-bag'; import { AddToBag } from './add-to-bag';
@@ -203,6 +204,8 @@ export function ProductDetail({ product }: { product: StorefrontProduct }) {
<AddToBag variant={selectedVariant} /> <AddToBag variant={selectedVariant} />
<WishlistButton productId={product.id} productSlug={product.slug} />
{selectedVariant ? ( {selectedVariant ? (
<p className="text-ink-400 text-center text-xs"> <p className="text-ink-400 text-center text-xs">
{t('sku')}: {selectedVariant.sku} {t('sku')}: {selectedVariant.sku}
@@ -0,0 +1,559 @@
'use client';
import { Heart, Package, Trash2 } from 'lucide-react';
import { useFormatter, useLocale, useTranslations } from 'next-intl';
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import { isApiClientError, type AddressPayload } from '@sport/api-client';
import type {
CustomerAddress,
CustomerProfile,
Locale,
OrderListItem,
ProductListItem,
} from '@sport/types';
import { Badge, Button, Input, Skeleton } from '@sport/ui';
import { Link } from '@/i18n/navigation';
import { browserApi } from '@/lib/api';
import { formatMoney } from '@/lib/format';
import { routes } from '@/lib/routes';
/**
* Shared loading/error scaffolding, so each panel is just its own content.
*
* `load` must be a stable reference (wrap it in `useCallback`) — it is a real
* dependency of the effect, and papering over that with a ref would be lying to
* the compiler about what this hook re-runs on.
*
* `refresh()` bumps a nonce rather than fetching directly, so every state
* update happens inside the effect behind the `cancelled` guard. A response
* that lands after the panel unmounted, or after a newer request was fired, is
* discarded instead of writing to dead state.
*/
function usePanel<T>(load: () => Promise<T>) {
const t = useTranslations('account');
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<string | null>(null);
const [nonce, setNonce] = useState(0);
useEffect(() => {
let cancelled = false;
load()
.then((result) => {
if (!cancelled) {
setData(result);
setError(null);
}
})
.catch((caught: unknown) => {
if (!cancelled) {
setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
}
});
return () => {
cancelled = true;
};
}, [load, nonce, t]);
const refresh = useCallback(async () => {
setNonce((current) => current + 1);
}, []);
return { data, error, refresh, setData };
}
// ---- Overview ---------------------------------------------------------------
export function AccountOverview() {
const t = useTranslations('account');
const format = useFormatter();
const { data: orders } = usePanel(
useCallback(() => browserApi.account.listOrders({ perPage: 3 }), []),
);
return (
<div className="space-y-8">
<h1 className="text-2xl font-black uppercase tracking-tight">{t('nav.overview')}</h1>
<section>
<div className="flex items-baseline justify-between">
<h2 className="text-xs font-semibold uppercase tracking-widest">{t('recentOrders')}</h2>
<Link href={routes.accountOrders()} className="text-ink-500 hover:text-ink-950 text-xs">
{t('viewAll')}
</Link>
</div>
{!orders ? (
<Skeleton className="mt-4 h-24 w-full" />
) : orders.items.length === 0 ? (
<p className="text-ink-500 mt-4 text-sm">{t('noOrders')}</p>
) : (
<ul className="divide-ink-100 mt-4 divide-y">
{orders.items.map((order) => (
<OrderRow key={order.id} order={order} format={format} />
))}
</ul>
)}
</section>
</div>
);
}
// ---- Orders -----------------------------------------------------------------
export function AccountOrders() {
const t = useTranslations('account');
const format = useFormatter();
const { data, error } = usePanel(
useCallback(() => browserApi.account.listOrders({ perPage: 50 }), []),
);
return (
<div className="space-y-6">
<h1 className="text-2xl font-black uppercase tracking-tight">{t('nav.orders')}</h1>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
{!data ? (
<Skeleton className="h-40 w-full" />
) : data.items.length === 0 ? (
<p className="text-ink-500 text-sm">{t('noOrders')}</p>
) : (
<ul className="divide-ink-100 divide-y border-y">
{data.items.map((order) => (
<OrderRow key={order.id} order={order} format={format} />
))}
</ul>
)}
</div>
);
}
function OrderRow({
order,
format,
}: {
order: OrderListItem;
format: ReturnType<typeof useFormatter>;
}) {
const t = useTranslations('account');
return (
<li className="flex flex-wrap items-center gap-4 py-4">
<Package className="text-ink-300 size-5 shrink-0" />
<div className="min-w-0 flex-1">
<p className="font-mono text-sm font-medium">{order.orderNumber}</p>
<p className="text-ink-500 text-xs">
{format.dateTime(new Date(order.placedAt), { dateStyle: 'medium' })} ·{' '}
{t('itemCount', { count: order.itemCount })}
</p>
</div>
<Badge variant="neutral">{order.status}</Badge>
<p className="text-sm font-semibold">{formatMoney(order.total, format)}</p>
</li>
);
}
// ---- Addresses --------------------------------------------------------------
const EMPTY_ADDRESS: AddressPayload = {
fullName: '',
phone: '',
line1: '',
ward: '',
district: '',
province: '',
isDefaultShipping: false,
};
export function AccountAddresses() {
const t = useTranslations('account');
const { data, error, refresh } = usePanel(
useCallback(() => browserApi.account.listAddresses(), []),
);
const [draft, setDraft] = useState<AddressPayload | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
async function save(event: FormEvent) {
event.preventDefault();
if (!draft) return;
setBusy(true);
setFormError(null);
try {
if (editingId) {
await browserApi.account.updateAddress(editingId, draft);
} else {
await browserApi.account.createAddress(draft);
}
setDraft(null);
setEditingId(null);
await refresh();
} catch (caught) {
setFormError(isApiClientError(caught) ? caught.message : t('saveFailed'));
} finally {
setBusy(false);
}
}
async function remove(id: string) {
await browserApi.account.deleteAddress(id);
await refresh();
}
function edit(address: CustomerAddress) {
setEditingId(address.id);
setDraft({
fullName: address.fullName,
phone: address.phone,
line1: address.line1,
line2: address.line2 ?? '',
ward: address.ward ?? '',
district: address.district ?? '',
province: address.province,
isDefaultShipping: address.isDefaultShipping,
isDefaultBilling: address.isDefaultBilling,
});
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-black uppercase tracking-tight">{t('nav.addresses')}</h1>
{!draft ? (
<Button
size="sm"
onClick={() => {
setEditingId(null);
setDraft({ ...EMPTY_ADDRESS });
}}
>
{t('addAddress')}
</Button>
) : null}
</div>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
{draft ? (
<form onSubmit={save} className="border-ink-200 space-y-4 border p-5">
<div className="grid gap-4 sm:grid-cols-2">
<Field
label={t('fullName')}
value={draft.fullName}
onChange={(v) => setDraft({ ...draft, fullName: v })}
required
/>
<Field
label={t('phone')}
value={draft.phone}
onChange={(v) => setDraft({ ...draft, phone: v })}
required
/>
</div>
<Field
label={t('line1')}
value={draft.line1}
onChange={(v) => setDraft({ ...draft, line1: v })}
required
/>
<div className="grid gap-4 sm:grid-cols-3">
<Field
label={t('ward')}
value={draft.ward ?? ''}
onChange={(v) => setDraft({ ...draft, ward: v })}
/>
<Field
label={t('district')}
value={draft.district ?? ''}
onChange={(v) => setDraft({ ...draft, district: v })}
/>
<Field
label={t('province')}
value={draft.province}
onChange={(v) => setDraft({ ...draft, province: v })}
required
/>
</div>
<label className="flex items-center gap-2.5">
<input
type="checkbox"
checked={draft.isDefaultShipping ?? false}
onChange={(event) => setDraft({ ...draft, isDefaultShipping: event.target.checked })}
className="border-ink-300 accent-ink-950 size-4"
/>
<span className="text-sm">{t('makeDefault')}</span>
</label>
{formError ? (
<p role="alert" className="text-danger text-sm">
{formError}
</p>
) : null}
<div className="flex gap-2">
<Button type="submit" size="sm" disabled={busy}>
{busy ? t('saving') : t('save')}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
setDraft(null);
setEditingId(null);
}}
>
{t('cancel')}
</Button>
</div>
</form>
) : null}
{!data ? (
<Skeleton className="h-32 w-full" />
) : data.length === 0 && !draft ? (
<p className="text-ink-500 text-sm">{t('noAddresses')}</p>
) : (
<ul className="grid gap-4 sm:grid-cols-2">
{data.map((address) => (
<li key={address.id} className="border-ink-200 space-y-2 border p-4">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-semibold">{address.fullName}</p>
{address.isDefaultShipping ? <Badge variant="success">{t('default')}</Badge> : null}
</div>
<p className="text-ink-600 text-sm leading-relaxed">
{address.phone}
<br />
{[address.line1, address.ward, address.district, address.province]
.filter(Boolean)
.join(', ')}
</p>
<div className="flex gap-2 pt-1">
<Button variant="outline" size="sm" onClick={() => edit(address)}>
{t('edit')}
</Button>
<Button variant="ghost" size="sm" onClick={() => void remove(address.id)}>
<Trash2 className="size-4" />
</Button>
</div>
</li>
))}
</ul>
)}
</div>
);
}
function Field({
label,
value,
onChange,
required,
}: {
label: string;
value: string;
onChange: (value: string) => void;
required?: boolean;
}) {
return (
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">{label}</span>
<Input value={value} onChange={(event) => onChange(event.target.value)} required={required} />
</label>
);
}
// ---- Profile ----------------------------------------------------------------
export function AccountProfile() {
const t = useTranslations('account');
const { data, error, setData } = usePanel(useCallback(() => browserApi.account.getProfile(), []));
const [busy, setBusy] = useState(false);
const [saved, setSaved] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
async function save(event: FormEvent) {
event.preventDefault();
if (!data) return;
setBusy(true);
setSaved(false);
setFormError(null);
try {
const updated = await browserApi.account.updateProfile({
firstName: data.firstName,
lastName: data.lastName,
phone: data.phone || null,
acceptsMarketing: data.acceptsMarketing,
});
setData(updated);
setSaved(true);
} catch (caught) {
setFormError(isApiClientError(caught) ? caught.message : t('saveFailed'));
} finally {
setBusy(false);
}
}
if (error) {
return (
<p role="alert" className="text-danger text-sm">
{error}
</p>
);
}
if (!data) return <Skeleton className="h-64 w-full" />;
const patch = (fields: Partial<CustomerProfile>) => setData({ ...data, ...fields });
return (
<div className="space-y-6">
<h1 className="text-2xl font-black uppercase tracking-tight">{t('nav.profile')}</h1>
<form onSubmit={save} className="max-w-md space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<Field
label={t('firstName')}
value={data.firstName}
onChange={(v) => patch({ firstName: v })}
required
/>
<Field
label={t('lastName')}
value={data.lastName}
onChange={(v) => patch({ lastName: v })}
required
/>
</div>
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">{t('email')}</span>
{/*
Read-only: changing the address an account signs in with is an
identity change, not a profile edit, and needs its own verified
flow. The API refuses it too — this input simply says so.
*/}
<Input value={data.email} readOnly disabled />
<span className="text-ink-400 text-xs">{t('emailLocked')}</span>
</label>
<Field label={t('phone')} value={data.phone ?? ''} onChange={(v) => patch({ phone: v })} />
<label className="flex items-start gap-2.5">
<input
type="checkbox"
checked={data.acceptsMarketing}
onChange={(event) => patch({ acceptsMarketing: event.target.checked })}
className="border-ink-300 accent-ink-950 mt-0.5 size-4"
/>
<span className="text-ink-600 text-sm">{t('marketing')}</span>
</label>
{formError ? (
<p role="alert" className="text-danger text-sm">
{formError}
</p>
) : null}
{saved ? <p className="text-success text-sm">{t('saved')}</p> : null}
<Button type="submit" disabled={busy}>
{busy ? t('saving') : t('save')}
</Button>
</form>
</div>
);
}
// ---- Wishlist ---------------------------------------------------------------
export function AccountWishlist() {
const t = useTranslations('account');
const locale = useLocale() as Locale;
/**
* Two calls: the account owns the ids, the catalog owns what a product looks
* like. Resolving them here rather than having the account endpoint return
* product cards keeps CustomersModule from depending on ProductsModule for a
* presentation concern.
*/
const { data, error, refresh } = usePanel(
useCallback(async (): Promise<ProductListItem[]> => {
const ids = await browserApi.account.listWishlist();
if (ids.length === 0) return [];
const result = await browserApi.catalog.listProducts(locale, { ids, limit: ids.length });
// Restore the wishlist's own order — the catalog returns its default
// sort, and "most recently saved first" is what matters here.
const byId = new Map(result.items.map((item) => [item.id, item]));
return ids.flatMap((id) => {
const item = byId.get(id);
return item ? [item] : [];
});
}, [locale]),
);
return (
<div className="space-y-6">
<h1 className="text-2xl font-black uppercase tracking-tight">{t('nav.wishlist')}</h1>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
{!data ? (
<Skeleton className="h-32 w-full" />
) : data.length === 0 ? (
<p className="text-ink-500 text-sm">{t('noWishlist')}</p>
) : (
<ul className="divide-ink-100 divide-y border-y">
{data.map((product) => (
<li key={product.id} className="flex items-center gap-4 py-4">
<Heart className="fill-ink-950 text-ink-950 size-4 shrink-0" />
<Link href={routes.product(product.slug)} className="min-w-0 flex-1 hover:underline">
<span className="block truncate text-sm font-medium">{product.name}</span>
{product.brandName ? (
<span className="text-ink-500 block text-xs">{product.brandName}</span>
) : null}
</Link>
<Button
variant="ghost"
size="sm"
onClick={async () => {
await browserApi.account.removeFromWishlist(product.id);
await refresh();
}}
>
{t('remove')}
</Button>
</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,98 @@
'use client';
import { usePathname, useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useEffect, type ReactNode } from 'react';
import { Button, Skeleton, cn } from '@sport/ui';
import { Link } from '@/i18n/navigation';
import { routes } from '@/lib/routes';
import { useCustomer } from './customer-provider';
const NAV = [
{ href: routes.account(), key: 'overview' },
{ href: routes.accountOrders(), key: 'orders' },
{ href: routes.accountAddresses(), key: 'addresses' },
{ href: routes.accountWishlist(), key: 'wishlist' },
{ href: routes.accountProfile(), key: 'profile' },
] as const;
/**
* The signed-in shell around every /account page.
*
* Guards on the client rather than in middleware, because the session lives in
* an httpOnly cookie that only the API can validate — middleware would have to
* call the API on every navigation to learn anything the provider already
* knows. The API re-checks every request regardless, so this is presentation:
* it decides what to *show*, never what is *allowed*.
*/
export function AccountShell({ children }: { children: ReactNode }) {
const t = useTranslations('account');
const { customer, status, signOut } = useCustomer();
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (status !== 'anonymous') return;
// `next` so signing in returns them to the page they asked for, rather
// than dumping everyone on the account overview.
router.replace(`${routes.signIn()}?next=${encodeURIComponent(pathname)}`);
}, [status, router, pathname]);
if (status === 'loading') {
return (
<div className="space-y-4" aria-busy="true">
<Skeleton className="h-8 w-56" />
<Skeleton className="h-64 w-full" />
</div>
);
}
// The redirect above is already in flight; rendering nothing avoids a flash
// of the empty account page on the way out.
if (!customer) return null;
return (
<div className="grid gap-10 lg:grid-cols-[13rem_1fr]">
<aside>
<p className="text-xs font-semibold uppercase tracking-widest">{t('greeting')}</p>
<p className="mt-1 text-lg font-bold">{customer.displayName}</p>
<nav className="mt-6">
<ul className="space-y-0.5">
{NAV.map((item) => {
const active =
item.href === routes.account()
? pathname.endsWith('/account')
: pathname.includes(item.href);
return (
<li key={item.href}>
<Link
href={item.href}
aria-current={active ? 'page' : undefined}
className={cn(
'block px-3 py-2 text-sm transition-colors',
active ? 'bg-ink-950 text-white' : 'text-ink-600 hover:bg-ink-100',
)}
>
{t(`nav.${item.key}`)}
</Link>
</li>
);
})}
</ul>
</nav>
<Button variant="ghost" size="sm" className="mt-6" onClick={() => void signOut()}>
{t('signOut')}
</Button>
</aside>
<div className="min-w-0">{children}</div>
</div>
);
}
@@ -0,0 +1,188 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useState, type FormEvent } from 'react';
import { isApiClientError } from '@sport/api-client';
import { Button, Input } from '@sport/ui';
import { Link } from '@/i18n/navigation';
import { routes } from '@/lib/routes';
import { useCustomer } from './customer-provider';
/**
* Sign in, or create an account.
*
* One component for both because the difference is three extra fields and which
* call it makes — two near-identical forms would drift in validation, error
* handling and layout, and this is the screen where that shows most.
*/
export function AuthForm({ mode }: { mode: 'signIn' | 'register' }) {
const t = useTranslations('auth');
const router = useRouter();
const params = useSearchParams();
const { signIn, register } = useCustomer();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [phone, setPhone] = useState('');
const [acceptsMarketing, setAcceptsMarketing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const isRegister = mode === 'register';
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setError(null);
setBusy(true);
try {
if (isRegister) {
await register({
email,
password,
firstName,
lastName,
phone: phone.trim() || undefined,
acceptsMarketing,
});
} else {
await signIn(email, password);
}
/**
* Return to where they were headed, if anywhere.
*
* Only same-origin paths are honoured: `?next=https://evil.example` would
* otherwise turn the sign-in form into an open redirect that phishing can
* point at.
*/
const next = params.get('next');
const safeNext = next && next.startsWith('/') && !next.startsWith('//') ? next : null;
router.replace(safeNext ?? routes.account());
router.refresh();
} catch (caught) {
setError(isApiClientError(caught) ? caught.message : t('failed'));
} finally {
setBusy(false);
}
}
return (
<div className="mx-auto w-full max-w-sm">
<h1 className="text-3xl font-black uppercase tracking-tight">
{isRegister ? t('registerTitle') : t('signInTitle')}
</h1>
<p className="text-ink-500 mt-2 text-sm">
{isRegister ? t('registerIntro') : t('signInIntro')}
</p>
<form onSubmit={handleSubmit} className="mt-8 space-y-4">
{isRegister ? (
<div className="grid gap-4 sm:grid-cols-2">
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">
{t('firstName')}
</span>
<Input
value={firstName}
onChange={(event) => setFirstName(event.target.value)}
autoComplete="given-name"
required
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">
{t('lastName')}
</span>
<Input
value={lastName}
onChange={(event) => setLastName(event.target.value)}
autoComplete="family-name"
required
/>
</label>
</div>
) : null}
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">{t('email')}</span>
<Input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
autoComplete="email"
required
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">{t('password')}</span>
<Input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
// `new-password` on the register form tells a password manager to
// offer a generated one instead of autofilling the existing login.
autoComplete={isRegister ? 'new-password' : 'current-password'}
required
/>
{isRegister ? <span className="text-ink-400 text-xs">{t('passwordHint')}</span> : null}
</label>
{isRegister ? (
<>
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">
{t('phone')} <span className="text-ink-400 normal-case">{t('optional')}</span>
</span>
<Input
type="tel"
value={phone}
onChange={(event) => setPhone(event.target.value)}
autoComplete="tel"
placeholder="0912345678"
/>
</label>
<label className="flex items-start gap-2.5">
<input
type="checkbox"
checked={acceptsMarketing}
onChange={(event) => setAcceptsMarketing(event.target.checked)}
className="border-ink-300 accent-ink-950 mt-0.5 size-4"
/>
<span className="text-ink-600 text-sm">{t('marketing')}</span>
</label>
</>
) : null}
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
<Button type="submit" disabled={busy} className="w-full">
{busy ? t('working') : isRegister ? t('createAccount') : t('signIn')}
</Button>
</form>
<p className="text-ink-500 mt-6 text-sm">
{isRegister ? t('haveAccount') : t('noAccount')}{' '}
<Link
href={isRegister ? routes.signIn() : routes.register()}
className="text-ink-950 underline underline-offset-2"
>
{isRegister ? t('signIn') : t('createAccount')}
</Link>
</p>
</div>
);
}
@@ -0,0 +1,124 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import type { CurrentUser } from '@sport/types';
import { browserApi, customerTokenStore } from '@/lib/api';
interface CustomerSession {
customer: CurrentUser | null;
/** Distinguishes "not signed in" from "we have not checked yet". */
status: 'loading' | 'authenticated' | 'anonymous';
signIn: (email: string, password: string) => Promise<void>;
register: (input: {
email: string;
password: string;
firstName: string;
lastName: string;
phone?: string;
acceptsMarketing?: boolean;
}) => Promise<void>;
signOut: () => Promise<void>;
}
const CustomerContext = createContext<CustomerSession | null>(null);
/**
* The signed-in shopper, for the whole storefront.
*
* Mounted at the root rather than inside `/account`, because the header, the
* wishlist button on a product card and checkout prefill all need to know who
* is signed in — and none of those live under `/account`.
*
* On mount it attempts one silent refresh. The access token lives only in
* memory, so a page reload always starts with nothing; the httpOnly cookie is
* what makes the session survive, and this is where it gets cashed in.
*/
export function CustomerProvider({ children }: { children: ReactNode }) {
const [customer, setCustomer] = useState<CurrentUser | null>(null);
const [status, setStatus] = useState<'loading' | 'authenticated' | 'anonymous'>('loading');
useEffect(() => {
let cancelled = false;
async function restore() {
try {
const refreshed = await browserApi.auth.refresh();
customerTokenStore.set(refreshed.accessToken);
const me = await browserApi.auth.me();
if (!cancelled) {
setCustomer(me);
setStatus('authenticated');
}
} catch {
// No cookie, or it expired. Being signed out is the normal case for a
// storefront, not an error worth surfacing.
if (!cancelled) {
customerTokenStore.set(null);
setStatus('anonymous');
}
}
}
void restore();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
// Fires when a background refresh finally fails — the session ended while
// the shopper was doing something else.
customerTokenStore.onLost(() => {
setCustomer(null);
setStatus('anonymous');
});
return () => customerTokenStore.onLost(null);
}, []);
const signIn = useCallback(async (email: string, password: string) => {
const result = await browserApi.auth.login({ email, password });
customerTokenStore.set(result.accessToken);
setCustomer(result.user);
setStatus('authenticated');
}, []);
const register = useCallback<CustomerSession['register']>(async (input) => {
const result = await browserApi.auth.register(input);
customerTokenStore.set(result.accessToken);
setCustomer(result.user);
setStatus('authenticated');
}, []);
const signOut = useCallback(async () => {
try {
await browserApi.auth.logout();
} finally {
// Local state is cleared even if the call failed: a shopper who pressed
// "sign out" must end up signed out on this device regardless.
customerTokenStore.set(null);
setCustomer(null);
setStatus('anonymous');
}
}, []);
const value = useMemo<CustomerSession>(
() => ({ customer, status, signIn, register, signOut }),
[customer, status, signIn, register, signOut],
);
return <CustomerContext.Provider value={value}>{children}</CustomerContext.Provider>;
}
export function useCustomer(): CustomerSession {
const context = useContext(CustomerContext);
if (!context) {
throw new Error('useCustomer must be used inside <CustomerProvider>');
}
return context;
}
@@ -0,0 +1,100 @@
'use client';
import { Heart } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { Button, cn } from '@sport/ui';
import { browserApi } from '@/lib/api';
import { routes } from '@/lib/routes';
import { useCustomer } from './customer-provider';
/**
* Save a product for later.
*
* Anonymous shoppers are sent to sign-in with `next` pointing back at the
* product, rather than the button silently doing nothing or the wishlist being
* kept in localStorage. A local wishlist reads as working right up until the
* shopper switches device, which is exactly when they go looking for it.
*/
export function WishlistButton({
productId,
productSlug,
}: {
productId: string;
productSlug: string;
}) {
const t = useTranslations('account');
const router = useRouter();
const { status } = useCustomer();
const [saved, setSaved] = useState(false);
const [busy, setBusy] = useState(false);
// Derived rather than stored: signing out must un-fill the heart, and doing
// that with a setState inside an effect is a cascading render for a value
// that was already knowable during render.
const isSaved = status === 'authenticated' && saved;
useEffect(() => {
if (status !== 'authenticated') return;
let cancelled = false;
browserApi.account
.listWishlist()
.then((ids) => {
if (!cancelled) setSaved(ids.includes(productId));
})
.catch(() => {
// A wishlist that cannot be read is a button that shows "not saved" —
// never an error on a product page.
});
return () => {
cancelled = true;
};
}, [status, productId]);
async function toggle() {
if (status !== 'authenticated') {
router.push(`${routes.signIn()}?next=${encodeURIComponent(routes.product(productSlug))}`);
return;
}
setBusy(true);
// Optimistic: the heart is the feedback, and waiting a round-trip to fill
// it makes the tap feel broken. Reverted below if the call fails.
const next = !isSaved;
setSaved(next);
try {
if (next) {
await browserApi.account.addToWishlist(productId);
} else {
await browserApi.account.removeFromWishlist(productId);
}
} catch {
setSaved(!next);
} finally {
setBusy(false);
}
}
return (
<Button
type="button"
variant="outline"
className="w-full"
disabled={busy}
aria-pressed={isSaved}
onClick={() => void toggle()}
>
<Heart className={cn('size-4', isSaved ? 'fill-ink-950 text-ink-950' : '')} />
{isSaved ? t('savedLabel') : t('saveForLater')}
</Button>
);
}
+63 -1
View File
@@ -34,9 +34,71 @@ export function getServerApi() {
* and then break the moment customer sign-in lands in M8, which is precisely * and then break the moment customer sign-in lands in M8, which is precisely
* the kind of latent inconsistency worth removing now. * the kind of latent inconsistency worth removing now.
*/ */
/**
* In-memory access token.
*
* Module scope rather than React state, for the same reasons as the admin's:
* the API client reads it from inside a `fetch` callback and it must survive
* re-renders. It is never written to localStorage or a readable cookie —
* those outlive the tab and are readable by any script, which is exactly what
* an XSS payload goes looking for. The httpOnly refresh cookie is what
* survives a reload.
*/
let accessToken: string | null = null;
let onSessionLost: (() => void) | null = null;
/**
* The in-flight refresh, if any.
*
* Without it, every request that 401s at the same moment starts its own
* rotation — and because rotation invalidates the previous token, the second
* one is treated as *token reuse* and revokes the whole family. A shopper with
* two tabs open would be signed out for it. One shared promise, one rotation.
*/
let refreshInFlight: Promise<boolean> | null = null;
export const customerTokenStore = {
get: (): string | null => accessToken,
set: (token: string | null): void => {
accessToken = token;
},
onLost: (handler: (() => void) | null): void => {
onSessionLost = handler;
},
};
export const browserApi = createApiClient({ export const browserApi = createApiClient({
baseUrl: '', baseUrl: '',
getAccessToken: () => null, // wired to the customer auth store in M8 getAccessToken: () => customerTokenStore.get(),
/**
* Transparent re-auth: on a 401, rotate once and retry.
*
* Storefront-specific consequence: a shopper whose access token expires
* mid-checkout must not be bounced to a login form. If the rotation itself
* fails they were genuinely signed out, and the provider reacts.
*/
onUnauthorized: () => {
refreshInFlight ??= (async () => {
try {
const refreshed = await browserApi.auth.refresh();
customerTokenStore.set(refreshed.accessToken);
return true;
} catch {
customerTokenStore.set(null);
onSessionLost?.();
return false;
} finally {
// Cleared in a microtask so every caller awaiting this rotation sees
// the same result before a new one can start.
queueMicrotask(() => {
refreshInFlight = null;
});
}
})();
return refreshInFlight;
},
}); });
/** /**
+2
View File
@@ -21,6 +21,8 @@ export const routes = {
checkout: () => '/checkout', checkout: () => '/checkout',
orderConfirmation: () => '/order-confirmation', orderConfirmation: () => '/order-confirmation',
signIn: () => '/login',
register: () => '/register',
account: () => '/account', account: () => '/account',
accountProfile: () => '/account/profile', accountProfile: () => '/account/profile',
accountOrders: () => '/account/orders', accountOrders: () => '/account/orders',
+59 -5
View File
@@ -110,11 +110,45 @@
"policies": "Information" "policies": "Information"
}, },
"account": { "account": {
"title": "Account", "greeting": "Signed in as",
"profile": "Profile", "signOut": "Sign out",
"orders": "Orders", "nav": {
"addresses": "Addresses", "overview": "Overview",
"wishlist": "Wishlist" "orders": "Orders",
"addresses": "Addresses",
"wishlist": "Wishlist",
"profile": "Profile"
},
"loadFailed": "Could not load that.",
"saveFailed": "Could not save that.",
"recentOrders": "Recent orders",
"viewAll": "View all",
"noOrders": "No orders yet.",
"itemCount": "{count, plural, one {# item} other {# items}}",
"noAddresses": "No saved addresses yet.",
"addAddress": "Add address",
"fullName": "Full name",
"phone": "Phone",
"line1": "Street address",
"ward": "Ward",
"district": "District",
"province": "Province / City",
"makeDefault": "Use as my default delivery address",
"default": "Default",
"edit": "Edit",
"remove": "Remove",
"save": "Save",
"saving": "Saving…",
"cancel": "Cancel",
"saved": "Saved.",
"firstName": "First name",
"lastName": "Last name",
"email": "Email",
"emailLocked": "Contact us to change the email on your account.",
"marketing": "Email me about new drops and offers.",
"noWishlist": "Nothing saved yet. Tap the heart on any product.",
"saveForLater": "Save for later",
"savedLabel": "Saved"
}, },
"notFound": { "notFound": {
"code": "404", "code": "404",
@@ -254,5 +288,25 @@
"empty": "Nothing published yet.", "empty": "Nothing published yet.",
"backToJournal": "Back to the journal", "backToJournal": "Back to the journal",
"by": "By {name}" "by": "By {name}"
},
"auth": {
"signInTitle": "Sign in",
"registerTitle": "Create an account",
"signInIntro": "Your orders, addresses and saved products in one place.",
"registerIntro": "Checkout faster, and keep your order history.",
"email": "Email",
"password": "Password",
"firstName": "First name",
"lastName": "Last name",
"phone": "Phone",
"optional": "(optional)",
"passwordHint": "At least 10 characters, with an uppercase letter and a number.",
"marketing": "Email me about new drops and offers.",
"signIn": "Sign in",
"createAccount": "Create account",
"working": "Just a moment…",
"failed": "That did not work. Check your details and try again.",
"noAccount": "No account yet?",
"haveAccount": "Already have an account?"
} }
} }
+59 -5
View File
@@ -110,11 +110,45 @@
"policies": "Thông tin" "policies": "Thông tin"
}, },
"account": { "account": {
"title": "Tài khoản", "greeting": "Đang đăng nhập",
"profile": "Hồ sơ", "signOut": "Đăng xuất",
"orders": "Đơn hàng", "nav": {
"addresses": "Địa chỉ", "overview": "Tổng quan",
"wishlist": "Yêu thích" "orders": "Đơn hàng",
"addresses": "Địa chỉ",
"wishlist": "Yêu thích",
"profile": "Hồ sơ"
},
"loadFailed": "Không tải được dữ liệu.",
"saveFailed": "Không lưu được.",
"recentOrders": "Đơn hàng gần đây",
"viewAll": "Xem tất cả",
"noOrders": "Chưa có đơn hàng nào.",
"itemCount": "{count, plural, other {# sản phẩm}}",
"noAddresses": "Chưa lưu địa chỉ nào.",
"addAddress": "Thêm địa chỉ",
"fullName": "Họ và tên",
"phone": "Số điện thoại",
"line1": "Địa chỉ",
"ward": "Phường / Xã",
"district": "Quận / Huyện",
"province": "Tỉnh / Thành phố",
"makeDefault": "Dùng làm địa chỉ giao hàng mặc định",
"default": "Mặc định",
"edit": "Sửa",
"remove": "Xoá",
"save": "Lưu",
"saving": "Đang lưu…",
"cancel": "Huỷ",
"saved": "Đã lưu.",
"firstName": "Tên",
"lastName": "Họ",
"email": "Email",
"emailLocked": "Liên hệ với chúng tôi để đổi email tài khoản.",
"marketing": "Nhận email về sản phẩm mới và ưu đãi.",
"noWishlist": "Chưa lưu sản phẩm nào. Nhấn vào biểu tượng trái tim.",
"saveForLater": "Lưu để mua sau",
"savedLabel": "Đã lưu"
}, },
"notFound": { "notFound": {
"code": "404", "code": "404",
@@ -254,5 +288,25 @@
"empty": "Chưa có bài viết nào.", "empty": "Chưa có bài viết nào.",
"backToJournal": "Quay lại danh sách bài viết", "backToJournal": "Quay lại danh sách bài viết",
"by": "Bởi {name}" "by": "Bởi {name}"
},
"auth": {
"signInTitle": "Đăng nhập",
"registerTitle": "Tạo tài khoản",
"signInIntro": "Đơn hàng, địa chỉ và sản phẩm đã lưu ở cùng một nơi.",
"registerIntro": "Thanh toán nhanh hơn và lưu lại lịch sử đơn hàng.",
"email": "Email",
"password": "Mật khẩu",
"firstName": "Tên",
"lastName": "Họ",
"phone": "Số điện thoại",
"optional": "(không bắt buộc)",
"passwordHint": "Tối thiểu 10 ký tự, có chữ hoa và chữ số.",
"marketing": "Nhận email về sản phẩm mới và ưu đãi.",
"signIn": "Đăng nhập",
"createAccount": "Tạo tài khoản",
"working": "Vui lòng đợi…",
"failed": "Chưa được. Kiểm tra lại thông tin và thử lần nữa.",
"noAccount": "Chưa có tài khoản?",
"haveAccount": "Đã có tài khoản?"
} }
} }
@@ -0,0 +1,103 @@
# ADR-0023: Accounts adopt guest orders, and never gate checkout
- **Status:** Accepted
- **Date:** 2026-08-13
## Context
Guest checkout has been the only way to buy since M5, and it works: the shopper
types an email, the order carries it, and a capability URL lets them look it up
later. Adding accounts risks two familiar regressions.
The first is gating. It is tempting to make checkout require an account,
because then every order has a customer and the data model is tidy. It also
costs conversions from exactly the people least willing to spend time on a
store they have not bought from before.
The second is amnesia. A shopper who bought twice as a guest and then registers
with the same email has an order history the store already holds and refuses to
show them, because those rows have `customer_id = NULL`. From the outside that
looks like the store lost their orders.
## Decision
**Checkout never requires an account.** `POST /checkout/orders` stays public. A
signed-in shopper's order is _attached_ to their account; a guest's is not.
Same endpoint, same flow, one nullable column different.
**A public route may recognise a token without requiring one.** `AccessTokenGuard`
gained an optional branch: on a `@Public()` route it verifies a bearer token if
one is present and populates `request.actor`, and otherwise proceeds. A missing,
malformed or expired token leaves the request anonymous rather than rejected.
The alternatives were worse. Taking a customer id from the request body is an
account-takeover primitive. Re-verifying the token by hand in the checkout
controller duplicates the guard, and the copy is the one that will rot.
**Registration adopts guest orders by email.** `UPDATE orders SET customer_id =
… WHERE customer_id IS NULL AND lower(email) = lower(…)`. This is safe _only_
at registration, where the password was just set by whoever controls the
address — the same assumption every password-reset flow already rests on. It
deliberately does not run at login, where it would let someone who changed
their account email vacuum up a stranger's orders.
**Every account route is scoped to the session.** There is no
`GET /customers/:id` anywhere. Profile, addresses, wishlist and order history
all read `actor.userId` and take no id from the client, so horizontal privilege
escalation is impossible by construction rather than by remembering an
ownership check in each handler.
**Account routes require the storefront audience.** An admin token is refused
(403), even though it is a valid credential. A back-office token is for
back-office endpoints; letting it act as a customer would blur which surface an
action came from.
**The access token lives in memory only.** Same as the admin (ADR-0015): never
localStorage, never a readable cookie. The httpOnly refresh cookie is what
survives a reload, and one shared in-flight refresh promise stops concurrent
401s from tripping reuse detection and revoking the family.
**The wishlist is keyed on product, not variant.** Saving a jacket means "this
one, later" — not "this one in black, size M". A variant key would kill a saved
item when a colourway is discontinued and list the same jacket five times.
## Consequences
Guest checkout, signed-in checkout, and a checkout carrying a garbage token all
place orders successfully; only the middle one attaches to a customer. That is
three paths through one endpoint, and all three are exercised.
A shopper who registers with an email they previously used as a guest sees
those orders immediately. Verified: order SP-000001, placed as a guest, appeared
in the account's history the moment the account was created.
Email is not editable from the profile. Changing the address an account signs in
with is an identity change needing its own verified flow, and a `PATCH` that
quietly accepted a new email would be an account-takeover primitive of exactly
the kind this ADR is trying to avoid. The field is read-only and says so.
The address book maintains "at most one default" in application code, not with a
partial unique index — an index would reject the intermediate state of a swap.
Deleting the default promotes another, so checkout always has something to
prefill.
Client-side route guarding on `/account` is presentation only: it decides what
to _show_. The API re-checks every request, which is what decides what is
_allowed_. Middleware could not do better without calling the API on every
navigation, because only the API can validate the httpOnly cookie.
## Alternatives considered
**Require an account to check out.** Tidier data, fewer orders. Rejected.
**Merge guest orders at login rather than registration.** Catches the shopper
who registered with a different address, and lets anyone who changes their
account email claim orders that were never theirs. Rejected.
**A localStorage wishlist for anonymous shoppers.** Feels seamless and reads as
working right up until they switch device — which is precisely when they go
looking for the thing they saved. The button sends them to sign in instead.
**Keep the token in localStorage so it survives reloads directly.** Removes the
refresh round-trip on first paint, and hands any XSS a durable credential. The
in-memory + httpOnly split is the same trade the admin already made.
+1
View File
@@ -31,6 +31,7 @@ An ADR is immutable once accepted. If a decision changes, add a new ADR that sup
| [0020](./0020-promotions-and-coupons-are-one-entity.md) | Promotions and coupons are one entity with one engine | Accepted | | [0020](./0020-promotions-and-coupons-are-one-entity.md) | Promotions and coupons are one entity with one engine | Accepted |
| [0021](./0021-a-review-is-anchored-to-an-order-line.md) | A review is anchored to an order line | Accepted | | [0021](./0021-a-review-is-anchored-to-an-order-line.md) | A review is anchored to an order line | Accepted |
| [0022](./0022-editorial-content-is-markdown-not-a-page-builder.md) | Editorial content is Markdown, not a page builder | Accepted | | [0022](./0022-editorial-content-is-markdown-not-a-page-builder.md) | Editorial content is Markdown, not a page builder | Accepted |
| [0023](./0023-accounts-adopt-guest-orders-and-never-gate-checkout.md) | Accounts adopt guest orders, and never gate checkout | Accepted |
## Decisions deliberately NOT recorded yet ## Decisions deliberately NOT recorded yet
+3
View File
@@ -1,4 +1,5 @@
import { HttpClient, type HttpClientOptions } from './http-client'; import { HttpClient, type HttpClientOptions } from './http-client';
import { createAccountResource, type AccountResource } from './resources/account';
import { createAdminResource, type AdminResource } from './resources/admin'; import { createAdminResource, type AdminResource } from './resources/admin';
import { createAuthResource, type AuthResource } from './resources/auth'; import { createAuthResource, type AuthResource } from './resources/auth';
import { createCatalogResource, type CatalogResource } from './resources/catalog'; import { createCatalogResource, type CatalogResource } from './resources/catalog';
@@ -45,6 +46,7 @@ export interface ApiClient {
readonly promotionsAdmin: PromotionsAdminResource; readonly promotionsAdmin: PromotionsAdminResource;
readonly reviews: ReviewsResource; readonly reviews: ReviewsResource;
readonly reviewsAdmin: ReviewsAdminResource; readonly reviewsAdmin: ReviewsAdminResource;
readonly account: AccountResource;
readonly content: ContentResource; readonly content: ContentResource;
readonly contentAdmin: ContentAdminResource; readonly contentAdmin: ContentAdminResource;
} }
@@ -64,6 +66,7 @@ export function createApiClient(options: HttpClientOptions): ApiClient {
promotionsAdmin: createPromotionsAdminResource(http), promotionsAdmin: createPromotionsAdminResource(http),
reviews: createReviewsResource(http), reviews: createReviewsResource(http),
reviewsAdmin: createReviewsAdminResource(http), reviewsAdmin: createReviewsAdminResource(http),
account: createAccountResource(http),
content: createContentResource(http), content: createContentResource(http),
contentAdmin: createContentAdminResource(http), contentAdmin: createContentAdminResource(http),
}; };
+2 -1
View File
@@ -16,7 +16,7 @@ export { HttpClient } from './http-client';
export type { HttpClientOptions, RequestOptions } from './http-client'; export type { HttpClientOptions, RequestOptions } from './http-client';
export { createApiClient } from './create-client'; export { createApiClient } from './create-client';
export type { CatalogResource, ProductListQuery } from './resources/catalog'; export type { CatalogResource, ProductListQuery } from './resources/catalog';
export type { AuthResource, LoginCredentials } from './resources/auth'; export type { AuthResource, LoginCredentials, RegisterPayload } from './resources/auth';
export type { export type {
AdminResource, AdminResource,
CreateUserPayload, CreateUserPayload,
@@ -58,4 +58,5 @@ export type {
ContentTranslationPayload, ContentTranslationPayload,
PostListParams, PostListParams,
} from './resources/content'; } from './resources/content';
export type { AccountResource, AddressPayload, ProfilePayload } from './resources/account';
export type { ApiClient } from './create-client'; export type { ApiClient } from './create-client';
@@ -0,0 +1,89 @@
import type {
CustomerAddress,
CustomerProfile,
OffsetPaginated,
Order,
OrderListItem,
} from '@sport/types';
import type { HttpClient } from '../http-client';
export interface ProfilePayload {
firstName?: string;
lastName?: string;
phone?: string | null;
acceptsMarketing?: boolean;
dateOfBirth?: string | null;
}
export interface AddressPayload {
fullName: string;
phone: string;
line1: string;
line2?: string | null;
ward?: string | null;
district?: string | null;
province: string;
countryCode?: string;
postalCode?: string | null;
isDefaultShipping?: boolean;
isDefaultBilling?: boolean;
}
/**
* The signed-in shopper's own account.
*
* Every method is implicitly scoped to the bearer token — there is no customer
* id parameter anywhere, because the server takes it from the session.
*/
export interface AccountResource {
getProfile(): Promise<CustomerProfile>;
updateProfile(payload: ProfilePayload): Promise<CustomerProfile>;
listAddresses(): Promise<CustomerAddress[]>;
createAddress(payload: AddressPayload): Promise<CustomerAddress>;
updateAddress(id: string, payload: AddressPayload): Promise<CustomerAddress>;
deleteAddress(id: string): Promise<void>;
listOrders(params?: { page?: number; perPage?: number }): Promise<OffsetPaginated<OrderListItem>>;
getOrder(id: string): Promise<Order>;
listWishlist(): Promise<string[]>;
addToWishlist(productId: string): Promise<{ ok: true }>;
removeFromWishlist(productId: string): Promise<{ ok: true }>;
}
export function createAccountResource(http: HttpClient): AccountResource {
// Never cached. Everything here is one person's own data, and a stale
// address book is one that shows a home they moved out of.
const uncached = { cache: 'no-store' } as const;
return {
getProfile: () => http.get<CustomerProfile>('/account/profile', uncached),
updateProfile: (payload) => http.patch<CustomerProfile>('/account/profile', payload, uncached),
listAddresses: () => http.get<CustomerAddress[]>('/account/addresses', uncached),
createAddress: (payload) => http.post<CustomerAddress>('/account/addresses', payload, uncached),
updateAddress: (id, payload) =>
http.patch<CustomerAddress>(
`/account/addresses/${encodeURIComponent(id)}`,
payload,
uncached,
),
deleteAddress: (id) =>
http.delete<void>(`/account/addresses/${encodeURIComponent(id)}`, uncached),
listOrders: (params = {}) =>
http.get<OffsetPaginated<OrderListItem>>('/account/orders', {
...uncached,
query: { ...params },
}),
getOrder: (id) => http.get<Order>(`/account/orders/${encodeURIComponent(id)}`, uncached),
listWishlist: () => http.get<string[]>('/account/wishlist', uncached),
addToWishlist: (productId) =>
http.post<{ ok: true }>('/account/wishlist', { productId }, uncached),
removeFromWishlist: (productId) =>
http.delete<{ ok: true }>(`/account/wishlist/${encodeURIComponent(productId)}`, uncached),
};
}
+12
View File
@@ -12,8 +12,19 @@ export interface LoginCredentials {
* cookies for separate audiences. Exposing them as one method with a flag would * cookies for separate audiences. Exposing them as one method with a flag would
* make it far too easy to point a customer credential at the admin surface. * make it far too easy to point a customer credential at the admin surface.
*/ */
export interface RegisterPayload {
email: string;
password: string;
firstName: string;
lastName: string;
phone?: string;
acceptsMarketing?: boolean;
}
export interface AuthResource { export interface AuthResource {
login(credentials: LoginCredentials): Promise<LoginResult>; login(credentials: LoginCredentials): Promise<LoginResult>;
/** Storefront only — there is deliberately no way to self-register staff. */
register(payload: RegisterPayload): Promise<LoginResult>;
adminLogin(credentials: LoginCredentials): Promise<LoginResult>; adminLogin(credentials: LoginCredentials): Promise<LoginResult>;
refresh(): Promise<RefreshResult>; refresh(): Promise<RefreshResult>;
adminRefresh(): Promise<RefreshResult>; adminRefresh(): Promise<RefreshResult>;
@@ -40,6 +51,7 @@ export function createAuthResource(http: HttpClient): AuthResource {
return { return {
login: (credentials) => http.post<LoginResult>('/auth/login', credentials, uncached), login: (credentials) => http.post<LoginResult>('/auth/login', credentials, uncached),
register: (payload) => http.post<LoginResult>('/auth/register', payload, uncached),
adminLogin: (credentials) => http.post<LoginResult>('/auth/admin/login', credentials, uncached), adminLogin: (credentials) => http.post<LoginResult>('/auth/admin/login', credentials, uncached),
refresh: () => http.post<RefreshResult>('/auth/refresh', undefined, uncached), refresh: () => http.post<RefreshResult>('/auth/refresh', undefined, uncached),
adminRefresh: () => http.post<RefreshResult>('/auth/admin/refresh', undefined, uncached), adminRefresh: () => http.post<RefreshResult>('/auth/admin/refresh', undefined, uncached),
@@ -18,6 +18,7 @@ import type { HttpClient, RequestOptions } from '../http-client';
* for callers, never the enforcement point. * for callers, never the enforcement point.
*/ */
export interface ProductListQuery { export interface ProductListQuery {
ids?: string[];
q?: string; q?: string;
categorySlug?: string; categorySlug?: string;
collectionSlug?: string; collectionSlug?: string;
+28
View File
@@ -0,0 +1,28 @@
import type { Id, Nullable } from '../primitives';
/** The signed-in shopper's own profile. */
export interface CustomerProfile {
readonly id: Id;
readonly email: string;
readonly firstName: string;
readonly lastName: string;
readonly phone: Nullable<string>;
readonly acceptsMarketing: boolean;
/** ISO date, no time — a birthday has no clock. */
readonly dateOfBirth: Nullable<string>;
}
export interface CustomerAddress {
readonly id: Id;
readonly fullName: string;
readonly phone: string;
readonly line1: string;
readonly line2: Nullable<string>;
readonly ward: Nullable<string>;
readonly district: Nullable<string>;
readonly province: string;
readonly countryCode: string;
readonly postalCode: Nullable<string>;
readonly isDefaultShipping: boolean;
readonly isDefaultBilling: boolean;
}
+1
View File
@@ -30,3 +30,4 @@ export * from './commerce/discount';
export * from './commerce/order'; export * from './commerce/order';
export * from './commerce/review'; export * from './commerce/review';
export * from './content/entry'; export * from './content/entry';
export * from './customers/account';
+12
View File
@@ -42,6 +42,18 @@ const csvList = <T extends z.ZodTypeAny>(item: T) =>
*/ */
export const productFilterSchema = cursorPageQuerySchema.extend({ export const productFilterSchema = cursorPageQuerySchema.extend({
q: z.string().trim().max(120).optional(), q: z.string().trim().max(120).optional(),
/**
* Fetch specific products by id.
*
* Exists for callers that already hold a set of ids and need them rendered as
* cards — the wishlist is the first. Safe on a public endpoint because the
* usual visibility rules still apply: an id for an unpublished product simply
* does not come back. Capped so it cannot be used to dump the catalog in one
* request.
*/
ids: csvList(z.uuid()).refine((value) => !value || value.length <= 60, {
message: 'Too many ids',
}),
categorySlug: slugSchema.optional(), categorySlug: slugSchema.optional(),
collectionSlug: slugSchema.optional(), collectionSlug: slugSchema.optional(),
brandSlugs: csvList(slugSchema), brandSlugs: csvList(slugSchema),
+42
View File
@@ -0,0 +1,42 @@
import { z } from 'zod';
import { phoneSchema } from './common';
/**
* The shopper's own profile.
*
* Email is absent on purpose: changing the address an account signs in with is
* an identity change, not a profile edit, and needs its own verified flow. A
* `PATCH /account/profile` that quietly accepted a new email would be an
* account-takeover primitive.
*/
export const updateProfileSchema = z.object({
firstName: z.string().trim().min(1).max(80).optional(),
lastName: z.string().trim().min(1).max(80).optional(),
phone: phoneSchema.nullish(),
acceptsMarketing: z.boolean().optional(),
dateOfBirth: z.iso.date().nullish(),
});
export const customerAddressSchema = z.object({
fullName: z.string().trim().min(1).max(160),
phone: phoneSchema,
line1: z.string().trim().min(1).max(255),
line2: z.string().trim().max(255).nullish(),
ward: z.string().trim().max(120).nullish(),
district: z.string().trim().max(120).nullish(),
province: z.string().trim().min(1).max(120),
countryCode: z.string().trim().length(2).default('VN'),
postalCode: z.string().trim().max(20).nullish(),
isDefaultShipping: z.boolean().default(false),
isDefaultBilling: z.boolean().default(false),
});
/** Email is proof of nothing here — the endpoint is authenticated. */
export const wishlistItemSchema = z.object({
productId: z.uuid(),
});
export type UpdateProfileInput = z.output<typeof updateProfileSchema>;
export type CustomerAddressInput = z.output<typeof customerAddressSchema>;
export type WishlistItemInput = z.output<typeof wishlistItemSchema>;
+1
View File
@@ -19,3 +19,4 @@ export * from './users';
export * from './catalog-admin'; export * from './catalog-admin';
export * from './commerce'; export * from './commerce';
export * from './content'; export * from './content';
export * from './customers';