diff --git a/src/packages/next/components/landing/pricing-item.tsx b/src/packages/next/components/landing/pricing-item.tsx index 9873512357..f9ea227ddb 100644 --- a/src/packages/next/components/landing/pricing-item.tsx +++ b/src/packages/next/components/landing/pricing-item.tsx @@ -8,19 +8,44 @@ import { ReactNode } from "react"; import { Icon, IconName } from "@cocalc/frontend/components/icon"; import { COLORS } from "@cocalc/util/theme"; +import { CSS } from "components/misc"; + +import styles from "./pricing.module.css"; interface Props { children: ReactNode; icon: IconName; title: string; + style?: CSS; + active?: boolean; + onClick?: () => void; } -export default function PricingItem({ icon, children, title }: Props) { +const ACTIVE_STYLE: CSS = { + outline: `2px solid ${COLORS.BLUE_D}`, +} as const; + +export default function PricingItem({ + icon, + children, + title, + style, + active, + onClick, +}: Props) { + const outerStyle: CSS = { + padding: 0, + ...style, + } as const; + const activeStyle: CSS = active === true ? ACTIVE_STYLE : {}; + const innerStyle: CSS = { color: COLORS.GRAY_M, ...activeStyle }; + return ( - + @@ -44,10 +69,11 @@ const STYLE: React.CSSProperties = { interface Line { amount?: string | number | ReactNode; desc?: string | ReactNode; + indent?: boolean; } export function Line(props: Line) { - const { amount, desc } = props; + const { amount, desc, indent = true } = props; if (!amount) return (
@@ -67,14 +93,13 @@ export function Line(props: Line) { unit = "x"; } } + + const indentStyle: CSS = indent ? { width: "3em", textAlign: "right" } : {}; + return (
-
- {amount} -
{" "} +
{amount}
{" "} {unit}
{" "} {desc} diff --git a/src/packages/next/components/landing/pricing.module.css b/src/packages/next/components/landing/pricing.module.css new file mode 100644 index 0000000000..5501ed3090 --- /dev/null +++ b/src/packages/next/components/landing/pricing.module.css @@ -0,0 +1,10 @@ +.item { + cursor: pointer; + position: relative; + transition: 0.1s; + top: 0; +} + +.item:hover { + top: -5px; +} diff --git a/src/packages/next/components/store/quota-config-presets.tsx b/src/packages/next/components/store/quota-config-presets.tsx index 06ef3887d4..839e942946 100644 --- a/src/packages/next/components/store/quota-config-presets.tsx +++ b/src/packages/next/components/store/quota-config-presets.tsx @@ -5,10 +5,11 @@ import { IconName } from "@cocalc/frontend/components/icon"; import { Uptime } from "@cocalc/util/consts/site-license"; +import { Paragraph } from "components/misc"; import A from "components/misc/A"; import { ReactNode } from "react"; -export type Presets = "standard" | "instructor" | "research"; +export type Preset = "standard" | "instructor" | "research"; // Fields to be used to match a configured license against a pre-existing preset. // @@ -20,20 +21,22 @@ export const PRESET_MATCH_FIELDS: Record = { member: "member hosting", }; -export interface Preset { - icon?: IconName; +export interface PresetConfig { + icon: IconName; name: string; descr: ReactNode; - details?: ReactNode; + details: ReactNode; cpu: number; ram: number; disk: number; uptime: Uptime; member: boolean; + expect: string[]; + note?: ReactNode; } type PresetEntries = { - [key in Presets]: Preset; + [key in Preset]: PresetConfig; }; // some constants to keep text and preset in sync @@ -41,11 +44,28 @@ const STANDARD_CPU = 1; const STANDARD_RAM = 4; const STANDARD_DISK = 3; +const PRESET_STANDARD_NAME = "Standard"; + export const PRESETS: PresetEntries = { standard: { icon: "line-chart", - name: "Standard", - descr: "is a good choice for most users and students to get started", + name: PRESET_STANDARD_NAME, + descr: + "is a good choice for most users to get started and students in a course", + expect: [ + "Run 2 or 3 Jupyter Notebooks at the same time,", + "Edit LaTeX, Markdown, and R Documents,", + `${STANDARD_DISK} GB disk space is sufficient to store many files and small datasets.`, + ], + note: ( + + You can start small with just a "Run Limit" of one and small quotas. + Later, if your usage incrases, you can edit your license to change the + "Run Limit" and/or the quotas. Read more about{" "} + Managing Licenses{" "} + in our documentation. + + ), details: ( <> You can run two or three Jupyter Notebooks in the same project at the @@ -64,8 +84,45 @@ export const PRESETS: PresetEntries = { instructor: { icon: "slides", name: "Instructor", - descr: - "is a good choice for the instructor's project when teaching a course", + descr: "for your instructor project when teaching a course", + expect: [ + "Grade the work of students,", + "Run several Jupyter Notebooks at the same time¹,", + "Store the files of all students,", + "Make longer breaks without your project being shut down.", + ], + note: ( + <> + + For your instructor project, you only need one such license with a + "Run Limit" of 1. Apply that license via the{" "} + + project settings + + . For the students, select a "{PRESET_STANDARD_NAME}" license with a + "Run Limit" of the number of students and distribute it via the{" "} + + course configuration + + . + + + ¹ Still, make sure to use the{" "} + + Halt button + + . + + + ), details: ( <> The upgrade schema is suitable for grading the work of students: by @@ -99,7 +156,29 @@ export const PRESETS: PresetEntries = { research: { icon: "users", name: "Researcher", - descr: "is a good choice for a research group", + descr: "is a good choice for intesse usage or a research group", + expect: [ + "Run many Jupyter Notebooks at once,", + "Run memory-intensive computations,", + "1 day idle-timeout is sufficient to not interrupt your work,", + "and to execute long-running calculations.", + "More disk space also allows you to store larger datasets.", + ], + note: ( + <> + + If you need vastly more dedicated disk space, CPU or RAM, you + should instead{" "} + + rent a{" "} + + compute server + + . + + + + ), details: ( <> This configuration allows the project to run many Jupyter Notebooks and diff --git a/src/packages/next/components/store/quota-config.tsx b/src/packages/next/components/store/quota-config.tsx index 63afc09a15..a2d4e7cd09 100644 --- a/src/packages/next/components/store/quota-config.tsx +++ b/src/packages/next/components/store/quota-config.tsx @@ -5,8 +5,10 @@ import { Alert, + Button, Col, Divider, + Flex, Form, Radio, Row, @@ -14,19 +16,21 @@ import { Tabs, Typography, } from "antd"; +import { useEffect, useRef, useState } 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 { CSS } from "components/misc"; +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, PRESET_MATCH_FIELDS, Preset, - Presets, + PresetConfig, } from "./quota-config-presets"; const { Text } = Typography; @@ -50,8 +54,8 @@ interface Props { // boost doesn't define any of the below, that's only for site-license configMode?: "preset" | "expert"; setConfigMode?: (mode: "preset" | "expert") => void; - preset?: Presets | null; - setPreset?: (preset: Presets | null) => void; + preset?: Preset | null; + setPreset?: (preset: Preset | null) => void; presetAdjusted?: boolean; setPresetAdjusted?: (adjusted: boolean) => void; } @@ -71,6 +75,32 @@ export const QuotaConfig: React.FC = (props: Props) => { setPresetAdjusted, } = props; + const presetsRef = useRef(null); + const [isClient, setIsClient] = useState(false); + const [narrow, setNarrow] = useState(false); + + useEffect(() => { + setIsClient(true); + }, []); + + useEffect(() => { + const observer = new ResizeObserver((entries) => { + if (isClient && entries[0].contentRect.width < 600) { + setNarrow(true); + } else { + setNarrow(false); + } + }); + + if (presetsRef.current) { + observer.observe(presetsRef.current); + } + + return () => { + observer.disconnect(); + }; + }, [presetsRef.current]); + const ramVal = Form.useWatch("ram", form); const cpuVal = Form.useWatch("cpu", form); @@ -285,13 +315,14 @@ export const QuotaConfig: React.FC = (props: Props) => { ); } - function infoText() { - if (preset == null) { + function presetIsAdjusted() { + if (preset == null) return; + const presetData: PresetConfig = PRESETS[preset]; + if (presetData == null) { return ( - - Currently, no preset selection is active. Select a preset above to - reset your recent changes. - +
+ Error: preset {preset} is not known. +
); } @@ -305,16 +336,6 @@ export const QuotaConfig: React.FC = (props: Props) => { return; } - const presetData: Preset = PRESETS[preset]; - if (presetData == null) { - return ( -
- Error: preset {preset} is not known. -
- ); - } - const { name, descr, details } = presetData; - const presetDiff = Object.keys(PRESET_MATCH_FIELDS).reduce( (diff, presetField) => { if (presetData[presetField] !== quotaConfig[presetField]) { @@ -326,109 +347,187 @@ export const QuotaConfig: React.FC = (props: Props) => { [] as string[], ); - function presetDescription() { - if (!descr) { - return ""; - } else { - return <>{descr}. It; - } - } - - function renderDetails() { - if (details) { - return details; - } - } - - function renderProvides() { - if (preset) { - const { cpu, disk, ram, uptime, member } = PRESETS[preset]; - - const basic = ( + if (!presetAdjusted || !presetDiff.length) return; + return ( + - provides up to{" "} - - {cpu} {plural(cpu, "vCPU")} - - , {ram} GB memory, and{" "} - {disk} GB disk space for each project. + The currently configured license differs from the selected preset in{" "} + {listFormat.format(presetDiff)}. By clicking any of + the presets below, you reconfigure your license configuration to + match the original preset. - ); - - const mh = - member === false ? ( - member hosting is disabled - ) : null; - - const ut = - uptime && uptime !== "short" ? ( - <> - {mh != null ? " and" : ""} the project's{" "} - idle timeout is {displaySiteLicense(uptime)} - - ) : null; - - const any = mh != null || ut != null; + } + /> + ); + } - return ( + function presetsCommon() { + if (!showExplanations) return null; + return ( + + {preset == null ? ( + <>After selecting a preset, feel free to + ) : ( <> - {basic} {any ? "Additionally, " : ""} - {mh} - {ut} - {any ? "." : ""} + Selected preset "{PRESETS[preset]?.name}". You can - ); - } - } + )}{" "} + fine tune the selection in the "{EXPERT_CONFIG}" tab. Subsequent preset + selections will reset your adjustments. + + ); + } - function presetIsAdjusted() { - if (!presetAdjusted || !presetDiff.length) return; - return ( - - - The currently configured license differs from the selected preset in - {listFormat.format(presetDiff)}. By clicking any of the - above buttons, you can ensure your license configuration matches the - original preset configuration. + function renderNoPresetWarning() { + if (preset != null) return; + return ( + + Currently, no preset selection is active. Select a preset above to reset + your recent changes. + + ); + } + + function renderPresetsNarrow() { + const p = preset != null ? PRESETS[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 ( <> - {presetIsAdjusted()} - - Preset "{name}" {presetDescription()}{" "} - {renderProvides()} {renderDetails()} - + + onPresetChange(e.target.value)} + > + + {(Object.keys(PRESETS) as Array).map((p) => { + const { name, icon, descr } = PRESETS[p]; + return ( + + + {" "} + {name}: {descr} + + + ); + })} + + + + {presetInfo} ); } - function presetsCommon() { - if (!showExplanations) return null; + 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} + + + +
+ ); + }); return ( - - After selecting a preset, feel free to fine tune the selection in the " - {EXPERT_CONFIG}" tab. Subsequent preset selections will reset your - adjustments. - + + {panels} + ); } function presetExtra() { return ( - -
-
{infoText()}
+ +
+ {presetIsAdjusted()} + {renderPresetPanels()} + {renderNoPresetWarning()} +
{presetsCommon()}
); } - function onPresetChange(newVal) { - const val = newVal.target.value; + function onPresetChange(val: Preset) { if (val == null || setPreset == null) return; setPreset(val); setPresetAdjusted?.(false); @@ -440,31 +539,6 @@ export const QuotaConfig: React.FC = (props: Props) => { onChange(); } - function presets() { - return ( - <> - - - - {Object.keys(PRESETS).map((p) => { - const presetData = PRESETS[p]; - return ( - - - {presetData.name} - - ); - })} - - - - - ); - } - function detailed() { return ( <> @@ -510,7 +584,7 @@ export const QuotaConfig: React.FC = (props: Props) => { Presets ), - children: presets(), + children: presetExtra(), }, { key: "expert", diff --git a/src/packages/next/components/store/site-license.tsx b/src/packages/next/components/store/site-license.tsx index e36708c7c0..b0cc033027 100644 --- a/src/packages/next/components/store/site-license.tsx +++ b/src/packages/next/components/store/site-license.tsx @@ -9,6 +9,7 @@ Create a new site license. import { Form, Input } from "antd"; import { isEmpty } from "lodash"; 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"; @@ -26,7 +27,7 @@ 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, Presets } from "./quota-config-presets"; +import { PRESETS, PRESET_MATCH_FIELDS, Preset } from "./quota-config-presets"; import { decodeFormValues, encodeFormValues } from "./quota-query-params"; import { Reset } from "./reset"; import { RunLimit } from "./run-limit"; @@ -35,7 +36,7 @@ import { TitleDescription } from "./title-description"; import { ToggleExplanations } from "./toggle-explanations"; import { UsageAndDuration } from "./usage-and-duration"; -const DEFAULT_PRESET: Presets = "standard"; +const DEFAULT_PRESET: Preset = "standard"; const STYLE: React.CSSProperties = { marginTop: "15px", @@ -125,7 +126,7 @@ function CreateSiteLicense({ showInfoBar = false, noAccount = false }) { const [form] = Form.useForm(); const router = useRouter(); - const [preset, setPreset] = useState(DEFAULT_PRESET); + const [preset, setPreset] = useState(DEFAULT_PRESET); const [presetAdjusted, setPresetAdjusted] = useState(false); /** @@ -136,19 +137,20 @@ function CreateSiteLicense({ showInfoBar = false, noAccount = false }) { const currentConfiguration = form.getFieldsValue( Object.keys(PRESET_MATCH_FIELDS), ); - let foundPreset: Presets | undefined; + + let foundPreset: Preset | undefined; Object.keys(PRESETS).some((p) => { - const presetMismatch = Object.keys(PRESET_MATCH_FIELDS).some( + const presetMatches = Object.keys(PRESET_MATCH_FIELDS).every( (formField) => - !(PRESETS[p][formField] === currentConfiguration[formField]), + PRESETS[p][formField] === currentConfiguration[formField], ); - if (!presetMismatch) { - foundPreset = p as Presets; + if (presetMatches) { + foundPreset = p as Preset; } - return !presetMismatch; + return presetMatches; }); return foundPreset; @@ -244,8 +246,8 @@ function CreateSiteLicense({ showInfoBar = false, noAccount = false }) { form={form} style={STYLE} name="basic" - labelCol={{ span: 6 }} - wrapperCol={{ span: 18 }} + labelCol={{ span: 3 }} + wrapperCol={{ span: 21 }} autoComplete="off" onValuesChange={onLicenseChange} > @@ -279,13 +281,13 @@ function CreateSiteLicense({ showInfoBar = false, noAccount = false }) { setPreset={setPreset} presetAdjusted={presetAdjusted} /> - {configMode === "expert" && ( + {configMode === "expert" ? ( - )} + ) : undefined} diff --git a/src/packages/next/lib/api/schema/licenses/common.ts b/src/packages/next/lib/api/schema/licenses/common.ts index bb2bc9a13a..0d0be87c4c 100644 --- a/src/packages/next/lib/api/schema/licenses/common.ts +++ b/src/packages/next/lib/api/schema/licenses/common.ts @@ -16,8 +16,8 @@ export const SiteLicenseIdleTimeoutSchema = z export const SiteLicenseUptimeSchema = z .union([SiteLicenseIdleTimeoutSchema, z.literal("always_running")]) .describe( - `Determines how long a project runs while not being used before being automatically - stopped. A \`short\` value corresponds to a 30-minute timeout, and a \`medium\` value + `Determines how long a project runs while not being used before being automatically + stopped. A \`short\` value corresponds to a 30-minute timeout, and a \`medium\` value to a 2-hour timeout.`, ); @@ -31,7 +31,7 @@ export const SiteLicenseQuotaSchema = z.object({ .boolean() .nullish() .describe( - `Indicates whether the project(s) this license is applied to should be + `Indicates whether the project(s) this license is applied to should be allowed to always be running.`, ), boost: z @@ -39,26 +39,26 @@ export const SiteLicenseQuotaSchema = z.object({ .nullish() .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 + temporarily boost the amount of resources available to a project by the amount specified in the \`cpu\`, \`memory\`, and \`disk\` fields.`, ), cpu: z .number() .min(1) .describe("Limits the total number of vCPUs allocated to a project."), - dedicated_cpu: z.number().min(1), - dedicated_ram: z.number().min(1), + dedicated_cpu: z.number().min(1).nullish(), + dedicated_ram: z.number().min(1).nullish(), disk: z .number() .min(1) .describe( - `Disk size in GB to be allocated to the project to which this license is + `Disk size in GB to be allocated to the project to which this license is applied.`, ), - idle_timeout: SiteLicenseIdleTimeoutSchema, + idle_timeout: SiteLicenseIdleTimeoutSchema.nullish(), member: z.boolean().describe( - `Member hosting significantly reduces competition for resources, and we - prioritize support requests much higher. _Please be aware: licenses of + `Member hosting significantly reduces competition for resources, and we + prioritize support requests much higher. _Please be aware: licenses of different member hosting service levels cannot be combined!_`, ), ram: z diff --git a/src/packages/next/pages/pricing/subscriptions.tsx b/src/packages/next/pages/pricing/subscriptions.tsx index 4fdea1d55d..5a9bf0cfca 100644 --- a/src/packages/next/pages/pricing/subscriptions.tsx +++ b/src/packages/next/pages/pricing/subscriptions.tsx @@ -4,11 +4,13 @@ */ import { Alert, Layout, List } from "antd"; +import dayjs from "dayjs"; import { Icon, IconName } from "@cocalc/frontend/components/icon"; import { LicenseIdleTimeouts } from "@cocalc/util/consts/site-license"; import { compute_cost } from "@cocalc/util/licenses/purchase/compute-cost"; import { + CURRENT_VERSION, discount_monthly_pct, discount_yearly_pct, MIN_QUOTE, @@ -20,6 +22,7 @@ import Footer from "components/landing/footer"; import Head from "components/landing/head"; import Header from "components/landing/header"; import PricingItem, { Line } from "components/landing/pricing-item"; +import { Paragraph, Title } from "components/misc"; import A from "components/misc/A"; import { applyLicense, @@ -30,9 +33,6 @@ import { LinkToStore, StoreConf } from "components/store/link"; import { MAX_WIDTH } from "lib/config"; import { Customize } from "lib/customize"; import withCustomize from "lib/with-customize"; -import { Paragraph, Title } from "components/misc"; -import dayjs from "dayjs"; -import { CURRENT_VERSION } from "@cocalc/util/licenses/purchase/consts"; function addMonth(date: Date): Date { return dayjs(date).add(30, "days").add(12, "hours").toDate(); @@ -291,7 +291,7 @@ function Body(): JSX.Element { - + {item.academic ? ( ) : (