Path: blob/master/src/packages/next/components/store/site-license.tsx
6034 views
/*1* This file is part of CoCalc: Copyright © 2022 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45/*6Create a new site license.7*/8import { Form, Input } from "antd";9import { isEmpty } from "lodash";10import { useRouter } from "next/router";11import { useEffect, useRef, useState } from "react";1213import { Icon } from "@cocalc/frontend/components/icon";14import { get_local_storage } from "@cocalc/frontend/misc/local-storage";15import { CostInputPeriod, User } from "@cocalc/util/licenses/purchase/types";16import { computeCost } from "@cocalc/util/licenses/store/compute-cost";17import type { LicenseSource } from "@cocalc/util/upgrades/shopping";18import { Paragraph, Title } from "components/misc";19import A from "components/misc/A";20import Loading from "components/share/loading";21import SiteName from "components/share/site-name";22import apiPost from "lib/api/post";23import { MAX_WIDTH } from "lib/config";24import { useScrollY } from "lib/use-scroll-y";25import { AddBox } from "./add-box";26import { ApplyLicenseToProject } from "./apply-license-to-project";27import { InfoBar } from "./cost-info-bar";28import { IdleTimeout } from "./member-idletime";29import { QuotaConfig } from "./quota-config";30import {31SITE_LICENSE,32PRESET_MATCH_FIELDS,33Preset,34COURSE,35} from "./quota-config-presets";36import {37decodeFormValues,38encodeFormValues,39setAllowUrlEncoding,40} from "./quota-query-params";41import { RunLimit } from "./run-limit";42import { SignInToPurchase } from "./sign-in-to-purchase";43import { TitleDescription } from "./title-description";44import { ToggleExplanations } from "./toggle-explanations";45import { UsageAndDuration } from "./usage-and-duration";4647const DEFAULT_PRESET: Preset = "standard";4849const STYLE: React.CSSProperties = {50marginTop: "15px",51maxWidth: MAX_WIDTH,52margin: "auto",53border: "1px solid #ddd",54padding: "15px",55} as const;5657interface Props {58noAccount: boolean;59source: LicenseSource;60}6162// depending on the type, this either purchases a license with all settings,63// or a license for a course with a subset of controls.64export default function SiteLicense({ noAccount, source }: Props) {65const router = useRouter();66const headerRef = useRef<HTMLHeadingElement>(null);6768// most likely, user will go to the cart next69useEffect(() => {70router.prefetch("/store/cart");71}, []);7273const [offsetHeader, setOffsetHeader] = useState(0);74const scrollY = useScrollY();7576useEffect(() => {77if (headerRef.current) {78setOffsetHeader(headerRef.current.offsetTop);79}80}, []);8182return (83<>84<Title level={3} ref={headerRef}>85<Icon86name={source === "course" ? "graduation-cap" : "key"}87style={{ marginRight: "5px" }}88/>{" "}89{router.query.id != null90? "Edit License in Shopping Cart"91: source === "course"92? "Purchase a License for a Course"93: "Configure a License"}94</Title>95{router.query.id == null && (96<>97{source === "site-license" && (98<div>99<Paragraph style={{ fontSize: "12pt" }}>100<A href="https://doc.cocalc.com/licenses.html">101<SiteName /> licenses102</A>{" "}103allow you to upgrade projects to run more quickly, have network104access, more disk space and memory. Licenses cover a wide range105of use cases, ranging from a single hobbyist project to106thousands of simultaneous users across a large organization.107</Paragraph>108109<Paragraph style={{ fontSize: "12pt" }}>110Create a license using the form below then add it to your{" "}111<A href="/store/cart">shopping cart</A>. If you aren't sure112exactly what to buy, you can always edit your licenses later.113Subscriptions are also flexible and can be{" "}114<A115href="https://doc.cocalc.com/account/purchases.html#recent-updates-to-subscriptions"116external117>118edited at any time.{" "}119</A>120</Paragraph>121</div>122)}123{source === "course" && (124<div>125<Paragraph style={{ fontSize: "12pt" }}>126Teaching with CoCalc makes your course management effortless.127Students work in their own secure spaces where you can128distribute assignments, track their progress in real-time, and129provide help directly within their work environment. No software130installation required for students – everything runs in the131browser. Used by thousands of instructors since 2013. Learn more132in our{" "}133<A href={"https://doc.cocalc.com/teaching-instructors.html"}>134instructor guide135</A>136.137</Paragraph>138</div>139)}140</>141)}142<CreateSiteLicense143showInfoBar={scrollY > offsetHeader}144noAccount={noAccount}145source={source}146/>147</>148);149}150151// Note -- the back and forth between moment and Date below152// is a *workaround* because of some sort of bug in moment/antd/react.153154function CreateSiteLicense({155showInfoBar = false,156noAccount = false,157source,158}: {159source: LicenseSource;160noAccount: boolean;161showInfoBar: boolean;162}) {163const [cost, setCost] = useState<CostInputPeriod | undefined>(undefined);164const [loading, setLoading] = useState<boolean>(false);165const [cartError, setCartError] = useState<string>("");166const [showExplanations, setShowExplanations] = useState<boolean>(false);167const [configMode, setConfigMode] = useState<"preset" | "expert">("preset");168const [form] = Form.useForm();169const router = useRouter();170171const [preset, setPreset] = useState<Preset | null>(DEFAULT_PRESET);172const [presetAdjusted, setPresetAdjusted] = useState<boolean>(false);173const [initializing, setInitializing] = useState<boolean>(true);174175const presets = source === "course" ? COURSE : SITE_LICENSE;176177/**178* Utility function to match current license configuration to a particular preset. If none is179* found, this function returns undefined.180*/181function findPreset(configuration?: any) {182const currentConfiguration =183configuration || form.getFieldsValue(Object.keys(PRESET_MATCH_FIELDS));184185let foundPreset: Preset | undefined;186187Object.keys(presets).some((p) => {188const presetMatches = Object.keys(PRESET_MATCH_FIELDS).every(189(formField) => {190const presetValue = presets[p][formField];191const configValue = currentConfiguration[formField];192return presetValue === configValue;193},194);195196if (presetMatches) {197foundPreset = p as Preset;198}199200return presetMatches;201});202203return foundPreset;204}205206function onLicenseChange(skipUrlUpdate = false) {207const vals = form.getFieldsValue(true);208// console.log("form vals=", vals);209// Don't encode URL during component initialization to prevent overwriting URL parameters210if (!skipUrlUpdate && !initializing) {211encodeFormValues(router, vals, "regular");212}213setCost(computeCost(vals));214215const foundPreset = findPreset();216217if (foundPreset) {218setPresetAdjusted(false);219setPreset(foundPreset);220221// For course source, ensure period and user are always correct222if (source === "course") {223const currentVals = form.getFieldsValue();224if (currentVals.period !== "range" || currentVals.user !== "academic") {225const correctedValues = {226...currentVals,227period: "range",228user: "academic",229};230form.setFieldsValue(correctedValues);231setCost(computeCost(correctedValues));232encodeFormValues(router, correctedValues, "regular");233}234}235} else {236// If no preset matches, we set the preset to "standard" in the "course" case237if (source === "course") {238// For course source, force standard preset if no match found239setPreset("standard");240setPresetAdjusted(false);241setConfigMode("preset");242// Set form values to match standard preset243const standardPreset = presets["standard"];244const newValues = {245period: "range",246user: "academic",247cpu: standardPreset.cpu,248ram: standardPreset.ram,249disk: standardPreset.disk,250uptime: standardPreset.uptime,251member: standardPreset.member,252};253form.setFieldsValue(newValues);254// Recalculate cost with new values255setCost(computeCost({ ...vals, ...newValues }));256encodeFormValues(router, { ...vals, ...newValues }, "regular");257} else {258setPresetAdjusted(true);259}260}261}262263useEffect(() => {264// Disable URL encoding during initialization265setAllowUrlEncoding(false);266267const store_site_license_show_explanations = get_local_storage(268"store_site_license_show_explanations",269);270if (store_site_license_show_explanations != null) {271setShowExplanations(!!store_site_license_show_explanations);272}273274const { id } = router.query;275276if (!noAccount && id != null) {277// editing something in the shopping cart278(async () => {279try {280setLoading(true);281const item = await apiPost("/shopping/cart/get", { id });282if (item.product == "site-license") {283form.setFieldsValue({ ...item.description, type: "regular" });284}285} catch (err) {286setCartError(err.message);287} finally {288setLoading(false);289}290onLicenseChange();291})();292} else {293const vals = decodeFormValues(router, "regular");294const dflt = presets[DEFAULT_PRESET];295// Only use the configuration fields from the default preset, not the entire object296const defaultConfig = {297cpu: dflt.cpu,298ram: dflt.ram,299disk: dflt.disk,300uptime: dflt.uptime,301member: dflt.member,302// Add other form fields that might be needed303period: source === "course" ? "range" : "monthly",304user: source === "course" ? "academic" : "business",305};306if (isEmpty(vals)) {307const fullConfig = {308...defaultConfig,309type: "quota" as const,310run_limit: source === "site-license" ? 1 : 25,311range: [undefined, undefined] as [Date | undefined, Date | undefined],312always_running: false,313user: (source === "course" ? "academic" : "business") as User,314period: (source === "course" ? "range" : "monthly") as315| "range"316| "monthly"317| "yearly",318};319form.setFieldsValue(fullConfig);320// Calculate cost with the complete configuration321setCost(computeCost(fullConfig));322// For site-license, also set the preset to standard since we're using default config323if (source === "site-license") {324setPreset(DEFAULT_PRESET);325setPresetAdjusted(false);326}327} else {328// we have to make sure cpu, mem and disk are set, otherwise there is no "cost"329// For URL params, vals should override defaultConfig, not the other way around330const formValues = {331...defaultConfig,332...vals, // URL parameters take precedence333};334form.setFieldsValue(formValues);335336// For source==course, check preset with the actual values we're setting337if (source === "course") {338const foundPreset = findPreset(formValues);339if (foundPreset) {340setPreset(foundPreset);341setPresetAdjusted(false);342// Ensure period and user are correct for course343if (344formValues.period !== "range" ||345formValues.user !== "academic"346) {347// Only set the corrected fields to preserve other form values like range348form.setFieldsValue({349period: "range",350user: "academic",351});352}353} else {354// None of the presets match, configure the form according to the standard preset355setPreset("standard");356setPresetAdjusted(false);357setConfigMode("preset");358const standardPreset = presets["standard"];359const newValues = {360...formValues,361period: "range",362user: "academic",363cpu: standardPreset.cpu,364ram: standardPreset.ram,365disk: standardPreset.disk,366uptime: standardPreset.uptime,367member: standardPreset.member,368};369form.setFieldsValue(newValues);370}371372// In both cases: calculate cost for the preset we found373setCost(computeCost(form.getFieldsValue(true)));374375// Don't call onLicenseChange for course source since we handled everything above376} else {377// For source==site-license, we still need onLicenseChange to set cost and preset378onLicenseChange(true);379}380}381// Mark initialization as complete and enable URL encoding382setInitializing(false);383setAllowUrlEncoding(true);384}385}, [source, router.asPath]);386387// Update the form source field when the source prop changes388useEffect(() => {389form.setFieldValue("source", source);390}, [source]);391392if (loading) {393return <Loading large center />;394}395396const addBox = (397<AddBox398cost={cost}399router={router}400form={form}401cartError={cartError}402setCartError={setCartError}403noAccount={noAccount}404source={source}405/>406);407408return (409<div>410<ApplyLicenseToProject router={router} />411<SignInToPurchase noAccount={noAccount} />412<InfoBar413show={showInfoBar}414cost={cost}415router={router}416form={form}417cartError={cartError}418setCartError={setCartError}419noAccount={noAccount}420/>421<Form422form={form}423style={STYLE}424name="basic"425labelCol={{ span: 3 }}426wrapperCol={{ span: 21 }}427autoComplete="off"428onValuesChange={onLicenseChange}429>430<Form.Item wrapperCol={{ offset: 0, span: 24 }}>{addBox}</Form.Item>431{/* Hidden form item to track which page (license or course) created this license */}432<Form.Item name="source" initialValue={source} noStyle>433<Input type="hidden" />434</Form.Item>435<ToggleExplanations436showExplanations={showExplanations}437setShowExplanations={setShowExplanations}438/>439{/* Hidden form item, used to disambiguate between boost and regular licenses */}440<Form.Item name="type" initialValue={"regular"} noStyle>441<Input type="hidden" />442</Form.Item>443<UsageAndDuration444showExplanations={showExplanations}445form={form}446onChange={onLicenseChange}447source={source}448/>449<RunLimit450source={source}451showExplanations={showExplanations}452form={form}453onChange={onLicenseChange}454/>455<QuotaConfig456source={source}457boost={false}458form={form}459onChange={onLicenseChange}460showExplanations={showExplanations}461configMode={configMode}462setConfigMode={setConfigMode}463preset={preset}464setPreset={setPreset}465presetAdjusted={presetAdjusted}466/>467{configMode === "expert" && source !== "course" ? (468<IdleTimeout469showExplanations={showExplanations}470form={form}471onChange={onLicenseChange}472/>473) : undefined}474<TitleDescription showExplanations={showExplanations} form={form} />475</Form>476</div>477);478}479480481