@nuasite/components 0.11.0 → 0.12.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.
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@nuasite/components",
3
3
  "description": "Nua Site astro components.",
4
4
  "license": "Apache-2.0",
5
- "version": "0.11.0",
5
+ "version": "0.12.0",
6
6
  "files": [
7
7
  "dist/**",
8
8
  "src/**",
@@ -36,6 +36,21 @@
36
36
  "types": "./src/image/index.astro",
37
37
  "import": "./src/image/index.astro",
38
38
  "default": "./src/image/index.astro"
39
+ },
40
+ "./ReservationCheckout.astro": {
41
+ "types": "./src/reservation/checkout/index.astro",
42
+ "import": "./src/reservation/checkout/index.astro",
43
+ "default": "./src/reservation/checkout/index.astro"
44
+ },
45
+ "./ReservationAvailability.astro": {
46
+ "types": "./src/reservation/availability/index.astro",
47
+ "import": "./src/reservation/availability/index.astro",
48
+ "default": "./src/reservation/availability/index.astro"
49
+ },
50
+ "./ReservationStatus.astro": {
51
+ "types": "./src/reservation/status/index.astro",
52
+ "import": "./src/reservation/status/index.astro",
53
+ "default": "./src/reservation/status/index.astro"
39
54
  }
40
55
  },
41
56
  "peerDependencies": {
package/src/index.ts CHANGED
@@ -2,3 +2,10 @@ export { default as Form } from './form/index.astro'
2
2
  export type { FormProps } from './form/types'
3
3
  export { default as Image } from './image/index.astro'
4
4
  export type { CloudflareImageTransformOptions, ImageProps } from './image/types'
5
+ export { default as ReservationCheckout } from './reservation/checkout/index.astro'
6
+ export type { ReservationCheckoutProps } from './reservation/checkout/types'
7
+ export { default as ReservationAvailability } from './reservation/availability/index.astro'
8
+ export type { ReservationAvailabilityProps } from './reservation/availability/types'
9
+ export { default as ReservationStatus } from './reservation/status/index.astro'
10
+ export type { ReservationStatusProps } from './reservation/status/types'
11
+ export type { ReservationProduct, ReservationTimeSlot, ReservationError } from './reservation/types'
@@ -0,0 +1,116 @@
1
+ ---
2
+ import type { ReservationAvailabilityProps } from './types'
3
+
4
+ export type Props = ReservationAvailabilityProps
5
+
6
+ const {
7
+ reservationId,
8
+ action = `/_nua/reservation/availability/${reservationId}`,
9
+ loadingMessage = 'Loading availability...',
10
+ errorMessage = 'Failed to load availability.',
11
+ pollInterval,
12
+ } = Astro.props
13
+
14
+ if (!action && !reservationId) {
15
+ throw new Error('ReservationAvailability requires either "reservationId" or "action" prop')
16
+ }
17
+ ---
18
+
19
+ <nua-reservation-availability
20
+ data-action={action}
21
+ data-loading-message={loadingMessage}
22
+ data-error-message={errorMessage}
23
+ data-poll-interval={pollInterval}
24
+ data-state="loading"
25
+ >
26
+ <div data-status="loading">
27
+ <span>{loadingMessage}</span>
28
+ </div>
29
+ <div data-status="loaded" class="hidden">
30
+ <slot />
31
+ </div>
32
+ <div data-status="error" class="hidden">
33
+ <span>{errorMessage}</span>
34
+ </div>
35
+ </nua-reservation-availability>
36
+
37
+ <script>
38
+ import type { ReservationProduct } from '../types'
39
+
40
+ class NuaReservationAvailability extends HTMLElement {
41
+ private products: ReservationProduct[] = []
42
+ private pollTimer: ReturnType<typeof setInterval> | null = null
43
+ private fetchController: AbortController | null = null
44
+
45
+ connectedCallback() {
46
+ this.fetchAvailability()
47
+
48
+ const pollInterval = this.dataset.pollInterval
49
+ if (pollInterval) {
50
+ const ms = parseInt(pollInterval, 10)
51
+ if (ms > 0) {
52
+ this.pollTimer = setInterval(() => this.fetchAvailability(), ms)
53
+ }
54
+ }
55
+ }
56
+
57
+ disconnectedCallback() {
58
+ if (this.pollTimer) {
59
+ clearInterval(this.pollTimer)
60
+ this.pollTimer = null
61
+ }
62
+ this.fetchController?.abort()
63
+ this.fetchController = null
64
+ }
65
+
66
+ getProducts(): ReservationProduct[] {
67
+ return this.products
68
+ }
69
+
70
+ async refresh() {
71
+ await this.fetchAvailability()
72
+ }
73
+
74
+ private showState(state: 'loading' | 'loaded' | 'error') {
75
+ this.dataset.state = state
76
+ const loading = this.querySelector('[data-status="loading"]')
77
+ const loaded = this.querySelector('[data-status="loaded"]')
78
+ const error = this.querySelector('[data-status="error"]')
79
+
80
+ loading?.classList.toggle('hidden', state !== 'loading')
81
+ loaded?.classList.toggle('hidden', state !== 'loaded')
82
+ error?.classList.toggle('hidden', state !== 'error')
83
+ }
84
+
85
+ private async fetchAvailability() {
86
+ this.fetchController?.abort()
87
+ this.fetchController = new AbortController()
88
+
89
+ this.showState('loading')
90
+ const action = this.dataset.action!
91
+
92
+ try {
93
+ const response = await fetch(action, { signal: this.fetchController.signal })
94
+
95
+ if (!response.ok) {
96
+ this.showState('error')
97
+ return
98
+ }
99
+
100
+ const data = await response.json()
101
+ this.products = data.products || []
102
+
103
+ this.showState('loaded')
104
+ this.dispatchEvent(new CustomEvent('availability:loaded', {
105
+ bubbles: true,
106
+ detail: { products: this.products },
107
+ }))
108
+ } catch (err) {
109
+ if (err instanceof DOMException && err.name === 'AbortError') return
110
+ this.showState('error')
111
+ }
112
+ }
113
+ }
114
+
115
+ customElements.define('nua-reservation-availability', NuaReservationAvailability)
116
+ </script>
@@ -0,0 +1,10 @@
1
+ export type ReservationAvailabilityProps =
2
+ & {
3
+ loadingMessage?: string
4
+ errorMessage?: string
5
+ pollInterval?: number
6
+ }
7
+ & (
8
+ | { reservationId: string; action?: string }
9
+ | { reservationId?: string; action: string }
10
+ )
@@ -0,0 +1,92 @@
1
+ ---
2
+ import type { ReservationCheckoutProps } from './types'
3
+
4
+ export type Props = ReservationCheckoutProps
5
+
6
+ const {
7
+ reservationId,
8
+ action = `/_nua/reservation/checkout/${reservationId}`,
9
+ submittingMessage = 'Processing...',
10
+ errorMessage = 'Something went wrong. Please try again.',
11
+ unavailableMessage = 'This product is no longer available.',
12
+ } = Astro.props
13
+
14
+ if (!action && !reservationId) {
15
+ throw new Error('ReservationCheckout requires either "reservationId" or "action" prop')
16
+ }
17
+ ---
18
+
19
+ <nua-reservation-checkout
20
+ data-action={action}
21
+ data-submitting-message={submittingMessage}
22
+ data-error-message={errorMessage}
23
+ data-unavailable-message={unavailableMessage}
24
+ >
25
+ <form method="POST" action={action}>
26
+ <slot />
27
+ </form>
28
+ <div data-status="error" class="hidden">
29
+ <span>{errorMessage}</span>
30
+ </div>
31
+ </nua-reservation-checkout>
32
+
33
+ <script>
34
+ class NuaReservationCheckout extends HTMLElement {
35
+ connectedCallback() {
36
+ const form = this.querySelector('form')
37
+ if (!form) return
38
+
39
+ const submittingMessage = this.dataset.submittingMessage!
40
+ const unavailableMessage = this.dataset.unavailableMessage!
41
+
42
+ form.addEventListener('submit', (e) => {
43
+ const productId = form.querySelector<HTMLInputElement>('[name="productId"]')
44
+ if (!productId || !productId.value) {
45
+ e.preventDefault()
46
+ this.showError(unavailableMessage)
47
+ this.dispatchEvent(new CustomEvent('checkout:error', {
48
+ bubbles: true,
49
+ detail: { message: unavailableMessage },
50
+ }))
51
+ return
52
+ }
53
+
54
+ this.hideError()
55
+
56
+ const submitButton = form.querySelector<HTMLButtonElement>('button[type="submit"], button:not([type])')
57
+ if (submitButton) {
58
+ submitButton.disabled = true
59
+ if (submittingMessage) {
60
+ submitButton.dataset.originalText = submitButton.textContent || ''
61
+ submitButton.textContent = submittingMessage
62
+ }
63
+ }
64
+ })
65
+
66
+ window.addEventListener('pageshow', (e) => {
67
+ if (!e.persisted) return
68
+ const submitButton = form.querySelector<HTMLButtonElement>('button[type="submit"], button:not([type])')
69
+ if (submitButton && submitButton.dataset.originalText) {
70
+ submitButton.disabled = false
71
+ submitButton.textContent = submitButton.dataset.originalText
72
+ delete submitButton.dataset.originalText
73
+ }
74
+ })
75
+ }
76
+
77
+ private showError(message: string) {
78
+ const errorDiv = this.querySelector('[data-status="error"]')
79
+ if (!errorDiv) return
80
+ const span = errorDiv.querySelector('span')
81
+ if (span) span.textContent = message
82
+ errorDiv.classList.remove('hidden')
83
+ }
84
+
85
+ private hideError() {
86
+ const errorDiv = this.querySelector('[data-status="error"]')
87
+ errorDiv?.classList.add('hidden')
88
+ }
89
+ }
90
+
91
+ customElements.define('nua-reservation-checkout', NuaReservationCheckout)
92
+ </script>
@@ -0,0 +1,10 @@
1
+ export type ReservationCheckoutProps =
2
+ & {
3
+ submittingMessage?: string
4
+ errorMessage?: string
5
+ unavailableMessage?: string
6
+ }
7
+ & (
8
+ | { reservationId: string; action?: string }
9
+ | { reservationId?: string; action: string }
10
+ )
@@ -0,0 +1,114 @@
1
+ ---
2
+ import type { ReservationStatusProps } from './types'
3
+
4
+ export type Props = ReservationStatusProps
5
+
6
+ const {
7
+ reservationId,
8
+ action = `/_nua/reservation/status/${reservationId}`,
9
+ loadingMessage = 'Loading booking details...',
10
+ errorMessage = 'Failed to load booking details.',
11
+ missingBookingMessage = 'No booking found.',
12
+ notFoundMessage = 'Booking not found.',
13
+ } = Astro.props
14
+
15
+ if (!action && !reservationId) {
16
+ throw new Error('ReservationStatus requires either "reservationId" or "action" prop')
17
+ }
18
+ ---
19
+
20
+ <nua-reservation-status
21
+ data-action={action}
22
+ data-loading-message={loadingMessage}
23
+ data-error-message={errorMessage}
24
+ data-missing-session-message={missingBookingMessage}
25
+ data-not-found-message={notFoundMessage}
26
+ data-state="loading"
27
+ >
28
+ <div data-status="loading">
29
+ <span>{loadingMessage}</span>
30
+ </div>
31
+ <div data-status="loaded" class="hidden">
32
+ <slot />
33
+ </div>
34
+ <div data-status="error" class="hidden">
35
+ <span>{errorMessage}</span>
36
+ </div>
37
+ </nua-reservation-status>
38
+
39
+ <script>
40
+ class NuaReservationStatus extends HTMLElement {
41
+ private fetchController: AbortController | null = null
42
+
43
+ connectedCallback() {
44
+ this.fetchStatus()
45
+ }
46
+
47
+ disconnectedCallback() {
48
+ this.fetchController?.abort()
49
+ this.fetchController = null
50
+ }
51
+
52
+ private showState(state: 'loading' | 'loaded' | 'error', message?: string) {
53
+ this.dataset.state = state
54
+ const loading = this.querySelector('[data-status="loading"]')
55
+ const loaded = this.querySelector('[data-status="loaded"]')
56
+ const error = this.querySelector('[data-status="error"]')
57
+
58
+ loading?.classList.toggle('hidden', state !== 'loading')
59
+ loaded?.classList.toggle('hidden', state !== 'loaded')
60
+ error?.classList.toggle('hidden', state !== 'error')
61
+
62
+ if (message && state === 'error') {
63
+ const span = error?.querySelector('span')
64
+ if (span) span.textContent = message
65
+ }
66
+ }
67
+
68
+ private async fetchStatus() {
69
+ this.fetchController?.abort()
70
+ this.fetchController = new AbortController()
71
+
72
+ const action = this.dataset.action!
73
+ const missingBookingMessage = this.dataset.missingBookingMessage!
74
+ const notFoundMessage = this.dataset.notFoundMessage!
75
+ const errorMessage = this.dataset.errorMessage!
76
+
77
+ const params = new URLSearchParams(window.location.search)
78
+ const bookingId = params.get('booking_id')
79
+
80
+ if (!bookingId) {
81
+ this.showState('error', missingBookingMessage)
82
+ return
83
+ }
84
+
85
+ try {
86
+ const url = `${action}?booking_id=${encodeURIComponent(bookingId)}`
87
+ const response = await fetch(url, { signal: this.fetchController.signal })
88
+
89
+ if (response.status === 404) {
90
+ this.showState('error', notFoundMessage)
91
+ return
92
+ }
93
+
94
+ if (!response.ok) {
95
+ this.showState('error', errorMessage)
96
+ return
97
+ }
98
+
99
+ const data = await response.json()
100
+
101
+ this.showState('loaded')
102
+ this.dispatchEvent(new CustomEvent('status:loaded', {
103
+ bubbles: true,
104
+ detail: { booking: data.booking || data },
105
+ }))
106
+ } catch (err) {
107
+ if (err instanceof DOMException && err.name === 'AbortError') return
108
+ this.showState('error', errorMessage)
109
+ }
110
+ }
111
+ }
112
+
113
+ customElements.define('nua-reservation-status', NuaReservationStatus)
114
+ </script>
@@ -0,0 +1,11 @@
1
+ export type ReservationStatusProps =
2
+ & {
3
+ loadingMessage?: string
4
+ errorMessage?: string
5
+ missingBookingMessage?: string
6
+ notFoundMessage?: string
7
+ }
8
+ & (
9
+ | { reservationId: string; action?: string }
10
+ | { reservationId?: string; action: string }
11
+ )
@@ -0,0 +1,21 @@
1
+ export interface ReservationTimeSlot {
2
+ id: string
3
+ startTime: string
4
+ endTime: string
5
+ available: boolean
6
+ remainingCapacity?: number
7
+ }
8
+
9
+ export interface ReservationProduct {
10
+ id: string
11
+ name: string
12
+ description?: string
13
+ price: number
14
+ currency: string
15
+ timeSlots?: ReservationTimeSlot[]
16
+ }
17
+
18
+ export interface ReservationError {
19
+ error: string
20
+ code?: string
21
+ }