diff --git a/.gitignore b/.gitignore index 331ad90a59b..91fd8511099 100644 --- a/.gitignore +++ b/.gitignore @@ -160,5 +160,7 @@ src/packages/frontend/i18n/trans/*.compiled.json **/project-env.sh **/*.bash_history +src/.claude/settings.local.json + # test reports by jest-junit junit.xml diff --git a/src/.claude/settings.local.json b/src/.claude/settings.json similarity index 75% rename from src/.claude/settings.local.json rename to src/.claude/settings.json index 11bf37a4f99..2142db2625a 100644 --- a/src/.claude/settings.local.json +++ b/src/.claude/settings.json @@ -1,22 +1,24 @@ { "permissions": { "allow": [ - "Bash(pnpm tsc:*)", - "Bash(pnpm build:*)", + "Bash(find:*)", + "Bash(gh pr view:*)", + "Bash(gh:*)", "Bash(git add:*)", "Bash(git commit:*)", - "Bash(node:*)", "Bash(grep:*)", - "Bash(find:*)", - "WebFetch(domain:github.com)", - "WebFetch(domain:cocalc.com)", - "WebFetch(domain:doc.cocalc.com)", + "Bash(node:*)", "Bash(npm show:*)", - "Bash(prettier -w:*)", "Bash(npx tsc:*)", - "Bash(gh pr view:*)", - "Bash(gh:*)" + "Bash(pnpm build:*)", + "Bash(pnpm ts-build:*)", + "Bash(pnpm tsc:*)", + "Bash(prettier -w:*)", + "WebFetch(domain:cocalc.com)", + "WebFetch(domain:doc.cocalc.com)", + "WebFetch(domain:docs.anthropic.com)", + "WebFetch(domain:github.com)" ], "deny": [] } -} \ No newline at end of file +} 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/add-box.tsx b/src/packages/next/components/store/add-box.tsx index 53bcd8f807c..526186d39f0 100644 --- a/src/packages/next/components/store/add-box.tsx +++ b/src/packages/next/components/store/add-box.tsx @@ -7,15 +7,18 @@ Add a cash voucher to your shopping cart. */ import { useState, type JSX } from "react"; +import { Alert, Button, Spin } from "antd"; + import { CostInputPeriod } from "@cocalc/util/licenses/purchase/types"; import { round2up } from "@cocalc/util/misc"; import { money } from "@cocalc/util/licenses/purchase/utils"; -import { Alert, Button, Spin } from "antd"; 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 type { LicenseSource } from "@cocalc/util/upgrades/shopping"; + export const ADD_STYLE = { display: "inline-block", maxWidth: "550px", @@ -37,6 +40,7 @@ interface Props { dedicatedItem?: boolean; disabled?: boolean; noAccount: boolean; + source: LicenseSource; } export function AddBox({ @@ -48,6 +52,7 @@ export function AddBox({ dedicatedItem = false, noAccount, disabled = false, + source, }: Props) { if (cost?.input.type == "cash-voucher") { return null; @@ -76,7 +81,8 @@ export function AddBox({ }} message={ <> - {money(round2up(costPer))} per project{" "} + {money(round2up(costPer))}{" "} + per {source === "course" ? "student" : "project"}{" "} {!!cost.period && cost.period != "range" ? cost.period : ""} } @@ -175,8 +181,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/cart.tsx b/src/packages/next/components/store/cart.tsx index aa17c1deef9..87162d31d0e 100644 --- a/src/packages/next/components/store/cart.tsx +++ b/src/packages/next/components/store/cart.tsx @@ -11,26 +11,27 @@ shopping cart experience, so most likely to feel familiar to users and easy to use. */ +import { Alert, Button, Checkbox, Popconfirm, Table } from "antd"; +import { useRouter } from "next/router"; +import { useEffect, useMemo, useState, type JSX } from "react"; + import { Icon } from "@cocalc/frontend/components/icon"; +import type { + ProductDescription, + ProductType, +} from "@cocalc/util/db-schema/shopping-cart-items"; import { describeQuotaFromInfo } from "@cocalc/util/licenses/describe-quota"; import { CostInputPeriod } from "@cocalc/util/licenses/purchase/types"; +import { computeCost } from "@cocalc/util/licenses/store/compute-cost"; import { capitalize, isValidUUID } from "@cocalc/util/misc"; -import { Alert, Button, Checkbox, Popconfirm, Table } from "antd"; import A from "components/misc/A"; import Loading from "components/share/loading"; import SiteName from "components/share/site-name"; import apiPost from "lib/api/post"; import useAPI from "lib/hooks/api"; import useIsMounted from "lib/hooks/mounted"; -import { useRouter } from "next/router"; -import { useEffect, useMemo, useState, type JSX } from "react"; -import { computeCost } from "@cocalc/util/licenses/store/compute-cost"; import OtherItems from "./other-items"; import { describeItem, describePeriod, DisplayCost } from "./site-license-cost"; -import type { - ProductDescription, - ProductType, -} from "@cocalc/util/db-schema/shopping-cart-items"; export default function ShoppingCart() { const isMounted = useIsMounted(); @@ -353,9 +354,9 @@ export function DescriptionColumn(props: DCProps) { const router = useRouter(); const { id, description, style, readOnly } = props; if ( - description.type == "disk" || - description.type == "vm" || - description.type == "quota" + description.type === "disk" || + description.type === "vm" || + description.type === "quota" ) { return ; } else if (description.type == "cash-voucher") { @@ -390,9 +391,9 @@ function DescriptionColumnSiteLicense(props: DCProps) { const { id, cost, description, compact, project_id, readOnly } = props; if ( !( - description.type == "disk" || - description.type == "vm" || - description.type == "quota" + description.type === "disk" || + description.type === "vm" || + description.type === "quota" ) ) { throw Error("BUG -- incorrect typing"); @@ -403,7 +404,7 @@ function DescriptionColumnSiteLicense(props: DCProps) { return
{JSON.stringify(description, undefined, 2)}
; } const { input } = cost; - if (input.type == "cash-voucher") { + if (input.type === "cash-voucher") { throw Error("incorrect typing"); } @@ -423,7 +424,7 @@ function DescriptionColumnSiteLicense(props: DCProps) { } function editableQuota() { - if (input.type == "cash-voucher") return null; + if (input.type === "cash-voucher") return null; return (
{describeQuotaFromInfo(input)}
@@ -433,9 +434,14 @@ function DescriptionColumnSiteLicense(props: DCProps) { } // this could rely an the "type" field, but we rather check the data directly - function editPage(): "site-license" | "vouchers" { - if (input.type == "cash-voucher") { + function editPage(): "site-license" | "vouchers" | "course" { + if (input.type === "cash-voucher") { return "vouchers"; + } else if ( + description.type === "quota" && + description.source === "course" + ) { + return "course"; } return "site-license"; } @@ -451,7 +457,7 @@ function DescriptionColumnSiteLicense(props: DCProps) {
- {input.subscription == "no" + {input.subscription === "no" ? describePeriod({ quota: input }) : capitalize(input.subscription) + " subscription"} diff --git a/src/packages/next/components/store/index.tsx b/src/packages/next/components/store/index.tsx index 264d6da4e3e..8c397a52898 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..c6a9cba1c5b 100644 --- a/src/packages/next/components/store/menu.tsx +++ b/src/packages/next/components/store/menu.tsx @@ -3,13 +3,15 @@ * 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"; +import { StoreBalanceContext } from "lib/balance"; type MenuItem = Required["items"][number]; @@ -17,12 +19,13 @@ const styles: { [k: string]: React.CSSProperties } = { menuBookend: { height: "100%", whiteSpace: "nowrap", - flexGrow: 1, + flex: "0 1 auto", textAlign: "end", }, menu: { width: "100%", height: "100%", + flex: "1 1 auto", border: 0, }, menuRoot: { @@ -38,7 +41,7 @@ const styles: { [k: string]: React.CSSProperties } = { maxWidth: "100%", flexGrow: 1, }, -}; +} as const; export interface ConfigMenuProps { main?: string; @@ -64,6 +67,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/quota-config-presets.tsx b/src/packages/next/components/store/quota-config-presets.tsx index f8a4a0d3afb..38ccb345731 100644 --- a/src/packages/next/components/store/quota-config-presets.tsx +++ b/src/packages/next/components/store/quota-config-presets.tsx @@ -46,7 +46,7 @@ const STANDARD_DISK = 3; const PRESET_STANDARD_NAME = "Standard"; -export const PRESETS: PresetEntries = { +export const SITE_LICENSE: PresetEntries = { standard: { icon: "line-chart", name: PRESET_STANDARD_NAME, @@ -197,3 +197,57 @@ 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: <>Suitable for most courses., + 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, + }, + 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 1b168e0a3c8..56a65c62c57 100644 --- a/src/packages/next/components/store/quota-config.tsx +++ b/src/packages/next/components/store/quota-config.tsx @@ -17,16 +17,21 @@ import { 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 } from "@cocalc/util/misc"; +import { plural, unreachable } from "@cocalc/util/misc"; import { BOOST, DISK_DEFAULT_GB, REGULAR } from "@cocalc/util/upgrades/consts"; +import type { LicenseSource } from "@cocalc/util/upgrades/shopping"; + 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 { - PRESETS, + COURSE, + SITE_LICENSE, PRESET_MATCH_FIELDS, Preset, PresetConfig, @@ -57,6 +62,7 @@ interface Props { setPreset?: (preset: Preset | null) => void; presetAdjusted?: boolean; setPresetAdjusted?: (adjusted: boolean) => void; + source: LicenseSource; } export const QuotaConfig: React.FC = (props: Props) => { @@ -72,6 +78,7 @@ export const QuotaConfig: React.FC = (props: Props) => { setPreset, presetAdjusted, setPresetAdjusted, + source, } = props; const presetsRef = useRef(null); @@ -107,7 +114,14 @@ export const QuotaConfig: React.FC = (props: Props) => { if (boost) { return "Booster"; } else { - return "Quota Upgrades"; + switch (source) { + case "site-license": + return "Quota Upgrades"; + case "course": + return "Project Upgrades"; + default: + unreachable(source); + } } } @@ -310,7 +324,7 @@ export const QuotaConfig: React.FC = (props: Props) => { function presetIsAdjusted() { if (preset == null) return; - const presetData: PresetConfig = PRESETS[preset]; + const presetData: PresetConfig = SITE_LICENSE[preset]; if (presetData == null) { return (
@@ -357,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 ( @@ -365,7 +393,8 @@ export const QuotaConfig: React.FC = (props: Props) => { <>After selecting a preset, feel free to ) : ( <> - Selected preset "{PRESETS[preset]?.name}". You can + Selected preset "{SITE_LICENSE[preset]?.name}". You + can )}{" "} fine tune the selection in the "{EXPERT_CONFIG}" tab. Subsequent preset @@ -384,8 +413,65 @@ 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, details } = p; + const basic = ( + <> + Each student project will be outfitted with up to{" "} + + {cpu} {plural(cpu, "vCPU")} + + , {ram} GB memory, and{" "} + {disk} GB disk space with an{" "} + + {renderIdleTimeoutWithHelp()} of {displaySiteLicense(uptime)} + + . + + ); + presetInfo = ( + <> + + {name}: {note} {basic} + + {details} + + ); + } + + return ( + <> + + onPresetChange(COURSE, 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; + const p = preset != null ? SITE_LICENSE[preset] : undefined; let presetInfo: JSX.Element | undefined = undefined; if (p != null) { const { name, cpu, disk, ram, uptime, note } = p; @@ -402,7 +488,9 @@ export const QuotaConfig: React.FC = (props: Props) => { const ut = ( <> the project's{" "} - idle timeout is {displaySiteLicense(uptime)} + + {renderIdleTimeoutWithHelp()} is {displaySiteLicense(uptime)} + ); presetInfo = ( @@ -418,11 +506,11 @@ export const QuotaConfig: React.FC = (props: Props) => { onPresetChange(e.target.value)} + onChange={(e) => onPresetChange(SITE_LICENSE, e.target.value)} > - {(Object.keys(PRESETS) as Array).map((p) => { - const { name, icon, descr } = PRESETS[p]; + {(Object.keys(SITE_LICENSE) as Array).map((p) => { + const { name, icon, descr } = SITE_LICENSE[p]; return ( @@ -443,58 +531,62 @@ export const QuotaConfig: React.FC = (props: Props) => { function renderPresetPanels() { if (narrow) return renderPresetsNarrow(); - const panels = (Object.keys(PRESETS) as Array).map((p, idx) => { - const { name, icon, cpu, ram, disk, uptime, expect, descr, note } = - PRESETS[p]; - const active = preset === p; - return ( - onPresetChange(p)} - > - - {name} {descr}. - - - - - - - - - In each project, you will be able to: -
    - {expect.map((what, idx) => ( -
  • {what}
  • - ))} -
-
- {active && note != null ? ( - <> - - {note} - - ) : 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 ( = (props: Props) => { ); } - function onPresetChange(val: Preset) { + function onPresetChange( + preset: { [key: string]: PresetConfig }, + val: Preset, + ) { if (val == null || setPreset == null) return; setPreset(val); setPresetAdjusted?.(false); - const presetData = PRESETS[val]; + const presetData = preset[val]; if (presetData != null) { const { cpu, ram, disk, uptime = "short", member = true } = presetData; form.setFieldsValue({ uptime, member, cpu, ram, disk }); @@ -560,38 +655,45 @@ export const QuotaConfig: React.FC = (props: Props) => { ); } else { - return ( - - - Presets -
- ), - children: presetExtra(), - }, - { - key: "expert", - label: ( - - - {EXPERT_CONFIG} - - ), - children: detailed(), - }, - ]} - /> - ); + switch (source) { + case "site-license": + return ( + + + Presets + + ), + children: presetExtra(), + }, + { + key: "expert", + label: ( + + + {EXPERT_CONFIG} + + ), + children: detailed(), + }, + ]} + /> + ); + case "course": + return renderCoursePresets(); + default: + unreachable(source); + } } } diff --git a/src/packages/next/components/store/quota-query-params.ts b/src/packages/next/components/store/quota-query-params.ts index 3a3481448d0..c8600263203 100644 --- a/src/packages/next/components/store/quota-query-params.ts +++ b/src/packages/next/components/store/quota-query-params.ts @@ -3,6 +3,10 @@ * License: MS-RSL – see LICENSE.md for details */ +import dayjs from "dayjs"; +import { clamp, isDate } from "lodash"; +import { NextRouter } from "next/router"; + import { testDedicatedDiskNameBasic } from "@cocalc/util/licenses/check-disk-name-basics"; import { BOOST, REGULAR } from "@cocalc/util/upgrades/consts"; import { @@ -14,15 +18,11 @@ import { PRICES, } from "@cocalc/util/upgrades/dedicated"; import type { DateRange } from "@cocalc/util/upgrades/shopping"; -import { clamp, isDate } from "lodash"; -import dayjs from "dayjs"; -import { NextRouter } from "next/router"; import { MAX_ALLOWED_RUN_LIMIT } from "./run-limit"; - // Various support functions for storing quota parameters as a query parameter in the browser URL export function encodeRange( - vals: [Date | string | undefined, Date | string | undefined] + vals: [Date | string | undefined, Date | string | undefined], ): string { const [start, end] = vals; if (start == null || end == null) { @@ -75,7 +75,7 @@ const DEDICATED_FIELDS = [ ] as const; function getFormFields( - type: "regular" | "boost" | "dedicated" + type: "regular" | "boost" | "dedicated", ): readonly string[] { switch (type) { case "regular": @@ -87,14 +87,27 @@ function getFormFields( } export const ALL_FIELDS: Set = new Set( - REGULAR_FIELDS.concat(DEDICATED_FIELDS as any).concat(["type" as any]) + REGULAR_FIELDS.concat(DEDICATED_FIELDS as any).concat([ + "type", + "source", + ] 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" + type: "regular" | "boost" | "dedicated", ): void { + if (!allowUrlEncoding) { + return; + } const { query } = router; for (const key in vals) { if (!getFormFields(type).includes(key)) continue; @@ -120,7 +133,7 @@ function decodeValue(val): boolean | number | string | DateRange { function fixNumVal( val: any, - param: { min: number; max: number; dflt: number } + param: { min: number; max: number; dflt: number }, ): number { if (typeof val !== "number") { return param.dflt; @@ -136,7 +149,7 @@ function fixNumVal( */ export function decodeFormValues( router: NextRouter, - type: "regular" | "boost" | "dedicated" + type: "regular" | "boost" | "dedicated", ): { [key: string]: string | number | boolean; } { @@ -146,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/run-limit.tsx b/src/packages/next/components/store/run-limit.tsx index 8af28ecb24e..b1a2409a087 100644 --- a/src/packages/next/components/store/run-limit.tsx +++ b/src/packages/next/components/store/run-limit.tsx @@ -4,78 +4,146 @@ */ import { Divider, Form } from "antd"; +import { useRouter } from "next/router"; + +import { unreachable } from "@cocalc/util/misc"; import A from "components/misc/A"; import IntegerSlider from "components/misc/integer-slider"; +import type { LicenseSource } from "@cocalc/util/upgrades/shopping"; + export const MAX_ALLOWED_RUN_LIMIT = 10000; +interface RunLimitProps { + showExplanations: boolean; + form: any; + onChange: () => void; + disabled?: boolean; + boost?: boolean; + source: LicenseSource; +} + export function RunLimit({ showExplanations, form, onChange, disabled = false, boost = false, -}) { + source, +}: RunLimitProps) { + const router = useRouter(); + function extra() { if (!showExplanations) return; - return ( -
- {boost ? ( -
- It's not necessary to match the run limit of the license you want to - boost! + switch (source) { + case "site-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) + + + . +
+ ); + case "course": + return ( +
+ 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{" "} + router.push("/store/site-license")}> + site license + {" "} + to cover it.
- ) : 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) - - - . -
- ); + ); + + default: + unreachable(source); + } } - return ( - <> - Simultaneous Project Upgrades - - { - form.setFieldsValue({ run_limit }); - onChange(); - }} - /> - - - ); + switch (source) { + case "site-license": + return ( + <> + Simultaneous Project Upgrades + + { + form.setFieldsValue({ run_limit }); + onChange(); + }} + /> + + + ); + + case "course": + return ( + <> + Size of Course + + { + form.setFieldsValue({ run_limit }); + onChange(); + }} + /> + + + ); + + default: + unreachable(source); + } } -export function EditRunLimit({ +function EditRunLimit({ value, onChange, disabled, + source, }: { - value?; - onChange?; - disabled?; + value?: number; + onChange: (run_limit: number) => void; + disabled?: boolean; + source: LicenseSource; }) { return ( ); } diff --git a/src/packages/next/components/store/site-license.tsx b/src/packages/next/components/store/site-license.tsx index 73c462ad8cf..1b1f99f858d 100644 --- a/src/packages/next/components/store/site-license.tsx +++ b/src/packages/next/components/store/site-license.tsx @@ -8,11 +8,14 @@ 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"; +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"; import A from "components/misc/A"; import Loading from "components/share/loading"; @@ -20,14 +23,22 @@ 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"; 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"; @@ -46,9 +57,12 @@ const STYLE: React.CSSProperties = { interface Props { noAccount: boolean; + source: LicenseSource; } -export default function SiteLicense({ noAccount }: Props) { +// 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, source }: Props) { const router = useRouter(); const headerRef = useRef(null); @@ -69,40 +83,67 @@ export default function SiteLicense({ noAccount }: 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" - : "Configure a License"} + : source === "course" + ? "Purchase a License for a Course" + : "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. - - - - 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.{" "} - - -
+ <> + {source === "site-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.{" "} + + +
+ )} + {source === "course" && ( +
+ + Teaching with CoCalc makes your course management effortless. + Students work in their own secure spaces where you can + distribute assignments, track their progress in real-time, and + provide help directly within their work environment. No software + installation required for students – everything runs in the + browser. Used by thousands of instructors since 2013. Learn more + in our{" "} + + instructor guide + + . + +
+ )} + )} offsetHeader} noAccount={noAccount} + source={source} /> ); @@ -111,7 +152,15 @@ export default function SiteLicense({ noAccount }: 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, + source, +}: { + source: LicenseSource; + noAccount: boolean; + showInfoBar: boolean; +}) { const [cost, setCost] = useState(undefined); const [loading, setLoading] = useState(false); const [cartError, setCartError] = useState(""); @@ -122,22 +171,27 @@ function CreateSiteLicense({ showInfoBar = false, noAccount = false }) { const [preset, setPreset] = useState(DEFAULT_PRESET); const [presetAdjusted, setPresetAdjusted] = useState(false); + const [initializing, setInitializing] = useState(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) { @@ -150,9 +204,13 @@ function CreateSiteLicense({ showInfoBar = false, noAccount = false }) { return foundPreset; } - function onLicenseChange() { + function onLicenseChange(skipUrlUpdate = false) { const vals = form.getFieldsValue(true); - encodeFormValues(router, vals, "regular"); + // console.log("form vals=", vals); + // 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(); @@ -160,12 +218,53 @@ function CreateSiteLicense({ showInfoBar = false, noAccount = false }) { 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", ); @@ -174,6 +273,7 @@ function CreateSiteLicense({ showInfoBar = false, noAccount = false }) { } const { id } = router.query; + if (!noAccount && id != null) { // editing something in the shopping cart (async () => { @@ -192,21 +292,103 @@ function CreateSiteLicense({ showInfoBar = false, noAccount = false }) { })(); } 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]); + + // Update the form source field when the source prop changes + useEffect(() => { + form.setFieldValue("source", source); + }, [source]); if (loading) { return ; @@ -220,6 +402,7 @@ function CreateSiteLicense({ showInfoBar = false, noAccount = false }) { cartError={cartError} setCartError={setCartError} noAccount={noAccount} + source={source} /> ); @@ -246,6 +429,10 @@ function CreateSiteLicense({ showInfoBar = false, noAccount = false }) { onValuesChange={onLicenseChange} > {addBox} + {/* Hidden form item to track which page (license or course) created this license */} + + + - {configMode === "expert" ? ( + {configMode === "expert" && source !== "course" ? ( void; disabled?: boolean; showUsage?: boolean; - duration?: "all" | "subscriptions" | "monthly" | "yearly" | "range"; + duration?: Duration; discount?: boolean; extraDuration?: ReactNode; + source: LicenseSource; } function getTimezoneFromDate( @@ -42,42 +48,92 @@ export function UsageAndDuration(props: Props) { onChange, disabled = false, showUsage = true, - duration = "all", discount = true, extraDuration, + source, } = 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 (source) { + case "site-license": + return ( + <> + Will this license be used for academic or commercial purposes? + {ac} + + ); + case "course": + return ac; + default: + unreachable(source); + } + } + + function renderUsageItem() { + switch (source) { + case "site-license": + return ( + + + + Business - for commercial purposes + + + Academic - students, teachers, academic researchers, non-profit + organizations and hobbyists (40% discount) + + {" "} + + ); + case "course": + return <>Academic; + + default: + unreachable(source); + } + } + 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 - } - > - - - Business - for commercial purposes - - Academic - students, teachers, academic researchers, non-profit - organizations and hobbyists (40% discount) - - {" "} - - - ); + + switch (source) { + case "course": + return ( + + + Academic + + ); + case "site-license": + return ( + + {renderUsageItem()} + + ); + default: + unreachable(source); + } } function renderRangeSelector(getFieldValue) { @@ -88,11 +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 end = new Date(start.valueOf() + 1000 * 60 * 60 * 24 * 30); - 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 { @@ -114,14 +179,16 @@ 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 (source) { + case "course": + return <>{tz}; + + case "site-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(source); + } + } + + function renderPeriod() { + const init = + source === "course" + ? "range" + : duration === "range" + ? "range" + : "monthly"; + + switch (source) { + case "course": + return ( + + ); + + case "site-license": + return ( + + + + {renderSubsOptions()} + {renderRangeOption()} + + + + ); + + default: + unreachable(source); + } } function renderDuration() { - const init = duration === "range" ? "range" : "monthly"; return ( <> - - - - {renderSubsOptions()} - {renderRangeOption()} - - - - + {renderPeriod()} {renderRange()} ); diff --git a/src/packages/next/lib/api/schema/licenses/common.ts b/src/packages/next/lib/api/schema/licenses/common.ts index 0d0be87c4c1..1f66a6980eb 100644 --- a/src/packages/next/lib/api/schema/licenses/common.ts +++ b/src/packages/next/lib/api/schema/licenses/common.ts @@ -29,14 +29,14 @@ export const SiteLicenseRunLimitSchema = z export const SiteLicenseQuotaSchema = z.object({ always_running: z .boolean() - .nullish() + .optional() .describe( `Indicates whether the project(s) this license is applied to should be allowed to always be running.`, ), boost: z .boolean() - .nullish() + .optional() .describe( `If \`true\`, this license is a boost license and allows for a project to temporarily boost the amount of resources available to a project by the amount @@ -68,4 +68,10 @@ export const SiteLicenseQuotaSchema = z.object({ "Limits the total memory a project can use. At least 2GB is recommended.", ), user: z.enum(["academic", "business"]).describe("User type."), + source: z + .enum(["site-license", "course"]) + .optional() + .describe( + "Indicates which page (license or course) was used to create this license.", + ), }); diff --git a/src/packages/next/lib/api/schema/shopping/cart/add.ts b/src/packages/next/lib/api/schema/shopping/cart/add.ts index de731928cd5..8735784c4f7 100644 --- a/src/packages/next/lib/api/schema/shopping/cart/add.ts +++ b/src/packages/next/lib/api/schema/shopping/cart/add.ts @@ -1,6 +1,10 @@ import { z } from "../../../framework"; import { FailedAPIOperationSchema, OkAPIOperationSchema } from "../../common"; +import { + ProductDescription, + ProductType, +} from "@cocalc/util/db-schema/shopping-cart-items"; import { ProjectIdSchema } from "../../projects/common"; import { @@ -9,24 +13,21 @@ import { SiteLicenseUptimeSchema, } from "../../licenses/common"; -const LicenseRangeSchema = z - .array(z.string()) - .length(2) - .describe( - `Array of two ISO 8601-formatted timestamps. The first element indicates the start +const LicenseRangeSchema = z.tuple([z.string(), z.string()]).describe( + `Array of two ISO 8601-formatted timestamps. The first element indicates the start date of the license, and the second indicates the end date. Used when the \`period\` field is set to \`range\`.`, - ); +); const LicenseTitleSchema = z .string() .describe("Semantic license title.") - .nullish(); + .optional(); const LicenseDescriptionSchema = z .string() .describe("Semantic license description") - .nullish(); + .optional(); // OpenAPI spec // @@ -76,7 +77,7 @@ export const ShoppingCartAddInputSchema = z prefix: z.string(), postfix: z.string(), charset: z.string(), - expire: z.any(), + expire: z.date(), }) .describe("Used to specify cash voucher."), ]) @@ -97,3 +98,11 @@ export const ShoppingCartAddOutputSchema = z.union([ export type ShoppingCartAddInput = z.infer; export type ShoppingCartAddOutput = z.infer; + +// consistency checks +export const _1: ProductType = {} as NonNullable< + ShoppingCartAddInput["product"] +>; +export const _2: ProductDescription = {} as NonNullable< + ShoppingCartAddInput["description"] +>; 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; diff --git a/src/packages/next/pages/api/v2/shopping/cart/add.ts b/src/packages/next/pages/api/v2/shopping/cart/add.ts index a41c8fbf19e..a00a738829c 100644 --- a/src/packages/next/pages/api/v2/shopping/cart/add.ts +++ b/src/packages/next/pages/api/v2/shopping/cart/add.ts @@ -8,18 +8,19 @@ or - id: to move something back into the cart that was removed */ + import addToCart, { - putBackInCart, buyItAgain, + putBackInCart, } from "@cocalc/server/shopping/cart/add"; +import throttle from "@cocalc/util/api/throttle"; import getAccountId from "lib/account/get-account"; -import getParams from "lib/api/get-params"; import { apiRoute, apiRouteOperation } from "lib/api"; +import getParams from "lib/api/get-params"; import { ShoppingCartAddInputSchema, ShoppingCartAddOutputSchema, } from "lib/api/schema/shopping/cart/add"; -import throttle from "@cocalc/util/api/throttle"; async function handle(req, res) { try { diff --git a/src/packages/next/pages/store/[[...page]].tsx b/src/packages/next/pages/store/[[...page]].tsx index 233bbd43c0a..762faf7cb55 100644 --- a/src/packages/next/pages/store/[[...page]].tsx +++ b/src/packages/next/pages/store/[[...page]].tsx @@ -4,15 +4,16 @@ */ import { Layout } from "antd"; -import Header from "components/landing/header"; +import Error from "next/error"; + +import { capitalize } from "@cocalc/util/misc"; import Footer from "components/landing/footer"; import Head from "components/landing/head"; +import Header from "components/landing/header"; import Store from "components/store"; import { StorePages } from "components/store/types"; import { Customize } from "lib/customize"; import withCustomize from "lib/with-customize"; -import { capitalize } from "@cocalc/util/misc"; -import Error from "next/error"; export default function Preferences({ customize, page, pageNotFound }) { const subpage = page[0] != null ? ` - ${capitalize(page[0])}` : ""; diff --git a/src/packages/next/tsconfig.json b/src/packages/next/tsconfig.json index da863e598fa..80eab8bca7d 100644 --- a/src/packages/next/tsconfig.json +++ b/src/packages/next/tsconfig.json @@ -33,7 +33,16 @@ "locales/*/*.json", "locales/en/common.ts" ], - "exclude": ["node_modules", "public", "styles", "dist"], + "exclude": [ + "node_modules", + "public", + "styles", + "dist", + ".next", + "target", + "locales", + "software-inventory" + ], "references": [ { "path": "../backend" }, { "path": "../database" }, diff --git a/src/packages/server/purchases/edit-license.test.ts b/src/packages/server/purchases/edit-license.test.ts index 1157134bcaf..4e1a9183a7b 100644 --- a/src/packages/server/purchases/edit-license.test.ts +++ b/src/packages/server/purchases/edit-license.test.ts @@ -3,22 +3,23 @@ * License: MS-RSL – see LICENSE.md for details */ -import createLicense from "@cocalc/server/licenses/purchase/create-license"; -import createAccount from "@cocalc/server/accounts/create-account"; -import editLicenseOwner from "./edit-license-owner"; -import editLicense from "./edit-license"; -import getLicense from "@cocalc/server/licenses/get-license"; -import getPurchaseInfo from "@cocalc/util/licenses/purchase/purchase-info"; -import createPurchase from "./create-purchase"; -import { uuid } from "@cocalc/util/misc"; +import dayjs from "dayjs"; + import getPool, { - initEphemeralDatabase, getPoolClient, + initEphemeralDatabase, } from "@cocalc/database/pool"; -import dayjs from "dayjs"; -import purchaseShoppingCartItem from "./purchase-shopping-cart-item"; +import createAccount from "@cocalc/server/accounts/create-account"; +import getLicense from "@cocalc/server/licenses/get-license"; +import createLicense from "@cocalc/server/licenses/purchase/create-license"; +import getPurchaseInfo from "@cocalc/util/licenses/purchase/purchase-info"; import { computeCost } from "@cocalc/util/licenses/store/compute-cost"; +import { uuid } from "@cocalc/util/misc"; +import createPurchase from "./create-purchase"; +import editLicense from "./edit-license"; +import editLicenseOwner from "./edit-license-owner"; import getSubscriptions from "./get-subscriptions"; +import purchaseShoppingCartItem from "./purchase-shopping-cart-item"; import { license0 } from "./test-data"; beforeAll(async () => { diff --git a/src/packages/server/shopping/cart/add.ts b/src/packages/server/shopping/cart/add.ts index eb4718ada3c..9158ff3292c 100644 --- a/src/packages/server/shopping/cart/add.ts +++ b/src/packages/server/shopping/cart/add.ts @@ -15,14 +15,15 @@ any value to a spammer so it's very unlikely to be exploited maliciously. I did add throttling to the api handler. */ -import { isValidUUID } from "@cocalc/util/misc"; +import dayjs from "dayjs"; + import getPool from "@cocalc/database/pool"; import { - ProductType, ProductDescription, + ProductType, } from "@cocalc/util/db-schema/shopping-cart-items"; +import { isValidUUID } from "@cocalc/util/misc"; import { getItem } from "./get"; -import dayjs from "dayjs"; //import { getLogger } from "@cocalc/backend/logger"; //const logger = getLogger("server:shopping:cart:add"); diff --git a/src/packages/util/licenses/purchase/purchase-info.ts b/src/packages/util/licenses/purchase/purchase-info.ts index a22cbfd252d..b5c4c0175ce 100644 --- a/src/packages/util/licenses/purchase/purchase-info.ts +++ b/src/packages/util/licenses/purchase/purchase-info.ts @@ -27,6 +27,7 @@ export default function getPurchaseInfo( disk, member, uptime, + source, boost = false, } = conf; return { @@ -47,6 +48,7 @@ export default function getPurchaseInfo( boost, title, description, + source, }; case "vm": diff --git a/src/packages/util/licenses/purchase/types.ts b/src/packages/util/licenses/purchase/types.ts index 7716eccd7cc..fb419a1fcd0 100644 --- a/src/packages/util/licenses/purchase/types.ts +++ b/src/packages/util/licenses/purchase/types.ts @@ -1,3 +1,16 @@ +/* + * This file is part of CoCalc: Copyright © 2020 Sagemath, Inc. + * License: MS-RSL – see LICENSE.md for details + */ + +import type { Uptime } from "@cocalc/util/consts/site-license"; +import type { DedicatedDisk, DedicatedVM } from "@cocalc/util/types/dedicated"; +import type { + CustomDescription, + LicenseSource, + Period, +} from "@cocalc/util/upgrades/shopping"; + export type User = "academic" | "business"; export type Upgrade = "basic" | "standard" | "max" | "custom"; export type Subscription = "no" | "monthly" | "yearly"; @@ -45,15 +58,6 @@ export interface StartEndDatesWithStrings { end: Date | string; } -/* - * This file is part of CoCalc: Copyright © 2020 Sagemath, Inc. - * License: MS-RSL – see LICENSE.md for details - */ - -import type { Uptime } from "@cocalc/util/consts/site-license"; -import type { DedicatedDisk, DedicatedVM } from "@cocalc/util/types/dedicated"; -import type { CustomDescription, Period } from "../../upgrades/shopping"; - interface Version { version: string; // it's just a string with no special interpretation. } @@ -81,9 +85,12 @@ interface PurchaseInfoQuota0 { run_limit?: number; } +type PurchseInfoSource = { source?: LicenseSource }; + export type PurchaseInfoQuota = PurchaseInfoQuota0 & CustomDescription & - StartEndDates; + StartEndDates & + PurchseInfoSource; export type PurchaseInfoVoucher = { type: "vouchers"; 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, diff --git a/src/packages/util/upgrades/shopping.ts b/src/packages/util/upgrades/shopping.ts index 03e18fc30dc..b70df927bb6 100644 --- a/src/packages/util/upgrades/shopping.ts +++ b/src/packages/util/upgrades/shopping.ts @@ -26,8 +26,12 @@ export type VMCostProps = { dedicated_vm: DedicatedVM; }; +// the store's source page from where a site-license has been created +export type LicenseSource = "site-license" | "course"; + export type QuotaCostProps = { type: "quota"; + source?: LicenseSource; user: User; run_limit: number; period: Period;