Stage M7
This commit is contained in:
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user