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