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;
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,84 @@
|
||||
--animate-rise: rise 0.5s var(--ease-out-quint) both;
|
||||
}
|
||||
|
||||
/**
|
||||
* ---- shadcn/ui semantic layer --------------------------------------------
|
||||
*
|
||||
* shadcn components are written against role tokens (`bg-background`,
|
||||
* `text-muted-foreground`, `border-input`, `ring-ring`) rather than against a
|
||||
* palette. This block is the adapter: it gives those roles values drawn from
|
||||
* the ink/volt scales above, so an unmodified component pasted from the
|
||||
* registry already looks like this store.
|
||||
*
|
||||
* ONE TRAP WORTH NAMING: shadcn's `--accent` is not a brand accent. It is the
|
||||
* subtle surface behind a hovered ghost button or a highlighted menu row. It
|
||||
* maps to ink-100. The brand accent stays `--color-volt-*` and is applied
|
||||
* deliberately — mapping volt here would turn every hover state neon.
|
||||
*
|
||||
* The store is committed to a light palette (`color-scheme: light`), so there
|
||||
* is no `.dark` block. shadcn's `dark:` utilities simply never match, which is
|
||||
* harmless and keeps the registry components paste-able unedited.
|
||||
*/
|
||||
:root {
|
||||
--background: var(--color-white);
|
||||
--foreground: var(--color-ink-950);
|
||||
|
||||
--card: var(--color-white);
|
||||
--card-foreground: var(--color-ink-950);
|
||||
--popover: var(--color-white);
|
||||
--popover-foreground: var(--color-ink-950);
|
||||
|
||||
--primary: var(--color-ink-950);
|
||||
--primary-foreground: var(--color-white);
|
||||
--secondary: var(--color-ink-100);
|
||||
--secondary-foreground: var(--color-ink-950);
|
||||
|
||||
--muted: var(--color-ink-100);
|
||||
--muted-foreground: var(--color-ink-500);
|
||||
|
||||
/* Hover/active surface — see the trap noted above. */
|
||||
--accent: var(--color-ink-100);
|
||||
--accent-foreground: var(--color-ink-950);
|
||||
|
||||
--destructive: var(--color-danger);
|
||||
--destructive-foreground: var(--color-white);
|
||||
|
||||
--border: var(--color-ink-200);
|
||||
--input: var(--color-ink-200);
|
||||
--ring: var(--color-ink-950);
|
||||
|
||||
/* Sport/streetwear geometry: near-square. Raising this rounds every
|
||||
registry component at once, which is the point of keeping it here. */
|
||||
--radius: 0.25rem;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
--radius-sm: calc(var(--radius) - 2px);
|
||||
--radius-md: var(--radius);
|
||||
--radius-lg: calc(var(--radius) + 2px);
|
||||
--radius-xl: calc(var(--radius) + 6px);
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { Locale } from '../i18n/locale';
|
||||
import type { Id, IsoDateTime, Money, Nullable, Slug } from '../primitives';
|
||||
|
||||
import type { MediaKind } from './media';
|
||||
import type { GenderTarget, ProductStatus, SportType } from './product';
|
||||
import type { VariantStatus } from './variant';
|
||||
|
||||
/**
|
||||
* Admin-facing catalog shapes.
|
||||
*
|
||||
* Deliberately separate from the storefront types. The storefront gets one
|
||||
* resolved locale and no cost price; the admin needs *every* locale at once and
|
||||
* the commercially sensitive fields. Serving one shape to both would mean
|
||||
* either leaking margin to shoppers or hiding it from merchandisers.
|
||||
*/
|
||||
|
||||
/** Per-locale content for one entity, keyed by locale. */
|
||||
export type TranslationMap<T> = Readonly<Partial<Record<Locale, T>>>;
|
||||
|
||||
export interface ProductTranslationFields {
|
||||
readonly name: string;
|
||||
readonly slug: Slug;
|
||||
readonly shortDescription: Nullable<string>;
|
||||
readonly description: Nullable<string>;
|
||||
readonly metaTitle: Nullable<string>;
|
||||
readonly metaDescription: Nullable<string>;
|
||||
}
|
||||
|
||||
/** Row in the admin product table. */
|
||||
export interface AdminProductListItem {
|
||||
readonly id: Id;
|
||||
readonly name: string;
|
||||
readonly slug: Slug;
|
||||
readonly status: ProductStatus;
|
||||
readonly brandName: Nullable<string>;
|
||||
readonly thumbnailUrl: Nullable<string>;
|
||||
readonly variantCount: number;
|
||||
/** Sum of available stock across every active variant. */
|
||||
readonly totalStock: number;
|
||||
readonly priceRange: Nullable<{ readonly min: Money; readonly max: Money }>;
|
||||
readonly isOnSale: boolean;
|
||||
readonly publishedAt: Nullable<IsoDateTime>;
|
||||
readonly updatedAt: IsoDateTime;
|
||||
}
|
||||
|
||||
/** Everything the product editor needs, in every locale. */
|
||||
export interface AdminProductDetail {
|
||||
readonly id: Id;
|
||||
readonly status: ProductStatus;
|
||||
readonly publishedAt: Nullable<IsoDateTime>;
|
||||
readonly brandId: Nullable<Id>;
|
||||
readonly primaryCategoryId: Nullable<Id>;
|
||||
readonly genderTargets: readonly GenderTarget[];
|
||||
readonly sportTypes: readonly SportType[];
|
||||
readonly collectionIds: readonly Id[];
|
||||
readonly translations: TranslationMap<ProductTranslationFields>;
|
||||
readonly options: readonly AdminProductOption[];
|
||||
readonly variants: readonly AdminProductVariant[];
|
||||
readonly images: readonly AdminProductImage[];
|
||||
readonly attributes: readonly AdminProductAttribute[];
|
||||
readonly createdAt: IsoDateTime;
|
||||
readonly updatedAt: IsoDateTime;
|
||||
}
|
||||
|
||||
export interface AdminProductOption {
|
||||
readonly id: Id;
|
||||
readonly key: string;
|
||||
readonly position: number;
|
||||
readonly names: TranslationMap<string>;
|
||||
readonly values: readonly AdminProductOptionValue[];
|
||||
}
|
||||
|
||||
export interface AdminProductOptionValue {
|
||||
readonly id: Id;
|
||||
readonly value: string;
|
||||
readonly position: number;
|
||||
readonly swatchHex: Nullable<string>;
|
||||
readonly labels: TranslationMap<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of the variant matrix.
|
||||
*
|
||||
* `optionValueIds` is the identity of the row — the combination it represents.
|
||||
* Everything else is editable per row, which is the entire point of the model
|
||||
* (ADR-0003): stock and price are per combination, not per product.
|
||||
*/
|
||||
export interface AdminProductVariant {
|
||||
readonly id: Id;
|
||||
readonly sku: string;
|
||||
readonly barcode: Nullable<string>;
|
||||
readonly title: string;
|
||||
readonly optionValueIds: readonly Id[];
|
||||
readonly priceAmount: number;
|
||||
readonly salePriceAmount: Nullable<number>;
|
||||
readonly compareAtAmount: Nullable<number>;
|
||||
readonly costAmount: Nullable<number>;
|
||||
readonly weightGrams: Nullable<number>;
|
||||
readonly status: VariantStatus;
|
||||
readonly position: number;
|
||||
/** Aggregated across locations; per-location detail lives in inventory. */
|
||||
readonly onHand: number;
|
||||
readonly reserved: number;
|
||||
readonly available: number;
|
||||
}
|
||||
|
||||
export interface AdminProductImage {
|
||||
readonly id: Id;
|
||||
readonly mediaId: Id;
|
||||
readonly url: string;
|
||||
readonly altText: Nullable<string>;
|
||||
readonly position: number;
|
||||
readonly optionValueId: Nullable<Id>;
|
||||
}
|
||||
|
||||
export interface AdminProductAttribute {
|
||||
readonly id: Id;
|
||||
readonly key: string;
|
||||
readonly position: number;
|
||||
readonly translations: TranslationMap<{ readonly label: string; readonly value: string }>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Media
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MediaAssetSummary {
|
||||
readonly id: Id;
|
||||
readonly kind: MediaKind;
|
||||
readonly url: string;
|
||||
readonly storageKey: string;
|
||||
readonly mimeType: string;
|
||||
readonly sizeBytes: number;
|
||||
readonly width: Nullable<number>;
|
||||
readonly height: Nullable<number>;
|
||||
readonly altText: Nullable<string>;
|
||||
readonly createdAt: IsoDateTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* The browser uploads straight to object storage with this, then calls back to
|
||||
* register the asset. Bytes never pass through the API — no memory pressure, no
|
||||
* request timeouts on a large file, and no need to scale the API for bandwidth.
|
||||
*/
|
||||
export interface PresignedUploadTarget {
|
||||
readonly uploadUrl: string;
|
||||
readonly storageKey: string;
|
||||
readonly publicUrl: string;
|
||||
readonly expiresInSeconds: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inventory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface InventoryLevel {
|
||||
readonly variantId: Id;
|
||||
readonly sku: string;
|
||||
readonly variantTitle: string;
|
||||
readonly productName: string;
|
||||
readonly locationId: Id;
|
||||
readonly locationName: string;
|
||||
readonly onHand: number;
|
||||
readonly reserved: number;
|
||||
readonly available: number;
|
||||
readonly updatedAt: IsoDateTime;
|
||||
}
|
||||
|
||||
export interface StockMovementEntry {
|
||||
readonly id: Id;
|
||||
readonly variantId: Id;
|
||||
readonly sku: string;
|
||||
readonly quantityDelta: number;
|
||||
readonly reason: string;
|
||||
readonly note: Nullable<string>;
|
||||
readonly createdByName: Nullable<string>;
|
||||
readonly createdAt: IsoDateTime;
|
||||
}
|
||||
@@ -21,5 +21,6 @@ export * from './catalog/taxonomy';
|
||||
export * from './catalog/media';
|
||||
export * from './catalog/navigation';
|
||||
export * from './catalog/facets';
|
||||
export * from './catalog/admin';
|
||||
export * from './i18n/locale';
|
||||
export * from './inventory/stock';
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "../config/tailwind/theme.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -14,6 +14,8 @@
|
||||
"dependencies": {
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "1.31.0",
|
||||
"radix-ui": "1.6.7",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { Slot } from 'radix-ui';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
/**
|
||||
* Status and merchandising labels.
|
||||
*
|
||||
* Kept on the raw ink/volt/semantic scales rather than the shadcn role tokens:
|
||||
* a sale badge is a brand decision, not a surface role, and mapping it through
|
||||
* `--primary` would make it change meaning the next time that token moves.
|
||||
*/
|
||||
const badgeVariants = cva(
|
||||
'inline-flex w-fit shrink-0 items-center justify-center gap-1 px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-widest ' +
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none ' +
|
||||
"[&>svg]:pointer-events-none [&>svg:not([class*='size-'])]:size-3",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
neutral: 'bg-ink-100 text-ink-700',
|
||||
solid: 'bg-ink-950 text-white',
|
||||
sale: 'bg-sale text-white',
|
||||
new: 'bg-volt-500 text-ink-950',
|
||||
success: 'bg-success text-white',
|
||||
warning: 'bg-warning text-ink-950',
|
||||
outline: 'border border-ink-300 text-ink-700',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'neutral' },
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : 'span';
|
||||
|
||||
return (
|
||||
<Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,110 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { Slot } from 'radix-ui';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
/**
|
||||
* The shadcn Button, tuned to this store's design language.
|
||||
*
|
||||
* Three deliberate departures from the registry default, each for a reason:
|
||||
*
|
||||
* - **Square, not rounded.** The sport/streetwear direction is hard-edged.
|
||||
* `shape` exists so a pill can be asked for explicitly rather than becoming
|
||||
* the default everywhere.
|
||||
* - **Uppercase with tracking on the larger sizes.** This is the single
|
||||
* strongest brand signal in the button and it belongs in the component, not
|
||||
* repeated as `className` at ninety call sites.
|
||||
* - **An `accent` variant on volt.** The registry has no equivalent, and volt
|
||||
* is reserved for the one action that matters on a screen.
|
||||
*
|
||||
* Everything else — the variant names, `asChild`, the `data-slot` attributes,
|
||||
* the focus ring on `--ring` — is left exactly as the registry writes it, so
|
||||
* anything pasted from shadcn keeps working.
|
||||
*/
|
||||
const buttonVariants = cva(
|
||||
'inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap font-medium transition-all outline-none ' +
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] ' +
|
||||
'disabled:pointer-events-none disabled:opacity-40 ' +
|
||||
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 ' +
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
/**
|
||||
* Transparent, not `bg-background`.
|
||||
*
|
||||
* The registry assumes an outline button sits on a light surface and
|
||||
* gives it a white fill. Here they sit on the black hero as often as on
|
||||
* a white page, and a white fill turned the label invisible. Letting the
|
||||
* surface show through is what "outline" should mean.
|
||||
*/
|
||||
outline: 'border border-current bg-transparent hover:bg-foreground hover:text-background',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-foreground underline-offset-4 hover:underline',
|
||||
destructive: 'bg-destructive text-white hover:bg-destructive/90',
|
||||
/** Volt. One per screen — the primary conversion action. */
|
||||
accent: 'bg-volt-500 text-ink-950 hover:bg-volt-400',
|
||||
},
|
||||
size: {
|
||||
default: 'h-11 px-6 text-sm uppercase tracking-wide has-[>svg]:px-5',
|
||||
sm: 'h-9 px-4 text-xs uppercase tracking-wide has-[>svg]:px-3',
|
||||
lg: 'h-14 px-8 text-sm uppercase tracking-widest has-[>svg]:px-6',
|
||||
/** Body-copy sizing — for inline text actions where caps would shout. */
|
||||
inline: 'h-auto p-0 text-sm normal-case tracking-normal',
|
||||
icon: 'size-11',
|
||||
'icon-sm': 'size-9',
|
||||
},
|
||||
shape: {
|
||||
square: 'rounded-none',
|
||||
rounded: 'rounded-md',
|
||||
pill: 'rounded-pill',
|
||||
},
|
||||
fullWidth: {
|
||||
true: 'w-full',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
shape: 'square',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
shape = 'square',
|
||||
fullWidth,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
/**
|
||||
* Renders the child element instead of a `<button>`, forwarding styles and
|
||||
* behaviour onto it.
|
||||
*
|
||||
* This is what a Button wrapping a `<Link>` must use. `<Link><Button>`
|
||||
* produces `<a><button>`, which is invalid HTML and gives screen readers
|
||||
* two nested controls to announce.
|
||||
*/
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : 'button';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, shape, fullWidth, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,145 @@
|
||||
'use client';
|
||||
|
||||
import { XIcon } from 'lucide-react';
|
||||
import { Dialog as DialogPrimitive } from 'radix-ui';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
import { Button } from './button';
|
||||
|
||||
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
'bg-background data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg outline-none duration-200 sm:max-w-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="rounded-xs ring-offset-background focus:ring-ring focus:outline-hidden data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn('text-lg font-semibold leading-none', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
@@ -0,0 +1,228 @@
|
||||
'use client';
|
||||
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'max-h-(--radix-dropdown-menu-content-available-height) origin-(--radix-dropdown-menu-content-transform-origin) bg-popover text-popover-foreground data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border p-1 shadow-md',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: 'default' | 'destructive';
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"outline-hidden focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive! relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"outline-hidden focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"outline-hidden focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[inset]:pl-8 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
'origin-(--radix-dropdown-menu-content-transform-origin) bg-popover text-popover-foreground data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
/**
|
||||
* The shadcn Input at this store's proportions: taller (h-11, comfortable on
|
||||
* touch), square, and on a solid background rather than transparent.
|
||||
*
|
||||
* The focus treatment is the registry's and is the reason to adopt it — the
|
||||
* previous hand-rolled input set `focus:outline-none` and signalled focus with
|
||||
* a border tint alone, which is close to invisible against `--border`. This
|
||||
* draws a real 3px ring on `--ring`.
|
||||
*/
|
||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
'border-input bg-background text-foreground h-11 w-full min-w-0 border px-4 py-1 text-sm outline-none transition-[color,box-shadow]',
|
||||
'selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground',
|
||||
'file:text-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium',
|
||||
'disabled:bg-muted disabled:cursor-not-allowed disabled:opacity-60',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-invalid:ring-[3px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import { XIcon } from 'lucide-react';
|
||||
import { Dialog as SheetPrimitive } from 'radix-ui';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||
}
|
||||
|
||||
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||
}
|
||||
|
||||
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = 'right',
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
'bg-background data-[state=closed]:animate-out data-[state=open]:animate-in fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
side === 'right' &&
|
||||
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
|
||||
side === 'left' &&
|
||||
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
|
||||
side === 'top' &&
|
||||
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
||||
side === 'bottom' &&
|
||||
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close className="rounded-xs ring-offset-background focus:ring-ring focus:outline-hidden data-[state=open]:bg-secondary absolute right-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn('flex flex-col gap-1.5 p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn('text-foreground font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div data-slot="skeleton" className={cn('bg-accent animate-pulse', className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { Tabs as TabsPrimitive } from 'radix-ui';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = 'horizontal',
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn('group/tabs flex gap-2 data-[orientation=horizontal]:flex-col', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
'group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-muted',
|
||||
line: 'gap-1 bg-transparent',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> & VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"text-foreground/60 hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-2 py-1 text-sm font-medium transition-all focus-visible:outline-1 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
'group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent',
|
||||
'data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground',
|
||||
'after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn('flex-1 outline-none', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
|
||||
+78
-20
@@ -1,27 +1,85 @@
|
||||
/**
|
||||
* @sport/ui — the design system.
|
||||
* @sport/ui — the shared component layer.
|
||||
*
|
||||
* WHAT BELONGS HERE
|
||||
* Presentational primitives that are identical in the storefront and the
|
||||
* admin: buttons, inputs, badges, skeletons, typography, layout helpers.
|
||||
* Two tiers, and the split is the whole point:
|
||||
*
|
||||
* WHAT MUST NEVER BE HERE
|
||||
* - Data fetching or any @sport/api-client import.
|
||||
* - App state (cart, auth session, filters).
|
||||
* - Domain components. A `<ProductCard>` knows about pricing, sale badges and
|
||||
* variant swatches — that is storefront business UI and lives in
|
||||
* `apps/storefront/src/features/product/`. Sharing it would couple two apps
|
||||
* that must be free to diverge.
|
||||
* `src/components/ui/` Infrastructure. Dialog, Sheet, Popover, Dropdown,
|
||||
* Tabs, Button, Input. These are shadcn/ui components,
|
||||
* owned as source in this repo. They carry no domain
|
||||
* knowledge, look the same in the storefront and the
|
||||
* admin, and are the things nobody should be rebuilding.
|
||||
*
|
||||
* NOT HERE Anything that carries the brand. Product cards,
|
||||
* headers, heroes, mega menus, the PDP — those are
|
||||
* built by hand in `apps/storefront/src/components/
|
||||
* commerce/`, because they are the product, not
|
||||
* plumbing. A `<ProductCard>` knows about sale badges,
|
||||
* price ranges and colourways; sharing it would couple
|
||||
* two apps that must stay free to diverge.
|
||||
*
|
||||
* Also never here: data fetching, `@sport/api-client`, app state (cart, auth,
|
||||
* filters).
|
||||
*
|
||||
* The test: if the component would be meaningless in the admin dashboard, it
|
||||
* does not go in this package.
|
||||
* does not belong in this package.
|
||||
*
|
||||
* ---
|
||||
* Adding a component from the registry:
|
||||
*
|
||||
* pnpm dlx shadcn@latest add <name> --cwd packages/ui
|
||||
*
|
||||
* then rewrite its `@/lib/utils` and `@/components/ui/*` imports to relative
|
||||
* paths (a workspace package cannot rely on an app's tsconfig aliases) and
|
||||
* re-export it below.
|
||||
*/
|
||||
|
||||
export { cn } from './lib/cn';
|
||||
export { Button, buttonVariants } from './primitives/button';
|
||||
export type { ButtonProps } from './primitives/button';
|
||||
export { Badge, badgeVariants } from './primitives/badge';
|
||||
export type { BadgeProps } from './primitives/badge';
|
||||
export { Input } from './primitives/input';
|
||||
export type { InputProps } from './primitives/input';
|
||||
export { Skeleton } from './primitives/skeleton';
|
||||
export { cn } from './lib/utils';
|
||||
|
||||
export { Button, buttonVariants } from './components/ui/button';
|
||||
export { Badge, badgeVariants } from './components/ui/badge';
|
||||
export { Input } from './components/ui/input';
|
||||
export { Skeleton } from './components/ui/skeleton';
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from './components/ui/dialog';
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from './components/ui/sheet';
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from './components/ui/dropdown-menu';
|
||||
|
||||
export { Tabs, TabsContent, TabsList, TabsTrigger, tabsListVariants } from './components/ui/tabs';
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
/** Conditional classes + last-wins conflict resolution for Tailwind utilities. */
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
/**
|
||||
* Conditional classes + last-wins conflict resolution for Tailwind utilities.
|
||||
*
|
||||
* Lives at `@/lib/utils` because that is the path every component in the
|
||||
* shadcn registry imports. Keeping it here means a component copied from the
|
||||
* registry compiles unedited.
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { cn } from '../lib/cn';
|
||||
|
||||
export const badgeVariants = cva(
|
||||
'inline-flex items-center gap-1 px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-widest',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
neutral: 'bg-ink-100 text-ink-700',
|
||||
solid: 'bg-ink-950 text-white',
|
||||
sale: 'bg-sale text-white',
|
||||
new: 'bg-volt-500 text-ink-950',
|
||||
success: 'bg-success text-white',
|
||||
warning: 'bg-warning text-ink-950',
|
||||
outline: 'border border-ink-300 text-ink-700',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'neutral' },
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends HTMLAttributes<HTMLSpanElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
export function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import type { ButtonHTMLAttributes, Ref } from 'react';
|
||||
|
||||
import { cn } from '../lib/cn';
|
||||
|
||||
export const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium transition-colors ' +
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ink-900 focus-visible:ring-offset-2 ' +
|
||||
'disabled:pointer-events-none disabled:opacity-40',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: 'bg-ink-950 text-white hover:bg-ink-800',
|
||||
secondary: 'bg-ink-100 text-ink-950 hover:bg-ink-200',
|
||||
outline:
|
||||
'border border-ink-950 bg-transparent text-ink-950 hover:bg-ink-950 hover:text-white',
|
||||
ghost: 'bg-transparent text-ink-950 hover:bg-ink-100',
|
||||
accent: 'bg-volt-500 text-ink-950 hover:bg-volt-400',
|
||||
danger: 'bg-danger text-white hover:opacity-90',
|
||||
},
|
||||
size: {
|
||||
sm: 'h-9 px-4 text-xs uppercase tracking-wide',
|
||||
md: 'h-11 px-6 text-sm uppercase tracking-wide',
|
||||
lg: 'h-14 px-8 text-sm uppercase tracking-widest',
|
||||
icon: 'size-10',
|
||||
},
|
||||
shape: {
|
||||
square: 'rounded-none',
|
||||
rounded: 'rounded-card',
|
||||
pill: 'rounded-pill',
|
||||
},
|
||||
fullWidth: {
|
||||
true: 'w-full',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
shape: 'square',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
|
||||
ref?: Ref<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
export function Button({ className, variant, size, shape, fullWidth, ...props }: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, shape, fullWidth }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { InputHTMLAttributes, Ref } from 'react';
|
||||
|
||||
import { cn } from '../lib/cn';
|
||||
|
||||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
invalid?: boolean;
|
||||
ref?: Ref<HTMLInputElement>;
|
||||
}
|
||||
|
||||
export function Input({ className, invalid, ...props }: InputProps) {
|
||||
return (
|
||||
<input
|
||||
aria-invalid={invalid || undefined}
|
||||
className={cn(
|
||||
'border-ink-300 text-ink-950 h-11 w-full border bg-white px-4 text-sm transition-colors',
|
||||
'placeholder:text-ink-400 focus:border-ink-950 focus:outline-none',
|
||||
'disabled:bg-ink-50 disabled:cursor-not-allowed',
|
||||
invalid && 'border-danger focus:border-danger',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { cn } from '../lib/cn';
|
||||
|
||||
export function Skeleton({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('bg-ink-100 animate-pulse', className)} {...props} />;
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
{
|
||||
"extends": "@sport/config/typescript/react-library.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules"],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { LOCALES } from '@sport/types';
|
||||
|
||||
import { genderTargetSchema, sportTypeSchema } from './catalog';
|
||||
import { anyIdSchema, skuSchema, slugSchema } from './common';
|
||||
import { offsetPageQuerySchema } from './pagination';
|
||||
|
||||
/**
|
||||
* Admin catalog write schemas.
|
||||
*
|
||||
* Money stays an integer in minor units all the way from the form to the
|
||||
* database (ADR-0011) — there is deliberately no decimal input anywhere in this
|
||||
* file, because the moment one exists someone will round it.
|
||||
*/
|
||||
|
||||
const localeKeySchema = z.enum(LOCALES);
|
||||
|
||||
/** At least the default locale must be present, or the product has no name. */
|
||||
function requiredTranslations<T extends z.ZodTypeAny>(fields: T) {
|
||||
return z
|
||||
.record(localeKeySchema, fields)
|
||||
.refine((value) => Object.keys(value).length > 0, 'Provide content for at least one language');
|
||||
}
|
||||
|
||||
export const productTranslationSchema = z.object({
|
||||
name: z.string().trim().min(1).max(255),
|
||||
/** Generated from the name when omitted; see `slugify`. */
|
||||
slug: slugSchema.optional(),
|
||||
shortDescription: z.string().trim().max(500).nullish(),
|
||||
description: z.string().trim().max(20_000).nullish(),
|
||||
metaTitle: z.string().trim().max(255).nullish(),
|
||||
metaDescription: z.string().trim().max(500).nullish(),
|
||||
});
|
||||
|
||||
export const productOptionValueInputSchema = z.object({
|
||||
/** Present when editing an existing value; absent when adding one. */
|
||||
id: anyIdSchema.optional(),
|
||||
value: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(80)
|
||||
.regex(/^[a-z0-9][a-z0-9._-]*$/, 'Use lowercase letters, digits, . _ -'),
|
||||
position: z.coerce.number().int().min(0).default(0),
|
||||
swatchHex: z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/, 'Use a hex colour like #1a2b3c')
|
||||
.nullish(),
|
||||
labels: requiredTranslations(z.string().trim().min(1).max(80)),
|
||||
});
|
||||
|
||||
export const productOptionInputSchema = z.object({
|
||||
id: anyIdSchema.optional(),
|
||||
key: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(40)
|
||||
.regex(/^[a-z][a-z0-9_]*$/, 'Use lowercase letters, digits and underscores'),
|
||||
position: z.coerce.number().int().min(0).default(0),
|
||||
names: requiredTranslations(z.string().trim().min(1).max(60)),
|
||||
values: z.array(productOptionValueInputSchema).min(1, 'An option needs at least one value'),
|
||||
});
|
||||
|
||||
export const productAttributeInputSchema = z.object({
|
||||
key: z.string().trim().min(1).max(60),
|
||||
position: z.coerce.number().int().min(0).default(0),
|
||||
translations: requiredTranslations(
|
||||
z.object({
|
||||
label: z.string().trim().min(1).max(120),
|
||||
value: z.string().trim().min(1).max(500),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const createProductSchema = z.object({
|
||||
translations: requiredTranslations(productTranslationSchema),
|
||||
brandId: anyIdSchema.nullish(),
|
||||
primaryCategoryId: anyIdSchema.nullish(),
|
||||
genderTargets: z.array(genderTargetSchema).default([]),
|
||||
sportTypes: z.array(sportTypeSchema).default([]),
|
||||
collectionIds: z.array(anyIdSchema).default([]),
|
||||
/** Seeds the generated SKUs. Falls back to the slug when omitted. */
|
||||
skuPrefix: z.string().trim().max(24).optional(),
|
||||
options: z.array(productOptionInputSchema).default([]),
|
||||
attributes: z.array(productAttributeInputSchema).default([]),
|
||||
/** Applied to every generated variant; per-variant edits come after. */
|
||||
basePriceAmount: z.int().min(0).default(0),
|
||||
});
|
||||
|
||||
export const updateProductSchema = createProductSchema.partial().extend({
|
||||
status: z.enum(['DRAFT', 'ACTIVE', 'ARCHIVED']).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Per-variant edits.
|
||||
*
|
||||
* Stock is deliberately NOT settable here — it moves through the inventory
|
||||
* endpoint so that every change lands in the movement ledger. A price edit and
|
||||
* a stock correction are different events with different audit needs.
|
||||
*/
|
||||
export const updateVariantSchema = z.object({
|
||||
sku: skuSchema.optional(),
|
||||
barcode: z.string().trim().max(64).nullish(),
|
||||
priceAmount: z.int().min(0).optional(),
|
||||
salePriceAmount: z.int().min(0).nullish(),
|
||||
compareAtAmount: z.int().min(0).nullish(),
|
||||
costAmount: z.int().min(0).nullish(),
|
||||
weightGrams: z.int().min(0).nullish(),
|
||||
status: z.enum(['ACTIVE', 'ARCHIVED']).optional(),
|
||||
});
|
||||
|
||||
export const bulkUpdateVariantsSchema = z.object({
|
||||
variants: z
|
||||
.array(updateVariantSchema.extend({ id: anyIdSchema }))
|
||||
.min(1)
|
||||
.max(500),
|
||||
});
|
||||
|
||||
export const productImagesSchema = z.object({
|
||||
images: z
|
||||
.array(
|
||||
z.object({
|
||||
mediaId: anyIdSchema,
|
||||
position: z.coerce.number().int().min(0),
|
||||
/** Ties the image to a colourway so the gallery swaps on selection. */
|
||||
optionValueId: anyIdSchema.nullish(),
|
||||
}),
|
||||
)
|
||||
.max(50),
|
||||
});
|
||||
|
||||
export const adminProductListQuerySchema = offsetPageQuerySchema.extend({
|
||||
q: z.string().trim().max(120).optional(),
|
||||
status: z.enum(['DRAFT', 'ACTIVE', 'ARCHIVED']).optional(),
|
||||
brandId: anyIdSchema.optional(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Media
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const presignUploadSchema = z.object({
|
||||
filename: z.string().trim().min(1).max(255),
|
||||
mimeType: z.string().trim().min(1).max(120),
|
||||
sizeBytes: z
|
||||
.int()
|
||||
.min(1)
|
||||
.max(25 * 1024 * 1024),
|
||||
prefix: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^[a-z0-9/-]+$/, 'Use lowercase letters, digits, / and -')
|
||||
.default('products'),
|
||||
});
|
||||
|
||||
/** Called after the browser has PUT the bytes to object storage. */
|
||||
export const registerMediaSchema = z.object({
|
||||
storageKey: z.string().trim().min(1).max(512),
|
||||
mimeType: z.string().trim().min(1).max(120),
|
||||
sizeBytes: z.int().min(1),
|
||||
width: z.int().min(1).nullish(),
|
||||
height: z.int().min(1).nullish(),
|
||||
altText: z.string().trim().max(255).nullish(),
|
||||
blurDataUrl: z.string().max(4096).nullish(),
|
||||
});
|
||||
|
||||
export const mediaListQuerySchema = offsetPageQuerySchema.extend({
|
||||
q: z.string().trim().max(120).optional(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inventory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const STOCK_REASONS = [
|
||||
'PURCHASE_RECEIPT',
|
||||
'RETURN',
|
||||
'MANUAL_ADJUSTMENT',
|
||||
'STOCK_TAKE',
|
||||
'TRANSFER_IN',
|
||||
'TRANSFER_OUT',
|
||||
'DAMAGE',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* A stock adjustment is a *delta*, not a new absolute value — except for a
|
||||
* stock take, which is the one case where an operator genuinely knows the
|
||||
* count. Modelling both as deltas would make a stock take require mental
|
||||
* arithmetic against a number that may have changed since the page loaded.
|
||||
*/
|
||||
export const adjustStockSchema = z
|
||||
.object({
|
||||
variantId: anyIdSchema,
|
||||
locationId: anyIdSchema.optional(),
|
||||
reason: z.enum(STOCK_REASONS),
|
||||
quantityDelta: z.int().optional(),
|
||||
/** Stock take only: the counted quantity. */
|
||||
countedQuantity: z.int().min(0).optional(),
|
||||
note: z.string().trim().max(500).nullish(),
|
||||
})
|
||||
.refine(
|
||||
(value) =>
|
||||
value.reason === 'STOCK_TAKE'
|
||||
? value.countedQuantity !== undefined
|
||||
: value.quantityDelta !== undefined && value.quantityDelta !== 0,
|
||||
'Provide a non-zero quantityDelta, or countedQuantity for a stock take',
|
||||
);
|
||||
|
||||
export const inventoryListQuerySchema = offsetPageQuerySchema.extend({
|
||||
q: z.string().trim().max(120).optional(),
|
||||
lowStockOnly: z.coerce.boolean().optional(),
|
||||
});
|
||||
|
||||
export type CreateProductInput = z.output<typeof createProductSchema>;
|
||||
export type UpdateProductInput = z.output<typeof updateProductSchema>;
|
||||
export type ProductOptionInput = z.output<typeof productOptionInputSchema>;
|
||||
export type BulkUpdateVariantsInput = z.output<typeof bulkUpdateVariantsSchema>;
|
||||
export type ProductImagesInput = z.output<typeof productImagesSchema>;
|
||||
export type AdminProductListQuery = z.output<typeof adminProductListQuerySchema>;
|
||||
export type PresignUploadInput = z.output<typeof presignUploadSchema>;
|
||||
export type RegisterMediaInput = z.output<typeof registerMediaSchema>;
|
||||
export type MediaListQuery = z.output<typeof mediaListQuerySchema>;
|
||||
export type AdjustStockInput = z.output<typeof adjustStockSchema>;
|
||||
export type InventoryListQuery = z.output<typeof inventoryListQuerySchema>;
|
||||
@@ -16,3 +16,4 @@ export * from './pagination';
|
||||
export * from './auth';
|
||||
export * from './catalog';
|
||||
export * from './users';
|
||||
export * from './catalog-admin';
|
||||
|
||||
Reference in New Issue
Block a user