Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/next/components/store/checkout.tsx
1450 views
1
/*
2
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
/*
7
Checkout -- finalize purchase and pay.
8
*/
9
import {
10
Alert,
11
Button,
12
Card,
13
Checkbox,
14
Divider,
15
InputNumber,
16
Col,
17
Row,
18
Space,
19
Slider,
20
Spin,
21
} from "antd";
22
import { useContext, useEffect, useMemo, useState, type JSX } from "react";
23
import { Icon } from "@cocalc/frontend/components/icon";
24
import { money } from "@cocalc/util/licenses/purchase/utils";
25
import { copy_without as copyWithout, isValidUUID } from "@cocalc/util/misc";
26
import A from "components/misc/A";
27
import SiteName from "components/share/site-name";
28
import useIsMounted from "lib/hooks/mounted";
29
import { useRouter } from "next/router";
30
import { describeItem, DisplayCost } from "./site-license-cost";
31
import { useProfileWithReload } from "lib/hooks/profile";
32
import { Paragraph, Title, Text } from "components/misc";
33
import { COLORS } from "@cocalc/util/theme";
34
import { ChangeEmailAddress } from "components/account/config/account/email";
35
import {
36
getShoppingCartCheckoutParams,
37
shoppingCartCheckout,
38
} from "@cocalc/frontend/purchases/api";
39
import { currency, plural, round2up, round2down } from "@cocalc/util/misc";
40
import { type CheckoutParams } from "@cocalc/server/purchases/shopping-cart-checkout";
41
import { ProductColumn } from "./cart";
42
import ShowError from "@cocalc/frontend/components/error";
43
import { StoreBalanceContext } from "../../lib/balance";
44
import StripePayment from "@cocalc/frontend/purchases/stripe-payment";
45
import { toFriendlyDescription } from "@cocalc/util/upgrades/describe";
46
import { creditLineItem } from "@cocalc/util/upgrades/describe";
47
import { SHOPPING_CART_CHECKOUT } from "@cocalc/util/db-schema/purchases";
48
import { decimalSubtract } from "@cocalc/util/stripe/calc";
49
50
export default function Checkout() {
51
const router = useRouter();
52
const isMounted = useIsMounted();
53
const [completingPurchase, setCompletingPurchase] = useState<boolean>(false);
54
const [completedPurchase, setCompletedPurchase] = useState<boolean>(false);
55
const [showApplyCredit, setShowApplyCredit] = useState<boolean>(false);
56
const [applyCredit, setApplyCredit] = useState<number | null>(0);
57
const [lastApplyCredit, setLastApplyCredit] = useState<number | null>(
58
applyCredit,
59
);
60
const [totalCost, setTotalCost] = useState<number>(0);
61
const [error, setError] = useState<string>("");
62
const { profile, reload: reloadProfile } = useProfileWithReload({
63
noCache: true,
64
});
65
66
const [userSuccessfullyAddedCredit, setUserSuccessfullyAddedCredit] =
67
useState<boolean>(false);
68
const { refreshBalance } = useContext(StoreBalanceContext);
69
const [paymentAmount, setPaymentAmount0] = useState<number>(0);
70
const setPaymentAmount = (amount: number) => {
71
// no matter how this is set, always round it up to nearest penny.
72
setPaymentAmount0(round2up(amount));
73
};
74
const [params, setParams] = useState<CheckoutParams | null>(null);
75
const updateParams = async (applyCredit0?: number | null) => {
76
const applyCredit1 = applyCredit0 ?? applyCredit ?? 0;
77
try {
78
const params = await getShoppingCartCheckoutParams();
79
const cost = params.total;
80
setParams(params);
81
setTotalCost(round2up(cost));
82
setPaymentAmount(cost - applyCredit1);
83
setLastApplyCredit(applyCredit1);
84
} catch (err) {
85
setError(`${err}`);
86
}
87
};
88
89
const lineItems = useMemo(() => {
90
if (params?.cart == null) {
91
return [];
92
}
93
const v = params.cart.map((x) => {
94
return {
95
description: toFriendlyDescription(x.description),
96
amount: x.lineItemAmount,
97
};
98
});
99
const { credit } = creditLineItem({
100
lineItems: v,
101
amount:
102
paymentAmount > 0 ? Math.max(params.minPayment, paymentAmount) : 0,
103
});
104
if (credit) {
105
// add one more line item to make the grand total be equal to amount
106
if (credit.amount > 0) {
107
credit.description = `${credit.description} -- adjustment so payment is at least ${currency(params.minPayment)}`;
108
}
109
v.push(credit);
110
}
111
return v;
112
}, [paymentAmount, params]);
113
114
useEffect(() => {
115
// on load also get current price, cart, etc.
116
updateParams();
117
}, []);
118
119
if (error) {
120
return <ShowError error={error} setError={setError} />;
121
}
122
async function completePurchase() {
123
try {
124
setError("");
125
setCompletingPurchase(true);
126
await shoppingCartCheckout();
127
setCompletedPurchase(true);
128
if (isMounted.current) {
129
router.push("/store/congrats");
130
}
131
} catch (err) {
132
// The purchase failed.
133
setError(err.message);
134
setCompletingPurchase(false);
135
} finally {
136
refreshBalance();
137
if (!isMounted.current) {
138
return;
139
}
140
// do NOT set completing purchase back, since the
141
// above router.push
142
// will move to next page, but we don't want to
143
// see the complete purchase button
144
// again ever... unless there is an error.
145
}
146
}
147
148
if (params == null) {
149
return (
150
<div style={{ textAlign: "center" }}>
151
<Spin size="large" tip="Loading" />
152
</div>
153
);
154
}
155
156
let mode;
157
if (completingPurchase) {
158
mode = "completing";
159
} else if (params == null || paymentAmount == 0) {
160
mode = "complete";
161
} else if (completedPurchase) {
162
mode = "completed";
163
} else {
164
mode = "add";
165
}
166
167
function getApplyCreditWarning() {
168
if (params == null) {
169
return null;
170
}
171
const balance = params.balance ?? 0;
172
if (!balance) {
173
return null;
174
}
175
const max = round2down(
176
Math.max(
177
0,
178
Math.min(params.balance, decimalSubtract(totalCost, params.minPayment)),
179
),
180
);
181
return (
182
<>
183
Between{" "}
184
<a
185
onClick={() => {
186
setApplyCredit(0);
187
updateParams(0);
188
}}
189
>
190
{currency(0)}
191
</a>{" "}
192
and{" "}
193
<a
194
onClick={() => {
195
setApplyCredit(max);
196
updateParams(max);
197
}}
198
>
199
{currency(max)}
200
</a>
201
{totalCost <= params.balance && (
202
<>
203
{" "}
204
or exactly{" "}
205
<a
206
onClick={() => {
207
setApplyCredit(totalCost);
208
updateParams(totalCost);
209
}}
210
>
211
{currency(totalCost)}
212
</a>
213
.
214
</>
215
)}
216
<Slider
217
min={0}
218
max={max}
219
tipFormatter={currency}
220
step={0.01}
221
value={applyCredit ?? 0}
222
onChange={setApplyCredit}
223
/>
224
The minimum payment size is {currency(params.minPayment)}, which
225
constrains the amount of credit that can be applied.
226
</>
227
);
228
}
229
230
return (
231
<>
232
<div>
233
<RequireEmailAddress profile={profile} reloadProfile={reloadProfile} />
234
{params.cart.length == 0 && (
235
<div style={{ maxWidth: "800px", margin: "auto" }}>
236
<h3>
237
<Icon name={"shopping-cart"} style={{ marginRight: "5px" }} />
238
{params.cart.length > 0 && (
239
<>
240
Nothing in Your <SiteName />{" "}
241
<A href="/store/cart">Shopping Cart</A> is Selected
242
</>
243
)}
244
{(params.cart.length ?? 0) == 0 && (
245
<>
246
Your <SiteName /> <A href="/store/cart">Shopping Cart</A> is
247
Empty
248
</>
249
)}
250
</h3>
251
<br />
252
<br />
253
You must have at least one item in{" "}
254
<A href="/store/cart">your cart</A> to checkout. Shop for{" "}
255
<A href="/store/site-license">licenses</A> or{" "}
256
<A href="/store/vouchers">vouchers</A>.
257
</div>
258
)}
259
{params.cart.length > 0 && (
260
<>
261
<ShowError error={error} setError={setError} />
262
<Card title={<>Place Your Order</>}>
263
<Row>
264
<Col
265
sm={12}
266
style={{
267
paddingRight: "30px",
268
borderRight: "1px solid #ddd",
269
display: "flex",
270
justifyContent: "center",
271
alignItems: "center",
272
}}
273
>
274
{round2down(params.balance ?? 0) > 0 && (
275
<div>
276
<Checkbox
277
style={{ fontSize: "12pt" }}
278
checked={showApplyCredit}
279
onChange={(e) => {
280
let x = 0;
281
if (e.target.checked) {
282
setShowApplyCredit(true);
283
if (params.balance >= totalCost) {
284
x = totalCost;
285
} else if (
286
params.balance > 0 &&
287
params.balance <= totalCost - params.minPayment
288
) {
289
x = round2down(params.balance);
290
}
291
} else {
292
setShowApplyCredit(false);
293
}
294
setApplyCredit(x);
295
updateParams(x);
296
}}
297
>
298
<b>
299
Use Account Balance -{" "}
300
<span style={{ color: "#666" }}>
301
{currency(round2down(params.balance))} available
302
</span>
303
</b>
304
</Checkbox>
305
{showApplyCredit && (
306
<div style={{ textAlign: "center", marginTop: "30px" }}>
307
<Space.Compact>
308
<InputNumber
309
value={applyCredit}
310
disabled={round2down(params.balance ?? 0) <= 0}
311
min={0}
312
max={Math.max(
313
0,
314
Math.min(totalCost, params.balance),
315
)}
316
addonBefore="$"
317
onChange={(value) => setApplyCredit(value)}
318
onPressEnter={() => {
319
updateParams(applyCredit);
320
}}
321
/>
322
<Button
323
type="primary"
324
disabled={
325
round2down(params.balance ?? 0) <= 0 ||
326
applyCredit == lastApplyCredit
327
}
328
onClick={() => {
329
updateParams(applyCredit);
330
}}
331
>
332
Apply
333
</Button>
334
</Space.Compact>
335
{applyCredit != totalCost &&
336
applyCredit != params.balance && (
337
<Alert
338
showIcon
339
style={{ marginTop: "30px", textAlign: "left" }}
340
message={getApplyCreditWarning()}
341
/>
342
)}
343
</div>
344
)}
345
</div>
346
)}
347
</Col>
348
<Col sm={12} style={{ paddingLeft: "30px" }}>
349
<div style={{ fontSize: "15pt" }}>
350
<Terms />
351
</div>
352
</Col>
353
</Row>
354
<GetAQuote items={params.cart} />
355
356
<div style={{ textAlign: "center" }}>
357
<Divider />
358
{mode == "completing" && (
359
<Alert
360
showIcon
361
style={{ margin: "30px auto", maxWidth: "700px" }}
362
type="success"
363
message={
364
<>
365
Transferring the items in your cart to your account...
366
<Spin style={{ marginLeft: "10px" }} />
367
</>
368
}
369
/>
370
)}
371
</div>
372
{!userSuccessfullyAddedCredit && (
373
<div>
374
<StripePayment
375
description={`Purchasing ${params.cart.length} ${plural(params.cart, "item")} in the CoCalc store.`}
376
style={{ maxWidth: "600px", margin: "30px auto" }}
377
lineItems={lineItems}
378
purpose={SHOPPING_CART_CHECKOUT}
379
metadata={{
380
cart_ids: JSON.stringify(
381
params.cart.map((item) => item.id),
382
),
383
}}
384
onFinished={async () => {
385
setUserSuccessfullyAddedCredit(true);
386
// user paid successfully and money should be in their account
387
await refreshBalance();
388
if (!isMounted.current) {
389
return;
390
}
391
if (paymentAmount <= 0) {
392
// now do the purchase flow with money available.
393
await completePurchase();
394
} else {
395
// actually paid something so processing happens on the backend in response
396
// to payment completion
397
router.push("/store/processing");
398
}
399
}}
400
/>
401
</div>
402
)}
403
{completingPurchase ||
404
totalCost >= params.minPayment ||
405
params == null ||
406
totalCost <= 0 ||
407
paymentAmount >= params.minPayment ? null : (
408
<Alert
409
showIcon
410
type="warning"
411
style={{ marginTop: "15px" }}
412
message={
413
<>
414
The minimum payment amount is{" "}
415
{currency(params.minPayment)}. Extra money you deposit for
416
this purchase can be used toward future purchases.
417
</>
418
}
419
/>
420
)}
421
</Card>
422
</>
423
)}
424
<ShowError error={error} setError={setError} />
425
</div>
426
</>
427
);
428
}
429
430
export function fullCost(items) {
431
let full_cost = 0;
432
for (const { cost, checked } of items) {
433
if (checked) {
434
full_cost += cost.cost_sub_first_period ?? cost.cost;
435
}
436
}
437
return full_cost;
438
}
439
440
function Terms() {
441
return (
442
<Paragraph
443
style={{ color: COLORS.GRAY, fontSize: "10pt", marginTop: "8px" }}
444
>
445
By placing your order, you agree to{" "}
446
<A href="/policies/terms" external>
447
our terms of service
448
</A>{" "}
449
regarding refunds and subscriptions.
450
</Paragraph>
451
);
452
}
453
454
export function DescriptionColumn({ cost, description, voucherPeriod }) {
455
const { input } = cost;
456
return (
457
<>
458
<div style={{ fontSize: "12pt" }}>
459
{description.title && (
460
<div>
461
<b>{description.title}</b>
462
</div>
463
)}
464
{description.description && <div>{description.description}</div>}
465
{describeItem({ info: input, voucherPeriod })}
466
</div>
467
</>
468
);
469
}
470
471
const MIN_AMOUNT = 100;
472
473
function GetAQuote({ items }) {
474
const router = useRouter();
475
const [more, setMore] = useState<boolean>(false);
476
let isSub;
477
for (const item of items) {
478
if (item.description.period != "range" && item.product == "site-license") {
479
isSub = true;
480
break;
481
}
482
}
483
484
function createSupportRequest() {
485
const x: any[] = [];
486
for (const item of items) {
487
x.push({
488
cost: money(item.cost.cost),
489
...copyWithout(item, [
490
"account_id",
491
"added",
492
"removed",
493
"purchased",
494
"checked",
495
"cost",
496
]),
497
});
498
}
499
const body = `Hello,\n\nI would like to request a quote. I filled out the online form with the\ndetails listed below:\n\n\`\`\`\n${JSON.stringify(
500
x,
501
undefined,
502
2,
503
)}\n\`\`\``;
504
router.push({
505
pathname: "/support/new",
506
query: {
507
hideExtra: true,
508
subject: "Request for a quote",
509
body,
510
type: "question",
511
},
512
});
513
}
514
515
return (
516
<Paragraph style={{ paddingTop: "15px" }}>
517
<div style={{ textAlign: "right" }}>
518
<A onClick={() => setMore(!more)}>
519
Need a quote, invoice or modified terms?
520
</A>
521
</div>
522
{more && (
523
<Paragraph>
524
{fullCost(items) < MIN_AMOUNT || isSub ? (
525
<Alert
526
showIcon
527
style={{
528
margin: "15px 0",
529
fontSize: "12pt",
530
borderRadius: "5px",
531
}}
532
type="warning"
533
message={"Customized Payment Options"}
534
description={
535
<>
536
Customized payment options are available for{" "}
537
<b>non-subscription purchases over ${MIN_AMOUNT}</b>. Make
538
sure your cost (currently {currency(fullCost(items))}) is over
539
${MIN_AMOUNT} and <A href="/store/cart">edit</A> any
540
subscriptions in your cart to have explicit date ranges, then
541
try again. If this is confusing,{" "}
542
<A href="/support/new">make a support request</A>.
543
</>
544
}
545
/>
546
) : (
547
<Alert
548
showIcon
549
style={{
550
margin: "15px 0",
551
fontSize: "12pt",
552
borderRadius: "5px",
553
}}
554
type="info"
555
message={"Customized Payment Options"}
556
description={
557
<>
558
Click the button below to copy your shopping cart contents to
559
a support request, and we will take if from there!
560
<div style={{ textAlign: "center", marginTop: "15px" }}>
561
<Button
562
type="primary"
563
size="large"
564
onClick={createSupportRequest}
565
>
566
<Icon name="medkit" /> Copy Shopping Cart to a Support
567
Request
568
</Button>
569
</div>
570
</>
571
}
572
/>
573
)}
574
</Paragraph>
575
)}
576
</Paragraph>
577
);
578
}
579
580
function RequireEmailAddressDescr({
581
emailSuccess,
582
onSuccess,
583
profile,
584
}): JSX.Element {
585
if (emailSuccess) {
586
return (
587
<Paragraph>
588
Your email address is now:{" "}
589
<Text code>{profile?.email_address ?? ""}</Text>.
590
</Paragraph>
591
);
592
} else {
593
return (
594
<Paragraph
595
style={{
596
backgroundColor: "white",
597
padding: "20px",
598
borderRadius: "10px",
599
}}
600
>
601
<ChangeEmailAddress embedded={true} onSuccess={onSuccess} />
602
</Paragraph>
603
);
604
}
605
}
606
607
function RequireEmailAddressMesg({ emailSuccess }): JSX.Element {
608
return (
609
<>
610
<Title level={2}>
611
<Icon name="envelope" />{" "}
612
{!emailSuccess ? "Missing Email Address" : "Email Address Saved"}
613
</Title>
614
{!emailSuccess && (
615
<Paragraph>
616
To place an order, we need to know an email address of yours. Please
617
save it to your profile:
618
</Paragraph>
619
)}
620
</>
621
);
622
}
623
624
export function RequireEmailAddress({ profile, reloadProfile }) {
625
const [emailSuccess, setEmailSuccess] = useState<boolean>(false);
626
627
if (profile == null) {
628
// profile not yet loaded.
629
// there was a bug where it would flash the alert below while
630
// loading the user's profile, which looks really dumb.
631
return null;
632
}
633
if (profile?.email_address != null && !emailSuccess) {
634
// address is defined, and they didn't just set it (so we don't
635
// have to show a message confirming that), then nothing to do.
636
return null;
637
}
638
639
return (
640
<Alert
641
style={{ marginBottom: "30px" }}
642
type={emailSuccess ? "success" : "error"}
643
message={<RequireEmailAddressMesg emailSuccess={emailSuccess} />}
644
description={
645
<RequireEmailAddressDescr
646
emailSuccess={emailSuccess}
647
profile={profile}
648
onSuccess={() => {
649
reloadProfile();
650
setEmailSuccess(true);
651
}}
652
/>
653
}
654
/>
655
);
656
}
657
658
export function getColumns({
659
noDiscount,
660
voucherPeriod,
661
}: { noDiscount?: boolean; voucherPeriod?: boolean } = {}) {
662
return [
663
{
664
responsive: ["xs" as "xs"],
665
render: ({ cost, description, project_id }) => {
666
return (
667
<div>
668
<DescriptionColumn
669
cost={cost}
670
description={description}
671
voucherPeriod={voucherPeriod}
672
/>
673
<ProjectID project_id={project_id} />
674
<div>
675
<b style={{ fontSize: "11pt" }}>
676
<DisplayCost
677
cost={cost}
678
simple
679
oneLine
680
noDiscount={noDiscount}
681
/>
682
</b>
683
</div>
684
</div>
685
);
686
},
687
},
688
{
689
responsive: ["sm" as "sm"],
690
title: "Product",
691
align: "center" as "center",
692
render: (_, { product }) => <ProductColumn product={product} />,
693
},
694
{
695
responsive: ["sm" as "sm"],
696
width: "60%",
697
render: (_, { cost, description, project_id }) => {
698
if (cost == null) {
699
return null;
700
}
701
return (
702
<>
703
<DescriptionColumn
704
cost={cost}
705
description={description}
706
voucherPeriod={voucherPeriod}
707
/>{" "}
708
<ProjectID project_id={project_id} />
709
</>
710
);
711
},
712
},
713
{
714
responsive: ["sm" as "sm"],
715
title: "Price",
716
align: "right" as "right",
717
render: (_, { cost }) => (
718
<b style={{ fontSize: "11pt" }}>
719
<DisplayCost cost={cost} simple noDiscount={noDiscount} />
720
</b>
721
),
722
},
723
] as any;
724
}
725
726
function ProjectID({ project_id }: { project_id: string }): JSX.Element | null {
727
if (!project_id || !isValidUUID(project_id)) return null;
728
return (
729
<div>
730
For project: <code>{project_id}</code>
731
</div>
732
);
733
}
734
735