tail-fin-shopee 0.7.8

Shopee adapter for tail-fin: account info, search (browser-only), product detail. Multi-region (TW/SG/MY/...).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
use serde::{Deserialize, Serialize};

/// One item from the search results.
///
/// Surfaces the most useful fields from `items[].item_basic`. Prices
/// are in **micros** — Shopee multiplies the actual price by 100 000
/// to keep them as integers (so `3 999 000 000` micros = NT$39 990.00).
/// Divide by 100 000 to render. Stored as `u64` to preserve precision.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SearchItem {
    /// Numeric Shopee item ID — globally unique within a region.
    pub itemid: u64,
    /// ID of the shop selling this item. Combine as
    /// `https://shopee.tw/product/{shopid}/{itemid}` to build the
    /// canonical product URL.
    pub shopid: u64,
    /// Localised product title (e.g. `"iPhone 15 Pro 256GB 黑色"`).
    pub name: String,
    /// Price in **micros** (actual price × 100 000). For a fixed-
    /// price item `price_min == price == price_max`. For a range
    /// (variants) `price` is the floor.
    pub price: u64,
    /// Lowest variant price in micros.
    pub price_min: u64,
    /// Highest variant price in micros.
    pub price_max: u64,
    /// ISO currency code echoed by Shopee (`"TWD"`, `"SGD"`, …).
    #[serde(default)]
    pub currency: Option<String>,
    /// Stock — sum across variants. Shopee returns `-1` for sellers
    /// who hide stock, so stored as `i64`.
    #[serde(default)]
    pub stock: i64,
    /// Lifetime sales count (Shopee's `historical_sold`). `-1` when
    /// hidden.
    #[serde(default)]
    pub historical_sold: i64,
    /// Number of users who tapped the heart icon.
    #[serde(default)]
    pub liked_count: u64,
    /// Average star rating (0.0–5.0). `0.0` when no reviews.
    #[serde(default)]
    pub rating_star: f64,
    /// Total number of ratings used to compute `rating_star`.
    #[serde(default)]
    pub rating_count: u64,
    /// Shop's listed location (`"新北市"`, `"Singapore"`, …) — the
    /// closest thing Shopee surfaces to a shipping origin.
    #[serde(default)]
    pub shop_location: Option<String>,
    /// Cover-image hash. Build the full URL via
    /// `https://cf.shopee.tw/file/{image}` (region-prefixed CDN).
    #[serde(default)]
    pub image: Option<String>,
}

/// Parsed `/api/v4/search/search_items` response.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SearchResults {
    /// Search keyword echoed by the caller (not by Shopee — this is
    /// what we passed in).
    pub keyword: String,
    /// Total result count Shopee claims (server-side; usually higher
    /// than `items.len()` because Shopee paginates).
    #[serde(default)]
    pub total_count: u64,
    /// Items on this page. Empty when Shopee returned no matches OR
    /// when the search was anti-bot-walled (in which case the wrapper
    /// would have surfaced a non-zero `error` and the caller would
    /// have errored out before reaching here).
    pub items: Vec<SearchItem>,
    /// Page index this batch corresponds to (0-indexed). Shopee
    /// returns the same `total_count` regardless of page; pages
    /// past `total_count / items.len()` return an empty `items`.
    #[serde(default)]
    pub page: u32,
}

/// Items related to a given product, gathered from the two recommend
/// endpoints Shopee fires when its PDP loads:
/// - `pdp/hot_sales/get_item_cards` — same-shop or category-level
///   bestsellers (typically 8).
/// - `recommend/product_detail_page` — personalised "you may also
///   like" feed (typically 48, key `you_may_also_like`).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RelatedItems {
    /// Item this set is related to.
    pub source_itemid: u64,
    pub source_shopid: u64,
    /// Bestseller-style related items (`pdp/hot_sales`).
    pub hot_sales: Vec<RecommendedItem>,
    /// Personalised "you may also like" list
    /// (`recommend/product_detail_page`).
    pub recommended: Vec<RecommendedItem>,
}

/// One related-product entry. A subset of [`SearchItem`] — Shopee's
/// recommend endpoints sometimes split fields across nested objects
/// (`item_data` + `item_card_displayed_asset` for `hot_sales`); the
/// parser flattens that so callers see a single shape.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RecommendedItem {
    pub itemid: u64,
    pub shopid: u64,
    pub name: String,
    /// Price in **micros** (actual × 100 000).
    pub price: u64,
    #[serde(default)]
    pub currency: Option<String>,
    /// Stock; `-1` when seller hides it; `0` when out of stock.
    /// Some endpoints (notably `shop_items`) don't surface a real
    /// stock count — they always populate `-1` and rely on
    /// [`Self::is_sold_out`] to convey availability.
    #[serde(default)]
    pub stock: i64,
    /// `true` when Shopee has flagged the item as sold out on
    /// listing endpoints that don't ship a real stock count
    /// (`shop_items`'s `rcmd_items` / `search_items` only ship
    /// `is_sold_out: bool` + textual `sold_count.text`). Other
    /// endpoints (search, related, discover) leave this `false`
    /// because their `stock` value already carries the signal.
    #[serde(default)]
    pub is_sold_out: bool,
    /// Average rating star (0.0 if no reviews).
    #[serde(default)]
    pub rating_star: f64,
    /// Total rating count.
    #[serde(default)]
    pub rating_count: u64,
    #[serde(default)]
    pub image: Option<String>,
    #[serde(default)]
    pub shop_location: Option<String>,
}

/// Detailed product info from `/api/v4/pdp/get_pc` (or `item/get` —
/// Shopee has shipped both; the parser accepts either wrapper shape).
///
/// A superset of [`SearchItem`] — adds description, full image list,
/// variant models, and shop-side info that the search-results wire
/// shape omits. Prices are still in **micros** (see `SearchItem`).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProductDetail {
    pub itemid: u64,
    pub shopid: u64,
    pub name: String,
    /// Full HTML/markdown-stripped description blob. May contain
    /// embedded `<br>` or newline characters depending on how the
    /// seller authored it.
    #[serde(default)]
    pub description: Option<String>,
    /// Price in **micros** (actual × 100 000).
    pub price: u64,
    /// Lowest variant price in micros.
    pub price_min: u64,
    /// Highest variant price in micros.
    pub price_max: u64,
    /// ISO currency code (`"TWD"`, `"SGD"`, …).
    #[serde(default)]
    pub currency: Option<String>,
    /// Total stock across variants. `-1` when seller hides stock.
    #[serde(default)]
    pub stock: i64,
    /// Lifetime sales count. `-1` when hidden.
    #[serde(default)]
    pub historical_sold: i64,
    /// Heart-icon count.
    #[serde(default)]
    pub liked_count: u64,
    /// Average star rating (0.0–5.0).
    #[serde(default)]
    pub rating_star: f64,
    /// Total number of ratings.
    #[serde(default)]
    pub rating_count: u64,
    /// Shop-listed shipping origin.
    #[serde(default)]
    pub shop_location: Option<String>,
    /// Cover image hash (same as `images[0]` for items with a primary
    /// photo).
    #[serde(default)]
    pub image: Option<String>,
    /// Full image gallery (hashes — build URL via
    /// `https://cf.shopee.tw/file/{hash}`).
    #[serde(default)]
    pub images: Vec<String>,
    /// Variant models (size / color combinations). Empty for items
    /// without variants.
    #[serde(default)]
    pub models: Vec<ProductModel>,
}

/// One variant of a product (e.g. `"Black / 256GB"`). For products
/// without variants, `models` is empty and `ProductDetail::price` is
/// the actual price.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProductModel {
    /// Numeric model ID — passed to cart/checkout APIs to pin a
    /// specific variant.
    pub modelid: u64,
    /// Variant name, e.g. `"黑色,256GB"`.
    pub name: String,
    /// Price for this variant in micros.
    pub price: u64,
    /// Stock for this variant. `-1` when hidden.
    #[serde(default)]
    pub stock: i64,
}

/// Authenticated user's cart preview from `/api/v4/cart/mini`.
///
/// Shopee fires this endpoint on every page load (drives the cart-
/// count badge in the site header). Carries the 5 most recently
/// added items plus the total / unique counts across the whole
/// cart — NOT the full cart contents. The dedicated full-cart
/// endpoint (`cart/get_items_brief`) requires a POST body listing
/// which shop IDs to query and is more useful for power users
/// driving the actual cart page.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CartPreview {
    /// Total items in the cart (sums quantities across variants).
    pub total_count: u64,
    /// Distinct (itemid, modelid) pairs in the cart.
    pub unique_count: u64,
    /// Up to 5 most-recently-added items — Shopee caps this list
    /// regardless of how many distinct items the cart contains.
    pub recent_items: Vec<CartItem>,
}

/// One cart entry. Subset of [`SearchItem`] with the variant /
/// status fields cart needs.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CartItem {
    pub itemid: u64,
    pub shopid: u64,
    /// Variant ID (size / color / spec). `0` for items without
    /// variants.
    pub modelid: u64,
    pub name: String,
    /// Price for the chosen variant in **micros** (actual × 100 000).
    pub price: u64,
    /// Cover image hash. URL: `https://cf.shopee.tw/file/{image}`.
    #[serde(default)]
    pub image: Option<String>,
    /// Item status flag — `1` for normal active items. Other values
    /// surface in `raw` when the item is unavailable, OOS, or
    /// pending seller approval; we don't pin specific other values
    /// because Shopee re-numbers them periodically.
    #[serde(default)]
    pub status: i64,
    /// `true` when this entry is an add-on tier sub-item (free /
    /// discounted gift attached to a parent purchase).
    #[serde(default)]
    pub is_add_on_sub_item: bool,
}

/// Combined homepage "discover" surface — what Shopee shows users
/// when they land on `shopee.{region}/` without a search keyword.
///
/// Aggregates three endpoints that all fire automatically on a
/// single homepage navigation:
/// - `homepage/get_daily_discover` — personalised "for you" feed
/// - `flash_sale/flash_sale_get_items` — limited-time flash deals
/// - `homepage/mall_shops` — official-mall shop highlights
///
/// Combining into one struct avoids paying ~30 s of navigate cost
/// three times when callers want the whole homepage state.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Discover {
    /// Personalised feed (Shopee's "daily discover"). Items share
    /// `RecommendedItem`'s shape — same wire format as PDP
    /// `pdp/hot_sales` from `related_products`.
    pub feeds: Vec<RecommendedItem>,
    /// Total number of feed items Shopee claims to have available
    /// (the user can paginate through more; we only return this
    /// page's batch).
    #[serde(default)]
    pub feed_total: u64,
    /// Flash-sale items currently active.
    pub flash_sale: Vec<FlashSaleItem>,
    /// Highlighted official-mall shops.
    pub mall_shops: Vec<MallShop>,
}

/// One flash-sale entry from `/api/v4/flash_sale/flash_sale_get_items`.
///
/// Distinct from `SearchItem` because it carries flash-sale-specific
/// fields (`raw_discount`, `end_time`, `promotionid`) and Shopee
/// returns the image as a full URL on this endpoint rather than the
/// hash alone — handled transparently by the parser.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct FlashSaleItem {
    pub itemid: u64,
    pub shopid: u64,
    pub name: String,
    /// Discounted price in **micros** (actual × 100 000).
    pub price: u64,
    /// Discount percentage (e.g. `69` for 69% off). `0` when Shopee
    /// flags the item as a flash-sale entry without an explicit %.
    #[serde(default)]
    pub raw_discount: u32,
    /// Unix timestamp (seconds) when the flash sale ends — caller
    /// can compare against `now()` to detect expired entries.
    #[serde(default)]
    pub end_time: i64,
    /// Stock remaining at flash-sale price.
    #[serde(default)]
    pub stock: i64,
    /// Cover image — **mixed format on this endpoint**: Shopee
    /// returns either a bare hash (`"tw-11134207-…"`) or a full
    /// HTTPS URL (`"https://mms.img.susercontent.com/…"`)
    /// depending on the item. Callers should detect with a
    /// `starts_with("https://")` check before deciding whether to
    /// prefix the CDN host. Verified empirically 2026-04-30.
    #[serde(default)]
    pub image: Option<String>,
    /// Flash-sale's own promotion ID (separate from the item's
    /// `promotion_type` flag) — useful for cross-referencing
    /// against the seller's promo log.
    #[serde(default)]
    pub promotionid: u64,
}

/// One official-mall shop highlight from
/// `/api/v4/homepage/mall_shops`. Lightweight — Shopee surfaces
/// just the shop ID, the shop's URL slug, a banner image, and the
/// active promo blurb.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MallShop {
    pub shopid: u64,
    /// Shop URL (`https://shopee.tw/<slug>`).
    pub url: String,
    /// Banner image hash. URL: `https://cf.shopee.tw/file/{image}`.
    #[serde(default)]
    pub image: Option<String>,
    /// Localised promo blurb (`"無門檻8折券"`, `"領券享92折"`, …).
    /// Empty when the shop is featured without an active promo.
    #[serde(default)]
    pub promo_text: Option<String>,
}

/// One Shopee category from `/api/v4/pages/get_homepage_category_list`.
///
/// The endpoint returns only top-level categories
/// (`level: 1`, `parent_catid: 0`); the `children` field is
/// `null` for every entry. Sub-category drilling lives behind
/// other endpoints we haven't wrapped yet, so the recursive
/// `children: Vec<Category>` field is reserved for future use
/// (always empty today, but lets callers code against the
/// eventual tree shape).
///
/// **For UI display use [`Self::display_name`]**; [`Self::name`]
/// is Shopee's internal English label (`"Women's Apparel"`) and
/// is rarely what end-users want.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Category {
    /// Numeric Shopee category ID.
    pub catid: u64,
    /// Parent category ID; `0` for top-level.
    #[serde(default)]
    pub parent_catid: u64,
    /// Internal English name (`"Women's Apparel"`).
    #[serde(default)]
    pub name: String,
    /// Localised display name (`"女生衣著"`, `"3C與配件"`, …).
    #[serde(default)]
    pub display_name: String,
    /// Default icon image hash. URL:
    /// `https://cf.shopee.tw/file/{image}`.
    #[serde(default)]
    pub image: Option<String>,
    /// Tree level — `1` for top-level, increasing with depth
    /// when sub-category endpoints are wrapped in the future.
    #[serde(default)]
    pub level: u32,
    /// Sub-categories. Always empty for the
    /// `homepage_category_list` endpoint; populated only when
    /// future drill-down endpoints land.
    #[serde(default)]
    pub children: Vec<Category>,
}

/// Per-category metadata from `/api/v4/search/get_fe_category_detail`.
///
/// Distinct from [`Category`]: this endpoint fires on category
/// landing pages and returns a richer-but-differently-shaped record:
/// `parent_cat_id` arrives as a **JSON array** (`[parent_id]`) — we
/// flatten to a scalar `Option<u64>` (top-level → `None`); and
/// `display_name` arrives as a locale array (`[{lang, value,
/// is_default}, ...]`) — we extract the default locale's `value`.
///
/// Returned as the `category` field of [`CategoryPage`] so callers
/// rendering a "browse this category" view can show the breadcrumb
/// (`name` + `parent_cat_id`) alongside the items list.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CategoryDetail {
    /// Numeric Shopee category ID (matches the `<catid>` argument).
    pub catid: u64,
    /// Internal English name (`"Women's Apparel"`, `"Pants"`).
    #[serde(default)]
    pub name: String,
    /// Localised display name extracted from the `is_default: true`
    /// locale entry (typically `zh-Hant` on shopee.tw, `"長褲"`).
    #[serde(default)]
    pub display_name: String,
    /// Tree depth — `1` for top-level, `2`+ for sub-categories.
    #[serde(default)]
    pub level: u32,
    /// Parent category ID; `None` for top-level (`level: 1`).
    /// Flattened from Shopee's `[parent_id]` array shape.
    #[serde(default)]
    pub parent_cat_id: Option<u64>,
    /// Default category icon hash. URL:
    /// `https://cf.shopee.tw/file/{image}`.
    #[serde(default)]
    pub image: Option<String>,
}

/// Browse-a-category response — combined output of `category(catid,
/// page)`: category metadata + paginated items list. Same item
/// wire shape as [`SearchResults`] (60 items per page).
///
/// Each component degrades independently:
/// - `category` → `None` when `get_fe_category_detail` doesn't fire
///   (rare; mostly when the navigation lands on an unrelated page,
///   e.g. CAPTCHA / verify-page).
/// - `items` → empty when paged past `total_count / 60`, OR when
///   the items endpoint was anti-bot-walled (caller would have
///   errored out first in that case).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CategoryPage {
    /// Category metadata. `None` only when the metadata endpoint
    /// silently failed to fire — items list might still be valid.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub category: Option<CategoryDetail>,
    /// Items in this page. Same wire shape as [`SearchResults::items`].
    pub items: Vec<SearchItem>,
    /// Total result count Shopee claims for this category — full
    /// catalogue size, not page-limited.
    #[serde(default)]
    pub total_count: u64,
    /// Page index this batch corresponds to (0-indexed). Pages
    /// past `total_count / 60` return an empty `items`.
    #[serde(default)]
    pub page: u32,
    /// `true` when more pages are available — derived from
    /// `total_count` and `page`.
    #[serde(default)]
    pub has_more: bool,
}

/// Seller / shop info from `/api/v4/promotion/get_shop_info`.
///
/// Drives Shopee's seller-info card on PDPs and shop pages —
/// shop identity, location, follower count, and the rating
/// breakdown (good / normal / bad reviews of the SHOP, not of
/// individual items). [`Self::rating_star`] holds the 0.0–5.0
/// average; the `rating_good` / `rating_normal` / `rating_bad`
/// counts are useful for "X/Y positive" rendering.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ShopInfo {
    pub shop_id: u64,
    /// Seller's user account ID — distinct from `shop_id` and
    /// rarely directly useful (every seller has both).
    #[serde(default)]
    pub user_id: u64,
    /// Shop name (`"miko 米可|手機門號配件專賣"`).
    pub name: String,
    /// Full shop address as the seller has registered it
    /// (`"700 臺南市中西區和意路68號"`). May include shop number
    /// and street; localised per region.
    #[serde(default)]
    pub place: Option<String>,
    /// `true` when the shop is on Shopee's official-mall track
    /// (verified brand / first-party listing).
    #[serde(default)]
    pub is_official_shop: bool,
    /// `true` when Shopee has separately verified the seller's
    /// identity (KYC). Distinct from `is_official_shop`.
    #[serde(default)]
    pub is_shopee_verified: bool,
    /// `true` when the seller has flagged themselves as on
    /// vacation; orders may be paused.
    #[serde(default)]
    pub holiday_mode: bool,
    /// Number of items the shop has listed.
    #[serde(default)]
    pub item_count: u64,
    /// Number of users following this shop.
    #[serde(default)]
    pub follower_count: u64,
    /// Average shop rating star (0.0–5.0). Note: this is the
    /// SHOP's overall rating across all listings, not an item
    /// rating.
    #[serde(default)]
    pub rating_star: f64,
    /// Count of 4–5 star reviews.
    #[serde(default)]
    pub rating_good: u64,
    /// Count of 3 star reviews.
    #[serde(default)]
    pub rating_normal: u64,
    /// Count of 1–2 star reviews.
    #[serde(default)]
    pub rating_bad: u64,
    /// Customer-service response rate (0–100). `0` when unknown
    /// (new shops with no inbound questions yet).
    #[serde(default)]
    pub response_rate: u32,
    /// Average response time in seconds. `0` when unknown.
    #[serde(default)]
    pub response_time: u64,
    /// Shop creation Unix timestamp.
    #[serde(default)]
    pub ctime: i64,
    /// Last seller-active Unix timestamp.
    #[serde(default)]
    pub last_active_time: i64,
}

/// Parsed `/api/v2/item/get_ratings` response — list of reviews
/// for a single item plus aggregate summary fields.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Reviews {
    /// Item these reviews are for (echoed back from the request).
    pub itemid: u64,
    pub shopid: u64,
    /// Average star (0.0–5.0).
    #[serde(default)]
    pub item_rating_star: f64,
    /// Total ratings count.
    #[serde(default)]
    pub item_rating_count: u64,
    /// `true` when more pages exist past this batch (callers can
    /// re-call with `offset += limit`).
    #[serde(default)]
    pub has_more: bool,
    /// Reviews on this page (capped at 6 by Shopee on the
    /// default request).
    pub ratings: Vec<Review>,
}

/// One review entry from `/api/v2/item/get_ratings`.
///
/// Surfaces the most useful fields for displaying a review.
/// Shopee returns a lot of UI-housekeeping flags
/// (`fe_component_toggles`, `is_show_similar_ratings_section`,
/// etc.) we don't expose — callers needing them should drive
/// the raw JSON.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Review {
    /// Comment ID (`cmtid`) — globally unique per review.
    pub cmtid: u64,
    pub itemid: u64,
    pub shopid: u64,
    /// 1–5 stars.
    pub rating_star: u32,
    /// Reviewer's text. May be empty (Shopee allows star-only
    /// reviews without text).
    #[serde(default)]
    pub comment: String,
    /// Image hashes attached by the reviewer. URL:
    /// `https://cf.shopee.tw/file/{hash}`.
    #[serde(default)]
    pub images: Vec<String>,
    /// Review timestamp (Unix).
    pub ctime: i64,
    /// Reviewer's masked username (e.g. `"k*****s"`). Always
    /// masked when the reviewer is anonymous.
    #[serde(default)]
    pub author_username: String,
    /// `true` when the reviewer chose to hide their identity.
    #[serde(default)]
    pub anonymous: bool,
}

/// Parsed `/api/v4/shop/search_items` response — paginated catalogue
/// of items from one shop.
///
/// Shopee uses this endpoint when the shop page renders its
/// item-list section. Up to 30 items per page (Shopee's default);
/// `nomore: true` indicates the last page. Items reuse
/// [`RecommendedItem`] since the wire shape is the same split
/// `item_data`-equivalent + `item_card_displayed_asset` layout
/// as PDP `pdp/hot_sales` (PR #193).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ShopItems {
    /// Shop these items belong to (echoed back from caller).
    pub shopid: u64,
    /// 0-indexed page that produced this batch.
    pub page: u32,
    /// Total items in the shop's catalogue (independent of page).
    #[serde(default)]
    pub total_count: u64,
    /// Shopee's pagination flag — `true` when this batch is the
    /// last page (no further pages available).
    #[serde(default)]
    pub nomore: bool,
    /// Items on this page. Up to 30 per call.
    pub items: Vec<RecommendedItem>,
}

/// Parsed `/api/v4/search/search_user` response — shops matching
/// a search keyword.
///
/// Empirically Shopee returns 1 result regardless of which
/// search-page URL fires the request (we tried both `/search?keyword=X`
/// and `?searchType=user` — both came back with `limit=1`,
/// returning only the single most-relevant shop). Captures
/// the same single-shop "featured shop" sidebar a user sees on
/// the product-search page. To get more matching shops the
/// caller would need to drive a different UI surface (Shopee's
/// "View all" button on the sidebar, or a dedicated Shops tab —
/// follow-up work).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UserSearchResults {
    /// Search keyword echoed by the caller.
    pub keyword: String,
    /// Shops matching the keyword. Length depends on which UI
    /// surface fired the request (1 from product-search sidebar,
    /// up to 10 from the dedicated Users tab).
    pub users: Vec<UserMatch>,
}

/// One shop entry from a user-search result.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UserMatch {
    pub shopid: u64,
    /// Seller's account ID. Distinct from `shopid` but rarely
    /// directly useful.
    #[serde(default)]
    pub userid: u64,
    /// Username slug (`"orz_orz_orz"`) — appears in shop URLs as
    /// `https://shopee.tw/{username}`.
    pub username: String,
    /// Display name for the shop. Often identical to
    /// [`Self::nickname`]; minor variation when the seller has
    /// rebranded.
    #[serde(default)]
    pub shopname: String,
    /// Display nickname (typically same as `shopname`).
    #[serde(default)]
    pub nickname: String,
    /// Avatar / portrait image hash. URL:
    /// `https://cf.shopee.tw/file/{portrait}`.
    #[serde(default)]
    pub portrait: Option<String>,
    /// Shop rating star (0.0–5.0).
    #[serde(default)]
    pub shop_rating: f64,
    /// Number of followers.
    #[serde(default)]
    pub follower_count: u64,
    /// Number of items the shop has listed.
    #[serde(default)]
    pub products: u64,
    /// `true` for Shopee official-mall shops.
    #[serde(default)]
    pub is_official_shop: bool,
    /// `1` when Shopee has KYC-verified the seller, `0` otherwise.
    /// Treated as `u32` because Shopee may add intermediate
    /// values (`2` for "in review", etc.) over time.
    #[serde(default)]
    pub shopee_verified_flag: u32,
    /// Customer-service response rate (0–100).
    #[serde(default)]
    pub response_rate: u32,
    /// Average response time in seconds.
    #[serde(default)]
    pub response_time: u64,
    /// Region code (`"tw"`, `"sg"`, …).
    #[serde(default)]
    pub country: String,
}

/// Combined homepage state — cart preview + discover surface +
/// top-level category tree, all from a single homepage
/// navigation.
///
/// Each of the three components has a dedicated method
/// ([`crate::ShopeeBrowserClient::cart_preview`],
/// [`crate::ShopeeBrowserClient::discover`],
/// [`crate::ShopeeBrowserClient::category_tree`]); calling them
/// individually pays the ~30 s navigation cost three times.
/// `homepage_bundle()` does one navigate and drains all three
/// from the same capture buffer.
///
/// Each component degrades independently:
///
/// - `cart` is `Option` because absence is **semantic** —
///   `None` means the user isn't logged in (cart-mini only
///   fires for authenticated sessions), distinct from "logged
///   in with empty cart" (which produces `Some(CartPreview { total_count: 0, … })`).
/// - `discover.feeds` may be empty: daily-discover is scroll-
///   triggered on shopee.tw, not initial-load. `flash_sale` and
///   `mall_shops` populate independently.
/// - `categories` always populates on a healthy homepage render
///   (24 entries on shopee.tw). **`categories.is_empty()`
///   indicates a degraded response** — the most reliable signal
///   that something went wrong even when the bundle didn't
///   hard-fail.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HomepageBundle {
    /// Authenticated user's cart preview. `None` when not
    /// logged in (cart-mini doesn't fire for anonymous sessions).
    /// Distinct from "logged in with empty cart" (`Some` with
    /// zero counts).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cart: Option<CartPreview>,
    /// Personalised feed + flash sale + mall shops.
    pub discover: Discover,
    /// Top-level category list — typically 24 on shopee.tw.
    /// **Empty indicates a degraded response**: the homepage
    /// either didn't render the sidebar or shipped a malformed
    /// `category_list`. Callers should branch on
    /// `categories.is_empty()` for partial-failure detection.
    pub categories: Vec<Category>,
}

/// Account-info response from `/api/v4/account/basic/get_account_info`.
///
/// Most identity fields are surfaced as `Option<String>` because
/// Shopee returns `null` (not empty string) for fields the user
/// hasn't set or hasn't verified. The raw `data` blob is preserved
/// in `raw` so callers can drill into fields the struct doesn't pin.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AccountInfo {
    /// Numeric Shopee user ID (`SPC_U` cookie carries the same value).
    #[serde(default)]
    pub userid: u64,
    /// Username — the public handle (e.g. `motosan_dev`). Almost
    /// always set for active accounts.
    #[serde(default)]
    pub username: Option<String>,
    /// Display name — the user-visible nickname.
    #[serde(default)]
    pub nickname: Option<String>,
    /// Account email (often masked: `m***n@example.com`). `null`
    /// if the user hasn't verified an email.
    #[serde(default)]
    pub email: Option<String>,
    /// Phone — usually masked (`+886 9** *** *35`). `null` if the
    /// user hasn't verified a phone number.
    #[serde(default)]
    pub phone: Option<String>,
    /// Two-letter region code echoed back from the request (`"TW"`,
    /// `"SG"`, etc.).
    #[serde(default)]
    pub country: Option<String>,
    /// Avatar image hash. Construct full URL via
    /// `https://cf.shopee.tw/file/{portrait}`. Empty string when
    /// the user hasn't uploaded an avatar.
    #[serde(default)]
    pub portrait: Option<String>,
    /// Account creation timestamp (Unix seconds).
    #[serde(default)]
    pub created_time: i64,
    /// Account verification flags (email/phone confirmed, real-name
    /// verified, etc.). Shopee returns this as an opaque bitmask;
    /// callers parse via `raw` if they need specific bits.
    #[serde(default)]
    pub verify_status: u32,

    /// Full raw response body for callers that need fields not
    /// surfaced here (loyalty tier, region settings, KYC state, …).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub raw: Option<serde_json::Value>,
}