@tuturuuu/ui 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/package.json +41 -34
- package/src/components/ui/currency-input.tsx +65 -23
- package/src/components/ui/custom/__tests__/sidebar-context.test.tsx +64 -0
- package/src/components/ui/custom/__tests__/sidebar-remote-behavior-bridge.test.tsx +109 -0
- package/src/components/ui/custom/combobox.test.tsx +141 -0
- package/src/components/ui/custom/combobox.tsx +105 -36
- package/src/components/ui/custom/settings/task-settings.tsx +50 -0
- package/src/components/ui/custom/settings/task-sound-settings.test.tsx +21 -1
- package/src/components/ui/custom/sidebar-context.tsx +68 -6
- package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +21 -2
- package/src/components/ui/finance/finance-layout.tsx +2 -4
- package/src/components/ui/finance/shared/balance-mode-toggle.tsx +35 -0
- package/src/components/ui/finance/shared/finance-layout-controls.tsx +43 -0
- package/src/components/ui/finance/shared/quick-actions.tsx +14 -6
- package/src/components/ui/finance/shared/use-finance-balance-mode.ts +72 -0
- package/src/components/ui/finance/shared/wallet-balance-mode.test.ts +66 -0
- package/src/components/ui/finance/shared/wallet-balance-mode.ts +42 -0
- package/src/components/ui/finance/transactions/form-types.ts +23 -0
- package/src/components/ui/finance/transactions/form.tsx +81 -22
- package/src/components/ui/finance/transactions/wallet-filter.tsx +21 -2
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +73 -26
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-history-dialog.tsx +617 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-panel.tsx +2 -1
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-sections.tsx +4 -4
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +298 -34
- package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +219 -46
- package/src/components/ui/finance/wallets/columns-rendering.test.tsx +125 -0
- package/src/components/ui/finance/wallets/columns.test.ts +56 -0
- package/src/components/ui/finance/wallets/columns.tsx +196 -43
- package/src/components/ui/finance/wallets/form.test.tsx +79 -14
- package/src/components/ui/finance/wallets/form.tsx +41 -197
- package/src/components/ui/finance/wallets/query-invalidation.ts +1 -0
- package/src/components/ui/finance/wallets/wallet-basics-fields.tsx +141 -0
- package/src/components/ui/finance/wallets/wallet-credit-fields.tsx +136 -0
- package/src/components/ui/finance/wallets/walletId/credit-wallet-summary.tsx +143 -68
- package/src/components/ui/finance/wallets/walletId/wallet-details-actions.test.tsx +105 -0
- package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +120 -16
- package/src/components/ui/finance/wallets/walletId/wallet-details-amount.test.tsx +64 -0
- package/src/components/ui/finance/wallets/walletId/wallet-details-amount.tsx +226 -6
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +64 -2
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +42 -35
- package/src/components/ui/finance/wallets/wallets-data-table.test.tsx +171 -0
- package/src/components/ui/finance/wallets/wallets-data-table.tsx +132 -29
- package/src/components/ui/finance/wallets/wallets-page.test.tsx +111 -37
- package/src/components/ui/finance/wallets/wallets-page.tsx +38 -78
- package/src/components/ui/storefront/accent-button.tsx +33 -0
- package/src/components/ui/storefront/cart-summary.tsx +140 -0
- package/src/components/ui/storefront/empty-listings.tsx +32 -0
- package/src/components/ui/storefront/hero-panel.tsx +70 -0
- package/src/components/ui/storefront/image-panel.tsx +40 -0
- package/src/components/ui/storefront/index.ts +12 -0
- package/src/components/ui/storefront/listing-card.tsx +129 -0
- package/src/components/ui/storefront/storefront-surface.test.tsx +85 -0
- package/src/components/ui/storefront/storefront-surface.tsx +235 -0
- package/src/components/ui/storefront/types.ts +99 -0
- package/src/components/ui/storefront/utils.ts +90 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +134 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +127 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +17 -42
- package/src/components/ui/tu-do/boards/boardId/timeline-board-open-task.test.tsx +164 -0
- package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +25 -16
- package/src/components/ui/tu-do/hooks/useTaskDialog.ts +15 -1
- package/src/components/ui/tu-do/my-tasks/use-my-tasks-state.ts +2 -0
- package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +114 -7
- package/src/components/ui/tu-do/providers/__tests__/task-dialog-provider.test.tsx +217 -5
- package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +180 -35
- package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +222 -26
- package/src/components/ui/tu-do/shared/board-client.tsx +1 -3
- package/src/components/ui/tu-do/shared/list-view-context-menu.test.tsx +55 -2
- package/src/components/ui/tu-do/shared/list-view.tsx +23 -16
- package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +93 -76
- package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +11 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +128 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +104 -69
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.test.tsx +129 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.tsx +358 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +1 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +6 -2
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.test.tsx +17 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.tsx +151 -111
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-form-reset.ts +18 -2
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-realtime-sync.ts +1 -2
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +5 -3
- package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +584 -53
- package/src/hooks/useBoardRealtime.ts +54 -1
- package/src/hooks/useBoardRealtimeEventHandler.ts +169 -4
- package/src/hooks/useTaskUserRealtime.ts +338 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { ArrowRight, TriangleAlert } from '@tuturuuu/icons';
|
|
4
|
+
import type { InventoryStorefront } from '@tuturuuu/internal-api/inventory';
|
|
5
|
+
import { cn } from '@tuturuuu/utils/format';
|
|
6
|
+
import type { FormEvent } from 'react';
|
|
7
|
+
import { Badge } from '../badge';
|
|
8
|
+
import { Button } from '../button';
|
|
9
|
+
import { AccentButton } from './accent-button';
|
|
10
|
+
import type { StorefrontCartEntry, StorefrontSurfaceLabels } from './types';
|
|
11
|
+
import { formatStorefrontPrice, storefrontSurfaceClasses } from './utils';
|
|
12
|
+
|
|
13
|
+
export function StorefrontCartSummary({
|
|
14
|
+
cartEntries,
|
|
15
|
+
checkoutHref,
|
|
16
|
+
currency,
|
|
17
|
+
isCheckout,
|
|
18
|
+
isPreview,
|
|
19
|
+
isSubmitting,
|
|
20
|
+
labels,
|
|
21
|
+
onCheckoutSubmit,
|
|
22
|
+
radius,
|
|
23
|
+
storefront,
|
|
24
|
+
total,
|
|
25
|
+
}: {
|
|
26
|
+
cartEntries: StorefrontCartEntry[];
|
|
27
|
+
checkoutHref?: string;
|
|
28
|
+
currency: string;
|
|
29
|
+
isCheckout: boolean;
|
|
30
|
+
isPreview: boolean;
|
|
31
|
+
isSubmitting: boolean;
|
|
32
|
+
labels: StorefrontSurfaceLabels;
|
|
33
|
+
onCheckoutSubmit?: (formData: FormData) => void;
|
|
34
|
+
radius: string;
|
|
35
|
+
storefront: InventoryStorefront;
|
|
36
|
+
total: number;
|
|
37
|
+
}) {
|
|
38
|
+
const hasCart = cartEntries.length > 0;
|
|
39
|
+
const isCheckoutDisabled = storefront.checkoutMode === 'disabled';
|
|
40
|
+
const submitDisabled = !hasCart || isSubmitting || isCheckoutDisabled;
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<aside
|
|
44
|
+
className={cn(
|
|
45
|
+
'h-fit p-4 lg:sticky lg:top-4',
|
|
46
|
+
storefrontSurfaceClasses[storefront.surfaceStyle],
|
|
47
|
+
radius
|
|
48
|
+
)}
|
|
49
|
+
>
|
|
50
|
+
<div className="flex items-center justify-between gap-3">
|
|
51
|
+
<p className="font-semibold">{labels.cart}</p>
|
|
52
|
+
<Badge className="border-border bg-background" variant="outline">
|
|
53
|
+
{cartEntries.length}
|
|
54
|
+
</Badge>
|
|
55
|
+
</div>
|
|
56
|
+
<p className="mt-2 text-muted-foreground text-sm leading-6">
|
|
57
|
+
{labels.reservedCopy}
|
|
58
|
+
</p>
|
|
59
|
+
<div className="mt-4 grid gap-2">
|
|
60
|
+
{cartEntries.map(({ line, listing }) => (
|
|
61
|
+
<div
|
|
62
|
+
className="flex items-center justify-between gap-3 text-sm"
|
|
63
|
+
key={line.listingId}
|
|
64
|
+
>
|
|
65
|
+
<span className="min-w-0 truncate">
|
|
66
|
+
{line.quantity} x {listing.title}
|
|
67
|
+
</span>
|
|
68
|
+
<span className="font-medium">
|
|
69
|
+
{formatStorefrontPrice(listing.price * line.quantity, currency)}
|
|
70
|
+
</span>
|
|
71
|
+
</div>
|
|
72
|
+
))}
|
|
73
|
+
</div>
|
|
74
|
+
<div className="mt-4 flex items-center justify-between border-border border-t pt-4">
|
|
75
|
+
<span className="text-muted-foreground text-sm">{labels.total}</span>
|
|
76
|
+
<span className="font-semibold">
|
|
77
|
+
{formatStorefrontPrice(total, currency)}
|
|
78
|
+
</span>
|
|
79
|
+
</div>
|
|
80
|
+
{!hasCart ? (
|
|
81
|
+
<p className="mt-4 flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-muted-foreground text-sm">
|
|
82
|
+
<TriangleAlert className="h-4 w-4" />
|
|
83
|
+
{labels.emptyCart}
|
|
84
|
+
</p>
|
|
85
|
+
) : null}
|
|
86
|
+
{isCheckout ? (
|
|
87
|
+
<form
|
|
88
|
+
className="mt-4 grid gap-2"
|
|
89
|
+
onSubmit={(event: FormEvent<HTMLFormElement>) => {
|
|
90
|
+
event.preventDefault();
|
|
91
|
+
onCheckoutSubmit?.(new FormData(event.currentTarget));
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
<input
|
|
95
|
+
className="h-10 rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
|
96
|
+
name="name"
|
|
97
|
+
placeholder={labels.form.name}
|
|
98
|
+
required
|
|
99
|
+
/>
|
|
100
|
+
<input
|
|
101
|
+
className="h-10 rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
|
102
|
+
name="email"
|
|
103
|
+
placeholder={labels.form.email}
|
|
104
|
+
required
|
|
105
|
+
type="email"
|
|
106
|
+
/>
|
|
107
|
+
<input
|
|
108
|
+
className="h-10 rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
|
109
|
+
name="phone"
|
|
110
|
+
placeholder={labels.form.phone}
|
|
111
|
+
/>
|
|
112
|
+
<textarea
|
|
113
|
+
className="min-h-20 rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
|
114
|
+
name="note"
|
|
115
|
+
placeholder={labels.form.note}
|
|
116
|
+
/>
|
|
117
|
+
<AccentButton disabled={submitDisabled} radius={radius}>
|
|
118
|
+
{isSubmitting ? labels.reserving : labels.reserve}
|
|
119
|
+
<ArrowRight className="h-4 w-4" />
|
|
120
|
+
</AccentButton>
|
|
121
|
+
</form>
|
|
122
|
+
) : isPreview || isCheckoutDisabled ? (
|
|
123
|
+
<Button className={cn('mt-4 w-full', radius)} disabled type="button">
|
|
124
|
+
{labels.checkoutDisabled}
|
|
125
|
+
</Button>
|
|
126
|
+
) : (
|
|
127
|
+
<Button
|
|
128
|
+
asChild
|
|
129
|
+
className={cn('mt-4 w-full', radius)}
|
|
130
|
+
disabled={!hasCart}
|
|
131
|
+
>
|
|
132
|
+
<a aria-disabled={!hasCart} href={hasCart ? checkoutHref : undefined}>
|
|
133
|
+
{labels.checkout}
|
|
134
|
+
<ArrowRight className="h-4 w-4" />
|
|
135
|
+
</a>
|
|
136
|
+
</Button>
|
|
137
|
+
)}
|
|
138
|
+
</aside>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { PackageOpen } from '@tuturuuu/icons';
|
|
2
|
+
import { cn } from '@tuturuuu/utils/format';
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
import type { StorefrontSurfaceLabels } from './types';
|
|
5
|
+
|
|
6
|
+
export function StorefrontEmptyListings({
|
|
7
|
+
action,
|
|
8
|
+
labels,
|
|
9
|
+
radius,
|
|
10
|
+
}: {
|
|
11
|
+
action?: ReactNode;
|
|
12
|
+
labels: StorefrontSurfaceLabels;
|
|
13
|
+
radius: string;
|
|
14
|
+
}) {
|
|
15
|
+
return (
|
|
16
|
+
<div
|
|
17
|
+
className={cn(
|
|
18
|
+
'grid min-h-56 place-items-center border border-dashed bg-muted/25 p-6 text-center sm:col-span-2 xl:col-span-3',
|
|
19
|
+
radius
|
|
20
|
+
)}
|
|
21
|
+
>
|
|
22
|
+
<div className="max-w-sm">
|
|
23
|
+
<PackageOpen className="mx-auto h-8 w-8 text-muted-foreground" />
|
|
24
|
+
<p className="mt-3 font-semibold">{labels.emptyListingsTitle}</p>
|
|
25
|
+
<p className="mt-1 text-muted-foreground text-sm leading-6">
|
|
26
|
+
{labels.emptyListingsDescription}
|
|
27
|
+
</p>
|
|
28
|
+
{action ? <div className="mt-4">{action}</div> : null}
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Store } from '@tuturuuu/icons';
|
|
2
|
+
import type { InventoryStorefront } from '@tuturuuu/internal-api/inventory';
|
|
3
|
+
import { cn } from '@tuturuuu/utils/format';
|
|
4
|
+
import { Badge } from '../badge';
|
|
5
|
+
import { StorefrontImagePanel } from './image-panel';
|
|
6
|
+
import type { StorefrontSurfaceLabels } from './types';
|
|
7
|
+
import { storefrontSurfaceClasses } from './utils';
|
|
8
|
+
|
|
9
|
+
export function StorefrontHeroPanel({
|
|
10
|
+
currency,
|
|
11
|
+
labels,
|
|
12
|
+
listingsCount,
|
|
13
|
+
radius,
|
|
14
|
+
storefront,
|
|
15
|
+
}: {
|
|
16
|
+
currency: string;
|
|
17
|
+
labels: StorefrontSurfaceLabels;
|
|
18
|
+
listingsCount: number;
|
|
19
|
+
radius: string;
|
|
20
|
+
storefront: InventoryStorefront;
|
|
21
|
+
}) {
|
|
22
|
+
return (
|
|
23
|
+
<section
|
|
24
|
+
className={cn(
|
|
25
|
+
'grid min-h-44 overflow-hidden',
|
|
26
|
+
storefrontSurfaceClasses[storefront.surfaceStyle],
|
|
27
|
+
radius,
|
|
28
|
+
storefront.themePreset === 'editorial'
|
|
29
|
+
? 'md:grid-cols-[minmax(0,1.15fr)_360px]'
|
|
30
|
+
: 'md:grid-cols-[minmax(0,1fr)_280px]'
|
|
31
|
+
)}
|
|
32
|
+
>
|
|
33
|
+
<div className="flex min-w-0 flex-col justify-between gap-6 p-5">
|
|
34
|
+
<div>
|
|
35
|
+
<div className="flex items-center gap-2 text-muted-foreground text-xs">
|
|
36
|
+
<Store className="h-4 w-4" />
|
|
37
|
+
<span>{labels.browse}</span>
|
|
38
|
+
</div>
|
|
39
|
+
<h2
|
|
40
|
+
className={cn(
|
|
41
|
+
'mt-3 text-balance font-semibold tracking-normal',
|
|
42
|
+
storefront.themePreset === 'editorial'
|
|
43
|
+
? 'text-3xl md:text-4xl'
|
|
44
|
+
: 'text-2xl'
|
|
45
|
+
)}
|
|
46
|
+
>
|
|
47
|
+
{storefront.name}
|
|
48
|
+
</h2>
|
|
49
|
+
<p className="mt-2 max-w-2xl text-muted-foreground text-sm leading-6">
|
|
50
|
+
{storefront.description ?? labels.fallbackDescription}
|
|
51
|
+
</p>
|
|
52
|
+
</div>
|
|
53
|
+
<div className="flex flex-wrap gap-2">
|
|
54
|
+
<Badge className="border-border bg-background" variant="outline">
|
|
55
|
+
{listingsCount} {labels.product}
|
|
56
|
+
</Badge>
|
|
57
|
+
<Badge className="border-border bg-background" variant="outline">
|
|
58
|
+
{currency}
|
|
59
|
+
</Badge>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<StorefrontImagePanel
|
|
64
|
+
className="min-h-44 md:min-h-full"
|
|
65
|
+
imageUrl={storefront.heroImageUrl}
|
|
66
|
+
label={storefront.name}
|
|
67
|
+
/>
|
|
68
|
+
</section>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { PackageOpen } from '@tuturuuu/icons';
|
|
2
|
+
import { cn } from '@tuturuuu/utils/format';
|
|
3
|
+
import { getListingInitials } from './utils';
|
|
4
|
+
|
|
5
|
+
export function StorefrontImagePanel({
|
|
6
|
+
className,
|
|
7
|
+
imageUrl,
|
|
8
|
+
label,
|
|
9
|
+
}: {
|
|
10
|
+
className?: string;
|
|
11
|
+
imageUrl: string | null;
|
|
12
|
+
label: string;
|
|
13
|
+
}) {
|
|
14
|
+
if (imageUrl) {
|
|
15
|
+
return (
|
|
16
|
+
// biome-ignore lint/performance/noImgElement: storefront images are workspace-controlled external URLs
|
|
17
|
+
<img
|
|
18
|
+
alt=""
|
|
19
|
+
className={cn('w-full object-cover', className)}
|
|
20
|
+
src={imageUrl}
|
|
21
|
+
/>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
className={cn(
|
|
28
|
+
'grid w-full place-items-center border-border bg-muted/55 text-muted-foreground',
|
|
29
|
+
className
|
|
30
|
+
)}
|
|
31
|
+
>
|
|
32
|
+
<div className="grid place-items-center gap-2 text-center">
|
|
33
|
+
<PackageOpen className="h-6 w-6" />
|
|
34
|
+
<span className="max-w-24 truncate font-semibold text-sm">
|
|
35
|
+
{getListingInitials(label) || label}
|
|
36
|
+
</span>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { StorefrontSurface } from './storefront-surface';
|
|
2
|
+
export type {
|
|
3
|
+
StorefrontCartEntry,
|
|
4
|
+
StorefrontCartLine,
|
|
5
|
+
StorefrontSurfaceLabels,
|
|
6
|
+
StorefrontSurfaceMode,
|
|
7
|
+
} from './types';
|
|
8
|
+
export {
|
|
9
|
+
formatStorefrontPrice,
|
|
10
|
+
getStorefrontListingLimit,
|
|
11
|
+
sanitizeStorefrontAccentColor,
|
|
12
|
+
} from './utils';
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Minus, Plus } from '@tuturuuu/icons';
|
|
4
|
+
import type { InventoryStorefrontListing } from '@tuturuuu/internal-api/inventory';
|
|
5
|
+
import { cn } from '@tuturuuu/utils/format';
|
|
6
|
+
import { Badge } from '../badge';
|
|
7
|
+
import { Button } from '../button';
|
|
8
|
+
import { AccentButton } from './accent-button';
|
|
9
|
+
import { StorefrontImagePanel } from './image-panel';
|
|
10
|
+
import type { StorefrontSurfaceLabels } from './types';
|
|
11
|
+
import { formatStorefrontPrice, getStorefrontListingLimit } from './utils';
|
|
12
|
+
|
|
13
|
+
export function StorefrontListingCard({
|
|
14
|
+
currency,
|
|
15
|
+
isList,
|
|
16
|
+
labels,
|
|
17
|
+
listing,
|
|
18
|
+
onDecrement,
|
|
19
|
+
onIncrement,
|
|
20
|
+
quantity,
|
|
21
|
+
radius,
|
|
22
|
+
showInventoryBadges,
|
|
23
|
+
surfaceClassName,
|
|
24
|
+
}: {
|
|
25
|
+
currency: string;
|
|
26
|
+
isList: boolean;
|
|
27
|
+
labels: StorefrontSurfaceLabels;
|
|
28
|
+
listing: InventoryStorefrontListing;
|
|
29
|
+
onDecrement?: (listingId: string) => void;
|
|
30
|
+
onIncrement?: (listingId: string, maxQuantity: number) => void;
|
|
31
|
+
quantity: number;
|
|
32
|
+
radius: string;
|
|
33
|
+
showInventoryBadges: boolean;
|
|
34
|
+
surfaceClassName: string;
|
|
35
|
+
}) {
|
|
36
|
+
const limit = getStorefrontListingLimit(listing);
|
|
37
|
+
const disabled = limit === 0 || quantity >= limit;
|
|
38
|
+
const canChange = Boolean(onIncrement || onDecrement);
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<article
|
|
42
|
+
className={cn(
|
|
43
|
+
surfaceClassName,
|
|
44
|
+
radius,
|
|
45
|
+
isList
|
|
46
|
+
? 'grid gap-3 p-3 sm:grid-cols-[112px_minmax(0,1fr)_auto] sm:items-center'
|
|
47
|
+
: 'grid min-h-full gap-4 p-3'
|
|
48
|
+
)}
|
|
49
|
+
>
|
|
50
|
+
<StorefrontImagePanel
|
|
51
|
+
className={cn(isList ? 'aspect-square' : 'aspect-[4/3]')}
|
|
52
|
+
imageUrl={listing.imageUrl}
|
|
53
|
+
label={listing.title}
|
|
54
|
+
/>
|
|
55
|
+
<div className="min-w-0">
|
|
56
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
57
|
+
<p className="min-w-0 truncate font-semibold">{listing.title}</p>
|
|
58
|
+
<Badge className="border-border bg-background" variant="outline">
|
|
59
|
+
{listing.listingType === 'bundle' ? labels.bundle : labels.product}
|
|
60
|
+
</Badge>
|
|
61
|
+
</div>
|
|
62
|
+
<p className="mt-1 line-clamp-2 text-muted-foreground text-sm leading-6">
|
|
63
|
+
{listing.description ?? labels.fallbackDescription}
|
|
64
|
+
</p>
|
|
65
|
+
{showInventoryBadges ? (
|
|
66
|
+
<p className="mt-2 text-muted-foreground text-xs">
|
|
67
|
+
{limit === 0
|
|
68
|
+
? labels.soldOut
|
|
69
|
+
: `${listing.availableQuantity ?? labels.available} ${
|
|
70
|
+
typeof listing.availableQuantity === 'number'
|
|
71
|
+
? labels.available
|
|
72
|
+
: ''
|
|
73
|
+
}`.trim()}
|
|
74
|
+
</p>
|
|
75
|
+
) : null}
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<div className="mt-auto flex flex-wrap items-center justify-between gap-3">
|
|
79
|
+
<div>
|
|
80
|
+
<p className="font-semibold">
|
|
81
|
+
{formatStorefrontPrice(listing.price, currency)}
|
|
82
|
+
</p>
|
|
83
|
+
{listing.compareAtPrice ? (
|
|
84
|
+
<p className="text-muted-foreground text-xs line-through">
|
|
85
|
+
{formatStorefrontPrice(listing.compareAtPrice, currency)}
|
|
86
|
+
</p>
|
|
87
|
+
) : null}
|
|
88
|
+
</div>
|
|
89
|
+
{canChange ? (
|
|
90
|
+
quantity > 0 ? (
|
|
91
|
+
<div className="flex items-center gap-1">
|
|
92
|
+
<Button
|
|
93
|
+
aria-label={`${labels.quantity} -`}
|
|
94
|
+
className={cn('h-8 w-8 p-0', radius)}
|
|
95
|
+
onClick={() => onDecrement?.(listing.id)}
|
|
96
|
+
type="button"
|
|
97
|
+
variant="outline"
|
|
98
|
+
>
|
|
99
|
+
<Minus className="h-4 w-4" />
|
|
100
|
+
</Button>
|
|
101
|
+
<span className="min-w-8 text-center font-medium text-sm">
|
|
102
|
+
{quantity}
|
|
103
|
+
</span>
|
|
104
|
+
<Button
|
|
105
|
+
aria-label={`${labels.quantity} +`}
|
|
106
|
+
className={cn('h-8 w-8 p-0', radius)}
|
|
107
|
+
disabled={disabled}
|
|
108
|
+
onClick={() => onIncrement?.(listing.id, limit)}
|
|
109
|
+
type="button"
|
|
110
|
+
variant="outline"
|
|
111
|
+
>
|
|
112
|
+
<Plus className="h-4 w-4" />
|
|
113
|
+
</Button>
|
|
114
|
+
</div>
|
|
115
|
+
) : (
|
|
116
|
+
<AccentButton
|
|
117
|
+
disabled={disabled}
|
|
118
|
+
onClick={() => onIncrement?.(listing.id, limit)}
|
|
119
|
+
radius={radius}
|
|
120
|
+
>
|
|
121
|
+
<Plus className="h-4 w-4" />
|
|
122
|
+
{limit === 0 ? labels.soldOut : labels.add}
|
|
123
|
+
</AccentButton>
|
|
124
|
+
)
|
|
125
|
+
) : null}
|
|
126
|
+
</div>
|
|
127
|
+
</article>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import type { InventoryStorefront } from '@tuturuuu/internal-api/inventory';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { StorefrontSurface } from './storefront-surface';
|
|
5
|
+
import { sanitizeStorefrontAccentColor } from './utils';
|
|
6
|
+
|
|
7
|
+
const storefront: InventoryStorefront = {
|
|
8
|
+
accentColor: '#abc',
|
|
9
|
+
cornerStyle: 'rounded',
|
|
10
|
+
createdAt: '2026-06-12T00:00:00.000Z',
|
|
11
|
+
currency: 'USD',
|
|
12
|
+
checkoutMode: 'polar',
|
|
13
|
+
description: 'Buyer-facing copy',
|
|
14
|
+
heroImageUrl: null,
|
|
15
|
+
id: 'storefront-1',
|
|
16
|
+
layoutStyle: 'grid',
|
|
17
|
+
listingsCount: 0,
|
|
18
|
+
name: 'Preview Store',
|
|
19
|
+
showInventoryBadges: true,
|
|
20
|
+
slug: 'preview-store',
|
|
21
|
+
status: 'published',
|
|
22
|
+
surfaceStyle: 'soft',
|
|
23
|
+
themePreset: 'catalog',
|
|
24
|
+
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
25
|
+
visibility: 'public',
|
|
26
|
+
wsId: 'ws-1',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
describe('StorefrontSurface', () => {
|
|
30
|
+
it('sanitizes hex accent colors only', () => {
|
|
31
|
+
expect(sanitizeStorefrontAccentColor('#abc')).toBe('#aabbcc');
|
|
32
|
+
expect(sanitizeStorefrontAccentColor('#123abc')).toBe('#123abc');
|
|
33
|
+
expect(sanitizeStorefrontAccentColor('red')).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('renders a preview empty state without enabling checkout', () => {
|
|
37
|
+
render(
|
|
38
|
+
<StorefrontSurface
|
|
39
|
+
labels={{
|
|
40
|
+
checkoutDisabled: 'Preview checkout disabled',
|
|
41
|
+
emptyListingsDescription: 'Create a listing next.',
|
|
42
|
+
emptyListingsTitle: 'No buyer listings',
|
|
43
|
+
}}
|
|
44
|
+
listings={[]}
|
|
45
|
+
mode="preview"
|
|
46
|
+
storefront={storefront}
|
|
47
|
+
/>
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
expect(screen.getAllByText('Preview Store')).toHaveLength(2);
|
|
51
|
+
expect(screen.getByText('No buyer listings')).toBeInTheDocument();
|
|
52
|
+
expect(screen.getByText('Create a listing next.')).toBeInTheDocument();
|
|
53
|
+
expect(screen.getByText('Preview checkout disabled')).toBeDisabled();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('shows simulated checkout mode badges', () => {
|
|
57
|
+
render(
|
|
58
|
+
<StorefrontSurface
|
|
59
|
+
labels={{ simulatedBadge: 'Simulated checkout' }}
|
|
60
|
+
listings={[]}
|
|
61
|
+
mode="store"
|
|
62
|
+
storefront={{ ...storefront, checkoutMode: 'simulated' }}
|
|
63
|
+
/>
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
expect(screen.getByText('Simulated checkout')).toBeInTheDocument();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('shows disabled checkout mode badges and blocks checkout', () => {
|
|
70
|
+
render(
|
|
71
|
+
<StorefrontSurface
|
|
72
|
+
labels={{
|
|
73
|
+
checkoutDisabled: 'Checkout unavailable',
|
|
74
|
+
checkoutDisabledBadge: 'Checkout disabled',
|
|
75
|
+
}}
|
|
76
|
+
listings={[]}
|
|
77
|
+
mode="store"
|
|
78
|
+
storefront={{ ...storefront, checkoutMode: 'disabled' }}
|
|
79
|
+
/>
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
expect(screen.getByText('Checkout disabled')).toBeInTheDocument();
|
|
83
|
+
expect(screen.getByText('Checkout unavailable')).toBeDisabled();
|
|
84
|
+
});
|
|
85
|
+
});
|