@ticketboothapp/booking 0.1.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 ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@ticketboothapp/booking",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "sideEffects": false,
6
+ "publishConfig": {
7
+ "access": "restricted"
8
+ },
9
+ "exports": {
10
+ ".": "./src/index.ts"
11
+ },
12
+ "peerDependencies": {
13
+ "next": "^15.0.0",
14
+ "react": "^18.0.0",
15
+ "react-dom": "^18.0.0"
16
+ }
17
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Keep in sync with `src/lib/booking/correlation-id.ts` (main site).
3
+ * Same storage key so partner portal catalog calls share one id with `booking-api` on this origin.
4
+ */
5
+
6
+ export const BOOKING_CORRELATION_HEADER = 'X-Correlation-Id';
7
+
8
+ const STORAGE_KEY = 'tb_booking_correlation_id';
9
+
10
+ function newCorrelationId(): string {
11
+ try {
12
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
13
+ return crypto.randomUUID();
14
+ }
15
+ } catch {
16
+ /* fall through */
17
+ }
18
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 12)}`;
19
+ }
20
+
21
+ export function getOrCreateBookingCorrelationId(): string {
22
+ if (typeof window === 'undefined') return '';
23
+ try {
24
+ let id = sessionStorage.getItem(STORAGE_KEY);
25
+ if (!id) {
26
+ id = newCorrelationId();
27
+ sessionStorage.setItem(STORAGE_KEY, id);
28
+ }
29
+ return id;
30
+ } catch {
31
+ return newCorrelationId();
32
+ }
33
+ }
34
+
35
+ export function withBookingCorrelationId(
36
+ headers: Record<string, string>
37
+ ): Record<string, string> {
38
+ if (typeof window === 'undefined') return headers;
39
+ const id = getOrCreateBookingCorrelationId();
40
+ if (!id) return headers;
41
+ return {
42
+ ...headers,
43
+ [BOOKING_CORRELATION_HEADER]: id,
44
+ };
45
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ export {
2
+ getPublicPartners,
3
+ getPublicPartnerAgents,
4
+ getPartnerPortalDirectoryPartners,
5
+ getPartnerPortalDirectoryAgents,
6
+ getPublicPartnerPortalSignInEmails,
7
+ getPublicStaffPortalSignInOptions,
8
+ getPublicStaffPortalSignInEmails,
9
+ type PublicPartner,
10
+ type PublicPartnerAgent,
11
+ type PublicStaffPortalSignInOption,
12
+ } from './public-partners';
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Public partner/agent list for anonymous partner booking (no JWT).
3
+ * Uses NEXT_PUBLIC_* env from the consuming Next app at build time.
4
+ */
5
+
6
+ function apiBase(): string {
7
+ const u = process.env.NEXT_PUBLIC_API_URL;
8
+ if (!u) {
9
+ throw new Error('NEXT_PUBLIC_API_URL is not set');
10
+ }
11
+ return u.replace(/\/$/, '');
12
+ }
13
+
14
+ function parsePickupLocationIds(row: Record<string, unknown>): string[] | undefined {
15
+ const multi =
16
+ row.pickupLocationIds ??
17
+ row.pickup_location_ids ??
18
+ row.preferredPickupLocationIds ??
19
+ row.preferred_pickup_location_ids;
20
+ if (Array.isArray(multi)) {
21
+ const ids = multi
22
+ .filter((x): x is string => typeof x === 'string' && x.trim().length > 0)
23
+ .map((x) => x.trim());
24
+ if (ids.length > 0) return ids;
25
+ }
26
+ const single =
27
+ row.pickupLocationId ??
28
+ row.pickup_location_id ??
29
+ row.defaultPickupLocationId ??
30
+ row.default_pickup_location_id;
31
+ if (typeof single === 'string' && single.trim()) return [single.trim()];
32
+ const nested = row.pickupLocation ?? row.pickup_location;
33
+ if (nested && typeof nested === 'object' && nested !== null && 'id' in nested) {
34
+ const id = (nested as { id?: unknown }).id;
35
+ if (typeof id === 'string' && id.trim()) return [id.trim()];
36
+ }
37
+ return undefined;
38
+ }
39
+
40
+ export interface PublicPartner {
41
+ id: string;
42
+ displayName: string;
43
+ /**
44
+ * Optional pickup location id(s) from the API — must match `Product.pickupLocations[].id`
45
+ * for highlight / portal auto-select.
46
+ */
47
+ pickupLocationIds?: string[];
48
+ /**
49
+ * From `directory=1` responses. When false, anonymous users must sign in before the embedded book flow.
50
+ */
51
+ anonymousBookAllowed?: boolean;
52
+ /** B2B / partner pricing profile id when the API exposes it (optional). */
53
+ pricingProfileId?: string;
54
+ /** Partner cancellation-policy profile id when the API exposes it (optional). */
55
+ cancellationPolicyProfileId?: string;
56
+ }
57
+
58
+ export interface PublicPartnerAgent {
59
+ id: string;
60
+ displayName: string;
61
+ pickupLocationIds?: string[];
62
+ }
63
+
64
+ export interface PublicStaffPortalSignInOption {
65
+ staffId?: string;
66
+ email: string;
67
+ displayName: string;
68
+ }
69
+
70
+ function mapPartnerApiRow(
71
+ r: Record<string, unknown> & { partnerId: string; name: string },
72
+ ): PublicPartner {
73
+ const pickupLocationIds = parsePickupLocationIds(r);
74
+ const anonymousBookAllowed = r.anonymousBookAllowed !== false;
75
+ const cap =
76
+ r.capabilities && typeof r.capabilities === 'object' && !Array.isArray(r.capabilities)
77
+ ? (r.capabilities as Record<string, unknown>)
78
+ : null;
79
+ const rawProfile =
80
+ typeof r.pricingProfileId === 'string'
81
+ ? r.pricingProfileId
82
+ : cap && typeof cap.pricingProfileId === 'string'
83
+ ? cap.pricingProfileId
84
+ : '';
85
+ const pricingProfileId = rawProfile.trim() || undefined;
86
+ const rawCancellationProfile =
87
+ typeof r.cancellationPolicyProfileId === 'string'
88
+ ? r.cancellationPolicyProfileId
89
+ : cap && typeof cap.cancellationPolicyProfileId === 'string'
90
+ ? cap.cancellationPolicyProfileId
91
+ : '';
92
+ const cancellationPolicyProfileId = rawCancellationProfile.trim() || undefined;
93
+ return {
94
+ id: r.partnerId,
95
+ displayName: r.name,
96
+ anonymousBookAllowed,
97
+ ...(pickupLocationIds?.length ? { pickupLocationIds } : {}),
98
+ ...(pricingProfileId ? { pricingProfileId } : {}),
99
+ ...(cancellationPolicyProfileId ? { cancellationPolicyProfileId } : {}),
100
+ };
101
+ }
102
+
103
+ /** Partners that allow anonymous embedded booking (marketing / public widgets). */
104
+ export async function getPublicPartners(companyId: string): Promise<PublicPartner[]> {
105
+ const url = new URL(
106
+ `${apiBase()}/1/companies/${encodeURIComponent(companyId)}/partners/public`,
107
+ );
108
+ const res = await fetch(url.toString());
109
+ if (!res.ok) {
110
+ const text = await res.text().catch(() => '');
111
+ throw new Error(`getPublicPartners failed: ${res.status} ${text}`);
112
+ }
113
+ const json = (await res.json()) as {
114
+ data?: { partners?: Array<Record<string, unknown> & { partnerId: string; name: string }> };
115
+ };
116
+ const rows = json.data?.partners ?? [];
117
+ return rows.map((r) => mapPartnerApiRow(r));
118
+ }
119
+
120
+ /**
121
+ * All enabled partner orgs for the partner portal (sign-in org picker, etc.), including auth-only orgs.
122
+ * Use {@link getPublicPartners} when you only want anonymous-book-eligible partners.
123
+ */
124
+ export async function getPartnerPortalDirectoryPartners(companyId: string): Promise<PublicPartner[]> {
125
+ const url = new URL(
126
+ `${apiBase()}/1/companies/${encodeURIComponent(companyId)}/partners/public`,
127
+ );
128
+ url.searchParams.set('directory', '1');
129
+ const res = await fetch(url.toString());
130
+ if (!res.ok) {
131
+ const text = await res.text().catch(() => '');
132
+ throw new Error(`getPartnerPortalDirectoryPartners failed: ${res.status} ${text}`);
133
+ }
134
+ const json = (await res.json()) as {
135
+ data?: { partners?: Array<Record<string, unknown> & { partnerId: string; name: string }> };
136
+ };
137
+ const rows = json.data?.partners ?? [];
138
+ return rows.map((r) => mapPartnerApiRow(r));
139
+ }
140
+
141
+ export async function getPublicPartnerAgents(
142
+ companyId: string,
143
+ partnerId: string,
144
+ ): Promise<PublicPartnerAgent[]> {
145
+ const url = new URL(
146
+ `${apiBase()}/1/companies/${encodeURIComponent(companyId)}/partners/${encodeURIComponent(partnerId)}/agents/public`,
147
+ );
148
+ const res = await fetch(url.toString());
149
+ if (!res.ok) {
150
+ const text = await res.text().catch(() => '');
151
+ throw new Error(`getPublicPartnerAgents failed: ${res.status} ${text}`);
152
+ }
153
+ const json = (await res.json()) as {
154
+ data?: { agents?: Array<Record<string, unknown> & { agentId: string; name: string }> };
155
+ };
156
+ const rows = json.data?.agents ?? [];
157
+ return rows.map((r) => {
158
+ const pickupLocationIds = parsePickupLocationIds(r);
159
+ return {
160
+ id: r.agentId,
161
+ displayName: r.name,
162
+ ...(pickupLocationIds?.length ? { pickupLocationIds } : {}),
163
+ };
164
+ });
165
+ }
166
+
167
+ /** Agents for portal UI (includes orgs that require partner sign-in for booking). */
168
+ export async function getPartnerPortalDirectoryAgents(
169
+ companyId: string,
170
+ partnerId: string,
171
+ ): Promise<PublicPartnerAgent[]> {
172
+ const url = new URL(
173
+ `${apiBase()}/1/companies/${encodeURIComponent(companyId)}/partners/${encodeURIComponent(partnerId)}/agents/public`,
174
+ );
175
+ url.searchParams.set('directory', '1');
176
+ const res = await fetch(url.toString());
177
+ if (!res.ok) {
178
+ const text = await res.text().catch(() => '');
179
+ throw new Error(`getPartnerPortalDirectoryAgents failed: ${res.status} ${text}`);
180
+ }
181
+ const json = (await res.json()) as {
182
+ data?: { agents?: Array<Record<string, unknown> & { agentId: string; name: string }> };
183
+ };
184
+ const rows = json.data?.agents ?? [];
185
+ return rows.map((r) => {
186
+ const pickupLocationIds = parsePickupLocationIds(r);
187
+ return {
188
+ id: r.agentId,
189
+ displayName: r.name,
190
+ ...(pickupLocationIds?.length ? { pickupLocationIds } : {}),
191
+ };
192
+ });
193
+ }
194
+
195
+ /** Emails eligible for partner-portal emailed sign-in code (org + account + enabled agents' payout). */
196
+ export async function getPublicPartnerPortalSignInEmails(
197
+ companyId: string,
198
+ partnerId: string,
199
+ ): Promise<string[]> {
200
+ const url = new URL(
201
+ `${apiBase()}/1/companies/${encodeURIComponent(companyId)}/partners/${encodeURIComponent(partnerId)}/portal-signin-emails/public`,
202
+ );
203
+ const res = await fetch(url.toString());
204
+ if (!res.ok) {
205
+ const text = await res.text().catch(() => '');
206
+ throw new Error(`getPublicPartnerPortalSignInEmails failed: ${res.status} ${text}`);
207
+ }
208
+ const json = (await res.json()) as { data?: { emails?: string[] } };
209
+ const emails = json.data?.emails;
210
+ if (!Array.isArray(emails)) return [];
211
+ return emails.filter((e): e is string => typeof e === 'string' && e.trim().length > 0);
212
+ }
213
+
214
+ /** Staff portal sign-in options (displayName label + normalized email value). */
215
+ export async function getPublicStaffPortalSignInOptions(
216
+ companyId: string,
217
+ ): Promise<PublicStaffPortalSignInOption[]> {
218
+ const url = new URL(
219
+ `${apiBase()}/1/companies/${encodeURIComponent(companyId)}/staff-portal-signin-emails/public`,
220
+ );
221
+ // Keep this request "simple" (no custom headers) to avoid CORS preflight latency.
222
+ const res = await fetch(url.toString());
223
+ if (!res.ok) {
224
+ const text = await res.text().catch(() => '');
225
+ throw new Error(`getPublicStaffPortalSignInEmails failed: ${res.status} ${text}`);
226
+ }
227
+ const json = (await res.json()) as {
228
+ data?: { options?: Array<Record<string, unknown>>; emails?: string[] };
229
+ };
230
+ const optionsRaw = json.data?.options;
231
+ if (Array.isArray(optionsRaw) && optionsRaw.length > 0) {
232
+ return optionsRaw
233
+ .map((row) => {
234
+ const staffId = typeof row.staffId === 'string' ? row.staffId.trim() : '';
235
+ const email = typeof row.email === 'string' ? row.email.trim() : '';
236
+ const displayName = typeof row.displayName === 'string' ? row.displayName.trim() : '';
237
+ if (!email) return null;
238
+ return {
239
+ ...(staffId ? { staffId } : {}),
240
+ email,
241
+ displayName: displayName || email,
242
+ };
243
+ })
244
+ .filter((row): row is PublicStaffPortalSignInOption => row != null);
245
+ }
246
+ // Backward compatibility with older backend shape.
247
+ const emails = json.data?.emails;
248
+ if (!Array.isArray(emails)) return [];
249
+ return emails
250
+ .filter((e): e is string => typeof e === 'string' && e.trim().length > 0)
251
+ .map((email) => ({ email: email.trim(), displayName: email.trim() }));
252
+ }
253
+
254
+ /** Legacy helper retained for callers that still only need email values. */
255
+ export async function getPublicStaffPortalSignInEmails(companyId: string): Promise<string[]> {
256
+ const options = await getPublicStaffPortalSignInOptions(companyId);
257
+ return options.map((o) => o.email);
258
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Keep in sync with `src/lib/booking/trace-context.ts` (main site).
3
+ * Same storage keys so portal catalog calls share W3C trace id with booking-api on this origin.
4
+ */
5
+
6
+ import {
7
+ BOOKING_CORRELATION_HEADER,
8
+ getOrCreateBookingCorrelationId,
9
+ } from './correlation-id';
10
+
11
+ /** Lowercase header name per W3C Trace Context. */
12
+ export const BOOKING_TRACEPARENT_HEADER = 'traceparent';
13
+
14
+ const STORAGE_W3C_TRACE_ID = 'tb_booking_w3c_trace_id';
15
+
16
+ function randomHex(byteLength: number): string {
17
+ const buf = new Uint8Array(byteLength);
18
+ crypto.getRandomValues(buf);
19
+ return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join('');
20
+ }
21
+
22
+ function getOrCreateW3CTraceId(): string {
23
+ if (typeof window === 'undefined') {
24
+ return randomHex(16);
25
+ }
26
+ try {
27
+ let tid = sessionStorage.getItem(STORAGE_W3C_TRACE_ID);
28
+ if (!tid || !/^[0-9a-f]{32}$/i.test(tid)) {
29
+ tid = randomHex(16);
30
+ sessionStorage.setItem(STORAGE_W3C_TRACE_ID, tid);
31
+ }
32
+ return tid.toLowerCase();
33
+ } catch {
34
+ return randomHex(16);
35
+ }
36
+ }
37
+
38
+ export function buildTraceparent(): string {
39
+ const traceId = getOrCreateW3CTraceId();
40
+ const spanId = randomHex(8);
41
+ return `00-${traceId}-${spanId}-01`;
42
+ }
43
+
44
+ export function withBookingOutboundHeaders(
45
+ headers: Record<string, string>,
46
+ ): Record<string, string> {
47
+ if (typeof window === 'undefined') return headers;
48
+ const cid = getOrCreateBookingCorrelationId();
49
+ const next = { ...headers };
50
+ if (cid) next[BOOKING_CORRELATION_HEADER] = cid;
51
+ next[BOOKING_TRACEPARENT_HEADER] = buildTraceparent();
52
+ return next;
53
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2017",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "strict": true,
6
+ "noEmit": true,
7
+ "module": "esnext",
8
+ "moduleResolution": "bundler",
9
+ "jsx": "react-jsx",
10
+ "isolatedModules": true,
11
+ "skipLibCheck": true
12
+ },
13
+ "include": ["src/**/*.ts", "src/**/*.tsx"]
14
+ }