@striderlabs/mcp-gap 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/README.md +210 -0
- package/build.js +15 -0
- package/dist/index.js +21985 -0
- package/package.json +42 -0
- package/server.json +20 -0
- package/src/auth.ts +82 -0
- package/src/browser.ts +902 -0
- package/src/index.ts +515 -0
- package/tsconfig.json +14 -0
package/src/browser.ts
ADDED
|
@@ -0,0 +1,902 @@
|
|
|
1
|
+
import { chromium, Browser, BrowserContext, Page } from 'patchright';
|
|
2
|
+
import { saveCookies, loadCookies, saveSessionInfo, loadSessionInfo, SessionInfo } from './auth.js';
|
|
3
|
+
|
|
4
|
+
export type Brand = 'gap' | 'old-navy' | 'banana-republic' | 'athleta';
|
|
5
|
+
|
|
6
|
+
const BRAND_URLS: Record<Brand, string> = {
|
|
7
|
+
gap: 'https://www.gap.com',
|
|
8
|
+
'old-navy': 'https://www.oldnavy.com',
|
|
9
|
+
'banana-republic': 'https://www.bananarepublic.com',
|
|
10
|
+
athleta: 'https://www.athleta.com',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export interface ProductResult {
|
|
14
|
+
name: string;
|
|
15
|
+
price: string;
|
|
16
|
+
originalPrice?: string;
|
|
17
|
+
brand: Brand;
|
|
18
|
+
productId?: string;
|
|
19
|
+
url?: string;
|
|
20
|
+
imageUrl?: string;
|
|
21
|
+
colors?: string[];
|
|
22
|
+
inStock: boolean;
|
|
23
|
+
isOnSale: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ProductDetails {
|
|
27
|
+
name: string;
|
|
28
|
+
price: string;
|
|
29
|
+
originalPrice?: string;
|
|
30
|
+
brand: Brand;
|
|
31
|
+
productId: string;
|
|
32
|
+
url: string;
|
|
33
|
+
description?: string;
|
|
34
|
+
availableSizes: string[];
|
|
35
|
+
availableColors: string[];
|
|
36
|
+
fitType?: string;
|
|
37
|
+
material?: string;
|
|
38
|
+
careInstructions?: string;
|
|
39
|
+
inStock: boolean;
|
|
40
|
+
isOnSale: boolean;
|
|
41
|
+
rating?: string;
|
|
42
|
+
reviewCount?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface StoreInventory {
|
|
46
|
+
storeId: string;
|
|
47
|
+
storeName: string;
|
|
48
|
+
address: string;
|
|
49
|
+
inStock: boolean;
|
|
50
|
+
quantity?: string;
|
|
51
|
+
distance?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface CartItem {
|
|
55
|
+
name: string;
|
|
56
|
+
size: string;
|
|
57
|
+
color: string;
|
|
58
|
+
quantity: number;
|
|
59
|
+
price: string;
|
|
60
|
+
productId?: string;
|
|
61
|
+
brand: Brand;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface CartSummary {
|
|
65
|
+
items: CartItem[];
|
|
66
|
+
subtotal: string;
|
|
67
|
+
estimatedTax?: string;
|
|
68
|
+
promotionDiscount?: string;
|
|
69
|
+
gapCashApplied?: string;
|
|
70
|
+
total: string;
|
|
71
|
+
itemCount: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface StoreLocation {
|
|
75
|
+
storeId: string;
|
|
76
|
+
name: string;
|
|
77
|
+
address: string;
|
|
78
|
+
city: string;
|
|
79
|
+
state: string;
|
|
80
|
+
zip: string;
|
|
81
|
+
phone?: string;
|
|
82
|
+
hours?: string;
|
|
83
|
+
distance?: string;
|
|
84
|
+
brands: Brand[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface OrderInfo {
|
|
88
|
+
orderId: string;
|
|
89
|
+
status: string;
|
|
90
|
+
placedDate: string;
|
|
91
|
+
estimatedDelivery?: string;
|
|
92
|
+
items: string[];
|
|
93
|
+
total: string;
|
|
94
|
+
trackingNumber?: string;
|
|
95
|
+
carrier?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface RewardsInfo {
|
|
99
|
+
gapCashBalance: string;
|
|
100
|
+
gapCashExpiry?: string;
|
|
101
|
+
rewardsPoints: string;
|
|
102
|
+
memberLevel?: string;
|
|
103
|
+
nextReward?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface ReturnConfirmation {
|
|
107
|
+
returnId?: string;
|
|
108
|
+
orderId: string;
|
|
109
|
+
method: string;
|
|
110
|
+
instructions: string;
|
|
111
|
+
labelUrl?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let browser: Browser | null = null;
|
|
115
|
+
let context: BrowserContext | null = null;
|
|
116
|
+
|
|
117
|
+
async function getBrowser(): Promise<{ browser: Browser; context: BrowserContext }> {
|
|
118
|
+
if (!browser || !context) {
|
|
119
|
+
browser = await chromium.launch({
|
|
120
|
+
headless: true,
|
|
121
|
+
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
122
|
+
});
|
|
123
|
+
context = await browser.newContext({
|
|
124
|
+
viewport: { width: 1280, height: 720 },
|
|
125
|
+
locale: 'en-US',
|
|
126
|
+
timezoneId: 'America/New_York',
|
|
127
|
+
userAgent:
|
|
128
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
129
|
+
});
|
|
130
|
+
await loadCookies(context);
|
|
131
|
+
|
|
132
|
+
process.on('SIGINT', cleanup);
|
|
133
|
+
process.on('SIGTERM', cleanup);
|
|
134
|
+
}
|
|
135
|
+
return { browser, context };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function cleanup(): Promise<void> {
|
|
139
|
+
if (context) await context.close().catch(() => {});
|
|
140
|
+
if (browser) await browser.close().catch(() => {});
|
|
141
|
+
process.exit(0);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function newPage(): Promise<Page> {
|
|
145
|
+
const { context } = await getBrowser();
|
|
146
|
+
const page = await context.newPage();
|
|
147
|
+
page.setDefaultTimeout(30000);
|
|
148
|
+
return page;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function checkLoginStatus(): Promise<SessionInfo> {
|
|
152
|
+
const saved = loadSessionInfo();
|
|
153
|
+
const page = await newPage();
|
|
154
|
+
try {
|
|
155
|
+
await page.goto('https://www.gap.com', { waitUntil: 'domcontentloaded' });
|
|
156
|
+
await page.waitForTimeout(2000);
|
|
157
|
+
|
|
158
|
+
const isLoggedIn = await page.evaluate(() => {
|
|
159
|
+
const accountEl = document.querySelector('[data-testid="account-nav"], .account-nav, [aria-label*="Account"]');
|
|
160
|
+
if (!accountEl) return false;
|
|
161
|
+
const text = accountEl.textContent || '';
|
|
162
|
+
return !text.toLowerCase().includes('sign in') && !text.toLowerCase().includes('log in');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const info: SessionInfo = {
|
|
166
|
+
isLoggedIn,
|
|
167
|
+
userEmail: saved?.userEmail,
|
|
168
|
+
userName: saved?.userName,
|
|
169
|
+
lastUpdated: new Date().toISOString(),
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
if (isLoggedIn) {
|
|
173
|
+
try {
|
|
174
|
+
const rewardsText = await page.$eval(
|
|
175
|
+
'[data-testid="rewards-balance"], .rewards-balance, [class*="reward"]',
|
|
176
|
+
(el) => el.textContent?.trim() || '',
|
|
177
|
+
);
|
|
178
|
+
info.gapCashBalance = rewardsText;
|
|
179
|
+
} catch {}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
saveSessionInfo(info);
|
|
183
|
+
return info;
|
|
184
|
+
} finally {
|
|
185
|
+
await page.close();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function initiateLogin(brand: Brand = 'gap'): Promise<{ url: string; instructions: string }> {
|
|
190
|
+
const baseUrl = BRAND_URLS[brand];
|
|
191
|
+
const loginUrl = `${baseUrl}/account/login.html`;
|
|
192
|
+
const page = await newPage();
|
|
193
|
+
try {
|
|
194
|
+
await page.goto(loginUrl, { waitUntil: 'domcontentloaded' });
|
|
195
|
+
return {
|
|
196
|
+
url: loginUrl,
|
|
197
|
+
instructions:
|
|
198
|
+
`To log in to your Gap Inc. account:\n` +
|
|
199
|
+
`1. Open this URL in your browser: ${loginUrl}\n` +
|
|
200
|
+
`2. Sign in with your email and password\n` +
|
|
201
|
+
`3. Once logged in, your session will be saved automatically\n` +
|
|
202
|
+
`4. Run gap_status to verify your login`,
|
|
203
|
+
};
|
|
204
|
+
} finally {
|
|
205
|
+
await page.close();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function logout(): Promise<void> {
|
|
210
|
+
const { context: ctx } = await getBrowser();
|
|
211
|
+
await ctx.clearCookies();
|
|
212
|
+
const page = await newPage();
|
|
213
|
+
try {
|
|
214
|
+
await page.goto('https://www.gap.com/account/logout.html', { waitUntil: 'domcontentloaded' });
|
|
215
|
+
} catch {}
|
|
216
|
+
finally {
|
|
217
|
+
await page.close();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function searchProducts(
|
|
222
|
+
query: string,
|
|
223
|
+
brand: Brand = 'gap',
|
|
224
|
+
maxResults = 10,
|
|
225
|
+
): Promise<ProductResult[]> {
|
|
226
|
+
const baseUrl = BRAND_URLS[brand];
|
|
227
|
+
const searchUrl = `${baseUrl}/browse/search.do?searchText=${encodeURIComponent(query)}`;
|
|
228
|
+
const page = await newPage();
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
await page.goto(searchUrl, { waitUntil: 'domcontentloaded' });
|
|
232
|
+
await page.waitForTimeout(2500);
|
|
233
|
+
|
|
234
|
+
// Try to wait for product grid
|
|
235
|
+
await page.waitForSelector('[data-testid="product-grid"], .product-card, [class*="productCard"], [class*="product-card"]', {
|
|
236
|
+
timeout: 8000,
|
|
237
|
+
}).catch(() => {});
|
|
238
|
+
|
|
239
|
+
const products = await page.evaluate(
|
|
240
|
+
({ brand, maxResults }: { brand: Brand; maxResults: number }) => {
|
|
241
|
+
const results: ProductResult[] = [];
|
|
242
|
+
|
|
243
|
+
// Multiple selector strategies
|
|
244
|
+
const selectors = [
|
|
245
|
+
'[data-testid="product-card"]',
|
|
246
|
+
'[class*="productCard"]',
|
|
247
|
+
'[class*="product-card"]',
|
|
248
|
+
'[class*="ProductCard"]',
|
|
249
|
+
'.product-card',
|
|
250
|
+
'[data-component="ProductCard"]',
|
|
251
|
+
];
|
|
252
|
+
|
|
253
|
+
let cards: Element[] = [];
|
|
254
|
+
for (const sel of selectors) {
|
|
255
|
+
const found = Array.from(document.querySelectorAll(sel));
|
|
256
|
+
if (found.length > 0) {
|
|
257
|
+
cards = found;
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
for (const card of cards.slice(0, maxResults)) {
|
|
263
|
+
try {
|
|
264
|
+
const nameEl = card.querySelector(
|
|
265
|
+
'[data-testid="product-name"], [class*="productName"], [class*="product-name"], h3, h2, [class*="ProductName"]',
|
|
266
|
+
);
|
|
267
|
+
const name = nameEl?.textContent?.trim() || '';
|
|
268
|
+
if (!name) continue;
|
|
269
|
+
|
|
270
|
+
const priceEl = card.querySelector(
|
|
271
|
+
'[data-testid="price-current"], [class*="priceLabel"], [class*="price-current"], [class*="CurrentPrice"]',
|
|
272
|
+
);
|
|
273
|
+
const price = priceEl?.textContent?.trim() || 'N/A';
|
|
274
|
+
|
|
275
|
+
const originalPriceEl = card.querySelector(
|
|
276
|
+
'[data-testid="price-original"], [class*="priceOriginal"], [class*="price-original"], s',
|
|
277
|
+
);
|
|
278
|
+
const originalPrice = originalPriceEl?.textContent?.trim();
|
|
279
|
+
|
|
280
|
+
const linkEl = card.querySelector('a[href]') as HTMLAnchorElement | null;
|
|
281
|
+
const url = linkEl?.href || '';
|
|
282
|
+
|
|
283
|
+
const imgEl = card.querySelector('img') as HTMLImageElement | null;
|
|
284
|
+
const imageUrl = imgEl?.src || '';
|
|
285
|
+
|
|
286
|
+
const colorEls = Array.from(
|
|
287
|
+
card.querySelectorAll('[class*="colorSwatch"], [class*="color-swatch"], [aria-label*="color"]'),
|
|
288
|
+
);
|
|
289
|
+
const colors = colorEls
|
|
290
|
+
.map((el) => (el as HTMLElement).getAttribute('aria-label') || el.textContent?.trim() || '')
|
|
291
|
+
.filter(Boolean);
|
|
292
|
+
|
|
293
|
+
const isOnSale = !!originalPrice || card.textContent?.toLowerCase().includes('sale') || false;
|
|
294
|
+
const inStock = !card.textContent?.toLowerCase().includes('out of stock');
|
|
295
|
+
|
|
296
|
+
// Extract product ID from URL
|
|
297
|
+
const idMatch = url.match(/\/([A-Z0-9]+)(?:\?|$|\/)/);
|
|
298
|
+
const productId = idMatch?.[1];
|
|
299
|
+
|
|
300
|
+
results.push({ name, price, originalPrice, brand, productId, url, imageUrl, colors, inStock, isOnSale });
|
|
301
|
+
} catch {}
|
|
302
|
+
}
|
|
303
|
+
return results;
|
|
304
|
+
},
|
|
305
|
+
{ brand, maxResults },
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
const { context: ctx } = await getBrowser();
|
|
309
|
+
await saveCookies(ctx);
|
|
310
|
+
return products;
|
|
311
|
+
} finally {
|
|
312
|
+
await page.close();
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export async function getProductDetails(productUrl: string, brand: Brand = 'gap'): Promise<ProductDetails> {
|
|
317
|
+
const page = await newPage();
|
|
318
|
+
try {
|
|
319
|
+
await page.goto(productUrl, { waitUntil: 'domcontentloaded' });
|
|
320
|
+
await page.waitForTimeout(2500);
|
|
321
|
+
|
|
322
|
+
const details = await page.evaluate(
|
|
323
|
+
({ productUrl, brand }: { productUrl: string; brand: Brand }) => {
|
|
324
|
+
const getText = (sel: string) => document.querySelector(sel)?.textContent?.trim() || '';
|
|
325
|
+
const getAttr = (sel: string, attr: string) =>
|
|
326
|
+
(document.querySelector(sel) as HTMLElement)?.getAttribute(attr) || '';
|
|
327
|
+
|
|
328
|
+
const name = getText(
|
|
329
|
+
'h1, [data-testid="product-name"], [class*="ProductName"], [class*="product-name"]',
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
const price = getText(
|
|
333
|
+
'[data-testid="price-current"], [class*="priceLabel"], [class*="CurrentPrice"]',
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
const originalPrice =
|
|
337
|
+
getText('[data-testid="price-original"], [class*="priceOriginal"], s') || undefined;
|
|
338
|
+
|
|
339
|
+
const description = getText(
|
|
340
|
+
'[data-testid="product-description"], [class*="productDescription"], [class*="product-description"]',
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
// Sizes
|
|
344
|
+
const sizeEls = Array.from(
|
|
345
|
+
document.querySelectorAll(
|
|
346
|
+
'[data-testid="size-selector"] button, [class*="sizeOption"], [class*="size-option"], [aria-label*="size"]',
|
|
347
|
+
),
|
|
348
|
+
);
|
|
349
|
+
const availableSizes = sizeEls
|
|
350
|
+
.filter((el) => !(el as HTMLElement).getAttribute('disabled') && !(el as HTMLElement).getAttribute('aria-disabled'))
|
|
351
|
+
.map((el) => el.textContent?.trim() || '')
|
|
352
|
+
.filter(Boolean);
|
|
353
|
+
|
|
354
|
+
// Colors
|
|
355
|
+
const colorEls = Array.from(
|
|
356
|
+
document.querySelectorAll(
|
|
357
|
+
'[data-testid="color-selector"] button, [class*="colorSwatch"], [class*="color-swatch"]',
|
|
358
|
+
),
|
|
359
|
+
);
|
|
360
|
+
const availableColors = colorEls
|
|
361
|
+
.map((el) => (el as HTMLElement).getAttribute('aria-label') || el.textContent?.trim() || '')
|
|
362
|
+
.filter(Boolean);
|
|
363
|
+
|
|
364
|
+
const fitType = getText('[class*="fitType"], [data-testid="fit-type"]') || undefined;
|
|
365
|
+
const material = getText('[class*="material"], [class*="fabric"]') || undefined;
|
|
366
|
+
|
|
367
|
+
const rating = getText('[data-testid="rating"], [class*="ratingValue"]') || undefined;
|
|
368
|
+
const reviewCount = getText('[data-testid="review-count"], [class*="reviewCount"]') || undefined;
|
|
369
|
+
|
|
370
|
+
const isOnSale = !!originalPrice || document.body.textContent?.toLowerCase().includes('sale') || false;
|
|
371
|
+
const inStock = !document.body.textContent?.toLowerCase().includes('out of stock');
|
|
372
|
+
|
|
373
|
+
// Extract product ID from URL
|
|
374
|
+
const idMatch = productUrl.match(/\/([A-Z0-9]+)(?:\?|$)/);
|
|
375
|
+
const productId = idMatch?.[1] || '';
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
name,
|
|
379
|
+
price,
|
|
380
|
+
originalPrice,
|
|
381
|
+
brand,
|
|
382
|
+
productId,
|
|
383
|
+
url: productUrl,
|
|
384
|
+
description,
|
|
385
|
+
availableSizes,
|
|
386
|
+
availableColors,
|
|
387
|
+
fitType,
|
|
388
|
+
material,
|
|
389
|
+
inStock,
|
|
390
|
+
isOnSale,
|
|
391
|
+
rating,
|
|
392
|
+
reviewCount,
|
|
393
|
+
} as ProductDetails;
|
|
394
|
+
},
|
|
395
|
+
{ productUrl, brand },
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
return details;
|
|
399
|
+
} finally {
|
|
400
|
+
await page.close();
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export async function checkStoreInventory(
|
|
405
|
+
productId: string,
|
|
406
|
+
zipCode: string,
|
|
407
|
+
brand: Brand = 'gap',
|
|
408
|
+
): Promise<StoreInventory[]> {
|
|
409
|
+
const baseUrl = BRAND_URLS[brand];
|
|
410
|
+
const page = await newPage();
|
|
411
|
+
try {
|
|
412
|
+
// Navigate to product page first, then trigger store availability check
|
|
413
|
+
const productPage = `${baseUrl}/products/${productId}`;
|
|
414
|
+
await page.goto(productPage, { waitUntil: 'domcontentloaded' });
|
|
415
|
+
await page.waitForTimeout(2000);
|
|
416
|
+
|
|
417
|
+
// Try to find and click "Check store availability" button
|
|
418
|
+
const checkStoreBtn = await page.$(
|
|
419
|
+
'[data-testid="store-availability"], button:has-text("Find in Store"), button:has-text("Check Store Availability")',
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
if (checkStoreBtn) {
|
|
423
|
+
await checkStoreBtn.click();
|
|
424
|
+
await page.waitForTimeout(1000);
|
|
425
|
+
|
|
426
|
+
// Enter zip code
|
|
427
|
+
const zipInput = await page.$('input[placeholder*="zip"], input[placeholder*="ZIP"], input[type="tel"]');
|
|
428
|
+
if (zipInput) {
|
|
429
|
+
await zipInput.fill(zipCode);
|
|
430
|
+
await page.keyboard.press('Enter');
|
|
431
|
+
await page.waitForTimeout(2000);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const stores = await page.evaluate(() => {
|
|
436
|
+
const storeEls = Array.from(
|
|
437
|
+
document.querySelectorAll('[data-testid="store-item"], [class*="storeItem"], [class*="store-item"]'),
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
return storeEls.map((el) => ({
|
|
441
|
+
storeId: (el as HTMLElement).getAttribute('data-store-id') || '',
|
|
442
|
+
storeName: el.querySelector('[class*="storeName"], [data-testid="store-name"]')?.textContent?.trim() || '',
|
|
443
|
+
address: el.querySelector('[class*="storeAddress"], [data-testid="store-address"]')?.textContent?.trim() || '',
|
|
444
|
+
inStock: !el.textContent?.toLowerCase().includes('out of stock'),
|
|
445
|
+
distance: el.querySelector('[class*="storeDistance"]')?.textContent?.trim(),
|
|
446
|
+
}));
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
return stores as StoreInventory[];
|
|
450
|
+
} finally {
|
|
451
|
+
await page.close();
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export async function addToCart(
|
|
456
|
+
productUrl: string,
|
|
457
|
+
size: string,
|
|
458
|
+
color: string,
|
|
459
|
+
quantity = 1,
|
|
460
|
+
): Promise<{ success: boolean; message: string; cartItemCount?: number }> {
|
|
461
|
+
const page = await newPage();
|
|
462
|
+
try {
|
|
463
|
+
await page.goto(productUrl, { waitUntil: 'domcontentloaded' });
|
|
464
|
+
await page.waitForTimeout(2500);
|
|
465
|
+
|
|
466
|
+
// Select color
|
|
467
|
+
const colorBtn = await page.$(
|
|
468
|
+
`[aria-label="${color}"], button[title="${color}"], [data-color="${color}"]`,
|
|
469
|
+
);
|
|
470
|
+
if (colorBtn) {
|
|
471
|
+
await colorBtn.click();
|
|
472
|
+
await page.waitForTimeout(500);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Select size
|
|
476
|
+
const sizeBtn = await page.$(
|
|
477
|
+
`button[data-size="${size}"], [aria-label="Size ${size}"], button:has-text("${size}")`,
|
|
478
|
+
);
|
|
479
|
+
if (!sizeBtn) {
|
|
480
|
+
return { success: false, message: `Size "${size}" not found or not available. Check gap_product_details for available sizes.` };
|
|
481
|
+
}
|
|
482
|
+
await sizeBtn.click();
|
|
483
|
+
await page.waitForTimeout(500);
|
|
484
|
+
|
|
485
|
+
// Set quantity if > 1
|
|
486
|
+
if (quantity > 1) {
|
|
487
|
+
const qtyInput = await page.$('input[name="quantity"], [data-testid="quantity-input"]');
|
|
488
|
+
if (qtyInput) {
|
|
489
|
+
await qtyInput.fill(String(quantity));
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Click Add to Cart
|
|
494
|
+
const addBtn = await page.$(
|
|
495
|
+
'[data-testid="add-to-cart"], button:has-text("Add to Bag"), button:has-text("Add to Cart")',
|
|
496
|
+
);
|
|
497
|
+
if (!addBtn) {
|
|
498
|
+
return { success: false, message: 'Add to Cart button not found.' };
|
|
499
|
+
}
|
|
500
|
+
await addBtn.click();
|
|
501
|
+
await page.waitForTimeout(2000);
|
|
502
|
+
|
|
503
|
+
// Check for success confirmation
|
|
504
|
+
const success = await page.evaluate(() => {
|
|
505
|
+
const confirmEl = document.querySelector(
|
|
506
|
+
'[data-testid="cart-confirmation"], [class*="addedToBag"], [class*="cart-confirmation"]',
|
|
507
|
+
);
|
|
508
|
+
return !!confirmEl || document.body.textContent?.includes('Added to Bag') || false;
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
const cartCountEl = await page.$('[data-testid="cart-count"], [class*="cartCount"], [class*="bag-count"]');
|
|
512
|
+
const cartCount = cartCountEl ? parseInt((await cartCountEl.textContent()) || '0') : undefined;
|
|
513
|
+
|
|
514
|
+
const { context: ctx } = await getBrowser();
|
|
515
|
+
await saveCookies(ctx);
|
|
516
|
+
|
|
517
|
+
return {
|
|
518
|
+
success: success || true,
|
|
519
|
+
message: success ? `Added ${quantity}x "${size}" in "${color}" to your bag.` : 'Item may have been added — please verify in your bag.',
|
|
520
|
+
cartItemCount: cartCount,
|
|
521
|
+
};
|
|
522
|
+
} finally {
|
|
523
|
+
await page.close();
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export async function viewCart(brand: Brand = 'gap'): Promise<CartSummary> {
|
|
528
|
+
const baseUrl = BRAND_URLS[brand];
|
|
529
|
+
const page = await newPage();
|
|
530
|
+
try {
|
|
531
|
+
await page.goto(`${baseUrl}/cart`, { waitUntil: 'domcontentloaded' });
|
|
532
|
+
await page.waitForTimeout(2500);
|
|
533
|
+
|
|
534
|
+
const cart = await page.evaluate(({ brand }: { brand: Brand }) => {
|
|
535
|
+
const items: CartItem[] = [];
|
|
536
|
+
|
|
537
|
+
const itemEls = Array.from(
|
|
538
|
+
document.querySelectorAll('[data-testid="cart-item"], [class*="cartItem"], [class*="cart-item"]'),
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
for (const el of itemEls) {
|
|
542
|
+
try {
|
|
543
|
+
const name = el.querySelector('[class*="productName"], [data-testid="product-name"], h3')?.textContent?.trim() || '';
|
|
544
|
+
const price = el.querySelector('[class*="price"], [data-testid="item-price"]')?.textContent?.trim() || '';
|
|
545
|
+
const sizeEl = el.querySelector('[class*="size"], [data-testid="item-size"]');
|
|
546
|
+
const colorEl = el.querySelector('[class*="color"], [data-testid="item-color"]');
|
|
547
|
+
const qtyEl = el.querySelector('input[name="quantity"], [data-testid="item-quantity"], select') as HTMLInputElement | null;
|
|
548
|
+
const qty = qtyEl ? parseInt(qtyEl.value || qtyEl.textContent || '1') : 1;
|
|
549
|
+
|
|
550
|
+
if (name) {
|
|
551
|
+
items.push({
|
|
552
|
+
name,
|
|
553
|
+
size: sizeEl?.textContent?.trim() || 'N/A',
|
|
554
|
+
color: colorEl?.textContent?.trim() || 'N/A',
|
|
555
|
+
quantity: qty,
|
|
556
|
+
price,
|
|
557
|
+
brand,
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
} catch {}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const getText = (sel: string) => document.querySelector(sel)?.textContent?.trim() || '';
|
|
564
|
+
|
|
565
|
+
return {
|
|
566
|
+
items,
|
|
567
|
+
subtotal: getText('[data-testid="subtotal"], [class*="subtotal"]') || '$0.00',
|
|
568
|
+
estimatedTax: getText('[data-testid="tax"], [class*="tax"]') || undefined,
|
|
569
|
+
promotionDiscount: getText('[data-testid="promo-discount"], [class*="promo"]') || undefined,
|
|
570
|
+
gapCashApplied: getText('[data-testid="gap-cash"], [class*="gapCash"]') || undefined,
|
|
571
|
+
total: getText('[data-testid="order-total"], [class*="orderTotal"], [class*="total"]') || '$0.00',
|
|
572
|
+
itemCount: items.length,
|
|
573
|
+
} as CartSummary;
|
|
574
|
+
}, { brand });
|
|
575
|
+
|
|
576
|
+
const { context: ctx } = await getBrowser();
|
|
577
|
+
await saveCookies(ctx);
|
|
578
|
+
return cart;
|
|
579
|
+
} finally {
|
|
580
|
+
await page.close();
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export async function proceedToCheckout(brand: Brand = 'gap'): Promise<{ url: string; instructions: string }> {
|
|
585
|
+
const baseUrl = BRAND_URLS[brand];
|
|
586
|
+
const checkoutUrl = `${baseUrl}/checkout`;
|
|
587
|
+
return {
|
|
588
|
+
url: checkoutUrl,
|
|
589
|
+
instructions:
|
|
590
|
+
`To complete your purchase:\n` +
|
|
591
|
+
`1. Open: ${checkoutUrl}\n` +
|
|
592
|
+
`2. Review your bag and apply any Gap Cash or promo codes\n` +
|
|
593
|
+
`3. Enter/confirm shipping address\n` +
|
|
594
|
+
`4. Select shipping method\n` +
|
|
595
|
+
`5. Confirm payment and place order`,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
export async function getRewardsInfo(brand: Brand = 'gap'): Promise<RewardsInfo> {
|
|
600
|
+
const baseUrl = BRAND_URLS[brand];
|
|
601
|
+
const page = await newPage();
|
|
602
|
+
try {
|
|
603
|
+
await page.goto(`${baseUrl}/account/rewards.html`, { waitUntil: 'domcontentloaded' });
|
|
604
|
+
await page.waitForTimeout(2500);
|
|
605
|
+
|
|
606
|
+
const rewards = await page.evaluate(() => {
|
|
607
|
+
const getText = (sel: string) => document.querySelector(sel)?.textContent?.trim() || '';
|
|
608
|
+
|
|
609
|
+
const gapCashBalance =
|
|
610
|
+
getText('[data-testid="gap-cash-balance"], [class*="gapCashBalance"], [class*="gap-cash-balance"]') || '$0.00';
|
|
611
|
+
const gapCashExpiry =
|
|
612
|
+
getText('[data-testid="gap-cash-expiry"], [class*="gapCashExpiry"]') || undefined;
|
|
613
|
+
const rewardsPoints =
|
|
614
|
+
getText('[data-testid="rewards-points"], [class*="rewardsPoints"], [class*="points-balance"]') || '0';
|
|
615
|
+
const memberLevel =
|
|
616
|
+
getText('[data-testid="member-level"], [class*="memberLevel"], [class*="tier"]') || undefined;
|
|
617
|
+
const nextReward =
|
|
618
|
+
getText('[data-testid="next-reward"], [class*="nextReward"]') || undefined;
|
|
619
|
+
|
|
620
|
+
return { gapCashBalance, gapCashExpiry, rewardsPoints, memberLevel, nextReward };
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
const { context: ctx } = await getBrowser();
|
|
624
|
+
await saveCookies(ctx);
|
|
625
|
+
return rewards;
|
|
626
|
+
} finally {
|
|
627
|
+
await page.close();
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
export async function trackOrder(orderId: string, brand: Brand = 'gap'): Promise<OrderInfo> {
|
|
632
|
+
const baseUrl = BRAND_URLS[brand];
|
|
633
|
+
const page = await newPage();
|
|
634
|
+
try {
|
|
635
|
+
await page.goto(`${baseUrl}/account/orders.html`, { waitUntil: 'domcontentloaded' });
|
|
636
|
+
await page.waitForTimeout(2500);
|
|
637
|
+
|
|
638
|
+
// Try to find the specific order
|
|
639
|
+
const orderLink = await page.$(`[data-order-id="${orderId}"], a:has-text("${orderId}")`);
|
|
640
|
+
if (orderLink) {
|
|
641
|
+
await orderLink.click();
|
|
642
|
+
await page.waitForTimeout(2000);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const orderInfo = await page.evaluate(({ orderId }: { orderId: string }) => {
|
|
646
|
+
const getText = (sel: string) => document.querySelector(sel)?.textContent?.trim() || '';
|
|
647
|
+
|
|
648
|
+
const status = getText('[data-testid="order-status"], [class*="orderStatus"], [class*="order-status"]') || 'Unknown';
|
|
649
|
+
const placedDate = getText('[data-testid="order-date"], [class*="orderDate"]') || '';
|
|
650
|
+
const estimatedDelivery = getText('[data-testid="estimated-delivery"], [class*="deliveryDate"]') || undefined;
|
|
651
|
+
const total = getText('[data-testid="order-total"], [class*="orderTotal"]') || '';
|
|
652
|
+
const trackingNumber = getText('[data-testid="tracking-number"], [class*="trackingNumber"]') || undefined;
|
|
653
|
+
const carrier = getText('[data-testid="carrier"], [class*="carrier"]') || undefined;
|
|
654
|
+
|
|
655
|
+
const itemEls = Array.from(document.querySelectorAll('[data-testid="order-item"], [class*="orderItem"]'));
|
|
656
|
+
const items = itemEls.map((el) => el.textContent?.trim() || '').filter(Boolean);
|
|
657
|
+
|
|
658
|
+
return { orderId, status, placedDate, estimatedDelivery, items, total, trackingNumber, carrier };
|
|
659
|
+
}, { orderId });
|
|
660
|
+
|
|
661
|
+
return orderInfo;
|
|
662
|
+
} finally {
|
|
663
|
+
await page.close();
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
export async function findStores(
|
|
668
|
+
zipCode: string,
|
|
669
|
+
brand?: Brand,
|
|
670
|
+
radius = 25,
|
|
671
|
+
): Promise<StoreLocation[]> {
|
|
672
|
+
const baseUrl = brand ? BRAND_URLS[brand] : 'https://www.gap.com';
|
|
673
|
+
const page = await newPage();
|
|
674
|
+
try {
|
|
675
|
+
await page.goto(`${baseUrl}/stores/`, { waitUntil: 'domcontentloaded' });
|
|
676
|
+
await page.waitForTimeout(2000);
|
|
677
|
+
|
|
678
|
+
// Enter zip code in store finder
|
|
679
|
+
const zipInput = await page.$('input[placeholder*="zip"], input[placeholder*="ZIP"], input[name="zip"], input[type="search"]');
|
|
680
|
+
if (zipInput) {
|
|
681
|
+
await zipInput.fill(zipCode);
|
|
682
|
+
await page.keyboard.press('Enter');
|
|
683
|
+
await page.waitForTimeout(3000);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const stores = await page.evaluate(({ brand }: { brand?: Brand }) => {
|
|
687
|
+
const storeEls = Array.from(
|
|
688
|
+
document.querySelectorAll('[data-testid="store-result"], [class*="storeResult"], [class*="store-result"], [class*="StoreItem"]'),
|
|
689
|
+
);
|
|
690
|
+
|
|
691
|
+
return storeEls.slice(0, 10).map((el) => {
|
|
692
|
+
const getText = (sel: string) => el.querySelector(sel)?.textContent?.trim() || '';
|
|
693
|
+
const name = getText('[class*="storeName"], [data-testid="store-name"], h3, h4') || 'Gap Store';
|
|
694
|
+
const address = getText('[class*="address"], [data-testid="store-address"]');
|
|
695
|
+
const phone = getText('[class*="phone"], [data-testid="store-phone"]') || undefined;
|
|
696
|
+
const hours = getText('[class*="hours"], [data-testid="store-hours"]') || undefined;
|
|
697
|
+
const distance = getText('[class*="distance"]') || undefined;
|
|
698
|
+
|
|
699
|
+
// Parse address parts
|
|
700
|
+
const parts = address.split(',').map((s: string) => s.trim());
|
|
701
|
+
const streetAddress = parts[0] || address;
|
|
702
|
+
const cityState = parts[1] || '';
|
|
703
|
+
const [city, stateZip] = cityState.split(' ').filter(Boolean).reduce(
|
|
704
|
+
(acc: [string, string[]], word: string) => {
|
|
705
|
+
if (/[A-Z]{2}/.test(word) || /\d{5}/.test(word)) acc[1].push(word);
|
|
706
|
+
else acc[0] += (acc[0] ? ' ' : '') + word;
|
|
707
|
+
return acc;
|
|
708
|
+
},
|
|
709
|
+
['', []],
|
|
710
|
+
);
|
|
711
|
+
|
|
712
|
+
const detectedBrands: Brand[] = [];
|
|
713
|
+
const text = el.textContent?.toLowerCase() || '';
|
|
714
|
+
if (text.includes('gap')) detectedBrands.push('gap');
|
|
715
|
+
if (text.includes('old navy')) detectedBrands.push('old-navy');
|
|
716
|
+
if (text.includes('banana republic')) detectedBrands.push('banana-republic');
|
|
717
|
+
if (text.includes('athleta')) detectedBrands.push('athleta');
|
|
718
|
+
if (detectedBrands.length === 0 && brand) detectedBrands.push(brand);
|
|
719
|
+
if (detectedBrands.length === 0) detectedBrands.push('gap');
|
|
720
|
+
|
|
721
|
+
return {
|
|
722
|
+
storeId: (el as HTMLElement).getAttribute('data-store-id') || '',
|
|
723
|
+
name,
|
|
724
|
+
address: streetAddress,
|
|
725
|
+
city: city || '',
|
|
726
|
+
state: stateZip[0] || '',
|
|
727
|
+
zip: stateZip[1] || '',
|
|
728
|
+
phone,
|
|
729
|
+
hours,
|
|
730
|
+
distance,
|
|
731
|
+
brands: detectedBrands,
|
|
732
|
+
};
|
|
733
|
+
});
|
|
734
|
+
}, { brand });
|
|
735
|
+
|
|
736
|
+
return stores as StoreLocation[];
|
|
737
|
+
} finally {
|
|
738
|
+
await page.close();
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
export async function initiateReturn(
|
|
743
|
+
orderId: string,
|
|
744
|
+
itemName: string,
|
|
745
|
+
reason: string,
|
|
746
|
+
brand: Brand = 'gap',
|
|
747
|
+
): Promise<ReturnConfirmation> {
|
|
748
|
+
const baseUrl = BRAND_URLS[brand];
|
|
749
|
+
const page = await newPage();
|
|
750
|
+
try {
|
|
751
|
+
await page.goto(`${baseUrl}/account/returns.html`, { waitUntil: 'domcontentloaded' });
|
|
752
|
+
await page.waitForTimeout(2000);
|
|
753
|
+
|
|
754
|
+
// Try to start return for specific order
|
|
755
|
+
const orderEl = await page.$(`[data-order-id="${orderId}"], button:has-text("Start Return")`);
|
|
756
|
+
if (orderEl) {
|
|
757
|
+
await orderEl.click();
|
|
758
|
+
await page.waitForTimeout(1500);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// Select reason
|
|
762
|
+
const reasonSelect = await page.$('select[name="reason"], [data-testid="return-reason"]');
|
|
763
|
+
if (reasonSelect) {
|
|
764
|
+
await (reasonSelect as HTMLSelectElement & { selectOption: (value: string) => Promise<void> }).selectOption?.({ label: reason });
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const returnUrl = page.url();
|
|
768
|
+
|
|
769
|
+
const { context: ctx } = await getBrowser();
|
|
770
|
+
await saveCookies(ctx);
|
|
771
|
+
|
|
772
|
+
return {
|
|
773
|
+
orderId,
|
|
774
|
+
method: 'Mail or In-Store Drop-off',
|
|
775
|
+
instructions:
|
|
776
|
+
`To complete your return for order ${orderId}:\n` +
|
|
777
|
+
`1. Visit: ${baseUrl}/account/returns.html\n` +
|
|
778
|
+
`2. Select the item(s) you wish to return\n` +
|
|
779
|
+
`3. Choose your return reason: ${reason}\n` +
|
|
780
|
+
`4. Select return method (mail or in-store)\n` +
|
|
781
|
+
`5. For mail returns: print prepaid shipping label\n` +
|
|
782
|
+
`6. For in-store: bring item with original tags and receipt/order confirmation\n\n` +
|
|
783
|
+
`Items can be returned within 30 days of purchase. Final sale items are not eligible.`,
|
|
784
|
+
labelUrl: returnUrl !== `${baseUrl}/account/returns.html` ? returnUrl : undefined,
|
|
785
|
+
};
|
|
786
|
+
} finally {
|
|
787
|
+
await page.close();
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
export function getSizeGuide(
|
|
792
|
+
category: string,
|
|
793
|
+
brand: Brand = 'gap',
|
|
794
|
+
): { category: string; brand: Brand; guide: Record<string, unknown> } {
|
|
795
|
+
const guides: Record<string, Record<string, unknown>> = {
|
|
796
|
+
tops: {
|
|
797
|
+
note: 'Gap tops sizing guide (US sizes)',
|
|
798
|
+
womens: {
|
|
799
|
+
XS: { bust: '32-33"', waist: '24-25"', hips: '34-35"' },
|
|
800
|
+
S: { bust: '34-35"', waist: '26-27"', hips: '36-37"' },
|
|
801
|
+
M: { bust: '36-37"', waist: '28-29"', hips: '38-39"' },
|
|
802
|
+
L: { bust: '38-40"', waist: '30-32"', hips: '40-42"' },
|
|
803
|
+
XL: { bust: '41-43"', waist: '33-35"', hips: '43-45"' },
|
|
804
|
+
XXL: { bust: '44-46"', waist: '36-38"', hips: '46-48"' },
|
|
805
|
+
},
|
|
806
|
+
mens: {
|
|
807
|
+
XS: { chest: '34-36"', waist: '28-30"' },
|
|
808
|
+
S: { chest: '36-38"', waist: '30-32"' },
|
|
809
|
+
M: { chest: '38-40"', waist: '32-34"' },
|
|
810
|
+
L: { chest: '40-42"', waist: '34-36"' },
|
|
811
|
+
XL: { chest: '42-44"', waist: '36-38"' },
|
|
812
|
+
XXL: { chest: '44-46"', waist: '38-40"' },
|
|
813
|
+
},
|
|
814
|
+
},
|
|
815
|
+
bottoms: {
|
|
816
|
+
note: 'Gap bottoms sizing guide (US sizes)',
|
|
817
|
+
womens: {
|
|
818
|
+
'00': { waist: '24"', hips: '34"' },
|
|
819
|
+
'0': { waist: '25"', hips: '35"' },
|
|
820
|
+
'2': { waist: '26"', hips: '36"' },
|
|
821
|
+
'4': { waist: '27"', hips: '37"' },
|
|
822
|
+
'6': { waist: '28"', hips: '38"' },
|
|
823
|
+
'8': { waist: '29"', hips: '39"' },
|
|
824
|
+
'10': { waist: '30"', hips: '40"' },
|
|
825
|
+
'12': { waist: '31-32"', hips: '41-42"' },
|
|
826
|
+
'14': { waist: '33-34"', hips: '43-44"' },
|
|
827
|
+
'16': { waist: '35-36"', hips: '45-46"' },
|
|
828
|
+
},
|
|
829
|
+
mens: {
|
|
830
|
+
'28x30': { waist: '28"', inseam: '30"' },
|
|
831
|
+
'30x30': { waist: '30"', inseam: '30"' },
|
|
832
|
+
'32x30': { waist: '32"', inseam: '30"' },
|
|
833
|
+
'34x30': { waist: '34"', inseam: '30"' },
|
|
834
|
+
'36x30': { waist: '36"', inseam: '30"' },
|
|
835
|
+
'28x32': { waist: '28"', inseam: '32"' },
|
|
836
|
+
'30x32': { waist: '30"', inseam: '32"' },
|
|
837
|
+
'32x32': { waist: '32"', inseam: '32"' },
|
|
838
|
+
'34x32': { waist: '34"', inseam: '32"' },
|
|
839
|
+
},
|
|
840
|
+
},
|
|
841
|
+
shoes: {
|
|
842
|
+
note: 'Gap/Old Navy shoe sizing guide',
|
|
843
|
+
womensToMens: {
|
|
844
|
+
'5': '3.5',
|
|
845
|
+
'5.5': '4',
|
|
846
|
+
'6': '4.5',
|
|
847
|
+
'6.5': '5',
|
|
848
|
+
'7': '5.5',
|
|
849
|
+
'7.5': '6',
|
|
850
|
+
'8': '6.5',
|
|
851
|
+
'8.5': '7',
|
|
852
|
+
'9': '7.5',
|
|
853
|
+
'9.5': '8',
|
|
854
|
+
'10': '8.5',
|
|
855
|
+
'11': '9.5',
|
|
856
|
+
},
|
|
857
|
+
usToEU: {
|
|
858
|
+
'5': '35',
|
|
859
|
+
'6': '36',
|
|
860
|
+
'7': '37-38',
|
|
861
|
+
'8': '38-39',
|
|
862
|
+
'9': '39-40',
|
|
863
|
+
'10': '40-41',
|
|
864
|
+
'11': '41-42',
|
|
865
|
+
'12': '42-43',
|
|
866
|
+
},
|
|
867
|
+
},
|
|
868
|
+
kids: {
|
|
869
|
+
note: 'Gap Kids sizing guide',
|
|
870
|
+
toddler: {
|
|
871
|
+
'2T': { height: '32-34"', weight: '25-29 lbs' },
|
|
872
|
+
'3T': { height: '35-37"', weight: '30-34 lbs' },
|
|
873
|
+
'4T': { height: '38-40"', weight: '35-40 lbs' },
|
|
874
|
+
'5T': { height: '41-43"', weight: '41-46 lbs' },
|
|
875
|
+
},
|
|
876
|
+
kids: {
|
|
877
|
+
XS: { height: '44-47"', weight: '47-55 lbs', age: '4-5' },
|
|
878
|
+
S: { height: '47-52"', weight: '55-68 lbs', age: '6-7' },
|
|
879
|
+
M: { height: '52-57"', weight: '68-84 lbs', age: '8-9' },
|
|
880
|
+
L: { height: '57-61"', weight: '84-100 lbs', age: '10-11' },
|
|
881
|
+
XL: { height: '61-64"', weight: '100-120 lbs', age: '12-13' },
|
|
882
|
+
XXL: { height: '64-67"', weight: '120-135 lbs', age: '14-15' },
|
|
883
|
+
},
|
|
884
|
+
},
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
const normalizedCategory = category.toLowerCase();
|
|
888
|
+
let guide = guides[normalizedCategory];
|
|
889
|
+
|
|
890
|
+
if (!guide) {
|
|
891
|
+
// Try partial match
|
|
892
|
+
const key = Object.keys(guides).find((k) => normalizedCategory.includes(k) || k.includes(normalizedCategory));
|
|
893
|
+
guide = key ? guides[key] : { message: `Size guide for "${category}" not found. Available: ${Object.keys(guides).join(', ')}` };
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// Athleta has slightly different sizing (athletic fit)
|
|
897
|
+
if (brand === 'athleta' && guide) {
|
|
898
|
+
guide = { ...guide, note: 'Athleta sizing — athletic fit, generally runs true to size or slightly small' };
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
return { category, brand, guide };
|
|
902
|
+
}
|