CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/next/components/store/cart.tsx
Views: 687
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, currency, isValidUUID } from "@cocalc/util/misc";
18
import { Alert, Button, Checkbox, Popconfirm, Space, 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 } 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 [subTotal, setSubTotal] = 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 subTotal = 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
subTotal += item.cost.cost;
70
}
71
x.push(item);
72
}
73
setSubTotal(subTotal);
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 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 Proceed() {
199
const checkout = (
200
<Button
201
disabled={subTotal == 0 || updating}
202
size="large"
203
type="primary"
204
onClick={() => {
205
router.push("/store/checkout");
206
}}
207
>
208
Proceed to Checkout
209
</Button>
210
);
211
return (
212
<Space>
213
{checkout}
214
<Button
215
disabled={subTotal == 0 || updating}
216
size="large"
217
onClick={() => {
218
router.push("/store/vouchers");
219
}}
220
>
221
Create Vouchers
222
</Button>
223
</Space>
224
);
225
}
226
227
function renderItems() {
228
return (
229
<>
230
<div style={{ float: "right" }}>
231
<Proceed />
232
</div>
233
<h3>
234
<Icon name={"shopping-cart"} style={{ marginRight: "5px" }} />{" "}
235
Shopping Cart
236
</h3>
237
<div style={{ marginTop: "-10px" }}>
238
<SelectAllItems items={items} onChange={reload} />
239
<Button
240
type="link"
241
style={{ marginLeft: "15px" }}
242
onClick={() => router.push("/store/site-license")}
243
>
244
Continue Shopping
245
</Button>
246
</div>
247
<div style={{ border: "1px solid #eee", marginTop: "15px" }}>
248
<Table
249
showHeader={false}
250
columns={columns}
251
dataSource={items}
252
rowKey={"id"}
253
pagination={{ hideOnSinglePage: true }}
254
/>
255
</div>
256
</>
257
);
258
}
259
260
return (
261
<>
262
{items.length == 0 && noItems()}
263
{items.length > 0 && renderItems()}
264
265
<div
266
style={{
267
marginTop: "60px",
268
border: "1px solid #eee",
269
}}
270
>
271
<OtherItems onChange={reload} cart={cart} />
272
</div>
273
</>
274
);
275
}
276
277
function SelectAllItems({ items, onChange }) {
278
const numSelected = useMemo(() => {
279
let n = 0;
280
if (items == null) return n;
281
for (const item of items) {
282
if (item.checked) n += 1;
283
}
284
return n;
285
}, [items]);
286
if (items == null) return null;
287
288
async function doSelectAll(checked: boolean) {
289
await apiPost("/shopping/cart/checked", { checked });
290
onChange();
291
}
292
293
if (numSelected == 0) {
294
return (
295
<>
296
<Button type="primary" onClick={() => doSelectAll(true)}>
297
Select all items
298
</Button>
299
</>
300
);
301
}
302
if (numSelected < items.length) {
303
return (
304
<Button type="link" onClick={() => doSelectAll(true)}>
305
Select all items
306
</Button>
307
);
308
}
309
return (
310
<Button type="link" onClick={() => doSelectAll(false)}>
311
Deselect all items
312
</Button>
313
);
314
}
315
316
function CheckboxColumn({
317
id,
318
checked,
319
updating,
320
setUpdating,
321
isMounted,
322
reload,
323
}) {
324
return (
325
<Checkbox
326
disabled={updating}
327
checked={checked}
328
onChange={async (e) => {
329
setUpdating(true);
330
try {
331
await apiPost("/shopping/cart/checked", {
332
id,
333
checked: e.target.checked,
334
});
335
if (!isMounted.current) return;
336
await reload();
337
} finally {
338
if (!isMounted.current) return;
339
setUpdating(false);
340
}
341
}}
342
>
343
<span className="sr-only">Select</span>
344
</Checkbox>
345
);
346
}
347
348
interface DCProps {
349
product: ProductType;
350
id: string;
351
cost: CostInputPeriod;
352
description: ProductDescription;
353
updating: boolean;
354
setUpdating: (u: boolean) => void;
355
isMounted: { current: boolean };
356
reload: () => void;
357
compact: boolean;
358
project_id?: string;
359
readOnly?: boolean; // if true, don't show any buttons
360
style?;
361
}
362
363
const DESCRIPTION_STYLE = {
364
border: "1px solid lightblue",
365
background: "white",
366
padding: "15px",
367
margin: "5px 0 10px 0",
368
borderRadius: "5px",
369
} as const;
370
371
// Also used externally for showing what a voucher is for in next/pages/vouchers/[id].tsx
372
export function DescriptionColumn(props: DCProps) {
373
const { description, style, readOnly } = props;
374
if (
375
description.type == "disk" ||
376
description.type == "vm" ||
377
description.type == "quota"
378
) {
379
return <DescriptionColumnSiteLicense {...props} />;
380
} else if (description.type == "cash-voucher") {
381
return (
382
<div style={style}>
383
<b style={{ fontSize: "12pt" }}>Cash voucher</b>
384
<div style={DESCRIPTION_STYLE}>
385
Voucher for {currency(description.amount)}.
386
</div>
387
{!readOnly && (
388
<>
389
<SaveForLater {...props} />
390
<DeleteItem {...props} />
391
</>
392
)}
393
</div>
394
);
395
} else {
396
return <pre>{JSON.stringify(description, undefined, 2)}</pre>;
397
}
398
}
399
400
function DescriptionColumnSiteLicense(props: DCProps) {
401
const { id, cost, description, compact, project_id, readOnly } = props;
402
if (
403
!(
404
description.type == "disk" ||
405
description.type == "vm" ||
406
description.type == "quota"
407
)
408
) {
409
throw Error("BUG -- incorrect typing");
410
}
411
const router = useRouter();
412
if (cost == null) {
413
// don't crash when used on deprecated items
414
return <pre>{JSON.stringify(description, undefined, 2)}</pre>;
415
}
416
const { input } = cost;
417
if (input.type == "cash-voucher") {
418
throw Error("incorrect typing");
419
}
420
421
function renderProjectID(): JSX.Element | null {
422
if (!project_id || !isValidUUID(project_id)) return null;
423
return (
424
<Alert
425
type="info"
426
banner={true}
427
message={
428
<>
429
For project: <code>{project_id}</code>
430
</>
431
}
432
/>
433
);
434
}
435
436
function editableQuota() {
437
if (input.type == "cash-voucher") return null;
438
return (
439
<div>
440
<div>{describeQuotaFromInfo(input)}</div>
441
{renderProjectID()}
442
</div>
443
);
444
}
445
446
// this could rely an the "type" field, but we rather check the data directly
447
function editPage(): "site-license" | "boost" | "dedicated" | "vouchers" {
448
if (input.type == "cash-voucher") {
449
return "vouchers";
450
} else if (input.type === "disk" || input.type === "vm") {
451
return "dedicated";
452
} else if (input.boost) {
453
return "boost";
454
}
455
return "site-license";
456
}
457
458
return (
459
<div style={{ fontSize: "12pt" }}>
460
{description.title && (
461
<div>
462
<b>{description.title}</b>
463
</div>
464
)}
465
{description.description && <div>{description.description}</div>}
466
<div style={DESCRIPTION_STYLE}>
467
<div style={{ marginBottom: "8px" }}>
468
<b>
469
{input.subscription == "no"
470
? describePeriod({ quota: input })
471
: capitalize(input.subscription) + " subscription"}
472
</b>
473
</div>
474
{compact || readOnly ? describeItem({ info: input }) : editableQuota()}{" "}
475
</div>
476
{!readOnly && (
477
<>
478
<Button
479
style={{ marginRight: "5px" }}
480
onClick={() => {
481
const page = editPage();
482
router.push(`/store/${page}?id=${id}`);
483
}}
484
>
485
<Icon name="pencil" /> Edit
486
</Button>
487
<SaveForLater {...props} />
488
<DeleteItem {...props} />
489
</>
490
)}
491
</div>
492
);
493
}
494
495
function SaveForLater({ id, reload, updating, setUpdating, isMounted }) {
496
return (
497
<Button
498
style={{ margin: "0 5px 5px 0" }}
499
disabled={updating}
500
onClick={async () => {
501
setUpdating(true);
502
try {
503
await apiPost("/shopping/cart/remove", { id });
504
if (!isMounted.current) return;
505
await reload();
506
} finally {
507
if (!isMounted.current) return;
508
setUpdating(false);
509
}
510
}}
511
>
512
<Icon name="save" /> Save for later
513
</Button>
514
);
515
}
516
517
function DeleteItem({ id, reload, updating, setUpdating, isMounted }) {
518
return (
519
<Popconfirm
520
title={"Are you sure you want to delete this item?"}
521
onConfirm={async () => {
522
setUpdating(true);
523
try {
524
await apiPost("/shopping/cart/delete", { id });
525
if (!isMounted.current) return;
526
await reload();
527
} finally {
528
if (!isMounted.current) return;
529
setUpdating(false);
530
}
531
}}
532
okText={"Yes, delete this item"}
533
cancelText={"Cancel"}
534
>
535
<Button disabled={updating} type="dashed">
536
<Icon name="trash" /> Delete
537
</Button>
538
</Popconfirm>
539
);
540
}
541
542
const PRODUCTS = {
543
"site-license": { icon: "key", label: "License" },
544
"cash-voucher": { icon: "money", label: "Cash Voucher" },
545
};
546
547
export function ProductColumn({ product }) {
548
const { icon, label } = PRODUCTS[product] ?? {
549
icon: "check",
550
label: "Unknown",
551
};
552
return (
553
<div style={{ color: "darkblue" }}>
554
<Icon name={icon} style={{ fontSize: "24px" }} />
555
<div style={{ fontSize: "10pt" }}>{label}</div>
556
</div>
557
);
558
}
559
560