@frigear-nu/subscriptions 0.1.1 → 0.1.3
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 +5 -5
- package/dist/module.d.mts +2 -2
- package/dist/module.json +1 -1
- package/dist/module.mjs +20 -31
- package/dist/runtime/app/components/SubscriptionPlans.vue +9 -15
- package/dist/runtime/app/components/SubscriptionStatus.d.vue.ts +7 -12
- package/dist/runtime/app/components/SubscriptionStatus.vue +26 -26
- package/dist/runtime/app/components/SubscriptionStatus.vue.d.ts +7 -12
- package/dist/runtime/app/components/Users/Admin/Membership.vue +3 -2
- package/dist/runtime/app/composables/usePlans.d.ts +4 -11
- package/dist/runtime/app/composables/usePlans.js +8 -11
- package/dist/runtime/app/composables/useSubscription.d.ts +6 -24
- package/dist/runtime/app/composables/useSubscription.js +12 -17
- package/dist/runtime/app/middleware/subscription.js +2 -6
- package/dist/runtime/app/pages/cancel.vue +18 -6
- package/dist/runtime/app/pages/membership.vue +42 -17
- package/dist/runtime/app/pages/success.vue +19 -7
- package/dist/runtime/app/tabs/Membership.vue +19 -9
- package/dist/runtime/app/widgets/MembershipSummary.vue +2 -2
- package/dist/runtime/i18n/lang/da.json +16 -11
- package/dist/runtime/i18n/lang/en.json +18 -13
- package/dist/runtime/menus.js +4 -4
- package/dist/runtime/server/bootstrap/my-subscriptions.d.ts +7 -0
- package/dist/runtime/server/bootstrap/my-subscriptions.js +4 -0
- package/dist/runtime/server/routes/admin/{subscription.delete.js → membership.delete.js} +3 -2
- package/dist/runtime/server/routes/admin/{subscription.get.js → membership.get.js} +2 -2
- package/dist/runtime/server/routes/admin/{subscription.post.js → membership.post.js} +9 -14
- package/dist/runtime/server/routes/admin/payment-link.post.js +3 -2
- package/dist/runtime/server/routes/{subscriptions → me}/checkout.post.js +9 -18
- package/dist/runtime/server/routes/{subscriptions → me}/portal.post.js +5 -8
- package/dist/runtime/server/routes/subscriptions/me.get.js +2 -4
- package/dist/runtime/server/utils/checkout.js +6 -5
- package/dist/runtime/server/utils/subscriptions.d.ts +6 -1
- package/dist/runtime/server/utils/subscriptions.js +15 -12
- package/dist/runtime/utils/api.d.ts +20 -0
- package/dist/runtime/utils/api.js +23 -0
- package/dist/runtime/utils/index.d.ts +2 -1
- package/dist/runtime/utils/index.js +2 -1
- package/package.json +19 -14
- package/dist/runtime/app/plugins/warm-plans.client.d.ts +0 -6
- package/dist/runtime/app/plugins/warm-plans.client.js +0 -12
- /package/dist/runtime/server/routes/admin/{subscription.delete.d.ts → membership-history.get.d.ts} +0 -0
- /package/dist/runtime/server/routes/admin/{subscriptions.get.js → membership-history.get.js} +0 -0
- /package/dist/runtime/server/routes/admin/{subscription.get.d.ts → membership.delete.d.ts} +0 -0
- /package/dist/runtime/server/routes/admin/{subscription.post.d.ts → membership.get.d.ts} +0 -0
- /package/dist/runtime/server/routes/admin/{subscriptions.get.d.ts → membership.post.d.ts} +0 -0
- /package/dist/runtime/server/routes/{subscriptions → me}/checkout.post.d.ts +0 -0
- /package/dist/runtime/server/routes/{subscriptions → me}/portal.post.d.ts +0 -0
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ webhook) and [`@frigear-nu/users`](../users). One-off payments are out of scope.
|
|
|
13
13
|
- 🔐 Entitlement utils (`hasActiveSubscription`, `getUserSubscription`) and a `subscription` route middleware.
|
|
14
14
|
- 🧩 `usePlans` / `useSubscription` composables, `SubscriptionPlans` / `SubscriptionStatus` components with slots and
|
|
15
15
|
optional i18n, and opt-in membership/success/cancel pages (built with [`@nuxt/ui`](https://ui.nuxt.com)).
|
|
16
|
-
- 🗂️ Contributes a "
|
|
16
|
+
- 🗂️ Contributes a "Membership" item to `@frigear-nu/menu` (when installed).
|
|
17
17
|
|
|
18
18
|
## Quick Setup
|
|
19
19
|
|
|
@@ -77,12 +77,12 @@ a local user are logged and ignored.
|
|
|
77
77
|
|
|
78
78
|
- `GET /api/subscriptions/plans` - public; `{ plans }` with live prices, after the `subscriptions:plans:filter` hook.
|
|
79
79
|
- `GET /api/subscriptions/me` - `{ subscriptions, activePlan }` (401 when signed out).
|
|
80
|
-
- `POST /api/subscriptions/checkout` - body `{ planKey?, priceId?, successUrl?, cancelUrl? }` (one of `planKey`/`priceId`
|
|
80
|
+
- `POST /api/subscriptions/me/checkout` - session only (a bearer token gets 403 `session_required`); body `{ planKey?, priceId?, successUrl?, cancelUrl? }` (one of `planKey`/`priceId`
|
|
81
81
|
required; a multi-price plan needs `priceId`). Returns `{ url }` (new Checkout Session) or
|
|
82
82
|
`{ updated: true, kind: 'upgrade' | 'downgrade' }`. A higher rank upgrades immediately (`always_invoice` proration);
|
|
83
83
|
a lower or equal rank (including monthly to yearly of one plan) is scheduled for period end. `400` for unknown or hidden
|
|
84
84
|
plans/prices and URLs outside the request origin, `409` when already on that price.
|
|
85
|
-
- `POST /api/subscriptions/portal` - body `{ returnUrl? }`; returns `{ url }` of a billing portal session.
|
|
85
|
+
- `POST /api/subscriptions/me/portal` - session only; body `{ returnUrl? }`; returns `{ url }` of a billing portal session.
|
|
86
86
|
|
|
87
87
|
## Policy hooks
|
|
88
88
|
|
|
@@ -119,8 +119,8 @@ Redirects to `redirectTo` (else the membership page) with `?redirect=<route>`; a
|
|
|
119
119
|
|
|
120
120
|
## Composables and components
|
|
121
121
|
|
|
122
|
-
- `usePlans()` - `{ plans, status, error, refresh }`.
|
|
123
|
-
- `useSubscription()` - `{ subscription, plan, status, isActive, cancelsAt, has(plan), pending, error, refresh, subscribe(planKeyOrPriceId, { successUrl?, cancelUrl? }), openPortal(returnUrl?) }`.
|
|
122
|
+
- `usePlans()` - `{ plans, status, loading, error, refresh }`.
|
|
123
|
+
- `useSubscription()` - `{ subscription, plan, status, loading, loadError, isActive, cancelsAt, has(plan), pending, error, refresh, subscribe(planKeyOrPriceId, { successUrl?, cancelUrl? }), openPortal(returnUrl?) }`.
|
|
124
124
|
- `<SubscriptionPlans>` - props `locale`, `currency`, `interval`, `plans`, `confirmChange`; slots `loading`,
|
|
125
125
|
`empty`, `header`, `badge`, `price`, `features`, `action`, `confirm`, `confirm-actions`.
|
|
126
126
|
- `<SubscriptionStatus>` - props `locale`; slots `header`, `loading`, `empty`, `plan`, `badge`, `dates`, `actions`.
|
package/dist/module.d.mts
CHANGED
|
@@ -140,7 +140,7 @@ type PlansFilterContext = {
|
|
|
140
140
|
/** The plans about to be returned. Mutate or reassign `plans` to hide / reorder them. */
|
|
141
141
|
plans: ResolvedPlan[];
|
|
142
142
|
};
|
|
143
|
-
/** `GET /api/admin/users/:id/
|
|
143
|
+
/** `GET /api/admin/users/:id/membership` (staff). */
|
|
144
144
|
type AdminMembershipResponse = {
|
|
145
145
|
plans: Array<{
|
|
146
146
|
key: string;
|
|
@@ -156,7 +156,7 @@ type AdminMembershipResponse = {
|
|
|
156
156
|
cancelAtPeriodEnd: boolean;
|
|
157
157
|
} | null;
|
|
158
158
|
};
|
|
159
|
-
/** `POST /api/admin/users/:id/
|
|
159
|
+
/** `POST /api/admin/users/:id/membership/payment-link` (staff). */
|
|
160
160
|
type AdminPaymentLinkResponse = {
|
|
161
161
|
url: string;
|
|
162
162
|
emailed: boolean;
|
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { defineNuxtModule, createResolver, hasNuxtModule,
|
|
2
|
-
import { registerModule, registerTailwindSource, registerLocales, resolvePagePath, registerDatabaseTable,
|
|
1
|
+
import { defineNuxtModule, createResolver, hasNuxtModule, addServerImportsDir, addImports, addComponentsDir, addRouteMiddleware } from '@nuxt/kit';
|
|
2
|
+
import { registerModule, registerTailwindSource, registerLocales, resolvePagePath, registerDatabaseTable, addModuleRoutes, registerBootstrapProvider, inlineModuleRuntime, addModulePages, addOptionalMenuSource } from '@frigear-nu/module-kit/utils';
|
|
3
3
|
import { addAuthScope } from '@frigear-nu/auth/utils';
|
|
4
4
|
import { extendUsersAdmin } from '@frigear-nu/users/utils';
|
|
5
5
|
import { addMcpDefinitions } from '@frigear-nu/mcp/utils';
|
|
@@ -37,8 +37,7 @@ const module$1 = defineNuxtModule({
|
|
|
37
37
|
registerModule(MODULE_NAME, nuxt);
|
|
38
38
|
addAuthScope(
|
|
39
39
|
nuxt,
|
|
40
|
-
{ name: "subscriptions:read", description: "Read your subscription and plans" }
|
|
41
|
-
{ name: "subscriptions:write", description: "Start a subscription checkout" }
|
|
40
|
+
{ name: "subscriptions:read", description: "Read your subscription and plans" }
|
|
42
41
|
);
|
|
43
42
|
const resolver = createResolver(import.meta.url);
|
|
44
43
|
registerTailwindSource(nuxt, resolver.resolve("./runtime/app"));
|
|
@@ -87,39 +86,30 @@ const module$1 = defineNuxtModule({
|
|
|
87
86
|
handler: resolver.resolve("./runtime/server/stripe/invoice-events")
|
|
88
87
|
});
|
|
89
88
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
method: "get",
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
addServerHandler({
|
|
101
|
-
route: moduleApiUrl("subscriptions", "checkout"),
|
|
102
|
-
method: "post",
|
|
103
|
-
handler: resolver.resolve("./runtime/server/routes/subscriptions/checkout.post")
|
|
104
|
-
});
|
|
105
|
-
addServerHandler({
|
|
106
|
-
route: moduleApiUrl("subscriptions", "portal"),
|
|
107
|
-
method: "post",
|
|
108
|
-
handler: resolver.resolve("./runtime/server/routes/subscriptions/portal.post")
|
|
89
|
+
addModuleRoutes(resolver, [
|
|
90
|
+
{ method: "get", path: "plans", file: "subscriptions/plans.get" },
|
|
91
|
+
{ method: "get", path: "me", file: "subscriptions/me.get" },
|
|
92
|
+
{ method: "post", path: "me/checkout", file: "me/checkout.post" },
|
|
93
|
+
{ method: "post", path: "me/portal", file: "me/portal.post" }
|
|
94
|
+
], { base: "/api/subscriptions" });
|
|
95
|
+
registerBootstrapProvider(nuxt, {
|
|
96
|
+
scope: "account",
|
|
97
|
+
key: "subscriptions:me",
|
|
98
|
+
handler: resolver.resolve("./runtime/server/bootstrap/my-subscriptions")
|
|
109
99
|
});
|
|
110
100
|
await extendUsersAdmin(nuxt, {
|
|
111
101
|
routes: [
|
|
112
|
-
["get", "
|
|
113
|
-
["get", "
|
|
114
|
-
["post", "
|
|
115
|
-
["delete", "
|
|
116
|
-
["post", "
|
|
102
|
+
["get", "membership", "membership.get"],
|
|
103
|
+
["get", "membership/history", "membership-history.get"],
|
|
104
|
+
["post", "membership", "membership.post"],
|
|
105
|
+
["delete", "membership", "membership.delete"],
|
|
106
|
+
["post", "membership/payment-link", "payment-link.post"]
|
|
117
107
|
].map(([method, path, file]) => ({ method, path, handler: resolver.resolve(`./runtime/server/routes/admin/${file}`) })),
|
|
118
108
|
tab: {
|
|
119
|
-
id: "
|
|
109
|
+
id: "membership",
|
|
120
110
|
component: resolver.resolve("./runtime/app/tabs/Membership.vue"),
|
|
121
111
|
order: 10,
|
|
122
|
-
label: "
|
|
112
|
+
label: "Membership",
|
|
123
113
|
labelKey: "subscriptions.admin.tab",
|
|
124
114
|
icon: "i-lucide-badge-check"
|
|
125
115
|
},
|
|
@@ -139,7 +129,6 @@ const module$1 = defineNuxtModule({
|
|
|
139
129
|
{ name: "useSubscription", from: resolver.resolve("./runtime/app/composables/useSubscription") }
|
|
140
130
|
]);
|
|
141
131
|
addComponentsDir({ path: resolver.resolve("./runtime/app/components") });
|
|
142
|
-
addPlugin({ src: resolver.resolve("./runtime/app/plugins/warm-plans.client"), mode: "client" });
|
|
143
132
|
addRouteMiddleware({
|
|
144
133
|
name: "subscription",
|
|
145
134
|
path: resolver.resolve("./runtime/app/middleware/subscription")
|
|
@@ -17,8 +17,8 @@ const emit = defineEmits(["select"]);
|
|
|
17
17
|
const t = useModuleT("subscriptions.plans");
|
|
18
18
|
const tp = useModuleT("subscriptions.catalog");
|
|
19
19
|
const ti = useModuleT("subscriptions.interval");
|
|
20
|
-
const { plans: allPlans,
|
|
21
|
-
const { plan: currentPlan, isActive, cancelsAt, pending, subscribe, error, status: subscriptionStatus } = useSubscription();
|
|
20
|
+
const { plans: allPlans, loading: plansLoading } = usePlans();
|
|
21
|
+
const { plan: currentPlan, isActive, cancelsAt, pending, subscribe, error, status: subscriptionStatus, loading: subscriptionLoading } = useSubscription();
|
|
22
22
|
const { loggedIn } = useUserSession();
|
|
23
23
|
const format = useFormat();
|
|
24
24
|
const route = useRoute();
|
|
@@ -54,8 +54,7 @@ const showGrid = computed(() => props.mode !== "tabs");
|
|
|
54
54
|
const activeTab = ref();
|
|
55
55
|
const tabItems = computed(() => visible.value.map((plan) => ({
|
|
56
56
|
value: plan.key,
|
|
57
|
-
label: priceFor(plan) ? `/ ${intervalLabel(priceFor(plan))}` : labelOf(plan)
|
|
58
|
-
plan
|
|
57
|
+
label: priceFor(plan) ? `/ ${intervalLabel(priceFor(plan))}` : labelOf(plan)
|
|
59
58
|
})));
|
|
60
59
|
watchEffect(() => {
|
|
61
60
|
if (!activeTab.value || !visible.value.some((p) => p.key === activeTab.value)) {
|
|
@@ -131,12 +130,10 @@ watch([visible, subscriptionStatus, loggedIn], () => {
|
|
|
131
130
|
:highlight="!preview && highlighted(plan)"
|
|
132
131
|
:badge="!preview && stateOf(plan) === 'current' ? t('current', 'Current plan') : void 0"
|
|
133
132
|
:tagline="priceFor(plan)?.trialPeriodDays ? t('trial', '{days}-day free trial', { days: priceFor(plan).trialPeriodDays }) : void 0"
|
|
134
|
-
variant="
|
|
133
|
+
variant="outline"
|
|
135
134
|
:ui="{
|
|
136
|
-
root: 'rounded-xl transition-shadow hover:shadow-lg',
|
|
137
135
|
title: 'text-lg font-semibold',
|
|
138
|
-
|
|
139
|
-
billingCycle: 'uppercase ml-1 text-muted',
|
|
136
|
+
billingCycle: 'ml-1 text-muted',
|
|
140
137
|
terms: 'text-xs text-muted'
|
|
141
138
|
}"
|
|
142
139
|
>
|
|
@@ -195,8 +192,7 @@ watch([visible, subscriptionStatus, loggedIn], () => {
|
|
|
195
192
|
<UButton
|
|
196
193
|
block
|
|
197
194
|
size="lg"
|
|
198
|
-
|
|
199
|
-
:loading="pending"
|
|
195
|
+
:loading="pending || loggedIn && subscriptionLoading"
|
|
200
196
|
:disabled="stateOf(plan) === 'current' || !priceFor(plan)"
|
|
201
197
|
:variant="stateOf(plan) === 'current' ? 'subtle' : 'solid'"
|
|
202
198
|
:label="loggedIn ? stateLabel(stateOf(plan)) : t('subscribe', 'Subscribe')"
|
|
@@ -208,7 +204,7 @@ watch([visible, subscriptionStatus, loggedIn], () => {
|
|
|
208
204
|
</DefineCard>
|
|
209
205
|
|
|
210
206
|
<slot
|
|
211
|
-
v-if="
|
|
207
|
+
v-if="plansLoading && !visible.length"
|
|
212
208
|
name="loading"
|
|
213
209
|
>
|
|
214
210
|
<p class="text-center text-sm text-muted">
|
|
@@ -234,11 +230,9 @@ watch([visible, subscriptionStatus, loggedIn], () => {
|
|
|
234
230
|
|
|
235
231
|
<template v-if="visible.length">
|
|
236
232
|
<template v-if="showTabs">
|
|
237
|
-
<
|
|
233
|
+
<PageTabs
|
|
238
234
|
v-model="activeTab"
|
|
239
235
|
:items="tabItems"
|
|
240
|
-
:content="false"
|
|
241
|
-
class="w-full"
|
|
242
236
|
:class="{ 'sm:hidden': showGrid }"
|
|
243
237
|
/>
|
|
244
238
|
<div
|
|
@@ -253,7 +247,7 @@ watch([visible, subscriptionStatus, loggedIn], () => {
|
|
|
253
247
|
</template>
|
|
254
248
|
<div
|
|
255
249
|
v-if="showGrid"
|
|
256
|
-
class="items-stretch gap-
|
|
250
|
+
class="items-stretch gap-4 sm:grid-cols-2"
|
|
257
251
|
:class="[showTabs ? 'hidden sm:grid' : 'grid', visible.length >= 3 ? 'lg:grid-cols-3' : '']"
|
|
258
252
|
>
|
|
259
253
|
<ReuseCard
|
|
@@ -8,18 +8,13 @@ declare var __VLS_9: {}, __VLS_11: {}, __VLS_13: {
|
|
|
8
8
|
}, __VLS_15: {
|
|
9
9
|
subscription: any;
|
|
10
10
|
badge: any;
|
|
11
|
-
},
|
|
11
|
+
}, __VLS_22: {
|
|
12
12
|
subscription: any;
|
|
13
13
|
cancelsAt: any;
|
|
14
14
|
dateText: any;
|
|
15
|
-
},
|
|
16
|
-
manage: () =>
|
|
17
|
-
resubscribe: () =>
|
|
18
|
-
url: string;
|
|
19
|
-
} | {
|
|
20
|
-
updated: true;
|
|
21
|
-
kind: "upgrade" | "downgrade" | "resume";
|
|
22
|
-
}) | undefined>;
|
|
15
|
+
}, __VLS_24: {}, __VLS_32: {
|
|
16
|
+
manage: () => any;
|
|
17
|
+
resubscribe: () => any;
|
|
23
18
|
cancelling: boolean;
|
|
24
19
|
pending: any;
|
|
25
20
|
};
|
|
@@ -32,11 +27,11 @@ type __VLS_Slots = {} & {
|
|
|
32
27
|
} & {
|
|
33
28
|
badge?: (props: typeof __VLS_15) => any;
|
|
34
29
|
} & {
|
|
35
|
-
dates?: (props: typeof
|
|
30
|
+
dates?: (props: typeof __VLS_22) => any;
|
|
36
31
|
} & {
|
|
37
|
-
empty?: (props: typeof
|
|
32
|
+
empty?: (props: typeof __VLS_24) => any;
|
|
38
33
|
} & {
|
|
39
|
-
actions?: (props: typeof
|
|
34
|
+
actions?: (props: typeof __VLS_32) => any;
|
|
40
35
|
};
|
|
41
36
|
declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
42
37
|
declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
|
|
@@ -10,19 +10,19 @@ const t = useModuleT("subscriptions.status");
|
|
|
10
10
|
const tp = useModuleT("subscriptions.catalog");
|
|
11
11
|
const format = useFormat();
|
|
12
12
|
const { plans } = usePlans();
|
|
13
|
-
const { subscription, plan,
|
|
13
|
+
const { subscription, plan, isActive, cancelsAt, loading, pending, error, subscribe, openPortal } = useSubscription();
|
|
14
14
|
const planLabel = computed(() => {
|
|
15
15
|
const found = plans.value.find((p) => p.key === plan.value);
|
|
16
16
|
return plan.value ? tp(`${plan.value}.label`, found?.label ?? plan.value) : plan.value;
|
|
17
17
|
});
|
|
18
18
|
const badge = computed(() => {
|
|
19
19
|
const value = subscription.value?.status;
|
|
20
|
-
if (cancelsAt.value) return { label: t("cancelling", "
|
|
21
|
-
if (value === "active") return { label: t("active", "Active"), color: statusColor(value), icon: "i-lucide-shield-check" };
|
|
22
|
-
if (value === "trialing") return { label: t("trialing", "Trial"), color: statusColor(value) };
|
|
23
|
-
if (value === "past_due" || value === "unpaid") return { label: t("pastDue", "Past due"), color: statusColor("past_due") };
|
|
24
|
-
if (value === "canceled") return { label: t("canceled", "Canceled"), color: statusColor(value) };
|
|
25
|
-
return { label: t("other", "Inactive"), color: statusColor(value) };
|
|
20
|
+
if (cancelsAt.value) return { status: "cancelling", label: t("cancelling", "Membership is ending"), color: "warning", icon: "i-lucide-battery-warning" };
|
|
21
|
+
if (value === "active") return { status: value, label: t("active", "Active"), color: statusColor(value), icon: "i-lucide-shield-check" };
|
|
22
|
+
if (value === "trialing") return { status: value, label: t("trialing", "Trial"), color: statusColor(value), icon: void 0 };
|
|
23
|
+
if (value === "past_due" || value === "unpaid") return { status: "past_due", label: t("pastDue", "Past due"), color: statusColor("past_due"), icon: void 0 };
|
|
24
|
+
if (value === "canceled") return { status: value, label: t("canceled", "Canceled"), color: statusColor(value), icon: void 0 };
|
|
25
|
+
return { status: value, label: t("other", "Inactive"), color: statusColor(value), icon: void 0 };
|
|
26
26
|
});
|
|
27
27
|
const formatDate = (value) => formatDateValue(value, props.locale ?? format.locale.value, { dateStyle: "long" });
|
|
28
28
|
const isManual = computed(() => subscription.value?.source === "manual");
|
|
@@ -38,8 +38,9 @@ const manage = () => openPortal().catch(() => void 0);
|
|
|
38
38
|
|
|
39
39
|
<template>
|
|
40
40
|
<SectionCard
|
|
41
|
-
:title="t('title', 'Your
|
|
41
|
+
:title="t('title', 'Your membership')"
|
|
42
42
|
icon="i-lucide-credit-card"
|
|
43
|
+
:loading="loading && !$slots.loading"
|
|
43
44
|
>
|
|
44
45
|
<template
|
|
45
46
|
v-if="$slots.header"
|
|
@@ -49,11 +50,11 @@ const manage = () => openPortal().catch(() => void 0);
|
|
|
49
50
|
</template>
|
|
50
51
|
|
|
51
52
|
<slot
|
|
52
|
-
v-if="
|
|
53
|
+
v-if="loading"
|
|
53
54
|
name="loading"
|
|
54
55
|
>
|
|
55
56
|
<p class="text-muted text-sm">
|
|
56
|
-
{{ t("loading", "Loading
|
|
57
|
+
{{ t("loading", "Loading membership\u2026") }}
|
|
57
58
|
</p>
|
|
58
59
|
</slot>
|
|
59
60
|
|
|
@@ -78,13 +79,12 @@ const manage = () => openPortal().catch(() => void 0);
|
|
|
78
79
|
:subscription="subscription"
|
|
79
80
|
:badge="badge"
|
|
80
81
|
>
|
|
81
|
-
<
|
|
82
|
-
:
|
|
82
|
+
<StatusBadge
|
|
83
|
+
:status="badge.status"
|
|
84
|
+
:label="badge.label"
|
|
83
85
|
:icon="badge.icon"
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
{{ badge.label }}
|
|
87
|
-
</UBadge>
|
|
86
|
+
:colors="{ cancelling: 'warning' }"
|
|
87
|
+
/>
|
|
88
88
|
</slot>
|
|
89
89
|
</div>
|
|
90
90
|
<slot
|
|
@@ -107,7 +107,7 @@ const manage = () => openPortal().catch(() => void 0);
|
|
|
107
107
|
name="empty"
|
|
108
108
|
>
|
|
109
109
|
<p class="text-muted text-sm">
|
|
110
|
-
{{ t("none", "You do not have an active
|
|
110
|
+
{{ t("none", "You do not have an active membership.") }}
|
|
111
111
|
</p>
|
|
112
112
|
</slot>
|
|
113
113
|
|
|
@@ -130,23 +130,16 @@ const manage = () => openPortal().catch(() => void 0);
|
|
|
130
130
|
:cancelling="!!cancelsAt"
|
|
131
131
|
:pending="pending"
|
|
132
132
|
>
|
|
133
|
-
<div class="flex flex-wrap gap-
|
|
133
|
+
<div class="flex flex-wrap justify-end gap-2">
|
|
134
134
|
<UButton
|
|
135
135
|
v-if="!cancelsAt"
|
|
136
136
|
color="error"
|
|
137
|
-
variant="
|
|
137
|
+
variant="outline"
|
|
138
138
|
icon="i-lucide-x"
|
|
139
139
|
:loading="pending"
|
|
140
140
|
:label="t('cancel', 'Cancel membership')"
|
|
141
141
|
@click="manage"
|
|
142
142
|
/>
|
|
143
|
-
<UButton
|
|
144
|
-
v-else
|
|
145
|
-
icon="i-lucide-rotate-ccw"
|
|
146
|
-
:loading="pending"
|
|
147
|
-
:label="t('resubscribe', 'Resubscribe')"
|
|
148
|
-
@click="resubscribe"
|
|
149
|
-
/>
|
|
150
143
|
<UButton
|
|
151
144
|
color="neutral"
|
|
152
145
|
variant="outline"
|
|
@@ -155,6 +148,13 @@ const manage = () => openPortal().catch(() => void 0);
|
|
|
155
148
|
:label="t('changePaymentMethod', 'Change payment method')"
|
|
156
149
|
@click="manage"
|
|
157
150
|
/>
|
|
151
|
+
<UButton
|
|
152
|
+
v-if="cancelsAt"
|
|
153
|
+
icon="i-lucide-rotate-ccw"
|
|
154
|
+
:loading="pending"
|
|
155
|
+
:label="t('resubscribe', 'Resubscribe')"
|
|
156
|
+
@click="resubscribe"
|
|
157
|
+
/>
|
|
158
158
|
</div>
|
|
159
159
|
</slot>
|
|
160
160
|
</template>
|
|
@@ -8,18 +8,13 @@ declare var __VLS_9: {}, __VLS_11: {}, __VLS_13: {
|
|
|
8
8
|
}, __VLS_15: {
|
|
9
9
|
subscription: any;
|
|
10
10
|
badge: any;
|
|
11
|
-
},
|
|
11
|
+
}, __VLS_22: {
|
|
12
12
|
subscription: any;
|
|
13
13
|
cancelsAt: any;
|
|
14
14
|
dateText: any;
|
|
15
|
-
},
|
|
16
|
-
manage: () =>
|
|
17
|
-
resubscribe: () =>
|
|
18
|
-
url: string;
|
|
19
|
-
} | {
|
|
20
|
-
updated: true;
|
|
21
|
-
kind: "upgrade" | "downgrade" | "resume";
|
|
22
|
-
}) | undefined>;
|
|
15
|
+
}, __VLS_24: {}, __VLS_32: {
|
|
16
|
+
manage: () => any;
|
|
17
|
+
resubscribe: () => any;
|
|
23
18
|
cancelling: boolean;
|
|
24
19
|
pending: any;
|
|
25
20
|
};
|
|
@@ -32,11 +27,11 @@ type __VLS_Slots = {} & {
|
|
|
32
27
|
} & {
|
|
33
28
|
badge?: (props: typeof __VLS_15) => any;
|
|
34
29
|
} & {
|
|
35
|
-
dates?: (props: typeof
|
|
30
|
+
dates?: (props: typeof __VLS_22) => any;
|
|
36
31
|
} & {
|
|
37
|
-
empty?: (props: typeof
|
|
32
|
+
empty?: (props: typeof __VLS_24) => any;
|
|
38
33
|
} & {
|
|
39
|
-
actions?: (props: typeof
|
|
34
|
+
actions?: (props: typeof __VLS_32) => any;
|
|
40
35
|
};
|
|
41
36
|
declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
42
37
|
declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
+
import { subscriptionsApi } from "../../../../utils/api";
|
|
2
3
|
defineOptions({ name: "UsersAdminMembership" });
|
|
3
4
|
const props = defineProps({
|
|
4
5
|
user: { type: Object, required: true }
|
|
@@ -12,7 +13,7 @@ const toast = useToast();
|
|
|
12
13
|
const grantAction = useAction();
|
|
13
14
|
const linkAction = useAction();
|
|
14
15
|
const revokeAction = useAction();
|
|
15
|
-
const base = computed(() =>
|
|
16
|
+
const base = computed(() => subscriptionsApi.admin.membership(props.user.id));
|
|
16
17
|
const { data, refresh, status } = useLazyFetch(() => base.value, { key: () => `admin-membership-${props.user.id}` });
|
|
17
18
|
const loading = computed(() => status.value === "pending" && !data.value);
|
|
18
19
|
const planKey = ref(void 0);
|
|
@@ -60,7 +61,7 @@ async function revoke() {
|
|
|
60
61
|
}, { success: t("revoked", "Membership revoked."), error: t("error", "Something went wrong.") });
|
|
61
62
|
}
|
|
62
63
|
async function sendLink() {
|
|
63
|
-
const result = await linkAction.run(() => $fetch(
|
|
64
|
+
const result = await linkAction.run(() => $fetch(subscriptionsApi.admin.paymentLink(props.user.id), {
|
|
64
65
|
method: "POST",
|
|
65
66
|
body: { planKey: planKey.value, email: emailUser.value }
|
|
66
67
|
}), { error: t("error", "Something went wrong.") });
|
|
@@ -21,14 +21,7 @@ export type PlansResponse = {
|
|
|
21
21
|
};
|
|
22
22
|
/** Matches the server-side KV cache (`CACHE_TTL_SECONDS`), so a client-side hit is never older than the server's own. */
|
|
23
23
|
export declare const PLANS_CLIENT_CACHE_MS: number;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
export declare const usePlansCache: () => any;
|
|
29
|
-
export declare const usePlans: () => {
|
|
30
|
-
plans: any;
|
|
31
|
-
status: any;
|
|
32
|
-
error: any;
|
|
33
|
-
refresh: any;
|
|
34
|
-
};
|
|
24
|
+
/** The `useCachedFetch` key of the plans. */
|
|
25
|
+
export declare const PLANS_KEY = "subscriptions:plans";
|
|
26
|
+
/** The plans, sorted by rank. Awaitable, like `useSubscription`. */
|
|
27
|
+
export declare const usePlans: () => any;
|
|
@@ -1,18 +1,15 @@
|
|
|
1
|
+
import { subscriptionsApi } from "../../utils/api.js";
|
|
1
2
|
export const PLANS_CLIENT_CACHE_MS = 5 * 60 * 1e3;
|
|
2
|
-
export const
|
|
3
|
+
export const PLANS_KEY = "subscriptions:plans";
|
|
3
4
|
export const usePlans = () => {
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
default: () => ({ plans: [] }),
|
|
8
|
-
getCachedData: (_key, _nuxtApp, ctx) => ctx.cause !== "refresh:manual" && cache.value && Date.now() - cache.value.at < PLANS_CLIENT_CACHE_MS ? cache.value.data : void 0,
|
|
9
|
-
transform: (response) => {
|
|
10
|
-
cache.value = { at: Date.now(), data: response };
|
|
11
|
-
return response;
|
|
12
|
-
}
|
|
5
|
+
const fetched = useCachedFetch(subscriptionsApi.plans, {
|
|
6
|
+
key: PLANS_KEY,
|
|
7
|
+
ttl: PLANS_CLIENT_CACHE_MS
|
|
13
8
|
});
|
|
9
|
+
const { data, status, error, loading, refresh } = fetched;
|
|
14
10
|
const plans = computed(
|
|
15
11
|
() => [...data.value?.plans ?? []].sort((a, b) => a.rank - b.rank)
|
|
16
12
|
);
|
|
17
|
-
|
|
13
|
+
const api = { plans, status, error, loading, refresh };
|
|
14
|
+
return Object.assign(fetched.then(() => api), api);
|
|
18
15
|
};
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { MaybeRefOrGetter } from 'vue';
|
|
2
1
|
export interface UserSubscription {
|
|
3
2
|
id: string;
|
|
4
3
|
planKey: string | null;
|
|
@@ -10,26 +9,9 @@ export interface UserSubscription {
|
|
|
10
9
|
endedAt: string | null;
|
|
11
10
|
source?: 'stripe' | 'manual';
|
|
12
11
|
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
export declare const useSubscription: () => {
|
|
20
|
-
subscription: any;
|
|
21
|
-
plan: any;
|
|
22
|
-
status: any;
|
|
23
|
-
isActive: any;
|
|
24
|
-
cancelsAt: any;
|
|
25
|
-
has: (planKey: MaybeRefOrGetter<string>) => any;
|
|
26
|
-
pending: any;
|
|
27
|
-
error: any;
|
|
28
|
-
refresh: any;
|
|
29
|
-
subscribe: (planKeyOrPriceId: string, options?: {
|
|
30
|
-
successUrl?: string;
|
|
31
|
-
cancelUrl?: string;
|
|
32
|
-
}) => Promise<CheckoutResponse>;
|
|
33
|
-
openPortal: (returnUrl?: string) => Promise<void>;
|
|
34
|
-
};
|
|
35
|
-
export {};
|
|
12
|
+
/**
|
|
13
|
+
* The signed-in user's subscription and the checkout / billing-portal actions. Awaitable: `await useSubscription()` in
|
|
14
|
+
* a page renders the subscription on the server; components and widgets can use it without `await` and show a
|
|
15
|
+
* placeholder while `loading`.
|
|
16
|
+
*/
|
|
17
|
+
export declare const useSubscription: () => any;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { subscriptionsApi } from "../../utils/api.js";
|
|
1
2
|
const ACTIVE_STATUSES = ["active", "trialing"];
|
|
2
3
|
const CLIENT_CACHE_MS = 60 * 1e3;
|
|
4
|
+
const ME_KEY = "subscriptions:me";
|
|
3
5
|
function isEntitled(item) {
|
|
4
6
|
if (!ACTIVE_STATUSES.includes(item.status) || item.endedAt) return false;
|
|
5
7
|
return !(item.source === "manual" && item.currentPeriodEnd && new Date(item.currentPeriodEnd).getTime() <= Date.now());
|
|
@@ -9,24 +11,16 @@ export const useSubscription = () => {
|
|
|
9
11
|
const t = useModuleT("subscriptions");
|
|
10
12
|
const pending = ref(false);
|
|
11
13
|
const error = ref(null);
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
default: () => ({ subscriptions: [], activePlan: null }),
|
|
14
|
+
const fetched = useCachedFetch(subscriptionsApi.me.subscriptions, {
|
|
15
|
+
key: ME_KEY,
|
|
16
|
+
ttl: CLIENT_CACHE_MS,
|
|
16
17
|
immediate: loggedIn.value,
|
|
17
|
-
watch: false
|
|
18
|
-
getCachedData: (_key, _nuxtApp, ctx) => ctx.cause !== "refresh:manual" && cache.value && Date.now() - cache.value.at < CLIENT_CACHE_MS ? cache.value.data : void 0,
|
|
19
|
-
transform: (response) => {
|
|
20
|
-
cache.value = { at: Date.now(), data: response };
|
|
21
|
-
return response;
|
|
22
|
-
}
|
|
18
|
+
watch: false
|
|
23
19
|
});
|
|
20
|
+
const { data, status, error: loadError, loading, refresh, clear } = fetched;
|
|
24
21
|
watch(loggedIn, (value) => {
|
|
25
22
|
if (value) refresh();
|
|
26
|
-
else
|
|
27
|
-
cache.value = null;
|
|
28
|
-
data.value = { subscriptions: [], activePlan: null };
|
|
29
|
-
}
|
|
23
|
+
else clear();
|
|
30
24
|
});
|
|
31
25
|
const subscription = computed(() => {
|
|
32
26
|
const list = data.value?.subscriptions ?? [];
|
|
@@ -58,7 +52,7 @@ export const useSubscription = () => {
|
|
|
58
52
|
const origin = import.meta.client ? window.location.origin : "";
|
|
59
53
|
const absolute = (path) => path ? `${origin}${path}` : void 0;
|
|
60
54
|
return run(async () => {
|
|
61
|
-
const result = await $fetch(
|
|
55
|
+
const result = await $fetch(subscriptionsApi.me.checkout, {
|
|
62
56
|
method: "POST",
|
|
63
57
|
body: {
|
|
64
58
|
...isPrice ? { priceId: planKeyOrPriceId } : { planKey: planKeyOrPriceId },
|
|
@@ -76,12 +70,13 @@ export const useSubscription = () => {
|
|
|
76
70
|
}
|
|
77
71
|
function openPortal(returnUrl) {
|
|
78
72
|
return run(async () => {
|
|
79
|
-
const { url } = await $fetch(
|
|
73
|
+
const { url } = await $fetch(subscriptionsApi.me.portal, {
|
|
80
74
|
method: "POST",
|
|
81
75
|
body: { returnUrl: returnUrl ?? (import.meta.client ? window.location.href : void 0) }
|
|
82
76
|
});
|
|
83
77
|
await navigateTo(url, { external: true });
|
|
84
78
|
}, t("errors.portal", "Failed to open the billing portal."));
|
|
85
79
|
}
|
|
86
|
-
|
|
80
|
+
const api = { subscription, plan, status, loading, loadError, isActive, cancelsAt, has, pending, error, refresh, subscribe, openPortal };
|
|
81
|
+
return Object.assign(fetched.then(() => api), api);
|
|
87
82
|
};
|
|
@@ -3,12 +3,8 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|
|
3
3
|
const config = useRuntimeConfig().public.subscriptions;
|
|
4
4
|
const target = meta?.redirectTo ?? config?.pages?.membership;
|
|
5
5
|
if (!target || to.path === target) return;
|
|
6
|
-
const {
|
|
7
|
-
|
|
8
|
-
if (loggedIn.value) {
|
|
9
|
-
const me = await useRequestFetch()("/api/subscriptions/me").catch(() => null);
|
|
10
|
-
activePlan = me?.activePlan ?? null;
|
|
11
|
-
}
|
|
6
|
+
const { plan } = await useSubscription();
|
|
7
|
+
const activePlan = plan.value;
|
|
12
8
|
const allowed = activePlan !== null && (!meta?.plans?.length || meta.plans.includes(activePlan));
|
|
13
9
|
if (!allowed) return navigateTo({ path: target, query: { redirect: to.fullPath } });
|
|
14
10
|
});
|
|
@@ -2,14 +2,26 @@
|
|
|
2
2
|
defineOptions({ name: "SubscriptionCancelPage" });
|
|
3
3
|
const t = useModuleT("subscriptions.cancel");
|
|
4
4
|
const { localize } = useLocaleLink();
|
|
5
|
+
const tm = useModuleT("subscriptions.menu.membership");
|
|
5
6
|
const membership = useRuntimeConfig().public.subscriptions?.pages?.membership;
|
|
7
|
+
const breadcrumb = computed(() => [
|
|
8
|
+
...membership ? [{ label: tm("label", "Membership"), to: localize(membership) }] : [],
|
|
9
|
+
{ label: t("crumb", "Checkout canceled") }
|
|
10
|
+
]);
|
|
6
11
|
</script>
|
|
7
12
|
|
|
8
13
|
<template>
|
|
9
|
-
<
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
<div class="flex flex-col gap-4">
|
|
15
|
+
<PageHeading
|
|
16
|
+
:title="tm('label', 'Membership')"
|
|
17
|
+
:description="tm('description', 'Manage your plan and billing.')"
|
|
18
|
+
:breadcrumb="breadcrumb"
|
|
19
|
+
/>
|
|
20
|
+
<UEmpty
|
|
21
|
+
icon="i-lucide-circle-x"
|
|
22
|
+
:title="t('title', 'Checkout canceled')"
|
|
23
|
+
:description="t('body', 'No changes were made and you have not been charged.')"
|
|
24
|
+
:actions="membership ? [{ label: t('back', 'Back to membership'), to: localize(membership) }] : []"
|
|
25
|
+
/>
|
|
26
|
+
</div>
|
|
15
27
|
</template>
|