@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olwiba/ui",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -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 subscription mode only */}
96
- {!isOneTime && (
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
- <Button
99
- variant="ghost"
100
- size="sm"
101
- onClick={() => setAnnual(false)}
102
- className={cn(
103
- 'rounded-full px-4 py-1.5 text-sm font-medium transition-colors',
104
- !annual ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
105
- )}
106
- >
107
- Monthly
108
- </Button>
109
- <Button
110
- variant="ghost"
111
- size="sm"
112
- onClick={() => setAnnual(true)}
113
- className={cn(
114
- 'flex items-center gap-2 rounded-full px-4 py-1.5 text-sm font-medium transition-colors',
115
- annual ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
116
- )}
117
- >
118
- Annual
119
- {saveBadge && (
120
- <Badge variant="secondary" className="text-xs">{saveBadge}</Badge>
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="mt-10 grid gap-4 lg:grid-cols-3">
261
+ <StaggerChildren className={cn('mt-10 grid gap-4', gridClassesFor(plans.length))}>
129
262
  {plans.map((plan) => {
130
- const rawPrice = isOneTime ? plan.monthly : (annual ? plan.annual : plan.monthly);
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
- const period = plan.periodDisplay ?? (isOneTime ? 'one-time' : (rawPrice > 0 ? '/mo' : ''));
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">