This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:23 +07:00
parent 1e356a2578
commit d9f159a8c3
84 changed files with 7992 additions and 180 deletions
+26
View File
@@ -9,7 +9,23 @@ import {
type CommerceResource,
type OrdersAdminResource,
} from './resources/commerce';
import {
createContentAdminResource,
createContentResource,
type ContentAdminResource,
type ContentResource,
} from './resources/content';
import { createHealthResource, type HealthResource } from './resources/health';
import {
createPromotionsAdminResource,
type PromotionsAdminResource,
} from './resources/promotions-admin';
import {
createReviewsAdminResource,
createReviewsResource,
type ReviewsAdminResource,
type ReviewsResource,
} from './resources/reviews';
/**
* Resource modules are added here as the backend grows — one file per bounded
@@ -26,6 +42,11 @@ export interface ApiClient {
readonly catalogAdmin: CatalogAdminResource;
readonly commerce: CommerceResource;
readonly ordersAdmin: OrdersAdminResource;
readonly promotionsAdmin: PromotionsAdminResource;
readonly reviews: ReviewsResource;
readonly reviewsAdmin: ReviewsAdminResource;
readonly content: ContentResource;
readonly contentAdmin: ContentAdminResource;
}
export function createApiClient(options: HttpClientOptions): ApiClient {
@@ -40,5 +61,10 @@ export function createApiClient(options: HttpClientOptions): ApiClient {
catalogAdmin: createCatalogAdminResource(http),
commerce: createCommerceResource(http),
ordersAdmin: createOrdersAdminResource(http),
promotionsAdmin: createPromotionsAdminResource(http),
reviews: createReviewsResource(http),
reviewsAdmin: createReviewsAdminResource(http),
content: createContentResource(http),
contentAdmin: createContentAdminResource(http),
};
}
+22
View File
@@ -36,4 +36,26 @@ export type {
OrdersAdminResource,
PlaceOrderInput,
} from './resources/commerce';
export type {
DiscountListParams,
DiscountPayload,
PromotionsAdminResource,
} from './resources/promotions-admin';
export type {
AdminReviewListParams,
ProductReviews,
ReviewCredentials,
ReviewListParams,
ReviewsAdminResource,
ReviewsResource,
SubmitReviewPayload,
} from './resources/reviews';
export type {
AdminContentListParams,
ContentAdminResource,
ContentEntryPayload,
ContentResource,
ContentTranslationPayload,
PostListParams,
} from './resources/content';
export type { ApiClient } from './create-client';
@@ -52,6 +52,8 @@ export interface CommerceResource {
options?: RequestOptions,
): Promise<Cart>;
removeCartLine(locale: Locale, variantId: string, options?: RequestOptions): Promise<Cart>;
applyDiscountCode(locale: Locale, code: string, options?: RequestOptions): Promise<Cart>;
removeDiscountCode(locale: Locale, code: string, options?: RequestOptions): Promise<Cart>;
getCheckoutQuote(locale: Locale, options?: RequestOptions): Promise<Cart>;
/**
@@ -119,6 +121,15 @@ export function createCommerceResource(http: HttpClient): CommerceResource {
cartOptions(locale, options),
),
applyDiscountCode: (locale, code, options) =>
http.post<Cart>('/cart/discounts', { code }, cartOptions(locale, options)),
removeDiscountCode: (locale, code, options) =>
http.delete<Cart>(
`/cart/discounts/${encodeURIComponent(code)}`,
cartOptions(locale, options),
),
getCheckoutQuote: (locale, options) =>
http.get<Cart>('/checkout/quote', cartOptions(locale, options)),
@@ -0,0 +1,116 @@
import type {
AdminContentEntry,
ContentDetail,
ContentStatus,
ContentSummary,
ContentType,
Locale,
OffsetPaginated,
} from '@sport/types';
import type { HttpClient, RequestOptions } from '../http-client';
export interface PostListParams {
page?: number;
perPage?: number;
}
export interface AdminContentListParams {
page?: number;
perPage?: number;
type?: ContentType;
status?: ContentStatus;
q?: string;
}
export interface ContentTranslationPayload {
slug: string;
title: string;
excerpt?: string | null;
body: string;
metaTitle?: string | null;
metaDescription?: string | null;
}
export interface ContentEntryPayload {
type: ContentType;
status?: ContentStatus;
coverImageId?: string | null;
translations: Record<string, ContentTranslationPayload>;
}
/** Editorial content: pages and posts. Published entries only. */
export interface ContentResource {
listPosts(
locale: Locale,
params?: PostListParams,
options?: RequestOptions,
): Promise<OffsetPaginated<ContentSummary>>;
getPost(locale: Locale, slug: string, options?: RequestOptions): Promise<ContentDetail>;
getPage(locale: Locale, slug: string, options?: RequestOptions): Promise<ContentDetail>;
listPostSlugs(
locale: Locale,
options?: RequestOptions,
): Promise<{ slug: string; title: string; updatedAt: string }[]>;
listPageSlugs(
locale: Locale,
options?: RequestOptions,
): Promise<{ slug: string; title: string; updatedAt: string }[]>;
}
export interface ContentAdminResource {
list(params?: AdminContentListParams): Promise<OffsetPaginated<AdminContentEntry>>;
getById(id: string): Promise<AdminContentEntry>;
create(payload: ContentEntryPayload): Promise<AdminContentEntry>;
update(id: string, payload: ContentEntryPayload): Promise<AdminContentEntry>;
remove(id: string): Promise<void>;
}
export function createContentResource(http: HttpClient): ContentResource {
return {
listPosts: (locale, params = {}, options = {}) =>
http.get<OffsetPaginated<ContentSummary>>('/content/posts', {
...options,
query: { locale, ...params },
}),
getPost: (locale, slug, options = {}) =>
http.get<ContentDetail>(`/content/posts/${encodeURIComponent(slug)}`, {
...options,
query: { locale },
}),
getPage: (locale, slug, options = {}) =>
http.get<ContentDetail>(`/content/pages/${encodeURIComponent(slug)}`, {
...options,
query: { locale },
}),
listPostSlugs: (locale, options = {}) =>
http.get<{ slug: string; title: string; updatedAt: string }[]>('/content/posts/slugs', {
...options,
query: { locale },
}),
listPageSlugs: (locale, options = {}) =>
http.get<{ slug: string; title: string; updatedAt: string }[]>('/content/pages/slugs', {
...options,
query: { locale },
}),
};
}
export function createContentAdminResource(http: HttpClient): ContentAdminResource {
// Never cached: an editor who just saved a draft must see the draft.
const uncached = { cache: 'no-store' } as const;
return {
list: (params = {}) =>
http.get<OffsetPaginated<AdminContentEntry>>('/admin/content', {
...uncached,
query: { ...params },
}),
getById: (id) =>
http.get<AdminContentEntry>(`/admin/content/${encodeURIComponent(id)}`, uncached),
create: (payload) => http.post<AdminContentEntry>('/admin/content', payload, uncached),
update: (id, payload) =>
http.patch<AdminContentEntry>(`/admin/content/${encodeURIComponent(id)}`, payload, uncached),
remove: (id) => http.delete<void>(`/admin/content/${encodeURIComponent(id)}`, uncached),
};
}
@@ -0,0 +1,70 @@
import type { AdminDiscount, OffsetPaginated } from '@sport/types';
import type { HttpClient } from '../http-client';
export interface DiscountListParams {
page?: number;
perPage?: number;
q?: string;
trigger?: 'AUTOMATIC' | 'CODE';
}
/**
* A discount as the admin form submits it.
*
* Deliberately not `DiscountInput` from `@sport/validation`: that is the type
* *after* parsing, with defaults already applied, so using it here would force
* every caller to supply fields the schema is perfectly happy to fill in.
*/
export interface DiscountPayload {
code?: string | null;
trigger: 'AUTOMATIC' | 'CODE';
type: 'PERCENTAGE' | 'FIXED_AMOUNT';
scope?: 'ORDER' | 'PRODUCT';
value: number;
translations: Record<string, { name: string; description?: string | null }>;
minSubtotalAmount?: number | null;
startsAt?: string | null;
endsAt?: string | null;
isActive?: boolean;
usageLimit?: number | null;
stackable?: boolean;
priority?: number;
productIds?: string[];
collectionIds?: string[];
}
/**
* Promotions and coupons, which are one resource.
*
* There is no `coupons` resource next to this one, and that is the point: a
* coupon is a promotion that needs a code typed (ADR-0020). Two client
* resources would reintroduce the split the data model exists to avoid.
*/
export interface PromotionsAdminResource {
list(params?: DiscountListParams): Promise<OffsetPaginated<AdminDiscount>>;
getById(id: string): Promise<AdminDiscount>;
create(payload: DiscountPayload): Promise<AdminDiscount>;
update(id: string, payload: DiscountPayload): Promise<AdminDiscount>;
remove(id: string): Promise<void>;
}
export function createPromotionsAdminResource(http: HttpClient): PromotionsAdminResource {
// Never cached. An operator who just switched a promotion off must see it
// off — a stale list here is a discount they believe they stopped giving.
const uncached = { cache: 'no-store' } as const;
return {
list: (params = {}) =>
http.get<OffsetPaginated<AdminDiscount>>('/admin/discounts', {
...uncached,
query: { ...params },
}),
getById: (id) =>
http.get<AdminDiscount>(`/admin/discounts/${encodeURIComponent(id)}`, uncached),
create: (payload) => http.post<AdminDiscount>('/admin/discounts', payload, uncached),
update: (id, payload) =>
http.patch<AdminDiscount>(`/admin/discounts/${encodeURIComponent(id)}`, payload, uncached),
remove: (id) => http.delete<void>(`/admin/discounts/${encodeURIComponent(id)}`, uncached),
};
}
@@ -0,0 +1,108 @@
import type {
AdminReview,
Locale,
OffsetPaginated,
ReviewSummary,
ReviewableItem,
StorefrontReview,
} from '@sport/types';
import type { HttpClient, RequestOptions } from '../http-client';
export interface ReviewListParams {
page?: number;
perPage?: number;
sort?: 'newest' | 'rating_desc' | 'rating_asc';
}
export type ProductReviews = OffsetPaginated<StorefrontReview> & { summary: ReviewSummary };
/** Proof of purchase: the order id from the confirmation URL plus its email. */
export interface ReviewCredentials {
orderId: string;
email: string;
}
export interface SubmitReviewPayload extends ReviewCredentials {
orderLineId: string;
rating: number;
title?: string | null;
body?: string | null;
authorName: string;
}
export interface AdminReviewListParams {
page?: number;
perPage?: number;
status?: 'PENDING' | 'APPROVED' | 'REJECTED';
q?: string;
}
export interface ReviewsResource {
/** Approved reviews for a product, with the summary the header needs. */
listForProduct(
productId: string,
params?: ReviewListParams,
options?: RequestOptions,
): Promise<ProductReviews>;
/**
* What an order entitles its buyer to review.
*
* A POST because the email is proof of ownership — in a query string it would
* land in server logs, browser history, and the Referer header sent with
* every image on the page.
*/
listReviewable(
credentials: ReviewCredentials,
locale?: Locale,
): Promise<readonly ReviewableItem[]>;
submit(payload: SubmitReviewPayload, locale?: Locale): Promise<readonly ReviewableItem[]>;
}
export interface ReviewsAdminResource {
list(params?: AdminReviewListParams): Promise<OffsetPaginated<AdminReview>>;
moderate(
id: string,
decision: { status: 'APPROVED' | 'REJECTED'; note?: string | null },
): Promise<AdminReview>;
}
export function createReviewsResource(http: HttpClient): ReviewsResource {
return {
listForProduct: (productId, params = {}, options = {}) =>
http.get<ProductReviews>(`/reviews/product/${encodeURIComponent(productId)}`, {
...options,
query: { ...params },
}),
listReviewable: (credentials, locale) =>
http.post<readonly ReviewableItem[]>('/reviews/reviewable', credentials, {
cache: 'no-store',
query: locale ? { locale } : undefined,
}),
submit: (payload, locale) =>
http.post<readonly ReviewableItem[]>('/reviews', payload, {
cache: 'no-store',
query: locale ? { locale } : undefined,
}),
};
}
export function createReviewsAdminResource(http: HttpClient): ReviewsAdminResource {
// A moderation queue must never be served from cache: two operators working
// the same queue would otherwise both see, and both action, the same review.
const uncached = { cache: 'no-store' } as const;
return {
list: (params = {}) =>
http.get<OffsetPaginated<AdminReview>>('/admin/reviews', {
...uncached,
query: { ...params },
}),
moderate: (id, decision) =>
http.patch<AdminReview>(`/admin/reviews/${encodeURIComponent(id)}`, decision, uncached),
};
}