diff --git a/public/sample-products/CREDITS.md b/public/sample-products/CREDITS.md new file mode 100644 index 0000000..08a8be5 --- /dev/null +++ b/public/sample-products/CREDITS.md @@ -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 + +`-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. diff --git a/public/sample-products/ac.jpg b/public/sample-products/ac.jpg new file mode 100644 index 0000000..a8daee3 Binary files /dev/null and b/public/sample-products/ac.jpg differ diff --git a/public/sample-products/audio.jpg b/public/sample-products/audio.jpg new file mode 100644 index 0000000..1949f25 Binary files /dev/null and b/public/sample-products/audio.jpg differ diff --git a/public/sample-products/fridge.jpg b/public/sample-products/fridge.jpg new file mode 100644 index 0000000..26ae73e Binary files /dev/null and b/public/sample-products/fridge.jpg differ diff --git a/public/sample-products/gallery/apple-watch-se-1.jpg b/public/sample-products/gallery/apple-watch-se-1.jpg new file mode 100644 index 0000000..64159c5 Binary files /dev/null and b/public/sample-products/gallery/apple-watch-se-1.jpg differ diff --git a/public/sample-products/gallery/apple-watch-se-2.jpg b/public/sample-products/gallery/apple-watch-se-2.jpg new file mode 100644 index 0000000..0ae36fc Binary files /dev/null and b/public/sample-products/gallery/apple-watch-se-2.jpg differ diff --git a/public/sample-products/gallery/apple-watch-se-3.jpg b/public/sample-products/gallery/apple-watch-se-3.jpg new file mode 100644 index 0000000..c532094 Binary files /dev/null and b/public/sample-products/gallery/apple-watch-se-3.jpg differ diff --git a/public/sample-products/gallery/boat-airdopes-141-1.jpg b/public/sample-products/gallery/boat-airdopes-141-1.jpg new file mode 100644 index 0000000..287aba9 Binary files /dev/null and b/public/sample-products/gallery/boat-airdopes-141-1.jpg differ diff --git a/public/sample-products/gallery/boat-airdopes-141-2.jpg b/public/sample-products/gallery/boat-airdopes-141-2.jpg new file mode 100644 index 0000000..3b739e6 Binary files /dev/null and b/public/sample-products/gallery/boat-airdopes-141-2.jpg differ diff --git a/public/sample-products/gallery/bosch-washer-front-1.jpg b/public/sample-products/gallery/bosch-washer-front-1.jpg new file mode 100644 index 0000000..728e9c0 Binary files /dev/null and b/public/sample-products/gallery/bosch-washer-front-1.jpg differ diff --git a/public/sample-products/gallery/bosch-washer-front-2.jpg b/public/sample-products/gallery/bosch-washer-front-2.jpg new file mode 100644 index 0000000..585b649 Binary files /dev/null and b/public/sample-products/gallery/bosch-washer-front-2.jpg differ diff --git a/public/sample-products/gallery/bosch-washer-front-3.jpg b/public/sample-products/gallery/bosch-washer-front-3.jpg new file mode 100644 index 0000000..b6ed334 Binary files /dev/null and b/public/sample-products/gallery/bosch-washer-front-3.jpg differ diff --git a/public/sample-products/gallery/daikin-ac-1t-1.jpg b/public/sample-products/gallery/daikin-ac-1t-1.jpg new file mode 100644 index 0000000..0f5e2e9 Binary files /dev/null and b/public/sample-products/gallery/daikin-ac-1t-1.jpg differ diff --git a/public/sample-products/gallery/daikin-ac-1t-2.jpg b/public/sample-products/gallery/daikin-ac-1t-2.jpg new file mode 100644 index 0000000..b7ac967 Binary files /dev/null and b/public/sample-products/gallery/daikin-ac-1t-2.jpg differ diff --git a/public/sample-products/gallery/daikin-ac-1t-3.jpg b/public/sample-products/gallery/daikin-ac-1t-3.jpg new file mode 100644 index 0000000..0e063d3 Binary files /dev/null and b/public/sample-products/gallery/daikin-ac-1t-3.jpg differ diff --git a/public/sample-products/gallery/galaxy-s24-fe-1.jpg b/public/sample-products/gallery/galaxy-s24-fe-1.jpg new file mode 100644 index 0000000..9b1b153 Binary files /dev/null and b/public/sample-products/gallery/galaxy-s24-fe-1.jpg differ diff --git a/public/sample-products/gallery/galaxy-s24-fe-2.jpg b/public/sample-products/gallery/galaxy-s24-fe-2.jpg new file mode 100644 index 0000000..c0d5cc0 Binary files /dev/null and b/public/sample-products/gallery/galaxy-s24-fe-2.jpg differ diff --git a/public/sample-products/gallery/galaxy-s24-fe-3.jpg b/public/sample-products/gallery/galaxy-s24-fe-3.jpg new file mode 100644 index 0000000..fe19c66 Binary files /dev/null and b/public/sample-products/gallery/galaxy-s24-fe-3.jpg differ diff --git a/public/sample-products/gallery/galaxy-tab-s9-fe-1.jpg b/public/sample-products/gallery/galaxy-tab-s9-fe-1.jpg new file mode 100644 index 0000000..dcef68b Binary files /dev/null and b/public/sample-products/gallery/galaxy-tab-s9-fe-1.jpg differ diff --git a/public/sample-products/gallery/galaxy-tab-s9-fe-2.jpg b/public/sample-products/gallery/galaxy-tab-s9-fe-2.jpg new file mode 100644 index 0000000..57e057e Binary files /dev/null and b/public/sample-products/gallery/galaxy-tab-s9-fe-2.jpg differ diff --git a/public/sample-products/gallery/galaxy-tab-s9-fe-3.jpg b/public/sample-products/gallery/galaxy-tab-s9-fe-3.jpg new file mode 100644 index 0000000..189ea84 Binary files /dev/null and b/public/sample-products/gallery/galaxy-tab-s9-fe-3.jpg differ diff --git a/public/sample-products/gallery/hp-pavilion-15-1.jpg b/public/sample-products/gallery/hp-pavilion-15-1.jpg new file mode 100644 index 0000000..ea827ad Binary files /dev/null and b/public/sample-products/gallery/hp-pavilion-15-1.jpg differ diff --git a/public/sample-products/gallery/hp-pavilion-15-2.jpg b/public/sample-products/gallery/hp-pavilion-15-2.jpg new file mode 100644 index 0000000..09ac475 Binary files /dev/null and b/public/sample-products/gallery/hp-pavilion-15-2.jpg differ diff --git a/public/sample-products/gallery/hp-pavilion-15-3.jpg b/public/sample-products/gallery/hp-pavilion-15-3.jpg new file mode 100644 index 0000000..90bd621 Binary files /dev/null and b/public/sample-products/gallery/hp-pavilion-15-3.jpg differ diff --git a/public/sample-products/gallery/ifb-washer-top-1.jpg b/public/sample-products/gallery/ifb-washer-top-1.jpg new file mode 100644 index 0000000..0eddb28 Binary files /dev/null and b/public/sample-products/gallery/ifb-washer-top-1.jpg differ diff --git a/public/sample-products/gallery/ifb-washer-top-2.jpg b/public/sample-products/gallery/ifb-washer-top-2.jpg new file mode 100644 index 0000000..7f02611 Binary files /dev/null and b/public/sample-products/gallery/ifb-washer-top-2.jpg differ diff --git a/public/sample-products/gallery/ifb-washer-top-3.jpg b/public/sample-products/gallery/ifb-washer-top-3.jpg new file mode 100644 index 0000000..7f02611 Binary files /dev/null and b/public/sample-products/gallery/ifb-washer-top-3.jpg differ diff --git a/public/sample-products/gallery/iphone-16-1.jpg b/public/sample-products/gallery/iphone-16-1.jpg new file mode 100644 index 0000000..4309135 Binary files /dev/null and b/public/sample-products/gallery/iphone-16-1.jpg differ diff --git a/public/sample-products/gallery/iphone-16-2.jpg b/public/sample-products/gallery/iphone-16-2.jpg new file mode 100644 index 0000000..8b7196a Binary files /dev/null and b/public/sample-products/gallery/iphone-16-2.jpg differ diff --git a/public/sample-products/gallery/iphone-16-3.jpg b/public/sample-products/gallery/iphone-16-3.jpg new file mode 100644 index 0000000..4db5277 Binary files /dev/null and b/public/sample-products/gallery/iphone-16-3.jpg differ diff --git a/public/sample-products/gallery/lg-fridge-sbs-1.jpg b/public/sample-products/gallery/lg-fridge-sbs-1.jpg new file mode 100644 index 0000000..33e4310 Binary files /dev/null and b/public/sample-products/gallery/lg-fridge-sbs-1.jpg differ diff --git a/public/sample-products/gallery/lg-fridge-sbs-2.jpg b/public/sample-products/gallery/lg-fridge-sbs-2.jpg new file mode 100644 index 0000000..ef07066 Binary files /dev/null and b/public/sample-products/gallery/lg-fridge-sbs-2.jpg differ diff --git a/public/sample-products/gallery/lg-fridge-sbs-3.jpg b/public/sample-products/gallery/lg-fridge-sbs-3.jpg new file mode 100644 index 0000000..33e4310 Binary files /dev/null and b/public/sample-products/gallery/lg-fridge-sbs-3.jpg differ diff --git a/public/sample-products/gallery/lg-oled-65-1.jpg b/public/sample-products/gallery/lg-oled-65-1.jpg new file mode 100644 index 0000000..87d72fa Binary files /dev/null and b/public/sample-products/gallery/lg-oled-65-1.jpg differ diff --git a/public/sample-products/gallery/lg-oled-65-2.jpg b/public/sample-products/gallery/lg-oled-65-2.jpg new file mode 100644 index 0000000..805303f Binary files /dev/null and b/public/sample-products/gallery/lg-oled-65-2.jpg differ diff --git a/public/sample-products/gallery/macbook-air-m3-1.jpg b/public/sample-products/gallery/macbook-air-m3-1.jpg new file mode 100644 index 0000000..66bdd9d Binary files /dev/null and b/public/sample-products/gallery/macbook-air-m3-1.jpg differ diff --git a/public/sample-products/gallery/macbook-air-m3-2.jpg b/public/sample-products/gallery/macbook-air-m3-2.jpg new file mode 100644 index 0000000..862b510 Binary files /dev/null and b/public/sample-products/gallery/macbook-air-m3-2.jpg differ diff --git a/public/sample-products/gallery/macbook-air-m3-3.jpg b/public/sample-products/gallery/macbook-air-m3-3.jpg new file mode 100644 index 0000000..181bf68 Binary files /dev/null and b/public/sample-products/gallery/macbook-air-m3-3.jpg differ diff --git a/public/sample-products/gallery/noise-colorfit-pro-6-1.jpg b/public/sample-products/gallery/noise-colorfit-pro-6-1.jpg new file mode 100644 index 0000000..b0063e3 Binary files /dev/null and b/public/sample-products/gallery/noise-colorfit-pro-6-1.jpg differ diff --git a/public/sample-products/gallery/noise-colorfit-pro-6-2.jpg b/public/sample-products/gallery/noise-colorfit-pro-6-2.jpg new file mode 100644 index 0000000..9843310 Binary files /dev/null and b/public/sample-products/gallery/noise-colorfit-pro-6-2.jpg differ diff --git a/public/sample-products/gallery/noise-colorfit-pro-6-3.jpg b/public/sample-products/gallery/noise-colorfit-pro-6-3.jpg new file mode 100644 index 0000000..bef1951 Binary files /dev/null and b/public/sample-products/gallery/noise-colorfit-pro-6-3.jpg differ diff --git a/public/sample-products/gallery/oneplus-nord-ce4-1.jpg b/public/sample-products/gallery/oneplus-nord-ce4-1.jpg new file mode 100644 index 0000000..c094b45 Binary files /dev/null and b/public/sample-products/gallery/oneplus-nord-ce4-1.jpg differ diff --git a/public/sample-products/gallery/oneplus-nord-ce4-2.jpg b/public/sample-products/gallery/oneplus-nord-ce4-2.jpg new file mode 100644 index 0000000..3e36b3c Binary files /dev/null and b/public/sample-products/gallery/oneplus-nord-ce4-2.jpg differ diff --git a/public/sample-products/gallery/oneplus-nord-ce4-3.jpg b/public/sample-products/gallery/oneplus-nord-ce4-3.jpg new file mode 100644 index 0000000..fcd7252 Binary files /dev/null and b/public/sample-products/gallery/oneplus-nord-ce4-3.jpg differ diff --git a/public/sample-products/gallery/panasonic-microwave-27l-1.jpg b/public/sample-products/gallery/panasonic-microwave-27l-1.jpg new file mode 100644 index 0000000..05a79de Binary files /dev/null and b/public/sample-products/gallery/panasonic-microwave-27l-1.jpg differ diff --git a/public/sample-products/gallery/panasonic-microwave-27l-2.jpg b/public/sample-products/gallery/panasonic-microwave-27l-2.jpg new file mode 100644 index 0000000..20f9096 Binary files /dev/null and b/public/sample-products/gallery/panasonic-microwave-27l-2.jpg differ diff --git a/public/sample-products/gallery/panasonic-microwave-27l-3.jpg b/public/sample-products/gallery/panasonic-microwave-27l-3.jpg new file mode 100644 index 0000000..5fc048b Binary files /dev/null and b/public/sample-products/gallery/panasonic-microwave-27l-3.jpg differ diff --git a/public/sample-products/gallery/redmi-note-14-1.jpg b/public/sample-products/gallery/redmi-note-14-1.jpg new file mode 100644 index 0000000..e05dfab Binary files /dev/null and b/public/sample-products/gallery/redmi-note-14-1.jpg differ diff --git a/public/sample-products/gallery/redmi-note-14-2.jpg b/public/sample-products/gallery/redmi-note-14-2.jpg new file mode 100644 index 0000000..9b6ad78 Binary files /dev/null and b/public/sample-products/gallery/redmi-note-14-2.jpg differ diff --git a/public/sample-products/gallery/redmi-note-14-3.jpg b/public/sample-products/gallery/redmi-note-14-3.jpg new file mode 100644 index 0000000..456ee89 Binary files /dev/null and b/public/sample-products/gallery/redmi-note-14-3.jpg differ diff --git a/public/sample-products/gallery/samsung-fridge-sd-1.jpg b/public/sample-products/gallery/samsung-fridge-sd-1.jpg new file mode 100644 index 0000000..118d302 Binary files /dev/null and b/public/sample-products/gallery/samsung-fridge-sd-1.jpg differ diff --git a/public/sample-products/gallery/samsung-fridge-sd-2.jpg b/public/sample-products/gallery/samsung-fridge-sd-2.jpg new file mode 100644 index 0000000..1adf0b3 Binary files /dev/null and b/public/sample-products/gallery/samsung-fridge-sd-2.jpg differ diff --git a/public/sample-products/gallery/samsung-fridge-sd-3.jpg b/public/sample-products/gallery/samsung-fridge-sd-3.jpg new file mode 100644 index 0000000..e78d470 Binary files /dev/null and b/public/sample-products/gallery/samsung-fridge-sd-3.jpg differ diff --git a/public/sample-products/gallery/samsung-tv-55-1.jpg b/public/sample-products/gallery/samsung-tv-55-1.jpg new file mode 100644 index 0000000..fbccbbd Binary files /dev/null and b/public/sample-products/gallery/samsung-tv-55-1.jpg differ diff --git a/public/sample-products/gallery/samsung-tv-55-2.jpg b/public/sample-products/gallery/samsung-tv-55-2.jpg new file mode 100644 index 0000000..f4f5601 Binary files /dev/null and b/public/sample-products/gallery/samsung-tv-55-2.jpg differ diff --git a/public/sample-products/gallery/samsung-tv-55-3.jpg b/public/sample-products/gallery/samsung-tv-55-3.jpg new file mode 100644 index 0000000..4cb837c Binary files /dev/null and b/public/sample-products/gallery/samsung-tv-55-3.jpg differ diff --git a/public/sample-products/gallery/sony-tv-43-1.jpg b/public/sample-products/gallery/sony-tv-43-1.jpg new file mode 100644 index 0000000..8c180a1 Binary files /dev/null and b/public/sample-products/gallery/sony-tv-43-1.jpg differ diff --git a/public/sample-products/gallery/sony-tv-43-2.jpg b/public/sample-products/gallery/sony-tv-43-2.jpg new file mode 100644 index 0000000..d7f0aaf Binary files /dev/null and b/public/sample-products/gallery/sony-tv-43-2.jpg differ diff --git a/public/sample-products/gallery/sony-wh1000xm5-1.jpg b/public/sample-products/gallery/sony-wh1000xm5-1.jpg new file mode 100644 index 0000000..d289f75 Binary files /dev/null and b/public/sample-products/gallery/sony-wh1000xm5-1.jpg differ diff --git a/public/sample-products/gallery/sony-wh1000xm5-2.jpg b/public/sample-products/gallery/sony-wh1000xm5-2.jpg new file mode 100644 index 0000000..c7651ef Binary files /dev/null and b/public/sample-products/gallery/sony-wh1000xm5-2.jpg differ diff --git a/public/sample-products/gallery/sony-wh1000xm5-3.jpg b/public/sample-products/gallery/sony-wh1000xm5-3.jpg new file mode 100644 index 0000000..0007ee7 Binary files /dev/null and b/public/sample-products/gallery/sony-wh1000xm5-3.jpg differ diff --git a/public/sample-products/gallery/voltas-ac-15t-1.jpg b/public/sample-products/gallery/voltas-ac-15t-1.jpg new file mode 100644 index 0000000..7315ab2 Binary files /dev/null and b/public/sample-products/gallery/voltas-ac-15t-1.jpg differ diff --git a/public/sample-products/gallery/voltas-ac-15t-2.jpg b/public/sample-products/gallery/voltas-ac-15t-2.jpg new file mode 100644 index 0000000..1fa2f44 Binary files /dev/null and b/public/sample-products/gallery/voltas-ac-15t-2.jpg differ diff --git a/public/sample-products/gallery/whirlpool-fridge-dd-1.jpg b/public/sample-products/gallery/whirlpool-fridge-dd-1.jpg new file mode 100644 index 0000000..e1d63f2 Binary files /dev/null and b/public/sample-products/gallery/whirlpool-fridge-dd-1.jpg differ diff --git a/public/sample-products/gallery/whirlpool-fridge-dd-2.jpg b/public/sample-products/gallery/whirlpool-fridge-dd-2.jpg new file mode 100644 index 0000000..7b8cd47 Binary files /dev/null and b/public/sample-products/gallery/whirlpool-fridge-dd-2.jpg differ diff --git a/public/sample-products/gallery/whirlpool-fridge-dd-3.jpg b/public/sample-products/gallery/whirlpool-fridge-dd-3.jpg new file mode 100644 index 0000000..5906a64 Binary files /dev/null and b/public/sample-products/gallery/whirlpool-fridge-dd-3.jpg differ diff --git a/public/sample-products/laptop.jpg b/public/sample-products/laptop.jpg new file mode 100644 index 0000000..e37f288 Binary files /dev/null and b/public/sample-products/laptop.jpg differ diff --git a/public/sample-products/microwave.jpg b/public/sample-products/microwave.jpg new file mode 100644 index 0000000..355198d Binary files /dev/null and b/public/sample-products/microwave.jpg differ diff --git a/public/sample-products/mobile.jpg b/public/sample-products/mobile.jpg new file mode 100644 index 0000000..9e16731 Binary files /dev/null and b/public/sample-products/mobile.jpg differ diff --git a/public/sample-products/tablet.jpg b/public/sample-products/tablet.jpg new file mode 100644 index 0000000..bedb107 Binary files /dev/null and b/public/sample-products/tablet.jpg differ diff --git a/public/sample-products/tv.jpg b/public/sample-products/tv.jpg new file mode 100644 index 0000000..f058f5f Binary files /dev/null and b/public/sample-products/tv.jpg differ diff --git a/public/sample-products/washer.jpg b/public/sample-products/washer.jpg new file mode 100644 index 0000000..c10eb55 Binary files /dev/null and b/public/sample-products/washer.jpg differ diff --git a/public/sample-products/wearable.jpg b/public/sample-products/wearable.jpg new file mode 100644 index 0000000..7489458 Binary files /dev/null and b/public/sample-products/wearable.jpg differ diff --git a/src/app/(workspace)/products/page.tsx b/src/app/(workspace)/products/page.tsx new file mode 100644 index 0000000..7aee665 --- /dev/null +++ b/src/app/(workspace)/products/page.tsx @@ -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 ( + + } + /> + + + + + The platform does not record a product catalogue or stock levels yet. + The products, prices and stock below are examples. + + + {rows ? ( + + + + + + + ) : ( + + )} + + + + + + ); +} diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 2873244..eb5cc18 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -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 = []; + 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), ); } diff --git a/src/app/api/sales/[id]/route.ts b/src/app/api/sales/[id]/route.ts index e4ea93f..9add6d1 100644 --- a/src/app/api/sales/[id]/route.ts +++ b/src/app/api/sales/[id]/route.ts @@ -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) { diff --git a/src/app/api/sales/route.ts b/src/app/api/sales/route.ts index bcb2858..afe0ca0 100644 --- a/src/app/api/sales/route.ts +++ b/src/app/api/sales/route.ts @@ -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(); - 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 { + 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), ); } diff --git a/src/features/auth/components/LoginSplit.tsx b/src/features/auth/components/LoginSplit.tsx index ec14ad0..72fcfb0 100644 --- a/src/features/auth/components/LoginSplit.tsx +++ b/src/features/auth/components/LoginSplit.tsx @@ -72,12 +72,8 @@ export function LoginSplit({
-
+
-
- - Merchant OS -
diff --git a/src/features/floor/components/TodaySummaryCard.tsx b/src/features/floor/components/TodaySummaryCard.tsx index 051485e..8a1c024 100644 --- a/src/features/floor/components/TodaySummaryCard.tsx +++ b/src/features/floor/components/TodaySummaryCard.tsx @@ -28,8 +28,8 @@ export function TodaySummaryCard({resource}: {resource: Resource}) value={today.currency === 'INR' ? formatInr(today.revenue) : `${today.revenue} ${today.currency}`} /> - - + + )} @@ -37,3 +37,8 @@ export function TodaySummaryCard({resource}: {resource: Resource}) ); } + +/** A figure the platform does not supply yet reads "—", not a false 0. */ +function countOrDash(v: number | null): string { + return v == null ? '—' : formatCount(v); +} diff --git a/src/features/floor/services/mapSummary.ts b/src/features/floor/services/mapSummary.ts index 08acdb0..102bb11 100644 --- a/src/features/floor/services/mapSummary.ts +++ b/src/features/floor/services/mapSummary.ts @@ -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, }; } diff --git a/src/features/floor/types/summary.ts b/src/features/floor/types/summary.ts index 276b98e..a6738e6 100644 --- a/src/features/floor/types/summary.ts +++ b/src/features/floor/types/summary.ts @@ -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; } diff --git a/src/features/loyaly-ai/components/ChatHeader.tsx b/src/features/loyaly-ai/components/ChatHeader.tsx index a6dd796..e4ba94f 100644 --- a/src/features/loyaly-ai/components/ChatHeader.tsx +++ b/src/features/loyaly-ai/components/ChatHeader.tsx @@ -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={} onClick={toggleFullscreen} /> - } - onClick={reload} - /> 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(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 ( + + + + + + setIsFocused(true)} + // Delayed, so a chip click lands before the chips hide. + onBlur={() => setTimeout(() => setIsFocused(false), 150)} + /> + + {showChips ? ( + + + + ) : null} + + + + ); + } + return ( - + - {hasMessages ? ( - - ) : ( - - )} + - - + + {!isStreaming ? : null} - - - {children} - - - ); -} - /** - * 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 ( - - - - - Ready to optimize your stores? + + + + + + + Ask anything about your business. - - Your proactive AI copilot for smarter retail decisions and instant analytics. - - - - - - - Footfall{' '} - - +{SAMPLE_AI_PULSE.footfallDeltaPct}% - - - - Sync{' '} - - {SAMPLE_AI_PULSE.syncPct}% - - - - - - - - - Active insights - - - - {SAMPLE_AI_INSIGHTS.map((item) => ( - onPick?.(item.prompt)} - padding={4} - > - - - - - - - - {item.title} - - - - - {item.description} - - - - {item.actionText} - - - - - - - ))} - - - - Quick queries - - {SUGGESTIONS.slice(0, 5).map((s) => ( - onPick?.(s.prompt)} /> - ))} - + + Business context · {scopeLabel} + ); } diff --git a/src/features/loyaly-ai/components/SuggestionChips.tsx b/src/features/loyaly-ai/components/SuggestionChips.tsx new file mode 100644 index 0000000..202edde --- /dev/null +++ b/src/features/loyaly-ai/components/SuggestionChips.tsx @@ -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 ( + + {items.map((s) => ( + + ))} + + ); +} diff --git a/src/features/products/components/ProductCatalog.tsx b/src/features/products/components/ProductCatalog.tsx new file mode 100644 index 0000000..a28de0a --- /dev/null +++ b/src/features/products/components/ProductCatalog.tsx @@ -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 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(); + 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. + + {/* + 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. + */} + onChange(ALL)} + className="shrink-0" + > + + + All brands + + + {formatCount(total)} products + + + + + {tabs.map((tab) => ( + onChange(tab.brand)} + className="shrink-0" + > + + + {tab.brand} + + {/* + 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. + */} + + {formatCount(tab.count)} products · from {formatInr(tab.fromInr)} + + + + ))} + + ); +} + +function Rating({item}: {item: SampleCatalogItem}) { + return ( + + + + {item.rating.toFixed(1)} + + + + + ({formatCompact(item.reviews)}) + + + ); +} + +function Price({item}: {item: SampleCatalogItem}) { + return ( + + + {formatInr(item.priceInr)} + + {item.discountPct > 0 ? ( + <> + + {formatInr(item.mrpInr)} + + + {item.discountPct}% off + + + ) : null} + + ); +} + +/** + * 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 ( + { + hover.cancel(); + onOpen(); + }} + onPointerMove={hover.onPointerMove} + onPointerLeave={hover.onPointerLeave} + > + + + + + + {item.brand} + + + {item.name} + + + {item.specs} + + + + + + + + + + {formatCount(item.units)} sold + + + + + ); +} + +const COLUMNS: TableColumn[] = [ + { + key: 'name', + header: 'Product', + width: proportional(3), + renderCell: (row) => ( + + + + + {row.brand} {row.name} + + + {row.sku} · {row.category} + + + + ), + }, + { + key: 'priceInr', + header: 'Price', + width: pixel(120), + align: 'end', + renderCell: (row) => ( + + + {formatInr(row.priceInr)} + + {row.discountPct > 0 ? ( + + {row.discountPct}% off + + ) : null} + + ), + }, + { + key: 'units', + header: 'Sold', + width: pixel(72), + align: 'end', + renderCell: (row) => {formatCount(row.units)}, + }, + { + key: 'revenueInr', + header: 'Revenue', + width: pixel(100), + align: 'end', + renderCell: (row) => ( + + {formatInrCompact(row.revenueInr)} + + ), + }, + { + key: 'deltaPct', + header: 'Change', + width: pixel(96), + align: 'end', + renderCell: (row) => , + }, + { + key: 'stock', + header: 'Stock', + width: pixel(72), + align: 'end', + renderCell: (row) => {formatCount(row.stock)}, + }, + { + key: 'stockStatus', + header: 'Status', + width: pixel(140), + align: 'end', + renderCell: (row) => ( + + ), + }, +]; + +/** + * 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}) { + const [view, setView] = useState('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('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(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 ( + <> + setView(v as View)} + label="Product view" + size="sm" + > + } /> + } /> + + } + resource={products} + loading={} + > + {(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 ( + + + + setCategory(String(v))} + options={[ + {label: 'All categories', value: ALL}, + ...categories.map((c) => ({label: c, value: c})), + ]} + /> + setSort(v as Sort)} + options={SORTS.map((s) => ({label: `Sort: ${s.label}`, value: s.value}))} + /> + + + + + + Showing {visible.length} of {rows.length} products + + + {visible.length === 0 ? ( + + ) : view === 'grid' ? ( + + {visible.map((item) => ( + setOpened(item)} /> + ))} + + ) : ( + + )} + + ); + }} + + + {/* Outside PanelCard: the dialog must survive a filter change that empties the grid. */} + {opened ? ( + { + // 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} + + ); +} diff --git a/src/features/products/components/ProductDetailDialog.tsx b/src/features/products/components/ProductDetailDialog.tsx new file mode 100644 index 0000000..f81de36 --- /dev/null +++ b/src/features/products/components/ProductDetailDialog.tsx @@ -0,0 +1,1164 @@ +'use client'; + +import {useState, useTransition} from 'react'; +import Image from 'next/image'; +import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog'; +import { + Layout, + LayoutContent, + LayoutFooter, + VStack, + HStack, +} from '@astryxdesign/core/Layout'; +import {Grid} from '@astryxdesign/core/Grid'; +import {Card} from '@astryxdesign/core/Card'; +import {Icon} from '@astryxdesign/core/Icon'; +import {Text, Heading} from '@astryxdesign/core/Text'; +import {Badge} from '@astryxdesign/core/Badge'; +import {StatusDot} from '@astryxdesign/core/StatusDot'; +import {Button} from '@astryxdesign/core/Button'; +import {ButtonGroup} from '@astryxdesign/core/ButtonGroup'; +import {ProgressBar} from '@astryxdesign/core/ProgressBar'; +import {Carousel} from '@astryxdesign/core/Carousel'; +import {Thumbnail} from '@astryxdesign/core/Thumbnail'; +import {Avatar} from '@astryxdesign/core/Avatar'; +import { + SegmentedControl, + SegmentedControlItem, +} from '@astryxdesign/core/SegmentedControl'; +import {MetricDelta} from '@/shared/components/primitives/MetricDelta'; +import {ICONS} from '@/shared/utils/icons'; +import {formatCompact, formatCount, formatInr} from '@/shared/utils/format'; +import {ACCENT} from '@/shared/utils/accent'; +import {KIND_ICON, STOCK, productImages} from '@/features/products/utils/productArtwork'; +import type {SampleCatalogItem, SampleProductPlatform} from '@/shared/mocks/sampleData'; +import {getProductSpecs} from '@/features/products/utils/productSpecs'; + +type ViewMode = 'studio' | 'barcode'; + +/** + * Generate a deterministic retail barcode pattern from the SKU string. + */ +function SkuBarcodeGraphic({sku}: {sku: string}) { + // Deterministic bar widths based on SKU characters + const bars = sku.split('').flatMap((char) => { + const code = char.charCodeAt(0); + return [ + (code % 3) + 1.2, + ((code >> 1) % 2) + 0.8, + ((code >> 2) % 3) + 1.5, + ((code >> 3) % 2) + 0.8, + ]; + }); + + return ( + + + + *{sku}* + + + ); +} + +/** + * Interactive product image viewer with hover zoom & cover/fit controls. + * + * Expands the photo to fill the showcase edge-to-edge, and tracks mouse coordinates + * for smooth real-time magnification so store staff can inspect product details. + */ +function ProductZoomStage({ + item, + src, +}: { + item: SampleCatalogItem; + src: string; +}) { + const [isHovered, setIsHovered] = useState(false); + const [mousePos, setMousePos] = useState({x: 50, y: 50}); + + const handleMouseMove = (e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + if (!rect.width || !rect.height) return; + const x = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100)); + const y = Math.max(0, Math.min(100, ((e.clientY - rect.top) / rect.height) * 100)); + setMousePos({x, y}); + }; + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onMouseMove={handleMouseMove} + > + {`${item.brand} + + {/* Floating subtle zoom hint */} + + + + Hover to zoom + + + + ); +} + +/** Five down to one, the order a shopper expects a rating breakdown in. */ +/** + * How a platform's figures became known, said plainly. + * + * The wording is the catalogue's own: a listing that was fetched and read is a + * different kind of fact from a price a search result happened to mention, and + * collapsing the two into one "price" column is how a catalogue starts lying. +/** + * Where it is sold — verified platforms known to carry this product. + * + * Deduplicated per platform (best price first), formatted into a clean, + * space-efficient 2-column layout with larger typography and no empty gaps. + */ +function ProductAvailability({item}: {item: SampleCatalogItem}) { + if (item.platforms.length === 0) return null; + + // Deduplicate platforms so each platform only appears once (prefer stated & lower price) + const uniquePlatforms = item.platforms.reduce((acc, current) => { + const existingIndex = acc.findIndex( + (p) => p.platform.trim().toLowerCase() === current.platform.trim().toLowerCase(), + ); + if (existingIndex === -1) { + acc.push(current); + } else { + const existing = acc[existingIndex]; + if ( + (existing.priceInr === null && current.priceInr !== null) || + (existing.priceInr !== null && current.priceInr !== null && current.priceInr < existing.priceInr) + ) { + acc[existingIndex] = current; + } + } + return acc; + }, []); + + const midPoint = Math.ceil(uniquePlatforms.length / 2); + const leftColumn = uniquePlatforms.slice(0, midPoint); + const rightColumn = uniquePlatforms.slice(midPoint); + + return ( + + + + Where it is sold + + + {uniquePlatforms.length} listings + + + + + + {/* Left Column */} + + + + Platform + + + Price · Seen + + + {leftColumn.map((row, idx) => ( + + + + + {row.platform} + + {row.isTamilNadu ? : null} + + {row.variant ? ( + + {row.variant} + + ) : null} + + + + + {row.priceInr === null ? ( + not stated + ) : ( + formatInr(row.priceInr) + )} + + + {row.seenHoursAgo} h ago + + + + ))} + + + {/* Right Column */} + + {rightColumn.length > 0 ? ( + + + Platform + + + Price · Seen + + + ) : null} + {rightColumn.map((row, idx) => ( + + + + + {row.platform} + + {row.isTamilNadu ? : null} + + {row.variant ? ( + + {row.variant} + + ) : null} + + + + + {row.priceInr === null ? ( + not stated + ) : ( + formatInr(row.priceInr) + )} + + + {row.seenHoursAgo} h ago + + + + ))} + + + + + + + + Prices are national listing prices as published by each platform; none was checked + against a Tamil Nadu pincode unless marked. + + + + ); +} + +const STAR_ROWS = [5, 4, 3, 2, 1]; + +/** + * What shoppers said about a product. + * + * Two halves. The spread on the left answers "is this a safe thing to + * recommend" — a 4.4 built from 5s and 1s is a different product from a 4.4 + * built from 4s, and the average alone hides that. The quotes on the right + * answer "what will they come back and complain about". + * + * Reads `ratingSpread` and `reviewHighlights` off the item, so the day the + * product API carries real reviews this block needs no change. + */ +function ProductReviews({item}: {item: SampleCatalogItem}) { + return ( + + + + Customer Experience + + + {formatCompact(item.reviews)} store reviews + + + + + {/* Score and its shape */} + + + + + {item.rating.toFixed(1)} + + + + + + out of 5 + + + + {formatCount(item.reviews)} ratings at this store + + + + + + {STAR_ROWS.map((stars, index) => ( + + + + {stars} + + + + + + + + {item.ratingSpread[index]}% + + + ))} + + + + + {/* The quotes themselves */} + + + {item.reviewHighlights.map((review, index) => ( + 0 ? 'border-t border-border/60' : undefined} + > + + + + + + {review.author} + + + {review.daysAgo} days ago + + + + + + + {review.rating} + + + + + + + + {review.title} + + + {review.body} + + + + + {review.isVerifiedPurchase ? ( + + ) : null} + + {formatCount(review.helpfulCount)} found this helpful + + + + ))} + + + + + ); +} + +/** + * Enhanced Product Details Dialog. + * + * Designed specifically for retail merchants and store staff: + * 1. Interactive Studio Showcase & Shelf Barcode Mode. + * 2. High-contrast commercial identity, feature pills & pricing savings. + * 3. Dedicated 4-card Store Performance & Sales Velocity matrix. + * 4. Structured technical specifications & 1-click SKU copying. + * 5. Fixed action bar for seamless POS workflows. + */ +export function ProductDetailDialog({ + item, + onClose, +}: { + item: SampleCatalogItem; + onClose: () => void; +}) { + const [viewMode, setViewMode] = useState('studio'); + const [shotIndex, setShotIndex] = useState(0); + const [copiedAll, setCopiedAll] = useState(false); + const [, startTransition] = useTransition(); + + const close = (open: boolean) => (open ? undefined : onClose()); + + // Parse specifications into individual feature pills + const specPills = item.specs + .split(/·|,|\|/) + .map((s) => s.trim()) + .filter(Boolean); + + const stockInfo = STOCK[item.stockStatus]; + const savings = Math.max(0, item.mrpInr - item.priceInr); + + // Market price intelligence & price range calculations + const platformPrices = (item.platforms ?? []) + .map((p) => p.priceInr) + .filter((price): price is number => price !== null && price > 0); + + const marketMin = platformPrices.length > 0 ? Math.min(...platformPrices) : item.priceInr; + const marketMax = platformPrices.length > 0 ? Math.max(...platformPrices) : item.mrpInr; + + const hasMarketSpread = marketMin < marketMax; + const hasStoreSpread = item.priceInr < item.mrpInr; + + // Clamped, because the dialog keeps its state when the merchant opens a + // second product and that one may carry fewer shots than the first. + const shots = productImages(item); + const activeShot = Math.min(shotIndex, shots.length - 1); + const stockProgress = Math.min(100, Math.max(12, (item.stock / 15) * 100)); + + const specSheet = getProductSpecs(item); + + const handleCopyAll = () => { + startTransition(() => { + const priceSummary = hasStoreSpread + ? `Price Range: ${formatInr(item.priceInr)} – ${formatInr(item.mrpInr)}` + : `Price: ${formatInr(item.priceInr)}`; + const marketSummary = hasMarketSpread + ? `\nMarket Range: ${formatInr(marketMin)} – ${formatInr(marketMax)}` + : ''; + const summary = `${item.brand} ${item.name} (${item.specs})\nSKU: ${item.sku}\n${priceSummary}${marketSummary}\nStock: ${item.stock} on hand (${stockInfo.label})`; + navigator.clipboard?.writeText(summary); + setCopiedAll(true); + setTimeout(() => setCopiedAll(false), 2000); + }); + }; + + return ( + + + + + {item.sku} + + + + + } + onOpenChange={close} + /> + } + content={ + + + {/* TOP SECTION: Interactive Showcase Stage & Commercial Identity */} + + {/* LEFT: Product Stage (Studio View or Barcode) */} + + + {/* View Switcher Bar */} + + setViewMode(v as ViewMode)} + label="Product display mode" + size="sm" + > + } + /> + } + /> + + + + + + {formatCount(item.stock)} on hand + + + + + {/* Canvas Area */} + {viewMode === 'studio' ? ( + + {/* Top floating pill */} + + + + + {item.category} + + + + + + + {item.rating.toFixed(1)} + + + + + {/* Elevated Central Product Showcase with Hover Zoom & Edge-to-Edge Cover */} + + + {/* + The rest of the shots. A carousel rather than a row + because the product API will send however many photos + a product has, and four or five must not push the + verification badge out of the showcase. + */} + {shots.length > 1 ? ( + + {shots.map((shot, index) => ( + setShotIndex(index)} + className={ + index === activeShot + ? 'rounded-lg ring-2 ring-foreground cursor-pointer scale-105 transition-all' + : 'rounded-lg opacity-70 hover:opacity-100 cursor-pointer transition-all' + } + /> + ))} + + ) : null} + + {/* Bottom verification badge */} + + + + Store Catalogue Verified · Active Registry + + + + ) : ( + /* Barcode & Shelf Label View */ + + + + STORE SHELF TAG + + + + + {/* Barcode SVG graphic */} + + + {/* Retail price badge */} + + + + Store Price Range + + + {hasStoreSpread + ? `${formatInr(item.priceInr)} – ${formatInr(item.mrpInr)}` + : formatInr(item.priceInr)} + + + + + Market Benchmark + + + {formatInr(marketMin)} – {formatInr(marketMax)} + + + + + + Ready for optical handheld scanner or camera checkout + + + )} + + + + {/* RIGHT: Commercial Identity, Dynamic Pricing & Stock Health */} + + + {/* Brand & Category path */} + + + + / + + + {item.category} + + + + {/* Product Name */} + + {item.name} + + + {/* Feature Pills */} + + {specPills.map((spec, idx) => ( + + + {spec} + + + ))} + + + + {/* Hero Pricing Card */} + + + + + + Price Range + + + {hasMarketSpread ? ( + + ) : null} + + + + + {hasStoreSpread + ? `${formatInr(item.priceInr)} – ${formatInr(item.mrpInr)}` + : formatInr(item.priceInr)} + + {item.discountPct > 0 ? ( + + ) : null} + + + {/* Structured price comparison bar */} + + + Store Price: + {formatInr(item.priceInr)} + + · + + M.R.P.: + {formatInr(item.mrpInr)} + + {hasMarketSpread ? ( + <> + · + + Across Retailers: + + {formatInr(marketMin)} – {formatInr(marketMax)} + + + + ) : null} + + + + Price range for active store register & verified external market channels · Inclusive of GST + + + + {/* Stock Health & Ratings */} + + + + + + {stockInfo.label} + + + + {formatCount(item.stock)} units available + + + + + + {/* Rating & Social Proof */} + + + + + {item.rating.toFixed(1)} + + + + + ({formatCompact(item.reviews)} store reviews) + + + + + Top Rated in {item.category} + + + + + + + {/* + SECTION 2: Where it is sold (Market & Competitor Platform Pricing) + Directly connects to the Price Range right above it so the merchant + immediately sees the multi-platform pricing benchmark. + */} + + + {/* + SECTION 3: Store Intelligence & Sales Velocity. + Revenue, volume and markdown efficiency for the store. + */} + + + + + Store Performance & Sales Velocity + + + Period analytics for the current reporting window + + + + + + + {/* Metric 1: Attributed Revenue */} + + + + + + + + Revenue + + + + + {formatInr(item.revenueInr)} + + + Attributed sales + + + + + + {/* Metric 2: Units Sold */} + + + + + + + + Volume + + + + + {formatCount(item.units)} units + + + {item.units > 8 + ? 'High velocity' + : 'Consistent mover'} + + + + + + {/* Metric 3: Promo & Savings Efficiency */} + + + + + + + + Discount + + + + + {item.discountPct > 0 + ? `${item.discountPct}% off` + : 'Full MRP'} + + + {item.discountPct > 0 + ? `Save ${formatInr(savings)}/u` + : 'Zero markdown'} + + + + + + + + {/* + SECTION 4: Technical Specifications + Structured two-column technical specifications sheet matching reference design. + */} + + + Specifications + + + + + {/* Left Column */} + + {specSheet.left.map((spec, idx) => ( + + + {spec.label} + + + {spec.value} + + + ))} + + + {/* Right Column */} + + {specSheet.right.map((spec, idx) => ( + + + {spec.label} + + + {spec.value} + + + ))} + + + + + + {/* + SECTION 5: Customer Experience & Reviews + Ratings, review distribution, and verified shopper feedback. + */} + + + + } + footer={ + + + + + + Store POS inventory sync: Active + + + + + + ); +} diff --git a/src/features/products/components/ProductImage.tsx b/src/features/products/components/ProductImage.tsx new file mode 100644 index 0000000..bfdda7e --- /dev/null +++ b/src/features/products/components/ProductImage.tsx @@ -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 ( + + {`${item.brand} + + ); +} diff --git a/src/features/products/hooks/useProducts.ts b/src/features/products/hooks/useProducts.ts new file mode 100644 index 0000000..70d3ea7 --- /dev/null +++ b/src/features/products/hooks/useProducts.ts @@ -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 { + const {storeId, range} = useScope(); + return useSampleResource(() => buildProductCatalog(storeId, range), [storeId, range]); +} diff --git a/src/features/products/utils/productArtwork.ts b/src/features/products/utils/productArtwork.ts new file mode 100644 index 0000000..b758472 --- /dev/null +++ b/src/features/products/utils/productArtwork.ts @@ -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 = { + 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 = { + 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 = { + '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 `-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'}, +}; diff --git a/src/features/products/utils/productSpecs.ts b/src/features/products/utils/productSpecs.ts new file mode 100644 index 0000000..46fbbcc --- /dev/null +++ b/src/features/products/utils/productSpecs.ts @@ -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 = { + '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}; +} diff --git a/src/services/api/reportsApi.ts b/src/services/api/reportsApi.ts index a413a1e..e23a582 100644 --- a/src/services/api/reportsApi.ts +++ b/src/services/api/reportsApi.ts @@ -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({ + upstreamRequest({ path: '/api/dashboard/summary', query: {tz, site}, accessToken, diff --git a/src/services/api/saleMapper.ts b/src/services/api/saleMapper.ts new file mode 100644 index 0000000..70e09f4 --- /dev/null +++ b/src/services/api/saleMapper.ts @@ -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[]) : []; +} diff --git a/src/services/api/salesApi.ts b/src/services/api/salesApi.ts index 385bff8..0845a80 100644 --- a/src/services/api/salesApi.ts +++ b/src/services/api/salesApi.ts @@ -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) => - upstreamRequest<{items: ApiSale[]}>({path: '/api/sales', query, accessToken}), + upstreamRequest({path: '/api/sales', query, accessToken}), create: ( accessToken: string, @@ -34,5 +38,5 @@ export const salesApi = { }), byId: (accessToken: string, id: string) => - upstreamRequest({path: `/api/sales/${encodeURIComponent(id)}`, accessToken}), + upstreamRequest({path: `/api/sales/${encodeURIComponent(id)}`, accessToken}), }; diff --git a/src/services/api/types.ts b/src/services/api/types.ts index 21fbb57..5254437 100644 --- a/src/services/api/types.ts +++ b/src/services/api/types.ts @@ -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 diff --git a/src/shared/hooks/useHoverIntent.ts b/src/shared/hooks/useHoverIntent.ts new file mode 100644 index 0000000..a72b19d --- /dev/null +++ b/src/shared/hooks/useHoverIntent.ts @@ -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({ + 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 | 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) => { + 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}; +} diff --git a/src/shared/layouts/workspace/nav-config.ts b/src/shared/layouts/workspace/nav-config.ts index 6dfa022..5d79a27 100644 --- a/src/shared/layouts/workspace/nav-config.ts +++ b/src/shared/layouts/workspace/nav-config.ts @@ -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}, ]; diff --git a/src/shared/mocks/sampleData.ts b/src/shared/mocks/sampleData.ts index fd163c0..8c67839 100644 --- a/src/shared/mocks/sampleData.ts +++ b/src/shared/mocks/sampleData.ts @@ -121,6 +121,8 @@ export function createRng(...parts: (string | number)[]) { }; } +export type Rng = ReturnType; + // =========================================================================== // 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 { + 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 { + 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 { + 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 = { + 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 + ); +} diff --git a/src/shared/utils/icons.ts b/src/shared/utils/icons.ts index e27e2d9..f7b15c7 100644 --- a/src/shared/utils/icons.ts +++ b/src/shared/utils/icons.ts @@ -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,