Path: blob/master/src/packages/next/components/store/cart.tsx
1450 views
/*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, isValidUUID } from "@cocalc/util/misc";17import { Alert, Button, Checkbox, Popconfirm, 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, type JSX } 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 [numChecked, setNumChecked] = 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 numChecked = 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) {68numChecked += 1;69}70x.push(item);71}72setNumChecked(numChecked);73return x;74}, [cart.result]);7576if (cart.error) {77return <Alert type="error" message={cart.error} />;78}7980if (!items) {81return <Loading large 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 renderItems() {198return (199<>200<div style={{ float: "right" }}>201<Button202disabled={numChecked == 0 || updating}203size="large"204type="primary"205onClick={() => {206router.push("/store/checkout");207}}208>209Proceed to Checkout210</Button>211</div>212<h3>213<Icon name={"shopping-cart"} style={{ marginRight: "5px" }} />{" "}214Shopping Cart215</h3>216<div style={{ marginTop: "-10px" }}>217<SelectAllItems items={items} onChange={reload} />218<Button219type="link"220style={{ marginLeft: "15px" }}221onClick={() => router.push("/store/site-license")}222>223Continue Shopping224</Button>225</div>226<div style={{ border: "1px solid #eee", marginTop: "15px" }}>227<Table228showHeader={false}229columns={columns}230dataSource={items}231rowKey={"id"}232pagination={{ hideOnSinglePage: true }}233/>234</div>235</>236);237}238239return (240<>241{items.length == 0 && noItems()}242{items.length > 0 && renderItems()}243244<div245style={{246marginTop: "60px",247border: "1px solid #eee",248}}249>250<OtherItems onChange={reload} cart={cart} />251</div>252</>253);254}255256function SelectAllItems({ items, onChange }) {257const numSelected = useMemo(() => {258let n = 0;259if (items == null) return n;260for (const item of items) {261if (item.checked) n += 1;262}263return n;264}, [items]);265if (items == null) return null;266267async function doSelectAll(checked: boolean) {268await apiPost("/shopping/cart/checked", { checked });269onChange();270}271272if (numSelected == 0) {273return (274<>275<Button type="primary" onClick={() => doSelectAll(true)}>276Select all items277</Button>278</>279);280}281if (numSelected < items.length) {282return (283<Button type="link" onClick={() => doSelectAll(true)}>284Select all items285</Button>286);287}288return (289<Button type="link" onClick={() => doSelectAll(false)}>290Deselect all items291</Button>292);293}294295function CheckboxColumn({296id,297checked,298updating,299setUpdating,300isMounted,301reload,302}) {303return (304<Checkbox305disabled={updating}306checked={checked}307onChange={async (e) => {308setUpdating(true);309try {310await apiPost("/shopping/cart/checked", {311id,312checked: e.target.checked,313});314if (!isMounted.current) return;315await reload();316} finally {317if (!isMounted.current) return;318setUpdating(false);319}320}}321>322<span className="sr-only">Select</span>323</Checkbox>324);325}326327interface DCProps {328product: ProductType;329id: string;330cost: CostInputPeriod;331description: ProductDescription;332updating: boolean;333setUpdating: (u: boolean) => void;334isMounted: { current: boolean };335reload: () => void;336compact: boolean;337project_id?: string;338readOnly?: boolean; // if true, don't show any buttons339style?;340}341342const DESCRIPTION_STYLE = {343border: "1px solid lightblue",344background: "white",345padding: "15px",346margin: "5px 0 10px 0",347borderRadius: "5px",348} as const;349350// Also used externally for showing what a voucher is for in next/pages/vouchers/[id].tsx351export function DescriptionColumn(props: DCProps) {352const router = useRouter();353const { id, description, style, readOnly } = props;354if (355description.type == "disk" ||356description.type == "vm" ||357description.type == "quota"358) {359return <DescriptionColumnSiteLicense {...props} />;360} else if (description.type == "cash-voucher") {361return (362<div style={style}>363<b style={{ fontSize: "12pt" }}>Cash Voucher: {description.title}</b>364<div style={DESCRIPTION_STYLE}>365{describeItem({ info: description })}366</div>367{!readOnly && (368<>369<Button370style={{ marginRight: "5px" }}371onClick={() => {372router.push(`/store/vouchers?id=${id}`);373}}374>375<Icon name="pencil" /> Edit376</Button>377<SaveForLater {...props} />378<DeleteItem {...props} />379</>380)}381</div>382);383} else {384return <pre>{JSON.stringify(description, undefined, 2)}</pre>;385}386}387388function DescriptionColumnSiteLicense(props: DCProps) {389const { id, cost, description, compact, project_id, readOnly } = props;390if (391!(392description.type == "disk" ||393description.type == "vm" ||394description.type == "quota"395)396) {397throw Error("BUG -- incorrect typing");398}399const router = useRouter();400if (cost == null) {401// don't crash when used on deprecated items402return <pre>{JSON.stringify(description, undefined, 2)}</pre>;403}404const { input } = cost;405if (input.type == "cash-voucher") {406throw Error("incorrect typing");407}408409function renderProjectID(): JSX.Element | null {410if (!project_id || !isValidUUID(project_id)) return null;411return (412<Alert413type="info"414banner={true}415message={416<>417For project: <code>{project_id}</code>418</>419}420/>421);422}423424function editableQuota() {425if (input.type == "cash-voucher") return null;426return (427<div>428<div>{describeQuotaFromInfo(input)}</div>429{renderProjectID()}430</div>431);432}433434// this could rely an the "type" field, but we rather check the data directly435function editPage(): "site-license" | "vouchers" {436if (input.type == "cash-voucher") {437return "vouchers";438}439return "site-license";440}441442return (443<div style={{ fontSize: "12pt" }}>444{description.title && (445<div>446<b>{description.title}</b>447</div>448)}449{description.description && <div>{description.description}</div>}450<div style={DESCRIPTION_STYLE}>451<div style={{ marginBottom: "8px" }}>452<b>453{input.subscription == "no"454? describePeriod({ quota: input })455: capitalize(input.subscription) + " subscription"}456</b>457</div>458{compact || readOnly ? describeItem({ info: input }) : editableQuota()}{" "}459</div>460{!readOnly && (461<>462<Button463style={{ marginRight: "5px" }}464onClick={() => {465const page = editPage();466router.push(`/store/${page}?id=${id}`);467}}468>469<Icon name="pencil" /> Edit470</Button>471<SaveForLater {...props} />472<DeleteItem {...props} />473</>474)}475</div>476);477}478479function SaveForLater({ id, reload, updating, setUpdating, isMounted }) {480return (481<Button482style={{ margin: "0 5px 5px 0" }}483disabled={updating}484onClick={async () => {485setUpdating(true);486try {487await apiPost("/shopping/cart/remove", { id });488if (!isMounted.current) return;489await reload();490} finally {491if (!isMounted.current) return;492setUpdating(false);493}494}}495>496<Icon name="save" /> Save for later497</Button>498);499}500501function DeleteItem({ id, reload, updating, setUpdating, isMounted }) {502return (503<Popconfirm504title={"Are you sure you want to delete this item?"}505onConfirm={async () => {506setUpdating(true);507try {508await apiPost("/shopping/cart/delete", { id });509if (!isMounted.current) return;510await reload();511} finally {512if (!isMounted.current) return;513setUpdating(false);514}515}}516okText={"Yes, delete this item"}517cancelText={"Cancel"}518>519<Button disabled={updating} type="dashed">520<Icon name="trash" /> Delete521</Button>522</Popconfirm>523);524}525526const PRODUCTS = {527"site-license": { icon: "key", label: "License" },528"cash-voucher": { icon: "money", label: "Cash Voucher" },529};530531export function ProductColumn({ product }) {532const { icon, label } = PRODUCTS[product] ?? {533icon: "check",534label: "Unknown",535};536return (537<div style={{ color: "darkblue" }}>538<Icon name={icon} style={{ fontSize: "24px" }} />539<div style={{ fontSize: "10pt" }}>{label}</div>540</div>541);542}543544545