@olwiba/ui 0.2.7 → 0.2.8
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.
- package/dist/index.d.ts +35 -1
- package/dist/index.js +59 -6
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/marketing/PricingSection.tsx +172 -29
package/package.json
CHANGED
|
@@ -7,10 +7,37 @@ import { StaggerChildren } from '../motion/StaggerChildren';
|
|
|
7
7
|
import { CountdownTimer } from '../motion/CountdownTimer';
|
|
8
8
|
import type { AppShellRenderLink } from '../app/AppShell';
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* One billing period a plan can be bought at.
|
|
12
|
+
*
|
|
13
|
+
* Products are not all sold monthly-or-annually: weekly, quarterly, and
|
|
14
|
+
* one-off all exist. Pass `cadences` to describe whatever this product
|
|
15
|
+
* actually sells and the section renders a tab per entry, rather than the
|
|
16
|
+
* fixed Monthly/Annual pair it assumes otherwise.
|
|
17
|
+
*/
|
|
18
|
+
export interface PricingCadence {
|
|
19
|
+
/** Matches the keys of `PricingPlan.prices`. */
|
|
20
|
+
key: string;
|
|
21
|
+
label: string;
|
|
22
|
+
/**
|
|
23
|
+
* Billing periods in a year — 52 weekly, 12 monthly, 1 annual. Used only to
|
|
24
|
+
* compare cadences for the savings badge; omit it and no badge is computed
|
|
25
|
+
* for this cadence.
|
|
26
|
+
*/
|
|
27
|
+
periodsPerYear?: number;
|
|
28
|
+
/** Price suffix, e.g. "/ week". Falls back to `PricingPlan.periodDisplay`. */
|
|
29
|
+
suffix?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
10
32
|
export interface PricingPlan {
|
|
11
33
|
name: string;
|
|
12
34
|
monthly: number;
|
|
13
35
|
annual: number;
|
|
36
|
+
/**
|
|
37
|
+
* Price per cadence key, for products using `cadences`. The `monthly` and
|
|
38
|
+
* `annual` fields above stay as the fallback for callers that don't.
|
|
39
|
+
*/
|
|
40
|
+
prices?: Record<string, number>;
|
|
14
41
|
description: string;
|
|
15
42
|
cta: string;
|
|
16
43
|
highlighted?: boolean;
|
|
@@ -24,6 +51,61 @@ export interface PricingPlan {
|
|
|
24
51
|
periodDisplay?: string;
|
|
25
52
|
}
|
|
26
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Cheapest-per-year wins: annualise every cadence across all plans and return
|
|
56
|
+
* a `Save N%` label for each one that costs less than the default.
|
|
57
|
+
*
|
|
58
|
+
* Computed rather than configured because a hand-written "Save 34%" silently
|
|
59
|
+
* stops being true the first time a price changes.
|
|
60
|
+
*/
|
|
61
|
+
function computeSaveBadges(
|
|
62
|
+
plans: PricingPlan[],
|
|
63
|
+
cadences: PricingCadence[],
|
|
64
|
+
defaultKey: string,
|
|
65
|
+
): Record<string, string> {
|
|
66
|
+
const annualised = (key: string) => {
|
|
67
|
+
const cadence = cadences.find((c) => c.key === key);
|
|
68
|
+
if (!cadence?.periodsPerYear) return null;
|
|
69
|
+
// Plans priced by `priceDisplay` (pay-what-you-can, "contact us") carry no
|
|
70
|
+
// comparable number, so they're left out of the comparison entirely.
|
|
71
|
+
const totals = plans
|
|
72
|
+
.filter((plan) => plan.priceDisplay === undefined && plan.prices?.[key] !== undefined)
|
|
73
|
+
.map((plan) => plan.prices![key]! * cadence.periodsPerYear!);
|
|
74
|
+
return totals.length > 0 ? totals.reduce((sum, n) => sum + n, 0) : null;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const baseline = annualised(defaultKey);
|
|
78
|
+
if (!baseline) return {};
|
|
79
|
+
|
|
80
|
+
const badges: Record<string, string> = {};
|
|
81
|
+
for (const cadence of cadences) {
|
|
82
|
+
if (cadence.key === defaultKey) continue;
|
|
83
|
+
const total = annualised(cadence.key);
|
|
84
|
+
if (!total || total >= baseline) continue;
|
|
85
|
+
const percent = Math.round(((baseline - total) / baseline) * 100);
|
|
86
|
+
if (percent > 0) badges[cadence.key] = `Save ${percent}%`;
|
|
87
|
+
}
|
|
88
|
+
return badges;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Column layout for the number of plans actually being shown.
|
|
93
|
+
*
|
|
94
|
+
* A fixed three-column grid leaves one or two plans hugging the left edge with
|
|
95
|
+
* dead space beside them, which reads as a rendering fault rather than a
|
|
96
|
+
* deliberate layout. Width is capped per count so cards keep a sensible size
|
|
97
|
+
* instead of stretching to fill.
|
|
98
|
+
*/
|
|
99
|
+
function gridClassesFor(count: number): string {
|
|
100
|
+
if (count <= 1) return 'mx-auto max-w-sm';
|
|
101
|
+
if (count === 2) return 'mx-auto max-w-3xl sm:grid-cols-2';
|
|
102
|
+
if (count === 3) return 'mx-auto max-w-5xl lg:grid-cols-3';
|
|
103
|
+
if (count === 4) return 'mx-auto max-w-6xl sm:grid-cols-2 lg:grid-cols-4';
|
|
104
|
+
// Beyond four, wrapping at three keeps each card readable. A carousel is the
|
|
105
|
+
// answer if a catalogue ever genuinely needs it.
|
|
106
|
+
return 'mx-auto max-w-5xl sm:grid-cols-2 lg:grid-cols-3';
|
|
107
|
+
}
|
|
108
|
+
|
|
27
109
|
export interface PricingSectionProps {
|
|
28
110
|
title?: string;
|
|
29
111
|
description?: string;
|
|
@@ -41,6 +123,14 @@ export interface PricingSectionProps {
|
|
|
41
123
|
highlightedBadgeLabel?: string;
|
|
42
124
|
/** Rendered below each plan's CTA button (e.g. a "Get notified" link). */
|
|
43
125
|
renderPlanFooter?: (plan: PricingPlan) => React.ReactNode;
|
|
126
|
+
/**
|
|
127
|
+
* Billing periods this product sells at. One entry renders no toggle at all;
|
|
128
|
+
* two or more render a tab each, with savings badges computed from
|
|
129
|
+
* `periodsPerYear`. Omit to keep the built-in Monthly/Annual pair.
|
|
130
|
+
*/
|
|
131
|
+
cadences?: PricingCadence[];
|
|
132
|
+
/** Which cadence opens selected. Defaults to the first in `cadences`. */
|
|
133
|
+
defaultCadence?: string;
|
|
44
134
|
}
|
|
45
135
|
|
|
46
136
|
const defaultRenderLink: AppShellRenderLink = ({ href, children, className }) => (
|
|
@@ -61,9 +151,26 @@ export function PricingSection({
|
|
|
61
151
|
currency = '$',
|
|
62
152
|
highlightedBadgeLabel = 'Founding member',
|
|
63
153
|
renderPlanFooter,
|
|
154
|
+
cadences,
|
|
155
|
+
defaultCadence,
|
|
64
156
|
}: PricingSectionProps) {
|
|
65
157
|
const [annual, setAnnual] = React.useState(false);
|
|
66
158
|
const isOneTime = mode === 'one-time';
|
|
159
|
+
|
|
160
|
+
// Explicit cadences replace the built-in Monthly/Annual pair entirely.
|
|
161
|
+
const useCadences = !!cadences?.length;
|
|
162
|
+
const initialCadence =
|
|
163
|
+
(defaultCadence && cadences?.some((c) => c.key === defaultCadence) ? defaultCadence : null) ??
|
|
164
|
+
cadences?.[0]?.key ??
|
|
165
|
+
'';
|
|
166
|
+
const [activeCadence, setActiveCadence] = React.useState(initialCadence);
|
|
167
|
+
const cadence = cadences?.find((c) => c.key === activeCadence);
|
|
168
|
+
const saveBadges = React.useMemo(
|
|
169
|
+
() => (useCadences ? computeSaveBadges(plans, cadences!, initialCadence) : {}),
|
|
170
|
+
[useCadences, plans, cadences, initialCadence],
|
|
171
|
+
);
|
|
172
|
+
// A single cadence is just a label for the price — nothing to switch between.
|
|
173
|
+
const showToggle = useCadences ? cadences!.length > 1 : !isOneTime;
|
|
67
174
|
const uiMode = useUIVariant();
|
|
68
175
|
const sectionClasses = cn(
|
|
69
176
|
'overflow-hidden bg-card',
|
|
@@ -92,44 +199,80 @@ export function PricingSection({
|
|
|
92
199
|
</p>
|
|
93
200
|
)}
|
|
94
201
|
|
|
95
|
-
{/* Billing toggle
|
|
96
|
-
{
|
|
202
|
+
{/* Billing toggle: one tab per cadence, or the legacy Monthly/Annual pair */}
|
|
203
|
+
{showToggle && (
|
|
97
204
|
<div className="mt-6 inline-flex items-center gap-3 rounded-full border bg-muted p-1">
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
205
|
+
{useCadences
|
|
206
|
+
? cadences!.map((entry) => (
|
|
207
|
+
<Button
|
|
208
|
+
key={entry.key}
|
|
209
|
+
variant="ghost"
|
|
210
|
+
size="sm"
|
|
211
|
+
onClick={() => setActiveCadence(entry.key)}
|
|
212
|
+
className={cn(
|
|
213
|
+
'flex items-center gap-2 rounded-full px-4 py-1.5 text-sm font-medium transition-colors',
|
|
214
|
+
entry.key === activeCadence
|
|
215
|
+
? 'bg-background text-foreground shadow-sm'
|
|
216
|
+
: 'text-muted-foreground hover:text-foreground',
|
|
217
|
+
)}
|
|
218
|
+
>
|
|
219
|
+
{entry.label}
|
|
220
|
+
{saveBadges[entry.key] && (
|
|
221
|
+
<Badge variant="secondary" className="text-xs">
|
|
222
|
+
{saveBadges[entry.key]}
|
|
223
|
+
</Badge>
|
|
224
|
+
)}
|
|
225
|
+
</Button>
|
|
226
|
+
))
|
|
227
|
+
: (
|
|
228
|
+
<>
|
|
229
|
+
<Button
|
|
230
|
+
variant="ghost"
|
|
231
|
+
size="sm"
|
|
232
|
+
onClick={() => setAnnual(false)}
|
|
233
|
+
className={cn(
|
|
234
|
+
'rounded-full px-4 py-1.5 text-sm font-medium transition-colors',
|
|
235
|
+
!annual ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
|
|
236
|
+
)}
|
|
237
|
+
>
|
|
238
|
+
Monthly
|
|
239
|
+
</Button>
|
|
240
|
+
<Button
|
|
241
|
+
variant="ghost"
|
|
242
|
+
size="sm"
|
|
243
|
+
onClick={() => setAnnual(true)}
|
|
244
|
+
className={cn(
|
|
245
|
+
'flex items-center gap-2 rounded-full px-4 py-1.5 text-sm font-medium transition-colors',
|
|
246
|
+
annual ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
|
|
247
|
+
)}
|
|
248
|
+
>
|
|
249
|
+
Annual
|
|
250
|
+
{saveBadge && (
|
|
251
|
+
<Badge variant="secondary" className="text-xs">{saveBadge}</Badge>
|
|
252
|
+
)}
|
|
253
|
+
</Button>
|
|
254
|
+
</>
|
|
121
255
|
)}
|
|
122
|
-
</Button>
|
|
123
256
|
</div>
|
|
124
257
|
)}
|
|
125
258
|
</div>
|
|
126
259
|
|
|
127
260
|
{/* Plan cards */}
|
|
128
|
-
<StaggerChildren className=
|
|
261
|
+
<StaggerChildren className={cn('mt-10 grid gap-4', gridClassesFor(plans.length))}>
|
|
129
262
|
{plans.map((plan) => {
|
|
130
|
-
const rawPrice =
|
|
263
|
+
const rawPrice = useCadences
|
|
264
|
+
? (plan.prices?.[activeCadence] ?? plan.monthly)
|
|
265
|
+
: isOneTime
|
|
266
|
+
? plan.monthly
|
|
267
|
+
: annual
|
|
268
|
+
? plan.annual
|
|
269
|
+
: plan.monthly;
|
|
131
270
|
const price = plan.priceDisplay ?? `${currency}${rawPrice}`;
|
|
132
|
-
|
|
271
|
+
// With cadences the suffix follows the selected tab, so a plan's
|
|
272
|
+
// own periodDisplay would pin it to whichever it was written for.
|
|
273
|
+
const period = useCadences
|
|
274
|
+
? (cadence?.suffix ?? plan.periodDisplay ?? '')
|
|
275
|
+
: (plan.periodDisplay ?? (isOneTime ? 'one-time' : rawPrice > 0 ? '/mo' : ''));
|
|
133
276
|
const badge = plan.highlighted && foundingDeadline
|
|
134
277
|
? (
|
|
135
278
|
<span className="inline-flex items-center rounded-full border bg-secondary px-2.5 py-0.5 text-xs font-semibold text-secondary-foreground">
|