@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
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import {client} from '@/lib/vendure/api';
|
|
4
|
+
import {
|
|
5
|
+
CreateSharedCartMutation,
|
|
6
|
+
UpdateSharedCartMutation,
|
|
7
|
+
DeleteSharedCartMutation,
|
|
8
|
+
AddItemToSharedCartMutation,
|
|
9
|
+
AdjustSharedCartLineMutation,
|
|
10
|
+
RemoveSharedCartLineMutation,
|
|
11
|
+
AddSharedCartMemberMutation,
|
|
12
|
+
RemoveSharedCartMemberMutation,
|
|
13
|
+
SetSharedCartMemberRoleMutation,
|
|
14
|
+
TransferSharedCartOwnershipMutation,
|
|
15
|
+
InitiateSharedCartCheckoutMutation,
|
|
16
|
+
} from '@/lib/vendure/mutations';
|
|
17
|
+
import {GetSharedCartQuery, GetMySharedCartsQuery, SearchVariantsQuery, GetShopCompanyUsersQuery} from '@/lib/vendure/queries';
|
|
18
|
+
import {CompanyUserFragment} from '@/lib/vendure/fragments';
|
|
19
|
+
import {getActiveCustomer} from '@/lib/vendure/actions';
|
|
20
|
+
import {roleAtLeast} from '@/components/commerce/shared-cart-role';
|
|
21
|
+
import {readFragment, type ResultOf} from '@/graphql';
|
|
22
|
+
import {revalidatePath, updateTag} from 'next/cache';
|
|
23
|
+
|
|
24
|
+
export type SharedCartRole = 'view' | 'edit' | 'checkout';
|
|
25
|
+
type ActionResult = {success: boolean; message?: string; id?: string};
|
|
26
|
+
|
|
27
|
+
function invalidateCart() {
|
|
28
|
+
updateTag('cart');
|
|
29
|
+
updateTag('active-order');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function revalidateCart(id: string) {
|
|
33
|
+
revalidatePath(`/account/shared-carts/${id}`);
|
|
34
|
+
revalidatePath('/account/shared-carts');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// --- Read (server-action wrapper for the client poll) ----------------------
|
|
38
|
+
|
|
39
|
+
export type SharedCartData = NonNullable<ResultOf<typeof GetSharedCartQuery>['sharedCart']>;
|
|
40
|
+
|
|
41
|
+
export async function fetchSharedCart(id: string): Promise<SharedCartData | null> {
|
|
42
|
+
const [result, error] = await client.query(GetSharedCartQuery, {id});
|
|
43
|
+
if (error) return null;
|
|
44
|
+
return result.data.sharedCart ?? null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// --- Lifecycle -------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
export async function createSharedCart(name: string, description?: string): Promise<ActionResult> {
|
|
50
|
+
const [result, error] = await client.mutate(CreateSharedCartMutation, {
|
|
51
|
+
input: {name, description: description || undefined},
|
|
52
|
+
});
|
|
53
|
+
if (error) return {success: false, message: error.message};
|
|
54
|
+
revalidatePath('/account/shared-carts');
|
|
55
|
+
return {success: true, id: result.data.createSharedCart.id};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* "Share my cart": create a blank shared cart, then copy the active order's
|
|
60
|
+
* lines into it. Non-atomic by design for v1 — if a line fails to copy we keep
|
|
61
|
+
* the cart and surface which step failed, so the user lands on a usable cart
|
|
62
|
+
* rather than losing the whole action.
|
|
63
|
+
*/
|
|
64
|
+
export async function shareMyCart(
|
|
65
|
+
name: string,
|
|
66
|
+
description: string | undefined,
|
|
67
|
+
lines: Array<{productVariantId: string; quantity: number}>,
|
|
68
|
+
): Promise<ActionResult> {
|
|
69
|
+
const created = await createSharedCart(name, description);
|
|
70
|
+
if (!created.success || !created.id) return created;
|
|
71
|
+
for (const line of lines) {
|
|
72
|
+
const [, err] = await client.mutate(AddItemToSharedCartMutation, {
|
|
73
|
+
sharedCartId: created.id,
|
|
74
|
+
productVariantId: line.productVariantId,
|
|
75
|
+
quantity: line.quantity,
|
|
76
|
+
});
|
|
77
|
+
if (err) {
|
|
78
|
+
return {success: false, message: `Cart created, but an item couldn't be copied: ${err.message}`, id: created.id};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
revalidatePath('/account/shared-carts');
|
|
82
|
+
return {success: true, id: created.id};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function renameSharedCart(id: string, name: string, description?: string): Promise<ActionResult> {
|
|
86
|
+
const [, error] = await client.mutate(UpdateSharedCartMutation, {
|
|
87
|
+
input: {id, name, description: description || undefined},
|
|
88
|
+
});
|
|
89
|
+
if (error) return {success: false, message: error.message};
|
|
90
|
+
revalidateCart(id);
|
|
91
|
+
return {success: true};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function deleteSharedCart(id: string): Promise<ActionResult> {
|
|
95
|
+
const [, error] = await client.mutate(DeleteSharedCartMutation, {id});
|
|
96
|
+
if (error) return {success: false, message: error.message};
|
|
97
|
+
revalidatePath('/account/shared-carts');
|
|
98
|
+
return {success: true};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- Lines (role >= edit; server enforces) ---------------------------------
|
|
102
|
+
|
|
103
|
+
export async function addItemToSharedCart(sharedCartId: string, productVariantId: string, quantity: number): Promise<ActionResult> {
|
|
104
|
+
const [, error] = await client.mutate(AddItemToSharedCartMutation, {sharedCartId, productVariantId, quantity});
|
|
105
|
+
if (error) return {success: false, message: error.message};
|
|
106
|
+
revalidateCart(sharedCartId);
|
|
107
|
+
return {success: true};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Add several lines at once — powers the "Add to shared cart" picker when it
|
|
112
|
+
* carries a whole cart (the /cart page) rather than a single product. Non-atomic
|
|
113
|
+
* like {@link shareMyCart}: if a line fails we stop and report which step broke,
|
|
114
|
+
* leaving the earlier lines in place rather than rolling the cart back.
|
|
115
|
+
*/
|
|
116
|
+
export async function addItemsToSharedCart(
|
|
117
|
+
sharedCartId: string,
|
|
118
|
+
lines: Array<{productVariantId: string; quantity: number}>,
|
|
119
|
+
): Promise<ActionResult> {
|
|
120
|
+
for (const line of lines) {
|
|
121
|
+
const [, error] = await client.mutate(AddItemToSharedCartMutation, {
|
|
122
|
+
sharedCartId,
|
|
123
|
+
productVariantId: line.productVariantId,
|
|
124
|
+
quantity: line.quantity,
|
|
125
|
+
});
|
|
126
|
+
if (error) return {success: false, message: error.message};
|
|
127
|
+
}
|
|
128
|
+
revalidateCart(sharedCartId);
|
|
129
|
+
return {success: true};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function adjustSharedCartLine(sharedCartId: string, lineId: string, quantity: number): Promise<ActionResult> {
|
|
133
|
+
const [, error] = await client.mutate(AdjustSharedCartLineMutation, {sharedCartId, lineId, quantity});
|
|
134
|
+
if (error) return {success: false, message: error.message};
|
|
135
|
+
revalidateCart(sharedCartId);
|
|
136
|
+
return {success: true};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function removeSharedCartLine(sharedCartId: string, lineId: string): Promise<ActionResult> {
|
|
140
|
+
const [, error] = await client.mutate(RemoveSharedCartLineMutation, {sharedCartId, lineId});
|
|
141
|
+
if (error) return {success: false, message: error.message};
|
|
142
|
+
revalidateCart(sharedCartId);
|
|
143
|
+
return {success: true};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export type EditableSharedCart = {id: string; name: string};
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Open carts the viewer can add items to (owner, or member with role >= edit).
|
|
150
|
+
* Powers the product-page "Add to shared cart" picker.
|
|
151
|
+
*/
|
|
152
|
+
export async function fetchEditableOpenSharedCarts(): Promise<EditableSharedCart[]> {
|
|
153
|
+
const [customer, [result, error]] = await Promise.all([
|
|
154
|
+
getActiveCustomer(),
|
|
155
|
+
client.query(GetMySharedCartsQuery, {options: {take: 100, filter: {status: {eq: 'Open'}}}}),
|
|
156
|
+
]);
|
|
157
|
+
if (!customer || error) return [];
|
|
158
|
+
return result.data.mySharedCarts.items
|
|
159
|
+
.filter(cart => {
|
|
160
|
+
if (cart.owner.id === customer.id) return true;
|
|
161
|
+
return roleAtLeast(cart.members.find(m => m.customer.id === customer.id)?.role, 'edit');
|
|
162
|
+
})
|
|
163
|
+
.map(cart => ({id: cart.id, name: cart.name}));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export type VariantSearchResult = {
|
|
167
|
+
productVariantId: string;
|
|
168
|
+
productName: string;
|
|
169
|
+
variantName: string;
|
|
170
|
+
sku: string;
|
|
171
|
+
imageUrl?: string;
|
|
172
|
+
price: number;
|
|
173
|
+
currencyCode: string;
|
|
174
|
+
inStock: boolean;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
export async function searchVariantsForSharedCart(term: string): Promise<VariantSearchResult[]> {
|
|
178
|
+
const [result, error] = await client.query(SearchVariantsQuery, {
|
|
179
|
+
input: {term: term.trim(), take: 10, groupByProduct: false},
|
|
180
|
+
});
|
|
181
|
+
if (error) return [];
|
|
182
|
+
return result.data.search.items
|
|
183
|
+
.filter(item => item.price.__typename === 'SinglePrice')
|
|
184
|
+
.map(item => ({
|
|
185
|
+
productVariantId: item.productVariantId,
|
|
186
|
+
productName: item.productName,
|
|
187
|
+
variantName: item.productVariantName,
|
|
188
|
+
sku: item.sku,
|
|
189
|
+
imageUrl: item.productAsset?.preview ?? undefined,
|
|
190
|
+
price: (item.price as {__typename: 'SinglePrice'; value: number}).value,
|
|
191
|
+
currencyCode: item.currencyCode,
|
|
192
|
+
inStock: item.inStock,
|
|
193
|
+
}));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// --- Members & governance (owner OR company-admin; server enforces) --------
|
|
197
|
+
|
|
198
|
+
export type CompanyMemberOption = {
|
|
199
|
+
customerId: string;
|
|
200
|
+
firstName: string;
|
|
201
|
+
lastName: string;
|
|
202
|
+
emailAddress: string;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
export async function fetchCompanyMembers(): Promise<CompanyMemberOption[]> {
|
|
206
|
+
const [result, error] = await client.query(GetShopCompanyUsersQuery, {});
|
|
207
|
+
if (error) return [];
|
|
208
|
+
return result.data.shopCompanyUsers
|
|
209
|
+
.map(user => readFragment(CompanyUserFragment, user))
|
|
210
|
+
.map(user => ({
|
|
211
|
+
customerId: user.customer.id,
|
|
212
|
+
firstName: user.customer.firstName,
|
|
213
|
+
lastName: user.customer.lastName,
|
|
214
|
+
emailAddress: user.customer.emailAddress,
|
|
215
|
+
}));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
export async function addSharedCartMember(sharedCartId: string, customerId: string, role: SharedCartRole): Promise<ActionResult> {
|
|
220
|
+
const [, error] = await client.mutate(AddSharedCartMemberMutation, {input: {sharedCartId, customerId, role}});
|
|
221
|
+
if (error) return {success: false, message: error.message};
|
|
222
|
+
revalidateCart(sharedCartId);
|
|
223
|
+
return {success: true};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function removeSharedCartMember(sharedCartId: string, customerId: string): Promise<ActionResult> {
|
|
227
|
+
const [, error] = await client.mutate(RemoveSharedCartMemberMutation, {sharedCartId, customerId});
|
|
228
|
+
if (error) return {success: false, message: error.message};
|
|
229
|
+
revalidateCart(sharedCartId);
|
|
230
|
+
return {success: true};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function setSharedCartMemberRole(sharedCartId: string, customerId: string, role: SharedCartRole): Promise<ActionResult> {
|
|
234
|
+
const [, error] = await client.mutate(SetSharedCartMemberRoleMutation, {input: {sharedCartId, customerId, role}});
|
|
235
|
+
if (error) return {success: false, message: error.message};
|
|
236
|
+
revalidateCart(sharedCartId);
|
|
237
|
+
return {success: true};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function transferSharedCartOwnership(sharedCartId: string, newOwnerCustomerId: string): Promise<ActionResult> {
|
|
241
|
+
const [, error] = await client.mutate(TransferSharedCartOwnershipMutation, {sharedCartId, newOwnerCustomerId});
|
|
242
|
+
if (error) return {success: false, message: error.message};
|
|
243
|
+
revalidateCart(sharedCartId);
|
|
244
|
+
return {success: true};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// --- Checkout hand-off (role >= checkout; server enforces) -----------------
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Lock the shared cart and adopt its order as the viewer's active order. The
|
|
251
|
+
* caller then sends the buyer to the normal /checkout flow; approval, if the
|
|
252
|
+
* company requires it, runs through the existing approval UI.
|
|
253
|
+
*/
|
|
254
|
+
export async function initiateSharedCartCheckout(sharedCartId: string): Promise<ActionResult> {
|
|
255
|
+
const [, error] = await client.mutate(InitiateSharedCartCheckoutMutation, {sharedCartId});
|
|
256
|
+
if (error) return {success: false, message: error.message};
|
|
257
|
+
invalidateCart();
|
|
258
|
+
revalidateCart(sharedCartId);
|
|
259
|
+
return {success: true};
|
|
260
|
+
}
|
package/dist/assets/storefront/src/app/[locale]/account/shared-carts/new/new-shared-cart-form.tsx
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {useState, useTransition} from 'react';
|
|
4
|
+
import {useRouter} from '@/i18n/navigation';
|
|
5
|
+
import {toast} from 'sonner';
|
|
6
|
+
import {Button} from '@/components/ui/button';
|
|
7
|
+
import {Input} from '@/components/ui/input';
|
|
8
|
+
import {Textarea} from '@/components/ui/textarea';
|
|
9
|
+
import {Label} from '@/components/ui/label';
|
|
10
|
+
import {Loader2} from 'lucide-react';
|
|
11
|
+
import {createSharedCart} from '../actions';
|
|
12
|
+
|
|
13
|
+
interface NewSharedCartFormProps {
|
|
14
|
+
defaultName: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function NewSharedCartForm({defaultName}: NewSharedCartFormProps) {
|
|
18
|
+
const router = useRouter();
|
|
19
|
+
const [name, setName] = useState(defaultName);
|
|
20
|
+
const [description, setDescription] = useState('');
|
|
21
|
+
const [isPending, startTransition] = useTransition();
|
|
22
|
+
|
|
23
|
+
const trimmed = name.trim();
|
|
24
|
+
|
|
25
|
+
function handleSubmit(e: React.FormEvent) {
|
|
26
|
+
e.preventDefault();
|
|
27
|
+
if (!trimmed) return;
|
|
28
|
+
startTransition(async () => {
|
|
29
|
+
const result = await createSharedCart(trimmed, description.trim() || undefined);
|
|
30
|
+
if (!result.success || !result.id) {
|
|
31
|
+
toast.error(result.message ?? 'Could not create the shared cart.');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
router.push(`/account/shared-carts/${result.id}`);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<form onSubmit={handleSubmit} className="max-w-xl space-y-6">
|
|
40
|
+
<div className="space-y-2">
|
|
41
|
+
<Label htmlFor="shared-cart-name">Name</Label>
|
|
42
|
+
<Input
|
|
43
|
+
id="shared-cart-name"
|
|
44
|
+
value={name}
|
|
45
|
+
onChange={e => setName(e.target.value)}
|
|
46
|
+
placeholder="e.g. Q3 office supplies"
|
|
47
|
+
autoFocus
|
|
48
|
+
maxLength={255}
|
|
49
|
+
required
|
|
50
|
+
/>
|
|
51
|
+
<p className="text-xs text-muted-foreground">Give the cart a name your colleagues will recognise.</p>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<div className="space-y-2">
|
|
55
|
+
<Label htmlFor="shared-cart-description">Description (optional)</Label>
|
|
56
|
+
<Textarea
|
|
57
|
+
id="shared-cart-description"
|
|
58
|
+
value={description}
|
|
59
|
+
onChange={e => setDescription(e.target.value)}
|
|
60
|
+
placeholder="What is this cart for?"
|
|
61
|
+
rows={3}
|
|
62
|
+
maxLength={1000}
|
|
63
|
+
/>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<div className="flex items-center gap-3">
|
|
67
|
+
<Button type="submit" disabled={!trimmed || isPending}>
|
|
68
|
+
{isPending && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
|
69
|
+
Create shared cart
|
|
70
|
+
</Button>
|
|
71
|
+
<Button type="button" variant="ghost" onClick={() => router.push('/account/shared-carts')} disabled={isPending}>
|
|
72
|
+
Cancel
|
|
73
|
+
</Button>
|
|
74
|
+
</div>
|
|
75
|
+
</form>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type {Metadata} from 'next';
|
|
2
|
+
import {client} from '@/lib/vendure/api';
|
|
3
|
+
import {readFragment} from '@/graphql';
|
|
4
|
+
import {GetActiveCustomerQuery} from '@/lib/vendure/queries';
|
|
5
|
+
import {ActiveCustomerFragment} from '@/lib/vendure/fragments';
|
|
6
|
+
import {getCompanyAccount} from '@/lib/vendure/actions';
|
|
7
|
+
import {canCreateSharedCarts} from '@/components/commerce/shared-cart-role';
|
|
8
|
+
import {redirect, Link} from '@/i18n/navigation';
|
|
9
|
+
import {ArrowLeft} from 'lucide-react';
|
|
10
|
+
import {NewSharedCartForm} from './new-shared-cart-form';
|
|
11
|
+
|
|
12
|
+
export const metadata: Metadata = {
|
|
13
|
+
title: 'New Shared Cart',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export default async function NewSharedCartPage() {
|
|
17
|
+
const [[customerResult, customerError], companyAccount] = await Promise.all([
|
|
18
|
+
client.query(GetActiveCustomerQuery, {}),
|
|
19
|
+
getCompanyAccount(),
|
|
20
|
+
]);
|
|
21
|
+
if (customerError || !customerResult.data.activeCustomer) {
|
|
22
|
+
return await redirect('/sign-in');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!canCreateSharedCarts(companyAccount?.permissions)) {
|
|
26
|
+
return await redirect('/account/shared-carts');
|
|
27
|
+
}
|
|
28
|
+
const customer = readFragment(ActiveCustomerFragment, customerResult.data.activeCustomer);
|
|
29
|
+
const defaultName = `${customer.firstName}'s shared cart`;
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div>
|
|
33
|
+
<Link
|
|
34
|
+
href="/account/shared-carts"
|
|
35
|
+
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground mb-4"
|
|
36
|
+
>
|
|
37
|
+
<ArrowLeft className="h-4 w-4" />
|
|
38
|
+
Shared carts
|
|
39
|
+
</Link>
|
|
40
|
+
<h1 className="text-3xl font-bold mb-1">New shared cart</h1>
|
|
41
|
+
<p className="text-sm text-muted-foreground mb-6">
|
|
42
|
+
Create an empty cart, then invite colleagues and add items.
|
|
43
|
+
</p>
|
|
44
|
+
<NewSharedCartForm defaultName={defaultName} />
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import type {Metadata} from 'next';
|
|
2
|
+
import {Suspense} from 'react';
|
|
3
|
+
import {client} from '@/lib/vendure/api';
|
|
4
|
+
import {GetMySharedCartsQuery, GetCompanySharedCartsQuery} from '@/lib/vendure/queries';
|
|
5
|
+
import {getActiveCustomer, getCompanyAccount} from '@/lib/vendure/actions';
|
|
6
|
+
import {
|
|
7
|
+
Table,
|
|
8
|
+
TableBody,
|
|
9
|
+
TableCell,
|
|
10
|
+
TableHead,
|
|
11
|
+
TableHeader,
|
|
12
|
+
TableRow,
|
|
13
|
+
} from '@/components/ui/table';
|
|
14
|
+
import {Pagination} from '@/components/shared/pagination';
|
|
15
|
+
import {Badge} from '@/components/ui/badge';
|
|
16
|
+
import {ErrorState} from '@/components/shared/error-state';
|
|
17
|
+
import {ArrowRight, Plus, Users, ShoppingCart} from 'lucide-react';
|
|
18
|
+
import {Button} from '@/components/ui/button';
|
|
19
|
+
import {Price} from '@/components/commerce/price';
|
|
20
|
+
import {SharedCartStatusBadge} from '@/components/commerce/shared-cart-status-badge';
|
|
21
|
+
import {roleLabel, canCreateSharedCarts, canManageSharedCarts} from '@/components/commerce/shared-cart-role';
|
|
22
|
+
import {formatDate} from '@/lib/format';
|
|
23
|
+
import {cn} from '@/lib/utils';
|
|
24
|
+
import {setRequestLocale} from 'next-intl/server';
|
|
25
|
+
import {Link, redirect} from '@/i18n/navigation';
|
|
26
|
+
|
|
27
|
+
export const metadata: Metadata = {
|
|
28
|
+
title: 'Shared Carts',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const ITEMS_PER_PAGE = 10;
|
|
32
|
+
|
|
33
|
+
export default function SharedCartsPage(props: {
|
|
34
|
+
params: Promise<{locale: string}>;
|
|
35
|
+
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
|
36
|
+
}) {
|
|
37
|
+
return (
|
|
38
|
+
<Suspense
|
|
39
|
+
fallback={
|
|
40
|
+
<div>
|
|
41
|
+
<div className="flex items-center justify-between mb-6">
|
|
42
|
+
<h1 className="text-3xl font-bold">Shared Carts</h1>
|
|
43
|
+
</div>
|
|
44
|
+
<p className="text-sm text-muted-foreground">Loading shared carts…</p>
|
|
45
|
+
</div>
|
|
46
|
+
}
|
|
47
|
+
>
|
|
48
|
+
<SharedCartsContent params={props.params} searchParamsPromise={props.searchParams} />
|
|
49
|
+
</Suspense>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function SharedCartsContent({
|
|
54
|
+
params,
|
|
55
|
+
searchParamsPromise,
|
|
56
|
+
}: {
|
|
57
|
+
params: Promise<{locale: string}>;
|
|
58
|
+
searchParamsPromise: Promise<Record<string, string | string[] | undefined>>;
|
|
59
|
+
}) {
|
|
60
|
+
const {locale} = await params;
|
|
61
|
+
setRequestLocale(locale);
|
|
62
|
+
const [searchParams, activeCustomer, companyAccount] = await Promise.all([
|
|
63
|
+
searchParamsPromise,
|
|
64
|
+
getActiveCustomer(),
|
|
65
|
+
getCompanyAccount(),
|
|
66
|
+
]);
|
|
67
|
+
if (!activeCustomer) return await redirect('/sign-in');
|
|
68
|
+
const canCreate = canCreateSharedCarts(companyAccount?.permissions);
|
|
69
|
+
const isAdmin = canManageSharedCarts(companyAccount?.permissions);
|
|
70
|
+
|
|
71
|
+
// Company admins get a second "All company carts" view; everyone else only ever sees their own.
|
|
72
|
+
const scopeParam = searchParams.scope;
|
|
73
|
+
const scope =
|
|
74
|
+
isAdmin && (Array.isArray(scopeParam) ? scopeParam[0] : scopeParam) === 'company'
|
|
75
|
+
? 'company'
|
|
76
|
+
: 'mine';
|
|
77
|
+
|
|
78
|
+
const pageParam = searchParams.page;
|
|
79
|
+
const parsedPage = parseInt(Array.isArray(pageParam) ? pageParam[0] : pageParam || '1', 10);
|
|
80
|
+
const currentPage = Number.isFinite(parsedPage) && parsedPage > 0 ? parsedPage : 1;
|
|
81
|
+
const skip = (currentPage - 1) * ITEMS_PER_PAGE;
|
|
82
|
+
|
|
83
|
+
const options = {take: ITEMS_PER_PAGE, skip, sort: {updatedAt: 'DESC' as never}};
|
|
84
|
+
const [result, error] =
|
|
85
|
+
scope === 'company'
|
|
86
|
+
? await client.query(GetCompanySharedCartsQuery, {options})
|
|
87
|
+
: await client.query(GetMySharedCartsQuery, {options});
|
|
88
|
+
|
|
89
|
+
if (error) {
|
|
90
|
+
return (
|
|
91
|
+
<div>
|
|
92
|
+
<Header canCreate={canCreate} />
|
|
93
|
+
{isAdmin && <ScopeTabs scope={scope} />}
|
|
94
|
+
<ErrorState message="We couldn't load your shared carts. Please try again later." />
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const list = 'companySharedCarts' in result.data ? result.data.companySharedCarts : result.data.mySharedCarts;
|
|
100
|
+
const carts = list.items;
|
|
101
|
+
const totalItems = list.totalItems;
|
|
102
|
+
const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE);
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div>
|
|
106
|
+
<Header canCreate={canCreate} />
|
|
107
|
+
{isAdmin && <ScopeTabs scope={scope} />}
|
|
108
|
+
|
|
109
|
+
{carts.length === 0 ? (
|
|
110
|
+
<EmptyState canCreate={canCreate} scope={scope} />
|
|
111
|
+
) : (
|
|
112
|
+
<div className="border rounded-lg overflow-hidden">
|
|
113
|
+
<Table>
|
|
114
|
+
<TableHeader className="bg-muted">
|
|
115
|
+
<TableRow>
|
|
116
|
+
<TableHead>Name</TableHead>
|
|
117
|
+
<TableHead>Status</TableHead>
|
|
118
|
+
<TableHead>Your role</TableHead>
|
|
119
|
+
<TableHead>Owner</TableHead>
|
|
120
|
+
<TableHead className="text-center">Members</TableHead>
|
|
121
|
+
<TableHead className="text-center">Items</TableHead>
|
|
122
|
+
<TableHead className="text-right">Total</TableHead>
|
|
123
|
+
<TableHead>Updated</TableHead>
|
|
124
|
+
<TableHead className="w-12" />
|
|
125
|
+
</TableRow>
|
|
126
|
+
</TableHeader>
|
|
127
|
+
<TableBody>
|
|
128
|
+
{carts.map(cart => {
|
|
129
|
+
const isOwner = cart.owner.id === activeCustomer.id;
|
|
130
|
+
const membership = cart.members.find(m => m.customer.id === activeCustomer.id);
|
|
131
|
+
const yourRole = isOwner ? 'owner' : membership?.role;
|
|
132
|
+
return (
|
|
133
|
+
<TableRow key={cart.id} className="hover:bg-muted/50">
|
|
134
|
+
<TableCell className="font-medium">
|
|
135
|
+
<Link
|
|
136
|
+
href={`/account/shared-carts/${cart.id}`}
|
|
137
|
+
className="hover:underline underline-offset-4"
|
|
138
|
+
>
|
|
139
|
+
{cart.name}
|
|
140
|
+
</Link>
|
|
141
|
+
</TableCell>
|
|
142
|
+
<TableCell>
|
|
143
|
+
<SharedCartStatusBadge status={cart.status} />
|
|
144
|
+
</TableCell>
|
|
145
|
+
<TableCell>
|
|
146
|
+
{yourRole ? (
|
|
147
|
+
<Badge variant={isOwner ? 'default' : 'outline'}>
|
|
148
|
+
{isOwner ? 'Owner' : roleLabel(yourRole)}
|
|
149
|
+
</Badge>
|
|
150
|
+
) : (
|
|
151
|
+
<span className="text-muted-foreground">—</span>
|
|
152
|
+
)}
|
|
153
|
+
</TableCell>
|
|
154
|
+
<TableCell>
|
|
155
|
+
{cart.owner.firstName} {cart.owner.lastName}
|
|
156
|
+
</TableCell>
|
|
157
|
+
<TableCell className="text-center tabular-nums">{cart.members.length}</TableCell>
|
|
158
|
+
<TableCell className="text-center tabular-nums">{cart.order.totalQuantity}</TableCell>
|
|
159
|
+
<TableCell className="text-right">
|
|
160
|
+
<Price value={cart.order.totalWithTax} currencyCode={cart.order.currencyCode} taxLabel="incl" />
|
|
161
|
+
</TableCell>
|
|
162
|
+
<TableCell className="text-muted-foreground whitespace-nowrap">
|
|
163
|
+
{formatDate(cart.updatedAt, locale)}
|
|
164
|
+
</TableCell>
|
|
165
|
+
<TableCell>
|
|
166
|
+
<Button asChild variant="ghost" size="icon">
|
|
167
|
+
<Link href={`/account/shared-carts/${cart.id}`} aria-label={`Open ${cart.name}`}>
|
|
168
|
+
<ArrowRight className="h-4 w-4" />
|
|
169
|
+
</Link>
|
|
170
|
+
</Button>
|
|
171
|
+
</TableCell>
|
|
172
|
+
</TableRow>
|
|
173
|
+
);
|
|
174
|
+
})}
|
|
175
|
+
</TableBody>
|
|
176
|
+
</Table>
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
|
|
180
|
+
{totalPages > 1 && (
|
|
181
|
+
<div className="mt-6">
|
|
182
|
+
<Pagination currentPage={currentPage} totalPages={totalPages} />
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function Header({canCreate}: {canCreate: boolean}) {
|
|
190
|
+
return (
|
|
191
|
+
<div className="flex flex-wrap items-start justify-between gap-4 mb-6">
|
|
192
|
+
<div>
|
|
193
|
+
<h1 className="text-3xl font-bold">Shared Carts</h1>
|
|
194
|
+
<p className="text-sm text-muted-foreground mt-1 max-w-prose">
|
|
195
|
+
Build orders together with your colleagues. Add items, set who can edit, and hand off to
|
|
196
|
+
checkout when you're ready.
|
|
197
|
+
</p>
|
|
198
|
+
</div>
|
|
199
|
+
{canCreate && (
|
|
200
|
+
<Button asChild>
|
|
201
|
+
<Link href="/account/shared-carts/new">
|
|
202
|
+
<Plus className="h-4 w-4 mr-2" />
|
|
203
|
+
New shared cart
|
|
204
|
+
</Link>
|
|
205
|
+
</Button>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Segmented control letting a company admin switch between their own carts and every cart in the
|
|
212
|
+
// company. Rendered only for admins; the query and access check are enforced server-side.
|
|
213
|
+
function ScopeTabs({scope}: {scope: 'mine' | 'company'}) {
|
|
214
|
+
const tabClass = (active: boolean) =>
|
|
215
|
+
cn(
|
|
216
|
+
'px-3 py-1.5 text-sm font-medium rounded-md transition-colors',
|
|
217
|
+
active ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground',
|
|
218
|
+
);
|
|
219
|
+
return (
|
|
220
|
+
<div className="mb-6 inline-flex rounded-lg border bg-muted p-1">
|
|
221
|
+
<Link href="/account/shared-carts" className={tabClass(scope === 'mine')}>
|
|
222
|
+
My carts
|
|
223
|
+
</Link>
|
|
224
|
+
<Link href="/account/shared-carts?scope=company" className={tabClass(scope === 'company')}>
|
|
225
|
+
All company carts
|
|
226
|
+
</Link>
|
|
227
|
+
</div>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function EmptyState({canCreate, scope}: {canCreate: boolean; scope: 'mine' | 'company'}) {
|
|
232
|
+
return (
|
|
233
|
+
<div className="border border-dashed rounded-lg py-16 px-6 text-center">
|
|
234
|
+
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
|
235
|
+
<Users className="h-6 w-6 text-muted-foreground" />
|
|
236
|
+
</div>
|
|
237
|
+
<h2 className="text-lg font-semibold">No shared carts yet</h2>
|
|
238
|
+
<p className="text-sm text-muted-foreground mt-1 mx-auto max-w-sm">
|
|
239
|
+
{scope === 'company'
|
|
240
|
+
? 'No one in your company has created a shared cart yet.'
|
|
241
|
+
: canCreate
|
|
242
|
+
? 'Start a cart your team can build together, or share your current cart from the cart page.'
|
|
243
|
+
: "You're not a member of any shared carts yet. A colleague can add you to one."}
|
|
244
|
+
</p>
|
|
245
|
+
{canCreate && (
|
|
246
|
+
<div className="mt-6 flex items-center justify-center gap-3">
|
|
247
|
+
<Button asChild>
|
|
248
|
+
<Link href="/account/shared-carts/new">
|
|
249
|
+
<Plus className="h-4 w-4 mr-2" />
|
|
250
|
+
New shared cart
|
|
251
|
+
</Link>
|
|
252
|
+
</Button>
|
|
253
|
+
<Button asChild variant="outline">
|
|
254
|
+
<Link href="/cart">
|
|
255
|
+
<ShoppingCart className="h-4 w-4 mr-2" />
|
|
256
|
+
Go to cart
|
|
257
|
+
</Link>
|
|
258
|
+
</Button>
|
|
259
|
+
</div>
|
|
260
|
+
)}
|
|
261
|
+
</div>
|
|
262
|
+
);
|
|
263
|
+
}
|