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;
}
}
+78
View File
@@ -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;
+178
View File
@@ -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;
}
+1
View File
@@ -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';
+21
View File
@@ -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"
}
+2
View File
@@ -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": {
+47
View File
@@ -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 };
+110
View File
@@ -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 };
+145
View File
@@ -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,
};
+33
View File
@@ -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 };
+134
View File
@@ -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 };
+81
View File
@@ -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
View File
@@ -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';
-7
View File
@@ -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));
}
+13
View File
@@ -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));
}
-29
View File
@@ -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} />;
}
-56
View File
@@ -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}
/>
);
}
-24
View File
@@ -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}
/>
);
}
-7
View File
@@ -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} />;
}
+7 -1
View File
@@ -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/*"]
}
}
}
+226
View File
@@ -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>;
+1
View File
@@ -16,3 +16,4 @@ export * from './pagination';
export * from './auth';
export * from './catalog';
export * from './users';
export * from './catalog-admin';