@vendure-platform/create 0.4.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.
Files changed (29) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/assets/storefront/messages/de.json +2 -0
  3. package/dist/assets/storefront/messages/en.json +2 -0
  4. package/dist/assets/storefront/package.json +1 -1
  5. package/dist/assets/storefront/src/app/[locale]/(catalog)/product/[slug]/page.tsx +7 -2
  6. package/dist/assets/storefront/src/app/[locale]/account/shared-carts/[id]/activity-feed.tsx +152 -0
  7. package/dist/assets/storefront/src/app/[locale]/account/shared-carts/[id]/add-item-search.tsx +130 -0
  8. package/dist/assets/storefront/src/app/[locale]/account/shared-carts/[id]/add-member-form.tsx +124 -0
  9. package/dist/assets/storefront/src/app/[locale]/account/shared-carts/[id]/members-panel.tsx +240 -0
  10. package/dist/assets/storefront/src/app/[locale]/account/shared-carts/[id]/page.tsx +47 -0
  11. package/dist/assets/storefront/src/app/[locale]/account/shared-carts/[id]/rename-dialog.tsx +96 -0
  12. package/dist/assets/storefront/src/app/[locale]/account/shared-carts/[id]/shared-cart-detail-client.tsx +555 -0
  13. package/dist/assets/storefront/src/app/[locale]/account/shared-carts/actions.ts +260 -0
  14. package/dist/assets/storefront/src/app/[locale]/account/shared-carts/new/new-shared-cart-form.tsx +77 -0
  15. package/dist/assets/storefront/src/app/[locale]/account/shared-carts/new/page.tsx +47 -0
  16. package/dist/assets/storefront/src/app/[locale]/account/shared-carts/page.tsx +263 -0
  17. package/dist/assets/storefront/src/app/[locale]/cart/order-summary.tsx +23 -0
  18. package/dist/assets/storefront/src/components/commerce/account-nav-items.ts +2 -0
  19. package/dist/assets/storefront/src/components/commerce/add-to-shared-cart.tsx +225 -0
  20. package/dist/assets/storefront/src/components/commerce/product-info.tsx +14 -1
  21. package/dist/assets/storefront/src/components/commerce/shared-cart-role.ts +39 -0
  22. package/dist/assets/storefront/src/components/commerce/shared-cart-status-badge.tsx +24 -0
  23. package/dist/assets/storefront/src/graphql-env.d.ts +18 -5
  24. package/dist/assets/storefront/src/lib/vendure/api.ts +8 -4
  25. package/dist/assets/storefront/src/lib/vendure/mutations.ts +96 -0
  26. package/dist/assets/storefront/src/lib/vendure/queries.ts +142 -0
  27. package/dist/assets/storefront/src/schema.graphql +134 -0
  28. package/dist/plugin-registry.js +1 -1
  29. package/package.json +2 -2
@@ -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&apos;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
+ }