Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/next/components/store/site-license.tsx
6034 views
1
/*
2
* This file is part of CoCalc: Copyright © 2022 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
/*
7
Create a new site license.
8
*/
9
import { Form, Input } from "antd";
10
import { isEmpty } from "lodash";
11
import { useRouter } from "next/router";
12
import { useEffect, useRef, useState } from "react";
13
14
import { Icon } from "@cocalc/frontend/components/icon";
15
import { get_local_storage } from "@cocalc/frontend/misc/local-storage";
16
import { CostInputPeriod, User } from "@cocalc/util/licenses/purchase/types";
17
import { computeCost } from "@cocalc/util/licenses/store/compute-cost";
18
import type { LicenseSource } from "@cocalc/util/upgrades/shopping";
19
import { Paragraph, Title } from "components/misc";
20
import A from "components/misc/A";
21
import Loading from "components/share/loading";
22
import SiteName from "components/share/site-name";
23
import apiPost from "lib/api/post";
24
import { MAX_WIDTH } from "lib/config";
25
import { useScrollY } from "lib/use-scroll-y";
26
import { AddBox } from "./add-box";
27
import { ApplyLicenseToProject } from "./apply-license-to-project";
28
import { InfoBar } from "./cost-info-bar";
29
import { IdleTimeout } from "./member-idletime";
30
import { QuotaConfig } from "./quota-config";
31
import {
32
SITE_LICENSE,
33
PRESET_MATCH_FIELDS,
34
Preset,
35
COURSE,
36
} from "./quota-config-presets";
37
import {
38
decodeFormValues,
39
encodeFormValues,
40
setAllowUrlEncoding,
41
} from "./quota-query-params";
42
import { RunLimit } from "./run-limit";
43
import { SignInToPurchase } from "./sign-in-to-purchase";
44
import { TitleDescription } from "./title-description";
45
import { ToggleExplanations } from "./toggle-explanations";
46
import { UsageAndDuration } from "./usage-and-duration";
47
48
const DEFAULT_PRESET: Preset = "standard";
49
50
const STYLE: React.CSSProperties = {
51
marginTop: "15px",
52
maxWidth: MAX_WIDTH,
53
margin: "auto",
54
border: "1px solid #ddd",
55
padding: "15px",
56
} as const;
57
58
interface Props {
59
noAccount: boolean;
60
source: LicenseSource;
61
}
62
63
// depending on the type, this either purchases a license with all settings,
64
// or a license for a course with a subset of controls.
65
export default function SiteLicense({ noAccount, source }: Props) {
66
const router = useRouter();
67
const headerRef = useRef<HTMLHeadingElement>(null);
68
69
// most likely, user will go to the cart next
70
useEffect(() => {
71
router.prefetch("/store/cart");
72
}, []);
73
74
const [offsetHeader, setOffsetHeader] = useState(0);
75
const scrollY = useScrollY();
76
77
useEffect(() => {
78
if (headerRef.current) {
79
setOffsetHeader(headerRef.current.offsetTop);
80
}
81
}, []);
82
83
return (
84
<>
85
<Title level={3} ref={headerRef}>
86
<Icon
87
name={source === "course" ? "graduation-cap" : "key"}
88
style={{ marginRight: "5px" }}
89
/>{" "}
90
{router.query.id != null
91
? "Edit License in Shopping Cart"
92
: source === "course"
93
? "Purchase a License for a Course"
94
: "Configure a License"}
95
</Title>
96
{router.query.id == null && (
97
<>
98
{source === "site-license" && (
99
<div>
100
<Paragraph style={{ fontSize: "12pt" }}>
101
<A href="https://doc.cocalc.com/licenses.html">
102
<SiteName /> licenses
103
</A>{" "}
104
allow you to upgrade projects to run more quickly, have network
105
access, more disk space and memory. Licenses cover a wide range
106
of use cases, ranging from a single hobbyist project to
107
thousands of simultaneous users across a large organization.
108
</Paragraph>
109
110
<Paragraph style={{ fontSize: "12pt" }}>
111
Create a license using the form below then add it to your{" "}
112
<A href="/store/cart">shopping cart</A>. If you aren't sure
113
exactly what to buy, you can always edit your licenses later.
114
Subscriptions are also flexible and can be{" "}
115
<A
116
href="https://doc.cocalc.com/account/purchases.html#recent-updates-to-subscriptions"
117
external
118
>
119
edited at any time.{" "}
120
</A>
121
</Paragraph>
122
</div>
123
)}
124
{source === "course" && (
125
<div>
126
<Paragraph style={{ fontSize: "12pt" }}>
127
Teaching with CoCalc makes your course management effortless.
128
Students work in their own secure spaces where you can
129
distribute assignments, track their progress in real-time, and
130
provide help directly within their work environment. No software
131
installation required for students – everything runs in the
132
browser. Used by thousands of instructors since 2013. Learn more
133
in our{" "}
134
<A href={"https://doc.cocalc.com/teaching-instructors.html"}>
135
instructor guide
136
</A>
137
.
138
</Paragraph>
139
</div>
140
)}
141
</>
142
)}
143
<CreateSiteLicense
144
showInfoBar={scrollY > offsetHeader}
145
noAccount={noAccount}
146
source={source}
147
/>
148
</>
149
);
150
}
151
152
// Note -- the back and forth between moment and Date below
153
// is a *workaround* because of some sort of bug in moment/antd/react.
154
155
function CreateSiteLicense({
156
showInfoBar = false,
157
noAccount = false,
158
source,
159
}: {
160
source: LicenseSource;
161
noAccount: boolean;
162
showInfoBar: boolean;
163
}) {
164
const [cost, setCost] = useState<CostInputPeriod | undefined>(undefined);
165
const [loading, setLoading] = useState<boolean>(false);
166
const [cartError, setCartError] = useState<string>("");
167
const [showExplanations, setShowExplanations] = useState<boolean>(false);
168
const [configMode, setConfigMode] = useState<"preset" | "expert">("preset");
169
const [form] = Form.useForm();
170
const router = useRouter();
171
172
const [preset, setPreset] = useState<Preset | null>(DEFAULT_PRESET);
173
const [presetAdjusted, setPresetAdjusted] = useState<boolean>(false);
174
const [initializing, setInitializing] = useState<boolean>(true);
175
176
const presets = source === "course" ? COURSE : SITE_LICENSE;
177
178
/**
179
* Utility function to match current license configuration to a particular preset. If none is
180
* found, this function returns undefined.
181
*/
182
function findPreset(configuration?: any) {
183
const currentConfiguration =
184
configuration || form.getFieldsValue(Object.keys(PRESET_MATCH_FIELDS));
185
186
let foundPreset: Preset | undefined;
187
188
Object.keys(presets).some((p) => {
189
const presetMatches = Object.keys(PRESET_MATCH_FIELDS).every(
190
(formField) => {
191
const presetValue = presets[p][formField];
192
const configValue = currentConfiguration[formField];
193
return presetValue === configValue;
194
},
195
);
196
197
if (presetMatches) {
198
foundPreset = p as Preset;
199
}
200
201
return presetMatches;
202
});
203
204
return foundPreset;
205
}
206
207
function onLicenseChange(skipUrlUpdate = false) {
208
const vals = form.getFieldsValue(true);
209
// console.log("form vals=", vals);
210
// Don't encode URL during component initialization to prevent overwriting URL parameters
211
if (!skipUrlUpdate && !initializing) {
212
encodeFormValues(router, vals, "regular");
213
}
214
setCost(computeCost(vals));
215
216
const foundPreset = findPreset();
217
218
if (foundPreset) {
219
setPresetAdjusted(false);
220
setPreset(foundPreset);
221
222
// For course source, ensure period and user are always correct
223
if (source === "course") {
224
const currentVals = form.getFieldsValue();
225
if (currentVals.period !== "range" || currentVals.user !== "academic") {
226
const correctedValues = {
227
...currentVals,
228
period: "range",
229
user: "academic",
230
};
231
form.setFieldsValue(correctedValues);
232
setCost(computeCost(correctedValues));
233
encodeFormValues(router, correctedValues, "regular");
234
}
235
}
236
} else {
237
// If no preset matches, we set the preset to "standard" in the "course" case
238
if (source === "course") {
239
// For course source, force standard preset if no match found
240
setPreset("standard");
241
setPresetAdjusted(false);
242
setConfigMode("preset");
243
// Set form values to match standard preset
244
const standardPreset = presets["standard"];
245
const newValues = {
246
period: "range",
247
user: "academic",
248
cpu: standardPreset.cpu,
249
ram: standardPreset.ram,
250
disk: standardPreset.disk,
251
uptime: standardPreset.uptime,
252
member: standardPreset.member,
253
};
254
form.setFieldsValue(newValues);
255
// Recalculate cost with new values
256
setCost(computeCost({ ...vals, ...newValues }));
257
encodeFormValues(router, { ...vals, ...newValues }, "regular");
258
} else {
259
setPresetAdjusted(true);
260
}
261
}
262
}
263
264
useEffect(() => {
265
// Disable URL encoding during initialization
266
setAllowUrlEncoding(false);
267
268
const store_site_license_show_explanations = get_local_storage(
269
"store_site_license_show_explanations",
270
);
271
if (store_site_license_show_explanations != null) {
272
setShowExplanations(!!store_site_license_show_explanations);
273
}
274
275
const { id } = router.query;
276
277
if (!noAccount && id != null) {
278
// editing something in the shopping cart
279
(async () => {
280
try {
281
setLoading(true);
282
const item = await apiPost("/shopping/cart/get", { id });
283
if (item.product == "site-license") {
284
form.setFieldsValue({ ...item.description, type: "regular" });
285
}
286
} catch (err) {
287
setCartError(err.message);
288
} finally {
289
setLoading(false);
290
}
291
onLicenseChange();
292
})();
293
} else {
294
const vals = decodeFormValues(router, "regular");
295
const dflt = presets[DEFAULT_PRESET];
296
// Only use the configuration fields from the default preset, not the entire object
297
const defaultConfig = {
298
cpu: dflt.cpu,
299
ram: dflt.ram,
300
disk: dflt.disk,
301
uptime: dflt.uptime,
302
member: dflt.member,
303
// Add other form fields that might be needed
304
period: source === "course" ? "range" : "monthly",
305
user: source === "course" ? "academic" : "business",
306
};
307
if (isEmpty(vals)) {
308
const fullConfig = {
309
...defaultConfig,
310
type: "quota" as const,
311
run_limit: source === "site-license" ? 1 : 25,
312
range: [undefined, undefined] as [Date | undefined, Date | undefined],
313
always_running: false,
314
user: (source === "course" ? "academic" : "business") as User,
315
period: (source === "course" ? "range" : "monthly") as
316
| "range"
317
| "monthly"
318
| "yearly",
319
};
320
form.setFieldsValue(fullConfig);
321
// Calculate cost with the complete configuration
322
setCost(computeCost(fullConfig));
323
// For site-license, also set the preset to standard since we're using default config
324
if (source === "site-license") {
325
setPreset(DEFAULT_PRESET);
326
setPresetAdjusted(false);
327
}
328
} else {
329
// we have to make sure cpu, mem and disk are set, otherwise there is no "cost"
330
// For URL params, vals should override defaultConfig, not the other way around
331
const formValues = {
332
...defaultConfig,
333
...vals, // URL parameters take precedence
334
};
335
form.setFieldsValue(formValues);
336
337
// For source==course, check preset with the actual values we're setting
338
if (source === "course") {
339
const foundPreset = findPreset(formValues);
340
if (foundPreset) {
341
setPreset(foundPreset);
342
setPresetAdjusted(false);
343
// Ensure period and user are correct for course
344
if (
345
formValues.period !== "range" ||
346
formValues.user !== "academic"
347
) {
348
// Only set the corrected fields to preserve other form values like range
349
form.setFieldsValue({
350
period: "range",
351
user: "academic",
352
});
353
}
354
} else {
355
// None of the presets match, configure the form according to the standard preset
356
setPreset("standard");
357
setPresetAdjusted(false);
358
setConfigMode("preset");
359
const standardPreset = presets["standard"];
360
const newValues = {
361
...formValues,
362
period: "range",
363
user: "academic",
364
cpu: standardPreset.cpu,
365
ram: standardPreset.ram,
366
disk: standardPreset.disk,
367
uptime: standardPreset.uptime,
368
member: standardPreset.member,
369
};
370
form.setFieldsValue(newValues);
371
}
372
373
// In both cases: calculate cost for the preset we found
374
setCost(computeCost(form.getFieldsValue(true)));
375
376
// Don't call onLicenseChange for course source since we handled everything above
377
} else {
378
// For source==site-license, we still need onLicenseChange to set cost and preset
379
onLicenseChange(true);
380
}
381
}
382
// Mark initialization as complete and enable URL encoding
383
setInitializing(false);
384
setAllowUrlEncoding(true);
385
}
386
}, [source, router.asPath]);
387
388
// Update the form source field when the source prop changes
389
useEffect(() => {
390
form.setFieldValue("source", source);
391
}, [source]);
392
393
if (loading) {
394
return <Loading large center />;
395
}
396
397
const addBox = (
398
<AddBox
399
cost={cost}
400
router={router}
401
form={form}
402
cartError={cartError}
403
setCartError={setCartError}
404
noAccount={noAccount}
405
source={source}
406
/>
407
);
408
409
return (
410
<div>
411
<ApplyLicenseToProject router={router} />
412
<SignInToPurchase noAccount={noAccount} />
413
<InfoBar
414
show={showInfoBar}
415
cost={cost}
416
router={router}
417
form={form}
418
cartError={cartError}
419
setCartError={setCartError}
420
noAccount={noAccount}
421
/>
422
<Form
423
form={form}
424
style={STYLE}
425
name="basic"
426
labelCol={{ span: 3 }}
427
wrapperCol={{ span: 21 }}
428
autoComplete="off"
429
onValuesChange={onLicenseChange}
430
>
431
<Form.Item wrapperCol={{ offset: 0, span: 24 }}>{addBox}</Form.Item>
432
{/* Hidden form item to track which page (license or course) created this license */}
433
<Form.Item name="source" initialValue={source} noStyle>
434
<Input type="hidden" />
435
</Form.Item>
436
<ToggleExplanations
437
showExplanations={showExplanations}
438
setShowExplanations={setShowExplanations}
439
/>
440
{/* Hidden form item, used to disambiguate between boost and regular licenses */}
441
<Form.Item name="type" initialValue={"regular"} noStyle>
442
<Input type="hidden" />
443
</Form.Item>
444
<UsageAndDuration
445
showExplanations={showExplanations}
446
form={form}
447
onChange={onLicenseChange}
448
source={source}
449
/>
450
<RunLimit
451
source={source}
452
showExplanations={showExplanations}
453
form={form}
454
onChange={onLicenseChange}
455
/>
456
<QuotaConfig
457
source={source}
458
boost={false}
459
form={form}
460
onChange={onLicenseChange}
461
showExplanations={showExplanations}
462
configMode={configMode}
463
setConfigMode={setConfigMode}
464
preset={preset}
465
setPreset={setPreset}
466
presetAdjusted={presetAdjusted}
467
/>
468
{configMode === "expert" && source !== "course" ? (
469
<IdleTimeout
470
showExplanations={showExplanations}
471
form={form}
472
onChange={onLicenseChange}
473
/>
474
) : undefined}
475
<TitleDescription showExplanations={showExplanations} form={form} />
476
</Form>
477
</div>
478
);
479
}
480
481