@peektravel/app-utilities 0.7.3 → 0.8.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/README.md +17 -0
- package/dist/index.cjs +199 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +118 -1
- package/dist/index.d.ts +118 -1
- package/dist/index.js +199 -0
- package/dist/index.js.map +1 -1
- package/dist/ui/index.cjs +11 -0
- package/dist/ui/index.cjs.map +1 -1
- package/dist/ui/index.d.cts +16 -1
- package/dist/ui/index.d.ts +16 -1
- package/dist/ui/index.js +10 -1
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/odyssey.css +30 -0
- package/docs/ui.md +27 -0
- package/llms.txt +22 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -374,6 +374,23 @@ attribute. Exported classes/types and helpers (`iconSvg`, `registerIcon`,
|
|
|
374
374
|
`portal`, `position`, `toast`) are available from `@peektravel/app-utilities/ui`
|
|
375
375
|
for subclassing or typing.
|
|
376
376
|
|
|
377
|
+
### Lazy registration & avoiding a flash of raw text
|
|
378
|
+
|
|
379
|
+
Registration is a side effect and **idempotent** — importing `/ui` twice (or from
|
|
380
|
+
two bundles) never throws. If you register lazily (a dynamic `import()`), two
|
|
381
|
+
things keep un-upgraded elements from flashing as their raw text: `odyssey.css`
|
|
382
|
+
ships a `:not(:defined) { visibility: hidden }` guard (load the CSS eagerly for it
|
|
383
|
+
to apply before first paint), and `whenOdysseyReady()` resolves once every
|
|
384
|
+
imported element has upgraded, so you can gate your first render on it:
|
|
385
|
+
|
|
386
|
+
```ts
|
|
387
|
+
import { whenOdysseyReady } from '@peektravel/app-utilities/ui';
|
|
388
|
+
await whenOdysseyReady();
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
A failed dynamic import still rejects at the `import()` call site — handle load
|
|
392
|
+
failures there.
|
|
393
|
+
|
|
377
394
|
### Using the components from React 19
|
|
378
395
|
|
|
379
396
|
React 19 sets JSX props on a custom element as DOM **properties**
|
package/dist/index.cjs
CHANGED
|
@@ -1061,6 +1061,94 @@ function toBookingAddon(item) {
|
|
|
1061
1061
|
return { addonId, addonName, total: item.total, addonOptions };
|
|
1062
1062
|
}
|
|
1063
1063
|
|
|
1064
|
+
// src/internal/peek/bookings/custom-question-answer.ts
|
|
1065
|
+
var QUESTION_TYPE_CHECK_BOX = "CHECK_BOX";
|
|
1066
|
+
var QUESTION_TYPE_TEXT = "TEXT";
|
|
1067
|
+
var QUESTION_TYPE_SELECT_ONE = "SELECT_ONE";
|
|
1068
|
+
var QUESTION_TYPE_LOCATION = "LOCATION";
|
|
1069
|
+
var QUESTION_ID_REGEX = /^cq_[a-z0-9]+$/;
|
|
1070
|
+
var OPTION_ID_REGEX = /^cqao_[a-z0-9]+$/;
|
|
1071
|
+
var CHECKBOX_TRUE = /* @__PURE__ */ new Set(["yes", "true"]);
|
|
1072
|
+
var CHECKBOX_VALID = /* @__PURE__ */ new Set(["yes", "no", "true", "false"]);
|
|
1073
|
+
var CHECKBOX_TEXT_TRUE = "Yes";
|
|
1074
|
+
var CHECKBOX_TEXT_FALSE = "No";
|
|
1075
|
+
var errUnknownQuestionId = (v) => `No custom question matches id "${v}" for this activity`;
|
|
1076
|
+
var errNoTextMatch = (v) => `No custom question matches text "${v}" for this activity`;
|
|
1077
|
+
var errAmbiguousText = (v) => `Custom question text "${v}" matches more than one question`;
|
|
1078
|
+
var errPerGuestUnsupported = (id) => `Per-guest custom question "${id}" is not yet supported`;
|
|
1079
|
+
var errUnsupportedType = (type, id) => `Custom question "${id}" has unsupported type "${type}"`;
|
|
1080
|
+
var errInvalidCheckbox = (v, id) => `Checkbox answer for question "${id}" must be yes/no/true/false, got "${v}"`;
|
|
1081
|
+
var errUnknownOptionId = (v, id) => `No option matches id "${v}" for question "${id}"`;
|
|
1082
|
+
var errNoOptionMatch = (v, id) => `No option matches "${v}" for question "${id}"`;
|
|
1083
|
+
var errAmbiguousOption = (v, id) => `Option "${v}" matches more than one option for question "${id}"`;
|
|
1084
|
+
function normalize(value) {
|
|
1085
|
+
return value.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
1086
|
+
}
|
|
1087
|
+
function resolveQuestion(idOrText, questions) {
|
|
1088
|
+
if (QUESTION_ID_REGEX.test(idOrText)) {
|
|
1089
|
+
const byId = questions.find((question) => question.id === idOrText);
|
|
1090
|
+
if (!byId) throw new Error(errUnknownQuestionId(idOrText));
|
|
1091
|
+
return byId;
|
|
1092
|
+
}
|
|
1093
|
+
const target = normalize(idOrText);
|
|
1094
|
+
const matches = questions.filter(
|
|
1095
|
+
(question) => normalize(question.questionText) === target
|
|
1096
|
+
);
|
|
1097
|
+
if (matches.length === 0) throw new Error(errNoTextMatch(idOrText));
|
|
1098
|
+
if (matches.length > 1) throw new Error(errAmbiguousText(idOrText));
|
|
1099
|
+
return matches[0];
|
|
1100
|
+
}
|
|
1101
|
+
function resolveOption(value, question) {
|
|
1102
|
+
if (OPTION_ID_REGEX.test(value)) {
|
|
1103
|
+
const byId = question.options.find((option) => option.id === value);
|
|
1104
|
+
if (!byId) throw new Error(errUnknownOptionId(value, question.id));
|
|
1105
|
+
return byId;
|
|
1106
|
+
}
|
|
1107
|
+
const target = normalize(value);
|
|
1108
|
+
const matches = question.options.filter(
|
|
1109
|
+
(option) => normalize(option.value) === target
|
|
1110
|
+
);
|
|
1111
|
+
if (matches.length === 0) throw new Error(errNoOptionMatch(value, question.id));
|
|
1112
|
+
if (matches.length > 1) throw new Error(errAmbiguousOption(value, question.id));
|
|
1113
|
+
return matches[0];
|
|
1114
|
+
}
|
|
1115
|
+
function resolveOne(answer, questions) {
|
|
1116
|
+
const question = resolveQuestion(answer.questionIdOrText, questions);
|
|
1117
|
+
if (question.perGuest) {
|
|
1118
|
+
throw new Error(errPerGuestUnsupported(question.id));
|
|
1119
|
+
}
|
|
1120
|
+
switch (question.questionType) {
|
|
1121
|
+
case QUESTION_TYPE_CHECK_BOX: {
|
|
1122
|
+
const normalized = answer.value.trim().toLowerCase();
|
|
1123
|
+
if (!CHECKBOX_VALID.has(normalized)) {
|
|
1124
|
+
throw new Error(errInvalidCheckbox(answer.value, question.id));
|
|
1125
|
+
}
|
|
1126
|
+
const isChecked = CHECKBOX_TRUE.has(normalized);
|
|
1127
|
+
return {
|
|
1128
|
+
questionId: question.id,
|
|
1129
|
+
isChecked,
|
|
1130
|
+
questionAnswerText: isChecked ? CHECKBOX_TEXT_TRUE : CHECKBOX_TEXT_FALSE
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
case QUESTION_TYPE_TEXT:
|
|
1134
|
+
return { questionId: question.id, questionAnswerText: answer.value };
|
|
1135
|
+
case QUESTION_TYPE_SELECT_ONE:
|
|
1136
|
+
case QUESTION_TYPE_LOCATION: {
|
|
1137
|
+
const option = resolveOption(answer.value, question);
|
|
1138
|
+
return {
|
|
1139
|
+
questionId: question.id,
|
|
1140
|
+
questionAnswerOptionId: option.id,
|
|
1141
|
+
questionAnswerText: option.value
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
default:
|
|
1145
|
+
throw new Error(errUnsupportedType(question.questionType, question.id));
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
function resolveCustomQuestionAnswers(answers, questions) {
|
|
1149
|
+
return answers.map((answer) => resolveOne(answer, questions));
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1064
1152
|
// src/internal/peek/bookings/booking-service.ts
|
|
1065
1153
|
var DEFAULT_PAGE_SIZE2 = 50;
|
|
1066
1154
|
var DEFAULT_CANCEL_NOTE = "Canceled";
|
|
@@ -1580,6 +1668,7 @@ ${note}`;
|
|
|
1580
1668
|
*/
|
|
1581
1669
|
async create(input) {
|
|
1582
1670
|
validateCreateInput(input);
|
|
1671
|
+
const questionAnswers = await this.buildQuestionAnswers(input);
|
|
1583
1672
|
const tickets = input.tickets.flatMap(
|
|
1584
1673
|
(ticket) => Array.from({ length: ticket.quantity }, () => ({
|
|
1585
1674
|
resourceOptionId: ticket.resourceOptionId,
|
|
@@ -1606,6 +1695,9 @@ ${note}`;
|
|
|
1606
1695
|
if (input.operatorNotes) {
|
|
1607
1696
|
bookingQuote.operatorNotes = input.operatorNotes;
|
|
1608
1697
|
}
|
|
1698
|
+
if (questionAnswers) {
|
|
1699
|
+
bookingQuote.questionAnswers = questionAnswers;
|
|
1700
|
+
}
|
|
1609
1701
|
const quoteInput = { bookingQuotes: [bookingQuote] };
|
|
1610
1702
|
if (input.parentOrderId) {
|
|
1611
1703
|
quoteInput.source = { clonedFromId: input.parentOrderId };
|
|
@@ -1645,6 +1737,25 @@ ${note}`;
|
|
|
1645
1737
|
}
|
|
1646
1738
|
return created;
|
|
1647
1739
|
}
|
|
1740
|
+
/**
|
|
1741
|
+
* Resolves the input's custom-question answers into the quote's
|
|
1742
|
+
* `questionAnswers` payload (each tagged with a fresh `refid`), or `null` when
|
|
1743
|
+
* none were supplied. Fetches the activity's custom questions to validate
|
|
1744
|
+
* against; throws on the first unmatched/ambiguous/invalid answer.
|
|
1745
|
+
*/
|
|
1746
|
+
async buildQuestionAnswers(input) {
|
|
1747
|
+
const answers = input.customQuestionAnswers;
|
|
1748
|
+
if (!answers || answers.length === 0) {
|
|
1749
|
+
return null;
|
|
1750
|
+
}
|
|
1751
|
+
const questions = await this.deps.productService.getCustomQuestions(
|
|
1752
|
+
input.activityId
|
|
1753
|
+
);
|
|
1754
|
+
return resolveCustomQuestionAnswers(answers, questions).map((resolved) => ({
|
|
1755
|
+
...resolved,
|
|
1756
|
+
refid: crypto.randomUUID()
|
|
1757
|
+
}));
|
|
1758
|
+
}
|
|
1648
1759
|
async markCreatedBookingPaid(booking, input) {
|
|
1649
1760
|
const parsedPartial = input.markAsPaidAmount ? parseFloat(input.markAsPaidAmount) : NaN;
|
|
1650
1761
|
const amount = !Number.isNaN(parsedPartial) && parsedPartial > 0 ? parsedPartial.toFixed(2) : booking.balanceAmount;
|
|
@@ -2957,6 +3068,33 @@ function fromItemOptionNodes(nodes) {
|
|
|
2957
3068
|
return Array.from(grouped.values());
|
|
2958
3069
|
}
|
|
2959
3070
|
|
|
3071
|
+
// src/internal/peek/products/custom-question-converter.ts
|
|
3072
|
+
function fromQuestionConfigurations(configs) {
|
|
3073
|
+
return configs.map(fromQuestionConfiguration);
|
|
3074
|
+
}
|
|
3075
|
+
function fromQuestionConfiguration(config) {
|
|
3076
|
+
const question = config.question;
|
|
3077
|
+
return {
|
|
3078
|
+
id: question.id,
|
|
3079
|
+
order: config.order ?? 0,
|
|
3080
|
+
isRequired: config.isRequired ?? false,
|
|
3081
|
+
questionText: question.text,
|
|
3082
|
+
hintText: question.hint ?? null,
|
|
3083
|
+
questionType: question.questionType,
|
|
3084
|
+
internalLabel: question.internalLabel ?? null,
|
|
3085
|
+
perGuest: question.perGuest ?? false,
|
|
3086
|
+
defaultValue: question.answerDefaultValue ?? null,
|
|
3087
|
+
options: (question.options ?? []).map(fromQuestionOption)
|
|
3088
|
+
};
|
|
3089
|
+
}
|
|
3090
|
+
function fromQuestionOption(option) {
|
|
3091
|
+
return {
|
|
3092
|
+
id: option.id,
|
|
3093
|
+
order: option.order,
|
|
3094
|
+
value: option.text
|
|
3095
|
+
};
|
|
3096
|
+
}
|
|
3097
|
+
|
|
2960
3098
|
// src/internal/peek/products/product-queries.ts
|
|
2961
3099
|
var PRODUCTS_QUERY = `
|
|
2962
3100
|
query Sales {
|
|
@@ -3015,7 +3153,38 @@ var ITEM_OPTIONS_QUERY = `
|
|
|
3015
3153
|
}
|
|
3016
3154
|
`;
|
|
3017
3155
|
|
|
3156
|
+
// src/internal/peek/products/custom-question-queries.ts
|
|
3157
|
+
var CUSTOM_QUESTIONS_QUERY = `
|
|
3158
|
+
query Sales($id: ID!) {
|
|
3159
|
+
activity(id: $id) {
|
|
3160
|
+
id
|
|
3161
|
+
name
|
|
3162
|
+
questionActivityConfigurations {
|
|
3163
|
+
isRequired
|
|
3164
|
+
order
|
|
3165
|
+
question {
|
|
3166
|
+
id
|
|
3167
|
+
questionType
|
|
3168
|
+
text
|
|
3169
|
+
answerDefaultValue
|
|
3170
|
+
hint
|
|
3171
|
+
internalLabel
|
|
3172
|
+
perGuest
|
|
3173
|
+
... on ChoiceQuestion {
|
|
3174
|
+
options {
|
|
3175
|
+
id
|
|
3176
|
+
text
|
|
3177
|
+
order
|
|
3178
|
+
}
|
|
3179
|
+
}
|
|
3180
|
+
}
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
}
|
|
3184
|
+
`;
|
|
3185
|
+
|
|
3018
3186
|
// src/internal/peek/products/product-service.ts
|
|
3187
|
+
var ERROR_PRODUCT_ID_REQUIRED2 = "A non-empty product id is required.";
|
|
3019
3188
|
var DEFAULT_ITEM_OPTIONS_PAGE_SIZE = 50;
|
|
3020
3189
|
var ProductService = class {
|
|
3021
3190
|
constructor(client, options = {}) {
|
|
@@ -3060,6 +3229,32 @@ var ProductService = class {
|
|
|
3060
3229
|
const nodes = await this.fetchAllItemOptionNodes();
|
|
3061
3230
|
return fromItemOptionNodes(nodes);
|
|
3062
3231
|
}
|
|
3232
|
+
/**
|
|
3233
|
+
* Returns every custom question configured on a single activity, in the order
|
|
3234
|
+
* Peek reports them. Choice-style questions (`SELECT_ONE`, `LOCATION`, …)
|
|
3235
|
+
* carry their selectable {@link CustomQuestion.options}; free-text/checkbox
|
|
3236
|
+
* questions return an empty option list.
|
|
3237
|
+
*
|
|
3238
|
+
* These are question **definitions**, not customer answers — no PII is
|
|
3239
|
+
* involved, so the result is unaffected by `fullCustomerAccess`.
|
|
3240
|
+
*
|
|
3241
|
+
* @param productId The activity's id.
|
|
3242
|
+
* @returns The activity's custom questions, or an empty list when the activity
|
|
3243
|
+
* has none or is not found.
|
|
3244
|
+
* @throws Error when `productId` is missing or blank.
|
|
3245
|
+
*/
|
|
3246
|
+
async getCustomQuestions(productId) {
|
|
3247
|
+
if (!productId?.trim()) {
|
|
3248
|
+
throw new Error(ERROR_PRODUCT_ID_REQUIRED2);
|
|
3249
|
+
}
|
|
3250
|
+
const body = await this.client.request(
|
|
3251
|
+
SALES_ENDPOINT,
|
|
3252
|
+
CUSTOM_QUESTIONS_QUERY,
|
|
3253
|
+
{ id: productId }
|
|
3254
|
+
);
|
|
3255
|
+
const configs = body.data?.activity?.questionActivityConfigurations ?? [];
|
|
3256
|
+
return fromQuestionConfigurations(configs);
|
|
3257
|
+
}
|
|
3063
3258
|
async fetchActivities() {
|
|
3064
3259
|
const body = await this.client.request(
|
|
3065
3260
|
SALES_ENDPOINT,
|
|
@@ -3773,6 +3968,10 @@ var PeekAccessService = class {
|
|
|
3773
3968
|
getAllAddons() {
|
|
3774
3969
|
return this.getProductService().getAllAddons();
|
|
3775
3970
|
}
|
|
3971
|
+
/** An activity's custom questions. Delegates to {@link ProductService.getCustomQuestions}. */
|
|
3972
|
+
getCustomQuestions(productId) {
|
|
3973
|
+
return this.getProductService().getCustomQuestions(productId);
|
|
3974
|
+
}
|
|
3776
3975
|
// ─── Account-user short-forms ─────────────────────────────────────────────
|
|
3777
3976
|
/** All active account users. Delegates to {@link AccountUserService.getAll}. */
|
|
3778
3977
|
getAllAccountUsers() {
|