@ttoss/react-billing 0.3.5 → 0.4.0

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/README.md CHANGED
@@ -42,13 +42,81 @@ export const Example = () => (
42
42
  );
43
43
  ```
44
44
 
45
- ### SubscriptionCard
45
+ ### PlansPanel
46
+
47
+ A higher-level component that renders a grid of `PlanCard` components with optional multi-dimensional `SegmentedControl` filters. Plans are shown only when they match all active filter values.
48
+
49
+ ```tsx
50
+ import { PlansPanel } from '@ttoss/react-billing';
51
+
52
+ export const PricingPage = () => (
53
+ <PlansPanel
54
+ filters={[
55
+ {
56
+ id: 'interval',
57
+ label: 'Billing frequency',
58
+ options: [
59
+ { label: 'Monthly', value: 'monthly' },
60
+ { label: 'Yearly', value: 'yearly' },
61
+ ],
62
+ defaultValue: 'monthly',
63
+ },
64
+ ]}
65
+ plans={[
66
+ {
67
+ id: 'plan_basic_monthly',
68
+ filterValues: { interval: 'monthly' },
69
+ title: 'Basic',
70
+ price: {
71
+ value: '$29',
72
+ interval: '/month',
73
+ description: 'Billed monthly',
74
+ },
75
+ features: ['10 proposals/month'],
76
+ buttonProps: { label: 'Subscribe' },
77
+ },
78
+ {
79
+ id: 'plan_basic_yearly',
80
+ filterValues: { interval: 'yearly' },
81
+ title: 'Basic',
82
+ price: {
83
+ value: '$24',
84
+ interval: '/month',
85
+ description: 'Billed annually',
86
+ },
87
+ features: ['10 proposals/month'],
88
+ buttonProps: { label: 'Subscribe' },
89
+ },
90
+ ]}
91
+ activePlanId="plan_basic_monthly"
92
+ onSelectPlan={(planId) => console.log('Selected:', planId)}
93
+ />
94
+ );
95
+ ```
96
+
97
+ Controlled mode (parent owns filter state):
98
+
99
+ ```tsx
100
+ const [filterValues, setFilterValues] = React.useState({ interval: 'monthly' });
101
+
102
+ <PlansPanel
103
+ filters={filters}
104
+ plans={plans}
105
+ filterValues={filterValues}
106
+ onFilterChange={(id, value) =>
107
+ setFilterValues((prev) => ({ ...prev, [id]: value }))
108
+ }
109
+ onSelectPlan={(planId) => checkout(planId)}
110
+ />;
111
+ ```
112
+
113
+ ### SubscriptionPanel
46
114
 
47
115
  ```tsx
48
- import { SubscriptionCard } from '@ttoss/react-billing';
116
+ import { SubscriptionPanel } from '@ttoss/react-billing';
49
117
 
50
118
  export const Example = () => (
51
- <SubscriptionCard
119
+ <SubscriptionPanel
52
120
  planName="Pro"
53
121
  price={{ value: 'R$ 99,00', interval: 'month' }}
54
122
  status={{ status: 'active', interval: 'Monthly' }}
@@ -76,5 +144,6 @@ export const Example = () => (
76
144
  ## Exports
77
145
 
78
146
  - `PlanCard`
79
- - `SubscriptionCard`
80
- - Types: `PlanCardProps`, `PlanCardVariant`, `PlanCardPrice`, `PlanCardMetadata`, `PlanCardButtonProps`, `PlanCardMetadataSlotService` (and related slot types)
147
+ - `PlansPanel`
148
+ - `SubscriptionPanel`
149
+ - Types: `PlanCardProps`, `PlanCardVariant`, `PlanCardPrice`, `PlanCardMetadata`, `PlanCardButtonProps`, `PlanCardMetadataSlotService` (and related slot types), `PlansPanelProps`, `PlansPanelPlan`, `PlansPanelFilter`, `PlansPanelFilterOption`
package/dist/esm/index.js CHANGED
@@ -345,10 +345,117 @@ var PlanCard = /* @__PURE__ */__name(props => {
345
345
  }));
346
346
  }, "PlanCard");
347
347
 
348
+ // src/components/plansPanel/PlansPanel.tsx
349
+ import { Flex as Flex6, SegmentedControl, Stack as Stack3, Text as Text5 } from "@ttoss/ui";
350
+ import * as React3 from "react";
351
+ var PlansPanel = /* @__PURE__ */__name(({
352
+ filters = [],
353
+ plans,
354
+ activePlanId,
355
+ onSelectPlan,
356
+ filterValues: controlledFilterValues,
357
+ onFilterChange
358
+ }) => {
359
+ const [internalFilterValues, setInternalFilterValues] = React3.useState(() => {
360
+ const initial = {};
361
+ for (const filter of filters) {
362
+ if (filter.defaultValue !== void 0) {
363
+ initial[filter.id] = filter.defaultValue;
364
+ }
365
+ }
366
+ return initial;
367
+ });
368
+ const isControlled = controlledFilterValues !== void 0;
369
+ const activeFilterValues = isControlled ? controlledFilterValues : internalFilterValues;
370
+ const handleFilterChange = /* @__PURE__ */__name((id, value) => {
371
+ const strValue = String(value);
372
+ if (isControlled) {
373
+ onFilterChange?.(id, strValue);
374
+ } else {
375
+ setInternalFilterValues(prev => {
376
+ return {
377
+ ...prev,
378
+ [id]: strValue
379
+ };
380
+ });
381
+ }
382
+ }, "handleFilterChange");
383
+ const visiblePlans = plans.filter(plan => {
384
+ if (!plan.filterValues || Object.keys(plan.filterValues).length === 0) {
385
+ return true;
386
+ }
387
+ return Object.entries(activeFilterValues).every(([filterId, filterValue]) => {
388
+ if (!plan.filterValues || !(filterId in plan.filterValues)) {
389
+ return true;
390
+ }
391
+ return plan.filterValues[filterId] === filterValue;
392
+ });
393
+ });
394
+ return /* @__PURE__ */React3.createElement(Stack3, {
395
+ sx: {
396
+ gap: "6",
397
+ width: "full",
398
+ alignItems: "center"
399
+ }
400
+ }, filters.length > 0 && /* @__PURE__ */React3.createElement(Stack3, {
401
+ sx: {
402
+ gap: "4",
403
+ alignItems: "center"
404
+ }
405
+ }, filters.map(filter => {
406
+ return /* @__PURE__ */React3.createElement(Stack3, {
407
+ key: filter.id,
408
+ sx: {
409
+ gap: "2",
410
+ alignItems: "center"
411
+ }
412
+ }, filter.label && /* @__PURE__ */React3.createElement(Text5, {
413
+ sx: {
414
+ fontWeight: "semibold",
415
+ color: "display.text.primary.default"
416
+ }
417
+ }, filter.label), /* @__PURE__ */React3.createElement(SegmentedControl, {
418
+ options: filter.options,
419
+ value: isControlled ? controlledFilterValues[filter.id] ?? filter.defaultValue : activeFilterValues[filter.id],
420
+ defaultValue: isControlled ? void 0 : filter.defaultValue,
421
+ onChange: /* @__PURE__ */__name(value => {
422
+ return handleFilterChange(filter.id, value);
423
+ }, "onChange")
424
+ }));
425
+ })), /* @__PURE__ */React3.createElement(Flex6, {
426
+ sx: {
427
+ flexWrap: "wrap",
428
+ gap: "4",
429
+ width: "full",
430
+ justifyContent: "center"
431
+ }
432
+ }, visiblePlans.map(plan => {
433
+ const isActive = plan.id === activePlanId;
434
+ const effectiveVariant = isActive ? "secondary" : plan.variant ?? "primary";
435
+ return /* @__PURE__ */React3.createElement(PlanCard, {
436
+ key: plan.id,
437
+ title: plan.title,
438
+ subtitle: plan.subtitle,
439
+ price: plan.price,
440
+ features: plan.features,
441
+ metadata: plan.metadata,
442
+ variant: effectiveVariant,
443
+ topTag: plan.topTag,
444
+ buttonProps: {
445
+ ...plan.buttonProps,
446
+ onClick: /* @__PURE__ */__name(e => {
447
+ onSelectPlan?.(plan.id);
448
+ plan.buttonProps?.onClick?.(e);
449
+ }, "onClick")
450
+ }
451
+ });
452
+ })));
453
+ }, "PlansPanel");
454
+
348
455
  // src/components/subscriptionPanel/SubscriptionPanel.tsx
349
456
  import { EnhancedTitle } from "@ttoss/components/EnhancedTitle";
350
457
  import { MetricCard } from "@ttoss/components/MetricCard";
351
- import { Card as Card2, Flex as Flex7, Spinner } from "@ttoss/ui";
458
+ import { Card as Card2, Flex as Flex8, Spinner } from "@ttoss/ui";
352
459
 
353
460
  // src/components/subscriptionPanel/SubscriptionPanel.styles.ts
354
461
  import { keyframes } from "@ttoss/ui";
@@ -435,14 +542,14 @@ var SubscriptionPanelAccentBarSx = getSubscriptionPanelAccentBarSx("spotlight-ac
435
542
 
436
543
  // src/components/subscriptionPanel/SubscriptionPanelActionsSlot.tsx
437
544
  import { Icon as Icon3 } from "@ttoss/react-icons";
438
- import { Button as Button2, Flex as Flex6 } from "@ttoss/ui";
545
+ import { Button as Button2, Flex as Flex7 } from "@ttoss/ui";
439
546
  var SubscriptionPanelActionsSlot = /* @__PURE__ */__name(({
440
547
  actions
441
548
  }) => {
442
549
  if (actions.length === 0) {
443
550
  return null;
444
551
  }
445
- return /* @__PURE__ */React.createElement(Flex6, {
552
+ return /* @__PURE__ */React.createElement(Flex7, {
446
553
  sx: {
447
554
  gap: "3",
448
555
  flexDirection: ["column", "row"],
@@ -516,16 +623,16 @@ var SubscriptionPanel = /* @__PURE__ */__name(({
516
623
  sx: {
517
624
  width: "full"
518
625
  }
519
- }, /* @__PURE__ */React.createElement(Flex7, {
626
+ }, /* @__PURE__ */React.createElement(Flex8, {
520
627
  sx: getSubscriptionPanelAccentBarSx(variant)
521
- }), /* @__PURE__ */React.createElement(Flex7, {
628
+ }), /* @__PURE__ */React.createElement(Flex8, {
522
629
  sx: {
523
630
  width: "full",
524
631
  paddingX: "6",
525
632
  paddingY: "6",
526
633
  flexDirection: "column"
527
634
  }
528
- }, /* @__PURE__ */React.createElement(Flex7, {
635
+ }, /* @__PURE__ */React.createElement(Flex8, {
529
636
  sx: {
530
637
  paddingY: "10",
531
638
  paddingX: "3",
@@ -583,7 +690,7 @@ var SubscriptionPanel = /* @__PURE__ */__name(({
583
690
  })
584
691
  }), actions.length > 0 && /* @__PURE__ */React.createElement(SubscriptionPanelActionsSlot, {
585
692
  actions
586
- })), metrics.length > 0 && /* @__PURE__ */React.createElement(React.Fragment, null, /* @__PURE__ */React.createElement(Flex7, {
693
+ })), metrics.length > 0 && /* @__PURE__ */React.createElement(React.Fragment, null, /* @__PURE__ */React.createElement(Flex8, {
587
694
  sx: {
588
695
  paddingY: "10",
589
696
  paddingX: "3",
@@ -600,4 +707,4 @@ var SubscriptionPanel = /* @__PURE__ */__name(({
600
707
  });
601
708
  })))));
602
709
  }, "SubscriptionPanel");
603
- export { PlanCard, SubscriptionPanel };
710
+ export { PlanCard, PlansPanel, SubscriptionPanel };
package/dist/index.d.ts CHANGED
@@ -54,6 +54,133 @@ interface PlanCardProps extends Omit<CardProps, 'children'> {
54
54
  }
55
55
  declare const PlanCard: (props: PlanCardProps) => react_jsx_runtime.JSX.Element;
56
56
 
57
+ /**
58
+ * A single option inside a PlansPanel filter.
59
+ */
60
+ interface PlansPanelFilterOption {
61
+ /** Display label shown in the SegmentedControl. */
62
+ label: string;
63
+ /** Value used for filtering plans. */
64
+ value: string;
65
+ }
66
+ /**
67
+ * Defines one filter dimension rendered as a SegmentedControl above the plans grid.
68
+ */
69
+ interface PlansPanelFilter {
70
+ /** Unique identifier for this filter dimension. */
71
+ id: string;
72
+ /** Optional label rendered above the SegmentedControl. */
73
+ label?: string;
74
+ /** List of selectable options. */
75
+ options: PlansPanelFilterOption[];
76
+ /** Initial value used in uncontrolled mode. */
77
+ defaultValue?: string;
78
+ }
79
+ /**
80
+ * A plan displayed inside the PlansPanel.
81
+ */
82
+ interface PlansPanelPlan {
83
+ /** Unique plan identifier. */
84
+ id: string;
85
+ /**
86
+ * Maps each filter id to the value this plan belongs to.
87
+ * Plans are only shown when all active filter values match.
88
+ */
89
+ filterValues?: Record<string, string>;
90
+ /** Plan title (e.g. "Basic", "Pro"). */
91
+ title: string;
92
+ /** Optional subtitle displayed below the title. */
93
+ subtitle?: string;
94
+ /** Pricing information. */
95
+ price: PlanCardPrice;
96
+ /** Feature list items. */
97
+ features?: unknown[];
98
+ /** Optional metadata sections. */
99
+ metadata?: PlanCardMetadata;
100
+ /** Props forwarded to the CTA button. onClick is merged with onSelectPlan. */
101
+ buttonProps?: PlanCardButtonProps;
102
+ /** Visual variant for the PlanCard. Active plans override this with "secondary". */
103
+ variant?: PlanCardVariant;
104
+ /** Optional content rendered at the top of the PlanCard. */
105
+ topTag?: React.ReactNode;
106
+ }
107
+ /**
108
+ * Props for the PlansPanel component.
109
+ */
110
+ interface PlansPanelProps {
111
+ /**
112
+ * Filter dimensions rendered as SegmentedControl rows above the plan grid.
113
+ * Each dimension independently narrows the visible set of plans.
114
+ */
115
+ filters?: PlansPanelFilter[];
116
+ /** All available plans. Only plans matching every active filter value are shown. */
117
+ plans: PlansPanelPlan[];
118
+ /** ID of the currently active/subscribed plan. That card is highlighted. */
119
+ activePlanId?: string;
120
+ /**
121
+ * Called when the user clicks the CTA button of a plan.
122
+ * @param planId - The id of the selected plan.
123
+ */
124
+ onSelectPlan?: (planId: string) => void;
125
+ /**
126
+ * Controlled filter values (record of filterId → selected value).
127
+ * When provided the component operates in controlled mode and internal
128
+ * state is not used.
129
+ */
130
+ filterValues?: Record<string, string>;
131
+ /**
132
+ * Called when a filter value changes in controlled mode.
133
+ * @param id - The filter id that changed.
134
+ * @param value - The new selected value.
135
+ */
136
+ onFilterChange?: (id: string, value: string) => void;
137
+ }
138
+
139
+ /**
140
+ * PlansPanel renders a grid of PlanCard components with optional multi-dimensional
141
+ * SegmentedControl filters above them.
142
+ *
143
+ * Each filter dimension independently narrows the visible set of plan cards.
144
+ * The component supports both uncontrolled mode (internal state from `defaultValue`)
145
+ * and controlled mode (via `filterValues` + `onFilterChange`).
146
+ *
147
+ * @example
148
+ * ```tsx
149
+ * // Uncontrolled — component manages filter state internally
150
+ * <PlansPanel
151
+ * filters={[
152
+ * {
153
+ * id: 'interval',
154
+ * label: 'Billing Frequency',
155
+ * options: [
156
+ * { label: 'Monthly', value: 'monthly' },
157
+ * { label: 'Yearly', value: 'yearly' },
158
+ * ],
159
+ * defaultValue: 'monthly',
160
+ * },
161
+ * ]}
162
+ * plans={plans}
163
+ * activePlanId="plan_basic_monthly"
164
+ * onSelectPlan={(planId) => console.log(planId)}
165
+ * />
166
+ * ```
167
+ *
168
+ * @example
169
+ * ```tsx
170
+ * // Controlled — consumer owns filter state
171
+ * <PlansPanel
172
+ * filters={filters}
173
+ * filterValues={{ interval: 'yearly' }}
174
+ * onFilterChange={(id, value) =>
175
+ * setFilterValues((prev) => ({ ...prev, [id]: value }))
176
+ * }
177
+ * plans={plans}
178
+ * onSelectPlan={(planId) => checkout(planId)}
179
+ * />
180
+ * ```
181
+ */
182
+ declare const PlansPanel: ({ filters, plans, activePlanId, onSelectPlan, filterValues: controlledFilterValues, onFilterChange, }: PlansPanelProps) => react_jsx_runtime.JSX.Element;
183
+
57
184
  type SubscriptionPanelVariant = 'spotlight-accent' | 'spotlight-primary' | 'primary' | 'secondary' | 'accent';
58
185
 
59
186
  /**
@@ -304,4 +431,4 @@ interface SubscriptionPanelProps {
304
431
  */
305
432
  declare const SubscriptionPanel: ({ variant, icon, planName, price, status, features, actions, metrics, isLoading, }: SubscriptionPanelProps) => react_jsx_runtime.JSX.Element;
306
433
 
307
- export { PlanCard, type PlanCardButtonProps, type PlanCardMetadata, type PlanCardMetadataSlotParameter, type PlanCardMetadataSlotProps, type PlanCardMetadataSlotService, type PlanCardMetadataSlotVariant, type PlanCardPrice, type PlanCardProps, type PlanCardVariant, SubscriptionPanel };
434
+ export { PlanCard, type PlanCardButtonProps, type PlanCardMetadata, type PlanCardMetadataSlotParameter, type PlanCardMetadataSlotProps, type PlanCardMetadataSlotService, type PlanCardMetadataSlotVariant, type PlanCardPrice, type PlanCardProps, type PlanCardVariant, PlansPanel, type PlansPanelFilter, type PlansPanelFilterOption, type PlansPanelPlan, type PlansPanelProps, SubscriptionPanel };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttoss/react-billing",
3
- "version": "0.3.5",
3
+ "version": "0.4.0",
4
4
  "description": "Billing UI components for React apps.",
5
5
  "author": "Giovane Guimarães",
6
6
  "repository": {
@@ -20,9 +20,9 @@
20
20
  ],
21
21
  "sideEffects": false,
22
22
  "dependencies": {
23
- "@ttoss/react-icons": "^0.7.5",
24
- "@ttoss/components": "^2.14.5",
25
- "@ttoss/ui": "^6.9.5"
23
+ "@ttoss/components": "^2.14.7",
24
+ "@ttoss/react-icons": "^0.7.7",
25
+ "@ttoss/ui": "^6.9.7"
26
26
  },
27
27
  "peerDependencies": {
28
28
  "react": ">=16.8.0"
@@ -33,8 +33,8 @@
33
33
  "jest": "^30.3.0",
34
34
  "react": "^19.2.4",
35
35
  "tsup": "^8.5.1",
36
- "@ttoss/config": "^1.37.5",
37
- "@ttoss/test-utils": "^4.2.5"
36
+ "@ttoss/config": "^1.37.7",
37
+ "@ttoss/test-utils": "^4.2.7"
38
38
  },
39
39
  "keywords": [
40
40
  "React",