@vendure-platform/create 0.3.0 → 0.5.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 +8 -0
- package/README.md +7 -7
- package/dist/add-to-existing.js +1 -583
- package/dist/assets/storefront/messages/de.json +2 -0
- package/dist/assets/storefront/messages/en.json +2 -0
- package/dist/assets/storefront/package.json +1 -1
- package/dist/assets/storefront/src/app/[locale]/(catalog)/product/[slug]/page.tsx +7 -2
- package/dist/assets/storefront/src/app/[locale]/account/shared-carts/[id]/activity-feed.tsx +152 -0
- package/dist/assets/storefront/src/app/[locale]/account/shared-carts/[id]/add-item-search.tsx +130 -0
- package/dist/assets/storefront/src/app/[locale]/account/shared-carts/[id]/add-member-form.tsx +124 -0
- package/dist/assets/storefront/src/app/[locale]/account/shared-carts/[id]/members-panel.tsx +240 -0
- package/dist/assets/storefront/src/app/[locale]/account/shared-carts/[id]/page.tsx +47 -0
- package/dist/assets/storefront/src/app/[locale]/account/shared-carts/[id]/rename-dialog.tsx +96 -0
- package/dist/assets/storefront/src/app/[locale]/account/shared-carts/[id]/shared-cart-detail-client.tsx +555 -0
- package/dist/assets/storefront/src/app/[locale]/account/shared-carts/actions.ts +260 -0
- package/dist/assets/storefront/src/app/[locale]/account/shared-carts/new/new-shared-cart-form.tsx +77 -0
- package/dist/assets/storefront/src/app/[locale]/account/shared-carts/new/page.tsx +47 -0
- package/dist/assets/storefront/src/app/[locale]/account/shared-carts/page.tsx +263 -0
- package/dist/assets/storefront/src/app/[locale]/cart/cart.tsx +12 -1
- package/dist/assets/storefront/src/app/[locale]/cart/order-summary.tsx +23 -0
- package/dist/assets/storefront/src/components/commerce/account-nav-items.ts +2 -0
- package/dist/assets/storefront/src/components/commerce/add-to-shared-cart.tsx +225 -0
- package/dist/assets/storefront/src/components/commerce/product-info.tsx +14 -1
- package/dist/assets/storefront/src/components/commerce/shared-cart-role.ts +39 -0
- package/dist/assets/storefront/src/components/commerce/shared-cart-status-badge.tsx +24 -0
- package/dist/assets/storefront/src/graphql-env.d.ts +28 -15
- package/dist/assets/storefront/src/lib/vendure/api.ts +8 -4
- package/dist/assets/storefront/src/lib/vendure/mutations.ts +96 -0
- package/dist/assets/storefront/src/lib/vendure/queries.ts +142 -0
- package/dist/assets/storefront/src/schema.graphql +321 -61
- package/dist/bin/run.js +1 -119
- package/dist/cli.js +1 -309
- package/dist/git-init.js +1 -60
- package/dist/index.js +1 -19
- package/dist/plugin-registry.js +1 -203
- package/dist/registry.js +1 -29
- package/dist/scaffold-shared.js +1 -124
- package/dist/scaffold-storefront-only.js +1 -101
- package/dist/scaffolder.js +1 -203
- package/dist/sort-package-json.js +1 -26
- package/dist/storefront-copier.js +1 -149
- package/dist/template-renderer.js +1 -221
- package/dist/types.js +1 -3
- package/package.json +5 -4
- package/dist/add-to-existing.js.map +0 -1
- package/dist/assets/storefront/src/lib/quantity.test.js +0 -20
- package/dist/bin/run.js.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/git-init.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/plugin-registry.js.map +0 -1
- package/dist/registry.js.map +0 -1
- package/dist/scaffold-shared.js.map +0 -1
- package/dist/scaffold-storefront-only.js.map +0 -1
- package/dist/scaffolder.js.map +0 -1
- package/dist/sort-package-json.js.map +0 -1
- package/dist/storefront-copier.js.map +0 -1
- package/dist/template-renderer.js.map +0 -1
- package/dist/types.js.map +0 -1
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Price } from '@/components/commerce/price';
|
|
4
|
+
import { MANAGE_SHARED_CARTS_PERMISSION, roleAtLeast } from '@/components/commerce/shared-cart-role';
|
|
5
|
+
import { SharedCartStatusBadge } from '@/components/commerce/shared-cart-status-badge';
|
|
6
|
+
import {
|
|
7
|
+
AlertDialog,
|
|
8
|
+
AlertDialogAction,
|
|
9
|
+
AlertDialogCancel,
|
|
10
|
+
AlertDialogContent,
|
|
11
|
+
AlertDialogDescription,
|
|
12
|
+
AlertDialogFooter,
|
|
13
|
+
AlertDialogHeader,
|
|
14
|
+
AlertDialogTitle,
|
|
15
|
+
} from '@/components/ui/alert-dialog';
|
|
16
|
+
import { Button } from '@/components/ui/button';
|
|
17
|
+
import {
|
|
18
|
+
DropdownMenu,
|
|
19
|
+
DropdownMenuContent,
|
|
20
|
+
DropdownMenuItem,
|
|
21
|
+
DropdownMenuSeparator,
|
|
22
|
+
DropdownMenuTrigger,
|
|
23
|
+
} from '@/components/ui/dropdown-menu';
|
|
24
|
+
import { Input } from '@/components/ui/input';
|
|
25
|
+
import { useRouter } from '@/i18n/navigation';
|
|
26
|
+
import { useCompanyPermissions } from '@/lib/company-permissions-context';
|
|
27
|
+
import { MIN_ORDER_QUANTITY, normalizeQuantity, sanitizeQuantityInput } from '@/lib/quantity';
|
|
28
|
+
import { CreditCard, Loader2, Lock, Minus, MoreVertical, PackageOpen, Plus, Trash2 } from 'lucide-react';
|
|
29
|
+
import Image from 'next/image';
|
|
30
|
+
import { useCallback, useEffect, useRef, useState, useTransition } from 'react';
|
|
31
|
+
import { toast } from 'sonner';
|
|
32
|
+
import {
|
|
33
|
+
adjustSharedCartLine,
|
|
34
|
+
deleteSharedCart,
|
|
35
|
+
fetchSharedCart,
|
|
36
|
+
initiateSharedCartCheckout,
|
|
37
|
+
removeSharedCartLine,
|
|
38
|
+
type SharedCartData,
|
|
39
|
+
type SharedCartRole,
|
|
40
|
+
} from '../actions';
|
|
41
|
+
import { ActivityFeed } from './activity-feed';
|
|
42
|
+
import { AddItemSearch } from './add-item-search';
|
|
43
|
+
import { MembersPanel } from './members-panel';
|
|
44
|
+
import { RenameSharedCartDialog } from './rename-dialog';
|
|
45
|
+
|
|
46
|
+
const POLL_INTERVAL_MS = 7000;
|
|
47
|
+
|
|
48
|
+
interface SharedCartDetailClientProps {
|
|
49
|
+
initialCart: SharedCartData;
|
|
50
|
+
viewerId: string;
|
|
51
|
+
locale: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function SharedCartDetailClient({ initialCart, viewerId, locale }: SharedCartDetailClientProps) {
|
|
55
|
+
const router = useRouter();
|
|
56
|
+
const [cart, setCart] = useState<SharedCartData>(initialCart);
|
|
57
|
+
const [isRefreshing, startRefresh] = useTransition();
|
|
58
|
+
const { hasPermission } = useCompanyPermissions();
|
|
59
|
+
|
|
60
|
+
const requestSeq = useRef(0);
|
|
61
|
+
const load = useCallback(async () => {
|
|
62
|
+
const seq = ++requestSeq.current;
|
|
63
|
+
const next = await fetchSharedCart(initialCart.id);
|
|
64
|
+
if (next && seq === requestSeq.current) setCart(next);
|
|
65
|
+
}, [initialCart.id]);
|
|
66
|
+
|
|
67
|
+
const refresh = useCallback(() => {
|
|
68
|
+
startRefresh(load);
|
|
69
|
+
}, [load]);
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
73
|
+
const start = () => {
|
|
74
|
+
stop();
|
|
75
|
+
if (!document.hidden) void load();
|
|
76
|
+
timer = setInterval(() => {
|
|
77
|
+
if (!document.hidden) void load();
|
|
78
|
+
}, POLL_INTERVAL_MS);
|
|
79
|
+
};
|
|
80
|
+
const stop = () => timer && clearInterval(timer);
|
|
81
|
+
const onVisibility = () => (document.hidden ? stop() : start());
|
|
82
|
+
start();
|
|
83
|
+
document.addEventListener('visibilitychange', onVisibility);
|
|
84
|
+
return () => {
|
|
85
|
+
stop();
|
|
86
|
+
document.removeEventListener('visibilitychange', onVisibility);
|
|
87
|
+
};
|
|
88
|
+
}, [load]);
|
|
89
|
+
|
|
90
|
+
const isOwner = cart.owner.id === viewerId;
|
|
91
|
+
const isCompanyAdmin = hasPermission(MANAGE_SHARED_CARTS_PERMISSION);
|
|
92
|
+
const viewerRole: SharedCartRole | undefined = cart.members.find((m) => m.customer.id === viewerId)?.role;
|
|
93
|
+
const canManage = isOwner || isCompanyAdmin;
|
|
94
|
+
const canEdit = isOwner || roleAtLeast(viewerRole, 'edit');
|
|
95
|
+
const canCheckout = isOwner || roleAtLeast(viewerRole, 'checkout');
|
|
96
|
+
const isOpen = cart.status === 'Open';
|
|
97
|
+
const isLocked = cart.status === 'Locked';
|
|
98
|
+
const isClosed = cart.status === 'Closed';
|
|
99
|
+
const editable = isOpen && canEdit;
|
|
100
|
+
const isInitiator = cart.order.customer?.id === viewerId;
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<div className="space-y-6">
|
|
104
|
+
<Header
|
|
105
|
+
cart={cart}
|
|
106
|
+
canManage={canManage}
|
|
107
|
+
isOpen={isOpen}
|
|
108
|
+
isLocked={isLocked}
|
|
109
|
+
onRenamed={refresh}
|
|
110
|
+
onDeleted={() => router.push('/account/shared-carts')}
|
|
111
|
+
/>
|
|
112
|
+
|
|
113
|
+
{isLocked && (
|
|
114
|
+
<LockedBanner
|
|
115
|
+
cart={cart}
|
|
116
|
+
isInitiator={isInitiator}
|
|
117
|
+
onResume={() => router.push('/checkout')}
|
|
118
|
+
/>
|
|
119
|
+
)}
|
|
120
|
+
{isClosed && <ClosedBanner cart={cart} />}
|
|
121
|
+
|
|
122
|
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
123
|
+
<div className="lg:col-span-2 space-y-6">
|
|
124
|
+
<section className="border rounded-lg">
|
|
125
|
+
<div className="flex items-center justify-between px-4 py-3 border-b">
|
|
126
|
+
<h2 className="font-semibold">Items</h2>
|
|
127
|
+
<span className="text-sm text-muted-foreground tabular-nums">
|
|
128
|
+
{cart.order.totalQuantity} {cart.order.totalQuantity === 1 ? 'item' : 'items'}
|
|
129
|
+
</span>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
{editable && (
|
|
133
|
+
<div className="px-4 py-3 border-b bg-muted/30">
|
|
134
|
+
<AddItemSearch sharedCartId={cart.id} onAdded={refresh} />
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
|
|
138
|
+
{cart.order.lines.length === 0 ? (
|
|
139
|
+
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
|
|
140
|
+
<PackageOpen className="h-6 w-6 mx-auto mb-2 opacity-60" />
|
|
141
|
+
No items yet.
|
|
142
|
+
{editable && ' Search above to add the first product.'}
|
|
143
|
+
</div>
|
|
144
|
+
) : (
|
|
145
|
+
<ul className="divide-y">
|
|
146
|
+
{cart.order.lines.map((line) => (
|
|
147
|
+
<LineRow
|
|
148
|
+
key={line.id}
|
|
149
|
+
sharedCartId={cart.id}
|
|
150
|
+
line={line}
|
|
151
|
+
currencyCode={cart.order.currencyCode}
|
|
152
|
+
editable={editable}
|
|
153
|
+
onSettled={refresh}
|
|
154
|
+
/>
|
|
155
|
+
))}
|
|
156
|
+
</ul>
|
|
157
|
+
)}
|
|
158
|
+
</section>
|
|
159
|
+
|
|
160
|
+
<ActivityFeed activity={cart.activity} lines={cart.order.lines} locale={locale} />
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<aside className="space-y-6">
|
|
164
|
+
<section className="border rounded-lg p-4 space-y-3">
|
|
165
|
+
<h2 className="font-semibold">Summary</h2>
|
|
166
|
+
<div className="flex justify-between text-sm">
|
|
167
|
+
<span className="text-muted-foreground">Subtotal</span>
|
|
168
|
+
<Price
|
|
169
|
+
value={cart.order.subTotalWithTax}
|
|
170
|
+
currencyCode={cart.order.currencyCode}
|
|
171
|
+
/>
|
|
172
|
+
</div>
|
|
173
|
+
<div className="flex justify-between font-semibold pt-2 border-t">
|
|
174
|
+
<span>Total</span>
|
|
175
|
+
<Price
|
|
176
|
+
value={cart.order.totalWithTax}
|
|
177
|
+
currencyCode={cart.order.currencyCode}
|
|
178
|
+
taxLabel="incl"
|
|
179
|
+
/>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
{isOpen && canCheckout && (
|
|
183
|
+
<CheckoutButton
|
|
184
|
+
sharedCartId={cart.id}
|
|
185
|
+
disabled={cart.order.lines.length === 0}
|
|
186
|
+
onStarted={() => router.push('/checkout')}
|
|
187
|
+
/>
|
|
188
|
+
)}
|
|
189
|
+
{isOpen && !canCheckout && (
|
|
190
|
+
<p className="text-xs text-muted-foreground pt-1">
|
|
191
|
+
A member with checkout access can place this order.
|
|
192
|
+
</p>
|
|
193
|
+
)}
|
|
194
|
+
</section>
|
|
195
|
+
|
|
196
|
+
<MembersPanel cart={cart} viewerId={viewerId} canManage={canManage} onChanged={refresh} />
|
|
197
|
+
</aside>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
{isRefreshing && <span className="sr-only">Refreshing…</span>}
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function Header({
|
|
206
|
+
cart,
|
|
207
|
+
canManage,
|
|
208
|
+
isOpen,
|
|
209
|
+
isLocked,
|
|
210
|
+
onRenamed,
|
|
211
|
+
onDeleted,
|
|
212
|
+
}: {
|
|
213
|
+
cart: SharedCartData;
|
|
214
|
+
canManage: boolean;
|
|
215
|
+
isOpen: boolean;
|
|
216
|
+
isLocked: boolean;
|
|
217
|
+
onRenamed: () => void;
|
|
218
|
+
onDeleted: () => void;
|
|
219
|
+
}) {
|
|
220
|
+
const [renameOpen, setRenameOpen] = useState(false);
|
|
221
|
+
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
222
|
+
const [isPending, startTransition] = useTransition();
|
|
223
|
+
|
|
224
|
+
function handleDelete() {
|
|
225
|
+
startTransition(async () => {
|
|
226
|
+
const result = await deleteSharedCart(cart.id);
|
|
227
|
+
if (!result.success) {
|
|
228
|
+
toast.error(result.message ?? 'Could not delete the cart.');
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
toast.success('Shared cart deleted.');
|
|
232
|
+
onDeleted();
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return (
|
|
237
|
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
238
|
+
<div className="min-w-0">
|
|
239
|
+
<div className="flex items-center gap-3 flex-wrap">
|
|
240
|
+
<h1 className="text-2xl font-bold truncate">{cart.name}</h1>
|
|
241
|
+
<SharedCartStatusBadge status={cart.status} />
|
|
242
|
+
</div>
|
|
243
|
+
{cart.description && (
|
|
244
|
+
<p className="text-sm text-muted-foreground mt-1 max-w-prose">{cart.description}</p>
|
|
245
|
+
)}
|
|
246
|
+
<p className="text-xs text-muted-foreground mt-1">
|
|
247
|
+
Owner: {cart.owner.firstName} {cart.owner.lastName}
|
|
248
|
+
</p>
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
{canManage && (
|
|
252
|
+
<>
|
|
253
|
+
<DropdownMenu>
|
|
254
|
+
<DropdownMenuTrigger asChild>
|
|
255
|
+
<Button variant="outline" size="icon" aria-label="Cart actions">
|
|
256
|
+
<MoreVertical className="h-4 w-4" />
|
|
257
|
+
</Button>
|
|
258
|
+
</DropdownMenuTrigger>
|
|
259
|
+
<DropdownMenuContent align="end">
|
|
260
|
+
<DropdownMenuItem onSelect={() => setRenameOpen(true)} disabled={!isOpen}>
|
|
261
|
+
Rename
|
|
262
|
+
</DropdownMenuItem>
|
|
263
|
+
<DropdownMenuSeparator />
|
|
264
|
+
<DropdownMenuItem
|
|
265
|
+
onSelect={() => setDeleteOpen(true)}
|
|
266
|
+
disabled={isLocked}
|
|
267
|
+
className="text-destructive focus:text-destructive"
|
|
268
|
+
>
|
|
269
|
+
Delete cart
|
|
270
|
+
</DropdownMenuItem>
|
|
271
|
+
</DropdownMenuContent>
|
|
272
|
+
</DropdownMenu>
|
|
273
|
+
|
|
274
|
+
<RenameSharedCartDialog
|
|
275
|
+
open={renameOpen}
|
|
276
|
+
onOpenChange={setRenameOpen}
|
|
277
|
+
cartId={cart.id}
|
|
278
|
+
currentName={cart.name}
|
|
279
|
+
currentDescription={cart.description ?? ''}
|
|
280
|
+
onRenamed={onRenamed}
|
|
281
|
+
/>
|
|
282
|
+
|
|
283
|
+
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
|
284
|
+
<AlertDialogContent>
|
|
285
|
+
<AlertDialogHeader>
|
|
286
|
+
<AlertDialogTitle>Delete this shared cart?</AlertDialogTitle>
|
|
287
|
+
<AlertDialogDescription>
|
|
288
|
+
This permanently removes “{cart.name}” and its items for everyone. This
|
|
289
|
+
can't be undone.
|
|
290
|
+
</AlertDialogDescription>
|
|
291
|
+
</AlertDialogHeader>
|
|
292
|
+
<AlertDialogFooter>
|
|
293
|
+
<AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
|
|
294
|
+
<AlertDialogAction
|
|
295
|
+
onClick={(e) => {
|
|
296
|
+
e.preventDefault();
|
|
297
|
+
handleDelete();
|
|
298
|
+
}}
|
|
299
|
+
disabled={isPending}
|
|
300
|
+
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
301
|
+
>
|
|
302
|
+
{isPending && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
|
303
|
+
Delete
|
|
304
|
+
</AlertDialogAction>
|
|
305
|
+
</AlertDialogFooter>
|
|
306
|
+
</AlertDialogContent>
|
|
307
|
+
</AlertDialog>
|
|
308
|
+
</>
|
|
309
|
+
)}
|
|
310
|
+
</div>
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function LockedBanner({
|
|
315
|
+
cart,
|
|
316
|
+
isInitiator,
|
|
317
|
+
onResume,
|
|
318
|
+
}: {
|
|
319
|
+
cart: SharedCartData;
|
|
320
|
+
isInitiator: boolean;
|
|
321
|
+
onResume: () => void;
|
|
322
|
+
}) {
|
|
323
|
+
const initiator = cart.order.customer ?? cart.owner;
|
|
324
|
+
|
|
325
|
+
return (
|
|
326
|
+
<div className="flex flex-wrap items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-amber-900">
|
|
327
|
+
<Lock className="h-5 w-5 mt-0.5 shrink-0" />
|
|
328
|
+
<div className="min-w-0 flex-1 text-sm">
|
|
329
|
+
<p className="font-medium">Checkout in progress</p>
|
|
330
|
+
<p className="text-amber-800">
|
|
331
|
+
{isInitiator ? (
|
|
332
|
+
<>You started checkout for this cart. Resume to finish placing the order.</>
|
|
333
|
+
) : (
|
|
334
|
+
<>
|
|
335
|
+
{initiator.firstName} {initiator.lastName} is placing this order. The cart is
|
|
336
|
+
read-only until checkout completes.
|
|
337
|
+
</>
|
|
338
|
+
)}
|
|
339
|
+
</p>
|
|
340
|
+
</div>
|
|
341
|
+
{isInitiator && (
|
|
342
|
+
<Button size="sm" onClick={onResume} className="shrink-0">
|
|
343
|
+
<CreditCard className="mr-2 h-4 w-4" />
|
|
344
|
+
Resume checkout
|
|
345
|
+
</Button>
|
|
346
|
+
)}
|
|
347
|
+
</div>
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function ClosedBanner({ cart }: { cart: SharedCartData }) {
|
|
352
|
+
return (
|
|
353
|
+
<div className="flex items-start gap-3 rounded-lg border bg-muted px-4 py-3">
|
|
354
|
+
<PackageOpen className="h-5 w-5 mt-0.5 shrink-0 text-muted-foreground" />
|
|
355
|
+
<div className="text-sm">
|
|
356
|
+
<p className="font-medium">This cart is closed</p>
|
|
357
|
+
<p className="text-muted-foreground">
|
|
358
|
+
{cart.order.code
|
|
359
|
+
? `It was checked out as order ${cart.order.code}.`
|
|
360
|
+
: 'It has been checked out.'}
|
|
361
|
+
</p>
|
|
362
|
+
</div>
|
|
363
|
+
</div>
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function LineRow({
|
|
368
|
+
sharedCartId,
|
|
369
|
+
line,
|
|
370
|
+
currencyCode,
|
|
371
|
+
editable,
|
|
372
|
+
onSettled,
|
|
373
|
+
}: {
|
|
374
|
+
sharedCartId: string;
|
|
375
|
+
line: SharedCartData['order']['lines'][number];
|
|
376
|
+
currencyCode: string;
|
|
377
|
+
editable: boolean;
|
|
378
|
+
onSettled: () => void;
|
|
379
|
+
}) {
|
|
380
|
+
const [isPending, startTransition] = useTransition();
|
|
381
|
+
const [quantityInput, setQuantityInput] = useState(String(line.quantity));
|
|
382
|
+
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
383
|
+
const skipBlurSubmitRef = useRef(false);
|
|
384
|
+
|
|
385
|
+
// Resync the field when polling/refresh brings a new quantity (e.g. a
|
|
386
|
+
// colleague edited the same line) — adjust-during-render, no effect.
|
|
387
|
+
const [syncedQuantity, setSyncedQuantity] = useState(line.quantity);
|
|
388
|
+
if (line.quantity !== syncedQuantity) {
|
|
389
|
+
setSyncedQuantity(line.quantity);
|
|
390
|
+
setQuantityInput(String(line.quantity));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const normalizedQuantity = normalizeQuantity(quantityInput);
|
|
394
|
+
|
|
395
|
+
function submitQuantity(value: number | string) {
|
|
396
|
+
const next = normalizeQuantity(value);
|
|
397
|
+
setQuantityInput(String(next));
|
|
398
|
+
if (next === line.quantity) return;
|
|
399
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
400
|
+
debounceRef.current = setTimeout(() => {
|
|
401
|
+
startTransition(async () => {
|
|
402
|
+
const result = await adjustSharedCartLine(sharedCartId, line.id, next);
|
|
403
|
+
if (!result.success) {
|
|
404
|
+
setQuantityInput(String(line.quantity));
|
|
405
|
+
toast.error(result.message ?? 'Could not update quantity.');
|
|
406
|
+
}
|
|
407
|
+
onSettled();
|
|
408
|
+
});
|
|
409
|
+
}, 300);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
useEffect(() => () => clearTimeout(debounceRef.current), []);
|
|
413
|
+
|
|
414
|
+
function remove() {
|
|
415
|
+
startTransition(async () => {
|
|
416
|
+
const result = await removeSharedCartLine(sharedCartId, line.id);
|
|
417
|
+
if (!result.success) toast.error(result.message ?? 'Could not remove item.');
|
|
418
|
+
onSettled();
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return (
|
|
423
|
+
<li className="flex items-center gap-3 px-4 py-3">
|
|
424
|
+
<div className="h-12 w-12 shrink-0 rounded border bg-muted overflow-hidden">
|
|
425
|
+
{line.featuredAsset?.preview && (
|
|
426
|
+
<Image
|
|
427
|
+
src={`${line.featuredAsset.preview}?preset=tiny`}
|
|
428
|
+
alt={line.productVariant.name}
|
|
429
|
+
width={48}
|
|
430
|
+
height={48}
|
|
431
|
+
className="h-full w-full object-cover"
|
|
432
|
+
/>
|
|
433
|
+
)}
|
|
434
|
+
</div>
|
|
435
|
+
<div className="min-w-0 flex-1">
|
|
436
|
+
<p className="text-sm font-medium truncate">{line.productVariant.name}</p>
|
|
437
|
+
<p className="text-xs text-muted-foreground">{line.productVariant.sku}</p>
|
|
438
|
+
</div>
|
|
439
|
+
|
|
440
|
+
{editable ? (
|
|
441
|
+
<div className="flex items-center border rounded-md">
|
|
442
|
+
<Button
|
|
443
|
+
type="button"
|
|
444
|
+
variant="ghost"
|
|
445
|
+
size="icon"
|
|
446
|
+
className="h-8 w-8 rounded-none"
|
|
447
|
+
onMouseDown={() => {
|
|
448
|
+
skipBlurSubmitRef.current = true;
|
|
449
|
+
}}
|
|
450
|
+
onClick={() => submitQuantity(normalizedQuantity - 1)}
|
|
451
|
+
disabled={isPending || normalizedQuantity <= MIN_ORDER_QUANTITY}
|
|
452
|
+
aria-label="Decrease quantity"
|
|
453
|
+
>
|
|
454
|
+
<Minus className="h-3 w-3" />
|
|
455
|
+
</Button>
|
|
456
|
+
<Input
|
|
457
|
+
aria-label="Quantity"
|
|
458
|
+
inputMode="numeric"
|
|
459
|
+
pattern="[0-9]*"
|
|
460
|
+
value={quantityInput}
|
|
461
|
+
onChange={(e) => setQuantityInput(sanitizeQuantityInput(e.target.value))}
|
|
462
|
+
onBlur={() => {
|
|
463
|
+
if (skipBlurSubmitRef.current) {
|
|
464
|
+
skipBlurSubmitRef.current = false;
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
468
|
+
submitQuantity(quantityInput);
|
|
469
|
+
}}
|
|
470
|
+
onKeyDown={(e) => {
|
|
471
|
+
if (e.key === 'Enter') {
|
|
472
|
+
e.preventDefault();
|
|
473
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
474
|
+
submitQuantity(quantityInput);
|
|
475
|
+
} else if (e.key === 'Escape') {
|
|
476
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
477
|
+
setQuantityInput(String(line.quantity));
|
|
478
|
+
e.currentTarget.blur();
|
|
479
|
+
}
|
|
480
|
+
}}
|
|
481
|
+
disabled={isPending}
|
|
482
|
+
className="h-8 w-12 border-0 text-center shadow-none focus-visible:ring-0 tabular-nums"
|
|
483
|
+
/>
|
|
484
|
+
<Button
|
|
485
|
+
type="button"
|
|
486
|
+
variant="ghost"
|
|
487
|
+
size="icon"
|
|
488
|
+
className="h-8 w-8 rounded-none"
|
|
489
|
+
onMouseDown={() => {
|
|
490
|
+
skipBlurSubmitRef.current = true;
|
|
491
|
+
}}
|
|
492
|
+
onClick={() => submitQuantity(normalizedQuantity + 1)}
|
|
493
|
+
disabled={isPending}
|
|
494
|
+
aria-label="Increase quantity"
|
|
495
|
+
>
|
|
496
|
+
<Plus className="h-3 w-3" />
|
|
497
|
+
</Button>
|
|
498
|
+
</div>
|
|
499
|
+
) : (
|
|
500
|
+
<span className="text-sm text-muted-foreground tabular-nums">×{line.quantity}</span>
|
|
501
|
+
)}
|
|
502
|
+
|
|
503
|
+
<div className="w-24 text-right text-sm font-medium tabular-nums">
|
|
504
|
+
<Price value={line.linePriceWithTax} currencyCode={currencyCode} />
|
|
505
|
+
</div>
|
|
506
|
+
|
|
507
|
+
{editable && (
|
|
508
|
+
<Button
|
|
509
|
+
variant="ghost"
|
|
510
|
+
size="icon"
|
|
511
|
+
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
|
512
|
+
onClick={remove}
|
|
513
|
+
disabled={isPending}
|
|
514
|
+
aria-label="Remove item"
|
|
515
|
+
>
|
|
516
|
+
<Trash2 className="h-4 w-4" />
|
|
517
|
+
</Button>
|
|
518
|
+
)}
|
|
519
|
+
</li>
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function CheckoutButton({
|
|
524
|
+
sharedCartId,
|
|
525
|
+
disabled,
|
|
526
|
+
onStarted,
|
|
527
|
+
}: {
|
|
528
|
+
sharedCartId: string;
|
|
529
|
+
disabled: boolean;
|
|
530
|
+
onStarted: () => void;
|
|
531
|
+
}) {
|
|
532
|
+
const [isPending, startTransition] = useTransition();
|
|
533
|
+
|
|
534
|
+
function handleClick() {
|
|
535
|
+
startTransition(async () => {
|
|
536
|
+
const result = await initiateSharedCartCheckout(sharedCartId);
|
|
537
|
+
if (!result.success) {
|
|
538
|
+
toast.error(result.message ?? 'Could not start checkout.');
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
onStarted();
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return (
|
|
546
|
+
<Button className="w-full" disabled={disabled || isPending} onClick={handleClick}>
|
|
547
|
+
{isPending ? (
|
|
548
|
+
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
549
|
+
) : (
|
|
550
|
+
<CreditCard className="h-4 w-4 mr-2" />
|
|
551
|
+
)}
|
|
552
|
+
Proceed to checkout
|
|
553
|
+
</Button>
|
|
554
|
+
);
|
|
555
|
+
}
|