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/vouchers.tsx
Views: 687
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
Voucher -- create vouchers from the contents of your shopping cart.
8
*/
9
10
import {
11
Alert,
12
Button,
13
Col,
14
DatePicker,
15
Divider,
16
Form,
17
Input,
18
InputNumber,
19
Radio,
20
Row,
21
Table,
22
Space,
23
} from "antd";
24
import dayjs from "dayjs";
25
import { useContext, useEffect, useMemo, useState } from "react";
26
import { Icon } from "@cocalc/frontend/components/icon";
27
import { money } from "@cocalc/util/licenses/purchase/utils";
28
import { plural } from "@cocalc/util/misc";
29
import A from "components/misc/A";
30
import Loading from "components/share/loading";
31
import SiteName from "components/share/site-name";
32
import useAPI from "lib/hooks/api";
33
import useIsMounted from "lib/hooks/mounted";
34
import { useRouter } from "next/router";
35
import { computeCost } from "@cocalc/util/licenses/store/compute-cost";
36
import { useProfileWithReload } from "lib/hooks/profile";
37
import { Paragraph } from "components/misc";
38
import { fullCost, getColumns, RequireEmailAddress } from "./checkout";
39
import ShowError from "@cocalc/frontend/components/error";
40
import { COLORS } from "@cocalc/util/theme";
41
import vouchers, {
42
CharSet,
43
MAX_VOUCHERS,
44
WhenPay,
45
} from "@cocalc/util/vouchers";
46
import {
47
getCurrentCheckoutSession,
48
cancelCurrentCheckoutSession,
49
getVoucherCartCheckoutParams,
50
vouchersCheckout,
51
syncPaidInvoices,
52
} from "@cocalc/frontend/purchases/api";
53
import type { CheckoutParams } from "@cocalc/server/purchases/shopping-cart-checkout";
54
import { ExplainPaymentSituation } from "./checkout";
55
import AddCashVoucher from "./add-cash-voucher";
56
import { StoreBalanceContext } from "../../lib/balance";
57
58
interface Config {
59
whenPay: WhenPay;
60
numVouchers: number;
61
length: number;
62
title: string;
63
prefix: string;
64
postfix: string;
65
charset: CharSet;
66
expire: dayjs.Dayjs;
67
}
68
69
export default function CreateVouchers() {
70
const router = useRouter();
71
const isMounted = useIsMounted();
72
const { profile, reload: reloadProfile } = useProfileWithReload({
73
noCache: true,
74
});
75
const { refreshBalance } = useContext(StoreBalanceContext);
76
const [orderError, setOrderError] = useState<string>("");
77
const [subTotal, setSubTotal] = useState<number>(0);
78
79
// user configurable options: start
80
const [query, setQuery0] = useState<Config>(() => {
81
const q = router.query;
82
return {
83
whenPay: typeof q.whenPay == "string" ? (q.whenPay as WhenPay) : "now",
84
numVouchers:
85
typeof q.numVouchers == "string" ? parseInt(q.numVouchers) : 1,
86
length: typeof q.length == "string" ? parseInt(q.length) : 8,
87
title: typeof q.title == "string" ? q.title : "",
88
prefix: typeof q.prefix == "string" ? q.prefix : "",
89
postfix: typeof q.postfix == "string" ? q.postfix : "",
90
charset: typeof q.charset == "string" ? q.charset : "alphanumeric",
91
expire:
92
typeof q.expire == "string" ? dayjs(q.expire) : dayjs().add(30, "day"),
93
};
94
});
95
const {
96
whenPay,
97
numVouchers,
98
length,
99
title,
100
prefix,
101
postfix,
102
charset,
103
expire,
104
} = query;
105
const setQuery = (obj) => {
106
const query1 = { ...query };
107
for (const key in obj) {
108
const value = obj[key];
109
router.query[key] =
110
key == "expire" ? value.toDate().toISOString() : `${value}`;
111
query1[key] = value;
112
}
113
router.replace({ query: router.query }, undefined, {
114
shallow: true,
115
scroll: false,
116
});
117
setQuery0(query1);
118
};
119
120
const [params, setParams] = useState<CheckoutParams | null>(null);
121
const updateParams = async (count, whenPay) => {
122
if (whenPay == "admin" || count == null) {
123
setParams(null);
124
return;
125
}
126
try {
127
setParams(await getVoucherCartCheckoutParams(count));
128
} catch (err) {
129
setOrderError(`${err}`);
130
}
131
};
132
useEffect(() => {
133
updateParams(numVouchers, whenPay);
134
}, [subTotal, numVouchers, whenPay]);
135
136
//////
137
// Handling payment -- start
138
// This is very similar to checkout.tsx, but I couldn't think of a good way to
139
// avoid dup, and vouchers are *barely* used.
140
const [completingPurchase, setCompletingPurchase] = useState<boolean>(false);
141
const [session, setSession] = useState<{ id: string; url: string } | null>(
142
null,
143
);
144
const updateSession = async () => {
145
const session = await getCurrentCheckoutSession();
146
setSession(session);
147
return session;
148
};
149
useEffect(() => {
150
// on load, check for existing payent session.
151
updateSession();
152
}, []);
153
useEffect(() => {
154
if (router.query.complete == null) {
155
// nothing to handle
156
return;
157
}
158
159
(async () => {
160
// in case webhooks aren't configured, get the payment via sync:
161
try {
162
await syncPaidInvoices();
163
} catch (err) {
164
console.warn("syncPaidInvoices buying vouchers -- issue", err);
165
}
166
// now do the purchase flow again with money available.
167
completePurchase();
168
})();
169
}, []);
170
171
async function completePurchase() {
172
try {
173
setOrderError("");
174
setCompletingPurchase(true);
175
const curSession = await updateSession();
176
if (curSession != null || !isMounted.current) {
177
// there is already a stripe checkout session that hasn't been finished, so let's
178
// not cause confusion by creating another one.
179
// User will see a big alert with a link to finish this one, since updateSession
180
// sets the session state.
181
return;
182
}
183
// This api call tells the backend, "make a session that, when successfully finished, results in
184
// buying everything in my shopping cart", or, if it returns {done:true}, then
185
// It succeeds if the purchase goes through.
186
const currentUrl = window.location.href;
187
const success_url = `${currentUrl}${
188
currentUrl.includes("?") ? "&" : "?"
189
}complete=true`;
190
// This api call: "create requested vouchers from everything in my
191
// shopping cart that is not a subscription" if possible; otherwise, give me a stripe
192
// checkout session for the right amount.
193
const result = await vouchersCheckout({
194
success_url,
195
cancel_url: currentUrl,
196
config: {
197
count: numVouchers ?? 1,
198
expire: expire.toDate(),
199
cancelBy: dayjs().add(14, "day").toDate(),
200
active: dayjs().toDate(),
201
title,
202
whenPay,
203
generate: {
204
length,
205
charset,
206
prefix,
207
postfix,
208
},
209
},
210
});
211
if (result.done) {
212
// done -- nothing further to do!
213
if (isMounted.current) {
214
router.push("/store/congrats");
215
}
216
return;
217
}
218
// payment is required to complete the purchase, since user doesn't
219
// have enough credit.
220
window.location = result.session.url as any;
221
} catch (err) {
222
// The purchase failed.
223
setOrderError(err.message);
224
} finally {
225
refreshBalance();
226
if (!isMounted.current) return;
227
setCompletingPurchase(false);
228
}
229
}
230
const cancelPurchaseInProgress = async () => {
231
try {
232
await cancelCurrentCheckoutSession();
233
updateSession();
234
} catch (err) {
235
setOrderError(err.message);
236
}
237
};
238
// Handling payment -- end
239
//////
240
241
const exampleCodes: string = useMemo(() => {
242
return vouchers({ count: 5, length, charset, prefix, postfix }).join(", ");
243
}, [length, charset, prefix, postfix]);
244
245
// most likely, user will do the purchase and then see the congratulations page
246
useEffect(() => {
247
router.prefetch("/store/congrats");
248
}, []);
249
250
useEffect(() => {
251
if ((numVouchers ?? 0) > MAX_VOUCHERS[whenPay]) {
252
setQuery({ numVouchers: MAX_VOUCHERS[whenPay] });
253
}
254
}, [whenPay]);
255
256
const cart0 = useAPI("/shopping/cart/get");
257
258
const cart = useMemo(() => {
259
return cart0.result?.filter((item) => {
260
if (item.product == "site-license") {
261
return item.description?.period == "range";
262
}
263
if (item.product == "cash-voucher") {
264
return true;
265
}
266
return false;
267
});
268
}, [cart0.result]);
269
270
const items = useMemo(() => {
271
if (!cart) return undefined;
272
const x: any[] = [];
273
let subTotal = 0;
274
for (const item of cart) {
275
if (!item.checked) continue;
276
item.cost = computeCost(item.description);
277
subTotal += item.cost.cost;
278
x.push(item);
279
}
280
setSubTotal(subTotal);
281
return x;
282
}, [cart]);
283
284
if (session?.url != null) {
285
return (
286
<div style={{ textAlign: "center" }}>
287
<Alert
288
style={{ margin: "30px", display: "inline-block" }}
289
type="warning"
290
message={<h2>Purchase in Progress</h2>}
291
description={
292
<div style={{ fontSize: "14pt", width: "450px" }}>
293
<Divider />
294
<p>
295
<Button href={session.url} type="primary" size="large">
296
Complete Purchase
297
</Button>
298
</p>
299
or
300
<p style={{ marginTop: "15px" }}>
301
<Button onClick={cancelPurchaseInProgress}>Cancel</Button>
302
</p>
303
</div>
304
}
305
/>
306
</div>
307
);
308
}
309
310
if (cart0.error) {
311
return <Alert type="error" message={cart.error} />;
312
}
313
if (!items) {
314
return <Loading center />;
315
}
316
317
const columns = getColumns({
318
noDiscount: whenPay != "now",
319
voucherPeriod: true,
320
});
321
322
const disabled =
323
!numVouchers ||
324
completingPurchase ||
325
!title?.trim() ||
326
expire == null ||
327
subTotal == 0 ||
328
!profile?.email_address;
329
330
function CreateVouchersButton() {
331
const v = plural(numVouchers ?? 0, "Voucher Code");
332
return (
333
<Button
334
disabled={disabled}
335
style={{ marginTop: "7px", marginBottom: "15px" }}
336
size="large"
337
type="primary"
338
onClick={completePurchase}
339
>
340
{completingPurchase ? (
341
<Loading delay={0}>
342
Creating {numVouchers ?? 0} {v}...
343
</Loading>
344
) : (
345
<>
346
Create {numVouchers ?? 0} {v}
347
{whenPay == "now"}
348
{whenPay == "admin" && " (no charge)"}
349
{!title?.trim() && " (enter description above!)"}
350
</>
351
)}
352
</Button>
353
);
354
}
355
356
function EmptyCart() {
357
return (
358
<div style={{ maxWidth: "800px", margin: "auto" }}>
359
<h3>
360
<Icon name={"shopping-cart"} style={{ marginRight: "5px" }} />
361
{cart?.length > 0 && (
362
<>
363
Nothing in Your <SiteName />{" "}
364
<A href="/store/cart">Shopping Cart</A> is Selected
365
</>
366
)}
367
{(cart0.result?.length ?? 0) == 0 ? (
368
<>
369
Your <SiteName /> <A href="/store/cart">Shopping Cart</A> is Empty
370
</>
371
) : (
372
<>
373
Your <SiteName /> <A href="/store/cart">Shopping Cart</A> must
374
contain at least one non-subscription license or cash voucher
375
</>
376
)}
377
</h3>
378
<AddCashVoucher onAdd={() => cart0.call()} defaultExpand />
379
<p style={{ color: "#666" }}>
380
You must have at least one non-subscription item in{" "}
381
<A href="/store/cart">your cart</A> to create vouchers from the items
382
in your shopping cart. Shop for{" "}
383
<A href="/store/site-license">upgrades</A>, a{" "}
384
<A href="/store/boost">license boost</A>, or a{" "}
385
<A href="/dedicated">dedicated VM or disk</A>, and select a specific
386
range of dates. When you{" "}
387
<A href="/redeem">redeem a voucher for shopping cart items</A>, the
388
corresponding licenses start at the redemption date, and last for the
389
same number of days as your shopping cart item. You can also browse
390
all <A href="/vouchers/redeemed">vouchers you have redeemed</A> and
391
track everything about your vouchers in the{" "}
392
<A href="/vouchers">Voucher Center</A>.
393
</p>
394
</div>
395
);
396
}
397
398
// this can't just be a component, since it depends on a bunch of scope,
399
function nonemptyCart(items) {
400
return (
401
<>
402
<ShowError error={orderError} setError={setOrderError} />
403
<div>
404
<h3 style={{ fontSize: "16pt" }}>
405
<Icon name={"gift2"} style={{ marginRight: "10px" }} />
406
Create Voucher Codes
407
</h3>
408
<Paragraph style={{ color: "#666" }}>
409
Voucher codes can be <A href="/redeem">redeemed</A> for the{" "}
410
{items.length} {plural(items.length, "license")} listed below. The
411
license start and end dates are shifted to match when the license is
412
redeemed. Visit the <A href="/vouchers">Voucher Center</A> for more
413
about vouchers, and{" "}
414
<A href="https://doc.cocalc.com/vouchers.html">read the docs</A>.
415
</Paragraph>
416
{profile?.is_admin && (
417
<>
418
<h4 style={{ fontSize: "13pt", marginTop: "20px" }}>
419
<Check done /> Pay Now
420
</h4>
421
<div>
422
<Radio.Group
423
value={whenPay}
424
onChange={(e) => {
425
setQuery({ whenPay: e.target.value as WhenPay });
426
}}
427
>
428
<Space
429
direction="vertical"
430
style={{ margin: "5px 0 15px 15px" }}
431
>
432
<Radio value={"now"}>Pay Now</Radio>
433
{profile?.is_admin && (
434
<Radio value={"admin"}>
435
Admin Vouchers: you will not be charged (admins only)
436
</Radio>
437
)}
438
</Space>
439
</Radio.Group>
440
<br />
441
<Paragraph style={{ color: "#666" }}>
442
{profile?.is_admin && (
443
<>
444
As an admin, you may select the "Admin" option; this is
445
useful for creating free trials or fulfilling complicated
446
customer requirements.{" "}
447
</>
448
)}
449
</Paragraph>
450
</div>
451
</>
452
)}
453
<h4 style={{ fontSize: "13pt", marginTop: "20px" }}>
454
<Check done={(numVouchers ?? 0) > 0} /> How Many Voucher Codes?
455
</h4>
456
<Paragraph style={{ color: "#666" }}>
457
Input the number of voucher codes to create{" "}
458
{whenPay == "now" ? "buy" : "create"} (limit:{" "}
459
{MAX_VOUCHERS[whenPay]}):
460
<div style={{ textAlign: "center", marginTop: "15px" }}>
461
<InputNumber
462
size="large"
463
min={1}
464
max={MAX_VOUCHERS[whenPay]}
465
value={numVouchers}
466
onChange={(value) => setQuery({ numVouchers: value })}
467
/>
468
</div>
469
</Paragraph>
470
{whenPay == "admin" && (
471
<>
472
<h4 style={{ fontSize: "13pt", marginTop: "20px" }}>
473
<Check done={expire != null} />
474
When Voucher Codes Expire
475
</h4>
476
<Paragraph style={{ color: "#666" }}>
477
As an admin you can set any expiration date you want for the
478
voucher codes.
479
</Paragraph>
480
<Form
481
labelCol={{ span: 9 }}
482
wrapperCol={{ span: 9 }}
483
layout="horizontal"
484
>
485
<Form.Item label="Expire">
486
<DatePicker
487
value={expire}
488
presets={[
489
{
490
label: "+ 7 Days",
491
value: dayjs().add(7, "d"),
492
},
493
{
494
label: "+ 30 Days",
495
value: dayjs().add(30, "day"),
496
},
497
{
498
label: "+ 2 months",
499
value: dayjs().add(2, "months"),
500
},
501
{
502
label: "+ 6 months",
503
value: dayjs().add(6, "months"),
504
},
505
{
506
label: "+ 1 Year",
507
value: dayjs().add(1, "year"),
508
},
509
]}
510
onChange={(expire) => setQuery({ expire })}
511
disabledDate={(current) => {
512
if (!current) {
513
return true;
514
}
515
// Can not select days before today and today
516
if (current < dayjs().endOf("day")) {
517
return true;
518
}
519
// ok
520
return false;
521
}}
522
/>
523
</Form.Item>
524
</Form>
525
</>
526
)}
527
<h4
528
style={{
529
fontSize: "13pt",
530
marginTop: "20px",
531
color: !title ? "darkred" : undefined,
532
}}
533
>
534
<Check done={!!title.trim()} /> Customize
535
</h4>
536
<Paragraph style={{ color: "#666" }}>
537
<div
538
style={
539
!title
540
? { borderRight: "5px solid darkred", paddingRight: "15px" }
541
: undefined
542
}
543
>
544
<div
545
style={
546
!title ? { fontWeight: 700, color: "darkred" } : undefined
547
}
548
>
549
Describe this voucher:
550
</div>
551
<Input
552
allowClear
553
style={{ marginBottom: "15px", marginTop: "5px" }}
554
onChange={(e) => setQuery({ title: e.target.value })}
555
value={title}
556
addonBefore={"Description"}
557
/>
558
</div>
559
Customize how your voucher codes are randomly generated (optional):
560
<Space direction="vertical" style={{ marginTop: "5px" }}>
561
<Space>
562
<InputNumber
563
addonBefore={"Length"}
564
min={8}
565
max={16}
566
onChange={(length) => {
567
setQuery({ length: length ?? 8 });
568
}}
569
value={length}
570
/>
571
<Input
572
maxLength={10 /* also enforced via api */}
573
onChange={(e) => setQuery({ prefix: e.target.value })}
574
value={prefix}
575
addonBefore={"Prefix"}
576
allowClear
577
/>
578
<Input
579
maxLength={10 /* also enforced via api */}
580
onChange={(e) => setQuery({ postfix: e.target.value })}
581
value={postfix}
582
addonBefore={"Postfix"}
583
allowClear
584
/>{" "}
585
</Space>
586
<Space>
587
<Radio.Group
588
onChange={(e) => {
589
setQuery({ charset: e.target.value });
590
}}
591
defaultValue={charset}
592
>
593
<Radio.Button value="alphanumeric">alphanumeric</Radio.Button>
594
<Radio.Button value="alphabetic">alphabetic</Radio.Button>
595
<Radio.Button value="numbers">0123456789</Radio.Button>
596
<Radio.Button value="lower">lower</Radio.Button>
597
<Radio.Button value="upper">UPPER</Radio.Button>
598
</Radio.Group>
599
</Space>
600
<Space>
601
<div style={{ whiteSpace: "nowrap" }}>Examples:</div>{" "}
602
{exampleCodes}
603
</Space>
604
</Space>
605
</Paragraph>
606
</div>
607
608
<h4 style={{ fontSize: "13pt", marginTop: "15px" }}>
609
<Check done />
610
{(numVouchers ?? 0) == 1
611
? "Your Voucher"
612
: `Each of Your ${numVouchers ?? 0} Voucher Codes`}{" "}
613
Provides the Following {items.length} {plural(items.length, "Item")}
614
</h4>
615
<Paragraph style={{ color: "#666" }}>
616
These are the licenses with a fixed range of time from your shopping
617
cart (vouchers cannot be used to create subscriptions). When used, the
618
voucher code is redeemed for one or more license starting at the time
619
of redemption and running for the same length of time as each license
620
listed below. The license obtained using this voucher can also be
621
canceled early for a prorated refund resulting in credit to the
622
account holder, or edited to better fit the recipient's requirements.
623
</Paragraph>
624
<div style={{ border: "1px solid #eee" }}>
625
<Table
626
showHeader={false}
627
columns={columns}
628
dataSource={items}
629
rowKey={"id"}
630
pagination={{ hideOnSinglePage: true }}
631
/>
632
</div>
633
<Space style={{ marginTop: "15px" }}>
634
<AddCashVoucher onAdd={() => cart0.call()} />
635
<A href="/store/cart">
636
<Button>Edit Cart</Button>
637
</A>
638
</Space>
639
<h4 style={{ fontSize: "13pt", marginTop: "30px" }}>
640
<Check done={!disabled} /> Create Your{" "}
641
{plural(numVouchers ?? 0, "Voucher Code")}
642
</h4>
643
{numVouchers != null && (
644
<div style={{ fontSize: "12pt" }}>
645
{params != null && (
646
<ExplainPaymentSituation
647
params={params}
648
style={{ margin: "15px 0" }}
649
/>
650
)}
651
<Row>
652
<Col sm={12}>
653
<CreateVouchersButton />
654
</Col>
655
<Col sm={12}>
656
<div style={{ fontSize: "15pt" }}>
657
<TotalCost
658
items={cart}
659
numVouchers={numVouchers ?? 0}
660
whenPay={whenPay}
661
/>
662
<br />
663
<Terms whenPay={whenPay} />
664
</div>
665
</Col>
666
</Row>
667
</div>
668
)}
669
</>
670
);
671
}
672
673
return (
674
<>
675
<RequireEmailAddress profile={profile} reloadProfile={reloadProfile} />
676
{items.length == 0 && <EmptyCart />}
677
{items.length > 0 && nonemptyCart(items)}
678
<ShowError error={orderError} setError={setOrderError} />
679
</>
680
);
681
}
682
683
function TotalCost({ items, numVouchers, whenPay }) {
684
const cost = numVouchers * fullCost(items);
685
return (
686
<>
687
{whenPay == "now" ? "Total Amount" : "Maximum Amount"}:{" "}
688
<b style={{ float: "right", color: "darkred" }}>{money(cost)}</b>
689
</>
690
);
691
}
692
693
function Terms({ whenPay }) {
694
return (
695
<Paragraph style={{ color: COLORS.GRAY, fontSize: "10pt" }}>
696
By creating vouchers, you agree to{" "}
697
<A href="/policies/terms" external>
698
our terms of service,
699
</A>{" "}
700
{whenPay == "now" && (
701
<>and agree to pay for the voucher you have requested.</>
702
)}
703
{whenPay == "invoice" && (
704
<>
705
and agree to pay for any voucher codes that are redeemed, up to the
706
maxium amount listed here.
707
</>
708
)}
709
{whenPay == "admin" && (
710
<>
711
and as an admin agree to use the voucher for company purposes. The
712
cash value is listed above.
713
</>
714
)}
715
</Paragraph>
716
);
717
}
718
719
const CHECK_STYLE = { marginRight: "5px", fontSize: "14pt" };
720
function Check({ done }) {
721
if (done) {
722
return <Icon name="check" style={{ ...CHECK_STYLE, color: "green" }} />;
723
} else {
724
return (
725
<Icon name="arrow-right" style={{ ...CHECK_STYLE, color: "#cf1322" }} />
726
);
727
}
728
}
729
730