Wip stage M4

This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:22 +07:00
parent 5386bc51d1
commit d5cacc1208
113 changed files with 9486 additions and 919 deletions
+3
View File
@@ -2,6 +2,7 @@ import { HttpClient, type HttpClientOptions } from './http-client';
import { createAdminResource, type AdminResource } from './resources/admin';
import { createAuthResource, type AuthResource } from './resources/auth';
import { createCatalogResource, type CatalogResource } from './resources/catalog';
import { createCatalogAdminResource, type CatalogAdminResource } from './resources/catalog-admin';
import { createHealthResource, type HealthResource } from './resources/health';
/**
@@ -16,6 +17,7 @@ export interface ApiClient {
readonly catalog: CatalogResource;
readonly auth: AuthResource;
readonly admin: AdminResource;
readonly catalogAdmin: CatalogAdminResource;
}
export function createApiClient(options: HttpClientOptions): ApiClient {
@@ -27,5 +29,6 @@ export function createApiClient(options: HttpClientOptions): ApiClient {
catalog: createCatalogResource(http),
auth: createAuthResource(http),
admin: createAdminResource(http),
catalogAdmin: createCatalogAdminResource(http),
};
}
+6
View File
@@ -24,4 +24,10 @@ export type {
UpdateUserPayload,
UserListParams,
} from './resources/admin';
export type {
AdminProductListParams,
CatalogAdminResource,
StockAdjustment,
VariantPatch,
} from './resources/catalog-admin';
export type { ApiClient } from './create-client';
@@ -0,0 +1,209 @@
import type {
AdminProductDetail,
AdminProductListItem,
InventoryLevel,
MediaAssetSummary,
OffsetPaginated,
PresignedUploadTarget,
StockMovementEntry,
} from '@sport/types';
import { ApiClientError } from '../errors';
import type { HttpClient } from '../http-client';
export interface AdminProductListParams {
page?: number;
perPage?: number;
q?: string;
status?: 'DRAFT' | 'ACTIVE' | 'ARCHIVED';
brandId?: string;
}
export interface VariantPatch {
id: string;
sku?: string;
priceAmount?: number;
salePriceAmount?: number | null;
compareAtAmount?: number | null;
costAmount?: number | null;
weightGrams?: number | null;
status?: 'ACTIVE' | 'ARCHIVED';
}
export interface StockAdjustment {
variantId: string;
reason:
| 'PURCHASE_RECEIPT'
| 'RETURN'
| 'MANUAL_ADJUSTMENT'
| 'STOCK_TAKE'
| 'TRANSFER_IN'
| 'TRANSFER_OUT'
| 'DAMAGE';
quantityDelta?: number;
countedQuantity?: number;
note?: string | null;
}
export interface CatalogAdminResource {
listProducts(params?: AdminProductListParams): Promise<OffsetPaginated<AdminProductListItem>>;
getProduct(id: string): Promise<AdminProductDetail>;
createProduct(payload: unknown): Promise<AdminProductDetail>;
updateProduct(id: string, payload: unknown): Promise<AdminProductDetail>;
updateVariants(id: string, variants: VariantPatch[]): Promise<AdminProductDetail>;
setImages(
id: string,
images: { mediaId: string; position: number; optionValueId?: string | null }[],
): Promise<AdminProductDetail>;
setStatus(id: string, status: 'DRAFT' | 'ACTIVE' | 'ARCHIVED'): Promise<AdminProductDetail>;
listMedia(params?: {
page?: number;
perPage?: number;
q?: string;
}): Promise<OffsetPaginated<MediaAssetSummary>>;
deleteMedia(id: string): Promise<void>;
/** Presign → PUT to storage → register. See `uploadFile`. */
uploadFile(file: File, prefix?: string): Promise<MediaAssetSummary>;
listInventory(params?: {
page?: number;
perPage?: number;
q?: string;
lowStockOnly?: boolean;
}): Promise<OffsetPaginated<InventoryLevel>>;
adjustStock(payload: StockAdjustment): Promise<InventoryLevel>;
movements(variantId: string): Promise<StockMovementEntry[]>;
}
export function createCatalogAdminResource(http: HttpClient): CatalogAdminResource {
const uncached = { cache: 'no-store' } as const;
return {
listProducts: (params = {}) =>
http.get<OffsetPaginated<AdminProductListItem>>('/admin/products', {
...uncached,
query: { ...params },
}),
getProduct: (id) =>
http.get<AdminProductDetail>(`/admin/products/${encodeURIComponent(id)}`, uncached),
createProduct: (payload) => http.post<AdminProductDetail>('/admin/products', payload, uncached),
updateProduct: (id, payload) =>
http.patch<AdminProductDetail>(
`/admin/products/${encodeURIComponent(id)}`,
payload,
uncached,
),
updateVariants: (id, variants) =>
http.put<AdminProductDetail>(
`/admin/products/${encodeURIComponent(id)}/variants`,
{ variants },
uncached,
),
setImages: (id, images) =>
http.put<AdminProductDetail>(
`/admin/products/${encodeURIComponent(id)}/images`,
{ images },
uncached,
),
setStatus: (id, status) =>
http.post<AdminProductDetail>(
`/admin/products/${encodeURIComponent(id)}/status`,
{ status },
uncached,
),
listMedia: (params = {}) =>
http.get<OffsetPaginated<MediaAssetSummary>>('/admin/media', {
...uncached,
query: { ...params },
}),
deleteMedia: (id) => http.delete<void>(`/admin/media/${encodeURIComponent(id)}`, uncached),
/**
* Three steps, deliberately: ask for a target, PUT the bytes straight to
* object storage, then tell the API the asset exists.
*
* The middle step is a bare `fetch` rather than an api-client call — it
* targets the storage host, not the API, so it must not carry our auth
* header, our envelope handling or our base URL.
*/
uploadFile: async (file, prefix = 'products') => {
const target = await http.post<PresignedUploadTarget>(
'/admin/media/presign',
{
filename: file.name,
mimeType: file.type,
sizeBytes: file.size,
prefix,
},
uncached,
);
const upload = await globalThis.fetch(target.uploadUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type },
// No cookies to the storage host — the presigned URL is the credential.
credentials: 'omit',
});
if (!upload.ok) {
throw new ApiClientError({
code: 'UPLOAD_REJECTED',
message: `Storage rejected the upload (${upload.status}).`,
status: upload.status,
});
}
const dimensions = await readImageSize(file);
return http.post<MediaAssetSummary>(
'/admin/media',
{
storageKey: target.storageKey,
mimeType: file.type,
sizeBytes: file.size,
width: dimensions?.width ?? null,
height: dimensions?.height ?? null,
altText: file.name.replace(/\.[^.]+$/, ''),
},
uncached,
);
},
listInventory: (params = {}) =>
http.get<OffsetPaginated<InventoryLevel>>('/admin/inventory', {
...uncached,
query: { ...params },
}),
adjustStock: (payload) =>
http.post<InventoryLevel>('/admin/inventory/adjust', payload, uncached),
movements: (variantId) =>
http.get<StockMovementEntry[]>(
`/admin/inventory/movements/${encodeURIComponent(variantId)}`,
uncached,
),
};
}
/**
* Reads intrinsic dimensions in the browser so the API does not have to decode
* the image. Resolves null on anything that is not a decodable image — a
* missing width is a cosmetic loss (no `<Image>` aspect ratio), never a failed
* upload.
*/
async function readImageSize(file: File): Promise<{ width: number; height: number } | null> {
if (!file.type.startsWith('image/') || typeof createImageBitmap !== 'function') {
return null;
}
try {
const bitmap = await createImageBitmap(file);
const size = { width: bitmap.width, height: bitmap.height };
bitmap.close();
return size;
} catch {
return null;
}
}