Finish admin page

This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:23 +07:00
parent cda5d21d0d
commit 8fd23d162f
107 changed files with 6321 additions and 563 deletions
+6
View File
@@ -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),
};
+8
View File
@@ -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'),
};
}
+71
View File
@@ -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;
}
+1
View File
@@ -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>
);
}
+83
View File
@@ -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>
);
}
+48
View File
@@ -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>
);
}
+22
View File
@@ -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';
+40 -1
View File
@@ -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.
+1
View File
@@ -20,3 +20,4 @@ export * from './catalog-admin';
export * from './commerce';
export * from './content';
export * from './customers';
export * from './taxonomy-admin';
+75
View File
@@ -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>;