This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:23 +07:00
parent d9f159a8c3
commit cda5d21d0d
53 changed files with 2711 additions and 162 deletions
@@ -0,0 +1,23 @@
-- NOTE: Prisma's diff wanted to DROP INDEX "search_documents_document_idx" and
-- ALTER "search_documents"."document" DROP DEFAULT here. Removed on purpose —
-- see the M6 migration: `document` is a GENERATED tsvector column Prisma cannot
-- express, so it proposes undoing it in every migration. Applying it destroys
-- full-text search.
-- CreateTable
CREATE TABLE "wishlist_items" (
"customer_id" UUID NOT NULL,
"product_id" UUID NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "wishlist_items_pkey" PRIMARY KEY ("customer_id","product_id")
);
-- CreateIndex
CREATE INDEX "wishlist_items_customer_id_created_at_idx" ON "wishlist_items"("customer_id", "created_at");
-- AddForeignKey
ALTER TABLE "wishlist_items" ADD CONSTRAINT "wishlist_items_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wishlist_items" ADD CONSTRAINT "wishlist_items_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+26 -1
View File
@@ -210,9 +210,10 @@ model Customer {
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
addresses Address[]
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 -1
View File
@@ -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 -1
View File
@@ -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,
+67 -1
View File
@@ -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 = {
userId: claims.sub,
userType: claims.type,
audience: claims.aud,
permissions: claims.permissions ?? [],
sessionId: claims.sid,
};
request.actor = actor;
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,
};
}
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;
}
}
+4 -2
View File
@@ -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>
);
}
+4 -1
View File
@@ -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. */}
<CartProvider locale={locale as Locale}>{children}</CartProvider>
<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>
);
}
+63 -1
View File
@@ -34,9 +34,71 @@ export function getServerApi() {
* and then break the moment customer sign-in lands in M8, which is precisely
* 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;
},
});
/**
+2
View File
@@ -21,6 +21,8 @@ export const routes = {
checkout: () => '/checkout',
orderConfirmation: () => '/order-confirmation',
signIn: () => '/login',
register: () => '/register',
account: () => '/account',
accountProfile: () => '/account/profile',
accountOrders: () => '/account/orders',
+59 -5
View File
@@ -110,11 +110,45 @@
"policies": "Information"
},
"account": {
"title": "Account",
"profile": "Profile",
"orders": "Orders",
"addresses": "Addresses",
"wishlist": "Wishlist"
"greeting": "Signed in as",
"signOut": "Sign out",
"nav": {
"overview": "Overview",
"orders": "Orders",
"addresses": "Addresses",
"wishlist": "Wishlist",
"profile": "Profile"
},
"loadFailed": "Could not load that.",
"saveFailed": "Could not save that.",
"recentOrders": "Recent orders",
"viewAll": "View all",
"noOrders": "No orders yet.",
"itemCount": "{count, plural, one {# item} other {# items}}",
"noAddresses": "No saved addresses yet.",
"addAddress": "Add address",
"fullName": "Full name",
"phone": "Phone",
"line1": "Street address",
"ward": "Ward",
"district": "District",
"province": "Province / City",
"makeDefault": "Use as my default delivery address",
"default": "Default",
"edit": "Edit",
"remove": "Remove",
"save": "Save",
"saving": "Saving…",
"cancel": "Cancel",
"saved": "Saved.",
"firstName": "First name",
"lastName": "Last name",
"email": "Email",
"emailLocked": "Contact us to change the email on your account.",
"marketing": "Email me about new drops and offers.",
"noWishlist": "Nothing saved yet. Tap the heart on any product.",
"saveForLater": "Save for later",
"savedLabel": "Saved"
},
"notFound": {
"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?"
}
}
+59 -5
View File
@@ -110,11 +110,45 @@
"policies": "Thông tin"
},
"account": {
"title": "Tài khoản",
"profile": "Hồ sơ",
"orders": "Đơn hàng",
"addresses": "Địa chỉ",
"wishlist": "Yêu thích"
"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",
"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?"
}
}