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