@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
package/CHANGELOG.md CHANGED
@@ -1,3 +1,7 @@
1
+ ## 0.5.0 (2026-08-04)
2
+
3
+ Version bump only — no functional changes in @vendure-platform/create.
4
+
1
5
  ## 0.4.0 (2026-07-03)
2
6
 
3
7
  Version bump only — no functional changes in @vendure-platform/create.
@@ -19,6 +19,7 @@
19
19
  "greeting": "Hallo, {name}",
20
20
  "catalog": "Katalog",
21
21
  "quotes": "Angebote",
22
+ "sharedCarts": "Geteilte Warenkörbe",
22
23
  "company": "Unternehmen",
23
24
  "businessUnit": "Geschäftseinheit",
24
25
  "shoppingCart": "Warenkorb",
@@ -565,6 +566,7 @@
565
566
  "orders": "Bestellungen",
566
567
  "storeCredits": "Guthaben",
567
568
  "quotes": "Angebote",
569
+ "sharedCarts": "Geteilte Warenkörbe",
568
570
  "shoppingLists": "Einkaufslisten",
569
571
  "quickOrder": "Schnellbestellung",
570
572
  "approvals": "Genehmigungen",
@@ -19,6 +19,7 @@
19
19
  "greeting": "Hi, {name}",
20
20
  "catalog": "Catalog",
21
21
  "quotes": "Quotes",
22
+ "sharedCarts": "Shared Carts",
22
23
  "company": "Company",
23
24
  "businessUnit": "Business Unit",
24
25
  "shoppingCart": "Shopping Cart",
@@ -565,6 +566,7 @@
565
566
  "orders": "Orders",
566
567
  "storeCredits": "Store Credits",
567
568
  "quotes": "Quotes",
569
+ "sharedCarts": "Shared Carts",
568
570
  "shoppingLists": "Shopping Lists",
569
571
  "quickOrder": "Quick Order",
570
572
  "approvals": "Approvals",
@@ -37,7 +37,7 @@
37
37
  "@radix-ui/react-toggle": "^1.1.10",
38
38
  "@radix-ui/react-toggle-group": "^1.1.11",
39
39
  "@radix-ui/react-tooltip": "^1.2.8",
40
- "@vendure-platform/storefront-client": "^0.4.0",
40
+ "@vendure-platform/storefront-client": "^0.5.0",
41
41
  "class-variance-authority": "^0.7.1",
42
42
  "clsx": "^2.1.1",
43
43
  "cmdk": "^1.1.1",
@@ -27,6 +27,8 @@ import { getActiveChannelCached } from '@/lib/vendure/cached';
27
27
  import { getActiveChannelToken } from '@/lib/channel-server';
28
28
  import { addGiftCardToCart, evaluateConfiguration, addConfigurationToOrder } from './actions';
29
29
  import { getShoppingListsWithMembership } from '@/app/[locale]/account/shopping-lists/actions';
30
+ import { getCompanyAccount } from '@/lib/vendure/actions';
31
+ import { canCreateSharedCarts } from '@/components/commerce/shared-cart-role';
30
32
  import { ConfiguratorForm } from '@/components/commerce/configurator-form';
31
33
 
32
34
  async function getProductData(slug: string, locale: string, ctx?: VendureContext, channelToken?: string) {
@@ -176,11 +178,14 @@ async function ProductDetailContent({
176
178
 
177
179
  const isGiftCard = product.customFields?.isGiftCard === true;
178
180
 
179
- const [channel, shoppingLists] = await Promise.all([
181
+ const [channel, shoppingLists, companyAccount] = await Promise.all([
180
182
  getActiveChannelCached(ctx.channelToken),
181
183
  getShoppingListsWithMembership(),
184
+ getCompanyAccount(),
182
185
  ]);
183
186
  const currencyCode = channel.defaultCurrencyCode;
187
+ const isCompanyUser = !!companyAccount;
188
+ const canCreateSharedCart = canCreateSharedCarts(companyAccount?.permissions);
184
189
 
185
190
  let giftCardTemplates: GiftCardTemplate[] = [];
186
191
  if (isGiftCard) {
@@ -309,7 +314,7 @@ async function ProductDetailContent({
309
314
  {productDescription && <div className="mt-6">{productDescription}</div>}
310
315
  </div>
311
316
  <div>
312
- <InfoComponent product={product} searchParams={searchParamsResolved} shoppingLists={shoppingLists} currencyCode={currencyCode} />
317
+ <InfoComponent product={product} searchParams={searchParamsResolved} shoppingLists={shoppingLists} currencyCode={currencyCode} isCompanyUser={isCompanyUser} canCreateSharedCart={canCreateSharedCart} />
313
318
  </div>
314
319
  </div>
315
320
 
@@ -0,0 +1,152 @@
1
+ 'use client';
2
+
3
+ import { roleLabel } from '@/components/commerce/shared-cart-role';
4
+ import { formatDateTime } from '@/lib/format';
5
+ import {
6
+ CircleDot,
7
+ CreditCard,
8
+ Crown,
9
+ Lock,
10
+ MinusCircle,
11
+ PencilLine,
12
+ PlusCircle,
13
+ ShieldCheck,
14
+ UserMinus,
15
+ UserPlus,
16
+ type LucideIcon,
17
+ } from 'lucide-react';
18
+ import type { SharedCartData } from '../actions';
19
+
20
+ type Activity = SharedCartData['activity'][number];
21
+ type Lines = SharedCartData['order']['lines'];
22
+
23
+ const ICONS: Record<string, LucideIcon> = {
24
+ 'cart-created': CircleDot,
25
+ 'line-added': PlusCircle,
26
+ 'line-removed': MinusCircle,
27
+ 'line-adjusted': PencilLine,
28
+ 'member-added': UserPlus,
29
+ 'member-removed': UserMinus,
30
+ 'role-changed': ShieldCheck,
31
+ 'ownership-transferred': Crown,
32
+ 'checkout-initiated': CreditCard,
33
+ 'cart-closed': Lock,
34
+ };
35
+
36
+ // This activity-describing logic has a near-twin in the dashboard's shared-cart-detail
37
+ // route. They're deliberately kept separate copies: dashboard code can't import from the
38
+ // storefront (different bundle, no server-side deps allowed), so there's no shared module
39
+ // to reach for. If you change how an activity reads here, mirror it there.
40
+ function actorName(activity: Activity): string {
41
+ return activity.actorLabel;
42
+ }
43
+
44
+ // The product an item entry refers to, resolved from the cart's current lines: the affected line
45
+ // for adds/adjusts, or the recorded variant id as a fallback. A removed line is gone, so those
46
+ // entries fall back to the generic "an item".
47
+ function lineName(activity: Activity, lines: Lines): string | null {
48
+ if (activity.targetLineId) {
49
+ const byLine = lines.find((l) => String(l.id) === String(activity.targetLineId));
50
+ if (byLine) return byLine.productVariant.name;
51
+ }
52
+ const variantId = (activity.detail as Record<string, unknown> | null)?.productVariantId;
53
+ if (variantId != null) {
54
+ const byVariant = lines.find((l) => String(l.productVariant.id) === String(variantId));
55
+ if (byVariant) return byVariant.productVariant.name;
56
+ }
57
+ return null;
58
+ }
59
+
60
+ function targetName(activity: Activity): string | null {
61
+ if (!activity.targetCustomer) return null;
62
+ return `${activity.targetCustomer.firstName} ${activity.targetCustomer.lastName}`.trim() || null;
63
+ }
64
+
65
+ function describe(activity: Activity, lines: Lines): string {
66
+ const detail = (activity.detail ?? {}) as Record<string, unknown>;
67
+ const qty = typeof detail.quantity === 'number' ? detail.quantity : undefined;
68
+ const item = lineName(activity, lines);
69
+ const who = targetName(activity);
70
+ switch (activity.action) {
71
+ case 'cart-created':
72
+ return 'created the cart';
73
+ case 'line-added':
74
+ if (item) return qty ? `added ${item} (×${qty})` : `added ${item}`;
75
+ return qty ? `added ${qty} × an item` : 'added an item';
76
+ case 'line-removed':
77
+ return item ? `removed ${item}` : 'removed an item';
78
+ case 'line-adjusted':
79
+ if (item) return qty != null ? `changed ${item} to qty ${qty}` : `changed ${item}`;
80
+ return qty != null ? `changed an item to qty ${qty}` : 'changed an item quantity';
81
+ case 'member-added':
82
+ if (who)
83
+ return detail.role ? `added ${who} as ${roleLabel(String(detail.role))}` : `added ${who}`;
84
+ return detail.role ? `added a member as ${roleLabel(String(detail.role))}` : 'added a member';
85
+ case 'member-removed':
86
+ return who ? `removed ${who}` : 'removed a member';
87
+ case 'role-changed':
88
+ if (who)
89
+ return detail.to
90
+ ? `changed ${who}'s role to ${roleLabel(String(detail.to))}`
91
+ : `changed ${who}'s role`;
92
+ return detail.to
93
+ ? `changed a member's role to ${roleLabel(String(detail.to))}`
94
+ : "changed a member's role";
95
+ case 'ownership-transferred':
96
+ return who ? `transferred ownership to ${who}` : 'transferred ownership';
97
+ case 'checkout-initiated':
98
+ return 'started checkout';
99
+ case 'cart-closed':
100
+ return 'closed the cart';
101
+ default:
102
+ return activity.action;
103
+ }
104
+ }
105
+
106
+ export function ActivityFeed({
107
+ activity,
108
+ lines,
109
+ locale,
110
+ }: {
111
+ activity: ReadonlyArray<Activity>;
112
+ lines: Lines;
113
+ locale: string;
114
+ }) {
115
+ // Newest first.
116
+ const items = [...activity].sort(
117
+ (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
118
+ );
119
+
120
+ return (
121
+ <section className="border rounded-lg">
122
+ <div className="px-4 py-3 border-b">
123
+ <h2 className="font-semibold">Activity</h2>
124
+ </div>
125
+ {items.length === 0 ? (
126
+ <p className="px-4 py-6 text-sm text-muted-foreground">No activity yet.</p>
127
+ ) : (
128
+ <ul className="divide-y">
129
+ {items.map((item) => {
130
+ const Icon = ICONS[item.action] ?? CircleDot;
131
+ return (
132
+ <li key={item.id} className="flex items-start gap-3 px-4 py-3">
133
+ <div className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted">
134
+ <Icon className="h-3.5 w-3.5 text-muted-foreground" />
135
+ </div>
136
+ <div className="min-w-0 flex-1">
137
+ <p className="text-sm">
138
+ <span className="font-medium">{actorName(item)}</span>{' '}
139
+ <span className="text-muted-foreground">{describe(item, lines)}</span>
140
+ </p>
141
+ <p className="text-xs text-muted-foreground">
142
+ {formatDateTime(item.createdAt, locale)}
143
+ </p>
144
+ </div>
145
+ </li>
146
+ );
147
+ })}
148
+ </ul>
149
+ )}
150
+ </section>
151
+ );
152
+ }
@@ -0,0 +1,130 @@
1
+ 'use client';
2
+
3
+ import {useEffect, useRef, useState, useTransition} from 'react';
4
+ import Image from 'next/image';
5
+ import {toast} from 'sonner';
6
+ import {Input} from '@/components/ui/input';
7
+ import {Search, Plus, Loader2} from 'lucide-react';
8
+ import {searchVariantsForSharedCart, addItemToSharedCart, type VariantSearchResult} from '../actions';
9
+
10
+ export function AddItemSearch({sharedCartId, onAdded}: {sharedCartId: string; onAdded: () => void}) {
11
+ const [term, setTerm] = useState('');
12
+ const [results, setResults] = useState<VariantSearchResult[]>([]);
13
+ const [open, setOpen] = useState(false);
14
+ const [isSearching, setIsSearching] = useState(false);
15
+ const [addingId, setAddingId] = useState<string | null>(null);
16
+ const [, startTransition] = useTransition();
17
+ const containerRef = useRef<HTMLDivElement>(null);
18
+ const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
19
+
20
+ // Debounce in the change handler (not an effect) to match the storefront's
21
+ // search convention and avoid cascading effect re-renders.
22
+ function onTermChange(value: string) {
23
+ setTerm(value);
24
+ clearTimeout(debounceRef.current);
25
+ const q = value.trim();
26
+ if (q.length < 2) {
27
+ setResults([]);
28
+ setOpen(false);
29
+ setIsSearching(false);
30
+ return;
31
+ }
32
+ setIsSearching(true);
33
+ debounceRef.current = setTimeout(async () => {
34
+ const items = await searchVariantsForSharedCart(q);
35
+ setResults(items);
36
+ setOpen(true);
37
+ setIsSearching(false);
38
+ }, 300);
39
+ }
40
+
41
+ useEffect(() => {
42
+ function onClickOutside(e: MouseEvent) {
43
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) setOpen(false);
44
+ }
45
+ document.addEventListener('mousedown', onClickOutside);
46
+ return () => {
47
+ document.removeEventListener('mousedown', onClickOutside);
48
+ clearTimeout(debounceRef.current);
49
+ };
50
+ }, []);
51
+
52
+ function add(item: VariantSearchResult) {
53
+ setAddingId(item.productVariantId);
54
+ startTransition(async () => {
55
+ const result = await addItemToSharedCart(sharedCartId, item.productVariantId, 1);
56
+ setAddingId(null);
57
+ if (!result.success) {
58
+ toast.error(result.message ?? 'Could not add item.');
59
+ return;
60
+ }
61
+ toast.success(`Added ${item.productName}`);
62
+ setTerm('');
63
+ setResults([]);
64
+ setOpen(false);
65
+ onAdded();
66
+ });
67
+ }
68
+
69
+ return (
70
+ <div className="relative" ref={containerRef}>
71
+ <div className="relative">
72
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
73
+ <Input
74
+ value={term}
75
+ onChange={e => onTermChange(e.target.value)}
76
+ onFocus={() => results.length > 0 && setOpen(true)}
77
+ placeholder="Search products to add…"
78
+ className="pl-9"
79
+ />
80
+ {isSearching && <Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />}
81
+ </div>
82
+
83
+ {open && (
84
+ <div className="absolute z-10 mt-1 w-full rounded-lg border bg-background shadow-lg max-h-72 overflow-y-auto">
85
+ {results.length === 0 ? (
86
+ <p className="p-3 text-sm text-muted-foreground">No products found.</p>
87
+ ) : (
88
+ results.map(item => (
89
+ <button
90
+ key={item.productVariantId}
91
+ type="button"
92
+ className="flex items-center gap-3 w-full p-3 hover:bg-muted/50 text-left disabled:opacity-60"
93
+ onClick={() => add(item)}
94
+ disabled={addingId !== null}
95
+ >
96
+ <div className="h-8 w-8 shrink-0 rounded border bg-muted overflow-hidden">
97
+ {item.imageUrl && (
98
+ <Image
99
+ src={`${item.imageUrl}?preset=tiny`}
100
+ alt={item.productName}
101
+ width={32}
102
+ height={32}
103
+ className="h-full w-full object-cover"
104
+ />
105
+ )}
106
+ </div>
107
+ <div className="flex-1 min-w-0">
108
+ <div className="flex items-center gap-1.5">
109
+ <p className="text-sm font-medium truncate">{item.productName}</p>
110
+ {!item.inStock && (
111
+ <span className="shrink-0 text-[10px] font-medium px-1 py-0.5 rounded bg-red-100 text-red-700">
112
+ Out of stock
113
+ </span>
114
+ )}
115
+ </div>
116
+ <p className="text-xs text-muted-foreground">{item.sku}</p>
117
+ </div>
118
+ {addingId === item.productVariantId ? (
119
+ <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
120
+ ) : (
121
+ <Plus className="h-4 w-4 text-muted-foreground" />
122
+ )}
123
+ </button>
124
+ ))
125
+ )}
126
+ </div>
127
+ )}
128
+ </div>
129
+ );
130
+ }
@@ -0,0 +1,124 @@
1
+ 'use client';
2
+
3
+ import {useState, useTransition} from 'react';
4
+ import {toast} from 'sonner';
5
+ import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger} from '@/components/ui/dialog';
6
+ import {Button} from '@/components/ui/button';
7
+ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
8
+ import {Label} from '@/components/ui/label';
9
+ import {UserPlus, Loader2} from 'lucide-react';
10
+ import {ROLE_OPTIONS} from '@/components/commerce/shared-cart-role';
11
+ import {
12
+ fetchCompanyMembers,
13
+ addSharedCartMember,
14
+ type CompanyMemberOption,
15
+ type SharedCartRole,
16
+ } from '../actions';
17
+
18
+ export function AddMemberDialog({
19
+ cartId,
20
+ excludeCustomerIds,
21
+ onAdded,
22
+ }: {
23
+ cartId: string;
24
+ excludeCustomerIds: Set<string>;
25
+ onAdded: () => void;
26
+ }) {
27
+ const [open, setOpen] = useState(false);
28
+ const [candidates, setCandidates] = useState<CompanyMemberOption[] | null>(null);
29
+ const [selected, setSelected] = useState<string>('');
30
+ const [role, setRole] = useState<SharedCartRole>('edit');
31
+ const [isPending, startTransition] = useTransition();
32
+
33
+ function loadOnOpen(next: boolean) {
34
+ setOpen(next);
35
+ if (next && candidates === null) {
36
+ fetchCompanyMembers().then(setCandidates);
37
+ }
38
+ }
39
+
40
+ const available = (candidates ?? []).filter(c => !excludeCustomerIds.has(c.customerId));
41
+
42
+ function add() {
43
+ if (!selected) return;
44
+ startTransition(async () => {
45
+ const result = await addSharedCartMember(cartId, selected, role);
46
+ if (!result.success) {
47
+ toast.error(result.message ?? 'Could not add member.');
48
+ return;
49
+ }
50
+ toast.success('Member added.');
51
+ setSelected('');
52
+ setOpen(false);
53
+ onAdded();
54
+ });
55
+ }
56
+
57
+ return (
58
+ <Dialog open={open} onOpenChange={loadOnOpen}>
59
+ <DialogTrigger asChild>
60
+ <Button variant="outline" className="w-full">
61
+ <UserPlus className="h-4 w-4 mr-2" />
62
+ Add colleague
63
+ </Button>
64
+ </DialogTrigger>
65
+ <DialogContent>
66
+ <DialogHeader>
67
+ <DialogTitle>Add a colleague</DialogTitle>
68
+ <DialogDescription>Choose someone from your company and the access they should have.</DialogDescription>
69
+ </DialogHeader>
70
+
71
+ <div className="space-y-4 py-2">
72
+ <div className="space-y-2">
73
+ <Label>Colleague</Label>
74
+ {candidates === null ? (
75
+ <p className="text-sm text-muted-foreground">Loading colleagues…</p>
76
+ ) : available.length === 0 ? (
77
+ <p className="text-sm text-muted-foreground">Everyone in your company is already a member.</p>
78
+ ) : (
79
+ <Select value={selected} onValueChange={setSelected} disabled={isPending}>
80
+ <SelectTrigger className="w-full">
81
+ <SelectValue placeholder="Select a colleague" />
82
+ </SelectTrigger>
83
+ <SelectContent>
84
+ {available.map(c => (
85
+ <SelectItem key={c.customerId} value={c.customerId}>
86
+ {c.firstName} {c.lastName} — {c.emailAddress}
87
+ </SelectItem>
88
+ ))}
89
+ </SelectContent>
90
+ </Select>
91
+ )}
92
+ </div>
93
+
94
+ <div className="space-y-2">
95
+ <Label>Role</Label>
96
+ <Select value={role} onValueChange={v => setRole(v as SharedCartRole)} disabled={isPending}>
97
+ <SelectTrigger className="w-full">
98
+ <SelectValue />
99
+ </SelectTrigger>
100
+ <SelectContent>
101
+ {ROLE_OPTIONS.map(o => (
102
+ <SelectItem key={o.value} value={o.value}>
103
+ <span className="font-medium">{o.label}</span>
104
+ <span className="text-muted-foreground"> — {o.hint}</span>
105
+ </SelectItem>
106
+ ))}
107
+ </SelectContent>
108
+ </Select>
109
+ </div>
110
+ </div>
111
+
112
+ <DialogFooter>
113
+ <Button type="button" variant="ghost" onClick={() => setOpen(false)} disabled={isPending}>
114
+ Cancel
115
+ </Button>
116
+ <Button onClick={add} disabled={!selected || isPending}>
117
+ {isPending && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
118
+ Add colleague
119
+ </Button>
120
+ </DialogFooter>
121
+ </DialogContent>
122
+ </Dialog>
123
+ );
124
+ }