From 0cdad265e58a607737770a37acc91911c9b98fb2 Mon Sep 17 00:00:00 2001 From: Harald Schilly Date: Fri, 11 Jul 2025 12:14:18 +0200 Subject: [PATCH 1/2] next/store: basic scaffolding for a "course" purchase --- src/packages/next/components/store/index.tsx | 20 +++--- src/packages/next/components/store/menu.tsx | 13 ++-- .../next/components/store/overview.tsx | 13 ++-- .../next/components/store/site-license.tsx | 61 +++++++++++-------- src/packages/next/components/store/types.ts | 1 + src/packages/next/lib/styles/layouts.tsx | 6 +- 6 files changed, 63 insertions(+), 51 deletions(-) diff --git a/src/packages/next/components/store/index.tsx b/src/packages/next/components/store/index.tsx index 264d6da4e3..66bf23b55a 100644 --- a/src/packages/next/components/store/index.tsx +++ b/src/packages/next/components/store/index.tsx @@ -5,6 +5,7 @@ import { Alert, Layout } from "antd"; import { useRouter } from "next/router"; import { useEffect, useState, type JSX } from "react"; + import * as purchasesApi from "@cocalc/frontend/purchases/api"; import { COLORS } from "@cocalc/util/theme"; import Anonymous from "components/misc/anonymous"; @@ -16,28 +17,19 @@ import useProfile from "lib/hooks/profile"; import useCustomize from "lib/use-customize"; import Cart from "./cart"; import Checkout from "./checkout"; -import Processing from "./processing"; import Congrats from "./congrats"; import Menu from "./menu"; import Overview from "./overview"; +import Processing from "./processing"; import SiteLicense from "./site-license"; import { StoreInplaceSignInOrUp } from "./store-inplace-signup"; +import { StorePagesTypes } from "./types"; import Vouchers from "./vouchers"; const { Content } = Layout; interface Props { - page: ( - | "site-license" - | "boost" - | "dedicated" - | "cart" - | "checkout" - | "processing" - | "congrats" - | "vouchers" - | undefined - )[]; + page: (StorePagesTypes | undefined)[]; } export default function StoreLayout({ page }: Props) { @@ -131,7 +123,9 @@ export default function StoreLayout({ page }: Props) { switch (main) { case "site-license": - return ; + return ; + case "course": + return ; case "cart": return requireAccount(Cart); case "checkout": diff --git a/src/packages/next/components/store/menu.tsx b/src/packages/next/components/store/menu.tsx index 294e7dade6..6021eb18dc 100644 --- a/src/packages/next/components/store/menu.tsx +++ b/src/packages/next/components/store/menu.tsx @@ -3,12 +3,14 @@ * License: MS-RSL – see LICENSE.md for details */ -import React, { useContext } from "react"; -import { Button, Menu, MenuProps, Flex, Spin } from "antd"; +import type { MenuProps } from "antd"; +import { Button, Flex, Menu, Spin } from "antd"; import { useRouter } from "next/router"; +import React, { useContext } from "react"; + +import { Icon } from "@cocalc/frontend/components/icon"; import { currency, round2down } from "@cocalc/util/misc"; import { COLORS } from "@cocalc/util/theme"; -import { Icon } from "@cocalc/frontend/components/icon"; import { StoreBalanceContext } from "../../lib/balance"; type MenuItem = Required["items"][number]; @@ -17,7 +19,7 @@ const styles: { [k: string]: React.CSSProperties } = { menuBookend: { height: "100%", whiteSpace: "nowrap", - flexGrow: 1, + flex: "0 1 auto", textAlign: "end", }, menu: { @@ -38,7 +40,7 @@ const styles: { [k: string]: React.CSSProperties } = { maxWidth: "100%", flexGrow: 1, }, -}; +} as const; export interface ConfigMenuProps { main?: string; @@ -64,6 +66,7 @@ export default function ConfigMenu({ main }: ConfigMenuProps) { key: "site-license", icon: , }, + { label: "Course", key: "course", icon: }, { label: "Vouchers", key: "vouchers", diff --git a/src/packages/next/components/store/overview.tsx b/src/packages/next/components/store/overview.tsx index f3cf3c8bb6..a4ce1b7685 100644 --- a/src/packages/next/components/store/overview.tsx +++ b/src/packages/next/components/store/overview.tsx @@ -47,14 +47,17 @@ export default function Overview() { ) : undefined} - + Buy a license to upgrade projects, get internet access, more CPU, disk and memory. - - Purchase a voucher code to make {" "} - credit easily available to somebody else. + + Purchase a license for teaching a course. + + Purchase a voucher code{" "} + to make credit easily available to somebody else. + shopping cart or go straight to{" "} checkout. - + You can also browse your{" "} purchase history,{" "} licenses, and{" "} diff --git a/src/packages/next/components/store/site-license.tsx b/src/packages/next/components/store/site-license.tsx index 73c462ad8c..b3474d7577 100644 --- a/src/packages/next/components/store/site-license.tsx +++ b/src/packages/next/components/store/site-license.tsx @@ -8,7 +8,9 @@ Create a new site license. */ import { Form, Input } from "antd"; import { isEmpty } from "lodash"; +import { useRouter } from "next/router"; import { useEffect, useRef, useState } from "react"; + import { Icon } from "@cocalc/frontend/components/icon"; import { get_local_storage } from "@cocalc/frontend/misc/local-storage"; import { CostInputPeriod } from "@cocalc/util/licenses/purchase/types"; @@ -20,7 +22,6 @@ import SiteName from "components/share/site-name"; import apiPost from "lib/api/post"; import { MAX_WIDTH } from "lib/config"; import { useScrollY } from "lib/use-scroll-y"; -import { useRouter } from "next/router"; import { AddBox } from "./add-box"; import { ApplyLicenseToProject } from "./apply-license-to-project"; import { InfoBar } from "./cost-info-bar"; @@ -46,9 +47,10 @@ const STYLE: React.CSSProperties = { interface Props { noAccount: boolean; + type: "license" | "course"; } -export default function SiteLicense({ noAccount }: Props) { +export default function SiteLicense({ noAccount, type }: Props) { const router = useRouter(); const headerRef = useRef(null); @@ -75,30 +77,39 @@ export default function SiteLicense({ noAccount }: Props) { : "Configure a License"} {router.query.id == null && ( -
- - - licenses - {" "} - allow you to upgrade projects to run more quickly, have network - access, more disk space and memory. Licenses cover a wide range of - use cases, ranging from a single hobbyist project to thousands of - simultaneous users across a large organization. - + <> + {type === "license" && ( +
+ + + licenses + {" "} + allow you to upgrade projects to run more quickly, have network + access, more disk space and memory. Licenses cover a wide range + of use cases, ranging from a single hobbyist project to + thousands of simultaneous users across a large organization. + - - Create a license using the form below then add it to your{" "} - shopping cart. If you aren't sure exactly - what to buy, you can always edit your licenses later. Subscriptions - are also flexible and can be{" "} - - edited at any time.{" "} - - -
+ + Create a license using the form below then add it to your{" "} + shopping cart. If you aren't sure + exactly what to buy, you can always edit your licenses later. + Subscriptions are also flexible and can be{" "} + + edited at any time.{" "} + + +
+ )} + {type === "course" && ( +
+ Course License +
+ )} + )} offsetHeader} diff --git a/src/packages/next/components/store/types.ts b/src/packages/next/components/store/types.ts index db20d619c5..4e1456f06a 100644 --- a/src/packages/next/components/store/types.ts +++ b/src/packages/next/components/store/types.ts @@ -1,5 +1,6 @@ export const StorePages = [ "site-license", + "course", "boost", "dedicated", "cart", diff --git a/src/packages/next/lib/styles/layouts.tsx b/src/packages/next/lib/styles/layouts.tsx index 7136bc46f9..b96828c52e 100644 --- a/src/packages/next/lib/styles/layouts.tsx +++ b/src/packages/next/lib/styles/layouts.tsx @@ -5,7 +5,7 @@ import { Col, Row } from "antd"; -import { Icon } from "@cocalc/frontend/components/icon"; +import { Icon, IconName } from "@cocalc/frontend/components/icon"; import { COLORS } from "@cocalc/util/theme"; import { CSS, Paragraph, Title } from "components/misc"; import A from "components/misc/A"; @@ -48,8 +48,8 @@ export function Product({ children, external, }: { - icon; - icon2?; + icon: IconName; + icon2?: IconName; title; href; children; From cc33693ba8af0d96ce57b5d187ea5dc444d61efc Mon Sep 17 00:00:00 2001 From: Harald Schilly Date: Fri, 11 Jul 2025 17:30:28 +0200 Subject: [PATCH 2/2] next/store: dedicaed course license purchase page in the store --- .../next/components/store/add-box.tsx | 10 +- src/packages/next/components/store/menu.tsx | 3 +- .../next/components/store/run-limit.tsx | 162 ++++++++++----- .../next/components/store/site-license.tsx | 36 +++- src/packages/next/components/store/types.ts | 2 + .../components/store/usage-and-duration.tsx | 190 +++++++++++++----- .../util/licenses/store/compute-cost.ts | 5 + 7 files changed, 296 insertions(+), 112 deletions(-) diff --git a/src/packages/next/components/store/add-box.tsx b/src/packages/next/components/store/add-box.tsx index 53bcd8f807..2cd31bcd38 100644 --- a/src/packages/next/components/store/add-box.tsx +++ b/src/packages/next/components/store/add-box.tsx @@ -15,6 +15,7 @@ import { addToCart } from "./add-to-cart"; import { DisplayCost } from "./site-license-cost"; import { periodicCost } from "@cocalc/util/licenses/purchase/compute-cost"; import { decimalDivide } from "@cocalc/util/stripe/calc"; +import { LicenseType } from "./types"; export const ADD_STYLE = { display: "inline-block", @@ -37,6 +38,7 @@ interface Props { dedicatedItem?: boolean; disabled?: boolean; noAccount: boolean; + type: LicenseType; } export function AddBox({ @@ -48,6 +50,7 @@ export function AddBox({ dedicatedItem = false, noAccount, disabled = false, + type, }: Props) { if (cost?.input.type == "cash-voucher") { return null; @@ -76,7 +79,8 @@ export function AddBox({ }} message={ <> - {money(round2up(costPer))} per project{" "} + {money(round2up(costPer))}{" "} + per {type === "course" ? "student" : "project"}{" "} {!!cost.period && cost.period != "range" ? cost.period : ""} } @@ -175,8 +179,8 @@ export function AddToCartButton({ {clicked ? "Moving to Cart..." : router.query.id != null - ? "Save Changes" - : "Add to Cart"} + ? "Save Changes" + : "Add to Cart"} {clicked && } ); diff --git a/src/packages/next/components/store/menu.tsx b/src/packages/next/components/store/menu.tsx index 6021eb18dc..c6a9cba1c5 100644 --- a/src/packages/next/components/store/menu.tsx +++ b/src/packages/next/components/store/menu.tsx @@ -11,7 +11,7 @@ import React, { useContext } from "react"; import { Icon } from "@cocalc/frontend/components/icon"; import { currency, round2down } from "@cocalc/util/misc"; import { COLORS } from "@cocalc/util/theme"; -import { StoreBalanceContext } from "../../lib/balance"; +import { StoreBalanceContext } from "lib/balance"; type MenuItem = Required["items"][number]; @@ -25,6 +25,7 @@ const styles: { [k: string]: React.CSSProperties } = { menu: { width: "100%", height: "100%", + flex: "1 1 auto", border: 0, }, menuRoot: { diff --git a/src/packages/next/components/store/run-limit.tsx b/src/packages/next/components/store/run-limit.tsx index 8af28ecb24..571789c601 100644 --- a/src/packages/next/components/store/run-limit.tsx +++ b/src/packages/next/components/store/run-limit.tsx @@ -4,78 +4,136 @@ */ import { Divider, Form } from "antd"; + import A from "components/misc/A"; import IntegerSlider from "components/misc/integer-slider"; +import { LicenseType } from "./types"; +import { unreachable } from "@cocalc/util/misc"; export const MAX_ALLOWED_RUN_LIMIT = 10000; +interface RunLimitProps { + showExplanations: boolean; + form: any; + onChange: () => void; + disabled?: boolean; + boost?: boolean; + type: LicenseType; +} + export function RunLimit({ showExplanations, form, onChange, disabled = false, boost = false, -}) { + type, +}: RunLimitProps) { function extra() { if (!showExplanations) return; - return ( -
- {boost ? ( -
- It's not necessary to match the run limit of the license you want to - boost! + switch (type) { + case "license": + return ( +
+ {boost ? ( +
+ It's not necessary to match the run limit of the license you + want to boost! +
+ ) : undefined} + Simultaneously run this many projects using this license. You, and + anyone you share the license code with, can apply the license to an + unlimited number of projects, but it will only be used up to the run + limit. When{" "} + + teaching a course + + ,{" "} + + + the run limit is typically 2 more than the number of students + (one for each student, one for the shared project and one for + the instructor project) + + + .
- ) : undefined} - Simultaneously run this many projects using this license. You, and - anyone you share the license code with, can apply the license to an - unlimited number of projects, but it will only be used up to the run - limit. When{" "} - - teaching a course - - ,{" "} - - - the run limit is typically 2 more than the number of students (one - for each student, one for the shared project and one for the + ); + case "course": + return ( +
+ It's advised to select two more seatch than the number of students + (one for each student, one for the shared project and one for the instructor project) - - - . -
- ); +
+ ); + + default: + unreachable(type); + } } - return ( - <> - Simultaneous Project Upgrades - - { - form.setFieldsValue({ run_limit }); - onChange(); - }} - /> - - - ); + switch (type) { + case "license": + return ( + <> + Simultaneous Project Upgrades + + { + form.setFieldsValue({ run_limit }); + onChange(); + }} + /> + + + ); + + case "course": + return ( + <> + Size of Course + + { + form.setFieldsValue({ run_limit }); + onChange(); + }} + /> + + + ); + + default: + unreachable(type); + } } -export function EditRunLimit({ +function EditRunLimit({ value, onChange, disabled, + type, }: { - value?; - onChange?; - disabled?; + value?: number; + onChange: (run_limit: number) => void; + disabled?: boolean; + type: LicenseType; }) { return ( ); } diff --git a/src/packages/next/components/store/site-license.tsx b/src/packages/next/components/store/site-license.tsx index b3474d7577..0e1e071e95 100644 --- a/src/packages/next/components/store/site-license.tsx +++ b/src/packages/next/components/store/site-license.tsx @@ -33,6 +33,7 @@ import { RunLimit } from "./run-limit"; import { SignInToPurchase } from "./sign-in-to-purchase"; import { TitleDescription } from "./title-description"; import { ToggleExplanations } from "./toggle-explanations"; +import { LicenseType } from "./types"; import { UsageAndDuration } from "./usage-and-duration"; const DEFAULT_PRESET: Preset = "standard"; @@ -47,9 +48,11 @@ const STYLE: React.CSSProperties = { interface Props { noAccount: boolean; - type: "license" | "course"; + type: LicenseType; } +// depending on the type, this either purchases a license with all settings, +// or a license for a course with a subset of controls. export default function SiteLicense({ noAccount, type }: Props) { const router = useRouter(); const headerRef = useRef(null); @@ -74,6 +77,8 @@ export default function SiteLicense({ noAccount, type }: Props) { {" "} {router.query.id != null ? "Edit License in Shopping Cart" + : type === "course" + ? "Purchase a License for a Course" : "Configure a License"} {router.query.id == null && ( @@ -106,7 +111,19 @@ export default function SiteLicense({ noAccount, type }: Props) { )} {type === "course" && (
- Course License + + When you teach your course on CoCalc, you benefit from a + managed, reliable platform used by tens of thousands of students + since 2013. Each student works in an isolated workspace + (project), with options for group work. File-based assignments + are handed out to students and collected when completed. You can + easily monitor progress, review editing history, and assist + students directly. For more information, please consult the{" "} + + instructor guide + + . +
)} @@ -114,6 +131,7 @@ export default function SiteLicense({ noAccount, type }: Props) { offsetHeader} noAccount={noAccount} + type={type} /> ); @@ -122,7 +140,15 @@ export default function SiteLicense({ noAccount, type }: Props) { // Note -- the back and forth between moment and Date below // is a *workaround* because of some sort of bug in moment/antd/react. -function CreateSiteLicense({ showInfoBar = false, noAccount = false }) { +function CreateSiteLicense({ + showInfoBar = false, + noAccount = false, + type, +}: { + type: LicenseType; + noAccount: boolean; + showInfoBar: boolean; +}) { const [cost, setCost] = useState(undefined); const [loading, setLoading] = useState(false); const [cartError, setCartError] = useState(""); @@ -163,6 +189,7 @@ function CreateSiteLicense({ showInfoBar = false, noAccount = false }) { function onLicenseChange() { const vals = form.getFieldsValue(true); + // console.log("form vals=", vals); encodeFormValues(router, vals, "regular"); setCost(computeCost(vals)); @@ -231,6 +258,7 @@ function CreateSiteLicense({ showInfoBar = false, noAccount = false }) { cartError={cartError} setCartError={setCartError} noAccount={noAccount} + type={type} /> ); @@ -269,8 +297,10 @@ function CreateSiteLicense({ showInfoBar = false, noAccount = false }) { showExplanations={showExplanations} form={form} onChange={onLicenseChange} + type={type} /> void; disabled?: boolean; showUsage?: boolean; - duration?: "all" | "subscriptions" | "monthly" | "yearly" | "range"; + duration?: Duration; discount?: boolean; extraDuration?: ReactNode; + type: LicenseType; } function getTimezoneFromDate( @@ -42,40 +47,76 @@ export function UsageAndDuration(props: Props) { onChange, disabled = false, showUsage = true, - duration = "all", discount = true, extraDuration, + type, } = props; + //const duration: Duration = type === "license" ? "all" : "range"; + const duration = props.duration || "all"; + const profile = useProfile(); + function renderUsageExplanation() { + if (!showExplanations) return; + const ac = ( + <>Academic users receive a 40% discount off the standard price. + ); + switch (type) { + case "license": + return ( + <> + Will this license be used for academic or commercial purposes? + {ac} + + ); + case "course": + return ac; + default: + unreachable(type); + } + } + + function renderUsageItem() { + switch (type) { + case "license": + return ( + + + + Business - for commercial purposes + + + Academic - students, teachers, academic researchers, non-profit + organizations and hobbyists (40% discount) + + {" "} + + ); + case "course": + return <>Academic; + + default: + unreachable(type); + } + } + function renderUsage() { if (!showUsage) return; return ( - Will this license be used for academic or commercial purposes? - Academic users receive a 40% discount off the standard price. - - ) : undefined - } + extra={renderUsageExplanation()} > - - - Business - for commercial purposes - - Academic - students, teachers, academic researchers, non-profit - organizations and hobbyists (40% discount) - - {" "} - + {renderUsageItem()} ); } @@ -89,7 +130,9 @@ export function UsageAndDuration(props: Props) { let invalidRange = range?.[0] == null || range?.[1] == null; if (invalidRange) { const start = new Date(); - const end = new Date(start.valueOf() + 1000 * 60 * 60 * 24 * 30); + const dayMs = 1000 * 60 * 60 * 24; + const daysDelta = type === "course" ? 4 * 30 : 30; + const end = new Date(start.valueOf() + dayMs * daysDelta); range = [start, end]; form.setFieldsValue({ range }); onChange(); @@ -114,7 +157,7 @@ export function UsageAndDuration(props: Props) { } return ( - You can buy a license either via a subscription or a single purchase for - specific dates. Once you purchase a license,{" "} - you can always edit it later, or cancel it for a prorated refund{" "} - as credit that you can use to purchase something else. Subscriptions - will be canceled at the end of the paid for period.{" "} - {duration == "range" && ( - - Licenses start and end at the indicated times in your local - timezone. - - )} - + + const tz = ( + + Licenses start and end at the indicated times in your local timezone. + ); + + switch (type) { + case "course": + return <>{tz}; + + case "license": + return ( + <> + You can buy a license either via a subscription or a single purchase + for specific dates. Once you purchase a license,{" "} + + you can always edit it later, or cancel it for a prorated refund + {" "} + as credit that you can use to purchase something else. Subscriptions + will be canceled at the end of the paid for period.{" "} + {duration == "range" && { tz }} + + ); + default: + unreachable(type); + } + } + + function renderPeriod() { + const init = + type === "course" ? "range" : duration === "range" ? "range" : "monthly"; + + switch (type) { + case "course": + return ( + + Select the start and end date of your course below. + + ); + + case "license": + return ( + + + + {renderSubsOptions()} + {renderRangeOption()} + + + + ); + + default: + unreachable(type); + } } function renderDuration() { - const init = duration === "range" ? "range" : "monthly"; return ( <> - - - - {renderSubsOptions()} - {renderRangeOption()} - - - - + {renderPeriod()} {renderRange()} ); diff --git a/src/packages/util/licenses/store/compute-cost.ts b/src/packages/util/licenses/store/compute-cost.ts index dafa2dcbb0..6506cbe79b 100644 --- a/src/packages/util/licenses/store/compute-cost.ts +++ b/src/packages/util/licenses/store/compute-cost.ts @@ -76,6 +76,10 @@ export function computeCost( return undefined; } + if (run_limit == null) { + return undefined; + } + const input: PurchaseInfo = { version: CURRENT_VERSION, type: "quota", @@ -105,6 +109,7 @@ export function computeCost( ? fixRange(range, period, noRangeShift) : { start: null, end: null }), }; + return { ...compute_cost(input), input,