@curless/shopify-storefront 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/dist/__tests__/card-contract.test.d.ts +2 -0
- package/dist/__tests__/card-contract.test.d.ts.map +1 -0
- package/dist/__tests__/card-contract.test.js +22 -0
- package/dist/__tests__/card-contract.test.js.map +1 -0
- package/dist/__tests__/catalog-registry.test.d.ts +2 -0
- package/dist/__tests__/catalog-registry.test.d.ts.map +1 -0
- package/dist/__tests__/catalog-registry.test.js +109 -0
- package/dist/__tests__/catalog-registry.test.js.map +1 -0
- package/dist/__tests__/challenge.test.d.ts +2 -0
- package/dist/__tests__/challenge.test.d.ts.map +1 -0
- package/dist/__tests__/challenge.test.js +46 -0
- package/dist/__tests__/challenge.test.js.map +1 -0
- package/dist/__tests__/config.test.d.ts +2 -0
- package/dist/__tests__/config.test.d.ts.map +1 -0
- package/dist/__tests__/config.test.js +123 -0
- package/dist/__tests__/config.test.js.map +1 -0
- package/dist/__tests__/connection-state.test.d.ts +2 -0
- package/dist/__tests__/connection-state.test.d.ts.map +1 -0
- package/dist/__tests__/connection-state.test.js +40 -0
- package/dist/__tests__/connection-state.test.js.map +1 -0
- package/dist/__tests__/multi-token-store.test.d.ts +2 -0
- package/dist/__tests__/multi-token-store.test.d.ts.map +1 -0
- package/dist/__tests__/multi-token-store.test.js +89 -0
- package/dist/__tests__/multi-token-store.test.js.map +1 -0
- package/dist/__tests__/oauth.test.d.ts +2 -0
- package/dist/__tests__/oauth.test.d.ts.map +1 -0
- package/dist/__tests__/oauth.test.js +112 -0
- package/dist/__tests__/oauth.test.js.map +1 -0
- package/dist/__tests__/output-schema.test.d.ts +2 -0
- package/dist/__tests__/output-schema.test.d.ts.map +1 -0
- package/dist/__tests__/output-schema.test.js +112 -0
- package/dist/__tests__/output-schema.test.js.map +1 -0
- package/dist/__tests__/ping.test.d.ts +2 -0
- package/dist/__tests__/ping.test.d.ts.map +1 -0
- package/dist/__tests__/ping.test.js +22 -0
- package/dist/__tests__/ping.test.js.map +1 -0
- package/dist/__tests__/rate-limit.test.d.ts +2 -0
- package/dist/__tests__/rate-limit.test.d.ts.map +1 -0
- package/dist/__tests__/rate-limit.test.js +46 -0
- package/dist/__tests__/rate-limit.test.js.map +1 -0
- package/dist/__tests__/stock.test.d.ts +2 -0
- package/dist/__tests__/stock.test.d.ts.map +1 -0
- package/dist/__tests__/stock.test.js +109 -0
- package/dist/__tests__/stock.test.js.map +1 -0
- package/dist/__tests__/tenant.test.d.ts +2 -0
- package/dist/__tests__/tenant.test.d.ts.map +1 -0
- package/dist/__tests__/tenant.test.js +105 -0
- package/dist/__tests__/tenant.test.js.map +1 -0
- package/dist/__tests__/tools.test.d.ts +2 -0
- package/dist/__tests__/tools.test.d.ts.map +1 -0
- package/dist/__tests__/tools.test.js +543 -0
- package/dist/__tests__/tools.test.js.map +1 -0
- package/dist/__tests__/upstream-cap.test.d.ts +2 -0
- package/dist/__tests__/upstream-cap.test.d.ts.map +1 -0
- package/dist/__tests__/upstream-cap.test.js +78 -0
- package/dist/__tests__/upstream-cap.test.js.map +1 -0
- package/dist/__tests__/webhooks.test.d.ts +2 -0
- package/dist/__tests__/webhooks.test.d.ts.map +1 -0
- package/dist/__tests__/webhooks.test.js +88 -0
- package/dist/__tests__/webhooks.test.js.map +1 -0
- package/dist/card.generated.d.ts +2 -0
- package/dist/card.generated.d.ts.map +1 -0
- package/dist/card.generated.js +6 -0
- package/dist/card.generated.js.map +1 -0
- package/dist/catalog-registry.d.ts +44 -0
- package/dist/catalog-registry.d.ts.map +1 -0
- package/dist/catalog-registry.js +105 -0
- package/dist/catalog-registry.js.map +1 -0
- package/dist/catalog.d.ts +51 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +189 -0
- package/dist/catalog.js.map +1 -0
- package/dist/challenge.d.ts +11 -0
- package/dist/challenge.d.ts.map +1 -0
- package/dist/challenge.js +36 -0
- package/dist/challenge.js.map +1 -0
- package/dist/config.d.ts +141 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +65 -0
- package/dist/config.js.map +1 -0
- package/dist/connection.d.ts +10 -0
- package/dist/connection.d.ts.map +1 -0
- package/dist/connection.js +21 -0
- package/dist/connection.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +974 -0
- package/dist/index.js.map +1 -0
- package/dist/legal.d.ts +52 -0
- package/dist/legal.d.ts.map +1 -0
- package/dist/legal.js +139 -0
- package/dist/legal.js.map +1 -0
- package/dist/load-config.d.ts +11 -0
- package/dist/load-config.d.ts.map +1 -0
- package/dist/load-config.js +23 -0
- package/dist/load-config.js.map +1 -0
- package/dist/multi-token-store.d.ts +18 -0
- package/dist/multi-token-store.d.ts.map +1 -0
- package/dist/multi-token-store.js +95 -0
- package/dist/multi-token-store.js.map +1 -0
- package/dist/oauth.d.ts +20 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +123 -0
- package/dist/oauth.js.map +1 -0
- package/dist/rate-limit.d.ts +15 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +56 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/stock.d.ts +39 -0
- package/dist/stock.d.ts.map +1 -0
- package/dist/stock.js +77 -0
- package/dist/stock.js.map +1 -0
- package/dist/tenant.d.ts +69 -0
- package/dist/tenant.d.ts.map +1 -0
- package/dist/tenant.js +68 -0
- package/dist/tenant.js.map +1 -0
- package/dist/token-store.d.ts +16 -0
- package/dist/token-store.d.ts.map +1 -0
- package/dist/token-store.js +47 -0
- package/dist/token-store.js.map +1 -0
- package/dist/tools.d.ts +201 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +779 -0
- package/dist/tools.js.map +1 -0
- package/dist/webhooks.d.ts +30 -0
- package/dist/webhooks.d.ts.map +1 -0
- package/dist/webhooks.js +69 -0
- package/dist/webhooks.js.map +1 -0
- package/package.json +53 -0
package/dist/tools.js
ADDED
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
import { cartPermalink, lookupShopifyOrder, resolveVariant, snapChoices, } from '@curless/shopify-adapter';
|
|
2
|
+
// OpenAI's Apps review requires all three hints on every tool. All four read
|
|
3
|
+
// the merchant's own store and change nothing, in it or anywhere else.
|
|
4
|
+
//
|
|
5
|
+
// Anthropic's checklist additionally requires a `title` on every tool, and it is
|
|
6
|
+
// not decoration: the hints decide auto-permissions, and the title is the name a
|
|
7
|
+
// buyer reads in the permission line. `${deps.tools.checkout}` is the reason to
|
|
8
|
+
// care — a tool whose NAME says "create" is asked about in the buyer's language,
|
|
9
|
+
// and "Get a Moody checkout link" is what it actually does.
|
|
10
|
+
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, openWorldHint: false };
|
|
11
|
+
const reads = (title) => ({ ...READ_ONLY, title });
|
|
12
|
+
/**
|
|
13
|
+
* The tool table, built for THIS storefront's names.
|
|
14
|
+
*
|
|
15
|
+
* A constant until there was a second merchant. A model choosing among
|
|
16
|
+
* several storefront connectors picks by tool name, so the names carry the
|
|
17
|
+
* merchant — and that means they cannot be literals here.
|
|
18
|
+
*/
|
|
19
|
+
export const buildTools = (n, v) => [
|
|
20
|
+
{
|
|
21
|
+
name: n.list,
|
|
22
|
+
// The first clause says what this READS, not what to use it instead of.
|
|
23
|
+
//
|
|
24
|
+
// It said "use this INSTEAD of web search" for a day, written after watching
|
|
25
|
+
// an assistant answer "Moody brown dailies" from the scraped storefront —
|
|
26
|
+
// £14.84 in one paragraph and "$19.99–$24.99 USD" two below it, nothing
|
|
27
|
+
// about stock, not one orderable prescription. But Anthropic's criteria
|
|
28
|
+
// reject descriptions that "interfere with Claude calling other tools", and
|
|
29
|
+
// that sentence is exactly the shape they name.
|
|
30
|
+
//
|
|
31
|
+
// Nothing informative was lost. What could ever have moved the decision is
|
|
32
|
+
// the capability — live catalogue, real stock, prescriptions that can be
|
|
33
|
+
// ordered — and that is a fact about this tool. The imperative was the part
|
|
34
|
+
// arguing with the other tools, and arguing was never what worked.
|
|
35
|
+
description: `Open the ${v.merchant} storefront — the live catalogue of the shop itself: the price it charges today, what it can actually ship, and the combinations it will accept, rendered as a card the buyer can buy from. Call this FIRST, with NO arguments, as soon as the buyer wants ${v.nounPlural} — it renders a card with pictures, filter chips and a Buy button on every ${v.noun}, so they can browse and choose without being asked anything. Do not interview them first and do not recommend on top of it. Pass \`query\` only once they have said what they want.${v.hints.browse ? ` ${v.hints.browse}` : ''}`,
|
|
36
|
+
annotations: reads(`Browse ${v.merchant} ${v.nounPlural}`),
|
|
37
|
+
inputSchema: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
properties: {
|
|
40
|
+
query: {
|
|
41
|
+
type: 'string',
|
|
42
|
+
description: 'Free text over the name, tags and description — this is how colour is searched ("green", "brown", "matcha")',
|
|
43
|
+
},
|
|
44
|
+
maxPrice: { type: 'number', description: 'Upper bound in whole currency units' },
|
|
45
|
+
limit: { type: 'number', description: 'Default 20' },
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: n.get,
|
|
51
|
+
description: `One ${v.noun} in full: its options, every value offered, prices and images. Check this BEFORE checkout — how many choices the buyer must make is set by the product, not fixed, so read them here rather than assuming.${v.hints.product ? ` ${v.hints.product}` : ''}`,
|
|
52
|
+
annotations: reads(`View one ${v.merchant} ${v.noun}`),
|
|
53
|
+
inputSchema: {
|
|
54
|
+
type: 'object',
|
|
55
|
+
properties: {
|
|
56
|
+
product: {
|
|
57
|
+
type: 'string',
|
|
58
|
+
description: `The ${v.noun} name or handle${v.hints.example ? `, e.g. "${v.hints.example}"` : ''}`,
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
required: ['product'],
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: n.checkout,
|
|
66
|
+
description: `Get the checkout link for ONE specific ${v.noun} variant. Fill EVERY option the product has — if it offers two, both are required, and they are usually different. Never assume one answer carries to another option, and never round to a nearby value: ask. If the choices do not name exactly one variant this returns the options to ask about instead of guessing.${v.hints.checkout ? ` ${v.hints.checkout}` : ''}`,
|
|
67
|
+
annotations: reads(`Get a ${v.merchant} checkout link`),
|
|
68
|
+
inputSchema: {
|
|
69
|
+
type: 'object',
|
|
70
|
+
properties: {
|
|
71
|
+
product: { type: 'string', description: `The ${v.noun} name or handle` },
|
|
72
|
+
options: {
|
|
73
|
+
type: 'object',
|
|
74
|
+
// The SHAPE is generic; the example is not, so it comes from the
|
|
75
|
+
// merchant. A pet shop reading {"Left":"-2.00"} learns nothing except
|
|
76
|
+
// that this tool was written for someone else.
|
|
77
|
+
description: `One value per option, keyed by the store's own option names.${v.hints.checkout ? ` ${v.hints.checkout}` : ''}`,
|
|
78
|
+
additionalProperties: { type: 'string' },
|
|
79
|
+
},
|
|
80
|
+
quantity: { type: 'number', description: 'Default 1' },
|
|
81
|
+
},
|
|
82
|
+
required: ['product', 'options'],
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: n.lookupOrder,
|
|
87
|
+
description: 'Find an order the buyer already placed. BOTH the order number and the email it was placed with are required — that pair is what proves the order is theirs.',
|
|
88
|
+
annotations: reads(`Look up a ${v.merchant} order`),
|
|
89
|
+
inputSchema: {
|
|
90
|
+
type: 'object',
|
|
91
|
+
properties: {
|
|
92
|
+
orderNumber: { type: 'string', description: 'From the confirmation email, e.g. "#1052"' },
|
|
93
|
+
email: { type: 'string', description: 'The email the order was placed with' },
|
|
94
|
+
},
|
|
95
|
+
required: ['orderNumber', 'email'],
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
// The shape of each tool's `structuredContent`, so the model knows what it is
|
|
100
|
+
// holding without inferring it from one example.
|
|
101
|
+
//
|
|
102
|
+
// EVERY tool can return more than one shape — `${deps.tools.checkout}` answers
|
|
103
|
+
// `checkout`, `needs-options`, `out-of-stock` or `error` depending on what the
|
|
104
|
+
// buyer gave it — so `view` is the discriminator and is always present. That is
|
|
105
|
+
// the part a reader must be able to rely on, so it is the part declared exactly.
|
|
106
|
+
//
|
|
107
|
+
// Item internals are left open on purpose. A schema that promises fields the
|
|
108
|
+
// payload sometimes omits is worse than no schema: it is a claim that is
|
|
109
|
+
// sometimes false, and the reader trusting it is the model deciding what to say
|
|
110
|
+
// to a buyer.
|
|
111
|
+
const OBJ = { type: 'object' };
|
|
112
|
+
const ARR = { type: 'array', items: { type: 'object' } };
|
|
113
|
+
const STR = { type: 'string' };
|
|
114
|
+
export const buildOutputSchemas = (n) => ({
|
|
115
|
+
[n.list]: {
|
|
116
|
+
type: 'object',
|
|
117
|
+
properties: {
|
|
118
|
+
view: { const: 'products' },
|
|
119
|
+
merchant: STR,
|
|
120
|
+
logoUrl: STR,
|
|
121
|
+
toolNames: OBJ,
|
|
122
|
+
vocab: OBJ,
|
|
123
|
+
products: ARR,
|
|
124
|
+
total: { type: 'number' },
|
|
125
|
+
facets: ARR,
|
|
126
|
+
promotions: ARR,
|
|
127
|
+
// Set when the answer is broader than what was asked — the assistant
|
|
128
|
+
// should not read a relaxed result as an exact match.
|
|
129
|
+
relaxedFrom: STR,
|
|
130
|
+
},
|
|
131
|
+
required: ['view', 'products'],
|
|
132
|
+
},
|
|
133
|
+
[n.get]: {
|
|
134
|
+
type: 'object',
|
|
135
|
+
properties: {
|
|
136
|
+
view: { enum: ['product', 'error'] },
|
|
137
|
+
merchant: STR,
|
|
138
|
+
logoUrl: STR,
|
|
139
|
+
toolNames: OBJ,
|
|
140
|
+
vocab: OBJ,
|
|
141
|
+
// `options` is what the buyer must still answer; `inStock` is which
|
|
142
|
+
// combinations of those can actually be bought.
|
|
143
|
+
product: OBJ,
|
|
144
|
+
error: STR,
|
|
145
|
+
},
|
|
146
|
+
required: ['view'],
|
|
147
|
+
},
|
|
148
|
+
[n.checkout]: {
|
|
149
|
+
type: 'object',
|
|
150
|
+
properties: {
|
|
151
|
+
view: { enum: ['checkout', 'needs-options', 'out-of-stock', 'error'] },
|
|
152
|
+
merchant: STR,
|
|
153
|
+
logoUrl: STR,
|
|
154
|
+
toolNames: OBJ,
|
|
155
|
+
vocab: OBJ,
|
|
156
|
+
product: OBJ,
|
|
157
|
+
// view=checkout
|
|
158
|
+
variant: OBJ,
|
|
159
|
+
quantity: { type: 'number' },
|
|
160
|
+
total: STR,
|
|
161
|
+
checkoutUrl: STR,
|
|
162
|
+
// view=needs-options — what is still missing, and what was understood so
|
|
163
|
+
// far, already snapped to the store's own values.
|
|
164
|
+
options: ARR,
|
|
165
|
+
given: OBJ,
|
|
166
|
+
eyes: { enum: ['pair', 'single', 'unknown'] },
|
|
167
|
+
inStock: ARR,
|
|
168
|
+
error: STR,
|
|
169
|
+
},
|
|
170
|
+
required: ['view'],
|
|
171
|
+
},
|
|
172
|
+
[n.lookupOrder]: {
|
|
173
|
+
type: 'object',
|
|
174
|
+
properties: {
|
|
175
|
+
// `order-not-found` is returned both for no such order AND for a wrong
|
|
176
|
+
// email — deliberately indistinguishable.
|
|
177
|
+
view: { enum: ['order', 'order-not-found', 'rate-limited'] },
|
|
178
|
+
merchant: STR,
|
|
179
|
+
logoUrl: STR,
|
|
180
|
+
toolNames: OBJ,
|
|
181
|
+
vocab: OBJ,
|
|
182
|
+
order: OBJ,
|
|
183
|
+
},
|
|
184
|
+
required: ['view'],
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
// Colour words → what this store actually calls its lenses. The colour IS the
|
|
188
|
+
// product name here ("Matcha Green", "Oolong Brown"), so a buyer asking for a
|
|
189
|
+
// family — in either language — matches nothing without this. Not a translation
|
|
190
|
+
// table: only the words a lens shopper reaches for.
|
|
191
|
+
//
|
|
192
|
+
// Keyed on the BARE colour. A picker offering "灰/蓝色系" sends 蓝色系, and
|
|
193
|
+
// enumerating every suffix by hand is how 绿色系 and 棕色系 ended up mapped while
|
|
194
|
+
// 蓝色系 and 紫色系 silently fell through to the whole 382-product shelf.
|
|
195
|
+
const COLOUR_WORDS = {
|
|
196
|
+
棕: ['brown', 'choco', 'coffee', 'oolong', 'walnut', 'caramel', 'hazel', 'barley'],
|
|
197
|
+
咖啡: ['brown', 'coffee', 'choco'],
|
|
198
|
+
brown: ['choco', 'coffee', 'oolong', 'walnut', 'caramel', 'hazel'],
|
|
199
|
+
绿: ['green', 'matcha', 'olive', 'jade', 'mint', 'sage'],
|
|
200
|
+
green: ['matcha', 'olive', 'jade', 'mint', 'sage'],
|
|
201
|
+
灰: ['grey', 'gray', 'smoke', 'ash', 'silver', 'granite'],
|
|
202
|
+
grey: ['gray', 'smoke', 'ash', 'silver'],
|
|
203
|
+
gray: ['grey', 'smoke', 'ash', 'silver'],
|
|
204
|
+
蓝: ['blue', 'ocean', 'sky', 'aqua', 'denim'],
|
|
205
|
+
blue: ['ocean', 'sky', 'aqua', 'denim'],
|
|
206
|
+
紫: ['purple', 'violet', 'lilac', 'lavender', 'taro'],
|
|
207
|
+
purple: ['violet', 'lilac', 'lavender', 'taro'],
|
|
208
|
+
粉: ['pink', 'rose', 'peach', 'berry', 'sakura', 'coral'],
|
|
209
|
+
pink: ['rose', 'peach', 'berry', 'sakura', 'coral'],
|
|
210
|
+
黑: ['black', 'noir', 'onyx', 'halo'],
|
|
211
|
+
black: ['noir', 'onyx'],
|
|
212
|
+
日抛: ['1 day'],
|
|
213
|
+
月抛: ['1 month'],
|
|
214
|
+
半年抛: ['6 months'],
|
|
215
|
+
自然: ['natural', 'nude', 'daily'],
|
|
216
|
+
};
|
|
217
|
+
// "蓝色系" / "蓝色" / "蓝" are one key. Trimmed rather than enumerated, so a
|
|
218
|
+
// phrasing nobody thought of still lands on the colour.
|
|
219
|
+
const colourKey = (w) => w.replace(/(色系|色调|系|色)$/u, '') || w;
|
|
220
|
+
// The filter chips, taken from the merchant's OWN taxonomy.
|
|
221
|
+
//
|
|
222
|
+
// Moody's site files a lens under By Style / By Color / By G.Diameter /
|
|
223
|
+
// Replacement cycle, and their tags land on exactly those axes: Brown(26),
|
|
224
|
+
// Grey(7); With Limbal ring(49), Glowy Highlight(28); 1 Day(36), 6 Months(3).
|
|
225
|
+
// A flat row of the six commonest tags — which is what this was — mixes a colour
|
|
226
|
+
// with a product line and a lens finish, and asks the buyer to spot the
|
|
227
|
+
// difference. Grouped, each row answers one question.
|
|
228
|
+
//
|
|
229
|
+
// Two corrections their own site made obvious:
|
|
230
|
+
//
|
|
231
|
+
// • DIAMETER IS A FILTER, and I had been throwing it away. The tags are bare
|
|
232
|
+
// numbers (14.2, 13.5), which the old rule called noise along with base
|
|
233
|
+
// curve and water content — but Moody offers G.Diameter as a top-level
|
|
234
|
+
// choice, bucketed. Bucketed here too, at their thresholds.
|
|
235
|
+
// • Base curve (8.6) and water content (55%) really are specs, not filters.
|
|
236
|
+
// They appear on their spec sheets and in none of their filter menus.
|
|
237
|
+
const DIAMETER_BUCKETS = [
|
|
238
|
+
{ label: 'Large ≥13.7mm', min: 13.7, max: 99 },
|
|
239
|
+
{ label: 'Medium 13.4–13.6mm', min: 13.4, max: 13.69 },
|
|
240
|
+
{ label: 'Small ≤13.3mm', min: 0, max: 13.39 },
|
|
241
|
+
];
|
|
242
|
+
// A lens diameter, or null. Base curve (8.x) and water content (n%) are excluded
|
|
243
|
+
// by range and by shape — 8.6 is not a small lens, it is a curve.
|
|
244
|
+
const asDiameter = (tag) => {
|
|
245
|
+
if (!/^\d{2}(\.\d)?$/.test(tag))
|
|
246
|
+
return null;
|
|
247
|
+
const n = Number(tag);
|
|
248
|
+
return n >= 12 && n <= 16 ? n : null;
|
|
249
|
+
};
|
|
250
|
+
const AXES = [
|
|
251
|
+
{ axis: 'Colour', match: /^(brown|green|blue|black|gr[ae]y|pink|red|purple|gold[\s\w]*)$/i },
|
|
252
|
+
{
|
|
253
|
+
axis: 'Style',
|
|
254
|
+
match: /^(with limbal ring|without limbal ring|glowy highlight|iris pattern|bold|neutral)$/i,
|
|
255
|
+
},
|
|
256
|
+
{ axis: 'Cycle', match: /^(1 day|1 month|6 months|1 year|trials?)$/i },
|
|
257
|
+
];
|
|
258
|
+
// An axis is worth offering when its choices tell results apart. Judged on the
|
|
259
|
+
// AXIS, never on the individual value — dropping a value because everything has
|
|
260
|
+
// it deletes a true option from a row of alternatives, and the buyer reads the
|
|
261
|
+
// absence as an answer.
|
|
262
|
+
//
|
|
263
|
+
// That is not hypothetical: a lens is tagged with every diameter it comes in, so
|
|
264
|
+
// all 40 results offered a large size, and the value-level rule silently removed
|
|
265
|
+
// "Large ≥13.7mm" while keeping Medium and Small. The row then said this store
|
|
266
|
+
// has no large lenses — about the diameters it stocks most.
|
|
267
|
+
const worthOffering = (values, total) => values.length > 1 && values.some((v) => v.count < total);
|
|
268
|
+
const facetsOf = (products) => {
|
|
269
|
+
if (products.length < 3)
|
|
270
|
+
return [];
|
|
271
|
+
const covers = (tags) => products.filter((p) => (p.tags ?? []).some((t) => tags.includes(t))).length;
|
|
272
|
+
const out = [];
|
|
273
|
+
for (const { axis, match } of AXES) {
|
|
274
|
+
const tags = new Set();
|
|
275
|
+
for (const p of products)
|
|
276
|
+
for (const t of p.tags ?? [])
|
|
277
|
+
if (match.test(t))
|
|
278
|
+
tags.add(t);
|
|
279
|
+
const values = [...tags]
|
|
280
|
+
.map((t) => ({ label: t, tags: [t], count: covers([t]) }))
|
|
281
|
+
.filter((v) => v.count > 0)
|
|
282
|
+
.sort((a, b) => b.count - a.count);
|
|
283
|
+
if (worthOffering(values, products.length))
|
|
284
|
+
out.push({ axis, values });
|
|
285
|
+
}
|
|
286
|
+
// Diameter, bucketed at Moody's own thresholds rather than offering twelve
|
|
287
|
+
// bare numbers.
|
|
288
|
+
const diameters = new Set();
|
|
289
|
+
for (const p of products)
|
|
290
|
+
for (const t of p.tags ?? [])
|
|
291
|
+
if (asDiameter(t) !== null)
|
|
292
|
+
diameters.add(t);
|
|
293
|
+
if (diameters.size > 1) {
|
|
294
|
+
const values = DIAMETER_BUCKETS.map(({ label, min, max }) => {
|
|
295
|
+
const tags = [...diameters].filter((t) => {
|
|
296
|
+
const n = asDiameter(t);
|
|
297
|
+
return n >= min && n <= max;
|
|
298
|
+
});
|
|
299
|
+
return { label, tags, count: covers(tags) };
|
|
300
|
+
}).filter((v) => v.count > 0);
|
|
301
|
+
if (worthOffering(values, products.length))
|
|
302
|
+
out.push({ axis: 'Diameter', values });
|
|
303
|
+
}
|
|
304
|
+
return out;
|
|
305
|
+
};
|
|
306
|
+
// The combinations a buyer can actually buy, as option maps — the one thing the
|
|
307
|
+
// option lists cannot express. "Left -5.75 is offered" and "Right -5.50 is
|
|
308
|
+
// offered" says nothing about whether that PAIR exists, and at this store 674 of
|
|
309
|
+
// the 676 pairs do not.
|
|
310
|
+
const inStockCombos = (variants) => {
|
|
311
|
+
const stocked = variants.filter((v) => v.available);
|
|
312
|
+
return {
|
|
313
|
+
inStock: stocked
|
|
314
|
+
.slice(0, IN_STOCK_CAP)
|
|
315
|
+
.map((v) => Object.fromEntries(v.options.map((o) => [o.name, o.value]))),
|
|
316
|
+
// Tells the picker to stop filtering rather than filter on a truncated list,
|
|
317
|
+
// which would hide buyable prescriptions.
|
|
318
|
+
inStockTruncated: stocked.length > IN_STOCK_CAP,
|
|
319
|
+
};
|
|
320
|
+
};
|
|
321
|
+
// How many in-stock combinations a product payload carries. Generous, because
|
|
322
|
+
// the list is what stops the picker offering prescriptions nobody can buy, and
|
|
323
|
+
// stingy enough that a fully stocked pair (676) cannot flood a tool result the
|
|
324
|
+
// model also has to read.
|
|
325
|
+
const IN_STOCK_CAP = 300;
|
|
326
|
+
// The shop's offers, as the card and the assistant both need them: the
|
|
327
|
+
// merchant's own wording, which is usually the whole story — they write "Buy 2
|
|
328
|
+
// get 5% OFF | Buy 3 get 10% OFF" precisely because that is what a shopper reads.
|
|
329
|
+
//
|
|
330
|
+
// `promotionsBlocked` says WHY there are none when the reason is not "there are
|
|
331
|
+
// none". Those need a scope granted at install, so the honest answer to a
|
|
332
|
+
// merchant asking why their sale is invisible is "reinstall the app", and that
|
|
333
|
+
// answer only exists if we keep the distinction.
|
|
334
|
+
const promoPayload = (deps) => {
|
|
335
|
+
const snap = deps.catalog.current;
|
|
336
|
+
const promotions = snap.discounts
|
|
337
|
+
.map((d) => ({
|
|
338
|
+
title: d.title,
|
|
339
|
+
...(d.percentage !== undefined ? { percentOff: Math.round(d.percentage * 100) } : {}),
|
|
340
|
+
...(d.minQuantity !== undefined ? { minQuantity: d.minQuantity } : {}),
|
|
341
|
+
...(d.endsAt ? { endsAt: d.endsAt } : {}),
|
|
342
|
+
}))
|
|
343
|
+
// Only offers a shopper can act on.
|
|
344
|
+
//
|
|
345
|
+
// These are AUTOMATIC discounts — they apply at checkout with no code to
|
|
346
|
+
// type — so the title is all a buyer gets. Some merchants write that title
|
|
347
|
+
// for shoppers ("Buy 2 get 5% OFF"); this one named them for the back
|
|
348
|
+
// office: Student, ELSPETBF2024, DL230. Thirteen of those stacked above
|
|
349
|
+
// the products, saying nothing and offering nothing to do.
|
|
350
|
+
//
|
|
351
|
+
// A percentage, a minimum quantity or an end date makes it an offer. A
|
|
352
|
+
// bare internal label does not, whatever it is called.
|
|
353
|
+
.filter((d) => d.percentOff !== undefined || d.minQuantity !== undefined || d.endsAt);
|
|
354
|
+
return {
|
|
355
|
+
...(promotions.length ? { promotions } : {}),
|
|
356
|
+
...(snap.discountsBlocked ? { promotionsBlocked: snap.discountsBlocked } : {}),
|
|
357
|
+
};
|
|
358
|
+
};
|
|
359
|
+
const money = (minor, currency) => {
|
|
360
|
+
const d = currency === 'JPY' || currency === 'KRW' ? 0 : 2;
|
|
361
|
+
return `${(minor / 10 ** d).toFixed(d)} ${currency}`;
|
|
362
|
+
};
|
|
363
|
+
// What a lens looks like in one line — the shape the demo conversation used:
|
|
364
|
+
// name | replacement cycle | price | colours | rating.
|
|
365
|
+
const optionValues = (p, nameLike) => p.options.find((o) => nameLike.test(o.name))?.values ?? [];
|
|
366
|
+
// Prescription-ish option names, matched loosely: this store calls them `Left`,
|
|
367
|
+
// `Right` and `Diopter`, and the next one will call them something else again.
|
|
368
|
+
const POWER = /度数|prescription|power|diopter|sph|left|right/i;
|
|
369
|
+
// Whether a product is a PAIR or a single lens, which decides how many
|
|
370
|
+
// prescriptions the buyer has to give. Moody encodes it two ways at once — a
|
|
371
|
+
// `-L`/`-R` suffix on the name, and one option instead of two — so read the
|
|
372
|
+
// options (the structural fact) and use the name only to explain it.
|
|
373
|
+
const eyeShape = (p) => {
|
|
374
|
+
const power = p.options.filter((o) => POWER.test(o.name));
|
|
375
|
+
if (power.length >= 2)
|
|
376
|
+
return 'pair';
|
|
377
|
+
if (power.length === 1)
|
|
378
|
+
return 'single';
|
|
379
|
+
return 'unknown';
|
|
380
|
+
};
|
|
381
|
+
/**
|
|
382
|
+
* An accessory names the thing it belongs to: "Rake FOR ELS PET Spaceship",
|
|
383
|
+
* "Liners for litter tray", "Replaceable Accessories". A shopper opening a
|
|
384
|
+
* storefront wants the litter box before the sponge strip that fits it.
|
|
385
|
+
*
|
|
386
|
+
* A heuristic on the TITLE, because the structured fields cannot answer it —
|
|
387
|
+
* this shop files all 30 products as "pet supplies". Checked against the real
|
|
388
|
+
* catalogue before it went in: 11 main products, 19 accessories, split where a
|
|
389
|
+
* person would split them.
|
|
390
|
+
*/
|
|
391
|
+
const ACCESSORY = /\bfor\b|\baccessor|\bliner|\brefill|\bspare\b|\breplacement\b|\breplaceable\b/i;
|
|
392
|
+
export const isAccessory = (title) => ACCESSORY.test(title);
|
|
393
|
+
const productSummary = (p, imageProxy) => ({
|
|
394
|
+
name: p.title,
|
|
395
|
+
handle: p.handle,
|
|
396
|
+
// The lens's page on Moody's own site — reviews, the size guide, the photos
|
|
397
|
+
// we do not carry. A buyer who wants to read more should land there, not on a
|
|
398
|
+
// *.myshopify.com address they have never heard of.
|
|
399
|
+
url: p.url,
|
|
400
|
+
price: p.fromPriceMinor !== undefined ? money(p.fromPriceMinor, p.currency) : undefined,
|
|
401
|
+
// What the buyer must still choose, in the store's own words. Not "colours":
|
|
402
|
+
// at Moody the colour is the product name, and inventing a colour axis that
|
|
403
|
+
// does not exist would have the assistant asking an unanswerable question.
|
|
404
|
+
choices: p.options.map((o) => ({ name: o.name, count: o.values.length })),
|
|
405
|
+
// Present ONLY when the store actually has a prescription axis.
|
|
406
|
+
//
|
|
407
|
+
// These are derived from the option names, so a shop that sells litter boxes
|
|
408
|
+
// got `eyes: 'unknown'` and `prescriptions: []` on every product — which does
|
|
409
|
+
// not read as "not applicable", it reads as "this store has prescriptions and
|
|
410
|
+
// I could not find them", and invites the assistant to ask a buyer for a
|
|
411
|
+
// diopter they do not have. Absent says what empty could not.
|
|
412
|
+
...(() => {
|
|
413
|
+
const power = p.options.filter((o) => POWER.test(o.name));
|
|
414
|
+
return power.length ? { eyes: eyeShape(p), prescriptions: power[0]?.values ?? [] } : {};
|
|
415
|
+
})(),
|
|
416
|
+
type: p.productType || undefined,
|
|
417
|
+
tags: p.tags,
|
|
418
|
+
image: p.imageUrl && imageProxy ? `${imageProxy}${encodeURIComponent(p.imageUrl)}` : p.imageUrl,
|
|
419
|
+
// The direct CDN url, for a host whose CSP allows it (and for a reader that
|
|
420
|
+
// wants the real address rather than our proxy's).
|
|
421
|
+
imageCdn: p.imageUrl,
|
|
422
|
+
});
|
|
423
|
+
const notLoaded = (deps) => {
|
|
424
|
+
if (deps.catalog.list().length > 0)
|
|
425
|
+
return null;
|
|
426
|
+
const err = deps.catalog.current.lastError;
|
|
427
|
+
return {
|
|
428
|
+
structured: { view: 'error', error: 'catalog-unavailable', detail: err ?? null },
|
|
429
|
+
text: `The ${deps.merchantName} catalog is not loaded yet${err ? ` (${err})` : ''}. Try again in a moment.`,
|
|
430
|
+
};
|
|
431
|
+
};
|
|
432
|
+
export const callTool = async (deps, name, args) => {
|
|
433
|
+
if (name === deps.tools.list) {
|
|
434
|
+
const blocked = notLoaded(deps);
|
|
435
|
+
if (blocked)
|
|
436
|
+
return blocked;
|
|
437
|
+
const q = String(args.query ?? '')
|
|
438
|
+
.trim()
|
|
439
|
+
.toLowerCase();
|
|
440
|
+
const maxPrice = typeof args.maxPrice === 'number' ? args.maxPrice : undefined;
|
|
441
|
+
const limit = typeof args.limit === 'number' && args.limit > 0 ? Math.min(args.limit, 50) : 20;
|
|
442
|
+
let items = deps.catalog.list();
|
|
443
|
+
// Widened, and never empty when the shelf is not.
|
|
444
|
+
//
|
|
445
|
+
// The naive substring match answered "brown lenses" and "棕色系" with
|
|
446
|
+
// nothing, so the assistant tried again, and again, each attempt drawing its
|
|
447
|
+
// own card — the buyer watched three storefronts stack up in the transcript
|
|
448
|
+
// for one question. The store is 382 products; a search over it should
|
|
449
|
+
// return the near misses, not a wall.
|
|
450
|
+
let relaxedFrom;
|
|
451
|
+
if (q) {
|
|
452
|
+
const hay = (p) => [p.title, p.handle, p.productType, p.description, ...p.tags].join(' ').toLowerCase();
|
|
453
|
+
// WHERE the match landed decides the order, on BOTH paths below.
|
|
454
|
+
//
|
|
455
|
+
// The ranking used to exist only on the relaxed path — which is the one a
|
|
456
|
+
// single word almost never takes. "grey" IS a substring of several brown
|
|
457
|
+
// lenses' descriptions, so it matched exactly, skipped the ranking
|
|
458
|
+
// entirely, and led a search for grey with Oolong Brown and Barley Tea.
|
|
459
|
+
// The comment further down predicted this exact failure and the fix was
|
|
460
|
+
// applied to the wrong branch.
|
|
461
|
+
const nameFirst = (list, terms) => {
|
|
462
|
+
const inName = (p) => terms.some((t) => `${p.title} ${p.handle} ${p.tags.join(' ')}`.toLowerCase().includes(t));
|
|
463
|
+
return [...list.filter(inName), ...list.filter((p) => !inName(p))];
|
|
464
|
+
};
|
|
465
|
+
const exact = items.filter((p) => hay(p).includes(q));
|
|
466
|
+
if (exact.length > 0) {
|
|
467
|
+
items = nameFirst(exact, [q]);
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
// Any word, then any colour word we can map. "Brown lenses" keeps
|
|
471
|
+
// "brown"; a Chinese colour term becomes the English one this store
|
|
472
|
+
// actually names its products with.
|
|
473
|
+
//
|
|
474
|
+
// Ranked by WHERE the match landed, because the colour is the product
|
|
475
|
+
// NAME here and the descriptions are full of other colours: "Matcha
|
|
476
|
+
// Green" says "hojicha-brown iris details", so an unranked search for
|
|
477
|
+
// brown puts a green lens in the buyer's first row.
|
|
478
|
+
// A single CJK character IS a word (灰 = grey), so the one-letter filter
|
|
479
|
+
// only applies to alphabetic ones, where it drops "a"/"of".
|
|
480
|
+
// Split on every separator a picker or a person actually types, the
|
|
481
|
+
// full-width ones included: "棕色系(自然)" arrives whole, and one
|
|
482
|
+
// unhandled bracket is the difference between a colour family and all
|
|
483
|
+
// 382 products.
|
|
484
|
+
const words = q
|
|
485
|
+
.split(/[\s,,、;;::/|·()()[\]【】{}"'“”‘’]+/)
|
|
486
|
+
.filter((w) => w.length > 1 || /[一-鿿]/.test(w));
|
|
487
|
+
const terms = [
|
|
488
|
+
...new Set([...words, ...words.flatMap((w) => COLOUR_WORDS[colourKey(w)] ?? [])]),
|
|
489
|
+
];
|
|
490
|
+
const loose = terms.length
|
|
491
|
+
? items.filter((p) => terms.some((t) => hay(p).includes(t)))
|
|
492
|
+
: [];
|
|
493
|
+
if (loose.length > 0) {
|
|
494
|
+
items = nameFirst(loose, terms);
|
|
495
|
+
relaxedFrom = q;
|
|
496
|
+
}
|
|
497
|
+
else {
|
|
498
|
+
// Still nothing. Show the shelf rather than an empty card: the next
|
|
499
|
+
// move is the buyer narrowing it, and they cannot narrow nothing.
|
|
500
|
+
relaxedFrom = q;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
if (maxPrice !== undefined) {
|
|
505
|
+
items = items.filter((p) => {
|
|
506
|
+
if (p.fromPriceMinor === undefined)
|
|
507
|
+
return false;
|
|
508
|
+
const d = p.currency === 'JPY' || p.currency === 'KRW' ? 0 : 2;
|
|
509
|
+
return p.fromPriceMinor / 10 ** d <= maxPrice;
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
// Accessories after the things they attach to, and only when the buyer did
|
|
513
|
+
// not ask for something specific — a search for "liner" should still put
|
|
514
|
+
// liners first. Stable, so within each group the shop's own order survives.
|
|
515
|
+
const ranked = q
|
|
516
|
+
? items
|
|
517
|
+
: [
|
|
518
|
+
...items.filter((p) => !isAccessory(p.title)),
|
|
519
|
+
...items.filter((p) => isAccessory(p.title)),
|
|
520
|
+
];
|
|
521
|
+
const products = ranked.slice(0, limit).map((p) => productSummary(p, deps.imageProxy));
|
|
522
|
+
const lines = products.map((p) => `- **${p.name}**${p.price ? ` · ${p.price}` : ''}` +
|
|
523
|
+
`${p.eyes === 'pair' ? ' · pair (needs both eyes)' : p.eyes === 'single' ? ' · single lens' : ''}`);
|
|
524
|
+
return {
|
|
525
|
+
structured: {
|
|
526
|
+
view: 'products',
|
|
527
|
+
merchant: deps.merchantName,
|
|
528
|
+
...(deps.logoUrl ? { logoUrl: deps.logoUrl } : {}),
|
|
529
|
+
toolNames: { get: deps.tools.get, checkout: deps.tools.checkout },
|
|
530
|
+
vocab: { noun: deps.vocab.noun, nounPlural: deps.vocab.nounPlural },
|
|
531
|
+
products,
|
|
532
|
+
total: items.length,
|
|
533
|
+
// The shop's live offers. They are attached to no product, so without
|
|
534
|
+
// this the card quotes a price the buyer beats at checkout and never
|
|
535
|
+
// mentions the deal that would have made them buy two.
|
|
536
|
+
...promoPayload(deps),
|
|
537
|
+
// What the card offers as filter chips, computed from THESE results
|
|
538
|
+
// rather than a list we invented. The buyer narrows inside the card, so
|
|
539
|
+
// narrowing costs no tool call — and a tool call is what put three
|
|
540
|
+
// storefronts in the transcript for one question.
|
|
541
|
+
facets: facetsOf(products),
|
|
542
|
+
// Say when the answer is broader than what was asked, so the card can
|
|
543
|
+
// label it and the assistant does not read it as an exact match.
|
|
544
|
+
...(relaxedFrom ? { relaxedFrom } : {}),
|
|
545
|
+
},
|
|
546
|
+
text: [
|
|
547
|
+
products.length
|
|
548
|
+
? `${products.length} of ${items.length} ${deps.vocab.nounPlural}:\n${lines.join('\n')}`
|
|
549
|
+
: 'This store has no products loaded right now.',
|
|
550
|
+
// The offers, in one line. Shopify applies them at its own checkout
|
|
551
|
+
// whether or not anyone mentions them — but a buyer who does not know
|
|
552
|
+
// about "buy 2 get 5% off" buys one.
|
|
553
|
+
deps.catalog.current.discounts.length
|
|
554
|
+
? `Store offers right now: ${deps.catalog.current.discounts.map((d) => d.title).join(' · ')} (applied automatically at checkout).`
|
|
555
|
+
: '',
|
|
556
|
+
// Tell the assistant it already has the near misses. Otherwise it reads
|
|
557
|
+
// a narrow result as a failure and searches again, and every retry draws
|
|
558
|
+
// another storefront card in front of the buyer.
|
|
559
|
+
relaxedFrom
|
|
560
|
+
? `(Nothing matched "${relaxedFrom}" exactly — these are the closest. Do NOT search again; ask the buyer to narrow it, or show these.)`
|
|
561
|
+
: '',
|
|
562
|
+
]
|
|
563
|
+
.filter(Boolean)
|
|
564
|
+
.join('\n'),
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
if (name === deps.tools.get) {
|
|
568
|
+
const blocked = notLoaded(deps);
|
|
569
|
+
if (blocked)
|
|
570
|
+
return blocked;
|
|
571
|
+
const p = deps.catalog.byHandleOrTitle(String(args.product ?? ''));
|
|
572
|
+
if (!p) {
|
|
573
|
+
return {
|
|
574
|
+
structured: { view: 'error', error: 'no-such-product' },
|
|
575
|
+
text: `No ${deps.vocab.noun} called "${String(args.product ?? '')}". Try ${deps.tools.list}.`,
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
// Variants are loaded HERE, for this one product — the catalogue holds none.
|
|
579
|
+
const variants = await deps.catalog.variantsFor(p.handle);
|
|
580
|
+
const inStock = variants.filter((v) => v.available);
|
|
581
|
+
return {
|
|
582
|
+
structured: {
|
|
583
|
+
view: 'product',
|
|
584
|
+
merchant: deps.merchantName,
|
|
585
|
+
...(deps.logoUrl ? { logoUrl: deps.logoUrl } : {}),
|
|
586
|
+
toolNames: { get: deps.tools.get, checkout: deps.tools.checkout },
|
|
587
|
+
vocab: { noun: deps.vocab.noun, nounPlural: deps.vocab.nounPlural },
|
|
588
|
+
product: {
|
|
589
|
+
...productSummary(p, deps.imageProxy),
|
|
590
|
+
description: p.description,
|
|
591
|
+
images: p.images,
|
|
592
|
+
currency: p.currency,
|
|
593
|
+
options: p.options,
|
|
594
|
+
// Capped in the payload: 676 rows is not a thing to put in a tool
|
|
595
|
+
// result, and the options above are what the buyer chooses from.
|
|
596
|
+
variantCount: variants.length,
|
|
597
|
+
inStockCount: inStock.length,
|
|
598
|
+
// WHICH combinations can actually be bought, as option maps:
|
|
599
|
+
// [{Left:'-5.75', Right:'-5.50'}]. This is what the picker needs and
|
|
600
|
+
// the option lists above cannot express — "Left -5.75 is offered" and
|
|
601
|
+
// "Right -5.50 is offered" says nothing about whether that PAIR exists,
|
|
602
|
+
// and at this store 674 of the 676 pairs do not. Without it the buyer
|
|
603
|
+
// picks from 26×26 chips and is told "sold out" almost every time.
|
|
604
|
+
//
|
|
605
|
+
// Small in practice BECAUSE it is in-stock only: 2 rows for Matcha
|
|
606
|
+
// Green, 26 for a fully stocked single. Capped anyway — a fully stocked
|
|
607
|
+
// pair would be 676 rows in a tool result the model also reads — and
|
|
608
|
+
// the flag tells the picker to stop filtering rather than filter on a
|
|
609
|
+
// truncated list, which would hide buyable prescriptions.
|
|
610
|
+
...inStockCombos(variants),
|
|
611
|
+
// In-stock FIRST. A pair is Left × Right in the merchant's own order,
|
|
612
|
+
// so the first 30 rows are the whole 0.00 row and a slice of -1.00 —
|
|
613
|
+
// and at this store those are sold out while the stocked ones sit deep
|
|
614
|
+
// in the tail. Showing the first 30 answers "what can I buy?" with
|
|
615
|
+
// thirty things you cannot, which is worse than showing none.
|
|
616
|
+
variants: [...inStock, ...variants.filter((v) => !v.available)].slice(0, 30).map((v) => ({
|
|
617
|
+
variantId: v.variantId,
|
|
618
|
+
title: v.title,
|
|
619
|
+
price: money(v.priceMinor, p.currency),
|
|
620
|
+
wasPrice: v.compareAtMinor ? money(v.compareAtMinor, p.currency) : undefined,
|
|
621
|
+
available: v.available,
|
|
622
|
+
options: v.options,
|
|
623
|
+
})),
|
|
624
|
+
},
|
|
625
|
+
},
|
|
626
|
+
text: [
|
|
627
|
+
`**${p.title}** — ${p.fromPriceMinor !== undefined ? money(p.fromPriceMinor, p.currency) : ''}`,
|
|
628
|
+
...p.options.map((o) => `${o.name}: ${o.values.join(' / ')}`),
|
|
629
|
+
// Say it plainly. Every value above is offered; far fewer are stocked,
|
|
630
|
+
// and an assistant reading only the option lists will confidently name a
|
|
631
|
+
// prescription that cannot be bought.
|
|
632
|
+
inStock.length === 0
|
|
633
|
+
? `OUT OF STOCK: none of the ${variants.length} combinations is in stock right now.`
|
|
634
|
+
: inStock.length < variants.length
|
|
635
|
+
? `In stock: ${inStock.length} of ${variants.length} combinations — the values listed above are what the store OFFERS, not what it has. Check with ${deps.tools.checkout} before promising one.`
|
|
636
|
+
: '',
|
|
637
|
+
p.description,
|
|
638
|
+
]
|
|
639
|
+
.filter(Boolean)
|
|
640
|
+
.join('\n'),
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
if (name === deps.tools.checkout) {
|
|
644
|
+
const blocked = notLoaded(deps);
|
|
645
|
+
if (blocked)
|
|
646
|
+
return blocked;
|
|
647
|
+
const p = deps.catalog.byHandleOrTitle(String(args.product ?? ''));
|
|
648
|
+
if (!p) {
|
|
649
|
+
return {
|
|
650
|
+
structured: { view: 'error', error: 'no-such-product' },
|
|
651
|
+
text: `No ${deps.vocab.noun} called "${String(args.product ?? '')}".`,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
const choices = {};
|
|
655
|
+
for (const [k, v] of Object.entries((args.options ?? {}))) {
|
|
656
|
+
if (typeof v === 'string' || typeof v === 'number')
|
|
657
|
+
choices[k] = String(v);
|
|
658
|
+
}
|
|
659
|
+
const variants = await deps.catalog.variantsFor(p.handle);
|
|
660
|
+
const variant = resolveVariant(variants, choices);
|
|
661
|
+
if (!variant) {
|
|
662
|
+
// Deliberately NOT a best guess. The options on a lens are the colour and
|
|
663
|
+
// the prescription; getting the second one wrong sends someone a lens they
|
|
664
|
+
// cannot wear, and a plausible guess is exactly how that happens.
|
|
665
|
+
//
|
|
666
|
+
// In the store's own words, and only what the store actually offers. Both
|
|
667
|
+
// readers below need that: the card pre-selects from it, and the prose has
|
|
668
|
+
// to call `-2.0` against a `-2.00` option ANSWERED — telling an assistant
|
|
669
|
+
// a question is still open when the buyer already answered it is how the
|
|
670
|
+
// buyer gets asked their prescription twice.
|
|
671
|
+
const given = snapChoices(p.options, choices);
|
|
672
|
+
return {
|
|
673
|
+
structured: {
|
|
674
|
+
view: 'needs-options',
|
|
675
|
+
merchant: deps.merchantName,
|
|
676
|
+
...(deps.logoUrl ? { logoUrl: deps.logoUrl } : {}),
|
|
677
|
+
toolNames: { get: deps.tools.get, checkout: deps.tools.checkout },
|
|
678
|
+
vocab: { noun: deps.vocab.noun, nounPlural: deps.vocab.nounPlural },
|
|
679
|
+
product: productSummary(p, deps.imageProxy),
|
|
680
|
+
options: p.options,
|
|
681
|
+
eyes: eyeShape(p),
|
|
682
|
+
given,
|
|
683
|
+
// The picker opens on THIS payload when an assistant calls checkout
|
|
684
|
+
// straight from the conversation, so it needs the same stock list the
|
|
685
|
+
// product view gets — otherwise the buyer is offered 26x26 chips and
|
|
686
|
+
// told "sold out" on almost every one of them.
|
|
687
|
+
...inStockCombos(variants),
|
|
688
|
+
},
|
|
689
|
+
text: `**${p.title}** needs ${p.options.length === 1 ? 'one choice' : `${p.options.length} choices`}${eyeShape(p) === 'pair' ? ' — it is a PAIR, so both eyes' : ''}:\n${p.options
|
|
690
|
+
.map((o) => `- ${o.name}${given[o.name] ? ` (given: ${given[o.name]})` : ' ← still needed'}: ${o.values.join(' / ')}`)
|
|
691
|
+
.join('\n')}\nAsk the buyer for what is missing. Never assume one answer carries to another option, and never round to a nearby value — the wrong one is something they cannot use.`,
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
if (!variant.available) {
|
|
695
|
+
return {
|
|
696
|
+
structured: {
|
|
697
|
+
view: 'out-of-stock',
|
|
698
|
+
product: productSummary(p, deps.imageProxy),
|
|
699
|
+
variant: variant.title,
|
|
700
|
+
},
|
|
701
|
+
text: `**${p.title} — ${variant.title}** is out of stock right now.`,
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
// Bounded: a cart link is a public URL, and an unbounded quantity in one is
|
|
705
|
+
// a link that looks like this store sells 10,000 of something.
|
|
706
|
+
const quantity = typeof args.quantity === 'number' && args.quantity > 0
|
|
707
|
+
? Math.min(Math.floor(args.quantity), 20)
|
|
708
|
+
: 1;
|
|
709
|
+
// Send them to the checkout on the SAME host as the product page they were
|
|
710
|
+
// just looking at, not to `<handle>.myshopify.com`. Taken from the product
|
|
711
|
+
// rather than from config because that is where the shop's own domain
|
|
712
|
+
// already is, and because the two links must not disagree: a card offering
|
|
713
|
+
// moodycosmetics.com/products/… and a Buy leading somewhere else reads as a
|
|
714
|
+
// swapped link, which is precisely what buyers are taught to distrust.
|
|
715
|
+
// Falls back to the myshopify host, which still works, when the storefront
|
|
716
|
+
// domain is unknown.
|
|
717
|
+
const storefrontDomain = p.url ? new URL(p.url).host : undefined;
|
|
718
|
+
const url = cartPermalink({ ...deps.shopify, ...(storefrontDomain ? { storefrontDomain } : {}) }, [{ variantId: variant.variantId, quantity }], {
|
|
719
|
+
attributes: {
|
|
720
|
+
Source: 'Curless',
|
|
721
|
+
...(deps.assistant ? { Assistant: deps.assistant } : {}),
|
|
722
|
+
},
|
|
723
|
+
});
|
|
724
|
+
return {
|
|
725
|
+
structured: {
|
|
726
|
+
view: 'checkout',
|
|
727
|
+
merchant: deps.merchantName,
|
|
728
|
+
...(deps.logoUrl ? { logoUrl: deps.logoUrl } : {}),
|
|
729
|
+
toolNames: { get: deps.tools.get, checkout: deps.tools.checkout },
|
|
730
|
+
vocab: { noun: deps.vocab.noun, nounPlural: deps.vocab.nounPlural },
|
|
731
|
+
product: productSummary(p, deps.imageProxy),
|
|
732
|
+
variant: {
|
|
733
|
+
title: variant.title,
|
|
734
|
+
options: variant.options,
|
|
735
|
+
price: money(variant.priceMinor, p.currency),
|
|
736
|
+
},
|
|
737
|
+
quantity,
|
|
738
|
+
total: money(variant.priceMinor * quantity, p.currency),
|
|
739
|
+
checkoutUrl: url,
|
|
740
|
+
},
|
|
741
|
+
text: `**${p.title} — ${variant.title}** ×${quantity} · ${money(variant.priceMinor * quantity, p.currency)}\n\nPay on ${deps.merchantName}'s checkout: ${url}\n\n(Payment and the order itself happen on Moody's own Shopify checkout.)`,
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
if (name === deps.tools.lookupOrder) {
|
|
745
|
+
const res = await lookupShopifyOrder(deps.shopify, {
|
|
746
|
+
orderNumber: String(args.orderNumber ?? ''),
|
|
747
|
+
email: String(args.email ?? ''),
|
|
748
|
+
});
|
|
749
|
+
if (!res.found) {
|
|
750
|
+
// One answer for "no such order" and for "wrong email" — see the adapter.
|
|
751
|
+
return {
|
|
752
|
+
structured: { view: 'order-not-found' },
|
|
753
|
+
text: 'No order matches that order number and email. Check both against the confirmation email.',
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
const lines = res.lines
|
|
757
|
+
.map((l) => `- ${l.quantity}× ${l.title}${l.variantTitle ? ` (${l.variantTitle})` : ''}`)
|
|
758
|
+
.join('\n');
|
|
759
|
+
return {
|
|
760
|
+
structured: { view: 'order', merchant: deps.merchantName, order: res },
|
|
761
|
+
text: [
|
|
762
|
+
`**${res.name}** · ${money(res.totalMinor, res.currency)} · ${res.financialStatus}${res.fulfillmentStatus ? ` · ${res.fulfillmentStatus}` : ''}`,
|
|
763
|
+
lines,
|
|
764
|
+
res.trackingUrls.length ? `Tracking: ${res.trackingUrls.join(' ')}` : '',
|
|
765
|
+
res.statusUrl ? `Order status page: ${res.statusUrl}` : '',
|
|
766
|
+
]
|
|
767
|
+
.filter(Boolean)
|
|
768
|
+
.join('\n'),
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
return { structured: { view: 'error', error: 'unknown-tool' }, text: `Unknown tool: ${name}` };
|
|
772
|
+
};
|
|
773
|
+
/**
|
|
774
|
+
* `productSummary` under test. Exported rather than reached through a live
|
|
775
|
+
* catalogue because the thing worth pinning is the SHAPE — which fields exist
|
|
776
|
+
* for which store — and a fetch mock would bury that behind plumbing.
|
|
777
|
+
*/
|
|
778
|
+
export const productSummaryForTest = (p, imageProxy) => productSummary(p, imageProxy);
|
|
779
|
+
//# sourceMappingURL=tools.js.map
|