update the prodect itesm
240
public/sample-products/CREDITS.md
Normal file
@@ -0,0 +1,240 @@
|
||||
# Sample product photographs — sources and licences
|
||||
|
||||
Every picture under this folder is a freely licensed photograph from Wikimedia
|
||||
Commons, padded to 4:3 and re-encoded. Nothing here was drawn, generated or
|
||||
bought, and nothing is a manufacturer press image.
|
||||
|
||||
**These are category stand-ins, not the products they sit beside.** The file
|
||||
`bosch-washer-front-1.jpg` is a photograph of *a* front-loading washing machine,
|
||||
not of that Bosch; `iphone-16-1.jpg` is *an* iPhone, not the 16. They exist so a
|
||||
merchant reading the catalogue sees a refrigerator where a refrigerator belongs
|
||||
and a phone where a phone belongs, while the real images are still coming from
|
||||
the Product API. When `SampleCatalogItem.imageUrls` arrives populated, this whole
|
||||
folder and `PRODUCT_GALLERY` in
|
||||
`src/features/products/utils/productArtwork.ts` should go.
|
||||
|
||||
## Why one file per product and not one per category
|
||||
|
||||
An earlier version kept ten pictures, one per product *kind*, which put the same
|
||||
refrigerator on the Samsung single-door, the LG side-by-side and the Whirlpool
|
||||
double-door, and the same phone on the iPhone, the Galaxy and the OnePlus. A
|
||||
catalogue where three different products show one picture reads as broken data.
|
||||
The per-kind files below are kept only as a last-resort floor for a product with
|
||||
no gallery of its own.
|
||||
|
||||
## Licence obligations
|
||||
|
||||
Several of these licences require attribution by name, and the share-alike ones
|
||||
require that credit travel with the image. This file is that credit: keep it
|
||||
beside the images, and reproduce the relevant rows anywhere the images are
|
||||
published outside the dev build. No CC-NC or CC-ND work was taken — the
|
||||
harvester rejected those licences outright — so nothing here is limited to
|
||||
non-commercial use.
|
||||
|
||||
Licence names are as Commons reports them. `CC BY x.x` requires credit;
|
||||
`CC BY-SA x.x` requires credit and share-alike; `Public domain` and `CC0`
|
||||
require neither, and are credited anyway.
|
||||
|
||||
## gallery/ — per product
|
||||
|
||||
`<slug>-1.jpg` is the primary shot: the one the catalogue tile and the detail
|
||||
dialog's opening frame use. The rest fill the dialog's thumbnail carousel.
|
||||
|
||||
### Samsung Crystal 4K UHD Smart TV 55"
|
||||
|
||||
| file | source photograph | photographer | licence |
|
||||
| --- | --- | --- | --- |
|
||||
| `samsung-tv-55-1.jpg` | [Samsung Smart TV 2012 (E-Series).jpg](https://commons.wikimedia.org/wiki/File%3ASamsung_Smart_TV_2012_%28E-Series%29.jpg) | Dancingpolishcow | CC BY-SA 4.0 |
|
||||
| `samsung-tv-55-2.jpg` | [Samsung LED TV.jpg](https://commons.wikimedia.org/wiki/File%3ASamsung_LED_TV.jpg) | Kskhh | CC BY-SA 4.0 |
|
||||
| `samsung-tv-55-3.jpg` | [Samsung Series 5, Indonesia 20140422.jpg](https://commons.wikimedia.org/wiki/File%3ASamsung_Series_5%2C_Indonesia_20140422.jpg) | Crisco 1492 | CC BY-SA 3.0 |
|
||||
|
||||
### LG OLED evo C4 Smart TV 65"
|
||||
|
||||
| file | source photograph | photographer | licence |
|
||||
| --- | --- | --- | --- |
|
||||
| `lg-oled-65-1.jpg` | [LG OLED TV.jpg](https://commons.wikimedia.org/wiki/File%3ALG_OLED_TV.jpg) | LG Electronics | CC BY 2.0 |
|
||||
| `lg-oled-65-2.jpg` | [LG 로봇, 업계 첫 디자인 '대통령상' 수상 (24107621107).jpg](https://commons.wikimedia.org/wiki/File%3ALG_%EB%A1%9C%EB%B4%87%2C_%EC%97%85%EA%B3%84_%EC%B2%AB_%EB%94%94%EC%9E%90%EC%9D%B8_%27%EB%8C%80%ED%86%B5%EB%A0%B9%EC%83%81%27_%EC%88%98%EC%83%81_%2824107621107%29.jpg) | LG전자 | CC BY 2.0 |
|
||||
|
||||
### Sony Bravia 2 LED Google TV 43"
|
||||
|
||||
| file | source photograph | photographer | licence |
|
||||
| --- | --- | --- | --- |
|
||||
| `sony-tv-43-1.jpg` | [HTD Sony DigiDads 5683 DSC00119.jpg](https://commons.wikimedia.org/wiki/File%3AHTD_Sony_DigiDads_5683_DSC00119.jpg) | Michael Sheehan | CC BY 2.0 |
|
||||
| `sony-tv-43-2.jpg` | [HTD Sony DigiDads 5683 DSC00116.jpg](https://commons.wikimedia.org/wiki/File%3AHTD_Sony_DigiDads_5683_DSC00116.jpg) | Michael Sheehan | CC BY 2.0 |
|
||||
|
||||
### Apple iPhone 16 (128 GB)
|
||||
|
||||
| file | source photograph | photographer | licence |
|
||||
| --- | --- | --- | --- |
|
||||
| `iphone-16-1.jpg` | [About iPhone 16 Plus in Pink.jpg](https://commons.wikimedia.org/wiki/File%3AAbout_iPhone_16_Plus_in_Pink.jpg) | メイド理世 | CC BY-SA 4.0 |
|
||||
| `iphone-16-2.jpg` | [About iPhone 16 Pro White Titanium.jpg](https://commons.wikimedia.org/wiki/File%3AAbout_iPhone_16_Pro_White_Titanium.jpg) | メイド理世 | CC BY-SA 4.0 |
|
||||
| `iphone-16-3.jpg` | [About iPhone 16 Plus in dark mode.jpg](https://commons.wikimedia.org/wiki/File%3AAbout_iPhone_16_Plus_in_dark_mode.jpg) | メイド理世 | CC BY-SA 4.0 |
|
||||
|
||||
### Samsung Galaxy S24 FE 5G (256 GB)
|
||||
|
||||
| file | source photograph | photographer | licence |
|
||||
| --- | --- | --- | --- |
|
||||
| `galaxy-s24-fe-1.jpg` | [SAMSUNG Galaxy S24 Ultra (2).jpg](https://commons.wikimedia.org/wiki/File%3ASAMSUNG_Galaxy_S24_Ultra_%282%29.jpg) | Dinkun Chen | CC BY-SA 4.0 |
|
||||
| `galaxy-s24-fe-2.jpg` | [SAMSUNG Galaxy S24 Ultra.jpg](https://commons.wikimedia.org/wiki/File%3ASAMSUNG_Galaxy_S24_Ultra.jpg) | Dinkun Chen | CC BY-SA 4.0 |
|
||||
| `galaxy-s24-fe-3.jpg` | [SAMSUNG Galaxy S24 Ultra (6).jpg](https://commons.wikimedia.org/wiki/File%3ASAMSUNG_Galaxy_S24_Ultra_%286%29.jpg) | Dinkun Chen | CC BY-SA 4.0 |
|
||||
|
||||
### OnePlus Nord CE4 5G (128 GB)
|
||||
|
||||
| file | source photograph | photographer | licence |
|
||||
| --- | --- | --- | --- |
|
||||
| `oneplus-nord-ce4-1.jpg` | [OnePlus Nord 2T.png](https://commons.wikimedia.org/wiki/File%3AOnePlus_Nord_2T.png) | Rupturestriker | CC BY-SA 4.0 |
|
||||
| `oneplus-nord-ce4-2.jpg` | [Oneplus Nord.jpg](https://commons.wikimedia.org/wiki/File%3AOneplus_Nord.jpg) | Gannu03 | CC BY-SA 4.0 |
|
||||
| `oneplus-nord-ce4-3.jpg` | [OnePlus Nord 5 back.jpg](https://commons.wikimedia.org/wiki/File%3AOnePlus_Nord_5_back.jpg) | Maksdroider | CC BY-SA 4.0 |
|
||||
|
||||
### Redmi Note 14 5G (128 GB)
|
||||
|
||||
| file | source photograph | photographer | licence |
|
||||
| --- | --- | --- | --- |
|
||||
| `redmi-note-14-1.jpg` | [Redmi Note 10T (Back panel).jpg](https://commons.wikimedia.org/wiki/File%3ARedmi_Note_10T_%28Back_panel%29.jpg) | Astra.L | CC0 |
|
||||
| `redmi-note-14-2.jpg` | [REDMI Note 15 and REDMI Note 15 5G back.jpg](https://commons.wikimedia.org/wiki/File%3AREDMI_Note_15_and_REDMI_Note_15_5G_back.jpg) | Maksdroider | CC BY-SA 4.0 |
|
||||
| `redmi-note-14-3.jpg` | [Redmi Note 10 Pro back (4).jpg](https://commons.wikimedia.org/wiki/File%3ARedmi_Note_10_Pro_back_%284%29.jpg) | Maksdroider | CC BY-SA 4.0 |
|
||||
|
||||
### HP Pavilion 15 Laptop
|
||||
|
||||
| file | source photograph | photographer | licence |
|
||||
| --- | --- | --- | --- |
|
||||
| `hp-pavilion-15-1.jpg` | [HP Pavilion 15 cs3095nr.jpg](https://commons.wikimedia.org/wiki/File%3AHP_Pavilion_15_cs3095nr.jpg) | Dienthoaiquangcao62 | CC BY-SA 4.0 |
|
||||
| `hp-pavilion-15-2.jpg` | [HP Laptop.jpg](https://commons.wikimedia.org/wiki/File%3AHP_Laptop.jpg) | Srini297 | CC BY-SA 4.0 |
|
||||
| `hp-pavilion-15-3.jpg` | [HP Pavilion 15 cs3095nr.png](https://commons.wikimedia.org/wiki/File%3AHP_Pavilion_15_cs3095nr.png) | Dienthoaiquangcao68 | CC BY-SA 4.0 |
|
||||
|
||||
### Apple MacBook Air M3 13"
|
||||
|
||||
| file | source photograph | photographer | licence |
|
||||
| --- | --- | --- | --- |
|
||||
| `macbook-air-m3-1.jpg` | [M2 Macbook Air Midnight model - 1.jpg](https://commons.wikimedia.org/wiki/File%3AM2_Macbook_Air_Midnight_model_-_1.jpg) | KKPCW(Kyu3) | CC BY-SA 4.0 |
|
||||
| `macbook-air-m3-2.jpg` | [11" MacBook Air compared to 13" MacBook.jpg](https://commons.wikimedia.org/wiki/File%3A11%22_MacBook_Air_compared_to_13%22_MacBook.jpg) | Paul Hudson from United Kingdom | CC BY 2.0 |
|
||||
| `macbook-air-m3-3.jpg` | [MacBook Air.jpeg](https://commons.wikimedia.org/wiki/File%3AMacBook_Air.jpeg) | User:jpp44345 | CC BY-SA 3.0 |
|
||||
|
||||
### Samsung Galaxy Tab S9 FE Wi-Fi
|
||||
|
||||
| file | source photograph | photographer | licence |
|
||||
| --- | --- | --- | --- |
|
||||
| `galaxy-tab-s9-fe-1.jpg` | [20251110 181425 Samsung Galaxy Tab A9 Front.jpg](https://commons.wikimedia.org/wiki/File%3A20251110_181425_Samsung_Galaxy_Tab_A9_Front.jpg) | Saimmx | CC0 |
|
||||
| `galaxy-tab-s9-fe-2.jpg` | [Example of a working Samsung Galaxy Tab 4 in use.jpg](https://commons.wikimedia.org/wiki/File%3AExample_of_a_working_Samsung_Galaxy_Tab_4_in_use.jpg) | InstyTheScotFlag | CC BY 4.0 |
|
||||
| `galaxy-tab-s9-fe-3.jpg` | [20251110 181454 - Samsung Galaxy Tab A9 Back.jpg](https://commons.wikimedia.org/wiki/File%3A20251110_181454_-_Samsung_Galaxy_Tab_A9_Back.jpg) | Saimmx | CC0 |
|
||||
|
||||
### LG 655 L Side-by-Side Refrigerator
|
||||
|
||||
| file | source photograph | photographer | licence |
|
||||
| --- | --- | --- | --- |
|
||||
| `lg-fridge-sbs-1.jpg` | [LG refrigerator.jpg](https://commons.wikimedia.org/wiki/File%3ALG_refrigerator.jpg) | Milad Mosapoor | Attribution |
|
||||
| `lg-fridge-sbs-2.jpg` | [LG refrigerator interior.jpg](https://commons.wikimedia.org/wiki/File%3ALG_refrigerator_interior.jpg) | Milad Mosapoor | Attribution |
|
||||
| `lg-fridge-sbs-3.jpg` | [LG refrigerator.jpg](https://commons.wikimedia.org/wiki/File%3ALG_refrigerator.jpg) | Milad Mosapoor | Attribution |
|
||||
|
||||
### Whirlpool 265 L Frost Free Double Door
|
||||
|
||||
| file | source photograph | photographer | licence |
|
||||
| --- | --- | --- | --- |
|
||||
| `whirlpool-fridge-dd-1.jpg` | [Panasonic HOME REFRIGERATOR NR-C320WP-N.jpg](https://commons.wikimedia.org/wiki/File%3APanasonic_HOME_REFRIGERATOR_NR-C320WP-N.jpg) | Dinkun Chen | CC BY-SA 4.0 |
|
||||
| `whirlpool-fridge-dd-2.jpg` | [Hisense, IFA 2018, Berlin (P1070179).jpg](https://commons.wikimedia.org/wiki/File%3AHisense%2C_IFA_2018%2C_Berlin_%28P1070179%29.jpg) | Matti Blume | CC BY-SA 4.0 |
|
||||
| `whirlpool-fridge-dd-3.jpg` | [Sanyo Refrigerator SR-14T (edited and cropped).jpg](https://commons.wikimedia.org/wiki/File%3ASanyo_Refrigerator_SR-14T_%28edited_and_cropped%29.jpg) | Mr.ちゅらさん | CC BY-SA 4.0 |
|
||||
|
||||
### Samsung 183 L Single Door Refrigerator
|
||||
|
||||
| file | source photograph | photographer | licence |
|
||||
| --- | --- | --- | --- |
|
||||
| `samsung-fridge-sd-1.jpg` | [Prestcold packaway refrigerator.jpg](https://commons.wikimedia.org/wiki/File%3APrestcold_packaway_refrigerator.jpg) | Frankie Roberto | CC BY 2.0 |
|
||||
| `samsung-fridge-sd-2.jpg` | [Open refrigerator with food at night.jpg](https://commons.wikimedia.org/wiki/File%3AOpen_refrigerator_with_food_at_night.jpg) | W.carter | CC BY-SA 4.0 |
|
||||
| `samsung-fridge-sd-3.jpg` | [02 Plastic material issue - injection molding discoloration or plastic element yellowing with age.jpg](https://commons.wikimedia.org/wiki/File%3A02_Plastic_material_issue_-_injection_molding_discoloration_or_plastic_element_yellowing_with_age.jpg) | Marek Ślusarczyk (Tupungato) Photo portfolio | CC BY 3.0 |
|
||||
|
||||
### Bosch 8 kg Front Load Washing Machine
|
||||
|
||||
| file | source photograph | photographer | licence |
|
||||
| --- | --- | --- | --- |
|
||||
| `bosch-washer-front-1.jpg` | [Fagor washing machine front FF6314.jpg](https://commons.wikimedia.org/wiki/File%3AFagor_washing_machine_front_FF6314.jpg) | Dany kg | Public domain |
|
||||
| `bosch-washer-front-2.jpg` | [A Washing machine IMG 3785.jpg](https://commons.wikimedia.org/wiki/File%3AA_Washing_machine_IMG_3785.jpg) | ברוך שם | CC BY-SA 4.0 |
|
||||
| `bosch-washer-front-3.jpg` | [Waschmaschine.jpg](https://commons.wikimedia.org/wiki/File%3AWaschmaschine.jpg) | Fabian Kuntz | Public domain |
|
||||
|
||||
### IFB 7 kg Top Load Washing Machine
|
||||
|
||||
| file | source photograph | photographer | licence |
|
||||
| --- | --- | --- | --- |
|
||||
| `ifb-washer-top-1.jpg` | [Miele Wa75T1El2 H1a.jpg](https://commons.wikimedia.org/wiki/File%3AMiele_Wa75T1El2_H1a.jpg) | Christoph Rieder | CC0 |
|
||||
| `ifb-washer-top-2.jpg` | [Open top-loading washing machine.jpg](https://commons.wikimedia.org/wiki/File%3AOpen_top-loading_washing_machine.jpg) | W.carter | CC BY-SA 4.0 |
|
||||
| `ifb-washer-top-3.jpg` | [Open top-loading washing machine.jpg](https://commons.wikimedia.org/wiki/File%3AOpen_top-loading_washing_machine.jpg) | W.carter | CC BY-SA 4.0 |
|
||||
|
||||
### Voltas 1.5 Ton Inverter Split AC
|
||||
|
||||
| file | source photograph | photographer | licence |
|
||||
| --- | --- | --- | --- |
|
||||
| `voltas-ac-15t-1.jpg` | [Sanyo SAP-KRV-93EH.jpg](https://commons.wikimedia.org/wiki/File%3ASanyo_SAP-KRV-93EH.jpg) | Piisamson | CC BY-SA 3.0 |
|
||||
| `voltas-ac-15t-2.jpg` | [Panasonic AIR CONDITIONER INDOOR UNIT CS-C10KJ2 (2).jpg](https://commons.wikimedia.org/wiki/File%3APanasonic_AIR_CONDITIONER_INDOOR_UNIT_CS-C10KJ2_%282%29.jpg) | Dinkun Chen | CC BY-SA 4.0 |
|
||||
|
||||
### Daikin 1 Ton Inverter Split AC
|
||||
|
||||
| file | source photograph | photographer | licence |
|
||||
| --- | --- | --- | --- |
|
||||
| `daikin-ac-1t-1.jpg` | [DAIKIN AIR CONDITIONER INDOOR UNIT (12).jpg](https://commons.wikimedia.org/wiki/File%3ADAIKIN_AIR_CONDITIONER_INDOOR_UNIT_%2812%29.jpg) | Dinkun Chen | CC BY-SA 4.0 |
|
||||
| `daikin-ac-1t-2.jpg` | [DAIKIN AIR CONDITIONER INDOOR UNIT (10).jpg](https://commons.wikimedia.org/wiki/File%3ADAIKIN_AIR_CONDITIONER_INDOOR_UNIT_%2810%29.jpg) | Dinkun Chen | CC BY-SA 4.0 |
|
||||
| `daikin-ac-1t-3.jpg` | [DAIKIN AIR CONDITIONER INDOOR UNIT (14).jpg](https://commons.wikimedia.org/wiki/File%3ADAIKIN_AIR_CONDITIONER_INDOOR_UNIT_%2814%29.jpg) | Dinkun Chen | CC BY-SA 4.0 |
|
||||
|
||||
### Panasonic 27 L Convection Microwave
|
||||
|
||||
| file | source photograph | photographer | licence |
|
||||
| --- | --- | --- | --- |
|
||||
| `panasonic-microwave-27l-1.jpg` | [Black Panasonic Microwave.jpg](https://commons.wikimedia.org/wiki/File%3ABlack_Panasonic_Microwave.jpg) | Stacyz76 | CC BY 2.0 |
|
||||
| `panasonic-microwave-27l-2.jpg` | [Forno a microonde Panasonic.jpg](https://commons.wikimedia.org/wiki/File%3AForno_a_microonde_Panasonic.jpg) | Air fans | CC BY-SA 4.0 |
|
||||
| `panasonic-microwave-27l-3.jpg` | [LG Microwave oven.jpg](https://commons.wikimedia.org/wiki/File%3ALG_Microwave_oven.jpg) | Santeri Viinamäki | CC BY-SA 4.0 |
|
||||
|
||||
### Sony WH-1000XM5 Wireless Headphones
|
||||
|
||||
| file | source photograph | photographer | licence |
|
||||
| --- | --- | --- | --- |
|
||||
| `sony-wh1000xm5-1.jpg` | [Bose QuietComfort 25 Acoustic Noise Cancelling Headphones.jpg](https://commons.wikimedia.org/wiki/File%3ABose_QuietComfort_25_Acoustic_Noise_Cancelling_Headphones.jpg) | Florian Fuchs | CC BY-SA 3.0 |
|
||||
| `sony-wh1000xm5-2.jpg` | [Bose QuietComfort 25 Acoustic Noise Cancelling Headphones with Carry Case.jpg](https://commons.wikimedia.org/wiki/File%3ABose_QuietComfort_25_Acoustic_Noise_Cancelling_Headphones_with_Carry_Case.jpg) | Florian Fuchs | CC BY-SA 3.0 |
|
||||
| `sony-wh1000xm5-3.jpg` | [MiNe-5DII 106-0740U (25899709505).jpg](https://commons.wikimedia.org/wiki/File%3AMiNe-5DII_106-0740U_%2825899709505%29.jpg) | MiNe from Taipei, Taiwan | CC BY 2.0 |
|
||||
|
||||
### boAt Airdopes 141 TWS Earbuds
|
||||
|
||||
| file | source photograph | photographer | licence |
|
||||
| --- | --- | --- | --- |
|
||||
| `boat-airdopes-141-1.jpg` | [Technics-EAH-AZ60M2 09.jpg](https://commons.wikimedia.org/wiki/File%3ATechnics-EAH-AZ60M2_09.jpg) | RuinDig/Yuki Uchida | CC BY 4.0 |
|
||||
| `boat-airdopes-141-2.jpg` | [Earbuds with UKCA marking.jpg](https://commons.wikimedia.org/wiki/File%3AEarbuds_with_UKCA_marking.jpg) | 159753 | CC BY-SA 4.0 |
|
||||
|
||||
### Apple Watch SE (2nd Gen) 44mm
|
||||
|
||||
| file | source photograph | photographer | licence |
|
||||
| --- | --- | --- | --- |
|
||||
| `apple-watch-se-1.jpg` | [Apple Watch 8 (cropped).jpg](https://commons.wikimedia.org/wiki/File%3AApple_Watch_8_%28cropped%29.jpg) | RT2231 | CC BY-SA 4.0 |
|
||||
| `apple-watch-se-2.jpg` | [Apple Watch 8 with Box (cropped).jpg](https://commons.wikimedia.org/wiki/File%3AApple_Watch_8_with_Box_%28cropped%29.jpg) | iGeeksBlog | CC BY-SA 4.0 |
|
||||
| `apple-watch-se-3.jpg` | [Apple Watch 8.jpg](https://commons.wikimedia.org/wiki/File%3AApple_Watch_8.jpg) | RT2231 | CC BY-SA 4.0 |
|
||||
|
||||
### Noise ColorFit Pro 6 Smartwatch
|
||||
|
||||
| file | source photograph | photographer | licence |
|
||||
| --- | --- | --- | --- |
|
||||
| `noise-colorfit-pro-6-1.jpg` | [Altura Estadio Mushuc Runa.jpg](https://commons.wikimedia.org/wiki/File%3AAltura_Estadio_Mushuc_Runa.jpg) | Sdavidortizo | CC BY-SA 4.0 |
|
||||
| `noise-colorfit-pro-6-2.jpg` | [AmazfitBand7.jpg](https://commons.wikimedia.org/wiki/File%3AAmazfitBand7.jpg) | Silbof | CC BY-SA 4.0 |
|
||||
| `noise-colorfit-pro-6-3.jpg` | [Huawei Watch (16928913667).jpg](https://commons.wikimedia.org/wiki/File%3AHuawei_Watch_%2816928913667%29.jpg) | Maurizio Pesce from Milan, Italia | CC BY 2.0 |
|
||||
|
||||
## Per-kind fallbacks (one level up)
|
||||
|
||||
`KIND_IMAGE` still points at these for a product that has no gallery entry —
|
||||
a new kind, or a product the API sends before its pictures exist.
|
||||
|
||||
| file | source photograph | photographer | licence |
|
||||
| --- | --- | --- | --- |
|
||||
| `tv.jpg` | [LG 72" Cinema 3D Smart TV](https://commons.wikimedia.org/wiki/File:LG_72%22_Cinema_3D_Smart_TV.jpg) | LG전자 | CC BY 2.0 |
|
||||
| `mobile.jpg` | [Blackview A60](https://commons.wikimedia.org/wiki/File:Blackview_A60.jpg) | Acabashi | CC BY-SA 4.0 |
|
||||
| `laptop.jpg` | [Lenovo G500s](https://commons.wikimedia.org/wiki/File:Lenovo_G500s_laptop-2905.jpg) | Raimond Spekking | CC BY-SA 4.0 |
|
||||
| `tablet.jpg` | [Tablet computer](https://commons.wikimedia.org/wiki/File:Tablet_computer.jpg) | KK IN HK | CC BY-SA 4.0 |
|
||||
| `fridge.jpg` | [Samsung RF60J9030WZ](https://commons.wikimedia.org/wiki/File:Samsung_RF60J9030WZ.jpg) | Dinkun Chen | CC BY-SA 4.0 |
|
||||
| `washer.jpg` | [Beko washing machine](https://commons.wikimedia.org/wiki/File:Beko_Washing_Machine.JPG) | zzpza | CC BY 2.0 |
|
||||
| `ac.jpg` | [Panasonic CS-C10KJ2](https://commons.wikimedia.org/wiki/File:Panasonic_CS-C10KJ2.jpg) | Dinkun Chen | CC BY-SA 4.0 |
|
||||
| `microwave.jpg` | [Microwave oven](https://commons.wikimedia.org/wiki/File:Microwave_oven_flashon.jpg) | Wrightmt | Public domain |
|
||||
| `audio.jpg` | [Bluetooth speaker NBY-18](https://commons.wikimedia.org/wiki/File:Bluetooth_speaker_NBY-18.jpg) | Alexxx1979 | CC BY-SA 4.0 |
|
||||
| `wearable.jpg` | [PineTime](https://commons.wikimedia.org/wiki/File:PineTime.jpg) | Unnerving duck | CC0 |
|
||||
|
||||
## How the files were made
|
||||
|
||||
Harvested from the Commons search API, hand-picked off contact sheets, then
|
||||
fitted — not cropped — inside an 800×600 box with the canvas colour sampled from
|
||||
each photograph's own outer ring. Cropping to a landscape box is what once
|
||||
turned a tall refrigerator into a white rectangle and a phone into four app
|
||||
icons. The picking and padding scripts are dev scratch work and are not part of
|
||||
the app.
|
||||
BIN
public/sample-products/ac.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/sample-products/audio.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
public/sample-products/fridge.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/sample-products/gallery/apple-watch-se-1.jpg
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
public/sample-products/gallery/apple-watch-se-2.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/sample-products/gallery/apple-watch-se-3.jpg
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
public/sample-products/gallery/boat-airdopes-141-1.jpg
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
public/sample-products/gallery/boat-airdopes-141-2.jpg
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
public/sample-products/gallery/bosch-washer-front-1.jpg
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
public/sample-products/gallery/bosch-washer-front-2.jpg
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
public/sample-products/gallery/bosch-washer-front-3.jpg
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
public/sample-products/gallery/daikin-ac-1t-1.jpg
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
public/sample-products/gallery/daikin-ac-1t-2.jpg
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
public/sample-products/gallery/daikin-ac-1t-3.jpg
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
public/sample-products/gallery/galaxy-s24-fe-1.jpg
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
public/sample-products/gallery/galaxy-s24-fe-2.jpg
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
public/sample-products/gallery/galaxy-s24-fe-3.jpg
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
public/sample-products/gallery/galaxy-tab-s9-fe-1.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
public/sample-products/gallery/galaxy-tab-s9-fe-2.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/sample-products/gallery/galaxy-tab-s9-fe-3.jpg
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
public/sample-products/gallery/hp-pavilion-15-1.jpg
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
public/sample-products/gallery/hp-pavilion-15-2.jpg
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
public/sample-products/gallery/hp-pavilion-15-3.jpg
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
public/sample-products/gallery/ifb-washer-top-1.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
public/sample-products/gallery/ifb-washer-top-2.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/sample-products/gallery/ifb-washer-top-3.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/sample-products/gallery/iphone-16-1.jpg
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
public/sample-products/gallery/iphone-16-2.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
public/sample-products/gallery/iphone-16-3.jpg
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
public/sample-products/gallery/lg-fridge-sbs-1.jpg
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
public/sample-products/gallery/lg-fridge-sbs-2.jpg
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
public/sample-products/gallery/lg-fridge-sbs-3.jpg
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
public/sample-products/gallery/lg-oled-65-1.jpg
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
public/sample-products/gallery/lg-oled-65-2.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
public/sample-products/gallery/macbook-air-m3-1.jpg
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
public/sample-products/gallery/macbook-air-m3-2.jpg
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
public/sample-products/gallery/macbook-air-m3-3.jpg
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
public/sample-products/gallery/noise-colorfit-pro-6-1.jpg
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
public/sample-products/gallery/noise-colorfit-pro-6-2.jpg
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
public/sample-products/gallery/noise-colorfit-pro-6-3.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
public/sample-products/gallery/oneplus-nord-ce4-1.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
public/sample-products/gallery/oneplus-nord-ce4-2.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/sample-products/gallery/oneplus-nord-ce4-3.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
public/sample-products/gallery/panasonic-microwave-27l-1.jpg
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
public/sample-products/gallery/panasonic-microwave-27l-2.jpg
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
public/sample-products/gallery/panasonic-microwave-27l-3.jpg
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
public/sample-products/gallery/redmi-note-14-1.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/sample-products/gallery/redmi-note-14-2.jpg
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
public/sample-products/gallery/redmi-note-14-3.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
public/sample-products/gallery/samsung-fridge-sd-1.jpg
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
public/sample-products/gallery/samsung-fridge-sd-2.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
public/sample-products/gallery/samsung-fridge-sd-3.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/sample-products/gallery/samsung-tv-55-1.jpg
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
public/sample-products/gallery/samsung-tv-55-2.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/sample-products/gallery/samsung-tv-55-3.jpg
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
public/sample-products/gallery/sony-tv-43-1.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
public/sample-products/gallery/sony-tv-43-2.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
public/sample-products/gallery/sony-wh1000xm5-1.jpg
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
public/sample-products/gallery/sony-wh1000xm5-2.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
public/sample-products/gallery/sony-wh1000xm5-3.jpg
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
public/sample-products/gallery/voltas-ac-15t-1.jpg
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
public/sample-products/gallery/voltas-ac-15t-2.jpg
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
public/sample-products/gallery/whirlpool-fridge-dd-1.jpg
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
public/sample-products/gallery/whirlpool-fridge-dd-2.jpg
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
public/sample-products/gallery/whirlpool-fridge-dd-3.jpg
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
public/sample-products/laptop.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
public/sample-products/microwave.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/sample-products/mobile.jpg
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
public/sample-products/tablet.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
public/sample-products/tv.jpg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/sample-products/washer.jpg
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
public/sample-products/wearable.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
90
src/app/(workspace)/products/page.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {PageHeader} from '@/shared/components/primitives/PageHeader';
|
||||
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
|
||||
import {MetricCard} from '@/shared/components/patterns/MetricCard';
|
||||
import {SkeletonMetricGrid} from '@/shared/components/patterns/LoadingState';
|
||||
import {ProductCatalog} from '@/features/products/components/ProductCatalog';
|
||||
import {useProducts} from '@/features/products/hooks/useProducts';
|
||||
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
|
||||
import {SampleScope} from '@/shared/mocks/SampleScope';
|
||||
import {SampleNotice} from '@/shared/mocks/SampleNotice';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import {formatCompact, formatInrCompact} from '@/shared/utils/format';
|
||||
|
||||
/**
|
||||
* The product catalogue: what is sold, how much of it moved in the period,
|
||||
* and what is running low.
|
||||
*
|
||||
* SAMPLE throughout — the platform has no product or inventory endpoint yet.
|
||||
* The values live in shared/mocks/sampleData.ts (buildProductCatalog).
|
||||
*/
|
||||
export default function ProductsPage() {
|
||||
const products = useProducts();
|
||||
const scopeLabel = useScopeLabel();
|
||||
const rows = products.data;
|
||||
|
||||
const units = rows?.reduce((a, p) => a + p.units, 0) ?? 0;
|
||||
const revenue = rows?.reduce((a, p) => a + p.revenueInr, 0) ?? 0;
|
||||
const lowStock = rows?.filter((p) => p.stockStatus !== 'in_stock').length ?? 0;
|
||||
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
<PageHeader
|
||||
title="Products"
|
||||
description={`Catalogue, sales and stock across ${scopeLabel}.`}
|
||||
controls={<ScopeControls />}
|
||||
/>
|
||||
|
||||
<SampleScope>
|
||||
<VStack gap={5}>
|
||||
<SampleNotice>
|
||||
The platform does not record a product catalogue or stock levels yet.
|
||||
The products, prices and stock below are examples.
|
||||
</SampleNotice>
|
||||
|
||||
{rows ? (
|
||||
<Grid columns={{minWidth: 220, max: 4, repeat: 'fit'}} gap={4}>
|
||||
<MetricCard
|
||||
label="Products"
|
||||
value={rows.length}
|
||||
format={formatCompact}
|
||||
icon={ICONS.products}
|
||||
caption="In the catalogue"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Units sold"
|
||||
value={units}
|
||||
format={formatCompact}
|
||||
icon={ICONS.purchases}
|
||||
accent="cool"
|
||||
caption="In the selected period"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Product revenue"
|
||||
value={revenue}
|
||||
format={formatInrCompact}
|
||||
icon={ICONS.revenue}
|
||||
accent="warm"
|
||||
caption="In the selected period"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Low or out of stock"
|
||||
value={lowStock}
|
||||
format={formatCompact}
|
||||
icon={ICONS.alert}
|
||||
caption="Needs reordering"
|
||||
/>
|
||||
</Grid>
|
||||
) : (
|
||||
<SkeletonMetricGrid columns={4} />
|
||||
)}
|
||||
|
||||
<ProductCatalog products={products} />
|
||||
</VStack>
|
||||
</SampleScope>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,52 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
39
src/features/loyaly-ai/components/SuggestionChips.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import {HStack} from '@astryxdesign/core/Layout';
|
||||
import {SUGGESTIONS} from '@/features/loyaly-ai/utils/suggestions';
|
||||
import type {Suggestion} from '@/features/loyaly-ai/types/chat';
|
||||
|
||||
const CHIP = [
|
||||
'rounded-full border border-border bg-card',
|
||||
'px-3.5 py-2 text-sm text-secondary text-left',
|
||||
'transition-colors duration-150 cursor-pointer',
|
||||
'hover:bg-muted hover:text-primary hover:border-border-strong',
|
||||
'focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2',
|
||||
].join(' ');
|
||||
|
||||
/**
|
||||
* Suggested questions as chips — the same chips as the merchant console's
|
||||
* Loyaly AI. `items` narrows them (follow-ups after an answer); by default
|
||||
* every suggestion is offered.
|
||||
*/
|
||||
export function SuggestionChips({
|
||||
onPick,
|
||||
items = SUGGESTIONS,
|
||||
align = 'center',
|
||||
}: {
|
||||
onPick: (prompt: string) => void;
|
||||
items?: Suggestion[];
|
||||
align?: 'center' | 'start';
|
||||
}) {
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<HStack gap={2} wrap="wrap" hAlign={align}>
|
||||
{items.map((s) => (
|
||||
<button key={s.id} type="button" className={CHIP} onClick={() => onPick(s.prompt)}>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
483
src/features/products/components/ProductCatalog.tsx
Normal file
@@ -0,0 +1,483 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {proportional, pixel} from '@astryxdesign/core/Table';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {ClickableCard} from '@astryxdesign/core/ClickableCard';
|
||||
import {SelectableCard} from '@astryxdesign/core/SelectableCard';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {Selector} from '@astryxdesign/core/Selector';
|
||||
import {
|
||||
SegmentedControl,
|
||||
SegmentedControlItem,
|
||||
} from '@astryxdesign/core/SegmentedControl';
|
||||
import {PanelCard} from '@/shared/components/patterns/PanelCard';
|
||||
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
|
||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {MetricDelta} from '@/shared/components/primitives/MetricDelta';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import {formatCompact, formatCount, formatInr, formatInrCompact} from '@/shared/utils/format';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
import {useHoverIntent, suppressHoverIntent} from '@/shared/hooks/useHoverIntent';
|
||||
import {STOCK} from '@/features/products/utils/productArtwork';
|
||||
import {ProductImage} from '@/features/products/components/ProductImage';
|
||||
import {ProductDetailDialog} from '@/features/products/components/ProductDetailDialog';
|
||||
import {byBrand, type SampleCatalogItem} from '@/shared/mocks/sampleData';
|
||||
|
||||
type View = 'grid' | 'list';
|
||||
type Sort = 'brand' | 'popular' | 'price_asc' | 'price_desc' | 'discount' | 'rating';
|
||||
|
||||
const ALL = 'all';
|
||||
|
||||
const SORTS: {label: string; value: Sort}[] = [
|
||||
{label: 'Brand', value: 'brand'},
|
||||
{label: 'Best selling', value: 'popular'},
|
||||
{label: 'Price: low to high', value: 'price_asc'},
|
||||
{label: 'Price: high to low', value: 'price_desc'},
|
||||
{label: 'Biggest discount', value: 'discount'},
|
||||
{label: 'Customer rating', value: 'rating'},
|
||||
];
|
||||
|
||||
const SORTERS: Record<Sort, (a: SampleCatalogItem, b: SampleCatalogItem) => number> = {
|
||||
// The list arrives brand by brand, so this keeps that order.
|
||||
brand: byBrand,
|
||||
popular: (a, b) => b.revenueInr - a.revenueInr,
|
||||
price_asc: (a, b) => a.priceInr - b.priceInr,
|
||||
price_desc: (a, b) => b.priceInr - a.priceInr,
|
||||
discount: (a, b) => b.discountPct - a.discountPct,
|
||||
rating: (a, b) => b.rating - a.rating,
|
||||
};
|
||||
|
||||
/** One brand's entry in the strip: how many it has, and where its prices start. */
|
||||
type BrandTab = {brand: string; count: number; fromInr: number};
|
||||
|
||||
/** Every brand in the current scope, with its count and cheapest price. */
|
||||
function brandTabs(rows: SampleCatalogItem[]): BrandTab[] {
|
||||
const byName = new Map<string, BrandTab>();
|
||||
for (const item of rows) {
|
||||
const tab = byName.get(item.brand);
|
||||
if (!tab) byName.set(item.brand, {brand: item.brand, count: 1, fromInr: item.priceInr});
|
||||
else {
|
||||
tab.count += 1;
|
||||
tab.fromInr = Math.min(tab.fromInr, item.priceInr);
|
||||
}
|
||||
}
|
||||
// Most products first: the brands a merchant stocks deepest are the ones they
|
||||
// reach for, and they should not be the ones scrolled to.
|
||||
return [...byName.values()].sort((a, b) => b.count - a.count || a.brand.localeCompare(b.brand));
|
||||
}
|
||||
|
||||
/**
|
||||
* The brand strip: pick a brand the way the shop floor does, by name.
|
||||
*
|
||||
* A row of brands across the top rather than headings down the page. Headings
|
||||
* split one dense grid into a column of near-empty shelves — a brand with one
|
||||
* product got a whole row to itself and the page became mostly white space.
|
||||
* Here the grid stays whole and the brand is a filter, with the count and the
|
||||
* starting price on the tab so the choice is informed before it is made.
|
||||
*/
|
||||
function BrandStrip({
|
||||
tabs,
|
||||
total,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
tabs: BrandTab[];
|
||||
total: number;
|
||||
value: string;
|
||||
onChange: (brand: string) => void;
|
||||
}) {
|
||||
return (
|
||||
// Scrolls sideways rather than wrapping: a wrapped strip of a dozen brands
|
||||
// grows tall enough to push the products themselves below the fold.
|
||||
<HStack gap={2} isScrollable role="group" aria-label="Filter by brand">
|
||||
{/*
|
||||
SelectableCard, not ClickableCard: this picks a filter, it does not
|
||||
navigate, and the selected border comes with the component instead of a
|
||||
hand-rolled ring. Single-select, so one brand is tracked and clicking a
|
||||
card always selects it rather than toggling it off.
|
||||
*/}
|
||||
<SelectableCard
|
||||
label="Show all brands"
|
||||
padding={3}
|
||||
isSelected={value === ALL}
|
||||
onChange={() => onChange(ALL)}
|
||||
className="shrink-0"
|
||||
>
|
||||
<VStack gap={0.5}>
|
||||
<Text size="xsm" weight="semibold">
|
||||
All brands
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary" className="whitespace-nowrap">
|
||||
{formatCount(total)} products
|
||||
</Text>
|
||||
</VStack>
|
||||
</SelectableCard>
|
||||
|
||||
{tabs.map((tab) => (
|
||||
<SelectableCard
|
||||
key={tab.brand}
|
||||
label={`Show ${tab.brand} products`}
|
||||
padding={3}
|
||||
isSelected={value === tab.brand}
|
||||
onChange={() => onChange(tab.brand)}
|
||||
className="shrink-0"
|
||||
>
|
||||
<VStack gap={0.5}>
|
||||
<Text size="xsm" weight="semibold">
|
||||
{tab.brand}
|
||||
</Text>
|
||||
{/*
|
||||
The count and the entry price, because "Dell" alone is not a
|
||||
reason to click: whether Dell is four products from ₹56,860 or one
|
||||
from ₹1.4L is the whole of what the merchant wants to know first.
|
||||
*/}
|
||||
<Text size="xsm" color="secondary" className="whitespace-nowrap">
|
||||
{formatCount(tab.count)} products · from {formatInr(tab.fromInr)}
|
||||
</Text>
|
||||
</VStack>
|
||||
</SelectableCard>
|
||||
))}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
function Rating({item}: {item: SampleCatalogItem}) {
|
||||
return (
|
||||
<HStack gap={1} vAlign="center">
|
||||
<HStack gap={0.5} vAlign="center" className="rounded-sm bg-muted px-1.5 py-0.5">
|
||||
<Text size="xsm" weight="semibold">
|
||||
{item.rating.toFixed(1)}
|
||||
</Text>
|
||||
<Icon icon={ICONS.rating} size="xsm" color="inherit" className="fill-current" />
|
||||
</HStack>
|
||||
<Text size="xsm" color="secondary">
|
||||
({formatCompact(item.reviews)})
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
function Price({item}: {item: SampleCatalogItem}) {
|
||||
return (
|
||||
<HStack gap={2} vAlign="end" wrap="wrap">
|
||||
<Text size="lg" weight="bold">
|
||||
{formatInr(item.priceInr)}
|
||||
</Text>
|
||||
{item.discountPct > 0 ? (
|
||||
<>
|
||||
<Text size="sm" color="secondary" hasStrikethrough>
|
||||
{formatInr(item.mrpInr)}
|
||||
</Text>
|
||||
<Text size="sm" weight="semibold" color="inherit" className="text-success">
|
||||
{item.discountPct}% off
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How long the cursor must sit still on a tile before the detail dialog opens.
|
||||
*
|
||||
* Longer than it sounds, because the clock starts when the cursor *stops*, not
|
||||
* when it arrives: a merchant reading a tile waits eight tenths of a second, a
|
||||
* merchant on the way to the sort control waits forever.
|
||||
*/
|
||||
const HOVER_OPEN_MS = 800;
|
||||
|
||||
/**
|
||||
* One listing tile: artwork, brand, title, specs, rating, price and stock.
|
||||
*
|
||||
* Settling on the tile opens the product dialog; clicking opens it at once.
|
||||
*
|
||||
* The dwell is measured from the last pointer movement rather than from entry,
|
||||
* which is what keeps the grid scrollable. On a scroll the cursor never moves —
|
||||
* the tiles travel underneath it — so entry is not evidence that anyone is
|
||||
* pointing at anything, and every card passing the cursor would throw up a
|
||||
* modal. See useHoverIntent for the scroll guard that backs this up.
|
||||
*
|
||||
* The click path stays because hover never fires on a tablet.
|
||||
*/
|
||||
function ProductTile({item, onOpen}: {item: SampleCatalogItem; onOpen: () => void}) {
|
||||
const hover = useHoverIntent({onIntent: onOpen, delayMs: HOVER_OPEN_MS});
|
||||
|
||||
return (
|
||||
<ClickableCard
|
||||
padding={4}
|
||||
label={`${item.brand} ${item.name}`}
|
||||
onClick={() => {
|
||||
hover.cancel();
|
||||
onOpen();
|
||||
}}
|
||||
onPointerMove={hover.onPointerMove}
|
||||
onPointerLeave={hover.onPointerLeave}
|
||||
>
|
||||
<VStack gap={3}>
|
||||
<ProductImage item={item} className="rounded-lg bg-muted" />
|
||||
|
||||
<VStack gap={1}>
|
||||
<Text size="xsm" weight="semibold" color="secondary">
|
||||
{item.brand}
|
||||
</Text>
|
||||
<Text size="sm" weight="medium" maxLines={2}>
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary" maxLines={1}>
|
||||
{item.specs}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<Rating item={item} />
|
||||
<Price item={item} />
|
||||
|
||||
<HStack hAlign="between" vAlign="center" gap={2}>
|
||||
<Badge variant={STOCK[item.stockStatus].variant} label={STOCK[item.stockStatus].label} />
|
||||
<Text size="xsm" color="secondary">
|
||||
{formatCount(item.units)} sold
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</ClickableCard>
|
||||
);
|
||||
}
|
||||
|
||||
const COLUMNS: TableColumn<SampleCatalogItem>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Product',
|
||||
width: proportional(3),
|
||||
renderCell: (row) => (
|
||||
<HStack gap={3} vAlign="center">
|
||||
<ProductImage item={row} ratio={1} className="w-10 shrink-0 rounded-md bg-muted" />
|
||||
<VStack gap={0}>
|
||||
<Text size="sm" weight="medium" maxLines={1}>
|
||||
{row.brand} {row.name}
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
{row.sku} · {row.category}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'priceInr',
|
||||
header: 'Price',
|
||||
width: pixel(120),
|
||||
align: 'end',
|
||||
renderCell: (row) => (
|
||||
<VStack gap={0} hAlign="end">
|
||||
<Text size="sm" weight="medium">
|
||||
{formatInr(row.priceInr)}
|
||||
</Text>
|
||||
{row.discountPct > 0 ? (
|
||||
<Text size="xsm" color="inherit" className="text-success">
|
||||
{row.discountPct}% off
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'units',
|
||||
header: 'Sold',
|
||||
width: pixel(72),
|
||||
align: 'end',
|
||||
renderCell: (row) => <Text size="sm">{formatCount(row.units)}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'revenueInr',
|
||||
header: 'Revenue',
|
||||
width: pixel(100),
|
||||
align: 'end',
|
||||
renderCell: (row) => (
|
||||
<Text size="sm" weight="medium">
|
||||
{formatInrCompact(row.revenueInr)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'deltaPct',
|
||||
header: 'Change',
|
||||
width: pixel(96),
|
||||
align: 'end',
|
||||
renderCell: (row) => <MetricDelta value={row.deltaPct} />,
|
||||
},
|
||||
{
|
||||
key: 'stock',
|
||||
header: 'Stock',
|
||||
width: pixel(72),
|
||||
align: 'end',
|
||||
renderCell: (row) => <Text size="sm">{formatCount(row.stock)}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'stockStatus',
|
||||
header: 'Status',
|
||||
width: pixel(140),
|
||||
align: 'end',
|
||||
renderCell: (row) => (
|
||||
<Badge variant={STOCK[row.stockStatus].variant} label={STOCK[row.stockStatus].label} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The catalogue as a shopping grid (default) or an operations table.
|
||||
* Search, category and sort work on the loaded list — it is small enough that
|
||||
* a round trip per keystroke would only add latency.
|
||||
*/
|
||||
export function ProductCatalog({products}: {products: Resource<SampleCatalogItem[]>}) {
|
||||
const [view, setView] = useState<View>('grid');
|
||||
const [search, setSearch] = useState('');
|
||||
const [category, setCategory] = useState(ALL);
|
||||
const [brand, setBrand] = useState(ALL);
|
||||
// Brand first, so the grid arrives grouped by maker instead of as the random
|
||||
// jumble a revenue ranking produces — see byBrand.
|
||||
const [sort, setSort] = useState<Sort>('brand');
|
||||
// The clicked product, held by value: the catalogue behind the backdrop keeps
|
||||
// its filters and scroll, so closing returns the merchant exactly where they were.
|
||||
const [opened, setOpened] = useState<SampleCatalogItem | null>(null);
|
||||
|
||||
const categories = [...new Set((products.data ?? []).map((p) => p.category))].sort();
|
||||
// Brands of the products in the chosen category only: a Bosch shows up under
|
||||
// washing machines and nowhere else, so offering it under televisions would
|
||||
// hand the merchant a filter that can only return nothing.
|
||||
const brands = [
|
||||
...new Set(
|
||||
(products.data ?? [])
|
||||
.filter((p) => category === ALL || p.category === category)
|
||||
.map((p) => p.brand),
|
||||
),
|
||||
].sort();
|
||||
const term = search.trim().toLowerCase();
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelCard
|
||||
title="All products"
|
||||
subtitle="Price, offers, sales and stock for the selected store and period"
|
||||
actions={
|
||||
<SegmentedControl
|
||||
value={view}
|
||||
onChange={(v) => setView(v as View)}
|
||||
label="Product view"
|
||||
size="sm"
|
||||
>
|
||||
<SegmentedControlItem value="grid" label="Grid" icon={<Icon icon={ICONS.viewGrid} size="xsm" />} />
|
||||
<SegmentedControlItem value="list" label="List" icon={<Icon icon={ICONS.viewList} size="xsm" />} />
|
||||
</SegmentedControl>
|
||||
}
|
||||
resource={products}
|
||||
loading={<SkeletonRows count={8} height={52} />}
|
||||
>
|
||||
{(rows) => {
|
||||
// A brand the new category does not stock falls back to all of them,
|
||||
// rather than leaving the merchant on an empty grid wondering why.
|
||||
const activeBrand = brands.includes(brand) ? brand : ALL;
|
||||
// Everything the category and the search term allow, brand aside. The
|
||||
// strip's counts and prices are read off this, so a brand card can never
|
||||
// promise eight products that the active filters would hide.
|
||||
const scoped = rows.filter(
|
||||
(p) =>
|
||||
(category === ALL || p.category === category) &&
|
||||
(!term ||
|
||||
p.name.toLowerCase().includes(term) ||
|
||||
p.brand.toLowerCase().includes(term) ||
|
||||
p.sku.toLowerCase().includes(term)),
|
||||
);
|
||||
const visible = scoped
|
||||
.filter((p) => activeBrand === ALL || p.brand === activeBrand)
|
||||
.sort(SORTERS[sort]);
|
||||
|
||||
return (
|
||||
<VStack gap={4}>
|
||||
<Grid columns={{minWidth: 200, max: 3, repeat: 'fit'}} gap={3}>
|
||||
<TextInput
|
||||
label="Search products"
|
||||
isLabelHidden
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Search by product, brand or SKU"
|
||||
hasClear
|
||||
/>
|
||||
<Selector
|
||||
label="Category"
|
||||
isLabelHidden
|
||||
value={category}
|
||||
onChange={(v) => setCategory(String(v))}
|
||||
options={[
|
||||
{label: 'All categories', value: ALL},
|
||||
...categories.map((c) => ({label: c, value: c})),
|
||||
]}
|
||||
/>
|
||||
<Selector
|
||||
label="Sort by"
|
||||
isLabelHidden
|
||||
value={sort}
|
||||
onChange={(v) => setSort(v as Sort)}
|
||||
options={SORTS.map((s) => ({label: `Sort: ${s.label}`, value: s.value}))}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<BrandStrip
|
||||
tabs={brandTabs(scoped)}
|
||||
total={scoped.length}
|
||||
value={activeBrand}
|
||||
onChange={setBrand}
|
||||
/>
|
||||
|
||||
<Text size="sm" color="secondary">
|
||||
Showing {visible.length} of {rows.length} products
|
||||
</Text>
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<EmptyPanel
|
||||
icon="commerce"
|
||||
title="No product matches"
|
||||
description="Try another product, brand or SKU, or pick a different category."
|
||||
/>
|
||||
) : view === 'grid' ? (
|
||||
<Grid columns={{minWidth: 220, max: 4, repeat: 'fill'}} gap={4}>
|
||||
{visible.map((item) => (
|
||||
<ProductTile key={item.id} item={item} onOpen={() => setOpened(item)} />
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<ResponsiveTable
|
||||
columns={COLUMNS}
|
||||
data={visible}
|
||||
idKey="id"
|
||||
primaryKey="name"
|
||||
summaryKeys={['priceInr', 'units', 'stockStatus']}
|
||||
density="compact"
|
||||
/>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}}
|
||||
</PanelCard>
|
||||
|
||||
{/* Outside PanelCard: the dialog must survive a filter change that empties the grid. */}
|
||||
{opened ? (
|
||||
<ProductDetailDialog
|
||||
item={opened}
|
||||
onClose={() => {
|
||||
// The cursor is still wherever it was when the dialog was dismissed —
|
||||
// most likely on the tile that opened it. Without the pause, the first
|
||||
// twitch of the mouse reopens it and the dialog looks unclosable.
|
||||
suppressHoverIntent();
|
||||
setOpened(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
1164
src/features/products/components/ProductDetailDialog.tsx
Normal file
58
src/features/products/components/ProductImage.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import {AspectRatio} from '@astryxdesign/core/AspectRatio';
|
||||
import {productImage} from '@/features/products/utils/productArtwork';
|
||||
import type {SampleCatalogItem} from '@/shared/mocks/sampleData';
|
||||
|
||||
/**
|
||||
* A product's picture, boxed to a fixed ratio.
|
||||
*
|
||||
* One component for the grid tile, the table row and the detail dialog, so a
|
||||
* product looks identical wherever the merchant meets it — the same artwork on
|
||||
* hover as on open is what makes the dialog read as the tile expanding rather
|
||||
* than a second product appearing.
|
||||
*
|
||||
* Contained, not cropped. The files are already padded to 4:3, so at the
|
||||
* default ratio they still fill the box edge to edge — but a caller that asks
|
||||
* for a square thumbnail gets the whole appliance letterboxed instead of its
|
||||
* middle third. Cropping a second time is what turned a refrigerator into a
|
||||
* white rectangle and a phone into four app icons.
|
||||
*
|
||||
* When the product API starts sending `imageUrl`, its host has to be added to
|
||||
* `images.remotePatterns` in next.config.ts or the optimiser will refuse it.
|
||||
*/
|
||||
export function ProductImage({
|
||||
item,
|
||||
src,
|
||||
ratio = 4 / 3,
|
||||
fit = 'contain',
|
||||
zoomOnHover = false,
|
||||
className,
|
||||
}: {
|
||||
item: SampleCatalogItem;
|
||||
/** A specific shot from the product's gallery. Defaults to its first. */
|
||||
src?: string;
|
||||
/** Width over height. Defaults to the 4:3 a listing tile uses. */
|
||||
ratio?: number;
|
||||
/** How the image fills its frame: contained (default) or cropped edge-to-edge. */
|
||||
fit?: 'contain' | 'cover';
|
||||
/** Smooth zoom-in on hover. */
|
||||
zoomOnHover?: boolean;
|
||||
/** Utility classes for the box — radius and background belong to the caller. */
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<AspectRatio ratio={ratio} fit={fit} className={`overflow-hidden ${className ?? ''}`}>
|
||||
<Image
|
||||
src={src ?? productImage(item)}
|
||||
alt={`${item.brand} ${item.name}`}
|
||||
width={640}
|
||||
height={480}
|
||||
className={`size-full ${fit === 'cover' ? 'object-cover' : 'object-contain'} ${
|
||||
zoomOnHover ? 'transition-transform duration-500 ease-out group-hover:scale-115 hover:scale-115' : ''
|
||||
}`}
|
||||
/>
|
||||
</AspectRatio>
|
||||
);
|
||||
}
|
||||
18
src/features/products/hooks/useProducts.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import {useScope} from '@/shared/hooks/useScope';
|
||||
import {useSampleResource} from '@/shared/mocks/withSample';
|
||||
import {buildProductCatalog, type SampleCatalogItem} from '@/shared/mocks/sampleData';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
/**
|
||||
* The product catalogue for the selected store and period.
|
||||
*
|
||||
* SAMPLE: the platform has no product endpoint yet. When one lands, add it to
|
||||
* a products repository, swap this body for useResource(), and delete
|
||||
* buildProductCatalog from sampleData.ts.
|
||||
*/
|
||||
export function useProducts(): Resource<SampleCatalogItem[]> {
|
||||
const {storeId, range} = useScope();
|
||||
return useSampleResource(() => buildProductCatalog(storeId, range), [storeId, range]);
|
||||
}
|
||||
133
src/features/products/utils/productArtwork.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import type {IconType} from '@astryxdesign/core/Icon';
|
||||
import type {SampleProductKind, SampleStockStatus} from '@/shared/mocks/sampleData';
|
||||
|
||||
/**
|
||||
* Stand-in artwork until products carry real images.
|
||||
*
|
||||
* Lives here rather than in ProductCatalog so the detail dialog can show the
|
||||
* same glyph as the tile the merchant clicked — a product that changes
|
||||
* appearance when it opens reads as a different product.
|
||||
*/
|
||||
export const KIND_ICON: Record<SampleProductKind, IconType> = {
|
||||
tv: ICONS.productTv,
|
||||
mobile: ICONS.productMobile,
|
||||
laptop: ICONS.productLaptop,
|
||||
tablet: ICONS.productTablet,
|
||||
fridge: ICONS.productFridge,
|
||||
washer: ICONS.productWasher,
|
||||
ac: ICONS.productAc,
|
||||
microwave: ICONS.productMicrowave,
|
||||
audio: ICONS.productAudio,
|
||||
wearable: ICONS.productWearable,
|
||||
};
|
||||
|
||||
/**
|
||||
* Last-resort photographs, one per kind.
|
||||
*
|
||||
* Only reached by a product with no gallery of its own — a kind picture is the
|
||||
* reason three refrigerators once looked like the same refrigerator, so it is
|
||||
* a floor, not the normal path. See PRODUCT_GALLERY below.
|
||||
*/
|
||||
export const KIND_IMAGE: Record<SampleProductKind, string> = {
|
||||
tv: '/sample-products/tv.jpg',
|
||||
mobile: '/sample-products/mobile.jpg',
|
||||
laptop: '/sample-products/laptop.jpg',
|
||||
tablet: '/sample-products/tablet.jpg',
|
||||
fridge: '/sample-products/fridge.jpg',
|
||||
washer: '/sample-products/washer.jpg',
|
||||
ac: '/sample-products/ac.jpg',
|
||||
microwave: '/sample-products/microwave.jpg',
|
||||
audio: '/sample-products/audio.jpg',
|
||||
wearable: '/sample-products/wearable.jpg',
|
||||
};
|
||||
|
||||
/**
|
||||
* Sample photographs per product, two or three each, until the product API
|
||||
* carries images.
|
||||
*
|
||||
* Keyed by slug, not by kind. Keying by kind is what put one refrigerator
|
||||
* picture on the Samsung single-door, the LG side-by-side and the Whirlpool
|
||||
* alike, and one phone picture on the iPhone, the Galaxy and the OnePlus — the
|
||||
* complaint this map exists to answer.
|
||||
*
|
||||
* Local files rather than a stock-photo host: the catalogue must render the
|
||||
* same on a shop PC behind a restrictive firewall as it does here, and a broken
|
||||
* image is worse than no image. Every file is a freely licensed photograph
|
||||
* padded to 4:3 — see CREDITS.md beside them for the source and licence of
|
||||
* each, which several of them require. They are honest stand-ins, not the named
|
||||
* product: a picture of a front-loading washing machine, not of that Bosch.
|
||||
*
|
||||
* Delete this map when `SampleCatalogItem.imageUrls` arrives populated — see
|
||||
* productImages() for the switchover.
|
||||
*/
|
||||
export const PRODUCT_GALLERY: Record<string, string[]> = {
|
||||
'samsung-tv-55': gallery('samsung-tv-55', 3),
|
||||
'lg-oled-65': gallery('lg-oled-65', 2),
|
||||
'sony-tv-43': gallery('sony-tv-43', 2),
|
||||
'iphone-16': gallery('iphone-16', 3),
|
||||
'galaxy-s24-fe': gallery('galaxy-s24-fe', 3),
|
||||
'oneplus-nord-ce4': gallery('oneplus-nord-ce4', 3),
|
||||
'redmi-note-14': gallery('redmi-note-14', 3),
|
||||
'hp-pavilion-15': gallery('hp-pavilion-15', 3),
|
||||
'macbook-air-m3': gallery('macbook-air-m3', 3),
|
||||
'galaxy-tab-s9-fe': gallery('galaxy-tab-s9-fe', 3),
|
||||
'lg-fridge-sbs': ['/sample-products/gallery/lg-fridge-sbs-1.jpg'],
|
||||
'whirlpool-fridge-dd': gallery('whirlpool-fridge-dd', 3),
|
||||
'samsung-fridge-sd': gallery('samsung-fridge-sd', 3),
|
||||
'bosch-washer-front': gallery('bosch-washer-front', 3),
|
||||
'ifb-washer-top': gallery('ifb-washer-top', 3),
|
||||
'voltas-ac-15t': gallery('voltas-ac-15t', 2),
|
||||
'daikin-ac-1t': gallery('daikin-ac-1t', 3),
|
||||
'panasonic-microwave-27l': gallery('panasonic-microwave-27l', 3),
|
||||
'sony-wh1000xm5': gallery('sony-wh1000xm5', 3),
|
||||
'boat-airdopes-141': gallery('boat-airdopes-141', 2),
|
||||
'apple-watch-se': gallery('apple-watch-se', 3),
|
||||
'noise-colorfit-pro-6': gallery('noise-colorfit-pro-6', 3),
|
||||
};
|
||||
|
||||
/** The files on disk are `<slug>-1.jpg` upwards, primary first. */
|
||||
function gallery(slug: string, count: number): string[] {
|
||||
return Array.from({length: count}, (_, i) => `/sample-products/gallery/${slug}-${i + 1}.jpg`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every picture of a product, primary first: the real ones when the API sends
|
||||
* them, otherwise this product's samples, otherwise the one for its kind.
|
||||
*
|
||||
* Both the tile and the dialog's carousel read this, so a product shows the
|
||||
* same opening frame wherever the merchant meets it.
|
||||
*/
|
||||
export function productImages(item: {
|
||||
kind: SampleProductKind;
|
||||
slug: string;
|
||||
imageUrl?: string;
|
||||
imageUrls?: string[];
|
||||
}): string[] {
|
||||
const shots = item.imageUrls?.length ? item.imageUrls : PRODUCT_GALLERY[item.slug];
|
||||
return shots?.length ? shots : [KIND_IMAGE[item.kind]];
|
||||
}
|
||||
|
||||
/**
|
||||
* The single picture to show for a product — its primary shot.
|
||||
*
|
||||
* Every call site goes through here, so the day products arrive with photos
|
||||
* the catalogue, the table and the dialog all pick them up at once.
|
||||
*/
|
||||
export function productImage(item: {
|
||||
kind: SampleProductKind;
|
||||
slug: string;
|
||||
imageUrl?: string;
|
||||
imageUrls?: string[];
|
||||
}): string {
|
||||
return item.imageUrl ?? productImages(item)[0];
|
||||
}
|
||||
|
||||
export const STOCK: Record<
|
||||
SampleStockStatus,
|
||||
{label: string; variant: 'success' | 'warning' | 'error'}
|
||||
> = {
|
||||
in_stock: {label: 'In stock', variant: 'success'},
|
||||
low_stock: {label: 'Only a few left', variant: 'warning'},
|
||||
out_of_stock: {label: 'Out of stock', variant: 'error'},
|
||||
};
|
||||
402
src/features/products/utils/productSpecs.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
import type {SampleCatalogItem} from '@/shared/mocks/sampleData';
|
||||
|
||||
export interface ProductSpecItem {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ProductSpecSheet {
|
||||
left: ProductSpecItem[];
|
||||
right: ProductSpecItem[];
|
||||
}
|
||||
|
||||
const CATALOG_SPECS: Record<string, ProductSpecSheet> = {
|
||||
'hp-pavilion-15': {
|
||||
left: [
|
||||
{label: 'OS', value: 'Windows 11 Home'},
|
||||
{label: 'RAM (GB)', value: '16'},
|
||||
{label: 'Weight (kg)', value: '1.75'},
|
||||
{label: 'Storage (GB)', value: '512'},
|
||||
{label: 'Storage type', value: 'SSD'},
|
||||
],
|
||||
right: [
|
||||
{label: 'Graphics', value: 'Intel Iris Xe Graphics'},
|
||||
{label: 'Processor', value: 'Intel Core i5-1335U'},
|
||||
{label: 'Resolution', value: '1920 × 1080'},
|
||||
{label: 'Display (inch)', value: '15.6'},
|
||||
],
|
||||
},
|
||||
'macbook-air-m3': {
|
||||
left: [
|
||||
{label: 'OS', value: 'macOS Sequoia'},
|
||||
{label: 'RAM (GB)', value: '8 Unified'},
|
||||
{label: 'Weight (kg)', value: '1.24'},
|
||||
{label: 'Storage (GB)', value: '256'},
|
||||
{label: 'Storage type', value: 'SSD'},
|
||||
],
|
||||
right: [
|
||||
{label: 'Processor', value: 'Apple M3 (8-core CPU)'},
|
||||
{label: 'Graphics', value: 'Apple 10-core GPU'},
|
||||
{label: 'Resolution', value: '2560 × 1664'},
|
||||
{label: 'Display (inch)', value: '13.6 Liquid Retina'},
|
||||
{label: 'Battery', value: 'Up to 18 hours'},
|
||||
],
|
||||
},
|
||||
'iphone-16': {
|
||||
left: [
|
||||
{label: 'OS', value: 'iOS 18'},
|
||||
{label: 'Storage (GB)', value: '128'},
|
||||
{label: 'RAM (GB)', value: '8'},
|
||||
{label: 'Weight (g)', value: '170'},
|
||||
{label: 'Battery', value: '3,561 mAh (MagSafe)'},
|
||||
],
|
||||
right: [
|
||||
{label: 'Processor', value: 'Apple A18 Bionic'},
|
||||
{label: 'Graphics', value: 'Apple 5-core GPU'},
|
||||
{label: 'Display (inch)', value: '6.1'},
|
||||
{label: 'Resolution', value: '2556 × 1179 Super Retina'},
|
||||
{label: 'Camera', value: '48 MP + 12 MP Ultra Wide'},
|
||||
],
|
||||
},
|
||||
'galaxy-s24-fe': {
|
||||
left: [
|
||||
{label: 'OS', value: 'Android 14 (One UI)'},
|
||||
{label: 'RAM (GB)', value: '8'},
|
||||
{label: 'Storage (GB)', value: '256'},
|
||||
{label: 'Weight (g)', value: '213'},
|
||||
{label: 'Battery', value: '4,700 mAh (25W)'},
|
||||
],
|
||||
right: [
|
||||
{label: 'Processor', value: 'Exynos 2400e (4nm)'},
|
||||
{label: 'Graphics', value: 'Samsung Xclipse 940'},
|
||||
{label: 'Display (inch)', value: '6.7 Dynamic AMOLED 2X'},
|
||||
{label: 'Resolution', value: '2340 × 1080 FHD+'},
|
||||
{label: 'Camera', value: '50 MP + 12 MP + 8 MP'},
|
||||
],
|
||||
},
|
||||
'oneplus-nord-ce4': {
|
||||
left: [
|
||||
{label: 'OS', value: 'OxygenOS 14'},
|
||||
{label: 'RAM (GB)', value: '8'},
|
||||
{label: 'Storage (GB)', value: '128'},
|
||||
{label: 'Weight (g)', value: '186'},
|
||||
{label: 'Battery', value: '5,500 mAh'},
|
||||
],
|
||||
right: [
|
||||
{label: 'Processor', value: 'Snapdragon 7 Gen 3'},
|
||||
{label: 'Graphics', value: 'Adreno 720'},
|
||||
{label: 'Display (inch)', value: '6.7 Fluid AMOLED'},
|
||||
{label: 'Resolution', value: '2412 × 1080'},
|
||||
{label: 'Charging', value: '100W SuperVOOC'},
|
||||
],
|
||||
},
|
||||
'redmi-note-14': {
|
||||
left: [
|
||||
{label: 'OS', value: 'Xiaomi HyperOS'},
|
||||
{label: 'RAM (GB)', value: '6'},
|
||||
{label: 'Storage (GB)', value: '128'},
|
||||
{label: 'Weight (g)', value: '190'},
|
||||
{label: 'Battery', value: '5,110 mAh'},
|
||||
],
|
||||
right: [
|
||||
{label: 'Processor', value: 'MediaTek Dimensity 7025'},
|
||||
{label: 'Display (inch)', value: '6.67 OLED 120Hz'},
|
||||
{label: 'Resolution', value: '2400 × 1080'},
|
||||
{label: 'Camera', value: '50 MP with OIS'},
|
||||
{label: 'Durability', value: 'IP64 Dust & Splash'},
|
||||
],
|
||||
},
|
||||
'galaxy-tab-s9-fe': {
|
||||
left: [
|
||||
{label: 'OS', value: 'Android 14 (One UI)'},
|
||||
{label: 'RAM (GB)', value: '6'},
|
||||
{label: 'Storage (GB)', value: '128'},
|
||||
{label: 'Weight (g)', value: '523'},
|
||||
{label: 'Battery', value: '8,000 mAh'},
|
||||
],
|
||||
right: [
|
||||
{label: 'Processor', value: 'Exynos 1380 Octa-core'},
|
||||
{label: 'Display (inch)', value: '10.9 LCD 90Hz'},
|
||||
{label: 'Resolution', value: '2304 × 1440 WQXGA'},
|
||||
{label: 'Stylus', value: 'S Pen included in box'},
|
||||
{label: 'Durability', value: 'IP68 Water Resistant'},
|
||||
],
|
||||
},
|
||||
'samsung-tv-55': {
|
||||
left: [
|
||||
{label: 'OS', value: 'Tizen OS'},
|
||||
{label: 'Screen Size (inch)', value: '55'},
|
||||
{label: 'Refresh Rate', value: '50 Hz'},
|
||||
{label: 'Weight (kg)', value: '11.6'},
|
||||
{label: 'Sound Output', value: '20W Q-Symphony'},
|
||||
],
|
||||
right: [
|
||||
{label: 'Resolution', value: '3840 × 2160 (4K UHD)'},
|
||||
{label: 'Processor', value: 'Crystal Processor 4K'},
|
||||
{label: 'HDR', value: 'HDR10+ support'},
|
||||
{label: 'Connectivity', value: '3 HDMI, 1 USB, Wi-Fi'},
|
||||
{label: 'Smart Features', value: 'Apple AirPlay, SmartThings'},
|
||||
],
|
||||
},
|
||||
'lg-oled-65': {
|
||||
left: [
|
||||
{label: 'OS', value: 'webOS 24'},
|
||||
{label: 'Screen Size (inch)', value: '65'},
|
||||
{label: 'Refresh Rate', value: '120 Hz'},
|
||||
{label: 'Weight (kg)', value: '16.6'},
|
||||
{label: 'Sound Output', value: '40W Dolby Atmos'},
|
||||
],
|
||||
right: [
|
||||
{label: 'Resolution', value: '3840 × 2160 (4K OLED)'},
|
||||
{label: 'Processor', value: 'α9 AI Gen7 4K'},
|
||||
{label: 'Panel', value: 'Self-lit OLED evo'},
|
||||
{label: 'HDR', value: 'Dolby Vision, HDR10'},
|
||||
{label: 'Gaming', value: 'G-Sync, FreeSync, 4× HDMI 2.1'},
|
||||
],
|
||||
},
|
||||
'sony-tv-43': {
|
||||
left: [
|
||||
{label: 'OS', value: 'Google TV'},
|
||||
{label: 'Screen Size (inch)', value: '43'},
|
||||
{label: 'Refresh Rate', value: '60 Hz'},
|
||||
{label: 'Weight (kg)', value: '7.8'},
|
||||
{label: 'Sound Output', value: '20W Bass Reflex'},
|
||||
],
|
||||
right: [
|
||||
{label: 'Resolution', value: '3840 × 2160 (4K UHD)'},
|
||||
{label: 'Processor', value: '4K Processor X1'},
|
||||
{label: 'Picture Engine', value: '4K X-Reality PRO'},
|
||||
{label: 'Connectivity', value: '3 HDMI, 2 USB, Bluetooth'},
|
||||
{label: 'Voice Control', value: 'Google Assistant Built-in'},
|
||||
],
|
||||
},
|
||||
'apple-watch-se': {
|
||||
left: [
|
||||
{label: 'OS', value: 'watchOS 10'},
|
||||
{label: 'Case Size (mm)', value: '44'},
|
||||
{label: 'Weight (g)', value: '32.9'},
|
||||
{label: 'Water Resistance', value: '50m Swimproof'},
|
||||
{label: 'Battery', value: 'Up to 18 hours'},
|
||||
],
|
||||
right: [
|
||||
{label: 'Processor', value: 'Apple S8 SiP dual-core'},
|
||||
{label: 'Display', value: 'Retina OLED (1,000 nits)'},
|
||||
{label: 'Sensors', value: 'Heart rate, Gyro, Accel'},
|
||||
{label: 'Safety', value: 'Crash & Fall Detection'},
|
||||
{label: 'Connectivity', value: 'GPS, Wi-Fi, Bluetooth 5.3'},
|
||||
],
|
||||
},
|
||||
'noise-colorfit-pro-6': {
|
||||
left: [
|
||||
{label: 'Display (inch)', value: '1.96 AMOLED Always-On'},
|
||||
{label: 'Resolution', value: '410 × 502 (600 nits)'},
|
||||
{label: 'Battery', value: 'Up to 7 days (300 mAh)'},
|
||||
{label: 'Weight (g)', value: '41.5'},
|
||||
{label: 'Water Resistance', value: 'IP68 Rating'},
|
||||
],
|
||||
right: [
|
||||
{label: 'Calling', value: 'Bluetooth 5.3 (Tru Sync)'},
|
||||
{label: 'Sensors', value: 'SpO2, 24×7 Heart rate, Sleep'},
|
||||
{label: 'Sports Modes', value: '100+ Sports tracking'},
|
||||
{label: 'Strap (mm)', value: '22 Quick-release'},
|
||||
{label: 'Compatibility', value: 'Android & iOS'},
|
||||
],
|
||||
},
|
||||
'sony-wh1000xm5': {
|
||||
left: [
|
||||
{label: 'Type', value: 'Over-Ear Wireless'},
|
||||
{label: 'Weight (g)', value: '250'},
|
||||
{label: 'Battery (hr)', value: '30 (ANC On) / 40 (Off)'},
|
||||
{label: 'Fast Charge', value: '3 min = 3 hr playback'},
|
||||
{label: 'Bluetooth', value: 'v5.2 (LDAC, AAC, SBC)'},
|
||||
],
|
||||
right: [
|
||||
{label: 'Noise Cancelling', value: 'Auto NC Optimizer (8 mics)'},
|
||||
{label: 'Driver (mm)', value: '30 Precision Carbon'},
|
||||
{label: 'Microphones', value: '4 Beamforming (AI Noise)'},
|
||||
{label: 'Hi-Res Audio', value: 'Hi-Res Wireless certified'},
|
||||
{label: 'Features', value: 'Multipoint, Speak-to-Chat'},
|
||||
],
|
||||
},
|
||||
'boat-airdopes-141': {
|
||||
left: [
|
||||
{label: 'Type', value: 'In-Ear TWS Earbuds'},
|
||||
{label: 'Total Playback (hr)', value: '42 (6 hr earbuds)'},
|
||||
{label: 'Fast Charge', value: '5 min = 75 min (ASAP)'},
|
||||
{label: 'Water Resistance', value: 'IPX4 Splash proof'},
|
||||
{label: 'Weight (g)', value: '60 (with case)'},
|
||||
],
|
||||
right: [
|
||||
{label: 'Driver (mm)', value: '8 Dynamic drivers'},
|
||||
{label: 'Mic Tech', value: 'ENx Environmental Noise'},
|
||||
{label: 'Latency', value: 'BEAST Mode (Low latency)'},
|
||||
{label: 'Bluetooth', value: 'v5.1 Insta Wake N\' Pair'},
|
||||
{label: 'Charging Port', value: 'Type-C Fast Charge'},
|
||||
],
|
||||
},
|
||||
'lg-fridge-sbs': {
|
||||
left: [
|
||||
{label: 'Capacity (L)', value: '655'},
|
||||
{label: 'Refrigerator Type', value: 'Side-by-Side'},
|
||||
{label: 'Compressor', value: 'Smart Inverter Compressor'},
|
||||
{label: 'Defrost Type', value: 'Frost Free'},
|
||||
{label: 'Weight (kg)', value: '116'},
|
||||
],
|
||||
right: [
|
||||
{label: 'Cooling Tech', value: 'DoorCooling+ & Multi Air Flow'},
|
||||
{label: 'Smart Tech', value: 'LG ThinQ Wi-Fi control'},
|
||||
{label: 'Shelves', value: 'Toughened Glass'},
|
||||
{label: 'Ice Maker', value: 'Express Freeze & Twist Ice'},
|
||||
{label: 'Energy Star', value: '3 Star compliant'},
|
||||
],
|
||||
},
|
||||
'whirlpool-fridge-dd': {
|
||||
left: [
|
||||
{label: 'Capacity (L)', value: '265'},
|
||||
{label: 'Refrigerator Type', value: 'Double Door'},
|
||||
{label: 'Energy Rating', value: '3 Star'},
|
||||
{label: 'Compressor', value: 'IntelliSense Inverter'},
|
||||
{label: 'Weight (kg)', value: '56'},
|
||||
],
|
||||
right: [
|
||||
{label: 'Cooling Retention', value: '12 hours during power cut'},
|
||||
{label: 'Freshness Tech', value: 'Zeolite & Microblock'},
|
||||
{label: 'Crisper', value: 'Honeycomb Moisture Lock'},
|
||||
{label: 'Shelves', value: 'Toughened Glass'},
|
||||
{label: 'Warranty', value: '10 Years on compressor'},
|
||||
],
|
||||
},
|
||||
'samsung-fridge-sd': {
|
||||
left: [
|
||||
{label: 'Capacity (L)', value: '183'},
|
||||
{label: 'Refrigerator Type', value: 'Single Door'},
|
||||
{label: 'Energy Rating', value: '4 Star'},
|
||||
{label: 'Compressor', value: 'Digital Inverter Compressor'},
|
||||
{label: 'Weight (kg)', value: '30'},
|
||||
],
|
||||
right: [
|
||||
{label: 'Cooling Type', value: 'Direct Cool'},
|
||||
{label: 'Base Stand', value: 'Base drawer included'},
|
||||
{label: 'Voltage Range', value: '100V – 300V stabilizer free'},
|
||||
{label: 'Shelves', value: 'Toughened Glass (175 kg)'},
|
||||
{label: 'Warranty', value: '20 Years on compressor'},
|
||||
],
|
||||
},
|
||||
'bosch-washer-front': {
|
||||
left: [
|
||||
{label: 'Capacity (kg)', value: '8.0'},
|
||||
{label: 'Spin Speed (RPM)', value: '1200'},
|
||||
{label: 'Type', value: 'Front Load Fully Automatic'},
|
||||
{label: 'Energy Rating', value: '5 Star'},
|
||||
{label: 'Weight (kg)', value: '71.5'},
|
||||
],
|
||||
right: [
|
||||
{label: 'Motor', value: 'EcoSilence Drive'},
|
||||
{label: 'Programs', value: '15 Wash cycles'},
|
||||
{label: 'Drum', value: 'VarioDrum fabric protection'},
|
||||
{label: 'Inbuilt Heater', value: 'Yes (Up to 90°C)'},
|
||||
{label: 'Anti-Stain', value: 'Automatic stain removal'},
|
||||
],
|
||||
},
|
||||
'ifb-washer-top': {
|
||||
left: [
|
||||
{label: 'Capacity (kg)', value: '7.0'},
|
||||
{label: 'Spin Speed (RPM)', value: '740'},
|
||||
{label: 'Type', value: 'Top Load Fully Automatic'},
|
||||
{label: 'Energy Rating', value: '5 Star'},
|
||||
{label: 'Weight (kg)', value: '35'},
|
||||
],
|
||||
right: [
|
||||
{label: 'Wash System', value: '3D Wash Triadic Pulsator'},
|
||||
{label: 'Filter', value: 'Aqua Energie hard water filter'},
|
||||
{label: 'Drum', value: 'Crescent Moon stainless steel'},
|
||||
{label: 'Programs', value: '8 Wash programs'},
|
||||
{label: 'Tub Clean', value: 'Auto Tub Clean'},
|
||||
],
|
||||
},
|
||||
'voltas-ac-15t': {
|
||||
left: [
|
||||
{label: 'Tonnage', value: '1.5 Ton'},
|
||||
{label: 'Energy Rating', value: '5 Star (ISEER 5.0)'},
|
||||
{label: 'Condenser Coil', value: '100% Copper'},
|
||||
{label: 'Refrigerant', value: 'R32'},
|
||||
{label: 'Weight (kg)', value: '11.5 IDU / 29.5 ODU'},
|
||||
],
|
||||
right: [
|
||||
{label: 'Cooling Capacity (W)', value: '5100'},
|
||||
{label: 'Inverter', value: 'High ambient rotary inverter'},
|
||||
{label: 'Modes', value: '4-in-1 Adjustable Cooling'},
|
||||
{label: 'Ambient Temp', value: 'Cools at up to 52°C'},
|
||||
{label: 'Filter', value: 'Anti-microbial & dust filter'},
|
||||
],
|
||||
},
|
||||
'daikin-ac-1t': {
|
||||
left: [
|
||||
{label: 'Tonnage', value: '1.0 Ton'},
|
||||
{label: 'Energy Rating', value: '3 Star (ISEER 3.95)'},
|
||||
{label: 'Condenser', value: '100% Grooved Copper'},
|
||||
{label: 'Refrigerant', value: 'R32'},
|
||||
{label: 'Weight (kg)', value: '9.5 IDU / 25 ODU'},
|
||||
],
|
||||
right: [
|
||||
{label: 'Airflow', value: 'Coanda Airflow'},
|
||||
{label: 'Filter', value: 'PM 2.5 Air Filter built-in'},
|
||||
{label: 'Inverter', value: 'Neo Swing compressor'},
|
||||
{label: 'Noise Level (dB)', value: '32'},
|
||||
{label: 'Operation', value: 'Stabilizer inside'},
|
||||
],
|
||||
},
|
||||
'panasonic-microwave-27l': {
|
||||
left: [
|
||||
{label: 'Capacity (L)', value: '27'},
|
||||
{label: 'Type', value: 'Convection Microwave'},
|
||||
{label: 'Power (W)', value: '900W Micro / 1400W Grill'},
|
||||
{label: 'Turntable (mm)', value: '305 Glass'},
|
||||
{label: 'Weight (kg)', value: '16.5'},
|
||||
],
|
||||
right: [
|
||||
{label: 'Auto Cook', value: '101 Pre-set Indian menus'},
|
||||
{label: 'Heating', value: '360° Heat Wrap'},
|
||||
{label: 'Cavity', value: 'Stainless Steel interior'},
|
||||
{label: 'Control', value: 'Touch Keypad & Digital display'},
|
||||
{label: 'Features', value: 'Re-heat, Defrost, Ferment'},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns structured two-column technical specifications matching the reference design.
|
||||
*/
|
||||
export function getProductSpecs(item: SampleCatalogItem): ProductSpecSheet {
|
||||
if (CATALOG_SPECS[item.slug]) {
|
||||
return CATALOG_SPECS[item.slug];
|
||||
}
|
||||
|
||||
// Fallback: Dynamically generate balanced specs from item details
|
||||
const parsedSpecs = item.specs
|
||||
.split(/·|,|\|/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const left: ProductSpecItem[] = [
|
||||
{label: 'Brand', value: item.brand},
|
||||
{label: 'Category', value: item.category},
|
||||
{label: 'SKU', value: item.sku},
|
||||
];
|
||||
|
||||
const right: ProductSpecItem[] = [];
|
||||
|
||||
parsedSpecs.forEach((spec, idx) => {
|
||||
if (idx % 2 === 0) {
|
||||
left.push({label: `Feature ${idx + 1}`, value: spec});
|
||||
} else {
|
||||
right.push({label: `Feature ${idx + 1}`, value: spec});
|
||||
}
|
||||
});
|
||||
|
||||
if (right.length === 0) {
|
||||
right.push({label: 'Configuration', value: item.specs});
|
||||
}
|
||||
|
||||
return {left, right};
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import 'server-only';
|
||||
import {upstreamRequest} from './apiClient';
|
||||
import 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,
|
||||
|
||||
58
src/services/api/saleMapper.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type {ApiPlatformSale} from './types';
|
||||
import type {Sale} from '@/features/commerce/types/sale';
|
||||
|
||||
/** Rupees from the platform to integer paise, once, here. */
|
||||
export function rupeesToPaise(amount: number | null | undefined): number {
|
||||
// numeric(14,2) upstream, so ×100 is exact up to float noise and rounding
|
||||
// recovers the integer the database holds.
|
||||
return Math.round((Number(amount) || 0) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* A platform sale (`GET /api/sales`, `GET /api/sales/{id}`) → the console's
|
||||
* `Sale`.
|
||||
*
|
||||
* The platform records a sale as a total plus product NAMES, with no price per
|
||||
* item. So the detail view gets ONE purchased line naming every item and
|
||||
* carrying the total, rather than a line per item priced at ₹0 — a made-up
|
||||
* price is worse than an honest grouping. Every platform sale is a purchase:
|
||||
* it has no enquiry lines and no void state.
|
||||
*/
|
||||
export function fromPlatformSale(s: ApiPlatformSale): Sale {
|
||||
const totalPaise = rupeesToPaise(s.amount);
|
||||
const items = (s.items ?? []).filter((i) => typeof i === 'string' && i.trim() !== '');
|
||||
return {
|
||||
id: s.id,
|
||||
invoiceNo: s.external_ref || null,
|
||||
siteId: s.site_slug || s.site_id,
|
||||
customerRef: s.visitor_ref || null,
|
||||
customerLabel: s.visitor_label || null,
|
||||
// The platform derives the seller from the session and does not return it.
|
||||
staffName: null,
|
||||
totalPaise,
|
||||
currency: s.currency || 'INR',
|
||||
status: 'completed',
|
||||
at: s.occurred_at,
|
||||
purchasedLines: items.length,
|
||||
enquiryLines: 0,
|
||||
lines: [
|
||||
{
|
||||
productName: items.length > 0 ? items.join(', ') : 'Sale',
|
||||
pricePaise: totalPaise,
|
||||
intent: 'purchased',
|
||||
billablePaise: totalPaise,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The list response is a bare array. Also tolerates the `{items: [...]}` page
|
||||
* the console was first written against, so a platform that adopts it later
|
||||
* does not empty the screen a second time.
|
||||
*/
|
||||
export function platformSales(body: unknown): ApiPlatformSale[] {
|
||||
if (Array.isArray(body)) return body as ApiPlatformSale[];
|
||||
const items = (body as {items?: unknown} | null)?.items;
|
||||
return Array.isArray(items) ? (items as ApiPlatformSale[]) : [];
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'server-only';
|
||||
import {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}),
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
138
src/shared/hooks/useHoverIntent.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
'use client';
|
||||
|
||||
import {useCallback, useEffect, useRef} from 'react';
|
||||
import type {PointerEvent as ReactPointerEvent} from 'react';
|
||||
|
||||
/** How long the cursor must sit still on the target before it counts as intent. */
|
||||
const DEFAULT_DELAY_MS = 700;
|
||||
|
||||
/**
|
||||
* How long after a scroll a pointer event is treated as scroll fallout.
|
||||
*
|
||||
* A browser fires a synthetic mousemove after scrolling to refresh which element
|
||||
* is hovered. That move is the page arriving under a stationary cursor, not the
|
||||
* merchant pointing at anything, so it must not arm anything.
|
||||
*/
|
||||
const SCROLL_QUIET_MS = 350;
|
||||
|
||||
/** Set when the cursor lands on a target: how long the cursor must rest. */
|
||||
let suppressedUntil = 0;
|
||||
let lastScrollAt = 0;
|
||||
let subscribers = 0;
|
||||
|
||||
function markScroll() {
|
||||
lastScrollAt = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* One scroll clock for the whole page.
|
||||
*
|
||||
* A grid of forty tiles must not install forty scroll listeners, so the
|
||||
* listeners are shared and reference-counted.
|
||||
*
|
||||
* `capture` matters: the scrolling element is the dashboard's own scroll region,
|
||||
* not the window, and scroll events do not bubble — a plain listener on window
|
||||
* would never hear the one scroll we care about.
|
||||
*/
|
||||
function subscribeToScroll(): () => void {
|
||||
if (subscribers === 0) {
|
||||
window.addEventListener('scroll', markScroll, {capture: true, passive: true});
|
||||
window.addEventListener('wheel', markScroll, {passive: true});
|
||||
window.addEventListener('touchmove', markScroll, {passive: true});
|
||||
}
|
||||
subscribers += 1;
|
||||
|
||||
return () => {
|
||||
subscribers -= 1;
|
||||
if (subscribers === 0) {
|
||||
window.removeEventListener('scroll', markScroll, {capture: true});
|
||||
window.removeEventListener('wheel', markScroll);
|
||||
window.removeEventListener('touchmove', markScroll);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold off every hover intent on the page for a moment.
|
||||
*
|
||||
* Call this when closing something a hover opened: the cursor is still sitting
|
||||
* where it was, and without the pause the first twitch of the mouse reopens what
|
||||
* the merchant just dismissed.
|
||||
*/
|
||||
export function suppressHoverIntent(ms = 600) {
|
||||
suppressedUntil = Date.now() + ms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires `onIntent` once the cursor has come to rest on the target.
|
||||
*
|
||||
* Deliberately measures *stillness*, not arrival. Arrival is the wrong signal on
|
||||
* a scrolling page — the cursor stays put while the content moves beneath it, so
|
||||
* every card that slides past would report a hover. Requiring the pointer to
|
||||
* move and then stop means only a merchant actually pointing at something
|
||||
* triggers it, and dragging the cursor across a grid on the way somewhere else
|
||||
* triggers nothing.
|
||||
*
|
||||
* Mouse only: a touch never hovers, and a tap that opened something behind the
|
||||
* user's own finger is disorienting.
|
||||
*/
|
||||
export function useHoverIntent<T extends HTMLElement = HTMLElement>({
|
||||
onIntent,
|
||||
delayMs = DEFAULT_DELAY_MS,
|
||||
isDisabled = false,
|
||||
}: {
|
||||
onIntent: () => void;
|
||||
/** Stillness required before firing. */
|
||||
delayMs?: number;
|
||||
/** Stops firing and drops anything pending — e.g. while the target is open. */
|
||||
isDisabled?: boolean;
|
||||
}) {
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Read the callback through a ref: call sites pass a fresh closure every
|
||||
// render, and a changing handler identity must not drop a pending timer.
|
||||
const intent = useRef(onIntent);
|
||||
useEffect(() => {
|
||||
intent.current = onIntent;
|
||||
}, [onIntent]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current);
|
||||
timer.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(subscribeToScroll, []);
|
||||
|
||||
// A target unmounted mid-dwell — by a filter change, say — must not fire for
|
||||
// something that is no longer on screen.
|
||||
useEffect(() => cancel, [cancel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDisabled) cancel();
|
||||
}, [isDisabled, cancel]);
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(event: ReactPointerEvent<T>) => {
|
||||
if (isDisabled || event.pointerType !== 'mouse') return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastScrollAt < SCROLL_QUIET_MS || now < suppressedUntil) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Restart on every move, so the delay times how long the cursor has been
|
||||
// still rather than how long it has been inside the target.
|
||||
cancel();
|
||||
timer.current = setTimeout(() => {
|
||||
timer.current = null;
|
||||
intent.current();
|
||||
}, delayMs);
|
||||
},
|
||||
[cancel, delayMs, isDisabled],
|
||||
);
|
||||
|
||||
return {onPointerMove, onPointerLeave: cancel, cancel};
|
||||
}
|
||||
@@ -14,9 +14,10 @@ export interface NavEntry {
|
||||
*/
|
||||
export const PRIMARY_NAV: NavEntry[] = [
|
||||
{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},
|
||||
];
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||