@vellumai/assistant 0.4.11 → 0.4.13
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/ARCHITECTURE.md +401 -385
- package/package.json +1 -1
- package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +75 -61
- package/src/__tests__/registry.test.ts +235 -187
- package/src/__tests__/secure-keys.test.ts +27 -0
- package/src/__tests__/session-agent-loop.test.ts +521 -256
- package/src/__tests__/session-surfaces-task-progress.test.ts +1 -0
- package/src/__tests__/session-tool-setup-app-refresh.test.ts +1 -0
- package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -0
- package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -0
- package/src/__tests__/skills.test.ts +334 -276
- package/src/__tests__/slack-skill.test.ts +124 -0
- package/src/__tests__/starter-task-flow.test.ts +7 -17
- package/src/agent/loop.ts +10 -3
- package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +449 -0
- package/src/config/bundled-skills/doordash/SKILL.md +171 -0
- package/src/config/bundled-skills/doordash/__tests__/doordash-client.test.ts +203 -0
- package/src/config/bundled-skills/doordash/__tests__/doordash-session.test.ts +164 -0
- package/src/config/bundled-skills/doordash/doordash-cli.ts +1193 -0
- package/src/config/bundled-skills/doordash/doordash-entry.ts +22 -0
- package/src/config/bundled-skills/doordash/lib/cart-queries.ts +787 -0
- package/src/config/bundled-skills/doordash/lib/client.ts +1071 -0
- package/src/config/bundled-skills/doordash/lib/order-queries.ts +85 -0
- package/src/config/bundled-skills/doordash/lib/queries.ts +28 -0
- package/src/config/bundled-skills/doordash/lib/query-extractor.ts +94 -0
- package/src/config/bundled-skills/doordash/lib/search-queries.ts +203 -0
- package/src/config/bundled-skills/doordash/lib/session.ts +93 -0
- package/src/config/bundled-skills/doordash/lib/shared/errors.ts +61 -0
- package/src/config/bundled-skills/doordash/lib/shared/ipc.ts +32 -0
- package/src/config/bundled-skills/doordash/lib/shared/network-recorder.ts +380 -0
- package/src/config/bundled-skills/doordash/lib/shared/platform.ts +35 -0
- package/src/config/bundled-skills/doordash/lib/shared/recording-store.ts +43 -0
- package/src/config/bundled-skills/doordash/lib/shared/recording-types.ts +49 -0
- package/src/config/bundled-skills/doordash/lib/shared/truncate.ts +6 -0
- package/src/config/bundled-skills/doordash/lib/store-queries.ts +246 -0
- package/src/config/bundled-skills/doordash/lib/types.ts +367 -0
- package/src/config/bundled-skills/google-calendar/SKILL.md +4 -5
- package/src/config/bundled-skills/google-oauth-setup/SKILL.md +41 -41
- package/src/config/bundled-skills/messaging/SKILL.md +59 -42
- package/src/config/bundled-skills/messaging/TOOLS.json +14 -92
- package/src/config/bundled-skills/messaging/tools/gmail-archive-by-query.ts +5 -1
- package/src/config/bundled-skills/messaging/tools/gmail-batch-archive.ts +11 -2
- package/src/config/bundled-skills/messaging/tools/gmail-outreach-scan.ts +8 -1
- package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +12 -4
- package/src/config/bundled-skills/messaging/tools/gmail-unsubscribe.ts +5 -1
- package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +5 -1
- package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +5 -2
- package/src/config/bundled-skills/notion/SKILL.md +240 -0
- package/src/config/bundled-skills/notion-oauth-setup/SKILL.md +127 -0
- package/src/config/bundled-skills/oauth-setup/SKILL.md +144 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +76 -45
- package/src/config/bundled-skills/skills-catalog/SKILL.md +32 -29
- package/src/config/bundled-skills/slack/SKILL.md +49 -0
- package/src/config/bundled-skills/slack/TOOLS.json +167 -0
- package/src/config/bundled-skills/slack/tools/shared.ts +23 -0
- package/src/config/bundled-skills/{messaging → slack}/tools/slack-add-reaction.ts +2 -5
- package/src/config/bundled-skills/slack/tools/slack-channel-details.ts +33 -0
- package/src/config/bundled-skills/slack/tools/slack-configure-channels.ts +75 -0
- package/src/config/bundled-skills/{messaging → slack}/tools/slack-delete-message.ts +2 -5
- package/src/config/bundled-skills/{messaging → slack}/tools/slack-leave-channel.ts +2 -5
- package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +193 -0
- package/src/config/{vellum-skills → bundled-skills}/sms-setup/SKILL.md +29 -22
- package/src/config/{vellum-skills → bundled-skills}/telegram-setup/SKILL.md +17 -14
- package/src/config/{vellum-skills → bundled-skills}/twilio-setup/SKILL.md +20 -5
- package/src/config/bundled-tool-registry.ts +292 -267
- package/src/config/schema.ts +1 -1
- package/src/daemon/handlers/skills.ts +334 -234
- package/src/daemon/ipc-contract/messages.ts +2 -0
- package/src/daemon/ipc-contract/surfaces.ts +2 -0
- package/src/daemon/lifecycle.ts +358 -221
- package/src/daemon/response-tier.ts +2 -0
- package/src/daemon/server.ts +453 -193
- package/src/daemon/session-agent-loop-handlers.ts +43 -2
- package/src/daemon/session-agent-loop.ts +3 -0
- package/src/daemon/session-lifecycle.ts +3 -0
- package/src/daemon/session-process.ts +1 -0
- package/src/daemon/session-surfaces.ts +22 -20
- package/src/daemon/session-tool-setup.ts +1 -0
- package/src/daemon/session.ts +5 -2
- package/src/messaging/outreach-classifier.ts +12 -5
- package/src/messaging/provider-types.ts +5 -0
- package/src/messaging/provider.ts +1 -1
- package/src/messaging/providers/gmail/adapter.ts +11 -5
- package/src/messaging/providers/gmail/client.ts +2 -0
- package/src/messaging/providers/slack/adapter.ts +1 -0
- package/src/messaging/providers/slack/client.ts +8 -0
- package/src/messaging/providers/slack/types.ts +5 -0
- package/src/runtime/http-errors.ts +33 -20
- package/src/runtime/http-server.ts +706 -291
- package/src/runtime/http-types.ts +26 -16
- package/src/runtime/routes/secret-routes.ts +57 -2
- package/src/runtime/routes/surface-action-routes.ts +66 -0
- package/src/runtime/routes/trust-rules-routes.ts +140 -0
- package/src/security/keychain-to-encrypted-migration.ts +59 -0
- package/src/security/secure-keys.ts +17 -0
- package/src/skills/frontmatter.ts +9 -7
- package/src/tools/apps/executors.ts +2 -1
- package/src/tools/tool-manifest.ts +44 -42
- package/src/tools/types.ts +9 -0
- package/src/__tests__/skill-mirror-parity.test.ts +0 -176
- package/src/config/vellum-skills/catalog.json +0 -63
- package/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +0 -295
- package/src/skills/vellum-catalog-remote.ts +0 -166
- package/src/tools/skills/vellum-catalog.ts +0 -168
- /package/src/config/{vellum-skills → bundled-skills}/chatgpt-import/SKILL.md +0 -0
- /package/src/config/{vellum-skills → bundled-skills}/chatgpt-import/TOOLS.json +0 -0
- /package/src/config/{vellum-skills → bundled-skills}/deploy-fullstack-vercel/SKILL.md +0 -0
- /package/src/config/{vellum-skills → bundled-skills}/document-writer/SKILL.md +0 -0
- /package/src/config/{vellum-skills → bundled-skills}/guardian-verify-setup/SKILL.md +0 -0
- /package/src/config/{vellum-skills → bundled-skills}/slack-oauth-setup/SKILL.md +0 -0
- /package/src/config/{vellum-skills → bundled-skills}/trusted-contacts/SKILL.md +0 -0
|
@@ -0,0 +1,1071 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DoorDash GraphQL API client.
|
|
3
|
+
* Executes GraphQL queries through Chrome's CDP (Runtime.evaluate) so requests
|
|
4
|
+
* go through the browser's authenticated session with Cloudflare tokens intact.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
CREATE_ORDER_FROM_CART_QUERY,
|
|
9
|
+
DETAILED_CART_QUERY,
|
|
10
|
+
DROPOFF_OPTIONS_QUERY,
|
|
11
|
+
HOME_PAGE_QUERY,
|
|
12
|
+
ITEM_PAGE_QUERY,
|
|
13
|
+
LIST_CARTS_QUERY,
|
|
14
|
+
PAYMENT_METHODS_QUERY,
|
|
15
|
+
REMOVE_CART_ITEM_QUERY,
|
|
16
|
+
RETAIL_SEARCH_QUERY,
|
|
17
|
+
RETAIL_STORE_FEED_QUERY,
|
|
18
|
+
SEARCH_QUERY,
|
|
19
|
+
STORE_PAGE_QUERY,
|
|
20
|
+
UPDATE_CART_ITEM_QUERY,
|
|
21
|
+
} from "./queries.js";
|
|
22
|
+
import { loadCapturedQueries } from "./query-extractor.js";
|
|
23
|
+
import { type DoorDashSession, loadSession } from "./session.js";
|
|
24
|
+
import { ProviderError, RateLimitError } from "./shared/errors.js";
|
|
25
|
+
import { truncate } from "./shared/truncate.js";
|
|
26
|
+
import type {
|
|
27
|
+
DDCart,
|
|
28
|
+
DDCreateOrderResult,
|
|
29
|
+
DDDropoffOption,
|
|
30
|
+
DDFacetFeed,
|
|
31
|
+
DDItemPage,
|
|
32
|
+
DDMenuCategory,
|
|
33
|
+
DDNestedExtra,
|
|
34
|
+
DDOptionChoice,
|
|
35
|
+
DDOptionList,
|
|
36
|
+
DDPaymentMethod,
|
|
37
|
+
DDRetailItemCustom,
|
|
38
|
+
DDRetailSearchResult,
|
|
39
|
+
DDRetailStorePageFeed,
|
|
40
|
+
DDSearchClickData,
|
|
41
|
+
DDStorepageFeed,
|
|
42
|
+
} from "./types.js";
|
|
43
|
+
|
|
44
|
+
export { RateLimitError };
|
|
45
|
+
|
|
46
|
+
const GRAPHQL_BASE = "https://www.doordash.com/graphql";
|
|
47
|
+
const CDP_BASE = "http://localhost:9222";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Returns a captured query if one exists for the given operation name,
|
|
51
|
+
* otherwise falls back to the static query string from queries.ts.
|
|
52
|
+
*/
|
|
53
|
+
function getQuery(operationName: string, staticFallback: string): string {
|
|
54
|
+
const captured = loadCapturedQueries();
|
|
55
|
+
return captured[operationName]?.query ?? staticFallback;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface GraphQLResponse<T = unknown> {
|
|
59
|
+
data?: T;
|
|
60
|
+
errors?: Array<{ message: string; extensions?: unknown }>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Thrown when the session is missing or expired. The CLI handles this specially. */
|
|
64
|
+
export class SessionExpiredError extends Error {
|
|
65
|
+
constructor(reason: string) {
|
|
66
|
+
super(reason);
|
|
67
|
+
this.name = "SessionExpiredError";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function requireSession(): DoorDashSession {
|
|
72
|
+
const session = loadSession();
|
|
73
|
+
if (!session) {
|
|
74
|
+
throw new SessionExpiredError("No DoorDash session found.");
|
|
75
|
+
}
|
|
76
|
+
return session;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Find a Chrome tab on doordash.com and return its WebSocket debugger URL.
|
|
81
|
+
*/
|
|
82
|
+
async function findDoordashTab(): Promise<string> {
|
|
83
|
+
const res = await fetch(`${CDP_BASE}/json/list`).catch(() => null);
|
|
84
|
+
if (!res?.ok) {
|
|
85
|
+
throw new SessionExpiredError(
|
|
86
|
+
"Chrome CDP not available. Run `vellum doordash refresh` first.",
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
const targets = (await res.json()) as Array<{
|
|
90
|
+
type: string;
|
|
91
|
+
url: string;
|
|
92
|
+
webSocketDebuggerUrl: string;
|
|
93
|
+
}>;
|
|
94
|
+
// Prefer a tab already on doordash.com
|
|
95
|
+
const ddTab = targets.find(
|
|
96
|
+
(t) => t.type === "page" && t.url.includes("doordash.com"),
|
|
97
|
+
);
|
|
98
|
+
const tab = ddTab ?? targets.find((t) => t.type === "page");
|
|
99
|
+
if (!tab?.webSocketDebuggerUrl) {
|
|
100
|
+
throw new SessionExpiredError(
|
|
101
|
+
"No Chrome tab available for DoorDash requests.",
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return tab.webSocketDebuggerUrl;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Execute a fetch() call inside Chrome's page context via CDP Runtime.evaluate.
|
|
109
|
+
* This ensures the request uses the browser's cookies and Cloudflare clearance.
|
|
110
|
+
*/
|
|
111
|
+
async function cdpFetch(
|
|
112
|
+
wsUrl: string,
|
|
113
|
+
url: string,
|
|
114
|
+
body: string,
|
|
115
|
+
): Promise<unknown> {
|
|
116
|
+
return new Promise((resolve, reject) => {
|
|
117
|
+
const ws = new WebSocket(wsUrl);
|
|
118
|
+
const id = 1;
|
|
119
|
+
|
|
120
|
+
const timeout = setTimeout(() => {
|
|
121
|
+
ws.close();
|
|
122
|
+
reject(new Error("CDP fetch timed out after 30s"));
|
|
123
|
+
}, 30000);
|
|
124
|
+
|
|
125
|
+
ws.onopen = () => {
|
|
126
|
+
// First navigate to doordash.com if not already there (needed for CORS)
|
|
127
|
+
// Extract CSRF token from cookies and include in fetch headers
|
|
128
|
+
const fetchScript = `
|
|
129
|
+
(function() {
|
|
130
|
+
var csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
|
131
|
+
return fetch(${JSON.stringify(url)}, {
|
|
132
|
+
method: 'POST',
|
|
133
|
+
headers: {
|
|
134
|
+
'Content-Type': 'application/json',
|
|
135
|
+
'x-channel-id': 'marketplace',
|
|
136
|
+
'x-experience-id': 'doordash',
|
|
137
|
+
'x-csrftoken': csrf,
|
|
138
|
+
'apollographql-client-name': '@doordash/app-consumer-production-ssr-client',
|
|
139
|
+
'apollographql-client-version': '3.0',
|
|
140
|
+
},
|
|
141
|
+
body: ${JSON.stringify(body)},
|
|
142
|
+
credentials: 'include',
|
|
143
|
+
})
|
|
144
|
+
.then(function(r) {
|
|
145
|
+
if (!r.ok) return r.text().then(function(t) {
|
|
146
|
+
return JSON.stringify({ __status: r.status, __error: true, __body: t.substring(0, 500) });
|
|
147
|
+
});
|
|
148
|
+
return r.text();
|
|
149
|
+
})
|
|
150
|
+
.catch(function(e) { return JSON.stringify({ __error: true, __message: e.message }); });
|
|
151
|
+
})()
|
|
152
|
+
`;
|
|
153
|
+
|
|
154
|
+
ws.send(
|
|
155
|
+
JSON.stringify({
|
|
156
|
+
id,
|
|
157
|
+
method: "Runtime.evaluate",
|
|
158
|
+
params: {
|
|
159
|
+
expression: fetchScript,
|
|
160
|
+
awaitPromise: true,
|
|
161
|
+
returnByValue: true,
|
|
162
|
+
},
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
ws.onmessage = (event) => {
|
|
168
|
+
try {
|
|
169
|
+
const msg = JSON.parse(
|
|
170
|
+
typeof event.data === "string" ? event.data : "",
|
|
171
|
+
);
|
|
172
|
+
if (msg.id === id) {
|
|
173
|
+
clearTimeout(timeout);
|
|
174
|
+
ws.close();
|
|
175
|
+
|
|
176
|
+
if (msg.error) {
|
|
177
|
+
reject(new Error(`CDP error: ${msg.error.message}`));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const value = msg.result?.result?.value;
|
|
182
|
+
if (!value) {
|
|
183
|
+
reject(new Error("Empty CDP response"));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const parsed = typeof value === "string" ? JSON.parse(value) : value;
|
|
188
|
+
if (parsed.__error) {
|
|
189
|
+
if (parsed.__status === 401) {
|
|
190
|
+
reject(new SessionExpiredError("DoorDash session has expired."));
|
|
191
|
+
} else if (parsed.__status === 403) {
|
|
192
|
+
reject(new RateLimitError("DoorDash rate limit hit (HTTP 403)."));
|
|
193
|
+
} else {
|
|
194
|
+
reject(
|
|
195
|
+
new Error(
|
|
196
|
+
parsed.__message ??
|
|
197
|
+
`HTTP ${parsed.__status}: ${parsed.__body ?? ""}`,
|
|
198
|
+
),
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
resolve(parsed);
|
|
204
|
+
}
|
|
205
|
+
} catch (err) {
|
|
206
|
+
clearTimeout(timeout);
|
|
207
|
+
ws.close();
|
|
208
|
+
reject(err);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
ws.onerror = () => {
|
|
213
|
+
clearTimeout(timeout);
|
|
214
|
+
reject(new SessionExpiredError("CDP connection failed."));
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
let lastRequestTime = 0;
|
|
220
|
+
|
|
221
|
+
async function graphql<T = unknown>(
|
|
222
|
+
operationName: string,
|
|
223
|
+
query: string,
|
|
224
|
+
variables: Record<string, unknown>,
|
|
225
|
+
_session?: DoorDashSession,
|
|
226
|
+
): Promise<T> {
|
|
227
|
+
if (!_session) requireSession();
|
|
228
|
+
|
|
229
|
+
const wsUrl = await findDoordashTab();
|
|
230
|
+
const url = `${GRAPHQL_BASE}/${operationName}?operation=${operationName}`;
|
|
231
|
+
const body = JSON.stringify({ operationName, variables, query });
|
|
232
|
+
|
|
233
|
+
const backoffSchedule = [5000, 10000, 20000];
|
|
234
|
+
|
|
235
|
+
for (let attempt = 0; ; attempt++) {
|
|
236
|
+
// Inter-request delay
|
|
237
|
+
const now = Date.now();
|
|
238
|
+
const elapsed = now - lastRequestTime;
|
|
239
|
+
if (lastRequestTime > 0 && elapsed < 2000) {
|
|
240
|
+
await new Promise((r) => setTimeout(r, 2000 - elapsed));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
lastRequestTime = Date.now();
|
|
245
|
+
const json = (await cdpFetch(wsUrl, url, body)) as GraphQLResponse<T>;
|
|
246
|
+
|
|
247
|
+
if (json.errors?.length) {
|
|
248
|
+
const msgs = json.errors
|
|
249
|
+
.map((e) => e.message || JSON.stringify(e))
|
|
250
|
+
.join("; ");
|
|
251
|
+
throw new ProviderError(
|
|
252
|
+
`Unexpected response from DoorDash API: ${msgs}`,
|
|
253
|
+
"doordash",
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
if (!json.data) {
|
|
257
|
+
throw new ProviderError(
|
|
258
|
+
"Unexpected response format from DoorDash API",
|
|
259
|
+
"doordash",
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
return json.data;
|
|
263
|
+
} catch (err) {
|
|
264
|
+
if (err instanceof RateLimitError && attempt < backoffSchedule.length) {
|
|
265
|
+
const delay = backoffSchedule[attempt];
|
|
266
|
+
process.stderr.write(
|
|
267
|
+
`[doordash] Rate limited, retrying in ${delay / 1000}s... (attempt ${attempt + 1}/${backoffSchedule.length})\n`,
|
|
268
|
+
);
|
|
269
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
throw err;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
// Public API
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
export interface SearchResult {
|
|
282
|
+
id: string;
|
|
283
|
+
name: string;
|
|
284
|
+
description?: string;
|
|
285
|
+
imageUrl?: string;
|
|
286
|
+
rating?: string;
|
|
287
|
+
deliveryFee?: string;
|
|
288
|
+
storeId?: string;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export async function search(query: string): Promise<SearchResult[]> {
|
|
292
|
+
const data = await graphql<{ autocompleteFacetFeed: DDFacetFeed }>(
|
|
293
|
+
"autocompleteFacetFeed",
|
|
294
|
+
getQuery("autocompleteFacetFeed", SEARCH_QUERY),
|
|
295
|
+
{ query, serializedBundleGlobalSearchContext: null },
|
|
296
|
+
);
|
|
297
|
+
return extractSearchResults(data.autocompleteFacetFeed);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Search for items/stores using the home page feed with a filter query.
|
|
302
|
+
* This works for convenience/retail stores that don't expose menus through storepageFeed.
|
|
303
|
+
*/
|
|
304
|
+
export async function searchItems(
|
|
305
|
+
query: string,
|
|
306
|
+
opts?: { debug?: boolean },
|
|
307
|
+
): Promise<SearchResult[]> {
|
|
308
|
+
const data = await graphql<{ homePageFacetFeed: DDFacetFeed }>(
|
|
309
|
+
"homePageFacetFeed",
|
|
310
|
+
getQuery("homePageFacetFeed", HOME_PAGE_QUERY),
|
|
311
|
+
{
|
|
312
|
+
cursor: null,
|
|
313
|
+
filterQuery: query,
|
|
314
|
+
displayHeader: false,
|
|
315
|
+
isDebug: false,
|
|
316
|
+
cuisineFilterVerticalIds: "",
|
|
317
|
+
},
|
|
318
|
+
);
|
|
319
|
+
if (opts?.debug) {
|
|
320
|
+
process.stderr.write(
|
|
321
|
+
`[debug] homePageFacetFeed raw: ${truncate(JSON.stringify(data.homePageFacetFeed), 3000, "")}\n`,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
return extractSearchResults(data.homePageFacetFeed);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Search for items within a specific retail/convenience store.
|
|
329
|
+
* Uses the retailSearch API (convenienceSearchQuery).
|
|
330
|
+
*/
|
|
331
|
+
export async function retailSearch(
|
|
332
|
+
storeId: string,
|
|
333
|
+
query: string,
|
|
334
|
+
opts?: { limit?: number },
|
|
335
|
+
): Promise<{
|
|
336
|
+
items: MenuItem[];
|
|
337
|
+
totalCount: number;
|
|
338
|
+
suggestedKeyword?: string;
|
|
339
|
+
}> {
|
|
340
|
+
const data = await graphql<{ retailSearch: DDRetailSearchResult }>(
|
|
341
|
+
"convenienceSearchQuery",
|
|
342
|
+
getQuery("convenienceSearchQuery", RETAIL_SEARCH_QUERY),
|
|
343
|
+
{
|
|
344
|
+
input: {
|
|
345
|
+
query,
|
|
346
|
+
storeId,
|
|
347
|
+
disableSpellCheck: false,
|
|
348
|
+
limit: opts?.limit ?? 30,
|
|
349
|
+
origin: "RETAIL_SEARCH",
|
|
350
|
+
filterQuery: "",
|
|
351
|
+
cursor: null,
|
|
352
|
+
aggregateStoreIds: [],
|
|
353
|
+
isDebug: false,
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
);
|
|
357
|
+
const result = data.retailSearch;
|
|
358
|
+
const legoItems = result.legoRetailItems ?? [];
|
|
359
|
+
const summary = result.searchSummary ?? {};
|
|
360
|
+
|
|
361
|
+
const items: MenuItem[] = [];
|
|
362
|
+
for (const facet of legoItems) {
|
|
363
|
+
try {
|
|
364
|
+
const customStr = facet.custom;
|
|
365
|
+
if (!customStr) continue;
|
|
366
|
+
const custom = JSON.parse(customStr) as DDRetailItemCustom;
|
|
367
|
+
const itemData = custom.item_data;
|
|
368
|
+
if (!itemData) continue;
|
|
369
|
+
const price = itemData.price;
|
|
370
|
+
const image = custom.image;
|
|
371
|
+
items.push({
|
|
372
|
+
id: String(itemData.item_id ?? ""),
|
|
373
|
+
name: String(itemData.item_name ?? ""),
|
|
374
|
+
description: itemData.description,
|
|
375
|
+
price: price?.display_string,
|
|
376
|
+
imageUrl: image?.remote?.uri,
|
|
377
|
+
storeId: String(itemData.store_id ?? ""),
|
|
378
|
+
menuId: String(itemData.menu_id ?? ""),
|
|
379
|
+
unitAmount: price?.unit_amount,
|
|
380
|
+
});
|
|
381
|
+
} catch {
|
|
382
|
+
/* skip malformed entries */
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
items,
|
|
388
|
+
totalCount: Number(summary.totalCount ?? items.length),
|
|
389
|
+
suggestedKeyword: summary.suggestedSearchKeyword,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export interface StoreInfo {
|
|
394
|
+
id: string;
|
|
395
|
+
name: string;
|
|
396
|
+
description?: string;
|
|
397
|
+
address?: string;
|
|
398
|
+
rating?: number;
|
|
399
|
+
numRatings?: string;
|
|
400
|
+
deliveryFee?: string;
|
|
401
|
+
deliveryTime?: string;
|
|
402
|
+
priceRange?: string;
|
|
403
|
+
categories: Array<{ id: string; name: string; numItems: number }>;
|
|
404
|
+
items: Array<MenuItem>;
|
|
405
|
+
/** True for convenience/pharmacy stores that require store-search for items */
|
|
406
|
+
isRetail?: boolean;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export interface MenuItem {
|
|
410
|
+
id: string;
|
|
411
|
+
name: string;
|
|
412
|
+
description?: string;
|
|
413
|
+
price?: string;
|
|
414
|
+
imageUrl?: string;
|
|
415
|
+
storeId?: string;
|
|
416
|
+
menuId?: string;
|
|
417
|
+
unitAmount?: number;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export async function getStoreMenu(
|
|
421
|
+
storeId: string,
|
|
422
|
+
menuId?: string,
|
|
423
|
+
opts?: { debug?: boolean },
|
|
424
|
+
): Promise<StoreInfo> {
|
|
425
|
+
const data = await graphql<{ storepageFeed: DDStorepageFeed }>(
|
|
426
|
+
"storepageFeed",
|
|
427
|
+
getQuery("storepageFeed", STORE_PAGE_QUERY),
|
|
428
|
+
{
|
|
429
|
+
storeId,
|
|
430
|
+
menuId: menuId ?? null,
|
|
431
|
+
isMerchantPreview: false,
|
|
432
|
+
fulfillmentType: "Delivery",
|
|
433
|
+
cursor: null,
|
|
434
|
+
scheduledTime: null,
|
|
435
|
+
entryPoint: "HomePage",
|
|
436
|
+
},
|
|
437
|
+
);
|
|
438
|
+
const feed = data.storepageFeed;
|
|
439
|
+
const rawItemLists = feed.itemLists ?? [];
|
|
440
|
+
const rawCarousels = feed.carousels ?? [];
|
|
441
|
+
|
|
442
|
+
if (opts?.debug) {
|
|
443
|
+
const menuBook = feed.menuBook;
|
|
444
|
+
process.stderr.write(
|
|
445
|
+
`[debug] storepageFeed keys: ${Object.keys(feed).join(", ")}\n` +
|
|
446
|
+
`[debug] itemLists count: ${rawItemLists.length}, carousels count: ${rawCarousels.length}\n` +
|
|
447
|
+
`[debug] menuBook: ${truncate(JSON.stringify(menuBook), 2000, "")}\n`,
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const info = extractStoreInfo(feed);
|
|
452
|
+
|
|
453
|
+
// If storepageFeed returned no items, try the retail store feed
|
|
454
|
+
// (convenience/pharmacy stores use a different API)
|
|
455
|
+
if (info.items.length === 0 && info.categories.length === 0) {
|
|
456
|
+
if (opts?.debug) {
|
|
457
|
+
process.stderr.write(
|
|
458
|
+
"[debug] No items from storepageFeed, trying retailStorePageFeed...\n",
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
return getRetailStoreMenu(storeId, opts);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return info;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Get menu for a retail/convenience store (CVS, Duane Reade, etc.).
|
|
469
|
+
* These stores use `retailStorePageFeed` instead of `storepageFeed`.
|
|
470
|
+
*/
|
|
471
|
+
export async function getRetailStoreMenu(
|
|
472
|
+
storeId: string,
|
|
473
|
+
opts?: { debug?: boolean },
|
|
474
|
+
): Promise<StoreInfo> {
|
|
475
|
+
const data = await graphql<{ retailStorePageFeed: DDRetailStorePageFeed }>(
|
|
476
|
+
"storeFeed",
|
|
477
|
+
getQuery("storeFeed", RETAIL_STORE_FEED_QUERY),
|
|
478
|
+
{
|
|
479
|
+
storeId,
|
|
480
|
+
attrSrc: "store",
|
|
481
|
+
cursor: null,
|
|
482
|
+
enableDebug: false,
|
|
483
|
+
},
|
|
484
|
+
);
|
|
485
|
+
if (opts?.debug) {
|
|
486
|
+
const feed = data.retailStorePageFeed;
|
|
487
|
+
const l1Cats = feed.l1Categories ?? [];
|
|
488
|
+
const collections = feed.collections ?? [];
|
|
489
|
+
const page = feed.page;
|
|
490
|
+
process.stderr.write(
|
|
491
|
+
`[debug] retailStorePageFeed keys: ${Object.keys(feed).join(", ")}\n` +
|
|
492
|
+
`[debug] l1Categories count: ${l1Cats.length}, collections count: ${collections.length}\n` +
|
|
493
|
+
`[debug] page: ${truncate(JSON.stringify(page), 500, "")}\n` +
|
|
494
|
+
`[debug] collections sample: ${truncate(JSON.stringify(collections.slice(0, 2)), 2000, "")}\n`,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
return extractRetailStoreInfo(data.retailStorePageFeed);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export interface ItemDetails {
|
|
501
|
+
id: string;
|
|
502
|
+
name: string;
|
|
503
|
+
description?: string;
|
|
504
|
+
price?: string;
|
|
505
|
+
unitAmount?: number;
|
|
506
|
+
currency?: string;
|
|
507
|
+
imageUrl?: string;
|
|
508
|
+
menuId?: string;
|
|
509
|
+
options: Array<{
|
|
510
|
+
id: string;
|
|
511
|
+
name: string;
|
|
512
|
+
required: boolean;
|
|
513
|
+
minSelections?: number;
|
|
514
|
+
maxSelections?: number;
|
|
515
|
+
choices: Array<{
|
|
516
|
+
id: string;
|
|
517
|
+
name: string;
|
|
518
|
+
price?: string;
|
|
519
|
+
unitAmount?: number;
|
|
520
|
+
defaultQuantity?: number;
|
|
521
|
+
nestedOptions?: Array<{
|
|
522
|
+
id: string;
|
|
523
|
+
name: string;
|
|
524
|
+
required: boolean;
|
|
525
|
+
choices: Array<{
|
|
526
|
+
id: string;
|
|
527
|
+
name: string;
|
|
528
|
+
price?: string;
|
|
529
|
+
}>;
|
|
530
|
+
}>;
|
|
531
|
+
}>;
|
|
532
|
+
}>;
|
|
533
|
+
specialInstructionsConfig?: {
|
|
534
|
+
maxLength: number;
|
|
535
|
+
placeholderText?: string;
|
|
536
|
+
isEnabled: boolean;
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
export async function getItemDetails(
|
|
541
|
+
storeId: string,
|
|
542
|
+
itemId: string,
|
|
543
|
+
): Promise<ItemDetails> {
|
|
544
|
+
const data = await graphql<{ itemPage: DDItemPage }>(
|
|
545
|
+
"itemPage",
|
|
546
|
+
getQuery("itemPage", ITEM_PAGE_QUERY),
|
|
547
|
+
{
|
|
548
|
+
storeId,
|
|
549
|
+
itemId,
|
|
550
|
+
isMerchantPreview: false,
|
|
551
|
+
isNested: false,
|
|
552
|
+
shouldFetchPresetCarousels: false,
|
|
553
|
+
fulfillmentType: "Delivery",
|
|
554
|
+
shouldFetchStoreLiteData: false,
|
|
555
|
+
},
|
|
556
|
+
);
|
|
557
|
+
return extractItemDetails(data.itemPage);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export interface CartSummary {
|
|
561
|
+
cartId: string;
|
|
562
|
+
storeName?: string;
|
|
563
|
+
storeId?: string;
|
|
564
|
+
subtotal?: number;
|
|
565
|
+
total?: number;
|
|
566
|
+
items: Array<{
|
|
567
|
+
id: string;
|
|
568
|
+
name: string;
|
|
569
|
+
quantity: number;
|
|
570
|
+
price?: string;
|
|
571
|
+
}>;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
export async function addToCart(opts: {
|
|
575
|
+
storeId: string;
|
|
576
|
+
menuId: string;
|
|
577
|
+
itemId: string;
|
|
578
|
+
itemName: string;
|
|
579
|
+
itemDescription?: string;
|
|
580
|
+
unitPrice: number;
|
|
581
|
+
quantity?: number;
|
|
582
|
+
cartId?: string;
|
|
583
|
+
nestedOptions?: string;
|
|
584
|
+
specialInstructions?: string;
|
|
585
|
+
}): Promise<CartSummary> {
|
|
586
|
+
// Use updateCartItemV2 — DoorDash now uses this for both adding and updating cart items
|
|
587
|
+
const data = await graphql<{ updateCartItemV2: DDCart }>(
|
|
588
|
+
"updateCartItem",
|
|
589
|
+
getQuery("updateCartItem", UPDATE_CART_ITEM_QUERY),
|
|
590
|
+
{
|
|
591
|
+
updateCartItemApiParams: {
|
|
592
|
+
cartId: opts.cartId ?? "",
|
|
593
|
+
cartItemId: "",
|
|
594
|
+
itemId: opts.itemId,
|
|
595
|
+
itemName: opts.itemName,
|
|
596
|
+
itemDescription: opts.itemDescription ?? "",
|
|
597
|
+
currency: "USD",
|
|
598
|
+
quantity: opts.quantity ?? 1,
|
|
599
|
+
unitPrice: opts.unitPrice,
|
|
600
|
+
storeId: opts.storeId,
|
|
601
|
+
menuId: opts.menuId,
|
|
602
|
+
creatorId: "",
|
|
603
|
+
nestedOptions: opts.nestedOptions ?? "[]",
|
|
604
|
+
specialInstructions: opts.specialInstructions ?? "",
|
|
605
|
+
substitutionPreference: "contact",
|
|
606
|
+
purchaseTypeOptions: {
|
|
607
|
+
purchaseType: "PURCHASE_TYPE_UNIT",
|
|
608
|
+
unit: "qty",
|
|
609
|
+
estimatedPricingDescription: "",
|
|
610
|
+
continuousQuantity: 0,
|
|
611
|
+
},
|
|
612
|
+
isAdsItem: false,
|
|
613
|
+
isBundle: false,
|
|
614
|
+
bundleType: "BUNDLE_TYPE_UNSPECIFIED",
|
|
615
|
+
cartFilter: null,
|
|
616
|
+
},
|
|
617
|
+
fulfillmentContext: {
|
|
618
|
+
shouldUpdateFulfillment: false,
|
|
619
|
+
fulfillmentType: "Delivery",
|
|
620
|
+
},
|
|
621
|
+
returnCartFromOrderService: false,
|
|
622
|
+
shouldKeepOnlyOneActiveCart: false,
|
|
623
|
+
},
|
|
624
|
+
);
|
|
625
|
+
return extractCartSummary(data.updateCartItemV2);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
export async function removeFromCart(
|
|
629
|
+
cartId: string,
|
|
630
|
+
itemId: string,
|
|
631
|
+
): Promise<CartSummary> {
|
|
632
|
+
const data = await graphql<{ removeCartItemV2: DDCart }>(
|
|
633
|
+
"removeCartItem",
|
|
634
|
+
getQuery("removeCartItem", REMOVE_CART_ITEM_QUERY),
|
|
635
|
+
{
|
|
636
|
+
cartId,
|
|
637
|
+
itemId,
|
|
638
|
+
returnCartFromOrderService: false,
|
|
639
|
+
monitoringContext: { isGroup: false },
|
|
640
|
+
cartFilter: null,
|
|
641
|
+
cartContext: { deleteBundleCarts: false },
|
|
642
|
+
},
|
|
643
|
+
);
|
|
644
|
+
return extractCartSummary(data.removeCartItemV2);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
export async function viewCart(cartId: string): Promise<CartSummary> {
|
|
648
|
+
const data = await graphql<{ orderCart: DDCart }>(
|
|
649
|
+
"detailedCartItems",
|
|
650
|
+
getQuery("detailedCartItems", DETAILED_CART_QUERY),
|
|
651
|
+
{ orderCartId: cartId, isCardPayment: true },
|
|
652
|
+
);
|
|
653
|
+
return extractCartSummary(data.orderCart);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
export async function listCarts(storeId?: string): Promise<CartSummary[]> {
|
|
657
|
+
const input: {
|
|
658
|
+
cartFilter: { shouldIncludeSubmitted: boolean };
|
|
659
|
+
cartContextFilter?: {
|
|
660
|
+
experienceCase: string;
|
|
661
|
+
multiCartExperienceContext: { storeId: string };
|
|
662
|
+
};
|
|
663
|
+
} = {
|
|
664
|
+
cartFilter: { shouldIncludeSubmitted: true },
|
|
665
|
+
};
|
|
666
|
+
if (storeId) {
|
|
667
|
+
input.cartContextFilter = {
|
|
668
|
+
experienceCase: "MULTI_CART_EXPERIENCE_CONTEXT",
|
|
669
|
+
multiCartExperienceContext: { storeId },
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
const data = await graphql<{ listCarts: DDCart[] }>(
|
|
673
|
+
"listCarts",
|
|
674
|
+
getQuery("listCarts", LIST_CARTS_QUERY),
|
|
675
|
+
{ input },
|
|
676
|
+
);
|
|
677
|
+
return (data.listCarts ?? []).map(extractCartSummary);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
export interface DropoffOption {
|
|
681
|
+
id: string;
|
|
682
|
+
displayString: string;
|
|
683
|
+
isDefault: boolean;
|
|
684
|
+
isEnabled: boolean;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
export async function getDropoffOptions(
|
|
688
|
+
cartId: string,
|
|
689
|
+
addressId?: string,
|
|
690
|
+
): Promise<DropoffOption[]> {
|
|
691
|
+
const data = await graphql<{
|
|
692
|
+
dropoffOptions: DDDropoffOption[];
|
|
693
|
+
}>("dropoffOptions", getQuery("dropoffOptions", DROPOFF_OPTIONS_QUERY), {
|
|
694
|
+
cartId,
|
|
695
|
+
addressId: addressId ?? null,
|
|
696
|
+
});
|
|
697
|
+
return (data.dropoffOptions ?? []).map((o) => ({
|
|
698
|
+
id: String(o.id),
|
|
699
|
+
displayString: String(o.displayString ?? ""),
|
|
700
|
+
isDefault: Boolean(o.isDefault),
|
|
701
|
+
isEnabled: Boolean(o.isEnabled),
|
|
702
|
+
}));
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
export interface PaymentMethod {
|
|
706
|
+
id: string;
|
|
707
|
+
type: string;
|
|
708
|
+
last4: string;
|
|
709
|
+
isDefault: boolean;
|
|
710
|
+
uuid: string;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
export async function getPaymentMethods(): Promise<PaymentMethod[]> {
|
|
714
|
+
const data = await graphql<{ getPaymentMethodList: DDPaymentMethod[] }>(
|
|
715
|
+
"paymentMethodQuery",
|
|
716
|
+
getQuery("paymentMethodQuery", PAYMENT_METHODS_QUERY),
|
|
717
|
+
{
|
|
718
|
+
country: "US",
|
|
719
|
+
usePaymentConfigQuery: true,
|
|
720
|
+
usePaymentConfigQueryV2: true,
|
|
721
|
+
},
|
|
722
|
+
);
|
|
723
|
+
return (data.getPaymentMethodList ?? []).map((p) => ({
|
|
724
|
+
id: String(p.id ?? ""),
|
|
725
|
+
type: String(p.type ?? ""),
|
|
726
|
+
last4: String(p.last4 ?? ""),
|
|
727
|
+
isDefault: Boolean(p.isDefault),
|
|
728
|
+
uuid: String(p.paymentMethodUuid ?? p.uuid ?? ""),
|
|
729
|
+
}));
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
export interface PlaceOrderResult {
|
|
733
|
+
cartId: string;
|
|
734
|
+
orderUuid: string;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
export async function placeOrder(opts: {
|
|
738
|
+
cartId: string;
|
|
739
|
+
storeId: string;
|
|
740
|
+
total: number;
|
|
741
|
+
tipAmount?: number;
|
|
742
|
+
deliveryOptionType?: string;
|
|
743
|
+
dropoffOptionId?: string;
|
|
744
|
+
paymentMethodUuid?: string;
|
|
745
|
+
paymentMethodType?: string;
|
|
746
|
+
}): Promise<PlaceOrderResult> {
|
|
747
|
+
// If no payment method specified, use the default one
|
|
748
|
+
let pmUuid = opts.paymentMethodUuid;
|
|
749
|
+
let pmType = opts.paymentMethodType ?? "Card";
|
|
750
|
+
if (!pmUuid) {
|
|
751
|
+
const methods = await getPaymentMethods();
|
|
752
|
+
const defaultMethod = methods.find((m) => m.isDefault) ?? methods[0];
|
|
753
|
+
if (!defaultMethod) {
|
|
754
|
+
throw new ProviderError(
|
|
755
|
+
"No payment method found. Add a payment method in the DoorDash app first.",
|
|
756
|
+
"doordash",
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
pmUuid = defaultMethod.uuid;
|
|
760
|
+
// defaultMethod.type is the card brand (e.g. "Visa"), not the PaymentMethodType enum
|
|
761
|
+
pmType = "Card";
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Build dropoff preferences
|
|
765
|
+
const dropoffPreferences = opts.dropoffOptionId
|
|
766
|
+
? JSON.stringify([
|
|
767
|
+
{
|
|
768
|
+
typename: "DropoffPreference",
|
|
769
|
+
option_id: opts.dropoffOptionId,
|
|
770
|
+
is_default: true,
|
|
771
|
+
instructions: "",
|
|
772
|
+
},
|
|
773
|
+
])
|
|
774
|
+
: "[]";
|
|
775
|
+
|
|
776
|
+
const data = await graphql<{ createOrderFromCart: DDCreateOrderResult }>(
|
|
777
|
+
"createOrderFromCart",
|
|
778
|
+
getQuery("createOrderFromCart", CREATE_ORDER_FROM_CART_QUERY),
|
|
779
|
+
{
|
|
780
|
+
cartId: opts.cartId,
|
|
781
|
+
storeId: opts.storeId,
|
|
782
|
+
total: opts.total,
|
|
783
|
+
sosDeliveryFee: 0,
|
|
784
|
+
isPickupOrder: false,
|
|
785
|
+
verifiedAgeRequirement: false,
|
|
786
|
+
deliveryTime: "ASAP",
|
|
787
|
+
menuOptions: null,
|
|
788
|
+
attributionData: "{}",
|
|
789
|
+
fulfillsOwnDeliveries: false,
|
|
790
|
+
teamId: null,
|
|
791
|
+
budgetId: null,
|
|
792
|
+
giftOptions: null,
|
|
793
|
+
recipientShippingDetails: null,
|
|
794
|
+
tipAmounts: [{ tipRecipient: "DASHER", amount: opts.tipAmount ?? 0 }],
|
|
795
|
+
paymentMethod: null,
|
|
796
|
+
deliveryOptionType: opts.deliveryOptionType ?? "STANDARD",
|
|
797
|
+
workOrderOptions: null,
|
|
798
|
+
isCardPayment: true,
|
|
799
|
+
clientFraudContext: null,
|
|
800
|
+
programId: "",
|
|
801
|
+
membershipId: "",
|
|
802
|
+
dropoffPreferences,
|
|
803
|
+
monitoringContext: { isGroup: false },
|
|
804
|
+
routineReorderDetails: {},
|
|
805
|
+
supplementalPaymentDetailsList: [],
|
|
806
|
+
shouldApplyCredits: true,
|
|
807
|
+
dasherPickupInstructions: "",
|
|
808
|
+
paymentMethodUuid: pmUuid,
|
|
809
|
+
paymentMethodType: pmType,
|
|
810
|
+
deviceTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
811
|
+
},
|
|
812
|
+
);
|
|
813
|
+
|
|
814
|
+
return {
|
|
815
|
+
cartId: String(data.createOrderFromCart.cartId ?? ""),
|
|
816
|
+
orderUuid: String(data.createOrderFromCart.orderUuid ?? ""),
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// ---------------------------------------------------------------------------
|
|
821
|
+
// Response extraction helpers
|
|
822
|
+
// ---------------------------------------------------------------------------
|
|
823
|
+
|
|
824
|
+
function extractSearchResults(
|
|
825
|
+
feed: DDFacetFeed | null | undefined,
|
|
826
|
+
): SearchResult[] {
|
|
827
|
+
const results: SearchResult[] = [];
|
|
828
|
+
if (!feed) return results;
|
|
829
|
+
const bodies = feed.body ?? [];
|
|
830
|
+
for (const section of bodies) {
|
|
831
|
+
const items = section.body ?? [];
|
|
832
|
+
for (const item of items) {
|
|
833
|
+
const text = item.text;
|
|
834
|
+
const images = item.images;
|
|
835
|
+
const events = item.events;
|
|
836
|
+
let storeId: string | undefined;
|
|
837
|
+
// Try click event data first
|
|
838
|
+
if (events?.click?.data) {
|
|
839
|
+
try {
|
|
840
|
+
const clickData = JSON.parse(events.click.data) as DDSearchClickData;
|
|
841
|
+
storeId = String(clickData.store_id ?? clickData.storeId ?? "");
|
|
842
|
+
} catch {
|
|
843
|
+
/* ignore */
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
// Fall back to parsing from the item ID (format: "row.search-result:STORE_ID:INDEX")
|
|
847
|
+
if (!storeId) {
|
|
848
|
+
const idStr = String(item.id ?? "");
|
|
849
|
+
const match = idStr.match(/search-result:(\d+)/);
|
|
850
|
+
if (match) storeId = match[1];
|
|
851
|
+
}
|
|
852
|
+
if (text?.title) {
|
|
853
|
+
results.push({
|
|
854
|
+
id: String(item.id ?? ""),
|
|
855
|
+
name: text.title,
|
|
856
|
+
description: text.subtitle || text.description,
|
|
857
|
+
imageUrl: images?.main?.uri,
|
|
858
|
+
rating: text.accessory,
|
|
859
|
+
storeId,
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
return results;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function extractStoreInfo(feed: DDStorepageFeed): StoreInfo {
|
|
868
|
+
const header = feed.storeHeader ?? {};
|
|
869
|
+
const menuBook = feed.menuBook ?? {};
|
|
870
|
+
const itemLists = feed.itemLists ?? [];
|
|
871
|
+
const address = header.address;
|
|
872
|
+
const ratings = header.ratings;
|
|
873
|
+
const deliveryFee = header.deliveryFeeLayout;
|
|
874
|
+
const deliveryTime = header.deliveryTimeLayout;
|
|
875
|
+
|
|
876
|
+
const categories = (menuBook.menuCategories ?? []).map(
|
|
877
|
+
(c: DDMenuCategory) => ({
|
|
878
|
+
id: String(c.id),
|
|
879
|
+
name: String(c.name),
|
|
880
|
+
numItems: Number(c.numItems ?? 0),
|
|
881
|
+
}),
|
|
882
|
+
);
|
|
883
|
+
|
|
884
|
+
const items: MenuItem[] = [];
|
|
885
|
+
for (const list of itemLists) {
|
|
886
|
+
for (const item of list.items ?? []) {
|
|
887
|
+
items.push({
|
|
888
|
+
id: String(item.id),
|
|
889
|
+
name: String(item.name ?? ""),
|
|
890
|
+
description: item.description,
|
|
891
|
+
price: item.displayPrice,
|
|
892
|
+
imageUrl: item.imageUrl,
|
|
893
|
+
storeId: item.storeId,
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// Also extract from carousels (used by convenience/pharmacy stores)
|
|
899
|
+
const carousels = feed.carousels ?? [];
|
|
900
|
+
for (const carousel of carousels) {
|
|
901
|
+
for (const item of carousel.items ?? []) {
|
|
902
|
+
items.push({
|
|
903
|
+
id: String(item.id),
|
|
904
|
+
name: String(item.name ?? ""),
|
|
905
|
+
description: item.description,
|
|
906
|
+
price: item.displayPrice,
|
|
907
|
+
imageUrl: item.imgUrl,
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
return {
|
|
913
|
+
id: String(header.id ?? ""),
|
|
914
|
+
name: String(header.name ?? ""),
|
|
915
|
+
description: header.description,
|
|
916
|
+
address: address?.displayAddress,
|
|
917
|
+
rating: ratings?.averageRating,
|
|
918
|
+
numRatings: ratings?.numRatingsDisplayString,
|
|
919
|
+
deliveryFee: deliveryFee?.title,
|
|
920
|
+
deliveryTime: deliveryTime?.title,
|
|
921
|
+
priceRange: header.priceRangeDisplayString,
|
|
922
|
+
categories,
|
|
923
|
+
items,
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function extractNestedOptions(
|
|
928
|
+
extrasList: DDNestedExtra[],
|
|
929
|
+
): ItemDetails["options"][number]["choices"][number]["nestedOptions"] {
|
|
930
|
+
return extrasList.map((nested) => ({
|
|
931
|
+
id: String(nested.id),
|
|
932
|
+
name: String(nested.name ?? ""),
|
|
933
|
+
required: !nested.isOptional,
|
|
934
|
+
choices: (nested.options ?? []).map((o) => ({
|
|
935
|
+
id: String(o.id),
|
|
936
|
+
name: String(o.name ?? ""),
|
|
937
|
+
price: o.displayString,
|
|
938
|
+
})),
|
|
939
|
+
}));
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function extractItemDetails(page: DDItemPage): ItemDetails {
|
|
943
|
+
const header = page.itemHeader ?? {};
|
|
944
|
+
const optionLists = page.optionLists ?? [];
|
|
945
|
+
const itemPreferences = page.itemPreferences;
|
|
946
|
+
|
|
947
|
+
const result: ItemDetails = {
|
|
948
|
+
id: String(header.id ?? ""),
|
|
949
|
+
name: String(header.name ?? ""),
|
|
950
|
+
description: header.description,
|
|
951
|
+
price: header.displayString,
|
|
952
|
+
unitAmount: header.unitAmount,
|
|
953
|
+
currency: header.currency,
|
|
954
|
+
imageUrl: header.imgUrl,
|
|
955
|
+
menuId: header.menuId,
|
|
956
|
+
options: optionLists.map((ol: DDOptionList) => {
|
|
957
|
+
const choices = (ol.options ?? []).map((o: DDOptionChoice) => {
|
|
958
|
+
const choice: ItemDetails["options"][number]["choices"][number] = {
|
|
959
|
+
id: String(o.id),
|
|
960
|
+
name: String(o.name ?? ""),
|
|
961
|
+
price: o.displayString,
|
|
962
|
+
unitAmount: o.unitAmount,
|
|
963
|
+
defaultQuantity: o.defaultQuantity,
|
|
964
|
+
};
|
|
965
|
+
const nestedExtrasList = o.nestedExtrasList ?? [];
|
|
966
|
+
if (nestedExtrasList.length > 0) {
|
|
967
|
+
choice.nestedOptions = extractNestedOptions(nestedExtrasList);
|
|
968
|
+
}
|
|
969
|
+
return choice;
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
return {
|
|
973
|
+
id: String(ol.id),
|
|
974
|
+
name: String(ol.name ?? ""),
|
|
975
|
+
required: !ol.isOptional,
|
|
976
|
+
minSelections: ol.minNumOptions,
|
|
977
|
+
maxSelections: ol.maxNumOptions,
|
|
978
|
+
choices,
|
|
979
|
+
};
|
|
980
|
+
}),
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
if (itemPreferences) {
|
|
984
|
+
const specialInstructions = itemPreferences.specialInstructions ?? {};
|
|
985
|
+
result.specialInstructionsConfig = {
|
|
986
|
+
maxLength: Number(specialInstructions.characterMaxLength ?? 500),
|
|
987
|
+
placeholderText: specialInstructions.placeholderText,
|
|
988
|
+
isEnabled: specialInstructions.isEnabled !== false,
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
return result;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function extractRetailStoreInfo(feed: DDRetailStorePageFeed): StoreInfo {
|
|
996
|
+
const storeDetails = feed.storeDetails ?? {};
|
|
997
|
+
const storeHeader = storeDetails.storeHeader ?? {};
|
|
998
|
+
const ratings = storeHeader.ratings;
|
|
999
|
+
const deliveryFee = storeHeader.deliveryFeeLayout;
|
|
1000
|
+
const status = storeHeader.status;
|
|
1001
|
+
|
|
1002
|
+
const l1Categories = feed.l1Categories ?? [];
|
|
1003
|
+
const collections = feed.collections ?? [];
|
|
1004
|
+
|
|
1005
|
+
const categories = l1Categories.map((c) => ({
|
|
1006
|
+
id: String(c.id),
|
|
1007
|
+
name: String(c.name),
|
|
1008
|
+
numItems: Number(c.numItems ?? 0),
|
|
1009
|
+
}));
|
|
1010
|
+
|
|
1011
|
+
const items: MenuItem[] = [];
|
|
1012
|
+
for (const collection of collections) {
|
|
1013
|
+
// Retail collections use `products`, not `items`
|
|
1014
|
+
const products = collection.products ?? collection.items ?? [];
|
|
1015
|
+
for (const item of products) {
|
|
1016
|
+
const price = item.price;
|
|
1017
|
+
items.push({
|
|
1018
|
+
id: String(item.id),
|
|
1019
|
+
name: String(item.name ?? ""),
|
|
1020
|
+
description: item.description,
|
|
1021
|
+
price: price?.displayString ?? item.displayPrice,
|
|
1022
|
+
imageUrl: item.imageUrl ?? item.imgUrl,
|
|
1023
|
+
storeId: item.storeId,
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
const address = storeHeader.address;
|
|
1029
|
+
|
|
1030
|
+
return {
|
|
1031
|
+
id: String(storeDetails.id ?? ""),
|
|
1032
|
+
name: String(storeHeader.name ?? storeDetails.name ?? ""),
|
|
1033
|
+
description: storeHeader.description,
|
|
1034
|
+
address: address?.displayAddress,
|
|
1035
|
+
rating: ratings?.averageRating,
|
|
1036
|
+
numRatings: ratings?.numRatingsDisplayString,
|
|
1037
|
+
deliveryFee: deliveryFee?.title,
|
|
1038
|
+
deliveryTime: status?.delivery?.etaDisplayString,
|
|
1039
|
+
priceRange: storeHeader.priceRangeDisplayString,
|
|
1040
|
+
categories,
|
|
1041
|
+
items,
|
|
1042
|
+
isRetail: true,
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
function extractCartSummary(cart: DDCart): CartSummary {
|
|
1047
|
+
const restaurant = cart.restaurant ?? {};
|
|
1048
|
+
const orders = cart.orders ?? [];
|
|
1049
|
+
|
|
1050
|
+
const items: CartSummary["items"] = [];
|
|
1051
|
+
for (const order of orders) {
|
|
1052
|
+
for (const oi of order.orderItems ?? []) {
|
|
1053
|
+
const item = oi.item ?? {};
|
|
1054
|
+
items.push({
|
|
1055
|
+
id: String(oi.id ?? ""),
|
|
1056
|
+
name: String(item.name ?? ""),
|
|
1057
|
+
quantity: Number(oi.quantity ?? 1),
|
|
1058
|
+
price: oi.priceDisplayString,
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
return {
|
|
1064
|
+
cartId: String(cart.id ?? ""),
|
|
1065
|
+
storeName: restaurant.name,
|
|
1066
|
+
storeId: restaurant.id,
|
|
1067
|
+
subtotal: cart.subtotal,
|
|
1068
|
+
total: cart.total,
|
|
1069
|
+
items,
|
|
1070
|
+
};
|
|
1071
|
+
}
|