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