From 0cdad265e58a607737770a37acc91911c9b98fb2 Mon Sep 17 00:00:00 2001 From: Harald Schilly Date: Fri, 11 Jul 2025 12:14:18 +0200 Subject: [PATCH 1/9] 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 264d6da4e3e..66bf23b55a5 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 294e7dade69..6021eb18dcd 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 f3cf3c8bb63..a4ce1b76855 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 73c462ad8cf..b3474d7577f 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 db20d619c5a..4e1456f06a0 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 7136bc46f94..b96828c52e2 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/9] 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 53bcd8f807c..2cd31bcd386 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 6021eb18dcd..c6a9cba1c5b 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 8af28ecb24e..571789c601c 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 b3474d7577f..0e1e071e957 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 dafa2dcbb09..6506cbe79b2 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, From b14951d09237eb897f5d5b5c8ee8de5cefb261f5 Mon Sep 17 00:00:00 2001 From: Harald Schilly Date: Mon, 14 Jul 2025 16:49:49 +0200 Subject: [PATCH 3/9] next/store/course: scaffolding for simple option selector configuration --- .../components/store/quota-config-presets.tsx | 28 ++++ .../next/components/store/quota-config.tsx | 151 +++++++++++++----- .../next/components/store/site-license.tsx | 1 + 3 files changed, 142 insertions(+), 38 deletions(-) diff --git a/src/packages/next/components/store/quota-config-presets.tsx b/src/packages/next/components/store/quota-config-presets.tsx index f8a4a0d3afb..b8db958de02 100644 --- a/src/packages/next/components/store/quota-config-presets.tsx +++ b/src/packages/next/components/store/quota-config-presets.tsx @@ -197,3 +197,31 @@ export const PRESETS: PresetEntries = { member: true, }, } as const; + +export const COURSE = { + standard: { + icon: "line-chart", + name: PRESET_STANDARD_NAME, + descr: "is a good choice for most use cases in a course", + expect: [ + "Run a couple of Jupyter Notebooks at once,", + "Edit LaTeX, Markdown, R Documents, and use VS Code,", + `${STANDARD_DISK} GB disk space is sufficient to store many files and small datasets.`, + ], + note: TODO NOTE, + details: ( + <> + You can run a couple of Jupyter Notebooks in a project at once, + depending on the kernel and memory usage. This quota is fine for editing + LaTeX documents, working with Sage Worksheets, using VS Code, and + editing all other document types. Also, {STANDARD_DISK} GB of disk space + is sufficient to store many files and a few small datasets. + + ), + cpu: STANDARD_CPU, + ram: STANDARD_RAM, + disk: STANDARD_DISK, + uptime: "short", + member: true, + }, +} as const satisfies { [key in "standard"]: PresetConfig }; diff --git a/src/packages/next/components/store/quota-config.tsx b/src/packages/next/components/store/quota-config.tsx index 1b168e0a3c8..e6793382d5e 100644 --- a/src/packages/next/components/store/quota-config.tsx +++ b/src/packages/next/components/store/quota-config.tsx @@ -3,6 +3,12 @@ * License: MS-RSL – see LICENSE.md for details */ +import { useEffect, useRef, useState, type JSX } from "react"; + +import { Icon } from "@cocalc/frontend/components/icon"; +import { displaySiteLicense } from "@cocalc/util/consts/site-license"; +import { plural, unreachable } from "@cocalc/util/misc"; +import { BOOST, DISK_DEFAULT_GB, REGULAR } from "@cocalc/util/upgrades/consts"; import { Alert, Button, @@ -16,21 +22,18 @@ import { Tabs, Typography, } from "antd"; -import { useEffect, useRef, useState, type JSX } from "react"; -import { Icon } from "@cocalc/frontend/components/icon"; -import { displaySiteLicense } from "@cocalc/util/consts/site-license"; -import { plural } from "@cocalc/util/misc"; -import { BOOST, DISK_DEFAULT_GB, REGULAR } from "@cocalc/util/upgrades/consts"; import PricingItem, { Line } from "components/landing/pricing-item"; import { CSS, Paragraph } from "components/misc"; import A from "components/misc/A"; import IntegerSlider from "components/misc/integer-slider"; import { + COURSE, PRESETS, PRESET_MATCH_FIELDS, Preset, PresetConfig, } from "./quota-config-presets"; +import type { LicenseType } from "./types"; const { Text } = Typography; @@ -57,6 +60,7 @@ interface Props { setPreset?: (preset: Preset | null) => void; presetAdjusted?: boolean; setPresetAdjusted?: (adjusted: boolean) => void; + type: LicenseType; } export const QuotaConfig: React.FC = (props: Props) => { @@ -72,6 +76,7 @@ export const QuotaConfig: React.FC = (props: Props) => { setPreset, presetAdjusted, setPresetAdjusted, + type, } = props; const presetsRef = useRef(null); @@ -107,7 +112,14 @@ export const QuotaConfig: React.FC = (props: Props) => { if (boost) { return "Booster"; } else { - return "Quota Upgrades"; + switch (type) { + case "license": + return "Quota Upgrades"; + case "course": + return "Project Upgrades"; + default: + unreachable(type); + } } } @@ -384,6 +396,62 @@ export const QuotaConfig: React.FC = (props: Props) => { ); } + function renderCoursePresets() { + const p = preset != null ? COURSE[preset] : undefined; + let presetInfo: JSX.Element | undefined = undefined; + if (p != null) { + const { name, cpu, disk, ram, uptime, note } = p; + const basic = ( + <> + provides up to{" "} + + {cpu} {plural(cpu, "vCPU")} + + , {ram} GB memory, and{" "} + {disk} GB disk space for each project. + + ); + const ut = ( + <> + the project's{" "} + idle timeout is {displaySiteLicense(uptime)} + + ); + presetInfo = ( + + {name} {basic} Additionally, {ut}. {note} + + ); + } + + return ( + <> + + onPresetChange(e.target.value)} + > + + {(Object.keys(COURSE) as Array).map((p) => { + const { name, icon, descr } = COURSE[p]; + return ( + + + {" "} + {name}: {descr} + + + ); + })} + + + + {presetInfo} + + ); + } + function renderPresetsNarrow() { const p = preset != null ? PRESETS[preset] : undefined; let presetInfo: JSX.Element | undefined = undefined; @@ -560,38 +628,45 @@ export const QuotaConfig: React.FC = (props: Props) => { ); } else { - return ( - - - Presets - - ), - children: presetExtra(), - }, - { - key: "expert", - label: ( - - - {EXPERT_CONFIG} - - ), - children: detailed(), - }, - ]} - /> - ); + switch (type) { + case "license": + return ( + + + Presets + + ), + children: presetExtra(), + }, + { + key: "expert", + label: ( + + + {EXPERT_CONFIG} + + ), + children: detailed(), + }, + ]} + /> + ); + case "course": + return renderCoursePresets(); + default: + unreachable(type); + } } } diff --git a/src/packages/next/components/store/site-license.tsx b/src/packages/next/components/store/site-license.tsx index 0e1e071e957..b18fceb6ebc 100644 --- a/src/packages/next/components/store/site-license.tsx +++ b/src/packages/next/components/store/site-license.tsx @@ -306,6 +306,7 @@ function CreateSiteLicense({ onChange={onLicenseChange} /> Date: Wed, 16 Jul 2025 11:40:21 +0200 Subject: [PATCH 4/9] next/store/course license: set academic user value properly, adjust presets, add idle timeout help and hide period selection in form --- src/.claude/settings.local.json | 3 +- src/CLAUDE.md | 59 ++++++++++----- .../frontend/components/help-icon.tsx | 8 +- .../components/store/quota-config-presets.tsx | 30 +++++++- .../next/components/store/quota-config.tsx | 74 ++++++++++++------- .../components/store/usage-and-duration.tsx | 58 +++++++++------ 6 files changed, 158 insertions(+), 74 deletions(-) diff --git a/src/.claude/settings.local.json b/src/.claude/settings.local.json index d69e67b58f2..d49e8aeed3c 100644 --- a/src/.claude/settings.local.json +++ b/src/.claude/settings.local.json @@ -4,7 +4,8 @@ "Bash(pnpm tsc:*)", "Bash(pnpm build:*)", "Bash(git add:*)", - "Bash(git commit:*)" + "Bash(git commit:*)", + "Bash(prettier -w:*)" ], "deny": [] } diff --git a/src/CLAUDE.md b/src/CLAUDE.md index a816597ef04..48ebab2e327 100644 --- a/src/CLAUDE.md +++ b/src/CLAUDE.md @@ -4,15 +4,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co # CoCalc Source Repository -* This is the source code of CoCalc in a Git repository -* It is a complex JavaScript/TypeScript SaaS application -* CoCalc is organized as a monorepository (multi-packages) in the subdirectory "./packages" -* The packages are managed as a pnpm workspace in "./packages/pnpm-workspace.yaml" +- This is the source code of CoCalc in a Git repository +- It is a complex JavaScript/TypeScript SaaS application +- CoCalc is organized as a monorepository (multi-packages) in the subdirectory "./packages" +- The packages are managed as a pnpm workspace in "./packages/pnpm-workspace.yaml" ## Code Style - Everything is written in TypeScript code - Indentation: 2-spaces +- Run `pretter -w [filename]` after modifying a file (ts, tsx, md, json, ...) to format it correctly. - All .js and .ts files are formatted by the tool prettier - Add suitable types when you write code - Variable name styles are "camelCase" for local and "FOO_BAR" for global variables. If you edit older code not following these guidlines, adjust this rule to fit the files style. @@ -23,28 +24,32 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Development Commands ### Essential Commands + - `pnpm build-dev` - Build all packages for development - `pnpm clean` - Clean all node_modules and dist directories -- `pnpm database` - Start PostgreSQL database server -- `pnpm hub` - Start the main hub server -- `pnpm psql` - Connect to the PostgreSQL database - `pnpm test` - Run full test suite -- `pnpm test-parallel` - Run tests in parallel across packages - `pnpm depcheck` - Check for dependency issues +- `prettier -w [filename]` to format the style of a file after editing it +- after creating a file, run `git add [filename]` to start tracking it ### Package-Specific Commands -- `cd packages/[package] && pnpm tsc` - Watch TypeScript compilation for a specific package + +- `cd packages/[package] && pnpm build` - Build and compile a specific package + - for packages/next and packages/static, run `cd packages/[package] && pnpm build-dev` +- `cd packages/[package] && pnpm tsc:watch` - TypeScript compilation in watch mode for a specific package - `cd packages/[package] && pnpm test` - Run tests for a specific package - `cd packages/[package] && pnpm build` - Build a specific package +- **IMPORTANT**: When modifying packages like `util` that other packages depend on, you must run `pnpm build` in the modified package before typechecking dependent packages -### Development Setup -1. Start database: `pnpm database` -2. Start hub: `pnpm hub` -3. For TypeScript changes, run `pnpm tsc` in the relevant package directory +### Development + +- After code changes, run `pretter -w [filename]` to ensure consistent styling +- After TypeScript or `*.tsx` changes, run `pnpm build` in the relevant package directory ## Architecture Overview ### Package Structure + CoCalc is organized as a monorepo with key packages: - **frontend** - React/TypeScript frontend application using Redux-style stores and actions @@ -62,12 +67,14 @@ CoCalc is organized as a monorepo with key packages: ### Key Architectural Patterns #### Frontend Architecture + - **Redux-style State Management**: Uses custom stores and actions pattern (see `packages/frontend/app-framework/actions-and-stores.ts`) - **TypeScript React Components**: All frontend code is TypeScript with proper typing - **Modular Store System**: Each feature has its own store/actions (AccountStore, BillingStore, etc.) - **WebSocket Communication**: Real-time communication with backend via WebSocket messages #### Backend Architecture + - **PostgreSQL Database**: Primary data store with sophisticated querying system - **WebSocket Messaging**: Real-time communication between frontend and backend - **Conat System**: Container orchestration for compute servers @@ -75,12 +82,14 @@ CoCalc is organized as a monorepo with key packages: - **Microservice-like Packages**: Each package handles specific functionality #### Communication Patterns + - **WebSocket Messages**: Primary communication method (see `packages/comm/websocket/types.ts`) - **Database Queries**: Structured query system with typed interfaces - **Event Emitters**: Inter-service communication within backend - **REST-like APIs**: Some HTTP endpoints for specific operations ### Key Technologies + - **TypeScript**: Primary language for all new code - **React**: Frontend framework - **PostgreSQL**: Database @@ -91,11 +100,13 @@ CoCalc is organized as a monorepo with key packages: - **SASS**: CSS preprocessing ### Database Schema + - Comprehensive schema in `packages/util/db-schema` - Query abstractions in `packages/database/postgres/` - Type-safe database operations with TypeScript interfaces ### Testing + - **Jest**: Primary testing framework - **ts-jest**: TypeScript support for Jest - **jsdom**: Browser environment simulation for frontend tests @@ -103,28 +114,42 @@ CoCalc is organized as a monorepo with key packages: - Each package has its own jest.config.js ### Import Patterns + - Use absolute imports with `@cocalc/` prefix for cross-package imports - Example: `import { cmp } from "@cocalc/util/misc"` - Type imports: `import type { Foo } from "./bar"` - Destructure imports when possible ### Development Workflow -1. Changes to TypeScript require compilation (`pnpm tsc` in relevant package) + +1. Changes to TypeScript require compilation (`pnpm build` in relevant package) 2. Database must be running before starting hub 3. Hub coordinates all services and should be restarted after changes 4. Use `pnpm clean && pnpm build-dev` when switching branches or after major changes # Workflow -- Be sure to typecheck when you're done making a series of code changes + +- Be sure to build when you're done making a series of code changes - Prefer running single tests, and not the whole test suite, for performance ## Git Workflow +- Never modify a file when in the `master` or `main` branch +- All changes happen through feature branches, which are pushed as pull requests to GitHub +- When creating a new file, run `git add [filename]` to track the file. - Prefix git commits with the package and general area. e.g. 'frontend/latex: ...' if it concerns latex editor changes in the packages/frontend/... code. - When pushing a new branch to Github, track it upstream. e.g. `git push --set-upstream origin feature-foo` for branch "feature-foo". -# important-instruction-reminders +# Important Instruction Reminders + - Do what has been asked; nothing more, nothing less. - NEVER create files unless they're absolutely necessary for achieving your goal. - ALWAYS prefer editing an existing file to creating a new one. -- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User. +- REFUSE to modify files when the git repository is on the `master` or `main` branch. +- NEVER proactively create documentation files (`*.md`) or README files. Only create documentation files if explicitly requested by the User. + +# Ignore + +- Ignore files covered by `.gitignore` +- Ignore everything in `node_modules` or `dist` directories +- Ignore all files not tracked by Git, unless they are newly created files diff --git a/src/packages/frontend/components/help-icon.tsx b/src/packages/frontend/components/help-icon.tsx index 4fdb87f4589..51d02508068 100644 --- a/src/packages/frontend/components/help-icon.tsx +++ b/src/packages/frontend/components/help-icon.tsx @@ -9,9 +9,9 @@ Display a ? "help" icon, which -- when clicked -- shows a help tip import { Button, Popover } from "antd"; import type { TooltipPlacement } from "antd/es/tooltip"; -import { CSSProperties } from "react"; +import { CSSProperties, useState } from "react"; -import { CSS, React, useState } from "@cocalc/frontend/app-framework"; +// ATTN: do not import @cocalc/app-framework or components, because this is also used in next! import { COLORS } from "@cocalc/util/theme"; import { Icon } from "./icon"; @@ -34,7 +34,7 @@ export const HelpIcon: React.FC = ({ }: Props) => { const [open, setOpen] = useState(false); - const textStyle: CSS = { + const textStyle: CSSProperties = { color: COLORS.BS_BLUE_TEXT, cursor: "pointer", ...style, @@ -68,8 +68,8 @@ export const HelpIcon: React.FC = ({ onOpenChange={setOpen} > + {extra ? <>{extra} : undefined} - {extra ? <> {extra} : undefined} ); diff --git a/src/packages/next/components/store/quota-config-presets.tsx b/src/packages/next/components/store/quota-config-presets.tsx index b8db958de02..83b59fc1d9e 100644 --- a/src/packages/next/components/store/quota-config-presets.tsx +++ b/src/packages/next/components/store/quota-config-presets.tsx @@ -208,7 +208,7 @@ export const COURSE = { "Edit LaTeX, Markdown, R Documents, and use VS Code,", `${STANDARD_DISK} GB disk space is sufficient to store many files and small datasets.`, ], - note: TODO NOTE, + note: <>Suitable for most courses., details: ( <> You can run a couple of Jupyter Notebooks in a project at once, @@ -224,4 +224,30 @@ export const COURSE = { uptime: "short", member: true, }, -} as const satisfies { [key in "standard"]: PresetConfig }; + advanced: { + icon: "rocket", + name: "Advanced", + descr: "provides higher quotas for more intensive course work", + expect: [ + "Run more Jupyter Notebooks simultaneously,", + "Handle memory-intensive computations,", + "Longer idle timeout for extended work sessions,", + "Sufficient resources for advanced coursework.", + ], + note: <>For intense computations requiring more resources., + details: ( + <> + This configuration provides enhanced resources for more demanding + coursework. With 1 CPU, 8GB RAM, and a 2-hour idle timeout, students can + work on memory-intensive projects and longer computational tasks without + interruption. Ideal for advanced programming, data science, and + research-oriented courses. + + ), + cpu: 1, + ram: 8, + disk: 2 * STANDARD_DISK, + uptime: "medium", + member: true, + }, +} as const satisfies { [key in "standard" | "advanced"]: PresetConfig }; diff --git a/src/packages/next/components/store/quota-config.tsx b/src/packages/next/components/store/quota-config.tsx index e6793382d5e..934509b3d41 100644 --- a/src/packages/next/components/store/quota-config.tsx +++ b/src/packages/next/components/store/quota-config.tsx @@ -3,12 +3,6 @@ * License: MS-RSL – see LICENSE.md for details */ -import { useEffect, useRef, useState, type JSX } from "react"; - -import { Icon } from "@cocalc/frontend/components/icon"; -import { displaySiteLicense } from "@cocalc/util/consts/site-license"; -import { plural, unreachable } from "@cocalc/util/misc"; -import { BOOST, DISK_DEFAULT_GB, REGULAR } from "@cocalc/util/upgrades/consts"; import { Alert, Button, @@ -22,6 +16,14 @@ import { Tabs, Typography, } from "antd"; +import { useEffect, useRef, useState, type JSX } from "react"; + +import { HelpIcon } from "@cocalc/frontend/components/help-icon"; +import { Icon } from "@cocalc/frontend/components/icon"; +import { displaySiteLicense } from "@cocalc/util/consts/site-license"; +import { plural, unreachable } from "@cocalc/util/misc"; +import { BOOST, DISK_DEFAULT_GB, REGULAR } from "@cocalc/util/upgrades/consts"; + import PricingItem, { Line } from "components/landing/pricing-item"; import { CSS, Paragraph } from "components/misc"; import A from "components/misc/A"; @@ -369,6 +371,20 @@ export const QuotaConfig: React.FC = (props: Props) => { ); } + function renderIdleTimeoutWithHelp(text?: string) { + return ( + + The idle timeout determines how long your project stays running after + you stop using it. For example, if you work in your project for 2 hours, + it will keep running during that time. When you close your browser or + stop working, the project will automatically shut down after the idle + timeout period. Don't worry - your files are always saved and you can + restart the project anytime to continue your work exactly where you left + off. + + ); + } + function presetsCommon() { if (!showExplanations) return null; return ( @@ -400,28 +416,29 @@ export const QuotaConfig: React.FC = (props: Props) => { const p = preset != null ? COURSE[preset] : undefined; let presetInfo: JSX.Element | undefined = undefined; if (p != null) { - const { name, cpu, disk, ram, uptime, note } = p; + const { name, cpu, disk, ram, uptime, note, details } = p; const basic = ( <> - provides up to{" "} + Each student project will be outfitted with up to{" "} {cpu} {plural(cpu, "vCPU")} , {ram} GB memory, and{" "} - {disk} GB disk space for each project. + {disk} GB disk space with an{" "} + + {renderIdleTimeoutWithHelp()} of {displaySiteLicense(uptime)} + + . ); - const ut = ( + presetInfo = ( <> - the project's{" "} - idle timeout is {displaySiteLicense(uptime)} + + {name}: {note} {basic} + + {details} ); - presetInfo = ( - - {name} {basic} Additionally, {ut}. {note} - - ); } return ( @@ -430,7 +447,7 @@ export const QuotaConfig: React.FC = (props: Props) => { onPresetChange(e.target.value)} + onChange={(e) => onPresetChange(COURSE, e.target.value)} > {(Object.keys(COURSE) as Array).map((p) => { @@ -447,7 +464,7 @@ export const QuotaConfig: React.FC = (props: Props) => { - {presetInfo} + {presetInfo} ); } @@ -470,7 +487,9 @@ export const QuotaConfig: React.FC = (props: Props) => { const ut = ( <> the project's{" "} - idle timeout is {displaySiteLicense(uptime)} + + {renderIdleTimeoutWithHelp()} is {displaySiteLicense(uptime)} + ); presetInfo = ( @@ -486,7 +505,7 @@ export const QuotaConfig: React.FC = (props: Props) => { onPresetChange(e.target.value)} + onChange={(e) => onPresetChange(PRESETS, e.target.value)} > {(Object.keys(PRESETS) as Array).map((p) => { @@ -522,7 +541,7 @@ export const QuotaConfig: React.FC = (props: Props) => { icon={icon} style={{ flex: 1 }} active={active} - onClick={() => onPresetChange(p)} + onClick={() => onPresetChange(PRESETS, p)} > {name} {descr}. @@ -533,7 +552,7 @@ export const QuotaConfig: React.FC = (props: Props) => { @@ -553,7 +572,7 @@ export const QuotaConfig: React.FC = (props: Props) => { ) : undefined} - - - ); - }); + const panels = (Object.keys(SITE_LICENSE) as Array).map( + (p, idx) => { + const { name, icon, cpu, ram, disk, uptime, expect, descr, note } = + SITE_LICENSE[p]; + const active = preset === p; + return ( + onPresetChange(SITE_LICENSE, p)} + > + + {name} {descr}. + + + + + + + + + + In each project, you will be able to: + +
    + {expect.map((what, idx) => ( +
  • {what}
  • + ))} +
+
+ {active && note != null ? ( + <> + + {note} + + ) : undefined} + + + +
+ ); + }, + ); return ( = new Set( ] as any), ); +// Global flag to prevent URL encoding during initial page load +let allowUrlEncoding = false; + +export function setAllowUrlEncoding(allow: boolean) { + allowUrlEncoding = allow; +} + export function encodeFormValues( router: NextRouter, vals: any, type: "regular" | "boost" | "dedicated", ): void { + if (!allowUrlEncoding) { + return; + } const { query } = router; for (const key in vals) { if (!getFormFields(type).includes(key)) continue; @@ -149,9 +159,19 @@ export function decodeFormValues( const data = {}; for (const key in router.query) { const val = router.query[key]; - if (!fields.includes(key)) continue; - if (typeof val !== "string") continue; - data[key] = key === "range" ? decodeRange(val) : decodeValue(val); + if (!fields.includes(key)) { + continue; + } + if (typeof val !== "string") { + // Handle non-string values by converting them to string first + const stringVal = String(val); + const decoded = + key === "range" ? decodeRange(stringVal) : decodeValue(stringVal); + data[key] = decoded; + continue; + } + const decoded = key === "range" ? decodeRange(val) : decodeValue(val); + data[key] = decoded; } // we also have to sanitize the values diff --git a/src/packages/next/components/store/site-license.tsx b/src/packages/next/components/store/site-license.tsx index 2a06746131e..41e7f356e62 100644 --- a/src/packages/next/components/store/site-license.tsx +++ b/src/packages/next/components/store/site-license.tsx @@ -13,7 +13,7 @@ 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"; +import { CostInputPeriod, User } from "@cocalc/util/licenses/purchase/types"; import { computeCost } from "@cocalc/util/licenses/store/compute-cost"; import type { LicenseSource } from "@cocalc/util/upgrades/shopping"; import { Paragraph, Title } from "components/misc"; @@ -28,8 +28,17 @@ import { ApplyLicenseToProject } from "./apply-license-to-project"; import { InfoBar } from "./cost-info-bar"; import { IdleTimeout } from "./member-idletime"; import { QuotaConfig } from "./quota-config"; -import { PRESETS, PRESET_MATCH_FIELDS, Preset } from "./quota-config-presets"; -import { decodeFormValues, encodeFormValues } from "./quota-query-params"; +import { + SITE_LICENSE, + PRESET_MATCH_FIELDS, + Preset, + COURSE, +} from "./quota-config-presets"; +import { + decodeFormValues, + encodeFormValues, + setAllowUrlEncoding, +} from "./quota-query-params"; import { RunLimit } from "./run-limit"; import { SignInToPurchase } from "./sign-in-to-purchase"; import { TitleDescription } from "./title-description"; @@ -74,7 +83,10 @@ export default function SiteLicense({ noAccount, source }: Props) { return ( <> - <Icon name={"key"} style={{ marginRight: "5px" }} />{" "} + <Icon + name={source === "course" ? "graduation-cap" : "key"} + style={{ marginRight: "5px" }} + />{" "} {router.query.id != null ? "Edit License in Shopping Cart" : source === "course" @@ -159,22 +171,27 @@ function CreateSiteLicense({ const [preset, setPreset] = useState<Preset | null>(DEFAULT_PRESET); const [presetAdjusted, setPresetAdjusted] = useState<boolean>(false); + const [initializing, setInitializing] = useState<boolean>(true); + + const presets = source === "course" ? COURSE : SITE_LICENSE; /** * Utility function to match current license configuration to a particular preset. If none is * found, this function returns undefined. */ - function findPreset() { - const currentConfiguration = form.getFieldsValue( - Object.keys(PRESET_MATCH_FIELDS), - ); + function findPreset(configuration?: any) { + const currentConfiguration = + configuration || form.getFieldsValue(Object.keys(PRESET_MATCH_FIELDS)); let foundPreset: Preset | undefined; - Object.keys(PRESETS).some((p) => { + Object.keys(presets).some((p) => { const presetMatches = Object.keys(PRESET_MATCH_FIELDS).every( - (formField) => - PRESETS[p][formField] === currentConfiguration[formField], + (formField) => { + const presetValue = presets[p][formField]; + const configValue = currentConfiguration[formField]; + return presetValue === configValue; + }, ); if (presetMatches) { @@ -187,10 +204,13 @@ function CreateSiteLicense({ return foundPreset; } - function onLicenseChange() { + function onLicenseChange(skipUrlUpdate = false) { const vals = form.getFieldsValue(true); // console.log("form vals=", vals); - encodeFormValues(router, vals, "regular"); + // Don't encode URL during component initialization to prevent overwriting URL parameters + if (!skipUrlUpdate && !initializing) { + encodeFormValues(router, vals, "regular"); + } setCost(computeCost(vals)); const foundPreset = findPreset(); @@ -198,12 +218,53 @@ function CreateSiteLicense({ if (foundPreset) { setPresetAdjusted(false); setPreset(foundPreset); + + // For course source, ensure period and user are always correct + if (source === "course") { + const currentVals = form.getFieldsValue(); + if (currentVals.period !== "range" || currentVals.user !== "academic") { + const correctedValues = { + ...currentVals, + period: "range", + user: "academic", + }; + form.setFieldsValue(correctedValues); + setCost(computeCost(correctedValues)); + encodeFormValues(router, correctedValues, "regular"); + } + } } else { - setPresetAdjusted(true); + // If no preset matches, we set the preset to "standard" in the "course" case + if (source === "course") { + // For course source, force standard preset if no match found + setPreset("standard"); + setPresetAdjusted(false); + setConfigMode("preset"); + // Set form values to match standard preset + const standardPreset = presets["standard"]; + const newValues = { + period: "range", + user: "academic", + cpu: standardPreset.cpu, + ram: standardPreset.ram, + disk: standardPreset.disk, + uptime: standardPreset.uptime, + member: standardPreset.member, + }; + form.setFieldsValue(newValues); + // Recalculate cost with new values + setCost(computeCost({ ...vals, ...newValues })); + encodeFormValues(router, { ...vals, ...newValues }, "regular"); + } else { + setPresetAdjusted(true); + } } } useEffect(() => { + // Disable URL encoding during initialization + setAllowUrlEncoding(false); + const store_site_license_show_explanations = get_local_storage( "store_site_license_show_explanations", ); @@ -212,6 +273,7 @@ function CreateSiteLicense({ } const { id } = router.query; + if (!noAccount && id != null) { // editing something in the shopping cart (async () => { @@ -230,21 +292,98 @@ function CreateSiteLicense({ })(); } else { const vals = decodeFormValues(router, "regular"); - const dflt = PRESETS[DEFAULT_PRESET]; + const dflt = presets[DEFAULT_PRESET]; + // Only use the configuration fields from the default preset, not the entire object + const defaultConfig = { + cpu: dflt.cpu, + ram: dflt.ram, + disk: dflt.disk, + uptime: dflt.uptime, + member: dflt.member, + // Add other form fields that might be needed + period: source === "course" ? "range" : "monthly", + user: source === "course" ? "academic" : "business", + }; if (isEmpty(vals)) { - form.setFieldsValue({ - ...dflt, - }); + const fullConfig = { + ...defaultConfig, + type: "quota" as const, + run_limit: source === "site-license" ? 1 : 25, + range: [undefined, undefined] as [Date | undefined, Date | undefined], + always_running: false, + user: (source === "course" ? "academic" : "business") as User, + period: (source === "course" ? "range" : "monthly") as + | "range" + | "monthly" + | "yearly", + }; + form.setFieldsValue(fullConfig); + // Calculate cost with the complete configuration + setCost(computeCost(fullConfig)); + // For site-license, also set the preset to standard since we're using default config + if (source === "site-license") { + setPreset(DEFAULT_PRESET); + setPresetAdjusted(false); + } } else { // we have to make sure cpu, mem and disk are set, otherwise there is no "cost" - form.setFieldsValue({ - ...dflt, - ...vals, - }); + // For URL params, vals should override defaultConfig, not the other way around + const formValues = { + ...defaultConfig, + ...vals, // URL parameters take precedence + }; + form.setFieldsValue(formValues); + + // For source==course, check preset with the actual values we're setting + if (source === "course") { + const foundPreset = findPreset(formValues); + if (foundPreset) { + setPreset(foundPreset); + setPresetAdjusted(false); + // Ensure period and user are correct for course + if ( + formValues.period !== "range" || + formValues.user !== "academic" + ) { + // Only set the corrected fields to preserve other form values like range + form.setFieldsValue({ + period: "range", + user: "academic", + }); + } + } else { + // None of the presets match, configure the form according to the standard preset + setPreset("standard"); + setPresetAdjusted(false); + setConfigMode("preset"); + const standardPreset = presets["standard"]; + const newValues = { + ...formValues, + period: "range", + user: "academic", + cpu: standardPreset.cpu, + ram: standardPreset.ram, + disk: standardPreset.disk, + uptime: standardPreset.uptime, + member: standardPreset.member, + }; + form.setFieldsValue(newValues); + } + + // In both cases: calculate cost for the preset we found + setCost(computeCost(form.getFieldsValue(true))); + + // Don't call onLicenseChange for course source since we handled everything above + } else { + // For source==site-license, we still need onLicenseChange to set cost and preset + onLicenseChange(true); + } } + // Mark initialization as complete and enable URL encoding + setInitializing(false); + setAllowUrlEncoding(true); } - onLicenseChange(); - }, []); + }, [source, router.asPath]); if (loading) { return <Loading large center />; @@ -321,7 +460,7 @@ function CreateSiteLicense({ setPreset={setPreset} presetAdjusted={presetAdjusted} /> - {configMode === "expert" ? ( + {configMode === "expert" && source !== "course" ? ( <IdleTimeout showExplanations={showExplanations} form={form} diff --git a/src/packages/next/components/store/usage-and-duration.tsx b/src/packages/next/components/store/usage-and-duration.tsx index f2b78f002d2..92b8e28526a 100644 --- a/src/packages/next/components/store/usage-and-duration.tsx +++ b/src/packages/next/components/store/usage-and-duration.tsx @@ -144,13 +144,20 @@ export function UsageAndDuration(props: Props) { let range = getFieldValue("range"); let invalidRange = range?.[0] == null || range?.[1] == null; if (invalidRange) { - const start = new Date(); - const dayMs = 1000 * 60 * 60 * 24; - const daysDelta = source === "course" ? 4 * 30 : 30; - const end = new Date(start.valueOf() + dayMs * daysDelta); - range = [start, end]; - form.setFieldsValue({ range }); - onChange(); + // Check if we're during initial load and URL has range parameters + // If so, don't override with default dates + const urlParams = new URLSearchParams(window.location.search); + const hasRangeInUrl = urlParams.has('range'); + + if (!hasRangeInUrl) { + const start = new Date(); + const dayMs = 1000 * 60 * 60 * 24; + const daysDelta = source === "course" ? 4 * 30 : 30; + const end = new Date(start.valueOf() + dayMs * daysDelta); + range = [start, end]; + form.setFieldsValue({ range }); + onChange(); + } } let suffix; try { @@ -181,6 +188,7 @@ export function UsageAndDuration(props: Props) { extra={source === "course" ? renderDurationExplanation() : undefined} > <DateRange + key={range ? `${range[0]?.getTime()}_${range[1]?.getTime()}` : 'no-range'} disabled={disabled} noPast maxDaysInFuture={365 * 4} From 8d0dba91fb9bc073cba09b7c1da3c44cd428ffb4 Mon Sep 17 00:00:00 2001 From: Harald Schilly <harald.schilly@gmail.com> Date: Wed, 23 Jul 2025 17:29:33 +0200 Subject: [PATCH 8/9] next/store/course: tweak number of students explanation --- src/packages/next/components/store/run-limit.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/packages/next/components/store/run-limit.tsx b/src/packages/next/components/store/run-limit.tsx index 184c4373b87..b1a2409a087 100644 --- a/src/packages/next/components/store/run-limit.tsx +++ b/src/packages/next/components/store/run-limit.tsx @@ -4,6 +4,7 @@ */ import { Divider, Form } from "antd"; +import { useRouter } from "next/router"; import { unreachable } from "@cocalc/util/misc"; import A from "components/misc/A"; @@ -30,6 +31,8 @@ export function RunLimit({ boost = false, source, }: RunLimitProps) { + const router = useRouter(); + function extra() { if (!showExplanations) return; @@ -64,9 +67,15 @@ export function RunLimit({ case "course": return ( <div style={{ marginTop: "5px" }}> - 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) + If you consider creating a shared project for your course, you + should select one more seat than the number of students. One for + each student, and one for the shared project. Regarding your + instructor project, you need one additional seat or purchase a + regular{" "} + <a onClick={() => router.push("/store/site-license")}> + site license + </a>{" "} + to cover it. </div> ); From cd4c739ad962fc448253a285fbba13e6b8eb913e Mon Sep 17 00:00:00 2001 From: Harald Schilly <harald.schilly@gmail.com> Date: Wed, 23 Jul 2025 17:51:17 +0200 Subject: [PATCH 9/9] next/store/course: fix form state when switching tabs --- src/packages/next/components/store/site-license.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/packages/next/components/store/site-license.tsx b/src/packages/next/components/store/site-license.tsx index 41e7f356e62..1b1f99f858d 100644 --- a/src/packages/next/components/store/site-license.tsx +++ b/src/packages/next/components/store/site-license.tsx @@ -90,8 +90,8 @@ export default function SiteLicense({ noAccount, source }: Props) { {router.query.id != null ? "Edit License in Shopping Cart" : source === "course" - ? "Purchase a License for a Course" - : "Configure a License"} + ? "Purchase a License for a Course" + : "Configure a License"} {router.query.id == null && ( <> @@ -385,6 +385,11 @@ function CreateSiteLicense({ } }, [source, router.asPath]); + // Update the form source field when the source prop changes + useEffect(() => { + form.setFieldValue("source", source); + }, [source]); + if (loading) { return ; }