@curless/hotel-storefront 0.57.0 → 0.58.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/dist/tools.js CHANGED
@@ -140,7 +140,7 @@ export const homeListing = (t, brand, count) => {
140
140
  };
141
141
  };
142
142
  export const withheldTools = (n, caps) => new Set([
143
- ...(caps.members ? [] : [n.signIn, n.signOut, n.forgetPreferences]),
143
+ ...(caps.members ? [] : [n.signIn, n.signOut, n.forgetPreferences, n.memberCard]),
144
144
  ...(caps.extras ? [] : [n.listExtras]),
145
145
  ...(caps.changes || caps.extras ? [] : [n.previewChange, n.changeBooking]),
146
146
  ...(caps.policies === false ? [n.policies] : []),
@@ -280,7 +280,7 @@ const forCapabilities = (tools, n, v, caps) => {
280
280
  if (t.name === n.signIn)
281
281
  out = {
282
282
  ...out,
283
- description: out.description.replace('and answers with their tier, their point balance', 'and answers with their tier, their point balance, the coupons they hold'),
283
+ description: out.description.replace('it answers with their tier, their point balance', 'it answers with their tier, their point balance, the coupons they hold'),
284
284
  };
285
285
  }
286
286
  // ⚠️ THE PICKED ROOM'S BOOKING TAKES THE SAME GUEST FIELDS as the trip's:
@@ -887,19 +887,43 @@ const allTools = (n, v) => {
887
887
  },
888
888
  {
889
889
  name: n.signIn,
890
- // ⚠️ A MEMBERSHIP NUMBER AND A SURNAME. No password appears in this
891
- // schema because none exists: the connector has nowhere to put one, and
892
- // a description that mentioned passwords would invite a model to ask for
893
- // one in a chat transcript.
894
- description: `Signs a ${v.brand} loyalty member in for this conversation with their membership number and the surname on it, and answers with their tier, their point balance and what the tier takes off the public rate. Room rates and trip totals are then quoted at the member rate until they are signed out.`,
890
+ // ⚠️ NOTHING ABOUT THE MEMBER IS ASKED FOR HERE (0.58.0, robin 11-06: 「我们
891
+ // 自己的登陆也要改成外链」): the membership number and the surname are typed
892
+ // on our page (account/member-login.ts), never in the chat - so no schema
893
+ // here has a field a model could fill with them.
894
+ description: `Starts signing a ${v.brand} loyalty member in for this conversation, on ${v.brand}'s own sign-in page: answers with a one-time link and a short code the page shows too, where the guest types their membership number and the surname on it; the chat does not carry them. Once they have, it answers with their tier, their point balance and what the tier takes off the public rate; room rates and trip totals are then quoted at the member rate until they are signed out.`,
895
895
  annotations: edits(`Sign in to ${v.brand}`, open),
896
896
  inputSchema: {
897
897
  type: 'object',
898
898
  properties: {
899
- number: { type: 'string', description: 'The membership number, as it is printed' },
900
- surname: { type: 'string', description: 'The surname on that membership' },
899
+ lang: {
900
+ type: 'string',
901
+ enum: ['zh', 'en'],
902
+ description: 'The language the page opens in: zh for a guest writing Chinese, en otherwise. The page has a switch.',
903
+ },
901
904
  },
902
- required: ['number', 'surname'],
905
+ },
906
+ },
907
+ {
908
+ name: n.memberCard,
909
+ // The card's own (app-only, server.ts): never offered to the assistant.
910
+ description: `The booking card's own way to start a ${v.brand} membership sign-in, or to see whether its page has been used yet - for the conversation the card was drawn for.`,
911
+ annotations: edits(`${v.brand} membership (card)`, open),
912
+ inputSchema: {
913
+ type: 'object',
914
+ properties: {
915
+ action: {
916
+ type: 'string',
917
+ enum: ['sign_in', 'view'],
918
+ description: 'sign_in: a new one-time sign-in page; view: whether this conversation is signed in yet',
919
+ },
920
+ lang: {
921
+ type: 'string',
922
+ enum: ['zh', 'en'],
923
+ description: 'The language the page opens in',
924
+ },
925
+ },
926
+ required: ['action'],
903
927
  },
904
928
  },
905
929
  {
@@ -1532,13 +1556,35 @@ export const buildOutputSchemas = (n) => ({
1532
1556
  [n.signIn]: {
1533
1557
  type: 'object',
1534
1558
  properties: {
1535
- view: { enum: ['member', 'member-not-found'] },
1559
+ view: { enum: ['member', 'member-sign-in', 'error'] },
1536
1560
  brand: STR,
1537
1561
  toolNames: OBJ,
1538
1562
  bookingMode: STR,
1539
1563
  demo: BOOL,
1540
1564
  demoNote: STR,
1541
1565
  member: OBJ,
1566
+ signedIn: BOOL,
1567
+ url: STR,
1568
+ shortCode: STR,
1569
+ expiresAt: STR,
1570
+ expiresInSeconds: { type: 'number' },
1571
+ code: STR,
1572
+ },
1573
+ required: ['view'],
1574
+ },
1575
+ [n.memberCard]: {
1576
+ type: 'object',
1577
+ properties: {
1578
+ view: { enum: ['member', 'member-sign-in', 'no-member', 'error'] },
1579
+ brand: STR,
1580
+ toolNames: OBJ,
1581
+ bookingMode: STR,
1582
+ member: OBJ,
1583
+ signedIn: BOOL,
1584
+ waiting: BOOL,
1585
+ expiresAt: STR,
1586
+ expiresInSeconds: { type: 'number' },
1587
+ code: STR,
1542
1588
  },
1543
1589
  required: ['view'],
1544
1590
  },
@@ -2329,6 +2375,36 @@ notSent = false) => c.returned
2329
2375
  const POINTS_DO_NOT_PAY = 'Points cannot pay for a room here, not even in part: what a point is worth has not been published, so give no rate for them.';
2330
2376
  /** The rule a coupon is used by, said once where coupons are listed. */
2331
2377
  const COUPON_RULE = 'One coupon per booking, taken off after the member rate. Use one only when the guest names it or agrees to it - using it spends it - by passing its id as `coupon` to the trip view (to price it) and to the booking.';
2378
+ /** Beside a member sign-in's answer where a card is drawn: the link and its short code, the card's alone. */
2379
+ export const MEMBER_SIGN_IN_META_KEY = 'curless/member-sign-in';
2380
+ const MEMBER_NEVER_IN_CHAT = 'Never in this chat: do not ask for, or accept, a membership number or a surname here, and never write back one the guest typed - not even to say which to change.';
2381
+ /**
2382
+ * A signed-in member, as the model reads it (0.58.0): signed in, the tier and
2383
+ * what it takes off, the points and the coupons - what pricing and booking
2384
+ * need. ⚠️ NOT THE NAME OR THE NUMBER: those are the card's, beside the answer
2385
+ * (server.ts `memberApart`) - the model is told who is signed in by what the
2386
+ * membership does, not who it is.
2387
+ */
2388
+ const memberAnswer = (deps, key, member, lead) => ({
2389
+ structured: {
2390
+ view: 'member',
2391
+ ...common(deps, key),
2392
+ member: memberForCard(member, deps.vocab.brand),
2393
+ },
2394
+ text: [
2395
+ `${lead ? `${lead}: ` : ''}signed in as a ${member.programme} ${member.tier.name} member.`,
2396
+ `Rates now show the ${member.tier.name} member rate, ${Math.round(member.tier.discount * 100)}% off the public one.`,
2397
+ ...((deps.backend.capabilities ?? FULL_CAPABILITIES).coupons
2398
+ ? memberSaid(member, deps.vocab.brand)
2399
+ : [POINTS_DO_NOT_PAY]),
2400
+ member.preferences?.length
2401
+ ? `Saved preferences on this membership: ${member.preferences.map((k) => REQUEST_WORDS[k]).join(', ')}. They are ticked on the booking form; mention them, and ask before booking with them.`
2402
+ : '',
2403
+ ]
2404
+ .filter(Boolean)
2405
+ .join('\n')
2406
+ .replace(/^signed/, 'Signed'),
2407
+ });
2332
2408
  /** A signed-in member's coupons and points, for the sign-in answer - see `memberCouponsSaid`. */
2333
2409
  const memberSaid = (m, brand) => [
2334
2410
  `Points: ${m.points.toLocaleString('en-GB')}.${m.pointsExpiring ? ` ${m.pointsExpiring.points.toLocaleString('en-GB')} of them lapse on ${m.pointsExpiring.on}.` : ''} ${POINTS_DO_NOT_PAY}`,
@@ -7072,64 +7148,77 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
7072
7148
  .join('\n'),
7073
7149
  };
7074
7150
  }
7075
- if (name === t.signIn) {
7076
- const number = str(args.number);
7077
- const surname = str(args.surname);
7078
- // Counted like a booking lookup - see limits.ts. A number and a surname
7079
- // read a tier, a point balance and saved preferences.
7080
- const tries = [
7081
- `signin|${key}`,
7082
- `member|${number.replace(/\s+/g, '').toUpperCase()}`,
7083
- // The network too, as for bookings - see `guarded`.
7084
- ...(ctx.ip ? [`ip|${ctx.ip}`] : []),
7085
- ];
7086
- if (deps.misses?.blocked(tries)) {
7151
+ if (name === t.signIn || name === t.memberCard) {
7152
+ const fromCard = name === t.memberCard;
7153
+ // ⚠️ THE CARD'S OWN WHERE A CARD IS DRAWN (app-only, server.ts): a client
7154
+ // that draws none is refused it - the code it hands out is the card's alone.
7155
+ if (fromCard && ctx.noCards)
7156
+ return {
7157
+ structured: { view: 'error', ...common(deps, key), code: 'card_only' },
7158
+ text: `${t.memberCard} is the booking card's own. ${t.signIn} signs a member in from the chat.`,
7159
+ isError: true,
7160
+ };
7161
+ const signedIn = deps.members.read(key);
7162
+ if (signedIn)
7163
+ return memberAnswer(deps, key, signedIn, fromCard ? '' : 'Already signed in');
7164
+ if (fromCard && args.action !== 'sign_in') {
7087
7165
  return {
7088
7166
  structured: {
7089
- view: 'member-not-found',
7167
+ view: 'no-member',
7090
7168
  ...common(deps, key),
7091
- query: number,
7092
- tooMany: true,
7169
+ ...(deps.memberLogin?.waiting(key) ? { waiting: true } : {}),
7093
7170
  },
7094
- text: 'Too many membership numbers and surnames that did not match, so signing in is paused for a quarter of an hour. Check both as they are printed on the card, then try again.',
7171
+ text: `Nobody is signed in as a ${deps.vocab.brand} member in this conversation.`,
7095
7172
  };
7096
7173
  }
7097
- const member = await deps.backend.signInMember(number, surname);
7098
- if (!member)
7099
- deps.misses?.miss(tries);
7100
- if (!member) {
7101
- // ⚠️ NO TIER FOR A NUMBER THAT DID NOT MATCH. Being generous here - "close
7102
- // enough, have the discount" - is inventing a membership, and the guest
7103
- // would find out at the desk.
7174
+ const login = deps.memberLogin;
7175
+ if (!login)
7104
7176
  return {
7105
- structured: { view: 'member-not-found', ...common(deps, key), query: number },
7106
- text: `No ${deps.vocab.brand} membership matches that number and surname. Check both as they are printed; nothing else is needed - a password is never asked for here.`,
7177
+ structured: { view: 'error', ...common(deps, key), code: 'no_sign_in_page' },
7178
+ text: `Signing in as a ${deps.vocab.brand} member is not open here.`,
7179
+ isError: true,
7107
7180
  };
7108
- }
7109
- deps.members.set(key, member);
7181
+ const made = login.start(key, { noCards: ctx.noCards, lang: args.lang, ip: ctx.ip });
7182
+ if (!made.ok)
7183
+ return {
7184
+ structured: { view: 'error', ...common(deps, key), code: made.code },
7185
+ text: made.code === 'too_many_links'
7186
+ ? `Too many sign-in links were asked for in this conversation just now. Wait a few minutes, then ask again. ${MEMBER_NEVER_IN_CHAT}`
7187
+ : 'Too many sign-ins are being started just now. Try again in a minute.',
7188
+ isError: true,
7189
+ };
7190
+ const said = { expiresAt: made.expiresAt, expiresInSeconds: made.expiresInSeconds };
7191
+ if (ctx.noCards)
7192
+ return {
7193
+ structured: {
7194
+ view: 'member-sign-in',
7195
+ ...common(deps, key),
7196
+ signedIn: false,
7197
+ url: made.url,
7198
+ shortCode: made.short,
7199
+ ...said,
7200
+ },
7201
+ text: `A one-time ${deps.vocab.brand} member sign-in page is ready: ${made.url} - the guest opens it in their browser and types their membership number and the surname on it there. ${MEMBER_NEVER_IN_CHAT} The page shows the code ${made.short}: tell the guest it must match the code you give them here, and to close the page if it does not. The link works once, for ${made.minutes} minutes. When they say they are done, call ${t.signIn} again: it then answers with their tier and member rate.`,
7202
+ };
7203
+ // ⚠️ THE LINK AND THE CODE ARE THE CARD'S (as the account's, Nexva's review
7204
+ // P1-1): where a card is drawn the model is never handed the short code, so
7205
+ // text a page slipped into the chat cannot show a matching one.
7110
7206
  return {
7111
- structured: {
7112
- view: 'member',
7113
- ...common(deps, key),
7114
- member: memberForCard(member, deps.vocab.brand),
7207
+ structured: { view: 'member-sign-in', ...common(deps, key), signedIn: false, ...said },
7208
+ text: `A one-time ${deps.vocab.brand} member sign-in page is ready for the guest, opened from the card: they type their membership number and surname on that page. ${MEMBER_NEVER_IN_CHAT} The card shows a code the page shows too, and shows them signed in when they are done; the link works once, for ${made.minutes} minutes.`,
7209
+ meta: {
7210
+ [MEMBER_SIGN_IN_META_KEY]: {
7211
+ url: made.url,
7212
+ shortCode: made.short,
7213
+ expiresAt: made.expiresAt,
7214
+ },
7115
7215
  },
7116
- text: [
7117
- `Signed in as ${member.name}${member.names?.zh ? ` (${member.names.zh})` : ''} · ${member.programme} ${member.tier.name} · ${member.points.toLocaleString('en-GB')} points.`,
7118
- `Rates now show the ${member.tier.name} member rate, ${Math.round(member.tier.discount * 100)}% off the public one.`,
7119
- ...((deps.backend.capabilities ?? FULL_CAPABILITIES).coupons
7120
- ? memberSaid(member, deps.vocab.brand)
7121
- : [POINTS_DO_NOT_PAY]),
7122
- member.preferences?.length
7123
- ? `Saved preferences on this membership: ${member.preferences.map((k) => REQUEST_WORDS[k]).join(', ')}. They are ticked on the booking form; mention them, and ask before booking with them.`
7124
- : '',
7125
- ]
7126
- .filter(Boolean)
7127
- .join('\n'),
7128
7216
  };
7129
7217
  }
7130
7218
  if (name === t.signOut) {
7131
7219
  const was = deps.members.read(key);
7132
7220
  deps.members.clear(key);
7221
+ deps.memberLogin?.cancel(key);
7133
7222
  return {
7134
7223
  structured: { view: 'signed-out', ...common(deps, key) },
7135
7224
  text: was