@frigear-nu/subscriptions 0.1.2 → 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.
Files changed (39) hide show
  1. package/README.md +3 -3
  2. package/dist/module.d.mts +2 -2
  3. package/dist/module.json +1 -1
  4. package/dist/module.mjs +16 -31
  5. package/dist/runtime/app/components/SubscriptionPlans.vue +5 -11
  6. package/dist/runtime/app/components/SubscriptionStatus.d.vue.ts +5 -5
  7. package/dist/runtime/app/components/SubscriptionStatus.vue +24 -24
  8. package/dist/runtime/app/components/SubscriptionStatus.vue.d.ts +5 -5
  9. package/dist/runtime/app/components/Users/Admin/Membership.vue +3 -2
  10. package/dist/runtime/app/composables/usePlans.js +2 -1
  11. package/dist/runtime/app/composables/useSubscription.js +4 -3
  12. package/dist/runtime/app/pages/cancel.vue +18 -6
  13. package/dist/runtime/app/pages/membership.vue +28 -17
  14. package/dist/runtime/app/pages/success.vue +18 -6
  15. package/dist/runtime/app/tabs/Membership.vue +8 -8
  16. package/dist/runtime/i18n/lang/da.json +13 -11
  17. package/dist/runtime/i18n/lang/en.json +15 -13
  18. package/dist/runtime/menus.js +4 -4
  19. package/dist/runtime/server/routes/admin/{subscription.delete.js → membership.delete.js} +3 -2
  20. package/dist/runtime/server/routes/admin/{subscription.get.js → membership.get.js} +2 -2
  21. package/dist/runtime/server/routes/admin/{subscription.post.js → membership.post.js} +9 -14
  22. package/dist/runtime/server/routes/admin/payment-link.post.js +3 -2
  23. package/dist/runtime/server/routes/{subscriptions → me}/checkout.post.js +9 -18
  24. package/dist/runtime/server/routes/{subscriptions → me}/portal.post.js +5 -8
  25. package/dist/runtime/server/utils/checkout.js +6 -5
  26. package/dist/runtime/server/utils/subscriptions.d.ts +6 -1
  27. package/dist/runtime/server/utils/subscriptions.js +15 -12
  28. package/dist/runtime/utils/api.d.ts +20 -0
  29. package/dist/runtime/utils/api.js +23 -0
  30. package/dist/runtime/utils/index.d.ts +2 -1
  31. package/dist/runtime/utils/index.js +2 -1
  32. package/package.json +17 -12
  33. /package/dist/runtime/server/routes/admin/{subscription.delete.d.ts → membership-history.get.d.ts} +0 -0
  34. /package/dist/runtime/server/routes/admin/{subscriptions.get.js → membership-history.get.js} +0 -0
  35. /package/dist/runtime/server/routes/admin/{subscription.get.d.ts → membership.delete.d.ts} +0 -0
  36. /package/dist/runtime/server/routes/admin/{subscription.post.d.ts → membership.get.d.ts} +0 -0
  37. /package/dist/runtime/server/routes/admin/{subscriptions.get.d.ts → membership.post.d.ts} +0 -0
  38. /package/dist/runtime/server/routes/{subscriptions → me}/checkout.post.d.ts +0 -0
  39. /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 "Subscription" item to `@frigear-nu/menu` (when installed).
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
 
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/subscription` (staff). */
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/subscription/payment-link` (staff). */
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@frigear-nu/subscriptions",
3
3
  "configKey": "subscriptions",
4
- "version": "0.1.2",
4
+ "version": "0.1.3",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.3",
7
7
  "unbuild": "unknown"
package/dist/module.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { defineNuxtModule, createResolver, hasNuxtModule, addServerHandler, addServerImportsDir, addImports, addComponentsDir, addRouteMiddleware } from '@nuxt/kit';
2
- import { registerModule, registerTailwindSource, registerLocales, resolvePagePath, registerDatabaseTable, moduleApiUrl, registerBootstrapProvider, inlineModuleRuntime, addModulePages, addOptionalMenuSource } from '@frigear-nu/module-kit/utils';
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,44 +86,30 @@ const module$1 = defineNuxtModule({
87
86
  handler: resolver.resolve("./runtime/server/stripe/invoice-events")
88
87
  });
89
88
  }
90
- addServerHandler({
91
- route: moduleApiUrl("subscriptions", "plans"),
92
- method: "get",
93
- handler: resolver.resolve("./runtime/server/routes/subscriptions/plans.get")
94
- });
95
- addServerHandler({
96
- route: moduleApiUrl("subscriptions", "me"),
97
- method: "get",
98
- handler: resolver.resolve("./runtime/server/routes/subscriptions/me.get")
99
- });
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" });
100
95
  registerBootstrapProvider(nuxt, {
101
96
  scope: "account",
102
97
  key: "subscriptions:me",
103
98
  handler: resolver.resolve("./runtime/server/bootstrap/my-subscriptions")
104
99
  });
105
- addServerHandler({
106
- route: moduleApiUrl("subscriptions", "checkout"),
107
- method: "post",
108
- handler: resolver.resolve("./runtime/server/routes/subscriptions/checkout.post")
109
- });
110
- addServerHandler({
111
- route: moduleApiUrl("subscriptions", "portal"),
112
- method: "post",
113
- handler: resolver.resolve("./runtime/server/routes/subscriptions/portal.post")
114
- });
115
100
  await extendUsersAdmin(nuxt, {
116
101
  routes: [
117
- ["get", "subscription", "subscription.get"],
118
- ["get", "subscriptions", "subscriptions.get"],
119
- ["post", "subscription", "subscription.post"],
120
- ["delete", "subscription", "subscription.delete"],
121
- ["post", "subscription/payment-link", "payment-link.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"]
122
107
  ].map(([method, path, file]) => ({ method, path, handler: resolver.resolve(`./runtime/server/routes/admin/${file}`) })),
123
108
  tab: {
124
- id: "subscription",
109
+ id: "membership",
125
110
  component: resolver.resolve("./runtime/app/tabs/Membership.vue"),
126
111
  order: 10,
127
- label: "Subscription",
112
+ label: "Membership",
128
113
  labelKey: "subscriptions.admin.tab",
129
114
  icon: "i-lucide-badge-check"
130
115
  },
@@ -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="subtle"
133
+ variant="outline"
135
134
  :ui="{
136
- root: 'rounded-xl transition-shadow hover:shadow-lg',
137
135
  title: 'text-lg font-semibold',
138
- price: 'text-4xl font-extrabold text-primary',
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,7 +192,6 @@ watch([visible, subscriptionStatus, loggedIn], () => {
195
192
  <UButton
196
193
  block
197
194
  size="lg"
198
- class="uppercase"
199
195
  :loading="pending || loggedIn && subscriptionLoading"
200
196
  :disabled="stateOf(plan) === 'current' || !priceFor(plan)"
201
197
  :variant="stateOf(plan) === 'current' ? 'subtle' : 'solid'"
@@ -234,11 +230,9 @@ watch([visible, subscriptionStatus, loggedIn], () => {
234
230
 
235
231
  <template v-if="visible.length">
236
232
  <template v-if="showTabs">
237
- <UTabs
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-6 sm:grid-cols-2"
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,11 +8,11 @@ declare var __VLS_9: {}, __VLS_11: {}, __VLS_13: {
8
8
  }, __VLS_15: {
9
9
  subscription: any;
10
10
  badge: any;
11
- }, __VLS_23: {
11
+ }, __VLS_22: {
12
12
  subscription: any;
13
13
  cancelsAt: any;
14
14
  dateText: any;
15
- }, __VLS_25: {}, __VLS_33: {
15
+ }, __VLS_24: {}, __VLS_32: {
16
16
  manage: () => any;
17
17
  resubscribe: () => any;
18
18
  cancelling: boolean;
@@ -27,11 +27,11 @@ type __VLS_Slots = {} & {
27
27
  } & {
28
28
  badge?: (props: typeof __VLS_15) => any;
29
29
  } & {
30
- dates?: (props: typeof __VLS_23) => any;
30
+ dates?: (props: typeof __VLS_22) => any;
31
31
  } & {
32
- empty?: (props: typeof __VLS_25) => any;
32
+ empty?: (props: typeof __VLS_24) => any;
33
33
  } & {
34
- actions?: (props: typeof __VLS_33) => any;
34
+ actions?: (props: typeof __VLS_32) => any;
35
35
  };
36
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>;
37
37
  declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
@@ -17,12 +17,12 @@ const planLabel = computed(() => {
17
17
  });
18
18
  const badge = computed(() => {
19
19
  const value = subscription.value?.status;
20
- if (cancelsAt.value) return { label: t("cancelling", "Subscription is ending"), color: "warning", icon: "i-lucide-battery-warning" };
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 subscription')"
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"
@@ -53,7 +54,7 @@ const manage = () => openPortal().catch(() => void 0);
53
54
  name="loading"
54
55
  >
55
56
  <p class="text-muted text-sm">
56
- {{ t("loading", "Loading subscription\u2026") }}
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
- <UBadge
82
- :color="badge.color"
82
+ <StatusBadge
83
+ :status="badge.status"
84
+ :label="badge.label"
83
85
  :icon="badge.icon"
84
- variant="subtle"
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 subscription.") }}
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-3">
133
+ <div class="flex flex-wrap justify-end gap-2">
134
134
  <UButton
135
135
  v-if="!cancelsAt"
136
136
  color="error"
137
- variant="soft"
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,11 +8,11 @@ declare var __VLS_9: {}, __VLS_11: {}, __VLS_13: {
8
8
  }, __VLS_15: {
9
9
  subscription: any;
10
10
  badge: any;
11
- }, __VLS_23: {
11
+ }, __VLS_22: {
12
12
  subscription: any;
13
13
  cancelsAt: any;
14
14
  dateText: any;
15
- }, __VLS_25: {}, __VLS_33: {
15
+ }, __VLS_24: {}, __VLS_32: {
16
16
  manage: () => any;
17
17
  resubscribe: () => any;
18
18
  cancelling: boolean;
@@ -27,11 +27,11 @@ type __VLS_Slots = {} & {
27
27
  } & {
28
28
  badge?: (props: typeof __VLS_15) => any;
29
29
  } & {
30
- dates?: (props: typeof __VLS_23) => any;
30
+ dates?: (props: typeof __VLS_22) => any;
31
31
  } & {
32
- empty?: (props: typeof __VLS_25) => any;
32
+ empty?: (props: typeof __VLS_24) => any;
33
33
  } & {
34
- actions?: (props: typeof __VLS_33) => any;
34
+ actions?: (props: typeof __VLS_32) => any;
35
35
  };
36
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>;
37
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(() => `/api/admin/users/${props.user.id}/subscription`);
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(`${base.value}/payment-link`, {
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.") });
@@ -1,7 +1,8 @@
1
+ import { subscriptionsApi } from "../../utils/api.js";
1
2
  export const PLANS_CLIENT_CACHE_MS = 5 * 60 * 1e3;
2
3
  export const PLANS_KEY = "subscriptions:plans";
3
4
  export const usePlans = () => {
4
- const fetched = useCachedFetch("/api/subscriptions/plans", {
5
+ const fetched = useCachedFetch(subscriptionsApi.plans, {
5
6
  key: PLANS_KEY,
6
7
  ttl: PLANS_CLIENT_CACHE_MS
7
8
  });
@@ -1,3 +1,4 @@
1
+ import { subscriptionsApi } from "../../utils/api.js";
1
2
  const ACTIVE_STATUSES = ["active", "trialing"];
2
3
  const CLIENT_CACHE_MS = 60 * 1e3;
3
4
  const ME_KEY = "subscriptions:me";
@@ -10,7 +11,7 @@ export const useSubscription = () => {
10
11
  const t = useModuleT("subscriptions");
11
12
  const pending = ref(false);
12
13
  const error = ref(null);
13
- const fetched = useCachedFetch("/api/subscriptions/me", {
14
+ const fetched = useCachedFetch(subscriptionsApi.me.subscriptions, {
14
15
  key: ME_KEY,
15
16
  ttl: CLIENT_CACHE_MS,
16
17
  immediate: loggedIn.value,
@@ -51,7 +52,7 @@ export const useSubscription = () => {
51
52
  const origin = import.meta.client ? window.location.origin : "";
52
53
  const absolute = (path) => path ? `${origin}${path}` : void 0;
53
54
  return run(async () => {
54
- const result = await $fetch("/api/subscriptions/checkout", {
55
+ const result = await $fetch(subscriptionsApi.me.checkout, {
55
56
  method: "POST",
56
57
  body: {
57
58
  ...isPrice ? { priceId: planKeyOrPriceId } : { planKey: planKeyOrPriceId },
@@ -69,7 +70,7 @@ export const useSubscription = () => {
69
70
  }
70
71
  function openPortal(returnUrl) {
71
72
  return run(async () => {
72
- const { url } = await $fetch("/api/subscriptions/portal", {
73
+ const { url } = await $fetch(subscriptionsApi.me.portal, {
73
74
  method: "POST",
74
75
  body: { returnUrl: returnUrl ?? (import.meta.client ? window.location.href : void 0) }
75
76
  });
@@ -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
- <UEmpty
10
- icon="i-lucide-circle-x"
11
- :title="t('title', 'Checkout canceled')"
12
- :description="t('body', 'No changes were made and you have not been charged.')"
13
- :actions="membership ? [{ label: t('back', 'Back to membership'), to: localize(membership) }] : []"
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>
@@ -4,16 +4,22 @@ import SubscriptionStatus from "../components/SubscriptionStatus.vue";
4
4
  import { useSubscription } from "../composables/useSubscription";
5
5
  defineOptions({ name: "SubscriptionMembershipPage" });
6
6
  const t = useModuleT("subscriptions");
7
+ const tm = useModuleT("subscriptions.menu.membership");
7
8
  const { isActive, cancelsAt, loading, loadError, refresh } = await useSubscription();
8
9
  const { loggedIn } = useUserSession();
9
10
  </script>
10
11
 
11
12
  <template>
12
- <div class="flex flex-col gap-6">
13
- <!-- Until the subscription is known, a placeholder the size of the status card: never the "join" hero. -->
13
+ <div class="flex flex-col gap-4">
14
+ <PageHeading
15
+ :title="tm('label', 'Membership')"
16
+ :description="tm('description', 'Manage your plan and billing.')"
17
+ />
18
+
19
+ <!-- Until the subscription is known, a placeholder the size of the status card: never the "join" empty state. -->
14
20
  <SectionCard
15
21
  v-if="loading"
16
- :title="t('status.title', 'Your subscription')"
22
+ :title="t('status.title', 'Your membership')"
17
23
  icon="i-lucide-credit-card"
18
24
  loading
19
25
  />
@@ -25,23 +31,28 @@ const { loggedIn } = useUserSession();
25
31
  :description="errorMessage(loadError, '')"
26
32
  :actions="[{ label: t('status.tryAgain', 'Try again'), icon: 'i-lucide-rotate-ccw', color: 'neutral', variant: 'outline', onClick: () => refresh() }]"
27
33
  />
28
- <div
34
+ <SectionCard
29
35
  v-else-if="loggedIn"
30
- class="flex flex-col gap-1"
36
+ :title="t('status.title', 'Your membership')"
37
+ icon="i-lucide-credit-card"
31
38
  >
32
- <h2 class="text-highlighted text-lg font-semibold">
33
- {{ t("status.heroTitle", "Join the team today") }}
34
- </h2>
35
- <p class="text-muted text-sm">
36
- {{ t("status.heroDescription", "Set up your membership to get started!") }}
37
- </p>
38
- </div>
39
- <h2
39
+ <UEmpty
40
+ variant="naked"
41
+ icon="i-lucide-badge-check"
42
+ :title="t('status.emptyTitle', 'No membership yet')"
43
+ :description="t('status.emptyDescription', 'Set up your membership to get started!')"
44
+ />
45
+ </SectionCard>
46
+
47
+ <SectionCard
40
48
  v-if="cancelsAt"
41
- class="text-highlighted text-lg font-semibold"
49
+ :title="t('plans.resubscribe', 'Resubscribe')"
42
50
  >
43
- {{ t("plans.resubscribe", "Resubscribe") }}
44
- </h2>
45
- <SubscriptionPlans confirm-change />
51
+ <SubscriptionPlans confirm-change />
52
+ </SectionCard>
53
+ <SubscriptionPlans
54
+ v-else
55
+ confirm-change
56
+ />
46
57
  </div>
47
58
  </template>
@@ -4,7 +4,12 @@ defineOptions({ name: "SubscriptionSuccessPage" });
4
4
  const t = useModuleT("subscriptions.success");
5
5
  const { refresh, isActive } = await useSubscription();
6
6
  const { localize } = useLocaleLink();
7
+ const tm = useModuleT("subscriptions.menu.membership");
7
8
  const membership = useRuntimeConfig().public.subscriptions?.pages?.membership;
9
+ const breadcrumb = computed(() => [
10
+ ...membership ? [{ label: tm("label", "Membership"), to: localize(membership) }] : [],
11
+ { label: t("crumb", "Checkout complete") }
12
+ ]);
8
13
  const MAX_ATTEMPTS = 40;
9
14
  const INTERVAL_MS = 3e3;
10
15
  let stopped = false;
@@ -20,10 +25,17 @@ onMounted(async () => {
20
25
  </script>
21
26
 
22
27
  <template>
23
- <UEmpty
24
- :icon="isActive ? 'i-lucide-circle-check' : 'i-lucide-loader-circle'"
25
- :title="t('title', 'Thank you!')"
26
- :description="isActive ? t('activeBody', 'Your subscription is now active.') : t('body', 'Your subscription is being activated. This can take a few seconds.')"
27
- :actions="membership ? [{ label: t('back', 'Back to membership'), to: localize(membership) }] : []"
28
- />
28
+ <div class="flex flex-col gap-4">
29
+ <PageHeading
30
+ :title="tm('label', 'Membership')"
31
+ :description="tm('description', 'Manage your plan and billing.')"
32
+ :breadcrumb="breadcrumb"
33
+ />
34
+ <UEmpty
35
+ :icon="isActive ? 'i-lucide-circle-check' : 'i-lucide-loader-circle'"
36
+ :title="t('title', 'Thank you!')"
37
+ :description="isActive ? t('activeBody', 'Your membership is now active.') : t('body', 'Your membership is being activated. This can take a few seconds.')"
38
+ :actions="membership ? [{ label: t('back', 'Back to membership'), to: localize(membership) }] : []"
39
+ />
40
+ </div>
29
41
  </template>
@@ -1,4 +1,5 @@
1
1
  <script setup>
2
+ import { subscriptionsApi } from "../../utils/api";
2
3
  defineOptions({ name: "UsersAdminMembershipTab" });
3
4
  const props = defineProps({
4
5
  user: { type: Object, required: true }
@@ -6,16 +7,16 @@ const props = defineProps({
6
7
  const emit = defineEmits(["refresh"]);
7
8
  const t = useModuleT("subscriptions.admin");
8
9
  const format = useFormat();
9
- const UBadge = resolveComponent("UBadge");
10
+ const StatusBadge = resolveComponent("StatusBadge");
10
11
  const { data: history, refresh: refreshHistory, status, error } = useLazyFetch(
11
- () => `/api/admin/users/${props.user.id}/subscriptions`,
12
+ () => subscriptionsApi.admin.history(props.user.id),
12
13
  { key: () => `admin-subscriptions-${props.user.id}` }
13
14
  );
14
15
  const loading = computed(() => status.value === "pending" && !history.value);
15
- const date = (value) => format.date(value) || "\u2013";
16
+ const date = (value) => format.date(value) || emptyCell();
16
17
  const columns = computed(() => [
17
18
  { accessorKey: "planKey", header: t("history.plan", "Plan"), cell: ({ row }) => row.original.planKey ?? row.original.priceId },
18
- { accessorKey: "status", header: t("history.status", "Status"), cell: ({ row }) => h(UBadge, { label: row.original.status, variant: "subtle", color: statusColor(row.original.status) }) },
19
+ { accessorKey: "status", header: t("history.status", "Status"), cell: ({ row }) => h(StatusBadge, { status: row.original.status }) },
19
20
  { accessorKey: "source", header: t("history.source", "Source"), cell: ({ row }) => row.original.source === "manual" ? t("free", "Free of charge") : t("paid", "Paid (Stripe)") },
20
21
  { accessorKey: "currentPeriodStart", header: t("history.started", "Started"), cell: ({ row }) => date(row.original.currentPeriodStart) },
21
22
  { accessorKey: "currentPeriodEnd", header: t("history.periodEnd", "Period end"), cell: ({ row }) => date(row.original.currentPeriodEnd) },
@@ -36,15 +37,14 @@ async function changed() {
36
37
  />
37
38
  </SectionCard>
38
39
 
39
- <SectionCard
40
+ <TableCard
40
41
  :title="t('history.title', 'Subscription history')"
41
- flush
42
42
  :loading="loading"
43
+ :refreshing="status === 'pending' && !loading"
43
44
  >
44
45
  <UTable
45
46
  :data="history?.subscriptions ?? []"
46
47
  :columns="columns"
47
- :loading="status === 'pending' && !loading"
48
48
  :ui="tableUi({ tr: '' })"
49
49
  >
50
50
  <template #empty>
@@ -63,6 +63,6 @@ async function changed() {
63
63
  />
64
64
  </template>
65
65
  </UTable>
66
- </SectionCard>
66
+ </TableCard>
67
67
  </div>
68
68
  </template>