Skip to content

next/store: "course" purchases #8437

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Jul 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
0cdad26
next/store: basic scaffolding for a "course" purchase
haraldschilly Jul 11, 2025
cc33693
next/store: dedicaed course license purchase page in the store
haraldschilly Jul 11, 2025
0d48bfc
Merge remote-tracking branch 'origin/master' into store-purchase-course
haraldschilly Jul 14, 2025
b14951d
next/store/course: scaffolding for simple option selector configuration
haraldschilly Jul 14, 2025
ffacd41
Merge remote-tracking branch 'origin/master' into store-purchase-course
haraldschilly Jul 15, 2025
6f1a555
Merge remote-tracking branch 'origin/master' into store-purchase-course
haraldschilly Jul 16, 2025
3a8af73
next/store/course license: set academic user value properly, adjust p…
haraldschilly Jul 16, 2025
e730f95
next/store/course: rename type to source, to be later able to distinq…
haraldschilly Jul 16, 2025
353aecd
Merge branch 'master' into store-purchase-course
haraldschilly Jul 17, 2025
8eed770
Merge remote-tracking branch 'origin/master' into store-purchase-course
haraldschilly Jul 21, 2025
022163d
store/course: record "source"
haraldschilly Jul 21, 2025
18e669d
Merge remote-tracking branch 'origin/master' into store-purchase-course
haraldschilly Jul 22, 2025
b00436f
next/store: debug and fix URL value parsing for site-license and cour…
haraldschilly Jul 22, 2025
bf046b1
Merge remote-tracking branch 'origin/master' into store-purchase-course
haraldschilly Jul 23, 2025
8d0dba9
next/store/course: tweak number of students explanation
haraldschilly Jul 23, 2025
cd4c739
next/store/course: fix form state when switching tabs
haraldschilly Jul 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
24 changes: 13 additions & 11 deletions src/.claude/settings.local.json → src/.claude/settings.json
Original file line number Diff line number Diff line change
@@ -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": []
}
}
}
8 changes: 4 additions & 4 deletions src/packages/frontend/components/help-icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -34,7 +34,7 @@ export const HelpIcon: React.FC<Props> = ({
}: Props) => {
const [open, setOpen] = useState<boolean>(false);

const textStyle: CSS = {
const textStyle: CSSProperties = {
color: COLORS.BS_BLUE_TEXT,
cursor: "pointer",
...style,
Expand Down Expand Up @@ -68,8 +68,8 @@ export const HelpIcon: React.FC<Props> = ({
onOpenChange={setOpen}
>
<span style={textStyle}>
{extra ? <>{extra} </> : undefined}
<Icon style={textStyle} name="question-circle" />
{extra ? <> {extra}</> : undefined}
</span>
</Popover>
);
Expand Down
14 changes: 10 additions & 4 deletions src/packages/next/components/store/add-box.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -37,6 +40,7 @@ interface Props {
dedicatedItem?: boolean;
disabled?: boolean;
noAccount: boolean;
source: LicenseSource;
}

export function AddBox({
Expand All @@ -48,6 +52,7 @@ export function AddBox({
dedicatedItem = false,
noAccount,
disabled = false,
source,
}: Props) {
if (cost?.input.type == "cash-voucher") {
return null;
Expand Down Expand Up @@ -76,7 +81,8 @@ export function AddBox({
}}
message={
<>
{money(round2up(costPer))} <b>per project</b>{" "}
{money(round2up(costPer))}{" "}
<b>per {source === "course" ? "student" : "project"}</b>{" "}
{!!cost.period && cost.period != "range" ? cost.period : ""}
</>
}
Expand Down Expand Up @@ -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 && <Spin style={{ marginLeft: "15px" }} />}
</Button>
);
Expand Down
44 changes: 25 additions & 19 deletions src/packages/next/components/store/cart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 <DescriptionColumnSiteLicense {...props} />;
} else if (description.type == "cash-voucher") {
Expand Down Expand Up @@ -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");
Expand All @@ -403,7 +404,7 @@ function DescriptionColumnSiteLicense(props: DCProps) {
return <pre>{JSON.stringify(description, undefined, 2)}</pre>;
}
const { input } = cost;
if (input.type == "cash-voucher") {
if (input.type === "cash-voucher") {
throw Error("incorrect typing");
}

Expand All @@ -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 (
<div>
<div>{describeQuotaFromInfo(input)}</div>
Expand All @@ -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";
}
Expand All @@ -451,7 +457,7 @@ function DescriptionColumnSiteLicense(props: DCProps) {
<div style={DESCRIPTION_STYLE}>
<div style={{ marginBottom: "8px" }}>
<b>
{input.subscription == "no"
{input.subscription === "no"
? describePeriod({ quota: input })
: capitalize(input.subscription) + " subscription"}
</b>
Expand Down
20 changes: 7 additions & 13 deletions src/packages/next/components/store/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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) {
Expand Down Expand Up @@ -131,7 +123,9 @@ export default function StoreLayout({ page }: Props) {

switch (main) {
case "site-license":
return <SiteLicense noAccount={noAccount} />;
return <SiteLicense noAccount={noAccount} source="site-license" />;
case "course":
return <SiteLicense noAccount={noAccount} source="course" />;
case "cart":
return requireAccount(Cart);
case "checkout":
Expand Down
16 changes: 10 additions & 6 deletions src/packages/next/components/store/menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,29 @@
* 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<MenuProps>["items"][number];

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: {
Expand All @@ -38,7 +41,7 @@ const styles: { [k: string]: React.CSSProperties } = {
maxWidth: "100%",
flexGrow: 1,
},
};
} as const;

export interface ConfigMenuProps {
main?: string;
Expand All @@ -64,6 +67,7 @@ export default function ConfigMenu({ main }: ConfigMenuProps) {
key: "site-license",
icon: <Icon name="key" />,
},
{ label: "Course", key: "course", icon: <Icon name="graduation-cap" /> },
{
label: "Vouchers",
key: "vouchers",
Expand Down
13 changes: 8 additions & 5 deletions src/packages/next/components/store/overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,17 @@ export default function Overview() {
</Paragraph>
) : undefined}
<OverviewRow>
<Product icon="key" title="Licenses" href="/store/site-license">
<Product icon="key" title="License" href="/store/site-license">
Buy a license to upgrade projects, get internet access, more CPU, disk
and memory.
</Product>
<Product href={"/store/vouchers"} icon="gift" title="Vouchers">
Purchase a <A href={"/vouchers"}>voucher code</A> to make <SiteName />{" "}
credit easily available to somebody else.
<Product icon="graduation-cap" title="Course" href="/store/course">
Purchase a license for teaching a course.
</Product>
<Paragraph style={{ textAlign: "center", width: "100%" }}>
<Icon name="gift" /> Purchase a <A href={"/vouchers"}>voucher code</A>{" "}
to make <SiteName /> credit easily available to somebody else.
</Paragraph>
<Divider />
<Product
href={"/features/compute-server"}
Expand All @@ -74,7 +77,7 @@ export default function Overview() {
<A href="/store/cart">shopping cart</A> or go straight to{" "}
<A href="/store/checkout">checkout</A>.
</Paragraph>
<Paragraph>
<Paragraph style={{ marginBottom: "4em" }}>
You can also browse your{" "}
<A href="/settings/purchases">purchase history</A>,{" "}
<A href="/settings/licenses">licenses</A>, and{" "}
Expand Down
Loading