update the prodect itesm

This commit is contained in:
2026-09-30 19:09:48 +05:30
parent 76839eede2
commit 418081a545
99 changed files with 3501 additions and 203 deletions

View 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View 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>
);
}

View File

@@ -1,23 +1,52 @@
import type {NextRequest} from 'next/server';
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 {serveUpstream} from '@/shared/services/bff';
import {UpstreamError} from '@/services/api/apiClient';
import {toTodaySummary} from '@/features/floor/services/mapSummary';
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.
*
* 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
* 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) {
return serveUpstream(
req,
(token, query) =>
reportsApi.todaySummary(token, DEFAULT_TZ, toSiteParam(query.storeId)),
toTodaySummary,
async (token, query) => {
const site = toSiteParam(query.storeId);
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),
);
}

View File

@@ -2,7 +2,7 @@ import type {NextRequest} from 'next/server';
import {salesApi} from '@/services/api/salesApi';
import {withUpstream} from '@/features/auth/services/upstreamSession';
import {failureFrom} from '@/shared/services/bff';
import {toSale} from '@/app/api/sales/route';
import {fromPlatformSale} from '@/services/api/saleMapper';
export const dynamic = 'force-dynamic';
@@ -20,7 +20,7 @@ export async function GET(
try {
const sale = await withUpstream((token) => salesApi.byId(token, id));
return Response.json(
{data: toSale(sale)},
{data: fromPlatformSale(sale)},
{headers: {'cache-control': 'no-store'}},
);
} catch (err) {

View File

@@ -4,7 +4,8 @@ import {toReportWindow, toSiteParam} from '@/services/api/range';
import {serveUpstream, failureFrom} from '@/shared/services/bff';
import {withUpstream} from '@/features/auth/services/upstreamSession';
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';
export const dynamic = 'force-dynamic';
@@ -42,37 +43,23 @@ export function toSale(s: ApiSale): Sale {
};
}
const PAGE_SIZE = 200;
/** 2,000 sales. A window past this answers with the newest 2,000. */
const MAX_PAGES = 10;
/** The platform's cap on one sales request. */
const SALES_LIMIT = 200;
/**
* 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
* 50-row page would silently under-report any shop with a busy month. The
* platform pages by `limit`/`offset` (limit capped at 200).
*
* Deduplicated by id, and it stops when a page adds nothing new: a platform
* build that ignores `offset` would otherwise hand back page one ten times and
* the totals would be ten times too high.
* The platform returns a bare array capped at 200 and has NO paging: `offset`
* is ignored and there is no cursor, because `purchases` has no monotonic
* 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
* newest 200; narrow the range for a busy shop.
*/
async function listWindow(
token: string,
filter: {site?: string; from: string; to: string},
): Promise<{items: ApiSale[]}> {
const seen = new Map<string, ApiSale>();
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()]};
): Promise<ApiPlatformSale[]> {
return platformSales(await salesApi.list(token, {...filter, limit: SALES_LIMIT}));
}
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
// does not exist is a real answer and still surfaces.
if (err instanceof UpstreamError && err.isRouteMissing) {
return {items: []};
return [];
}
throw err;
}
},
(page) => (page.items ?? []).map(toSale),
(rows) => rows.map(fromPlatformSale),
);
}

View File

@@ -72,12 +72,8 @@ export function LoginSplit({
<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="flex items-center gap-3">
<div className="flex items-center">
<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 className="mt-9 lg:mt-10">

View File

@@ -28,8 +28,8 @@ export function TodaySummaryCard({resource}: {resource: Resource<TodaySummary>})
value={today.currency === 'INR' ? formatInr(today.revenue) : `${today.revenue} ${today.currency}`}
/>
<StatPair label="Sales" value={formatCount(today.salesCount)} />
<StatPair label="Enquiries" value={formatCount(today.enquiriesCount)} />
<StatPair label="On the floor now" value={formatCount(today.onFloorNow)} />
<StatPair label="Enquiries" value={countOrDash(today.enquiriesCount)} />
<StatPair label="On the floor now" value={countOrDash(today.onFloorNow)} />
<StatPair label="Visitors today" value={formatCount(today.visitors)} align="end" />
</StatRow>
)}
@@ -37,3 +37,8 @@ export function TodaySummaryCard({resource}: {resource: Resource<TodaySummary>})
</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);
}

View File

@@ -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 {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 {
date: s.date,
revenue: (s.total_revenue_paise ?? 0) / 100,
currency: s.currency || 'INR',
salesCount: s.sales_count,
enquiriesCount: s.enquiries_count,
onFloorNow: s.active_customer_count,
visitors: s.visitors_count,
revenue: paise / 100,
currency: sales[0]?.currency || 'INR',
salesCount: sales.length,
enquiriesCount: null,
onFloorNow: null,
visitors: s.visitors ?? 0,
};
}

View File

@@ -11,8 +11,10 @@ export interface TodaySummary {
revenue: number;
currency: string;
salesCount: number;
enquiriesCount: number;
/** Waiting or being served right now. */
onFloorNow: number;
/** `null` until the platform records enquiries — shown as "—", never 0. */
enquiriesCount: number | null;
/** Waiting or being served right now. `null` while the platform has no
* source for it. */
onFloorNow: number | null;
visitors: number;
}

View File

@@ -23,7 +23,6 @@ import {BrandLogo} from '@/shared/components/brand/BrandLogo';
export function ChatHeader({onClose}: {onClose?: () => void}) {
const {
newChat,
reload,
isHistoryOpen,
setHistoryOpen,
panelMode,
@@ -67,14 +66,6 @@ export function ChatHeader({onClose}: {onClose?: () => void}) {
icon={<Icon icon={isFullscreen ? ICONS.minimize : ICONS.fullscreen} size="sm" />}
onClick={toggleFullscreen}
/>
<IconButton
variant="ghost"
size="sm"
label="Reload"
tooltip="Reload"
icon={<Icon icon={ICONS.reload} size="sm" />}
onClick={reload}
/>
<IconButton
variant="ghost"
size="sm"

View File

@@ -1,11 +1,13 @@
'use client';
import {useEffect, useRef} from 'react';
import {useEffect, useMemo, useRef, useState} from 'react';
import {StackItem, VStack} from '@astryxdesign/core/Layout';
import {useLoyalyAi} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
import {Composer} from './Composer';
import {EmptyState} from './EmptyState';
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. */
const CONTENT_MAX = 768;
@@ -46,6 +48,18 @@ const FOLLOW_SLACK_PX = 160;
export function Conversation() {
const {conversation, draft, setDraft, send, stop, isStreaming} = useLoyalyAi();
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 last = conversation.messages.at(-1);
// Changes on every new message AND on every streamed chunk.
@@ -63,32 +77,55 @@ export function Conversation() {
}
}, [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 (
<StackItem size="fill" className="bg-background">
<VStack height="100%">
<StackItem size="fill" isScrollable ref={scrollRef}>
<VStack
minHeight="100%"
vAlign={hasMessages ? 'start' : 'center'}
paddingInline={4}
paddingBlock={5}
>
<VStack minHeight="100%" vAlign="start" paddingInline={4} paddingBlock={5}>
<VStack gap={4} width="100%" maxWidth={CONTENT_MAX} className="mx-auto">
{hasMessages ? (
<MessageList messages={conversation.messages} isStreaming={isStreaming} />
) : (
<EmptyState onPick={send} />
)}
<MessageList messages={conversation.messages} isStreaming={isStreaming} />
</VStack>
</VStack>
</StackItem>
<VStack
paddingInline={3}
paddingBlock={3}
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
value={draft}
onChange={setDraft}

View File

@@ -1,17 +1,15 @@
'use client';
import {HStack, VStack} from '@astryxdesign/core/Layout';
import {VStack} from '@astryxdesign/core/Layout';
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 {ICONS} from '@/shared/utils/icons';
import {ACCENT} from '@/shared/utils/accent';
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';
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
/**
* 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 {
id: string;
tag: string;
@@ -23,124 +21,27 @@ export interface InsightCardItem {
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
* queries. The composer is NOT here — Conversation docks it below the scroll
* region so it sits in the same place on every screen.
*
* 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.
* The Loyaly AI landing view: the heart mark, one question, and the context it
* answers in. The composer sits directly below it (see Conversation), exactly
* as the Loyaly AI panel in the merchant console.
*/
export function EmptyState({onPick}: {onPick?: (prompt: string) => void}) {
export function EmptyState() {
const scopeLabel = useScopeLabel();
return (
<VStack gap={6} width="100%">
<VStack gap={2} hAlign="center">
<BrandMark size={48} priority />
<Heading level={2} justify="center">
Ready to optimize your stores?
<VStack gap={0} hAlign="center" width="100%" maxWidth={576} className="mx-auto text-center">
<VStack hAlign="center" className="mb-6">
<BrandMark size={56} priority />
</VStack>
<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>
<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>
<Text type="supporting" justify="center" className="text-sm">
Business context · {scopeLabel}
</Text>
</VStack>
);
}

View 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>
);
}

View 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}
</>
);
}

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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]);
}

View 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'},
};

View 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};
}

View File

@@ -2,7 +2,7 @@ import 'server-only';
import {upstreamRequest} from './apiClient';
import type {
ApiConversionReport,
ApiDashboardSummary,
ApiPlatformDashboardSummary,
ApiFootfallReport,
} from './types';
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
* footfall. With no `from`/`to` the platform uses TODAY in `tz` — the
* Today at a glance: footfall and shop health (no money - see the BFF
* 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
* screen asks, so no window is sent.
*/
todaySummary: (accessToken: string, tz: string, site?: string) =>
upstreamRequest<ApiDashboardSummary>({
upstreamRequest<ApiPlatformDashboardSummary>({
path: '/api/dashboard/summary',
query: {tz, site},
accessToken,

View 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[]) : [];
}

View File

@@ -1,6 +1,6 @@
import 'server-only';
import {upstreamRequest} from './apiClient';
import type {ApiSale, ApiSaleResult} from './types';
import type {ApiPlatformSale, ApiSaleResult} from './types';
/**
* 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
* only writer the current mobile flow has, and migrating it would break a
* 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 = {
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: (
accessToken: string,
@@ -34,5 +38,5 @@ export const salesApi = {
}),
byId: (accessToken: string, id: string) =>
upstreamRequest<ApiSale>({path: `/api/sales/${encodeURIComponent(id)}`, accessToken}),
upstreamRequest<ApiPlatformSale>({path: `/api/sales/${encodeURIComponent(id)}`, accessToken}),
};

View File

@@ -609,6 +609,50 @@ export interface ApiSaleResult {
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/* ──────────────────────────
*
* The one surface upstream that is NOT tenant-scoped. Every shape below is

View 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};
}

View File

@@ -14,9 +14,10 @@ export interface NavEntry {
*/
export const PRIMARY_NAV: NavEntry[] = [
{label: 'Dashboard', href: '/dashboard', icon: ICONS.dashboard},
{label: 'Store', href: '/stores', icon: ICONS.stores},
{label: 'Commerce', href: '/commerce', icon: ICONS.commerce},
{label: 'Store', href: '/stores', icon: ICONS.stores},
{label: 'Leaderboard', href: '/staff', icon: ICONS.leaderboard},
{label: 'Products', href: '/products', icon: ICONS.products},
{label: 'Lyts', href: '/lyts', icon: ICONS.lyts},
];

View File

@@ -121,6 +121,8 @@ export function createRng(...parts: (string | number)[]) {
};
}
export type Rng = ReturnType<typeof createRng>;
// ===========================================================================
// Stores — the fixture roster every sample shares
// (was features/stores/mocks/stores.mock.ts)
@@ -1860,3 +1862,428 @@ export function buildSampleJourney(range: RangeKey, storeId: string, endMs: numb
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
);
}

View File

@@ -13,6 +13,7 @@
import {
ArrowRight,
BadgeCheck,
Barcode,
Bell,
BookOpen,
Building2,
@@ -47,6 +48,20 @@ import {
Megaphone,
Menu,
MessageSquare,
Headphones,
LayoutGrid,
Laptop,
List,
Microwave,
Refrigerator,
Smartphone,
Snowflake,
Star,
Tablet,
Tv,
WashingMachine,
Watch,
Package,
Mic,
Minimize2,
PanelLeftClose,
@@ -68,8 +83,10 @@ import {
Sliders,
Square,
SquarePen,
ScanBarcode,
Sparkles,
Store,
Tag,
Target,
Ticket,
TrendingDown,
@@ -95,6 +112,25 @@ export const ICONS = {
dashboard: LayoutDashboard,
commerce: ShoppingCart,
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,
lyts: Gift,
staff: Users,