Stage M8
This commit is contained in:
@@ -158,7 +158,7 @@ sport-store/
|
||||
│
|
||||
├── docs/
|
||||
│ ├── 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
|
||||
├── 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 |
|
||||
| **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 |
|
||||
| **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 |
|
||||
|
||||
**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
|
||||
ledger. What it still cannot do is take money — which is the one gap between this and a shop that
|
||||
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 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
|
||||
- **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
|
||||
@@ -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
|
||||
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
|
||||
- **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
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
@@ -213,6 +213,7 @@ model Customer {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
addresses Address[]
|
||||
orders Order[]
|
||||
wishlist WishlistItem[]
|
||||
|
||||
@@map("customers")
|
||||
}
|
||||
@@ -525,6 +526,7 @@ model Product {
|
||||
|
||||
searchDocuments SearchDocument[]
|
||||
reviews Review[]
|
||||
wishlistedBy WishlistItem[]
|
||||
discounts DiscountProduct[]
|
||||
brand Brand? @relation(fields: [brandId], references: [id], onDelete: SetNull)
|
||||
primaryCategory Category? @relation(fields: [primaryCategoryId], references: [id], onDelete: SetNull)
|
||||
@@ -1346,3 +1348,26 @@ model ContentEntryTranslation {
|
||||
@@unique([locale, slug])
|
||||
@@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,7 +21,12 @@ import {
|
||||
type SessionSummary,
|
||||
type TokenAudience,
|
||||
} 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 { Public } from '@/common/decorators/public.decorator';
|
||||
@@ -61,6 +66,21 @@ export class AuthController {
|
||||
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()
|
||||
@Post('admin/login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Global, Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
import { CustomersModule } from '@/modules/customers/customers.module';
|
||||
import { UsersModule } from '@/modules/users/users.module';
|
||||
|
||||
import { AuthController } from './auth.controller';
|
||||
@@ -24,7 +25,7 @@ import { TokenService } from './token.service';
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [JwtModule.register({}), UsersModule],
|
||||
imports: [JwtModule.register({}), UsersModule, CustomersModule],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
API_ERROR_CODES,
|
||||
SYSTEM_ROLES,
|
||||
TOKEN_AUDIENCES,
|
||||
isBackOfficeUser,
|
||||
type CurrentUser,
|
||||
@@ -11,10 +12,11 @@ import {
|
||||
type TokenAudience,
|
||||
type UserType,
|
||||
} from '@sport/types';
|
||||
import type { LoginInput } from '@sport/validation';
|
||||
import type { LoginInput, RegisterInput } from '@sport/validation';
|
||||
|
||||
import { AppException } from '@/common/errors/app.exception';
|
||||
import { PasswordService } from '@/common/security/password.service';
|
||||
import { CustomersService } from '@/modules/customers/public';
|
||||
import { UsersService, type AuthUserRow } from '@/modules/users/public';
|
||||
|
||||
import { AuthRepository } from './auth.repository';
|
||||
@@ -43,6 +45,7 @@ export class AuthService {
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly repository: AuthRepository,
|
||||
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
|
||||
* 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(
|
||||
input: LoginInput,
|
||||
audience: TokenAudience,
|
||||
|
||||
@@ -38,13 +38,32 @@ export class AccessTokenGuard implements CanActivate {
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<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) {
|
||||
throw AppException.unauthenticated();
|
||||
}
|
||||
@@ -74,17 +93,31 @@ export class AccessTokenGuard implements CanActivate {
|
||||
throw AppException.forbidden('This credential cannot be used here.');
|
||||
}
|
||||
|
||||
const actor: AuthenticatedActor = {
|
||||
request.actor = toActor(claims);
|
||||
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,
|
||||
};
|
||||
|
||||
request.actor = actor;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function extractBearerToken(request: Request): string | null {
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
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 { 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 type { AppConfig } from '@/config/configuration';
|
||||
import { clearCartCookie, resolveCartToken, setCartCookie } from '@/modules/carts/public';
|
||||
import { CustomersService } from '@/modules/customers/public';
|
||||
import { OrdersService } from '@/modules/orders/public';
|
||||
|
||||
import { CheckoutService } from './checkout.service';
|
||||
|
||||
/**
|
||||
* Public because guest checkout is the default. Customer accounts arrive in M8
|
||||
* and will attach an order to a customer, not gate the ability to place one.
|
||||
* Public because guest checkout is the default.
|
||||
*
|
||||
* 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')
|
||||
@Public()
|
||||
@@ -38,6 +42,7 @@ export class CheckoutController {
|
||||
constructor(
|
||||
private readonly service: CheckoutService,
|
||||
private readonly orders: OrdersService,
|
||||
private readonly customers: CustomersService,
|
||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||
) {}
|
||||
|
||||
@@ -81,7 +86,21 @@ export class CheckoutController {
|
||||
}
|
||||
|
||||
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
|
||||
// next visit reads an empty cart under a stale token forever.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CartsModule } from '@/modules/carts/carts.module';
|
||||
import { CustomersModule } from '@/modules/customers/customers.module';
|
||||
import { OrdersModule } from '@/modules/orders/orders.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.
|
||||
*/
|
||||
@Module({
|
||||
imports: [CartsModule, OrdersModule, PromotionsModule],
|
||||
imports: [CartsModule, OrdersModule, PromotionsModule, CustomersModule],
|
||||
controllers: [CheckoutController],
|
||||
providers: [CheckoutService],
|
||||
exports: [CheckoutService],
|
||||
|
||||
@@ -70,6 +70,14 @@ export class CheckoutService {
|
||||
idempotencyKey: string,
|
||||
input: PlaceOrderInput,
|
||||
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> {
|
||||
const key = CACHE_KEYS.idempotency('checkout', idempotencyKey);
|
||||
const claimed = await this.redis.setIfAbsent(key, {}, CACHE_TTL.idempotency);
|
||||
@@ -86,7 +94,7 @@ export class CheckoutService {
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.place(cartToken, key, input, locale);
|
||||
return await this.place(cartToken, key, input, locale, customerId);
|
||||
} catch (error) {
|
||||
// Release, so the shopper can fix whatever went wrong and try again.
|
||||
await this.redis.delete(key);
|
||||
@@ -99,6 +107,7 @@ export class CheckoutService {
|
||||
idempotencyCacheKey: string,
|
||||
input: PlaceOrderInput,
|
||||
locale: Locale,
|
||||
customerId: string | null,
|
||||
): Promise<Order> {
|
||||
const cart = await this.carts.resolveForCheckout(cartToken, locale);
|
||||
|
||||
@@ -166,6 +175,7 @@ export class CheckoutService {
|
||||
*/
|
||||
const order = await tx.order.create({
|
||||
data: {
|
||||
customerId,
|
||||
email: input.email,
|
||||
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 { 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.
|
||||
*
|
||||
* 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
|
||||
* Exported for AuthModule, which provisions a customer record during
|
||||
* registration — the only place another module writes here.
|
||||
*/
|
||||
@Module({})
|
||||
@Module({
|
||||
controllers: [CustomersController],
|
||||
providers: [CustomersService, CustomersRepository],
|
||||
exports: [CustomersService],
|
||||
})
|
||||
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.
|
||||
*
|
||||
* This barrel is the ONLY thing other modules may import from here. Everything
|
||||
* else — repository, DTOs, internal services — is private, and the ESLint
|
||||
* boundary rule in @sport/eslint-config/nest enforces it.
|
||||
*
|
||||
* Keep it narrow: each export is a promise to the rest of the codebase.
|
||||
* AuthModule uses `provision()` during registration; CheckoutModule reads the
|
||||
* default shipping address to prefill. Everything else stays private.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CustomersModule } from '@/modules/customers/customers.module';
|
||||
import { PromotionsModule } from '@/modules/promotions/promotions.module';
|
||||
|
||||
import { MyOrdersController } from './my-orders.controller';
|
||||
import { OrdersAdminController } from './orders.controller';
|
||||
import { OrdersMapper } from './orders.mapper';
|
||||
import { OrdersRepository } from './orders.repository';
|
||||
@@ -18,8 +20,8 @@ import { OrdersService } from './orders.service';
|
||||
* EXTRACTION CANDIDATE.
|
||||
*/
|
||||
@Module({
|
||||
imports: [PromotionsModule],
|
||||
controllers: [OrdersAdminController],
|
||||
imports: [PromotionsModule, CustomersModule],
|
||||
controllers: [OrdersAdminController, MyOrdersController],
|
||||
providers: [OrdersService, OrdersRepository, OrdersMapper],
|
||||
exports: [OrdersService],
|
||||
})
|
||||
|
||||
@@ -18,6 +18,9 @@ const lineSelect = {
|
||||
const detailSelect = {
|
||||
id: 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,
|
||||
paymentStatus: true,
|
||||
fulfillmentStatus: true,
|
||||
|
||||
@@ -66,6 +66,46 @@ export class OrdersService {
|
||||
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>> {
|
||||
const where: Prisma.OrderWhereInput = {
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
|
||||
@@ -131,6 +131,56 @@ export class UsersService {
|
||||
* granted access that was never granted — the worst possible failure mode for
|
||||
* 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> {
|
||||
if (roleIds.length === 0) return;
|
||||
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import type { Metadata } from 'next';
|
||||
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 }> };
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'placeholder.addresses' });
|
||||
return { title: t('title') };
|
||||
const t = await getTranslations({ locale, namespace: 'account' });
|
||||
// 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;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations('placeholder.addresses');
|
||||
|
||||
return (
|
||||
<PageScaffold title={t('title')} description={t('body')} milestone="M8 — customer account" />
|
||||
);
|
||||
return <AccountAddresses />;
|
||||
}
|
||||
|
||||
@@ -1,62 +1,11 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import type { Locale } from '@sport/types';
|
||||
|
||||
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') },
|
||||
];
|
||||
import { AccountShell } from '@/features/account/account-shell';
|
||||
|
||||
export default function AccountLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<SiteHeader navigation={navigation} locale={locale as Locale} />
|
||||
<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 className="max-w-page px-gutter mx-auto py-12">
|
||||
<AccountShell>{children}</AccountShell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import type { Metadata } from 'next';
|
||||
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 }> };
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'placeholder.orders' });
|
||||
return { title: t('title') };
|
||||
const t = await getTranslations({ locale, namespace: 'account' });
|
||||
// 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;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations('placeholder.orders');
|
||||
|
||||
return (
|
||||
<PageScaffold title={t('title')} description={t('body')} milestone="M8 — customer account" />
|
||||
);
|
||||
return <AccountOrders />;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import type { Metadata } from 'next';
|
||||
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 }> };
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'placeholder.account' });
|
||||
return { title: t('title') };
|
||||
const t = await getTranslations({ locale, namespace: 'account' });
|
||||
// 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;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations('placeholder.account');
|
||||
|
||||
return (
|
||||
<PageScaffold title={t('title')} description={t('body')} milestone="M8 — customer account" />
|
||||
);
|
||||
return <AccountOverview />;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import type { Metadata } from 'next';
|
||||
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 }> };
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'placeholder.profile' });
|
||||
return { title: t('title') };
|
||||
const t = await getTranslations({ locale, namespace: 'account' });
|
||||
// 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;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations('placeholder.profile');
|
||||
|
||||
return (
|
||||
<PageScaffold title={t('title')} description={t('body')} milestone="M8 — customer account" />
|
||||
);
|
||||
return <AccountProfile />;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import type { Metadata } from 'next';
|
||||
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 }> };
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'placeholder.wishlist' });
|
||||
return { title: t('title') };
|
||||
const t = await getTranslations({ locale, namespace: 'account' });
|
||||
// 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;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations('placeholder.wishlist');
|
||||
|
||||
return (
|
||||
<PageScaffold title={t('title')} description={t('body')} milestone="M8 — customer account" />
|
||||
);
|
||||
return <AccountWishlist />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import { LOCALE_TAGS, type Locale } from '@sport/types';
|
||||
|
||||
import { CustomerProvider } from '@/features/account/customer-provider';
|
||||
import { CartProvider } from '@/features/cart/cart-provider';
|
||||
import { routing } from '@/i18n/routing';
|
||||
|
||||
@@ -70,7 +71,9 @@ export default async function LocaleLayout({
|
||||
<NextIntlClientProvider>
|
||||
{/* Above both route groups: the bag survives moving from browsing
|
||||
into checkout, which is the one transition it must not lose. */}
|
||||
<CustomerProvider>
|
||||
<CartProvider locale={locale as Locale}>{children}</CartProvider>
|
||||
</CustomerProvider>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
import { Loader2, Lock } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useFormatter, useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { isApiClientError } from '@sport/api-client';
|
||||
import type { Locale } from '@sport/types';
|
||||
import { Button, Input } from '@sport/ui';
|
||||
|
||||
import { useCustomer } from '@/features/account/customer-provider';
|
||||
import { useCart } from '@/features/cart/cart-provider';
|
||||
import { Link, useRouter } from '@/i18n/navigation';
|
||||
import { browserApi } from '@/lib/api';
|
||||
@@ -54,6 +55,55 @@ export function CheckoutForm({ locale }: { locale: Locale }) {
|
||||
const { cart, loading, refresh } = useCart();
|
||||
|
||||
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 [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 { cn } from '@sport/ui';
|
||||
|
||||
import { WishlistButton } from '@/features/account/wishlist-button';
|
||||
import { discountPercent, formatMoney } from '@/lib/format';
|
||||
|
||||
import { AddToBag } from './add-to-bag';
|
||||
@@ -203,6 +204,8 @@ export function ProductDetail({ product }: { product: StorefrontProduct }) {
|
||||
|
||||
<AddToBag variant={selectedVariant} />
|
||||
|
||||
<WishlistButton productId={product.id} productSlug={product.slug} />
|
||||
|
||||
{selectedVariant ? (
|
||||
<p className="text-ink-400 text-center text-xs">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -34,9 +34,71 @@ export function getServerApi() {
|
||||
* and then break the moment customer sign-in lands in M8, which is precisely
|
||||
* 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({
|
||||
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;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,6 +21,8 @@ export const routes = {
|
||||
checkout: () => '/checkout',
|
||||
orderConfirmation: () => '/order-confirmation',
|
||||
|
||||
signIn: () => '/login',
|
||||
register: () => '/register',
|
||||
account: () => '/account',
|
||||
accountProfile: () => '/account/profile',
|
||||
accountOrders: () => '/account/orders',
|
||||
|
||||
@@ -110,11 +110,45 @@
|
||||
"policies": "Information"
|
||||
},
|
||||
"account": {
|
||||
"title": "Account",
|
||||
"profile": "Profile",
|
||||
"greeting": "Signed in as",
|
||||
"signOut": "Sign out",
|
||||
"nav": {
|
||||
"overview": "Overview",
|
||||
"orders": "Orders",
|
||||
"addresses": "Addresses",
|
||||
"wishlist": "Wishlist"
|
||||
"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": {
|
||||
"code": "404",
|
||||
@@ -254,5 +288,25 @@
|
||||
"empty": "Nothing published yet.",
|
||||
"backToJournal": "Back to the journal",
|
||||
"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?"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,11 +110,45 @@
|
||||
"policies": "Thông tin"
|
||||
},
|
||||
"account": {
|
||||
"title": "Tài khoản",
|
||||
"profile": "Hồ sơ",
|
||||
"greeting": "Đang đăng nhập",
|
||||
"signOut": "Đăng xuất",
|
||||
"nav": {
|
||||
"overview": "Tổng quan",
|
||||
"orders": "Đơn hàng",
|
||||
"addresses": "Địa chỉ",
|
||||
"wishlist": "Yêu thí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": {
|
||||
"code": "404",
|
||||
@@ -254,5 +288,25 @@
|
||||
"empty": "Chưa có bài viết nào.",
|
||||
"backToJournal": "Quay lại danh sách bài viết",
|
||||
"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.
|
||||
@@ -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 |
|
||||
| [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 |
|
||||
| [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
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { HttpClient, type HttpClientOptions } from './http-client';
|
||||
import { createAccountResource, type AccountResource } from './resources/account';
|
||||
import { createAdminResource, type AdminResource } from './resources/admin';
|
||||
import { createAuthResource, type AuthResource } from './resources/auth';
|
||||
import { createCatalogResource, type CatalogResource } from './resources/catalog';
|
||||
@@ -45,6 +46,7 @@ export interface ApiClient {
|
||||
readonly promotionsAdmin: PromotionsAdminResource;
|
||||
readonly reviews: ReviewsResource;
|
||||
readonly reviewsAdmin: ReviewsAdminResource;
|
||||
readonly account: AccountResource;
|
||||
readonly content: ContentResource;
|
||||
readonly contentAdmin: ContentAdminResource;
|
||||
}
|
||||
@@ -64,6 +66,7 @@ export function createApiClient(options: HttpClientOptions): ApiClient {
|
||||
promotionsAdmin: createPromotionsAdminResource(http),
|
||||
reviews: createReviewsResource(http),
|
||||
reviewsAdmin: createReviewsAdminResource(http),
|
||||
account: createAccountResource(http),
|
||||
content: createContentResource(http),
|
||||
contentAdmin: createContentAdminResource(http),
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ export { HttpClient } from './http-client';
|
||||
export type { HttpClientOptions, RequestOptions } from './http-client';
|
||||
export { createApiClient } from './create-client';
|
||||
export type { CatalogResource, ProductListQuery } from './resources/catalog';
|
||||
export type { AuthResource, LoginCredentials } from './resources/auth';
|
||||
export type { AuthResource, LoginCredentials, RegisterPayload } from './resources/auth';
|
||||
export type {
|
||||
AdminResource,
|
||||
CreateUserPayload,
|
||||
@@ -58,4 +58,5 @@ export type {
|
||||
ContentTranslationPayload,
|
||||
PostListParams,
|
||||
} from './resources/content';
|
||||
export type { AccountResource, AddressPayload, ProfilePayload } from './resources/account';
|
||||
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,8 +12,19 @@ export interface LoginCredentials {
|
||||
* 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.
|
||||
*/
|
||||
export interface RegisterPayload {
|
||||
email: string;
|
||||
password: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone?: string;
|
||||
acceptsMarketing?: boolean;
|
||||
}
|
||||
|
||||
export interface AuthResource {
|
||||
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>;
|
||||
refresh(): Promise<RefreshResult>;
|
||||
adminRefresh(): Promise<RefreshResult>;
|
||||
@@ -40,6 +51,7 @@ export function createAuthResource(http: HttpClient): AuthResource {
|
||||
|
||||
return {
|
||||
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),
|
||||
refresh: () => http.post<RefreshResult>('/auth/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.
|
||||
*/
|
||||
export interface ProductListQuery {
|
||||
ids?: string[];
|
||||
q?: string;
|
||||
categorySlug?: string;
|
||||
collectionSlug?: string;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -30,3 +30,4 @@ export * from './commerce/discount';
|
||||
export * from './commerce/order';
|
||||
export * from './commerce/review';
|
||||
export * from './content/entry';
|
||||
export * from './customers/account';
|
||||
|
||||
@@ -42,6 +42,18 @@ const csvList = <T extends z.ZodTypeAny>(item: T) =>
|
||||
*/
|
||||
export const productFilterSchema = cursorPageQuerySchema.extend({
|
||||
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(),
|
||||
collectionSlug: slugSchema.optional(),
|
||||
brandSlugs: csvList(slugSchema),
|
||||
|
||||
@@ -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>;
|
||||
@@ -19,3 +19,4 @@ export * from './users';
|
||||
export * from './catalog-admin';
|
||||
export * from './commerce';
|
||||
export * from './content';
|
||||
export * from './customers';
|
||||
|
||||
Reference in New Issue
Block a user