@frigear-nu/subscriptions 0.1.0 → 0.1.2

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 (28) hide show
  1. package/README.md +2 -2
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +10 -3
  4. package/dist/runtime/app/components/SubscriptionPlans.vue +4 -4
  5. package/dist/runtime/app/components/SubscriptionStatus.d.vue.ts +2 -7
  6. package/dist/runtime/app/components/SubscriptionStatus.vue +2 -2
  7. package/dist/runtime/app/components/SubscriptionStatus.vue.d.ts +2 -7
  8. package/dist/runtime/app/composables/usePlans.d.ts +4 -11
  9. package/dist/runtime/app/composables/usePlans.js +7 -11
  10. package/dist/runtime/app/composables/useSubscription.d.ts +6 -24
  11. package/dist/runtime/app/composables/useSubscription.js +9 -15
  12. package/dist/runtime/app/middleware/subscription.js +2 -6
  13. package/dist/runtime/app/pages/membership.vue +16 -2
  14. package/dist/runtime/app/pages/success.vue +1 -1
  15. package/dist/runtime/app/tabs/Membership.vue +12 -2
  16. package/dist/runtime/app/widgets/MembershipSummary.vue +2 -2
  17. package/dist/runtime/emails/subscriptions-payment-link.d.vue.ts +8 -0
  18. package/dist/runtime/emails/subscriptions-payment-link.vue +34 -0
  19. package/dist/runtime/emails/subscriptions-payment-link.vue.d.ts +8 -0
  20. package/dist/runtime/i18n/lang/da.json +4 -1
  21. package/dist/runtime/i18n/lang/en.json +4 -1
  22. package/dist/runtime/server/bootstrap/my-subscriptions.d.ts +7 -0
  23. package/dist/runtime/server/bootstrap/my-subscriptions.js +4 -0
  24. package/dist/runtime/server/routes/admin/payment-link.post.js +2 -1
  25. package/dist/runtime/server/routes/subscriptions/me.get.js +2 -4
  26. package/package.json +19 -13
  27. package/dist/runtime/app/plugins/warm-plans.client.d.ts +0 -6
  28. package/dist/runtime/app/plugins/warm-plans.client.js +0 -12
package/README.md CHANGED
@@ -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.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@frigear-nu/subscriptions",
3
3
  "configKey": "subscriptions",
4
- "version": "0.1.0",
4
+ "version": "0.1.2",
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, addPlugin, addRouteMiddleware } from '@nuxt/kit';
2
- import { registerModule, registerTailwindSource, registerLocales, resolvePagePath, registerDatabaseTable, moduleApiUrl, inlineModuleRuntime, addModulePages, addOptionalMenuSource } from '@frigear-nu/module-kit/utils';
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';
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';
@@ -21,6 +21,8 @@ const module$1 = defineNuxtModule({
21
21
  "@frigear-nu/auth": { version: ">=0.0.1" },
22
22
  "@frigear-nu/mcp": { version: ">=0.0.1" },
23
23
  "@frigear-nu/users": { version: ">=0.0.1" },
24
+ // The payment link email is dispatched through it.
25
+ "@frigear-nu/notify": { version: ">=0.0.1" },
24
26
  "@frigear-nu/stripe": { version: ">=0.0.1" },
25
27
  // The pages and admin pieces are built from its components and composables (`SectionCard`, `useFormat`, ...).
26
28
  "@frigear-nu/ui": { version: ">=0.0.1" },
@@ -95,6 +97,11 @@ const module$1 = defineNuxtModule({
95
97
  method: "get",
96
98
  handler: resolver.resolve("./runtime/server/routes/subscriptions/me.get")
97
99
  });
100
+ registerBootstrapProvider(nuxt, {
101
+ scope: "account",
102
+ key: "subscriptions:me",
103
+ handler: resolver.resolve("./runtime/server/bootstrap/my-subscriptions")
104
+ });
98
105
  addServerHandler({
99
106
  route: moduleApiUrl("subscriptions", "checkout"),
100
107
  method: "post",
@@ -128,6 +135,7 @@ const module$1 = defineNuxtModule({
128
135
  span: 2
129
136
  }
130
137
  });
138
+ nuxt.hook("notify:email:templates", (sources) => sources.push({ dir: resolver.resolve("./runtime/emails") }));
131
139
  addServerImportsDir(resolver.resolve("./runtime/server/utils"));
132
140
  inlineModuleRuntime(nuxt, resolver);
133
141
  if (settings.mcp === true) addMcpDefinitions(nuxt, resolver.resolve("./runtime/server/mcp"));
@@ -136,7 +144,6 @@ const module$1 = defineNuxtModule({
136
144
  { name: "useSubscription", from: resolver.resolve("./runtime/app/composables/useSubscription") }
137
145
  ]);
138
146
  addComponentsDir({ path: resolver.resolve("./runtime/app/components") });
139
- addPlugin({ src: resolver.resolve("./runtime/app/plugins/warm-plans.client"), mode: "client" });
140
147
  addRouteMiddleware({
141
148
  name: "subscription",
142
149
  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, status } = usePlans();
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();
@@ -196,7 +196,7 @@ watch([visible, subscriptionStatus, loggedIn], () => {
196
196
  block
197
197
  size="lg"
198
198
  class="uppercase"
199
- :loading="pending"
199
+ :loading="pending || loggedIn && subscriptionLoading"
200
200
  :disabled="stateOf(plan) === 'current' || !priceFor(plan)"
201
201
  :variant="stateOf(plan) === 'current' ? 'subtle' : 'solid'"
202
202
  :label="loggedIn ? stateLabel(stateOf(plan)) : t('subscribe', 'Subscribe')"
@@ -208,7 +208,7 @@ watch([visible, subscriptionStatus, loggedIn], () => {
208
208
  </DefineCard>
209
209
 
210
210
  <slot
211
- v-if="status === 'pending' && !visible.length"
211
+ v-if="plansLoading && !visible.length"
212
212
  name="loading"
213
213
  >
214
214
  <p class="text-center text-sm text-muted">
@@ -13,13 +13,8 @@ declare var __VLS_9: {}, __VLS_11: {}, __VLS_13: {
13
13
  cancelsAt: any;
14
14
  dateText: any;
15
15
  }, __VLS_25: {}, __VLS_33: {
16
- manage: () => Promise<void | undefined>;
17
- resubscribe: () => Promise<({
18
- url: string;
19
- } | {
20
- updated: true;
21
- kind: "upgrade" | "downgrade" | "resume";
22
- }) | undefined>;
16
+ manage: () => any;
17
+ resubscribe: () => any;
23
18
  cancelling: boolean;
24
19
  pending: any;
25
20
  };
@@ -10,7 +10,7 @@ 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, status, isActive, cancelsAt, pending, error, subscribe, openPortal } = useSubscription();
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;
@@ -49,7 +49,7 @@ const manage = () => openPortal().catch(() => void 0);
49
49
  </template>
50
50
 
51
51
  <slot
52
- v-if="status === 'pending' && !subscription"
52
+ v-if="loading"
53
53
  name="loading"
54
54
  >
55
55
  <p class="text-muted text-sm">
@@ -13,13 +13,8 @@ declare var __VLS_9: {}, __VLS_11: {}, __VLS_13: {
13
13
  cancelsAt: any;
14
14
  dateText: any;
15
15
  }, __VLS_25: {}, __VLS_33: {
16
- manage: () => Promise<void | undefined>;
17
- resubscribe: () => Promise<({
18
- url: string;
19
- } | {
20
- updated: true;
21
- kind: "upgrade" | "downgrade" | "resume";
22
- }) | undefined>;
16
+ manage: () => any;
17
+ resubscribe: () => any;
23
18
  cancelling: boolean;
24
19
  pending: any;
25
20
  };
@@ -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
- export type PlansCache = {
25
- at: number;
26
- data: PlansResponse;
27
- } | null;
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,14 @@
1
1
  export const PLANS_CLIENT_CACHE_MS = 5 * 60 * 1e3;
2
- export const usePlansCache = () => useState("subscriptions:plans:cache", () => null);
2
+ export const PLANS_KEY = "subscriptions:plans";
3
3
  export const usePlans = () => {
4
- const cache = usePlansCache();
5
- const { data, status, error, refresh } = useFetch("/api/subscriptions/plans", {
6
- key: "subscriptions:plans",
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
- }
4
+ const fetched = useCachedFetch("/api/subscriptions/plans", {
5
+ key: PLANS_KEY,
6
+ ttl: PLANS_CLIENT_CACHE_MS
13
7
  });
8
+ const { data, status, error, loading, refresh } = fetched;
14
9
  const plans = computed(
15
10
  () => [...data.value?.plans ?? []].sort((a, b) => a.rank - b.rank)
16
11
  );
17
- return { plans, status, error, refresh };
12
+ const api = { plans, status, error, loading, refresh };
13
+ return Object.assign(fetched.then(() => api), api);
18
14
  };
@@ -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
- type CheckoutResponse = {
14
- url: string;
15
- } | {
16
- updated: true;
17
- kind: 'upgrade' | 'downgrade' | 'resume';
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,6 @@
1
1
  const ACTIVE_STATUSES = ["active", "trialing"];
2
2
  const CLIENT_CACHE_MS = 60 * 1e3;
3
+ const ME_KEY = "subscriptions:me";
3
4
  function isEntitled(item) {
4
5
  if (!ACTIVE_STATUSES.includes(item.status) || item.endedAt) return false;
5
6
  return !(item.source === "manual" && item.currentPeriodEnd && new Date(item.currentPeriodEnd).getTime() <= Date.now());
@@ -9,24 +10,16 @@ export const useSubscription = () => {
9
10
  const t = useModuleT("subscriptions");
10
11
  const pending = ref(false);
11
12
  const error = ref(null);
12
- const cache = useState("subscriptions:me:cache", () => null);
13
- const { data, status, refresh } = useFetch("/api/subscriptions/me", {
14
- key: "subscriptions:me",
15
- default: () => ({ subscriptions: [], activePlan: null }),
13
+ const fetched = useCachedFetch("/api/subscriptions/me", {
14
+ key: ME_KEY,
15
+ ttl: CLIENT_CACHE_MS,
16
16
  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
- }
17
+ watch: false
23
18
  });
19
+ const { data, status, error: loadError, loading, refresh, clear } = fetched;
24
20
  watch(loggedIn, (value) => {
25
21
  if (value) refresh();
26
- else {
27
- cache.value = null;
28
- data.value = { subscriptions: [], activePlan: null };
29
- }
22
+ else clear();
30
23
  });
31
24
  const subscription = computed(() => {
32
25
  const list = data.value?.subscriptions ?? [];
@@ -83,5 +76,6 @@ export const useSubscription = () => {
83
76
  await navigateTo(url, { external: true });
84
77
  }, t("errors.portal", "Failed to open the billing portal."));
85
78
  }
86
- return { subscription, plan, status, isActive, cancelsAt, has, pending, error, refresh, subscribe, openPortal };
79
+ const api = { subscription, plan, status, loading, loadError, isActive, cancelsAt, has, pending, error, refresh, subscribe, openPortal };
80
+ return Object.assign(fetched.then(() => api), api);
87
81
  };
@@ -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 { loggedIn } = useUserSession();
7
- let activePlan = null;
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
  });
@@ -4,13 +4,27 @@ 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 { isActive, cancelsAt } = useSubscription();
7
+ const { isActive, cancelsAt, loading, loadError, refresh } = await useSubscription();
8
8
  const { loggedIn } = useUserSession();
9
9
  </script>
10
10
 
11
11
  <template>
12
12
  <div class="flex flex-col gap-6">
13
- <SubscriptionStatus v-if="isActive" />
13
+ <!-- Until the subscription is known, a placeholder the size of the status card: never the "join" hero. -->
14
+ <SectionCard
15
+ v-if="loading"
16
+ :title="t('status.title', 'Your subscription')"
17
+ icon="i-lucide-credit-card"
18
+ loading
19
+ />
20
+ <SubscriptionStatus v-else-if="isActive" />
21
+ <UEmpty
22
+ v-else-if="loadError"
23
+ icon="i-lucide-circle-alert"
24
+ :title="t('status.loadFailed', 'Your membership could not be loaded')"
25
+ :description="errorMessage(loadError, '')"
26
+ :actions="[{ label: t('status.tryAgain', 'Try again'), icon: 'i-lucide-rotate-ccw', color: 'neutral', variant: 'outline', onClick: () => refresh() }]"
27
+ />
14
28
  <div
15
29
  v-else-if="loggedIn"
16
30
  class="flex flex-col gap-1"
@@ -2,7 +2,7 @@
2
2
  import { useSubscription } from "../composables/useSubscription";
3
3
  defineOptions({ name: "SubscriptionSuccessPage" });
4
4
  const t = useModuleT("subscriptions.success");
5
- const { refresh, isActive } = useSubscription();
5
+ const { refresh, isActive } = await useSubscription();
6
6
  const { localize } = useLocaleLink();
7
7
  const membership = useRuntimeConfig().public.subscriptions?.pages?.membership;
8
8
  const MAX_ATTEMPTS = 40;
@@ -7,10 +7,11 @@ const emit = defineEmits(["refresh"]);
7
7
  const t = useModuleT("subscriptions.admin");
8
8
  const format = useFormat();
9
9
  const UBadge = resolveComponent("UBadge");
10
- const { data: history, refresh: refreshHistory, status } = useLazyFetch(
10
+ const { data: history, refresh: refreshHistory, status, error } = useLazyFetch(
11
11
  () => `/api/admin/users/${props.user.id}/subscriptions`,
12
12
  { key: () => `admin-subscriptions-${props.user.id}` }
13
13
  );
14
+ const loading = computed(() => status.value === "pending" && !history.value);
14
15
  const date = (value) => format.date(value) || "\u2013";
15
16
  const columns = computed(() => [
16
17
  { accessorKey: "planKey", header: t("history.plan", "Plan"), cell: ({ row }) => row.original.planKey ?? row.original.priceId },
@@ -38,15 +39,24 @@ async function changed() {
38
39
  <SectionCard
39
40
  :title="t('history.title', 'Subscription history')"
40
41
  flush
42
+ :loading="loading"
41
43
  >
42
44
  <UTable
43
45
  :data="history?.subscriptions ?? []"
44
46
  :columns="columns"
45
- :loading="status === 'pending'"
47
+ :loading="status === 'pending' && !loading"
46
48
  :ui="tableUi({ tr: '' })"
47
49
  >
48
50
  <template #empty>
49
51
  <UEmpty
52
+ v-if="error"
53
+ variant="naked"
54
+ icon="i-lucide-circle-alert"
55
+ :title="t('history.loadFailed', 'The subscription history could not be loaded')"
56
+ :description="errorMessage(error, '')"
57
+ />
58
+ <UEmpty
59
+ v-else
50
60
  variant="naked"
51
61
  icon="i-lucide-badge-check"
52
62
  :title="t('history.empty', 'No subscriptions yet.')"
@@ -8,7 +8,7 @@ const tp = useModuleT("subscriptions.catalog");
8
8
  const format = useFormat();
9
9
  const { localize } = useLocaleLink();
10
10
  const { plans } = usePlans();
11
- const { subscription, plan, status, isActive, cancelsAt } = useSubscription();
11
+ const { subscription, plan, isActive, cancelsAt, loading } = useSubscription();
12
12
  const membership = useRuntimeConfig().public.subscriptions?.pages?.membership;
13
13
  const planLabel = computed(() => {
14
14
  if (!plan.value) return "";
@@ -28,7 +28,7 @@ const hint = computed(() => {
28
28
  :label="t('label', 'Membership')"
29
29
  icon="i-lucide-credit-card"
30
30
  :to="membership ? localize(membership) : void 0"
31
- :loading="status === 'pending' && !subscription"
31
+ :loading="loading"
32
32
  :value="isActive ? planLabel : void 0"
33
33
  :hint="isActive ? hint : void 0"
34
34
  :empty="t('none', 'No active membership')"
@@ -0,0 +1,8 @@
1
+ type __VLS_Props = {
2
+ name: string;
3
+ label: string;
4
+ url: string;
5
+ };
6
+ declare const __VLS_export: 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>;
7
+ declare const _default: typeof __VLS_export;
8
+ export default _default;
@@ -0,0 +1,34 @@
1
+ <script setup>
2
+ import { EmailButton, EmailDivider, EmailHeading, EmailLayout, EmailLinkFallback, EmailText } from "@frigear-nu/email-templates/components";
3
+ defineProps({
4
+ name: { type: String, required: true },
5
+ label: { type: String, required: true },
6
+ url: { type: String, required: true }
7
+ });
8
+ </script>
9
+
10
+ <template>
11
+ <EmailLayout preview="Complete your membership">
12
+ <ESubject>Complete your {{ label }} membership</ESubject>
13
+ <EmailHeading>
14
+ Complete your {{ label }} membership
15
+ </EmailHeading>
16
+ <EmailDivider />
17
+ <EmailText>
18
+ Hi {{ name }},
19
+ </EmailText>
20
+ <EmailText>
21
+ Use the button below to complete your {{ label }} membership. The link is valid for 24 hours.
22
+ </EmailText>
23
+ <EmailButton
24
+ :href="url"
25
+ >
26
+ Complete membership
27
+ </EmailButton>
28
+ <EmailLinkFallback :href="url" />
29
+ <EmailDivider />
30
+ <EmailText>
31
+ If you weren’t expecting this email, you can safely ignore it.
32
+ </EmailText>
33
+ </EmailLayout>
34
+ </template>
@@ -0,0 +1,8 @@
1
+ type __VLS_Props = {
2
+ name: string;
3
+ label: string;
4
+ url: string;
5
+ };
6
+ declare const __VLS_export: 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>;
7
+ declare const _default: typeof __VLS_export;
8
+ export default _default;
@@ -56,7 +56,9 @@
56
56
  "free": "Gratis medlemskab",
57
57
  "freeUntil": "Gratis medlemskab til {date}",
58
58
  "heroTitle": "Bliv en del af holdet i dag",
59
- "heroDescription": "Opret dit medlemskab for at komme i gang!"
59
+ "heroDescription": "Opret dit medlemskab for at komme i gang!",
60
+ "loadFailed": "Dit medlemskab kunne ikke indlæses",
61
+ "tryAgain": "Prøv igen"
60
62
  },
61
63
  "success": {
62
64
  "title": "Tak!",
@@ -107,6 +109,7 @@
107
109
  "currentSubscription": "Nuværende abonnement",
108
110
  "history": {
109
111
  "title": "Abonnementshistorik",
112
+ "loadFailed": "Abonnementshistorikken kunne ikke hentes",
110
113
  "empty": "Ingen abonnementer endnu.",
111
114
  "plan": "Plan",
112
115
  "status": "Status",
@@ -56,7 +56,9 @@
56
56
  "free": "Free membership",
57
57
  "freeUntil": "Free membership until {date}",
58
58
  "heroTitle": "Join the team today",
59
- "heroDescription": "Set up your membership to get started!"
59
+ "heroDescription": "Set up your membership to get started!",
60
+ "loadFailed": "Your membership could not be loaded",
61
+ "tryAgain": "Try again"
60
62
  },
61
63
  "success": {
62
64
  "title": "Thank you!",
@@ -107,6 +109,7 @@
107
109
  "currentSubscription": "Current subscription",
108
110
  "history": {
109
111
  "title": "Subscription history",
112
+ "loadFailed": "The subscription history could not be loaded",
110
113
  "empty": "No subscriptions yet.",
111
114
  "plan": "Plan",
112
115
  "status": "Status",
@@ -0,0 +1,7 @@
1
+ import type { H3Event } from 'h3';
2
+ import type { MeResponse } from '../../../types.js';
3
+ /**
4
+ * The caller's subscriptions: the body of `GET /api/subscriptions/me` and the account bootstrap's `subscriptions:me`
5
+ * (the key `useSubscription()` reads), so both always return the same thing.
6
+ */
7
+ export default function mySubscriptions(event: H3Event): Promise<MeResponse>;
@@ -0,0 +1,4 @@
1
+ export default async function mySubscriptions(event) {
2
+ const user = await requireUser(event, { scopes: ["subscriptions:read"] });
3
+ return listUserSubscriptions(user.id);
4
+ }
@@ -38,7 +38,8 @@ export default defineEventHandler(async (event) => {
38
38
  body: `<p>Hi ${escapeHtml(target.name)},</p><p>Use the link below to complete your ${escapeHtml(label)} membership. The link is valid for 24 hours.</p><p><a href="${escapeHtml(session.url)}">${escapeHtml(session.url)}</a></p>`,
39
39
  text: `Hi ${target.name},
40
40
 
41
- Complete your ${label} membership (valid for 24 hours): ${session.url}`
41
+ Complete your ${label} membership (valid for 24 hours): ${session.url}`,
42
+ template: { name: "subscriptions-payment-link", props: { name: target.name, label, url: session.url } }
42
43
  });
43
44
  emailed = true;
44
45
  }
@@ -1,4 +1,2 @@
1
- export default defineEventHandler(async (event) => {
2
- const user = await requireUser(event, { scopes: ["subscriptions:read"] });
3
- return listUserSubscriptions(user.id);
4
- });
1
+ import mySubscriptions from "../../bootstrap/my-subscriptions.js";
2
+ export default defineEventHandler(mySubscriptions);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frigear-nu/subscriptions",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Recurring subscriptions (plans, checkout, upgrade/downgrade, billing portal and entitlements) for the frigear ecosystem, built on @frigear-nu/stripe.",
5
5
  "homepage": "https://docs.frigear.dev/modules/subscriptions",
6
6
  "repository": {
@@ -30,12 +30,13 @@
30
30
  "playground"
31
31
  ],
32
32
  "dependencies": {
33
- "@frigear-nu/auth": "0.1.0",
34
- "@frigear-nu/mcp": "0.1.0",
35
- "@frigear-nu/module-kit": "1.1.0",
36
- "@frigear-nu/stripe": "0.1.0",
37
- "@frigear-nu/ui": "0.1.0",
38
- "@frigear-nu/users": "1.1.0",
33
+ "@frigear-nu/auth": "0.1.3",
34
+ "@frigear-nu/mcp": "0.1.2",
35
+ "@frigear-nu/module-kit": "1.1.2",
36
+ "@frigear-nu/notify": "0.1.2",
37
+ "@frigear-nu/stripe": "0.1.2",
38
+ "@frigear-nu/ui": "0.1.2",
39
+ "@frigear-nu/users": "1.1.3",
39
40
  "@libsql/client": "^0.18.0",
40
41
  "@nuxt/kit": "^4.6.1",
41
42
  "@vueuse/core": "^15.0.0",
@@ -47,12 +48,16 @@
47
48
  "zod": "^4.6.5"
48
49
  },
49
50
  "peerDependencies": {
50
- "@frigear-nu/menu": "0.1.0",
51
- "@frigear-nu/widget": "0.1.0",
52
- "@nuxt/ui": "^4.11.2",
51
+ "@frigear-nu/email-templates": "0.1.0",
52
+ "@frigear-nu/menu": "0.1.2",
53
+ "@frigear-nu/widget": "0.1.2",
54
+ "@nuxt/ui": "^4.12.0",
53
55
  "@nuxthub/core": "^0.10.8"
54
56
  },
55
57
  "peerDependenciesMeta": {
58
+ "@frigear-nu/email-templates": {
59
+ "optional": true
60
+ },
56
61
  "@frigear-nu/menu": {
57
62
  "optional": true
58
63
  },
@@ -64,14 +69,15 @@
64
69
  }
65
70
  },
66
71
  "devDependencies": {
67
- "@frigear-nu/menu": "0.1.0",
68
- "@frigear-nu/widget": "0.1.0",
72
+ "@frigear-nu/email-templates": "0.1.0",
73
+ "@frigear-nu/menu": "0.1.2",
74
+ "@frigear-nu/widget": "0.1.2",
69
75
  "@nuxt/devtools": "^3.4.2",
70
76
  "@nuxt/eslint-config": "^1.17.0",
71
77
  "@nuxt/module-builder": "^1.0.3",
72
78
  "@nuxt/schema": "^4.6.1",
73
79
  "@nuxt/test-utils": "^4.3.2",
74
- "@nuxt/ui": "^4.11.2",
80
+ "@nuxt/ui": "^4.12.0",
75
81
  "@nuxthub/core": "^0.10.8",
76
82
  "@types/node": "^26.6.5",
77
83
  "changelogen": "^0.6.2",
@@ -1,6 +0,0 @@
1
- /**
2
- * Pre-loads the plans once the app is interactive, so the first client-side navigation to a page with the pricing
3
- * table (from a page that never fetched them) renders instantly instead of waiting for a round trip.
4
- */
5
- declare const _default: any;
6
- export default _default;
@@ -1,12 +0,0 @@
1
- import { PLANS_CLIENT_CACHE_MS, usePlansCache } from "../composables/usePlans.js";
2
- export default defineNuxtPlugin((nuxtApp) => {
3
- nuxtApp.hook("app:mounted", () => {
4
- const cache = usePlansCache();
5
- if (cache.value && Date.now() - cache.value.at < PLANS_CLIENT_CACHE_MS) return;
6
- const warm = () => $fetch("/api/subscriptions/plans").then((data) => {
7
- cache.value = { at: Date.now(), data };
8
- }).catch(() => void 0);
9
- if ("requestIdleCallback" in window) window.requestIdleCallback(() => void warm());
10
- else setTimeout(() => void warm(), 1e3);
11
- });
12
- });