add shadcn ui
This commit is contained in:
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
|
"typescript.enablePromptUseWorkspaceTsdk": true
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import createNextIntlPlugin from 'next-intl/plugin';
|
|
||||||
import type { NextConfig } from 'next';
|
import type { NextConfig } from 'next';
|
||||||
|
import createNextIntlPlugin from 'next-intl/plugin';
|
||||||
|
|
||||||
/** Registers the cookie-based locale resolver in src/i18n/request.ts. */
|
/** Registers the cookie-based locale resolver in src/i18n/request.ts. */
|
||||||
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
|
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl';
|
|||||||
import { useEffect, useState, type FormEvent } from 'react';
|
import { useEffect, useState, type FormEvent } from 'react';
|
||||||
|
|
||||||
import { isApiClientError } from '@sport/api-client';
|
import { isApiClientError } from '@sport/api-client';
|
||||||
import { Button, Input } from '@sport/ui';
|
import { Button, FormError, Input } from '@sport/ui';
|
||||||
import { loginSchema } from '@sport/validation';
|
import { loginSchema } from '@sport/validation';
|
||||||
|
|
||||||
import { useSession } from './session-provider';
|
import { useSession } from './session-provider';
|
||||||
@@ -91,11 +91,7 @@ export function LoginForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
<FormError message={error} />
|
||||||
<p role="alert" className="text-danger text-sm">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Button type="submit" fullWidth size="lg" disabled={submitting}>
|
<Button type="submit" fullWidth size="lg" disabled={submitting}>
|
||||||
{submitting ? t('signingIn') : t('signIn')}
|
{submitting ? t('signingIn') : t('signIn')}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
type ContentType,
|
type ContentType,
|
||||||
type Locale,
|
type Locale,
|
||||||
} from '@sport/types';
|
} from '@sport/types';
|
||||||
import { Badge, Button, Input, Skeleton, cn } from '@sport/ui';
|
import { Badge, Button, FormError, Input, Skeleton, Textarea, cn } from '@sport/ui';
|
||||||
|
|
||||||
import { browserApi } from '@/lib/api';
|
import { browserApi } from '@/lib/api';
|
||||||
|
|
||||||
@@ -275,12 +275,11 @@ export function ContentEditor({
|
|||||||
|
|
||||||
<label className="block space-y-1.5">
|
<label className="block space-y-1.5">
|
||||||
<span className="text-xs font-semibold uppercase tracking-widest">{t('body')}</span>
|
<span className="text-xs font-semibold uppercase tracking-widest">{t('body')}</span>
|
||||||
<textarea
|
<Textarea
|
||||||
value={fields.body}
|
value={fields.body}
|
||||||
onChange={(event) => setField('body', event.target.value)}
|
onChange={(event) => setField('body', event.target.value)}
|
||||||
rows={22}
|
rows={22}
|
||||||
spellCheck
|
spellCheck
|
||||||
className="border-ink-200 focus:border-ink-950 w-full border px-3 py-2 font-mono text-sm leading-relaxed outline-none"
|
|
||||||
/>
|
/>
|
||||||
<span className="text-ink-500 text-xs">{t('bodyHint')}</span>
|
<span className="text-ink-500 text-xs">{t('bodyHint')}</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -313,11 +312,7 @@ export function ContentEditor({
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
{error ? (
|
<FormError message={error} />
|
||||||
<p role="alert" className="text-danger text-sm">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="border-ink-200 flex flex-wrap items-center gap-2 border-t pt-4">
|
<div className="border-ink-200 flex flex-wrap items-center gap-2 border-t pt-4">
|
||||||
<Button type="submit" variant="outline" disabled={saving}>
|
<Button type="submit" variant="outline" disabled={saving}>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
DataTableHeader,
|
DataTableHeader,
|
||||||
DataTableRow,
|
DataTableRow,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
|
FormError,
|
||||||
Input,
|
Input,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
@@ -107,11 +108,7 @@ export function ContentTable() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
<FormError message={error} />
|
||||||
<p role="alert" className="text-danger text-sm">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!entries ? (
|
{!entries ? (
|
||||||
<div className="space-y-2" aria-busy="true">
|
<div className="space-y-2" aria-busy="true">
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
|
FormError,
|
||||||
Input,
|
Input,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
} from '@sport/ui';
|
} from '@sport/ui';
|
||||||
@@ -77,11 +78,7 @@ export function CustomersTable() {
|
|||||||
aria-label={t('searchPlaceholder')}
|
aria-label={t('searchPlaceholder')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error ? (
|
<FormError message={error} />
|
||||||
<p role="alert" className="text-danger text-sm">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!customers ? (
|
{!customers ? (
|
||||||
<div className="space-y-2" aria-busy="true">
|
<div className="space-y-2" aria-busy="true">
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import { isApiClientError, type DiscountPayload } from '@sport/api-client';
|
|||||||
import { DEFAULT_LOCALE, LOCALES, type AdminDiscount, type Locale } from '@sport/types';
|
import { DEFAULT_LOCALE, LOCALES, type AdminDiscount, type Locale } from '@sport/types';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
Checkbox,
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
|
FormError,
|
||||||
Input,
|
Input,
|
||||||
cn,
|
cn,
|
||||||
} from '@sport/ui';
|
} from '@sport/ui';
|
||||||
@@ -356,11 +358,7 @@ export function DiscountDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
<FormError message={error} />
|
||||||
<p role="alert" className="text-danger text-sm">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<DialogFooter className="sm:justify-between">
|
<DialogFooter className="sm:justify-between">
|
||||||
@@ -458,12 +456,10 @@ function Toggle({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-2.5">
|
<div className="flex items-start gap-2.5">
|
||||||
<input
|
<Checkbox
|
||||||
id={id}
|
id={id}
|
||||||
type="checkbox"
|
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onChange={(event) => onChange(event.target.checked)}
|
onCheckedChange={(checked) => onChange(checked === true)}
|
||||||
className="border-ink-300 accent-ink-950 mt-0.5 size-4"
|
|
||||||
/>
|
/>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<label htmlFor={id} className="block text-sm font-medium">
|
<label htmlFor={id} className="block text-sm font-medium">
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
DataTableHeader,
|
DataTableHeader,
|
||||||
DataTableRow,
|
DataTableRow,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
|
FormError,
|
||||||
Input,
|
Input,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
@@ -114,11 +115,7 @@ export function DiscountsTable() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
<FormError message={error} />
|
||||||
<p role="alert" className="text-danger text-sm">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!discounts ? (
|
{!discounts ? (
|
||||||
<div className="space-y-2" aria-busy="true">
|
<div className="space-y-2" aria-busy="true">
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
DataTableHead,
|
DataTableHead,
|
||||||
DataTableHeader,
|
DataTableHeader,
|
||||||
DataTableRow,
|
DataTableRow,
|
||||||
|
FormError,
|
||||||
Input,
|
Input,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
cn,
|
cn,
|
||||||
@@ -106,11 +107,7 @@ export function InventoryTable() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
<FormError message={error} />
|
||||||
<p role="alert" className="text-danger text-sm">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!levels ? (
|
{!levels ? (
|
||||||
<div className="space-y-2" aria-busy="true">
|
<div className="space-y-2" aria-busy="true">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|||||||
|
|
||||||
import { isApiClientError } from '@sport/api-client';
|
import { isApiClientError } from '@sport/api-client';
|
||||||
import { PERMISSIONS, type MediaAssetSummary } from '@sport/types';
|
import { PERMISSIONS, type MediaAssetSummary } from '@sport/types';
|
||||||
import { Button, Skeleton } from '@sport/ui';
|
import { Button, FormError, Skeleton } from '@sport/ui';
|
||||||
|
|
||||||
import { useSession } from '@/features/auth/session-provider';
|
import { useSession } from '@/features/auth/session-provider';
|
||||||
import { browserApi } from '@/lib/api';
|
import { browserApi } from '@/lib/api';
|
||||||
@@ -145,11 +145,7 @@ export function MediaLibrary() {
|
|||||||
</ul>
|
</ul>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{error ? (
|
<FormError message={error} />
|
||||||
<p role="alert" className="text-danger text-sm">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!assets ? (
|
{!assets ? (
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-6" aria-busy="true">
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-6" aria-busy="true">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useEffect, useState } from 'react';
|
|||||||
|
|
||||||
import { isApiClientError } from '@sport/api-client';
|
import { isApiClientError } from '@sport/api-client';
|
||||||
import { PERMISSIONS, type Order, type OrderStatus } from '@sport/types';
|
import { PERMISSIONS, type Order, type OrderStatus } from '@sport/types';
|
||||||
import { Badge, Button, Input, Skeleton } from '@sport/ui';
|
import { Badge, Button, FormError, Input, Skeleton } from '@sport/ui';
|
||||||
|
|
||||||
import { useSession } from '@/features/auth/session-provider';
|
import { useSession } from '@/features/auth/session-provider';
|
||||||
import { browserApi } from '@/lib/api';
|
import { browserApi } from '@/lib/api';
|
||||||
@@ -103,11 +103,7 @@ export function OrderDetail({ orderId }: { orderId: string }) {
|
|||||||
<Badge variant="outline">{t(`payment.${order.paymentStatus}`)}</Badge>
|
<Badge variant="outline">{t(`payment.${order.paymentStatus}`)}</Badge>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{error ? (
|
<FormError message={error} />
|
||||||
<p role="alert" className="text-danger text-sm">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{can(PERMISSIONS.ORDER_UPDATE) && next.length > 0 ? (
|
{can(PERMISSIONS.ORDER_UPDATE) && next.length > 0 ? (
|
||||||
<section className="border-ink-200 space-y-3 border bg-white p-6">
|
<section className="border-ink-200 space-y-3 border bg-white p-6">
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
DataTableHead,
|
DataTableHead,
|
||||||
DataTableHeader,
|
DataTableHeader,
|
||||||
DataTableRow,
|
DataTableRow,
|
||||||
|
FormError,
|
||||||
Input,
|
Input,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
cn,
|
cn,
|
||||||
@@ -115,11 +116,7 @@ export function OrdersTable() {
|
|||||||
<span className="text-ink-500 ml-auto text-xs">{t('count', { count: total })}</span>
|
<span className="text-ink-500 ml-auto text-xs">{t('count', { count: total })}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
<FormError message={error} />
|
||||||
<p role="alert" className="text-danger text-sm">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!orders ? (
|
{!orders ? (
|
||||||
<div className="space-y-2" aria-busy="true">
|
<div className="space-y-2" aria-busy="true">
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
type AdminProductDetail,
|
type AdminProductDetail,
|
||||||
type MediaAssetSummary,
|
type MediaAssetSummary,
|
||||||
} from '@sport/types';
|
} from '@sport/types';
|
||||||
import { Button, Skeleton, cn } from '@sport/ui';
|
import { Button, FormError, Skeleton, cn } from '@sport/ui';
|
||||||
|
|
||||||
import { useSession } from '@/features/auth/session-provider';
|
import { useSession } from '@/features/auth/session-provider';
|
||||||
import { browserApi } from '@/lib/api';
|
import { browserApi } from '@/lib/api';
|
||||||
@@ -139,11 +139,7 @@ export function ImageManager({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
<FormError message={error} />
|
||||||
<p role="alert" className="text-danger text-sm">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{slots.length === 0 ? (
|
{slots.length === 0 ? (
|
||||||
<p className="text-ink-500 text-sm">{t('noImages')}</p>
|
<p className="text-ink-500 text-sm">{t('noImages')}</p>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useEffect, useState } from 'react';
|
|||||||
|
|
||||||
import { isApiClientError } from '@sport/api-client';
|
import { isApiClientError } from '@sport/api-client';
|
||||||
import { PERMISSIONS, type AdminProductDetail } from '@sport/types';
|
import { PERMISSIONS, type AdminProductDetail } from '@sport/types';
|
||||||
import { Badge, Button, Skeleton, cn } from '@sport/ui';
|
import { Badge, Button, FormError, Skeleton, cn } from '@sport/ui';
|
||||||
|
|
||||||
import { useSession } from '@/features/auth/session-provider';
|
import { useSession } from '@/features/auth/session-provider';
|
||||||
import { browserApi } from '@/lib/api';
|
import { browserApi } from '@/lib/api';
|
||||||
@@ -127,11 +127,7 @@ export function ProductEditor({ productId }: { productId: string }) {
|
|||||||
) : null}
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{error ? (
|
<FormError message={error} />
|
||||||
<p role="alert" className="text-danger text-sm">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<nav className="border-ink-200 flex gap-1 border-b">
|
<nav className="border-ink-200 flex gap-1 border-b">
|
||||||
{(['details', 'variants', 'images'] as const).map((value) => (
|
{(['details', 'variants', 'images'] as const).map((value) => (
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
type AdminProductDetail,
|
type AdminProductDetail,
|
||||||
type Locale,
|
type Locale,
|
||||||
} from '@sport/types';
|
} from '@sport/types';
|
||||||
import { Badge, Button, Input, cn } from '@sport/ui';
|
import { Badge, Button, FormError, Input, Textarea, cn } from '@sport/ui';
|
||||||
|
|
||||||
import { browserApi } from '@/lib/api';
|
import { browserApi } from '@/lib/api';
|
||||||
|
|
||||||
@@ -264,7 +264,7 @@ export function ProductForm({
|
|||||||
<span className="text-xs font-semibold uppercase tracking-widest">
|
<span className="text-xs font-semibold uppercase tracking-widest">
|
||||||
{t('description')}
|
{t('description')}
|
||||||
</span>
|
</span>
|
||||||
<textarea
|
<Textarea
|
||||||
rows={5}
|
rows={5}
|
||||||
value={translations[locale].description}
|
value={translations[locale].description}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -347,11 +347,7 @@ export function ProductForm({
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{error ? (
|
<FormError message={error} />
|
||||||
<p role="alert" className="text-danger text-sm">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button size="lg" disabled={saving} onClick={() => void save()}>
|
<Button size="lg" disabled={saving} onClick={() => void save()}>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
DataTableHead,
|
DataTableHead,
|
||||||
DataTableHeader,
|
DataTableHeader,
|
||||||
DataTableRow,
|
DataTableRow,
|
||||||
|
FormError,
|
||||||
Input,
|
Input,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
cn,
|
cn,
|
||||||
@@ -141,11 +142,7 @@ export function ProductsTable() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
<FormError message={error} />
|
||||||
<p role="alert" className="text-danger text-sm">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!items ? (
|
{!items ? (
|
||||||
<div className="space-y-2" aria-busy="true">
|
<div className="space-y-2" aria-busy="true">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { isApiClientError, type VariantPatch } from '@sport/api-client';
|
import { isApiClientError, type VariantPatch } from '@sport/api-client';
|
||||||
import { PERMISSIONS, type AdminProductDetail, type AdminProductVariant } from '@sport/types';
|
import { PERMISSIONS, type AdminProductDetail, type AdminProductVariant } from '@sport/types';
|
||||||
import { Badge, Button, Input, cn } from '@sport/ui';
|
import { Badge, Button, FormError, Input, cn } from '@sport/ui';
|
||||||
|
|
||||||
import { useSession } from '@/features/auth/session-provider';
|
import { useSession } from '@/features/auth/session-provider';
|
||||||
import { browserApi } from '@/lib/api';
|
import { browserApi } from '@/lib/api';
|
||||||
@@ -131,11 +131,7 @@ export function VariantGrid({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
<FormError message={error} />
|
||||||
<p role="alert" className="text-danger text-sm">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="border-ink-200 overflow-x-auto border bg-white">
|
<div className="border-ink-200 overflow-x-auto border bg-white">
|
||||||
<table className="min-w-3xl w-full text-sm">
|
<table className="min-w-3xl w-full text-sm">
|
||||||
|
|||||||
@@ -5,7 +5,16 @@ import { useCallback, useEffect, useState } from 'react';
|
|||||||
|
|
||||||
import { isApiClientError } from '@sport/api-client';
|
import { isApiClientError } from '@sport/api-client';
|
||||||
import { PERMISSIONS, type AdminReview, type ReviewStatus } from '@sport/types';
|
import { PERMISSIONS, type AdminReview, type ReviewStatus } from '@sport/types';
|
||||||
import { Badge, Button, EmptyState, Input, Rating, SegmentedControl, Skeleton } from '@sport/ui';
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
EmptyState,
|
||||||
|
FormError,
|
||||||
|
Input,
|
||||||
|
Rating,
|
||||||
|
SegmentedControl,
|
||||||
|
Skeleton,
|
||||||
|
} from '@sport/ui';
|
||||||
|
|
||||||
import { useSession } from '@/features/auth/session-provider';
|
import { useSession } from '@/features/auth/session-provider';
|
||||||
import { browserApi } from '@/lib/api';
|
import { browserApi } from '@/lib/api';
|
||||||
@@ -97,11 +106,7 @@ export function ReviewsQueue() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
<FormError message={error} />
|
||||||
<p role="alert" className="text-danger text-sm">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!reviews ? (
|
{!reviews ? (
|
||||||
<div className="space-y-2" aria-busy="true">
|
<div className="space-y-2" aria-busy="true">
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
Field,
|
Field,
|
||||||
|
FormError,
|
||||||
Input,
|
Input,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
cn,
|
cn,
|
||||||
@@ -171,11 +172,7 @@ export function TaxonomyManager<TRow extends TaxonomyRow>({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{error ? (
|
<FormError message={error} />
|
||||||
<p role="alert" className="text-danger text-sm">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!rows ? (
|
{!rows ? (
|
||||||
<div className="space-y-2" aria-busy="true">
|
<div className="space-y-2" aria-busy="true">
|
||||||
@@ -437,11 +434,7 @@ function TaxonomyDialog<TRow extends TaxonomyRow>({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
<FormError message={error} />
|
||||||
<p role="alert" className="text-danger text-sm">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<DialogFooter className="sm:justify-between">
|
<DialogFooter className="sm:justify-between">
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
{
|
{
|
||||||
"extends": "@sport/config/typescript/nextjs.json",
|
"extends": "@sport/config/typescript/nextjs.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
{
|
{
|
||||||
"extends": "@sport/config/typescript/nestjs.json",
|
"extends": "@sport/config/typescript/nestjs.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"]
|
"@/*": ["./src/*"]
|
||||||
},
|
},
|
||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Image from 'next/image';
|
|||||||
import { useFormatter, useTranslations } from 'next-intl';
|
import { useFormatter, useTranslations } from 'next-intl';
|
||||||
|
|
||||||
import { CART_NOTICE_REASONS } from '@sport/types';
|
import { CART_NOTICE_REASONS } from '@sport/types';
|
||||||
import { Button, Skeleton } from '@sport/ui';
|
import { Button, FormError, Skeleton } from '@sport/ui';
|
||||||
|
|
||||||
import { useCart } from '@/features/cart/cart-provider';
|
import { useCart } from '@/features/cart/cart-provider';
|
||||||
import { Link } from '@/i18n/navigation';
|
import { Link } from '@/i18n/navigation';
|
||||||
@@ -76,11 +76,7 @@ export function CartView() {
|
|||||||
</ul>
|
</ul>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{error ? (
|
<FormError message={error} />
|
||||||
<p role="alert" className="text-danger text-sm">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<ul className="divide-ink-200 divide-y">
|
<ul className="divide-ink-200 divide-y">
|
||||||
{cart.lines.map((line) => (
|
{cart.lines.map((line) => (
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { isApiClientError, type ProductListQuery } from '@sport/api-client';
|
import { isApiClientError, type ProductListQuery } from '@sport/api-client';
|
||||||
import type { Locale, ProductListItem } from '@sport/types';
|
import type { Locale, ProductListItem } from '@sport/types';
|
||||||
import { Button } from '@sport/ui';
|
import { Button, FormError } from '@sport/ui';
|
||||||
|
|
||||||
import { Link } from '@/i18n/navigation';
|
import { Link } from '@/i18n/navigation';
|
||||||
import { browserApi } from '@/lib/api';
|
import { browserApi } from '@/lib/api';
|
||||||
@@ -88,11 +88,7 @@ export function LoadMore({
|
|||||||
{t('showing', { shown, total: totalCount })}
|
{t('showing', { shown, total: totalCount })}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{error ? (
|
<FormError message={error} />
|
||||||
<p role="alert" className="text-danger text-sm">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{cursor ? (
|
{cursor ? (
|
||||||
<Button variant="outline" size="lg" asChild>
|
<Button variant="outline" size="lg" asChild>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useEffect, useState } from 'react';
|
|||||||
|
|
||||||
import { isApiClientError } from '@sport/api-client';
|
import { isApiClientError } from '@sport/api-client';
|
||||||
import type { Locale, ReviewableItem } from '@sport/types';
|
import type { Locale, ReviewableItem } from '@sport/types';
|
||||||
import { Button, Input, cn } from '@sport/ui';
|
import { Button, FormError, Input, Textarea, cn } from '@sport/ui';
|
||||||
|
|
||||||
import { browserApi } from '@/lib/api';
|
import { browserApi } from '@/lib/api';
|
||||||
|
|
||||||
@@ -155,21 +155,16 @@ function ItemReview({
|
|||||||
aria-label={t('titlePlaceholder')}
|
aria-label={t('titlePlaceholder')}
|
||||||
maxLength={140}
|
maxLength={140}
|
||||||
/>
|
/>
|
||||||
<textarea
|
<Textarea
|
||||||
value={body}
|
value={body}
|
||||||
onChange={(event) => setBody(event.target.value)}
|
onChange={(event) => setBody(event.target.value)}
|
||||||
placeholder={t('bodyPlaceholder')}
|
placeholder={t('bodyPlaceholder')}
|
||||||
aria-label={t('bodyPlaceholder')}
|
aria-label={t('bodyPlaceholder')}
|
||||||
maxLength={2000}
|
maxLength={2000}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="border-ink-200 focus:border-ink-950 w-full border px-3 py-2 text-sm outline-none"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error ? (
|
<FormError message={error} />
|
||||||
<p role="alert" className="text-danger text-xs">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Button size="sm" disabled={saving || !authorName.trim()} onClick={submit}>
|
<Button size="sm" disabled={saving || !authorName.trim()} onClick={submit}>
|
||||||
{saving ? t('sending') : t('submit')}
|
{saving ? t('sending') : t('submit')}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type {
|
|||||||
OrderListItem,
|
OrderListItem,
|
||||||
ProductListItem,
|
ProductListItem,
|
||||||
} from '@sport/types';
|
} from '@sport/types';
|
||||||
import { Badge, Button, EmptyState, Input, Skeleton } from '@sport/ui';
|
import { Badge, Button, EmptyState, FormError, Input, Skeleton } from '@sport/ui';
|
||||||
|
|
||||||
import { Link } from '@/i18n/navigation';
|
import { Link } from '@/i18n/navigation';
|
||||||
import { browserApi } from '@/lib/api';
|
import { browserApi } from '@/lib/api';
|
||||||
@@ -115,11 +115,7 @@ export function AccountOrders() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h1 className="text-2xl font-black uppercase tracking-tight">{t('nav.orders')}</h1>
|
<h1 className="text-2xl font-black uppercase tracking-tight">{t('nav.orders')}</h1>
|
||||||
|
|
||||||
{error ? (
|
<FormError message={error} />
|
||||||
<p role="alert" className="text-danger text-sm">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!data ? (
|
{!data ? (
|
||||||
<Skeleton className="h-40 w-full" />
|
<Skeleton className="h-40 w-full" />
|
||||||
@@ -244,11 +240,7 @@ export function AccountAddresses() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
<FormError message={error} />
|
||||||
<p role="alert" className="text-danger text-sm">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{draft ? (
|
{draft ? (
|
||||||
<form onSubmit={save} className="border-ink-200 space-y-4 border p-5">
|
<form onSubmit={save} className="border-ink-200 space-y-4 border p-5">
|
||||||
@@ -301,11 +293,7 @@ export function AccountAddresses() {
|
|||||||
<span className="text-sm">{t('makeDefault')}</span>
|
<span className="text-sm">{t('makeDefault')}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{formError ? (
|
<FormError message={formError} />
|
||||||
<p role="alert" className="text-danger text-sm">
|
|
||||||
{formError}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button type="submit" size="sm" disabled={busy}>
|
<Button type="submit" size="sm" disabled={busy}>
|
||||||
@@ -469,11 +457,7 @@ export function AccountProfile() {
|
|||||||
<span className="text-ink-600 text-sm">{t('marketing')}</span>
|
<span className="text-ink-600 text-sm">{t('marketing')}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{formError ? (
|
<FormError message={formError} />
|
||||||
<p role="alert" className="text-danger text-sm">
|
|
||||||
{formError}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{saved ? <p className="text-success text-sm">{t('saved')}</p> : null}
|
{saved ? <p className="text-success text-sm">{t('saved')}</p> : null}
|
||||||
|
|
||||||
<Button type="submit" disabled={busy}>
|
<Button type="submit" disabled={busy}>
|
||||||
@@ -517,11 +501,7 @@ export function AccountWishlist() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h1 className="text-2xl font-black uppercase tracking-tight">{t('nav.wishlist')}</h1>
|
<h1 className="text-2xl font-black uppercase tracking-tight">{t('nav.wishlist')}</h1>
|
||||||
|
|
||||||
{error ? (
|
<FormError message={error} />
|
||||||
<p role="alert" className="text-danger text-sm">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!data ? (
|
{!data ? (
|
||||||
<Skeleton className="h-32 w-full" />
|
<Skeleton className="h-32 w-full" />
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl';
|
|||||||
import { useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
|
|
||||||
import { isApiClientError } from '@sport/api-client';
|
import { isApiClientError } from '@sport/api-client';
|
||||||
import { Button, Input } from '@sport/ui';
|
import { Button, Checkbox, FormError, Input } from '@sport/ui';
|
||||||
|
|
||||||
import { Link } from '@/i18n/navigation';
|
import { Link } from '@/i18n/navigation';
|
||||||
import { routes } from '@/lib/routes';
|
import { routes } from '@/lib/routes';
|
||||||
@@ -152,22 +152,16 @@ export function AuthForm({ mode }: { mode: 'signIn' | 'register' }) {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="flex items-start gap-2.5">
|
<label className="flex items-start gap-2.5">
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
|
||||||
checked={acceptsMarketing}
|
checked={acceptsMarketing}
|
||||||
onChange={(event) => setAcceptsMarketing(event.target.checked)}
|
onCheckedChange={(checked) => setAcceptsMarketing(checked === true)}
|
||||||
className="border-ink-300 accent-ink-950 mt-0.5 size-4"
|
|
||||||
/>
|
/>
|
||||||
<span className="text-ink-600 text-sm">{t('marketing')}</span>
|
<span className="text-ink-600 text-sm">{t('marketing')}</span>
|
||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{error ? (
|
<FormError message={error} />
|
||||||
<p role="alert" className="text-danger text-sm">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Button type="submit" disabled={busy} className="w-full">
|
<Button type="submit" disabled={busy} className="w-full">
|
||||||
{busy ? t('working') : isRegister ? t('createAccount') : t('signIn')}
|
{busy ? t('working') : isRegister ? t('createAccount') : t('signIn')}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
{
|
{
|
||||||
"extends": "@sport/config/typescript/nextjs.json",
|
"extends": "@sport/config/typescript/nextjs.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
|
|||||||
@@ -15,7 +15,9 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "1.31.0",
|
"lucide-react": "1.31.0",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"radix-ui": "1.6.7",
|
"radix-ui": "1.6.7",
|
||||||
|
"sonner": "^2.0.8",
|
||||||
"tailwind-merge": "^3.4.0"
|
"tailwind-merge": "^3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
'relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-card text-card-foreground',
|
||||||
|
destructive:
|
||||||
|
'bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription };
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Avatar as AvatarPrimitive } from 'radix-ui';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
function Avatar({
|
||||||
|
className,
|
||||||
|
size = 'default',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||||
|
size?: 'default' | 'sm' | 'lg';
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
data-slot="avatar"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
'group/avatar relative flex size-8 shrink-0 select-none overflow-hidden rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
data-slot="avatar-image"
|
||||||
|
className={cn('aspect-square size-full', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn(
|
||||||
|
'bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="avatar-badge"
|
||||||
|
className={cn(
|
||||||
|
'bg-primary text-primary-foreground ring-background absolute bottom-0 right-0 z-10 inline-flex select-none items-center justify-center rounded-full ring-2',
|
||||||
|
'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden',
|
||||||
|
'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2',
|
||||||
|
'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group"
|
||||||
|
className={cn(
|
||||||
|
'group/avatar-group *:data-[slot=avatar]:ring-background flex -space-x-2 *:data-[slot=avatar]:ring-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroupCount({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group-count"
|
||||||
|
className={cn(
|
||||||
|
'bg-muted text-muted-foreground ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 relative flex size-8 shrink-0 items-center justify-center rounded-full text-sm ring-2 [&>svg]:size-4',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback, AvatarBadge, AvatarGroup, AvatarGroupCount };
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||||
|
import { Slot } from 'radix-ui';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
|
||||||
|
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
|
||||||
|
return (
|
||||||
|
<ol
|
||||||
|
data-slot="breadcrumb-list"
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground flex flex-wrap items-center gap-1.5 break-words text-sm sm:gap-2.5',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-item"
|
||||||
|
className={cn('inline-flex items-center gap-1.5', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbLink({
|
||||||
|
asChild,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'a'> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : 'a';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="breadcrumb-link"
|
||||||
|
className={cn('hover:text-foreground transition-colors', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-page"
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn('text-foreground font-normal', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<'li'>) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-separator"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn('[&>svg]:size-3.5', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <ChevronRight />}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<'span'>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-ellipsis"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn('flex size-9 items-center justify-center', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
};
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
'@container/card-header has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6 grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn('font-semibold leading-none', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn('text-muted-foreground text-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn('[.border-t]:pt-6 flex items-center px-6', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { CheckIcon } from 'lucide-react';
|
||||||
|
import { Checkbox as CheckboxPrimitive } from 'radix-ui';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
'border-input shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary peer size-4 shrink-0 rounded-[4px] border outline-none transition-shadow focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="grid place-content-center text-current transition-none"
|
||||||
|
>
|
||||||
|
<CheckIcon className="size-3.5" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox };
|
||||||
@@ -1,20 +1,21 @@
|
|||||||
import type { ReactNode, ThHTMLAttributes, TdHTMLAttributes } from 'react';
|
import type { ReactNode, TdHTMLAttributes, ThHTMLAttributes } from 'react';
|
||||||
|
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './table';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The admin's table shell.
|
* The admin's table shell, on top of shadcn's `Table`.
|
||||||
*
|
*
|
||||||
* Seven screens had hand-written `<table>` markup with the same border, header
|
* These are not a replacement for the registry component — they *are* the
|
||||||
* background and eyebrow-cased column labels — and small divergences in each:
|
* registry component, with this project's three decisions baked in so seven
|
||||||
* different header text sizes, two different row hover colours, and one table
|
* screens stop repeating them: a bordered card that scrolls itself rather than
|
||||||
* that scrolled the whole page sideways on mobile instead of scrolling itself.
|
* the page, an ink-50 header row, and eyebrow-cased column labels.
|
||||||
*
|
*
|
||||||
* These are thin primitives rather than a generic `<DataTable columns={…}>`.
|
* Deliberately thin primitives rather than a generic `<DataTable columns={…}>`.
|
||||||
* A column-config table is the wrong abstraction here: every one of these
|
* Every screen here renders bespoke cells — badges, stacked name+SKU, action
|
||||||
* screens renders bespoke cells (badges, stacked name+SKU, action buttons),
|
* buttons — and a column-config API would immediately grow a `render` escape
|
||||||
* and a config API would immediately grow a `render` escape hatch for each —
|
* hatch for each, at which point it is JSX with extra steps.
|
||||||
* at which point it is JSX with extra steps.
|
|
||||||
*/
|
*/
|
||||||
export function DataTable({
|
export function DataTable({
|
||||||
children,
|
children,
|
||||||
@@ -23,32 +24,30 @@ export function DataTable({
|
|||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
/** Below this the wrapper scrolls rather than the page. */
|
/** Below this the wrapper scrolls, not the page. */
|
||||||
minWidth?: string;
|
minWidth?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('border-ink-200 rounded-card overflow-x-auto border bg-white', className)}>
|
<div className={cn('border-ink-200 rounded-card overflow-x-auto border bg-white', className)}>
|
||||||
<table className="w-full text-sm" style={{ minWidth }}>
|
<Table style={{ minWidth }}>{children}</Table>
|
||||||
{children}
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataTableHead({ children }: { children: ReactNode }) {
|
export function DataTableHead({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<thead className="border-ink-200 bg-ink-50 border-b text-left">
|
<TableHeader className="border-ink-200 bg-ink-50 border-b">
|
||||||
<tr>{children}</tr>
|
<TableRow className="hover:bg-transparent">{children}</TableRow>
|
||||||
</thead>
|
</TableHeader>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataTableBody({ children }: { children: ReactNode }) {
|
export function DataTableBody({ children }: { children: ReactNode }) {
|
||||||
return <tbody className="divide-ink-100 divide-y">{children}</tbody>;
|
return <TableBody className="divide-ink-100 divide-y">{children}</TableBody>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataTableRow({ children, className }: { children: ReactNode; className?: string }) {
|
export function DataTableRow({ children, className }: { children: ReactNode; className?: string }) {
|
||||||
return <tr className={cn('hover:bg-ink-50/60 transition-colors', className)}>{children}</tr>;
|
return <TableRow className={cn('hover:bg-ink-50/60', className)}>{children}</TableRow>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataTableHeader({
|
export function DataTableHeader({
|
||||||
@@ -57,13 +56,13 @@ export function DataTableHeader({
|
|||||||
...props
|
...props
|
||||||
}: ThHTMLAttributes<HTMLTableCellElement>) {
|
}: ThHTMLAttributes<HTMLTableCellElement>) {
|
||||||
return (
|
return (
|
||||||
<th
|
<TableHead
|
||||||
scope="col"
|
scope="col"
|
||||||
className={cn('text-eyebrow text-ink-500 px-4 py-3 uppercase', className)}
|
className={cn('text-eyebrow text-ink-500 h-auto px-4 py-3 uppercase', className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</th>
|
</TableHead>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,8 +72,8 @@ export function DataTableCell({
|
|||||||
...props
|
...props
|
||||||
}: TdHTMLAttributes<HTMLTableCellElement>) {
|
}: TdHTMLAttributes<HTMLTableCellElement>) {
|
||||||
return (
|
return (
|
||||||
<td className={cn('px-4 py-3 align-middle', className)} {...props}>
|
<TableCell className={cn('px-4 py-3 align-middle', className)} {...props}>
|
||||||
{children}
|
{children}
|
||||||
</td>
|
</TableCell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useId, type ReactNode } from 'react';
|
|||||||
|
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
import { Label } from './label';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A labelled form field.
|
* A labelled form field.
|
||||||
*
|
*
|
||||||
@@ -47,7 +49,7 @@ export function Field({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('space-y-1.5', className)}>
|
<div className={cn('space-y-1.5', className)}>
|
||||||
<label htmlFor={id} className="text-eyebrow text-ink-950 block uppercase">
|
<Label htmlFor={id} className="text-eyebrow text-ink-950 block uppercase">
|
||||||
{label}
|
{label}
|
||||||
{required ? (
|
{required ? (
|
||||||
<span aria-hidden className="text-danger ml-0.5">
|
<span aria-hidden className="text-danger ml-0.5">
|
||||||
@@ -59,7 +61,7 @@ export function Field({
|
|||||||
{optionalLabel}
|
{optionalLabel}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</label>
|
</Label>
|
||||||
|
|
||||||
{children({
|
{children({
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
import { Alert, AlertDescription } from './alert';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one way this product shows a form or request error.
|
||||||
|
*
|
||||||
|
* Twenty-seven files had grown their own `<p role="alert" className="text-danger
|
||||||
|
* text-sm">`, which is *almost* right — the role is there — but they differed in
|
||||||
|
* size, spacing and whether an icon appeared, so the same failure looked like
|
||||||
|
* three different severities depending on the screen.
|
||||||
|
*
|
||||||
|
* Renders nothing when there is no message, so callers can drop it in
|
||||||
|
* unconditionally instead of repeating a ternary.
|
||||||
|
*/
|
||||||
|
export function FormError({ message, className }: { message?: string | null; className?: string }) {
|
||||||
|
if (!message) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert variant="destructive" className={cn('border-danger/30 bg-danger/5', className)}>
|
||||||
|
<AlertCircle />
|
||||||
|
<AlertDescription>{message}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Label as LabelPrimitive } from 'radix-ui';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
'flex select-none items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label };
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from 'lucide-react';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
import { buttonVariants, type Button } from './button';
|
||||||
|
|
||||||
|
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
role="navigation"
|
||||||
|
aria-label="pagination"
|
||||||
|
data-slot="pagination"
|
||||||
|
className={cn('mx-auto flex w-full justify-center', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationContent({ className, ...props }: React.ComponentProps<'ul'>) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="pagination-content"
|
||||||
|
className={cn('flex flex-row items-center gap-1', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationItem({ ...props }: React.ComponentProps<'li'>) {
|
||||||
|
return <li data-slot="pagination-item" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginationLinkProps = {
|
||||||
|
isActive?: boolean;
|
||||||
|
} & Pick<React.ComponentProps<typeof Button>, 'size'> &
|
||||||
|
React.ComponentProps<'a'>;
|
||||||
|
|
||||||
|
// `children` is destructured rather than left in `...props` purely so
|
||||||
|
// jsx-a11y/anchor-has-content can see it — the registry spreads it, and the
|
||||||
|
// rule cannot follow that. Behaviour is unchanged.
|
||||||
|
function PaginationLink({
|
||||||
|
className,
|
||||||
|
isActive,
|
||||||
|
size = 'icon',
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: PaginationLinkProps) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
data-slot="pagination-link"
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({
|
||||||
|
variant: isActive ? 'outline' : 'ghost',
|
||||||
|
size,
|
||||||
|
}),
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationPrevious({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to previous page"
|
||||||
|
size="default"
|
||||||
|
className={cn('gap-1 px-2.5 sm:pl-2.5', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon />
|
||||||
|
<span className="hidden sm:block">Previous</span>
|
||||||
|
</PaginationLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationNext({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to next page"
|
||||||
|
size="default"
|
||||||
|
className={cn('gap-1 px-2.5 sm:pr-2.5', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="hidden sm:block">Next</span>
|
||||||
|
<ChevronRightIcon />
|
||||||
|
</PaginationLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationEllipsis({ className, ...props }: React.ComponentProps<'span'>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
data-slot="pagination-ellipsis"
|
||||||
|
className={cn('flex size-9 items-center justify-center', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon className="size-4" />
|
||||||
|
<span className="sr-only">More pages</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationPrevious,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationEllipsis,
|
||||||
|
};
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Popover as PopoverPrimitive } from 'radix-ui';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = 'center',
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
data-slot="popover-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'origin-(--radix-popover-content-transform-origin) bg-popover text-popover-foreground outline-hidden 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 w-72 rounded-md border p-4 shadow-md',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||||
|
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="popover-header"
|
||||||
|
className={cn('flex flex-col gap-1 text-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTitle({ className, ...props }: React.ComponentProps<'h2'>) {
|
||||||
|
return <div data-slot="popover-title" className={cn('font-medium', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="popover-description"
|
||||||
|
className={cn('text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Popover,
|
||||||
|
PopoverTrigger,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverAnchor,
|
||||||
|
PopoverHeader,
|
||||||
|
PopoverTitle,
|
||||||
|
PopoverDescription,
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { CircleIcon } from 'lucide-react';
|
||||||
|
import { RadioGroup as RadioGroupPrimitive } from 'radix-ui';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
function RadioGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
data-slot="radio-group"
|
||||||
|
className={cn('grid gap-3', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadioGroupItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
data-slot="radio-group-item"
|
||||||
|
className={cn(
|
||||||
|
'border-input text-primary shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40 aspect-square size-4 shrink-0 rounded-full border outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator
|
||||||
|
data-slot="radio-group-indicator"
|
||||||
|
className="relative flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<CircleIcon className="fill-primary absolute left-1/2 top-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem };
|
||||||
@@ -8,18 +8,25 @@ export interface SegmentedOption<T extends string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A row of mutually exclusive filters.
|
* A row of mutually exclusive filters, as one joined pill.
|
||||||
*
|
*
|
||||||
* Extracted from seven near-identical copies across the admin (discounts,
|
* Hand-rolled ARIA rather than shadcn's `RadioGroup`, and this is the third
|
||||||
* reviews, content, orders, products) that had drifted: some used
|
* attempt — the first two are worth recording so nobody repeats them:
|
||||||
* `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
|
* 1. shadcn's `RadioGroupItem` hard-codes its children as a circular
|
||||||
* exclusive, and that is what tells a screen reader "pick one of these" rather
|
* indicator and *ignores* anything passed to it. The labels rendered as
|
||||||
* than "here are five independent switches". Arrow keys move between options,
|
* blank 16px boxes.
|
||||||
* which is the native behaviour keyboard users expect from a radio group.
|
* 2. Dropping to `RadioGroupPrimitive` fixed the labels, and then every item
|
||||||
|
* came out with `tabIndex="-1"` — Radix's roving focus did not initialise,
|
||||||
|
* so the whole control became unreachable by keyboard. Worse than what it
|
||||||
|
* replaced, and invisible unless you actually measure it.
|
||||||
|
*
|
||||||
|
* So the roving tabindex is explicit here. It is about fifteen lines, it is
|
||||||
|
* verifiable, and it does what a radio group is supposed to do: one tab stop
|
||||||
|
* for the group, arrows to move within it.
|
||||||
|
*
|
||||||
|
* This is the exception, not the pattern. Everything the registry covers well
|
||||||
|
* — dialogs, selects, tooltips, tables — uses the registry.
|
||||||
*/
|
*/
|
||||||
export function SegmentedControl<T extends string>({
|
export function SegmentedControl<T extends string>({
|
||||||
value,
|
value,
|
||||||
@@ -32,8 +39,8 @@ export function SegmentedControl<T extends string>({
|
|||||||
value: T;
|
value: T;
|
||||||
onChange: (value: T) => void;
|
onChange: (value: T) => void;
|
||||||
options: readonly SegmentedOption<T>[];
|
options: readonly SegmentedOption<T>[];
|
||||||
/** Names the group for assistive tech. Required — an unlabelled filter row
|
/** Names the group. Required — an unlabelled filter row is announced as a
|
||||||
* is announced as a bare list of buttons. */
|
* bare list of buttons. */
|
||||||
label: string;
|
label: string;
|
||||||
size?: 'sm' | 'md';
|
size?: 'sm' | 'md';
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -43,7 +50,7 @@ export function SegmentedControl<T extends string>({
|
|||||||
if (delta === 0) return;
|
if (delta === 0) return;
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
// Wraps, which is what a radio group does natively.
|
// Wraps, the way a native radio group does.
|
||||||
const next = options[(index + delta + options.length) % options.length];
|
const next = options[(index + delta + options.length) % options.length];
|
||||||
if (next) onChange(next.value);
|
if (next) onChange(next.value);
|
||||||
}
|
}
|
||||||
@@ -69,8 +76,8 @@ export function SegmentedControl<T extends string>({
|
|||||||
onClick={() => onChange(option.value)}
|
onClick={() => onChange(option.value)}
|
||||||
onKeyDown={(event) => handleKeyDown(event, index)}
|
onKeyDown={(event) => handleKeyDown(event, index)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'duration-(--duration-base) cursor-pointer transition-colors',
|
'duration-(--duration-base) cursor-pointer whitespace-nowrap transition-colors',
|
||||||
'focus-visible:outline-ink-950 focus-visible:z-10 focus-visible:outline-2',
|
'focus-visible:ring-ring/50 focus-visible:z-10 focus-visible:outline-none focus-visible:ring-[3px]',
|
||||||
size === 'sm' ? 'px-2.5 py-1.5 text-xs' : 'px-3 py-2 text-sm',
|
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',
|
active ? 'bg-ink-950 text-white' : 'hover:bg-ink-100 bg-white',
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
|
||||||
|
import { Select as SelectPrimitive } from 'radix-ui';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = 'default',
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: 'sm' | 'default';
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"border-input shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='text-'])]:text-muted-foreground flex w-fit items-center justify-between gap-2 whitespace-nowrap rounded-md border bg-transparent px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = 'item-aligned',
|
||||||
|
align = 'center',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
'max-h-(--radix-select-content-available-height) origin-(--radix-select-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 relative z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border shadow-md',
|
||||||
|
position === 'popper' &&
|
||||||
|
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
align={align}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
'p-1',
|
||||||
|
position === 'popper' &&
|
||||||
|
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"outline-hidden focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-8 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
|
||||||
|
data-slot="select-item-indicator"
|
||||||
|
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Separator as SeparatorPrimitive } from 'radix-ui';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = 'horizontal',
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CircleCheckIcon,
|
||||||
|
InfoIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
OctagonXIcon,
|
||||||
|
TriangleAlertIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Toaster as Sonner, type ToasterProps } from 'sonner';
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = 'system' } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps['theme']}
|
||||||
|
className="toaster group"
|
||||||
|
icons={{
|
||||||
|
success: <CircleCheckIcon className="size-4" />,
|
||||||
|
info: <InfoIcon className="size-4" />,
|
||||||
|
warning: <TriangleAlertIcon className="size-4" />,
|
||||||
|
error: <OctagonXIcon className="size-4" />,
|
||||||
|
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||||
|
}}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--normal-bg': 'var(--popover)',
|
||||||
|
'--normal-text': 'var(--popover-foreground)',
|
||||||
|
'--normal-border': 'var(--border)',
|
||||||
|
'--border-radius': 'var(--radius)',
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { Toaster };
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Switch as SwitchPrimitive } from 'radix-ui';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
function Switch({
|
||||||
|
className,
|
||||||
|
size = 'default',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
||||||
|
size?: 'sm' | 'default';
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
'group/switch shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80 peer inline-flex shrink-0 items-center rounded-full border border-transparent outline-none transition-all focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=sm]:h-3.5 data-[size=default]:w-8 data-[size=sm]:w-6',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className={cn(
|
||||||
|
'bg-background dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground pointer-events-none block rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch };
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<'table'>) {
|
||||||
|
return (
|
||||||
|
<div data-slot="table-container" className="relative w-full overflow-x-auto">
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn('w-full caption-bottom text-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
|
||||||
|
return <thead data-slot="table-header" className={cn('[&_tr]:border-b', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn('[&_tr:last-child]:border-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
'hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
'text-foreground h-10 whitespace-nowrap px-2 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
'whitespace-nowrap p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({ className, ...props }: React.ComponentProps<'caption'>) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn('text-muted-foreground mt-4 text-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
'field-sizing-content border-input shadow-xs placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40 flex min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Tooltip as TooltipPrimitive } from 'radix-ui';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delayDuration = 0,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delayDuration={delayDuration}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
|
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'origin-(--radix-tooltip-content-transform-origin) animate-in bg-foreground text-background fade-in-0 zoom-in-95 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 z-50 w-fit text-balance rounded-md px-3 py-1.5 text-xs',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||||
@@ -91,10 +91,93 @@ export { Tabs, TabsContent, TabsList, TabsTrigger, tabsListVariants } from './co
|
|||||||
* that had already drifted across the storefront and the admin. They live here
|
* that had already drifted across the storefront and the admin. They live here
|
||||||
* rather than in either app because both use them.
|
* rather than in either app because both use them.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* ---- shadcn registry -------------------------------------------------------
|
||||||
|
*
|
||||||
|
* Added with `npx shadcn@latest add …` and kept as close to the registry as
|
||||||
|
* possible, so anything copied from ui.shadcn.com works unedited.
|
||||||
|
*
|
||||||
|
* TWO EDITS ARE APPLIED TO EVERY COMPONENT THE CLI WRITES, and they are not
|
||||||
|
* optional:
|
||||||
|
*
|
||||||
|
* 1. `@/lib/utils` becomes a relative import. These files are compiled by the
|
||||||
|
* *apps* via `transpilePackages`, where `@/*` resolves to the app's own
|
||||||
|
* `src` — so the alias silently points at the wrong file.
|
||||||
|
* 2. Quotes and semicolons are normalised by Prettier; the registry ships a
|
||||||
|
* different style.
|
||||||
|
*
|
||||||
|
* Re-running the CLI over an existing component OVERWRITES it. `button.tsx`
|
||||||
|
* carries deliberate departures (square corners, the volt `accent` variant, a
|
||||||
|
* transparent `outline` that stays legible on the dark hero) and was silently
|
||||||
|
* reverted once already. Use `--overwrite` only when you mean it, and re-check
|
||||||
|
* the diff afterwards.
|
||||||
|
*/
|
||||||
|
export { Alert, AlertDescription, AlertTitle } from './components/ui/alert';
|
||||||
|
export { Avatar, AvatarFallback, AvatarImage } from './components/ui/avatar';
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
} from './components/ui/breadcrumb';
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardAction,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from './components/ui/card';
|
||||||
|
export { Checkbox } from './components/ui/checkbox';
|
||||||
|
export { Label } from './components/ui/label';
|
||||||
|
export {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from './components/ui/pagination';
|
||||||
|
export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from './components/ui/popover';
|
||||||
|
export { RadioGroup, RadioGroupItem } from './components/ui/radio-group';
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from './components/ui/select';
|
||||||
|
export { Separator } from './components/ui/separator';
|
||||||
|
export { Toaster } from './components/ui/sonner';
|
||||||
|
export { Switch } from './components/ui/switch';
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCaption,
|
||||||
|
TableCell,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from './components/ui/table';
|
||||||
|
export { Textarea } from './components/ui/textarea';
|
||||||
|
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './components/ui/tooltip';
|
||||||
|
|
||||||
export { SegmentedControl } from './components/ui/segmented-control';
|
export { SegmentedControl } from './components/ui/segmented-control';
|
||||||
export type { SegmentedOption } from './components/ui/segmented-control';
|
export type { SegmentedOption } from './components/ui/segmented-control';
|
||||||
export { Rating } from './components/ui/rating';
|
export { Rating } from './components/ui/rating';
|
||||||
export { EmptyState } from './components/ui/empty-state';
|
export { EmptyState } from './components/ui/empty-state';
|
||||||
|
export { FormError } from './components/ui/form-error';
|
||||||
export { Field } from './components/ui/field';
|
export { Field } from './components/ui/field';
|
||||||
export { SkipLink } from './components/ui/skip-link';
|
export { SkipLink } from './components/ui/skip-link';
|
||||||
export {
|
export {
|
||||||
|
|||||||
Generated
+34
@@ -444,9 +444,15 @@ importers:
|
|||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: 1.31.0
|
specifier: 1.31.0
|
||||||
version: 1.31.0(react@19.2.8)
|
version: 1.31.0(react@19.2.8)
|
||||||
|
next-themes:
|
||||||
|
specifier: ^0.4.6
|
||||||
|
version: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||||
radix-ui:
|
radix-ui:
|
||||||
specifier: 1.6.7
|
specifier: 1.6.7
|
||||||
version: 1.6.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
version: 1.6.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||||
|
sonner:
|
||||||
|
specifier: ^2.0.8
|
||||||
|
version: 2.0.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.4.0
|
specifier: ^3.4.0
|
||||||
version: 3.6.0
|
version: 3.6.0
|
||||||
@@ -5252,6 +5258,12 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
next-themes@0.4.6:
|
||||||
|
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||||
|
|
||||||
next@16.3.0:
|
next@16.3.0:
|
||||||
resolution: {integrity: sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==}
|
resolution: {integrity: sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==}
|
||||||
engines: {node: '>=20.9.0'}
|
engines: {node: '>=20.9.0'}
|
||||||
@@ -5905,6 +5917,16 @@ packages:
|
|||||||
sonic-boom@4.2.1:
|
sonic-boom@4.2.1:
|
||||||
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
|
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
|
||||||
|
|
||||||
|
sonner@2.0.8:
|
||||||
|
resolution: {integrity: sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': ^18.0.0 || ^19.0.0
|
||||||
|
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
|
||||||
source-map-js@1.2.1:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -11901,6 +11923,11 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@swc/helpers'
|
- '@swc/helpers'
|
||||||
|
|
||||||
|
next-themes@0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.8
|
||||||
|
react-dom: 19.2.8(react@19.2.8)
|
||||||
|
|
||||||
next@16.3.0(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
|
next@16.3.0(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/env': 16.3.0
|
'@next/env': 16.3.0
|
||||||
@@ -12676,6 +12703,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
atomic-sleep: 1.0.0
|
atomic-sleep: 1.0.0
|
||||||
|
|
||||||
|
sonner@2.0.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.8
|
||||||
|
react-dom: 19.2.8(react@19.2.8)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.18
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
source-map-support@0.5.13:
|
source-map-support@0.5.13:
|
||||||
|
|||||||
Reference in New Issue
Block a user