update the prodect itesm
240
public/sample-products/CREDITS.md
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
# Sample product photographs — sources and licences
|
||||||
|
|
||||||
|
Every picture under this folder is a freely licensed photograph from Wikimedia
|
||||||
|
Commons, padded to 4:3 and re-encoded. Nothing here was drawn, generated or
|
||||||
|
bought, and nothing is a manufacturer press image.
|
||||||
|
|
||||||
|
**These are category stand-ins, not the products they sit beside.** The file
|
||||||
|
`bosch-washer-front-1.jpg` is a photograph of *a* front-loading washing machine,
|
||||||
|
not of that Bosch; `iphone-16-1.jpg` is *an* iPhone, not the 16. They exist so a
|
||||||
|
merchant reading the catalogue sees a refrigerator where a refrigerator belongs
|
||||||
|
and a phone where a phone belongs, while the real images are still coming from
|
||||||
|
the Product API. When `SampleCatalogItem.imageUrls` arrives populated, this whole
|
||||||
|
folder and `PRODUCT_GALLERY` in
|
||||||
|
`src/features/products/utils/productArtwork.ts` should go.
|
||||||
|
|
||||||
|
## Why one file per product and not one per category
|
||||||
|
|
||||||
|
An earlier version kept ten pictures, one per product *kind*, which put the same
|
||||||
|
refrigerator on the Samsung single-door, the LG side-by-side and the Whirlpool
|
||||||
|
double-door, and the same phone on the iPhone, the Galaxy and the OnePlus. A
|
||||||
|
catalogue where three different products show one picture reads as broken data.
|
||||||
|
The per-kind files below are kept only as a last-resort floor for a product with
|
||||||
|
no gallery of its own.
|
||||||
|
|
||||||
|
## Licence obligations
|
||||||
|
|
||||||
|
Several of these licences require attribution by name, and the share-alike ones
|
||||||
|
require that credit travel with the image. This file is that credit: keep it
|
||||||
|
beside the images, and reproduce the relevant rows anywhere the images are
|
||||||
|
published outside the dev build. No CC-NC or CC-ND work was taken — the
|
||||||
|
harvester rejected those licences outright — so nothing here is limited to
|
||||||
|
non-commercial use.
|
||||||
|
|
||||||
|
Licence names are as Commons reports them. `CC BY x.x` requires credit;
|
||||||
|
`CC BY-SA x.x` requires credit and share-alike; `Public domain` and `CC0`
|
||||||
|
require neither, and are credited anyway.
|
||||||
|
|
||||||
|
## gallery/ — per product
|
||||||
|
|
||||||
|
`<slug>-1.jpg` is the primary shot: the one the catalogue tile and the detail
|
||||||
|
dialog's opening frame use. The rest fill the dialog's thumbnail carousel.
|
||||||
|
|
||||||
|
### Samsung Crystal 4K UHD Smart TV 55"
|
||||||
|
|
||||||
|
| file | source photograph | photographer | licence |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `samsung-tv-55-1.jpg` | [Samsung Smart TV 2012 (E-Series).jpg](https://commons.wikimedia.org/wiki/File%3ASamsung_Smart_TV_2012_%28E-Series%29.jpg) | Dancingpolishcow | CC BY-SA 4.0 |
|
||||||
|
| `samsung-tv-55-2.jpg` | [Samsung LED TV.jpg](https://commons.wikimedia.org/wiki/File%3ASamsung_LED_TV.jpg) | Kskhh | CC BY-SA 4.0 |
|
||||||
|
| `samsung-tv-55-3.jpg` | [Samsung Series 5, Indonesia 20140422.jpg](https://commons.wikimedia.org/wiki/File%3ASamsung_Series_5%2C_Indonesia_20140422.jpg) | Crisco 1492 | CC BY-SA 3.0 |
|
||||||
|
|
||||||
|
### LG OLED evo C4 Smart TV 65"
|
||||||
|
|
||||||
|
| file | source photograph | photographer | licence |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `lg-oled-65-1.jpg` | [LG OLED TV.jpg](https://commons.wikimedia.org/wiki/File%3ALG_OLED_TV.jpg) | LG Electronics | CC BY 2.0 |
|
||||||
|
| `lg-oled-65-2.jpg` | [LG 로봇, 업계 첫 디자인 '대통령상' 수상 (24107621107).jpg](https://commons.wikimedia.org/wiki/File%3ALG_%EB%A1%9C%EB%B4%87%2C_%EC%97%85%EA%B3%84_%EC%B2%AB_%EB%94%94%EC%9E%90%EC%9D%B8_%27%EB%8C%80%ED%86%B5%EB%A0%B9%EC%83%81%27_%EC%88%98%EC%83%81_%2824107621107%29.jpg) | LG전자 | CC BY 2.0 |
|
||||||
|
|
||||||
|
### Sony Bravia 2 LED Google TV 43"
|
||||||
|
|
||||||
|
| file | source photograph | photographer | licence |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `sony-tv-43-1.jpg` | [HTD Sony DigiDads 5683 DSC00119.jpg](https://commons.wikimedia.org/wiki/File%3AHTD_Sony_DigiDads_5683_DSC00119.jpg) | Michael Sheehan | CC BY 2.0 |
|
||||||
|
| `sony-tv-43-2.jpg` | [HTD Sony DigiDads 5683 DSC00116.jpg](https://commons.wikimedia.org/wiki/File%3AHTD_Sony_DigiDads_5683_DSC00116.jpg) | Michael Sheehan | CC BY 2.0 |
|
||||||
|
|
||||||
|
### Apple iPhone 16 (128 GB)
|
||||||
|
|
||||||
|
| file | source photograph | photographer | licence |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `iphone-16-1.jpg` | [About iPhone 16 Plus in Pink.jpg](https://commons.wikimedia.org/wiki/File%3AAbout_iPhone_16_Plus_in_Pink.jpg) | メイド理世 | CC BY-SA 4.0 |
|
||||||
|
| `iphone-16-2.jpg` | [About iPhone 16 Pro White Titanium.jpg](https://commons.wikimedia.org/wiki/File%3AAbout_iPhone_16_Pro_White_Titanium.jpg) | メイド理世 | CC BY-SA 4.0 |
|
||||||
|
| `iphone-16-3.jpg` | [About iPhone 16 Plus in dark mode.jpg](https://commons.wikimedia.org/wiki/File%3AAbout_iPhone_16_Plus_in_dark_mode.jpg) | メイド理世 | CC BY-SA 4.0 |
|
||||||
|
|
||||||
|
### Samsung Galaxy S24 FE 5G (256 GB)
|
||||||
|
|
||||||
|
| file | source photograph | photographer | licence |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `galaxy-s24-fe-1.jpg` | [SAMSUNG Galaxy S24 Ultra (2).jpg](https://commons.wikimedia.org/wiki/File%3ASAMSUNG_Galaxy_S24_Ultra_%282%29.jpg) | Dinkun Chen | CC BY-SA 4.0 |
|
||||||
|
| `galaxy-s24-fe-2.jpg` | [SAMSUNG Galaxy S24 Ultra.jpg](https://commons.wikimedia.org/wiki/File%3ASAMSUNG_Galaxy_S24_Ultra.jpg) | Dinkun Chen | CC BY-SA 4.0 |
|
||||||
|
| `galaxy-s24-fe-3.jpg` | [SAMSUNG Galaxy S24 Ultra (6).jpg](https://commons.wikimedia.org/wiki/File%3ASAMSUNG_Galaxy_S24_Ultra_%286%29.jpg) | Dinkun Chen | CC BY-SA 4.0 |
|
||||||
|
|
||||||
|
### OnePlus Nord CE4 5G (128 GB)
|
||||||
|
|
||||||
|
| file | source photograph | photographer | licence |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `oneplus-nord-ce4-1.jpg` | [OnePlus Nord 2T.png](https://commons.wikimedia.org/wiki/File%3AOnePlus_Nord_2T.png) | Rupturestriker | CC BY-SA 4.0 |
|
||||||
|
| `oneplus-nord-ce4-2.jpg` | [Oneplus Nord.jpg](https://commons.wikimedia.org/wiki/File%3AOneplus_Nord.jpg) | Gannu03 | CC BY-SA 4.0 |
|
||||||
|
| `oneplus-nord-ce4-3.jpg` | [OnePlus Nord 5 back.jpg](https://commons.wikimedia.org/wiki/File%3AOnePlus_Nord_5_back.jpg) | Maksdroider | CC BY-SA 4.0 |
|
||||||
|
|
||||||
|
### Redmi Note 14 5G (128 GB)
|
||||||
|
|
||||||
|
| file | source photograph | photographer | licence |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `redmi-note-14-1.jpg` | [Redmi Note 10T (Back panel).jpg](https://commons.wikimedia.org/wiki/File%3ARedmi_Note_10T_%28Back_panel%29.jpg) | Astra.L | CC0 |
|
||||||
|
| `redmi-note-14-2.jpg` | [REDMI Note 15 and REDMI Note 15 5G back.jpg](https://commons.wikimedia.org/wiki/File%3AREDMI_Note_15_and_REDMI_Note_15_5G_back.jpg) | Maksdroider | CC BY-SA 4.0 |
|
||||||
|
| `redmi-note-14-3.jpg` | [Redmi Note 10 Pro back (4).jpg](https://commons.wikimedia.org/wiki/File%3ARedmi_Note_10_Pro_back_%284%29.jpg) | Maksdroider | CC BY-SA 4.0 |
|
||||||
|
|
||||||
|
### HP Pavilion 15 Laptop
|
||||||
|
|
||||||
|
| file | source photograph | photographer | licence |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `hp-pavilion-15-1.jpg` | [HP Pavilion 15 cs3095nr.jpg](https://commons.wikimedia.org/wiki/File%3AHP_Pavilion_15_cs3095nr.jpg) | Dienthoaiquangcao62 | CC BY-SA 4.0 |
|
||||||
|
| `hp-pavilion-15-2.jpg` | [HP Laptop.jpg](https://commons.wikimedia.org/wiki/File%3AHP_Laptop.jpg) | Srini297 | CC BY-SA 4.0 |
|
||||||
|
| `hp-pavilion-15-3.jpg` | [HP Pavilion 15 cs3095nr.png](https://commons.wikimedia.org/wiki/File%3AHP_Pavilion_15_cs3095nr.png) | Dienthoaiquangcao68 | CC BY-SA 4.0 |
|
||||||
|
|
||||||
|
### Apple MacBook Air M3 13"
|
||||||
|
|
||||||
|
| file | source photograph | photographer | licence |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `macbook-air-m3-1.jpg` | [M2 Macbook Air Midnight model - 1.jpg](https://commons.wikimedia.org/wiki/File%3AM2_Macbook_Air_Midnight_model_-_1.jpg) | KKPCW(Kyu3) | CC BY-SA 4.0 |
|
||||||
|
| `macbook-air-m3-2.jpg` | [11" MacBook Air compared to 13" MacBook.jpg](https://commons.wikimedia.org/wiki/File%3A11%22_MacBook_Air_compared_to_13%22_MacBook.jpg) | Paul Hudson from United Kingdom | CC BY 2.0 |
|
||||||
|
| `macbook-air-m3-3.jpg` | [MacBook Air.jpeg](https://commons.wikimedia.org/wiki/File%3AMacBook_Air.jpeg) | User:jpp44345 | CC BY-SA 3.0 |
|
||||||
|
|
||||||
|
### Samsung Galaxy Tab S9 FE Wi-Fi
|
||||||
|
|
||||||
|
| file | source photograph | photographer | licence |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `galaxy-tab-s9-fe-1.jpg` | [20251110 181425 Samsung Galaxy Tab A9 Front.jpg](https://commons.wikimedia.org/wiki/File%3A20251110_181425_Samsung_Galaxy_Tab_A9_Front.jpg) | Saimmx | CC0 |
|
||||||
|
| `galaxy-tab-s9-fe-2.jpg` | [Example of a working Samsung Galaxy Tab 4 in use.jpg](https://commons.wikimedia.org/wiki/File%3AExample_of_a_working_Samsung_Galaxy_Tab_4_in_use.jpg) | InstyTheScotFlag | CC BY 4.0 |
|
||||||
|
| `galaxy-tab-s9-fe-3.jpg` | [20251110 181454 - Samsung Galaxy Tab A9 Back.jpg](https://commons.wikimedia.org/wiki/File%3A20251110_181454_-_Samsung_Galaxy_Tab_A9_Back.jpg) | Saimmx | CC0 |
|
||||||
|
|
||||||
|
### LG 655 L Side-by-Side Refrigerator
|
||||||
|
|
||||||
|
| file | source photograph | photographer | licence |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `lg-fridge-sbs-1.jpg` | [LG refrigerator.jpg](https://commons.wikimedia.org/wiki/File%3ALG_refrigerator.jpg) | Milad Mosapoor | Attribution |
|
||||||
|
| `lg-fridge-sbs-2.jpg` | [LG refrigerator interior.jpg](https://commons.wikimedia.org/wiki/File%3ALG_refrigerator_interior.jpg) | Milad Mosapoor | Attribution |
|
||||||
|
| `lg-fridge-sbs-3.jpg` | [LG refrigerator.jpg](https://commons.wikimedia.org/wiki/File%3ALG_refrigerator.jpg) | Milad Mosapoor | Attribution |
|
||||||
|
|
||||||
|
### Whirlpool 265 L Frost Free Double Door
|
||||||
|
|
||||||
|
| file | source photograph | photographer | licence |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `whirlpool-fridge-dd-1.jpg` | [Panasonic HOME REFRIGERATOR NR-C320WP-N.jpg](https://commons.wikimedia.org/wiki/File%3APanasonic_HOME_REFRIGERATOR_NR-C320WP-N.jpg) | Dinkun Chen | CC BY-SA 4.0 |
|
||||||
|
| `whirlpool-fridge-dd-2.jpg` | [Hisense, IFA 2018, Berlin (P1070179).jpg](https://commons.wikimedia.org/wiki/File%3AHisense%2C_IFA_2018%2C_Berlin_%28P1070179%29.jpg) | Matti Blume | CC BY-SA 4.0 |
|
||||||
|
| `whirlpool-fridge-dd-3.jpg` | [Sanyo Refrigerator SR-14T (edited and cropped).jpg](https://commons.wikimedia.org/wiki/File%3ASanyo_Refrigerator_SR-14T_%28edited_and_cropped%29.jpg) | Mr.ちゅらさん | CC BY-SA 4.0 |
|
||||||
|
|
||||||
|
### Samsung 183 L Single Door Refrigerator
|
||||||
|
|
||||||
|
| file | source photograph | photographer | licence |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `samsung-fridge-sd-1.jpg` | [Prestcold packaway refrigerator.jpg](https://commons.wikimedia.org/wiki/File%3APrestcold_packaway_refrigerator.jpg) | Frankie Roberto | CC BY 2.0 |
|
||||||
|
| `samsung-fridge-sd-2.jpg` | [Open refrigerator with food at night.jpg](https://commons.wikimedia.org/wiki/File%3AOpen_refrigerator_with_food_at_night.jpg) | W.carter | CC BY-SA 4.0 |
|
||||||
|
| `samsung-fridge-sd-3.jpg` | [02 Plastic material issue - injection molding discoloration or plastic element yellowing with age.jpg](https://commons.wikimedia.org/wiki/File%3A02_Plastic_material_issue_-_injection_molding_discoloration_or_plastic_element_yellowing_with_age.jpg) | Marek Ślusarczyk (Tupungato) Photo portfolio | CC BY 3.0 |
|
||||||
|
|
||||||
|
### Bosch 8 kg Front Load Washing Machine
|
||||||
|
|
||||||
|
| file | source photograph | photographer | licence |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `bosch-washer-front-1.jpg` | [Fagor washing machine front FF6314.jpg](https://commons.wikimedia.org/wiki/File%3AFagor_washing_machine_front_FF6314.jpg) | Dany kg | Public domain |
|
||||||
|
| `bosch-washer-front-2.jpg` | [A Washing machine IMG 3785.jpg](https://commons.wikimedia.org/wiki/File%3AA_Washing_machine_IMG_3785.jpg) | ברוך שם | CC BY-SA 4.0 |
|
||||||
|
| `bosch-washer-front-3.jpg` | [Waschmaschine.jpg](https://commons.wikimedia.org/wiki/File%3AWaschmaschine.jpg) | Fabian Kuntz | Public domain |
|
||||||
|
|
||||||
|
### IFB 7 kg Top Load Washing Machine
|
||||||
|
|
||||||
|
| file | source photograph | photographer | licence |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `ifb-washer-top-1.jpg` | [Miele Wa75T1El2 H1a.jpg](https://commons.wikimedia.org/wiki/File%3AMiele_Wa75T1El2_H1a.jpg) | Christoph Rieder | CC0 |
|
||||||
|
| `ifb-washer-top-2.jpg` | [Open top-loading washing machine.jpg](https://commons.wikimedia.org/wiki/File%3AOpen_top-loading_washing_machine.jpg) | W.carter | CC BY-SA 4.0 |
|
||||||
|
| `ifb-washer-top-3.jpg` | [Open top-loading washing machine.jpg](https://commons.wikimedia.org/wiki/File%3AOpen_top-loading_washing_machine.jpg) | W.carter | CC BY-SA 4.0 |
|
||||||
|
|
||||||
|
### Voltas 1.5 Ton Inverter Split AC
|
||||||
|
|
||||||
|
| file | source photograph | photographer | licence |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `voltas-ac-15t-1.jpg` | [Sanyo SAP-KRV-93EH.jpg](https://commons.wikimedia.org/wiki/File%3ASanyo_SAP-KRV-93EH.jpg) | Piisamson | CC BY-SA 3.0 |
|
||||||
|
| `voltas-ac-15t-2.jpg` | [Panasonic AIR CONDITIONER INDOOR UNIT CS-C10KJ2 (2).jpg](https://commons.wikimedia.org/wiki/File%3APanasonic_AIR_CONDITIONER_INDOOR_UNIT_CS-C10KJ2_%282%29.jpg) | Dinkun Chen | CC BY-SA 4.0 |
|
||||||
|
|
||||||
|
### Daikin 1 Ton Inverter Split AC
|
||||||
|
|
||||||
|
| file | source photograph | photographer | licence |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `daikin-ac-1t-1.jpg` | [DAIKIN AIR CONDITIONER INDOOR UNIT (12).jpg](https://commons.wikimedia.org/wiki/File%3ADAIKIN_AIR_CONDITIONER_INDOOR_UNIT_%2812%29.jpg) | Dinkun Chen | CC BY-SA 4.0 |
|
||||||
|
| `daikin-ac-1t-2.jpg` | [DAIKIN AIR CONDITIONER INDOOR UNIT (10).jpg](https://commons.wikimedia.org/wiki/File%3ADAIKIN_AIR_CONDITIONER_INDOOR_UNIT_%2810%29.jpg) | Dinkun Chen | CC BY-SA 4.0 |
|
||||||
|
| `daikin-ac-1t-3.jpg` | [DAIKIN AIR CONDITIONER INDOOR UNIT (14).jpg](https://commons.wikimedia.org/wiki/File%3ADAIKIN_AIR_CONDITIONER_INDOOR_UNIT_%2814%29.jpg) | Dinkun Chen | CC BY-SA 4.0 |
|
||||||
|
|
||||||
|
### Panasonic 27 L Convection Microwave
|
||||||
|
|
||||||
|
| file | source photograph | photographer | licence |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `panasonic-microwave-27l-1.jpg` | [Black Panasonic Microwave.jpg](https://commons.wikimedia.org/wiki/File%3ABlack_Panasonic_Microwave.jpg) | Stacyz76 | CC BY 2.0 |
|
||||||
|
| `panasonic-microwave-27l-2.jpg` | [Forno a microonde Panasonic.jpg](https://commons.wikimedia.org/wiki/File%3AForno_a_microonde_Panasonic.jpg) | Air fans | CC BY-SA 4.0 |
|
||||||
|
| `panasonic-microwave-27l-3.jpg` | [LG Microwave oven.jpg](https://commons.wikimedia.org/wiki/File%3ALG_Microwave_oven.jpg) | Santeri Viinamäki | CC BY-SA 4.0 |
|
||||||
|
|
||||||
|
### Sony WH-1000XM5 Wireless Headphones
|
||||||
|
|
||||||
|
| file | source photograph | photographer | licence |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `sony-wh1000xm5-1.jpg` | [Bose QuietComfort 25 Acoustic Noise Cancelling Headphones.jpg](https://commons.wikimedia.org/wiki/File%3ABose_QuietComfort_25_Acoustic_Noise_Cancelling_Headphones.jpg) | Florian Fuchs | CC BY-SA 3.0 |
|
||||||
|
| `sony-wh1000xm5-2.jpg` | [Bose QuietComfort 25 Acoustic Noise Cancelling Headphones with Carry Case.jpg](https://commons.wikimedia.org/wiki/File%3ABose_QuietComfort_25_Acoustic_Noise_Cancelling_Headphones_with_Carry_Case.jpg) | Florian Fuchs | CC BY-SA 3.0 |
|
||||||
|
| `sony-wh1000xm5-3.jpg` | [MiNe-5DII 106-0740U (25899709505).jpg](https://commons.wikimedia.org/wiki/File%3AMiNe-5DII_106-0740U_%2825899709505%29.jpg) | MiNe from Taipei, Taiwan | CC BY 2.0 |
|
||||||
|
|
||||||
|
### boAt Airdopes 141 TWS Earbuds
|
||||||
|
|
||||||
|
| file | source photograph | photographer | licence |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `boat-airdopes-141-1.jpg` | [Technics-EAH-AZ60M2 09.jpg](https://commons.wikimedia.org/wiki/File%3ATechnics-EAH-AZ60M2_09.jpg) | RuinDig/Yuki Uchida | CC BY 4.0 |
|
||||||
|
| `boat-airdopes-141-2.jpg` | [Earbuds with UKCA marking.jpg](https://commons.wikimedia.org/wiki/File%3AEarbuds_with_UKCA_marking.jpg) | 159753 | CC BY-SA 4.0 |
|
||||||
|
|
||||||
|
### Apple Watch SE (2nd Gen) 44mm
|
||||||
|
|
||||||
|
| file | source photograph | photographer | licence |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `apple-watch-se-1.jpg` | [Apple Watch 8 (cropped).jpg](https://commons.wikimedia.org/wiki/File%3AApple_Watch_8_%28cropped%29.jpg) | RT2231 | CC BY-SA 4.0 |
|
||||||
|
| `apple-watch-se-2.jpg` | [Apple Watch 8 with Box (cropped).jpg](https://commons.wikimedia.org/wiki/File%3AApple_Watch_8_with_Box_%28cropped%29.jpg) | iGeeksBlog | CC BY-SA 4.0 |
|
||||||
|
| `apple-watch-se-3.jpg` | [Apple Watch 8.jpg](https://commons.wikimedia.org/wiki/File%3AApple_Watch_8.jpg) | RT2231 | CC BY-SA 4.0 |
|
||||||
|
|
||||||
|
### Noise ColorFit Pro 6 Smartwatch
|
||||||
|
|
||||||
|
| file | source photograph | photographer | licence |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `noise-colorfit-pro-6-1.jpg` | [Altura Estadio Mushuc Runa.jpg](https://commons.wikimedia.org/wiki/File%3AAltura_Estadio_Mushuc_Runa.jpg) | Sdavidortizo | CC BY-SA 4.0 |
|
||||||
|
| `noise-colorfit-pro-6-2.jpg` | [AmazfitBand7.jpg](https://commons.wikimedia.org/wiki/File%3AAmazfitBand7.jpg) | Silbof | CC BY-SA 4.0 |
|
||||||
|
| `noise-colorfit-pro-6-3.jpg` | [Huawei Watch (16928913667).jpg](https://commons.wikimedia.org/wiki/File%3AHuawei_Watch_%2816928913667%29.jpg) | Maurizio Pesce from Milan, Italia | CC BY 2.0 |
|
||||||
|
|
||||||
|
## Per-kind fallbacks (one level up)
|
||||||
|
|
||||||
|
`KIND_IMAGE` still points at these for a product that has no gallery entry —
|
||||||
|
a new kind, or a product the API sends before its pictures exist.
|
||||||
|
|
||||||
|
| file | source photograph | photographer | licence |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `tv.jpg` | [LG 72" Cinema 3D Smart TV](https://commons.wikimedia.org/wiki/File:LG_72%22_Cinema_3D_Smart_TV.jpg) | LG전자 | CC BY 2.0 |
|
||||||
|
| `mobile.jpg` | [Blackview A60](https://commons.wikimedia.org/wiki/File:Blackview_A60.jpg) | Acabashi | CC BY-SA 4.0 |
|
||||||
|
| `laptop.jpg` | [Lenovo G500s](https://commons.wikimedia.org/wiki/File:Lenovo_G500s_laptop-2905.jpg) | Raimond Spekking | CC BY-SA 4.0 |
|
||||||
|
| `tablet.jpg` | [Tablet computer](https://commons.wikimedia.org/wiki/File:Tablet_computer.jpg) | KK IN HK | CC BY-SA 4.0 |
|
||||||
|
| `fridge.jpg` | [Samsung RF60J9030WZ](https://commons.wikimedia.org/wiki/File:Samsung_RF60J9030WZ.jpg) | Dinkun Chen | CC BY-SA 4.0 |
|
||||||
|
| `washer.jpg` | [Beko washing machine](https://commons.wikimedia.org/wiki/File:Beko_Washing_Machine.JPG) | zzpza | CC BY 2.0 |
|
||||||
|
| `ac.jpg` | [Panasonic CS-C10KJ2](https://commons.wikimedia.org/wiki/File:Panasonic_CS-C10KJ2.jpg) | Dinkun Chen | CC BY-SA 4.0 |
|
||||||
|
| `microwave.jpg` | [Microwave oven](https://commons.wikimedia.org/wiki/File:Microwave_oven_flashon.jpg) | Wrightmt | Public domain |
|
||||||
|
| `audio.jpg` | [Bluetooth speaker NBY-18](https://commons.wikimedia.org/wiki/File:Bluetooth_speaker_NBY-18.jpg) | Alexxx1979 | CC BY-SA 4.0 |
|
||||||
|
| `wearable.jpg` | [PineTime](https://commons.wikimedia.org/wiki/File:PineTime.jpg) | Unnerving duck | CC0 |
|
||||||
|
|
||||||
|
## How the files were made
|
||||||
|
|
||||||
|
Harvested from the Commons search API, hand-picked off contact sheets, then
|
||||||
|
fitted — not cropped — inside an 800×600 box with the canvas colour sampled from
|
||||||
|
each photograph's own outer ring. Cropping to a landscape box is what once
|
||||||
|
turned a tall refrigerator into a white rectangle and a phone into four app
|
||||||
|
icons. The picking and padding scripts are dev scratch work and are not part of
|
||||||
|
the app.
|
||||||
BIN
public/sample-products/ac.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/sample-products/audio.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
public/sample-products/fridge.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/sample-products/gallery/apple-watch-se-1.jpg
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
public/sample-products/gallery/apple-watch-se-2.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/sample-products/gallery/apple-watch-se-3.jpg
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
public/sample-products/gallery/boat-airdopes-141-1.jpg
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
public/sample-products/gallery/boat-airdopes-141-2.jpg
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
public/sample-products/gallery/bosch-washer-front-1.jpg
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
public/sample-products/gallery/bosch-washer-front-2.jpg
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
public/sample-products/gallery/bosch-washer-front-3.jpg
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
public/sample-products/gallery/daikin-ac-1t-1.jpg
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
public/sample-products/gallery/daikin-ac-1t-2.jpg
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
public/sample-products/gallery/daikin-ac-1t-3.jpg
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
public/sample-products/gallery/galaxy-s24-fe-1.jpg
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
public/sample-products/gallery/galaxy-s24-fe-2.jpg
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
public/sample-products/gallery/galaxy-s24-fe-3.jpg
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
public/sample-products/gallery/galaxy-tab-s9-fe-1.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
public/sample-products/gallery/galaxy-tab-s9-fe-2.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/sample-products/gallery/galaxy-tab-s9-fe-3.jpg
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
public/sample-products/gallery/hp-pavilion-15-1.jpg
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
public/sample-products/gallery/hp-pavilion-15-2.jpg
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
public/sample-products/gallery/hp-pavilion-15-3.jpg
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
public/sample-products/gallery/ifb-washer-top-1.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
public/sample-products/gallery/ifb-washer-top-2.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/sample-products/gallery/ifb-washer-top-3.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/sample-products/gallery/iphone-16-1.jpg
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
public/sample-products/gallery/iphone-16-2.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
public/sample-products/gallery/iphone-16-3.jpg
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
public/sample-products/gallery/lg-fridge-sbs-1.jpg
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
public/sample-products/gallery/lg-fridge-sbs-2.jpg
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
public/sample-products/gallery/lg-fridge-sbs-3.jpg
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
public/sample-products/gallery/lg-oled-65-1.jpg
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
public/sample-products/gallery/lg-oled-65-2.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
public/sample-products/gallery/macbook-air-m3-1.jpg
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
public/sample-products/gallery/macbook-air-m3-2.jpg
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
public/sample-products/gallery/macbook-air-m3-3.jpg
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
public/sample-products/gallery/noise-colorfit-pro-6-1.jpg
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
public/sample-products/gallery/noise-colorfit-pro-6-2.jpg
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
public/sample-products/gallery/noise-colorfit-pro-6-3.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
public/sample-products/gallery/oneplus-nord-ce4-1.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
public/sample-products/gallery/oneplus-nord-ce4-2.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/sample-products/gallery/oneplus-nord-ce4-3.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
public/sample-products/gallery/panasonic-microwave-27l-1.jpg
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
public/sample-products/gallery/panasonic-microwave-27l-2.jpg
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
public/sample-products/gallery/panasonic-microwave-27l-3.jpg
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
public/sample-products/gallery/redmi-note-14-1.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/sample-products/gallery/redmi-note-14-2.jpg
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
public/sample-products/gallery/redmi-note-14-3.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
public/sample-products/gallery/samsung-fridge-sd-1.jpg
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
public/sample-products/gallery/samsung-fridge-sd-2.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
public/sample-products/gallery/samsung-fridge-sd-3.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/sample-products/gallery/samsung-tv-55-1.jpg
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
public/sample-products/gallery/samsung-tv-55-2.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/sample-products/gallery/samsung-tv-55-3.jpg
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
public/sample-products/gallery/sony-tv-43-1.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
public/sample-products/gallery/sony-tv-43-2.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
public/sample-products/gallery/sony-wh1000xm5-1.jpg
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
public/sample-products/gallery/sony-wh1000xm5-2.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
public/sample-products/gallery/sony-wh1000xm5-3.jpg
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
public/sample-products/gallery/voltas-ac-15t-1.jpg
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
public/sample-products/gallery/voltas-ac-15t-2.jpg
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
public/sample-products/gallery/whirlpool-fridge-dd-1.jpg
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
public/sample-products/gallery/whirlpool-fridge-dd-2.jpg
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
public/sample-products/gallery/whirlpool-fridge-dd-3.jpg
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
public/sample-products/laptop.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
public/sample-products/microwave.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/sample-products/mobile.jpg
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
public/sample-products/tablet.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
public/sample-products/tv.jpg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/sample-products/washer.jpg
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
public/sample-products/wearable.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
90
src/app/(workspace)/products/page.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {VStack} from '@astryxdesign/core/Layout';
|
||||||
|
import {Grid} from '@astryxdesign/core/Grid';
|
||||||
|
import {PageHeader} from '@/shared/components/primitives/PageHeader';
|
||||||
|
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
|
||||||
|
import {MetricCard} from '@/shared/components/patterns/MetricCard';
|
||||||
|
import {SkeletonMetricGrid} from '@/shared/components/patterns/LoadingState';
|
||||||
|
import {ProductCatalog} from '@/features/products/components/ProductCatalog';
|
||||||
|
import {useProducts} from '@/features/products/hooks/useProducts';
|
||||||
|
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
|
||||||
|
import {SampleScope} from '@/shared/mocks/SampleScope';
|
||||||
|
import {SampleNotice} from '@/shared/mocks/SampleNotice';
|
||||||
|
import {ICONS} from '@/shared/utils/icons';
|
||||||
|
import {formatCompact, formatInrCompact} from '@/shared/utils/format';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The product catalogue: what is sold, how much of it moved in the period,
|
||||||
|
* and what is running low.
|
||||||
|
*
|
||||||
|
* SAMPLE throughout — the platform has no product or inventory endpoint yet.
|
||||||
|
* The values live in shared/mocks/sampleData.ts (buildProductCatalog).
|
||||||
|
*/
|
||||||
|
export default function ProductsPage() {
|
||||||
|
const products = useProducts();
|
||||||
|
const scopeLabel = useScopeLabel();
|
||||||
|
const rows = products.data;
|
||||||
|
|
||||||
|
const units = rows?.reduce((a, p) => a + p.units, 0) ?? 0;
|
||||||
|
const revenue = rows?.reduce((a, p) => a + p.revenueInr, 0) ?? 0;
|
||||||
|
const lowStock = rows?.filter((p) => p.stockStatus !== 'in_stock').length ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack gap={5}>
|
||||||
|
<PageHeader
|
||||||
|
title="Products"
|
||||||
|
description={`Catalogue, sales and stock across ${scopeLabel}.`}
|
||||||
|
controls={<ScopeControls />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SampleScope>
|
||||||
|
<VStack gap={5}>
|
||||||
|
<SampleNotice>
|
||||||
|
The platform does not record a product catalogue or stock levels yet.
|
||||||
|
The products, prices and stock below are examples.
|
||||||
|
</SampleNotice>
|
||||||
|
|
||||||
|
{rows ? (
|
||||||
|
<Grid columns={{minWidth: 220, max: 4, repeat: 'fit'}} gap={4}>
|
||||||
|
<MetricCard
|
||||||
|
label="Products"
|
||||||
|
value={rows.length}
|
||||||
|
format={formatCompact}
|
||||||
|
icon={ICONS.products}
|
||||||
|
caption="In the catalogue"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Units sold"
|
||||||
|
value={units}
|
||||||
|
format={formatCompact}
|
||||||
|
icon={ICONS.purchases}
|
||||||
|
accent="cool"
|
||||||
|
caption="In the selected period"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Product revenue"
|
||||||
|
value={revenue}
|
||||||
|
format={formatInrCompact}
|
||||||
|
icon={ICONS.revenue}
|
||||||
|
accent="warm"
|
||||||
|
caption="In the selected period"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Low or out of stock"
|
||||||
|
value={lowStock}
|
||||||
|
format={formatCompact}
|
||||||
|
icon={ICONS.alert}
|
||||||
|
caption="Needs reordering"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
) : (
|
||||||
|
<SkeletonMetricGrid columns={4} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ProductCatalog products={products} />
|
||||||
|
</VStack>
|
||||||
|
</SampleScope>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,23 +1,52 @@
|
|||||||
import type {NextRequest} from 'next/server';
|
import type {NextRequest} from 'next/server';
|
||||||
import {reportsApi} from '@/services/api/reportsApi';
|
import {reportsApi} from '@/services/api/reportsApi';
|
||||||
|
import {salesApi} from '@/services/api/salesApi';
|
||||||
|
import {platformSales} from '@/services/api/saleMapper';
|
||||||
import {DEFAULT_TZ, toSiteParam} from '@/services/api/range';
|
import {DEFAULT_TZ, toSiteParam} from '@/services/api/range';
|
||||||
import {serveUpstream} from '@/shared/services/bff';
|
import {serveUpstream} from '@/shared/services/bff';
|
||||||
|
import {UpstreamError} from '@/services/api/apiClient';
|
||||||
import {toTodaySummary} from '@/features/floor/services/mapSummary';
|
import {toTodaySummary} from '@/features/floor/services/mapSummary';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/** The platform's cap on one sales page. */
|
||||||
|
const SALES_LIMIT = 200;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/dashboard/summary — today, for the selected shop.
|
* GET /api/dashboard/summary — today, for the selected shop.
|
||||||
*
|
*
|
||||||
* Deliberately NOT scoped by the range picker: "today" is the business day in
|
* Deliberately NOT scoped by the range picker: "today" is the business day in
|
||||||
* the shop's zone, which is the whole value of this read. The platform
|
* the shop's zone, which is the whole value of this read. The platform
|
||||||
* computes that window itself when none is sent.
|
* computes that window itself when none is sent.
|
||||||
|
*
|
||||||
|
* Two reads, because the platform splits them: footfall from its summary, and
|
||||||
|
* takings from the sales list for the SAME date the summary reports (`to` is
|
||||||
|
* inclusive upstream), so the two halves of the card describe one day. Past
|
||||||
|
* 200 sales in a day the takings are the newest 200's — the list has no cursor.
|
||||||
*/
|
*/
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
return serveUpstream(
|
return serveUpstream(
|
||||||
req,
|
req,
|
||||||
(token, query) =>
|
async (token, query) => {
|
||||||
reportsApi.todaySummary(token, DEFAULT_TZ, toSiteParam(query.storeId)),
|
const site = toSiteParam(query.storeId);
|
||||||
toTodaySummary,
|
const summary = await reportsApi.todaySummary(token, DEFAULT_TZ, site);
|
||||||
|
let sales: ReturnType<typeof platformSales> = [];
|
||||||
|
try {
|
||||||
|
sales = platformSales(
|
||||||
|
await salesApi.list(token, {
|
||||||
|
site,
|
||||||
|
tz: summary.timezone || DEFAULT_TZ,
|
||||||
|
from: summary.date,
|
||||||
|
to: summary.date,
|
||||||
|
limit: SALES_LIMIT,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// A platform without /api/sales still has footfall worth showing.
|
||||||
|
if (!(err instanceof UpstreamError && err.isRouteMissing)) throw err;
|
||||||
|
}
|
||||||
|
return {summary, sales};
|
||||||
|
},
|
||||||
|
({summary, sales}) => toTodaySummary(summary, sales),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type {NextRequest} from 'next/server';
|
|||||||
import {salesApi} from '@/services/api/salesApi';
|
import {salesApi} from '@/services/api/salesApi';
|
||||||
import {withUpstream} from '@/features/auth/services/upstreamSession';
|
import {withUpstream} from '@/features/auth/services/upstreamSession';
|
||||||
import {failureFrom} from '@/shared/services/bff';
|
import {failureFrom} from '@/shared/services/bff';
|
||||||
import {toSale} from '@/app/api/sales/route';
|
import {fromPlatformSale} from '@/services/api/saleMapper';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ export async function GET(
|
|||||||
try {
|
try {
|
||||||
const sale = await withUpstream((token) => salesApi.byId(token, id));
|
const sale = await withUpstream((token) => salesApi.byId(token, id));
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{data: toSale(sale)},
|
{data: fromPlatformSale(sale)},
|
||||||
{headers: {'cache-control': 'no-store'}},
|
{headers: {'cache-control': 'no-store'}},
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import {toReportWindow, toSiteParam} from '@/services/api/range';
|
|||||||
import {serveUpstream, failureFrom} from '@/shared/services/bff';
|
import {serveUpstream, failureFrom} from '@/shared/services/bff';
|
||||||
import {withUpstream} from '@/features/auth/services/upstreamSession';
|
import {withUpstream} from '@/features/auth/services/upstreamSession';
|
||||||
import {UpstreamError} from '@/services/api/apiClient';
|
import {UpstreamError} from '@/services/api/apiClient';
|
||||||
import type {ApiSale} from '@/services/api/types';
|
import {fromPlatformSale, platformSales} from '@/services/api/saleMapper';
|
||||||
|
import type {ApiPlatformSale, ApiSale} from '@/services/api/types';
|
||||||
import type {Sale} from '@/features/commerce/types/sale';
|
import type {Sale} from '@/features/commerce/types/sale';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -42,37 +43,23 @@ export function toSale(s: ApiSale): Sale {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const PAGE_SIZE = 200;
|
/** The platform's cap on one sales request. */
|
||||||
/** 2,000 sales. A window past this answers with the newest 2,000. */
|
const SALES_LIMIT = 200;
|
||||||
const MAX_PAGES = 10;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every sale in the window, not just the first page.
|
* The sales in the window, newest first.
|
||||||
*
|
*
|
||||||
* The Sales screen totals these (Today, Overall, the heatmap), so a single
|
* The platform returns a bare array capped at 200 and has NO paging: `offset`
|
||||||
* 50-row page would silently under-report any shop with a busy month. The
|
* is ignored and there is no cursor, because `purchases` has no monotonic
|
||||||
* platform pages by `limit`/`offset` (limit capped at 200).
|
* column to page on (API.md). So this is one read, not a loop - a loop would
|
||||||
*
|
* only fetch page one again. A window holding more than 200 sales totals the
|
||||||
* Deduplicated by id, and it stops when a page adds nothing new: a platform
|
* newest 200; narrow the range for a busy shop.
|
||||||
* build that ignores `offset` would otherwise hand back page one ten times and
|
|
||||||
* the totals would be ten times too high.
|
|
||||||
*/
|
*/
|
||||||
async function listWindow(
|
async function listWindow(
|
||||||
token: string,
|
token: string,
|
||||||
filter: {site?: string; from: string; to: string},
|
filter: {site?: string; from: string; to: string},
|
||||||
): Promise<{items: ApiSale[]}> {
|
): Promise<ApiPlatformSale[]> {
|
||||||
const seen = new Map<string, ApiSale>();
|
return platformSales(await salesApi.list(token, {...filter, limit: SALES_LIMIT}));
|
||||||
for (let page = 0; page < MAX_PAGES; page++) {
|
|
||||||
const {items = []} = await salesApi.list(token, {
|
|
||||||
...filter,
|
|
||||||
limit: PAGE_SIZE,
|
|
||||||
offset: page * PAGE_SIZE,
|
|
||||||
});
|
|
||||||
const before = seen.size;
|
|
||||||
for (const s of items) seen.set(s.id, s);
|
|
||||||
if (items.length < PAGE_SIZE || seen.size === before) break;
|
|
||||||
}
|
|
||||||
return {items: [...seen.values()]};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
@@ -92,12 +79,12 @@ export async function GET(req: NextRequest) {
|
|||||||
// rather than failing. Only a missing ROUTE — a 404 for a shop that
|
// rather than failing. Only a missing ROUTE — a 404 for a shop that
|
||||||
// does not exist is a real answer and still surfaces.
|
// does not exist is a real answer and still surfaces.
|
||||||
if (err instanceof UpstreamError && err.isRouteMissing) {
|
if (err instanceof UpstreamError && err.isRouteMissing) {
|
||||||
return {items: []};
|
return [];
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
(page) => (page.items ?? []).map(toSale),
|
(rows) => rows.map(fromPlatformSale),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,12 +72,8 @@ export function LoginSplit({
|
|||||||
<LoginHeroPanel />
|
<LoginHeroPanel />
|
||||||
|
|
||||||
<div className="w-full md:w-1/2 p-8 sm:p-10 lg:px-12 lg:py-10 flex flex-col justify-center">
|
<div className="w-full md:w-1/2 p-8 sm:p-10 lg:px-12 lg:py-10 flex flex-col justify-center">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center">
|
||||||
<BrandLogo height={34} priority />
|
<BrandLogo height={34} priority />
|
||||||
<div className="h-4 w-px bg-[#E2DFD4]" />
|
|
||||||
<span className="text-[11px] font-semibold tracking-[0.14em] text-[#8A8577] uppercase">
|
|
||||||
Merchant OS
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-9 lg:mt-10">
|
<div className="mt-9 lg:mt-10">
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ export function TodaySummaryCard({resource}: {resource: Resource<TodaySummary>})
|
|||||||
value={today.currency === 'INR' ? formatInr(today.revenue) : `${today.revenue} ${today.currency}`}
|
value={today.currency === 'INR' ? formatInr(today.revenue) : `${today.revenue} ${today.currency}`}
|
||||||
/>
|
/>
|
||||||
<StatPair label="Sales" value={formatCount(today.salesCount)} />
|
<StatPair label="Sales" value={formatCount(today.salesCount)} />
|
||||||
<StatPair label="Enquiries" value={formatCount(today.enquiriesCount)} />
|
<StatPair label="Enquiries" value={countOrDash(today.enquiriesCount)} />
|
||||||
<StatPair label="On the floor now" value={formatCount(today.onFloorNow)} />
|
<StatPair label="On the floor now" value={countOrDash(today.onFloorNow)} />
|
||||||
<StatPair label="Visitors today" value={formatCount(today.visitors)} align="end" />
|
<StatPair label="Visitors today" value={formatCount(today.visitors)} align="end" />
|
||||||
</StatRow>
|
</StatRow>
|
||||||
)}
|
)}
|
||||||
@@ -37,3 +37,8 @@ export function TodaySummaryCard({resource}: {resource: Resource<TodaySummary>})
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A figure the platform does not supply yet reads "—", not a false 0. */
|
||||||
|
function countOrDash(v: number | null): string {
|
||||||
|
return v == null ? '—' : formatCount(v);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,30 @@
|
|||||||
import type {ApiDashboardSummary} from '@/services/api/types';
|
import type {ApiPlatformDashboardSummary, ApiPlatformSale} from '@/services/api/types';
|
||||||
import type {TodaySummary} from '@/features/floor/types/summary';
|
import type {TodaySummary} from '@/features/floor/types/summary';
|
||||||
|
import {rupeesToPaise} from '@/services/api/saleMapper';
|
||||||
|
|
||||||
/** Paise to rupees here, once, so no component ever divides by 100 itself. */
|
/**
|
||||||
export function toTodaySummary(s: ApiDashboardSummary): TodaySummary {
|
* The platform's summary (footfall) plus today's sales (money) → the card.
|
||||||
|
*
|
||||||
|
* `/api/dashboard/summary` carries no revenue or sales, so those are counted
|
||||||
|
* from the same day's `/api/sales` rows. Summed in integer paise so a day of
|
||||||
|
* ₹0.10s cannot drift, then turned into rupees here, once, so no component
|
||||||
|
* ever divides by 100 itself.
|
||||||
|
*
|
||||||
|
* Enquiries and "on the floor now" have no source on the platform yet and are
|
||||||
|
* `null` — the card shows "—" rather than a 0 that reads as a real answer.
|
||||||
|
*/
|
||||||
|
export function toTodaySummary(
|
||||||
|
s: ApiPlatformDashboardSummary,
|
||||||
|
sales: ApiPlatformSale[],
|
||||||
|
): TodaySummary {
|
||||||
|
const paise = sales.reduce((sum, sale) => sum + rupeesToPaise(sale.amount), 0);
|
||||||
return {
|
return {
|
||||||
date: s.date,
|
date: s.date,
|
||||||
revenue: (s.total_revenue_paise ?? 0) / 100,
|
revenue: paise / 100,
|
||||||
currency: s.currency || 'INR',
|
currency: sales[0]?.currency || 'INR',
|
||||||
salesCount: s.sales_count,
|
salesCount: sales.length,
|
||||||
enquiriesCount: s.enquiries_count,
|
enquiriesCount: null,
|
||||||
onFloorNow: s.active_customer_count,
|
onFloorNow: null,
|
||||||
visitors: s.visitors_count,
|
visitors: s.visitors ?? 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ export interface TodaySummary {
|
|||||||
revenue: number;
|
revenue: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
salesCount: number;
|
salesCount: number;
|
||||||
enquiriesCount: number;
|
/** `null` until the platform records enquiries — shown as "—", never 0. */
|
||||||
/** Waiting or being served right now. */
|
enquiriesCount: number | null;
|
||||||
onFloorNow: number;
|
/** Waiting or being served right now. `null` while the platform has no
|
||||||
|
* source for it. */
|
||||||
|
onFloorNow: number | null;
|
||||||
visitors: number;
|
visitors: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import {BrandLogo} from '@/shared/components/brand/BrandLogo';
|
|||||||
export function ChatHeader({onClose}: {onClose?: () => void}) {
|
export function ChatHeader({onClose}: {onClose?: () => void}) {
|
||||||
const {
|
const {
|
||||||
newChat,
|
newChat,
|
||||||
reload,
|
|
||||||
isHistoryOpen,
|
isHistoryOpen,
|
||||||
setHistoryOpen,
|
setHistoryOpen,
|
||||||
panelMode,
|
panelMode,
|
||||||
@@ -67,14 +66,6 @@ export function ChatHeader({onClose}: {onClose?: () => void}) {
|
|||||||
icon={<Icon icon={isFullscreen ? ICONS.minimize : ICONS.fullscreen} size="sm" />}
|
icon={<Icon icon={isFullscreen ? ICONS.minimize : ICONS.fullscreen} size="sm" />}
|
||||||
onClick={toggleFullscreen}
|
onClick={toggleFullscreen}
|
||||||
/>
|
/>
|
||||||
<IconButton
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
label="Reload"
|
|
||||||
tooltip="Reload"
|
|
||||||
icon={<Icon icon={ICONS.reload} size="sm" />}
|
|
||||||
onClick={reload}
|
|
||||||
/>
|
|
||||||
<IconButton
|
<IconButton
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {useEffect, useRef} from 'react';
|
import {useEffect, useMemo, useRef, useState} from 'react';
|
||||||
import {StackItem, VStack} from '@astryxdesign/core/Layout';
|
import {StackItem, VStack} from '@astryxdesign/core/Layout';
|
||||||
import {useLoyalyAi} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
|
import {useLoyalyAi} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
|
||||||
import {Composer} from './Composer';
|
import {Composer} from './Composer';
|
||||||
import {EmptyState} from './EmptyState';
|
import {EmptyState} from './EmptyState';
|
||||||
import {MessageList} from './MessageList';
|
import {MessageList} from './MessageList';
|
||||||
|
import {SuggestionChips} from './SuggestionChips';
|
||||||
|
import {SUGGESTIONS} from '@/features/loyaly-ai/utils/suggestions';
|
||||||
|
|
||||||
/** Readable line length for replies, and a matching composer. */
|
/** Readable line length for replies, and a matching composer. */
|
||||||
const CONTENT_MAX = 768;
|
const CONTENT_MAX = 768;
|
||||||
@@ -46,6 +48,18 @@ const FOLLOW_SLACK_PX = 160;
|
|||||||
export function Conversation() {
|
export function Conversation() {
|
||||||
const {conversation, draft, setDraft, send, stop, isStreaming} = useLoyalyAi();
|
const {conversation, draft, setDraft, send, stop, isStreaming} = useLoyalyAi();
|
||||||
const hasMessages = conversation.messages.length > 0;
|
const hasMessages = conversation.messages.length > 0;
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
// Chips appear under the landing composer once it is focused or typed in.
|
||||||
|
const showChips = isFocused || draft.trim().length > 0;
|
||||||
|
// Follow-ups after an answer: the suggestions not asked yet in this chat.
|
||||||
|
const followUps = useMemo(() => {
|
||||||
|
const asked = new Set(
|
||||||
|
conversation.messages
|
||||||
|
.filter((m) => m.role === 'user')
|
||||||
|
.flatMap((m) => m.parts.map((p) => ('text' in p ? String(p.text).trim() : ''))),
|
||||||
|
);
|
||||||
|
return SUGGESTIONS.filter((s) => !asked.has(s.prompt));
|
||||||
|
}, [conversation.messages]);
|
||||||
const scrollRef = useRef<HTMLElement>(null);
|
const scrollRef = useRef<HTMLElement>(null);
|
||||||
const last = conversation.messages.at(-1);
|
const last = conversation.messages.at(-1);
|
||||||
// Changes on every new message AND on every streamed chunk.
|
// Changes on every new message AND on every streamed chunk.
|
||||||
@@ -63,32 +77,55 @@ export function Conversation() {
|
|||||||
}
|
}
|
||||||
}, [tail, lastRole]);
|
}, [tail, lastRole]);
|
||||||
|
|
||||||
|
// Before the first message: the landing view — mark, question, and the
|
||||||
|
// composer right beneath them, centred (the Loyaly AI panel design). After
|
||||||
|
// it: the conversation, with the composer pinned to the bottom.
|
||||||
|
if (!hasMessages) {
|
||||||
|
return (
|
||||||
|
<StackItem size="fill" className="bg-background">
|
||||||
|
<VStack height="100%" vAlign="center" hAlign="center" paddingInline={4} paddingBlock={5}>
|
||||||
|
<VStack width="100%" maxWidth={576} hAlign="center" className="-mt-20 sm:-mt-28">
|
||||||
|
<EmptyState />
|
||||||
|
<VStack width="100%" className="mt-9">
|
||||||
|
<Composer
|
||||||
|
value={draft}
|
||||||
|
onChange={setDraft}
|
||||||
|
onSubmit={send}
|
||||||
|
onStop={stop}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
onFocus={() => setIsFocused(true)}
|
||||||
|
// Delayed, so a chip click lands before the chips hide.
|
||||||
|
onBlur={() => setTimeout(() => setIsFocused(false), 150)}
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
{showChips ? (
|
||||||
|
<VStack width="100%" className="mt-7">
|
||||||
|
<SuggestionChips onPick={send} />
|
||||||
|
</VStack>
|
||||||
|
) : null}
|
||||||
|
</VStack>
|
||||||
|
</VStack>
|
||||||
|
</StackItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StackItem size="fill" className="bg-background">
|
<StackItem size="fill" className="bg-background">
|
||||||
<VStack height="100%">
|
<VStack height="100%">
|
||||||
<StackItem size="fill" isScrollable ref={scrollRef}>
|
<StackItem size="fill" isScrollable ref={scrollRef}>
|
||||||
<VStack
|
<VStack minHeight="100%" vAlign="start" paddingInline={4} paddingBlock={5}>
|
||||||
minHeight="100%"
|
|
||||||
vAlign={hasMessages ? 'start' : 'center'}
|
|
||||||
paddingInline={4}
|
|
||||||
paddingBlock={5}
|
|
||||||
>
|
|
||||||
<VStack gap={4} width="100%" maxWidth={CONTENT_MAX} className="mx-auto">
|
<VStack gap={4} width="100%" maxWidth={CONTENT_MAX} className="mx-auto">
|
||||||
{hasMessages ? (
|
<MessageList messages={conversation.messages} isStreaming={isStreaming} />
|
||||||
<MessageList messages={conversation.messages} isStreaming={isStreaming} />
|
|
||||||
) : (
|
|
||||||
<EmptyState onPick={send} />
|
|
||||||
)}
|
|
||||||
</VStack>
|
</VStack>
|
||||||
</VStack>
|
</VStack>
|
||||||
</StackItem>
|
</StackItem>
|
||||||
|
|
||||||
<VStack
|
<VStack
|
||||||
paddingInline={3}
|
paddingInline={3}
|
||||||
paddingBlock={3}
|
paddingBlock={3}
|
||||||
className="shrink-0 border-t border-border bg-surface"
|
className="shrink-0 border-t border-border bg-surface"
|
||||||
>
|
>
|
||||||
<VStack width="100%" maxWidth={CONTENT_MAX} className="mx-auto">
|
<VStack width="100%" maxWidth={CONTENT_MAX} gap={3} className="mx-auto">
|
||||||
|
{!isStreaming ? <SuggestionChips onPick={send} items={followUps} align="start" /> : null}
|
||||||
<Composer
|
<Composer
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={setDraft}
|
onChange={setDraft}
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {HStack, VStack} from '@astryxdesign/core/Layout';
|
import {VStack} from '@astryxdesign/core/Layout';
|
||||||
import {Heading, Text} from '@astryxdesign/core/Text';
|
import {Heading, Text} from '@astryxdesign/core/Text';
|
||||||
import {Icon} from '@astryxdesign/core/Icon';
|
|
||||||
import {Token} from '@astryxdesign/core/Token';
|
|
||||||
import {ClickableCard} from '@astryxdesign/core/ClickableCard';
|
|
||||||
import {BrandMark} from '@/shared/components/brand/BrandLogo';
|
import {BrandMark} from '@/shared/components/brand/BrandLogo';
|
||||||
import {ICONS} from '@/shared/utils/icons';
|
import {ICONS} from '@/shared/utils/icons';
|
||||||
import {ACCENT} from '@/shared/utils/accent';
|
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
|
||||||
import {SUGGESTIONS} from '@/features/loyaly-ai/utils/suggestions';
|
|
||||||
import {SampleTag} from '@/shared/mocks/SampleTag';
|
|
||||||
import {SAMPLE_AI_INSIGHTS, SAMPLE_AI_PULSE} from '@/shared/mocks/sampleData';
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kept for shared/mocks/sampleData.ts, which still types its AI insight
|
||||||
|
* samples with it. The empty state no longer renders insight cards.
|
||||||
|
*/
|
||||||
export interface InsightCardItem {
|
export interface InsightCardItem {
|
||||||
id: string;
|
id: string;
|
||||||
tag: string;
|
tag: string;
|
||||||
@@ -23,124 +21,27 @@ export interface InsightCardItem {
|
|||||||
icon: keyof typeof ICONS;
|
icon: keyof typeof ICONS;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SAMPLE_NOTE = 'Example figures — the assistant has no live store feed yet';
|
|
||||||
|
|
||||||
/** Small caps label above a group — "ACTIVE INSIGHTS". */
|
|
||||||
function GroupLabel({icon, children}: {icon: keyof typeof ICONS; children: string}) {
|
|
||||||
return (
|
|
||||||
<HStack gap={1.5} vAlign="center">
|
|
||||||
<Icon icon={ICONS[icon]} size="xsm" color="secondary" />
|
|
||||||
<Text size="xsm" weight="semibold" color="secondary" className="uppercase tracking-wider">
|
|
||||||
{children}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The assistant's landing: greeting, store pulse, insight cards, quick
|
* The Loyaly AI landing view: the heart mark, one question, and the context it
|
||||||
* queries. The composer is NOT here — Conversation docks it below the scroll
|
* answers in. The composer sits directly below it (see Conversation), exactly
|
||||||
* region so it sits in the same place on every screen.
|
* as the Loyaly AI panel in the merchant console.
|
||||||
*
|
|
||||||
* Built from design-system components end to end so it reflows by itself:
|
|
||||||
* cards stretch to the panel, the pulse strip and the chip row wrap on a
|
|
||||||
* narrow phone, and nothing carries a fixed pixel size or a raw colour. The
|
|
||||||
* two brand accents come only through ACCENT (see CLAUDE.md).
|
|
||||||
*
|
|
||||||
* The pulse figures and the insight cards are SAMPLE (shared/mocks/
|
|
||||||
* sampleData.ts), and say so — they used to be labelled "Live" and
|
|
||||||
* "Real-time" while being constants.
|
|
||||||
*/
|
*/
|
||||||
export function EmptyState({onPick}: {onPick?: (prompt: string) => void}) {
|
export function EmptyState() {
|
||||||
|
const scopeLabel = useScopeLabel();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack gap={6} width="100%">
|
<VStack gap={0} hAlign="center" width="100%" maxWidth={576} className="mx-auto text-center">
|
||||||
<VStack gap={2} hAlign="center">
|
<VStack hAlign="center" className="mb-6">
|
||||||
<BrandMark size={48} priority />
|
<BrandMark size={56} priority />
|
||||||
<Heading level={2} justify="center">
|
</VStack>
|
||||||
Ready to optimize your stores?
|
<VStack hAlign="center" className="mb-4">
|
||||||
|
<Heading level={2} justify="center" className="text-xl sm:text-2xl font-semibold">
|
||||||
|
Ask anything about your business.
|
||||||
</Heading>
|
</Heading>
|
||||||
<Text size="sm" color="secondary" justify="center">
|
|
||||||
Your proactive AI copilot for smarter retail decisions and instant analytics.
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<HStack
|
|
||||||
gap={3}
|
|
||||||
vAlign="center"
|
|
||||||
hAlign="between"
|
|
||||||
wrap="wrap"
|
|
||||||
paddingInline={4}
|
|
||||||
paddingBlock={2}
|
|
||||||
className="rounded-full border border-border bg-surface"
|
|
||||||
>
|
|
||||||
<HStack gap={4} wrap="wrap">
|
|
||||||
<Text size="sm" color="secondary">
|
|
||||||
Footfall{' '}
|
|
||||||
<Text type="inherit" weight="semibold" color="primary">
|
|
||||||
+{SAMPLE_AI_PULSE.footfallDeltaPct}%
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" color="secondary">
|
|
||||||
Sync{' '}
|
|
||||||
<Text type="inherit" weight="semibold" color="primary">
|
|
||||||
{SAMPLE_AI_PULSE.syncPct}%
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
<SampleTag show description={SAMPLE_NOTE} />
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
<VStack gap={3}>
|
|
||||||
<HStack hAlign="between" vAlign="center" gap={2}>
|
|
||||||
<GroupLabel icon="insight">Active insights</GroupLabel>
|
|
||||||
<SampleTag show description={SAMPLE_NOTE} />
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
{SAMPLE_AI_INSIGHTS.map((item) => (
|
|
||||||
<ClickableCard
|
|
||||||
key={item.id}
|
|
||||||
label={`${item.title}. ${item.actionText}`}
|
|
||||||
onClick={() => onPick?.(item.prompt)}
|
|
||||||
padding={4}
|
|
||||||
>
|
|
||||||
<HStack gap={3} vAlign="start">
|
|
||||||
<HStack
|
|
||||||
hAlign="center"
|
|
||||||
vAlign="center"
|
|
||||||
className={`size-8 shrink-0 rounded-md ${ACCENT[item.tagColor].chip}`}
|
|
||||||
>
|
|
||||||
<Icon icon={ICONS[item.icon] ?? ICONS.insight} size="sm" color="inherit" />
|
|
||||||
</HStack>
|
|
||||||
<VStack gap={1.5} className="min-w-0 flex-1">
|
|
||||||
<HStack gap={2} hAlign="between" vAlign="start" wrap="wrap">
|
|
||||||
<Text size="sm" weight="semibold">
|
|
||||||
{item.title}
|
|
||||||
</Text>
|
|
||||||
<Token size="sm" label={item.tag} />
|
|
||||||
</HStack>
|
|
||||||
<Text size="sm" color="secondary" className="line-clamp-3">
|
|
||||||
{item.description}
|
|
||||||
</Text>
|
|
||||||
<HStack gap={1} vAlign="center">
|
|
||||||
<Text size="sm" weight="medium">
|
|
||||||
{item.actionText}
|
|
||||||
</Text>
|
|
||||||
<Icon icon={ICONS.arrowRight} size="xsm" />
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
</HStack>
|
|
||||||
</ClickableCard>
|
|
||||||
))}
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<VStack gap={3}>
|
|
||||||
<GroupLabel icon="chat">Quick queries</GroupLabel>
|
|
||||||
<HStack gap={2} wrap="wrap">
|
|
||||||
{SUGGESTIONS.slice(0, 5).map((s) => (
|
|
||||||
<Token key={s.id} label={s.label} onClick={() => onPick?.(s.prompt)} />
|
|
||||||
))}
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
</VStack>
|
||||||
|
<Text type="supporting" justify="center" className="text-sm">
|
||||||
|
Business context · {scopeLabel}
|
||||||
|
</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
39
src/features/loyaly-ai/components/SuggestionChips.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {HStack} from '@astryxdesign/core/Layout';
|
||||||
|
import {SUGGESTIONS} from '@/features/loyaly-ai/utils/suggestions';
|
||||||
|
import type {Suggestion} from '@/features/loyaly-ai/types/chat';
|
||||||
|
|
||||||
|
const CHIP = [
|
||||||
|
'rounded-full border border-border bg-card',
|
||||||
|
'px-3.5 py-2 text-sm text-secondary text-left',
|
||||||
|
'transition-colors duration-150 cursor-pointer',
|
||||||
|
'hover:bg-muted hover:text-primary hover:border-border-strong',
|
||||||
|
'focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2',
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suggested questions as chips — the same chips as the merchant console's
|
||||||
|
* Loyaly AI. `items` narrows them (follow-ups after an answer); by default
|
||||||
|
* every suggestion is offered.
|
||||||
|
*/
|
||||||
|
export function SuggestionChips({
|
||||||
|
onPick,
|
||||||
|
items = SUGGESTIONS,
|
||||||
|
align = 'center',
|
||||||
|
}: {
|
||||||
|
onPick: (prompt: string) => void;
|
||||||
|
items?: Suggestion[];
|
||||||
|
align?: 'center' | 'start';
|
||||||
|
}) {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<HStack gap={2} wrap="wrap" hAlign={align}>
|
||||||
|
{items.map((s) => (
|
||||||
|
<button key={s.id} type="button" className={CHIP} onClick={() => onPick(s.prompt)}>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
483
src/features/products/components/ProductCatalog.tsx
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {useState} from 'react';
|
||||||
|
import {proportional, pixel} from '@astryxdesign/core/Table';
|
||||||
|
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||||
|
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||||
|
import {Grid} from '@astryxdesign/core/Grid';
|
||||||
|
import {ClickableCard} from '@astryxdesign/core/ClickableCard';
|
||||||
|
import {SelectableCard} from '@astryxdesign/core/SelectableCard';
|
||||||
|
import {Icon} from '@astryxdesign/core/Icon';
|
||||||
|
import {Text} from '@astryxdesign/core/Text';
|
||||||
|
import {Badge} from '@astryxdesign/core/Badge';
|
||||||
|
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||||
|
import {Selector} from '@astryxdesign/core/Selector';
|
||||||
|
import {
|
||||||
|
SegmentedControl,
|
||||||
|
SegmentedControlItem,
|
||||||
|
} from '@astryxdesign/core/SegmentedControl';
|
||||||
|
import {PanelCard} from '@/shared/components/patterns/PanelCard';
|
||||||
|
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
|
||||||
|
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||||
|
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||||
|
import {MetricDelta} from '@/shared/components/primitives/MetricDelta';
|
||||||
|
import {ICONS} from '@/shared/utils/icons';
|
||||||
|
import {formatCompact, formatCount, formatInr, formatInrCompact} from '@/shared/utils/format';
|
||||||
|
import type {Resource} from '@/shared/hooks/useResource';
|
||||||
|
import {useHoverIntent, suppressHoverIntent} from '@/shared/hooks/useHoverIntent';
|
||||||
|
import {STOCK} from '@/features/products/utils/productArtwork';
|
||||||
|
import {ProductImage} from '@/features/products/components/ProductImage';
|
||||||
|
import {ProductDetailDialog} from '@/features/products/components/ProductDetailDialog';
|
||||||
|
import {byBrand, type SampleCatalogItem} from '@/shared/mocks/sampleData';
|
||||||
|
|
||||||
|
type View = 'grid' | 'list';
|
||||||
|
type Sort = 'brand' | 'popular' | 'price_asc' | 'price_desc' | 'discount' | 'rating';
|
||||||
|
|
||||||
|
const ALL = 'all';
|
||||||
|
|
||||||
|
const SORTS: {label: string; value: Sort}[] = [
|
||||||
|
{label: 'Brand', value: 'brand'},
|
||||||
|
{label: 'Best selling', value: 'popular'},
|
||||||
|
{label: 'Price: low to high', value: 'price_asc'},
|
||||||
|
{label: 'Price: high to low', value: 'price_desc'},
|
||||||
|
{label: 'Biggest discount', value: 'discount'},
|
||||||
|
{label: 'Customer rating', value: 'rating'},
|
||||||
|
];
|
||||||
|
|
||||||
|
const SORTERS: Record<Sort, (a: SampleCatalogItem, b: SampleCatalogItem) => number> = {
|
||||||
|
// The list arrives brand by brand, so this keeps that order.
|
||||||
|
brand: byBrand,
|
||||||
|
popular: (a, b) => b.revenueInr - a.revenueInr,
|
||||||
|
price_asc: (a, b) => a.priceInr - b.priceInr,
|
||||||
|
price_desc: (a, b) => b.priceInr - a.priceInr,
|
||||||
|
discount: (a, b) => b.discountPct - a.discountPct,
|
||||||
|
rating: (a, b) => b.rating - a.rating,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One brand's entry in the strip: how many it has, and where its prices start. */
|
||||||
|
type BrandTab = {brand: string; count: number; fromInr: number};
|
||||||
|
|
||||||
|
/** Every brand in the current scope, with its count and cheapest price. */
|
||||||
|
function brandTabs(rows: SampleCatalogItem[]): BrandTab[] {
|
||||||
|
const byName = new Map<string, BrandTab>();
|
||||||
|
for (const item of rows) {
|
||||||
|
const tab = byName.get(item.brand);
|
||||||
|
if (!tab) byName.set(item.brand, {brand: item.brand, count: 1, fromInr: item.priceInr});
|
||||||
|
else {
|
||||||
|
tab.count += 1;
|
||||||
|
tab.fromInr = Math.min(tab.fromInr, item.priceInr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Most products first: the brands a merchant stocks deepest are the ones they
|
||||||
|
// reach for, and they should not be the ones scrolled to.
|
||||||
|
return [...byName.values()].sort((a, b) => b.count - a.count || a.brand.localeCompare(b.brand));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The brand strip: pick a brand the way the shop floor does, by name.
|
||||||
|
*
|
||||||
|
* A row of brands across the top rather than headings down the page. Headings
|
||||||
|
* split one dense grid into a column of near-empty shelves — a brand with one
|
||||||
|
* product got a whole row to itself and the page became mostly white space.
|
||||||
|
* Here the grid stays whole and the brand is a filter, with the count and the
|
||||||
|
* starting price on the tab so the choice is informed before it is made.
|
||||||
|
*/
|
||||||
|
function BrandStrip({
|
||||||
|
tabs,
|
||||||
|
total,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
tabs: BrandTab[];
|
||||||
|
total: number;
|
||||||
|
value: string;
|
||||||
|
onChange: (brand: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
// Scrolls sideways rather than wrapping: a wrapped strip of a dozen brands
|
||||||
|
// grows tall enough to push the products themselves below the fold.
|
||||||
|
<HStack gap={2} isScrollable role="group" aria-label="Filter by brand">
|
||||||
|
{/*
|
||||||
|
SelectableCard, not ClickableCard: this picks a filter, it does not
|
||||||
|
navigate, and the selected border comes with the component instead of a
|
||||||
|
hand-rolled ring. Single-select, so one brand is tracked and clicking a
|
||||||
|
card always selects it rather than toggling it off.
|
||||||
|
*/}
|
||||||
|
<SelectableCard
|
||||||
|
label="Show all brands"
|
||||||
|
padding={3}
|
||||||
|
isSelected={value === ALL}
|
||||||
|
onChange={() => onChange(ALL)}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<VStack gap={0.5}>
|
||||||
|
<Text size="xsm" weight="semibold">
|
||||||
|
All brands
|
||||||
|
</Text>
|
||||||
|
<Text size="xsm" color="secondary" className="whitespace-nowrap">
|
||||||
|
{formatCount(total)} products
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</SelectableCard>
|
||||||
|
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<SelectableCard
|
||||||
|
key={tab.brand}
|
||||||
|
label={`Show ${tab.brand} products`}
|
||||||
|
padding={3}
|
||||||
|
isSelected={value === tab.brand}
|
||||||
|
onChange={() => onChange(tab.brand)}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<VStack gap={0.5}>
|
||||||
|
<Text size="xsm" weight="semibold">
|
||||||
|
{tab.brand}
|
||||||
|
</Text>
|
||||||
|
{/*
|
||||||
|
The count and the entry price, because "Dell" alone is not a
|
||||||
|
reason to click: whether Dell is four products from ₹56,860 or one
|
||||||
|
from ₹1.4L is the whole of what the merchant wants to know first.
|
||||||
|
*/}
|
||||||
|
<Text size="xsm" color="secondary" className="whitespace-nowrap">
|
||||||
|
{formatCount(tab.count)} products · from {formatInr(tab.fromInr)}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</SelectableCard>
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Rating({item}: {item: SampleCatalogItem}) {
|
||||||
|
return (
|
||||||
|
<HStack gap={1} vAlign="center">
|
||||||
|
<HStack gap={0.5} vAlign="center" className="rounded-sm bg-muted px-1.5 py-0.5">
|
||||||
|
<Text size="xsm" weight="semibold">
|
||||||
|
{item.rating.toFixed(1)}
|
||||||
|
</Text>
|
||||||
|
<Icon icon={ICONS.rating} size="xsm" color="inherit" className="fill-current" />
|
||||||
|
</HStack>
|
||||||
|
<Text size="xsm" color="secondary">
|
||||||
|
({formatCompact(item.reviews)})
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Price({item}: {item: SampleCatalogItem}) {
|
||||||
|
return (
|
||||||
|
<HStack gap={2} vAlign="end" wrap="wrap">
|
||||||
|
<Text size="lg" weight="bold">
|
||||||
|
{formatInr(item.priceInr)}
|
||||||
|
</Text>
|
||||||
|
{item.discountPct > 0 ? (
|
||||||
|
<>
|
||||||
|
<Text size="sm" color="secondary" hasStrikethrough>
|
||||||
|
{formatInr(item.mrpInr)}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" weight="semibold" color="inherit" className="text-success">
|
||||||
|
{item.discountPct}% off
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long the cursor must sit still on a tile before the detail dialog opens.
|
||||||
|
*
|
||||||
|
* Longer than it sounds, because the clock starts when the cursor *stops*, not
|
||||||
|
* when it arrives: a merchant reading a tile waits eight tenths of a second, a
|
||||||
|
* merchant on the way to the sort control waits forever.
|
||||||
|
*/
|
||||||
|
const HOVER_OPEN_MS = 800;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One listing tile: artwork, brand, title, specs, rating, price and stock.
|
||||||
|
*
|
||||||
|
* Settling on the tile opens the product dialog; clicking opens it at once.
|
||||||
|
*
|
||||||
|
* The dwell is measured from the last pointer movement rather than from entry,
|
||||||
|
* which is what keeps the grid scrollable. On a scroll the cursor never moves —
|
||||||
|
* the tiles travel underneath it — so entry is not evidence that anyone is
|
||||||
|
* pointing at anything, and every card passing the cursor would throw up a
|
||||||
|
* modal. See useHoverIntent for the scroll guard that backs this up.
|
||||||
|
*
|
||||||
|
* The click path stays because hover never fires on a tablet.
|
||||||
|
*/
|
||||||
|
function ProductTile({item, onOpen}: {item: SampleCatalogItem; onOpen: () => void}) {
|
||||||
|
const hover = useHoverIntent({onIntent: onOpen, delayMs: HOVER_OPEN_MS});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ClickableCard
|
||||||
|
padding={4}
|
||||||
|
label={`${item.brand} ${item.name}`}
|
||||||
|
onClick={() => {
|
||||||
|
hover.cancel();
|
||||||
|
onOpen();
|
||||||
|
}}
|
||||||
|
onPointerMove={hover.onPointerMove}
|
||||||
|
onPointerLeave={hover.onPointerLeave}
|
||||||
|
>
|
||||||
|
<VStack gap={3}>
|
||||||
|
<ProductImage item={item} className="rounded-lg bg-muted" />
|
||||||
|
|
||||||
|
<VStack gap={1}>
|
||||||
|
<Text size="xsm" weight="semibold" color="secondary">
|
||||||
|
{item.brand}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" weight="medium" maxLines={2}>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
<Text size="xsm" color="secondary" maxLines={1}>
|
||||||
|
{item.specs}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<Rating item={item} />
|
||||||
|
<Price item={item} />
|
||||||
|
|
||||||
|
<HStack hAlign="between" vAlign="center" gap={2}>
|
||||||
|
<Badge variant={STOCK[item.stockStatus].variant} label={STOCK[item.stockStatus].label} />
|
||||||
|
<Text size="xsm" color="secondary">
|
||||||
|
{formatCount(item.units)} sold
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</ClickableCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLUMNS: TableColumn<SampleCatalogItem>[] = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'Product',
|
||||||
|
width: proportional(3),
|
||||||
|
renderCell: (row) => (
|
||||||
|
<HStack gap={3} vAlign="center">
|
||||||
|
<ProductImage item={row} ratio={1} className="w-10 shrink-0 rounded-md bg-muted" />
|
||||||
|
<VStack gap={0}>
|
||||||
|
<Text size="sm" weight="medium" maxLines={1}>
|
||||||
|
{row.brand} {row.name}
|
||||||
|
</Text>
|
||||||
|
<Text size="xsm" color="secondary">
|
||||||
|
{row.sku} · {row.category}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'priceInr',
|
||||||
|
header: 'Price',
|
||||||
|
width: pixel(120),
|
||||||
|
align: 'end',
|
||||||
|
renderCell: (row) => (
|
||||||
|
<VStack gap={0} hAlign="end">
|
||||||
|
<Text size="sm" weight="medium">
|
||||||
|
{formatInr(row.priceInr)}
|
||||||
|
</Text>
|
||||||
|
{row.discountPct > 0 ? (
|
||||||
|
<Text size="xsm" color="inherit" className="text-success">
|
||||||
|
{row.discountPct}% off
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</VStack>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'units',
|
||||||
|
header: 'Sold',
|
||||||
|
width: pixel(72),
|
||||||
|
align: 'end',
|
||||||
|
renderCell: (row) => <Text size="sm">{formatCount(row.units)}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'revenueInr',
|
||||||
|
header: 'Revenue',
|
||||||
|
width: pixel(100),
|
||||||
|
align: 'end',
|
||||||
|
renderCell: (row) => (
|
||||||
|
<Text size="sm" weight="medium">
|
||||||
|
{formatInrCompact(row.revenueInr)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'deltaPct',
|
||||||
|
header: 'Change',
|
||||||
|
width: pixel(96),
|
||||||
|
align: 'end',
|
||||||
|
renderCell: (row) => <MetricDelta value={row.deltaPct} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'stock',
|
||||||
|
header: 'Stock',
|
||||||
|
width: pixel(72),
|
||||||
|
align: 'end',
|
||||||
|
renderCell: (row) => <Text size="sm">{formatCount(row.stock)}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'stockStatus',
|
||||||
|
header: 'Status',
|
||||||
|
width: pixel(140),
|
||||||
|
align: 'end',
|
||||||
|
renderCell: (row) => (
|
||||||
|
<Badge variant={STOCK[row.stockStatus].variant} label={STOCK[row.stockStatus].label} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The catalogue as a shopping grid (default) or an operations table.
|
||||||
|
* Search, category and sort work on the loaded list — it is small enough that
|
||||||
|
* a round trip per keystroke would only add latency.
|
||||||
|
*/
|
||||||
|
export function ProductCatalog({products}: {products: Resource<SampleCatalogItem[]>}) {
|
||||||
|
const [view, setView] = useState<View>('grid');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [category, setCategory] = useState(ALL);
|
||||||
|
const [brand, setBrand] = useState(ALL);
|
||||||
|
// Brand first, so the grid arrives grouped by maker instead of as the random
|
||||||
|
// jumble a revenue ranking produces — see byBrand.
|
||||||
|
const [sort, setSort] = useState<Sort>('brand');
|
||||||
|
// The clicked product, held by value: the catalogue behind the backdrop keeps
|
||||||
|
// its filters and scroll, so closing returns the merchant exactly where they were.
|
||||||
|
const [opened, setOpened] = useState<SampleCatalogItem | null>(null);
|
||||||
|
|
||||||
|
const categories = [...new Set((products.data ?? []).map((p) => p.category))].sort();
|
||||||
|
// Brands of the products in the chosen category only: a Bosch shows up under
|
||||||
|
// washing machines and nowhere else, so offering it under televisions would
|
||||||
|
// hand the merchant a filter that can only return nothing.
|
||||||
|
const brands = [
|
||||||
|
...new Set(
|
||||||
|
(products.data ?? [])
|
||||||
|
.filter((p) => category === ALL || p.category === category)
|
||||||
|
.map((p) => p.brand),
|
||||||
|
),
|
||||||
|
].sort();
|
||||||
|
const term = search.trim().toLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PanelCard
|
||||||
|
title="All products"
|
||||||
|
subtitle="Price, offers, sales and stock for the selected store and period"
|
||||||
|
actions={
|
||||||
|
<SegmentedControl
|
||||||
|
value={view}
|
||||||
|
onChange={(v) => setView(v as View)}
|
||||||
|
label="Product view"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<SegmentedControlItem value="grid" label="Grid" icon={<Icon icon={ICONS.viewGrid} size="xsm" />} />
|
||||||
|
<SegmentedControlItem value="list" label="List" icon={<Icon icon={ICONS.viewList} size="xsm" />} />
|
||||||
|
</SegmentedControl>
|
||||||
|
}
|
||||||
|
resource={products}
|
||||||
|
loading={<SkeletonRows count={8} height={52} />}
|
||||||
|
>
|
||||||
|
{(rows) => {
|
||||||
|
// A brand the new category does not stock falls back to all of them,
|
||||||
|
// rather than leaving the merchant on an empty grid wondering why.
|
||||||
|
const activeBrand = brands.includes(brand) ? brand : ALL;
|
||||||
|
// Everything the category and the search term allow, brand aside. The
|
||||||
|
// strip's counts and prices are read off this, so a brand card can never
|
||||||
|
// promise eight products that the active filters would hide.
|
||||||
|
const scoped = rows.filter(
|
||||||
|
(p) =>
|
||||||
|
(category === ALL || p.category === category) &&
|
||||||
|
(!term ||
|
||||||
|
p.name.toLowerCase().includes(term) ||
|
||||||
|
p.brand.toLowerCase().includes(term) ||
|
||||||
|
p.sku.toLowerCase().includes(term)),
|
||||||
|
);
|
||||||
|
const visible = scoped
|
||||||
|
.filter((p) => activeBrand === ALL || p.brand === activeBrand)
|
||||||
|
.sort(SORTERS[sort]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack gap={4}>
|
||||||
|
<Grid columns={{minWidth: 200, max: 3, repeat: 'fit'}} gap={3}>
|
||||||
|
<TextInput
|
||||||
|
label="Search products"
|
||||||
|
isLabelHidden
|
||||||
|
value={search}
|
||||||
|
onChange={setSearch}
|
||||||
|
placeholder="Search by product, brand or SKU"
|
||||||
|
hasClear
|
||||||
|
/>
|
||||||
|
<Selector
|
||||||
|
label="Category"
|
||||||
|
isLabelHidden
|
||||||
|
value={category}
|
||||||
|
onChange={(v) => setCategory(String(v))}
|
||||||
|
options={[
|
||||||
|
{label: 'All categories', value: ALL},
|
||||||
|
...categories.map((c) => ({label: c, value: c})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Selector
|
||||||
|
label="Sort by"
|
||||||
|
isLabelHidden
|
||||||
|
value={sort}
|
||||||
|
onChange={(v) => setSort(v as Sort)}
|
||||||
|
options={SORTS.map((s) => ({label: `Sort: ${s.label}`, value: s.value}))}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<BrandStrip
|
||||||
|
tabs={brandTabs(scoped)}
|
||||||
|
total={scoped.length}
|
||||||
|
value={activeBrand}
|
||||||
|
onChange={setBrand}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text size="sm" color="secondary">
|
||||||
|
Showing {visible.length} of {rows.length} products
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{visible.length === 0 ? (
|
||||||
|
<EmptyPanel
|
||||||
|
icon="commerce"
|
||||||
|
title="No product matches"
|
||||||
|
description="Try another product, brand or SKU, or pick a different category."
|
||||||
|
/>
|
||||||
|
) : view === 'grid' ? (
|
||||||
|
<Grid columns={{minWidth: 220, max: 4, repeat: 'fill'}} gap={4}>
|
||||||
|
{visible.map((item) => (
|
||||||
|
<ProductTile key={item.id} item={item} onOpen={() => setOpened(item)} />
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
) : (
|
||||||
|
<ResponsiveTable
|
||||||
|
columns={COLUMNS}
|
||||||
|
data={visible}
|
||||||
|
idKey="id"
|
||||||
|
primaryKey="name"
|
||||||
|
summaryKeys={['priceInr', 'units', 'stockStatus']}
|
||||||
|
density="compact"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</PanelCard>
|
||||||
|
|
||||||
|
{/* Outside PanelCard: the dialog must survive a filter change that empties the grid. */}
|
||||||
|
{opened ? (
|
||||||
|
<ProductDetailDialog
|
||||||
|
item={opened}
|
||||||
|
onClose={() => {
|
||||||
|
// The cursor is still wherever it was when the dialog was dismissed —
|
||||||
|
// most likely on the tile that opened it. Without the pause, the first
|
||||||
|
// twitch of the mouse reopens it and the dialog looks unclosable.
|
||||||
|
suppressHoverIntent();
|
||||||
|
setOpened(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
1164
src/features/products/components/ProductDetailDialog.tsx
Normal file
58
src/features/products/components/ProductImage.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Image from 'next/image';
|
||||||
|
import {AspectRatio} from '@astryxdesign/core/AspectRatio';
|
||||||
|
import {productImage} from '@/features/products/utils/productArtwork';
|
||||||
|
import type {SampleCatalogItem} from '@/shared/mocks/sampleData';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A product's picture, boxed to a fixed ratio.
|
||||||
|
*
|
||||||
|
* One component for the grid tile, the table row and the detail dialog, so a
|
||||||
|
* product looks identical wherever the merchant meets it — the same artwork on
|
||||||
|
* hover as on open is what makes the dialog read as the tile expanding rather
|
||||||
|
* than a second product appearing.
|
||||||
|
*
|
||||||
|
* Contained, not cropped. The files are already padded to 4:3, so at the
|
||||||
|
* default ratio they still fill the box edge to edge — but a caller that asks
|
||||||
|
* for a square thumbnail gets the whole appliance letterboxed instead of its
|
||||||
|
* middle third. Cropping a second time is what turned a refrigerator into a
|
||||||
|
* white rectangle and a phone into four app icons.
|
||||||
|
*
|
||||||
|
* When the product API starts sending `imageUrl`, its host has to be added to
|
||||||
|
* `images.remotePatterns` in next.config.ts or the optimiser will refuse it.
|
||||||
|
*/
|
||||||
|
export function ProductImage({
|
||||||
|
item,
|
||||||
|
src,
|
||||||
|
ratio = 4 / 3,
|
||||||
|
fit = 'contain',
|
||||||
|
zoomOnHover = false,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
item: SampleCatalogItem;
|
||||||
|
/** A specific shot from the product's gallery. Defaults to its first. */
|
||||||
|
src?: string;
|
||||||
|
/** Width over height. Defaults to the 4:3 a listing tile uses. */
|
||||||
|
ratio?: number;
|
||||||
|
/** How the image fills its frame: contained (default) or cropped edge-to-edge. */
|
||||||
|
fit?: 'contain' | 'cover';
|
||||||
|
/** Smooth zoom-in on hover. */
|
||||||
|
zoomOnHover?: boolean;
|
||||||
|
/** Utility classes for the box — radius and background belong to the caller. */
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AspectRatio ratio={ratio} fit={fit} className={`overflow-hidden ${className ?? ''}`}>
|
||||||
|
<Image
|
||||||
|
src={src ?? productImage(item)}
|
||||||
|
alt={`${item.brand} ${item.name}`}
|
||||||
|
width={640}
|
||||||
|
height={480}
|
||||||
|
className={`size-full ${fit === 'cover' ? 'object-cover' : 'object-contain'} ${
|
||||||
|
zoomOnHover ? 'transition-transform duration-500 ease-out group-hover:scale-115 hover:scale-115' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</AspectRatio>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/features/products/hooks/useProducts.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {useScope} from '@/shared/hooks/useScope';
|
||||||
|
import {useSampleResource} from '@/shared/mocks/withSample';
|
||||||
|
import {buildProductCatalog, type SampleCatalogItem} from '@/shared/mocks/sampleData';
|
||||||
|
import type {Resource} from '@/shared/hooks/useResource';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The product catalogue for the selected store and period.
|
||||||
|
*
|
||||||
|
* SAMPLE: the platform has no product endpoint yet. When one lands, add it to
|
||||||
|
* a products repository, swap this body for useResource(), and delete
|
||||||
|
* buildProductCatalog from sampleData.ts.
|
||||||
|
*/
|
||||||
|
export function useProducts(): Resource<SampleCatalogItem[]> {
|
||||||
|
const {storeId, range} = useScope();
|
||||||
|
return useSampleResource(() => buildProductCatalog(storeId, range), [storeId, range]);
|
||||||
|
}
|
||||||
133
src/features/products/utils/productArtwork.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import {ICONS} from '@/shared/utils/icons';
|
||||||
|
import type {IconType} from '@astryxdesign/core/Icon';
|
||||||
|
import type {SampleProductKind, SampleStockStatus} from '@/shared/mocks/sampleData';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stand-in artwork until products carry real images.
|
||||||
|
*
|
||||||
|
* Lives here rather than in ProductCatalog so the detail dialog can show the
|
||||||
|
* same glyph as the tile the merchant clicked — a product that changes
|
||||||
|
* appearance when it opens reads as a different product.
|
||||||
|
*/
|
||||||
|
export const KIND_ICON: Record<SampleProductKind, IconType> = {
|
||||||
|
tv: ICONS.productTv,
|
||||||
|
mobile: ICONS.productMobile,
|
||||||
|
laptop: ICONS.productLaptop,
|
||||||
|
tablet: ICONS.productTablet,
|
||||||
|
fridge: ICONS.productFridge,
|
||||||
|
washer: ICONS.productWasher,
|
||||||
|
ac: ICONS.productAc,
|
||||||
|
microwave: ICONS.productMicrowave,
|
||||||
|
audio: ICONS.productAudio,
|
||||||
|
wearable: ICONS.productWearable,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last-resort photographs, one per kind.
|
||||||
|
*
|
||||||
|
* Only reached by a product with no gallery of its own — a kind picture is the
|
||||||
|
* reason three refrigerators once looked like the same refrigerator, so it is
|
||||||
|
* a floor, not the normal path. See PRODUCT_GALLERY below.
|
||||||
|
*/
|
||||||
|
export const KIND_IMAGE: Record<SampleProductKind, string> = {
|
||||||
|
tv: '/sample-products/tv.jpg',
|
||||||
|
mobile: '/sample-products/mobile.jpg',
|
||||||
|
laptop: '/sample-products/laptop.jpg',
|
||||||
|
tablet: '/sample-products/tablet.jpg',
|
||||||
|
fridge: '/sample-products/fridge.jpg',
|
||||||
|
washer: '/sample-products/washer.jpg',
|
||||||
|
ac: '/sample-products/ac.jpg',
|
||||||
|
microwave: '/sample-products/microwave.jpg',
|
||||||
|
audio: '/sample-products/audio.jpg',
|
||||||
|
wearable: '/sample-products/wearable.jpg',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sample photographs per product, two or three each, until the product API
|
||||||
|
* carries images.
|
||||||
|
*
|
||||||
|
* Keyed by slug, not by kind. Keying by kind is what put one refrigerator
|
||||||
|
* picture on the Samsung single-door, the LG side-by-side and the Whirlpool
|
||||||
|
* alike, and one phone picture on the iPhone, the Galaxy and the OnePlus — the
|
||||||
|
* complaint this map exists to answer.
|
||||||
|
*
|
||||||
|
* Local files rather than a stock-photo host: the catalogue must render the
|
||||||
|
* same on a shop PC behind a restrictive firewall as it does here, and a broken
|
||||||
|
* image is worse than no image. Every file is a freely licensed photograph
|
||||||
|
* padded to 4:3 — see CREDITS.md beside them for the source and licence of
|
||||||
|
* each, which several of them require. They are honest stand-ins, not the named
|
||||||
|
* product: a picture of a front-loading washing machine, not of that Bosch.
|
||||||
|
*
|
||||||
|
* Delete this map when `SampleCatalogItem.imageUrls` arrives populated — see
|
||||||
|
* productImages() for the switchover.
|
||||||
|
*/
|
||||||
|
export const PRODUCT_GALLERY: Record<string, string[]> = {
|
||||||
|
'samsung-tv-55': gallery('samsung-tv-55', 3),
|
||||||
|
'lg-oled-65': gallery('lg-oled-65', 2),
|
||||||
|
'sony-tv-43': gallery('sony-tv-43', 2),
|
||||||
|
'iphone-16': gallery('iphone-16', 3),
|
||||||
|
'galaxy-s24-fe': gallery('galaxy-s24-fe', 3),
|
||||||
|
'oneplus-nord-ce4': gallery('oneplus-nord-ce4', 3),
|
||||||
|
'redmi-note-14': gallery('redmi-note-14', 3),
|
||||||
|
'hp-pavilion-15': gallery('hp-pavilion-15', 3),
|
||||||
|
'macbook-air-m3': gallery('macbook-air-m3', 3),
|
||||||
|
'galaxy-tab-s9-fe': gallery('galaxy-tab-s9-fe', 3),
|
||||||
|
'lg-fridge-sbs': ['/sample-products/gallery/lg-fridge-sbs-1.jpg'],
|
||||||
|
'whirlpool-fridge-dd': gallery('whirlpool-fridge-dd', 3),
|
||||||
|
'samsung-fridge-sd': gallery('samsung-fridge-sd', 3),
|
||||||
|
'bosch-washer-front': gallery('bosch-washer-front', 3),
|
||||||
|
'ifb-washer-top': gallery('ifb-washer-top', 3),
|
||||||
|
'voltas-ac-15t': gallery('voltas-ac-15t', 2),
|
||||||
|
'daikin-ac-1t': gallery('daikin-ac-1t', 3),
|
||||||
|
'panasonic-microwave-27l': gallery('panasonic-microwave-27l', 3),
|
||||||
|
'sony-wh1000xm5': gallery('sony-wh1000xm5', 3),
|
||||||
|
'boat-airdopes-141': gallery('boat-airdopes-141', 2),
|
||||||
|
'apple-watch-se': gallery('apple-watch-se', 3),
|
||||||
|
'noise-colorfit-pro-6': gallery('noise-colorfit-pro-6', 3),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The files on disk are `<slug>-1.jpg` upwards, primary first. */
|
||||||
|
function gallery(slug: string, count: number): string[] {
|
||||||
|
return Array.from({length: count}, (_, i) => `/sample-products/gallery/${slug}-${i + 1}.jpg`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every picture of a product, primary first: the real ones when the API sends
|
||||||
|
* them, otherwise this product's samples, otherwise the one for its kind.
|
||||||
|
*
|
||||||
|
* Both the tile and the dialog's carousel read this, so a product shows the
|
||||||
|
* same opening frame wherever the merchant meets it.
|
||||||
|
*/
|
||||||
|
export function productImages(item: {
|
||||||
|
kind: SampleProductKind;
|
||||||
|
slug: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
imageUrls?: string[];
|
||||||
|
}): string[] {
|
||||||
|
const shots = item.imageUrls?.length ? item.imageUrls : PRODUCT_GALLERY[item.slug];
|
||||||
|
return shots?.length ? shots : [KIND_IMAGE[item.kind]];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single picture to show for a product — its primary shot.
|
||||||
|
*
|
||||||
|
* Every call site goes through here, so the day products arrive with photos
|
||||||
|
* the catalogue, the table and the dialog all pick them up at once.
|
||||||
|
*/
|
||||||
|
export function productImage(item: {
|
||||||
|
kind: SampleProductKind;
|
||||||
|
slug: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
imageUrls?: string[];
|
||||||
|
}): string {
|
||||||
|
return item.imageUrl ?? productImages(item)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STOCK: Record<
|
||||||
|
SampleStockStatus,
|
||||||
|
{label: string; variant: 'success' | 'warning' | 'error'}
|
||||||
|
> = {
|
||||||
|
in_stock: {label: 'In stock', variant: 'success'},
|
||||||
|
low_stock: {label: 'Only a few left', variant: 'warning'},
|
||||||
|
out_of_stock: {label: 'Out of stock', variant: 'error'},
|
||||||
|
};
|
||||||
402
src/features/products/utils/productSpecs.ts
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
import type {SampleCatalogItem} from '@/shared/mocks/sampleData';
|
||||||
|
|
||||||
|
export interface ProductSpecItem {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductSpecSheet {
|
||||||
|
left: ProductSpecItem[];
|
||||||
|
right: ProductSpecItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATALOG_SPECS: Record<string, ProductSpecSheet> = {
|
||||||
|
'hp-pavilion-15': {
|
||||||
|
left: [
|
||||||
|
{label: 'OS', value: 'Windows 11 Home'},
|
||||||
|
{label: 'RAM (GB)', value: '16'},
|
||||||
|
{label: 'Weight (kg)', value: '1.75'},
|
||||||
|
{label: 'Storage (GB)', value: '512'},
|
||||||
|
{label: 'Storage type', value: 'SSD'},
|
||||||
|
],
|
||||||
|
right: [
|
||||||
|
{label: 'Graphics', value: 'Intel Iris Xe Graphics'},
|
||||||
|
{label: 'Processor', value: 'Intel Core i5-1335U'},
|
||||||
|
{label: 'Resolution', value: '1920 × 1080'},
|
||||||
|
{label: 'Display (inch)', value: '15.6'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'macbook-air-m3': {
|
||||||
|
left: [
|
||||||
|
{label: 'OS', value: 'macOS Sequoia'},
|
||||||
|
{label: 'RAM (GB)', value: '8 Unified'},
|
||||||
|
{label: 'Weight (kg)', value: '1.24'},
|
||||||
|
{label: 'Storage (GB)', value: '256'},
|
||||||
|
{label: 'Storage type', value: 'SSD'},
|
||||||
|
],
|
||||||
|
right: [
|
||||||
|
{label: 'Processor', value: 'Apple M3 (8-core CPU)'},
|
||||||
|
{label: 'Graphics', value: 'Apple 10-core GPU'},
|
||||||
|
{label: 'Resolution', value: '2560 × 1664'},
|
||||||
|
{label: 'Display (inch)', value: '13.6 Liquid Retina'},
|
||||||
|
{label: 'Battery', value: 'Up to 18 hours'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'iphone-16': {
|
||||||
|
left: [
|
||||||
|
{label: 'OS', value: 'iOS 18'},
|
||||||
|
{label: 'Storage (GB)', value: '128'},
|
||||||
|
{label: 'RAM (GB)', value: '8'},
|
||||||
|
{label: 'Weight (g)', value: '170'},
|
||||||
|
{label: 'Battery', value: '3,561 mAh (MagSafe)'},
|
||||||
|
],
|
||||||
|
right: [
|
||||||
|
{label: 'Processor', value: 'Apple A18 Bionic'},
|
||||||
|
{label: 'Graphics', value: 'Apple 5-core GPU'},
|
||||||
|
{label: 'Display (inch)', value: '6.1'},
|
||||||
|
{label: 'Resolution', value: '2556 × 1179 Super Retina'},
|
||||||
|
{label: 'Camera', value: '48 MP + 12 MP Ultra Wide'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'galaxy-s24-fe': {
|
||||||
|
left: [
|
||||||
|
{label: 'OS', value: 'Android 14 (One UI)'},
|
||||||
|
{label: 'RAM (GB)', value: '8'},
|
||||||
|
{label: 'Storage (GB)', value: '256'},
|
||||||
|
{label: 'Weight (g)', value: '213'},
|
||||||
|
{label: 'Battery', value: '4,700 mAh (25W)'},
|
||||||
|
],
|
||||||
|
right: [
|
||||||
|
{label: 'Processor', value: 'Exynos 2400e (4nm)'},
|
||||||
|
{label: 'Graphics', value: 'Samsung Xclipse 940'},
|
||||||
|
{label: 'Display (inch)', value: '6.7 Dynamic AMOLED 2X'},
|
||||||
|
{label: 'Resolution', value: '2340 × 1080 FHD+'},
|
||||||
|
{label: 'Camera', value: '50 MP + 12 MP + 8 MP'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'oneplus-nord-ce4': {
|
||||||
|
left: [
|
||||||
|
{label: 'OS', value: 'OxygenOS 14'},
|
||||||
|
{label: 'RAM (GB)', value: '8'},
|
||||||
|
{label: 'Storage (GB)', value: '128'},
|
||||||
|
{label: 'Weight (g)', value: '186'},
|
||||||
|
{label: 'Battery', value: '5,500 mAh'},
|
||||||
|
],
|
||||||
|
right: [
|
||||||
|
{label: 'Processor', value: 'Snapdragon 7 Gen 3'},
|
||||||
|
{label: 'Graphics', value: 'Adreno 720'},
|
||||||
|
{label: 'Display (inch)', value: '6.7 Fluid AMOLED'},
|
||||||
|
{label: 'Resolution', value: '2412 × 1080'},
|
||||||
|
{label: 'Charging', value: '100W SuperVOOC'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'redmi-note-14': {
|
||||||
|
left: [
|
||||||
|
{label: 'OS', value: 'Xiaomi HyperOS'},
|
||||||
|
{label: 'RAM (GB)', value: '6'},
|
||||||
|
{label: 'Storage (GB)', value: '128'},
|
||||||
|
{label: 'Weight (g)', value: '190'},
|
||||||
|
{label: 'Battery', value: '5,110 mAh'},
|
||||||
|
],
|
||||||
|
right: [
|
||||||
|
{label: 'Processor', value: 'MediaTek Dimensity 7025'},
|
||||||
|
{label: 'Display (inch)', value: '6.67 OLED 120Hz'},
|
||||||
|
{label: 'Resolution', value: '2400 × 1080'},
|
||||||
|
{label: 'Camera', value: '50 MP with OIS'},
|
||||||
|
{label: 'Durability', value: 'IP64 Dust & Splash'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'galaxy-tab-s9-fe': {
|
||||||
|
left: [
|
||||||
|
{label: 'OS', value: 'Android 14 (One UI)'},
|
||||||
|
{label: 'RAM (GB)', value: '6'},
|
||||||
|
{label: 'Storage (GB)', value: '128'},
|
||||||
|
{label: 'Weight (g)', value: '523'},
|
||||||
|
{label: 'Battery', value: '8,000 mAh'},
|
||||||
|
],
|
||||||
|
right: [
|
||||||
|
{label: 'Processor', value: 'Exynos 1380 Octa-core'},
|
||||||
|
{label: 'Display (inch)', value: '10.9 LCD 90Hz'},
|
||||||
|
{label: 'Resolution', value: '2304 × 1440 WQXGA'},
|
||||||
|
{label: 'Stylus', value: 'S Pen included in box'},
|
||||||
|
{label: 'Durability', value: 'IP68 Water Resistant'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'samsung-tv-55': {
|
||||||
|
left: [
|
||||||
|
{label: 'OS', value: 'Tizen OS'},
|
||||||
|
{label: 'Screen Size (inch)', value: '55'},
|
||||||
|
{label: 'Refresh Rate', value: '50 Hz'},
|
||||||
|
{label: 'Weight (kg)', value: '11.6'},
|
||||||
|
{label: 'Sound Output', value: '20W Q-Symphony'},
|
||||||
|
],
|
||||||
|
right: [
|
||||||
|
{label: 'Resolution', value: '3840 × 2160 (4K UHD)'},
|
||||||
|
{label: 'Processor', value: 'Crystal Processor 4K'},
|
||||||
|
{label: 'HDR', value: 'HDR10+ support'},
|
||||||
|
{label: 'Connectivity', value: '3 HDMI, 1 USB, Wi-Fi'},
|
||||||
|
{label: 'Smart Features', value: 'Apple AirPlay, SmartThings'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'lg-oled-65': {
|
||||||
|
left: [
|
||||||
|
{label: 'OS', value: 'webOS 24'},
|
||||||
|
{label: 'Screen Size (inch)', value: '65'},
|
||||||
|
{label: 'Refresh Rate', value: '120 Hz'},
|
||||||
|
{label: 'Weight (kg)', value: '16.6'},
|
||||||
|
{label: 'Sound Output', value: '40W Dolby Atmos'},
|
||||||
|
],
|
||||||
|
right: [
|
||||||
|
{label: 'Resolution', value: '3840 × 2160 (4K OLED)'},
|
||||||
|
{label: 'Processor', value: 'α9 AI Gen7 4K'},
|
||||||
|
{label: 'Panel', value: 'Self-lit OLED evo'},
|
||||||
|
{label: 'HDR', value: 'Dolby Vision, HDR10'},
|
||||||
|
{label: 'Gaming', value: 'G-Sync, FreeSync, 4× HDMI 2.1'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'sony-tv-43': {
|
||||||
|
left: [
|
||||||
|
{label: 'OS', value: 'Google TV'},
|
||||||
|
{label: 'Screen Size (inch)', value: '43'},
|
||||||
|
{label: 'Refresh Rate', value: '60 Hz'},
|
||||||
|
{label: 'Weight (kg)', value: '7.8'},
|
||||||
|
{label: 'Sound Output', value: '20W Bass Reflex'},
|
||||||
|
],
|
||||||
|
right: [
|
||||||
|
{label: 'Resolution', value: '3840 × 2160 (4K UHD)'},
|
||||||
|
{label: 'Processor', value: '4K Processor X1'},
|
||||||
|
{label: 'Picture Engine', value: '4K X-Reality PRO'},
|
||||||
|
{label: 'Connectivity', value: '3 HDMI, 2 USB, Bluetooth'},
|
||||||
|
{label: 'Voice Control', value: 'Google Assistant Built-in'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'apple-watch-se': {
|
||||||
|
left: [
|
||||||
|
{label: 'OS', value: 'watchOS 10'},
|
||||||
|
{label: 'Case Size (mm)', value: '44'},
|
||||||
|
{label: 'Weight (g)', value: '32.9'},
|
||||||
|
{label: 'Water Resistance', value: '50m Swimproof'},
|
||||||
|
{label: 'Battery', value: 'Up to 18 hours'},
|
||||||
|
],
|
||||||
|
right: [
|
||||||
|
{label: 'Processor', value: 'Apple S8 SiP dual-core'},
|
||||||
|
{label: 'Display', value: 'Retina OLED (1,000 nits)'},
|
||||||
|
{label: 'Sensors', value: 'Heart rate, Gyro, Accel'},
|
||||||
|
{label: 'Safety', value: 'Crash & Fall Detection'},
|
||||||
|
{label: 'Connectivity', value: 'GPS, Wi-Fi, Bluetooth 5.3'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'noise-colorfit-pro-6': {
|
||||||
|
left: [
|
||||||
|
{label: 'Display (inch)', value: '1.96 AMOLED Always-On'},
|
||||||
|
{label: 'Resolution', value: '410 × 502 (600 nits)'},
|
||||||
|
{label: 'Battery', value: 'Up to 7 days (300 mAh)'},
|
||||||
|
{label: 'Weight (g)', value: '41.5'},
|
||||||
|
{label: 'Water Resistance', value: 'IP68 Rating'},
|
||||||
|
],
|
||||||
|
right: [
|
||||||
|
{label: 'Calling', value: 'Bluetooth 5.3 (Tru Sync)'},
|
||||||
|
{label: 'Sensors', value: 'SpO2, 24×7 Heart rate, Sleep'},
|
||||||
|
{label: 'Sports Modes', value: '100+ Sports tracking'},
|
||||||
|
{label: 'Strap (mm)', value: '22 Quick-release'},
|
||||||
|
{label: 'Compatibility', value: 'Android & iOS'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'sony-wh1000xm5': {
|
||||||
|
left: [
|
||||||
|
{label: 'Type', value: 'Over-Ear Wireless'},
|
||||||
|
{label: 'Weight (g)', value: '250'},
|
||||||
|
{label: 'Battery (hr)', value: '30 (ANC On) / 40 (Off)'},
|
||||||
|
{label: 'Fast Charge', value: '3 min = 3 hr playback'},
|
||||||
|
{label: 'Bluetooth', value: 'v5.2 (LDAC, AAC, SBC)'},
|
||||||
|
],
|
||||||
|
right: [
|
||||||
|
{label: 'Noise Cancelling', value: 'Auto NC Optimizer (8 mics)'},
|
||||||
|
{label: 'Driver (mm)', value: '30 Precision Carbon'},
|
||||||
|
{label: 'Microphones', value: '4 Beamforming (AI Noise)'},
|
||||||
|
{label: 'Hi-Res Audio', value: 'Hi-Res Wireless certified'},
|
||||||
|
{label: 'Features', value: 'Multipoint, Speak-to-Chat'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'boat-airdopes-141': {
|
||||||
|
left: [
|
||||||
|
{label: 'Type', value: 'In-Ear TWS Earbuds'},
|
||||||
|
{label: 'Total Playback (hr)', value: '42 (6 hr earbuds)'},
|
||||||
|
{label: 'Fast Charge', value: '5 min = 75 min (ASAP)'},
|
||||||
|
{label: 'Water Resistance', value: 'IPX4 Splash proof'},
|
||||||
|
{label: 'Weight (g)', value: '60 (with case)'},
|
||||||
|
],
|
||||||
|
right: [
|
||||||
|
{label: 'Driver (mm)', value: '8 Dynamic drivers'},
|
||||||
|
{label: 'Mic Tech', value: 'ENx Environmental Noise'},
|
||||||
|
{label: 'Latency', value: 'BEAST Mode (Low latency)'},
|
||||||
|
{label: 'Bluetooth', value: 'v5.1 Insta Wake N\' Pair'},
|
||||||
|
{label: 'Charging Port', value: 'Type-C Fast Charge'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'lg-fridge-sbs': {
|
||||||
|
left: [
|
||||||
|
{label: 'Capacity (L)', value: '655'},
|
||||||
|
{label: 'Refrigerator Type', value: 'Side-by-Side'},
|
||||||
|
{label: 'Compressor', value: 'Smart Inverter Compressor'},
|
||||||
|
{label: 'Defrost Type', value: 'Frost Free'},
|
||||||
|
{label: 'Weight (kg)', value: '116'},
|
||||||
|
],
|
||||||
|
right: [
|
||||||
|
{label: 'Cooling Tech', value: 'DoorCooling+ & Multi Air Flow'},
|
||||||
|
{label: 'Smart Tech', value: 'LG ThinQ Wi-Fi control'},
|
||||||
|
{label: 'Shelves', value: 'Toughened Glass'},
|
||||||
|
{label: 'Ice Maker', value: 'Express Freeze & Twist Ice'},
|
||||||
|
{label: 'Energy Star', value: '3 Star compliant'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'whirlpool-fridge-dd': {
|
||||||
|
left: [
|
||||||
|
{label: 'Capacity (L)', value: '265'},
|
||||||
|
{label: 'Refrigerator Type', value: 'Double Door'},
|
||||||
|
{label: 'Energy Rating', value: '3 Star'},
|
||||||
|
{label: 'Compressor', value: 'IntelliSense Inverter'},
|
||||||
|
{label: 'Weight (kg)', value: '56'},
|
||||||
|
],
|
||||||
|
right: [
|
||||||
|
{label: 'Cooling Retention', value: '12 hours during power cut'},
|
||||||
|
{label: 'Freshness Tech', value: 'Zeolite & Microblock'},
|
||||||
|
{label: 'Crisper', value: 'Honeycomb Moisture Lock'},
|
||||||
|
{label: 'Shelves', value: 'Toughened Glass'},
|
||||||
|
{label: 'Warranty', value: '10 Years on compressor'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'samsung-fridge-sd': {
|
||||||
|
left: [
|
||||||
|
{label: 'Capacity (L)', value: '183'},
|
||||||
|
{label: 'Refrigerator Type', value: 'Single Door'},
|
||||||
|
{label: 'Energy Rating', value: '4 Star'},
|
||||||
|
{label: 'Compressor', value: 'Digital Inverter Compressor'},
|
||||||
|
{label: 'Weight (kg)', value: '30'},
|
||||||
|
],
|
||||||
|
right: [
|
||||||
|
{label: 'Cooling Type', value: 'Direct Cool'},
|
||||||
|
{label: 'Base Stand', value: 'Base drawer included'},
|
||||||
|
{label: 'Voltage Range', value: '100V – 300V stabilizer free'},
|
||||||
|
{label: 'Shelves', value: 'Toughened Glass (175 kg)'},
|
||||||
|
{label: 'Warranty', value: '20 Years on compressor'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'bosch-washer-front': {
|
||||||
|
left: [
|
||||||
|
{label: 'Capacity (kg)', value: '8.0'},
|
||||||
|
{label: 'Spin Speed (RPM)', value: '1200'},
|
||||||
|
{label: 'Type', value: 'Front Load Fully Automatic'},
|
||||||
|
{label: 'Energy Rating', value: '5 Star'},
|
||||||
|
{label: 'Weight (kg)', value: '71.5'},
|
||||||
|
],
|
||||||
|
right: [
|
||||||
|
{label: 'Motor', value: 'EcoSilence Drive'},
|
||||||
|
{label: 'Programs', value: '15 Wash cycles'},
|
||||||
|
{label: 'Drum', value: 'VarioDrum fabric protection'},
|
||||||
|
{label: 'Inbuilt Heater', value: 'Yes (Up to 90°C)'},
|
||||||
|
{label: 'Anti-Stain', value: 'Automatic stain removal'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'ifb-washer-top': {
|
||||||
|
left: [
|
||||||
|
{label: 'Capacity (kg)', value: '7.0'},
|
||||||
|
{label: 'Spin Speed (RPM)', value: '740'},
|
||||||
|
{label: 'Type', value: 'Top Load Fully Automatic'},
|
||||||
|
{label: 'Energy Rating', value: '5 Star'},
|
||||||
|
{label: 'Weight (kg)', value: '35'},
|
||||||
|
],
|
||||||
|
right: [
|
||||||
|
{label: 'Wash System', value: '3D Wash Triadic Pulsator'},
|
||||||
|
{label: 'Filter', value: 'Aqua Energie hard water filter'},
|
||||||
|
{label: 'Drum', value: 'Crescent Moon stainless steel'},
|
||||||
|
{label: 'Programs', value: '8 Wash programs'},
|
||||||
|
{label: 'Tub Clean', value: 'Auto Tub Clean'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'voltas-ac-15t': {
|
||||||
|
left: [
|
||||||
|
{label: 'Tonnage', value: '1.5 Ton'},
|
||||||
|
{label: 'Energy Rating', value: '5 Star (ISEER 5.0)'},
|
||||||
|
{label: 'Condenser Coil', value: '100% Copper'},
|
||||||
|
{label: 'Refrigerant', value: 'R32'},
|
||||||
|
{label: 'Weight (kg)', value: '11.5 IDU / 29.5 ODU'},
|
||||||
|
],
|
||||||
|
right: [
|
||||||
|
{label: 'Cooling Capacity (W)', value: '5100'},
|
||||||
|
{label: 'Inverter', value: 'High ambient rotary inverter'},
|
||||||
|
{label: 'Modes', value: '4-in-1 Adjustable Cooling'},
|
||||||
|
{label: 'Ambient Temp', value: 'Cools at up to 52°C'},
|
||||||
|
{label: 'Filter', value: 'Anti-microbial & dust filter'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'daikin-ac-1t': {
|
||||||
|
left: [
|
||||||
|
{label: 'Tonnage', value: '1.0 Ton'},
|
||||||
|
{label: 'Energy Rating', value: '3 Star (ISEER 3.95)'},
|
||||||
|
{label: 'Condenser', value: '100% Grooved Copper'},
|
||||||
|
{label: 'Refrigerant', value: 'R32'},
|
||||||
|
{label: 'Weight (kg)', value: '9.5 IDU / 25 ODU'},
|
||||||
|
],
|
||||||
|
right: [
|
||||||
|
{label: 'Airflow', value: 'Coanda Airflow'},
|
||||||
|
{label: 'Filter', value: 'PM 2.5 Air Filter built-in'},
|
||||||
|
{label: 'Inverter', value: 'Neo Swing compressor'},
|
||||||
|
{label: 'Noise Level (dB)', value: '32'},
|
||||||
|
{label: 'Operation', value: 'Stabilizer inside'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'panasonic-microwave-27l': {
|
||||||
|
left: [
|
||||||
|
{label: 'Capacity (L)', value: '27'},
|
||||||
|
{label: 'Type', value: 'Convection Microwave'},
|
||||||
|
{label: 'Power (W)', value: '900W Micro / 1400W Grill'},
|
||||||
|
{label: 'Turntable (mm)', value: '305 Glass'},
|
||||||
|
{label: 'Weight (kg)', value: '16.5'},
|
||||||
|
],
|
||||||
|
right: [
|
||||||
|
{label: 'Auto Cook', value: '101 Pre-set Indian menus'},
|
||||||
|
{label: 'Heating', value: '360° Heat Wrap'},
|
||||||
|
{label: 'Cavity', value: 'Stainless Steel interior'},
|
||||||
|
{label: 'Control', value: 'Touch Keypad & Digital display'},
|
||||||
|
{label: 'Features', value: 'Re-heat, Defrost, Ferment'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns structured two-column technical specifications matching the reference design.
|
||||||
|
*/
|
||||||
|
export function getProductSpecs(item: SampleCatalogItem): ProductSpecSheet {
|
||||||
|
if (CATALOG_SPECS[item.slug]) {
|
||||||
|
return CATALOG_SPECS[item.slug];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Dynamically generate balanced specs from item details
|
||||||
|
const parsedSpecs = item.specs
|
||||||
|
.split(/·|,|\|/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const left: ProductSpecItem[] = [
|
||||||
|
{label: 'Brand', value: item.brand},
|
||||||
|
{label: 'Category', value: item.category},
|
||||||
|
{label: 'SKU', value: item.sku},
|
||||||
|
];
|
||||||
|
|
||||||
|
const right: ProductSpecItem[] = [];
|
||||||
|
|
||||||
|
parsedSpecs.forEach((spec, idx) => {
|
||||||
|
if (idx % 2 === 0) {
|
||||||
|
left.push({label: `Feature ${idx + 1}`, value: spec});
|
||||||
|
} else {
|
||||||
|
right.push({label: `Feature ${idx + 1}`, value: spec});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (right.length === 0) {
|
||||||
|
right.push({label: 'Configuration', value: item.specs});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {left, right};
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import 'server-only';
|
|||||||
import {upstreamRequest} from './apiClient';
|
import {upstreamRequest} from './apiClient';
|
||||||
import type {
|
import type {
|
||||||
ApiConversionReport,
|
ApiConversionReport,
|
||||||
ApiDashboardSummary,
|
ApiPlatformDashboardSummary,
|
||||||
ApiFootfallReport,
|
ApiFootfallReport,
|
||||||
} from './types';
|
} from './types';
|
||||||
import type {ReportWindow} from './range';
|
import type {ReportWindow} from './range';
|
||||||
@@ -34,13 +34,13 @@ export const reportsApi = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Today at a glance: takings, sales, enquiries, who is on the floor now, and
|
* Today at a glance: footfall and shop health (no money - see the BFF
|
||||||
* footfall. With no `from`/`to` the platform uses TODAY in `tz` — the
|
* route, which takes that from /api/sales). The platform uses TODAY in `tz` — the
|
||||||
* business day, not the UTC one — which is exactly the question a shop
|
* business day, not the UTC one — which is exactly the question a shop
|
||||||
* screen asks, so no window is sent.
|
* screen asks, so no window is sent.
|
||||||
*/
|
*/
|
||||||
todaySummary: (accessToken: string, tz: string, site?: string) =>
|
todaySummary: (accessToken: string, tz: string, site?: string) =>
|
||||||
upstreamRequest<ApiDashboardSummary>({
|
upstreamRequest<ApiPlatformDashboardSummary>({
|
||||||
path: '/api/dashboard/summary',
|
path: '/api/dashboard/summary',
|
||||||
query: {tz, site},
|
query: {tz, site},
|
||||||
accessToken,
|
accessToken,
|
||||||
|
|||||||
58
src/services/api/saleMapper.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import type {ApiPlatformSale} from './types';
|
||||||
|
import type {Sale} from '@/features/commerce/types/sale';
|
||||||
|
|
||||||
|
/** Rupees from the platform to integer paise, once, here. */
|
||||||
|
export function rupeesToPaise(amount: number | null | undefined): number {
|
||||||
|
// numeric(14,2) upstream, so ×100 is exact up to float noise and rounding
|
||||||
|
// recovers the integer the database holds.
|
||||||
|
return Math.round((Number(amount) || 0) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A platform sale (`GET /api/sales`, `GET /api/sales/{id}`) → the console's
|
||||||
|
* `Sale`.
|
||||||
|
*
|
||||||
|
* The platform records a sale as a total plus product NAMES, with no price per
|
||||||
|
* item. So the detail view gets ONE purchased line naming every item and
|
||||||
|
* carrying the total, rather than a line per item priced at ₹0 — a made-up
|
||||||
|
* price is worse than an honest grouping. Every platform sale is a purchase:
|
||||||
|
* it has no enquiry lines and no void state.
|
||||||
|
*/
|
||||||
|
export function fromPlatformSale(s: ApiPlatformSale): Sale {
|
||||||
|
const totalPaise = rupeesToPaise(s.amount);
|
||||||
|
const items = (s.items ?? []).filter((i) => typeof i === 'string' && i.trim() !== '');
|
||||||
|
return {
|
||||||
|
id: s.id,
|
||||||
|
invoiceNo: s.external_ref || null,
|
||||||
|
siteId: s.site_slug || s.site_id,
|
||||||
|
customerRef: s.visitor_ref || null,
|
||||||
|
customerLabel: s.visitor_label || null,
|
||||||
|
// The platform derives the seller from the session and does not return it.
|
||||||
|
staffName: null,
|
||||||
|
totalPaise,
|
||||||
|
currency: s.currency || 'INR',
|
||||||
|
status: 'completed',
|
||||||
|
at: s.occurred_at,
|
||||||
|
purchasedLines: items.length,
|
||||||
|
enquiryLines: 0,
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
productName: items.length > 0 ? items.join(', ') : 'Sale',
|
||||||
|
pricePaise: totalPaise,
|
||||||
|
intent: 'purchased',
|
||||||
|
billablePaise: totalPaise,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The list response is a bare array. Also tolerates the `{items: [...]}` page
|
||||||
|
* the console was first written against, so a platform that adopts it later
|
||||||
|
* does not empty the screen a second time.
|
||||||
|
*/
|
||||||
|
export function platformSales(body: unknown): ApiPlatformSale[] {
|
||||||
|
if (Array.isArray(body)) return body as ApiPlatformSale[];
|
||||||
|
const items = (body as {items?: unknown} | null)?.items;
|
||||||
|
return Array.isArray(items) ? (items as ApiPlatformSale[]) : [];
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'server-only';
|
import 'server-only';
|
||||||
import {upstreamRequest} from './apiClient';
|
import {upstreamRequest} from './apiClient';
|
||||||
import type {ApiSale, ApiSaleResult} from './types';
|
import type {ApiPlatformSale, ApiSaleResult} from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sales, from the merchant application's own domain.
|
* Sales, from the merchant application's own domain.
|
||||||
@@ -9,10 +9,14 @@ import type {ApiSale, ApiSaleResult} from './types';
|
|||||||
* the conversion report reads. Both exist on purpose: that one is still the
|
* the conversion report reads. Both exist on purpose: that one is still the
|
||||||
* only writer the current mobile flow has, and migrating it would break a
|
* only writer the current mobile flow has, and migrating it would break a
|
||||||
* working path while this domain has no writers yet.
|
* working path while this domain has no writers yet.
|
||||||
|
*
|
||||||
|
* `list` and `byId` are typed to what the platform actually serves
|
||||||
|
* (`ApiPlatformSale`, a bare array for the list). `create` targets a route the
|
||||||
|
* platform does not serve yet and answers 405 until it does.
|
||||||
*/
|
*/
|
||||||
export const salesApi = {
|
export const salesApi = {
|
||||||
list: (accessToken: string, query: Record<string, string | number | undefined>) =>
|
list: (accessToken: string, query: Record<string, string | number | undefined>) =>
|
||||||
upstreamRequest<{items: ApiSale[]}>({path: '/api/sales', query, accessToken}),
|
upstreamRequest<ApiPlatformSale[]>({path: '/api/sales', query, accessToken}),
|
||||||
|
|
||||||
create: (
|
create: (
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
@@ -34,5 +38,5 @@ export const salesApi = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
byId: (accessToken: string, id: string) =>
|
byId: (accessToken: string, id: string) =>
|
||||||
upstreamRequest<ApiSale>({path: `/api/sales/${encodeURIComponent(id)}`, accessToken}),
|
upstreamRequest<ApiPlatformSale>({path: `/api/sales/${encodeURIComponent(id)}`, accessToken}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -609,6 +609,50 @@ export interface ApiSaleResult {
|
|||||||
sale?: ApiSale;
|
sale?: ApiSale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of `GET /api/sales` and the body of `GET /api/sales/{id}`, exactly
|
||||||
|
* as the deployed platform returns them (`Sale` upstream, API.md).
|
||||||
|
*
|
||||||
|
* Not the planned `ApiSale` above: the list is a BARE ARRAY, money is
|
||||||
|
* `amount` in RUPEES (numeric(14,2) upstream), and `items` is product names
|
||||||
|
* with no per-item price. Reading this as `{items: ApiSale[]}` is what left
|
||||||
|
* the Sales screen empty against a platform full of sales.
|
||||||
|
*/
|
||||||
|
export interface ApiPlatformSale {
|
||||||
|
id: string;
|
||||||
|
occurred_at: string;
|
||||||
|
site_id: string;
|
||||||
|
site?: string;
|
||||||
|
site_slug?: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
visitor_id?: string;
|
||||||
|
visitor_ref?: string;
|
||||||
|
visitor_label?: string;
|
||||||
|
visit_id?: string;
|
||||||
|
items: string[];
|
||||||
|
source: string;
|
||||||
|
external_ref?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /api/dashboard/summary` as the platform returns it (`DashboardSummary`
|
||||||
|
* upstream). Footfall and shop health only — no revenue or sales, which the
|
||||||
|
* BFF takes from `/api/sales` for the same day.
|
||||||
|
*/
|
||||||
|
export interface ApiPlatformDashboardSummary {
|
||||||
|
date: string;
|
||||||
|
visitors: number;
|
||||||
|
visits: number;
|
||||||
|
sites_total: number;
|
||||||
|
sites_online: number;
|
||||||
|
cameras_total: number;
|
||||||
|
cameras_up: number;
|
||||||
|
fraction_below_gate: number;
|
||||||
|
worst_site?: string;
|
||||||
|
timezone: string;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── §11 Platform administration — /api/admin/* ──────────────────────────
|
/* ── §11 Platform administration — /api/admin/* ──────────────────────────
|
||||||
*
|
*
|
||||||
* The one surface upstream that is NOT tenant-scoped. Every shape below is
|
* The one surface upstream that is NOT tenant-scoped. Every shape below is
|
||||||
|
|||||||
138
src/shared/hooks/useHoverIntent.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {useCallback, useEffect, useRef} from 'react';
|
||||||
|
import type {PointerEvent as ReactPointerEvent} from 'react';
|
||||||
|
|
||||||
|
/** How long the cursor must sit still on the target before it counts as intent. */
|
||||||
|
const DEFAULT_DELAY_MS = 700;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long after a scroll a pointer event is treated as scroll fallout.
|
||||||
|
*
|
||||||
|
* A browser fires a synthetic mousemove after scrolling to refresh which element
|
||||||
|
* is hovered. That move is the page arriving under a stationary cursor, not the
|
||||||
|
* merchant pointing at anything, so it must not arm anything.
|
||||||
|
*/
|
||||||
|
const SCROLL_QUIET_MS = 350;
|
||||||
|
|
||||||
|
/** Set when the cursor lands on a target: how long the cursor must rest. */
|
||||||
|
let suppressedUntil = 0;
|
||||||
|
let lastScrollAt = 0;
|
||||||
|
let subscribers = 0;
|
||||||
|
|
||||||
|
function markScroll() {
|
||||||
|
lastScrollAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One scroll clock for the whole page.
|
||||||
|
*
|
||||||
|
* A grid of forty tiles must not install forty scroll listeners, so the
|
||||||
|
* listeners are shared and reference-counted.
|
||||||
|
*
|
||||||
|
* `capture` matters: the scrolling element is the dashboard's own scroll region,
|
||||||
|
* not the window, and scroll events do not bubble — a plain listener on window
|
||||||
|
* would never hear the one scroll we care about.
|
||||||
|
*/
|
||||||
|
function subscribeToScroll(): () => void {
|
||||||
|
if (subscribers === 0) {
|
||||||
|
window.addEventListener('scroll', markScroll, {capture: true, passive: true});
|
||||||
|
window.addEventListener('wheel', markScroll, {passive: true});
|
||||||
|
window.addEventListener('touchmove', markScroll, {passive: true});
|
||||||
|
}
|
||||||
|
subscribers += 1;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscribers -= 1;
|
||||||
|
if (subscribers === 0) {
|
||||||
|
window.removeEventListener('scroll', markScroll, {capture: true});
|
||||||
|
window.removeEventListener('wheel', markScroll);
|
||||||
|
window.removeEventListener('touchmove', markScroll);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hold off every hover intent on the page for a moment.
|
||||||
|
*
|
||||||
|
* Call this when closing something a hover opened: the cursor is still sitting
|
||||||
|
* where it was, and without the pause the first twitch of the mouse reopens what
|
||||||
|
* the merchant just dismissed.
|
||||||
|
*/
|
||||||
|
export function suppressHoverIntent(ms = 600) {
|
||||||
|
suppressedUntil = Date.now() + ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fires `onIntent` once the cursor has come to rest on the target.
|
||||||
|
*
|
||||||
|
* Deliberately measures *stillness*, not arrival. Arrival is the wrong signal on
|
||||||
|
* a scrolling page — the cursor stays put while the content moves beneath it, so
|
||||||
|
* every card that slides past would report a hover. Requiring the pointer to
|
||||||
|
* move and then stop means only a merchant actually pointing at something
|
||||||
|
* triggers it, and dragging the cursor across a grid on the way somewhere else
|
||||||
|
* triggers nothing.
|
||||||
|
*
|
||||||
|
* Mouse only: a touch never hovers, and a tap that opened something behind the
|
||||||
|
* user's own finger is disorienting.
|
||||||
|
*/
|
||||||
|
export function useHoverIntent<T extends HTMLElement = HTMLElement>({
|
||||||
|
onIntent,
|
||||||
|
delayMs = DEFAULT_DELAY_MS,
|
||||||
|
isDisabled = false,
|
||||||
|
}: {
|
||||||
|
onIntent: () => void;
|
||||||
|
/** Stillness required before firing. */
|
||||||
|
delayMs?: number;
|
||||||
|
/** Stops firing and drops anything pending — e.g. while the target is open. */
|
||||||
|
isDisabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Read the callback through a ref: call sites pass a fresh closure every
|
||||||
|
// render, and a changing handler identity must not drop a pending timer.
|
||||||
|
const intent = useRef(onIntent);
|
||||||
|
useEffect(() => {
|
||||||
|
intent.current = onIntent;
|
||||||
|
}, [onIntent]);
|
||||||
|
|
||||||
|
const cancel = useCallback(() => {
|
||||||
|
if (timer.current) {
|
||||||
|
clearTimeout(timer.current);
|
||||||
|
timer.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(subscribeToScroll, []);
|
||||||
|
|
||||||
|
// A target unmounted mid-dwell — by a filter change, say — must not fire for
|
||||||
|
// something that is no longer on screen.
|
||||||
|
useEffect(() => cancel, [cancel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDisabled) cancel();
|
||||||
|
}, [isDisabled, cancel]);
|
||||||
|
|
||||||
|
const onPointerMove = useCallback(
|
||||||
|
(event: ReactPointerEvent<T>) => {
|
||||||
|
if (isDisabled || event.pointerType !== 'mouse') return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastScrollAt < SCROLL_QUIET_MS || now < suppressedUntil) {
|
||||||
|
cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart on every move, so the delay times how long the cursor has been
|
||||||
|
// still rather than how long it has been inside the target.
|
||||||
|
cancel();
|
||||||
|
timer.current = setTimeout(() => {
|
||||||
|
timer.current = null;
|
||||||
|
intent.current();
|
||||||
|
}, delayMs);
|
||||||
|
},
|
||||||
|
[cancel, delayMs, isDisabled],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {onPointerMove, onPointerLeave: cancel, cancel};
|
||||||
|
}
|
||||||
@@ -14,9 +14,10 @@ export interface NavEntry {
|
|||||||
*/
|
*/
|
||||||
export const PRIMARY_NAV: NavEntry[] = [
|
export const PRIMARY_NAV: NavEntry[] = [
|
||||||
{label: 'Dashboard', href: '/dashboard', icon: ICONS.dashboard},
|
{label: 'Dashboard', href: '/dashboard', icon: ICONS.dashboard},
|
||||||
{label: 'Store', href: '/stores', icon: ICONS.stores},
|
|
||||||
{label: 'Commerce', href: '/commerce', icon: ICONS.commerce},
|
{label: 'Commerce', href: '/commerce', icon: ICONS.commerce},
|
||||||
|
{label: 'Store', href: '/stores', icon: ICONS.stores},
|
||||||
{label: 'Leaderboard', href: '/staff', icon: ICONS.leaderboard},
|
{label: 'Leaderboard', href: '/staff', icon: ICONS.leaderboard},
|
||||||
|
{label: 'Products', href: '/products', icon: ICONS.products},
|
||||||
{label: 'Lyts', href: '/lyts', icon: ICONS.lyts},
|
{label: 'Lyts', href: '/lyts', icon: ICONS.lyts},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ export function createRng(...parts: (string | number)[]) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Rng = ReturnType<typeof createRng>;
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// Stores — the fixture roster every sample shares
|
// Stores — the fixture roster every sample shares
|
||||||
// (was features/stores/mocks/stores.mock.ts)
|
// (was features/stores/mocks/stores.mock.ts)
|
||||||
@@ -1860,3 +1862,428 @@ export function buildSampleJourney(range: RangeKey, storeId: string, endMs: numb
|
|||||||
attribution: 'estimated',
|
attribution: 'estimated',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Products — the catalogue page (no product endpoint on the platform yet)
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/** Catalogue categories. Each maps to one glyph in the products UI. */
|
||||||
|
export type SampleProductKind =
|
||||||
|
| 'tv'
|
||||||
|
| 'mobile'
|
||||||
|
| 'laptop'
|
||||||
|
| 'tablet'
|
||||||
|
| 'fridge'
|
||||||
|
| 'washer'
|
||||||
|
| 'ac'
|
||||||
|
| 'microwave'
|
||||||
|
| 'audio'
|
||||||
|
| 'wearable';
|
||||||
|
|
||||||
|
/** One customer review, as the product detail dialog shows it. */
|
||||||
|
export interface SampleProductReview extends Record<string, unknown> {
|
||||||
|
id: string;
|
||||||
|
author: string;
|
||||||
|
/** Stars, 1–5. Whole numbers — a customer cannot leave 4.3. */
|
||||||
|
rating: number;
|
||||||
|
/** One-line headline. */
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
/** Age of the review. A date would make every snapshot look stale. */
|
||||||
|
daysAgo: number;
|
||||||
|
/** Matched to a bill at this store, rather than left by anyone passing. */
|
||||||
|
isVerifiedPurchase: boolean;
|
||||||
|
helpfulCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a platform's price and stock became known.
|
||||||
|
*
|
||||||
|
* The distinction is the catalogue's whole claim to honesty, so it travels with
|
||||||
|
* every offer rather than being inferred in the UI: `product_page` means the
|
||||||
|
* listing itself was fetched and read, `search_result` means the platform was
|
||||||
|
* never fetched and all that is known is what its search result stated.
|
||||||
|
*/
|
||||||
|
export type SampleEvidenceKind = 'product_page' | 'search_result';
|
||||||
|
|
||||||
|
/** One platform's offer for a product — a row of "Where it is sold". */
|
||||||
|
export interface SampleProductPlatform extends Record<string, unknown> {
|
||||||
|
id: string;
|
||||||
|
/** Retailer or marketplace name, as shoppers know it. */
|
||||||
|
platform: string;
|
||||||
|
/**
|
||||||
|
* A Tamil Nadu retailer. Badged as such and nothing more: no platform accepts
|
||||||
|
* a TN pincode, so a TN retailer's price is still its national listing price.
|
||||||
|
*/
|
||||||
|
isTamilNadu: boolean;
|
||||||
|
/** The variant the listing names, when it names one. */
|
||||||
|
variant?: string;
|
||||||
|
/** `null` when the listing states no price. Never a guess. */
|
||||||
|
priceInr: number | null;
|
||||||
|
/**
|
||||||
|
* `unknown` when the listing does not say. Deliberately not 'out_of_stock' —
|
||||||
|
* silence is not evidence of absence.
|
||||||
|
*/
|
||||||
|
stock: 'in_stock' | 'unknown';
|
||||||
|
evidence: SampleEvidenceKind;
|
||||||
|
/** How long ago the listing was last seen. */
|
||||||
|
seenHoursAgo: number;
|
||||||
|
/** The page the figures came from. */
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SampleCatalogItem extends Record<string, unknown> {
|
||||||
|
id: string;
|
||||||
|
/** Stable URL-safe handle. Also keys the sample artwork — see productImages(). */
|
||||||
|
slug: string;
|
||||||
|
sku: string;
|
||||||
|
brand: string;
|
||||||
|
name: string;
|
||||||
|
/** One line of headline specs, as a listing shows under the title. */
|
||||||
|
specs: string;
|
||||||
|
kind: SampleProductKind;
|
||||||
|
category: string;
|
||||||
|
/**
|
||||||
|
* Product photography, when the catalogue has any. Optional because the
|
||||||
|
* platform sends none yet — the UI falls back to sample artwork for the
|
||||||
|
* product (see productImage()), so this field can start arriving on a subset
|
||||||
|
* of products without a UI change.
|
||||||
|
*/
|
||||||
|
imageUrl?: string;
|
||||||
|
/**
|
||||||
|
* The rest of the gallery, in the order the carousel shows them. Same
|
||||||
|
* reasoning as `imageUrl`: absent until the API carries photographs.
|
||||||
|
*/
|
||||||
|
imageUrls?: string[];
|
||||||
|
/** Selling price. */
|
||||||
|
priceInr: number;
|
||||||
|
/** List price (MRP). Equal to priceInr when there is no offer. */
|
||||||
|
mrpInr: number;
|
||||||
|
discountPct: number;
|
||||||
|
rating: number;
|
||||||
|
reviews: number;
|
||||||
|
units: number;
|
||||||
|
revenueInr: number;
|
||||||
|
/** Change against the previous equal window, in percent. */
|
||||||
|
deltaPct: number;
|
||||||
|
stock: number;
|
||||||
|
stockStatus: SampleStockStatus;
|
||||||
|
/**
|
||||||
|
* Share of reviews at each star, five first, summing to 100. Percentages
|
||||||
|
* rather than counts: the bars are read against each other, and a count
|
||||||
|
* would have to be kept consistent with `reviews` on every edit.
|
||||||
|
*/
|
||||||
|
ratingSpread: number[];
|
||||||
|
/** A few reviews worth reading, newest first. */
|
||||||
|
reviewHighlights: SampleProductReview[];
|
||||||
|
/**
|
||||||
|
* Every platform known to sell this product, best evidence first. Drives the
|
||||||
|
* "Where it is sold" table: the merchant's own price is one column of a market,
|
||||||
|
* and a price with no source beside it is a number nobody can check.
|
||||||
|
*/
|
||||||
|
platforms: SampleProductPlatform[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAMPLE_CATALOG: {
|
||||||
|
slug: string;
|
||||||
|
brand: string;
|
||||||
|
name: string;
|
||||||
|
specs: string;
|
||||||
|
kind: SampleProductKind;
|
||||||
|
category: string;
|
||||||
|
mrpInr: number;
|
||||||
|
priceInr: number;
|
||||||
|
}[] = [
|
||||||
|
{slug: 'samsung-tv-55', brand: 'Samsung', name: 'Crystal 4K UHD Smart TV 55"', specs: '4K UHD · HDR10+ · Tizen OS · 3 HDMI', kind: 'tv', category: 'Televisions', mrpInr: 64900, priceInr: 47990},
|
||||||
|
{slug: 'lg-oled-65', brand: 'LG', name: 'OLED evo C4 Smart TV 65"', specs: '4K OLED · 120Hz · Dolby Vision · webOS', kind: 'tv', category: 'Televisions', mrpInr: 339990, priceInr: 229990},
|
||||||
|
{slug: 'sony-tv-43', brand: 'Sony', name: 'Bravia 2 LED Google TV 43"', specs: '4K HDR · X1 processor · Google TV', kind: 'tv', category: 'Televisions', mrpInr: 59900, priceInr: 41990},
|
||||||
|
{slug: 'iphone-16', brand: 'Apple', name: 'iPhone 16 (128 GB)', specs: 'A18 chip · 48MP camera · 6.1" OLED', kind: 'mobile', category: 'Mobiles', mrpInr: 79900, priceInr: 74900},
|
||||||
|
{slug: 'galaxy-s24-fe', brand: 'Samsung', name: 'Galaxy S24 FE 5G (256 GB)', specs: '8 GB RAM · 50MP triple camera · 6.7"', kind: 'mobile', category: 'Mobiles', mrpInr: 65999, priceInr: 39999},
|
||||||
|
{slug: 'oneplus-nord-ce4', brand: 'OnePlus', name: 'Nord CE4 5G (128 GB)', specs: '8 GB RAM · 100W charging · 6.7" AMOLED', kind: 'mobile', category: 'Mobiles', mrpInr: 26999, priceInr: 22999},
|
||||||
|
{slug: 'redmi-note-14', brand: 'Redmi', name: 'Note 14 5G (128 GB)', specs: '6 GB RAM · 50MP camera · 5110 mAh', kind: 'mobile', category: 'Mobiles', mrpInr: 21999, priceInr: 17999},
|
||||||
|
{slug: 'hp-pavilion-15', brand: 'HP', name: 'Pavilion 15 Laptop', specs: 'Core i5 13th Gen · 16 GB · 512 GB SSD', kind: 'laptop', category: 'Laptops', mrpInr: 82900, priceInr: 61990},
|
||||||
|
{slug: 'macbook-air-m3', brand: 'Apple', name: 'MacBook Air M3 13"', specs: 'M3 chip · 8 GB · 256 GB SSD', kind: 'laptop', category: 'Laptops', mrpInr: 114900, priceInr: 99900},
|
||||||
|
{slug: 'galaxy-tab-s9-fe', brand: 'Samsung', name: 'Galaxy Tab S9 FE Wi-Fi', specs: '10.9" display · S Pen included · 6 GB', kind: 'tablet', category: 'Tablets', mrpInr: 44999, priceInr: 29999},
|
||||||
|
{slug: 'lg-fridge-sbs', brand: 'LG', name: '655 L Side-by-Side Refrigerator', specs: 'Inverter compressor · Door cooling+ · Wi-Fi', kind: 'fridge', category: 'Refrigerators', mrpInr: 118999, priceInr: 76990},
|
||||||
|
{slug: 'whirlpool-fridge-dd', brand: 'Whirlpool', name: '265 L 3 Star Frost Free Double Door', specs: 'IntelliFresh · 12-hour cooling retention', kind: 'fridge', category: 'Refrigerators', mrpInr: 34950, priceInr: 26490},
|
||||||
|
{slug: 'samsung-fridge-sd', brand: 'Samsung', name: '183 L 4 Star Single Door Refrigerator', specs: 'Digital inverter · Base stand drawer', kind: 'fridge', category: 'Refrigerators', mrpInr: 22990, priceInr: 16790},
|
||||||
|
{slug: 'bosch-washer-front', brand: 'Bosch', name: '8 kg Fully Automatic Front Load', specs: '1200 RPM · Anti-stain · In-built heater', kind: 'washer', category: 'Washing machines', mrpInr: 49990, priceInr: 36990},
|
||||||
|
{slug: 'ifb-washer-top', brand: 'IFB', name: '7 kg Top Load Washing Machine', specs: 'Aqua Energie · 3D wash · 740 RPM', kind: 'washer', category: 'Washing machines', mrpInr: 28990, priceInr: 19490},
|
||||||
|
{slug: 'voltas-ac-15t', brand: 'Voltas', name: '1.5 Ton 5 Star Inverter Split AC', specs: 'Copper condenser · 4-in-1 convertible', kind: 'ac', category: 'Air conditioners', mrpInr: 75990, priceInr: 38990},
|
||||||
|
{slug: 'daikin-ac-1t', brand: 'Daikin', name: '1 Ton 3 Star Inverter Split AC', specs: 'PM 2.5 filter · Coanda airflow', kind: 'ac', category: 'Air conditioners', mrpInr: 51200, priceInr: 32490},
|
||||||
|
{slug: 'panasonic-microwave-27l', brand: 'Panasonic', name: '27 L Convection Microwave Oven', specs: '360° heat wrap · 101 auto menus', kind: 'microwave', category: 'Kitchen appliances', mrpInr: 17990, priceInr: 12490},
|
||||||
|
{slug: 'sony-wh1000xm5', brand: 'Sony', name: 'WH-1000XM5 Wireless Headphones', specs: 'Noise cancelling · 30 hr battery', kind: 'audio', category: 'Audio', mrpInr: 34990, priceInr: 26990},
|
||||||
|
{slug: 'boat-airdopes-141', brand: 'boAt', name: 'Airdopes 141 TWS Earbuds', specs: '42 hr playback · ENx mic · IPX4', kind: 'audio', category: 'Audio', mrpInr: 4490, priceInr: 1099},
|
||||||
|
{slug: 'apple-watch-se', brand: 'Apple', name: 'Watch SE (2nd Gen) GPS 44mm', specs: 'Crash detection · Heart-rate alerts', kind: 'wearable', category: 'Wearables', mrpInr: 32900, priceInr: 26900},
|
||||||
|
{slug: 'noise-colorfit-pro-6', brand: 'Noise', name: 'ColorFit Pro 6 Smartwatch', specs: '1.96" AMOLED · BT calling · 7-day battery', kind: 'wearable', category: 'Wearables', mrpInr: 7999, priceInr: 3999},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Review copy for the sample catalogue, written per category.
|
||||||
|
*
|
||||||
|
* Per category rather than per product because a review has to talk about the
|
||||||
|
* thing it reviews: "great product, fast delivery" is filler that tells a
|
||||||
|
* merchant nothing about how the review panel will read once real reviews
|
||||||
|
* arrive, and a panel designed against filler is a panel designed wrong. Each
|
||||||
|
* entry is [stars, headline, body], and the mix of stars is deliberate — every
|
||||||
|
* review being five stars is the other way to make a review list look fake.
|
||||||
|
*/
|
||||||
|
const REVIEW_COPY: Record<SampleProductKind, [number, string, string][]> = {
|
||||||
|
tv: [
|
||||||
|
[5, 'Cinema at home', 'Blacks are genuinely black with the lights off. Setup and calibration took ten minutes.'],
|
||||||
|
[4, 'Great panel, average sound', 'The picture is worth the money. We added a soundbar in the first week.'],
|
||||||
|
[5, 'Survives afternoon glare', 'Our hall faces west and it stays watchable, which the old set did not.'],
|
||||||
|
[3, 'Measure your table first', 'No complaints about the screen, but the feet sit very wide apart.'],
|
||||||
|
],
|
||||||
|
mobile: [
|
||||||
|
[5, 'Battery lasts the full day', 'Heavy use from 8am and I still get home at about 40 percent.'],
|
||||||
|
[4, 'Camera is the highlight', 'Night shots come out clean. Wish a charger was still in the box.'],
|
||||||
|
[5, 'Still smooth after months', 'No lag and no heating while running maps with the AC on.'],
|
||||||
|
[4, 'Obvious upgrade', 'Coming from a four-year-old phone, the difference in speed is immediate.'],
|
||||||
|
],
|
||||||
|
laptop: [
|
||||||
|
[5, 'Silent and quick', 'Boots in seconds and stays cool with a dozen tabs and a spreadsheet open.'],
|
||||||
|
[4, 'Crisp screen, firm keys', 'The display is excellent. The keyboard took a couple of days to get used to.'],
|
||||||
|
[4, 'Fine for work and college', 'Photo editing is comfortable. Heavy games are not what this is for.'],
|
||||||
|
[3, 'The adapter is bulky', 'The machine is excellent, but the charger takes up half the bag.'],
|
||||||
|
],
|
||||||
|
tablet: [
|
||||||
|
[5, 'Replaced my notebook', 'Pen lag is unnoticeable. I have not opened a paper notebook since.'],
|
||||||
|
[4, 'Excellent for video', 'Screen and speakers are far better than the phone we used to watch on.'],
|
||||||
|
[4, 'Light enough to hold', 'An hour of reading and my wrist has nothing to say about it.'],
|
||||||
|
[3, 'Slow to charge', 'The battery lasts well, but a full top-up takes most of an evening.'],
|
||||||
|
],
|
||||||
|
fridge: [
|
||||||
|
[5, 'Quiet and roomy', 'Cannot hear it from the next room, and vegetables hold a full week.'],
|
||||||
|
[4, 'Cooled down fast', 'Reached temperature about four hours after the installation visit.'],
|
||||||
|
[4, 'No frost so far', 'Two months in, the seals are firm and there is nothing to defrost.'],
|
||||||
|
[3, 'Tall bottles do not fit', 'Plenty of space overall, but the shelf spacing forces them onto the door.'],
|
||||||
|
],
|
||||||
|
washer: [
|
||||||
|
[5, 'Stains actually come out', 'Collar marks went on a normal cycle with no pre-soak at all.'],
|
||||||
|
[4, 'Gentle on clothes', 'Three months and nothing has come back stretched or faded.'],
|
||||||
|
[4, 'Uses less water', 'Noticeably less than the machine it replaced, on the same load size.'],
|
||||||
|
[3, 'Long cycles', 'It cleans well, but a full wash runs close to two hours.'],
|
||||||
|
],
|
||||||
|
ac: [
|
||||||
|
[5, 'Cools a big room', 'Set to 24 and the whole hall is comfortable inside ten minutes.'],
|
||||||
|
[4, 'The bill did not jump', 'Ran it nightly through May for far less than we had budgeted.'],
|
||||||
|
[4, 'Quiet on low fan', 'We sleep with it running and it wakes nobody.'],
|
||||||
|
[3, 'Budget for installation', 'The unit is good. Copper piping and the stand were a separate cost.'],
|
||||||
|
],
|
||||||
|
microwave: [
|
||||||
|
[5, 'Baking actually works', 'Convection mode handled a cake first try, straight off the stock menu.'],
|
||||||
|
[4, 'Heats evenly', 'No cold centre in reheated rice, which was our old one’s failing.'],
|
||||||
|
[4, 'Easy to wipe down', 'The cavity cleans without scrubbing, even after something boils over.'],
|
||||||
|
[3, 'Check your shelf depth', 'The capacity is genuinely useful, but it takes real counter space.'],
|
||||||
|
],
|
||||||
|
audio: [
|
||||||
|
[5, 'Noise cancelling is real', 'Cuts out traffic and office chatter almost completely.'],
|
||||||
|
[4, 'Comfortable for hours', 'Wore them for a full flight with no ache behind the ears.'],
|
||||||
|
[4, 'Battery outlasts the week', 'Charge on Monday and they are still going by Friday evening.'],
|
||||||
|
[3, 'Average on calls', 'Music is excellent. Callers say I sound a little distant outdoors.'],
|
||||||
|
],
|
||||||
|
wearable: [
|
||||||
|
[5, 'Sleep tracking is useful', 'The readings line up with how the morning actually feels.'],
|
||||||
|
[4, 'Readable in sunlight', 'Legible outdoors at full brightness, which was my worry before buying.'],
|
||||||
|
[4, 'Comfortable overnight', 'A month of wearing it to bed with no skin irritation.'],
|
||||||
|
[3, 'Charge every few days', 'It does everything I need, but the battery claim assumes light use.'],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const REVIEW_AUTHORS = [
|
||||||
|
'Aarti Menon', 'Rahul Nair', 'Sneha Iyer', 'Vikram Rao', 'Priya Bhat',
|
||||||
|
'Imran Shaikh', 'Divya Krishnan', 'Arjun Pillai', 'Meera Joshi', 'Tanvi Desai',
|
||||||
|
'Karthik Subramanian', 'Farhan Qureshi', 'Ananya Ghosh', 'Suresh Patel',
|
||||||
|
'Nisha Reddy', 'Manoj Kulkarni',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share of reviews at each star, five first.
|
||||||
|
*
|
||||||
|
* Shaped by hand rather than spread evenly around the average: real review
|
||||||
|
* distributions are J-shaped, with five stars dominant and a small one-star
|
||||||
|
* tail, and a symmetric bell around 4.2 would put four stars ahead of five —
|
||||||
|
* which no storefront has ever looked like.
|
||||||
|
*/
|
||||||
|
function ratingSpread(rating: number): number[] {
|
||||||
|
const five = Math.round(40 + (rating - 3.8) * 52);
|
||||||
|
const four = Math.round((100 - five) * 0.62);
|
||||||
|
const three = Math.round((100 - five - four) * 0.55);
|
||||||
|
const two = Math.round((100 - five - four - three) * 0.5);
|
||||||
|
return [five, four, three, two, 100 - five - four - three - two];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Three of a category's reviews, newest first, stable for a given product. */
|
||||||
|
function buildReviews(
|
||||||
|
rng: Rng,
|
||||||
|
kind: SampleProductKind,
|
||||||
|
productId: string,
|
||||||
|
): SampleProductReview[] {
|
||||||
|
const pool = REVIEW_COPY[kind].slice();
|
||||||
|
// Fisher-Yates, so a product shows a different three than its shelf-mate.
|
||||||
|
for (let i = pool.length - 1; i > 0; i--) {
|
||||||
|
const j = rng.int(0, i);
|
||||||
|
[pool[i], pool[j]] = [pool[j], pool[i]];
|
||||||
|
}
|
||||||
|
let daysAgo = rng.int(2, 9);
|
||||||
|
return pool.slice(0, 3).map(([rating, title, body], i) => {
|
||||||
|
daysAgo += rng.int(6, 40);
|
||||||
|
return {
|
||||||
|
id: `${productId}-rev-${i + 1}`,
|
||||||
|
author: REVIEW_AUTHORS[rng.int(0, REVIEW_AUTHORS.length - 1)],
|
||||||
|
rating,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
daysAgo,
|
||||||
|
// Most in-store reviews follow a bill; a few do not.
|
||||||
|
isVerifiedPurchase: rng.next() > 0.15,
|
||||||
|
helpfulCount: rng.int(0, 180),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The platforms the catalogue watches, and how much each one can be trusted.
|
||||||
|
*
|
||||||
|
* `evidence` is a property of the platform, not of the day: a retailer whose
|
||||||
|
* product pages can be fetched yields a read price every time, and a platform
|
||||||
|
* that blocks crawlers yields a search-result price every time. So the column
|
||||||
|
* reads the same way on every product, which is what makes it worth reading.
|
||||||
|
*
|
||||||
|
* `url` is the platform's own home page rather than a deep link, because a
|
||||||
|
* made-up product URL would open a 404 and look like a broken catalogue. Real
|
||||||
|
* offers carry the listing URL the price was read from.
|
||||||
|
*/
|
||||||
|
const CATALOG_PLATFORMS: {
|
||||||
|
platform: string;
|
||||||
|
isTamilNadu: boolean;
|
||||||
|
evidence: SampleEvidenceKind;
|
||||||
|
url: string;
|
||||||
|
/** How many search results this platform tends to yield for one product. */
|
||||||
|
listings: number;
|
||||||
|
}[] = [
|
||||||
|
{platform: 'Reliance Digital', isTamilNadu: false, evidence: 'product_page', url: 'https://www.reliancedigital.in/', listings: 1},
|
||||||
|
{platform: 'Poorvika', isTamilNadu: true, evidence: 'product_page', url: 'https://www.poorvika.com/', listings: 1},
|
||||||
|
{platform: 'Vijay Sales', isTamilNadu: false, evidence: 'product_page', url: 'https://www.vijaysales.com/', listings: 1},
|
||||||
|
{platform: 'Vasanth & Co', isTamilNadu: true, evidence: 'product_page', url: 'https://www.vasanthandco.in/', listings: 1},
|
||||||
|
{platform: 'Flipkart', isTamilNadu: false, evidence: 'search_result', url: 'https://www.flipkart.com/', listings: 1},
|
||||||
|
{platform: 'Amazon.in', isTamilNadu: false, evidence: 'search_result', url: 'https://www.amazon.in/', listings: 1},
|
||||||
|
{platform: 'Croma', isTamilNadu: false, evidence: 'search_result', url: 'https://www.croma.com/', listings: 1},
|
||||||
|
{platform: 'Tata CLiQ', isTamilNadu: false, evidence: 'search_result', url: 'https://www.tatacliq.com/', listings: 1},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Finishes a listing names. One per product, so every source agrees on it. */
|
||||||
|
const VARIANTS = [
|
||||||
|
'Natural Silver', 'Midnight Black', 'Graphite', 'Pearl White',
|
||||||
|
'Titanium Grey', 'Ocean Blue',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every platform offer for one product, best evidence first.
|
||||||
|
*
|
||||||
|
* Prices scatter around the store's own price rather than matching it: the point
|
||||||
|
* of the table is that the same product costs different amounts in different
|
||||||
|
* places, and a column of identical figures would say nothing. A read listing
|
||||||
|
* always states its price and stock; a search result often states neither, which
|
||||||
|
* is why so many cells say "unknown".
|
||||||
|
*/
|
||||||
|
function buildPlatforms(
|
||||||
|
rng: Rng,
|
||||||
|
productId: string,
|
||||||
|
priceInr: number,
|
||||||
|
): SampleProductPlatform[] {
|
||||||
|
const variant = VARIANTS[rng.int(0, VARIANTS.length - 1)];
|
||||||
|
const offers: SampleProductPlatform[] = [];
|
||||||
|
|
||||||
|
for (const site of CATALOG_PLATFORMS) {
|
||||||
|
// Not every platform carries every product — but the first one always does,
|
||||||
|
// because a product earns its place in the catalogue only once a retailer
|
||||||
|
// whose page can be read confirms it.
|
||||||
|
if (offers.length > 0 && rng.next() < 0.22) continue;
|
||||||
|
const read = site.evidence === 'product_page';
|
||||||
|
const count = read ? 1 : rng.int(1, site.listings);
|
||||||
|
|
||||||
|
for (let n = 0; n < count; n++) {
|
||||||
|
// A read listing always states a price; a search result usually does not.
|
||||||
|
const hasPrice = read ? rng.next() > 0.12 : rng.next() > 0.45;
|
||||||
|
offers.push({
|
||||||
|
id: `${productId}-plat-${offers.length + 1}`,
|
||||||
|
platform: site.platform,
|
||||||
|
isTamilNadu: site.isTamilNadu,
|
||||||
|
variant: read ? variant : undefined,
|
||||||
|
priceInr: hasPrice ? Math.round((priceInr * rng.float(0.92, 1.14)) / 10) * 10 : null,
|
||||||
|
// Stock is only ever known from a page that was actually read.
|
||||||
|
stock: read && hasPrice ? 'in_stock' : 'unknown',
|
||||||
|
evidence: site.evidence,
|
||||||
|
seenHoursAgo: rng.int(1, 9),
|
||||||
|
url: site.url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read listings first, then priced search results, cheapest first inside each
|
||||||
|
// group, and everything with no price at the bottom — the order a shopper
|
||||||
|
// comparing places to buy would put them in.
|
||||||
|
return offers.sort((a, b) => {
|
||||||
|
const rank = (o: SampleProductPlatform) =>
|
||||||
|
(o.evidence === 'product_page' ? 0 : 2) + (o.priceInr === null ? 1 : 0);
|
||||||
|
return rank(a) - rank(b) || (a.priceInr ?? 0) - (b.priceInr ?? 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The full catalogue for a scope, brand by brand. Deterministic per store and range. */
|
||||||
|
export function buildProductCatalog(storeId: string, range: RangeKey): SampleCatalogItem[] {
|
||||||
|
const rng = createRng('product-catalog', storeId, range);
|
||||||
|
const scale = storeScale(storeId) * RANGE_DAYS[range];
|
||||||
|
return SAMPLE_CATALOG.map((p, i): SampleCatalogItem => {
|
||||||
|
// Cheaper items move more units; noise so the ranking isn't just price.
|
||||||
|
const velocity = rng.float(0.5, 1.5) * Math.min(2.5, 12000 / p.priceInr);
|
||||||
|
const units = Math.max(1, Math.round(velocity * scale * 0.12));
|
||||||
|
const roll = rng.next();
|
||||||
|
const stock = roll < 0.08 ? 0 : roll < 0.24 ? rng.int(1, 9) : rng.int(10, 120);
|
||||||
|
const rating = Number(rng.float(3.8, 4.7).toFixed(1));
|
||||||
|
const id = `prd-${i + 1}`;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
slug: p.slug,
|
||||||
|
sku: `LY-${p.kind.toUpperCase().slice(0, 3)}-${String(1001 + i)}`,
|
||||||
|
brand: p.brand,
|
||||||
|
name: p.name,
|
||||||
|
specs: p.specs,
|
||||||
|
kind: p.kind,
|
||||||
|
category: p.category,
|
||||||
|
priceInr: p.priceInr,
|
||||||
|
mrpInr: p.mrpInr,
|
||||||
|
discountPct: Math.round((1 - p.priceInr / p.mrpInr) * 100),
|
||||||
|
rating,
|
||||||
|
reviews: rng.int(120, 48000),
|
||||||
|
units,
|
||||||
|
revenueInr: units * p.priceInr,
|
||||||
|
deltaPct: Number(rng.float(-12, 24).toFixed(1)),
|
||||||
|
stock,
|
||||||
|
stockStatus: stock === 0 ? 'out_of_stock' : stock < 10 ? 'low_stock' : 'in_stock',
|
||||||
|
ratingSpread: ratingSpread(rating),
|
||||||
|
reviewHighlights: buildReviews(rng, p.kind, id),
|
||||||
|
platforms: buildPlatforms(rng, id, p.priceInr),
|
||||||
|
};
|
||||||
|
}).sort(byBrand);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catalogue order: brand, then category, then best seller.
|
||||||
|
*
|
||||||
|
* Not by revenue. A revenue ranking mixes every brand together, so the grid
|
||||||
|
* reads as an arbitrary jumble — a Samsung TV, an Apple watch, a Voltas AC — and
|
||||||
|
* a merchant looking for "the Samsung ones" has to hunt the whole page for them.
|
||||||
|
* Shoppers and staff both arrive at a product the same way, by brand and then by
|
||||||
|
* the kind of thing it is, so that is the order the shelf is kept in. The sort
|
||||||
|
* control still offers revenue and the rest for when the question is a ranking.
|
||||||
|
*/
|
||||||
|
export function byBrand(a: SampleCatalogItem, b: SampleCatalogItem): number {
|
||||||
|
return (
|
||||||
|
a.brand.localeCompare(b.brand) ||
|
||||||
|
a.category.localeCompare(b.category) ||
|
||||||
|
b.revenueInr - a.revenueInr
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
|
Barcode,
|
||||||
Bell,
|
Bell,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Building2,
|
Building2,
|
||||||
@@ -47,6 +48,20 @@ import {
|
|||||||
Megaphone,
|
Megaphone,
|
||||||
Menu,
|
Menu,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
Headphones,
|
||||||
|
LayoutGrid,
|
||||||
|
Laptop,
|
||||||
|
List,
|
||||||
|
Microwave,
|
||||||
|
Refrigerator,
|
||||||
|
Smartphone,
|
||||||
|
Snowflake,
|
||||||
|
Star,
|
||||||
|
Tablet,
|
||||||
|
Tv,
|
||||||
|
WashingMachine,
|
||||||
|
Watch,
|
||||||
|
Package,
|
||||||
Mic,
|
Mic,
|
||||||
Minimize2,
|
Minimize2,
|
||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
@@ -68,8 +83,10 @@ import {
|
|||||||
Sliders,
|
Sliders,
|
||||||
Square,
|
Square,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
|
ScanBarcode,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Store,
|
Store,
|
||||||
|
Tag,
|
||||||
Target,
|
Target,
|
||||||
Ticket,
|
Ticket,
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
@@ -95,6 +112,25 @@ export const ICONS = {
|
|||||||
dashboard: LayoutDashboard,
|
dashboard: LayoutDashboard,
|
||||||
commerce: ShoppingCart,
|
commerce: ShoppingCart,
|
||||||
stores: Store,
|
stores: Store,
|
||||||
|
products: Package,
|
||||||
|
|
||||||
|
// Product catalogue — one glyph per product kind, plus the view toggle.
|
||||||
|
productTv: Tv,
|
||||||
|
productMobile: Smartphone,
|
||||||
|
productLaptop: Laptop,
|
||||||
|
productTablet: Tablet,
|
||||||
|
productFridge: Refrigerator,
|
||||||
|
productWasher: WashingMachine,
|
||||||
|
productAc: Snowflake,
|
||||||
|
productMicrowave: Microwave,
|
||||||
|
productAudio: Headphones,
|
||||||
|
productWearable: Watch,
|
||||||
|
rating: Star,
|
||||||
|
viewGrid: LayoutGrid,
|
||||||
|
viewList: List,
|
||||||
|
barcode: Barcode,
|
||||||
|
scanBarcode: ScanBarcode,
|
||||||
|
tag: Tag,
|
||||||
cameras: Camera,
|
cameras: Camera,
|
||||||
lyts: Gift,
|
lyts: Gift,
|
||||||
staff: Users,
|
staff: Users,
|
||||||
|
|||||||