@carlonicora/nextjs-jsonapi 1.32.2 → 1.33.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.
Files changed (97) hide show
  1. package/dist/{BlockNoteEditor-76UO4RKT.js → BlockNoteEditor-CUXI6ZTZ.js} +13 -13
  2. package/dist/{BlockNoteEditor-76UO4RKT.js.map → BlockNoteEditor-CUXI6ZTZ.js.map} +1 -1
  3. package/dist/{BlockNoteEditor-EWOPRRJN.mjs → BlockNoteEditor-UTZ7F23J.mjs} +3 -3
  4. package/dist/billing/index.d.mts +6 -3
  5. package/dist/billing/index.d.ts +6 -3
  6. package/dist/billing/index.js +461 -380
  7. package/dist/billing/index.js.map +1 -1
  8. package/dist/billing/index.mjs +113 -32
  9. package/dist/billing/index.mjs.map +1 -1
  10. package/dist/{chunk-CCCAOXA2.mjs → chunk-53WT73E6.mjs} +55 -63
  11. package/dist/chunk-53WT73E6.mjs.map +1 -0
  12. package/dist/{chunk-YCP2OMFD.mjs → chunk-HWQBSVBT.mjs} +40 -7
  13. package/dist/chunk-HWQBSVBT.mjs.map +1 -0
  14. package/dist/{chunk-SS6ZCTYH.js → chunk-RSHCU3TI.js} +508 -516
  15. package/dist/chunk-RSHCU3TI.js.map +1 -0
  16. package/dist/{chunk-KYG2PIRB.js → chunk-TZRAOUAR.js} +118 -85
  17. package/dist/chunk-TZRAOUAR.js.map +1 -0
  18. package/dist/client/index.d.mts +7 -6
  19. package/dist/client/index.d.ts +7 -6
  20. package/dist/client/index.js +3 -3
  21. package/dist/client/index.mjs +2 -2
  22. package/dist/components/index.d.mts +4 -3
  23. package/dist/components/index.d.ts +4 -3
  24. package/dist/components/index.js +3 -3
  25. package/dist/components/index.mjs +2 -2
  26. package/dist/{config-CHwoRDOp.d.ts → config-BbaBV_yk.d.ts} +1 -1
  27. package/dist/{config-DiWyJzk9.d.mts → config-BxwhHdCD.d.mts} +1 -1
  28. package/dist/{content.interface-BSpowEiW.d.mts → content.interface-CWV0q4lZ.d.mts} +1 -1
  29. package/dist/{content.interface-DFQ7mkpL.d.ts → content.interface-CgUu4771.d.ts} +1 -1
  30. package/dist/contexts/index.d.mts +3 -2
  31. package/dist/contexts/index.d.ts +3 -2
  32. package/dist/contexts/index.js +3 -3
  33. package/dist/contexts/index.mjs +2 -2
  34. package/dist/core/index.d.mts +17 -8
  35. package/dist/core/index.d.ts +17 -8
  36. package/dist/core/index.js +6 -2
  37. package/dist/core/index.js.map +1 -1
  38. package/dist/core/index.mjs +5 -1
  39. package/dist/feature.interface-BxFFOPNq.d.mts +19 -0
  40. package/dist/feature.interface-CIWxo8NP.d.ts +19 -0
  41. package/dist/index.d.mts +7 -6
  42. package/dist/index.d.ts +7 -6
  43. package/dist/index.js +6 -2
  44. package/dist/index.js.map +1 -1
  45. package/dist/index.mjs +5 -1
  46. package/dist/{notification.interface-D5MbtfZK.d.mts → notification.interface-DIln2r7X.d.mts} +2 -17
  47. package/dist/{notification.interface-CmKmObIU.d.ts → notification.interface-XARGKJAq.d.ts} +2 -17
  48. package/dist/{s3.service-CoC0k0iu.d.ts → s3.service-DcqkGrKD.d.ts} +12 -3
  49. package/dist/{s3.service-Duh9HW2n.d.mts → s3.service-ag6M_7GO.d.mts} +12 -3
  50. package/dist/scripts/generate-web-module/templates/pages/detail-page.template.js +1 -1
  51. package/dist/scripts/generate-web-module/templates/pages/detail-page.template.js.map +1 -1
  52. package/dist/scripts/generate-web-module/templates/pages/list-page.template.js +1 -1
  53. package/dist/scripts/generate-web-module/templates/pages/list-page.template.js.map +1 -1
  54. package/dist/server/index.d.mts +4 -3
  55. package/dist/server/index.d.ts +4 -3
  56. package/dist/server/index.js +3 -3
  57. package/dist/server/index.mjs +1 -1
  58. package/dist/{stripe-subscription.interface-BaZUngWe.d.ts → stripe-subscription.interface-Dm__xmvE.d.ts} +3 -0
  59. package/dist/{stripe-subscription.interface-Cm_It1fz.d.mts → stripe-subscription.interface-_VWPY2AA.d.mts} +3 -0
  60. package/dist/{useDataListRetriever-futhx3OP.d.mts → useDataListRetriever-BqJSFBck.d.mts} +1 -0
  61. package/dist/{useDataListRetriever-futhx3OP.d.ts → useDataListRetriever-BqJSFBck.d.ts} +1 -0
  62. package/dist/{useSocket-DUqGoPya.d.mts → useSocket-BILAdmZ0.d.mts} +1 -1
  63. package/dist/{useSocket-QuHa0ZmO.d.ts → useSocket-awibcC9B.d.ts} +1 -1
  64. package/package.json +1 -1
  65. package/scripts/generate-web-module/templates/pages/detail-page.template.ts +1 -1
  66. package/scripts/generate-web-module/templates/pages/list-page.template.ts +1 -1
  67. package/src/components/forms/DatePickerPopover.tsx +17 -15
  68. package/src/components/tables/ContentListTable.tsx +2 -2
  69. package/src/core/abstracts/AbstractService.ts +25 -0
  70. package/src/core/abstracts/ClientAbstractService.ts +10 -0
  71. package/src/features/billing/components/containers/BillingDashboardContainer.tsx +4 -1
  72. package/src/features/billing/stripe-invoice/components/details/InvoiceDetails.tsx +1 -1
  73. package/src/features/billing/stripe-invoice/components/lists/InvoicesList.tsx +1 -1
  74. package/src/features/billing/stripe-price/components/forms/PriceEditor.tsx +85 -1
  75. package/src/features/billing/stripe-price/data/stripe-price.interface.ts +3 -0
  76. package/src/features/billing/stripe-price/data/stripe-price.ts +18 -0
  77. package/src/features/billing/stripe-subscription/components/containers/SubscriptionsContainer.tsx +5 -2
  78. package/src/features/billing/stripe-subscription/components/forms/CancelSubscriptionDialog.tsx +5 -18
  79. package/src/features/billing/stripe-subscription/components/lists/SubscriptionsList.tsx +1 -1
  80. package/src/features/billing/stripe-subscription/components/widgets/ProductPricingList.tsx +16 -12
  81. package/src/features/billing/stripe-subscription/components/wizards/SubscriptionWizard.tsx +14 -3
  82. package/src/features/billing/stripe-subscription/components/wizards/WizardStepPlanSelection.tsx +14 -9
  83. package/src/features/billing/stripe-subscription/components/wizards/WizardStepReview.tsx +1 -1
  84. package/src/features/billing/stripe-subscription/data/stripe-subscription.service.ts +2 -2
  85. package/src/features/billing/stripe-usage/components/lists/UsageHistoryTable.tsx +1 -1
  86. package/src/features/company/components/details/TokenStatusIndicator.tsx +4 -6
  87. package/src/features/company/hooks/useSubscriptionStatus.ts +18 -0
  88. package/src/features/content/hooks/useContentTableStructure.tsx +1 -1
  89. package/src/features/user/contexts/CurrentUserContext.tsx +2 -1
  90. package/src/features/user/hooks/useUserTableStructure.tsx +1 -1
  91. package/src/hooks/useDataListRetriever.ts +13 -0
  92. package/src/shadcnui/ui/table.tsx +20 -49
  93. package/dist/chunk-CCCAOXA2.mjs.map +0 -1
  94. package/dist/chunk-KYG2PIRB.js.map +0 -1
  95. package/dist/chunk-SS6ZCTYH.js.map +0 -1
  96. package/dist/chunk-YCP2OMFD.mjs.map +0 -1
  97. /package/dist/{BlockNoteEditor-EWOPRRJN.mjs.map → BlockNoteEditor-UTZ7F23J.mjs.map} +0 -0
@@ -1,4 +1,5 @@
1
1
  import { A as ApiDataInterface } from './ApiDataInterface-DPP8s46n.js';
2
+ import { F as FeatureInterface } from './feature.interface-CIWxo8NP.js';
2
3
 
3
4
  interface StripeUsageInterface extends ApiDataInterface {
4
5
  get subscriptionId(): string;
@@ -151,6 +152,7 @@ interface StripePriceInterface extends ApiDataInterface {
151
152
  get description(): string | undefined;
152
153
  get features(): string[] | undefined;
153
154
  get token(): number | undefined;
155
+ get priceFeatures(): FeatureInterface[];
154
156
  }
155
157
  interface PriceRecurring {
156
158
  interval: "day" | "week" | "month" | "year";
@@ -172,6 +174,7 @@ type StripePriceInput = {
172
174
  description?: string;
173
175
  features?: string[];
174
176
  token?: number;
177
+ featureIds?: string[];
175
178
  };
176
179
 
177
180
  declare enum SubscriptionStatus {
@@ -1,4 +1,5 @@
1
1
  import { A as ApiDataInterface } from './ApiDataInterface-DPP8s46n.mjs';
2
+ import { F as FeatureInterface } from './feature.interface-BxFFOPNq.mjs';
2
3
 
3
4
  interface StripeUsageInterface extends ApiDataInterface {
4
5
  get subscriptionId(): string;
@@ -151,6 +152,7 @@ interface StripePriceInterface extends ApiDataInterface {
151
152
  get description(): string | undefined;
152
153
  get features(): string[] | undefined;
153
154
  get token(): number | undefined;
155
+ get priceFeatures(): FeatureInterface[];
154
156
  }
155
157
  interface PriceRecurring {
156
158
  interval: "day" | "week" | "month" | "year";
@@ -172,6 +174,7 @@ type StripePriceInput = {
172
174
  description?: string;
173
175
  features?: string[];
174
176
  token?: number;
177
+ featureIds?: string[];
175
178
  };
176
179
 
177
180
  declare enum SubscriptionStatus {
@@ -8,6 +8,7 @@ type DataListRetriever<T> = {
8
8
  setReady: (state: boolean) => void;
9
9
  isLoaded: boolean;
10
10
  data: T[] | undefined;
11
+ total?: number;
11
12
  next?: (onlyNewRecords?: boolean) => Promise<void>;
12
13
  previous?: (onlyNewRecords?: boolean) => Promise<void>;
13
14
  search: (search: string) => Promise<void>;
@@ -8,6 +8,7 @@ type DataListRetriever<T> = {
8
8
  setReady: (state: boolean) => void;
9
9
  isLoaded: boolean;
10
10
  data: T[] | undefined;
11
+ total?: number;
11
12
  next?: (onlyNewRecords?: boolean) => Promise<void>;
12
13
  previous?: (onlyNewRecords?: boolean) => Promise<void>;
13
14
  search: (search: string) => Promise<void>;
@@ -1,4 +1,4 @@
1
- import { N as NotificationInterface } from './notification.interface-D5MbtfZK.mjs';
1
+ import { N as NotificationInterface } from './notification.interface-DIln2r7X.mjs';
2
2
 
3
3
  interface UseSocketOptions {
4
4
  token: string;
@@ -1,4 +1,4 @@
1
- import { N as NotificationInterface } from './notification.interface-CmKmObIU.js';
1
+ import { N as NotificationInterface } from './notification.interface-XARGKJAq.js';
2
2
 
3
3
  interface UseSocketOptions {
4
4
  token: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carlonicora/nextjs-jsonapi",
3
- "version": "1.32.2",
3
+ "version": "1.33.0",
4
4
  "description": "Next.js JSON:API client with server/client support and caching",
5
5
  "author": "Carlo Nicora",
6
6
  "license": "GPL-3.0-or-later",
@@ -51,7 +51,7 @@ export default async function ${names.pascalCase}Page(props: { params: Promise<{
51
51
  const params = await props.params;
52
52
  const ${names.camelCase}: ${names.pascalCase}Interface = await getCached${names.pascalCase}(params.id);
53
53
 
54
- ServerSession.checkPermission({ module: Modules.${names.pascalCase}, action: Action.Read, data: ${names.camelCase} });
54
+ await ServerSession.checkPermission({ module: Modules.${names.pascalCase}, action: Action.Read, data: ${names.camelCase} });
55
55
 
56
56
  return (
57
57
  <${names.pascalCase}Provider dehydrated${names.pascalCase}={${names.camelCase}.dehydrate()}>
@@ -23,7 +23,7 @@ import { Action } from "@carlonicora/nextjs-jsonapi/core";
23
23
  import { ServerSession } from "@carlonicora/nextjs-jsonapi/server";
24
24
 
25
25
  export default async function ${names.pluralPascal}ListPage() {
26
- ServerSession.checkPermission({ module: Modules.${names.pascalCase}, action: Action.Read });
26
+ await ServerSession.checkPermission({ module: Modules.${names.pascalCase}, action: Action.Read });
27
27
 
28
28
  return (
29
29
  <${names.pascalCase}Provider>
@@ -127,7 +127,7 @@ export const DatePickerPopover = ({
127
127
  return (
128
128
  <Popover open={isOpen} onOpenChange={setIsOpen}>
129
129
  <PopoverTrigger>{children}</PopoverTrigger>
130
- <PopoverContent className={cn("w-auto p-0", className)} align={align} onClick={(e) => e.stopPropagation()}>
130
+ <PopoverContent className={cn("p-0", className)} align={align} onClick={(e) => e.stopPropagation()}>
131
131
  <div className="p-3">
132
132
  {/* Manual Input */}
133
133
  <div className="relative mb-3">
@@ -169,8 +169,8 @@ export const DatePickerPopover = ({
169
169
  setDisplayMonth(newDate);
170
170
  }}
171
171
  >
172
- <SelectTrigger className="w-[130px]">
173
- <SelectValue />
172
+ <SelectTrigger className="flex-1">
173
+ <SelectValue>{monthNames[displayMonth.getMonth()]}</SelectValue>
174
174
  </SelectTrigger>
175
175
  <SelectContent>
176
176
  {monthNames.map((month, index) => (
@@ -190,8 +190,8 @@ export const DatePickerPopover = ({
190
190
  setDisplayMonth(newDate);
191
191
  }}
192
192
  >
193
- <SelectTrigger className="w-[80px]">
194
- <SelectValue />
193
+ <SelectTrigger className="flex-1">
194
+ <SelectValue>{displayMonth.getFullYear()}</SelectValue>
195
195
  </SelectTrigger>
196
196
  <SelectContent>
197
197
  {yearOptions.reverse().map((year) => (
@@ -204,16 +204,18 @@ export const DatePickerPopover = ({
204
204
  </div>
205
205
 
206
206
  {/* Calendar */}
207
- <Calendar
208
- mode="single"
209
- selected={value}
210
- onSelect={handleCalendarSelect}
211
- disabled={(date) => (minDate && date < minDate ? true : false)}
212
- locale={dateFnsLocale}
213
- weekStartsOn={1}
214
- month={displayMonth}
215
- onMonthChange={setDisplayMonth}
216
- />
207
+ <div className="flex justify-center">
208
+ <Calendar
209
+ mode="single"
210
+ selected={value}
211
+ onSelect={handleCalendarSelect}
212
+ disabled={(date) => (minDate && date < minDate ? true : false)}
213
+ locale={dateFnsLocale}
214
+ weekStartsOn={1}
215
+ month={displayMonth}
216
+ onMonthChange={setDisplayMonth}
217
+ />
218
+ </div>
217
219
  </div>
218
220
  </PopoverContent>
219
221
  </Popover>
@@ -74,7 +74,7 @@ export const ContentListTable = memo(function ContentListTable(props: ContentLis
74
74
 
75
75
  return (
76
76
  <div className="flex w-full flex-col">
77
- <div className="overflow-hidden rounded-md border">
77
+ <div className="overflow-clip rounded-md border">
78
78
  <Table>
79
79
  <TableHeader className="bg-muted font-semibold">
80
80
  {props.title && (
@@ -140,7 +140,7 @@ export const ContentListTable = memo(function ContentListTable(props: ContentLis
140
140
  </Button>
141
141
  {data.pageInfo && (
142
142
  <span className="text-muted-foreground text-xs">
143
- {data.pageInfo.startItem}-{data.pageInfo.endItem}
143
+ {`${data.pageInfo.startItem}-${data.pageInfo.endItem}${data.total ? ` of ${data.total}` : ""}`}
144
144
  </span>
145
145
  )}
146
146
  <Button
@@ -21,6 +21,21 @@ export interface SelfRef {
21
21
  self?: string;
22
22
  }
23
23
 
24
+ export interface TotalRef {
25
+ total?: number;
26
+ }
27
+
28
+ // Store the last total from any API call - accessible by hooks
29
+ let lastApiTotal: number | undefined = undefined;
30
+
31
+ export function getLastApiTotal(): number | undefined {
32
+ return lastApiTotal;
33
+ }
34
+
35
+ export function clearLastApiTotal(): void {
36
+ lastApiTotal = undefined;
37
+ }
38
+
24
39
  let globalErrorHandler: ((status: number, message: string) => void) | null = null;
25
40
 
26
41
  /**
@@ -79,6 +94,7 @@ export abstract class AbstractService {
79
94
  next?: NextRef;
80
95
  previous?: PreviousRef;
81
96
  self?: SelfRef;
97
+ total?: TotalRef;
82
98
  }): Promise<T> {
83
99
  return await this.callApi<T>({
84
100
  method: HttpMethod.GET,
@@ -87,6 +103,7 @@ export abstract class AbstractService {
87
103
  next: params.next,
88
104
  previous: params.previous,
89
105
  self: params.self,
106
+ total: params.total,
90
107
  });
91
108
  }
92
109
 
@@ -99,6 +116,7 @@ export abstract class AbstractService {
99
116
  next?: NextRef;
100
117
  previous?: PreviousRef;
101
118
  self?: SelfRef;
119
+ total?: TotalRef;
102
120
  }): Promise<T> {
103
121
  return await this.callApi<T>({
104
122
  method: HttpMethod.GET,
@@ -107,6 +125,7 @@ export abstract class AbstractService {
107
125
  next: params.next,
108
126
  previous: params.previous,
109
127
  self: params.self,
128
+ total: params.total,
110
129
  });
111
130
  }
112
131
 
@@ -123,6 +142,7 @@ export abstract class AbstractService {
123
142
  next?: NextRef;
124
143
  previous?: PreviousRef;
125
144
  self?: SelfRef;
145
+ total?: TotalRef;
126
146
  responseType?: ApiRequestDataTypeInterface;
127
147
  files?: { [key: string]: File | Blob } | File | Blob;
128
148
  }): Promise<T> {
@@ -214,6 +234,11 @@ export abstract class AbstractService {
214
234
  if (apiResponse.next && params.next) params.next.next = apiResponse.next;
215
235
  if (apiResponse.prev && params.previous) params.previous.previous = apiResponse.prev;
216
236
  if (apiResponse.self && params.self) params.self.self = apiResponse.self;
237
+ // Always store total for hooks to access, and also populate ref if provided
238
+ if (apiResponse.meta?.total !== undefined) {
239
+ lastApiTotal = apiResponse.meta.total;
240
+ if (params.total) params.total.total = apiResponse.meta.total;
241
+ }
217
242
 
218
243
  return apiResponse.data as T;
219
244
  }
@@ -31,6 +31,10 @@ export interface ClientSelfRef {
31
31
  self?: string;
32
32
  }
33
33
 
34
+ export interface ClientTotalRef {
35
+ total?: number;
36
+ }
37
+
34
38
  let globalErrorHandler: ((status: number, message: string) => void) | null = null;
35
39
 
36
40
  /**
@@ -92,6 +96,7 @@ export abstract class ClientAbstractService {
92
96
  next?: ClientNextRef;
93
97
  previous?: ClientPreviousRef;
94
98
  self?: ClientSelfRef;
99
+ total?: ClientTotalRef;
95
100
  }): Promise<T> {
96
101
  return await this.callApi<T>({
97
102
  method: ClientHttpMethod.GET,
@@ -100,6 +105,7 @@ export abstract class ClientAbstractService {
100
105
  next: params.next,
101
106
  previous: params.previous,
102
107
  self: params.self,
108
+ total: params.total,
103
109
  });
104
110
  }
105
111
 
@@ -112,6 +118,7 @@ export abstract class ClientAbstractService {
112
118
  next?: ClientNextRef;
113
119
  previous?: ClientPreviousRef;
114
120
  self?: ClientSelfRef;
121
+ total?: ClientTotalRef;
115
122
  }): Promise<T> {
116
123
  return await this.callApi<T>({
117
124
  method: ClientHttpMethod.GET,
@@ -120,6 +127,7 @@ export abstract class ClientAbstractService {
120
127
  next: params.next,
121
128
  previous: params.previous,
122
129
  self: params.self,
130
+ total: params.total,
123
131
  });
124
132
  }
125
133
 
@@ -136,6 +144,7 @@ export abstract class ClientAbstractService {
136
144
  next?: ClientNextRef;
137
145
  previous?: ClientPreviousRef;
138
146
  self?: ClientSelfRef;
147
+ total?: ClientTotalRef;
139
148
  responseType?: ApiRequestDataTypeInterface;
140
149
  files?: { [key: string]: File | Blob } | File | Blob;
141
150
  }): Promise<T> {
@@ -216,6 +225,7 @@ export abstract class ClientAbstractService {
216
225
  if (apiResponse.next && params.next) params.next.next = apiResponse.next;
217
226
  if (apiResponse.prev && params.previous) params.previous.previous = apiResponse.prev;
218
227
  if (apiResponse.self && params.self) params.self.self = apiResponse.self;
228
+ if (apiResponse.meta?.total !== undefined && params.total) params.total.total = apiResponse.meta.total;
219
229
 
220
230
  return apiResponse.data as T;
221
231
  }
@@ -442,7 +442,10 @@ export function BillingDashboardContainer() {
442
442
  onOpenChange={handleModalClose}
443
443
  title={getModalTitle("subscriptions")}
444
444
  >
445
- <SubscriptionsContainer onOpenWizard={handleOpenWizard} />
445
+ <SubscriptionsContainer
446
+ onOpenWizard={handleOpenWizard}
447
+ hasActiveRecurringSubscription={hasActiveRecurringSubscription}
448
+ />
446
449
  </BillingDetailModal>
447
450
 
448
451
  <BillingDetailModal
@@ -86,7 +86,7 @@ export function InvoiceDetails({ invoice, open, onOpenChange, onInvoiceChange }:
86
86
  {/* Line Items */}
87
87
  <div>
88
88
  <h4 className="text-sm font-medium text-muted-foreground mb-2">Line Items</h4>
89
- <div className="border rounded-lg overflow-hidden">
89
+ <div className="border rounded-lg overflow-clip">
90
90
  <table className="w-full">
91
91
  <thead className="bg-muted">
92
92
  <tr>
@@ -29,7 +29,7 @@ export function InvoicesList({ invoices, onInvoicesChange }: InvoicesListProps)
29
29
 
30
30
  return (
31
31
  <>
32
- <div className="border rounded-lg overflow-hidden">
32
+ <div className="border rounded-lg overflow-clip">
33
33
  <Table>
34
34
  <TableHeader className="bg-muted">
35
35
  <TableRow>
@@ -19,6 +19,7 @@ import {
19
19
  Input,
20
20
  Label,
21
21
  } from "../../../../../shadcnui";
22
+ import { FeatureInterface, FeatureService } from "../../../../feature";
22
23
  import { StripePriceInterface, StripePriceService } from "../../data";
23
24
 
24
25
  type PriceEditorProps = {
@@ -40,10 +41,25 @@ type PriceFormValues = {
40
41
  description?: string;
41
42
  features: string[];
42
43
  token: string;
44
+ featureIds: string[]; // Platform Feature entity IDs
43
45
  };
44
46
 
45
47
  export function PriceEditor({ productId, price, open, onOpenChange, onSuccess }: PriceEditorProps) {
46
48
  const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
49
+ const [allFeatures, setAllFeatures] = useState<FeatureInterface[]>([]);
50
+
51
+ // Fetch all platform features on mount
52
+ useEffect(() => {
53
+ const fetchFeatures = async () => {
54
+ try {
55
+ const features = await FeatureService.findMany({});
56
+ setAllFeatures(features);
57
+ } catch (error) {
58
+ console.error("[PriceEditor] Failed to fetch features:", error);
59
+ }
60
+ };
61
+ fetchFeatures();
62
+ }, []);
47
63
 
48
64
  const formSchema = z.object({
49
65
  unitAmount: z.preprocess(
@@ -62,6 +78,7 @@ export function PriceEditor({ productId, price, open, onOpenChange, onSuccess }:
62
78
  description: z.string().optional(),
63
79
  features: z.array(z.string()),
64
80
  token: z.string(),
81
+ featureIds: z.array(z.string()),
65
82
  });
66
83
 
67
84
  const isEditMode = !!price;
@@ -69,6 +86,12 @@ export function PriceEditor({ productId, price, open, onOpenChange, onSuccess }:
69
86
  // Convert cents to dollars for display
70
87
  const defaultUnitAmount = price?.unitAmount ? price.unitAmount / 100 : 0;
71
88
 
89
+ // Get core feature IDs that should always be selected
90
+ const coreFeatureIds = allFeatures.filter((f) => f.isCore).map((f) => f.id);
91
+
92
+ // Combine existing price features with core features (ensure no duplicates)
93
+ const defaultFeatureIds = [...new Set([...(price?.priceFeatures?.map((f) => f.id) ?? []), ...coreFeatureIds])];
94
+
72
95
  const form = useForm<PriceFormValues>({
73
96
  resolver: zodResolver(formSchema) as any,
74
97
  defaultValues: {
@@ -82,12 +105,19 @@ export function PriceEditor({ productId, price, open, onOpenChange, onSuccess }:
82
105
  description: price?.description || "",
83
106
  features: price?.features || [],
84
107
  token: price?.token?.toString() ?? "",
108
+ featureIds: defaultFeatureIds,
85
109
  },
86
110
  });
87
111
 
88
112
  // Reset form when dialog opens to ensure fresh state
89
113
  useEffect(() => {
90
114
  if (open) {
115
+ // Recalculate core feature IDs with current allFeatures
116
+ const currentCoreFeatureIds = allFeatures.filter((f) => f.isCore).map((f) => f.id);
117
+ const resetFeatureIds = [
118
+ ...new Set([...(price?.priceFeatures?.map((f) => f.id) ?? []), ...currentCoreFeatureIds]),
119
+ ];
120
+
91
121
  form.reset({
92
122
  unitAmount: price?.unitAmount ? price.unitAmount / 100 : 0,
93
123
  currency: price?.currency || "usd",
@@ -99,9 +129,10 @@ export function PriceEditor({ productId, price, open, onOpenChange, onSuccess }:
99
129
  description: price?.description || "",
100
130
  features: price?.features || [],
101
131
  token: price?.token?.toString() ?? "",
132
+ featureIds: resetFeatureIds,
102
133
  });
103
134
  }
104
- }, [open, price?.id]);
135
+ }, [open, price?.id, allFeatures]);
105
136
 
106
137
  const watchInterval = form.watch("interval");
107
138
  const isRecurring = watchInterval !== "one_time";
@@ -121,6 +152,8 @@ export function PriceEditor({ productId, price, open, onOpenChange, onSuccess }:
121
152
  description: values.description || undefined,
122
153
  features: values.features.filter((f) => f.trim()) || undefined,
123
154
  token: values.token ? parseInt(values.token, 10) : undefined,
155
+ // Only include featureIds for recurring prices (one-time prices don't support platform features)
156
+ ...(price?.priceType === "recurring" ? { featureIds: values.featureIds } : {}),
124
157
  });
125
158
  } else {
126
159
  // Create new price
@@ -157,6 +190,11 @@ export function PriceEditor({ productId, price, open, onOpenChange, onSuccess }:
157
190
  createInput.token = parseInt(values.token, 10);
158
191
  }
159
192
 
193
+ // Add platform feature IDs only for recurring prices (Neo4j only, not sent to Stripe)
194
+ if (isRecurring && values.featureIds.length > 0) {
195
+ createInput.featureIds = values.featureIds;
196
+ }
197
+
160
198
  await StripePriceService.createPrice(createInput);
161
199
  }
162
200
 
@@ -317,6 +355,52 @@ export function PriceEditor({ productId, price, open, onOpenChange, onSuccess }:
317
355
  </div>
318
356
  </div>
319
357
 
358
+ {/* Platform Features Checkbox List - Only show for recurring prices */}
359
+ {isRecurring && allFeatures.length > 0 && (
360
+ <div className="space-y-2">
361
+ <Label>Platform Features</Label>
362
+ <div className="border rounded-md p-4 space-y-2 max-h-48 overflow-y-auto">
363
+ {allFeatures.map((feature) => {
364
+ const isCore = feature.isCore;
365
+ const isChecked = form.watch("featureIds").includes(feature.id);
366
+
367
+ return (
368
+ <div key={feature.id} className="flex items-center space-x-2">
369
+ <input
370
+ type="checkbox"
371
+ id={`feature-${feature.id}`}
372
+ checked={isChecked}
373
+ disabled={isCore}
374
+ onChange={(e) => {
375
+ const currentIds = form.getValues("featureIds");
376
+ if (e.target.checked) {
377
+ form.setValue("featureIds", [...currentIds, feature.id]);
378
+ } else {
379
+ // Don't allow unchecking core features
380
+ if (!isCore) {
381
+ form.setValue(
382
+ "featureIds",
383
+ currentIds.filter((id) => id !== feature.id),
384
+ );
385
+ }
386
+ }
387
+ }}
388
+ className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary disabled:opacity-50"
389
+ />
390
+ <label
391
+ htmlFor={`feature-${feature.id}`}
392
+ className={`text-sm ${isCore ? "text-muted-foreground" : ""}`}
393
+ >
394
+ {feature.name}
395
+ {isCore && <span className="ml-2 text-xs text-muted-foreground">(Core - Required)</span>}
396
+ </label>
397
+ </div>
398
+ );
399
+ })}
400
+ </div>
401
+ </div>
402
+ )}
403
+
320
404
  <FormCheckbox form={form} id="active" name="Active" />
321
405
 
322
406
  <CommonEditorButtons isEdit={isEditMode} form={form} disabled={isSubmitting} setOpen={onOpenChange} />
@@ -1,5 +1,6 @@
1
1
  import { ApiDataInterface } from "../../../../core";
2
2
  import { StripeProductInterface } from "../../stripe-product";
3
+ import { FeatureInterface } from "../../../feature";
3
4
 
4
5
  // ============================================================================
5
6
  // Stripe Price Interfaces
@@ -20,6 +21,7 @@ export interface StripePriceInterface extends ApiDataInterface {
20
21
  get description(): string | undefined;
21
22
  get features(): string[] | undefined;
22
23
  get token(): number | undefined;
24
+ get priceFeatures(): FeatureInterface[]; // Platform Feature entities linked to this price
23
25
  }
24
26
 
25
27
  export interface PriceRecurring {
@@ -47,4 +49,5 @@ export type StripePriceInput = {
47
49
  description?: string;
48
50
  features?: string[];
49
51
  token?: number;
52
+ featureIds?: string[]; // Feature entity IDs to link (Neo4j only, NOT sent to Stripe)
50
53
  };
@@ -1,5 +1,6 @@
1
1
  import { AbstractApiData, JsonApiHydratedDataInterface, Modules } from "../../../../core";
2
2
  import { StripeProductInterface } from "../../stripe-product";
3
+ import { FeatureInterface } from "../../../feature";
3
4
  import { PriceRecurring, StripePriceInput, StripePriceInterface } from "./stripe-price.interface";
4
5
 
5
6
  export class StripePrice extends AbstractApiData implements StripePriceInterface {
@@ -17,6 +18,7 @@ export class StripePrice extends AbstractApiData implements StripePriceInterface
17
18
  private _description?: string;
18
19
  private _features?: string[];
19
20
  private _token?: number;
21
+ private _priceFeatures: FeatureInterface[] = []; // Platform Feature entities
20
22
 
21
23
  get stripePriceId(): string {
22
24
  if (!this._stripePriceId) throw new Error("stripePriceId is not defined");
@@ -78,6 +80,10 @@ export class StripePrice extends AbstractApiData implements StripePriceInterface
78
80
  return this._token;
79
81
  }
80
82
 
83
+ get priceFeatures(): FeatureInterface[] {
84
+ return this._priceFeatures;
85
+ }
86
+
81
87
  rehydrate(data: JsonApiHydratedDataInterface): this {
82
88
  super.rehydrate(data);
83
89
 
@@ -118,6 +124,9 @@ export class StripePrice extends AbstractApiData implements StripePriceInterface
118
124
  // Hydrate product relationship
119
125
  this._product = this._readIncluded(data, "product", Modules.StripeProduct) as StripeProductInterface;
120
126
 
127
+ // Hydrate feature relationship (JSON:API key is "features")
128
+ this._priceFeatures = this._readIncluded(data, "features", Modules.Feature) as FeatureInterface[];
129
+
121
130
  return this;
122
131
  }
123
132
 
@@ -161,6 +170,15 @@ export class StripePrice extends AbstractApiData implements StripePriceInterface
161
170
  response.data.attributes.token = data.token;
162
171
  }
163
172
 
173
+ // Convert featureIds to JSON:API relationships format
174
+ if (data.featureIds && data.featureIds.length > 0) {
175
+ response.data.relationships = response.data.relationships || {};
176
+ response.data.relationships.features = data.featureIds.map((id) => ({
177
+ type: Modules.Feature.name,
178
+ id,
179
+ }));
180
+ }
181
+
164
182
  return response;
165
183
  }
166
184
  }
@@ -9,9 +9,10 @@ import { SubscriptionsList } from "../lists";
9
9
 
10
10
  type SubscriptionsContainerProps = {
11
11
  onOpenWizard?: (subscription?: StripeSubscriptionInterface) => void;
12
+ hasActiveRecurringSubscription?: boolean;
12
13
  };
13
14
 
14
- export function SubscriptionsContainer({ onOpenWizard }: SubscriptionsContainerProps) {
15
+ export function SubscriptionsContainer({ onOpenWizard, hasActiveRecurringSubscription }: SubscriptionsContainerProps) {
15
16
  const [subscriptions, setSubscriptions] = useState<StripeSubscriptionInterface[]>([]);
16
17
  const [loading, setLoading] = useState<boolean>(true);
17
18
 
@@ -57,7 +58,9 @@ export function SubscriptionsContainer({ onOpenWizard }: SubscriptionsContainerP
57
58
  <h1 className="text-3xl font-bold">Subscriptions</h1>
58
59
  </div>
59
60
  {subscriptions.length > 0 && (
60
- <Button onClick={() => onOpenWizard?.()}>Subscribe to a Plan</Button>
61
+ <Button onClick={() => onOpenWizard?.()}>
62
+ {hasActiveRecurringSubscription ? "Purchase Add-ons" : "Subscribe to a Plan"}
63
+ </Button>
61
64
  )}
62
65
  </div>
63
66
 
@@ -4,7 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
4
4
  import { useState } from "react";
5
5
  import { SubmitHandler, useForm } from "react-hook-form";
6
6
  import { z } from "zod";
7
- import { FormCheckbox, FormTextarea } from "../../../../../components";
7
+ import { FormTextarea } from "../../../../../components";
8
8
  import {
9
9
  Button,
10
10
  Dialog,
@@ -25,7 +25,6 @@ type CancelSubscriptionDialogProps = {
25
25
  };
26
26
 
27
27
  const formSchema = z.object({
28
- cancelImmediately: z.boolean(),
29
28
  reason: z.string().optional(),
30
29
  });
31
30
 
@@ -40,20 +39,17 @@ export function CancelSubscriptionDialog({
40
39
  const form = useForm<z.infer<typeof formSchema>>({
41
40
  resolver: zodResolver(formSchema),
42
41
  defaultValues: {
43
- cancelImmediately: false,
44
42
  reason: "",
45
43
  },
46
44
  });
47
45
 
48
- const cancelImmediately = form.watch("cancelImmediately");
49
-
50
46
  const onSubmit: SubmitHandler<z.infer<typeof formSchema>> = async (values) => {
51
47
  setIsSubmitting(true);
52
48
 
53
49
  try {
54
50
  await StripeSubscriptionService.cancelSubscription({
55
51
  id: subscription.id,
56
- cancelImmediately: values.cancelImmediately,
52
+ cancelImmediately: false,
57
53
  });
58
54
 
59
55
  onSuccess();
@@ -79,18 +75,9 @@ export function CancelSubscriptionDialog({
79
75
 
80
76
  <Form {...form}>
81
77
  <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
82
- <FormCheckbox form={form} id="cancelImmediately" name="Cancel Immediately" />
83
-
84
- {cancelImmediately ? (
85
- <div className="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-800">
86
- Your subscription will be canceled immediately and you will lose access right away.
87
- </div>
88
- ) : (
89
- <div className="bg-blue-50 border border-blue-200 rounded-lg p-3 text-sm text-blue-800">
90
- Your subscription will remain active until {periodEndDate}. You can continue using the service until
91
- then.
92
- </div>
93
- )}
78
+ <div className="bg-blue-50 border border-blue-200 rounded-lg p-3 text-sm text-blue-800">
79
+ Your subscription will remain active until {periodEndDate}. You can continue using the service until then.
80
+ </div>
94
81
 
95
82
  <FormTextarea
96
83
  form={form}