Skip to content

Commit c2e5bfe

Browse files
committed
feat: subscriptionConfig defaults values, slider in workflow dialog,
improve sale badge, improve number display with count suffix
1 parent 9ee5769 commit c2e5bfe

9 files changed

Lines changed: 13542 additions & 95 deletions

src/components/SubscriptionConfigurator.tsx

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -165,10 +165,10 @@ function PaymentPeriodSwitch({ content, locale, value, onChange }: PaymentPeriod
165165
onClick={() => onChange(option.value)}
166166
className={cn("relative z-10 min-w-0 rounded-xl px-3 py-2 text-sm font-medium transition-colors", active ? "text-white" : "text-secondary hover:text-white")}
167167
>
168-
<span className="inline-flex min-w-0 items-center justify-center gap-2">
168+
<span className="inline-flex min-w-0 items-center justify-center gap-1">
169169
<span className="min-w-0 truncate">{content[option.textKey]}</span>
170170
{discount > 0 ? (
171-
<span className={cn("shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none bg-brand/15 text-brand")}>
171+
<span className={cn("shrink-0 rounded-full -mt-3 px-1 py-0.5 text-[10px] tracking-wider leading-none bg-brand/15 text-brand")}>
172172
-{formatDiscountBadge(discount, locale)}
173173
</span>
174174
) : null}
@@ -235,10 +235,6 @@ function clampToRange(value: number, range: UsageRange) {
235235
return Math.min(Math.max(value, range.min), range.max)
236236
}
237237

238-
function formatUsageLabel(value: number, suffix: string, locale: AppLocale) {
239-
return `${value.toLocaleString(locale)} ${suffix}`
240-
}
241-
242238
function formatDiscountBadge(discount: number, locale: AppLocale) {
243239
return new Intl.NumberFormat(locale === "de" ? "de-DE" : "en-US", {
244240
style: "percent",
@@ -255,13 +251,18 @@ function getPaymentPeriodDiscount(period: PaymentPeriod, paymentPeriod: Subscrip
255251
export function SubscriptionConfigurator({ locale, content, icons }: { locale: AppLocale; content: SubscriptionConfigData; icons: SubscriptionIcons }) {
256252
const workflowExecutions = content.workflowExecutions
257253
const aiTokens = content.aiTokens
254+
const defaultSelection = content.defaults
255+
const defaultWorkflowExecutionRange = workflowExecutions[defaultSelection.customerType]
256+
const defaultAiTokenRange = aiTokens[defaultSelection.customerType]
257+
const defaultWorkflowExecutions = defaultSelection.workflowExecutions[defaultSelection.customerType]
258+
const defaultAiTokens = defaultSelection.aiTokens[defaultSelection.customerType]
258259

259260
const [selection, setSelection] = useState<SubscriptionSelection>({
260-
deployment: "self-hosted",
261-
customerType: "b2b",
262-
paymentPeriod: "monthly",
263-
workflowExecutions: 1000,
264-
aiTokens: 1000000,
261+
deployment: defaultSelection.deployment,
262+
customerType: defaultSelection.customerType,
263+
paymentPeriod: defaultSelection.paymentPeriod,
264+
workflowExecutions: clampToRange(defaultWorkflowExecutions, defaultWorkflowExecutionRange),
265+
aiTokens: clampToRange(defaultAiTokens, defaultAiTokenRange),
265266
})
266267
const workflowExecutionRange = workflowExecutions[selection.customerType]
267268
const aiTokenRange = aiTokens[selection.customerType]
@@ -407,9 +408,7 @@ export function SubscriptionConfigurator({ locale, content, icons }: { locale: A
407408
onChange={(workflowExecutionsValue) => setSelection((current) => ({ ...current, workflowExecutions: workflowExecutionsValue }))}
408409
ariaLabel={workflowExecutions.title}
409410
className="mt-4"
410-
minLabel={formatUsageLabel(workflowExecutionRange.min, workflowExecutions.suffix, locale)}
411-
centerLabel={formatUsageLabel(selection.workflowExecutions, workflowExecutions.suffix, locale)}
412-
maxLabel={formatUsageLabel(workflowExecutionRange.max, workflowExecutions.suffix, locale)}
411+
valueLabelSuffix={workflowExecutions.suffix}
413412
/>
414413
<div className="mt-2 flex flex-wrap items-center justify-end gap-2">
415414
<p className="text-sm font-medium text-tertiary">{content.contactSales.prompt}</p>
@@ -434,9 +433,7 @@ export function SubscriptionConfigurator({ locale, content, icons }: { locale: A
434433
onChange={(aiTokensValue) => setSelection((current) => ({ ...current, aiTokens: aiTokensValue }))}
435434
ariaLabel={aiTokens.title}
436435
className="mt-4"
437-
minLabel={formatUsageLabel(aiTokenRange.min, aiTokens.suffix, locale)}
438-
centerLabel={formatUsageLabel(selection.aiTokens, aiTokens.suffix, locale)}
439-
maxLabel={formatUsageLabel(aiTokenRange.max, aiTokens.suffix, locale)}
436+
valueLabelSuffix={aiTokens.suffix}
440437
/>
441438
</div>
442439

src/components/dialogs/WorkflowCalculatorDialog.tsx

Lines changed: 6 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,10 @@ import {
2121
Menu,
2222
MenuContent,
2323
MenuTrigger,
24-
NumberInput,
2524
} from "@code0-tech/pictor"
25+
import { Slider } from "@/components/ui/Slider"
2626
import { IconCalculator, IconCheck, IconChevronDown, IconSearch, IconX } from "@tabler/icons-react"
27-
import { type ReactNode, useEffect, useId, useRef, useState } from "react"
27+
import { type ReactNode, useEffect, useState } from "react"
2828

2929
interface WorkflowCalculatorContent {
3030
triggerLabel: string
@@ -76,6 +76,8 @@ export function WorkflowCalculatorDialog({ locale, content, businessTypeIcons, v
7676
const estimatedExecutions = Math.max(0, Math.round(rawEstimate))
7777
const applicableExecutions = clampToStep(estimatedExecutions, min, max, step)
7878
const formatterLocale = locale === "de" ? "de-DE" : "en-US"
79+
const unit = selectedBusinessType?.conversion_unit
80+
const unitLabel = unit ? `${unit.charAt(0).toUpperCase()}${unit.slice(1)} per month` : content.runsPerDayLabel
7981

8082
useEffect(() => {
8183
setRunsPerDay(value)
@@ -106,7 +108,7 @@ export function WorkflowCalculatorDialog({ locale, content, businessTypeIcons, v
106108
</DialogClose>
107109
</div>
108110

109-
<div className="grid gap-4 py-6">
111+
<div className="grid gap-8 py-6">
110112
<div className="flex flex-col gap-2 text-xs font-medium text-secondary">
111113
<span>{content.businessTypeLabel}</span>
112114
<Menu modal={false} open={businessTypeMenuOpen} onOpenChange={setBusinessTypeMenuOpen}>
@@ -150,7 +152,7 @@ export function WorkflowCalculatorDialog({ locale, content, businessTypeIcons, v
150152
</Menu>
151153
</div>
152154

153-
<CalculatorInput label={content.runsPerDayLabel} unit={selectedBusinessType?.conversion_unit} value={runsPerDay} min={min} max={max} onChange={setRunsPerDay} />
155+
<Slider min={min} max={max} step={step} value={runsPerDay} onChange={setRunsPerDay} ariaLabel={unitLabel} lines={48} valueLabelSuffix={unit} />
154156
</div>
155157

156158
<DialogFooter className="pt-4! items-end! justify-between!">
@@ -172,73 +174,3 @@ export function WorkflowCalculatorDialog({ locale, content, businessTypeIcons, v
172174
</Dialog>
173175
)
174176
}
175-
176-
interface CalculatorInputProps {
177-
label: string
178-
unit?: string
179-
value: number
180-
onChange: (value: number) => void
181-
min: number
182-
max: number
183-
}
184-
185-
function CalculatorInput({ label, unit, value, onChange, min, max }: CalculatorInputProps) {
186-
const inputId = useId()
187-
const wrapperRef = useRef<HTMLDivElement | null>(null)
188-
const unitLabel = unit ? `${unit.charAt(0).toUpperCase()}${unit.slice(1)} per month` : label
189-
const clampValue = (nextValue: number) => Math.min(max, Math.max(min, Number.isFinite(nextValue) ? nextValue : min))
190-
191-
const updateValue = (input: HTMLInputElement) => {
192-
const rawValue = input.value
193-
if (rawValue.trim() === "") return
194-
195-
const nextValue = Number(rawValue)
196-
if (!Number.isFinite(nextValue)) return
197-
198-
if (nextValue > max) {
199-
input.value = String(max)
200-
onChange(max)
201-
} else if (nextValue >= min) {
202-
onChange(nextValue)
203-
}
204-
}
205-
206-
useEffect(() => {
207-
const input = wrapperRef.current?.querySelector<HTMLInputElement>("input")
208-
if (input && Number(input.value) !== value) input.value = String(value)
209-
}, [value])
210-
211-
return (
212-
<div className="flex min-w-0 flex-col gap-2">
213-
<label htmlFor={inputId} className="text-xs font-medium text-secondary">
214-
{unitLabel}
215-
</label>
216-
<div
217-
ref={wrapperRef}
218-
className="[&_.input__control]:text-center"
219-
onInputCapture={(event) => {
220-
if (event.target instanceof HTMLInputElement) updateValue(event.target)
221-
}}
222-
onBlurCapture={(event) => {
223-
if (!(event.target instanceof HTMLInputElement)) return
224-
225-
const nextValue = clampValue(Number(event.target.value))
226-
event.target.value = String(nextValue)
227-
onChange(nextValue)
228-
}}
229-
onClick={(event) => {
230-
if (!(event.target instanceof Element) || !event.target.closest(".input__left, .input__right")) return
231-
232-
const input = event.currentTarget.querySelector<HTMLInputElement>("input")
233-
if (!input) return
234-
235-
const nextValue = clampValue(Number(input.value))
236-
input.value = String(nextValue)
237-
onChange(nextValue)
238-
}}
239-
>
240-
<NumberInput id={inputId} min={min} max={max} step={1} defaultValue={String(value)} className="number-input w-full text-center" />
241-
</div>
242-
</div>
243-
)
244-
}

src/components/ui/Slider.tsx

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,37 @@ type SliderProps = {
1717
maxLabel?: string
1818
centerLabel?: string
1919
ariaLabel?: string
20+
valueLabelSuffix?: string
2021
}
2122

22-
export function Slider({ min, max, step = 1, value, onChange, className, lines = 72, minLabel, maxLabel, centerLabel, ariaLabel }: SliderProps) {
23+
function formatCompactSliderValue(value: number) {
24+
const absoluteValue = Math.abs(value)
25+
const compactUnits = [
26+
{ suffix: "B", value: 1_000_000_000 },
27+
{ suffix: "M", value: 1_000_000 },
28+
{ suffix: "K", value: 1_000 },
29+
]
30+
const unit = compactUnits.find((compactUnit) => absoluteValue >= compactUnit.value)
31+
32+
if (!unit) return String(value)
33+
34+
const compactValue = value / unit.value
35+
const maximumFractionDigits = Number.isInteger(compactValue) ? 0 : 1
36+
37+
return `${compactValue.toLocaleString("en-US", { maximumFractionDigits })}${unit.suffix}`
38+
}
39+
40+
function formatSliderLabel(value: number, suffix?: string) {
41+
return `${formatCompactSliderValue(value)}${suffix ? ` ${suffix}` : ""}`
42+
}
43+
44+
export function Slider({ min, max, step = 1, value, onChange, className, lines = 72, minLabel, maxLabel, centerLabel, ariaLabel, valueLabelSuffix }: SliderProps) {
2345
const trackRef = useRef<HTMLDivElement>(null)
2446
const clampedValue = Math.min(max, Math.max(min, value))
2547
const progress = ((clampedValue - min) / (max - min)) * 100
48+
const resolvedMinLabel = minLabel ?? formatSliderLabel(min, valueLabelSuffix)
49+
const resolvedCenterLabel = centerLabel ?? formatSliderLabel(clampedValue, valueLabelSuffix)
50+
const resolvedMaxLabel = maxLabel ?? formatSliderLabel(max, valueLabelSuffix)
2651

2752
const ticks = useMemo(() => Array.from({ length: lines }, (_, index) => index), [lines])
2853
const majorTickIndexes = useMemo(() => new Set([0, Math.round((lines - 1) * 0.25), Math.round((lines - 1) * 0.5), Math.round((lines - 1) * 0.75), lines - 1]), [lines])
@@ -122,12 +147,12 @@ export function Slider({ min, max, step = 1, value, onChange, className, lines =
122147
</div>
123148

124149
<div className="mt-2 grid grid-cols-3 text-xs text-tertiary">
125-
<span>{minLabel ?? min}</span>
150+
<span>{resolvedMinLabel}</span>
126151
<span className="relative tabular-nums text-center text-base text-white">
127152
<span aria-hidden="true" className="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 h-4 w-20 rounded-full bg-white/25 blur-xl" />
128-
{centerLabel}
153+
{resolvedCenterLabel}
129154
</span>
130-
<span className="text-right">{maxLabel ?? max}</span>
155+
<span className="text-right">{resolvedMaxLabel}</span>
131156
</div>
132157
</div>
133158
)

src/globals/subscriptionConfig.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,90 @@ export const SubscriptionCollection: GlobalConfig = {
113113
localized: true,
114114
defaultValue: "Build the subscription shape",
115115
},
116+
{
117+
name: "defaults",
118+
label: "Configurator Defaults",
119+
type: "group",
120+
fields: [
121+
{
122+
name: "deployment",
123+
type: "select",
124+
required: false,
125+
defaultValue: "self-hosted",
126+
options: [
127+
{ label: "Self-hosted", value: "self-hosted" },
128+
{ label: "Cloud", value: "cloud" },
129+
],
130+
},
131+
{
132+
name: "customerType",
133+
type: "select",
134+
required: false,
135+
defaultValue: "b2b",
136+
options: [
137+
{ label: "B2B", value: "b2b" },
138+
{ label: "B2C", value: "b2c" },
139+
],
140+
},
141+
{
142+
name: "paymentPeriod",
143+
type: "select",
144+
required: false,
145+
defaultValue: "monthly",
146+
options: [
147+
{ label: "Monthly", value: "monthly" },
148+
{ label: "Quarterly", value: "quarterly" },
149+
{ label: "Yearly", value: "yearly" },
150+
],
151+
},
152+
{
153+
name: "workflowExecutions",
154+
label: "Workflow Executions",
155+
type: "group",
156+
fields: [
157+
{
158+
name: "b2b",
159+
label: "B2B",
160+
type: "number",
161+
required: false,
162+
defaultValue: 1000,
163+
min: 0,
164+
},
165+
{
166+
name: "b2c",
167+
label: "B2C",
168+
type: "number",
169+
required: false,
170+
defaultValue: 100,
171+
min: 0,
172+
},
173+
],
174+
},
175+
{
176+
name: "aiTokens",
177+
label: "AI Tokens",
178+
type: "group",
179+
fields: [
180+
{
181+
name: "b2b",
182+
label: "B2B",
183+
type: "number",
184+
required: false,
185+
defaultValue: 1000000,
186+
min: 0,
187+
},
188+
{
189+
name: "b2c",
190+
label: "B2C",
191+
type: "number",
192+
required: false,
193+
defaultValue: 100000,
194+
min: 0,
195+
},
196+
],
197+
},
198+
],
199+
},
116200
{
117201
name: "deployment",
118202
type: "group",

src/lib/cms.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,19 @@ export interface SubscriptionConfigData {
106106
id?: string | null
107107
}[]
108108
optionsPanelHeading: string
109+
defaults: {
110+
deployment: "self-hosted" | "cloud"
111+
customerType: "b2b" | "b2c"
112+
paymentPeriod: "monthly" | "quarterly" | "yearly"
113+
workflowExecutions: {
114+
b2b: number
115+
b2c: number
116+
}
117+
aiTokens: {
118+
b2b: number
119+
b2c: number
120+
}
121+
}
109122
deployment: {
110123
label: string
111124
selfHosted: {

0 commit comments

Comments
 (0)