Stage M8
This commit is contained in:
@@ -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