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