Finish admin page
This commit is contained in:
@@ -27,6 +27,10 @@ import {
|
||||
type ReviewsAdminResource,
|
||||
type ReviewsResource,
|
||||
} from './resources/reviews';
|
||||
import {
|
||||
createTaxonomyAdminResource,
|
||||
type TaxonomyAdminResource,
|
||||
} from './resources/taxonomy-admin';
|
||||
|
||||
/**
|
||||
* Resource modules are added here as the backend grows — one file per bounded
|
||||
@@ -47,6 +51,7 @@ export interface ApiClient {
|
||||
readonly reviews: ReviewsResource;
|
||||
readonly reviewsAdmin: ReviewsAdminResource;
|
||||
readonly account: AccountResource;
|
||||
readonly taxonomyAdmin: TaxonomyAdminResource;
|
||||
readonly content: ContentResource;
|
||||
readonly contentAdmin: ContentAdminResource;
|
||||
}
|
||||
@@ -67,6 +72,7 @@ export function createApiClient(options: HttpClientOptions): ApiClient {
|
||||
reviews: createReviewsResource(http),
|
||||
reviewsAdmin: createReviewsAdminResource(http),
|
||||
account: createAccountResource(http),
|
||||
taxonomyAdmin: createTaxonomyAdminResource(http),
|
||||
content: createContentResource(http),
|
||||
contentAdmin: createContentAdminResource(http),
|
||||
};
|
||||
|
||||
@@ -59,4 +59,12 @@ export type {
|
||||
PostListParams,
|
||||
} from './resources/content';
|
||||
export type { AccountResource, AddressPayload, ProfilePayload } from './resources/account';
|
||||
export type {
|
||||
BrandPayload,
|
||||
CategoryPayload,
|
||||
CollectionPayload,
|
||||
TaxonomyAdminResource,
|
||||
TaxonomyListParams,
|
||||
TaxonomyResource,
|
||||
} from './resources/taxonomy-admin';
|
||||
export type { ApiClient } from './create-client';
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { AdminBrand, AdminCategory, AdminCollection, OffsetPaginated } from '@sport/types';
|
||||
|
||||
import type { HttpClient } from '../http-client';
|
||||
|
||||
export interface TaxonomyListParams {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
q?: string;
|
||||
activeOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface TaxonomyTranslationPayload {
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string | null;
|
||||
metaTitle?: string | null;
|
||||
metaDescription?: string | null;
|
||||
}
|
||||
|
||||
interface TaxonomyBasePayload {
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string | null;
|
||||
isActive?: boolean;
|
||||
metaTitle?: string | null;
|
||||
metaDescription?: string | null;
|
||||
translations?: Record<string, TaxonomyTranslationPayload>;
|
||||
}
|
||||
|
||||
export interface BrandPayload extends TaxonomyBasePayload {
|
||||
logoId?: string | null;
|
||||
}
|
||||
|
||||
export interface CategoryPayload extends TaxonomyBasePayload {
|
||||
parentId?: string | null;
|
||||
position?: number;
|
||||
imageId?: string | null;
|
||||
}
|
||||
|
||||
export interface CollectionPayload extends TaxonomyBasePayload {
|
||||
type?: 'MANUAL' | 'AUTOMATED';
|
||||
bannerId?: string | null;
|
||||
startsAt?: string | null;
|
||||
endsAt?: string | null;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
/** CRUD for one taxonomy entity. The three share a shape, so they share a type. */
|
||||
export interface TaxonomyResource<TEntity, TPayload> {
|
||||
list(params?: TaxonomyListParams): Promise<OffsetPaginated<TEntity>>;
|
||||
getById(id: string): Promise<TEntity>;
|
||||
create(payload: TPayload): Promise<TEntity>;
|
||||
update(id: string, payload: TPayload): Promise<TEntity>;
|
||||
remove(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface TaxonomyAdminResource {
|
||||
brands: TaxonomyResource<AdminBrand, BrandPayload>;
|
||||
categories: TaxonomyResource<AdminCategory, CategoryPayload>;
|
||||
collections: TaxonomyResource<AdminCollection, CollectionPayload>;
|
||||
}
|
||||
|
||||
function resource<TEntity, TPayload>(
|
||||
http: HttpClient,
|
||||
path: string,
|
||||
): TaxonomyResource<TEntity, TPayload> {
|
||||
// Never cached: an operator who just deactivated a brand must see it off.
|
||||
const uncached = { cache: 'no-store' } as const;
|
||||
|
||||
return {
|
||||
list: (params = {}) =>
|
||||
http.get<OffsetPaginated<TEntity>>(`/admin/${path}`, { ...uncached, query: { ...params } }),
|
||||
getById: (id) => http.get<TEntity>(`/admin/${path}/${encodeURIComponent(id)}`, uncached),
|
||||
create: (payload) => http.post<TEntity>(`/admin/${path}`, payload, uncached),
|
||||
update: (id, payload) =>
|
||||
http.patch<TEntity>(`/admin/${path}/${encodeURIComponent(id)}`, payload, uncached),
|
||||
remove: (id) => http.delete<void>(`/admin/${path}/${encodeURIComponent(id)}`, uncached),
|
||||
};
|
||||
}
|
||||
|
||||
export function createTaxonomyAdminResource(http: HttpClient): TaxonomyAdminResource {
|
||||
return {
|
||||
brands: resource<AdminBrand, BrandPayload>(http, 'brands'),
|
||||
categories: resource<AdminCategory, CategoryPayload>(http, 'categories'),
|
||||
collections: resource<AdminCollection, CollectionPayload>(http, 'collections'),
|
||||
};
|
||||
}
|
||||
@@ -48,6 +48,17 @@
|
||||
|
||||
--tracking-display: -0.03em;
|
||||
|
||||
/**
|
||||
* Eyebrow: the 10px uppercase label used for section headings, table
|
||||
* headers and field labels. It appeared as the arbitrary value
|
||||
* `text-[0.625rem]` in twenty places before it was a token, which is
|
||||
* exactly how a type scale stops being a scale.
|
||||
*/
|
||||
--text-eyebrow: 0.625rem;
|
||||
--text-eyebrow--line-height: 1.4;
|
||||
--text-eyebrow--letter-spacing: 0.1em;
|
||||
--text-eyebrow--font-weight: 600;
|
||||
|
||||
/* ---- Layout ----------------------------------------------------------- */
|
||||
--spacing-gutter: 1.25rem;
|
||||
--container-page: 90rem;
|
||||
@@ -55,9 +66,24 @@
|
||||
--radius-card: 0.25rem;
|
||||
--radius-pill: 999px;
|
||||
|
||||
/**
|
||||
* Z-index scale. Named, because `z-50` scattered across files is how two
|
||||
* overlays end up fighting and the loser is whichever rendered first.
|
||||
*/
|
||||
--z-sticky: 20;
|
||||
--z-dropdown: 30;
|
||||
--z-overlay: 40;
|
||||
--z-modal: 50;
|
||||
--z-toast: 60;
|
||||
|
||||
/* ---- Motion ----------------------------------------------------------- */
|
||||
--ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--animate-rise: rise 0.5s var(--ease-out-quint) both;
|
||||
|
||||
/* Micro-interaction timing. 150–200ms reads as instant-but-smooth; past
|
||||
300ms a hover starts to feel like lag. */
|
||||
--duration-fast: 150ms;
|
||||
--duration-base: 200ms;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -171,4 +197,49 @@
|
||||
background-color: var(--color-volt-300);
|
||||
color: var(--color-ink-950);
|
||||
}
|
||||
|
||||
/**
|
||||
* One focus ring for the whole product.
|
||||
*
|
||||
* Applied to `:focus-visible` rather than `:focus`, so it appears for
|
||||
* keyboard and programmatic focus but not on mouse clicks — the reason
|
||||
* teams remove focus outlines in the first place.
|
||||
*
|
||||
* This is a *baseline*, not a replacement for component-level styles: it
|
||||
* means a plain <button> written in a hurry is still keyboard-navigable.
|
||||
* Sixteen of them across twelve files had no focus indicator at all before
|
||||
* this rule existed.
|
||||
*/
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-ink-950);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* On dark surfaces an ink ring is invisible; invert it. */
|
||||
.on-dark :focus-visible,
|
||||
.bg-ink-950 :focus-visible {
|
||||
outline-color: var(--color-volt-400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ---- Reduced motion --------------------------------------------------------
|
||||
*
|
||||
* Honouring `prefers-reduced-motion` is an accessibility requirement, not a
|
||||
* nicety: for people with vestibular disorders, movement can cause actual
|
||||
* nausea. Animation is reduced to near-zero rather than removed, because
|
||||
* `animation: none` can leave elements stuck in their pre-animation state —
|
||||
* `--animate-rise` starts at `opacity: 0`, so disabling it outright would
|
||||
* make content invisible.
|
||||
*/
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Id, Nullable } from '../primitives';
|
||||
|
||||
/**
|
||||
* Taxonomy as an operator edits it.
|
||||
*
|
||||
* Distinct from the storefront `Category`/`Collection`/`Brand` types because
|
||||
* the admin needs what the storefront must never see: inactive rows, the
|
||||
* per-locale translation set rather than one resolved language, and the counts
|
||||
* that make a delete decision safe.
|
||||
*/
|
||||
export interface AdminTaxonomyTranslation {
|
||||
readonly name: string;
|
||||
readonly slug: string;
|
||||
readonly description: Nullable<string>;
|
||||
readonly metaTitle: Nullable<string>;
|
||||
readonly metaDescription: Nullable<string>;
|
||||
}
|
||||
|
||||
interface AdminTaxonomyBase {
|
||||
readonly id: Id;
|
||||
readonly name: string;
|
||||
readonly slug: string;
|
||||
readonly description: Nullable<string>;
|
||||
readonly isActive: boolean;
|
||||
readonly metaTitle: Nullable<string>;
|
||||
readonly metaDescription: Nullable<string>;
|
||||
readonly translations: Readonly<Record<string, AdminTaxonomyTranslation>>;
|
||||
/** How many products point here. Deleting a non-empty row is refused. */
|
||||
readonly productCount: number;
|
||||
}
|
||||
|
||||
export interface AdminBrand extends AdminTaxonomyBase {
|
||||
readonly logoId: Nullable<Id>;
|
||||
readonly logoUrl: Nullable<string>;
|
||||
}
|
||||
|
||||
export interface AdminCategory extends AdminTaxonomyBase {
|
||||
readonly parentId: Nullable<Id>;
|
||||
/** Materialised path, derived — never edited directly. */
|
||||
readonly path: string;
|
||||
readonly depth: number;
|
||||
readonly position: number;
|
||||
readonly imageId: Nullable<Id>;
|
||||
readonly imageUrl: Nullable<string>;
|
||||
readonly childCount: number;
|
||||
}
|
||||
|
||||
export interface AdminCollection extends AdminTaxonomyBase {
|
||||
readonly type: 'MANUAL' | 'AUTOMATED';
|
||||
readonly bannerId: Nullable<Id>;
|
||||
readonly bannerUrl: Nullable<string>;
|
||||
readonly startsAt: Nullable<string>;
|
||||
readonly endsAt: Nullable<string>;
|
||||
readonly position: number;
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export * from './users/user';
|
||||
export * from './catalog/product';
|
||||
export * from './catalog/variant';
|
||||
export * from './catalog/taxonomy';
|
||||
export * from './catalog/taxonomy-admin';
|
||||
export * from './catalog/media';
|
||||
export * from './catalog/navigation';
|
||||
export * from './catalog/facets';
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { ReactNode, ThHTMLAttributes, TdHTMLAttributes } from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
/**
|
||||
* The admin's table shell.
|
||||
*
|
||||
* Seven screens had hand-written `<table>` markup with the same border, header
|
||||
* background and eyebrow-cased column labels — and small divergences in each:
|
||||
* different header text sizes, two different row hover colours, and one table
|
||||
* that scrolled the whole page sideways on mobile instead of scrolling itself.
|
||||
*
|
||||
* These are thin primitives rather than a generic `<DataTable columns={…}>`.
|
||||
* A column-config table is the wrong abstraction here: every one of these
|
||||
* screens renders bespoke cells (badges, stacked name+SKU, action buttons),
|
||||
* and a config API would immediately grow a `render` escape hatch for each —
|
||||
* at which point it is JSX with extra steps.
|
||||
*/
|
||||
export function DataTable({
|
||||
children,
|
||||
className,
|
||||
minWidth = '48rem',
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
/** Below this the wrapper scrolls rather than the page. */
|
||||
minWidth?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('border-ink-200 rounded-card overflow-x-auto border bg-white', className)}>
|
||||
<table className="w-full text-sm" style={{ minWidth }}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DataTableHead({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<thead className="border-ink-200 bg-ink-50 border-b text-left">
|
||||
<tr>{children}</tr>
|
||||
</thead>
|
||||
);
|
||||
}
|
||||
|
||||
export function DataTableBody({ children }: { children: ReactNode }) {
|
||||
return <tbody className="divide-ink-100 divide-y">{children}</tbody>;
|
||||
}
|
||||
|
||||
export function DataTableRow({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return <tr className={cn('hover:bg-ink-50/60 transition-colors', className)}>{children}</tr>;
|
||||
}
|
||||
|
||||
export function DataTableHeader({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ThHTMLAttributes<HTMLTableCellElement>) {
|
||||
return (
|
||||
<th
|
||||
scope="col"
|
||||
className={cn('text-eyebrow text-ink-500 px-4 py-3 uppercase', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
export function DataTableCell({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TdHTMLAttributes<HTMLTableCellElement>) {
|
||||
return (
|
||||
<td className={cn('px-4 py-3 align-middle', className)} {...props}>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
/**
|
||||
* The "nothing here" state.
|
||||
*
|
||||
* Extracted from four hand-rolled copies that differed in padding, border
|
||||
* style and text colour. An empty state is not a failure and should not look
|
||||
* like one — no red, no icon of a broken thing. It says what is missing and,
|
||||
* where there is one, offers the action that fills it.
|
||||
*/
|
||||
export function EmptyState({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-ink-200 rounded-card flex flex-col items-center border border-dashed px-6 py-12 text-center',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<p className="text-ink-700 text-sm font-medium">{title}</p>
|
||||
{description ? <p className="text-ink-500 mt-1.5 max-w-sm text-sm">{description}</p> : null}
|
||||
{action ? <div className="mt-5">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import { useId, type ReactNode } from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
/**
|
||||
* A labelled form field.
|
||||
*
|
||||
* The `<span class="text-eyebrow uppercase">` + control pairing appeared over
|
||||
* seventy times across both apps, hand-written each time, which is how a
|
||||
* hint ends up as `text-xs` in one form and `text-[0.625rem]` in the next —
|
||||
* and how some controls ended up with no programmatic label at all.
|
||||
*
|
||||
* `useId` wires label, hint and error to the control via `htmlFor`,
|
||||
* `aria-describedby` and `aria-invalid`, so the association survives however
|
||||
* the control is rendered. Children receive the id as a render prop.
|
||||
*/
|
||||
export function Field({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
required,
|
||||
optionalLabel,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
/** Presence flips the control to `aria-invalid` and shows the message. */
|
||||
error?: string | null;
|
||||
required?: boolean;
|
||||
/** e.g. "(optional)" — marking the shorter list is the kinder default. */
|
||||
optionalLabel?: string;
|
||||
className?: string;
|
||||
children: (props: {
|
||||
id: string;
|
||||
'aria-describedby'?: string;
|
||||
'aria-invalid'?: boolean;
|
||||
}) => ReactNode;
|
||||
}) {
|
||||
const id = useId();
|
||||
const hintId = `${id}-hint`;
|
||||
const errorId = `${id}-error`;
|
||||
|
||||
const describedBy = [hint ? hintId : null, error ? errorId : null].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-1.5', className)}>
|
||||
<label htmlFor={id} className="text-eyebrow text-ink-950 block uppercase">
|
||||
{label}
|
||||
{required ? (
|
||||
<span aria-hidden className="text-danger ml-0.5">
|
||||
*
|
||||
</span>
|
||||
) : null}
|
||||
{optionalLabel ? (
|
||||
<span className="text-ink-400 ml-1 font-normal normal-case tracking-normal">
|
||||
{optionalLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
|
||||
{children({
|
||||
id,
|
||||
...(describedBy ? { 'aria-describedby': describedBy } : {}),
|
||||
...(error ? { 'aria-invalid': true } : {}),
|
||||
})}
|
||||
|
||||
{hint && !error ? (
|
||||
<p id={hintId} className="text-ink-500 text-xs">
|
||||
{hint}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p id={errorId} role="alert" className="text-danger text-xs">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Star } from 'lucide-react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const SIZES = { sm: 'size-3.5', md: 'size-4', lg: 'size-5' } as const;
|
||||
|
||||
/**
|
||||
* A read-only star rating.
|
||||
*
|
||||
* Extracted from three copies — the admin moderation queue, the storefront
|
||||
* review list and the review form — which had drifted to three different star
|
||||
* sizes and two different fill colours for the same concept.
|
||||
*
|
||||
* The stars are `aria-hidden` and the value is exposed as text, because five
|
||||
* separate star glyphs announced individually are noise; "4 out of 5" is the
|
||||
* information. Rounding is the caller's business: a 4.5 average passed here
|
||||
* fills four stars, and the numeral beside it carries the half.
|
||||
*/
|
||||
export function Rating({
|
||||
value,
|
||||
max = 5,
|
||||
size = 'md',
|
||||
className,
|
||||
label,
|
||||
}: {
|
||||
value: number;
|
||||
max?: number;
|
||||
size?: keyof typeof SIZES;
|
||||
className?: string;
|
||||
/** Overrides the announced text, e.g. a localised "4 out of 5 stars". */
|
||||
label?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={label ?? `${value}/${max}`}
|
||||
className={cn('inline-flex items-center gap-0.5', className)}
|
||||
>
|
||||
{Array.from({ length: max }, (_, index) => index + 1).map((star) => (
|
||||
<Star
|
||||
key={star}
|
||||
aria-hidden
|
||||
className={cn(SIZES[size], star <= value ? 'fill-ink-950 text-ink-950' : 'text-ink-300')}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export interface SegmentedOption<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A row of mutually exclusive filters.
|
||||
*
|
||||
* Extracted from seven near-identical copies across the admin (discounts,
|
||||
* reviews, content, orders, products) that had drifted: some used
|
||||
* `aria-pressed`, some nothing; none had a focus ring; padding varied between
|
||||
* them. A filter control is exactly the kind of thing that must look and
|
||||
* behave the same everywhere, because an operator learns it once.
|
||||
*
|
||||
* `radiogroup` rather than a row of toggle buttons: the options are mutually
|
||||
* exclusive, and that is what tells a screen reader "pick one of these" rather
|
||||
* than "here are five independent switches". Arrow keys move between options,
|
||||
* which is the native behaviour keyboard users expect from a radio group.
|
||||
*/
|
||||
export function SegmentedControl<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
label,
|
||||
size = 'md',
|
||||
className,
|
||||
}: {
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
options: readonly SegmentedOption<T>[];
|
||||
/** Names the group for assistive tech. Required — an unlabelled filter row
|
||||
* is announced as a bare list of buttons. */
|
||||
label: string;
|
||||
size?: 'sm' | 'md';
|
||||
className?: string;
|
||||
}) {
|
||||
function handleKeyDown(event: React.KeyboardEvent, index: number) {
|
||||
const delta = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;
|
||||
if (delta === 0) return;
|
||||
|
||||
event.preventDefault();
|
||||
// Wraps, which is what a radio group does natively.
|
||||
const next = options[(index + delta + options.length) % options.length];
|
||||
if (next) onChange(next.value);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={label}
|
||||
className={cn('border-ink-200 rounded-card inline-flex overflow-hidden border', className)}
|
||||
>
|
||||
{options.map((option, index) => {
|
||||
const active = option.value === value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
// Only the selected option is tabbable; arrows move within the
|
||||
// group. Otherwise a five-option filter costs five tab stops.
|
||||
tabIndex={active ? 0 : -1}
|
||||
onClick={() => onChange(option.value)}
|
||||
onKeyDown={(event) => handleKeyDown(event, index)}
|
||||
className={cn(
|
||||
'duration-(--duration-base) cursor-pointer transition-colors',
|
||||
'focus-visible:outline-ink-950 focus-visible:z-10 focus-visible:outline-2',
|
||||
size === 'sm' ? 'px-2.5 py-1.5 text-xs' : 'px-3 py-2 text-sm',
|
||||
active ? 'bg-ink-950 text-white' : 'hover:bg-ink-100 bg-white',
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
/**
|
||||
* "Skip to content", for keyboard users.
|
||||
*
|
||||
* Visually hidden until focused. Without it, reaching the product grid on a
|
||||
* storefront page means tabbing through the whole header — logo, three mega-menu
|
||||
* triggers, language switcher, search, account, bag — on every single page.
|
||||
*
|
||||
* Deliberately the first focusable element in the DOM, and positioned rather
|
||||
* than clipped: `sr-only` alone would keep it unreachable-looking when focused,
|
||||
* which is the failure mode of most skip links that "exist" but are never seen.
|
||||
*/
|
||||
export function SkipLink({ href = '#main', children }: { href?: string; children: string }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={cn(
|
||||
'bg-ink-950 rounded-card sr-only px-4 py-2 text-sm font-medium text-white',
|
||||
'focus-visible:not-sr-only focus-visible:fixed focus-visible:left-4 focus-visible:top-4',
|
||||
'focus-visible:z-toast focus-visible:outline-volt-400 focus-visible:outline-2 focus-visible:outline-offset-2',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -83,3 +83,25 @@ export {
|
||||
} from './components/ui/dropdown-menu';
|
||||
|
||||
export { Tabs, TabsContent, TabsList, TabsTrigger, tabsListVariants } from './components/ui/tabs';
|
||||
|
||||
/**
|
||||
* ---- Commerce-agnostic patterns -------------------------------------------
|
||||
*
|
||||
* Not from the shadcn registry: these were extracted from duplicated markup
|
||||
* that had already drifted across the storefront and the admin. They live here
|
||||
* rather than in either app because both use them.
|
||||
*/
|
||||
export { SegmentedControl } from './components/ui/segmented-control';
|
||||
export type { SegmentedOption } from './components/ui/segmented-control';
|
||||
export { Rating } from './components/ui/rating';
|
||||
export { EmptyState } from './components/ui/empty-state';
|
||||
export { Field } from './components/ui/field';
|
||||
export { SkipLink } from './components/ui/skip-link';
|
||||
export {
|
||||
DataTable,
|
||||
DataTableBody,
|
||||
DataTableCell,
|
||||
DataTableHead,
|
||||
DataTableHeader,
|
||||
DataTableRow,
|
||||
} from './components/ui/data-table';
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { extendTailwindMerge } from 'tailwind-merge';
|
||||
|
||||
/**
|
||||
* tailwind-merge, taught about this project's custom scales.
|
||||
*
|
||||
* Without the `extend` block, `cn('text-eyebrow', 'text-ink-500')` silently
|
||||
* returns just `text-ink-500`. tailwind-merge resolves conflicts from a built-in
|
||||
* class list; a custom `--text-*` token it has never heard of gets classified as
|
||||
* a *text colour*, so the real colour that follows it looks like a conflict and
|
||||
* wins. The class vanishes with no error, at build or at runtime — it simply is
|
||||
* not in the DOM.
|
||||
*
|
||||
* That is exactly what happened to every table header in the admin: they
|
||||
* rendered at the inherited 14px instead of the 10px eyebrow size, and the only
|
||||
* way to notice was to read the computed style.
|
||||
*/
|
||||
const twMerge = extendTailwindMerge({
|
||||
extend: {
|
||||
classGroups: {
|
||||
// Each entry registers a custom token from theme.css into the class group
|
||||
// it actually belongs to. Without this, `cn()` cannot override any of
|
||||
// them: both classes survive the merge and the winner is decided by CSS
|
||||
// source order rather than by the caller's intent.
|
||||
'font-size': [{ text: ['eyebrow'] }],
|
||||
rounded: [{ rounded: ['card', 'pill'] }],
|
||||
'max-w': [{ 'max-w': ['page'] }],
|
||||
p: [{ p: ['gutter'] }],
|
||||
px: [{ px: ['gutter'] }],
|
||||
py: [{ py: ['gutter'] }],
|
||||
m: [{ m: ['gutter'] }],
|
||||
mx: [{ mx: ['gutter'] }],
|
||||
my: [{ my: ['gutter'] }],
|
||||
gap: [{ gap: ['gutter'] }],
|
||||
z: [{ z: ['sticky', 'dropdown', 'overlay', 'modal', 'toast'] }],
|
||||
duration: [{ duration: ['fast', 'base'] }],
|
||||
ease: [{ ease: ['out-quint'] }],
|
||||
tracking: [{ tracking: ['display'] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Conditional classes + last-wins conflict resolution for Tailwind utilities.
|
||||
|
||||
@@ -20,3 +20,4 @@ export * from './catalog-admin';
|
||||
export * from './commerce';
|
||||
export * from './content';
|
||||
export * from './customers';
|
||||
export * from './taxonomy-admin';
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { LOCALES } from '@sport/types';
|
||||
|
||||
import { anyIdSchema, slugSchema } from './common';
|
||||
import { offsetPageQuerySchema } from './pagination';
|
||||
|
||||
/**
|
||||
* Per-locale copy for a taxonomy row.
|
||||
*
|
||||
* A partial record, like products and content: publishing an English-only
|
||||
* brand is legitimate, and demanding every locale would mean inventing
|
||||
* Vietnamese copy before anything could be saved.
|
||||
*/
|
||||
const translationSchema = z.object({
|
||||
name: z.string().trim().min(1).max(160),
|
||||
slug: slugSchema,
|
||||
description: z.string().trim().max(2000).nullish(),
|
||||
metaTitle: z.string().trim().max(255).nullish(),
|
||||
metaDescription: z.string().trim().max(500).nullish(),
|
||||
});
|
||||
|
||||
const baseSchema = {
|
||||
/** The fallback name, used when a locale has no translation. */
|
||||
name: z.string().trim().min(1).max(160),
|
||||
slug: slugSchema,
|
||||
description: z.string().trim().max(2000).nullish(),
|
||||
isActive: z.boolean().default(true),
|
||||
metaTitle: z.string().trim().max(255).nullish(),
|
||||
metaDescription: z.string().trim().max(500).nullish(),
|
||||
translations: z.partialRecord(z.enum(LOCALES), translationSchema).default({}),
|
||||
};
|
||||
|
||||
export const brandInputSchema = z.object({
|
||||
...baseSchema,
|
||||
logoId: anyIdSchema.nullish(),
|
||||
});
|
||||
|
||||
export const categoryInputSchema = z.object({
|
||||
...baseSchema,
|
||||
/**
|
||||
* Null means a root category. The service rejects a parent that is the
|
||||
* category itself or one of its own descendants — that would detach the
|
||||
* subtree from the tree and orphan every product under it.
|
||||
*/
|
||||
parentId: anyIdSchema.nullish(),
|
||||
position: z.int().min(0).max(9999).default(0),
|
||||
imageId: anyIdSchema.nullish(),
|
||||
});
|
||||
|
||||
export const collectionInputSchema = z
|
||||
.object({
|
||||
...baseSchema,
|
||||
type: z.enum(['MANUAL', 'AUTOMATED']).default('MANUAL'),
|
||||
bannerId: anyIdSchema.nullish(),
|
||||
startsAt: z.iso.datetime().nullish(),
|
||||
endsAt: z.iso.datetime().nullish(),
|
||||
position: z.int().min(0).max(9999).default(0),
|
||||
})
|
||||
.refine(
|
||||
(value) =>
|
||||
!value.startsAt || !value.endsAt || new Date(value.startsAt) < new Date(value.endsAt),
|
||||
{ message: 'The end date must be after the start date', path: ['endsAt'] },
|
||||
);
|
||||
|
||||
export const taxonomyListQuerySchema = offsetPageQuerySchema.extend({
|
||||
q: z.string().trim().max(120).optional(),
|
||||
/** Admin default is "everything"; the storefront only ever sees active rows. */
|
||||
activeOnly: z.coerce.boolean().optional(),
|
||||
});
|
||||
|
||||
export type BrandInput = z.output<typeof brandInputSchema>;
|
||||
export type CategoryInput = z.output<typeof categoryInputSchema>;
|
||||
export type CollectionInput = z.output<typeof collectionInputSchema>;
|
||||
export type TaxonomyListQuery = z.output<typeof taxonomyListQuerySchema>;
|
||||
Reference in New Issue
Block a user