@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.
- package/CHANGELOG.md +4 -0
- 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/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 +18 -5
- 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 +134 -0
- package/dist/plugin-registry.js +1 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -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.
|
|
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
|
+
}
|