diff --git a/.claude/skills/grocery-comparison/SKILL.md b/.claude/skills/grocery-comparison/SKILL.md new file mode 100644 index 00000000..228bd82b --- /dev/null +++ b/.claude/skills/grocery-comparison/SKILL.md @@ -0,0 +1,942 @@ +--- +name: grocery-comparison +description: Smart grocery price comparison agent for Asian international students. Compares prices across Weee, Asian markets (H Mart, 168, etc.), and mainstream stores (Kroger, Costco, Sam's Club, Aldi). Considers user taste preferences, distance, and cultural factors. Use when user asks to compare grocery prices, find best deals, or get shopping recommendations. +--- + +# Smart Grocery Comparison Agent + +This skill helps users (primarily Asian international students) find the best value groceries by comparing prices across multiple channels while considering personal preferences. + +## Core Principles + +1. **Not just cheapest, but best value for YOU** - Consider taste, distance, delivery options +2. **Cultural awareness** - "American cakes are too sweet" is a valid preference +3. **Multi-channel** - Online (Weee, Yami) + Physical stores (H Mart, 168, Costco, Kroger) +4. **Explain trade-offs** - "$2 cheaper at 168 but 25 min drive vs Weee delivers free" + +--- + +## Data Source Availability Status (Tested 2026-04-02) + +| 数据源 | 状态 | 抓取方法 | 备注 | +|--------|------|----------|------| +| **Weee** | **可用** | browser-use | 成功抓取产品名称、价格、单位 | +| **Google Maps** | **可用** | browser-use | 成功获取店铺评分、评论、营业时间 | +| **H Mart** | 部分可用 | browser-use | 主页可访问,搜索功能需进一步测试 | +| **Yami** | 待测试 | browser-use | 应该可用,类似Weee | +| **小红书** | 被限制 | browser-use | 非中国IP被block,需VPN/代理 | +| **Kroger** | **可用** | 官方API | 已配置 OAuth2,支持产品搜索+店铺查询+价格 | +| **Costco** | 被限制 | 需登录+cookie | headless browser被block,需会员登录 | +| **Sam's Club** | 被限制 | 需登录+cookie | 有人机验证,需会员登录后导出cookie | + +**推荐抓取策略:** +1. **首选**: Weee + Google Maps (可直接browser-use) +2. **需API**: Kroger (申请developer API key) +3. **需特殊处理**: Costco (用户提供cookie)、小红书 (中国代理) + +--- + +## UI Questionnaire Component + +问卷 UI 组件位于 `website/src/components/intake/GroceryPreferencesQuestionnaire.jsx` + +**功能特性:** +- 6步向导式流程 +- 单选/多选按钮组 +- 滑块控件(甜度、辣度) +- 拖拽排序(优先级) +- 响应式设计 + +**使用方式:** +```jsx +import GroceryPreferencesQuestionnaire from './components/intake/GroceryPreferencesQuestionnaire'; + + { + // 保存到用户memory + console.log('User preferences:', formData); + }} + onCancel={() => { + // 取消问卷 + }} + initialData={{}} // 可传入已有偏好数据 +/> +``` + +--- + +## User Onboarding Questionnaire + +When a user first asks for grocery help and has no preference data in memory, run through this onboarding flow. This can be done conversationally (via email/chat) or through a UI questionnaire. + +### Onboarding Flow (Conversational) + +**Step 1: Basic Profile** +``` +你好!我是你的智能买菜助手。为了给你更好的推荐,我想先了解一下你的情况: + +1. 你的文化背景是? + - 中国大陆 + - 台湾/港澳 + - ABC/华裔美国人 + - 韩国/日本 + - 东南亚 + - 其他亚裔 + - 非亚裔但喜欢亚洲食品 + +2. 你住在哪个城市/zip code? + (用于计算到各超市的距离) + +3. 你一般是为几个人买菜? + - 1人(自己) + - 2人(情侣/室友) + - 3-4人(小家庭) + - 5人以上(大家庭) +``` + +**Step 2: Shopping Habits** +``` +4. 你有车吗?最远愿意开多久去买菜? + - 没有车,靠公共交通/走路 + - 有车,15分钟内 + - 有车,30分钟内 + - 有车,1小时内也可以 + +5. 你更喜欢? + - 网购送货(Weee/Instacart) + - 线下超市 + - 两者都可以 + +6. 你有这些会员卡吗?(多选) + - [ ] Costco + - [ ] Sam's Club + - [ ] Kroger Plus Card + - [ ] 其他: ___ +``` + +**Step 3: Main Shopping Categories** +``` +7. 你主要买哪些品类?(多选,我会针对你选的品类问更细的问题) + - [ ] 肉类(猪/牛/羊/鸡) + - [ ] 海鲜 + - [ ] 蔬菜 + - [ ] 水果 + - [ ] 零食饮料 + - [ ] 火锅/烧烤食材 + - [ ] 调味料/酱料 + - [ ] 速食/方便面 + - [ ] 奶制品/鸡蛋 + - [ ] 面包/烘焙 +``` + +**Step 4: Category-Specific Preferences (根据Step 3动态生成)** + +如果选了**肉类**: +``` +关于肉类: +- 你更常买哪种肉?猪肉 / 牛肉 / 鸡肉 / 羊肉 +- 有宗教/饮食限制吗?(如不吃猪肉) +- 你会自己处理生肉吗?还是更喜欢买切好的? +- 对肉的分量有要求吗?(例如:一次只买1-2lb,或者可以批量买冷冻) +``` + +如果选了**零食**: +``` +关于零食: +- 口味偏好?咸口 / 甜口 / 辣口 / 都喜欢 +- 有特别喜欢的品牌吗?(如旺旺、乐事、百草味等) +- 有特别排斥的品牌/口味吗? +- ABC/华裔:你习惯中式零食还是美式零食? +``` + +如果选了**蔬菜**: +``` +关于蔬菜: +- 你常买哪些蔬菜?(中式特有的如韭菜、空心菜还是常见蔬菜) +- 对新鲜度要求高吗?(愿意为更新鲜的多跑一趟店吗) +- 有机蔬菜重要吗? +``` + +如果选了**火锅食材**: +``` +关于火锅: +- 多久吃一次火锅? +- 喜欢什么锅底?(麻辣/清汤/番茄/菌菇) +- 喜欢的火锅品牌?(海底捞/小龙坎/德庄等) +``` + +**Step 5: Taste Profile** +``` +8. 口味偏好: +- 甜度接受度?1-5(1=很淡,5=很甜) + 美式甜品对你来说通常:太甜 / 刚好 / 可以更甜 +- 辣度接受度?1-5(1=不能吃辣,5=越辣越好) +- 咸度偏好:偏淡 / 正常 / 偏咸 + +9. 有什么特别排斥的食物/品牌吗? + 例如:Kraft芝士、美式糖果、某些调味品等 + +10. 有什么饮食限制吗? + - 无 + - 素食/纯素 + - 不吃猪肉(宗教原因) + - 乳糖不耐受 + - 麸质过敏 + - 其他过敏: ___ +``` + +**Step 6: Budget & Priorities** +``` +11. 买菜预算大概是? + - 能省则省,价格最重要 + - 性价比优先,质量也要看 + - 质量优先,价格其次 + - 不太在意价格 + +12. 以下因素对你的重要程度排序: + - 价格便宜 + - 产品新鲜/质量好 + - 距离近/方便 + - 选择多样 + - 有特定想买的品牌 +``` + +### Onboarding Result → Memory Storage + +完成问卷后,生成并保存到用户memory: + +```markdown +## Grocery Preferences (Generated from Onboarding 2026-04-02) + +### Profile +- background: chinese_mainland # chinese_mainland/taiwan_hk/abc/korean/japanese/southeast_asian/other +- city: Ann Arbor, MI +- zip_code: 48109 +- household_size: 2 # 为2人购物 + +### Transportation +- has_car: true +- max_drive_minutes: 30 +- prefers: both # online/offline/both + +### Memberships +- costco: true +- sams_club: false +- kroger_plus: true + +### Main Categories +- primary: [meat, vegetables, snacks, hotpot] +- secondary: [condiments, instant_noodles] + +### Category Preferences + +#### Meat +- preferred: [pork, chicken] # 常买猪肉和鸡肉 +- avoid: [] # 无忌口 +- processing: prefer_precut # 喜欢买切好的 +- bulk_buy: no # 不喜欢批量买 + +#### Snacks +- taste: salty # 偏咸口 +- favorite_brands: [旺旺, 乐事, 百草味] +- avoid_brands: [某品牌] +- style: chinese # 中式零食为主 + +#### Vegetables +- types: [chinese_special, common] # 买中式特色菜和普通蔬菜 +- freshness_priority: high +- organic: not_important + +#### Hotpot +- frequency: monthly +- base_flavor: [mala, tomato] +- favorite_brands: [海底捞, 小龙坎] + +### Taste Profile +- sweetness_tolerance: 2 # 1-5,美式甜品太甜 +- spice_tolerance: 4 # 能吃辣 +- saltiness: normal + +### Exclusions +- brands: [Kraft cheese, Hershey's] +- products: [American-style cakes, overly sweet desserts] +- dietary: [] # 无饮食限制 + +### Budget & Priorities +- budget_sensitivity: value_focused # price_first/value_focused/quality_first/price_insensitive +- priority_order: [price, quality, convenience, variety] + +### Notes +- 自动生成于 2026-04-02 +- 用户可随时说"更新我的买菜偏好"来修改 +``` + +### Quick Onboarding (Minimal Version) + +如果用户不想回答那么多问题,提供简化版: + +``` +快速设置(3个问题): + +1. 你的zip code? +2. 有Costco会员卡吗?有/没有 +3. 有什么不吃的吗?(留空表示无) + +好的!我会根据你的位置推荐附近的超市。之后如果你想设置更详细的偏好, +随时说"完善我的买菜偏好"就可以继续。 +``` + +--- + +## User Preferences Schema + +Store user preferences in their memory file. Expected format: + +```markdown +## Grocery Preferences + +### Location +- zip_code: 48109 +- address: Ann Arbor, MI + +### Taste Preferences +- avoid: ["American-style sweets (too sweet)", "Kraft cheese"] +- prefer: ["Chinese-style pastries", "Asian brands"] +- dietary: ["no pork"] (optional) + +### Shopping Habits +- has_car: true +- max_drive_time: 30 minutes +- preferred_stores: ["168 Asian Mart", "Weee", "Costco"] +- costco_membership: true +- sams_membership: false + +### Known Baselines (user-reported prices) +Last updated: 2026-04-01 +| Item | Store | Price | Unit | Notes | +|------|-------|-------|------|-------| +| 五花肉 Pork Belly | 168 Asian Mart | $3.99 | /lb | Fresh, good quality | +| 五花肉 Pork Belly | H Mart | $4.49 | /lb | | +| 老干妈 Lao Gan Ma | 168 Asian Mart | $3.29 | 280g | | +| 旺旺仙贝 Want Want | Weee | $4.99 | 472g | Often on sale | +``` + +## Data Sources (Priority Order) + +### Tier 1: API/Automated (Most Reliable) + +#### Kroger API (Official) +```bash +# Kroger has an official developer API +# Endpoint: https://api.kroger.com/v1/products +# Requires: API key from developer.kroger.com + +curl -X GET "https://api.kroger.com/v1/products?filter.term=pork+belly&filter.locationId=01400376" \ + -H "Authorization: Bearer $KROGER_ACCESS_TOKEN" \ + -H "Accept: application/json" +``` + +**Available data**: Product name, price, unit, UPC, stock status, store location + +#### Weee (via browser-use) +```bash +# Weee doesn't have public API, use browser automation +IN_DOCKER=true browser-use open "https://www.sayweee.com/en/search?keyword=pork%20belly" +browser-use state # Get product listings +browser-use screenshot /tmp/weee_search.png +``` + +**Parsing Weee results**: Look for price patterns like `$X.XX` and unit patterns like `/lb`, `/ea`, `/pack` + +### Tier 2: Browser Scraping (Medium Reliability) + +#### H Mart Online +```bash +IN_DOCKER=true browser-use open "https://www.hmart.com/search?q=pork+belly" +browser-use state +``` + +#### Yami (Yamibuy) +```bash +IN_DOCKER=true browser-use open "https://www.yamibuy.com/en/search?keywords=pork+belly" +browser-use state +``` + +#### Costco (Requires login) +```bash +# Check if user has saved Costco cookies +if [ -f ~/.costco_cookies.json ]; then + IN_DOCKER=true browser-use cookies import ~/.costco_cookies.json +fi +IN_DOCKER=true browser-use open "https://www.costco.com/CatalogSearch?keyword=pork+belly" +browser-use state +``` + +### Tier 3: Social Media & Reviews Mining (For Local Asian Markets) + +For stores without online presence (168 Asian Mart, Way1/Huaxing, local markets), use social media and review platforms to gather price intelligence: + +#### Google Maps Reviews +```bash +# Search for store and read reviews mentioning prices +IN_DOCKER=true browser-use open "https://www.google.com/maps/search/168+Asian+Mart+Madison+Heights+MI" +browser-use state # Find the store listing +browser-use click # Click to open details +browser-use scroll down # Scroll to reviews +browser-use state # Look for reviews mentioning prices + +# Search pattern in reviews: +# - "prices are good/cheap/expensive" +# - "$X.XX" patterns +# - "cheaper than H Mart" +# - Comparisons with other stores +``` + +**What to extract from Google Maps:** +- Overall price sentiment (cheap/moderate/expensive) +- Specific price mentions in reviews +- Comparisons with other stores +- Recent review dates (prioritize recent ones) + +#### 小红书 (Xiaohongshu) Price Discovery +```bash +# Search for store + location + price keywords +IN_DOCKER=true browser-use open "https://www.xiaohongshu.com/search_result?keyword=168亚洲超市+密歇根+价格" +browser-use state +browser-use screenshot /tmp/xhs_search.png + +# Alternative searches: +# "密歇根 华人超市 价格" +# "Ann Arbor 买菜 攻略" +# "168超市 什么便宜" +# "美国亚超 比价" +``` + +**小红书搜索关键词模板:** +| 场景 | 关键词 | +|------|--------| +| 特定商品 | `{城市} {商品名} 价格` | +| 超市攻略 | `{城市} 华人超市 攻略` | +| 比价笔记 | `{超市名} vs {超市名}` | +| 省钱技巧 | `美国买菜 省钱` | +| 产品评价 | `{超市名} {商品名} 好吃吗` | +| 踩坑避雷 | `{超市名} 避雷 不要买` | +| 推荐必买 | `{超市名} 必买清单` | + +**小红书内容解析要点 - 不只是价格!** + +| 维度 | 要提取的信息 | 示例 | +|------|-------------|------| +| **价格** | 具体价格、单位、是否促销 | "$3.99/lb, 特价" | +| **分量/规格** | 包装大小、几人份 | "一盒够4个人吃火锅" | +| **品质评价** | 新鲜度、口感、质量 | "很新鲜"、"肉质紧实" | +| **加工难度** | 是否需要处理、骨头多少 | "骨头很大不好切"、"已经切好了" | +| **口味描述** | 甜/咸/辣、是否正宗 | "偏甜"、"和国内味道一样" | +| **性价比** | 相比其他店如何 | "比H Mart便宜但分量小" | +| **适合人群** | 适合谁、不适合谁 | "适合北方人口味"、"ABC可能觉得太咸" | +| **踩坑提醒** | 需要注意的问题 | "保质期短"、"需要提前解冻" | + +**小红书"问一问"AI功能:** +```bash +# 小红书有AI问答功能,可以直接提问 +IN_DOCKER=true browser-use open "https://www.xiaohongshu.com/search_result?keyword=山姆+五花肉" +browser-use state +# 找到"问一问"入口或搜索结果中的AI总结 +# 可以直接问:"山姆的五花肉怎么样?骨头多吗?" +``` + +**产品评价综合报告示例:** +```markdown +## 山姆 五花肉 详细评价 + +### 基本信息 +- 价格: $3.29/lb (会员价) +- 规格: 整块约5-6lb,需要自己切 +- 产地: 美国 + +### 用户评价摘要 (来自小红书 12篇笔记) +- **优点**: + - 价格便宜,性价比高 + - 肉质新鲜,脂肪层分明 +- **缺点**: + - 骨头比较大,需要自己处理 + - 整块太大,1-2人家庭吃不完 + - 需要自己切片,不如168切好的方便 + +### 适合人群 +- ✅ 3人以上家庭 +- ✅ 会做饭、有切肉工具的人 +- ✅ 喜欢批量买然后冷冻的人 +- ❌ 1-2人小家庭(分量太大) +- ❌ 不想自己处理的人 + +### 加工建议 +- 买回来先切成小块分装冷冻 +- 或者让山姆肉柜帮忙切(部分店提供) + +### 综合推荐 +如果你是3人以上家庭,有时间处理,山姆的五花肉性价比最高。 +如果你是1-2人,建议去168买已切好的,虽然贵$0.70/lb但省事很多。 +``` + +#### WeChat Articles & Groups (微信公众号/群聊截图) +```bash +# Search for WeChat articles about local grocery shopping +IN_DOCKER=true browser-use open "https://www.google.com/search?q=site:mp.weixin.qq.com+密歇根+华人超市+价格" +browser-use state +``` + +**微信渠道信息来源:** +- 当地华人公众号的超市攻略 +- 留学生群里分享的价格截图 +- 团购群的拼单信息 + +#### Reddit & Local Forums +```bash +# Search Reddit for local grocery discussions +IN_DOCKER=true browser-use open "https://www.reddit.com/r/AnnArbor/search/?q=asian+grocery+prices" +browser-use state + +# Alternative: Google search with site filter +IN_DOCKER=true browser-use open "https://www.google.com/search?q=site:reddit.com+Ann+Arbor+168+Asian+Mart+prices" +``` + +#### Yelp Reviews +```bash +IN_DOCKER=true browser-use open "https://www.yelp.com/biz/168-asian-mart-madison-heights" +browser-use scroll down # Scroll to reviews +browser-use state +``` + +### Tier 4: User-Reported Data (Baseline Prices) + +For stores where social media mining doesn't yield results: + +1. **Check user's baseline prices** in their memory file +2. **Ask user to report** if data is stale (>7 days) +3. **Offer to help track** via receipt scanning (future feature) + +## Social Media Price Discovery Workflow + +When a user asks about a store without online pricing (like 168 Asian Mart), follow this discovery workflow: + +### Step 1: Quick Google Maps Check +```bash +# Fast sentiment check from reviews +IN_DOCKER=true browser-use open "https://www.google.com/maps/search/{store_name}+{city}+{state}" +browser-use state +# Click on store → scroll to reviews → extract price mentions +``` + +### Step 2: 小红书 Deep Dive (Best for Chinese stores) +```bash +# 小红书 is the BEST source for Chinese grocery price info +IN_DOCKER=true browser-use open "https://www.xiaohongshu.com/search_result?keyword={store_name}+{city}" +browser-use state +browser-use screenshot /tmp/xhs_results.png +``` + +**Parsing 小红书 Results:** +1. Look for posts with **价格/价签/账单** in title +2. Check images for price tag photos +3. Note the post date (ignore posts >6 months old) +4. Read comments for updated prices + +### Step 3: Synthesize Price Intelligence + +After gathering data, create a summary: + +```markdown +## Price Intelligence: 168 Asian Mart (Madison Heights, MI) + +### Data Sources +- Google Maps: 47 reviews mentioning prices (avg sentiment: "affordable") +- 小红书: 12 relevant posts (most recent: 2026-03-15) +- Yelp: 23 price mentions + +### Price Findings +| Item | Reported Price | Source | Date | +|------|---------------|--------|------| +| 五花肉 | $3.99/lb | 小红书@密歇根吃货 | 2026-03-15 | +| 老干妈 | $3.29 | Google review | 2026-02-20 | +| 整体印象 | "比H Mart便宜20-30%" | Multiple sources | - | + +### Confidence Level +- High: 多个来源一致的价格 +- Medium: 单一来源但近期 +- Low: 老旧数据或模糊描述 + +### Recommendation +基于社媒数据,168的价格整体比H Mart便宜,尤其是中国特色商品。 +建议将此信息保存到你的memory作为baseline。 +``` + +### Step 4: Update User Memory + +If useful price data is found, offer to save to user's memory: + +```markdown +我从小红书和Google Maps找到了168的一些价格信息: +- 五花肉 ~$3.99/lb +- 老干妈 ~$3.29 +- 整体比H Mart便宜20-30% + +要我帮你保存到memory作为baseline吗?下次比价可以直接用。 +``` + +--- + +## Comparison Workflow + +### Step 1: Understand the Request + +Parse user query to extract: +- **Product**: What they want to buy (e.g., "五花肉", "pork belly", "老干妈") +- **Quantity**: How much (optional, affects bulk store recommendations) +- **Urgency**: Need it today vs can wait for delivery + +Example queries: +- "帮我比一下五花肉哪里便宜" +- "Where should I buy pork belly?" +- "Weee上的旺旺和168比哪个划算" + +### Step 2: Gather Price Data + +```python +# Pseudocode for data gathering +async def gather_prices(product: str, user_prefs: dict): + results = [] + + # 1. Check user's baseline data first + baseline = check_user_baselines(product, user_prefs) + if baseline: + results.extend(baseline) + + # 2. Query online sources + if "Weee" in user_prefs.preferred_stores: + weee_price = await scrape_weee(product) + results.append(weee_price) + + if "Kroger" in user_prefs.preferred_stores: + kroger_price = await query_kroger_api(product, user_prefs.zip_code) + results.append(kroger_price) + + # 3. Browser scrape other online stores + for store in ["H Mart", "Yami"]: + if store in user_prefs.preferred_stores: + price = await browser_scrape(store, product) + results.append(price) + + return results +``` + +### Step 3: Normalize and Compare + +**Unit normalization is critical:** + +| Raw | Normalized | +|-----|-----------| +| $3.99/lb | $8.80/kg | +| $4.99/500g | $9.98/kg | +| $12.99/pack (2lb) | $6.50/lb = $14.33/kg | + +```python +def normalize_price(price: float, unit: str) -> float: + """Convert to $/kg for comparison""" + conversions = { + "lb": 2.20462, # 1kg = 2.20462 lb + "oz": 35.274, # 1kg = 35.274 oz + "g": 1000, # 1kg = 1000g + "kg": 1, + } + # Parse unit and convert + ... +``` + +### Step 4: Apply User Preferences + +**Scoring formula:** + +``` +score = base_price_score + + taste_penalty # -100 if American-style and user avoids + + distance_penalty # -10 per 10 min drive over threshold + + delivery_bonus # +20 if delivers to user's zip + + freshness_bonus # +15 for fresh vs frozen +``` + +### Step 5: Generate Recommendation + +**Output format:** + +```markdown +## 五花肉 (Pork Belly) 比价结果 + +### 推荐: 168 Asian Mart +- 价格: $3.99/lb ($8.80/kg) +- 距离: 15分钟车程 +- 优点: 最便宜,品质好,新鲜 +- 缺点: 需要开车 + +### 其他选项: + +| 渠道 | 价格 | 单价 | 配送 | 备注 | +|------|------|------|------|------| +| Weee | $5.49/lb | $12.10/kg | 免费送货 | 方便但贵$1.50/lb | +| H Mart | $4.49/lb | $9.90/kg | 需自取 | 20分钟车程 | +| Costco | $3.29/lb | $7.25/kg | 需自取 | 需买整块5lb+,会员价 | + +### 建议 +考虑到你住在Ann Arbor,168开车15分钟可以接受。如果你这周不想出门,Weee虽然贵$1.50/lb但免费送货,5lb以内差价不到$8,可能值得。 + +如果你要买大量(>5lb),Costco最划算,但需要会员卡。 +``` + +## Common Products (Chinese/English Mapping) + +| 中文 | English | Category | Typical Stores | +|------|---------|----------|----------------| +| 五花肉 | Pork Belly | Meat | 168, H Mart, Costco, Weee | +| 猪蹄 | Pork Feet/Trotters | Meat | 168, H Mart (rare at Costco) | +| 老干妈 | Lao Gan Ma Chili | Condiment | 168, Weee, Yami, H Mart | +| 旺旺仙贝 | Want Want Rice Crackers | Snack | Weee, Yami, 168 | +| 康师傅方便面 | Master Kong Instant Noodles | Noodles | Weee, 168, Yami | +| 王老吉 | Wong Lo Kat Herbal Tea | Beverage | 168, Weee, H Mart | +| 豆腐 | Tofu | Produce | All stores | +| 韭菜 | Chinese Chives | Produce | 168, H Mart, 99 Ranch | +| 火锅底料 | Hot Pot Base | Condiment | Weee, 168, H Mart | + +## Store Profiles + +### Online Delivery + +| Store | Delivery | Min Order | Coverage | Specialty | +|-------|----------|-----------|----------|-----------| +| Weee | Free >$35 | $35 | Major metros | Chinese + Pan-Asian groceries | +| Yami | $5.99 or free >$49 | None | Nationwide | Asian snacks, beauty | +| Instacart (Kroger) | $3.99+ | $10 | Nationwide | Mainstream groceries | +| Costco Delivery | Varies | $35 | Members only | Bulk items | + +### Physical Stores (Michigan Ann Arbor Area) + +| Store | Address | Drive Time | Best For | +|-------|---------|------------|----------| +| 168 Asian Mart | Madison Heights | ~25 min | Chinese groceries, best prices | +| H Mart | Troy | ~30 min | Korean + general Asian | +| Kroger | Multiple | 5-15 min | American groceries, good sales | +| Costco | Ann Arbor | 15 min | Bulk buying | +| Sam's Club | Ypsilanti | 20 min | Bulk buying | +| Aldi | Multiple | 5-10 min | Budget European goods | + +## Handling Edge Cases + +### Product not found online +``` +我在Weee上没找到这个产品。根据你之前报告的价格,168有卖$X.XX。 +你要我帮你记录一下其他店的价格吗?下次可以直接比较。 +``` + +### Price data is stale +``` +你上次记录168的价格是2周前了。这些价格可能有变化: +- 五花肉 $3.99/lb (记录于 2026-03-18) + +你最近去过168吗?可以帮我更新一下价格。 +``` + +### User asks about non-Asian product +``` +鸡蛋在这些店的价格大概是: +- Costco: $X.XX/18个 (需会员) +- Aldi: $X.XX/12个 (最便宜非会员选项) +- Kroger: $X.XX/12个 (查看本周flyer可能有折扣) + +注意:亚洲超市的鸡蛋通常比Costco/Aldi贵,除非你需要特定品种(如皮蛋、咸蛋)。 +``` + +## Weekly Deals Integration + +### Parsing Promotional Emails + +If user forwards promotional emails to DoWhiz: + +```python +# Extract deals from email body +def parse_promo_email(email_body: str, store: str): + # Look for patterns like: + # "Pork Belly $2.99/lb (reg $3.99)" + # "五花肉 特价 $2.99/磅" + deals = extract_deals(email_body) + + # Store in user's deal tracker + for deal in deals: + save_deal(user_id, store, deal.product, deal.sale_price, deal.valid_until) +``` + +### Proactive Recommendations + +If user has subscribed to weekly recommendations: + +```markdown +## 本周值得买 (Week of 2026-04-01) + +基于你的偏好和各店促销: + +1. **五花肉** - 168特价 $2.99/lb (原价$3.99,省25%) + - 比Weee便宜$2.50/lb + - 建议:多买点冻起来 + +2. **旺旺仙贝** - Weee买二送一 + - 相当于$3.33/包,比168便宜 + +3. **老干妈** - Yami有8折码 SPRING20 + - 但算上运费不如168划算,除非你要买其他东西凑$49免邮 +``` + +## Receipt OCR Integration (Future) + +When receipt scanning is available: + +```bash +# User uploads receipt image +receipt_ocr --image /tmp/receipt.jpg --output json > /tmp/receipt_data.json + +# Parse and update price database +update_prices_from_receipt /tmp/receipt_data.json --store "168 Asian Mart" +``` + +## Search Query Templates by Scenario + +### Scenario 1: Unknown Store Price Level +``` +Goal: Understand if a store is generally cheap or expensive + +Google Maps: "{store_name} {city}" → read reviews for price sentiment +小红书: "{store_name} 价格 贵不贵" +Reddit: "site:reddit.com {city} {store_name} prices cheap expensive" +``` + +### Scenario 2: Specific Product Price +``` +Goal: Find what a specific item costs at a store + +小红书: "{store_name} {product_name_zh} 价格" (e.g., "168超市 五花肉 价格") +Google: "{store_name} {product_name} price reddit OR yelp" +``` + +### Scenario 3: Store Comparison +``` +Goal: Compare two stores + +小红书: "{store_A} vs {store_B}" or "{store_A} {store_B} 比较" +Reddit: "{city} {store_A} vs {store_B}" +Google: "site:reddit.com OR site:yelp.com {store_A} cheaper than {store_B}" +``` + +### Scenario 4: Best Store for a Category +``` +Goal: Find which store is best for meat/produce/snacks + +小红书: "{city} 买肉 哪家便宜" or "{city} 亚洲零食 推荐" +Reddit: "{city} best asian grocery for {category}" +``` + +### Scenario 5: Current Deals/Sales +``` +Goal: Find current promotions + +小红书: "{store_name} 打折 {current_month}" (e.g., "Weee 打折 四月") +Store's Instagram/Facebook (if they have one) +Local WeChat groups (ask user if they're in any) +``` + +## Data Freshness Guidelines + +| Source | Typical Freshness | Trust Level | +|--------|-------------------|-------------| +| 小红书 posts <1 month | High | ⭐⭐⭐⭐ | +| 小红书 posts 1-6 months | Medium | ⭐⭐⭐ | +| Google reviews <3 months | High | ⭐⭐⭐⭐ | +| Google reviews 3-12 months | Medium | ⭐⭐⭐ | +| Reddit posts <6 months | Medium-High | ⭐⭐⭐ | +| Yelp reviews | Variable | ⭐⭐ | +| User's own baseline | Depends on date | ⭐⭐⭐⭐⭐ | + +**Rule of thumb:** +- Prices can change 5-15% over 6 months +- Sale items can change weekly +- Always note the date when reporting social media prices + +## Handling No Results + +If social media search yields no useful price data: + +```markdown +我在小红书和Google Maps上没找到{store_name}的具体价格信息。 + +可能的原因: +1. 这家店比较小众,网上讨论少 +2. 搜索关键词可能需要调整 + +建议: +1. 你下次去{store_name}的时候可以拍几张价签,我帮你记录 +2. 或者你知道大概价格的话可以告诉我,我保存到你的memory +3. 我可以先用H Mart/Weee的价格作为参考(通常本地小店会便宜10-20%) + +需要我用哪种方式帮你? +``` + +## Tips for Effective Use + +1. **First time setup**: Ask user for zip code and store preferences +2. **Build baseline**: Encourage user to share prices they see in stores +3. **Track over time**: Prices fluctuate; historical data helps identify deals +4. **Consider total trip**: Factor in gas cost for distant stores +5. **Batch recommendations**: "If you're going to 168 anyway, also grab X, Y, Z" +6. **Social media first**: For local Asian markets, always check 小红书 first - it's the best source for Chinese grocery price info +7. **Save discoveries**: When social media yields good price data, offer to save to user's memory + +## Example Conversations + +### Basic Price Check +``` +User: 老干妈在哪买便宜 +Agent: [Checks Weee, user's 168 baseline, Yami] + +老干妈 (280g装) 比价: +- 168 Asian Mart: $3.29 (你上次报告的价格) +- Weee: $3.99 +- Yami: $4.49 + +建议去168买,便宜$0.70。如果你这周有其他东西要在Weee买,可以顺便加购,差价不大。 +``` + +### Complex Comparison +``` +User: 我想买火锅食材,比较一下168和Weee + +Agent: [Comprehensive comparison] + +火锅食材比较 (168 vs Weee): + +| 食材 | 168 | Weee | 推荐 | +|------|-----|------|------| +| 肥牛卷 | $8.99/lb | $9.49/lb | 168 | +| 羊肉卷 | $12.99/lb | $11.99/lb | Weee | +| 豆腐 | $1.99 | $2.49 | 168 | +| 金针菇 | $1.79 | $1.99 | 168 | +| 海底捞底料 | $5.99 | $5.49 | Weee | + +总计 (估算一顿4人份): +- 168: ~$35 + 开车25分钟 +- Weee: ~$38 + 免费配送 + +建议:如果你有时间去168,能省$3+。如果懒得出门,Weee贵不了多少,而且不用自己搬。 +``` + +## Cleanup + +Always close browser sessions after scraping: +```bash +browser-use close +``` diff --git a/.claude/skills/grocery-comparison/example_grocery_preferences.md b/.claude/skills/grocery-comparison/example_grocery_preferences.md new file mode 100644 index 00000000..46d4c7c3 --- /dev/null +++ b/.claude/skills/grocery-comparison/example_grocery_preferences.md @@ -0,0 +1,136 @@ +# Example Grocery Preferences Template + +Copy this section to your memory file and customize it for the agent to understand your shopping preferences. + +--- + +## Grocery Preferences + +### Basic Profile +- **Cultural background**: 中国大陆 / 台湾港澳 / ABC华裔 / 韩国日本 / 东南亚 / 其他亚裔 / 非亚裔但喜欢亚洲食品 +- **Location**: Ann Arbor, MI (48109) +- **Household size**: 2人(情侣) + +### Transportation & Shopping Habits +- **Has car**: yes +- **Max drive time**: 30 minutes +- **Shopping preference**: 两者都可以(网购+线下) +- **Shopping frequency**: weekly + +### Memberships +- Costco: yes +- Sam's Club: no +- Kroger Plus Card: yes + +### Main Shopping Categories +主要买: +- [x] 肉类(猪/牛/鸡) +- [x] 蔬菜 +- [x] 零食饮料 +- [x] 火锅食材 +- [x] 调味料/酱料 +- [x] 奶制品/鸡蛋 + +### Category-Specific Preferences + +**肉类**: +- 常买:猪肉、鸡肉 +- 饮食限制:无 +- 处理偏好:更喜欢买切好的,省时间 +- 分量偏好:一次买1-2lb,不批量囤 + +**零食**: +- 口味偏好:咸口、辣口 +- 喜欢品牌:旺旺、卫龙、乐事 +- 排斥:美式糖果(太甜) +- 中式零食优先 + +**蔬菜**: +- 常买:韭菜、空心菜、小白菜、西兰花 +- 新鲜度:愿意为新鲜的多跑一趟 +- 有机:不是必须 + +**火锅**: +- 频率:每月1-2次 +- 锅底:麻辣、番茄 +- 品牌:海底捞、小龙坎 + +### Taste Profile +- **甜度接受度**: 2/5(美式甜品太甜) +- **辣度接受度**: 4/5(能吃辣) +- **咸度偏好**: 正常 + +**排斥的食物/品牌**: +- Kraft cheese products(美式芝士不习惯) +- American-style cakes and pastries(太甜腻) +- 过度加工食品 + +**饮食限制**: 无 + +### Budget & Priorities +- **Budget mindset**: 性价比优先,质量也要看 +- **Priority ranking** (1=最重要): + 1. 产品质量/新鲜 + 2. 价格便宜 + 3. 距离/方便 + 4. 品种齐全 + +### Preferred Stores (in order) +1. **168 Asian Mart** - 25 min drive, best prices for Chinese groceries +2. **Weee** - Delivery, free shipping over $35 +3. **Costco** - Bulk buying, membership required +4. **Kroger** - Close by, good for American groceries + weekly sales +5. **Aldi** - Budget European items, cheapest eggs + +--- + +## Known Baseline Prices + +(Update these when you visit stores. Agent will use these for comparison.) + +Last updated: 2026-04-01 + +| Item | Store | Price | Unit | Notes | +|------|-------|-------|------|-------| +| 五花肉 Pork Belly | 168 Asian Mart | $3.99 | /lb | Fresh, good quality | +| 五花肉 Pork Belly | H Mart | $4.49 | /lb | Korean cut | +| 五花肉 Pork Belly | Weee | $5.49 | /lb | Delivery included | +| 五花肉 Pork Belly | Costco | $3.29 | /lb | Must buy 5lb+ pack | +| 老干妈 Lao Gan Ma | 168 Asian Mart | $3.29 | 280g | | +| 老干妈 Lao Gan Ma | Weee | $3.99 | 280g | | +| 老干妈 Lao Gan Ma | Yami | $4.49 | 280g | | +| 旺旺仙贝 Want Want | 168 Asian Mart | $4.29 | 472g | | +| 旺旺仙贝 Want Want | Weee | $4.99 | 472g | Often on sale | +| 康师傅红烧牛肉面 | 168 Asian Mart | $12.99 | 5-pack | | +| 康师傅红烧牛肉面 | Weee | $13.99 | 5-pack | | +| 鸡蛋 Eggs | Costco | $6.99 | 24-pack | | +| 鸡蛋 Eggs | Aldi | $2.49 | 12-pack | | +| 鸡蛋 Eggs | Kroger | $3.29 | 12-pack | Check weekly ad | +| 豆腐 Tofu (firm) | 168 Asian Mart | $1.99 | 14oz | | +| 豆腐 Tofu (firm) | Kroger | $2.49 | 14oz | | +| 韭菜 Chinese Chives | 168 Asian Mart | $1.79 | /bunch | | +| 韭菜 Chinese Chives | H Mart | $1.99 | /bunch | | + +--- + +## Product Evaluation Notes + +(Store quality/experience notes from 小红书 and personal experience) + +| Product | Store | Rating | Notes | +|---------|-------|--------|-------| +| 五花肉 | Costco | 4/5 | 份量大(5lb起),肉质好但骨头多,需要自己处理 | +| 五花肉 | 168 Asian Mart | 5/5 | 切好的薄片,直接可以做回锅肉,肥瘦均匀 | +| 五花肉 | Sam's Club | 3/5 | 小红书反映骨头太多,不好加工 | +| 海底捞锅底 | Weee | 5/5 | 价格稳定,经常有满减活动 | +| 海底捞锅底 | 168 Asian Mart | 4/5 | 便宜$1但有时断货 | + +--- + +## Notes +- 168 Asian Mart is 25 min drive from Ann Arbor, but has the best prices for Chinese groceries +- Weee delivers to 48109 area, free shipping over $35 +- Costco meat is great value if buying in bulk, but often requires extra processing +- Kroger has good sales on American groceries, check weekly flyer +- Aldi eggs and dairy are cheapest for non-specialty items +- Sam's Club has some good Asian items but membership required (similar to Costco) diff --git a/DoWhiz_service/run_task_module/src/run_task/prompt.rs b/DoWhiz_service/run_task_module/src/run_task/prompt.rs index 27b731e4..2a3de448 100644 --- a/DoWhiz_service/run_task_module/src/run_task/prompt.rs +++ b/DoWhiz_service/run_task_module/src/run_task/prompt.rs @@ -371,6 +371,14 @@ Identity Lookup - for inviting Discord guild members to shared resources: Group Project Coordination: - When coordinating team workspaces or shared resources for multiple people, read `skills/group-project-coordination/SKILL.md` for the workflow. +Grocery Price Comparison (for shopping/price queries): +- When user asks to compare grocery prices, find deals, or get shopping recommendations, use `skills/grocery-comparison/SKILL.md`. +- This skill helps compare prices across Weee, Asian markets (H Mart, 168), and mainstream stores (Kroger, Costco, Aldi). +- Consider user's taste preferences (e.g., "American cakes are too sweet"), distance, and cultural factors. +- Use browser-use to scrape prices from online stores like Weee and Yami when needed. +- Store user preferences (zip code, taste preferences, memberships) in their memory files. +- For local Asian markets without online presence, check user's reported baseline prices in their memory. + Security: Only access files the CURRENT USER has shared. Never access other users' files. See `.agents/skills/google-*/SKILL.md` for detailed command references. diff --git a/DoWhiz_service/scheduler_module/src/grocery_store.rs b/DoWhiz_service/scheduler_module/src/grocery_store.rs new file mode 100644 index 00000000..2e04d4d3 --- /dev/null +++ b/DoWhiz_service/scheduler_module/src/grocery_store.rs @@ -0,0 +1,815 @@ +//! Grocery price comparison data store. +//! +//! Stores product prices, user preferences, and store information for the +//! smart grocery comparison agent. + +use chrono::{DateTime, Utc}; +use mongodb::bson::{doc, Bson, DateTime as BsonDateTime, Document}; +use mongodb::options::{FindOptions, IndexOptions, UpdateOptions}; +use mongodb::sync::Collection; +use mongodb::IndexModel; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +use crate::mongo_store::{create_client_from_env, database_from_env, ensure_index_compatible}; + +// ============================================================================ +// Error Types +// ============================================================================ + +#[derive(Debug, thiserror::Error)] +pub enum GroceryStoreError { + #[error("mongodb error: {0}")] + Mongo(#[from] mongodb::error::Error), + #[error("mongo config error: {0}")] + MongoConfig(String), + #[error("invalid data: {0}")] + InvalidData(String), +} + +// ============================================================================ +// Data Models +// ============================================================================ + +/// A grocery product with normalized name and category. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GroceryProduct { + pub product_id: String, + /// Canonical English name (e.g., "pork belly") + pub name_en: String, + /// Chinese name if applicable (e.g., "五花肉") + pub name_zh: Option, + /// Product category (e.g., "meat", "snack", "condiment") + pub category: String, + /// Brand if applicable + pub brand: Option, + /// Alternative names/aliases for search + pub aliases: Vec, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// A price observation for a product at a specific store. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PriceEntry { + pub entry_id: String, + pub product_id: String, + pub store_id: String, + /// Raw price as displayed (e.g., 3.99) + pub price: f64, + /// Unit as displayed (e.g., "lb", "kg", "ea", "pack") + pub unit: String, + /// Normalized price per kg for comparison + pub price_per_kg: Option, + /// Source of this price data + pub source: PriceSource, + /// When this price was observed + pub observed_at: DateTime, + /// When this entry was created in DB + pub created_at: DateTime, + /// Optional notes (e.g., "on sale", "member price") + pub notes: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PriceSource { + /// Fetched via official API (most reliable) + Api, + /// Scraped from website + WebScrape, + /// Reported by user (receipt scan or manual) + UserReport, + /// Extracted from promotional email + PromoEmail, + /// Imported from external dataset + Import, +} + +/// A grocery store with location and type. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GroceryStore { + pub store_id: String, + pub name: String, + /// Store type: "asian_market", "wholesale", "mainstream", "online" + pub store_type: String, + /// Address if physical + pub address: Option, + /// City + pub city: Option, + /// State + pub state: Option, + /// ZIP code + pub zip_code: Option, + /// Latitude for distance calculation + pub latitude: Option, + /// Longitude for distance calculation + pub longitude: Option, + /// Whether this is an online-only store + pub is_online: bool, + /// Delivery available + pub has_delivery: bool, + /// Minimum order for free delivery + pub free_delivery_min: Option, + /// Requires membership (Costco, Sam's) + pub requires_membership: bool, + /// Website URL + pub website: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// User's grocery preferences. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GroceryPreferences { + pub user_id: String, + /// User's ZIP code for distance calculation + pub zip_code: Option, + /// User's address for more precise distance + pub address: Option, + /// Latitude + pub latitude: Option, + /// Longitude + pub longitude: Option, + /// Taste preferences to avoid (e.g., "American-style sweets") + pub taste_avoid: Vec, + /// Taste preferences to prefer (e.g., "Chinese-style pastries") + pub taste_prefer: Vec, + /// Dietary restrictions (e.g., "no pork", "vegetarian") + pub dietary: Vec, + /// Whether user has a car + pub has_car: bool, + /// Maximum drive time in minutes + pub max_drive_minutes: Option, + /// Preferred stores (store_ids) + pub preferred_stores: Vec, + /// Store memberships (e.g., "costco", "sams") + pub memberships: Vec, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +// ============================================================================ +// Store Implementation +// ============================================================================ + +#[derive(Debug, Clone)] +pub struct GroceryStore_ { + products: Collection, + prices: Collection, + stores: Collection, + preferences: Collection, +} + +impl GroceryStore_ { + pub fn new() -> Result { + let client = + create_client_from_env().map_err(|err| GroceryStoreError::MongoConfig(err.to_string()))?; + let db = database_from_env(&client); + + let products = db.collection::("grocery_products"); + let prices = db.collection::("grocery_prices"); + let stores = db.collection::("grocery_stores"); + let preferences = db.collection::("grocery_preferences"); + + // Ensure indexes + Self::ensure_indexes(&products, &prices, &stores, &preferences)?; + + Ok(Self { + products, + prices, + stores, + preferences, + }) + } + + fn ensure_indexes( + products: &Collection, + prices: &Collection, + stores: &Collection, + preferences: &Collection, + ) -> Result<(), GroceryStoreError> { + // Products indexes + ensure_index_compatible( + products, + IndexModel::builder() + .keys(doc! { "product_id": 1 }) + .options(IndexOptions::builder().unique(Some(true)).build()) + .build(), + )?; + ensure_index_compatible( + products, + IndexModel::builder() + .keys(doc! { "name_en": 1 }) + .build(), + )?; + ensure_index_compatible( + products, + IndexModel::builder() + .keys(doc! { "name_zh": 1 }) + .build(), + )?; + ensure_index_compatible( + products, + IndexModel::builder() + .keys(doc! { "category": 1 }) + .build(), + )?; + + // Prices indexes + ensure_index_compatible( + prices, + IndexModel::builder() + .keys(doc! { "product_id": 1, "store_id": 1, "observed_at": -1 }) + .build(), + )?; + ensure_index_compatible( + prices, + IndexModel::builder() + .keys(doc! { "store_id": 1, "observed_at": -1 }) + .build(), + )?; + + // Stores indexes + ensure_index_compatible( + stores, + IndexModel::builder() + .keys(doc! { "store_id": 1 }) + .options(IndexOptions::builder().unique(Some(true)).build()) + .build(), + )?; + ensure_index_compatible( + stores, + IndexModel::builder() + .keys(doc! { "zip_code": 1 }) + .build(), + )?; + + // Preferences indexes + ensure_index_compatible( + preferences, + IndexModel::builder() + .keys(doc! { "user_id": 1 }) + .options(IndexOptions::builder().unique(Some(true)).build()) + .build(), + )?; + + Ok(()) + } + + // ======================================================================== + // Product Operations + // ======================================================================== + + /// Get or create a product by name. + pub fn get_or_create_product( + &self, + name_en: &str, + name_zh: Option<&str>, + category: &str, + ) -> Result { + let normalized = normalize_product_name(name_en); + let filter = doc! { "name_en": &normalized }; + + if let Some(existing) = self.products.find_one(filter.clone(), None)? { + return document_to_product(existing); + } + + let now = Utc::now(); + let product_id = uuid::Uuid::new_v4().to_string(); + let doc = doc! { + "product_id": &product_id, + "name_en": &normalized, + "name_zh": name_zh, + "category": category, + "brand": Bson::Null, + "aliases": Vec::::new(), + "created_at": BsonDateTime::from_chrono(now), + "updated_at": BsonDateTime::from_chrono(now), + }; + + self.products.insert_one(doc, None)?; + Ok(GroceryProduct { + product_id, + name_en: normalized, + name_zh: name_zh.map(String::from), + category: category.to_string(), + brand: None, + aliases: vec![], + created_at: now, + updated_at: now, + }) + } + + /// Search products by name (English or Chinese). + pub fn search_products(&self, query: &str, limit: i64) -> Result, GroceryStoreError> { + let normalized = normalize_product_name(query); + let filter = doc! { + "$or": [ + { "name_en": { "$regex": &normalized, "$options": "i" } }, + { "name_zh": { "$regex": query, "$options": "i" } }, + { "aliases": { "$elemMatch": { "$regex": &normalized, "$options": "i" } } }, + ] + }; + + let options = FindOptions::builder().limit(limit).build(); + let cursor = self.products.find(filter, options)?; + + let mut results = Vec::new(); + for doc in cursor { + results.push(document_to_product(doc?)?); + } + Ok(results) + } + + // ======================================================================== + // Price Operations + // ======================================================================== + + /// Record a price observation. + pub fn record_price( + &self, + product_id: &str, + store_id: &str, + price: f64, + unit: &str, + source: PriceSource, + notes: Option<&str>, + ) -> Result { + let now = Utc::now(); + let entry_id = uuid::Uuid::new_v4().to_string(); + let price_per_kg = normalize_price_to_kg(price, unit); + + let doc = doc! { + "entry_id": &entry_id, + "product_id": product_id, + "store_id": store_id, + "price": price, + "unit": unit, + "price_per_kg": price_per_kg, + "source": mongodb::bson::to_bson(&source).unwrap_or(Bson::String("unknown".to_string())), + "observed_at": BsonDateTime::from_chrono(now), + "created_at": BsonDateTime::from_chrono(now), + "notes": notes, + }; + + self.prices.insert_one(doc, None)?; + Ok(PriceEntry { + entry_id, + product_id: product_id.to_string(), + store_id: store_id.to_string(), + price, + unit: unit.to_string(), + price_per_kg, + source, + observed_at: now, + created_at: now, + notes: notes.map(String::from), + }) + } + + /// Get latest prices for a product across all stores. + pub fn get_latest_prices(&self, product_id: &str) -> Result, GroceryStoreError> { + // Aggregate to get latest price per store + let pipeline = vec![ + doc! { "$match": { "product_id": product_id } }, + doc! { "$sort": { "observed_at": -1 } }, + doc! { + "$group": { + "_id": "$store_id", + "doc": { "$first": "$$ROOT" } + } + }, + doc! { "$replaceRoot": { "newRoot": "$doc" } }, + ]; + + let cursor = self.prices.aggregate(pipeline, None)?; + let mut results = Vec::new(); + for doc in cursor { + results.push(document_to_price_entry(doc?)?); + } + Ok(results) + } + + /// Get price history for a product at a specific store. + pub fn get_price_history( + &self, + product_id: &str, + store_id: &str, + limit: i64, + ) -> Result, GroceryStoreError> { + let filter = doc! { + "product_id": product_id, + "store_id": store_id, + }; + let options = FindOptions::builder() + .sort(doc! { "observed_at": -1 }) + .limit(limit) + .build(); + + let cursor = self.prices.find(filter, options)?; + let mut results = Vec::new(); + for doc in cursor { + results.push(document_to_price_entry(doc?)?); + } + Ok(results) + } + + // ======================================================================== + // Store Operations + // ======================================================================== + + /// Get or create a store. + pub fn get_or_create_store( + &self, + name: &str, + store_type: &str, + is_online: bool, + ) -> Result { + let normalized_name = name.trim().to_lowercase(); + let filter = doc! { "name": { "$regex": format!("^{}$", regex::escape(&normalized_name)), "$options": "i" } }; + + if let Some(existing) = self.stores.find_one(filter, None)? { + return document_to_store(existing); + } + + let now = Utc::now(); + let store_id = uuid::Uuid::new_v4().to_string(); + let doc = doc! { + "store_id": &store_id, + "name": name, + "store_type": store_type, + "address": Bson::Null, + "city": Bson::Null, + "state": Bson::Null, + "zip_code": Bson::Null, + "latitude": Bson::Null, + "longitude": Bson::Null, + "is_online": is_online, + "has_delivery": is_online, + "free_delivery_min": Bson::Null, + "requires_membership": false, + "website": Bson::Null, + "created_at": BsonDateTime::from_chrono(now), + "updated_at": BsonDateTime::from_chrono(now), + }; + + self.stores.insert_one(doc, None)?; + Ok(GroceryStore { + store_id, + name: name.to_string(), + store_type: store_type.to_string(), + address: None, + city: None, + state: None, + zip_code: None, + latitude: None, + longitude: None, + is_online, + has_delivery: is_online, + free_delivery_min: None, + requires_membership: false, + website: None, + created_at: now, + updated_at: now, + }) + } + + /// List all stores. + pub fn list_stores(&self) -> Result, GroceryStoreError> { + let cursor = self.stores.find(doc! {}, None)?; + let mut results = Vec::new(); + for doc in cursor { + results.push(document_to_store(doc?)?); + } + Ok(results) + } + + /// Get store by ID. + pub fn get_store(&self, store_id: &str) -> Result, GroceryStoreError> { + let filter = doc! { "store_id": store_id }; + match self.stores.find_one(filter, None)? { + Some(doc) => Ok(Some(document_to_store(doc)?)), + None => Ok(None), + } + } + + // ======================================================================== + // Preferences Operations + // ======================================================================== + + /// Get user preferences. + pub fn get_preferences(&self, user_id: &str) -> Result, GroceryStoreError> { + let filter = doc! { "user_id": user_id }; + match self.preferences.find_one(filter, None)? { + Some(doc) => Ok(Some(document_to_preferences(doc)?)), + None => Ok(None), + } + } + + /// Update user preferences (upsert). + pub fn update_preferences(&self, prefs: &GroceryPreferences) -> Result<(), GroceryStoreError> { + let filter = doc! { "user_id": &prefs.user_id }; + let now = Utc::now(); + let update = doc! { + "$set": { + "zip_code": &prefs.zip_code, + "address": &prefs.address, + "latitude": prefs.latitude, + "longitude": prefs.longitude, + "taste_avoid": &prefs.taste_avoid, + "taste_prefer": &prefs.taste_prefer, + "dietary": &prefs.dietary, + "has_car": prefs.has_car, + "max_drive_minutes": prefs.max_drive_minutes, + "preferred_stores": &prefs.preferred_stores, + "memberships": &prefs.memberships, + "updated_at": BsonDateTime::from_chrono(now), + }, + "$setOnInsert": { + "user_id": &prefs.user_id, + "created_at": BsonDateTime::from_chrono(now), + } + }; + let options = UpdateOptions::builder().upsert(true).build(); + self.preferences.update_one(filter, update, options)?; + Ok(()) + } +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +fn normalize_product_name(name: &str) -> String { + name.trim().to_lowercase() +} + +/// Normalize price to per-kg for comparison. +/// Returns None if unit is not recognized. +fn normalize_price_to_kg(price: f64, unit: &str) -> Option { + let unit_lower = unit.to_lowercase(); + match unit_lower.as_str() { + "kg" => Some(price), + "lb" | "lbs" => Some(price * 2.20462), // 1 kg = 2.20462 lb + "oz" => Some(price * 35.274), // 1 kg = 35.274 oz + "g" => Some(price * 1000.0), // 1 kg = 1000 g + "100g" => Some(price * 10.0), + "500g" => Some(price * 2.0), + _ => None, // "ea", "pack", etc. can't be normalized without weight info + } +} + +fn document_to_product(doc: Document) -> Result { + let product_id = doc + .get_str("product_id") + .map_err(|e| GroceryStoreError::InvalidData(format!("missing product_id: {e}")))? + .to_string(); + let name_en = doc + .get_str("name_en") + .map_err(|e| GroceryStoreError::InvalidData(format!("missing name_en: {e}")))? + .to_string(); + let name_zh = doc.get_str("name_zh").ok().map(String::from); + let category = doc + .get_str("category") + .unwrap_or("unknown") + .to_string(); + let brand = doc.get_str("brand").ok().map(String::from); + let aliases = doc + .get_array("aliases") + .ok() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + let created_at = bson_datetime_to_utc(&doc, "created_at")?; + let updated_at = bson_datetime_to_utc(&doc, "updated_at")?; + + Ok(GroceryProduct { + product_id, + name_en, + name_zh, + category, + brand, + aliases, + created_at, + updated_at, + }) +} + +fn document_to_price_entry(doc: Document) -> Result { + let entry_id = doc + .get_str("entry_id") + .map_err(|e| GroceryStoreError::InvalidData(format!("missing entry_id: {e}")))? + .to_string(); + let product_id = doc + .get_str("product_id") + .map_err(|e| GroceryStoreError::InvalidData(format!("missing product_id: {e}")))? + .to_string(); + let store_id = doc + .get_str("store_id") + .map_err(|e| GroceryStoreError::InvalidData(format!("missing store_id: {e}")))? + .to_string(); + let price = doc.get_f64("price").unwrap_or(0.0); + let unit = doc.get_str("unit").unwrap_or("ea").to_string(); + let price_per_kg = doc.get_f64("price_per_kg").ok(); + let source = doc + .get_str("source") + .ok() + .and_then(|s| serde_json::from_str(&format!("\"{}\"", s)).ok()) + .unwrap_or(PriceSource::UserReport); + let observed_at = bson_datetime_to_utc(&doc, "observed_at")?; + let created_at = bson_datetime_to_utc(&doc, "created_at")?; + let notes = doc.get_str("notes").ok().map(String::from); + + Ok(PriceEntry { + entry_id, + product_id, + store_id, + price, + unit, + price_per_kg, + source, + observed_at, + created_at, + notes, + }) +} + +fn document_to_store(doc: Document) -> Result { + let store_id = doc + .get_str("store_id") + .map_err(|e| GroceryStoreError::InvalidData(format!("missing store_id: {e}")))? + .to_string(); + let name = doc + .get_str("name") + .map_err(|e| GroceryStoreError::InvalidData(format!("missing name: {e}")))? + .to_string(); + let store_type = doc.get_str("store_type").unwrap_or("unknown").to_string(); + let address = doc.get_str("address").ok().map(String::from); + let city = doc.get_str("city").ok().map(String::from); + let state = doc.get_str("state").ok().map(String::from); + let zip_code = doc.get_str("zip_code").ok().map(String::from); + let latitude = doc.get_f64("latitude").ok(); + let longitude = doc.get_f64("longitude").ok(); + let is_online = doc.get_bool("is_online").unwrap_or(false); + let has_delivery = doc.get_bool("has_delivery").unwrap_or(false); + let free_delivery_min = doc.get_f64("free_delivery_min").ok(); + let requires_membership = doc.get_bool("requires_membership").unwrap_or(false); + let website = doc.get_str("website").ok().map(String::from); + let created_at = bson_datetime_to_utc(&doc, "created_at")?; + let updated_at = bson_datetime_to_utc(&doc, "updated_at")?; + + Ok(GroceryStore { + store_id, + name, + store_type, + address, + city, + state, + zip_code, + latitude, + longitude, + is_online, + has_delivery, + free_delivery_min, + requires_membership, + website, + created_at, + updated_at, + }) +} + +fn document_to_preferences(doc: Document) -> Result { + let user_id = doc + .get_str("user_id") + .map_err(|e| GroceryStoreError::InvalidData(format!("missing user_id: {e}")))? + .to_string(); + let zip_code = doc.get_str("zip_code").ok().map(String::from); + let address = doc.get_str("address").ok().map(String::from); + let latitude = doc.get_f64("latitude").ok(); + let longitude = doc.get_f64("longitude").ok(); + let taste_avoid = doc + .get_array("taste_avoid") + .ok() + .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .unwrap_or_default(); + let taste_prefer = doc + .get_array("taste_prefer") + .ok() + .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .unwrap_or_default(); + let dietary = doc + .get_array("dietary") + .ok() + .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .unwrap_or_default(); + let has_car = doc.get_bool("has_car").unwrap_or(true); + let max_drive_minutes = doc.get_i32("max_drive_minutes").ok(); + let preferred_stores = doc + .get_array("preferred_stores") + .ok() + .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .unwrap_or_default(); + let memberships = doc + .get_array("memberships") + .ok() + .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .unwrap_or_default(); + let created_at = bson_datetime_to_utc(&doc, "created_at")?; + let updated_at = bson_datetime_to_utc(&doc, "updated_at")?; + + Ok(GroceryPreferences { + user_id, + zip_code, + address, + latitude, + longitude, + taste_avoid, + taste_prefer, + dietary, + has_car, + max_drive_minutes, + preferred_stores, + memberships, + created_at, + updated_at, + }) +} + +fn bson_datetime_to_utc(doc: &Document, key: &str) -> Result, GroceryStoreError> { + match doc.get(key) { + Some(Bson::DateTime(value)) => Ok(value.to_chrono()), + Some(Bson::String(value)) => DateTime::parse_from_rfc3339(value) + .map(|dt| dt.with_timezone(&Utc)) + .map_err(|e| GroceryStoreError::InvalidData(format!("invalid datetime {}: {}", key, e))), + _ => Ok(Utc::now()), // Default to now if missing + } +} + +// ============================================================================ +// Global Instance +// ============================================================================ + +static GROCERY_STORE: std::sync::OnceLock>> = std::sync::OnceLock::new(); + +/// Get or initialize the global GroceryStore (returns None if not configured). +pub fn get_global_grocery_store() -> Option> { + GROCERY_STORE + .get_or_init(|| match GroceryStore_::new() { + Ok(store) => { + tracing::info!("GroceryStore initialized for price comparison"); + Some(Arc::new(store)) + } + Err(e) => { + tracing::warn!("Failed to initialize GroceryStore: {}", e); + None + } + }) + .clone() +} + +// ============================================================================ +// Seed Data +// ============================================================================ + +/// Seed common stores for the Ann Arbor area. +pub fn seed_ann_arbor_stores(store: &GroceryStore_) -> Result<(), GroceryStoreError> { + let stores = vec![ + ("168 Asian Mart", "asian_market", false, Some("32393 John R Rd"), Some("Madison Heights"), Some("MI"), Some("48071")), + ("H Mart Troy", "asian_market", false, Some("2850 W Maple Rd"), Some("Troy"), Some("MI"), Some("48084")), + ("Weee", "online", true, None, None, None, None), + ("Yami", "online", true, None, None, None, None), + ("Costco Ann Arbor", "wholesale", false, Some("2800 S State St"), Some("Ann Arbor"), Some("MI"), Some("48104")), + ("Sam's Club Ypsilanti", "wholesale", false, Some("3737 Carpenter Rd"), Some("Ypsilanti"), Some("MI"), Some("48197")), + ("Kroger", "mainstream", false, None, Some("Ann Arbor"), Some("MI"), None), + ("Aldi", "mainstream", false, None, Some("Ann Arbor"), Some("MI"), None), + ]; + + for (name, store_type, is_online, _address, _city, _state, _zip) in stores { + let result = store.get_or_create_store(name, store_type, is_online)?; + tracing::debug!("Seeded store: {} ({})", result.name, result.store_id); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_normalize_price_to_kg() { + assert_eq!(normalize_price_to_kg(10.0, "kg"), Some(10.0)); + assert!((normalize_price_to_kg(10.0, "lb").unwrap() - 22.0462).abs() < 0.01); + assert_eq!(normalize_price_to_kg(10.0, "ea"), None); + } + + #[test] + fn test_normalize_product_name() { + assert_eq!(normalize_product_name(" Pork Belly "), "pork belly"); + assert_eq!(normalize_product_name("五花肉"), "五花肉"); + } +} diff --git a/DoWhiz_service/scheduler_module/src/kroger_api.rs b/DoWhiz_service/scheduler_module/src/kroger_api.rs new file mode 100644 index 00000000..ab29eef1 --- /dev/null +++ b/DoWhiz_service/scheduler_module/src/kroger_api.rs @@ -0,0 +1,650 @@ +//! Kroger API integration for grocery price comparison +//! +//! Kroger provides a public developer API at https://developer.kroger.com/ +//! This module handles authentication and product/price queries. +//! +//! Required environment variables: +//! - KROGER_CLIENT_ID: OAuth2 client ID from developer.kroger.com +//! - KROGER_CLIENT_SECRET: OAuth2 client secret +//! +//! API documentation: https://developer.kroger.com/reference + +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::sync::RwLock; +use tracing::{debug, error, info, warn}; + +/// Kroger API client with automatic token refresh +pub struct KrogerClient { + client: Client, + client_id: String, + client_secret: String, + token: Arc>>, +} + +#[derive(Debug, Clone)] +struct AccessToken { + access_token: String, + expires_at: std::time::Instant, +} + +/// Kroger product from search results +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KrogerProduct { + pub product_id: String, + pub upc: Option, + pub description: String, + pub brand: Option, + pub categories: Vec, + pub images: Vec, + pub items: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KrogerImage { + pub perspective: String, + pub sizes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KrogerImageSize { + pub size: String, + pub url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KrogerItem { + pub item_id: String, + pub size: Option, + pub price: Option, + pub fulfillment: KrogerFulfillment, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KrogerPrice { + pub regular: f64, + pub promo: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KrogerFulfillment { + pub curbside: bool, + pub delivery: bool, + pub in_store: bool, + pub ship_to_home: bool, +} + +/// Kroger store location +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KrogerStore { + pub location_id: String, + pub name: String, + pub address: KrogerAddress, + pub phone: Option, + pub hours: Option, + pub distance_miles: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KrogerAddress { + pub address_line1: String, + pub city: String, + pub state: String, + pub zip_code: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KrogerHours { + pub open_24_hours: bool, + pub monday: Option, + pub tuesday: Option, + pub wednesday: Option, + pub thursday: Option, + pub friday: Option, + pub saturday: Option, + pub sunday: Option, +} + +/// Search filters for Kroger products +#[derive(Debug, Clone, Default)] +pub struct KrogerSearchFilters { + pub term: String, + pub location_id: Option, + pub limit: Option, + pub start: Option, + pub fulfillment: Option, // "ais" (in-store), "csp" (curbside), "dth" (delivery) +} + +// API response structures (internal) +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, + expires_in: u64, + token_type: String, +} + +#[derive(Debug, Deserialize)] +struct ProductSearchResponse { + data: Vec, + meta: Option, +} + +#[derive(Debug, Deserialize)] +struct ProductData { + #[serde(rename = "productId")] + product_id: String, + upc: Option, + description: Option, + brand: Option, + categories: Option>, + images: Option>, + items: Option>, +} + +#[derive(Debug, Deserialize)] +struct ImageData { + perspective: Option, + sizes: Option>, +} + +#[derive(Debug, Deserialize)] +struct ImageSizeData { + size: Option, + url: Option, +} + +#[derive(Debug, Deserialize)] +struct ItemData { + #[serde(rename = "itemId")] + item_id: Option, + size: Option, + price: Option, + fulfillment: Option, +} + +#[derive(Debug, Deserialize)] +struct PriceData { + regular: Option, + promo: Option, +} + +#[derive(Debug, Deserialize)] +struct FulfillmentData { + curbside: Option, + delivery: Option, + #[serde(rename = "inStore")] + in_store: Option, + #[serde(rename = "shipToHome")] + ship_to_home: Option, +} + +#[derive(Debug, Deserialize)] +struct MetaData { + pagination: Option, +} + +#[derive(Debug, Deserialize)] +struct PaginationData { + total: Option, + start: Option, + limit: Option, +} + +/// Convert internal ProductData to public KrogerProduct +fn product_data_to_kroger_product(p: ProductData) -> KrogerProduct { + KrogerProduct { + product_id: p.product_id, + upc: p.upc, + description: p.description.unwrap_or_default(), + brand: p.brand, + categories: p.categories.unwrap_or_default(), + images: p + .images + .unwrap_or_default() + .into_iter() + .map(|img| KrogerImage { + perspective: img.perspective.unwrap_or_default(), + sizes: img + .sizes + .unwrap_or_default() + .into_iter() + .map(|s| KrogerImageSize { + size: s.size.unwrap_or_default(), + url: s.url.unwrap_or_default(), + }) + .collect(), + }) + .collect(), + items: p + .items + .unwrap_or_default() + .into_iter() + .map(|item| KrogerItem { + item_id: item.item_id.unwrap_or_default(), + size: item.size, + price: item.price.map(|pr| KrogerPrice { + regular: pr.regular.unwrap_or(0.0), + promo: pr.promo, + }), + fulfillment: item + .fulfillment + .map(|f| KrogerFulfillment { + curbside: f.curbside.unwrap_or(false), + delivery: f.delivery.unwrap_or(false), + in_store: f.in_store.unwrap_or(false), + ship_to_home: f.ship_to_home.unwrap_or(false), + }) + .unwrap_or(KrogerFulfillment { + curbside: false, + delivery: false, + in_store: false, + ship_to_home: false, + }), + }) + .collect(), + } +} + +#[derive(Debug, Deserialize)] +struct LocationSearchResponse { + data: Vec, +} + +#[derive(Debug, Deserialize)] +struct LocationData { + #[serde(rename = "locationId")] + location_id: String, + name: Option, + address: Option, + phone: Option, + hours: Option, + #[serde(rename = "distanceMiles")] + distance_miles: Option, +} + +#[derive(Debug, Deserialize)] +struct AddressData { + #[serde(rename = "addressLine1")] + address_line1: Option, + city: Option, + state: Option, + #[serde(rename = "zipCode")] + zip_code: Option, +} + +#[derive(Debug, Deserialize)] +struct HoursData { + #[serde(rename = "open24Hours")] + open_24_hours: Option, + monday: Option, + tuesday: Option, + wednesday: Option, + thursday: Option, + friday: Option, + saturday: Option, + sunday: Option, +} + +#[derive(Debug, Deserialize)] +struct HoursDetail { + open: Option, + close: Option, +} + +impl KrogerClient { + const TOKEN_URL: &'static str = "https://api.kroger.com/v1/connect/oauth2/token"; + const PRODUCT_URL: &'static str = "https://api.kroger.com/v1/products"; + const LOCATION_URL: &'static str = "https://api.kroger.com/v1/locations"; + + /// Create a new Kroger API client from environment variables + pub fn from_env() -> Result { + let client_id = std::env::var("KROGER_CLIENT_ID") + .map_err(|_| KrogerError::MissingCredentials("KROGER_CLIENT_ID not set".into()))?; + let client_secret = std::env::var("KROGER_CLIENT_SECRET") + .map_err(|_| KrogerError::MissingCredentials("KROGER_CLIENT_SECRET not set".into()))?; + + Ok(Self::new(client_id, client_secret)) + } + + /// Create a new Kroger API client with explicit credentials + pub fn new(client_id: String, client_secret: String) -> Self { + Self { + client: Client::new(), + client_id, + client_secret, + token: Arc::new(RwLock::new(None)), + } + } + + /// Get a valid access token, refreshing if necessary + async fn get_token(&self) -> Result { + // Check if we have a valid token + { + let token = self.token.read().await; + if let Some(ref t) = *token { + if t.expires_at > std::time::Instant::now() { + return Ok(t.access_token.clone()); + } + } + } + + // Need to refresh token + debug!("Refreshing Kroger access token"); + let new_token = self.fetch_token().await?; + + let mut token = self.token.write().await; + *token = Some(new_token.clone()); + + Ok(new_token.access_token) + } + + /// Fetch a new access token from Kroger OAuth2 + async fn fetch_token(&self) -> Result { + let response = self + .client + .post(Self::TOKEN_URL) + .basic_auth(&self.client_id, Some(&self.client_secret)) + .form(&[ + ("grant_type", "client_credentials"), + ("scope", "product.compact"), + ]) + .send() + .await + .map_err(|e| KrogerError::NetworkError(e.to_string()))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".into()); + error!("Kroger token request failed: {} - {}", status, body); + return Err(KrogerError::AuthenticationFailed(format!( + "{}: {}", + status, body + ))); + } + + let token_response: TokenResponse = response + .json() + .await + .map_err(|e| KrogerError::ParseError(e.to_string()))?; + + // Set expiry 60 seconds before actual expiry for safety margin + let expires_at = std::time::Instant::now() + + std::time::Duration::from_secs(token_response.expires_in.saturating_sub(60)); + + info!( + "Kroger token obtained, expires in {} seconds", + token_response.expires_in + ); + + Ok(AccessToken { + access_token: token_response.access_token, + expires_at, + }) + } + + /// Search for products by keyword + pub async fn search_products( + &self, + filters: &KrogerSearchFilters, + ) -> Result, KrogerError> { + let token = self.get_token().await?; + + let mut url = format!("{}?filter.term={}", Self::PRODUCT_URL, &filters.term); + + if let Some(ref loc) = filters.location_id { + url.push_str(&format!("&filter.locationId={}", loc)); + } + if let Some(limit) = filters.limit { + url.push_str(&format!("&filter.limit={}", limit)); + } + if let Some(start) = filters.start { + url.push_str(&format!("&filter.start={}", start)); + } + if let Some(ref fulfillment) = filters.fulfillment { + url.push_str(&format!("&filter.fulfillment={}", fulfillment)); + } + + debug!("Kroger product search: {}", url); + + let response = self + .client + .get(&url) + .bearer_auth(&token) + .send() + .await + .map_err(|e| KrogerError::NetworkError(e.to_string()))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".into()); + warn!("Kroger product search failed: {} - {}", status, body); + return Err(KrogerError::ApiError(format!("{}: {}", status, body))); + } + + let search_response: ProductSearchResponse = response + .json() + .await + .map_err(|e| KrogerError::ParseError(e.to_string()))?; + + let products = search_response + .data + .into_iter() + .map(product_data_to_kroger_product) + .collect(); + + Ok(products) + } + + /// Find nearby Kroger stores by zip code + pub async fn find_stores( + &self, + zip_code: &str, + radius_miles: Option, + limit: Option, + ) -> Result, KrogerError> { + let token = self.get_token().await?; + + let radius = radius_miles.unwrap_or(10); + let limit = limit.unwrap_or(10); + + let url = format!( + "{}?filter.zipCode.near={}&filter.radiusInMiles={}&filter.limit={}", + Self::LOCATION_URL, + zip_code, + radius, + limit + ); + + debug!("Kroger store search: {}", url); + + let response = self + .client + .get(&url) + .bearer_auth(&token) + .send() + .await + .map_err(|e| KrogerError::NetworkError(e.to_string()))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".into()); + warn!("Kroger store search failed: {} - {}", status, body); + return Err(KrogerError::ApiError(format!("{}: {}", status, body))); + } + + let search_response: LocationSearchResponse = response + .json() + .await + .map_err(|e| KrogerError::ParseError(e.to_string()))?; + + let stores = search_response + .data + .into_iter() + .map(|loc| { + let hours = loc.hours.map(|h| { + let format_hours = |detail: Option| { + detail.map(|d| { + format!( + "{}-{}", + d.open.unwrap_or_default(), + d.close.unwrap_or_default() + ) + }) + }; + KrogerHours { + open_24_hours: h.open_24_hours.unwrap_or(false), + monday: format_hours(h.monday), + tuesday: format_hours(h.tuesday), + wednesday: format_hours(h.wednesday), + thursday: format_hours(h.thursday), + friday: format_hours(h.friday), + saturday: format_hours(h.saturday), + sunday: format_hours(h.sunday), + } + }); + + KrogerStore { + location_id: loc.location_id, + name: loc.name.unwrap_or_else(|| "Kroger".into()), + address: loc + .address + .map(|a| KrogerAddress { + address_line1: a.address_line1.unwrap_or_default(), + city: a.city.unwrap_or_default(), + state: a.state.unwrap_or_default(), + zip_code: a.zip_code.unwrap_or_default(), + }) + .unwrap_or(KrogerAddress { + address_line1: String::new(), + city: String::new(), + state: String::new(), + zip_code: String::new(), + }), + phone: loc.phone, + hours, + distance_miles: loc.distance_miles, + } + }) + .collect(); + + Ok(stores) + } + + /// Get product details by product ID + pub async fn get_product( + &self, + product_id: &str, + location_id: Option<&str>, + ) -> Result, KrogerError> { + let token = self.get_token().await?; + + let mut url = format!("{}/{}", Self::PRODUCT_URL, product_id); + if let Some(loc) = location_id { + url.push_str(&format!("?filter.locationId={}", loc)); + } + + debug!("Kroger product detail: {}", url); + + let response = self + .client + .get(&url) + .bearer_auth(&token) + .send() + .await + .map_err(|e| KrogerError::NetworkError(e.to_string()))?; + + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".into()); + return Err(KrogerError::ApiError(format!("{}: {}", status, body))); + } + + // Single product response has different structure + #[derive(Deserialize)] + struct SingleProductResponse { + data: ProductData, + } + + let product_response: SingleProductResponse = response + .json() + .await + .map_err(|e| KrogerError::ParseError(e.to_string()))?; + + Ok(Some(product_data_to_kroger_product(product_response.data))) + } +} + +/// Errors that can occur when using the Kroger API +#[derive(Debug, thiserror::Error)] +pub enum KrogerError { + #[error("Missing credentials: {0}")] + MissingCredentials(String), + + #[error("Authentication failed: {0}")] + AuthenticationFailed(String), + + #[error("Network error: {0}")] + NetworkError(String), + + #[error("API error: {0}")] + ApiError(String), + + #[error("Parse error: {0}")] + ParseError(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_search_filters_default() { + let filters = KrogerSearchFilters { + term: "pork belly".into(), + ..Default::default() + }; + assert_eq!(filters.term, "pork belly"); + assert!(filters.location_id.is_none()); + } + + #[tokio::test] + #[ignore] // Requires valid credentials + async fn test_search_products() { + let client = KrogerClient::from_env().expect("Missing Kroger credentials"); + + let filters = KrogerSearchFilters { + term: "pork belly".into(), + limit: Some(5), + ..Default::default() + }; + + let products = client.search_products(&filters).await; + println!("Search results: {:?}", products); + } + + #[tokio::test] + #[ignore] // Requires valid credentials + async fn test_find_stores() { + let client = KrogerClient::from_env().expect("Missing Kroger credentials"); + + let stores = client.find_stores("48109", Some(10), Some(5)).await; + println!("Store results: {:?}", stores); + } +} diff --git a/DoWhiz_service/scheduler_module/src/lib.rs b/DoWhiz_service/scheduler_module/src/lib.rs index dbc98a09..0538a58d 100644 --- a/DoWhiz_service/scheduler_module/src/lib.rs +++ b/DoWhiz_service/scheduler_module/src/lib.rs @@ -10,6 +10,8 @@ pub mod google_auth; pub mod google_docs_poller; pub mod google_drive_changes; pub mod google_workspace_poller; +pub mod grocery_store; +pub mod kroger_api; pub mod ingestion; pub mod ingestion_queue; pub mod mailbox; diff --git a/docs/competitor_analysis_smart_grocery_agent.md b/docs/competitor_analysis_smart_grocery_agent.md new file mode 100644 index 00000000..0793542a --- /dev/null +++ b/docs/competitor_analysis_smart_grocery_agent.md @@ -0,0 +1,553 @@ +# 中国留学生智能比价Agent - 竞品分析报告 + +**调研日期**: 2026年4月2日 +**调研目的**: 评估为中国在美留学生打造智能比价Agent的市场可行性 + +--- + +## 一、执行摘要 + +### 调研目标 +为中国在美留学生打造一款智能比价Agent,能够: +- 跨平台比较价格(Weee、亚洲超市、Costco等) +- 理解用户口味偏好(如"不要美式甜品") +- 考虑距离/便利性权重 +- 提供个性化推荐和解释 + +### 核心发现 +| 维度 | 结论 | +|------|------| +| 市场空白 | **确认存在** - 无现有产品同时覆盖亚洲超市+跨平台比价+口味偏好 | +| 竞争格局 | AI购物助手赛道火热,但均专注主流市场,忽视ethnic grocery | +| 技术可行性 | 中等 - 亚超数据获取是主要瓶颈 | +| 建议 | 立项MVP验证,从Weee+少数亚超开始 | + +--- + +## 二、目标市场规模 + +### 核心用户群 +| 指标 | 数据 | 来源 | +|------|------|------| +| 在美中国留学生 | 26.6万人(2024/25学年) | IIE Open Doors | +| 同比变化 | -4%(较2017年峰值下降36%) | Statista | +| 月均食品消费 | $200-400(自己做饭) | 行业研究 | +| 月均食品消费 | $500-700(常外食) | 行业研究 | + +### 相关市场规模 +| 市场 | 规模 | 增长趋势 | +|------|------|----------| +| 亚裔美国杂货市场 | ~$600亿 | 稳定增长 | +| Weee! 年收入 | $10亿+ | 年增长25% | +| H Mart 年销售额 | $10亿+ | 持续扩张 | +| 亚洲超市门店数 | 1,000+ | 抢占传统超市空间 | + +### 市场天花板估算 +``` +25万用户 × $300/月 = $7,500万/月 食品消费 +假设capture 10%决策影响 = $750万/月 潜在价值 +``` + +--- + +## 三、AI购物助手竞品分析 + +### 3.1 主流AI购物助手对比 + +| 产品 | 用户规模 | 核心能力 | 覆盖渠道 | 亚超覆盖 | 口味偏好 | 距离权重 | +|------|----------|----------|----------|----------|----------|----------| +| **Amazon Rufus** | 2.5亿 | 账户记忆、一键加购、价格提醒 | 仅Amazon | ❌ | ❌ | ❌ | +| **ChatGPT Shopping** | 全量用户 | 跨平台比价、图片搜索 | Target/Walmart/Sephora等 | ❌ | ❌ | ❌ | +| **Perplexity Shopping** | Pro用户 | 一键购买、零广告、视觉搜索 | 自有商户网络 | ❌ | ❌ | ❌ | +| **Google AI Mode** | 搜索用户 | Kroger集成、食谱生成 | 主流超市 | ⚠️ 部分 | ❌ | ⚠️ 有库存 | + +### 3.2 逐一深度分析 + +#### Amazon Rufus +**定位**: Amazon生态内的AI购物助手 + +**优势**: +- 2.5亿用户使用,月活增长149% +- "账户记忆":记住用户偏好(如"偏好有机食品"、"有5岁的儿子") +- 可执行复杂指令:"帮我重新下单上周做南瓜派的所有食材" +- 价格提醒功能 +- 预计2028年带动Amazon GMV增长4.44% + +**局限**: +- 仅限Amazon生态内比价 +- 不覆盖Weee、H Mart、99 Ranch +- 不理解文化差异(如"美式甜品太甜") +- 不考虑距离/便利性 + +**对我们场景的适用性**: ❌ 不适用 + +--- + +#### ChatGPT Shopping +**定位**: 通用AI购物研究助手 + +**优势**: +- 免费用户可用 +- 基于ACP协议实时爬取价格 +- 支持上传图片搜同款 +- 合作商户:Target、Sephora、Nordstrom、Lowe's、Best Buy、Home Depot、Wayfair +- Walmart深度集成(账户关联、会员积分) + +**局限**: +- 合作商户不含Weee、亚洲超市 +- Instant Checkout已下线(用户接受度低) +- 无文化偏好理解 +- 无距离权重 + +**对我们场景的适用性**: ❌ 不适用 + +--- + +#### Perplexity Shopping +**定位**: 订阅制AI购物平台 + +**优势**: +- "Buy with Pro"一键购买,免运费 +- "Snap to Shop"拍照搜同款 +- 2026年2月取消所有广告,保持推荐中立 +- 商户零佣金(靠Pro订阅变现) +- PayPal深度集成 + +**局限**: +- 商户网络不含亚洲食品电商 +- 主打3C/家居/美妆,非食品杂货 +- 无个性化口味偏好 + +**对我们场景的适用性**: ❌ 不适用 + +--- + +#### Google AI Mode + Kroger +**定位**: 搜索+零售商深度集成 + +**优势**: +- Kroger专属AI助手:输入需求 → 生成食谱 → 自动生成购物清单 +- 实时库存、价格、配送时间 +- 49%的食品杂货查询已有AI Overview +- Native Checkout即将上线 +- Universal Commerce Protocol (UCP) 开放标准 + +**局限**: +- 仅覆盖Kroger,不覆盖亚超 +- 不知道用户想要"中国口味的蛋糕" +- 不比较Kroger vs H Mart vs Weee + +**对我们场景的适用性**: ⚠️ 最接近但仍不适用 + +--- + +## 四、亚洲食品电商竞品分析 + +### 4.1 主要平台对比 + +| 平台 | 月收入(2025.11) | 月交易数 | 转化率 | 优势 | 劣势 | +|------|-----------------|----------|--------|------|------| +| **Weee!** | $3,470万 | 20.7万 | 5.0-5.5% | 品类全、生鲜强、配送快 | 价格不是最低 | +| **Yamibuy** | $117万 | 7,800 | 8.5-9.0% | 零食选择最多、覆盖全美 | 价格最贵、无生鲜 | +| **H Mart Online** | - | - | - | 韩国特色强 | 覆盖区域有限 | +| **99 Ranch Online** | - | - | - | 中国食材最全 | 线上体验弱 | + +### 4.2 商业模式对比 + +| 维度 | Weee! | Yamibuy | +|------|-------|---------| +| **模式** | 中心化仓储,自有配送 | 平台模式,对接第三方 | +| **生鲜** | ✅ 强项 | ❌ 无 | +| **覆盖范围** | 限定配送区域 | 全美配送 | +| **价格** | 中等 | 最贵 | +| **品质一致性** | 高 | 参差不齐 | + +### 4.3 关键发现 +- 各平台**各自为战**,无跨平台比价工具 +- "Weee上买还是去实体店买更划算" — **没有现有产品能回答** +- 实体亚超(H Mart、99 Ranch)线上化程度低,价格信息不透明 + +--- + +## 五、竞品能力矩阵 + +``` + 通用品类 亚洲/华人品类 + ┌──────────────────────────┬──────────────────────────┐ + │ │ │ + 跨平台 │ • ChatGPT Shopping │ │ + 比价 │ • Perplexity Shopping │ 【市场空白】 │ + │ • Google AI Mode │ │ + │ │ │ + ├──────────────────────────┼──────────────────────────┤ + │ │ │ + 单平台 │ • Amazon Rufus │ • Weee! App │ + 比价 │ • Kroger AI │ • Yamibuy App │ + │ │ • H Mart App │ + │ │ │ + └──────────────────────────┴──────────────────────────┘ +``` + +**我们的目标位置**: 右上角 — 亚洲/华人品类 + 跨平台比价 + +--- + +## 六、关键差异化机会 + +### 6.1 功能层面 + +| 差异化点 | 现有竞品 | 我们的方案 | +|----------|----------|------------| +| **跨渠道比价** | 单一平台内比价 | Weee + 实体亚超 + Costco 统一比较 | +| **口味偏好** | 不考虑 | "中国人不买美式甜品"等文化理解 | +| **距离权重** | 不考虑或仅显示库存 | "便宜但开车30分钟"纳入决策 | +| **推荐解释** | 仅显示价格 | 说明trade-off(价格vs距离vs口味) | + +### 6.2 核心价值主张 + +> **"不是告诉你最便宜的在哪,而是告诉你对你来说最划算的是什么"** + +### 6.3 护城河分析 + +| 护城河类型 | 描述 | 可复制性 | +|------------|------|----------| +| **垂直场景理解** | "中国留学生不吃美式甜品"这类知识 | 低 - 需要深度用户研究 | +| **渠道覆盖** | Weee + 本地亚超的组合 | 中 - 大平台不会去整合分散市场 | +| **社区效应** | 用户互相分享价格情报 | 低 - 众包数据壁垒 | + +--- + +## 七、风险与挑战 + +### 7.1 风险矩阵 + +| 风险 | 严重程度 | 发生概率 | 缓解方案 | +|------|----------|----------|----------| +| **亚超数据获取难** | 高 | 高 | 先做Weee(有公开数据),亚超采用众包 | +| **中国留学生数量下降** | 中 | 中 | 仍有25万基数;可扩展至其他亚裔群体 | +| **Big Tech竞争** | 中 | 低 | 巨头专注主流市场,ethnic细分无人做 | +| **用户信任度** | 中 | 中 | 56%美国人不信任AI agent,但Z世代更开放 | +| **变现难度** | 中 | 中 | 可走affiliate/佣金模式 | + +### 7.2 技术可行性评估 + +| 数据源 | 获取方式 | 难度 | 优先级 | +|--------|----------|------|--------| +| Weee价格 | 公开API/爬虫 | ⭐⭐ | P0 | +| Costco/Kroger | 官方API或爬虫 | ⭐⭐⭐ | P1 | +| H Mart/99 Ranch | 众包或手动采集 | ⭐⭐⭐⭐⭐ | P2 | +| 用户偏好 | 用户输入 + 行为学习 | ⭐⭐ | P0 | +| 距离计算 | Google Maps API | ⭐ | P0 | + +--- + +## 八、建议与下一步 + +### 8.1 MVP方案(Phase 1 - 1~2月) + +**核心流程**: +``` +用户通过Email/微信/Discord发送:"帮我比一下XXX在哪买划算" + ↓ +Agent自动: +1. 抓取Weee价格 +2. 查询已配置的本地亚超(用户提供zip code) +3. 考虑用户已有的偏好标签 +4. 返回带权重的推荐 + 解释 +``` + +**最小功能集**: +- [ ] Weee价格监控 +- [ ] 用户偏好配置(口味、距离接受度) +- [ ] 1-2个本地亚超价格(手动录入) +- [ ] LLM生成推荐解释 + +### 8.2 扩展方案(Phase 2-3) + +| Phase | 功能 | 时间 | +|-------|------|------| +| Phase 2 | 主动推送"本周最值得囤货清单"、促销日历(中秋/春节) | +1月 | +| Phase 3 | 用户分享价格情报、众包数据、社区功能 | +2月 | + +### 8.3 与DoWhiz的契合度 + +| 维度 | 评估 | +|------|------| +| 符合"digital employee"定位 | ✅ 完美契合 | +| 技术栈复用 | ✅ 可复用现有Agent框架 | +| 渠道复用 | ✅ Email/Discord/微信均可触发 | +| 商业模式 | ⚠️ 需要验证变现路径 | + +--- + +## 九、结论 + +### 核心结论 +1. **市场空白确认**: 无现有产品覆盖"亚洲/华人品类 + 跨平台比价"象限 +2. **竞争威胁可控**: Big Tech专注主流市场,不太可能整合分散的亚洲超市 +3. **痛点真实存在**: 学术研究和用户反馈均证实国际学生面临食品选择困难 + +### 建议 +**立项MVP验证**,从Weee+少数亚超开始,用LLM做个性化解释,看用户是否愿意持续使用。 + +### 成功指标 +- [ ] 100个活跃用户(2月内) +- [ ] 用户周均使用2次以上 +- [ ] 用户反馈"确实帮我省了钱/时间" + +--- + +## 十、亚裔消费者购物习惯深度分析 + +### 10.1 线上vs线下偏好 + +| 指标 | 亚裔美国人 | 全美平均 | 差异 | +|------|-----------|----------|------| +| 过去30天网购杂货 | **44%** | 33% | +11% | +| 预期未来增加线上消费 | **40%** | - | 高于平均 | +| 享受购物过程 | **61%** | 56% | +5% | +| 与他人一起购物 | **72%** | 55% | +17% | +| 在仓储式超市购物 | **54%** | 38% | +16% | + +**关键洞察**: +- 亚裔消费者**线上渗透率更高**,但同时也更享受线下购物体验 +- 倾向于**群体购物**(与家人朋友一起),这可能与分享/拼单行为相关 +- **仓储式超市偏好明显**(Costco等),适合批量采购 + +### 10.2 多渠道购物行为 + +| 族群 | 平均使用渠道数 | 排名 | +|------|----------------|------| +| Hispanic | 3.84 | 1 | +| **Asian American** | **3.53** | **2** | +| African American | 3.33 | 3 | +| Caucasian/Non-Hispanic | 3.26 | 4 | + +**关键发现**: +> "大量亚裔消费者感到必须跑多家店才能买齐所需商品,并表达希望传统超市能提供更多亚洲品牌和产品。" + +这直接验证了你的痛点假设:**亚裔消费者确实在多个渠道间奔波**,且对此感到不满。 + +### 10.3 购物时间与粘性 + +| 超市类型 | 平均停留时间 | +|----------|--------------| +| **亚洲超市/Ethnic grocery** | **27-41分钟** | +| 传统超市 | 23分钟 | + +**解读**: 亚洲超市是"目的地"而非"便利店",消费者愿意花更多时间探索——这意味着**品类丰富度和发现感**是核心价值。 + +### 10.4 品牌偏好与文化认同 + +| 指标 | 数据 | +|------|------| +| 更倾向购买ethnic heritage品牌 | Asian American: **46%**, Hispanic: 49% | +| 文化食品可及性影响食品安全感 | 显著相关(p<0.05) | + +**解读**: 近半数亚裔消费者主动寻找"正宗"产品,而非简单的价格导向。这支持了**口味偏好权重**功能的必要性。 + +--- + +## 十一、国际学生特殊挑战 + +### 11.1 食品不安全率 + +| 群体 | 食品不安全率 | +|------|--------------| +| 国际学生(研究范围) | **5-37%**(不同研究) | +| 某大学样本 | **40.7%** | +| 全美家庭平均 | 13.7% | + +### 11.2 核心障碍 + +| 障碍 | 描述 | 解决方案契合度 | +|------|------|----------------| +| **交通限制** | 无车学生难以到达ethnic grocery | ✅ 可推荐配送选项/拼单 | +| **不熟悉本地环境** | 不知道哪里能买到家乡食材 | ✅ 核心功能 | +| **预算紧张** | 选择便宜但不健康的食品 | ✅ 帮助找到"划算且合口味"的选项 | +| **文化障碍** | 不好意思向他人求助 | ✅ 自助式Agent无社交压力 | + +### 11.3 南亚学生案例(参考价值) + +> 在某德州大学,2024年春季: +> - 国际学生8,276人中,84%来自印度、尼泊尔、孟加拉、巴基斯坦 +> - 校园食品银行用户中,**85%是南亚研究生** + +**启示**: 食品获取问题在亚洲留学生中普遍存在,中国留学生同样面临类似挑战。 + +--- + +## 十二、付费意愿与商业模式评估 + +### 12.1 订阅App市场基准数据 + +| 指标 | 数据 | 来源 | +|------|------|------| +| 美国人均订阅数 | **8.2个** | 2025行业报告 | +| 认为订阅比一次性购买更划算 | **54%** | 消费者调研 | +| 订阅疲劳感 | **41%** | 消费者调研 | +| 因涨价取消订阅 | **71%** | 流失原因调研 | + +### 12.2 订阅定价策略洞察 + +| 策略 | 效果 | +|------|------| +| 高价位App | Day 35转化率 **2.7%**(中位数) | +| 低价位App | Day 35转化率 **1.5%**(中位数) | +| 价值导向定价 | 愿付价格提升 **30-40%** | + +**关键洞察**: +> "高价App的Day 35转化率更高,说明高价值产品能吸引更有commitment的用户。" + +### 12.3 AI购物助手市场规模 + +| 年份 | 市场规模 | 增长率 | +|------|----------|--------| +| 2025 | $43.3亿 | - | +| 2035(预测) | $467.6亿 | CAGR 27% | + +| 指标 | 2026数据 | +|------|----------| +| 计划使用GenAI购物的消费者 | **80%** | +| 已用AI替代传统购物方式 | **33%** | +| AI平台占电商销售额 | 1.5%($209亿) | + +### 12.4 定价模式对比 + +| 平台 | 定价模式 | 费率 | +|------|----------|------| +| OpenAI (ChatGPT Shopping) | 交易佣金 | **4%** | +| Perplexity | Pro订阅 | $20/月 | +| Amazon Alexa+ | Prime捆绑 | 免费(含在$139/年会员中) | + +### 12.5 付费意愿估算模型 + +**假设条件**: +- 目标用户: 26万中国留学生 +- 月均食品消费: $300 +- 潜在节省比例: 10-15%(通过比价) + +**场景分析**: + +| 场景 | 渗透率 | 付费转化 | 定价 | 月收入 | +|------|--------|----------|------|--------| +| **保守** | 5% (13,000人) | 10% (1,300人) | $4.99/月 | $6,487 | +| **中性** | 10% (26,000人) | 15% (3,900人) | $7.99/月 | $31,161 | +| **乐观** | 20% (52,000人) | 20% (10,400人) | $9.99/月 | $103,896 | + +**替代变现模式**: + +| 模式 | 预估收入 | 可行性 | +|------|----------|--------| +| **Affiliate佣金** | Weee 5-10%佣金 × 引导消费 | ⭐⭐⭐⭐ 高 | +| **数据服务** | 向亚超卖消费者洞察 | ⭐⭐ 中 | +| **广告** | 展示位/推荐位 | ⭐⭐⭐ 中高 | +| **B2B工具** | 帮亚超做竞价监控 | ⭐⭐ 中 | + +### 12.6 付费意愿结论 + +| 维度 | 评估 | 理由 | +|------|------|------| +| **是否愿意付费** | ⚠️ 需验证 | 学生群体价格敏感,但pain point真实 | +| **合理定价区间** | $5-10/月 | 对标省钱App,月均节省$30-50才有吸引力 | +| **最可行模式** | Freemium + Affiliate | 免费基础功能 + 导购佣金 | +| **付费转化率预期** | 10-15% | 参考同类工具App | + +--- + +## 十三、深耕可行性总结 + +### 13.1 值得做的理由 + +| 维度 | 支撑证据 | 权重 | +|------|----------|------| +| **痛点真实** | 亚裔平均跑3.53家店;40%国际学生食品不安全 | ⭐⭐⭐⭐⭐ | +| **市场空白** | 无竞品覆盖ethnic + 跨平台 + 偏好 | ⭐⭐⭐⭐⭐ | +| **用户行为支持** | 44%亚裔已网购杂货,高于平均 | ⭐⭐⭐⭐ | +| **文化壁垒** | 46%亚裔主动寻找heritage品牌 | ⭐⭐⭐⭐ | +| **巨头不会做** | ethnic grocery太分散,ROI不高 | ⭐⭐⭐⭐ | + +### 13.2 需要谨慎的理由 + +| 维度 | 风险描述 | 权重 | +|------|----------|------| +| **市场规模天花板** | 26万留学生,且在下降 | ⭐⭐⭐ | +| **付费意愿不确定** | 学生群体价格敏感 | ⭐⭐⭐ | +| **数据获取难** | 亚超价格不透明 | ⭐⭐⭐⭐ | +| **留存挑战** | 购物频率低(周1-2次) | ⭐⭐ | + +### 13.3 扩展潜力 + +如果中国留学生验证成功,可扩展至: + +| 扩展方向 | 市场规模 | 扩展难度 | +|----------|----------|----------| +| 全美华人(非学生) | ~550万 | ⭐⭐ | +| 其他亚裔(韩、印、越) | ~2000万 | ⭐⭐⭐ | +| Hispanic grocery | ~6500万 | ⭐⭐⭐⭐ | + +### 13.4 最终建议 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 建议:值得立项MVP,但需控制投入 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ✅ 做的理由: │ +│ • 痛点真实且被数据验证 │ +│ • 市场空白明确 │ +│ • 与DoWhiz技术栈高度契合 │ +│ • 巨头不会进入的细分市场 │ +│ │ +│ ⚠️ 控制投入的理由: │ +│ • 市场天花板有限(先验证再扩展) │ +│ • 付费意愿需MVP验证 │ +│ • 数据获取是长期瓶颈 │ +│ │ +│ 📋 建议的验证路径: │ +│ 1. 2周内:用现有DoWhiz框架做最简MVP │ +│ 2. 1月内:获取100个测试用户 │ +│ 3. 2月内:验证留存和付费意愿 │ +│ 4. 根据数据决定是否深耕 │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 附录:数据来源 + +### 市场规模与人口统计 +- [Statista - Chinese students in US](https://www.statista.com/statistics/372900/number-of-chinese-students-that-study-in-the-us/) +- [IIE Open Doors 2025](https://www.iie.org/news/open-doors-2025-press-release/) +- [IBISWorld - Ethnic Supermarkets Industry](https://www.ibisworld.com/united-states/industry/ethnic-supermarkets/4333/) + +### 竞品分析 +- [Amazon Rufus Features - About Amazon](https://www.aboutamazon.com/news/retail/amazon-rufus-ai-assistant-personalized-shopping-features) +- [ChatGPT Shopping - OpenAI](https://openai.com/index/chatgpt-shopping-research/) +- [Perplexity Shopping - Perplexity Blog](https://www.perplexity.ai/hub/blog/shop-like-a-pro) +- [Google AI Shopping - Bloomberg](https://www.bloomberg.com/news/articles/2026-02-11/google-pushes-ai-shopping-features-in-search-and-gemini-chatbot) +- [Kroger Gemini Partnership - Grocery Dive](https://www.grocerydive.com/news/kroger-ai-google-gemini-shopping-assistant-technology-associate-platform-sage-nrf-2026/809435/) + +### 亚洲杂货电商 +- [Weee Revenue Data - Grips Intelligence](https://gripsintelligence.com/insights/retailers/sayweee.com) +- [Weee Competitors - CB Insights](https://www.cbinsights.com/research/weee-competitors-freshgogo-umamicart-yamibuy-asian-family-market/) +- [Asian American Online Grocery - China Daily](https://www.chinadaily.com.cn/a/202407/10/WS668de69fa31095c51c50d514.html) +- [How Asian Grocers Redefine Experience - Placer.ai](https://www.placer.ai/anchor/articles/how-asian-grocers-are-redefining-the-grocery-experience) + +### 消费者行为研究 +- [Multicultural Consumers Changing Grocery Shopping - Supermarket News](https://www.supermarketnews.com/center-store/multicultural-consumers-changing-grocery-shopping) +- [Online Grocery Shopping Behaviors Among Asian Americans - PMC](https://pmc.ncbi.nlm.nih.gov/articles/PMC9734475/) +- [ThinkNow - 2025 Consumer Shopping Habits](https://thinknow.com/blog/in-store-vs-online-how-2025-consumer-shopping-habits-impact-brands/) + +### 国际学生食品安全 +- [Food Insecurity Among International Students - Cambridge](https://www.cambridge.org/core/journals/public-health-nutrition/article/food-insecurity-and-cultural-food-access-among-international-college-students-in-the-usa/) +- [South Asian Graduate Students Food Insecurity - MDPI](https://www.mdpi.com/2072-6643/17/15/2508) +- [Food Insecurity Predictors - PMC](https://pmc.ncbi.nlm.nih.gov/articles/PMC11767435/) + +### 订阅经济与付费意愿 +- [State of Subscription Apps 2025 - RevenueCat](https://www.revenuecat.com/state-of-subscription-apps-2025/) +- [Subscription Statistics 2025 - Marketing LTB](https://marketingltb.com/blog/statistics/subscription-statistics/) +- [AI Shopping Assistant Market 2026-2035 - InsightAce Analytic](https://www.insightaceanalytic.com/report/ai-shopping-assistant-market/3071) +- [AI Shopping Statistics 2026 - Capital One Shopping](https://capitaloneshopping.com/research/ai-shopping-statistics/) +- [Why AI Shopping Agent Wars Heat Up 2026 - Modern Retail](https://www.modernretail.co/technology/why-the-ai-shopping-agent-wars-will-heat-up-in-2026/) diff --git a/website/src/components/intake/GroceryPreferencesQuestionnaire.css b/website/src/components/intake/GroceryPreferencesQuestionnaire.css new file mode 100644 index 00000000..43fb628f --- /dev/null +++ b/website/src/components/intake/GroceryPreferencesQuestionnaire.css @@ -0,0 +1,453 @@ +/* Grocery Preferences Questionnaire Styles */ + +.gq-container { + max-width: 600px; + margin: 0 auto; + padding: 2rem; + background: var(--glass-bg, #ffffff); + border-radius: var(--radius-xl, 1rem); + border: 1px solid var(--color-border, #e5e7eb); + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08); +} + +.gq-header { + text-align: center; + margin-bottom: 1.5rem; +} + +.gq-header h2 { + font-size: 1.75rem; + font-weight: 700; + color: var(--text-primary, #111827); + margin: 0 0 0.25rem; +} + +.gq-subtitle { + font-size: 0.875rem; + color: var(--text-secondary, #6b7280); + margin: 0; +} + +/* Progress Bar */ +.gq-progress-container { + position: relative; + height: 8px; + background: var(--color-muted, #f3f4f6); + border-radius: var(--radius-full, 9999px); + margin-bottom: 1rem; + overflow: hidden; +} + +.gq-progress-bar { + height: 100%; + background: var(--brand-primary, #6366f1); + border-radius: var(--radius-full, 9999px); + transition: width 0.3s ease; +} + +.gq-progress-text { + position: absolute; + right: 0; + top: 12px; + font-size: 0.75rem; + color: var(--text-secondary, #6b7280); +} + +/* Step Indicator */ +.gq-step-indicator { + display: flex; + justify-content: center; + gap: 0.5rem; + margin-bottom: 1.5rem; +} + +.gq-step-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--color-muted, #e5e7eb); + transition: all 0.2s ease; +} + +.gq-step-dot.active { + background: var(--brand-primary, #6366f1); + transform: scale(1.2); +} + +.gq-step-dot.completed { + background: var(--brand-primary, #6366f1); + opacity: 0.6; +} + +/* Step Content */ +.gq-step-content { + min-height: 400px; +} + +.gq-step-content h3 { + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary, #111827); + margin: 0 0 0.5rem; +} + +.gq-step-content > p { + color: var(--text-secondary, #6b7280); + margin: 0 0 1.5rem; + font-size: 0.9rem; +} + +/* Fields */ +.gq-field { + margin-bottom: 1.5rem; +} + +.gq-field label { + display: block; + font-weight: 500; + color: var(--text-primary, #111827); + margin-bottom: 0.5rem; + font-size: 0.9rem; +} + +.gq-field label .required { + color: #ef4444; +} + +.gq-field-row { + display: flex; + gap: 1rem; +} + +.gq-field-half { + flex: 1; +} + +.gq-hint { + font-size: 0.8rem; + color: var(--text-secondary, #9ca3af); + margin: 0.25rem 0 0.75rem; +} + +.gq-empty-hint { + text-align: center; + color: var(--text-secondary, #9ca3af); + padding: 2rem; + background: var(--color-muted, #f9fafb); + border-radius: var(--radius-lg, 0.75rem); +} + +/* Inputs */ +.gq-input { + width: 100%; + padding: 0.75rem 1rem; + border: 1px solid var(--color-border, #e5e7eb); + border-radius: var(--radius-lg, 0.5rem); + font-size: 0.9rem; + background: var(--color-background, #fff); + color: var(--text-primary, #111827); + transition: border-color 0.2s, box-shadow 0.2s; + box-sizing: border-box; +} + +.gq-input:focus { + outline: none; + border-color: var(--brand-primary, #6366f1); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); +} + +.gq-input::placeholder { + color: var(--text-secondary, #9ca3af); +} + +.gq-input-small { + margin-top: 0.5rem; +} + +.gq-textarea { + width: 100%; + padding: 0.75rem 1rem; + border: 1px solid var(--color-border, #e5e7eb); + border-radius: var(--radius-lg, 0.5rem); + font-size: 0.9rem; + font-family: inherit; + background: var(--color-background, #fff); + color: var(--text-primary, #111827); + resize: vertical; + min-height: 80px; + box-sizing: border-box; +} + +.gq-textarea:focus { + outline: none; + border-color: var(--brand-primary, #6366f1); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); +} + +/* Option Buttons (Single/Multi Select) */ +.gq-option-group { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.gq-option-btn { + display: inline-flex; + align-items: center; + padding: 0.5rem 1rem; + border: 1px solid var(--color-border, #e5e7eb); + border-radius: var(--radius-lg, 0.5rem); + background: var(--color-background, #fff); + cursor: pointer; + transition: all 0.2s ease; + font-size: 0.875rem; +} + +.gq-option-btn:hover { + border-color: var(--brand-primary, #6366f1); + background: rgba(99, 102, 241, 0.05); +} + +.gq-option-btn.selected { + border-color: var(--brand-primary, #6366f1); + background: rgba(99, 102, 241, 0.1); + color: var(--brand-primary, #6366f1); + font-weight: 500; +} + +.gq-option-btn input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +/* Category Sections */ +.gq-category-section { + background: var(--color-muted, #f9fafb); + border-radius: var(--radius-lg, 0.75rem); + padding: 1.25rem; + margin-bottom: 1rem; +} + +.gq-category-section h4 { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary, #111827); + margin: 0 0 1rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--color-border, #e5e7eb); +} + +.gq-category-section .gq-field:last-child { + margin-bottom: 0; +} + +/* Store Sections */ +.gq-store-section { + margin-bottom: 1rem; +} + +.gq-store-section h5 { + font-size: 0.85rem; + font-weight: 600; + color: var(--text-secondary, #6b7280); + margin: 0 0 0.5rem; + text-transform: uppercase; + letter-spacing: 0.025em; +} + +.gq-store-section .gq-option-group { + margin-bottom: 0.5rem; +} + +/* Slider */ +.gq-slider-container { + padding: 0.5rem 0; +} + +.gq-slider { + width: 100%; + height: 6px; + border-radius: 3px; + background: var(--color-muted, #e5e7eb); + outline: none; + -webkit-appearance: none; + appearance: none; +} + +.gq-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--brand-primary, #6366f1); + cursor: pointer; + box-shadow: 0 2px 6px rgba(99, 102, 241, 0.3); + transition: transform 0.2s; +} + +.gq-slider::-webkit-slider-thumb:hover { + transform: scale(1.1); +} + +.gq-slider::-moz-range-thumb { + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--brand-primary, #6366f1); + cursor: pointer; + border: none; + box-shadow: 0 2px 6px rgba(99, 102, 241, 0.3); +} + +.gq-slider-labels { + display: flex; + justify-content: space-between; + margin-top: 0.5rem; + font-size: 0.75rem; + color: var(--text-secondary, #9ca3af); +} + +.gq-slider-labels span.active { + color: var(--brand-primary, #6366f1); + font-weight: 600; +} + +/* Priority List (Draggable) */ +.gq-priority-list { + border: 1px solid var(--color-border, #e5e7eb); + border-radius: var(--radius-lg, 0.75rem); + overflow: hidden; +} + +.gq-priority-list .gq-hint { + padding: 0.75rem 1rem; + margin: 0; + background: var(--color-muted, #f9fafb); + border-bottom: 1px solid var(--color-border, #e5e7eb); +} + +.gq-priority-item { + display: flex; + align-items: center; + padding: 0.75rem 1rem; + background: var(--color-background, #fff); + border-bottom: 1px solid var(--color-border, #e5e7eb); + cursor: grab; + user-select: none; + transition: background 0.2s; +} + +.gq-priority-item:last-child { + border-bottom: none; +} + +.gq-priority-item:hover { + background: var(--color-muted, #f9fafb); +} + +.gq-priority-item.dragging { + background: rgba(99, 102, 241, 0.1); + opacity: 0.8; +} + +.gq-priority-rank { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--brand-primary, #6366f1); + color: #fff; + font-size: 0.75rem; + font-weight: 600; + margin-right: 0.75rem; +} + +.gq-priority-label { + flex: 1; + font-size: 0.9rem; + color: var(--text-primary, #111827); +} + +.gq-drag-handle { + color: var(--text-secondary, #9ca3af); + font-size: 1rem; + cursor: grab; +} + +/* Footer Buttons */ +.gq-footer { + display: flex; + justify-content: space-between; + gap: 1rem; + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid var(--color-border, #e5e7eb); +} + +.gq-btn { + flex: 1; + padding: 0.875rem 1.5rem; + border: none; + border-radius: var(--radius-lg, 0.5rem); + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.gq-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.gq-btn-primary { + background: var(--brand-primary, #6366f1); + color: #fff; +} + +.gq-btn-primary:hover:not(:disabled) { + background: var(--brand-primary-hover, #4f46e5); + transform: translateY(-1px); +} + +.gq-btn-secondary { + background: var(--color-secondary, #f3f4f6); + color: var(--text-primary, #374151); + border: 1px solid var(--color-border, #e5e7eb); +} + +.gq-btn-secondary:hover:not(:disabled) { + background: var(--color-muted, #e5e7eb); +} + +/* Responsive */ +@media (max-width: 640px) { + .gq-container { + padding: 1.5rem; + border-radius: 0; + border-left: none; + border-right: none; + } + + .gq-field-row { + flex-direction: column; + gap: 1rem; + } + + .gq-option-group { + flex-direction: column; + } + + .gq-option-btn { + width: 100%; + justify-content: center; + } + + .gq-step-content { + min-height: auto; + } +} diff --git a/website/src/components/intake/GroceryPreferencesQuestionnaire.jsx b/website/src/components/intake/GroceryPreferencesQuestionnaire.jsx new file mode 100644 index 00000000..b2253269 --- /dev/null +++ b/website/src/components/intake/GroceryPreferencesQuestionnaire.jsx @@ -0,0 +1,829 @@ +import { useState, useCallback, useMemo } from 'react'; +import './GroceryPreferencesQuestionnaire.css'; + +const STEPS = [ + { id: 'profile', title: 'Basic Profile', titleZh: '基本信息' }, + { id: 'shopping', title: 'Shopping Habits', titleZh: '购物习惯' }, + { id: 'categories', title: 'Categories', titleZh: '常买品类' }, + { id: 'category_prefs', title: 'Category Preferences', titleZh: '品类偏好' }, + { id: 'taste', title: 'Taste Profile', titleZh: '口味偏好' }, + { id: 'budget', title: 'Budget & Priorities', titleZh: '预算与优先级' } +]; + +const CULTURAL_BACKGROUNDS = [ + { value: 'mainland_china', label: '中国大陆' }, + { value: 'taiwan_hk_macau', label: '台湾/港澳' }, + { value: 'abc', label: 'ABC/华裔美国人' }, + { value: 'korea_japan', label: '韩国/日本' }, + { value: 'southeast_asia', label: '东南亚' }, + { value: 'other_asian', label: '其他亚裔' }, + { value: 'non_asian', label: '非亚裔但喜欢亚洲食品' } +]; + +const HOUSEHOLD_SIZES = [ + { value: '1', label: '1人(自己)' }, + { value: '2', label: '2人(情侣/室友)' }, + { value: '3-4', label: '3-4人(小家庭)' }, + { value: '5+', label: '5人以上(大家庭)' } +]; + +const TRANSPORT_OPTIONS = [ + { value: 'no_car', label: '没有车,靠公共交通/走路' }, + { value: 'car_15min', label: '有车,15分钟内' }, + { value: 'car_30min', label: '有车,30分钟内' }, + { value: 'car_60min', label: '有车,1小时内也可以' } +]; + +const SHOPPING_PREFERENCES = [ + { value: 'delivery', label: '网购送货(Weee/Instacart)' }, + { value: 'in_store', label: '线下超市' }, + { value: 'both', label: '两者都可以' } +]; + +const MEMBERSHIPS = [ + { value: 'sams_club', label: "Sam's Club" }, + { value: 'costco', label: 'Costco' }, + { value: 'kroger_plus', label: 'Kroger Plus Card' } +]; + +const PREFERRED_STORES = [ + // Asian grocery - online + { value: 'weee', label: 'Weee (网购)', category: 'asian_online' }, + { value: 'yami', label: 'Yami 亚米 (网购)', category: 'asian_online' }, + // Asian grocery - physical + { value: '168_asian_mart', label: '168 Asian Mart', category: 'asian_physical' }, + { value: 'hmart', label: 'H Mart 韩亚龙', category: 'asian_physical' }, + { value: 'great_wall', label: 'Great Wall 大中华', category: 'asian_physical' }, + { value: '99_ranch', label: '99 Ranch 大华', category: 'asian_physical' }, + { value: 'mitsuwa', label: 'Mitsuwa 日本超市', category: 'asian_physical' }, + // Warehouse clubs + { value: 'sams_club', label: "Sam's Club 山姆", category: 'warehouse' }, + { value: 'costco', label: 'Costco 开市客', category: 'warehouse' }, + // Mainstream US + { value: 'kroger', label: 'Kroger', category: 'mainstream' }, + { value: 'walmart', label: 'Walmart', category: 'mainstream' }, + { value: 'target', label: 'Target', category: 'mainstream' }, + { value: 'aldi', label: 'Aldi', category: 'mainstream' }, + { value: 'trader_joes', label: "Trader Joe's", category: 'mainstream' }, + { value: 'whole_foods', label: 'Whole Foods', category: 'mainstream' } +]; + +// Pre-computed store lists by category (avoids filtering on every render) +const STORES_ASIAN_ONLINE = PREFERRED_STORES.filter(s => s.category === 'asian_online'); +const STORES_ASIAN_PHYSICAL = PREFERRED_STORES.filter(s => s.category === 'asian_physical'); +const STORES_WAREHOUSE = PREFERRED_STORES.filter(s => s.category === 'warehouse'); +const STORES_MAINSTREAM = PREFERRED_STORES.filter(s => s.category === 'mainstream'); + +const MAIN_CATEGORIES = [ + { value: 'meat', label: '肉类(猪/牛/羊/鸡)', labelEn: 'Meat' }, + { value: 'seafood', label: '海鲜', labelEn: 'Seafood' }, + { value: 'vegetables', label: '蔬菜', labelEn: 'Vegetables' }, + { value: 'fruits', label: '水果', labelEn: 'Fruits' }, + { value: 'snacks', label: '零食饮料', labelEn: 'Snacks & Drinks' }, + { value: 'hotpot', label: '火锅/烧烤食材', labelEn: 'Hotpot/BBQ' }, + { value: 'condiments', label: '调味料/酱料', labelEn: 'Condiments' }, + { value: 'instant', label: '速食/方便面', labelEn: 'Instant Food' }, + { value: 'dairy', label: '奶制品/鸡蛋', labelEn: 'Dairy/Eggs' }, + { value: 'bakery', label: '面包/烘焙', labelEn: 'Bakery' } +]; + +const DIETARY_RESTRICTIONS = [ + { value: 'none', label: '无' }, + { value: 'vegetarian', label: '素食/纯素' }, + { value: 'no_pork', label: '不吃猪肉(宗教原因)' }, + { value: 'lactose', label: '乳糖不耐受' }, + { value: 'gluten', label: '麸质过敏' } +]; + +const BUDGET_MINDSETS = [ + { value: 'price_first', label: '能省则省,价格最重要' }, + { value: 'value', label: '性价比优先,质量也要看' }, + { value: 'quality_first', label: '质量优先,价格其次' }, + { value: 'no_concern', label: '不太在意价格' } +]; + +const PRIORITY_FACTORS = [ + { value: 'price', label: '价格便宜' }, + { value: 'quality', label: '产品新鲜/质量好' }, + { value: 'convenience', label: '距离近/方便' }, + { value: 'variety', label: '品种齐全' } +]; + +function ProgressBar({ currentStep, totalSteps }) { + const progress = ((currentStep + 1) / totalSteps) * 100; + return ( +
+
+ + {currentStep + 1} / {totalSteps} + +
+ ); +} + +function SingleSelect({ options, value, onChange, name }) { + return ( +
+ {options.map((opt) => ( + + ))} +
+ ); +} + +function MultiSelect({ options, values, onChange, name }) { + const handleToggle = (optValue) => { + if (values.includes(optValue)) { + onChange(values.filter((v) => v !== optValue)); + } else { + onChange([...values, optValue]); + } + }; + + return ( +
+ {options.map((opt) => ( + + ))} +
+ ); +} + +function RangeSlider({ value, onChange, min = 1, max = 5, labels }) { + return ( +
+ onChange(Number(e.target.value))} + className="gq-slider" + /> +
+ {labels.map((label, idx) => ( + + {label} + + ))} +
+
+ ); +} + +function DraggablePriorityList({ items, order, onChange }) { + const [draggedIdx, setDraggedIdx] = useState(null); + + const handleDragStart = useCallback((idx) => { + setDraggedIdx(idx); + }, []); + + const handleDragEnd = useCallback(() => { + setDraggedIdx(null); + }, []); + + // handleDragOver needs draggedIdx in deps since it reads current drag state + const handleDragOver = useCallback((e, idx) => { + e.preventDefault(); + if (draggedIdx === null || draggedIdx === idx) return; + + const newOrder = [...order]; + const [removed] = newOrder.splice(draggedIdx, 1); + newOrder.splice(idx, 0, removed); + onChange(newOrder); + setDraggedIdx(idx); + }, [draggedIdx, order, onChange]); + + const itemMap = useMemo(() => { + const map = {}; + items.forEach((item) => { + map[item.value] = item; + }); + return map; + }, [items]); + + return ( +
+

拖拽排序(1 = 最重要)

+ {order.map((value, idx) => ( +
handleDragStart(idx)} + onDragOver={(e) => handleDragOver(e, idx)} + onDragEnd={handleDragEnd} + > + {idx + 1} + {itemMap[value]?.label} + ⋮⋮ +
+ ))} +
+ ); +} + +function GroceryPreferencesQuestionnaire({ + onComplete, + onCancel, + initialData = {} +}) { + const [currentStep, setCurrentStep] = useState(0); + const [formData, setFormData] = useState({ + // Step 1: Profile + cultural_background: initialData.cultural_background || '', + zip_code: initialData.zip_code || '', + city: initialData.city || '', + household_size: initialData.household_size || '', + + // Step 2: Shopping Habits + transport: initialData.transport || '', + shopping_preference: initialData.shopping_preference || '', + memberships: initialData.memberships || [], + preferred_stores: initialData.preferred_stores || [], + other_stores: initialData.other_stores || '', + other_membership: initialData.other_membership || '', + + // Step 3: Categories + main_categories: initialData.main_categories || [], + + // Step 4: Category Preferences (dynamic based on step 3) + meat_type: initialData.meat_type || [], + meat_processing: initialData.meat_processing || '', + meat_quantity: initialData.meat_quantity || '', + snack_flavor: initialData.snack_flavor || [], + snack_brands_like: initialData.snack_brands_like || '', + snack_brands_avoid: initialData.snack_brands_avoid || '', + vegetable_types: initialData.vegetable_types || '', + vegetable_organic: initialData.vegetable_organic || '', + hotpot_frequency: initialData.hotpot_frequency || '', + hotpot_base: initialData.hotpot_base || [], + hotpot_brands: initialData.hotpot_brands || '', + + // Step 5: Taste Profile + sweetness: initialData.sweetness || 3, + spiciness: initialData.spiciness || 3, + saltiness: initialData.saltiness || 'normal', + american_sweets_opinion: initialData.american_sweets_opinion || '', + avoid_foods: initialData.avoid_foods || '', + dietary_restrictions: initialData.dietary_restrictions || [], + other_dietary: initialData.other_dietary || '', + + // Step 6: Budget + budget_mindset: initialData.budget_mindset || '', + priority_order: initialData.priority_order || ['quality', 'price', 'convenience', 'variety'] + }); + + const updateField = useCallback((field, value) => { + setFormData((prev) => ({ ...prev, [field]: value })); + }, []); + + const canProceed = useMemo(() => { + switch (currentStep) { + case 0: // Profile + return formData.cultural_background && formData.zip_code && formData.household_size; + case 1: // Shopping + return formData.transport && formData.shopping_preference && formData.preferred_stores.length > 0; + case 2: // Categories + return formData.main_categories.length > 0; + case 3: // Category prefs - optional + return true; + case 4: // Taste + return formData.sweetness && formData.spiciness; + case 5: // Budget + return formData.budget_mindset; + default: + return true; + } + }, [currentStep, formData]); + + const handleNext = () => { + if (currentStep < STEPS.length - 1) { + setCurrentStep(currentStep + 1); + } else { + onComplete?.(formData); + } + }; + + const handleBack = () => { + if (currentStep > 0) { + setCurrentStep(currentStep - 1); + } + }; + + const renderStepContent = () => { + switch (currentStep) { + case 0: + return ( +
+

你好!我是你的智能买菜助手

+

为了给你更好的推荐,先了解一下你的情况

+ +
+ + updateField('cultural_background', v)} + name="cultural_background" + /> +
+ +
+
+ + updateField('city', e.target.value)} + /> +
+
+ + updateField('zip_code', e.target.value)} + /> +
+
+ +
+ + updateField('household_size', v)} + name="household_size" + /> +
+
+ ); + + case 1: + return ( +
+

购物习惯

+ +
+ + updateField('transport', v)} + name="transport" + /> +
+ +
+ + updateField('shopping_preference', v)} + name="shopping_preference" + /> +
+ +
+ + updateField('memberships', v)} + name="memberships" + /> + updateField('other_membership', e.target.value)} + /> +
+ +
+ +

选择你常去的超市,我们会优先比较这些店的价格

+ +
+
亚洲超市 - 网购
+ updateField('preferred_stores', v)} + name="preferred_stores_asian_online" + /> +
+ +
+
亚洲超市 - 实体店
+ updateField('preferred_stores', v)} + name="preferred_stores_asian_physical" + /> +
+ +
+
仓储会员店
+ updateField('preferred_stores', v)} + name="preferred_stores_warehouse" + /> +
+ +
+
美国主流超市
+ updateField('preferred_stores', v)} + name="preferred_stores_mainstream" + /> +
+ + updateField('other_stores', e.target.value)} + /> +
+
+ ); + + case 2: + return ( +
+

你主要买哪些品类?

+

选择后会针对你选的品类问更细的问题

+ +
+ updateField('main_categories', v)} + name="main_categories" + /> +
+
+ ); + + case 3: + return ( +
+

品类偏好细节

+

根据你选择的品类,我们想了解更多细节

+ + {formData.main_categories.includes('meat') && ( +
+

关于肉类

+
+ + updateField('meat_type', v)} + name="meat_type" + /> +
+
+ + updateField('meat_processing', v)} + name="meat_processing" + /> +
+
+ + updateField('meat_quantity', v)} + name="meat_quantity" + /> +
+
+ )} + + {formData.main_categories.includes('snacks') && ( +
+

关于零食

+
+ + updateField('snack_flavor', v)} + name="snack_flavor" + /> +
+
+ + updateField('snack_brands_like', e.target.value)} + /> +
+
+ + updateField('snack_brands_avoid', e.target.value)} + /> +
+
+ )} + + {formData.main_categories.includes('vegetables') && ( +
+

关于蔬菜

+
+ + updateField('vegetable_types', e.target.value)} + /> +
+
+ + updateField('vegetable_organic', v)} + name="vegetable_organic" + /> +
+
+ )} + + {formData.main_categories.includes('hotpot') && ( +
+

关于火锅

+
+ + updateField('hotpot_frequency', v)} + name="hotpot_frequency" + /> +
+
+ + updateField('hotpot_base', v)} + name="hotpot_base" + /> +
+
+ + updateField('hotpot_brands', e.target.value)} + /> +
+
+ )} + + {formData.main_categories.length === 0 && ( +

请先在上一步选择你常买的品类

+ )} +
+ ); + + case 4: + return ( +
+

口味偏好

+ +
+ + updateField('sweetness', v)} + labels={['很淡', '偏淡', '适中', '偏甜', '很甜']} + /> +
+ +
+ + updateField('american_sweets_opinion', v)} + name="american_sweets_opinion" + /> +
+ +
+ + updateField('spiciness', v)} + labels={['不能吃辣', '微辣', '中辣', '辣', '越辣越好']} + /> +
+ +
+ + updateField('saltiness', v)} + name="saltiness" + /> +
+ +
+ +