@86d-store/memberships 0.1.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 (110) hide show
  1. package/README.md +193 -0
  2. package/dist/admin/components/_shared.d.ts +13 -0
  3. package/dist/admin/components/_shared.d.ts.map +1 -0
  4. package/dist/admin/components/_shared.js +17 -0
  5. package/dist/admin/components/membership-admin.d.ts +2 -0
  6. package/dist/admin/components/membership-admin.d.ts.map +1 -0
  7. package/dist/admin/components/membership-admin.js +47 -0
  8. package/dist/admin/components/membership-plans.d.ts +2 -0
  9. package/dist/admin/components/membership-plans.d.ts.map +1 -0
  10. package/dist/admin/components/membership-plans.js +153 -0
  11. package/dist/admin/endpoints/add-benefit.d.ts +28 -0
  12. package/dist/admin/endpoints/add-benefit.d.ts.map +1 -0
  13. package/dist/admin/endpoints/add-benefit.js +37 -0
  14. package/dist/admin/endpoints/cancel-membership.d.ts +16 -0
  15. package/dist/admin/endpoints/cancel-membership.d.ts.map +1 -0
  16. package/dist/admin/endpoints/cancel-membership.js +19 -0
  17. package/dist/admin/endpoints/create-plan.d.ts +29 -0
  18. package/dist/admin/endpoints/create-plan.d.ts.map +1 -0
  19. package/dist/admin/endpoints/create-plan.js +48 -0
  20. package/dist/admin/endpoints/delete-plan.d.ts +16 -0
  21. package/dist/admin/endpoints/delete-plan.d.ts.map +1 -0
  22. package/dist/admin/endpoints/delete-plan.js +14 -0
  23. package/dist/admin/endpoints/gate-product.d.ts +19 -0
  24. package/dist/admin/endpoints/gate-product.d.ts.map +1 -0
  25. package/dist/admin/endpoints/gate-product.js +25 -0
  26. package/dist/admin/endpoints/get-membership.d.ts +18 -0
  27. package/dist/admin/endpoints/get-membership.d.ts.map +1 -0
  28. package/dist/admin/endpoints/get-membership.js +15 -0
  29. package/dist/admin/endpoints/get-stats.d.ts +6 -0
  30. package/dist/admin/endpoints/get-stats.d.ts.map +1 -0
  31. package/dist/admin/endpoints/get-stats.js +7 -0
  32. package/dist/admin/endpoints/list-memberships.d.ts +20 -0
  33. package/dist/admin/endpoints/list-memberships.d.ts.map +1 -0
  34. package/dist/admin/endpoints/list-memberships.js +32 -0
  35. package/dist/admin/endpoints/list-plans.d.ts +16 -0
  36. package/dist/admin/endpoints/list-plans.d.ts.map +1 -0
  37. package/dist/admin/endpoints/list-plans.js +29 -0
  38. package/dist/admin/endpoints/pause-membership.d.ts +16 -0
  39. package/dist/admin/endpoints/pause-membership.d.ts.map +1 -0
  40. package/dist/admin/endpoints/pause-membership.js +19 -0
  41. package/dist/admin/endpoints/remove-benefit.d.ts +16 -0
  42. package/dist/admin/endpoints/remove-benefit.d.ts.map +1 -0
  43. package/dist/admin/endpoints/remove-benefit.js +14 -0
  44. package/dist/admin/endpoints/resume-membership.d.ts +16 -0
  45. package/dist/admin/endpoints/resume-membership.d.ts.map +1 -0
  46. package/dist/admin/endpoints/resume-membership.js +19 -0
  47. package/dist/admin/endpoints/routes.d.ts +237 -0
  48. package/dist/admin/endpoints/routes.d.ts.map +1 -0
  49. package/dist/admin/endpoints/routes.js +30 -0
  50. package/dist/admin/endpoints/ungate-product.d.ts +13 -0
  51. package/dist/admin/endpoints/ungate-product.d.ts.map +1 -0
  52. package/dist/admin/endpoints/ungate-product.js +22 -0
  53. package/dist/admin/endpoints/update-plan.d.ts +32 -0
  54. package/dist/admin/endpoints/update-plan.d.ts.map +1 -0
  55. package/dist/admin/endpoints/update-plan.js +62 -0
  56. package/dist/index.d.ts +10 -0
  57. package/dist/index.d.ts.map +1 -0
  58. package/dist/index.js +75 -0
  59. package/dist/schema.d.ts +102 -0
  60. package/dist/schema.d.ts.map +1 -0
  61. package/dist/schema.js +63 -0
  62. package/dist/service-impl.d.ts +4 -0
  63. package/dist/service-impl.d.ts.map +1 -0
  64. package/dist/service-impl.js +414 -0
  65. package/dist/service.d.ts +145 -0
  66. package/dist/service.d.ts.map +1 -0
  67. package/dist/service.js +1 -0
  68. package/dist/store/components/_hooks.d.ts +9 -0
  69. package/dist/store/components/_hooks.d.ts.map +1 -0
  70. package/dist/store/components/_hooks.js +13 -0
  71. package/dist/store/components/_utils.d.ts +11 -0
  72. package/dist/store/components/_utils.d.ts.map +1 -0
  73. package/dist/store/components/_utils.js +98 -0
  74. package/dist/store/components/mdx.d.ts +10 -0
  75. package/dist/store/components/mdx.d.ts.map +1 -0
  76. package/dist/store/components/mdx.js +9 -0
  77. package/dist/store/components/my-membership.d.ts +2 -0
  78. package/dist/store/components/my-membership.d.ts.map +1 -0
  79. package/dist/store/components/my-membership.js +42 -0
  80. package/dist/store/components/my-membership.mdx +13 -0
  81. package/dist/store/components/plan-detail.d.ts +5 -0
  82. package/dist/store/components/plan-detail.d.ts.map +1 -0
  83. package/dist/store/components/plan-detail.js +50 -0
  84. package/dist/store/components/plan-detail.mdx +15 -0
  85. package/dist/store/components/plan-listing.d.ts +2 -0
  86. package/dist/store/components/plan-listing.d.ts.map +1 -0
  87. package/dist/store/components/plan-listing.js +21 -0
  88. package/dist/store/components/plan-listing.mdx +13 -0
  89. package/dist/store/endpoints/cancel.d.ts +16 -0
  90. package/dist/store/endpoints/cancel.d.ts.map +1 -0
  91. package/dist/store/endpoints/cancel.js +25 -0
  92. package/dist/store/endpoints/check-access.d.ts +10 -0
  93. package/dist/store/endpoints/check-access.d.ts.map +1 -0
  94. package/dist/store/endpoints/check-access.js +20 -0
  95. package/dist/store/endpoints/get-membership.d.ts +19 -0
  96. package/dist/store/endpoints/get-membership.d.ts.map +1 -0
  97. package/dist/store/endpoints/get-membership.js +18 -0
  98. package/dist/store/endpoints/get-plan.d.ts +18 -0
  99. package/dist/store/endpoints/get-plan.d.ts.map +1 -0
  100. package/dist/store/endpoints/get-plan.js +17 -0
  101. package/dist/store/endpoints/list-plans.d.ts +11 -0
  102. package/dist/store/endpoints/list-plans.d.ts.map +1 -0
  103. package/dist/store/endpoints/list-plans.js +18 -0
  104. package/dist/store/endpoints/routes.d.ts +82 -0
  105. package/dist/store/endpoints/routes.d.ts.map +1 -0
  106. package/dist/store/endpoints/routes.js +14 -0
  107. package/dist/store/endpoints/subscribe.d.ts +16 -0
  108. package/dist/store/endpoints/subscribe.d.ts.map +1 -0
  109. package/dist/store/endpoints/subscribe.js +30 -0
  110. package/package.json +93 -0
package/README.md ADDED
@@ -0,0 +1,193 @@
1
+ <p align="center">
2
+ <a href="https://86d.app">
3
+ <img src="https://86d.app/icon" height="96" alt="86d" />
4
+ </a>
5
+ </p>
6
+
7
+ <p align="center">
8
+ The Modern Foundation for Commerce
9
+ </p>
10
+
11
+ <p align="center">
12
+ <a href="https://x.com/86d_app"><strong>X</strong></a> ·
13
+ <a href="https://www.linkedin.com/company/86d"><strong>LinkedIn</strong></a>
14
+ </p>
15
+ <br/>
16
+
17
+ > [!WARNING]
18
+ > This project is under active development and is not ready for production use. Please proceed with caution. Use at your own risk.
19
+
20
+ 📚 **Documentation:** [86d.app/docs/modules/memberships](https://86d.app/docs/modules/memberships)
21
+
22
+ # @86d-store/memberships
23
+
24
+ Paid membership plans for your commerce store. Create tiered subscription plans with exclusive benefits, gated products, and member-only pricing. Supports monthly, yearly, and lifetime billing intervals with optional trial periods.
25
+
26
+ ## Installation
27
+
28
+ The memberships module is included in the 86d platform. Enable it in your store's `config.json`:
29
+
30
+ ```json
31
+ {
32
+ "modules": {
33
+ "memberships": {}
34
+ }
35
+ }
36
+ ```
37
+
38
+ ## Configuration
39
+
40
+ | Option | Type | Default | Description |
41
+ |--------|------|---------|-------------|
42
+ | `defaultTrialDays` | string | `"0"` | Default trial period for plans |
43
+ | `defaultMaxMembers` | string | — | Max members per plan |
44
+
45
+ ## Usage
46
+
47
+ ```ts
48
+ import memberships from "@86d-store/memberships";
49
+
50
+ // In your store configuration
51
+ memberships({
52
+ defaultTrialDays: "14",
53
+ });
54
+ ```
55
+
56
+ ## Store Endpoints
57
+
58
+ | Method | Path | Description |
59
+ |--------|------|-------------|
60
+ | GET | `/memberships/plans` | List active plans |
61
+ | GET | `/memberships/plans/:slug` | Get plan details with benefits |
62
+ | GET | `/memberships/my-membership` | Get customer's current membership |
63
+ | POST | `/memberships/subscribe` | Subscribe to a plan |
64
+ | POST | `/memberships/cancel` | Cancel a membership |
65
+ | GET | `/memberships/check-access` | Check if customer can access a product |
66
+
67
+ ### List plans
68
+
69
+ ```
70
+ GET /memberships/plans?take=50&skip=0
71
+ → { plans: MembershipPlan[] }
72
+ ```
73
+
74
+ ### Get plan details
75
+
76
+ ```
77
+ GET /memberships/plans/gold
78
+ → { plan: MembershipPlan, benefits: MembershipBenefit[] }
79
+ ```
80
+
81
+ ### Subscribe (requires auth)
82
+
83
+ ```
84
+ POST /memberships/subscribe
85
+ { planId: string }
86
+ → { membership: Membership }
87
+ ```
88
+
89
+ Customer ID is derived from the session — never provided by the client.
90
+
91
+ ### Check product access (requires auth)
92
+
93
+ ```
94
+ GET /memberships/check-access?productId=prod_1
95
+ → { hasAccess: boolean }
96
+ ```
97
+
98
+ Returns `{ hasAccess: false }` for unauthenticated requests. Customer ID is derived from the session.
99
+
100
+ ## Admin Endpoints
101
+
102
+ | Method | Path | Description |
103
+ |--------|------|-------------|
104
+ | GET | `/admin/memberships` | List all memberships (filter by status, planId) |
105
+ | GET | `/admin/memberships/stats` | Dashboard statistics |
106
+ | GET | `/admin/memberships/plans` | List all plans |
107
+ | POST | `/admin/memberships/plans/create` | Create a plan |
108
+ | POST | `/admin/memberships/plans/:id/update` | Update a plan |
109
+ | POST | `/admin/memberships/plans/:id/delete` | Delete a plan (cascades) |
110
+ | GET | `/admin/memberships/:id` | Get membership details |
111
+ | POST | `/admin/memberships/:id/cancel` | Cancel a membership |
112
+ | POST | `/admin/memberships/:id/pause` | Pause a membership |
113
+ | POST | `/admin/memberships/:id/resume` | Resume a paused membership |
114
+ | POST | `/admin/memberships/plans/:planId/benefits/add` | Add benefit to plan |
115
+ | POST | `/admin/memberships/benefits/:id/remove` | Remove a benefit |
116
+ | POST | `/admin/memberships/plans/:planId/products/gate` | Gate products to plan |
117
+ | POST | `/admin/memberships/plans/:planId/products/ungate` | Ungate products |
118
+
119
+ ## Store UI
120
+
121
+ The module includes customer-facing store components in `src/store/components/` (TSX logic + MDX templates):
122
+
123
+ | Page | Component | Description |
124
+ |------|-----------|-------------|
125
+ | `/memberships` | `PlanListing` | Browse all active membership plans with pricing and features |
126
+ | `/memberships/:slug` | `PlanDetail` | Plan detail with benefits, features list, and subscribe action |
127
+ | (embeddable) | `MyMembership` | Customer membership dashboard — view status, benefits, cancel |
128
+
129
+ ## Admin UI
130
+
131
+ The module includes admin UI components in `src/admin/components/index.tsx` (client components using `useModuleClient`):
132
+
133
+ | Page | Component | Description |
134
+ |------|-----------|-------------|
135
+ | `/admin/memberships` | `MembershipAdmin` | Membership list with status filters, member management |
136
+ | `/admin/memberships/plans` | `MembershipPlans` | Plan list with create/edit forms, benefit and product gating management |
137
+
138
+ ## Service API
139
+
140
+ The `MembershipController` provides 24 methods:
141
+
142
+ ### Plans
143
+ - `createPlan(params)` — Create a membership plan
144
+ - `getPlan(id)` — Get plan by ID
145
+ - `getPlanBySlug(slug)` — Get plan by URL slug
146
+ - `updatePlan(id, params)` — Update plan fields (null clears optional fields)
147
+ - `deletePlan(id)` — Delete plan with cascade
148
+ - `listPlans(params?)` — List plans with filtering and pagination
149
+ - `countPlans(params?)` — Count plans
150
+
151
+ ### Memberships
152
+ - `subscribe({ customerId, planId })` — Subscribe customer (auto-cancels previous)
153
+ - `cancelMembership(id)` — Cancel membership
154
+ - `pauseMembership(id)` — Pause active/trial membership
155
+ - `resumeMembership(id)` — Resume paused membership
156
+ - `getMembership(id)` — Get by ID
157
+ - `getCustomerMembership(customerId)` — Get active membership with plan
158
+ - `listMemberships(params?)` — List with filters
159
+ - `countMemberships(params?)` — Count with filters
160
+
161
+ ### Benefits
162
+ - `addBenefit(params)` — Add benefit to plan
163
+ - `removeBenefit(id)` — Remove benefit
164
+ - `listBenefits(planId)` — List plan benefits
165
+ - `getCustomerBenefits(customerId)` — Get active benefits for member
166
+
167
+ ### Product Gating
168
+ - `gateProduct({ planId, productId })` — Gate product to plan
169
+ - `ungateProduct({ planId, productId })` — Remove gate
170
+ - `listGatedProducts({ planId })` — List gated products
171
+ - `countGatedProducts(planId)` — Count gated products
172
+ - `canAccessProduct({ customerId, productId })` — Check access
173
+
174
+ ### Admin
175
+ - `getStats()` — Dashboard statistics
176
+
177
+ ## Types
178
+
179
+ ```ts
180
+ type BillingInterval = "monthly" | "yearly" | "lifetime";
181
+ type MembershipStatus = "active" | "trial" | "expired" | "cancelled" | "paused";
182
+ type BenefitType = "discount_percentage" | "free_shipping" | "early_access" | "exclusive_products" | "priority_support";
183
+ ```
184
+
185
+ ## Notes
186
+
187
+ - A customer can have only one active membership at a time
188
+ - Subscribing to a new plan automatically cancels the previous one
189
+ - Plans with `trialDays > 0` start memberships in `trial` status
190
+ - Lifetime plans have no expiration date
191
+ - Product gating is plan-specific — a product can be gated to multiple plans
192
+ - `canAccessProduct` returns `true` for ungated products (no membership required)
193
+ - Deleting a plan cascades to its benefits, gated products, and memberships
@@ -0,0 +1,13 @@
1
+ export declare function useMembershipsApi(): {
2
+ listMemberships: import("@86d-store/core/client/types").AnyEndpointHook;
3
+ getMembership: import("@86d-store/core/client/types").AnyEndpointHook;
4
+ cancelMembership: import("@86d-store/core/client/types").AnyEndpointHook;
5
+ pauseMembership: import("@86d-store/core/client/types").AnyEndpointHook;
6
+ resumeMembership: import("@86d-store/core/client/types").AnyEndpointHook;
7
+ stats: import("@86d-store/core/client/types").AnyEndpointHook;
8
+ listPlans: import("@86d-store/core/client/types").AnyEndpointHook;
9
+ createPlan: import("@86d-store/core/client/types").AnyEndpointHook;
10
+ updatePlan: import("@86d-store/core/client/types").AnyEndpointHook;
11
+ deletePlan: import("@86d-store/core/client/types").AnyEndpointHook;
12
+ };
13
+ //# sourceMappingURL=_shared.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"_shared.d.ts","sourceRoot":"","sources":["../../../src/admin/components/_shared.tsx"],"names":[],"mappings":"AAIA,wBAAgB,iBAAiB;;;;;;;;;;;EAoBhC"}
@@ -0,0 +1,17 @@
1
+ "use client";
2
+ import { useModuleClient } from "@86d-store/core/client/provider";
3
+ export function useMembershipsApi() {
4
+ const client = useModuleClient();
5
+ return {
6
+ listMemberships: client.module("memberships").admin["/admin/memberships"],
7
+ getMembership: client.module("memberships").admin["/admin/memberships/:id"],
8
+ cancelMembership: client.module("memberships").admin["/admin/memberships/:id/cancel"],
9
+ pauseMembership: client.module("memberships").admin["/admin/memberships/:id/pause"],
10
+ resumeMembership: client.module("memberships").admin["/admin/memberships/:id/resume"],
11
+ stats: client.module("memberships").admin["/admin/memberships/stats"],
12
+ listPlans: client.module("memberships").admin["/admin/memberships/plans"],
13
+ createPlan: client.module("memberships").admin["/admin/memberships/plans/create"],
14
+ updatePlan: client.module("memberships").admin["/admin/memberships/plans/:id/update"],
15
+ deletePlan: client.module("memberships").admin["/admin/memberships/plans/:id/delete"],
16
+ };
17
+ }
@@ -0,0 +1,2 @@
1
+ export declare function MembershipAdmin(): import("react").JSX.Element;
2
+ //# sourceMappingURL=membership-admin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"membership-admin.d.ts","sourceRoot":"","sources":["../../../src/admin/components/membership-admin.tsx"],"names":[],"mappings":"AA+CA,wBAAgB,eAAe,gCA8O9B"}
@@ -0,0 +1,47 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useState } from "react";
4
+ import { useMembershipsApi } from "./_shared";
5
+ const MEMBERSHIP_SKELETON_IDS = ["a", "b", "c", "d"];
6
+ const STATUS_COLORS = {
7
+ active: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400",
8
+ trial: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400",
9
+ expired: "bg-muted text-muted-foreground",
10
+ cancelled: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400",
11
+ paused: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400",
12
+ };
13
+ function formatDate(dateStr) {
14
+ return new Date(dateStr).toLocaleDateString(undefined, {
15
+ year: "numeric",
16
+ month: "short",
17
+ day: "numeric",
18
+ });
19
+ }
20
+ export function MembershipAdmin() {
21
+ const api = useMembershipsApi();
22
+ const [statusFilter, setStatusFilter] = useState("");
23
+ const { data, isLoading } = api.listMemberships.useQuery({
24
+ ...(statusFilter ? { status: statusFilter } : {}),
25
+ });
26
+ const { data: statsData } = api.stats.useQuery({});
27
+ const cancelMutation = api.cancelMembership.useMutation();
28
+ const pauseMutation = api.pauseMembership.useMutation();
29
+ const resumeMutation = api.resumeMembership.useMutation();
30
+ const memberships = data?.memberships ?? [];
31
+ const stats = statsData?.stats;
32
+ const handleAction = async (id, action) => {
33
+ const mutation = action === "cancel"
34
+ ? cancelMutation
35
+ : action === "pause"
36
+ ? pauseMutation
37
+ : resumeMutation;
38
+ try {
39
+ await mutation.mutateAsync({ params: { id } });
40
+ window.location.reload();
41
+ }
42
+ catch {
43
+ // silently handled
44
+ }
45
+ };
46
+ return (_jsxs("div", { children: [_jsxs("div", { className: "mb-6", children: [_jsx("h1", { className: "font-bold text-2xl text-foreground", children: "Memberships" }), _jsx("p", { className: "mt-1 text-muted-foreground text-sm", children: "Manage customer memberships" })] }), stats ? (_jsxs("div", { className: "mb-6 grid gap-4 sm:grid-cols-3 lg:grid-cols-5", children: [_jsxs("div", { className: "rounded-lg border border-border bg-card p-4", children: [_jsx("p", { className: "font-medium text-muted-foreground text-xs uppercase tracking-wide", children: "Total" }), _jsx("p", { className: "mt-1 font-bold text-2xl text-foreground", children: stats.totalMemberships })] }), _jsxs("div", { className: "rounded-lg border border-border bg-card p-4", children: [_jsx("p", { className: "font-medium text-muted-foreground text-xs uppercase tracking-wide", children: "Active" }), _jsx("p", { className: "mt-1 font-bold text-2xl text-green-600", children: stats.activeMemberships })] }), _jsxs("div", { className: "rounded-lg border border-border bg-card p-4", children: [_jsx("p", { className: "font-medium text-muted-foreground text-xs uppercase tracking-wide", children: "Trial" }), _jsx("p", { className: "mt-1 font-bold text-2xl text-blue-600", children: stats.trialMemberships })] }), _jsxs("div", { className: "rounded-lg border border-border bg-card p-4", children: [_jsx("p", { className: "font-medium text-muted-foreground text-xs uppercase tracking-wide", children: "Paused" }), _jsx("p", { className: "mt-1 font-bold text-2xl text-yellow-600", children: stats.pausedMemberships })] }), _jsxs("div", { className: "rounded-lg border border-border bg-card p-4", children: [_jsx("p", { className: "font-medium text-muted-foreground text-xs uppercase tracking-wide", children: "Cancelled" }), _jsx("p", { className: "mt-1 font-bold text-2xl text-red-600", children: stats.cancelledMemberships })] })] })) : null, _jsx("div", { className: "mb-4", children: _jsxs("select", { "aria-label": "Filter by status", value: statusFilter, onChange: (e) => setStatusFilter(e.target.value), className: "rounded-md border border-border bg-background px-2 py-1.5 text-sm", children: [_jsx("option", { value: "", children: "All statuses" }), _jsx("option", { value: "active", children: "Active" }), _jsx("option", { value: "trial", children: "Trial" }), _jsx("option", { value: "expired", children: "Expired" }), _jsx("option", { value: "cancelled", children: "Cancelled" }), _jsx("option", { value: "paused", children: "Paused" })] }) }), isLoading ? (_jsx("div", { className: "space-y-3", children: MEMBERSHIP_SKELETON_IDS.map((id) => (_jsx("div", { className: "h-16 animate-pulse rounded-lg border border-border bg-muted/30" }, `membership-skel-${id}`))) })) : memberships.length === 0 ? (_jsx("div", { className: "rounded-lg border border-border bg-card p-8 text-center", children: _jsx("p", { className: "text-muted-foreground text-sm", children: "No memberships found." }) })) : (_jsx("div", { className: "overflow-x-auto rounded-md border border-border", children: _jsxs("table", { className: "w-full text-left text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-border border-b bg-muted", children: [_jsx("th", { scope: "col", className: "px-4 py-2 font-medium text-muted-foreground", children: "ID" }), _jsx("th", { scope: "col", className: "px-4 py-2 font-medium text-muted-foreground", children: "Customer" }), _jsx("th", { scope: "col", className: "px-4 py-2 font-medium text-muted-foreground", children: "Plan" }), _jsx("th", { scope: "col", className: "px-4 py-2 font-medium text-muted-foreground", children: "Status" }), _jsx("th", { scope: "col", className: "px-4 py-2 font-medium text-muted-foreground", children: "Started" }), _jsx("th", { scope: "col", className: "px-4 py-2 font-medium text-muted-foreground", children: "Actions" })] }) }), _jsx("tbody", { className: "divide-y divide-border", children: memberships.map((m) => (_jsxs("tr", { className: "transition-colors hover:bg-muted/50", children: [_jsxs("td", { className: "px-4 py-2 font-mono text-xs", children: [m.id.slice(0, 8), "..."] }), _jsxs("td", { className: "px-4 py-2 text-foreground", children: [m.customerId.slice(0, 8), "..."] }), _jsxs("td", { className: "px-4 py-2 text-foreground", children: [m.planId.slice(0, 8), "..."] }), _jsx("td", { className: "px-4 py-2", children: _jsx("span", { className: `inline-flex items-center rounded-full px-2 py-0.5 font-medium text-xs ${STATUS_COLORS[m.status] ?? "bg-muted text-muted-foreground"}`, children: m.status }) }), _jsx("td", { className: "px-4 py-2 text-muted-foreground text-xs", children: formatDate(m.startDate) }), _jsx("td", { className: "px-4 py-2", children: _jsxs("div", { className: "flex gap-1", children: [m.status === "active" || m.status === "trial" ? (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", onClick: () => handleAction(m.id, "pause"), className: "rounded px-2 py-1 text-xs hover:bg-muted", children: "Pause" }), _jsx("button", { type: "button", onClick: () => handleAction(m.id, "cancel"), className: "rounded px-2 py-1 text-red-600 text-xs hover:bg-red-50 dark:hover:bg-red-900/20", children: "Cancel" })] })) : null, m.status === "paused" ? (_jsx("button", { type: "button", onClick: () => handleAction(m.id, "resume"), className: "rounded px-2 py-1 text-xs hover:bg-muted", children: "Resume" })) : null] }) })] }, m.id))) })] }) }))] }));
47
+ }
@@ -0,0 +1,2 @@
1
+ export declare function MembershipPlans(): import("react").JSX.Element;
2
+ //# sourceMappingURL=membership-plans.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"membership-plans.d.ts","sourceRoot":"","sources":["../../../src/admin/components/membership-plans.tsx"],"names":[],"mappings":"AAgXA,wBAAgB,eAAe,gCAgL9B"}
@@ -0,0 +1,153 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { useMembershipsApi } from "./_shared";
5
+ function PlanSheet({ plan, onSaved, onCancel }) {
6
+ const api = useMembershipsApi();
7
+ const isEditing = !!plan;
8
+ const firstInputRef = useRef(null);
9
+ useEffect(() => {
10
+ firstInputRef.current?.focus();
11
+ }, []);
12
+ const [name, setName] = useState(plan?.name ?? "");
13
+ const [slug, setSlug] = useState(plan?.slug ?? "");
14
+ const [description, setDescription] = useState(plan?.description ?? "");
15
+ const [price, setPrice] = useState(plan ? String((plan.price / 100).toFixed(2)) : "");
16
+ const [billingInterval, setBillingInterval] = useState(plan?.billingInterval ?? "monthly");
17
+ const [trialDays, setTrialDays] = useState(String(plan?.trialDays ?? 0));
18
+ const [maxMembers, setMaxMembers] = useState(plan?.maxMembers ? String(plan.maxMembers) : "");
19
+ const [features, setFeatures] = useState(plan?.features ? plan.features.join(", ") : "");
20
+ const [isActive, setIsActive] = useState(plan?.isActive ?? true);
21
+ const [error, setError] = useState("");
22
+ const createMutation = api.createPlan.useMutation({
23
+ onSuccess: () => {
24
+ void api.listPlans.invalidate();
25
+ onSaved();
26
+ },
27
+ onError: (err) => setError(extractError(err)),
28
+ });
29
+ const updateMutation = api.updatePlan.useMutation({
30
+ onSuccess: () => {
31
+ void api.listPlans.invalidate();
32
+ onSaved();
33
+ },
34
+ onError: (err) => setError(extractError(err)),
35
+ });
36
+ const isPending = createMutation.isPending || updateMutation.isPending;
37
+ function handleSubmit(e) {
38
+ e.preventDefault();
39
+ setError("");
40
+ if (!name.trim()) {
41
+ setError("Plan name is required.");
42
+ return;
43
+ }
44
+ const parsedPrice = Math.round(Number.parseFloat(price) * 100);
45
+ if (Number.isNaN(parsedPrice) || parsedPrice < 0) {
46
+ setError("Enter a valid price.");
47
+ return;
48
+ }
49
+ const parsedTrialDays = Number.parseInt(trialDays, 10) || 0;
50
+ const parsedMaxMembers = maxMembers.trim()
51
+ ? Number.parseInt(maxMembers, 10)
52
+ : undefined;
53
+ const parsedFeatures = features.trim()
54
+ ? features
55
+ .split(",")
56
+ .map((f) => f.trim())
57
+ .filter(Boolean)
58
+ : undefined;
59
+ const autoSlug = name
60
+ .toLowerCase()
61
+ .replace(/[^a-z0-9]+/g, "-")
62
+ .replace(/^-|-$/g, "");
63
+ if (isEditing) {
64
+ updateMutation.mutate({
65
+ params: { id: plan.id },
66
+ body: {
67
+ name: name.trim(),
68
+ slug: slug.trim() || autoSlug,
69
+ description: description.trim() || null,
70
+ price: parsedPrice,
71
+ billingInterval: billingInterval,
72
+ trialDays: parsedTrialDays,
73
+ isActive,
74
+ ...(parsedMaxMembers != null ? { maxMembers: parsedMaxMembers } : {}),
75
+ ...(parsedFeatures != null ? { features: parsedFeatures } : {}),
76
+ },
77
+ });
78
+ }
79
+ else {
80
+ createMutation.mutate({
81
+ body: {
82
+ name: name.trim(),
83
+ slug: slug.trim() || autoSlug,
84
+ description: description.trim() || undefined,
85
+ price: parsedPrice,
86
+ billingInterval: billingInterval,
87
+ trialDays: parsedTrialDays,
88
+ ...(parsedMaxMembers != null ? { maxMembers: parsedMaxMembers } : {}),
89
+ ...(parsedFeatures != null ? { features: parsedFeatures } : {}),
90
+ },
91
+ });
92
+ }
93
+ }
94
+ return (_jsxs("div", { className: "fixed inset-0 z-50 flex justify-end", children: [_jsx("button", { type: "button", className: "absolute inset-0 cursor-default bg-black/40", "aria-label": "Close panel", onClick: onCancel }), _jsxs("div", { role: "dialog", "aria-modal": "true", className: "relative flex h-full w-full max-w-md flex-col overflow-y-auto border-border border-l bg-background shadow-2xl", children: [_jsxs("div", { className: "flex shrink-0 items-center justify-between border-border border-b px-6 py-4", children: [_jsx("h2", { className: "font-semibold text-foreground text-lg", children: isEditing ? "Edit Plan" : "New Plan" }), _jsx("button", { type: "button", onClick: onCancel, className: "rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground", "aria-label": "Close", children: "\u2715" })] }), _jsxs("form", { onSubmit: handleSubmit, className: "flex flex-1 flex-col gap-5 px-6 py-6", children: [error ? (_jsx("div", { role: "alert", className: "rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-destructive text-sm", children: error })) : null, _jsxs("div", { className: "space-y-4", children: [_jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [_jsxs("div", { children: [_jsxs("label", { htmlFor: "ps-name", className: labelCls, children: ["Name ", _jsx("span", { className: "text-destructive", children: "*" })] }), _jsx("input", { ref: firstInputRef, id: "ps-name", className: inputCls, value: name, onChange: (e) => setName(e.target.value), placeholder: "Premium" })] }), _jsxs("div", { children: [_jsx("label", { htmlFor: "ps-slug", className: labelCls, children: "Slug" }), _jsx("input", { id: "ps-slug", className: inputCls, value: slug, onChange: (e) => setSlug(e.target.value), placeholder: "premium" })] })] }), _jsxs("div", { className: "grid gap-4 sm:grid-cols-3", children: [_jsxs("div", { children: [_jsxs("label", { htmlFor: "ps-price", className: labelCls, children: ["Price ($) ", _jsx("span", { className: "text-destructive", children: "*" })] }), _jsx("input", { id: "ps-price", type: "number", step: "0.01", min: "0", className: inputCls, value: price, onChange: (e) => setPrice(e.target.value), placeholder: "9.99" })] }), _jsxs("div", { children: [_jsx("label", { htmlFor: "ps-interval", className: labelCls, children: "Billing" }), _jsxs("select", { id: "ps-interval", className: inputCls, value: billingInterval, onChange: (e) => setBillingInterval(e.target.value), children: [_jsx("option", { value: "monthly", children: "Monthly" }), _jsx("option", { value: "yearly", children: "Yearly" }), _jsx("option", { value: "lifetime", children: "Lifetime" })] })] }), _jsxs("div", { children: [_jsx("label", { htmlFor: "ps-trial", className: labelCls, children: "Trial days" }), _jsx("input", { id: "ps-trial", type: "number", min: "0", className: inputCls, value: trialDays, onChange: (e) => setTrialDays(e.target.value), placeholder: "0" })] })] }), _jsxs("div", { children: [_jsx("label", { htmlFor: "ps-description", className: labelCls, children: "Description" }), _jsx("input", { id: "ps-description", className: inputCls, value: description, onChange: (e) => setDescription(e.target.value), placeholder: "What's included in this plan" })] }), _jsxs("div", { children: [_jsxs("label", { htmlFor: "ps-features", className: labelCls, children: ["Features", " ", _jsx("span", { className: "font-normal text-muted-foreground", children: "(comma-separated)" })] }), _jsx("input", { id: "ps-features", className: inputCls, value: features, onChange: (e) => setFeatures(e.target.value), placeholder: "Unlimited orders, Priority support" })] }), _jsxs("div", { children: [_jsxs("label", { htmlFor: "ps-maxmembers", className: labelCls, children: ["Max members", " ", _jsx("span", { className: "font-normal text-muted-foreground", children: "(leave blank for unlimited)" })] }), _jsx("input", { id: "ps-maxmembers", type: "number", min: "1", className: inputCls, value: maxMembers, onChange: (e) => setMaxMembers(e.target.value), placeholder: "Unlimited" })] }), isEditing ? (_jsxs("label", { className: "flex cursor-pointer items-center gap-3", children: [_jsx("input", { type: "checkbox", checked: isActive, onChange: (e) => setIsActive(e.target.checked), className: "h-4 w-4 rounded border-border accent-foreground" }), _jsx("span", { className: "text-foreground text-sm", children: "Active" })] })) : null] }), _jsxs("div", { className: "mt-auto flex justify-end gap-2 border-border border-t pt-4", children: [_jsx("button", { type: "button", onClick: onCancel, className: "rounded-lg border border-border px-4 py-2 text-foreground text-sm hover:bg-muted", children: "Cancel" }), _jsx("button", { type: "submit", disabled: isPending, className: "rounded-lg bg-foreground px-4 py-2 font-medium text-background text-sm hover:opacity-90 disabled:opacity-50", children: isPending
95
+ ? isEditing
96
+ ? "Saving..."
97
+ : "Creating..."
98
+ : isEditing
99
+ ? "Save Changes"
100
+ : "Create Plan" })] })] })] })] }));
101
+ }
102
+ const PLAN_SKELETON_IDS = ["a", "b", "c"];
103
+ function formatCurrency(amount) {
104
+ return new Intl.NumberFormat(undefined, {
105
+ style: "currency",
106
+ currency: "USD",
107
+ }).format(amount / 100);
108
+ }
109
+ const INTERVAL_LABELS = {
110
+ monthly: "Monthly",
111
+ yearly: "Yearly",
112
+ lifetime: "Lifetime",
113
+ };
114
+ function extractError(err) {
115
+ if (err && typeof err === "object" && "message" in err) {
116
+ return String(err.message);
117
+ }
118
+ return "An unexpected error occurred";
119
+ }
120
+ const labelCls = "mb-1 block font-medium text-foreground text-sm";
121
+ const inputCls = "w-full rounded-lg border border-input bg-background px-3 py-2 text-sm outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-1 disabled:opacity-50";
122
+ export function MembershipPlans() {
123
+ const api = useMembershipsApi();
124
+ const [showCreate, setShowCreate] = useState(false);
125
+ const [editPlan, setEditPlan] = useState(null);
126
+ const anyModalOpen = showCreate || !!editPlan;
127
+ useEffect(() => {
128
+ if (!anyModalOpen)
129
+ return;
130
+ function handler(e) {
131
+ if (e.key === "Escape") {
132
+ setShowCreate(false);
133
+ setEditPlan(null);
134
+ }
135
+ }
136
+ document.addEventListener("keydown", handler);
137
+ return () => document.removeEventListener("keydown", handler);
138
+ }, [anyModalOpen]);
139
+ const { data, isLoading } = api.listPlans.useQuery({});
140
+ const deleteMutation = api.deletePlan.useMutation({
141
+ onSuccess: () => void api.listPlans.invalidate(),
142
+ });
143
+ const plans = data?.plans ?? [];
144
+ const handleDelete = (plan) => {
145
+ if (!confirm(`Delete plan "${plan.name}"? Active subscribers will not be affected, but new signups will be disabled.`))
146
+ return;
147
+ deleteMutation.mutate({ params: { id: plan.id } });
148
+ };
149
+ return (_jsxs("div", { children: [showCreate ? (_jsx(PlanSheet, { onSaved: () => setShowCreate(false), onCancel: () => setShowCreate(false) })) : null, editPlan ? (_jsx(PlanSheet, { plan: editPlan, onSaved: () => setEditPlan(null), onCancel: () => setEditPlan(null) })) : null, _jsxs("div", { className: "mb-6 flex items-center justify-between", children: [_jsxs("div", { children: [_jsx("h1", { className: "font-bold text-2xl text-foreground", children: "Membership Plans" }), _jsx("p", { className: "mt-1 text-muted-foreground text-sm", children: "Create and manage subscription plans" })] }), _jsx("button", { type: "button", onClick: () => setShowCreate(true), className: "rounded-lg bg-foreground px-4 py-2 font-medium text-background text-sm hover:opacity-90", children: "Create Plan" })] }), isLoading ? (_jsx("div", { className: "space-y-3", children: PLAN_SKELETON_IDS.map((id) => (_jsx("div", { className: "h-20 animate-pulse rounded-lg border border-border bg-muted/30" }, `plan-skel-${id}`))) })) : plans.length === 0 ? (_jsxs("div", { className: "rounded-lg border border-border bg-card p-10 text-center", children: [_jsx("p", { className: "font-medium text-foreground text-sm", children: "No plans yet" }), _jsx("p", { className: "mt-1 text-muted-foreground text-xs", children: "Create a plan to start accepting memberships" }), _jsx("button", { type: "button", onClick: () => setShowCreate(true), className: "mt-4 rounded-lg bg-foreground px-4 py-2 font-medium text-background text-sm hover:opacity-90", children: "Create Plan" })] })) : (_jsx("div", { className: "space-y-3", children: plans.map((plan) => (_jsx("div", { className: "rounded-lg border border-border bg-card p-4", children: _jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("p", { className: "font-medium text-foreground text-sm", children: plan.name }), _jsx("span", { className: `inline-flex items-center rounded-full px-2 py-0.5 font-medium text-xs ${plan.isActive
150
+ ? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
151
+ : "bg-muted text-muted-foreground"}`, children: plan.isActive ? "Active" : "Inactive" })] }), _jsxs("div", { className: "mt-1.5 flex flex-wrap items-center gap-3 text-muted-foreground text-xs", children: [_jsx("span", { children: formatCurrency(plan.price) }), _jsx("span", { children: INTERVAL_LABELS[plan.billingInterval] ??
152
+ plan.billingInterval }), plan.trialDays > 0 ? (_jsxs("span", { children: [plan.trialDays, "-day trial"] })) : null, plan.maxMembers ? (_jsxs("span", { children: ["Max ", plan.maxMembers, " members"] })) : null, plan.description ? _jsx("span", { children: plan.description }) : null] }), plan.features && plan.features.length > 0 ? (_jsx("div", { className: "mt-2 flex flex-wrap gap-1", children: plan.features.map((f) => (_jsx("span", { className: "rounded-full bg-muted px-2 py-0.5 text-muted-foreground text-xs", children: f }, f))) })) : null] }), _jsxs("div", { className: "flex gap-1", children: [_jsx("button", { type: "button", onClick: () => setEditPlan(plan), className: "rounded px-2 py-1 text-xs hover:bg-muted", children: "Edit" }), _jsx("button", { type: "button", onClick: () => handleDelete(plan), disabled: deleteMutation.isPending, className: "rounded px-2 py-1 text-red-600 text-xs hover:bg-red-50 disabled:opacity-50 dark:hover:bg-red-900/20", children: "Delete" })] })] }) }, plan.id))) }))] }));
153
+ }
@@ -0,0 +1,28 @@
1
+ import { z } from "zod";
2
+ export declare const addBenefit: import("better-call").StrictEndpoint<"/admin/memberships/plans/:planId/benefits/add", {
3
+ method: "POST";
4
+ params: z.ZodObject<{
5
+ planId: z.ZodString;
6
+ }, z.core.$strip>;
7
+ body: z.ZodObject<{
8
+ type: z.ZodEnum<{
9
+ discount_percentage: "discount_percentage";
10
+ free_shipping: "free_shipping";
11
+ early_access: "early_access";
12
+ exclusive_products: "exclusive_products";
13
+ priority_support: "priority_support";
14
+ }>;
15
+ value: z.ZodString;
16
+ description: z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>;
17
+ isActive: z.ZodOptional<z.ZodBoolean>;
18
+ }, z.core.$strip>;
19
+ }, {
20
+ error: string;
21
+ status: number;
22
+ benefit?: never;
23
+ } | {
24
+ benefit: import("../..").MembershipBenefit;
25
+ error?: never;
26
+ status?: never;
27
+ }>;
28
+ //# sourceMappingURL=add-benefit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"add-benefit.d.ts","sourceRoot":"","sources":["../../../src/admin/endpoints/add-benefit.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,eAAO,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;EAuCtB,CAAC"}
@@ -0,0 +1,37 @@
1
+ import { createAdminEndpoint } from "@86d-store/core/api";
2
+ import { sanitizeText } from "@86d-store/core/sanitize";
3
+ import { z } from "zod";
4
+ export const addBenefit = createAdminEndpoint("/admin/memberships/plans/:planId/benefits/add", {
5
+ method: "POST",
6
+ params: z.object({ planId: z.string().min(1) }),
7
+ body: z.object({
8
+ type: z.enum([
9
+ "discount_percentage",
10
+ "free_shipping",
11
+ "early_access",
12
+ "exclusive_products",
13
+ "priority_support",
14
+ ]),
15
+ value: z.string().min(1).max(200),
16
+ description: z.string().max(1000).transform(sanitizeText).optional(),
17
+ isActive: z.boolean().optional(),
18
+ }),
19
+ }, async (ctx) => {
20
+ const controller = ctx.context.controllers
21
+ .memberships;
22
+ const plan = await controller.getPlan(ctx.params.planId);
23
+ if (!plan) {
24
+ return { error: "Plan not found", status: 404 };
25
+ }
26
+ const params = {
27
+ planId: ctx.params.planId,
28
+ type: ctx.body.type,
29
+ value: ctx.body.value,
30
+ };
31
+ if (ctx.body.description != null)
32
+ params.description = ctx.body.description;
33
+ if (ctx.body.isActive != null)
34
+ params.isActive = ctx.body.isActive;
35
+ const benefit = await controller.addBenefit(params);
36
+ return { benefit };
37
+ });
@@ -0,0 +1,16 @@
1
+ import { z } from "zod";
2
+ export declare const cancelMembership: import("better-call").StrictEndpoint<"/admin/memberships/:id/cancel", {
3
+ method: "POST";
4
+ params: z.ZodObject<{
5
+ id: z.ZodString;
6
+ }, z.core.$strip>;
7
+ }, {
8
+ error: string;
9
+ status: number;
10
+ membership?: never;
11
+ } | {
12
+ membership: import("../..").Membership;
13
+ error?: never;
14
+ status?: never;
15
+ }>;
16
+ //# sourceMappingURL=cancel-membership.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cancel-membership.d.ts","sourceRoot":"","sources":["../../../src/admin/endpoints/cancel-membership.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;EAqB5B,CAAC"}
@@ -0,0 +1,19 @@
1
+ import { createAdminEndpoint } from "@86d-store/core/api";
2
+ import { z } from "zod";
3
+ export const cancelMembership = createAdminEndpoint("/admin/memberships/:id/cancel", {
4
+ method: "POST",
5
+ params: z.object({ id: z.string().min(1) }),
6
+ }, async (ctx) => {
7
+ const controller = ctx.context.controllers
8
+ .memberships;
9
+ const membership = await controller.cancelMembership(ctx.params.id);
10
+ if (!membership) {
11
+ return { error: "Membership not found", status: 404 };
12
+ }
13
+ void ctx.context.events?.emit("membership.cancelled", {
14
+ membershipId: membership.id,
15
+ customerId: membership.customerId,
16
+ planId: membership.planId,
17
+ });
18
+ return { membership };
19
+ });
@@ -0,0 +1,29 @@
1
+ import { z } from "zod";
2
+ export declare const createPlan: import("better-call").StrictEndpoint<"/admin/memberships/plans/create", {
3
+ method: "POST";
4
+ body: z.ZodObject<{
5
+ name: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
6
+ slug: z.ZodString;
7
+ description: z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>;
8
+ price: z.ZodNumber;
9
+ billingInterval: z.ZodEnum<{
10
+ monthly: "monthly";
11
+ yearly: "yearly";
12
+ lifetime: "lifetime";
13
+ }>;
14
+ trialDays: z.ZodOptional<z.ZodNumber>;
15
+ features: z.ZodOptional<z.ZodArray<z.ZodString>>;
16
+ isActive: z.ZodOptional<z.ZodBoolean>;
17
+ maxMembers: z.ZodOptional<z.ZodNumber>;
18
+ sortOrder: z.ZodOptional<z.ZodNumber>;
19
+ }, z.core.$strip>;
20
+ }, {
21
+ error: string;
22
+ status: number;
23
+ plan?: never;
24
+ } | {
25
+ plan: import("../..").MembershipPlan;
26
+ error?: never;
27
+ status?: never;
28
+ }>;
29
+ //# sourceMappingURL=create-plan.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"create-plan.d.ts","sourceRoot":"","sources":["../../../src/admin/endpoints/create-plan.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,eAAO,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;EA8CtB,CAAC"}