@antelopejs/dms-saas 0.1.8 → 0.2.1
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 +262 -13
- package/dist/auth/subscription-access-gate.d.ts +1 -1
- package/dist/billing-state/index.d.ts +1 -0
- package/dist/billing-state/index.js +1 -0
- package/dist/billing-state/index.js.map +1 -1
- package/dist/billing-state/past-due-banner.d.ts +4 -0
- package/dist/billing-state/past-due-banner.js +25 -0
- package/dist/billing-state/past-due-banner.js.map +1 -0
- package/dist/config/runtime.d.ts +6 -1
- package/dist/config/runtime.js +50 -1
- package/dist/config/runtime.js.map +1 -1
- package/dist/crons/recompute-billing-state.js +4 -1
- package/dist/crons/recompute-billing-state.js.map +1 -1
- package/dist/data-api/platformOwner/index.d.ts +1 -0
- package/dist/data-api/platformOwner/index.js +1 -0
- package/dist/data-api/platformOwner/index.js.map +1 -1
- package/dist/data-api/platformOwner/invitations.d.ts +22 -0
- package/dist/data-api/platformOwner/invitations.js +121 -0
- package/dist/data-api/platformOwner/invitations.js.map +1 -0
- package/dist/data-api/platformOwner/workspaces.d.ts +3 -0
- package/dist/data-api/platformOwner/workspaces.js +64 -12
- package/dist/data-api/platformOwner/workspaces.js.map +1 -1
- package/dist/data-api/tenant/tenant-invoices.js +2 -10
- package/dist/data-api/tenant/tenant-invoices.js.map +1 -1
- package/dist/hooks/register.js +2 -1
- package/dist/hooks/register.js.map +1 -1
- package/dist/implementations/dms-saas/billing.d.ts +2 -1
- package/dist/implementations/dms-saas/billing.js +6 -0
- package/dist/implementations/dms-saas/billing.js.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/invoice-line-items/injection.d.ts +10 -0
- package/dist/invoice-line-items/injection.js +7 -3
- package/dist/invoice-line-items/injection.js.map +1 -1
- package/dist/notifications/subjects.d.ts +19 -19
- package/dist/operator-actions/index.d.ts +1 -0
- package/dist/operator-actions/index.js +1 -0
- package/dist/operator-actions/index.js.map +1 -1
- package/dist/operator-actions/invitation-commands.d.ts +12 -0
- package/dist/operator-actions/invitation-commands.js +46 -0
- package/dist/operator-actions/invitation-commands.js.map +1 -0
- package/dist/operator-actions/types.d.ts +1 -1
- package/dist/pages/platform/categories.d.ts +6 -6
- package/dist/pages/platform/credit-notes.d.ts +1 -1
- package/dist/pages/platform/dashboard.d.ts +2 -2
- package/dist/pages/platform/dashboard.js +2 -4
- package/dist/pages/platform/dashboard.js.map +1 -1
- package/dist/pages/platform/features.d.ts +1 -1
- package/dist/pages/platform/invoices.d.ts +1 -1
- package/dist/pages/platform/plans/edit.d.ts +2 -2
- package/dist/pages/platform/plans/index.d.ts +1 -1
- package/dist/pages/platform/plans/migrations.d.ts +1 -1
- package/dist/pages/platform/segments/index.d.ts +1 -1
- package/dist/pages/platform/settings.d.ts +1 -1
- package/dist/pages/platform/users/detail.d.ts +1 -1
- package/dist/pages/platform/users/index.d.ts +1 -1
- package/dist/pages/platform/workspaces/detail.d.ts +1 -1
- package/dist/pages/platform/workspaces/detail.js +40 -1
- package/dist/pages/platform/workspaces/detail.js.map +1 -1
- package/dist/pages/platform/workspaces/index.d.ts +4 -10
- package/dist/pages/platform/workspaces/index.js +27 -111
- package/dist/pages/platform/workspaces/index.js.map +1 -1
- package/dist/pages/public/legal/privacy-policy.js +2 -1
- package/dist/pages/public/legal/privacy-policy.js.map +1 -1
- package/dist/pages/public/legal/terms-and-conditions.js +2 -1
- package/dist/pages/public/legal/terms-and-conditions.js.map +1 -1
- package/dist/pages/public/legal/terms-of-use.js +2 -1
- package/dist/pages/public/legal/terms-of-use.js.map +1 -1
- package/dist/pages/tenant/billing.d.ts +1 -1
- package/dist/plans/index.d.ts +1 -0
- package/dist/plans/index.js +1 -0
- package/dist/plans/index.js.map +1 -1
- package/dist/plans/platform-support.d.ts +2 -0
- package/dist/plans/platform-support.js +19 -0
- package/dist/plans/platform-support.js.map +1 -0
- package/dist/plans/seat-capacity.d.ts +9 -2
- package/dist/plans/seat-capacity.js +59 -12
- package/dist/plans/seat-capacity.js.map +1 -1
- package/dist/plans/seat-hooks.js +34 -13
- package/dist/plans/seat-hooks.js.map +1 -1
- package/dist/routes/platformOwner/billing-settings.d.ts +2 -2
- package/dist/routes/platformOwner/dashboard.d.ts +8 -6
- package/dist/routes/platformOwner/dashboard.js +31 -17
- package/dist/routes/platformOwner/dashboard.js.map +1 -1
- package/dist/routes/platformOwner/index.d.ts +1 -0
- package/dist/routes/platformOwner/index.js +1 -0
- package/dist/routes/platformOwner/index.js.map +1 -1
- package/dist/routes/platformOwner/plan-deletion.d.ts +1 -1
- package/dist/routes/platformOwner/plans.d.ts +5 -5
- package/dist/routes/platformOwner/segments.d.ts +1 -1
- package/dist/routes/platformOwner/workspace-invitations.d.ts +13 -0
- package/dist/routes/platformOwner/workspace-invitations.js +73 -0
- package/dist/routes/platformOwner/workspace-invitations.js.map +1 -0
- package/dist/routes/public/legal-documents.d.ts +2 -2
- package/dist/routes/public/register.d.ts +7 -3
- package/dist/routes/public/register.js +42 -27
- package/dist/routes/public/register.js.map +1 -1
- package/dist/routes/tenant/tenant-billing.d.ts +7 -11
- package/dist/routes/tenant/tenant-billing.js +34 -26
- package/dist/routes/tenant/tenant-billing.js.map +1 -1
- package/dist/routes/tenant/tenant-plan.d.ts +1 -0
- package/dist/routes/tenant/tenant-plan.js +27 -2
- package/dist/routes/tenant/tenant-plan.js.map +1 -1
- package/dist/routes/tenant/tenant-seats.d.ts +2 -4
- package/dist/routes/tenant/tenant-seats.js.map +1 -1
- package/dist/routes/tenant/workspaces.d.ts +3 -1
- package/dist/routes/tenant/workspaces.js +10 -4
- package/dist/routes/tenant/workspaces.js.map +1 -1
- package/dist/stripe/upcoming-invoice.d.ts +21 -0
- package/dist/stripe/upcoming-invoice.js +42 -0
- package/dist/stripe/upcoming-invoice.js.map +1 -0
- package/dist/stripe/webhook-dispatch.js +2 -0
- package/dist/stripe/webhook-dispatch.js.map +1 -1
- package/dist/types/config.d.ts +9 -0
- package/dist/types/config.js +7 -0
- package/dist/types/config.js.map +1 -1
- package/dist/upcoming-invoice/db/preview-cache.model.d.ts +38 -0
- package/dist/upcoming-invoice/db/preview-cache.model.js +57 -0
- package/dist/upcoming-invoice/db/preview-cache.model.js.map +1 -0
- package/dist/upcoming-invoice/db/preview-cache.table.d.ts +10 -0
- package/dist/upcoming-invoice/db/preview-cache.table.js +42 -0
- package/dist/upcoming-invoice/db/preview-cache.table.js.map +1 -0
- package/dist/upcoming-invoice/eligibility.d.ts +17 -0
- package/dist/upcoming-invoice/eligibility.js +52 -0
- package/dist/upcoming-invoice/eligibility.js.map +1 -0
- package/dist/upcoming-invoice/invalidation.d.ts +2 -0
- package/dist/upcoming-invoice/invalidation.js +39 -0
- package/dist/upcoming-invoice/invalidation.js.map +1 -0
- package/dist/upcoming-invoice/mapping.d.ts +8 -0
- package/dist/upcoming-invoice/mapping.js +82 -0
- package/dist/upcoming-invoice/mapping.js.map +1 -0
- package/dist/upcoming-invoice/outcomes.d.ts +13 -0
- package/dist/upcoming-invoice/outcomes.js +15 -0
- package/dist/upcoming-invoice/outcomes.js.map +1 -0
- package/dist/upcoming-invoice/preview.d.ts +3 -0
- package/dist/upcoming-invoice/preview.js +80 -0
- package/dist/upcoming-invoice/preview.js.map +1 -0
- package/dist/upcoming-invoice/pricing.d.ts +3 -0
- package/dist/upcoming-invoice/pricing.js +84 -0
- package/dist/upcoming-invoice/pricing.js.map +1 -0
- package/dist/upcoming-invoice/usage.d.ts +14 -0
- package/dist/upcoming-invoice/usage.js +53 -0
- package/dist/upcoming-invoice/usage.js.map +1 -0
- package/dist/utils/invoice-lines-type.d.ts +1 -1
- package/dist/workspaces/billing-identity.d.ts +19 -0
- package/dist/workspaces/billing-identity.js +51 -0
- package/dist/workspaces/billing-identity.js.map +1 -0
- package/dist/workspaces/default-plan.d.ts +5 -0
- package/dist/workspaces/default-plan.js +109 -0
- package/dist/workspaces/default-plan.js.map +1 -0
- package/dist/workspaces/index.d.ts +2 -0
- package/dist/workspaces/index.js +2 -0
- package/dist/workspaces/index.js.map +1 -1
- package/dist/workspaces/invitations.d.ts +26 -0
- package/dist/workspaces/invitations.js +112 -0
- package/dist/workspaces/invitations.js.map +1 -0
- package/dist/workspaces/provisioning.d.ts +3 -3
- package/dist/workspaces/provisioning.js +39 -11
- package/dist/workspaces/provisioning.js.map +1 -1
- package/frontend-vue/app/components/BillingIdentityFields.vue +179 -0
- package/frontend-vue/app/components/PastDueBanner.vue +81 -0
- package/frontend-vue/app/components/PaymentMethodCard.vue +94 -38
- package/frontend-vue/app/components/PlanComparisonModal.vue +62 -26
- package/frontend-vue/app/components/PlanUpgradeModal.vue +181 -0
- package/frontend-vue/app/components/PlatformNotes.vue +4 -0
- package/frontend-vue/app/components/RefundButton.vue +10 -1
- package/frontend-vue/app/components/Register.vue +49 -92
- package/frontend-vue/app/components/SeatQuotaBanner.vue +28 -0
- package/frontend-vue/app/components/TenantBillingForm.vue +52 -128
- package/frontend-vue/app/components/TenantPlanCard.vue +68 -10
- package/frontend-vue/app/components/UserSummaryCard.vue +12 -10
- package/frontend-vue/app/components/WorkspaceAdminCreateModal.vue +202 -0
- package/frontend-vue/app/components/WorkspaceCreateModal.vue +46 -9
- package/frontend-vue/app/components/WorkspaceInvitationLink.vue +106 -0
- package/frontend-vue/app/components/WorkspaceSummaryCard.vue +39 -15
- package/frontend-vue/app/components/WorkspaceSuspended.vue +3 -5
- package/frontend-vue/app/components/WorkspaceSwitcherWidget.vue +8 -5
- package/frontend-vue/app/composables/useBillingCountries.ts +25 -5
- package/frontend-vue/app/composables/useBillingIdentity.ts +141 -0
- package/frontend-vue/app/composables/useInvitationLink.ts +69 -0
- package/frontend-vue/app/composables/usePastDueBannerAction.ts +20 -0
- package/frontend-vue/app/composables/usePlanComparison.ts +15 -0
- package/frontend-vue/app/composables/usePlanFeatureFormat.ts +312 -20
- package/frontend-vue/app/composables/usePlanFeatureLabel.ts +24 -0
- package/frontend-vue/app/composables/useSaasRegistration.ts +110 -223
- package/frontend-vue/app/composables/useSeatQuota.ts +8 -0
- package/frontend-vue/app/composables/useStripePaymentElement.ts +3 -3
- package/frontend-vue/app/composables/useWorkspaceAccessCache.ts +29 -2
- package/frontend-vue/app/composables/useWorkspaceFinalization.ts +7 -32
- package/frontend-vue/app/custom-pages/auth/no-workspace.vue +49 -194
- package/frontend-vue/app/plugins/auth-links.ts +0 -4
- package/frontend-vue/app/types/runtime-config.ts +13 -0
- package/frontend-vue/dms.frontend.ts +10 -4
- package/frontend-vue/i18n/locales/saas-en-GB.json +161 -36
- package/frontend-vue/i18n/locales/saas-fr-FR.json +162 -37
- package/frontend-vue/package.json +0 -1
- package/frontend-vue/pnpm-lock.yaml +1 -125
- package/package.json +15 -15
- package/frontend-vue/app/middleware/workspace-suspended.global.ts +0 -59
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref, watch } from "vue";
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{
|
|
5
|
+
plan: TenantPlanView | null;
|
|
6
|
+
}>();
|
|
7
|
+
|
|
8
|
+
const emit = defineEmits<{ changed: [] }>();
|
|
9
|
+
|
|
10
|
+
const open = defineModel<boolean>("open", { default: false });
|
|
11
|
+
|
|
12
|
+
const KEY_PREFIX = "saas.workspace.plan.upgrade";
|
|
13
|
+
|
|
14
|
+
const toast = useToast();
|
|
15
|
+
const { t } = useI18n();
|
|
16
|
+
const { resolveApiError } = useApiErrorMessage();
|
|
17
|
+
const identity = useBillingIdentity();
|
|
18
|
+
const { changePlan } = useTenantPlan();
|
|
19
|
+
const { formatMajorUnits } = useMoneyFormat();
|
|
20
|
+
const planIntervalLabel = usePlanIntervalLabel("saas.workspace.plan.interval");
|
|
21
|
+
|
|
22
|
+
const draft = ref<BillingIdentityDraft>(emptyBillingIdentityDraft());
|
|
23
|
+
const hasTriedSubmit = ref(false);
|
|
24
|
+
// Flagged once a submit was attempted, then re-checked as the user types so
|
|
25
|
+
// a corrected field clears its error right away.
|
|
26
|
+
const invalidFields = computed<BillingIdentityField[]>(() =>
|
|
27
|
+
hasTriedSubmit.value ? findMissingBillingFields(draft.value) : [],
|
|
28
|
+
);
|
|
29
|
+
const isLoading = ref(false);
|
|
30
|
+
const loadFailed = ref(false);
|
|
31
|
+
const isSubmitting = ref(false);
|
|
32
|
+
|
|
33
|
+
const priceLabel = computed(() =>
|
|
34
|
+
props.plan ? formatMajorUnits(props.plan.price, props.plan.currency) : "",
|
|
35
|
+
);
|
|
36
|
+
const intervalLabel = computed(() =>
|
|
37
|
+
props.plan ? planIntervalLabel(props.plan.interval) : "",
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
async function loadIdentity(): Promise<void> {
|
|
41
|
+
isLoading.value = true;
|
|
42
|
+
loadFailed.value = false;
|
|
43
|
+
hasTriedSubmit.value = false;
|
|
44
|
+
try {
|
|
45
|
+
draft.value = toBillingIdentityDraft(await identity.load());
|
|
46
|
+
} catch {
|
|
47
|
+
loadFailed.value = true;
|
|
48
|
+
} finally {
|
|
49
|
+
isLoading.value = false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
watch(open, (isOpen) => {
|
|
54
|
+
if (isOpen) void loadIdentity();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
function notifyError(title: string): void {
|
|
58
|
+
toast.add({ title, color: "error", icon: "i-ph-warning-circle" });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The identity is saved first: the plan change checks the plan's audience
|
|
63
|
+
* against the stored customer type, and Stripe Checkout bills the customer
|
|
64
|
+
* this identity describes. The card itself is collected by Stripe.
|
|
65
|
+
*/
|
|
66
|
+
async function confirm(): Promise<void> {
|
|
67
|
+
const plan = props.plan;
|
|
68
|
+
if (!plan) return;
|
|
69
|
+
hasTriedSubmit.value = true;
|
|
70
|
+
if (invalidFields.value.length > 0) {
|
|
71
|
+
notifyError(t("saas.workspace.billing.identity.incomplete_toast"));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
isSubmitting.value = true;
|
|
75
|
+
try {
|
|
76
|
+
await identity.save(draft.value);
|
|
77
|
+
const result = await changePlan(plan._id);
|
|
78
|
+
if (result.checkoutUrl && typeof window !== "undefined") {
|
|
79
|
+
window.location.href = result.checkoutUrl;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
toast.add({
|
|
83
|
+
title: t("saas.workspace.plan.change_applied"),
|
|
84
|
+
color: "success",
|
|
85
|
+
icon: "i-ph-check-circle",
|
|
86
|
+
});
|
|
87
|
+
open.value = false;
|
|
88
|
+
emit("changed");
|
|
89
|
+
} catch (error) {
|
|
90
|
+
notifyError(resolveApiError(error, "saas.workspace.plan.change_error"));
|
|
91
|
+
} finally {
|
|
92
|
+
isSubmitting.value = false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
</script>
|
|
96
|
+
|
|
97
|
+
<template>
|
|
98
|
+
<UModal
|
|
99
|
+
v-model:open="open"
|
|
100
|
+
:title="$t(`${KEY_PREFIX}.title`, { plan: plan?.name ?? '' })"
|
|
101
|
+
:description="$t(`${KEY_PREFIX}.description`)"
|
|
102
|
+
:ui="{ content: 'max-w-2xl' }"
|
|
103
|
+
>
|
|
104
|
+
<template #body>
|
|
105
|
+
<div v-if="isLoading" class="flex flex-col gap-3">
|
|
106
|
+
<USkeleton class="h-20 w-full" />
|
|
107
|
+
<USkeleton class="h-10 w-full" />
|
|
108
|
+
<USkeleton class="h-10 w-full" />
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<DmsSaasLoadFailure v-else-if="loadFailed" @retry="loadIdentity" />
|
|
112
|
+
|
|
113
|
+
<form
|
|
114
|
+
v-else-if="plan"
|
|
115
|
+
id="saas-plan-upgrade-form"
|
|
116
|
+
class="flex flex-col gap-6"
|
|
117
|
+
novalidate
|
|
118
|
+
@submit.prevent="confirm"
|
|
119
|
+
>
|
|
120
|
+
<div class="border-default rounded-lg border p-4 text-sm">
|
|
121
|
+
<div class="flex justify-between gap-4">
|
|
122
|
+
<span>
|
|
123
|
+
{{ $t(`${KEY_PREFIX}.subscription`, { plan: plan.name }) }}
|
|
124
|
+
</span>
|
|
125
|
+
<span class="tabular-nums">
|
|
126
|
+
{{ priceLabel }}/{{ intervalLabel }}
|
|
127
|
+
</span>
|
|
128
|
+
</div>
|
|
129
|
+
<div
|
|
130
|
+
class="border-default mt-3 flex justify-between gap-4 border-t pt-3 font-semibold"
|
|
131
|
+
>
|
|
132
|
+
<span>{{ $t(`${KEY_PREFIX}.due_today`) }}</span>
|
|
133
|
+
<span class="text-primary tabular-nums">
|
|
134
|
+
{{ $t(`${KEY_PREFIX}.excl_tax`, { amount: priceLabel }) }}
|
|
135
|
+
</span>
|
|
136
|
+
</div>
|
|
137
|
+
<p class="text-muted mt-2 text-xs">
|
|
138
|
+
{{ $t(`${KEY_PREFIX}.tax_note`) }}
|
|
139
|
+
</p>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<section class="flex flex-col gap-3">
|
|
143
|
+
<h4 class="flex items-center gap-2 font-semibold">
|
|
144
|
+
<UIcon name="i-ph-identification-card" class="size-5" />
|
|
145
|
+
{{ $t("saas.workspace.billing.info_title") }}
|
|
146
|
+
</h4>
|
|
147
|
+
<DmsSaasBillingIdentityFields
|
|
148
|
+
v-model="draft"
|
|
149
|
+
:invalid-fields="invalidFields"
|
|
150
|
+
/>
|
|
151
|
+
</section>
|
|
152
|
+
|
|
153
|
+
<section class="flex flex-col gap-2">
|
|
154
|
+
<h4 class="flex items-center gap-2 font-semibold">
|
|
155
|
+
<UIcon name="i-ph-credit-card" class="size-5" />
|
|
156
|
+
{{ $t(`${KEY_PREFIX}.card_title`) }}
|
|
157
|
+
</h4>
|
|
158
|
+
<p class="text-muted text-sm">{{ $t(`${KEY_PREFIX}.card_note`) }}</p>
|
|
159
|
+
</section>
|
|
160
|
+
</form>
|
|
161
|
+
</template>
|
|
162
|
+
|
|
163
|
+
<template #footer>
|
|
164
|
+
<div class="flex w-full justify-end gap-2">
|
|
165
|
+
<UButton color="neutral" variant="subtle" @click="open = false">
|
|
166
|
+
{{ $t("common.cancel") }}
|
|
167
|
+
</UButton>
|
|
168
|
+
<UButton
|
|
169
|
+
type="submit"
|
|
170
|
+
form="saas-plan-upgrade-form"
|
|
171
|
+
color="primary"
|
|
172
|
+
icon="i-ph-lock-simple"
|
|
173
|
+
:loading="isSubmitting"
|
|
174
|
+
:disabled="isLoading || loadFailed"
|
|
175
|
+
>
|
|
176
|
+
{{ $t(`${KEY_PREFIX}.confirm`) }}
|
|
177
|
+
</UButton>
|
|
178
|
+
</div>
|
|
179
|
+
</template>
|
|
180
|
+
</UModal>
|
|
181
|
+
</template>
|
|
@@ -30,6 +30,10 @@ const props = defineProps<{
|
|
|
30
30
|
routeParams?: Record<string, string>;
|
|
31
31
|
}>();
|
|
32
32
|
|
|
33
|
+
// The card and its delete modal are sibling roots, so the renderer's extra
|
|
34
|
+
// attributes (page and component ids) have no single element to land on.
|
|
35
|
+
defineOptions({ inheritAttrs: false });
|
|
36
|
+
|
|
33
37
|
const { $authFetch } = useAuthFetch();
|
|
34
38
|
const { t } = useI18n();
|
|
35
39
|
const toast = useToast();
|
|
@@ -67,7 +67,16 @@ async function confirmRefund(): Promise<void> {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
/** The eligibility read is owner-only and, like the refund itself, closed
|
|
71
|
+
* while the access gate blocks the workspace: the card has nothing to offer
|
|
72
|
+
* then, so it skips the request instead of collecting a 403. */
|
|
73
|
+
async function loadIfOffered(): Promise<void> {
|
|
74
|
+
const access = await loadWorkspaceAccess();
|
|
75
|
+
if (access && (access.blocked || !access.isTenantOwner)) return;
|
|
76
|
+
await loadEligibility();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
onMounted(loadIfOffered);
|
|
71
80
|
</script>
|
|
72
81
|
|
|
73
82
|
<template>
|
|
@@ -2,37 +2,26 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Reference implementation of the public registration flow.
|
|
4
4
|
*
|
|
5
|
-
* Deliberately plain: every SaaS wants its own funnel
|
|
6
|
-
* of
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
5
|
+
* Deliberately plain: every SaaS wants its own funnel, so this screen exists
|
|
6
|
+
* to work out of the box, not to be the design anyone ships. Replace it by
|
|
7
|
+
* turning the bundled page off (`publicScreens.register: false` in the
|
|
8
|
+
* dms-saas config) and registering your own page on the `register` slug, then
|
|
9
|
+
* build the markup you want on `useSaasRegistration()`, which owns the card
|
|
10
|
+
* policy, the Stripe orchestration, the validation order and the provisioning
|
|
11
|
+
* call.
|
|
12
12
|
*/
|
|
13
|
-
interface DmsSaasPublicRuntimeConfig {
|
|
14
|
-
admissionMode?: "open" | "invitation-only";
|
|
15
|
-
}
|
|
16
|
-
|
|
17
13
|
const {
|
|
18
14
|
form,
|
|
19
|
-
plans,
|
|
20
|
-
countryItems,
|
|
21
15
|
paymentElementId,
|
|
16
|
+
canSkipPaymentMethod,
|
|
17
|
+
isPaymentStepVisible,
|
|
18
|
+
passwordStrength,
|
|
19
|
+
isRegistrationClosed,
|
|
22
20
|
errorMessage,
|
|
23
21
|
isSubmitting,
|
|
24
|
-
formatPlanPrice,
|
|
25
22
|
submit,
|
|
26
23
|
} = useSaasRegistration();
|
|
27
|
-
|
|
28
|
-
const customerTypeItems = [
|
|
29
|
-
{ value: "individual", label: "saas.register.customer_type.individual" },
|
|
30
|
-
{ value: "business", label: "saas.register.customer_type.business" },
|
|
31
|
-
];
|
|
32
|
-
const config = useDmsRuntimeConfig();
|
|
33
|
-
const isRegistrationClosed =
|
|
34
|
-
(config.public.dmsSaas as DmsSaasPublicRuntimeConfig | undefined)
|
|
35
|
-
?.admissionMode === "invitation-only";
|
|
24
|
+
const { strength, score, color } = passwordStrength;
|
|
36
25
|
</script>
|
|
37
26
|
|
|
38
27
|
<template>
|
|
@@ -46,93 +35,61 @@ const isRegistrationClosed =
|
|
|
46
35
|
<DmsOAuthButtons :note="$t('saas.oauth_registration.entry_note')" />
|
|
47
36
|
|
|
48
37
|
<form class="flex flex-col gap-4" @submit.prevent="submit">
|
|
49
|
-
<
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
/>
|
|
38
|
+
<UFormField :label="$t('saas.register.field.full_name')">
|
|
39
|
+
<UInput
|
|
40
|
+
v-model="form.name"
|
|
41
|
+
autocomplete="name"
|
|
42
|
+
class="w-full"
|
|
43
|
+
required
|
|
44
|
+
/>
|
|
45
|
+
</UFormField>
|
|
58
46
|
|
|
59
47
|
<UFormField :label="$t('saas.register.field.email')">
|
|
60
|
-
<UInput
|
|
48
|
+
<UInput
|
|
49
|
+
v-model="form.email"
|
|
50
|
+
type="email"
|
|
51
|
+
autocomplete="email"
|
|
52
|
+
class="w-full"
|
|
53
|
+
required
|
|
54
|
+
/>
|
|
61
55
|
</UFormField>
|
|
62
56
|
|
|
63
57
|
<UFormField :label="$t('saas.register.field.password')">
|
|
64
58
|
<UInput
|
|
65
59
|
v-model="form.password"
|
|
66
60
|
type="password"
|
|
61
|
+
autocomplete="new-password"
|
|
62
|
+
:color="color"
|
|
63
|
+
aria-describedby="password-strength"
|
|
67
64
|
class="w-full"
|
|
68
65
|
required
|
|
69
66
|
/>
|
|
70
67
|
</UFormField>
|
|
71
68
|
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
<UInput v-model="form.workspaceName" class="w-full" required />
|
|
78
|
-
</UFormField>
|
|
79
|
-
|
|
80
|
-
<template v-if="form.customerType === 'business'">
|
|
81
|
-
<UFormField :label="$t('saas.register.field.company_name')">
|
|
82
|
-
<UInput v-model="form.companyName" class="w-full" required />
|
|
83
|
-
</UFormField>
|
|
84
|
-
<UFormField :label="$t('saas.register.field.vat_number')">
|
|
85
|
-
<UInput v-model="form.vatNumber" class="w-full" />
|
|
86
|
-
</UFormField>
|
|
87
|
-
</template>
|
|
69
|
+
<DmsPasswordStrength
|
|
70
|
+
:color="color"
|
|
71
|
+
:score="score"
|
|
72
|
+
:strength="strength"
|
|
73
|
+
/>
|
|
88
74
|
|
|
89
|
-
<
|
|
75
|
+
<UCheckbox
|
|
76
|
+
v-if="canSkipPaymentMethod"
|
|
77
|
+
v-model="form.skipsPaymentMethod"
|
|
78
|
+
:label="$t('saas.register.skip_payment_method')"
|
|
79
|
+
/>
|
|
90
80
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
81
|
+
<!-- v-show keeps the Stripe element mounted while the step is hidden. -->
|
|
82
|
+
<UFormField
|
|
83
|
+
v-show="isPaymentStepVisible"
|
|
84
|
+
:label="$t('saas.register.field.card')"
|
|
85
|
+
:hint="$t('saas.register.hint.card')"
|
|
86
|
+
>
|
|
87
|
+
<div
|
|
88
|
+
:id="paymentElementId"
|
|
89
|
+
class="border-default rounded-md border p-4"
|
|
97
90
|
/>
|
|
98
91
|
</UFormField>
|
|
99
92
|
|
|
100
|
-
<UFormField :label="$t('saas.register.field.address_line1')">
|
|
101
|
-
<UInput v-model="form.addressLine1" class="w-full" />
|
|
102
|
-
</UFormField>
|
|
103
|
-
|
|
104
|
-
<div class="grid grid-cols-2 gap-4">
|
|
105
|
-
<UFormField :label="$t('saas.register.field.postal_code')">
|
|
106
|
-
<UInput v-model="form.postalCode" class="w-full" />
|
|
107
|
-
</UFormField>
|
|
108
|
-
<UFormField :label="$t('saas.register.field.city')">
|
|
109
|
-
<UInput v-model="form.city" class="w-full" />
|
|
110
|
-
</UFormField>
|
|
111
|
-
</div>
|
|
112
|
-
|
|
113
|
-
<h3 class="font-semibold">{{ $t("saas.register.choose_plan") }}</h3>
|
|
114
|
-
|
|
115
|
-
<div class="flex flex-col gap-2">
|
|
116
|
-
<button
|
|
117
|
-
v-for="plan in plans"
|
|
118
|
-
:key="plan._id"
|
|
119
|
-
type="button"
|
|
120
|
-
class="border-default rounded-md border p-3 text-left"
|
|
121
|
-
:class="form.selectedPlanId === plan._id ? 'border-primary' : ''"
|
|
122
|
-
@click="form.selectedPlanId = plan._id"
|
|
123
|
-
>
|
|
124
|
-
<div class="flex items-baseline justify-between gap-2">
|
|
125
|
-
<span class="font-medium">{{ plan.name }}</span>
|
|
126
|
-
<span class="tabular-nums">{{ formatPlanPrice(plan) }}</span>
|
|
127
|
-
</div>
|
|
128
|
-
<p class="text-muted text-sm">{{ plan.description }}</p>
|
|
129
|
-
</button>
|
|
130
|
-
</div>
|
|
131
|
-
|
|
132
|
-
<UFormField :label="$t('saas.register.field.card')">
|
|
133
|
-
<div :id="paymentElementId" class="border-default rounded-md border p-4" />
|
|
134
|
-
</UFormField>
|
|
135
|
-
|
|
136
93
|
<UCheckbox v-model="form.hasAcceptedLegal" required>
|
|
137
94
|
<template #label>
|
|
138
95
|
<i18n-t keypath="saas.register.legal_acceptance" tag="span">
|
|
@@ -30,6 +30,8 @@ const breakdown = computed(() =>
|
|
|
30
30
|
}),
|
|
31
31
|
);
|
|
32
32
|
|
|
33
|
+
const platformSupport = computed(() => quota.value?.platformSupport ?? []);
|
|
34
|
+
|
|
33
35
|
const limitMessage = computed(() =>
|
|
34
36
|
quota.value?.isTenantOwner
|
|
35
37
|
? t(`${KEY_PREFIX}.limit_reached_owner`)
|
|
@@ -74,5 +76,31 @@ onMounted(refresh);
|
|
|
74
76
|
{{ $t(`${KEY_PREFIX}.upgrade`) }}
|
|
75
77
|
</UButton>
|
|
76
78
|
</div>
|
|
79
|
+
<div
|
|
80
|
+
v-if="platformSupport.length > 0"
|
|
81
|
+
class="border-default mt-4 flex flex-col gap-2 border-t pt-4"
|
|
82
|
+
>
|
|
83
|
+
<div class="flex flex-col gap-1">
|
|
84
|
+
<h4 class="text-sm font-semibold">
|
|
85
|
+
{{ $t(`${KEY_PREFIX}.platform_support.title`) }}
|
|
86
|
+
</h4>
|
|
87
|
+
<p class="text-muted text-sm">
|
|
88
|
+
{{ $t(`${KEY_PREFIX}.platform_support.description`) }}
|
|
89
|
+
</p>
|
|
90
|
+
</div>
|
|
91
|
+
<ul class="flex flex-col gap-2">
|
|
92
|
+
<li
|
|
93
|
+
v-for="member in platformSupport"
|
|
94
|
+
:key="member.userId"
|
|
95
|
+
class="flex flex-wrap items-center gap-2 text-sm"
|
|
96
|
+
>
|
|
97
|
+
<span class="font-medium">{{ member.name }}</span>
|
|
98
|
+
<span class="text-muted">{{ member.email }}</span>
|
|
99
|
+
<UBadge color="info" variant="subtle" icon="i-ph-lifebuoy">
|
|
100
|
+
{{ $t(`${KEY_PREFIX}.platform_support.badge`) }}
|
|
101
|
+
</UBadge>
|
|
102
|
+
</li>
|
|
103
|
+
</ul>
|
|
104
|
+
</div>
|
|
77
105
|
</UCard>
|
|
78
106
|
</template>
|
|
@@ -1,79 +1,43 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, onMounted, ref } from "vue";
|
|
3
3
|
|
|
4
|
-
interface BillingAddress {
|
|
5
|
-
line1: string | null;
|
|
6
|
-
line2: string | null;
|
|
7
|
-
postalCode: string | null;
|
|
8
|
-
city: string | null;
|
|
9
|
-
state: string | null;
|
|
10
|
-
country: string | null;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
type VatVerificationStatus = "pending" | "verified" | "unverified";
|
|
14
|
-
|
|
15
|
-
interface BillingInfoResponse {
|
|
16
|
-
customerType: "individual" | "business" | null;
|
|
17
|
-
companyName: string | null;
|
|
18
|
-
vatNumber: string | null;
|
|
19
|
-
vatVerificationStatus: VatVerificationStatus | null;
|
|
20
|
-
billingEmail: string | null;
|
|
21
|
-
address: BillingAddress | null;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const VAT_BADGE_COLORS: Record<VatVerificationStatus, "success" | "warning"> = {
|
|
25
|
-
verified: "success",
|
|
26
|
-
pending: "warning",
|
|
27
|
-
unverified: "warning",
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
const BILLING_INFO_ENDPOINT = "/api/saas/tenant/billing-info";
|
|
31
|
-
|
|
32
|
-
const { $authFetch } = useAuthFetch();
|
|
33
|
-
const nuxtApp = useDmsApp();
|
|
34
4
|
const toast = useToast();
|
|
5
|
+
const { t } = useI18n();
|
|
35
6
|
const { resolveApiError } = useApiErrorMessage();
|
|
36
|
-
const
|
|
7
|
+
const identity = useBillingIdentity();
|
|
37
8
|
const { data: billingStatus, load: loadBillingStatus } = useBillingStatus();
|
|
38
9
|
|
|
39
10
|
/** Saving is owner-only server-side; a member gets the form read-only
|
|
40
11
|
* instead of a save button that can only end in a 403 toast. */
|
|
41
12
|
const isTenantOwner = computed(() => !!billingStatus.value?.isTenantOwner);
|
|
42
13
|
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
const vatNumber = ref("");
|
|
14
|
+
const draft = ref<BillingIdentityDraft>(emptyBillingIdentityDraft());
|
|
15
|
+
const savedMissingFields = ref<BillingIdentityField[]>([]);
|
|
46
16
|
const vatVerificationStatus = ref<VatVerificationStatus | null>(null);
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
17
|
+
const hasTriedSubmit = ref(false);
|
|
18
|
+
// Flagged once a submit was attempted, then re-checked as the user types so
|
|
19
|
+
// a corrected field clears its error right away.
|
|
20
|
+
const invalidFields = computed<BillingIdentityField[]>(() =>
|
|
21
|
+
hasTriedSubmit.value ? findMissingBillingFields(draft.value) : [],
|
|
22
|
+
);
|
|
52
23
|
const isLoading = ref(true);
|
|
53
24
|
const loadFailed = ref(false);
|
|
54
25
|
const isSaving = ref(false);
|
|
55
26
|
|
|
56
|
-
|
|
27
|
+
/** The badge reflects what is stored, not what is being typed. */
|
|
28
|
+
const isIncomplete = computed(() => savedMissingFields.value.length > 0);
|
|
57
29
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
30
|
+
function apply(info: BillingInfoResponse): void {
|
|
31
|
+
draft.value = toBillingIdentityDraft(info);
|
|
32
|
+
savedMissingFields.value = info.missingFields;
|
|
33
|
+
vatVerificationStatus.value = info.vatVerificationStatus;
|
|
34
|
+
}
|
|
62
35
|
|
|
63
36
|
async function load(): Promise<void> {
|
|
64
37
|
isLoading.value = true;
|
|
65
38
|
loadFailed.value = false;
|
|
66
39
|
try {
|
|
67
|
-
|
|
68
|
-
customerType.value = info.customerType;
|
|
69
|
-
companyName.value = info.companyName ?? "";
|
|
70
|
-
vatNumber.value = info.vatNumber ?? "";
|
|
71
|
-
vatVerificationStatus.value = info.vatVerificationStatus;
|
|
72
|
-
billingEmail.value = info.billingEmail ?? "";
|
|
73
|
-
country.value = info.address?.country ?? "";
|
|
74
|
-
addressLine1.value = info.address?.line1 ?? "";
|
|
75
|
-
postalCode.value = info.address?.postalCode ?? "";
|
|
76
|
-
city.value = info.address?.city ?? "";
|
|
40
|
+
apply(await identity.load());
|
|
77
41
|
} catch {
|
|
78
42
|
loadFailed.value = true;
|
|
79
43
|
} finally {
|
|
@@ -82,29 +46,20 @@ async function load(): Promise<void> {
|
|
|
82
46
|
}
|
|
83
47
|
|
|
84
48
|
async function save(): Promise<void> {
|
|
49
|
+
hasTriedSubmit.value = true;
|
|
50
|
+
if (invalidFields.value.length > 0) {
|
|
51
|
+
toast.add({
|
|
52
|
+
title: t("saas.workspace.billing.identity.incomplete_toast"),
|
|
53
|
+
color: "error",
|
|
54
|
+
icon: "i-ph-warning-circle",
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
85
58
|
isSaving.value = true;
|
|
86
59
|
try {
|
|
87
|
-
|
|
88
|
-
BILLING_INFO_ENDPOINT,
|
|
89
|
-
{
|
|
90
|
-
method: "PUT",
|
|
91
|
-
body: {
|
|
92
|
-
companyName: isBusiness.value ? companyName.value : undefined,
|
|
93
|
-
vatNumber: isBusiness.value ? vatNumber.value : undefined,
|
|
94
|
-
billingEmail: billingEmail.value || null,
|
|
95
|
-
address: {
|
|
96
|
-
country: country.value || undefined,
|
|
97
|
-
line1: addressLine1.value || undefined,
|
|
98
|
-
postalCode: postalCode.value || undefined,
|
|
99
|
-
city: city.value || undefined,
|
|
100
|
-
},
|
|
101
|
-
},
|
|
102
|
-
},
|
|
103
|
-
);
|
|
104
|
-
customerType.value = updated.customerType;
|
|
105
|
-
vatVerificationStatus.value = updated.vatVerificationStatus;
|
|
60
|
+
apply(await identity.save(draft.value));
|
|
106
61
|
toast.add({
|
|
107
|
-
title:
|
|
62
|
+
title: t("saas.workspace.billing.info_saved"),
|
|
108
63
|
color: "success",
|
|
109
64
|
icon: "i-ph-check-circle",
|
|
110
65
|
});
|
|
@@ -128,13 +83,22 @@ onMounted(() => {
|
|
|
128
83
|
<template>
|
|
129
84
|
<UCard>
|
|
130
85
|
<template #header>
|
|
131
|
-
<div>
|
|
132
|
-
<
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
86
|
+
<div class="flex flex-wrap items-start justify-between gap-2">
|
|
87
|
+
<div>
|
|
88
|
+
<h3 class="font-semibold">
|
|
89
|
+
{{ $t("saas.workspace.billing.info_title") }}
|
|
90
|
+
</h3>
|
|
91
|
+
<p class="text-muted mt-1 text-sm">
|
|
92
|
+
{{ $t("saas.workspace.billing.info_description") }}
|
|
93
|
+
</p>
|
|
94
|
+
</div>
|
|
95
|
+
<UBadge
|
|
96
|
+
v-if="!isLoading && !loadFailed && isIncomplete"
|
|
97
|
+
color="warning"
|
|
98
|
+
variant="subtle"
|
|
99
|
+
>
|
|
100
|
+
{{ $t("saas.workspace.billing.identity.to_complete") }}
|
|
101
|
+
</UBadge>
|
|
138
102
|
</div>
|
|
139
103
|
</template>
|
|
140
104
|
|
|
@@ -145,53 +109,13 @@ onMounted(() => {
|
|
|
145
109
|
|
|
146
110
|
<DmsSaasLoadFailure v-else-if="loadFailed" @retry="load" />
|
|
147
111
|
|
|
148
|
-
<form v-else class="flex flex-col gap-4" @submit.prevent="save">
|
|
149
|
-
<
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
<template v-if="isBusiness">
|
|
156
|
-
<UFormField :label="$t('saas.register.field.company_name')">
|
|
157
|
-
<UInput v-model="companyName" class="w-full" />
|
|
158
|
-
</UFormField>
|
|
159
|
-
<UFormField :label="$t('saas.register.field.vat_number')">
|
|
160
|
-
<div class="flex flex-wrap items-center gap-2">
|
|
161
|
-
<UInput v-model="vatNumber" class="min-w-40 grow" />
|
|
162
|
-
<UBadge
|
|
163
|
-
v-if="vatVerificationStatus"
|
|
164
|
-
:color="VAT_BADGE_COLORS[vatVerificationStatus]"
|
|
165
|
-
variant="subtle"
|
|
166
|
-
>
|
|
167
|
-
{{ $t(`saas.workspace.billing.vat.${vatVerificationStatus}`) }}
|
|
168
|
-
</UBadge>
|
|
169
|
-
</div>
|
|
170
|
-
</UFormField>
|
|
171
|
-
</template>
|
|
172
|
-
|
|
173
|
-
<h4 class="font-semibold">{{ $t("saas.register.billing_address") }}</h4>
|
|
174
|
-
<UFormField :label="$t('saas.register.field.country')">
|
|
175
|
-
<USelect v-model="country" :items="countryItems" class="w-full" />
|
|
176
|
-
</UFormField>
|
|
177
|
-
<UFormField :label="$t('saas.register.field.address_line1')">
|
|
178
|
-
<UInput v-model="addressLine1" class="w-full" />
|
|
179
|
-
</UFormField>
|
|
180
|
-
<div class="grid grid-cols-2 gap-4">
|
|
181
|
-
<UFormField :label="$t('saas.register.field.postal_code')">
|
|
182
|
-
<UInput v-model="postalCode" class="w-full" />
|
|
183
|
-
</UFormField>
|
|
184
|
-
<UFormField :label="$t('saas.register.field.city')">
|
|
185
|
-
<UInput v-model="city" class="w-full" />
|
|
186
|
-
</UFormField>
|
|
187
|
-
</div>
|
|
188
|
-
|
|
189
|
-
<UFormField
|
|
190
|
-
:label="$t('saas.workspace.billing.billing_email.label')"
|
|
191
|
-
:description="$t('saas.workspace.billing.billing_email.hint')"
|
|
192
|
-
>
|
|
193
|
-
<UInput v-model="billingEmail" type="email" class="w-full" />
|
|
194
|
-
</UFormField>
|
|
112
|
+
<form v-else class="flex flex-col gap-4" novalidate @submit.prevent="save">
|
|
113
|
+
<DmsSaasBillingIdentityFields
|
|
114
|
+
v-model="draft"
|
|
115
|
+
:invalid-fields="invalidFields"
|
|
116
|
+
:vat-verification-status="vatVerificationStatus"
|
|
117
|
+
:is-locked="!isTenantOwner"
|
|
118
|
+
/>
|
|
195
119
|
|
|
196
120
|
<div v-if="isTenantOwner" class="flex justify-end">
|
|
197
121
|
<UButton
|