Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/next/components/store/cart.tsx
1450 views
1
/*
2
* This file is part of CoCalc: Copyright © 2022 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
/*
7
Shopping cart.
8
9
The UX is similar to Amazon.com, since that's probably the single most popular
10
shopping cart experience, so most likely to feel familiar to users and easy
11
to use.
12
*/
13
14
import { Icon } from "@cocalc/frontend/components/icon";
15
import { describeQuotaFromInfo } from "@cocalc/util/licenses/describe-quota";
16
import { CostInputPeriod } from "@cocalc/util/licenses/purchase/types";
17
import { capitalize, isValidUUID } from "@cocalc/util/misc";
18
import { Alert, Button, Checkbox, Popconfirm, Table } from "antd";
19
import A from "components/misc/A";
20
import Loading from "components/share/loading";
21
import SiteName from "components/share/site-name";
22
import apiPost from "lib/api/post";
23
import useAPI from "lib/hooks/api";
24
import useIsMounted from "lib/hooks/mounted";
25
import { useRouter } from "next/router";
26
import { useEffect, useMemo, useState, type JSX } from "react";
27
import { computeCost } from "@cocalc/util/licenses/store/compute-cost";
28
import OtherItems from "./other-items";
29
import { describeItem, describePeriod, DisplayCost } from "./site-license-cost";
30
import type {
31
ProductDescription,
32
ProductType,
33
} from "@cocalc/util/db-schema/shopping-cart-items";
34
35
export default function ShoppingCart() {
36
const isMounted = useIsMounted();
37
const [updating, setUpdating] = useState<boolean>(false);
38
const [numChecked, setNumChecked] = useState<number>(0);
39
const router = useRouter();
40
41
// most likely, user will checkout next
42
useEffect(() => {
43
router.prefetch("/store/checkout");
44
}, []);
45
46
const cart = useAPI("/shopping/cart/get");
47
48
const items = useMemo(() => {
49
if (!cart.result) return undefined;
50
// TODO deal with errors returned by useAPI
51
if (cart.result.error != null) return undefined;
52
const x: any[] = [];
53
let numChecked = 0;
54
for (const item of cart.result) {
55
try {
56
item.cost = computeCost(item.description);
57
} catch (err) {
58
// sadly computeCost is buggy, or rather - it crashes because of other bugs.
59
// It's much better to
60
// have something not in the cart and an error than to make the cart and
61
// store just be 100% broken
62
// forever for a user!
63
// That said, I've fixed every bug I could find and tested things, so hopefully
64
// this doesn't come up.
65
console.warn("Invalid item in cart -- not showing", item);
66
continue;
67
}
68
if (item.checked) {
69
numChecked += 1;
70
}
71
x.push(item);
72
}
73
setNumChecked(numChecked);
74
return x;
75
}, [cart.result]);
76
77
if (cart.error) {
78
return <Alert type="error" message={cart.error} />;
79
}
80
81
if (!items) {
82
return <Loading large center />;
83
}
84
85
async function reload() {
86
if (!isMounted.current) return;
87
setUpdating(true);
88
try {
89
await cart.call();
90
} finally {
91
if (isMounted.current) {
92
setUpdating(false);
93
}
94
}
95
}
96
97
const columns = [
98
{
99
responsive: ["xs" as "xs"],
100
render: ({
101
id,
102
product,
103
checked,
104
cost,
105
description,
106
type,
107
project_id,
108
}) => {
109
return (
110
<div>
111
<CheckboxColumn
112
{...{ id, checked, updating, setUpdating, isMounted, reload }}
113
/>
114
<DescriptionColumn
115
{...{
116
product,
117
id,
118
cost,
119
description,
120
updating,
121
setUpdating,
122
isMounted,
123
reload,
124
type,
125
project_id,
126
}}
127
compact
128
/>
129
<div>
130
<b style={{ fontSize: "11pt" }}>
131
<DisplayCost cost={cost} simple oneLine />
132
</b>
133
</div>
134
</div>
135
);
136
},
137
},
138
{
139
responsive: ["sm" as "sm"],
140
title: "",
141
render: (_, { id, checked }) => (
142
<CheckboxColumn
143
{...{ id, checked, updating, setUpdating, isMounted, reload }}
144
/>
145
),
146
},
147
{
148
responsive: ["sm" as "sm"],
149
title: "Product",
150
align: "center" as "center",
151
render: (_, { product }) => <ProductColumn product={product} />,
152
},
153
{
154
responsive: ["sm" as "sm"],
155
width: "60%",
156
render: (_, { product, id, cost, description, type, project_id }) => (
157
<DescriptionColumn
158
{...{
159
product,
160
id,
161
cost,
162
description,
163
updating,
164
setUpdating,
165
isMounted,
166
reload,
167
type,
168
project_id,
169
}}
170
compact={false}
171
/>
172
),
173
},
174
{
175
responsive: ["sm" as "sm"],
176
title: "Price",
177
align: "right" as "right",
178
render: (_, { cost }) => (
179
<b style={{ fontSize: "11pt" }}>
180
<DisplayCost cost={cost} simple />
181
</b>
182
),
183
},
184
];
185
186
function noItems() {
187
return (
188
<>
189
<h3>
190
<Icon name={"shopping-cart"} style={{ marginRight: "5px" }} /> Your{" "}
191
<SiteName /> Shopping Cart is Empty
192
</h3>
193
<A href="/store/site-license">Buy a License</A>
194
</>
195
);
196
}
197
198
function renderItems() {
199
return (
200
<>
201
<div style={{ float: "right" }}>
202
<Button
203
disabled={numChecked == 0 || updating}
204
size="large"
205
type="primary"
206
onClick={() => {
207
router.push("/store/checkout");
208
}}
209
>
210
Proceed to Checkout
211
</Button>
212
</div>
213
<h3>
214
<Icon name={"shopping-cart"} style={{ marginRight: "5px" }} />{" "}
215
Shopping Cart
216
</h3>
217
<div style={{ marginTop: "-10px" }}>
218
<SelectAllItems items={items} onChange={reload} />
219
<Button
220
type="link"
221
style={{ marginLeft: "15px" }}
222
onClick={() => router.push("/store/site-license")}
223
>
224
Continue Shopping
225
</Button>
226
</div>
227
<div style={{ border: "1px solid #eee", marginTop: "15px" }}>
228
<Table
229
showHeader={false}
230
columns={columns}
231
dataSource={items}
232
rowKey={"id"}
233
pagination={{ hideOnSinglePage: true }}
234
/>
235
</div>
236
</>
237
);
238
}
239
240
return (
241
<>
242
{items.length == 0 && noItems()}
243
{items.length > 0 && renderItems()}
244
245
<div
246
style={{
247
marginTop: "60px",
248
border: "1px solid #eee",
249
}}
250
>
251
<OtherItems onChange={reload} cart={cart} />
252
</div>
253
</>
254
);
255
}
256
257
function SelectAllItems({ items, onChange }) {
258
const numSelected = useMemo(() => {
259
let n = 0;
260
if (items == null) return n;
261
for (const item of items) {
262
if (item.checked) n += 1;
263
}
264
return n;
265
}, [items]);
266
if (items == null) return null;
267
268
async function doSelectAll(checked: boolean) {
269
await apiPost("/shopping/cart/checked", { checked });
270
onChange();
271
}
272
273
if (numSelected == 0) {
274
return (
275
<>
276
<Button type="primary" onClick={() => doSelectAll(true)}>
277
Select all items
278
</Button>
279
</>
280
);
281
}
282
if (numSelected < items.length) {
283
return (
284
<Button type="link" onClick={() => doSelectAll(true)}>
285
Select all items
286
</Button>
287
);
288
}
289
return (
290
<Button type="link" onClick={() => doSelectAll(false)}>
291
Deselect all items
292
</Button>
293
);
294
}
295
296
function CheckboxColumn({
297
id,
298
checked,
299
updating,
300
setUpdating,
301
isMounted,
302
reload,
303
}) {
304
return (
305
<Checkbox
306
disabled={updating}
307
checked={checked}
308
onChange={async (e) => {
309
setUpdating(true);
310
try {
311
await apiPost("/shopping/cart/checked", {
312
id,
313
checked: e.target.checked,
314
});
315
if (!isMounted.current) return;
316
await reload();
317
} finally {
318
if (!isMounted.current) return;
319
setUpdating(false);
320
}
321
}}
322
>
323
<span className="sr-only">Select</span>
324
</Checkbox>
325
);
326
}
327
328
interface DCProps {
329
product: ProductType;
330
id: string;
331
cost: CostInputPeriod;
332
description: ProductDescription;
333
updating: boolean;
334
setUpdating: (u: boolean) => void;
335
isMounted: { current: boolean };
336
reload: () => void;
337
compact: boolean;
338
project_id?: string;
339
readOnly?: boolean; // if true, don't show any buttons
340
style?;
341
}
342
343
const DESCRIPTION_STYLE = {
344
border: "1px solid lightblue",
345
background: "white",
346
padding: "15px",
347
margin: "5px 0 10px 0",
348
borderRadius: "5px",
349
} as const;
350
351
// Also used externally for showing what a voucher is for in next/pages/vouchers/[id].tsx
352
export function DescriptionColumn(props: DCProps) {
353
const router = useRouter();
354
const { id, description, style, readOnly } = props;
355
if (
356
description.type == "disk" ||
357
description.type == "vm" ||
358
description.type == "quota"
359
) {
360
return <DescriptionColumnSiteLicense {...props} />;
361
} else if (description.type == "cash-voucher") {
362
return (
363
<div style={style}>
364
<b style={{ fontSize: "12pt" }}>Cash Voucher: {description.title}</b>
365
<div style={DESCRIPTION_STYLE}>
366
{describeItem({ info: description })}
367
</div>
368
{!readOnly && (
369
<>
370
<Button
371
style={{ marginRight: "5px" }}
372
onClick={() => {
373
router.push(`/store/vouchers?id=${id}`);
374
}}
375
>
376
<Icon name="pencil" /> Edit
377
</Button>
378
<SaveForLater {...props} />
379
<DeleteItem {...props} />
380
</>
381
)}
382
</div>
383
);
384
} else {
385
return <pre>{JSON.stringify(description, undefined, 2)}</pre>;
386
}
387
}
388
389
function DescriptionColumnSiteLicense(props: DCProps) {
390
const { id, cost, description, compact, project_id, readOnly } = props;
391
if (
392
!(
393
description.type == "disk" ||
394
description.type == "vm" ||
395
description.type == "quota"
396
)
397
) {
398
throw Error("BUG -- incorrect typing");
399
}
400
const router = useRouter();
401
if (cost == null) {
402
// don't crash when used on deprecated items
403
return <pre>{JSON.stringify(description, undefined, 2)}</pre>;
404
}
405
const { input } = cost;
406
if (input.type == "cash-voucher") {
407
throw Error("incorrect typing");
408
}
409
410
function renderProjectID(): JSX.Element | null {
411
if (!project_id || !isValidUUID(project_id)) return null;
412
return (
413
<Alert
414
type="info"
415
banner={true}
416
message={
417
<>
418
For project: <code>{project_id}</code>
419
</>
420
}
421
/>
422
);
423
}
424
425
function editableQuota() {
426
if (input.type == "cash-voucher") return null;
427
return (
428
<div>
429
<div>{describeQuotaFromInfo(input)}</div>
430
{renderProjectID()}
431
</div>
432
);
433
}
434
435
// this could rely an the "type" field, but we rather check the data directly
436
function editPage(): "site-license" | "vouchers" {
437
if (input.type == "cash-voucher") {
438
return "vouchers";
439
}
440
return "site-license";
441
}
442
443
return (
444
<div style={{ fontSize: "12pt" }}>
445
{description.title && (
446
<div>
447
<b>{description.title}</b>
448
</div>
449
)}
450
{description.description && <div>{description.description}</div>}
451
<div style={DESCRIPTION_STYLE}>
452
<div style={{ marginBottom: "8px" }}>
453
<b>
454
{input.subscription == "no"
455
? describePeriod({ quota: input })
456
: capitalize(input.subscription) + " subscription"}
457
</b>
458
</div>
459
{compact || readOnly ? describeItem({ info: input }) : editableQuota()}{" "}
460
</div>
461
{!readOnly && (
462
<>
463
<Button
464
style={{ marginRight: "5px" }}
465
onClick={() => {
466
const page = editPage();
467
router.push(`/store/${page}?id=${id}`);
468
}}
469
>
470
<Icon name="pencil" /> Edit
471
</Button>
472
<SaveForLater {...props} />
473
<DeleteItem {...props} />
474
</>
475
)}
476
</div>
477
);
478
}
479
480
function SaveForLater({ id, reload, updating, setUpdating, isMounted }) {
481
return (
482
<Button
483
style={{ margin: "0 5px 5px 0" }}
484
disabled={updating}
485
onClick={async () => {
486
setUpdating(true);
487
try {
488
await apiPost("/shopping/cart/remove", { id });
489
if (!isMounted.current) return;
490
await reload();
491
} finally {
492
if (!isMounted.current) return;
493
setUpdating(false);
494
}
495
}}
496
>
497
<Icon name="save" /> Save for later
498
</Button>
499
);
500
}
501
502
function DeleteItem({ id, reload, updating, setUpdating, isMounted }) {
503
return (
504
<Popconfirm
505
title={"Are you sure you want to delete this item?"}
506
onConfirm={async () => {
507
setUpdating(true);
508
try {
509
await apiPost("/shopping/cart/delete", { id });
510
if (!isMounted.current) return;
511
await reload();
512
} finally {
513
if (!isMounted.current) return;
514
setUpdating(false);
515
}
516
}}
517
okText={"Yes, delete this item"}
518
cancelText={"Cancel"}
519
>
520
<Button disabled={updating} type="dashed">
521
<Icon name="trash" /> Delete
522
</Button>
523
</Popconfirm>
524
);
525
}
526
527
const PRODUCTS = {
528
"site-license": { icon: "key", label: "License" },
529
"cash-voucher": { icon: "money", label: "Cash Voucher" },
530
};
531
532
export function ProductColumn({ product }) {
533
const { icon, label } = PRODUCTS[product] ?? {
534
icon: "check",
535
label: "Unknown",
536
};
537
return (
538
<div style={{ color: "darkblue" }}>
539
<Icon name={icon} style={{ fontSize: "24px" }} />
540
<div style={{ fontSize: "10pt" }}>{label}</div>
541
</div>
542
);
543
}
544
545