Path: blob/master/src/packages/next/components/store/cart.tsx
5950 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 { Alert, Button, Checkbox, Popconfirm, Table } from "antd";14import { useRouter } from "next/router";15import { useEffect, useMemo, useState, type JSX } from "react";1617import { Icon } from "@cocalc/frontend/components/icon";18import type {19ProductDescription,20ProductType,21} from "@cocalc/util/db-schema/shopping-cart-items";22import { describeQuotaFromInfo } from "@cocalc/util/licenses/describe-quota";23import { CostInputPeriod } from "@cocalc/util/licenses/purchase/types";24import { computeCost } from "@cocalc/util/licenses/store/compute-cost";25import { capitalize, isValidUUID } from "@cocalc/util/misc";26import A from "components/misc/A";27import Loading from "components/share/loading";28import SiteName from "components/share/site-name";29import apiPost from "lib/api/post";30import useAPI from "lib/hooks/api";31import useIsMounted from "lib/hooks/mounted";32import OtherItems from "./other-items";33import { describeItem, describePeriod, DisplayCost } from "./site-license-cost";3435export default function ShoppingCart() {36const isMounted = useIsMounted();37const [updating, setUpdating] = useState<boolean>(false);38const [numChecked, setNumChecked] = useState<number>(0);39const router = useRouter();4041// most likely, user will checkout next42useEffect(() => {43router.prefetch("/store/checkout");44}, []);4546const cart = useAPI("/shopping/cart/get");4748const items = useMemo(() => {49if (!cart.result) return undefined;50// TODO deal with errors returned by useAPI51if (cart.result.error != null) return undefined;52const x: any[] = [];53let numChecked = 0;54for (const item of cart.result) {55try {56item.cost = computeCost(item.description);57} catch (err) {58// sadly computeCost is buggy, or rather - it crashes because of other bugs.59// It's much better to60// have something not in the cart and an error than to make the cart and61// store just be 100% broken62// forever for a user!63// That said, I've fixed every bug I could find and tested things, so hopefully64// this doesn't come up.65console.warn("Invalid item in cart -- not showing", item);66continue;67}68if (item.checked) {69numChecked += 1;70}71x.push(item);72}73setNumChecked(numChecked);74return x;75}, [cart.result]);7677if (cart.error) {78return <Alert type="error" message={cart.error} />;79}8081if (!items) {82return <Loading large center />;83}8485async function reload() {86if (!isMounted.current) return;87setUpdating(true);88try {89await cart.call();90} finally {91if (isMounted.current) {92setUpdating(false);93}94}95}9697const columns = [98{99responsive: ["xs" as "xs"],100render: ({101id,102product,103checked,104cost,105description,106type,107project_id,108}) => {109return (110<div>111<CheckboxColumn112{...{ id, checked, updating, setUpdating, isMounted, reload }}113/>114<DescriptionColumn115{...{116product,117id,118cost,119description,120updating,121setUpdating,122isMounted,123reload,124type,125project_id,126}}127compact128/>129<div>130<b style={{ fontSize: "11pt" }}>131<DisplayCost cost={cost} simple oneLine />132</b>133</div>134</div>135);136},137},138{139responsive: ["sm" as "sm"],140title: "",141render: (_, { id, checked }) => (142<CheckboxColumn143{...{ id, checked, updating, setUpdating, isMounted, reload }}144/>145),146},147{148responsive: ["sm" as "sm"],149title: "Product",150align: "center" as "center",151render: (_, { product }) => <ProductColumn product={product} />,152},153{154responsive: ["sm" as "sm"],155width: "60%",156render: (_, { product, id, cost, description, type, project_id }) => (157<DescriptionColumn158{...{159product,160id,161cost,162description,163updating,164setUpdating,165isMounted,166reload,167type,168project_id,169}}170compact={false}171/>172),173},174{175responsive: ["sm" as "sm"],176title: "Price",177align: "right" as "right",178render: (_, { cost }) => (179<b style={{ fontSize: "11pt" }}>180<DisplayCost cost={cost} simple />181</b>182),183},184];185186function noItems() {187return (188<>189<h3>190<Icon name={"shopping-cart"} style={{ marginRight: "5px" }} /> Your{" "}191<SiteName /> Shopping Cart is Empty192</h3>193<A href="/store/site-license">Buy a License</A>194</>195);196}197198function renderItems() {199return (200<>201<div style={{ float: "right" }}>202<Button203disabled={numChecked == 0 || updating}204size="large"205type="primary"206onClick={() => {207router.push("/store/checkout");208}}209>210Proceed to Checkout211</Button>212</div>213<h3>214<Icon name={"shopping-cart"} style={{ marginRight: "5px" }} />{" "}215Shopping Cart216</h3>217<div style={{ marginTop: "-10px" }}>218<SelectAllItems items={items} onChange={reload} />219<Button220type="link"221style={{ marginLeft: "15px" }}222onClick={() => router.push("/store/site-license")}223>224Continue Shopping225</Button>226</div>227<div style={{ border: "1px solid #eee", marginTop: "15px" }}>228<Table229showHeader={false}230columns={columns}231dataSource={items}232rowKey={"id"}233pagination={{ hideOnSinglePage: true }}234/>235</div>236</>237);238}239240return (241<>242{items.length == 0 && noItems()}243{items.length > 0 && renderItems()}244245<div246style={{247marginTop: "60px",248border: "1px solid #eee",249}}250>251<OtherItems onChange={reload} cart={cart} />252</div>253</>254);255}256257function SelectAllItems({ items, onChange }) {258const numSelected = useMemo(() => {259let n = 0;260if (items == null) return n;261for (const item of items) {262if (item.checked) n += 1;263}264return n;265}, [items]);266if (items == null) return null;267268async function doSelectAll(checked: boolean) {269await apiPost("/shopping/cart/checked", { checked });270onChange();271}272273if (numSelected == 0) {274return (275<>276<Button type="primary" onClick={() => doSelectAll(true)}>277Select all items278</Button>279</>280);281}282if (numSelected < items.length) {283return (284<Button type="link" onClick={() => doSelectAll(true)}>285Select all items286</Button>287);288}289return (290<Button type="link" onClick={() => doSelectAll(false)}>291Deselect all items292</Button>293);294}295296function CheckboxColumn({297id,298checked,299updating,300setUpdating,301isMounted,302reload,303}) {304return (305<Checkbox306disabled={updating}307checked={checked}308onChange={async (e) => {309setUpdating(true);310try {311await apiPost("/shopping/cart/checked", {312id,313checked: e.target.checked,314});315if (!isMounted.current) return;316await reload();317} finally {318if (!isMounted.current) return;319setUpdating(false);320}321}}322>323<span className="sr-only">Select</span>324</Checkbox>325);326}327328interface DCProps {329product: ProductType;330id: string;331cost: CostInputPeriod;332description: ProductDescription;333updating: boolean;334setUpdating: (u: boolean) => void;335isMounted: { current: boolean };336reload: () => void;337compact: boolean;338project_id?: string;339readOnly?: boolean; // if true, don't show any buttons340style?;341}342343const DESCRIPTION_STYLE = {344border: "1px solid lightblue",345background: "white",346padding: "15px",347margin: "5px 0 10px 0",348borderRadius: "5px",349} as const;350351// Also used externally for showing what a voucher is for in next/pages/vouchers/[id].tsx352export function DescriptionColumn(props: DCProps) {353const router = useRouter();354const { id, description, style, readOnly } = props;355if (356description.type === "disk" ||357description.type === "vm" ||358description.type === "quota"359) {360return <DescriptionColumnSiteLicense {...props} />;361} else if (description.type == "cash-voucher") {362return (363<div style={style}>364<b style={{ fontSize: "12pt" }}>Cash Voucher: {description.title}</b>365<div style={DESCRIPTION_STYLE}>366{describeItem({ info: description })}367</div>368{!readOnly && (369<>370<Button371style={{ marginRight: "5px" }}372onClick={() => {373router.push(`/store/vouchers?id=${id}`);374}}375>376<Icon name="pencil" /> Edit377</Button>378<SaveForLater {...props} />379<DeleteItem {...props} />380</>381)}382</div>383);384} else {385return <pre>{JSON.stringify(description, undefined, 2)}</pre>;386}387}388389function DescriptionColumnSiteLicense(props: DCProps) {390const { id, cost, description, compact, project_id, readOnly } = props;391if (392!(393description.type === "disk" ||394description.type === "vm" ||395description.type === "quota"396)397) {398throw Error("BUG -- incorrect typing");399}400const router = useRouter();401if (cost == null) {402// don't crash when used on deprecated items403return <pre>{JSON.stringify(description, undefined, 2)}</pre>;404}405const { input } = cost;406if (input.type === "cash-voucher") {407throw Error("incorrect typing");408}409410function renderProjectID(): JSX.Element | null {411if (!project_id || !isValidUUID(project_id)) return null;412return (413<Alert414type="info"415banner={true}416message={417<>418For project: <code>{project_id}</code>419</>420}421/>422);423}424425function editableQuota() {426if (input.type === "cash-voucher") return null;427return (428<div>429<div>{describeQuotaFromInfo(input)}</div>430{renderProjectID()}431</div>432);433}434435// this could rely an the "type" field, but we rather check the data directly436function editPage(): "site-license" | "vouchers" | "course" {437if (input.type === "cash-voucher") {438return "vouchers";439} else if (440description.type === "quota" &&441description.source === "course"442) {443return "course";444}445return "site-license";446}447448return (449<div style={{ fontSize: "12pt" }}>450{description.title && (451<div>452<b>{description.title}</b>453</div>454)}455{description.description && <div>{description.description}</div>}456<div style={DESCRIPTION_STYLE}>457<div style={{ marginBottom: "8px" }}>458<b>459{input.subscription === "no"460? describePeriod({ quota: input })461: capitalize(input.subscription) + " subscription"}462</b>463</div>464{compact || readOnly ? describeItem({ info: input }) : editableQuota()}{" "}465</div>466{!readOnly && (467<>468<Button469style={{ marginRight: "5px" }}470onClick={() => {471const page = editPage();472router.push(`/store/${page}?id=${id}`);473}}474>475<Icon name="pencil" /> Edit476</Button>477<SaveForLater {...props} />478<DeleteItem {...props} />479</>480)}481</div>482);483}484485function SaveForLater({ id, reload, updating, setUpdating, isMounted }) {486return (487<Button488style={{ margin: "0 5px 5px 0" }}489disabled={updating}490onClick={async () => {491setUpdating(true);492try {493await apiPost("/shopping/cart/remove", { id });494if (!isMounted.current) return;495await reload();496} finally {497if (!isMounted.current) return;498setUpdating(false);499}500}}501>502<Icon name="save" /> Save for later503</Button>504);505}506507function DeleteItem({ id, reload, updating, setUpdating, isMounted }) {508return (509<Popconfirm510title={"Are you sure you want to delete this item?"}511onConfirm={async () => {512setUpdating(true);513try {514await apiPost("/shopping/cart/delete", { id });515if (!isMounted.current) return;516await reload();517} finally {518if (!isMounted.current) return;519setUpdating(false);520}521}}522okText={"Yes, delete this item"}523cancelText={"Cancel"}524>525<Button disabled={updating} type="dashed">526<Icon name="trash" /> Delete527</Button>528</Popconfirm>529);530}531532const PRODUCTS = {533"site-license": { icon: "key", label: "License" },534"cash-voucher": { icon: "money", label: "Cash Voucher" },535};536537export function ProductColumn({ product }) {538const { icon, label } = PRODUCTS[product] ?? {539icon: "check",540label: "Unknown",541};542return (543<div style={{ color: "darkblue" }}>544<Icon name={icon} style={{ fontSize: "24px" }} />545<div style={{ fontSize: "10pt" }}>{label}</div>546</div>547);548}549550551