@antelopejs/dms-saas 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/README.md +307 -33
  2. package/dist/billing-state/index.d.ts +1 -0
  3. package/dist/billing-state/index.js +1 -0
  4. package/dist/billing-state/index.js.map +1 -1
  5. package/dist/billing-state/past-due-banner.d.ts +4 -0
  6. package/dist/billing-state/past-due-banner.js +25 -0
  7. package/dist/billing-state/past-due-banner.js.map +1 -0
  8. package/dist/config/runtime.d.ts +6 -1
  9. package/dist/config/runtime.js +50 -1
  10. package/dist/config/runtime.js.map +1 -1
  11. package/dist/crons/recompute-billing-state.js +4 -1
  12. package/dist/crons/recompute-billing-state.js.map +1 -1
  13. package/dist/data-api/platformOwner/index.d.ts +1 -0
  14. package/dist/data-api/platformOwner/index.js +1 -0
  15. package/dist/data-api/platformOwner/index.js.map +1 -1
  16. package/dist/data-api/platformOwner/invitations.d.ts +22 -0
  17. package/dist/data-api/platformOwner/invitations.js +121 -0
  18. package/dist/data-api/platformOwner/invitations.js.map +1 -0
  19. package/dist/data-api/platformOwner/invoice-options.js +3 -2
  20. package/dist/data-api/platformOwner/invoice-options.js.map +1 -1
  21. package/dist/data-api/platformOwner/workspaces.d.ts +3 -0
  22. package/dist/data-api/platformOwner/workspaces.js +64 -12
  23. package/dist/data-api/platformOwner/workspaces.js.map +1 -1
  24. package/dist/data-api/tenant/tenant-invoices.js +2 -10
  25. package/dist/data-api/tenant/tenant-invoices.js.map +1 -1
  26. package/dist/hooks/register.js +2 -1
  27. package/dist/hooks/register.js.map +1 -1
  28. package/dist/implementations/dms-saas/billing.d.ts +2 -1
  29. package/dist/implementations/dms-saas/billing.js +6 -0
  30. package/dist/implementations/dms-saas/billing.js.map +1 -1
  31. package/dist/index.js +5 -0
  32. package/dist/index.js.map +1 -1
  33. package/dist/invoice-line-items/billing-window.js +2 -2
  34. package/dist/invoice-line-items/billing-window.js.map +1 -1
  35. package/dist/invoice-line-items/injection.d.ts +10 -0
  36. package/dist/invoice-line-items/injection.js +7 -3
  37. package/dist/invoice-line-items/injection.js.map +1 -1
  38. package/dist/operator-actions/db/lifecycle-delivery.model.js +1 -3
  39. package/dist/operator-actions/db/lifecycle-delivery.model.js.map +1 -1
  40. package/dist/operator-actions/db/lifecycle-delivery.table.d.ts +1 -1
  41. package/dist/operator-actions/db/lifecycle-delivery.table.js.map +1 -1
  42. package/dist/operator-actions/db/operator-action.model.js +1 -3
  43. package/dist/operator-actions/db/operator-action.model.js.map +1 -1
  44. package/dist/operator-actions/db/operator-action.table.d.ts +1 -1
  45. package/dist/operator-actions/db/operator-action.table.js.map +1 -1
  46. package/dist/operator-actions/index.d.ts +1 -0
  47. package/dist/operator-actions/index.js +1 -0
  48. package/dist/operator-actions/index.js.map +1 -1
  49. package/dist/operator-actions/invitation-commands.d.ts +12 -0
  50. package/dist/operator-actions/invitation-commands.js +46 -0
  51. package/dist/operator-actions/invitation-commands.js.map +1 -0
  52. package/dist/operator-actions/types.d.ts +1 -1
  53. package/dist/pages/platform/dashboard.js +2 -4
  54. package/dist/pages/platform/dashboard.js.map +1 -1
  55. package/dist/pages/platform/workspaces/detail.js +40 -1
  56. package/dist/pages/platform/workspaces/detail.js.map +1 -1
  57. package/dist/pages/platform/workspaces/index.d.ts +2 -8
  58. package/dist/pages/platform/workspaces/index.js +27 -111
  59. package/dist/pages/platform/workspaces/index.js.map +1 -1
  60. package/dist/pages/public/legal/privacy-policy.js +2 -1
  61. package/dist/pages/public/legal/privacy-policy.js.map +1 -1
  62. package/dist/pages/public/legal/terms-and-conditions.js +2 -1
  63. package/dist/pages/public/legal/terms-and-conditions.js.map +1 -1
  64. package/dist/pages/public/legal/terms-of-use.js +2 -1
  65. package/dist/pages/public/legal/terms-of-use.js.map +1 -1
  66. package/dist/plans/index.d.ts +1 -0
  67. package/dist/plans/index.js +1 -0
  68. package/dist/plans/index.js.map +1 -1
  69. package/dist/plans/platform-support.d.ts +2 -0
  70. package/dist/plans/platform-support.js +19 -0
  71. package/dist/plans/platform-support.js.map +1 -0
  72. package/dist/plans/seat-capacity.d.ts +9 -2
  73. package/dist/plans/seat-capacity.js +59 -12
  74. package/dist/plans/seat-capacity.js.map +1 -1
  75. package/dist/plans/seat-hooks.js +34 -13
  76. package/dist/plans/seat-hooks.js.map +1 -1
  77. package/dist/routes/platformOwner/credit-notes-issue.js +2 -1
  78. package/dist/routes/platformOwner/credit-notes-issue.js.map +1 -1
  79. package/dist/routes/platformOwner/dashboard.d.ts +8 -6
  80. package/dist/routes/platformOwner/dashboard.js +31 -17
  81. package/dist/routes/platformOwner/dashboard.js.map +1 -1
  82. package/dist/routes/platformOwner/index.d.ts +1 -0
  83. package/dist/routes/platformOwner/index.js +1 -0
  84. package/dist/routes/platformOwner/index.js.map +1 -1
  85. package/dist/routes/platformOwner/workspace-invitations.d.ts +13 -0
  86. package/dist/routes/platformOwner/workspace-invitations.js +73 -0
  87. package/dist/routes/platformOwner/workspace-invitations.js.map +1 -0
  88. package/dist/routes/public/register.d.ts +7 -3
  89. package/dist/routes/public/register.js +42 -27
  90. package/dist/routes/public/register.js.map +1 -1
  91. package/dist/routes/tenant/tenant-billing.d.ts +7 -11
  92. package/dist/routes/tenant/tenant-billing.js +34 -26
  93. package/dist/routes/tenant/tenant-billing.js.map +1 -1
  94. package/dist/routes/tenant/tenant-plan-ops.js +2 -2
  95. package/dist/routes/tenant/tenant-plan-ops.js.map +1 -1
  96. package/dist/routes/tenant/tenant-plan.d.ts +1 -0
  97. package/dist/routes/tenant/tenant-plan.js +27 -2
  98. package/dist/routes/tenant/tenant-plan.js.map +1 -1
  99. package/dist/routes/tenant/tenant-seats.d.ts +2 -4
  100. package/dist/routes/tenant/tenant-seats.js.map +1 -1
  101. package/dist/routes/tenant/workspaces.d.ts +2 -0
  102. package/dist/routes/tenant/workspaces.js +10 -4
  103. package/dist/routes/tenant/workspaces.js.map +1 -1
  104. package/dist/stripe/upcoming-invoice.d.ts +21 -0
  105. package/dist/stripe/upcoming-invoice.js +42 -0
  106. package/dist/stripe/upcoming-invoice.js.map +1 -0
  107. package/dist/stripe/webhook-dispatch.js +2 -0
  108. package/dist/stripe/webhook-dispatch.js.map +1 -1
  109. package/dist/types/config.d.ts +9 -0
  110. package/dist/types/config.js +7 -0
  111. package/dist/types/config.js.map +1 -1
  112. package/dist/upcoming-invoice/db/preview-cache.model.d.ts +38 -0
  113. package/dist/upcoming-invoice/db/preview-cache.model.js +57 -0
  114. package/dist/upcoming-invoice/db/preview-cache.model.js.map +1 -0
  115. package/dist/upcoming-invoice/db/preview-cache.table.d.ts +10 -0
  116. package/dist/upcoming-invoice/db/preview-cache.table.js +42 -0
  117. package/dist/upcoming-invoice/db/preview-cache.table.js.map +1 -0
  118. package/dist/upcoming-invoice/eligibility.d.ts +17 -0
  119. package/dist/upcoming-invoice/eligibility.js +52 -0
  120. package/dist/upcoming-invoice/eligibility.js.map +1 -0
  121. package/dist/upcoming-invoice/invalidation.d.ts +2 -0
  122. package/dist/upcoming-invoice/invalidation.js +39 -0
  123. package/dist/upcoming-invoice/invalidation.js.map +1 -0
  124. package/dist/upcoming-invoice/mapping.d.ts +8 -0
  125. package/dist/upcoming-invoice/mapping.js +82 -0
  126. package/dist/upcoming-invoice/mapping.js.map +1 -0
  127. package/dist/upcoming-invoice/outcomes.d.ts +13 -0
  128. package/dist/upcoming-invoice/outcomes.js +15 -0
  129. package/dist/upcoming-invoice/outcomes.js.map +1 -0
  130. package/dist/upcoming-invoice/preview.d.ts +3 -0
  131. package/dist/upcoming-invoice/preview.js +80 -0
  132. package/dist/upcoming-invoice/preview.js.map +1 -0
  133. package/dist/upcoming-invoice/pricing.d.ts +3 -0
  134. package/dist/upcoming-invoice/pricing.js +84 -0
  135. package/dist/upcoming-invoice/pricing.js.map +1 -0
  136. package/dist/upcoming-invoice/usage.d.ts +14 -0
  137. package/dist/upcoming-invoice/usage.js +53 -0
  138. package/dist/upcoming-invoice/usage.js.map +1 -0
  139. package/dist/workspaces/billing-identity.d.ts +19 -0
  140. package/dist/workspaces/billing-identity.js +51 -0
  141. package/dist/workspaces/billing-identity.js.map +1 -0
  142. package/dist/workspaces/default-plan.d.ts +5 -0
  143. package/dist/workspaces/default-plan.js +110 -0
  144. package/dist/workspaces/default-plan.js.map +1 -0
  145. package/dist/workspaces/index.d.ts +2 -0
  146. package/dist/workspaces/index.js +2 -0
  147. package/dist/workspaces/index.js.map +1 -1
  148. package/dist/workspaces/invitations.d.ts +26 -0
  149. package/dist/workspaces/invitations.js +112 -0
  150. package/dist/workspaces/invitations.js.map +1 -0
  151. package/dist/workspaces/provisioning.d.ts +3 -3
  152. package/dist/workspaces/provisioning.js +49 -11
  153. package/dist/workspaces/provisioning.js.map +1 -1
  154. package/frontend-vue/app/components/BillingIdentityFields.vue +179 -0
  155. package/frontend-vue/app/components/PastDueBanner.vue +81 -0
  156. package/frontend-vue/app/components/PaymentMethodCard.vue +94 -38
  157. package/frontend-vue/app/components/PlanComparisonModal.vue +62 -26
  158. package/frontend-vue/app/components/PlanUpgradeModal.vue +181 -0
  159. package/frontend-vue/app/components/PlatformNotes.vue +4 -0
  160. package/frontend-vue/app/components/RefundButton.vue +10 -1
  161. package/frontend-vue/app/components/Register.vue +49 -92
  162. package/frontend-vue/app/components/SeatQuotaBanner.vue +28 -0
  163. package/frontend-vue/app/components/TenantBillingForm.vue +52 -128
  164. package/frontend-vue/app/components/TenantPlanCard.vue +68 -10
  165. package/frontend-vue/app/components/UserSummaryCard.vue +12 -10
  166. package/frontend-vue/app/components/WorkspaceAdminCreateModal.vue +202 -0
  167. package/frontend-vue/app/components/WorkspaceCreateModal.vue +46 -9
  168. package/frontend-vue/app/components/WorkspaceInvitationLink.vue +106 -0
  169. package/frontend-vue/app/components/WorkspaceSummaryCard.vue +39 -15
  170. package/frontend-vue/app/components/WorkspaceSuspended.vue +3 -5
  171. package/frontend-vue/app/components/WorkspaceSwitcherWidget.vue +8 -5
  172. package/frontend-vue/app/composables/useBillingCountries.ts +25 -5
  173. package/frontend-vue/app/composables/useBillingDocumentType.ts +9 -5
  174. package/frontend-vue/app/composables/useBillingIdentity.ts +141 -0
  175. package/frontend-vue/app/composables/useInvitationLink.ts +69 -0
  176. package/frontend-vue/app/composables/usePastDueBannerAction.ts +20 -0
  177. package/frontend-vue/app/composables/usePlanComparison.ts +15 -0
  178. package/frontend-vue/app/composables/usePlanFeatureFormat.ts +312 -20
  179. package/frontend-vue/app/composables/usePlanFeatureLabel.ts +24 -0
  180. package/frontend-vue/app/composables/useSaasRegistration.ts +110 -223
  181. package/frontend-vue/app/composables/useSeatQuota.ts +8 -0
  182. package/frontend-vue/app/composables/useStripePaymentElement.ts +3 -3
  183. package/frontend-vue/app/composables/useWorkspaceAccessCache.ts +29 -2
  184. package/frontend-vue/app/composables/useWorkspaceFinalization.ts +7 -32
  185. package/frontend-vue/app/custom-pages/auth/no-workspace.vue +49 -194
  186. package/frontend-vue/app/plugins/auth-links.ts +0 -4
  187. package/frontend-vue/app/plugins/billing-data-types.ts +4 -2
  188. package/frontend-vue/app/types/runtime-config.ts +13 -0
  189. package/frontend-vue/dms.frontend.ts +10 -4
  190. package/frontend-vue/i18n/locales/saas-en-GB.json +161 -36
  191. package/frontend-vue/i18n/locales/saas-fr-FR.json +162 -37
  192. package/frontend-vue/package.json +0 -1
  193. package/frontend-vue/pnpm-lock.yaml +1 -125
  194. package/package.json +5 -5
  195. package/frontend-vue/app/middleware/workspace-suspended.global.ts +0 -59
@@ -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 — its arguments, its way
6
- * of presenting plans, one page or three steps — so this screen exists to work
7
- * out of the box, not to be the design anyone ships. Replace it by turning the
8
- * bundled page off (`publicScreens.register: false` in the dms-saas config) and
9
- * registering your own page on the `register` slug, then build the markup you
10
- * want on `useSaasRegistration()`, which owns the Stripe orchestration, the
11
- * validation order and the provisioning call.
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
- <URadioGroup
50
- v-model="form.customerType"
51
- :items="
52
- customerTypeItems.map((item) => ({
53
- value: item.value,
54
- label: $t(item.label),
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 v-model="form.email" type="email" class="w-full" required />
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
- <UFormField :label="$t('saas.register.field.full_name')">
73
- <UInput v-model="form.name" class="w-full" required />
74
- </UFormField>
75
-
76
- <UFormField :label="$t('saas.register.field.workspace_name')">
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
- <h3 class="font-semibold">{{ $t("saas.register.billing_address") }}</h3>
75
+ <UCheckbox
76
+ v-if="canSkipPaymentMethod"
77
+ v-model="form.skipsPaymentMethod"
78
+ :label="$t('saas.register.skip_payment_method')"
79
+ />
90
80
 
91
- <UFormField :label="$t('saas.register.field.country')">
92
- <USelect
93
- v-model="form.country"
94
- :items="countryItems"
95
- class="w-full"
96
- required
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 { countryItems } = useBillingCountries();
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 customerType = ref<"individual" | "business" | null>(null);
44
- const companyName = ref("");
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 billingEmail = ref("");
48
- const country = ref("");
49
- const addressLine1 = ref("");
50
- const postalCode = ref("");
51
- const city = ref("");
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
- const isBusiness = computed(() => customerType.value === "business");
27
+ /** The badge reflects what is stored, not what is being typed. */
28
+ const isIncomplete = computed(() => savedMissingFields.value.length > 0);
57
29
 
58
- const customerTypeLabel = computed(() => {
59
- if (!customerType.value) return "—";
60
- return nuxtApp.$i18n.t(`saas.register.customer_type.${customerType.value}`);
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
- const info = await $authFetch<BillingInfoResponse>(BILLING_INFO_ENDPOINT);
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
- const updated = await $authFetch<BillingInfoResponse>(
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: nuxtApp.$i18n.t("saas.workspace.billing.info_saved"),
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
- <h3 class="font-semibold">
133
- {{ $t("saas.workspace.billing.info_title") }}
134
- </h3>
135
- <p class="text-muted mt-1 text-sm">
136
- {{ $t("saas.workspace.billing.info_description") }}
137
- </p>
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
- <UFormField :label="$t('saas.register.customer_type.label')">
150
- <UBadge color="neutral" variant="subtle">
151
- {{ customerTypeLabel }}
152
- </UBadge>
153
- </UFormField>
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
@@ -2,6 +2,7 @@
2
2
  import { computed, onMounted, ref } from "vue";
3
3
 
4
4
  const FREE_PLAN_PRICE = 0;
5
+ const SUMMARY_SEPARATOR = " · ";
5
6
  const FULL_DATE_FORMAT: Intl.DateTimeFormatOptions = {
6
7
  year: "numeric",
7
8
  month: "long",
@@ -19,12 +20,17 @@ const {
19
20
  const { data: billingStatus, load: loadBillingStatus } = useBillingStatus();
20
21
  const { formatMajorUnits } = useMoneyFormat();
21
22
  const { t, locale } = useI18n();
23
+ const { formatFeatureValue } = usePlanFeatureFormat();
24
+ const { featureLabel } = usePlanFeatureLabel();
25
+ const { workspace, load: loadWorkspace } = useCurrentWorkspace();
26
+ const { isOpen: isComparisonOpen } = usePlanComparison();
22
27
  const planIntervalLabel = usePlanIntervalLabel("saas.workspace.plan.interval");
23
28
 
24
29
  const data = ref<TenantPlanResponse | null>(null);
25
30
  const isLoading = ref(true);
26
31
  const isCancelling = ref(false);
27
- const isComparisonOpen = ref(false);
32
+ const isUpgradeOpen = ref(false);
33
+ const upgradeTarget = ref<TenantPlanView | null>(null);
28
34
 
29
35
  const current = computed(() => data.value?.current ?? null);
30
36
  const pendingPlan = computed(() => data.value?.pendingPlan ?? null);
@@ -38,6 +44,15 @@ const isAccessBlocked = computed(
38
44
  !data.value?.canRecoverComplimentary,
39
45
  );
40
46
 
47
+ /** Settling an invoice only lifts a block when there is one to settle: an
48
+ * operator suspension, or a member who cannot see the invoice, gets the copy
49
+ * that promises nothing about payment. */
50
+ const blockedHintKey = computed(() =>
51
+ billingStatus.value?.unpaidInvoice
52
+ ? "saas.workspace.plan.blocked_hint"
53
+ : "saas.workspace.plan.blocked_hint_inactive",
54
+ );
55
+
41
56
  /** Plan mutations are owner-only server-side; a member gets the read-only
42
57
  * card instead of buttons that can only end in a 403 toast. */
43
58
  const isTenantOwner = computed(() => !!billingStatus.value?.isTenantOwner);
@@ -68,6 +83,34 @@ const changeAction = computed(() =>
68
83
  : { color: "primary" as const, variant: "solid" as const, key: "go_paid" },
69
84
  );
70
85
 
86
+ /**
87
+ * One line on what the plan includes: the catalogue's own description when it
88
+ * has one, else the plan's key (non-detail) limits.
89
+ */
90
+ const summary = computed(() => {
91
+ if (current.value?.description) return current.value.description;
92
+ const view = data.value?.available.find(
93
+ (plan) => plan._id === current.value?._id,
94
+ );
95
+ if (!view || !data.value) return "";
96
+ return data.value.features
97
+ .filter(
98
+ (feature) =>
99
+ !feature.isDetailRow &&
100
+ view.featureValues[feature.featureId] !== undefined,
101
+ )
102
+ .map(
103
+ (feature) =>
104
+ `${featureLabel(feature)} ${formatFeatureValue(feature, view.featureValues[feature.featureId], view.currency)}`,
105
+ )
106
+ .join(SUMMARY_SEPARATOR);
107
+ });
108
+
109
+ function openUpgrade(plan: TenantPlanView): void {
110
+ upgradeTarget.value = plan;
111
+ isUpgradeOpen.value = true;
112
+ }
113
+
71
114
  const priceLabel = computed(() =>
72
115
  current.value
73
116
  ? formatMajorUnits(current.value.price, current.value.currency)
@@ -111,8 +154,15 @@ async function cancelDowngrade(): Promise<void> {
111
154
  }
112
155
  }
113
156
 
157
+ /** The workspace name only titles the owner's plan comparison, and reading
158
+ * it is owner-only: a member's card never asks. */
159
+ async function loadWorkspaceForOwner(): Promise<void> {
160
+ const status = await loadBillingStatus();
161
+ if (status?.isTenantOwner) await loadWorkspace().catch(() => undefined);
162
+ }
163
+
114
164
  onMounted(() => {
115
- void loadBillingStatus();
165
+ void loadWorkspaceForOwner();
116
166
  return fetchPlan(false);
117
167
  });
118
168
  </script>
@@ -140,11 +190,11 @@ onMounted(() => {
140
190
  <div v-else-if="data" class="flex flex-col gap-4">
141
191
  <div class="flex flex-wrap items-start gap-4">
142
192
  <div class="min-w-60 grow">
143
- <h2 class="text-lg font-semibold">
144
- {{ current?.name ?? $t("saas.workspace.plan.none") }}
193
+ <h2 v-if="current" class="text-lg font-semibold">
194
+ {{ current.name }}
145
195
  </h2>
146
- <p v-if="current?.description" class="text-muted mt-1 text-sm">
147
- {{ current.description }}
196
+ <p v-if="summary" class="text-muted mt-1 text-sm">
197
+ {{ summary }}
148
198
  </p>
149
199
  </div>
150
200
  <div v-if="current" class="text-right">
@@ -167,9 +217,9 @@ onMounted(() => {
167
217
  </div>
168
218
  <div class="mt-2 flex justify-between gap-4 font-semibold">
169
219
  <span>{{ $t("saas.admission.total") }}</span>
170
- <span class="tabular-nums">{{
171
- formatMajorUnits(0, current.currency)
172
- }}</span>
220
+ <span class="tabular-nums">
221
+ {{ formatMajorUnits(0, current.currency) }}
222
+ </span>
173
223
  </div>
174
224
  </div>
175
225
 
@@ -181,7 +231,7 @@ onMounted(() => {
181
231
  </p>
182
232
 
183
233
  <p v-if="isAccessBlocked" class="text-muted text-sm">
184
- {{ $t("saas.workspace.plan.blocked_hint") }}
234
+ {{ $t(blockedHintKey) }}
185
235
  </p>
186
236
 
187
237
  <p v-else-if="data.isPlanChangeLocked" class="text-muted text-sm">
@@ -216,6 +266,14 @@ onMounted(() => {
216
266
  :current-plan-id="current?._id ?? null"
217
267
  :pending-plan-id="pendingPlan?.planId ?? null"
218
268
  :is-recovery="data.canRecoverComplimentary"
269
+ :is-current-paid="isPaid"
270
+ :workspace-name="workspace?.name ?? null"
271
+ @changed="refresh"
272
+ @upgrade="openUpgrade"
273
+ />
274
+ <DmsSaasPlanUpgradeModal
275
+ v-model:open="isUpgradeOpen"
276
+ :plan="upgradeTarget"
219
277
  @changed="refresh"
220
278
  />
221
279
  </div>
@@ -20,12 +20,19 @@ interface UserSummaryData {
20
20
  workspaces: Array<{ isTenantOwner: boolean }>;
21
21
  }
22
22
 
23
+ const MISSING_DATE = "—";
24
+ const DAY_FORMAT: Intl.DateTimeFormatOptions = {
25
+ day: "2-digit",
26
+ month: "short",
27
+ year: "numeric",
28
+ };
29
+
23
30
  const props = defineProps<{
24
31
  routeParams?: Record<string, string>;
25
32
  }>();
26
33
 
27
34
  const { $authFetch } = useAuthFetch();
28
- const { t } = useI18n();
35
+ const { t, locale } = useI18n();
29
36
 
30
37
  const userId = computed(() => props.routeParams?.id ?? "");
31
38
  const user = ref<UserSummaryData | null>(null);
@@ -48,13 +55,8 @@ const memberCount = computed(
48
55
  () => user.value?.workspaces.filter((w) => !w.isTenantOwner).length ?? 0,
49
56
  );
50
57
 
51
- function formatDate(value: string | null): string {
52
- if (!value) return "—";
53
- return new Date(value).toLocaleDateString("fr-FR", {
54
- day: "2-digit",
55
- month: "short",
56
- year: "numeric",
57
- });
58
+ function formatDay(value: string | null): string {
59
+ return formatDate(value, locale.value, DAY_FORMAT) ?? MISSING_DATE;
58
60
  }
59
61
 
60
62
  async function load(): Promise<void> {
@@ -114,11 +116,11 @@ onMounted(load);
114
116
  <dl class="grid grid-cols-2 gap-3 text-sm lg:grid-cols-4">
115
117
  <div>
116
118
  <dt class="text-muted">{{ $t("saas.users.column.created_at") }}</dt>
117
- <dd class="mt-0.5">{{ formatDate(user.createdAt) }}</dd>
119
+ <dd class="mt-0.5">{{ formatDay(user.createdAt) }}</dd>
118
120
  </div>
119
121
  <div>
120
122
  <dt class="text-muted">{{ $t("saas.users.info.last_active") }}</dt>
121
- <dd class="mt-0.5">{{ formatDate(user.lastActiveAt) }}</dd>
123
+ <dd class="mt-0.5">{{ formatDay(user.lastActiveAt) }}</dd>
122
124
  </div>
123
125
  <div>
124
126
  <dt class="text-muted">{{ $t("saas.users.info.language") }}</dt>