diff --git a/src/packages/next/components/store/add-box.tsx b/src/packages/next/components/store/add-box.tsx
index 53bcd8f807..2cd31bcd38 100644
--- a/src/packages/next/components/store/add-box.tsx
+++ b/src/packages/next/components/store/add-box.tsx
@@ -15,6 +15,7 @@ import { addToCart } from "./add-to-cart";
import { DisplayCost } from "./site-license-cost";
import { periodicCost } from "@cocalc/util/licenses/purchase/compute-cost";
import { decimalDivide } from "@cocalc/util/stripe/calc";
+import { LicenseType } from "./types";
export const ADD_STYLE = {
display: "inline-block",
@@ -37,6 +38,7 @@ interface Props {
dedicatedItem?: boolean;
disabled?: boolean;
noAccount: boolean;
+ type: LicenseType;
}
export function AddBox({
@@ -48,6 +50,7 @@ export function AddBox({
dedicatedItem = false,
noAccount,
disabled = false,
+ type,
}: Props) {
if (cost?.input.type == "cash-voucher") {
return null;
@@ -76,7 +79,8 @@ export function AddBox({
}}
message={
<>
- {money(round2up(costPer))} per project{" "}
+ {money(round2up(costPer))}{" "}
+ per {type === "course" ? "student" : "project"}{" "}
{!!cost.period && cost.period != "range" ? cost.period : ""}
>
}
@@ -175,8 +179,8 @@ export function AddToCartButton({
{clicked
? "Moving to Cart..."
: router.query.id != null
- ? "Save Changes"
- : "Add to Cart"}
+ ? "Save Changes"
+ : "Add to Cart"}
{clicked && }
);
diff --git a/src/packages/next/components/store/index.tsx b/src/packages/next/components/store/index.tsx
index 264d6da4e3..66bf23b55a 100644
--- a/src/packages/next/components/store/index.tsx
+++ b/src/packages/next/components/store/index.tsx
@@ -5,6 +5,7 @@
import { Alert, Layout } from "antd";
import { useRouter } from "next/router";
import { useEffect, useState, type JSX } from "react";
+
import * as purchasesApi from "@cocalc/frontend/purchases/api";
import { COLORS } from "@cocalc/util/theme";
import Anonymous from "components/misc/anonymous";
@@ -16,28 +17,19 @@ import useProfile from "lib/hooks/profile";
import useCustomize from "lib/use-customize";
import Cart from "./cart";
import Checkout from "./checkout";
-import Processing from "./processing";
import Congrats from "./congrats";
import Menu from "./menu";
import Overview from "./overview";
+import Processing from "./processing";
import SiteLicense from "./site-license";
import { StoreInplaceSignInOrUp } from "./store-inplace-signup";
+import { StorePagesTypes } from "./types";
import Vouchers from "./vouchers";
const { Content } = Layout;
interface Props {
- page: (
- | "site-license"
- | "boost"
- | "dedicated"
- | "cart"
- | "checkout"
- | "processing"
- | "congrats"
- | "vouchers"
- | undefined
- )[];
+ page: (StorePagesTypes | undefined)[];
}
export default function StoreLayout({ page }: Props) {
@@ -131,7 +123,9 @@ export default function StoreLayout({ page }: Props) {
switch (main) {
case "site-license":
- return ;
+ return ;
+ case "course":
+ return ;
case "cart":
return requireAccount(Cart);
case "checkout":
diff --git a/src/packages/next/components/store/menu.tsx b/src/packages/next/components/store/menu.tsx
index 294e7dade6..c6a9cba1c5 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 f3cf3c8bb6..a4ce1b7685 100644
--- a/src/packages/next/components/store/overview.tsx
+++ b/src/packages/next/components/store/overview.tsx
@@ -47,14 +47,17 @@ export default function Overview() {
) : undefined}
-
+
Buy a license to upgrade projects, get internet access, more CPU, disk
and memory.
-
- Purchase a voucher code to make {" "}
- credit easily available to somebody else.
+
+ Purchase a license for teaching a course.
+
+ Purchase a voucher code{" "}
+ to make credit easily available to somebody else.
+ shopping cart or go straight to{" "}
checkout.
-
+
You can also browse your{" "}
purchase history,{" "}
licenses, and{" "}
diff --git a/src/packages/next/components/store/run-limit.tsx b/src/packages/next/components/store/run-limit.tsx
index 8af28ecb24..571789c601 100644
--- a/src/packages/next/components/store/run-limit.tsx
+++ b/src/packages/next/components/store/run-limit.tsx
@@ -4,78 +4,136 @@
*/
import { Divider, Form } from "antd";
+
import A from "components/misc/A";
import IntegerSlider from "components/misc/integer-slider";
+import { LicenseType } from "./types";
+import { unreachable } from "@cocalc/util/misc";
export const MAX_ALLOWED_RUN_LIMIT = 10000;
+interface RunLimitProps {
+ showExplanations: boolean;
+ form: any;
+ onChange: () => void;
+ disabled?: boolean;
+ boost?: boolean;
+ type: LicenseType;
+}
+
export function RunLimit({
showExplanations,
form,
onChange,
disabled = false,
boost = false,
-}) {
+ type,
+}: RunLimitProps) {
function extra() {
if (!showExplanations) return;
- return (
-
- {boost ? (
-
- It's not necessary to match the run limit of the license you want to
- boost!
+ switch (type) {
+ case "license":
+ return (
+
+ {boost ? (
+
+ It's not necessary to match the run limit of the license you
+ want to boost!
+
+ ) : undefined}
+ Simultaneously run this many projects using this license. You, and
+ anyone you share the license code with, can apply the license to an
+ unlimited number of projects, but it will only be used up to the run
+ limit. When{" "}
+
+ teaching a course
+
+ ,{" "}
+
+
+ the run limit is typically 2 more than the number of students
+ (one for each student, one for the shared project and one for
+ the instructor project)
+
+
+ .
- ) : undefined}
- Simultaneously run this many projects using this license. You, and
- anyone you share the license code with, can apply the license to an
- unlimited number of projects, but it will only be used up to the run
- limit. When{" "}
-
- teaching a course
-
- ,{" "}
-
-
- the run limit is typically 2 more than the number of students (one
- for each student, one for the shared project and one for the
+ );
+ case "course":
+ return (
+
+ It's advised to select two more seatch than the number of students
+ (one for each student, one for the shared project and one for the
instructor project)
-
-
- .
-
- );
+
+ );
+
+ default:
+ unreachable(type);
+ }
}
- return (
- <>
- Simultaneous Project Upgrades
-
- {
- form.setFieldsValue({ run_limit });
- onChange();
- }}
- />
-
- >
- );
+ switch (type) {
+ case "license":
+ return (
+ <>
+ Simultaneous Project Upgrades
+
+ {
+ form.setFieldsValue({ run_limit });
+ onChange();
+ }}
+ />
+
+ >
+ );
+
+ case "course":
+ return (
+ <>
+ Size of Course
+
+ {
+ form.setFieldsValue({ run_limit });
+ onChange();
+ }}
+ />
+
+ >
+ );
+
+ default:
+ unreachable(type);
+ }
}
-export function EditRunLimit({
+function EditRunLimit({
value,
onChange,
disabled,
+ type,
}: {
- value?;
- onChange?;
- disabled?;
+ value?: number;
+ onChange: (run_limit: number) => void;
+ disabled?: boolean;
+ type: LicenseType;
}) {
return (
);
}
diff --git a/src/packages/next/components/store/site-license.tsx b/src/packages/next/components/store/site-license.tsx
index 73c462ad8c..0e1e071e95 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";
@@ -32,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";
@@ -46,9 +48,12 @@ const STYLE: React.CSSProperties = {
interface Props {
noAccount: boolean;
+ type: LicenseType;
}
-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, type }: Props) {
const router = useRouter();
const headerRef = useRef(null);
@@ -72,37 +77,61 @@ export default function SiteLicense({ noAccount }: 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 && (
-
-
-
- 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" && (
+
+
+ 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
+
+ .
+
+
+ )}
+ >
)}
offsetHeader}
noAccount={noAccount}
+ type={type}
/>
>
);
@@ -111,7 +140,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,
+ type,
+}: {
+ type: LicenseType;
+ noAccount: boolean;
+ showInfoBar: boolean;
+}) {
const [cost, setCost] = useState(undefined);
const [loading, setLoading] = useState(false);
const [cartError, setCartError] = useState("");
@@ -152,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));
@@ -220,6 +258,7 @@ function CreateSiteLicense({ showInfoBar = false, noAccount = false }) {
cartError={cartError}
setCartError={setCartError}
noAccount={noAccount}
+ type={type}
/>
);
@@ -258,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/next/lib/styles/layouts.tsx b/src/packages/next/lib/styles/layouts.tsx
index 7136bc46f9..b96828c52e 100644
--- a/src/packages/next/lib/styles/layouts.tsx
+++ b/src/packages/next/lib/styles/layouts.tsx
@@ -5,7 +5,7 @@
import { Col, Row } from "antd";
-import { Icon } from "@cocalc/frontend/components/icon";
+import { Icon, IconName } from "@cocalc/frontend/components/icon";
import { COLORS } from "@cocalc/util/theme";
import { CSS, Paragraph, Title } from "components/misc";
import A from "components/misc/A";
@@ -48,8 +48,8 @@ export function Product({
children,
external,
}: {
- icon;
- icon2?;
+ icon: IconName;
+ icon2?: IconName;
title;
href;
children;
diff --git a/src/packages/util/licenses/store/compute-cost.ts b/src/packages/util/licenses/store/compute-cost.ts
index dafa2dcbb0..6506cbe79b 100644
--- a/src/packages/util/licenses/store/compute-cost.ts
+++ b/src/packages/util/licenses/store/compute-cost.ts
@@ -76,6 +76,10 @@ export function computeCost(
return undefined;
}
+ if (run_limit == null) {
+ return undefined;
+ }
+
const input: PurchaseInfo = {
version: CURRENT_VERSION,
type: "quota",
@@ -105,6 +109,7 @@ export function computeCost(
? fixRange(range, period, noRangeShift)
: { start: null, end: null }),
};
+
return {
...compute_cost(input),
input,