@tomako/tools-runtime 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/package.json +13 -6
  2. package/src/components/tomato-logo.tsx +71 -0
  3. package/src/components/tools/workspace/tool-workspace-layout.tsx +1 -1
  4. package/src/constants/related-tools.ts +4 -0
  5. package/src/features/tools/app-icon-resizer/app-icon-resizer.container.tsx +15 -2
  6. package/src/features/tools/app-icon-resizer/app-icon-resizer.spec.ts +1 -1
  7. package/src/features/tools/app-icon-resizer/widget/app-icon-resizer.tsx +54 -11
  8. package/src/features/tools/app-icon-resizer/widget/platform-icons.tsx +2 -5
  9. package/src/features/tools/mrr-calculator/mrr-calculator.container.tsx +23 -0
  10. package/src/features/tools/mrr-calculator/mrr-calculator.spec.ts +15 -0
  11. package/src/features/tools/mrr-calculator/widget/calculations.ts +74 -0
  12. package/src/features/tools/mrr-calculator/widget/index.ts +1 -0
  13. package/src/features/tools/mrr-calculator/widget/mrr-calculator.tsx +581 -0
  14. package/src/features/tools/registry.ts +6 -0
  15. package/src/features/tools/runtime-registry.ts +4 -0
  16. package/src/features/tools/shared/create-standard-landing-container.tsx +35 -5
  17. package/src/features/tools/shared/tool-page-shell.tsx +10 -137
  18. package/src/features/tools/shared/tool-standard-landing-sections.tsx +262 -52
  19. package/src/features/tools/spec-registry.ts +2 -0
  20. package/src/features/tools/tarot-reading/tarot-reading.container.tsx +1 -22
  21. package/src/features/tools/widget-registry.ts +2 -0
  22. package/src/i18n/messages/en/tools/app-icon-resizer.ts +90 -62
  23. package/src/i18n/messages/en/tools/index.ts +2 -0
  24. package/src/i18n/messages/en/tools/mrr-calculator.ts +202 -0
  25. package/src/i18n/messages/en/tools-ui.ts +3 -2
  26. package/src/i18n/messages/zh/page/tools.ts +2 -3
  27. package/src/i18n/messages/zh/tools/app-icon-resizer.ts +90 -62
  28. package/src/i18n/messages/zh/tools/index.ts +2 -0
  29. package/src/i18n/messages/zh/tools/mrr-calculator.ts +112 -0
  30. package/src/i18n/messages/zh/tools-ui.ts +3 -2
  31. package/src/i18n/messages/zh-tw/tools/app-icon-resizer.ts +90 -62
  32. package/src/i18n/messages/zh-tw/tools/index.ts +2 -0
  33. package/src/i18n/messages/zh-tw/tools/mrr-calculator.ts +52 -0
  34. package/src/i18n/messages/zh-tw/tools-ui.ts +3 -2
  35. package/src/lib/tools/resolve-tool-locale-copy.ts +7 -1
@@ -0,0 +1,581 @@
1
+ "use client";
2
+
3
+ import { useLocale, useTranslations } from "../../../../i18n/client";
4
+ import {
5
+ ToolField,
6
+ ToolPreviewPanel,
7
+ ToolResultActions,
8
+ ToolWorkspaceLayout,
9
+ copyTextToClipboard,
10
+ toolInputClass,
11
+ toolInvalidControlClass,
12
+ toolSelectTriggerClass,
13
+ } from "../../../../components/tools/workspace";
14
+ import { Button } from "@tomako/ui/button";
15
+ import { Input } from "@tomako/ui/input";
16
+ import {
17
+ Select,
18
+ SelectContent,
19
+ SelectItem,
20
+ SelectTrigger,
21
+ SelectValue,
22
+ } from "@tomako/ui/select";
23
+ import { Switch } from "@tomako/ui/switch";
24
+ import { useMemo, useState } from "react";
25
+
26
+ import {
27
+ calculateExactMrr,
28
+ estimateMrr,
29
+ type BillingPeriod,
30
+ } from "./calculations";
31
+
32
+ type Currency = "USD" | "EUR" | "GBP" | "CNY";
33
+ type Mode = "calculate" | "estimate";
34
+
35
+ type ExactValues = {
36
+ activeCustomers: string;
37
+ recurringAmount: string;
38
+ billingPeriod: BillingPeriod;
39
+ previousMrr: string;
40
+ currency: Currency;
41
+ };
42
+
43
+ type EstimateValues = {
44
+ monthlyVisits: string;
45
+ planPrice: string;
46
+ billingPeriod: BillingPeriod;
47
+ eligibleVisitShare: string;
48
+ visitorToPaidRate: string;
49
+ averagePaidLifetimeMonths: string;
50
+ currency: Currency;
51
+ };
52
+
53
+ const INITIAL_EXACT: ExactValues = {
54
+ activeCustomers: "100",
55
+ recurringAmount: "49",
56
+ billingPeriod: "month",
57
+ previousMrr: "",
58
+ currency: "USD",
59
+ };
60
+
61
+ const INITIAL_ESTIMATE: EstimateValues = {
62
+ monthlyVisits: "10000",
63
+ planPrice: "49",
64
+ billingPeriod: "month",
65
+ eligibleVisitShare: "50",
66
+ visitorToPaidRate: "0.5",
67
+ averagePaidLifetimeMonths: "12",
68
+ currency: "USD",
69
+ };
70
+
71
+ function parseNonNegative(value: string): number | null {
72
+ if (value.trim() === "") return null;
73
+ const parsed = Number(value);
74
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
75
+ }
76
+
77
+ function parsePositive(value: string): number | null {
78
+ const parsed = parseNonNegative(value);
79
+ return parsed !== null && parsed > 0 ? parsed : null;
80
+ }
81
+
82
+ function parsePercent(value: string): number | null {
83
+ const parsed = parseNonNegative(value);
84
+ return parsed !== null && parsed <= 100 ? parsed : null;
85
+ }
86
+
87
+ export function MrrCalculator() {
88
+ const locale = useLocale();
89
+ const t = useTranslations("toolsPages.mrrCalculator.widget");
90
+ const [mode, setMode] = useState<Mode>("calculate");
91
+ const [showAdvanced, setShowAdvanced] = useState(false);
92
+ const [showPreviousMrr, setShowPreviousMrr] = useState(false);
93
+ const [copied, setCopied] = useState(false);
94
+ const [exact, setExact] = useState<ExactValues>(INITIAL_EXACT);
95
+ const [estimate, setEstimate] = useState<EstimateValues>(INITIAL_ESTIMATE);
96
+ const exactIsDirty = Object.keys(INITIAL_EXACT).some(
97
+ (key) => exact[key as keyof ExactValues] !== INITIAL_EXACT[key as keyof ExactValues],
98
+ );
99
+ const estimateIsDirty = Object.keys(INITIAL_ESTIMATE).some(
100
+ (key) => estimate[key as keyof EstimateValues] !== INITIAL_ESTIMATE[key as keyof EstimateValues],
101
+ );
102
+
103
+ const exactNumbers = useMemo(() => {
104
+ const activeCustomers = parseNonNegative(exact.activeCustomers);
105
+ const recurringAmount = parseNonNegative(exact.recurringAmount);
106
+ const previousMrr = exact.previousMrr.trim() === "" ? undefined : parseNonNegative(exact.previousMrr);
107
+ if (activeCustomers === null || recurringAmount === null || previousMrr === null) return null;
108
+ return { activeCustomers, recurringAmount, previousMrr };
109
+ }, [exact.activeCustomers, exact.previousMrr, exact.recurringAmount]);
110
+
111
+ const estimateNumbers = useMemo(() => {
112
+ const monthlyVisits = parseNonNegative(estimate.monthlyVisits);
113
+ const planPrice = parseNonNegative(estimate.planPrice);
114
+ const eligibleVisitShare = parsePercent(estimate.eligibleVisitShare);
115
+ const visitorToPaidRate = parsePercent(estimate.visitorToPaidRate);
116
+ const averagePaidLifetimeMonths = parsePositive(estimate.averagePaidLifetimeMonths);
117
+ if (
118
+ monthlyVisits === null ||
119
+ planPrice === null ||
120
+ eligibleVisitShare === null ||
121
+ visitorToPaidRate === null ||
122
+ averagePaidLifetimeMonths === null
123
+ ) {
124
+ return null;
125
+ }
126
+ return {
127
+ monthlyVisits,
128
+ planPrice,
129
+ eligibleVisitShare,
130
+ visitorToPaidRate,
131
+ averagePaidLifetimeMonths,
132
+ };
133
+ }, [estimate]);
134
+
135
+ const exactResult = useMemo(
136
+ () =>
137
+ exactNumbers
138
+ ? calculateExactMrr({
139
+ ...exactNumbers,
140
+ billingPeriod: exact.billingPeriod,
141
+ })
142
+ : null,
143
+ [exact.billingPeriod, exactNumbers],
144
+ );
145
+
146
+ const estimateResult = useMemo(
147
+ () =>
148
+ estimateNumbers
149
+ ? estimateMrr({
150
+ ...estimateNumbers,
151
+ billingPeriod: estimate.billingPeriod,
152
+ })
153
+ : null,
154
+ [estimate.billingPeriod, estimateNumbers],
155
+ );
156
+
157
+ const activeCurrency = mode === "calculate" ? exact.currency : estimate.currency;
158
+ const money = (value: number) =>
159
+ new Intl.NumberFormat(locale, {
160
+ style: "currency",
161
+ currency: activeCurrency,
162
+ maximumFractionDigits: value >= 100 ? 0 : 2,
163
+ }).format(value);
164
+ const number = (value: number) =>
165
+ new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(value);
166
+ const percent = (value: number) =>
167
+ new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 1 }).format(value);
168
+
169
+ const resultText = useMemo(() => {
170
+ if (mode === "calculate" && exactResult) {
171
+ return [
172
+ `${t("calculatedMrr")}: ${money(exactResult.mrr)}`,
173
+ `${t("arr")}: ${money(exactResult.arr)}`,
174
+ `${t("monthlyArpa")}: ${money(exactResult.monthlyArpa)}`,
175
+ exactResult.growthRate === null
176
+ ? null
177
+ : `${t("momGrowth")}: ${percent(exactResult.growthRate)}`,
178
+ ]
179
+ .filter(Boolean)
180
+ .join("\n");
181
+ }
182
+ if (mode === "estimate" && estimateResult) {
183
+ return [
184
+ `${t("estimatedMrr")}: ${money(estimateResult.mrr)}`,
185
+ `${t("estimatedArr")}: ${money(estimateResult.arr)}`,
186
+ `${t("newPaidCustomers")}: ${number(estimateResult.newPaidCustomers)}`,
187
+ `${t("activePaidCustomers")}: ${number(estimateResult.activePaidCustomers)}`,
188
+ t("estimateDisclaimer"),
189
+ ].join("\n");
190
+ }
191
+ return "";
192
+ }, [exactResult, estimateResult, mode, t]);
193
+
194
+ async function handleCopy() {
195
+ setCopied(await copyTextToClipboard(resultText));
196
+ }
197
+
198
+ function handleReset() {
199
+ setCopied(false);
200
+ if (mode === "calculate") {
201
+ setExact(INITIAL_EXACT);
202
+ setShowPreviousMrr(false);
203
+ } else {
204
+ setEstimate(INITIAL_ESTIMATE);
205
+ }
206
+ }
207
+
208
+ const periodOptions: BillingPeriod[] = ["month", "quarter", "year"];
209
+ const currencyOptions: Currency[] = ["USD", "EUR", "GBP", "CNY"];
210
+ const exactErrors = {
211
+ activeCustomers: parseNonNegative(exact.activeCustomers) === null ? t("errors.nonNegative") : undefined,
212
+ recurringAmount: parseNonNegative(exact.recurringAmount) === null ? t("errors.nonNegative") : undefined,
213
+ previousMrr:
214
+ exact.previousMrr.trim() !== "" && parseNonNegative(exact.previousMrr) === null
215
+ ? t("errors.nonNegative")
216
+ : undefined,
217
+ };
218
+ const estimateErrors = {
219
+ monthlyVisits: parseNonNegative(estimate.monthlyVisits) === null ? t("errors.nonNegative") : undefined,
220
+ planPrice: parseNonNegative(estimate.planPrice) === null ? t("errors.nonNegative") : undefined,
221
+ eligibleVisitShare: parsePercent(estimate.eligibleVisitShare) === null ? t("errors.percent") : undefined,
222
+ visitorToPaidRate: parsePercent(estimate.visitorToPaidRate) === null ? t("errors.percent") : undefined,
223
+ averagePaidLifetimeMonths:
224
+ parsePositive(estimate.averagePaidLifetimeMonths) === null ? t("errors.positive") : undefined,
225
+ };
226
+
227
+ const form = (
228
+ <div className="flex min-h-full flex-col">
229
+ <div className="mb-4 grid min-h-12 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2 rounded-lg border border-[#E4E9F1] bg-[#F8FAFC] p-3 sm:gap-3 sm:p-3.5">
230
+ <span className={mode === "calculate" ? "min-w-0 text-xs font-semibold leading-5 text-[#111111] sm:text-sm" : "min-w-0 text-xs leading-5 text-[#777777] sm:text-sm"}>
231
+ {t("calculateMode")}
232
+ </span>
233
+ <Switch
234
+ aria-label={t("modeSwitchLabel")}
235
+ checked={mode === "estimate"}
236
+ onCheckedChange={(checked) => {
237
+ setMode(checked ? "estimate" : "calculate");
238
+ setCopied(false);
239
+ }}
240
+ />
241
+ <span className={mode === "estimate" ? "min-w-0 text-right text-xs font-semibold leading-5 text-[#111111] sm:text-sm" : "min-w-0 text-right text-xs leading-5 text-[#777777] sm:text-sm"}>
242
+ {t("estimateMode")}
243
+ </span>
244
+ </div>
245
+
246
+ <p className="mb-4 text-sm leading-6 text-[#555555]">
247
+ {mode === "calculate" ? t("calculateHint") : t("estimateHint")}
248
+ </p>
249
+
250
+ <div className="min-w-0 flex-1 space-y-4">
251
+ {mode === "calculate" ? (
252
+ <>
253
+ <ToolField
254
+ error={exactErrors.activeCustomers}
255
+ errorId="mrr-active-customers-error"
256
+ htmlFor="mrr-active-customers"
257
+ label={t("activeCustomers")}
258
+ required
259
+ >
260
+ <Input
261
+ aria-describedby={exactErrors.activeCustomers ? "mrr-active-customers-error" : undefined}
262
+ aria-invalid={Boolean(exactErrors.activeCustomers)}
263
+ className={`${toolInputClass} ${exactErrors.activeCustomers ? toolInvalidControlClass : ""}`}
264
+ id="mrr-active-customers"
265
+ inputMode="decimal"
266
+ min="0"
267
+ step="1"
268
+ type="number"
269
+ value={exact.activeCustomers}
270
+ onChange={(event) => setExact((value) => ({ ...value, activeCustomers: event.target.value }))}
271
+ />
272
+ </ToolField>
273
+
274
+ <ToolField
275
+ error={exactErrors.recurringAmount}
276
+ errorId="mrr-recurring-amount-error"
277
+ htmlFor="mrr-recurring-amount"
278
+ label={t("recurringAmount")}
279
+ required
280
+ >
281
+ <Input
282
+ aria-describedby={exactErrors.recurringAmount ? "mrr-recurring-amount-error" : undefined}
283
+ aria-invalid={Boolean(exactErrors.recurringAmount)}
284
+ className={`${toolInputClass} ${exactErrors.recurringAmount ? toolInvalidControlClass : ""}`}
285
+ id="mrr-recurring-amount"
286
+ inputMode="decimal"
287
+ min="0"
288
+ step="0.01"
289
+ type="number"
290
+ value={exact.recurringAmount}
291
+ onChange={(event) => setExact((value) => ({ ...value, recurringAmount: event.target.value }))}
292
+ />
293
+ </ToolField>
294
+
295
+ <div className="grid min-w-0 grid-cols-2 gap-3 sm:gap-4">
296
+ <ToolField label={t("currency")}>
297
+ <Select value={exact.currency} onValueChange={(currency) => setExact((value) => ({ ...value, currency: currency as Currency }))}>
298
+ <SelectTrigger className={toolSelectTriggerClass}><SelectValue /></SelectTrigger>
299
+ <SelectContent>{currencyOptions.map((currency) => <SelectItem key={currency} value={currency}>{currency}</SelectItem>)}</SelectContent>
300
+ </Select>
301
+ </ToolField>
302
+ <ToolField label={t("billingPeriod")}>
303
+ <Select value={exact.billingPeriod} onValueChange={(billingPeriod) => setExact((value) => ({ ...value, billingPeriod: billingPeriod as BillingPeriod }))}>
304
+ <SelectTrigger className={toolSelectTriggerClass}><SelectValue /></SelectTrigger>
305
+ <SelectContent>{periodOptions.map((period) => <SelectItem key={period} value={period}>{t(`periods.${period}`)}</SelectItem>)}</SelectContent>
306
+ </Select>
307
+ </ToolField>
308
+ </div>
309
+
310
+ {showPreviousMrr ? (
311
+ <ToolField
312
+ error={exactErrors.previousMrr}
313
+ errorId="mrr-previous-error"
314
+ hint={t("optional")}
315
+ htmlFor="mrr-previous"
316
+ label={t("previousMrr")}
317
+ >
318
+ <Input
319
+ aria-describedby={exactErrors.previousMrr ? "mrr-previous-error" : undefined}
320
+ aria-invalid={Boolean(exactErrors.previousMrr)}
321
+ className={`${toolInputClass} ${exactErrors.previousMrr ? toolInvalidControlClass : ""}`}
322
+ id="mrr-previous"
323
+ inputMode="decimal"
324
+ min="0"
325
+ step="0.01"
326
+ type="number"
327
+ value={exact.previousMrr}
328
+ onChange={(event) => setExact((value) => ({ ...value, previousMrr: event.target.value }))}
329
+ />
330
+ </ToolField>
331
+ ) : (
332
+ <Button
333
+ className="h-auto justify-start px-0 text-sm font-medium text-[#111111] hover:bg-transparent hover:underline"
334
+ onClick={() => setShowPreviousMrr(true)}
335
+ type="button"
336
+ variant="ghost"
337
+ >
338
+ {t("addPreviousMrr")}
339
+ </Button>
340
+ )}
341
+ </>
342
+ ) : (
343
+ <>
344
+ <ToolField
345
+ error={estimateErrors.monthlyVisits}
346
+ errorId="mrr-visits-error"
347
+ htmlFor="mrr-visits"
348
+ label={t("monthlyVisits")}
349
+ required
350
+ >
351
+ <Input
352
+ aria-describedby={estimateErrors.monthlyVisits ? "mrr-visits-error" : undefined}
353
+ aria-invalid={Boolean(estimateErrors.monthlyVisits)}
354
+ className={`${toolInputClass} ${estimateErrors.monthlyVisits ? toolInvalidControlClass : ""}`}
355
+ id="mrr-visits"
356
+ inputMode="decimal"
357
+ min="0"
358
+ step="1"
359
+ type="number"
360
+ value={estimate.monthlyVisits}
361
+ onChange={(event) => setEstimate((value) => ({ ...value, monthlyVisits: event.target.value }))}
362
+ />
363
+ </ToolField>
364
+
365
+ <ToolField
366
+ error={estimateErrors.planPrice}
367
+ errorId="mrr-plan-price-error"
368
+ htmlFor="mrr-plan-price"
369
+ label={t("planPrice")}
370
+ required
371
+ >
372
+ <Input
373
+ aria-describedby={estimateErrors.planPrice ? "mrr-plan-price-error" : undefined}
374
+ aria-invalid={Boolean(estimateErrors.planPrice)}
375
+ className={`${toolInputClass} ${estimateErrors.planPrice ? toolInvalidControlClass : ""}`}
376
+ id="mrr-plan-price"
377
+ inputMode="decimal"
378
+ min="0"
379
+ step="0.01"
380
+ type="number"
381
+ value={estimate.planPrice}
382
+ onChange={(event) => setEstimate((value) => ({ ...value, planPrice: event.target.value }))}
383
+ />
384
+ </ToolField>
385
+
386
+ <div className="grid min-w-0 grid-cols-2 gap-3 sm:gap-4">
387
+ <ToolField label={t("currency")}>
388
+ <Select value={estimate.currency} onValueChange={(currency) => setEstimate((value) => ({ ...value, currency: currency as Currency }))}>
389
+ <SelectTrigger className={toolSelectTriggerClass}><SelectValue /></SelectTrigger>
390
+ <SelectContent>{currencyOptions.map((currency) => <SelectItem key={currency} value={currency}>{currency}</SelectItem>)}</SelectContent>
391
+ </Select>
392
+ </ToolField>
393
+ <ToolField label={t("billingPeriod")}>
394
+ <Select value={estimate.billingPeriod} onValueChange={(billingPeriod) => setEstimate((value) => ({ ...value, billingPeriod: billingPeriod as BillingPeriod }))}>
395
+ <SelectTrigger className={toolSelectTriggerClass}><SelectValue /></SelectTrigger>
396
+ <SelectContent>{periodOptions.map((period) => <SelectItem key={period} value={period}>{t(`periods.${period}`)}</SelectItem>)}</SelectContent>
397
+ </Select>
398
+ </ToolField>
399
+ </div>
400
+
401
+ <Button
402
+ className="h-auto justify-start px-0 text-sm font-medium text-[#111111] hover:bg-transparent hover:underline"
403
+ type="button"
404
+ variant="ghost"
405
+ onClick={() => setShowAdvanced((value) => !value)}
406
+ >
407
+ {showAdvanced ? t("hideAssumptions") : t("showAssumptions")}
408
+ </Button>
409
+
410
+ {showAdvanced ? (
411
+ <div className="space-y-4 rounded-lg border border-[#E4E9F1] bg-[#F8FAFC] p-4">
412
+ <p className="text-xs leading-5 text-[#666666]">{t("assumptionNote")}</p>
413
+ <ToolField
414
+ error={estimateErrors.eligibleVisitShare}
415
+ errorId="mrr-eligible-share-error"
416
+ htmlFor="mrr-eligible-share"
417
+ label={t("eligibleVisitShare")}
418
+ >
419
+ <Input
420
+ aria-describedby={estimateErrors.eligibleVisitShare ? "mrr-eligible-share-error" : undefined}
421
+ aria-invalid={Boolean(estimateErrors.eligibleVisitShare)}
422
+ className={`${toolInputClass} ${estimateErrors.eligibleVisitShare ? toolInvalidControlClass : ""}`}
423
+ id="mrr-eligible-share"
424
+ inputMode="decimal"
425
+ min="0"
426
+ max="100"
427
+ step="0.1"
428
+ type="number"
429
+ value={estimate.eligibleVisitShare}
430
+ onChange={(event) => setEstimate((value) => ({ ...value, eligibleVisitShare: event.target.value }))}
431
+ />
432
+ </ToolField>
433
+ <ToolField
434
+ error={estimateErrors.visitorToPaidRate}
435
+ errorId="mrr-conversion-rate-error"
436
+ htmlFor="mrr-conversion-rate"
437
+ label={t("visitorToPaidRate")}
438
+ >
439
+ <Input
440
+ aria-describedby={estimateErrors.visitorToPaidRate ? "mrr-conversion-rate-error" : undefined}
441
+ aria-invalid={Boolean(estimateErrors.visitorToPaidRate)}
442
+ className={`${toolInputClass} ${estimateErrors.visitorToPaidRate ? toolInvalidControlClass : ""}`}
443
+ id="mrr-conversion-rate"
444
+ inputMode="decimal"
445
+ min="0"
446
+ max="100"
447
+ step="0.1"
448
+ type="number"
449
+ value={estimate.visitorToPaidRate}
450
+ onChange={(event) => setEstimate((value) => ({ ...value, visitorToPaidRate: event.target.value }))}
451
+ />
452
+ </ToolField>
453
+ <ToolField
454
+ error={estimateErrors.averagePaidLifetimeMonths}
455
+ errorId="mrr-paid-lifetime-error"
456
+ htmlFor="mrr-paid-lifetime"
457
+ label={t("averagePaidLifetime")}
458
+ >
459
+ <Input
460
+ aria-describedby={estimateErrors.averagePaidLifetimeMonths ? "mrr-paid-lifetime-error" : undefined}
461
+ aria-invalid={Boolean(estimateErrors.averagePaidLifetimeMonths)}
462
+ className={`${toolInputClass} ${estimateErrors.averagePaidLifetimeMonths ? toolInvalidControlClass : ""}`}
463
+ id="mrr-paid-lifetime"
464
+ inputMode="decimal"
465
+ min="0.1"
466
+ step="0.1"
467
+ type="number"
468
+ value={estimate.averagePaidLifetimeMonths}
469
+ onChange={(event) => setEstimate((value) => ({ ...value, averagePaidLifetimeMonths: event.target.value }))}
470
+ />
471
+ </ToolField>
472
+ </div>
473
+ ) : null}
474
+ </>
475
+ )}
476
+ </div>
477
+ </div>
478
+ );
479
+
480
+ const result = mode === "calculate" ? exactResult : estimateResult;
481
+
482
+ return (
483
+ <ToolWorkspaceLayout
484
+ className="lg:h-[min(720px,calc(100vh-220px))] lg:min-h-[620px]"
485
+ defaultFormWidth={440}
486
+ maxFormWidth={540}
487
+ form={form}
488
+ preview={
489
+ <ToolPreviewPanel>
490
+ {result ? (
491
+ <div className="flex min-h-0 flex-1 flex-col">
492
+ <div className="flex min-w-0 items-start justify-between gap-2 sm:gap-3">
493
+ <div className="min-w-0">
494
+ <h3 className="text-lg font-semibold leading-7 text-[#111111] sm:text-xl">
495
+ {mode === "calculate" ? t("calculatedResultTitle") : t("estimatedResultTitle")}
496
+ </h3>
497
+ </div>
498
+ <ToolResultActions
499
+ className="shrink-0 [&>button]:h-9 [&>button]:px-3"
500
+ copied={copied}
501
+ copiedLabel={t("copied")}
502
+ copyLabel={t("copyResult")}
503
+ resetLabel={t("reset")}
504
+ onCopy={handleCopy}
505
+ onReset={(mode === "calculate" ? exactIsDirty : estimateIsDirty) ? handleReset : undefined}
506
+ />
507
+ </div>
508
+
509
+ <div className="min-h-0 flex-1 overflow-y-auto pt-4 sm:pt-6 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
510
+ {mode === "calculate" && exactResult && exactNumbers ? (
511
+ <>
512
+ <div className="rounded-xl border border-[#E4E9F1] bg-white p-4 sm:p-5">
513
+ <p className="text-sm font-medium text-[#666666]">{t("calculatedMrr")}</p>
514
+ <p className="mt-2 text-4xl font-semibold tracking-tight text-[#111111]">{money(exactResult.mrr)}</p>
515
+ </div>
516
+ <div className="mt-3 grid grid-cols-2 gap-3 sm:mt-4 sm:grid-cols-3">
517
+ <Metric label={t("arr")} value={money(exactResult.arr)} />
518
+ <Metric label={t("monthlyArpa")} value={money(exactResult.monthlyArpa)} />
519
+ {exact.previousMrr.trim() !== "" ? (
520
+ <Metric
521
+ className="col-span-2 sm:col-span-1"
522
+ label={t("momGrowth")}
523
+ value={exactResult.growthRate === null ? t("notAvailable") : percent(exactResult.growthRate)}
524
+ />
525
+ ) : null}
526
+ </div>
527
+ <div className="mt-3 rounded-xl border border-[#E4E9F1] bg-white p-4 sm:mt-4">
528
+ <p className="text-sm font-semibold text-[#111111]">{t("formula")}</p>
529
+ <p className="mt-2 break-words text-sm leading-6 text-[#555555]">
530
+ {number(exactNumbers.activeCustomers)} × {money(exactResult.monthlyArpa)} = {money(exactResult.mrr)} {t("perMonth")}
531
+ </p>
532
+ </div>
533
+ <p className="mt-3 text-xs leading-5 text-[#666666] sm:mt-4">{t("calculatedGuidance")}</p>
534
+ </>
535
+ ) : mode === "estimate" && estimateResult && estimateNumbers ? (
536
+ <>
537
+ <div className="rounded-xl border border-[#E4E9F1] bg-white p-5">
538
+ <p className="text-sm font-medium text-[#666666]">{t("estimatedMrr")}</p>
539
+ <p className="mt-2 text-4xl font-semibold tracking-tight text-[#111111]">{money(estimateResult.mrr)}</p>
540
+ <p className="mt-3 text-xs leading-5 text-[#666666]">{t("estimateDisclaimer")}</p>
541
+ </div>
542
+ <div className="mt-4 grid gap-3 sm:grid-cols-2">
543
+ <Metric label={t("estimatedArr")} value={money(estimateResult.arr)} />
544
+ <Metric label={t("monthlyPlanPrice")} value={money(estimateResult.monthlyPlanPrice)} />
545
+ <Metric label={t("newPaidCustomers")} value={number(estimateResult.newPaidCustomers)} />
546
+ <Metric label={t("activePaidCustomers")} value={number(estimateResult.activePaidCustomers)} />
547
+ </div>
548
+ <div className="mt-4 rounded-xl border border-[#E4E9F1] bg-white p-4">
549
+ <p className="text-sm font-semibold text-[#111111]">{t("assumptionsUsed")}</p>
550
+ <ul className="mt-3 grid gap-2 text-sm leading-6 text-[#555555]">
551
+ <li>{t("eligibleVisitShare")}: {estimateNumbers.eligibleVisitShare}%</li>
552
+ <li>{t("visitorToPaidRate")}: {estimateNumbers.visitorToPaidRate}%</li>
553
+ <li>{t("averagePaidLifetime")}: {number(estimateNumbers.averagePaidLifetimeMonths)}</li>
554
+ </ul>
555
+ </div>
556
+ </>
557
+ ) : null}
558
+ </div>
559
+ </div>
560
+ ) : (
561
+ <div className="grid min-h-full place-items-center text-center">
562
+ <div>
563
+ <h3 className="text-lg font-semibold text-[#111111]">{t("fixInputsTitle")}</h3>
564
+ <p className="mt-2 text-sm leading-6 text-[#555555]">{t("fixInputsBody")}</p>
565
+ </div>
566
+ </div>
567
+ )}
568
+ </ToolPreviewPanel>
569
+ }
570
+ />
571
+ );
572
+ }
573
+
574
+ function Metric({ className, label, value }: { className?: string; label: string; value: string }) {
575
+ return (
576
+ <div className={`min-w-0 rounded-xl border border-[#E4E9F1] bg-white p-4 ${className ?? ""}`}>
577
+ <p className="text-xs font-medium leading-5 text-[#666666]">{label}</p>
578
+ <p className="mt-1 break-words text-lg font-semibold text-[#111111]">{value}</p>
579
+ </div>
580
+ );
581
+ }
@@ -29,6 +29,8 @@ import { MarketForecastReportContainer } from "./market-forecast-report/market-f
29
29
  import { marketForecastReportSpec } from "./market-forecast-report/market-forecast-report.spec";
30
30
  import { MillionUserGrowthTestContainer } from "./million-user-growth-test/million-user-growth-test.container";
31
31
  import { millionUserGrowthTestSpec } from "./million-user-growth-test/million-user-growth-test.spec";
32
+ import { MrrCalculatorContainer } from "./mrr-calculator/mrr-calculator.container";
33
+ import { mrrCalculatorSpec } from "./mrr-calculator/mrr-calculator.spec";
32
34
  import { OgImageGeneratorContainer } from "./og-image-generator/og-image-generator.container";
33
35
  import { ogImageGeneratorSpec } from "./og-image-generator/og-image-generator.spec";
34
36
  import { createPackageToolModule } from "./package/to-tool-module";
@@ -91,6 +93,10 @@ export const toolRegistry: ToolModule[] = [
91
93
  spec: millionUserGrowthTestSpec,
92
94
  Container: MillionUserGrowthTestContainer,
93
95
  },
96
+ {
97
+ spec: mrrCalculatorSpec,
98
+ Container: MrrCalculatorContainer,
99
+ },
94
100
  {
95
101
  spec: marketForecastReportSpec,
96
102
  Container: MarketForecastReportContainer,
@@ -26,6 +26,10 @@ const toolContainerLoaders: Record<string, ToolContainerLoader> = {
26
26
  import("./million-user-growth-test/million-user-growth-test.container").then(
27
27
  (module) => module.MillionUserGrowthTestContainer,
28
28
  ),
29
+ "mrr-calculator": () =>
30
+ import("./mrr-calculator/mrr-calculator.container").then(
31
+ (module) => module.MrrCalculatorContainer,
32
+ ),
29
33
  "market-forecast-report": () =>
30
34
  import("./market-forecast-report/market-forecast-report.container").then(
31
35
  (module) => module.MarketForecastReportContainer,