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/cart.tsx
Views: 687
/*1* This file is part of CoCalc: Copyright © 2022 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45/*6Shopping cart.78The UX is similar to Amazon.com, since that's probably the single most popular9shopping cart experience, so most likely to feel familiar to users and easy10to use.11*/1213import { Icon } from "@cocalc/frontend/components/icon";14import { describeQuotaFromInfo } from "@cocalc/util/licenses/describe-quota";15import { CostInputPeriod } from "@cocalc/util/licenses/purchase/types";16import { capitalize, currency, isValidUUID } from "@cocalc/util/misc";17import { Alert, Button, Checkbox, Popconfirm, Space, Table } from "antd";18import A from "components/misc/A";19import Loading from "components/share/loading";20import SiteName from "components/share/site-name";21import apiPost from "lib/api/post";22import useAPI from "lib/hooks/api";23import useIsMounted from "lib/hooks/mounted";24import { useRouter } from "next/router";25import { useEffect, useMemo, useState } from "react";26import { computeCost } from "@cocalc/util/licenses/store/compute-cost";27import OtherItems from "./other-items";28import { describeItem, describePeriod, DisplayCost } from "./site-license-cost";29import type {30ProductDescription,31ProductType,32} from "@cocalc/util/db-schema/shopping-cart-items";3334export default function ShoppingCart() {35const isMounted = useIsMounted();36const [updating, setUpdating] = useState<boolean>(false);37const [subTotal, setSubTotal] = useState<number>(0);38const router = useRouter();3940// most likely, user will checkout next41useEffect(() => {42router.prefetch("/store/checkout");43}, []);4445const cart = useAPI("/shopping/cart/get");4647const items = useMemo(() => {48if (!cart.result) return undefined;49// TODO deal with errors returned by useAPI50if (cart.result.error != null) return undefined;51const x: any[] = [];52let subTotal = 0;53for (const item of cart.result) {54try {55item.cost = computeCost(item.description);56} catch (err) {57// sadly computeCost is buggy, or rather - it crashes because of other bugs.58// It's much better to59// have something not in the cart and an error than to make the cart and60// store just be 100% broken61// forever for a user!62// That said, I've fixed every bug I could find and tested things, so hopefully63// this doesn't come up.64console.warn("Invalid item in cart -- not showing", item);65continue;66}67if (item.checked) {68subTotal += item.cost.cost;69}70x.push(item);71}72setSubTotal(subTotal);73return x;74}, [cart.result]);7576if (cart.error) {77return <Alert type="error" message={cart.error} />;78}7980if (!items) {81return <Loading center />;82}8384async function reload() {85if (!isMounted.current) return;86setUpdating(true);87try {88await cart.call();89} finally {90if (isMounted.current) {91setUpdating(false);92}93}94}9596const columns = [97{98responsive: ["xs" as "xs"],99render: ({100id,101product,102checked,103cost,104description,105type,106project_id,107}) => {108return (109<div>110<CheckboxColumn111{...{ id, checked, updating, setUpdating, isMounted, reload }}112/>113<DescriptionColumn114{...{115product,116id,117cost,118description,119updating,120setUpdating,121isMounted,122reload,123type,124project_id,125}}126compact127/>128<div>129<b style={{ fontSize: "11pt" }}>130<DisplayCost cost={cost} simple oneLine />131</b>132</div>133</div>134);135},136},137{138responsive: ["sm" as "sm"],139title: "",140render: (_, { id, checked }) => (141<CheckboxColumn142{...{ id, checked, updating, setUpdating, isMounted, reload }}143/>144),145},146{147responsive: ["sm" as "sm"],148title: "Product",149align: "center" as "center",150render: (_, { product }) => <ProductColumn product={product} />,151},152{153responsive: ["sm" as "sm"],154width: "60%",155render: (_, { product, id, cost, description, type, project_id }) => (156<DescriptionColumn157{...{158product,159id,160cost,161description,162updating,163setUpdating,164isMounted,165reload,166type,167project_id,168}}169compact={false}170/>171),172},173{174responsive: ["sm" as "sm"],175title: "Price",176align: "right" as "right",177render: (_, { cost }) => (178<b style={{ fontSize: "11pt" }}>179<DisplayCost cost={cost} simple />180</b>181),182},183];184185function noItems() {186return (187<>188<h3>189<Icon name={"shopping-cart"} style={{ marginRight: "5px" }} /> Your{" "}190<SiteName /> Shopping Cart is Empty191</h3>192<A href="/store/site-license">Buy a License</A>193</>194);195}196197function Proceed() {198const checkout = (199<Button200disabled={subTotal == 0 || updating}201size="large"202type="primary"203onClick={() => {204router.push("/store/checkout");205}}206>207Proceed to Checkout208</Button>209);210return (211<Space>212{checkout}213<Button214disabled={subTotal == 0 || updating}215size="large"216onClick={() => {217router.push("/store/vouchers");218}}219>220Create Vouchers221</Button>222</Space>223);224}225226function renderItems() {227return (228<>229<div style={{ float: "right" }}>230<Proceed />231</div>232<h3>233<Icon name={"shopping-cart"} style={{ marginRight: "5px" }} />{" "}234Shopping Cart235</h3>236<div style={{ marginTop: "-10px" }}>237<SelectAllItems items={items} onChange={reload} />238<Button239type="link"240style={{ marginLeft: "15px" }}241onClick={() => router.push("/store/site-license")}242>243Continue Shopping244</Button>245</div>246<div style={{ border: "1px solid #eee", marginTop: "15px" }}>247<Table248showHeader={false}249columns={columns}250dataSource={items}251rowKey={"id"}252pagination={{ hideOnSinglePage: true }}253/>254</div>255</>256);257}258259return (260<>261{items.length == 0 && noItems()}262{items.length > 0 && renderItems()}263264<div265style={{266marginTop: "60px",267border: "1px solid #eee",268}}269>270<OtherItems onChange={reload} cart={cart} />271</div>272</>273);274}275276function SelectAllItems({ items, onChange }) {277const numSelected = useMemo(() => {278let n = 0;279if (items == null) return n;280for (const item of items) {281if (item.checked) n += 1;282}283return n;284}, [items]);285if (items == null) return null;286287async function doSelectAll(checked: boolean) {288await apiPost("/shopping/cart/checked", { checked });289onChange();290}291292if (numSelected == 0) {293return (294<>295<Button type="primary" onClick={() => doSelectAll(true)}>296Select all items297</Button>298</>299);300}301if (numSelected < items.length) {302return (303<Button type="link" onClick={() => doSelectAll(true)}>304Select all items305</Button>306);307}308return (309<Button type="link" onClick={() => doSelectAll(false)}>310Deselect all items311</Button>312);313}314315function CheckboxColumn({316id,317checked,318updating,319setUpdating,320isMounted,321reload,322}) {323return (324<Checkbox325disabled={updating}326checked={checked}327onChange={async (e) => {328setUpdating(true);329try {330await apiPost("/shopping/cart/checked", {331id,332checked: e.target.checked,333});334if (!isMounted.current) return;335await reload();336} finally {337if (!isMounted.current) return;338setUpdating(false);339}340}}341>342<span className="sr-only">Select</span>343</Checkbox>344);345}346347interface DCProps {348product: ProductType;349id: string;350cost: CostInputPeriod;351description: ProductDescription;352updating: boolean;353setUpdating: (u: boolean) => void;354isMounted: { current: boolean };355reload: () => void;356compact: boolean;357project_id?: string;358readOnly?: boolean; // if true, don't show any buttons359style?;360}361362const DESCRIPTION_STYLE = {363border: "1px solid lightblue",364background: "white",365padding: "15px",366margin: "5px 0 10px 0",367borderRadius: "5px",368} as const;369370// Also used externally for showing what a voucher is for in next/pages/vouchers/[id].tsx371export function DescriptionColumn(props: DCProps) {372const { description, style, readOnly } = props;373if (374description.type == "disk" ||375description.type == "vm" ||376description.type == "quota"377) {378return <DescriptionColumnSiteLicense {...props} />;379} else if (description.type == "cash-voucher") {380return (381<div style={style}>382<b style={{ fontSize: "12pt" }}>Cash voucher</b>383<div style={DESCRIPTION_STYLE}>384Voucher for {currency(description.amount)}.385</div>386{!readOnly && (387<>388<SaveForLater {...props} />389<DeleteItem {...props} />390</>391)}392</div>393);394} else {395return <pre>{JSON.stringify(description, undefined, 2)}</pre>;396}397}398399function DescriptionColumnSiteLicense(props: DCProps) {400const { id, cost, description, compact, project_id, readOnly } = props;401if (402!(403description.type == "disk" ||404description.type == "vm" ||405description.type == "quota"406)407) {408throw Error("BUG -- incorrect typing");409}410const router = useRouter();411if (cost == null) {412// don't crash when used on deprecated items413return <pre>{JSON.stringify(description, undefined, 2)}</pre>;414}415const { input } = cost;416if (input.type == "cash-voucher") {417throw Error("incorrect typing");418}419420function renderProjectID(): JSX.Element | null {421if (!project_id || !isValidUUID(project_id)) return null;422return (423<Alert424type="info"425banner={true}426message={427<>428For project: <code>{project_id}</code>429</>430}431/>432);433}434435function editableQuota() {436if (input.type == "cash-voucher") return null;437return (438<div>439<div>{describeQuotaFromInfo(input)}</div>440{renderProjectID()}441</div>442);443}444445// this could rely an the "type" field, but we rather check the data directly446function editPage(): "site-license" | "boost" | "dedicated" | "vouchers" {447if (input.type == "cash-voucher") {448return "vouchers";449} else if (input.type === "disk" || input.type === "vm") {450return "dedicated";451} else if (input.boost) {452return "boost";453}454return "site-license";455}456457return (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<div style={DESCRIPTION_STYLE}>466<div style={{ marginBottom: "8px" }}>467<b>468{input.subscription == "no"469? describePeriod({ quota: input })470: capitalize(input.subscription) + " subscription"}471</b>472</div>473{compact || readOnly ? describeItem({ info: input }) : editableQuota()}{" "}474</div>475{!readOnly && (476<>477<Button478style={{ marginRight: "5px" }}479onClick={() => {480const page = editPage();481router.push(`/store/${page}?id=${id}`);482}}483>484<Icon name="pencil" /> Edit485</Button>486<SaveForLater {...props} />487<DeleteItem {...props} />488</>489)}490</div>491);492}493494function SaveForLater({ id, reload, updating, setUpdating, isMounted }) {495return (496<Button497style={{ margin: "0 5px 5px 0" }}498disabled={updating}499onClick={async () => {500setUpdating(true);501try {502await apiPost("/shopping/cart/remove", { id });503if (!isMounted.current) return;504await reload();505} finally {506if (!isMounted.current) return;507setUpdating(false);508}509}}510>511<Icon name="save" /> Save for later512</Button>513);514}515516function DeleteItem({ id, reload, updating, setUpdating, isMounted }) {517return (518<Popconfirm519title={"Are you sure you want to delete this item?"}520onConfirm={async () => {521setUpdating(true);522try {523await apiPost("/shopping/cart/delete", { id });524if (!isMounted.current) return;525await reload();526} finally {527if (!isMounted.current) return;528setUpdating(false);529}530}}531okText={"Yes, delete this item"}532cancelText={"Cancel"}533>534<Button disabled={updating} type="dashed">535<Icon name="trash" /> Delete536</Button>537</Popconfirm>538);539}540541const PRODUCTS = {542"site-license": { icon: "key", label: "License" },543"cash-voucher": { icon: "money", label: "Cash Voucher" },544};545546export function ProductColumn({ product }) {547const { icon, label } = PRODUCTS[product] ?? {548icon: "check",549label: "Unknown",550};551return (552<div style={{ color: "darkblue" }}>553<Icon name={icon} style={{ fontSize: "24px" }} />554<div style={{ fontSize: "10pt" }}>{label}</div>555</div>556);557}558559560