@nuxt-customer-portal/planning 0.4.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 (96) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/LICENSE +21 -0
  3. package/README.md +115 -0
  4. package/app/components/PlanningAppointmentCreateForm.vue +49 -0
  5. package/app/components/PlanningAppointmentDetails.vue +163 -0
  6. package/app/components/PlanningAppointmentDrawer.vue +109 -0
  7. package/app/components/PlanningAvailabilityCalendar.vue +525 -0
  8. package/app/components/PlanningDashboardOverview.vue +62 -0
  9. package/app/components/PlanningDashboardUpcomingAppointments.vue +81 -0
  10. package/app/components/PlanningPersonalCalendarSettings.vue +218 -0
  11. package/app/components/PlanningPersonalConnection.vue +68 -0
  12. package/app/components/PlanningPersonalMeetingSettings.vue +1 -0
  13. package/app/components/PlanningPolicyFields.vue +196 -0
  14. package/app/components/PlanningSettingsMembers.vue +50 -0
  15. package/app/components/PlanningSettingsPolicy.vue +46 -0
  16. package/app/components/PlanningSettingsSynchronization.vue +433 -0
  17. package/app/components/PlanningSlotPicker.vue +588 -0
  18. package/app/components/PlanningTimezoneReadOnlyHover.vue +172 -0
  19. package/app/components/PlanningTimezoneSelect.vue +101 -0
  20. package/app/composables/usePlanning.ts +180 -0
  21. package/app/composables/usePlanningDashboard.ts +4 -0
  22. package/app/composables/usePlanningTimeDisplay.ts +15 -0
  23. package/app/pages/admin/planning.vue +36 -0
  24. package/app/pages/appointments/[id].vue +13 -0
  25. package/app/pages/appointments/index.vue +384 -0
  26. package/app/pages/availability.vue +585 -0
  27. package/app/pages/booking/[slug].vue +298 -0
  28. package/app/pages/my-appointment-settings.vue +33 -0
  29. package/app/plugins/planning.ts +5 -0
  30. package/i18n/locales/en.json +260 -0
  31. package/i18n/locales/nl.json +260 -0
  32. package/migrations/0000_baseline.sql +58 -0
  33. package/migrations/0001_availability_calendar_title.sql +2 -0
  34. package/migrations/0002_job_activity.sql +11 -0
  35. package/migrations/0003_deduplicate_availability_jobs.sql +19 -0
  36. package/migrations/0004_connection_label.sql +2 -0
  37. package/nuxt.config.ts +15 -0
  38. package/package.json +55 -0
  39. package/portal.manifest.mjs +8 -0
  40. package/server/api/planning/admin/appointments/[id]/audit.get.ts +11 -0
  41. package/server/api/planning/admin/appointments.get.ts +11 -0
  42. package/server/api/planning/admin/jobs/[id]/retry.post.ts +26 -0
  43. package/server/api/planning/admin/jobs.get.ts +199 -0
  44. package/server/api/planning/admin/policy.get.ts +6 -0
  45. package/server/api/planning/admin/policy.put.ts +8 -0
  46. package/server/api/planning/admin/products/[id].put.ts +8 -0
  47. package/server/api/planning/admin/providers/[userId].patch.ts +8 -0
  48. package/server/api/planning/admin/providers.get.ts +6 -0
  49. package/server/api/planning/admin/retry.post.ts +31 -0
  50. package/server/api/planning/appointments/[id]/availability.get.ts +19 -0
  51. package/server/api/planning/appointments/[id]/cancel.post.ts +8 -0
  52. package/server/api/planning/appointments/[id]/files/[assetId].get.ts +17 -0
  53. package/server/api/planning/appointments/[id]/files.get.ts +28 -0
  54. package/server/api/planning/appointments/[id]/pending.delete.ts +8 -0
  55. package/server/api/planning/appointments/[id]/reschedule.put.ts +8 -0
  56. package/server/api/planning/appointments/[id].get.ts +6 -0
  57. package/server/api/planning/appointments.get.ts +4 -0
  58. package/server/api/planning/availability/[id].delete.ts +12 -0
  59. package/server/api/planning/availability/[id].put.ts +8 -0
  60. package/server/api/planning/availability.get.ts +6 -0
  61. package/server/api/planning/availability.post.ts +8 -0
  62. package/server/api/planning/calendar-busy.get.ts +29 -0
  63. package/server/api/planning/calendars.get.ts +8 -0
  64. package/server/api/planning/dashboard.get.ts +36 -0
  65. package/server/api/planning/google/notifications.post.ts +24 -0
  66. package/server/api/planning/oauth/[provider]/callback.get.ts +18 -0
  67. package/server/api/planning/oauth/[provider]/start.post.ts +8 -0
  68. package/server/api/planning/oauth/[provider].delete.ts +15 -0
  69. package/server/api/planning/products.get.ts +11 -0
  70. package/server/api/planning/provider-appointments.get.ts +11 -0
  71. package/server/api/planning/provider.get.ts +6 -0
  72. package/server/api/planning/provider.put.ts +8 -0
  73. package/server/api/store/planning/[productId]/availability.get.ts +8 -0
  74. package/server/api/store/planning/hold.delete.ts +11 -0
  75. package/server/api/store/planning/hold.get.ts +36 -0
  76. package/server/api/store/planning/holds.post.ts +10 -0
  77. package/server/db/schema/planning.ts +213 -0
  78. package/server/plugins/openapi.ts +36 -0
  79. package/server/plugins/planning.ts +6 -0
  80. package/server/tasks/planning/reconcile.ts +30 -0
  81. package/server/utils/access.ts +80 -0
  82. package/server/utils/adapters.ts +468 -0
  83. package/server/utils/appointments.ts +37 -0
  84. package/server/utils/booking.ts +619 -0
  85. package/server/utils/crypto.ts +23 -0
  86. package/server/utils/jobs.ts +671 -0
  87. package/server/utils/management.ts +427 -0
  88. package/server/utils/oauth.ts +104 -0
  89. package/server/utils/staff-appointments.ts +158 -0
  90. package/shared/appointment-calendar.ts +82 -0
  91. package/shared/availability.ts +140 -0
  92. package/shared/emails.ts +99 -0
  93. package/shared/feature.ts +78 -0
  94. package/shared/invitation.ts +58 -0
  95. package/shared/types.ts +110 -0
  96. package/shared/validation.ts +77 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,61 @@
1
+ # Changelog
2
+
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 59c8921: Add an organization-scoped, searchable and paginated synchronization task dashboard with activity sorting and individual retry actions.
8
+ - 59c8921: Guide providers through connecting and fixing a writable main calendar before choosing optional conflict calendars, and show those calendars' busy periods in the availability view, including external events from the main calendar. Availability events now use a configurable calendar title and list the applicable products in their description. Sandbox bookings skip meeting-provider creation while still syncing appointments to the main calendar when development effects are enabled.
9
+ - 59c8921: Add optional plannable service products with provider availability, Google Calendar and Zoom connections, 60-minute checkout reservations, appointment invitations, paid rescheduling, and policy-based cancellation refunds. Extend the product catalog and checkout integration while preserving ordinary purchases.
10
+
11
+ ### Patch Changes
12
+
13
+ - 59c8921: Share appointment time formatting across Planning and checkout, respecting browser locale preferences and displaying midnight concisely.
14
+
15
+ Present checkout booking details with distinct date, time, and provider rows and a stable live reservation countdown.
16
+
17
+ - 59c8921: Reduce scheduled calendar repair frequency, avoid rewriting existing Zoom meetings during repair, and defer Zoom rate-limit retries until the next UTC day.
18
+ - 59c8921: Allow admins to configure maximum displayed appointment start-time spacing alongside minimum notice and start interval. Apply clock-aligned spacing server-side and default existing policies to 30 minutes.
19
+ - 59c8921: Let customers select and review a booking time before explicitly continuing to checkout. Reserve only on confirmation and clear the selection when the date, timezone, provider, month, or currency changes.
20
+ - 59c8921: Keep the selected appointment and checkout action together beneath a stable calendar workspace on desktop and in a safe-area-aware pinned bar on mobile. Compact the mobile offering summary and let customers move between the full calendar and an animated time-selection panel at every screen size, with reduced-motion support.
21
+ - 59c8921: Add role-aware planning dashboard cards for upcoming appointments, schedule totals, conflicts, and common actions.
22
+ - 59c8921: Present purchased files as reusable document cards, add them to appointment and purchase detail drawers, and use compact purchase cards in a fixed-toolbar collection layout.
23
+ - 59c8921: Release unpaid booking holds before returning to time selection. Atomically replace the previous hold when choosing another time in the same tab, preserving it if the new slot cannot be reserved. Session-bound credentials cannot release confirmed bookings or holds attached to an order.
24
+ - Updated dependencies [59c8921]
25
+ - Updated dependencies [59c8921]
26
+ - Updated dependencies [59c8921]
27
+ - Updated dependencies [59c8921]
28
+ - Updated dependencies [59c8921]
29
+ - Updated dependencies [59c8921]
30
+ - Updated dependencies [59c8921]
31
+ - Updated dependencies [59c8921]
32
+ - Updated dependencies [59c8921]
33
+ - Updated dependencies [59c8921]
34
+ - Updated dependencies [59c8921]
35
+ - Updated dependencies [59c8921]
36
+ - Updated dependencies [59c8921]
37
+ - Updated dependencies [59c8921]
38
+ - Updated dependencies [59c8921]
39
+ - Updated dependencies [59c8921]
40
+ - Updated dependencies [59c8921]
41
+ - Updated dependencies [59c8921]
42
+ - Updated dependencies [59c8921]
43
+ - Updated dependencies [59c8921]
44
+ - Updated dependencies [59c8921]
45
+ - Updated dependencies [59c8921]
46
+ - Updated dependencies [59c8921]
47
+ - Updated dependencies [59c8921]
48
+ - Updated dependencies [59c8921]
49
+ - Updated dependencies [59c8921]
50
+ - Updated dependencies [59c8921]
51
+ - Updated dependencies [59c8921]
52
+ - Updated dependencies [59c8921]
53
+ - Updated dependencies [59c8921]
54
+ - Updated dependencies [59c8921]
55
+ - @nuxt-customer-portal/core@0.4.0
56
+ - @nuxt-customer-portal/products@0.4.0
57
+ - @nuxt-customer-portal/ui@0.4.0
58
+
59
+ ## Unreleased
60
+
61
+ - Add plannable services, availability, calendar connections, reservations and appointment management.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nuxt Customer Portal contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # Planning
2
+
3
+ Optional service-product scheduling for Nuxt Customer Portal. Requires `products` and its dependencies; paid commerce also requires the existing invoice-products integration. This extension builds on products issue #22 / PR #23.
4
+
5
+ ## Installation
6
+
7
+ Add `@nuxt-customer-portal/planning` as a dependency and to `portal.config.ts` after `products` and before running the portal migration command:
8
+
9
+ ```ts
10
+ layers: [
11
+ '@nuxt-customer-portal/preset',
12
+ '@nuxt-customer-portal/products',
13
+ '@nuxt-customer-portal/invoice-products',
14
+ '@nuxt-customer-portal/planning'
15
+ ]
16
+ ```
17
+
18
+ The migration requires permission to install PostgreSQL's `btree_gist` extension. It adds scheduling tables and a provider/time exclusion constraint. Existing products default to ordinary checkout until explicitly marked plannable. Existing product JSON requires no backfill. Removing the planning layer prevents checkout and processing of plannable services; retain it while appointments or payment operations are outstanding.
19
+
20
+ Run Nitro's scheduled tasks every minute in production. HTTP deployments need a reachable HTTPS public URL for Google change notifications. Calendar checks remain live on booking requests; a five-minute polling fallback detects external conflicts and repairs portal mirrors when notifications are unavailable. Ensure only trusted proxy headers determine public API client IPs.
21
+
22
+ ## Credentials and setup
23
+
24
+ Use dedicated Google and Zoom OAuth applications, separate from portal sign-in applications:
25
+
26
+ ```dotenv
27
+ PLANNING_ENCRYPTION_KEY=<base64-encoded 32-byte random key>
28
+ PLANNING_GOOGLE_CLIENT_ID=
29
+ PLANNING_GOOGLE_CLIENT_SECRET=
30
+ PLANNING_ZOOM_CLIENT_ID=
31
+ PLANNING_ZOOM_CLIENT_SECRET=
32
+ ```
33
+
34
+ Generate the encryption key with `openssl rand -base64 32`. Keep this key stable and backed up with the encrypted database. OAuth tokens are encrypted with AES-256-GCM and never returned through setup APIs.
35
+
36
+ Register redirect URLs using the portal's configured public origin:
37
+
38
+ - `/api/planning/oauth/google/callback`
39
+ - `/api/planning/oauth/zoom/callback`
40
+
41
+ For local Google development use `http://localhost:3052`. Zoom requires the portal's configured public origin to be reachable over HTTPS; a local reverse proxy with a stable HTTPS hostname can forward to `http://localhost:3052`. Register the standard `/api/planning/oauth/zoom/callback` path on that hostname. Google authorization requests Calendar events, Calendar list read access, free/busy access, and identity/email.
42
+
43
+ Configure the Zoom user-managed application with these user-level granular scopes (without the `:admin` variants):
44
+
45
+ - Users → Get a user: `user:read:user`
46
+ - Meetings → List meetings: `meeting:read:list_meetings`
47
+ - Meetings → Get a meeting: `meeting:read:meeting`
48
+ - Meetings → Create a meeting: `meeting:write:meeting`
49
+ - Meetings → Update a meeting: `meeting:update:meeting`
50
+ - Meetings → Delete a meeting: `meeting:delete:meeting`
51
+
52
+ Enable appropriate test users or publish/verify the provider applications before connecting other users. Zoom account meeting limits still apply.
53
+
54
+ In hosts using SaaS configuration, enable the Planning module alongside Products. Admins enable planning per organization member at `/admin/planning`. Providers connect accounts, choose calendars, timezone and after-appointment grace time, and create availability at `/availability`. Choose several calendars for conflicts and one writable calendar; the writable calendar is automatically included in conflict checks. Assign providers to a service in the existing product editor and supply a positive duration. A product may be free, and may require Zoom or have no online meeting.
55
+
56
+ ## Availability and reservations
57
+
58
+ Availability supports individual windows and weekly series, end dates, skipped occurrences, and edits to one occurrence or the entire series. Windows apply to all assigned plannable products, including future assignments, or to selected products. UTC slot iteration preserves duration across daylight-saving changes and presents repeated wall times with timezone information.
59
+
60
+ Changing a provider's scheduling timezone keeps availability at its configured local wall times in the new timezone. Confirmed appointments retain their original UTC instants and timezone snapshots. Product and grace-time changes likewise do not alter confirmed appointments.
61
+
62
+ Google availability mirrors are free events; confirmed appointments are busy events with the grace time described. Customer checkout holds remain internal. Portal mirrors are managed from the portal; external edits/removals are repaired and reported to the provider. Personal Google events get no additional grace time. New conflicts preserve confirmed bookings and alert their provider.
63
+
64
+ Catalog products expose `planningEnabled`, `durationMinutes`, and the existing `purchaseUrl`, which points to `/store/{slug}/book` for plannable services. Customers choose their timezone, any available provider or a named provider, then a slot. Exactly one appointment product, quantity one, is supported per checkout.
65
+
66
+ The default hold is **60 minutes from selection**, configurable by organization policy/product override. Its opaque credential is bound to an HTTP-only browser cookie. Changing browsers requires selecting another slot. Stripe checkout does not extend the hold: its minimum session lifetime can exceed the remaining reservation time, so scheduled tasks explicitly close it at the original deadline. Payment completion timestamps come from verified Stripe events or authoritative success-event reconciliation, rather than webhook delivery time. Late payments never reclaim released inventory and are refunded through durable jobs. Appointment checkout currently uses card payments. Free products confirm after checkout without Stripe.
67
+
68
+ Calendar API failure closes new availability and retries paid-order processing. Google and PostgreSQL cannot be atomically locked together; conflicts appearing after confirmation are flagged for provider resolution.
69
+
70
+ ## Appointment management and policies
71
+
72
+ Guest customers sign in or verify a portal account and claim purchases by verified email before managing appointments at `/appointments`. Other users cannot manage those appointments.
73
+
74
+ Organization defaults can be overridden per product. Defaults: 60-minute hold, 15-minute start intervals, 90-day horizon, 24-hour booking notice, one free completed change, 24-hour change cutoff, and cancellation disabled. Configure a positive change fee in each supported booking currency to enable changes after the allowance. Monetary storage uses integer minor units; the policy UI displays currency amounts.
75
+
76
+ Customers may change to any currently eligible provider for the same product. The original appointment remains confirmed until replacement checkout succeeds. Free changes still require checkout; paid changes use a separate fee order and invoice. Failed/expired changes preserve the original and do not consume the allowance. Existing appointment duration and policy remain fixed. Reschedule/cancellation cutoffs are evaluated before accepting the customer action.
77
+
78
+ Cancellation must be explicitly enabled. Configure one notice cutoff and a refund percentage. Cancellation releases internal occupancy immediately and updates the calendar invitation and meeting through retryable tasks. Refunds cover the original appointment purchase, excluding separately earned change fees, capped by its remaining refundable balance. Refund failures do not undo cancellation. Reconciliation preserves invoice/credit-note behavior through the existing commerce integration.
79
+
80
+ Appointment invites keep their UID, organizer and incrementing sequence across changes and cancellations. Meetings, mirror updates and email use retryable jobs and stable identifiers. Admins can see appointments, conflicts and outstanding synchronization/refund jobs, and retry them at `/admin/planning`. The database retains an audit trail and permanent reservation confirmation evidence so replaying an original purchase never reverses a later reschedule or cancellation.
81
+
82
+ ## APIs and adapters
83
+
84
+ Public portal-origin booking mutations:
85
+
86
+ - `GET /api/store/planning/{productId}/availability?from=...&to=...`
87
+ - `POST /api/store/planning/holds`
88
+ - `DELETE /api/store/planning/hold` releases a session-bound hold before an order is created. Checkout waits for this release before returning to time selection. A new hold can supply `previousHoldToken` to atomically replace an unbound hold for the same product; failed slot validation rolls back the replacement. Browser-back/abandoned holds otherwise expire normally.
89
+ - `GET /api/store/planning/hold?holdToken=...`
90
+ - Existing `POST /api/store/checkout`, extended with `holdToken`.
91
+
92
+ Provider/session APIs under `/api/planning` manage account connections, selected calendars, settings and availability. Verified customer APIs under `/api/planning/appointments` list, quote changes, expose replacement availability, and cancel appointments. Organization administrator APIs under `/api/planning/admin` manage provider switches/policy and expose appointments/jobs.
93
+
94
+ `CalendarAdapter`, `MeetingAdapter`, and `registerPlanningAdapters` provide provider-neutral extension seams. `PlanningOrderIntegration` links checkout validation, reservation binding, pre-confirmation calendar checks and transactional confirmation to products. External provider API operations run outside database transactions.
95
+
96
+ ## Verification
97
+
98
+ ```sh
99
+ node --import tsx --test packages/planning/test/planning.test.ts
100
+ PLANNING_TEST_DATABASE_URL=postgresql://.../portal_planning_test node --import tsx --test packages/planning/test/database.test.ts
101
+ ```
102
+
103
+ The database test requires an empty disposable database named `portal_planning_test` or `portal_planning_test_<suffix>` and applies actual core, client, product and planning migrations. It uses real portal authentication and PostgreSQL, with Google/Zoom/Stripe/email fixtures that never send external requests. Recreate the disposable database before rerunning.
104
+
105
+ Before enabling live booking, connect Google/Zoom test accounts and verify consent/refresh, selected-calendar permissions, notification renewal, availability mirrors, real busy events, meeting creation and invitation delivery. These require provider credentials and approved test accounts; fixtures do not replace that deployment verification.
106
+
107
+ ### Appointment administration
108
+
109
+ The Appointments menu includes the appointment collection, provider availability, and planning settings. Active members of the provider organization can view its appointments; organization owners and admins can reschedule or cancel them. Customers can view and manage only their own claimed purchases, subject to the policy recorded at booking. Staff reschedules preserve the customer change allowance. Cancellation retains order and appointment history, and refunds remain subject to eligibility. New appointments use booking and checkout.
110
+
111
+ Planning settings use separate components for the Team members, Booking policy, and Synchronization tabs, with the same tab styling as Store settings.
112
+
113
+ Personal connections and preferences are managed under My appointment settings (`/planning/settings`): Calendar settings contains Google connection, timezone, grace time, and calendar selection; Meeting settings contains the Zoom connection. Availability (`/availability`) contains only the availability editor and appointment blocks.
114
+
115
+ Sandbox bookings use internal availability, holds, and appointments without requiring Google or Zoom connections or external calendar conflict checks. Live bookings still require healthy connections and configured calendars.
@@ -0,0 +1,49 @@
1
+ <script setup lang="ts">
2
+ import { z } from 'zod'
3
+
4
+ const emit = defineEmits<{ cancel: [] }>(),
5
+ api = usePlanning(),
6
+ { t, locale } = useI18n(),
7
+ products = ref<Array<{ slug: string; title: string }>>([]),
8
+ state = reactive({ slug: '' }),
9
+ error = ref('')
10
+ const schema = computed(() => z.object({ slug: z.string().min(1, t('planning.chooseProduct')) }))
11
+ try {
12
+ products.value = await api.bookableProducts()
13
+ } catch {
14
+ error.value = t('planning.loadError')
15
+ }
16
+ async function book() {
17
+ await navigateTo({ path: `/store/${encodeURIComponent(state.slug)}/book`, query: { locale: locale.value } })
18
+ }
19
+ </script>
20
+
21
+ <template>
22
+ <UCard class="scroll-mt-24" @keydown.esc.stop="emit('cancel')"
23
+ ><template #header
24
+ ><div class="flex items-center justify-between gap-2">
25
+ <h2 class="font-semibold">{{ t('planning.newAppointment') }}</h2>
26
+ <UButton
27
+ icon="i-lucide-x"
28
+ color="neutral"
29
+ variant="ghost"
30
+ :aria-label="t('planning.close')"
31
+ @click="emit('cancel')"
32
+ /></div></template
33
+ ><UAlert v-if="error" color="error" variant="outline" :title="error" />
34
+ <p class="mb-4 text-sm text-muted">{{ t('planning.newAppointmentNotice') }}</p>
35
+ <UForm :state="state" :schema="schema" novalidate class="space-y-4" @submit="book"
36
+ ><UFormField name="slug" :label="t('planning.product')"
37
+ ><USelectMenu
38
+ v-model="state.slug"
39
+ :items="products.map((p) => ({ value: p.slug, label: p.title }))"
40
+ value-key="value"
41
+ class="w-full"
42
+ /></UFormField>
43
+ <div class="flex justify-end gap-2">
44
+ <UButton color="neutral" variant="outline" @click="emit('cancel')">{{ t('planning.cancelAction') }}</UButton
45
+ ><UButton type="submit" :disabled="!state.slug">{{ t('planning.chooseTime') }}</UButton>
46
+ </div></UForm
47
+ ></UCard
48
+ >
49
+ </template>
@@ -0,0 +1,163 @@
1
+ <script setup lang="ts">
2
+ import type { AppointmentDetails, HoldResult } from '../composables/usePlanning'
3
+ import type { Slot } from '../../shared/types'
4
+ import { formatMoney } from '@nuxt-customer-portal/products/shared/money'
5
+
6
+ const props = withDefaults(defineProps<{ id: string; timezone?: string; showSummary?: boolean }>(), {
7
+ showSummary: true
8
+ })
9
+ const emit = defineEmits<{ changed: [] }>()
10
+ const { appointmentRange } = usePlanningTimeDisplay()
11
+ const api = usePlanning(),
12
+ { t, locale } = useI18n(),
13
+ item = ref<AppointmentDetails>(),
14
+ error = ref(''),
15
+ busy = ref(false),
16
+ changeOpen = ref(false),
17
+ cancelOpen = ref(false)
18
+ async function load() {
19
+ try {
20
+ item.value = await api.appointment(props.id)
21
+ } catch {
22
+ error.value = t('planning.loadError')
23
+ }
24
+ }
25
+ await load()
26
+ async function reserved(hold: HoldResult) {
27
+ await navigateTo({
28
+ path: `/store/${encodeURIComponent(item.value!.productSlug)}`,
29
+ query: { appointmentId: item.value!.id, holdToken: hold.holdToken, currency: hold.currency, locale: locale.value }
30
+ })
31
+ }
32
+ async function abandonChange() {
33
+ busy.value = true
34
+ try {
35
+ await api.abandonChange(item.value!.id)
36
+ await load()
37
+ emit('changed')
38
+ } catch {
39
+ error.value = t('planning.actionError')
40
+ } finally {
41
+ busy.value = false
42
+ }
43
+ }
44
+ async function selected(slot: Slot, timezone: string) {
45
+ if (busy.value) {
46
+ return
47
+ }
48
+ busy.value = true
49
+ try {
50
+ await api.staffReschedule(item.value!.id, {
51
+ providerUserId: slot.providerUserId,
52
+ start: slot.start,
53
+ customerTimezone: timezone,
54
+ revision: item.value!.revision
55
+ })
56
+ changeOpen.value = false
57
+ await load()
58
+ emit('changed')
59
+ } catch {
60
+ error.value = t('planning.actionError')
61
+ } finally {
62
+ busy.value = false
63
+ }
64
+ }
65
+ async function cancel() {
66
+ busy.value = true
67
+ try {
68
+ await api.cancel(item.value!.id)
69
+ cancelOpen.value = false
70
+ await load()
71
+ emit('changed')
72
+ } catch {
73
+ error.value = t('planning.actionError')
74
+ } finally {
75
+ busy.value = false
76
+ }
77
+ }
78
+ </script>
79
+
80
+ <template>
81
+ <div>
82
+ <div class="space-y-5">
83
+ <UAlert v-if="error" variant="outline" color="error" :title="error" /><template v-if="item">
84
+ <template v-if="showSummary">
85
+ <h1 class="text-2xl font-bold">{{ item.title }}</h1>
86
+ <p>
87
+ {{ appointmentRange(item.start, item.end, timezone || item.customerTimezone) }}
88
+ </p>
89
+ <p>{{ item.providerName }} · {{ t(`planning.${item.status}`) }}</p>
90
+ </template>
91
+ <UButton
92
+ v-if="showSummary && item.status === 'confirmed' && item.meetingUrl"
93
+ :to="item.meetingUrl"
94
+ target="_blank"
95
+ >{{ t(item.meetingProvider === 'zoom' ? 'planning.joinZoom' : 'planning.joinMeeting') }}</UButton
96
+ >
97
+ <UAlert v-if="item.pendingChangeExpiresAt" variant="outline" :title="t('planning.pendingChange')" />
98
+ <UButton
99
+ v-if="item.pendingChangeExpiresAt && item.canManage"
100
+ variant="outline"
101
+ :loading="busy"
102
+ @click="abandonChange"
103
+ >{{ t('planning.abandonChange') }}</UButton
104
+ >
105
+ <div
106
+ v-if="item.canReschedule || item.canCancel || !item.staff"
107
+ class="space-y-3 rounded-lg border border-default bg-muted/20 p-4"
108
+ >
109
+ <div v-if="item.canReschedule || item.canCancel" class="flex flex-wrap gap-3">
110
+ <UButton
111
+ v-if="item.canReschedule"
112
+ icon="i-lucide-calendar-sync"
113
+ variant="outline"
114
+ @click="changeOpen = true"
115
+ >{{ t('planning.reschedule') }}</UButton
116
+ ><UButton
117
+ v-if="item.canCancel"
118
+ icon="i-lucide-calendar-x-2"
119
+ color="error"
120
+ variant="soft"
121
+ @click="cancelOpen = true"
122
+ >{{ t('planning.cancelAppointmentAction') }}</UButton
123
+ >
124
+ </div>
125
+ <p v-if="!item.staff" class="text-sm text-muted">
126
+ {{ t('planning.changesUsed', { used: item.changes, free: item.freeChanges }) }}
127
+ </p>
128
+ </div>
129
+ </template>
130
+ <UModal v-if="changeOpen" v-model:open="changeOpen" :title="t('planning.reschedule')"
131
+ ><template #body
132
+ ><p v-if="item" class="mb-4">
133
+ {{
134
+ item.staff
135
+ ? t('planning.staffChangeNotice')
136
+ : item.changeFee
137
+ ? t('planning.changeCosts', { amount: formatMoney(item.changeFee, item.currency, locale) })
138
+ : t('planning.freeChange')
139
+ }}
140
+ </p>
141
+ <PlanningSlotPicker
142
+ v-if="item"
143
+ :product-id="item.productId"
144
+ :currency="item.currency"
145
+ :locale="locale === 'nl' ? 'nl' : 'en'"
146
+ :replaces-id="item.id"
147
+ :select-only="item.staff"
148
+ @selected="selected"
149
+ @reserved="reserved" /></template
150
+ ></UModal>
151
+ <UModal v-if="cancelOpen" v-model:open="cancelOpen" :title="t('planning.cancel')"
152
+ ><template #body
153
+ ><p v-if="item">
154
+ {{ t('planning.cancelConfirm', { amount: formatMoney(item.refundAmount, item.currency, locale) }) }}
155
+ </p>
156
+ <UButton class="mt-4" color="error" :loading="busy" @click="cancel">{{
157
+ t('planning.confirmCancellation')
158
+ }}</UButton></template
159
+ ></UModal
160
+ >
161
+ </div>
162
+ </div>
163
+ </template>
@@ -0,0 +1,109 @@
1
+ <script setup lang="ts">
2
+ import type { AppointmentFile, AppointmentListItem } from '../composables/usePlanning'
3
+
4
+ const props = defineProps<{ item: AppointmentListItem; timezone: string; staff?: boolean }>()
5
+ const emit = defineEmits<{ changed: [] }>()
6
+ const open = defineModel<boolean>('open', { required: true })
7
+ const { t } = useI18n()
8
+ const { appointmentRange } = usePlanningTimeDisplay()
9
+ const files = ref<AppointmentFile[]>([])
10
+ const filesError = ref(false)
11
+ try {
12
+ files.value = await usePlanning().appointmentFiles(props.item.id)
13
+ } catch {
14
+ filesError.value = true
15
+ }
16
+ const fileSource = (assetId: string) => `/api/planning/appointments/${props.item.id}/files/${assetId}`
17
+ </script>
18
+
19
+ <template>
20
+ <UDrawer
21
+ v-model:open="open"
22
+ direction="right"
23
+ :title="item.title"
24
+ :description="appointmentRange(item.start, item.end, timezone)"
25
+ close
26
+ :ui="{ content: 'w-full sm:max-w-xl' }"
27
+ >
28
+ <template #body>
29
+ <div class="space-y-5">
30
+ <div class="rounded-lg border border-default bg-muted/20 p-4">
31
+ <div class="flex flex-wrap items-center gap-2">
32
+ <UBadge :color="item.status === 'confirmed' ? 'success' : 'neutral'" variant="subtle">
33
+ {{ t(`planning.${item.status}`) }}
34
+ </UBadge>
35
+ <UBadge v-if="item.conflict" color="error" variant="subtle">{{ t('planning.conflict') }}</UBadge>
36
+ </div>
37
+ <dl class="mt-4 grid gap-4 text-sm sm:grid-cols-2">
38
+ <div>
39
+ <dt class="text-muted">{{ t('planning.time') }}</dt>
40
+ <dd class="mt-1 font-medium">{{ appointmentRange(item.start, item.end, timezone) }}</dd>
41
+ <dd class="text-muted">{{ timezone }}</dd>
42
+ </div>
43
+ <div>
44
+ <dt class="text-muted">{{ t('planning.host') }}</dt>
45
+ <dd class="mt-1 font-medium">{{ item.providerName || t('planning.unknownHost') }}</dd>
46
+ <dd v-if="staff && item.providerEmail" class="text-muted">{{ item.providerEmail }}</dd>
47
+ </div>
48
+ <div v-if="staff">
49
+ <dt class="text-muted">{{ t('planning.customer') }}</dt>
50
+ <dd class="mt-1 font-medium">{{ item.customerName || item.email }}</dd>
51
+ <dd class="text-muted">{{ item.email }}</dd>
52
+ </div>
53
+ <div v-if="item.meetingProvider === 'zoom'">
54
+ <dt class="text-muted">{{ t('planning.meetingProvider') }}</dt>
55
+ <dd class="mt-1">
56
+ <UButton
57
+ v-if="item.status === 'confirmed' && item.meetingUrl"
58
+ :to="item.meetingUrl"
59
+ target="_blank"
60
+ icon="i-lucide-video"
61
+ size="xs"
62
+ variant="outline"
63
+ >
64
+ {{ t('planning.joinZoom') }}
65
+ </UButton>
66
+ <span v-else class="flex items-start gap-2">
67
+ <UIcon name="i-lucide-video" class="mt-0.5 size-4 shrink-0 text-primary" />
68
+ <span>
69
+ <span class="block font-medium">{{ t('planning.zoomMeeting') }}</span>
70
+ <span v-if="item.status === 'confirmed'" class="block text-muted">{{
71
+ t('planning.zoomLinkPending')
72
+ }}</span>
73
+ </span>
74
+ </span>
75
+ </dd>
76
+ </div>
77
+ </dl>
78
+ </div>
79
+ <PlanningAppointmentDetails
80
+ :id="item.id"
81
+ :key="item.id"
82
+ :timezone="timezone"
83
+ :show-summary="false"
84
+ @changed="emit('changed')"
85
+ />
86
+ <section v-if="files.length || filesError" class="overflow-hidden rounded-lg border border-default bg-muted/20">
87
+ <header class="border-b border-default px-4 py-3">
88
+ <h2 class="font-semibold">{{ t(staff ? 'products.purchasedFiles' : 'products.yourFiles') }}</h2>
89
+ </header>
90
+ <UAlert
91
+ v-if="filesError"
92
+ class="m-4"
93
+ color="error"
94
+ variant="subtle"
95
+ :title="t('products.purchaseFilesFailed')"
96
+ />
97
+ <div v-else class="grid gap-3 p-4 sm:grid-cols-2">
98
+ <ProductsPurchasedFileCard
99
+ v-for="file in files"
100
+ :key="file.id"
101
+ :file="file"
102
+ :source="fileSource(file.id)"
103
+ />
104
+ </div>
105
+ </section>
106
+ </div>
107
+ </template>
108
+ </UDrawer>
109
+ </template>