From 7553badfeb183eecc93cc92b17c814bd3a90b762 Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:56:16 +0530 Subject: [PATCH 01/73] db level --- apps/backend/wrangler.dev.toml | 6 +- .../drizzle/0002_sku_split.sql | 229 ++ .../drizzle/meta/0001_snapshot.json | 3422 ++++++++++++++++ .../drizzle/meta/0002_snapshot.json | 3513 +++++++++++++++++ .../drizzle/meta/_journal.json | 14 + packages/db_helper_sqlite/src/db/schema.ts | 75 +- packages/db_helper_sqlite/src/db/types.ts | 14 +- 7 files changed, 7240 insertions(+), 33 deletions(-) create mode 100644 packages/db_helper_sqlite/drizzle/0002_sku_split.sql create mode 100644 packages/db_helper_sqlite/drizzle/meta/0001_snapshot.json create mode 100644 packages/db_helper_sqlite/drizzle/meta/0002_snapshot.json diff --git a/apps/backend/wrangler.dev.toml b/apps/backend/wrangler.dev.toml index b05b479..99b12c3 100644 --- a/apps/backend/wrangler.dev.toml +++ b/apps/backend/wrangler.dev.toml @@ -8,8 +8,10 @@ routes = [ [[d1_databases]] binding = "DB" -database_name = "freshyo-backend-dev" -database_id = "6b93ddc9-9b24-4cfc-9320-e81aec38887a" +#database_name = "freshyo-backend-dev" +#database_id = "6b93ddc9-9b24-4cfc-9320-e81aec38887a" +database_name = "freshyo-dev" +database_id = "3cad440f-dc14-4baa-ab05-04eb08e206de" migrations_dir="../../packages/db_helper_sqlite/drizzle" migrations_pattern="migration.sql" [durable_objects] diff --git a/packages/db_helper_sqlite/drizzle/0002_sku_split.sql b/packages/db_helper_sqlite/drizzle/0002_sku_split.sql new file mode 100644 index 0000000..fcc01de --- /dev/null +++ b/packages/db_helper_sqlite/drizzle/0002_sku_split.sql @@ -0,0 +1,229 @@ +-- Migration: split product_info into product_info (catalog) + product_skus + sku_features +-- and move all buyable references from product ids to sku ids. + +-- PRAGMA foreign_keys=OFF; +PRAGMA defer_foreign_keys = on; + +-- 1. Create new SKU tables. +CREATE TABLE `product_skus` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `product_id` integer NOT NULL, + `name` text, + `price` text NOT NULL, + `market_price` text, + `images` text, + `is_out_of_stock` integer DEFAULT false NOT NULL, + `is_suspended` integer DEFAULT false NOT NULL, + `is_flash_available` integer DEFAULT false NOT NULL, + `flash_price` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action +); + +CREATE TABLE `sku_features` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `sku_id` integer NOT NULL, + `feature_name` text NOT NULL, + `feature_value` text NOT NULL, + FOREIGN KEY (`sku_id`) REFERENCES `product_skus`(`id`) ON UPDATE no action ON DELETE no action +); + +CREATE UNIQUE INDEX `unique_sku_feature_name` ON `sku_features` (`sku_id`,`feature_name`); + +-- 2. Migrate each existing product into one default SKU plus a mandatory quantity feature. +INSERT INTO `product_skus` ( + `product_id`, `name`, `price`, `market_price`, `images`, `is_out_of_stock`, + `is_suspended`, `is_flash_available`, `flash_price`, `created_at` +) +SELECT + `id`, + NULL, + `price`, + `market_price`, + `images`, + `is_out_of_stock`, + `is_suspended`, + `is_flash_available`, + `flash_price`, + `created_at` +FROM `product_info`; + +INSERT INTO `sku_features` (`sku_id`, `feature_name`, `feature_value`) +SELECT + `ps`.`id`, + 'quantity', + CASE + WHEN CAST(`pi`.`product_quantity` AS INTEGER) = `pi`.`product_quantity` + THEN CAST(CAST(`pi`.`product_quantity` AS INTEGER) AS TEXT) + ELSE CAST(`pi`.`product_quantity` AS TEXT) + END || COALESCE(`u`.`short_notation`, '') +FROM `product_info` `pi` +JOIN `product_skus` `ps` ON `ps`.`product_id` = `pi`.`id` +LEFT JOIN `units` `u` ON `u`.`id` = `pi`.`unit_id`; + +-- 3. Build a product_id -> sku_id mapping for downstream tables. +CREATE TABLE `__product_to_sku` ( + `product_id` integer PRIMARY KEY, + `sku_id` integer NOT NULL +); + +INSERT INTO `__product_to_sku` (`product_id`, `sku_id`) +SELECT `product_id`, `id` FROM `product_skus`; + +-- 4. Recreate product_info with only catalog-level fields. +CREATE TABLE `__new_product_info` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `short_description` text, + `long_description` text, + `store_id` integer, + `increment_step` real DEFAULT 1 NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`store_id`) REFERENCES `store_info`(`id`) ON UPDATE no action ON DELETE no action +); + +INSERT INTO `__new_product_info` ( + `id`, `name`, `short_description`, `long_description`, `store_id`, `increment_step`, `created_at` +) +SELECT + `id`, `name`, `short_description`, `long_description`, `store_id`, `increment_step`, `created_at` +FROM `product_info`; + +DROP TABLE `product_info`; +ALTER TABLE `__new_product_info` RENAME TO `product_info`; + +-- 5. Recreate tables that previously referenced product_info.id so they now reference product_skus.id. +CREATE TABLE `__new_cart_items` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` integer NOT NULL, + `sku_id` integer NOT NULL, + `quantity` text NOT NULL, + `added_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`sku_id`) REFERENCES `product_skus`(`id`) ON UPDATE no action ON DELETE no action +); + +INSERT INTO `__new_cart_items` (`id`, `user_id`, `sku_id`, `quantity`, `added_at`) +SELECT + `ci`.`id`, `ci`.`user_id`, `m`.`sku_id`, `ci`.`quantity`, `ci`.`added_at` +FROM `cart_items` `ci` +JOIN `__product_to_sku` `m` ON `m`.`product_id` = `ci`.`product_id`; + +DROP TABLE `cart_items`; +ALTER TABLE `__new_cart_items` RENAME TO `cart_items`; +CREATE UNIQUE INDEX `unique_user_sku` ON `cart_items` (`user_id`,`sku_id`); + +CREATE TABLE `__new_order_items` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `order_id` integer NOT NULL, + `sku_id` integer NOT NULL, + `quantity` text NOT NULL, + `price` text NOT NULL, + `discounted_price` text, + `is_packaged` integer DEFAULT false NOT NULL, + `is_package_verified` integer DEFAULT false NOT NULL, + FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`sku_id`) REFERENCES `product_skus`(`id`) ON UPDATE no action ON DELETE no action +); + +INSERT INTO `__new_order_items` ( + `id`, `order_id`, `sku_id`, `quantity`, `price`, `discounted_price`, `is_packaged`, `is_package_verified` +) +SELECT + `oi`.`id`, `oi`.`order_id`, `m`.`sku_id`, `oi`.`quantity`, `oi`.`price`, + `oi`.`discounted_price`, `oi`.`is_packaged`, `oi`.`is_package_verified` +FROM `order_items` `oi` +JOIN `__product_to_sku` `m` ON `m`.`product_id` = `oi`.`product_id`; + +DROP TABLE `order_items`; +ALTER TABLE `__new_order_items` RENAME TO `order_items`; + +CREATE TABLE `__new_special_deals` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `sku_id` integer NOT NULL, + `quantity` text NOT NULL, + `price` text NOT NULL, + `valid_till` text NOT NULL, + FOREIGN KEY (`sku_id`) REFERENCES `product_skus`(`id`) ON UPDATE no action ON DELETE no action +); + +INSERT INTO `__new_special_deals` (`id`, `sku_id`, `quantity`, `price`, `valid_till`) +SELECT + `sd`.`id`, `m`.`sku_id`, `sd`.`quantity`, `sd`.`price`, `sd`.`valid_till` +FROM `special_deals` `sd` +JOIN `__product_to_sku` `m` ON `m`.`product_id` = `sd`.`product_id`; + +DROP TABLE `special_deals`; +ALTER TABLE `__new_special_deals` RENAME TO `special_deals`; + +CREATE TABLE `__new_coupon_applicable_products` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `coupon_id` integer NOT NULL, + `sku_id` integer NOT NULL, + FOREIGN KEY (`coupon_id`) REFERENCES `coupons`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`sku_id`) REFERENCES `product_skus`(`id`) ON UPDATE no action ON DELETE no action +); + +INSERT INTO `__new_coupon_applicable_products` (`id`, `coupon_id`, `sku_id`) +SELECT + `cap`.`id`, `cap`.`coupon_id`, `m`.`sku_id` +FROM `coupon_applicable_products` `cap` +JOIN `__product_to_sku` `m` ON `m`.`product_id` = `cap`.`product_id`; + +DROP TABLE `coupon_applicable_products`; +ALTER TABLE `__new_coupon_applicable_products` RENAME TO `coupon_applicable_products`; +CREATE UNIQUE INDEX `unique_coupon_sku` ON `coupon_applicable_products` (`coupon_id`,`sku_id`); + +-- 6. Remap JSON product id arrays to sku id arrays (before renaming the columns). +UPDATE `delivery_slot_info` AS `target` +SET `product_ids` = COALESCE( + (SELECT json_group_array(`m`.`sku_id`) + FROM json_each(`target`.`product_ids`) AS `je` + JOIN `__product_to_sku` `m` ON `m`.`product_id` = `je`.`value`), + CASE WHEN `target`.`product_ids` IS NULL THEN NULL ELSE '[]' END +); + +UPDATE `home_banners` AS `target` +SET `product_ids` = COALESCE( + (SELECT json_group_array(`m`.`sku_id`) + FROM json_each(`target`.`product_ids`) AS `je` + JOIN `__product_to_sku` `m` ON `m`.`product_id` = `je`.`value`), + CASE WHEN `target`.`product_ids` IS NULL THEN NULL ELSE '[]' END +); + +UPDATE `coupons` AS `target` +SET `product_ids` = COALESCE( + (SELECT json_group_array(`m`.`sku_id`) + FROM json_each(`target`.`product_ids`) AS `je` + JOIN `__product_to_sku` `m` ON `m`.`product_id` = `je`.`value`), + CASE WHEN `target`.`product_ids` IS NULL THEN NULL ELSE '[]' END +); + +UPDATE `reserved_coupons` AS `target` +SET `product_ids` = COALESCE( + (SELECT json_group_array(`m`.`sku_id`) + FROM json_each(`target`.`product_ids`) AS `je` + JOIN `__product_to_sku` `m` ON `m`.`product_id` = `je`.`value`), + CASE WHEN `target`.`product_ids` IS NULL THEN NULL ELSE '[]' END +); + +UPDATE `vendor_snippets` AS `target` +SET `product_ids` = COALESCE( + (SELECT json_group_array(`m`.`sku_id`) + FROM json_each(`target`.`product_ids`) AS `je` + JOIN `__product_to_sku` `m` ON `m`.`product_id` = `je`.`value`), + '[]' +); + +-- 7. Rename JSON columns from product_ids to sku_ids. +ALTER TABLE `delivery_slot_info` RENAME COLUMN `product_ids` TO `sku_ids`; +ALTER TABLE `home_banners` RENAME COLUMN `product_ids` TO `sku_ids`; +ALTER TABLE `coupons` RENAME COLUMN `product_ids` TO `sku_ids`; +ALTER TABLE `reserved_coupons` RENAME COLUMN `product_ids` TO `sku_ids`; +ALTER TABLE `vendor_snippets` RENAME COLUMN `product_ids` TO `sku_ids`; + +-- 8. Clean up helper table. +DROP TABLE `__product_to_sku`; + +-- PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys = off; diff --git a/packages/db_helper_sqlite/drizzle/meta/0001_snapshot.json b/packages/db_helper_sqlite/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..ab1a2e6 --- /dev/null +++ b/packages/db_helper_sqlite/drizzle/meta/0001_snapshot.json @@ -0,0 +1,3422 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "6333861e-b629-4b55-9c0d-479fb080070b", + "prevId": "8b667990-7cbb-4115-89ee-b28da799ca9d", + "tables": { + "address_areas": { + "name": "address_areas", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "place_name": { + "name": "place_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "zone_id": { + "name": "zone_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "address_areas_zone_id_address_zones_id_fk": { + "name": "address_areas_zone_id_address_zones_id_fk", + "tableFrom": "address_areas", + "tableTo": "address_zones", + "columnsFrom": [ + "zone_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "address_zones": { + "name": "address_zones", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "zone_name": { + "name": "zone_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "added_at": { + "name": "added_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "addresses": { + "name": "addresses", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "address_line1": { + "name": "address_line1", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "address_line2": { + "name": "address_line2", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pincode": { + "name": "pincode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "latitude": { + "name": "latitude", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "longitude": { + "name": "longitude", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "google_maps_url": { + "name": "google_maps_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "admin_latitude": { + "name": "admin_latitude", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "admin_longitude": { + "name": "admin_longitude", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "zone_id": { + "name": "zone_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "addresses_user_id_users_id_fk": { + "name": "addresses_user_id_users_id_fk", + "tableFrom": "addresses", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "addresses_zone_id_address_zones_id_fk": { + "name": "addresses_zone_id_address_zones_id_fk", + "tableFrom": "addresses", + "tableTo": "address_zones", + "columnsFrom": [ + "zone_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "cart_items": { + "name": "cart_items", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "product_id": { + "name": "product_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "added_at": { + "name": "added_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "unique_user_product": { + "name": "unique_user_product", + "columns": [ + "user_id", + "product_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "cart_items_user_id_users_id_fk": { + "name": "cart_items_user_id_users_id_fk", + "tableFrom": "cart_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cart_items_product_id_product_info_id_fk": { + "name": "cart_items_product_id_product_info_id_fk", + "tableFrom": "cart_items", + "tableTo": "product_info", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "complaints": { + "name": "complaints", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order_id": { + "name": "order_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "complaint_body": { + "name": "complaint_body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "images": { + "name": "images", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response": { + "name": "response", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_resolved": { + "name": "is_resolved", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "complaints_user_id_users_id_fk": { + "name": "complaints_user_id_users_id_fk", + "tableFrom": "complaints", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "complaints_order_id_orders_id_fk": { + "name": "complaints_order_id_orders_id_fk", + "tableFrom": "complaints", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "coupon_applicable_products": { + "name": "coupon_applicable_products", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "coupon_id": { + "name": "coupon_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "product_id": { + "name": "product_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "unique_coupon_product": { + "name": "unique_coupon_product", + "columns": [ + "coupon_id", + "product_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "coupon_applicable_products_coupon_id_coupons_id_fk": { + "name": "coupon_applicable_products_coupon_id_coupons_id_fk", + "tableFrom": "coupon_applicable_products", + "tableTo": "coupons", + "columnsFrom": [ + "coupon_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "coupon_applicable_products_product_id_product_info_id_fk": { + "name": "coupon_applicable_products_product_id_product_info_id_fk", + "tableFrom": "coupon_applicable_products", + "tableTo": "product_info", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "coupon_applicable_users": { + "name": "coupon_applicable_users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "coupon_id": { + "name": "coupon_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "unique_coupon_user": { + "name": "unique_coupon_user", + "columns": [ + "coupon_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "coupon_applicable_users_coupon_id_coupons_id_fk": { + "name": "coupon_applicable_users_coupon_id_coupons_id_fk", + "tableFrom": "coupon_applicable_users", + "tableTo": "coupons", + "columnsFrom": [ + "coupon_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "coupon_applicable_users_user_id_users_id_fk": { + "name": "coupon_applicable_users_user_id_users_id_fk", + "tableFrom": "coupon_applicable_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "coupon_usage": { + "name": "coupon_usage", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "coupon_id": { + "name": "coupon_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order_id": { + "name": "order_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order_item_id": { + "name": "order_item_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "coupon_usage_user_id_users_id_fk": { + "name": "coupon_usage_user_id_users_id_fk", + "tableFrom": "coupon_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "coupon_usage_coupon_id_coupons_id_fk": { + "name": "coupon_usage_coupon_id_coupons_id_fk", + "tableFrom": "coupon_usage", + "tableTo": "coupons", + "columnsFrom": [ + "coupon_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "coupon_usage_order_id_orders_id_fk": { + "name": "coupon_usage_order_id_orders_id_fk", + "tableFrom": "coupon_usage", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "coupon_usage_order_item_id_order_items_id_fk": { + "name": "coupon_usage_order_item_id_order_items_id_fk", + "tableFrom": "coupon_usage", + "tableTo": "order_items", + "columnsFrom": [ + "order_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "coupons": { + "name": "coupons", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "coupon_code": { + "name": "coupon_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_user_based": { + "name": "is_user_based", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "discount_percent": { + "name": "discount_percent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "flat_discount": { + "name": "flat_discount", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "min_order": { + "name": "min_order", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "product_ids": { + "name": "product_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "max_value": { + "name": "max_value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_apply_for_all": { + "name": "is_apply_for_all", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "valid_till": { + "name": "valid_till", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_limit_for_user": { + "name": "max_limit_for_user", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_invalidated": { + "name": "is_invalidated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "exclusive_apply": { + "name": "exclusive_apply", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "coupons_coupon_code_unique": { + "name": "coupons_coupon_code_unique", + "columns": [ + "coupon_code" + ], + "isUnique": true + } + }, + "foreignKeys": { + "coupons_created_by_staff_users_id_fk": { + "name": "coupons_created_by_staff_users_id_fk", + "tableFrom": "coupons", + "tableTo": "staff_users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "delivery_slot_info": { + "name": "delivery_slot_info", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "delivery_time": { + "name": "delivery_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "freeze_time": { + "name": "freeze_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "is_flash": { + "name": "is_flash", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_capacity_full": { + "name": "is_capacity_full", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "delivery_sequence": { + "name": "delivery_sequence", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_ids": { + "name": "group_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "product_ids": { + "name": "product_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "home_banners": { + "name": "home_banners", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "product_ids": { + "name": "product_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "serial_num": { + "name": "serial_num", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_updated": { + "name": "last_updated", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "key_val_store": { + "name": "key_val_store", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notif_creds": { + "name": "notif_creds", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "added_at": { + "name": "added_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_verified": { + "name": "last_verified", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "notif_creds_token_unique": { + "name": "notif_creds_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "notif_creds_user_id_users_id_fk": { + "name": "notif_creds_user_id_users_id_fk", + "tableFrom": "notif_creds", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notifications": { + "name": "notifications", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_read": { + "name": "is_read", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "order_items": { + "name": "order_items", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "order_id": { + "name": "order_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "product_id": { + "name": "product_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price": { + "name": "price", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "discounted_price": { + "name": "discounted_price", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_packaged": { + "name": "is_packaged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_package_verified": { + "name": "is_package_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "order_items_order_id_orders_id_fk": { + "name": "order_items_order_id_orders_id_fk", + "tableFrom": "order_items", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "order_items_product_id_product_info_id_fk": { + "name": "order_items_product_id_product_info_id_fk", + "tableFrom": "order_items", + "tableTo": "product_info", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "order_status": { + "name": "order_status", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "order_time": { + "name": "order_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order_id": { + "name": "order_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_packaged": { + "name": "is_packaged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_delivered": { + "name": "is_delivered", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_cancelled": { + "name": "is_cancelled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "cancel_reason": { + "name": "cancel_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_cancelled_by_admin": { + "name": "is_cancelled_by_admin", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_state": { + "name": "payment_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "cancellation_user_notes": { + "name": "cancellation_user_notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancellation_admin_notes": { + "name": "cancellation_admin_notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancellation_reviewed": { + "name": "cancellation_reviewed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "cancellation_reviewed_at": { + "name": "cancellation_reviewed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refund_coupon_id": { + "name": "refund_coupon_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "order_status_user_id_users_id_fk": { + "name": "order_status_user_id_users_id_fk", + "tableFrom": "order_status", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "order_status_order_id_orders_id_fk": { + "name": "order_status_order_id_orders_id_fk", + "tableFrom": "order_status", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "order_status_refund_coupon_id_coupons_id_fk": { + "name": "order_status_refund_coupon_id_coupons_id_fk", + "tableFrom": "order_status", + "tableTo": "coupons", + "columnsFrom": [ + "refund_coupon_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "orders": { + "name": "orders", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "address_id": { + "name": "address_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slot_id": { + "name": "slot_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_cod": { + "name": "is_cod", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_online_payment": { + "name": "is_online_payment", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "payment_info_id": { + "name": "payment_info_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_amount": { + "name": "total_amount", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delivery_charge": { + "name": "delivery_charge", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'0'" + }, + "readable_id": { + "name": "readable_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "admin_notes": { + "name": "admin_notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_notes": { + "name": "user_notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order_group_id": { + "name": "order_group_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order_group_proportion": { + "name": "order_group_proportion", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_flash_delivery": { + "name": "is_flash_delivery", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "orders_user_id_users_id_fk": { + "name": "orders_user_id_users_id_fk", + "tableFrom": "orders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "orders_address_id_addresses_id_fk": { + "name": "orders_address_id_addresses_id_fk", + "tableFrom": "orders", + "tableTo": "addresses", + "columnsFrom": [ + "address_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "orders_slot_id_delivery_slot_info_id_fk": { + "name": "orders_slot_id_delivery_slot_info_id_fk", + "tableFrom": "orders", + "tableTo": "delivery_slot_info", + "columnsFrom": [ + "slot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "orders_payment_info_id_payment_info_id_fk": { + "name": "orders_payment_info_id_payment_info_id_fk", + "tableFrom": "orders", + "tableTo": "payment_info", + "columnsFrom": [ + "payment_info_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "payment_info": { + "name": "payment_info", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "gateway": { + "name": "gateway", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order_id": { + "name": "order_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "merchant_order_id": { + "name": "merchant_order_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "payment_info_merchant_order_id_unique": { + "name": "payment_info_merchant_order_id_unique", + "columns": [ + "merchant_order_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "payments": { + "name": "payments", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "gateway": { + "name": "gateway", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order_id": { + "name": "order_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "merchant_order_id": { + "name": "merchant_order_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "payments_merchant_order_id_unique": { + "name": "payments_merchant_order_id_unique", + "columns": [ + "merchant_order_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "payments_order_id_orders_id_fk": { + "name": "payments_order_id_orders_id_fk", + "tableFrom": "payments", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "product_categories": { + "name": "product_categories", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "product_group_info": { + "name": "product_group_info", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "product_group_membership": { + "name": "product_group_membership", + "columns": { + "product_id": { + "name": "product_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "added_at": { + "name": "added_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "product_group_membership_product_id_product_info_id_fk": { + "name": "product_group_membership_product_id_product_info_id_fk", + "tableFrom": "product_group_membership", + "tableTo": "product_info", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "product_group_membership_group_id_product_group_info_id_fk": { + "name": "product_group_membership_group_id_product_group_info_id_fk", + "tableFrom": "product_group_membership", + "tableTo": "product_group_info", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "product_group_membership_pk": { + "columns": [ + "product_id", + "group_id" + ], + "name": "product_group_membership_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "product_info": { + "name": "product_info", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "short_description": { + "name": "short_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "long_description": { + "name": "long_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit_id": { + "name": "unit_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price": { + "name": "price", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "market_price": { + "name": "market_price", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "images": { + "name": "images", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_out_of_stock": { + "name": "is_out_of_stock", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_suspended": { + "name": "is_suspended", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_flash_available": { + "name": "is_flash_available", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "flash_price": { + "name": "flash_price", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "increment_step": { + "name": "increment_step", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "product_quantity": { + "name": "product_quantity", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "store_id": { + "name": "store_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "product_info_unit_id_units_id_fk": { + "name": "product_info_unit_id_units_id_fk", + "tableFrom": "product_info", + "tableTo": "units", + "columnsFrom": [ + "unit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "product_info_store_id_store_info_id_fk": { + "name": "product_info_store_id_store_info_id_fk", + "tableFrom": "product_info", + "tableTo": "store_info", + "columnsFrom": [ + "store_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "product_reviews": { + "name": "product_reviews", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "product_id": { + "name": "product_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "review_body": { + "name": "review_body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image_urls": { + "name": "image_urls", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "review_time": { + "name": "review_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "ratings": { + "name": "ratings", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "admin_response": { + "name": "admin_response", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "admin_response_images": { + "name": "admin_response_images", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "product_reviews_user_id_users_id_fk": { + "name": "product_reviews_user_id_users_id_fk", + "tableFrom": "product_reviews", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "product_reviews_product_id_product_info_id_fk": { + "name": "product_reviews_product_id_product_info_id_fk", + "tableFrom": "product_reviews", + "tableTo": "product_info", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "rating_check": { + "name": "rating_check", + "value": "\"product_reviews\".\"ratings\" >= 1 AND \"product_reviews\".\"ratings\" <= 5" + } + } + }, + "product_tag_info": { + "name": "product_tag_info", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "tag_name": { + "name": "tag_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_description": { + "name": "tag_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_dashboard_tag": { + "name": "is_dashboard_tag", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "related_stores": { + "name": "related_stores", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "product_tag_info_tag_name_unique": { + "name": "product_tag_info_tag_name_unique", + "columns": [ + "tag_name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "product_tags": { + "name": "product_tags", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "product_id": { + "name": "product_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "unique_product_tag": { + "name": "unique_product_tag", + "columns": [ + "product_id", + "tag_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "product_tags_product_id_product_info_id_fk": { + "name": "product_tags_product_id_product_info_id_fk", + "tableFrom": "product_tags", + "tableTo": "product_info", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "product_tags_tag_id_product_tag_info_id_fk": { + "name": "product_tags_tag_id_product_tag_info_id_fk", + "tableFrom": "product_tags", + "tableTo": "product_tag_info", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "refunds": { + "name": "refunds", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "order_id": { + "name": "order_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refund_amount": { + "name": "refund_amount", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refund_status": { + "name": "refund_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'none'" + }, + "merchant_refund_id": { + "name": "merchant_refund_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refund_processed_at": { + "name": "refund_processed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "refunds_order_id_orders_id_fk": { + "name": "refunds_order_id_orders_id_fk", + "tableFrom": "refunds", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reserved_coupons": { + "name": "reserved_coupons", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "secret_code": { + "name": "secret_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "coupon_code": { + "name": "coupon_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "discount_percent": { + "name": "discount_percent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "flat_discount": { + "name": "flat_discount", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "min_order": { + "name": "min_order", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "product_ids": { + "name": "product_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_value": { + "name": "max_value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "valid_till": { + "name": "valid_till", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_limit_for_user": { + "name": "max_limit_for_user", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exclusive_apply": { + "name": "exclusive_apply", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_redeemed": { + "name": "is_redeemed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "redeemed_by": { + "name": "redeemed_by", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "reserved_coupons_secret_code_unique": { + "name": "reserved_coupons_secret_code_unique", + "columns": [ + "secret_code" + ], + "isUnique": true + } + }, + "foreignKeys": { + "reserved_coupons_redeemed_by_users_id_fk": { + "name": "reserved_coupons_redeemed_by_users_id_fk", + "tableFrom": "reserved_coupons", + "tableTo": "users", + "columnsFrom": [ + "redeemed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "reserved_coupons_created_by_staff_users_id_fk": { + "name": "reserved_coupons_created_by_staff_users_id_fk", + "tableFrom": "reserved_coupons", + "tableTo": "staff_users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "special_deals": { + "name": "special_deals", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "product_id": { + "name": "product_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price": { + "name": "price", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "valid_till": { + "name": "valid_till", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "special_deals_product_id_product_info_id_fk": { + "name": "special_deals_product_id_product_info_id_fk", + "tableFrom": "special_deals", + "tableTo": "product_info", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "staff_permissions": { + "name": "staff_permissions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "permission_name": { + "name": "permission_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "unique_permission_name": { + "name": "unique_permission_name", + "columns": [ + "permission_name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "staff_role_permissions": { + "name": "staff_role_permissions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "staff_role_id": { + "name": "staff_role_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "staff_permission_id": { + "name": "staff_permission_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "unique_role_permission": { + "name": "unique_role_permission", + "columns": [ + "staff_role_id", + "staff_permission_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "staff_role_permissions_staff_role_id_staff_roles_id_fk": { + "name": "staff_role_permissions_staff_role_id_staff_roles_id_fk", + "tableFrom": "staff_role_permissions", + "tableTo": "staff_roles", + "columnsFrom": [ + "staff_role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "staff_role_permissions_staff_permission_id_staff_permissions_id_fk": { + "name": "staff_role_permissions_staff_permission_id_staff_permissions_id_fk", + "tableFrom": "staff_role_permissions", + "tableTo": "staff_permissions", + "columnsFrom": [ + "staff_permission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "staff_roles": { + "name": "staff_roles", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "role_name": { + "name": "role_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "unique_role_name": { + "name": "unique_role_name", + "columns": [ + "role_name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "staff_users": { + "name": "staff_users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "staff_role_id": { + "name": "staff_role_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "staff_users_staff_role_id_staff_roles_id_fk": { + "name": "staff_users_staff_role_id_staff_roles_id_fk", + "tableFrom": "staff_users", + "tableTo": "staff_roles", + "columnsFrom": [ + "staff_role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "store_info": { + "name": "store_info", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "owner": { + "name": "owner", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "store_info_owner_staff_users_id_fk": { + "name": "store_info_owner_staff_users_id_fk", + "tableFrom": "store_info", + "tableTo": "staff_users", + "columnsFrom": [ + "owner" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "units": { + "name": "units", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "short_notation": { + "name": "short_notation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "unique_short_notation": { + "name": "unique_short_notation", + "columns": [ + "short_notation" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "unlogged_user_tokens": { + "name": "unlogged_user_tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "added_at": { + "name": "added_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_verified": { + "name": "last_verified", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "unlogged_user_tokens_token_unique": { + "name": "unlogged_user_tokens_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "upload_url_status": { + "name": "upload_url_status", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_creds": { + "name": "user_creds", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_password": { + "name": "user_password", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "user_creds_user_id_users_id_fk": { + "name": "user_creds_user_id_users_id_fk", + "tableFrom": "user_creds", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_details": { + "name": "user_details", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "date_of_birth": { + "name": "date_of_birth", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gender": { + "name": "gender", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "occupation": { + "name": "occupation", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "profile_image": { + "name": "profile_image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_suspended": { + "name": "is_suspended", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "user_details_user_id_unique": { + "name": "user_details_user_id_unique", + "columns": [ + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_details_user_id_users_id_fk": { + "name": "user_details_user_id_users_id_fk", + "tableFrom": "user_details", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_incidents": { + "name": "user_incidents", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order_id": { + "name": "order_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "date_added": { + "name": "date_added", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "admin_comment": { + "name": "admin_comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "added_by": { + "name": "added_by", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "negativity_score": { + "name": "negativity_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_incidents_user_id_users_id_fk": { + "name": "user_incidents_user_id_users_id_fk", + "tableFrom": "user_incidents", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_incidents_order_id_orders_id_fk": { + "name": "user_incidents_order_id_orders_id_fk", + "tableFrom": "user_incidents", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_incidents_added_by_staff_users_id_fk": { + "name": "user_incidents_added_by_staff_users_id_fk", + "tableFrom": "user_incidents", + "tableTo": "staff_users", + "columnsFrom": [ + "added_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_notifications": { + "name": "user_notifications", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applicable_users": { + "name": "applicable_users", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mobile": { + "name": "mobile", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "unique_email": { + "name": "unique_email", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vendor_snippets": { + "name": "vendor_snippets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "snippet_code": { + "name": "snippet_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slot_id": { + "name": "slot_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_permanent": { + "name": "is_permanent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "product_ids": { + "name": "product_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "valid_till": { + "name": "valid_till", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "vendor_snippets_snippet_code_unique": { + "name": "vendor_snippets_snippet_code_unique", + "columns": [ + "snippet_code" + ], + "isUnique": true + } + }, + "foreignKeys": { + "vendor_snippets_slot_id_delivery_slot_info_id_fk": { + "name": "vendor_snippets_slot_id_delivery_slot_info_id_fk", + "tableFrom": "vendor_snippets", + "tableTo": "delivery_slot_info", + "columnsFrom": [ + "slot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db_helper_sqlite/drizzle/meta/0002_snapshot.json b/packages/db_helper_sqlite/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..5983e40 --- /dev/null +++ b/packages/db_helper_sqlite/drizzle/meta/0002_snapshot.json @@ -0,0 +1,3513 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f0d1afa9-2cca-4080-b870-2c49e0e37883", + "prevId": "6333861e-b629-4b55-9c0d-479fb080070b", + "tables": { + "address_areas": { + "name": "address_areas", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "place_name": { + "name": "place_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "zone_id": { + "name": "zone_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "address_areas_zone_id_address_zones_id_fk": { + "name": "address_areas_zone_id_address_zones_id_fk", + "tableFrom": "address_areas", + "tableTo": "address_zones", + "columnsFrom": [ + "zone_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "address_zones": { + "name": "address_zones", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "zone_name": { + "name": "zone_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "added_at": { + "name": "added_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "addresses": { + "name": "addresses", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "address_line1": { + "name": "address_line1", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "address_line2": { + "name": "address_line2", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pincode": { + "name": "pincode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "latitude": { + "name": "latitude", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "longitude": { + "name": "longitude", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "google_maps_url": { + "name": "google_maps_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "admin_latitude": { + "name": "admin_latitude", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "admin_longitude": { + "name": "admin_longitude", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "zone_id": { + "name": "zone_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "addresses_user_id_users_id_fk": { + "name": "addresses_user_id_users_id_fk", + "tableFrom": "addresses", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "addresses_zone_id_address_zones_id_fk": { + "name": "addresses_zone_id_address_zones_id_fk", + "tableFrom": "addresses", + "tableTo": "address_zones", + "columnsFrom": [ + "zone_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "cart_items": { + "name": "cart_items", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sku_id": { + "name": "sku_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "added_at": { + "name": "added_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "unique_user_sku": { + "name": "unique_user_sku", + "columns": [ + "user_id", + "sku_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "cart_items_user_id_users_id_fk": { + "name": "cart_items_user_id_users_id_fk", + "tableFrom": "cart_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cart_items_sku_id_product_skus_id_fk": { + "name": "cart_items_sku_id_product_skus_id_fk", + "tableFrom": "cart_items", + "tableTo": "product_skus", + "columnsFrom": [ + "sku_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "complaints": { + "name": "complaints", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order_id": { + "name": "order_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "complaint_body": { + "name": "complaint_body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "images": { + "name": "images", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response": { + "name": "response", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_resolved": { + "name": "is_resolved", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "complaints_user_id_users_id_fk": { + "name": "complaints_user_id_users_id_fk", + "tableFrom": "complaints", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "complaints_order_id_orders_id_fk": { + "name": "complaints_order_id_orders_id_fk", + "tableFrom": "complaints", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "coupon_applicable_products": { + "name": "coupon_applicable_products", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "coupon_id": { + "name": "coupon_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sku_id": { + "name": "sku_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "unique_coupon_sku": { + "name": "unique_coupon_sku", + "columns": [ + "coupon_id", + "sku_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "coupon_applicable_products_coupon_id_coupons_id_fk": { + "name": "coupon_applicable_products_coupon_id_coupons_id_fk", + "tableFrom": "coupon_applicable_products", + "tableTo": "coupons", + "columnsFrom": [ + "coupon_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "coupon_applicable_products_sku_id_product_skus_id_fk": { + "name": "coupon_applicable_products_sku_id_product_skus_id_fk", + "tableFrom": "coupon_applicable_products", + "tableTo": "product_skus", + "columnsFrom": [ + "sku_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "coupon_applicable_users": { + "name": "coupon_applicable_users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "coupon_id": { + "name": "coupon_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "unique_coupon_user": { + "name": "unique_coupon_user", + "columns": [ + "coupon_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "coupon_applicable_users_coupon_id_coupons_id_fk": { + "name": "coupon_applicable_users_coupon_id_coupons_id_fk", + "tableFrom": "coupon_applicable_users", + "tableTo": "coupons", + "columnsFrom": [ + "coupon_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "coupon_applicable_users_user_id_users_id_fk": { + "name": "coupon_applicable_users_user_id_users_id_fk", + "tableFrom": "coupon_applicable_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "coupon_usage": { + "name": "coupon_usage", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "coupon_id": { + "name": "coupon_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order_id": { + "name": "order_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order_item_id": { + "name": "order_item_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "coupon_usage_user_id_users_id_fk": { + "name": "coupon_usage_user_id_users_id_fk", + "tableFrom": "coupon_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "coupon_usage_coupon_id_coupons_id_fk": { + "name": "coupon_usage_coupon_id_coupons_id_fk", + "tableFrom": "coupon_usage", + "tableTo": "coupons", + "columnsFrom": [ + "coupon_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "coupon_usage_order_id_orders_id_fk": { + "name": "coupon_usage_order_id_orders_id_fk", + "tableFrom": "coupon_usage", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "coupon_usage_order_item_id_order_items_id_fk": { + "name": "coupon_usage_order_item_id_order_items_id_fk", + "tableFrom": "coupon_usage", + "tableTo": "order_items", + "columnsFrom": [ + "order_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "coupons": { + "name": "coupons", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "coupon_code": { + "name": "coupon_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_user_based": { + "name": "is_user_based", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "discount_percent": { + "name": "discount_percent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "flat_discount": { + "name": "flat_discount", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "min_order": { + "name": "min_order", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sku_ids": { + "name": "sku_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "max_value": { + "name": "max_value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_apply_for_all": { + "name": "is_apply_for_all", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "valid_till": { + "name": "valid_till", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_limit_for_user": { + "name": "max_limit_for_user", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_invalidated": { + "name": "is_invalidated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "exclusive_apply": { + "name": "exclusive_apply", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "coupons_coupon_code_unique": { + "name": "coupons_coupon_code_unique", + "columns": [ + "coupon_code" + ], + "isUnique": true + } + }, + "foreignKeys": { + "coupons_created_by_staff_users_id_fk": { + "name": "coupons_created_by_staff_users_id_fk", + "tableFrom": "coupons", + "tableTo": "staff_users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "delivery_slot_info": { + "name": "delivery_slot_info", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "delivery_time": { + "name": "delivery_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "freeze_time": { + "name": "freeze_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "is_flash": { + "name": "is_flash", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_capacity_full": { + "name": "is_capacity_full", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "delivery_sequence": { + "name": "delivery_sequence", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_ids": { + "name": "group_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sku_ids": { + "name": "sku_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "home_banners": { + "name": "home_banners", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sku_ids": { + "name": "sku_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "serial_num": { + "name": "serial_num", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_updated": { + "name": "last_updated", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "key_val_store": { + "name": "key_val_store", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notif_creds": { + "name": "notif_creds", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "added_at": { + "name": "added_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_verified": { + "name": "last_verified", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "notif_creds_token_unique": { + "name": "notif_creds_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "notif_creds_user_id_users_id_fk": { + "name": "notif_creds_user_id_users_id_fk", + "tableFrom": "notif_creds", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notifications": { + "name": "notifications", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_read": { + "name": "is_read", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "order_items": { + "name": "order_items", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "order_id": { + "name": "order_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sku_id": { + "name": "sku_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price": { + "name": "price", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "discounted_price": { + "name": "discounted_price", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_packaged": { + "name": "is_packaged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_package_verified": { + "name": "is_package_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "order_items_order_id_orders_id_fk": { + "name": "order_items_order_id_orders_id_fk", + "tableFrom": "order_items", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "order_items_sku_id_product_skus_id_fk": { + "name": "order_items_sku_id_product_skus_id_fk", + "tableFrom": "order_items", + "tableTo": "product_skus", + "columnsFrom": [ + "sku_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "order_status": { + "name": "order_status", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "order_time": { + "name": "order_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order_id": { + "name": "order_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_packaged": { + "name": "is_packaged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_delivered": { + "name": "is_delivered", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_cancelled": { + "name": "is_cancelled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "cancel_reason": { + "name": "cancel_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_cancelled_by_admin": { + "name": "is_cancelled_by_admin", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_state": { + "name": "payment_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "cancellation_user_notes": { + "name": "cancellation_user_notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancellation_admin_notes": { + "name": "cancellation_admin_notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancellation_reviewed": { + "name": "cancellation_reviewed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "cancellation_reviewed_at": { + "name": "cancellation_reviewed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refund_coupon_id": { + "name": "refund_coupon_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "order_status_user_id_users_id_fk": { + "name": "order_status_user_id_users_id_fk", + "tableFrom": "order_status", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "order_status_order_id_orders_id_fk": { + "name": "order_status_order_id_orders_id_fk", + "tableFrom": "order_status", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "order_status_refund_coupon_id_coupons_id_fk": { + "name": "order_status_refund_coupon_id_coupons_id_fk", + "tableFrom": "order_status", + "tableTo": "coupons", + "columnsFrom": [ + "refund_coupon_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "orders": { + "name": "orders", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "address_id": { + "name": "address_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slot_id": { + "name": "slot_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_cod": { + "name": "is_cod", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_online_payment": { + "name": "is_online_payment", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "payment_info_id": { + "name": "payment_info_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_amount": { + "name": "total_amount", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delivery_charge": { + "name": "delivery_charge", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'0'" + }, + "readable_id": { + "name": "readable_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "admin_notes": { + "name": "admin_notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_notes": { + "name": "user_notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order_group_id": { + "name": "order_group_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order_group_proportion": { + "name": "order_group_proportion", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_flash_delivery": { + "name": "is_flash_delivery", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "orders_user_id_users_id_fk": { + "name": "orders_user_id_users_id_fk", + "tableFrom": "orders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "orders_address_id_addresses_id_fk": { + "name": "orders_address_id_addresses_id_fk", + "tableFrom": "orders", + "tableTo": "addresses", + "columnsFrom": [ + "address_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "orders_slot_id_delivery_slot_info_id_fk": { + "name": "orders_slot_id_delivery_slot_info_id_fk", + "tableFrom": "orders", + "tableTo": "delivery_slot_info", + "columnsFrom": [ + "slot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "orders_payment_info_id_payment_info_id_fk": { + "name": "orders_payment_info_id_payment_info_id_fk", + "tableFrom": "orders", + "tableTo": "payment_info", + "columnsFrom": [ + "payment_info_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "payment_info": { + "name": "payment_info", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "gateway": { + "name": "gateway", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order_id": { + "name": "order_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "merchant_order_id": { + "name": "merchant_order_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "payment_info_merchant_order_id_unique": { + "name": "payment_info_merchant_order_id_unique", + "columns": [ + "merchant_order_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "payments": { + "name": "payments", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "gateway": { + "name": "gateway", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order_id": { + "name": "order_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "merchant_order_id": { + "name": "merchant_order_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "payments_merchant_order_id_unique": { + "name": "payments_merchant_order_id_unique", + "columns": [ + "merchant_order_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "payments_order_id_orders_id_fk": { + "name": "payments_order_id_orders_id_fk", + "tableFrom": "payments", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "product_categories": { + "name": "product_categories", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "product_group_info": { + "name": "product_group_info", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "product_group_membership": { + "name": "product_group_membership", + "columns": { + "product_id": { + "name": "product_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "added_at": { + "name": "added_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "product_group_membership_product_id_product_info_id_fk": { + "name": "product_group_membership_product_id_product_info_id_fk", + "tableFrom": "product_group_membership", + "tableTo": "product_info", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "product_group_membership_group_id_product_group_info_id_fk": { + "name": "product_group_membership_group_id_product_group_info_id_fk", + "tableFrom": "product_group_membership", + "tableTo": "product_group_info", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "product_group_membership_pk": { + "columns": [ + "product_id", + "group_id" + ], + "name": "product_group_membership_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "product_info": { + "name": "product_info", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "short_description": { + "name": "short_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "long_description": { + "name": "long_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "store_id": { + "name": "store_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "increment_step": { + "name": "increment_step", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "product_info_store_id_store_info_id_fk": { + "name": "product_info_store_id_store_info_id_fk", + "tableFrom": "product_info", + "tableTo": "store_info", + "columnsFrom": [ + "store_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "product_reviews": { + "name": "product_reviews", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "product_id": { + "name": "product_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "review_body": { + "name": "review_body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image_urls": { + "name": "image_urls", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "review_time": { + "name": "review_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "ratings": { + "name": "ratings", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "admin_response": { + "name": "admin_response", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "admin_response_images": { + "name": "admin_response_images", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "product_reviews_user_id_users_id_fk": { + "name": "product_reviews_user_id_users_id_fk", + "tableFrom": "product_reviews", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "product_reviews_product_id_product_info_id_fk": { + "name": "product_reviews_product_id_product_info_id_fk", + "tableFrom": "product_reviews", + "tableTo": "product_info", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "rating_check": { + "name": "rating_check", + "value": "\"product_reviews\".\"ratings\" >= 1 AND \"product_reviews\".\"ratings\" <= 5" + } + } + }, + "product_skus": { + "name": "product_skus", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "product_id": { + "name": "product_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "price": { + "name": "price", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "market_price": { + "name": "market_price", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "images": { + "name": "images", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_out_of_stock": { + "name": "is_out_of_stock", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_suspended": { + "name": "is_suspended", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_flash_available": { + "name": "is_flash_available", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "flash_price": { + "name": "flash_price", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "product_skus_product_id_product_info_id_fk": { + "name": "product_skus_product_id_product_info_id_fk", + "tableFrom": "product_skus", + "tableTo": "product_info", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "product_tag_info": { + "name": "product_tag_info", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "tag_name": { + "name": "tag_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_description": { + "name": "tag_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_dashboard_tag": { + "name": "is_dashboard_tag", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "related_stores": { + "name": "related_stores", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "product_tag_info_tag_name_unique": { + "name": "product_tag_info_tag_name_unique", + "columns": [ + "tag_name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "product_tags": { + "name": "product_tags", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "product_id": { + "name": "product_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "unique_product_tag": { + "name": "unique_product_tag", + "columns": [ + "product_id", + "tag_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "product_tags_product_id_product_info_id_fk": { + "name": "product_tags_product_id_product_info_id_fk", + "tableFrom": "product_tags", + "tableTo": "product_info", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "product_tags_tag_id_product_tag_info_id_fk": { + "name": "product_tags_tag_id_product_tag_info_id_fk", + "tableFrom": "product_tags", + "tableTo": "product_tag_info", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "refunds": { + "name": "refunds", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "order_id": { + "name": "order_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refund_amount": { + "name": "refund_amount", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refund_status": { + "name": "refund_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'none'" + }, + "merchant_refund_id": { + "name": "merchant_refund_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refund_processed_at": { + "name": "refund_processed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "refunds_order_id_orders_id_fk": { + "name": "refunds_order_id_orders_id_fk", + "tableFrom": "refunds", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reserved_coupons": { + "name": "reserved_coupons", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "secret_code": { + "name": "secret_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "coupon_code": { + "name": "coupon_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "discount_percent": { + "name": "discount_percent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "flat_discount": { + "name": "flat_discount", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "min_order": { + "name": "min_order", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sku_ids": { + "name": "sku_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_value": { + "name": "max_value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "valid_till": { + "name": "valid_till", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_limit_for_user": { + "name": "max_limit_for_user", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exclusive_apply": { + "name": "exclusive_apply", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_redeemed": { + "name": "is_redeemed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "redeemed_by": { + "name": "redeemed_by", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "reserved_coupons_secret_code_unique": { + "name": "reserved_coupons_secret_code_unique", + "columns": [ + "secret_code" + ], + "isUnique": true + } + }, + "foreignKeys": { + "reserved_coupons_redeemed_by_users_id_fk": { + "name": "reserved_coupons_redeemed_by_users_id_fk", + "tableFrom": "reserved_coupons", + "tableTo": "users", + "columnsFrom": [ + "redeemed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "reserved_coupons_created_by_staff_users_id_fk": { + "name": "reserved_coupons_created_by_staff_users_id_fk", + "tableFrom": "reserved_coupons", + "tableTo": "staff_users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sku_features": { + "name": "sku_features", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "sku_id": { + "name": "sku_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "feature_name": { + "name": "feature_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "feature_value": { + "name": "feature_value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "unique_sku_feature_name": { + "name": "unique_sku_feature_name", + "columns": [ + "sku_id", + "feature_name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "sku_features_sku_id_product_skus_id_fk": { + "name": "sku_features_sku_id_product_skus_id_fk", + "tableFrom": "sku_features", + "tableTo": "product_skus", + "columnsFrom": [ + "sku_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "special_deals": { + "name": "special_deals", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "sku_id": { + "name": "sku_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price": { + "name": "price", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "valid_till": { + "name": "valid_till", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "special_deals_sku_id_product_skus_id_fk": { + "name": "special_deals_sku_id_product_skus_id_fk", + "tableFrom": "special_deals", + "tableTo": "product_skus", + "columnsFrom": [ + "sku_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "staff_permissions": { + "name": "staff_permissions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "permission_name": { + "name": "permission_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "unique_permission_name": { + "name": "unique_permission_name", + "columns": [ + "permission_name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "staff_role_permissions": { + "name": "staff_role_permissions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "staff_role_id": { + "name": "staff_role_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "staff_permission_id": { + "name": "staff_permission_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "unique_role_permission": { + "name": "unique_role_permission", + "columns": [ + "staff_role_id", + "staff_permission_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "staff_role_permissions_staff_role_id_staff_roles_id_fk": { + "name": "staff_role_permissions_staff_role_id_staff_roles_id_fk", + "tableFrom": "staff_role_permissions", + "tableTo": "staff_roles", + "columnsFrom": [ + "staff_role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "staff_role_permissions_staff_permission_id_staff_permissions_id_fk": { + "name": "staff_role_permissions_staff_permission_id_staff_permissions_id_fk", + "tableFrom": "staff_role_permissions", + "tableTo": "staff_permissions", + "columnsFrom": [ + "staff_permission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "staff_roles": { + "name": "staff_roles", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "role_name": { + "name": "role_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "unique_role_name": { + "name": "unique_role_name", + "columns": [ + "role_name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "staff_users": { + "name": "staff_users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "staff_role_id": { + "name": "staff_role_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "staff_users_staff_role_id_staff_roles_id_fk": { + "name": "staff_users_staff_role_id_staff_roles_id_fk", + "tableFrom": "staff_users", + "tableTo": "staff_roles", + "columnsFrom": [ + "staff_role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "store_info": { + "name": "store_info", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "owner": { + "name": "owner", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "store_info_owner_staff_users_id_fk": { + "name": "store_info_owner_staff_users_id_fk", + "tableFrom": "store_info", + "tableTo": "staff_users", + "columnsFrom": [ + "owner" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "units": { + "name": "units", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "short_notation": { + "name": "short_notation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "unique_short_notation": { + "name": "unique_short_notation", + "columns": [ + "short_notation" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "unlogged_user_tokens": { + "name": "unlogged_user_tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "added_at": { + "name": "added_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_verified": { + "name": "last_verified", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "unlogged_user_tokens_token_unique": { + "name": "unlogged_user_tokens_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "upload_url_status": { + "name": "upload_url_status", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_creds": { + "name": "user_creds", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_password": { + "name": "user_password", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "user_creds_user_id_users_id_fk": { + "name": "user_creds_user_id_users_id_fk", + "tableFrom": "user_creds", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_details": { + "name": "user_details", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "date_of_birth": { + "name": "date_of_birth", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gender": { + "name": "gender", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "occupation": { + "name": "occupation", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "profile_image": { + "name": "profile_image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_suspended": { + "name": "is_suspended", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "user_details_user_id_unique": { + "name": "user_details_user_id_unique", + "columns": [ + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_details_user_id_users_id_fk": { + "name": "user_details_user_id_users_id_fk", + "tableFrom": "user_details", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_incidents": { + "name": "user_incidents", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order_id": { + "name": "order_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "date_added": { + "name": "date_added", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "admin_comment": { + "name": "admin_comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "added_by": { + "name": "added_by", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "negativity_score": { + "name": "negativity_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_incidents_user_id_users_id_fk": { + "name": "user_incidents_user_id_users_id_fk", + "tableFrom": "user_incidents", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_incidents_order_id_orders_id_fk": { + "name": "user_incidents_order_id_orders_id_fk", + "tableFrom": "user_incidents", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_incidents_added_by_staff_users_id_fk": { + "name": "user_incidents_added_by_staff_users_id_fk", + "tableFrom": "user_incidents", + "tableTo": "staff_users", + "columnsFrom": [ + "added_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_notifications": { + "name": "user_notifications", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applicable_users": { + "name": "applicable_users", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mobile": { + "name": "mobile", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "unique_email": { + "name": "unique_email", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vendor_snippets": { + "name": "vendor_snippets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "snippet_code": { + "name": "snippet_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slot_id": { + "name": "slot_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_permanent": { + "name": "is_permanent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sku_ids": { + "name": "sku_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "valid_till": { + "name": "valid_till", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "vendor_snippets_snippet_code_unique": { + "name": "vendor_snippets_snippet_code_unique", + "columns": [ + "snippet_code" + ], + "isUnique": true + } + }, + "foreignKeys": { + "vendor_snippets_slot_id_delivery_slot_info_id_fk": { + "name": "vendor_snippets_slot_id_delivery_slot_info_id_fk", + "tableFrom": "vendor_snippets", + "tableTo": "delivery_slot_info", + "columnsFrom": [ + "slot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": { + "\"cart_items\".\"product_id\"": "\"cart_items\".\"sku_id\"", + "\"delivery_slot_info\".\"product_ids\"": "\"delivery_slot_info\".\"sku_ids\"", + "\"home_banners\".\"product_ids\"": "\"home_banners\".\"sku_ids\"", + "\"order_items\".\"product_id\"": "\"order_items\".\"sku_id\"" + } + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db_helper_sqlite/drizzle/meta/_journal.json b/packages/db_helper_sqlite/drizzle/meta/_journal.json index b4636c1..f81f69f 100644 --- a/packages/db_helper_sqlite/drizzle/meta/_journal.json +++ b/packages/db_helper_sqlite/drizzle/meta/_journal.json @@ -8,6 +8,20 @@ "when": 1774588140474, "tag": "0000_nifty_sauron", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1785336112559, + "tag": "0001_migrate_product_slots", + "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1785336600165, + "tag": "0002_sku_split", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db_helper_sqlite/src/db/schema.ts b/packages/db_helper_sqlite/src/db/schema.ts index 4985f39..14dfc7f 100644 --- a/packages/db_helper_sqlite/src/db/schema.ts +++ b/packages/db_helper_sqlite/src/db/schema.ts @@ -189,7 +189,15 @@ export const productInfo = sqliteTable('product_info', { name: text().notNull(), shortDescription: text('short_description'), longDescription: text('long_description'), - unitId: integer('unit_id').notNull().references(() => units.id), + storeId: integer('store_id').references(() => storeInfo.id), + incrementStep: real('increment_step').notNull().default(1), + createdAt: timestampText('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), +}) + +export const productSkus = sqliteTable('product_skus', { + id: integer().primaryKey({ autoIncrement: true }), + productId: integer('product_id').notNull().references(() => productInfo.id), + name: text(), price: numericText('price').notNull(), marketPrice: numericText('market_price'), images: jsonText('images'), @@ -198,11 +206,17 @@ export const productInfo = sqliteTable('product_info', { isFlashAvailable: integer('is_flash_available', { mode: 'boolean' }).notNull().default(false), flashPrice: numericText('flash_price'), createdAt: timestampText('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), - incrementStep: real('increment_step').notNull().default(1), - productQuantity: real('product_quantity').notNull().default(1), - storeId: integer('store_id').references(() => storeInfo.id), }) +export const skuFeatures = sqliteTable('sku_features', { + id: integer().primaryKey({ autoIncrement: true }), + skuId: integer('sku_id').notNull().references(() => productSkus.id), + featureName: text('feature_name').notNull(), + featureValue: text('feature_value').notNull(), +}, (t) => ({ + unq_sku_feature_name: uniqueIndex('unique_sku_feature_name').on(t.skuId, t.featureName), +})) + export const productGroupInfo = sqliteTable('product_group_info', { id: integer().primaryKey({ autoIncrement: true }), groupName: text('group_name').notNull(), @@ -223,7 +237,7 @@ export const homeBanners = sqliteTable('home_banners', { name: text('name').notNull(), imageUrl: text('image_url').notNull(), description: text('description'), - productIds: jsonText('product_ids'), + skuIds: jsonText('sku_ids'), redirectUrl: text('redirect_url'), serialNum: integer('serial_num'), isActive: integer('is_active', { mode: 'boolean' }).notNull().default(false), @@ -280,7 +294,7 @@ export const deliverySlotInfo = sqliteTable('delivery_slot_info', { isCapacityFull: integer('is_capacity_full', { mode: 'boolean' }).notNull().default(false), deliverySequence: jsonText>('delivery_sequence').$defaultFn(() => ({})), groupIds: jsonText('group_ids').$defaultFn(() => []), - productIds: jsonText('product_ids').$defaultFn(() => []), + skuIds: jsonText('sku_ids').$defaultFn(() => []), }) export const vendorSnippets = sqliteTable('vendor_snippets', { @@ -288,14 +302,14 @@ export const vendorSnippets = sqliteTable('vendor_snippets', { snippetCode: text('snippet_code').notNull().unique(), slotId: integer('slot_id').references(() => deliverySlotInfo.id), isPermanent: integer('is_permanent', { mode: 'boolean' }).notNull().default(false), - productIds: jsonText('product_ids').notNull(), + skuIds: jsonText('sku_ids').notNull(), validTill: timestampText('valid_till'), createdAt: timestampText('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), }) export const specialDeals = sqliteTable('special_deals', { id: integer().primaryKey({ autoIncrement: true }), - productId: integer('product_id').notNull().references(() => productInfo.id), + skuId: integer('sku_id').notNull().references(() => productSkus.id), quantity: numericText('quantity').notNull(), price: numericText('price').notNull(), validTill: timestampText('valid_till').notNull(), @@ -333,7 +347,7 @@ export const orders = sqliteTable('orders', { export const orderItems = sqliteTable('order_items', { id: integer().primaryKey({ autoIncrement: true }), orderId: integer('order_id').notNull().references(() => orders.id), - productId: integer('product_id').notNull().references(() => productInfo.id), + skuId: integer('sku_id').notNull().references(() => productSkus.id), quantity: text('quantity').notNull(), price: numericText('price').notNull(), discountedPrice: numericText('discounted_price'), @@ -403,11 +417,11 @@ export const productCategories = sqliteTable('product_categories', { export const cartItems = sqliteTable('cart_items', { id: integer().primaryKey({ autoIncrement: true }), userId: integer('user_id').notNull().references(() => users.id), - productId: integer('product_id').notNull().references(() => productInfo.id), + skuId: integer('sku_id').notNull().references(() => productSkus.id), quantity: numericText('quantity').notNull(), addedAt: timestampText('added_at').notNull().default(sql`CURRENT_TIMESTAMP`), }, (t) => ({ - unq_user_product: uniqueIndex('unique_user_product').on(t.userId, t.productId), + unq_user_sku: uniqueIndex('unique_user_sku').on(t.userId, t.skuId), })) export const complaints = sqliteTable('complaints', { @@ -428,7 +442,7 @@ export const coupons = sqliteTable('coupons', { discountPercent: numericText('discount_percent'), flatDiscount: numericText('flat_discount'), minOrder: numericText('min_order'), - productIds: jsonText('product_ids'), + skuIds: jsonText('sku_ids'), createdBy: integer('created_by').notNull().references(() => staffUsers.id), maxValue: numericText('max_value'), isApplyForAll: integer('is_apply_for_all', { mode: 'boolean' }).notNull().default(false), @@ -459,9 +473,9 @@ export const couponApplicableUsers = sqliteTable('coupon_applicable_users', { export const couponApplicableProducts = sqliteTable('coupon_applicable_products', { id: integer().primaryKey({ autoIncrement: true }), couponId: integer('coupon_id').notNull().references(() => coupons.id), - productId: integer('product_id').notNull().references(() => productInfo.id), + skuId: integer('sku_id').notNull().references(() => productSkus.id), }, (t) => ({ - unq_coupon_product: uniqueIndex('unique_coupon_product').on(t.couponId, t.productId), + unq_coupon_sku: uniqueIndex('unique_coupon_sku').on(t.couponId, t.skuId), })) export const userIncidents = sqliteTable('user_incidents', { @@ -481,7 +495,7 @@ export const reservedCoupons = sqliteTable('reserved_coupons', { discountPercent: numericText('discount_percent'), flatDiscount: numericText('flat_discount'), minOrder: numericText('min_order'), - productIds: jsonText('product_ids'), + skuIds: jsonText('sku_ids'), maxValue: numericText('max_value'), validTill: timestampText('valid_till'), maxLimitForUser: integer('max_limit_for_user'), @@ -548,20 +562,29 @@ export const addressesRelations = relations(addresses, ({ one, many }) => ({ zone: one(addressZones, { fields: [addresses.zoneId], references: [addressZones.id] }), })) -export const unitsRelations = relations(units, ({ many }) => ({ - products: many(productInfo), +export const unitsRelations = relations(units, ({}) => ({ + // Units are no longer linked to products/SKUs; kept as a reference table. })) export const productInfoRelations = relations(productInfo, ({ one, many }) => ({ - unit: one(units, { fields: [productInfo.unitId], references: [units.id] }), store: one(storeInfo, { fields: [productInfo.storeId], references: [storeInfo.id] }), + skus: many(productSkus), + tags: many(productTags), + reviews: many(productReviews), + groups: many(productGroupMembership), +})) + +export const productSkusRelations = relations(productSkus, ({ one, many }) => ({ + product: one(productInfo, { fields: [productSkus.productId], references: [productInfo.id] }), + features: many(skuFeatures), specialDeals: many(specialDeals), orderItems: many(orderItems), cartItems: many(cartItems), - tags: many(productTags), applicableCoupons: many(couponApplicableProducts), - reviews: many(productReviews), - groups: many(productGroupMembership), +})) + +export const skuFeaturesRelations = relations(skuFeatures, ({ one }) => ({ + sku: one(productSkus, { fields: [skuFeatures.skuId], references: [productSkus.id] }), })) export const productTagInfoRelations = relations(productTagInfo, ({ many }) => ({ @@ -579,7 +602,7 @@ export const deliverySlotInfoRelations = relations(deliverySlotInfo, ({ many }) })) export const specialDealsRelations = relations(specialDeals, ({ one }) => ({ - product: one(productInfo, { fields: [specialDeals.productId], references: [productInfo.id] }), + sku: one(productSkus, { fields: [specialDeals.skuId], references: [productSkus.id] }), })) export const ordersRelations = relations(orders, ({ one, many }) => ({ @@ -597,7 +620,7 @@ export const ordersRelations = relations(orders, ({ one, many }) => ({ export const orderItemsRelations = relations(orderItems, ({ one }) => ({ order: one(orders, { fields: [orderItems.orderId], references: [orders.id] }), - product: one(productInfo, { fields: [orderItems.productId], references: [productInfo.id] }), + sku: one(productSkus, { fields: [orderItems.skuId], references: [productSkus.id] }), })) export const orderStatusRelations = relations(orderStatus, ({ one }) => ({ @@ -626,7 +649,7 @@ export const productCategoriesRelations = relations(productCategories, ({}) => ( export const cartItemsRelations = relations(cartItems, ({ one }) => ({ user: one(users, { fields: [cartItems.userId], references: [users.id] }), - product: one(productInfo, { fields: [cartItems.productId], references: [productInfo.id] }), + sku: one(productSkus, { fields: [cartItems.skuId], references: [productSkus.id] }), })) export const complaintsRelations = relations(complaints, ({ one }) => ({ @@ -672,7 +695,7 @@ export const couponApplicableUsersRelations = relations(couponApplicableUsers, ( export const couponApplicableProductsRelations = relations(couponApplicableProducts, ({ one }) => ({ coupon: one(coupons, { fields: [couponApplicableProducts.couponId], references: [coupons.id] }), - product: one(productInfo, { fields: [couponApplicableProducts.productId], references: [productInfo.id] }), + sku: one(productSkus, { fields: [couponApplicableProducts.skuId], references: [productSkus.id] }), })) export const reservedCouponsRelations = relations(reservedCoupons, ({ one }) => ({ @@ -704,7 +727,7 @@ export const productGroupMembershipRelations = relations(productGroupMembership, })) export const homeBannersRelations = relations(homeBanners, ({}) => ({ - // Relations for productIds array would be more complex, skipping for now + // Relations for skuIds array would be more complex, skipping for now })) export const staffRolesRelations = relations(staffRoles, ({ many }) => ({ diff --git a/packages/db_helper_sqlite/src/db/types.ts b/packages/db_helper_sqlite/src/db/types.ts index 5fb5214..84e2c17 100644 --- a/packages/db_helper_sqlite/src/db/types.ts +++ b/packages/db_helper_sqlite/src/db/types.ts @@ -4,6 +4,8 @@ import type { addresses, units, productInfo, + productSkus, + skuFeatures, deliverySlotInfo, specialDeals, orders, @@ -19,6 +21,8 @@ export type User = InferSelectModel export type Address = InferSelectModel export type Unit = InferSelectModel export type ProductInfo = InferSelectModel +export type ProductSku = InferSelectModel +export type SkuFeature = InferSelectModel export type DeliverySlotInfo = InferSelectModel export type SpecialDeal = InferSelectModel export type Order = InferSelectModel @@ -30,16 +34,16 @@ export type CartItem = InferSelectModel export type Coupon = InferSelectModel // Combined types -export type ProductWithUnit = ProductInfo & { - unit: Unit +export type ProductWithSkus = ProductInfo & { + skus: (ProductSku & { features: SkuFeature[] })[] } export type OrderWithItems = Order & { - items: (OrderItem & { product: ProductInfo })[] + items: (OrderItem & { sku: ProductSku & { product: ProductInfo } })[] address: Address slot: DeliverySlotInfo } -export type CartItemWithProduct = CartItem & { - product: ProductInfo +export type CartItemWithSku = CartItem & { + sku: ProductSku & { product: ProductInfo } } From ad714493fc20eeb008a7db617e58c9222db98506 Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:42:40 +0530 Subject: [PATCH 02/73] backend top level --- .../manage-orders/orders/index_old.tsx | 801 ++++++++++++++++++ .../app/(drawer)/prices-overview/index.tsx | 162 ++-- apps/admin-ui/app/(drawer)/products/add.tsx | 123 +-- .../app/(drawer)/products/add_old.tsx | 82 ++ .../app/(drawer)/products/detail/[id].tsx | 169 ++-- apps/admin-ui/app/(drawer)/products/edit.tsx | 177 ++-- apps/admin-ui/app/(drawer)/products/index.tsx | 93 +- apps/admin-ui/components/ProductsSelector.tsx | 100 +-- apps/admin-ui/components/SlotForm.tsx | 45 +- apps/admin-ui/src/components/ProductForm.tsx | 476 ++++++----- .../src/components/ProductForm_old.tsx | 262 ++++++ .../src/trpc/apis/admin-apis/apis/product.ts | 199 ++--- .../src/trpc/apis/admin-apis/apis/slots.ts | 24 +- .../src/trpc/apis/common-apis/common.ts | 7 + packages/db_helper_sqlite/index.ts | 2 + .../db_helper_sqlite/src/admin-apis/order.ts | 46 +- .../src/admin-apis/product.ts | 241 ++++-- .../db_helper_sqlite/src/admin-apis/slots.ts | 145 ++-- .../db_helper_sqlite/src/user-apis/product.ts | 31 + packages/shared/types/admin.ts | 105 ++- 20 files changed, 2350 insertions(+), 940 deletions(-) create mode 100644 apps/admin-ui/app/(drawer)/manage-orders/orders/index_old.tsx create mode 100644 apps/admin-ui/app/(drawer)/products/add_old.tsx create mode 100644 apps/admin-ui/src/components/ProductForm_old.tsx diff --git a/apps/admin-ui/app/(drawer)/manage-orders/orders/index_old.tsx b/apps/admin-ui/app/(drawer)/manage-orders/orders/index_old.tsx new file mode 100644 index 0000000..fd17855 --- /dev/null +++ b/apps/admin-ui/app/(drawer)/manage-orders/orders/index_old.tsx @@ -0,0 +1,801 @@ +import React, { useState , useEffect } from 'react'; +import { View, TouchableOpacity, Alert, TextInput, ActivityIndicator, Linking } from 'react-native'; +import { AppContainer, MyText, tw, MyFlatList, BottomDialog, BottomDropdown, Checkbox, theme, MyTextInput } from 'common-ui'; +import { trpc } from '@/src/trpc-client'; +import { useRouter, useLocalSearchParams } from 'expo-router'; +import dayjs from 'dayjs'; +import MaterialIcons from '@expo/vector-icons/MaterialIcons'; +import { Entypo } from '@expo/vector-icons'; +import CancelOrderDialog from '@/components/CancelOrderDialog'; +import { OrderOptionsMenu } from '@/components/OrderOptionsMenu'; +import * as Location from 'expo-location'; + +const AdminNotesForm = ({ orderId, existingNotes, onClose, refetch }: { orderId: string; existingNotes?: string | null; onClose: () => void; refetch: () => void }) => { + const [notesText, setNotesText] = useState(existingNotes || ''); + const updateNotesMutation = trpc.admin.order.updateNotes.useMutation(); + + return ( + + Admin Notes + + + { + updateNotesMutation.mutate( + { orderId: parseInt(orderId), adminNotes: notesText }, + { + onSuccess: () => { + onClose(); + Alert.alert('Success', 'Notes updated successfully'); + refetch(); + }, + onError: (error: any) => { + Alert.alert('Error', error.message || 'Failed to update notes'); + }, + } + ); + }} + > + Save + + + + ); +}; + + +interface OrderType { + id: number; + orderId: string; + readableId: number; + customerName: string | null; + customerMobile?: string | null; + address: string; + addressId: number; + latitude: number | null; + longitude: number | null; + totalAmount: number; + deliveryCharge: number; + items: { + id?: number; + name: string; + quantity: number; + price: number; + amount: number; + unit: string; + isPackaged?: boolean; + isPackageVerified?: boolean; + productSize: number; + }[]; + createdAt: string; + deliveryTime: string | null; + status: 'pending' | 'delivered' | 'cancelled'; + isPackaged: boolean; + isDelivered: boolean; + isCod: boolean; + isFlashDelivery: boolean; + couponCode?: string; + couponDescription?: string; + discountAmount?: number; + adminNotes?: string | null; + userNotes?: string | null; + userNegativityScore?: number; +} + +const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }) => { + const id = order.orderId; + const router = useRouter(); + const [menuOpen, setMenuOpen] = useState(false); + const [itemsDialogOpen, setItemsDialogOpen] = useState(false); + const [notesDialogOpen, setNotesDialogOpen] = useState(false); + const [cancelDialogOpen, setCancelDialogOpen] = useState(false); + const [userNotesDialogOpen, setUserNotesDialogOpen] = useState(false); + const [adminNotesDialogOpen, setAdminNotesDialogOpen] = useState(false); + const [updatingItems, setUpdatingItems] = useState>(new Set()); + + const updatePackagedMutation = trpc.admin.order.updatePackaged.useMutation(); + const updateDeliveredMutation = trpc.admin.order.updateDelivered.useMutation(); + const updateItemPackagingMutation = trpc.admin.order.updateOrderItemPackaging.useMutation(); + + const handleOrderPress = () => { + router.push(`/manage-orders/order-details/${order.orderId}` as any); + }; + + const handleMenuOption = () => { + setMenuOpen(false); + router.push(`/manage-orders/order-details/${order.orderId}` as any); + }; + + const handleMarkPackaged = (isPackaged: boolean) => { + updatePackagedMutation.mutate( + { orderId: order.orderId.toString(), isPackaged }, + { + onSuccess: () => { + refetch(); + }, + } + ); + }; + + const handleMarkDelivered = (isDelivered: boolean) => { + updateDeliveredMutation.mutate( + { orderId: order.orderId.toString(), isDelivered }, + { + onSuccess: () => { + refetch(); + }, + } + ); + }; + + const handleItemPackagingToggle = (itemId: number, field: 'isPackaged' | 'isPackageVerified', value: boolean) => { + setUpdatingItems(prev => new Set(prev).add(itemId)); + + updateItemPackagingMutation.mutate( + { orderItemId: itemId, [field]: value }, + { + onSuccess: () => { + setUpdatingItems(prev => { + const newSet = new Set(prev); + newSet.delete(itemId); + return newSet; + }); + refetch(); + }, + onError: (error: any) => { + setUpdatingItems(prev => { + const newSet = new Set(prev); + newSet.delete(itemId); + return newSet; + }); + Alert.alert("Error", error.message || "Failed to update packaging status"); + }, + } + ); + }; + + return ( + <> + + {/* Header Section */} + + + + + 0 ? 'text-yellow-600' : 'text-gray-900')}`}> + {order.customerName || order.customerMobile || 'Unknown Customer'} + + + #{order.readableId} + + {order.isFlashDelivery && ( + + + FLASH + + )} + + + + + {dayjs(order.createdAt).format('MMM D, h:mm A')} + + {order.userNegativityScore && order.userNegativityScore > 0 && ( + + + Negative Customer + + )} + + + + setMenuOpen(true)} + style={tw`p-2 -mr-2 -mt-2 rounded-full`} + > + + + + + + {/* Main Content */} + + {/* Status Badges */} + + {/* + {order.status} + */} + {/* {order.isCod && ( + + COD + + )} */} + + Packaged + handleMarkPackaged(!order.isPackaged)} + onPress={() => {}} + size={18} + fillColor={theme.colors.gray500} + checkColor="#FFFFFF" + /> + + + Delivered + handleMarkDelivered(!order.isDelivered)} + size={18} + fillColor="#10B981" + checkColor="#FFFFFF" + /> + + {order.status === 'cancelled' && ( + + CANCELLED + + )} + + + {/* Delivery Info */} + + + + Delivery Address + + {order.address} + + + + + {order.isFlashDelivery ? "1 Hr Delivery:" : "Slot:"} {order.isFlashDelivery ? dayjs(order.createdAt).add(30, 'minutes').format('MMM D, h:mm A') : order.deliveryTime ? dayjs(order.deliveryTime).format("ddd, MMM D • h:mm A") : 'Not scheduled'} + + + {order.isFlashDelivery && ( + + + + 1 Hour Delivery • High Priority + + + )} + + + + {/* Items Summary & Total */} + + + + setItemsDialogOpen(true)} + style={tw`flex-row items-center py-2 px-3 bg-blue-50 rounded-lg flex-1 mr-3`} + > + + + {order.items.length} {order.items.length === 1 ? 'item' : 'items'} + + {order.isFlashDelivery && ( + + + + )} + + + Total: + ₹{order.totalAmount} + + + + + + {/* Coupons */} + {order.couponCode && ( + + Applied Coupons + + + {order.couponCode} + + {order.couponDescription && ( + + {order.couponDescription} + + )} + {order.discountAmount && ( + + Discount: ₹{order.discountAmount} + + )} + + + )} + + {/* Notes Section */} + + {order.userNotes && ( + setUserNotesDialogOpen(true)} + > + + + User Notes + + + )} + {order.adminNotes && ( + setNotesDialogOpen(true)} + > + + + Admin Notes + + + )} + + + {/* Footer / Delivery Charge */} + {order.deliveryCharge > 0 && ( + + + Delivery Charge + ₹{order.deliveryCharge} + + + )} + + + + setMenuOpen(false)} + order={{ + id: order.id, + readableId: order.readableId, + isPackaged: order.isPackaged, + isDelivered: order.isDelivered, + isFlashDelivery: order.isFlashDelivery, + address: order.address, + addressId: order.addressId, + adminNotes: order.adminNotes, + userNotes: order.userNotes, + latitude: order.latitude, + longitude: order.longitude, + status: order.status, + }} + onViewDetails={handleMenuOption} + onTogglePackaged={() => handleMarkPackaged(!order.isPackaged)} + onToggleDelivered={() => handleMarkDelivered(!order.isDelivered)} + onOpenAdminNotes={() => { + setMenuOpen(false); + setNotesDialogOpen(true); + }} + onCancelOrder={() => { + setMenuOpen(false); + setCancelDialogOpen(true); + }} + onAttachLocation={() => refetch()} + onWhatsApp={() => {}} + onDial={() => {}} + /> + + setItemsDialogOpen(false)}> + + + + Order Items + + {order.isFlashDelivery && ( + + + FLASH + + )} + + + Total: ₹{order.totalAmount} + + {order.items.map((item, idx) => ( + + + + {item.quantity * item.productSize } {item.unit} + + + {item.name.length > 30 ? `${item.name.substring(0, 30)}...` : item.name} + + {item.isPackaged !== undefined && item.isPackageVerified !== undefined && ( + <> + + pkg + handleItemPackagingToggle(item.id!, 'isPackaged', !item.isPackaged)} + size={18} + fillColor={updatingItems.has(item.id!) ? "#F59E0B" : "#10B981"} + checkColor="#FFFFFF" + /> + + + verf + handleItemPackagingToggle(item.id!, 'isPackageVerified', !item.isPackageVerified)} + size={18} + fillColor={updatingItems.has(item.id!) ? "#F59E0B" : "#10B981"} + checkColor="#FFFFFF" + /> + + {updatingItems.has(item.id!) && ( + + )} + + )} + + + ))} + + + + setNotesDialogOpen(false)}> + setNotesDialogOpen(false)} refetch={refetch} /> + + + setCancelDialogOpen(false)} + onSuccess={refetch} + /> + + setUserNotesDialogOpen(false)}> + + + User Notes + + + + {order.userNotes} + + + + + + setAdminNotesDialogOpen(false)}> + + + Admin Notes + + + + {order.adminNotes} + + + + + + ); + }; + +export default function Orders() { + const router = useRouter(); + const { filter } = useLocalSearchParams<{ filter?: string }>(); + const [selectedSlot, setSelectedSlot] = useState(null); + const [selectedSlotType, setSelectedSlotType] = useState<'slot' | 'flash' | null>(null); + const [packagedFilter, setPackagedFilter] = useState<'all' | 'packaged' | 'not_packaged'>('all'); + const [packagedChecked, setPackagedChecked] = useState(false); + const [notPackagedChecked, setNotPackagedChecked] = useState(false); + const [deliveredFilter, setDeliveredFilter] = useState<'all' | 'delivered' | 'not_delivered'>('all'); + const [deliveredChecked, setDeliveredChecked] = useState(false); + const [notDeliveredChecked, setNotDeliveredChecked] = useState(false); + const [cancellationFilter, setCancellationFilter] = useState<'all' | 'cancelled' | 'not_cancelled'>('all'); + const [cancelledChecked, setCancelledChecked] = useState(false); + const [notCancelledChecked, setNotCancelledChecked] = useState(false); + const [flashDeliveryFilter, setFlashDeliveryFilter] = useState<'all' | 'flash' | 'regular'>('all'); + const [flashChecked, setFlashChecked] = useState(false); + const [regularChecked, setRegularChecked] = useState(false); + const [filterDialogOpen, setFilterDialogOpen] = useState(false); + + // Handle initial filter from URL params + useEffect(() => { + if (filter === 'flash') { + setSelectedSlotType('flash'); + setFlashDeliveryFilter('flash'); + setFlashChecked(true); + setRegularChecked(false); + } + }, [filter]); + const { data: slotsData } = trpc.admin.slots.getAll.useQuery(); + const { data, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage, refetch } = trpc.admin.order.getAll.useInfiniteQuery( + { + limit: 20, + slotId: selectedSlotType === 'slot' ? selectedSlot : null, + packagedFilter, + deliveredFilter, + cancellationFilter, + flashDeliveryFilter: selectedSlotType === 'flash' ? 'flash' : flashDeliveryFilter + }, + { + getNextPageParam: (lastPage) => lastPage?.nextCursor, + } + ); + + const orders = data?.pages.flatMap(page => page?.orders) || []; + + if (isLoading) { + return ( + + + Loading orders... + + ); + } + + const slotOptions = [ + { label: '⚡ Flash Deliveries', value: 'flash' }, + ...(slotsData?.slots?.map(slot => ({ + label: dayjs(slot.deliveryTime).format('ddd DD MMM, h:mm a'), + value: slot.id.toString(), + })) || []) + ]; + + + return ( + <> + item!.orderId} + renderItem={({ item }) => item ? : null} + onEndReached={() => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }} + onEndReachedThreshold={0.5} + onRefresh={() => refetch()} + ListHeaderComponent={ + <> + + + { + if (val === 'flash') { + setSelectedSlotType('flash'); + setSelectedSlot(null); + setFlashDeliveryFilter('flash'); + // Reset other filters when switching to flash + setPackagedFilter('all'); + setPackagedChecked(false); + setNotPackagedChecked(false); + setDeliveredFilter('all'); + setDeliveredChecked(false); + setNotDeliveredChecked(false); + setCancellationFilter('all'); + setCancelledChecked(false); + setNotCancelledChecked(false); + } else { + setSelectedSlotType('slot'); + setSelectedSlot(val ? Number(val) : null); + setFlashDeliveryFilter('all'); + } + }} + placeholder="All slots" + /> + + setFilterDialogOpen(true)} + style={tw`p-2`} + > + + + + {!isLoading && selectedSlotType && ( + + + {selectedSlotType === 'flash' + ? `${orders.length} Flash delivery orders` + : `${orders.length} Orders in slot` + } + + + )} + + } + ListFooterComponent={ + isFetchingNextPage ? ( + + + Loading more... + + ) : null + } + /> + + setFilterDialogOpen(false)}> + + + Packaged Status + + { + const newValue = !packagedChecked; + setPackagedChecked(newValue); + if (newValue && notPackagedChecked) { + setPackagedFilter('all'); + } else if (newValue) { + setPackagedFilter('packaged'); + } else if (notPackagedChecked) { + setPackagedFilter('not_packaged'); + } else { + setPackagedFilter('all'); + } + }} + /> + Packaged + + + { + const newValue = !notPackagedChecked; + setNotPackagedChecked(newValue); + if (packagedChecked && newValue) { + setPackagedFilter('all'); + } else if (newValue) { + setPackagedFilter('not_packaged'); + } else if (packagedChecked) { + setPackagedFilter('packaged'); + } else { + setPackagedFilter('all'); + } + }} + /> + Not Packaged + + + + Delivered Status + + { + const newValue = !deliveredChecked; + setDeliveredChecked(newValue); + if (newValue && notDeliveredChecked) { + setDeliveredFilter('all'); + } else if (newValue) { + setDeliveredFilter('delivered'); + } else if (notDeliveredChecked) { + setDeliveredFilter('not_delivered'); + } else { + setDeliveredFilter('all'); + } + }} + /> + Delivered + + + { + const newValue = !notDeliveredChecked; + setNotDeliveredChecked(newValue); + if (deliveredChecked && newValue) { + setDeliveredFilter('all'); + } else if (newValue) { + setDeliveredFilter('not_delivered'); + } else if (deliveredChecked) { + setDeliveredFilter('delivered'); + } else { + setDeliveredFilter('all'); + } + }} + /> + Not Delivered + + + + Cancellation Status + + { + const newValue = !cancelledChecked; + setCancelledChecked(newValue); + if (newValue && notCancelledChecked) { + setCancellationFilter('all'); + } else if (newValue) { + setCancellationFilter('cancelled'); + } else if (notCancelledChecked) { + setCancellationFilter('not_cancelled'); + } else { + setCancellationFilter('all'); + } + }} + /> + Cancelled + + + { + const newValue = !notCancelledChecked; + setNotCancelledChecked(newValue); + if (cancelledChecked && newValue) { + setCancellationFilter('all'); + } else if (newValue) { + setCancellationFilter('not_cancelled'); + } else if (cancelledChecked) { + setCancellationFilter('cancelled'); + } else { + setCancellationFilter('all'); + } + }} + /> + Not Cancelled + + + + Delivery Type + + { + const newValue = !flashChecked; + setFlashChecked(newValue); + if (newValue && regularChecked) { + setFlashDeliveryFilter('all'); + } else if (newValue) { + setFlashDeliveryFilter('flash'); + } else if (regularChecked) { + setFlashDeliveryFilter('regular'); + } else { + setFlashDeliveryFilter('all'); + } + }} + /> + ⚡ 1 Hr Delivery + + + { + const newValue = !regularChecked; + setRegularChecked(newValue); + if (flashChecked && newValue) { + setFlashDeliveryFilter('all'); + } else if (newValue) { + setFlashDeliveryFilter('regular'); + } else if (flashChecked) { + setFlashDeliveryFilter('flash'); + } else { + setFlashDeliveryFilter('all'); + } + }} + /> + Regular Delivery + + + + + + ); +} diff --git a/apps/admin-ui/app/(drawer)/prices-overview/index.tsx b/apps/admin-ui/app/(drawer)/prices-overview/index.tsx index 091a8bb..1a192b9 100644 --- a/apps/admin-ui/app/(drawer)/prices-overview/index.tsx +++ b/apps/admin-ui/app/(drawer)/prices-overview/index.tsx @@ -21,31 +21,32 @@ import { trpc } from "@/src/trpc-client"; import MaterialIcons from "@expo/vector-icons/MaterialIcons"; import { Entypo } from "@expo/vector-icons"; -interface ProductItemProps { - item: any; - hasChanges: (productId: number) => boolean; +interface SkuItemProps { + sku: any; + productName: string; + hasChanges: (skuId: number) => boolean; pendingChanges: Record; setPendingChanges: React.Dispatch>>; - openEditDialog: (product: any) => void; + openEditDialog: (sku: any, productName: string) => void; } -const ProductItemComponent: React.FC = ({ - item: product, +const SkuItemComponent: React.FC = ({ + sku, + productName, hasChanges, pendingChanges, setPendingChanges, openEditDialog, }) => { - const changed = hasChanges(product.id); - const change = pendingChanges[product.id] || {}; - const displayPrice = change.price !== undefined ? change.price : product.price; - const displayMarketPrice = change.marketPrice !== undefined ? change.marketPrice : product.marketPrice; - const displayFlashPrice = change.flashPrice !== undefined ? change.flashPrice : product.flashPrice; - const displayProductQuantity = change.productQuantity !== undefined ? change.productQuantity : product.productQuantity; + const changed = hasChanges(sku.id); + const change = pendingChanges[sku.id] || {}; + const displayPrice = change.price !== undefined ? change.price : sku.price; + const displayMarketPrice = change.marketPrice !== undefined ? change.marketPrice : sku.marketPrice; + const displayFlashPrice = change.flashPrice !== undefined ? change.flashPrice : sku.flashPrice; + const displayProductQuantity = change.productQuantity !== undefined ? change.productQuantity : sku.productQuantity; return ( - {/* Change indicator */} = ({ - {/* First row: Image and Name */} - {/* Product image */} -{/* Product name and Flash Checkbox */} - - {product.name.length > 25 ? product.name.substring(0, 25) + '...' : product.name} - + + + {productName.length > 20 ? productName.substring(0, 20) + '...' : productName} + + + {sku.displayName || sku.name || ''} + + { - const currentValue = change.isFlashAvailable ?? product.isFlashAvailable ?? false; + const currentValue = change.isFlashAvailable ?? sku.isFlashAvailable ?? false; setPendingChanges(prev => ({ ...prev, - [product.id]: { - ...change, + [sku.id]: { + ...prev[sku.id], isFlashAvailable: !currentValue, }, })); @@ -93,47 +96,42 @@ const ProductItemComponent: React.FC = ({ -{/* Prices and Product Size Row */} - {/* Our Price */} Our Price ₹{displayPrice} - openEditDialog(product)} style={tw`ml-1`}> + openEditDialog(sku, productName)} style={tw`ml-1`}> - {/* Market Price */} Market Price {displayMarketPrice ? `₹${displayMarketPrice}` : "N/A"} - openEditDialog(product)} style={tw`ml-1`}> + openEditDialog(sku, productName)} style={tw`ml-1`}> - {/* Flash Price */} Flash Price {displayFlashPrice ? `₹${displayFlashPrice}` : "N/A"} - openEditDialog(product)} style={tw`ml-1`}> + openEditDialog(sku, productName)} style={tw`ml-1`}> - {/* Product Size */} Size - {displayProductQuantity ? `${displayProductQuantity}${product.unit.shortNotation || ''}` : "N/A"} - openEditDialog(product)} style={tw`ml-1`}> + {displayProductQuantity ? `${displayProductQuantity}${sku.unit?.shortNotation || ''}` : "N/A"} + openEditDialog(sku, productName)} style={tw`ml-1`}> @@ -153,7 +151,8 @@ interface PendingChange { interface EditDialogState { open: boolean; - product: any; + sku: any; + productName: string; tempPrice: string; tempMarketPrice: string; tempFlashPrice: string; @@ -166,7 +165,8 @@ export default function PricesOverview() { const [pendingChanges, setPendingChanges] = useState>({}); const [editDialog, setEditDialog] = useState({ open: false, - product: null, + sku: null, + productName: "", tempPrice: "", tempMarketPrice: "", tempFlashPrice: "", @@ -184,13 +184,11 @@ export default function PricesOverview() { const stores = storesData?.stores || []; const allProducts = productsData?.products || []; - // Sort stores alphabetically const sortedStores = useMemo(() => [...stores].sort((a, b) => a.name.localeCompare(b.name)), [stores] ); - // Store options for dropdown const storeOptions = useMemo(() => sortedStores.map(store => ({ label: store.name, @@ -199,38 +197,46 @@ export default function PricesOverview() { [sortedStores] ); - // Initialize selectedStores to all if not set useEffect(() => { if (stores.length > 0 && selectedStores.length === 0) { setSelectedStores(stores.map(s => s.id.toString())); } }, [stores, selectedStores]); - // Filter products by selected stores - const filteredProducts = useMemo(() => { - if (selectedStores.length === 0) return allProducts; - return allProducts.filter(product => - product.storeId && selectedStores.includes(product.storeId.toString()) + const allSkus = useMemo(() => { + const skus: any[] = []; + for (const product of allProducts) { + if (product.skus && product.skus.length > 0) { + for (const sku of product.skus) { + skus.push({ ...sku, _productName: product.name, _storeId: product.storeId }); + } + } + } + return skus; + }, [allProducts]); + + const filteredSkus = useMemo(() => { + if (selectedStores.length === 0) return allSkus; + return allSkus.filter(sku => + sku._storeId && selectedStores.includes(sku._storeId.toString()) ); - }, [allProducts, selectedStores]); + }, [allSkus, selectedStores]); - // Check if a product has changes - const hasChanges = (productId: number) => !!pendingChanges[productId]; + const hasChanges = (skuId: number) => !!pendingChanges[skuId]; - // Open edit dialog - const openEditDialog = (product: any) => { - const change = pendingChanges[product.id] || {}; + const openEditDialog = (sku: any, productName: string) => { + const change = pendingChanges[sku.id] || {}; setEditDialog({ open: true, - product, - tempPrice: (change.price ?? product.price)?.toString() || "", - tempMarketPrice: (change.marketPrice ?? product.marketPrice)?.toString() || "", - tempFlashPrice: (change.flashPrice ?? product.flashPrice)?.toString() || "", - tempProductQuantity: (change.productQuantity ?? product.productQuantity)?.toString() || "", + sku, + productName, + tempPrice: (change.price ?? sku.price)?.toString() || "", + tempMarketPrice: (change.marketPrice ?? sku.marketPrice)?.toString() || "", + tempFlashPrice: (change.flashPrice ?? sku.flashPrice)?.toString() || "", + tempProductQuantity: (change.productQuantity ?? sku.productQuantity)?.toString() || "", }); }; - // Save edit dialog const saveEditDialog = () => { const price = parseFloat(editDialog.tempPrice); const marketPrice = editDialog.tempMarketPrice ? parseFloat(editDialog.tempMarketPrice) : null; @@ -253,27 +259,27 @@ export default function PricesOverview() { } if (editDialog.tempProductQuantity && (isNaN(productQuantity!) || productQuantity! <= 0)) { - Alert.alert("Error", "Please enter a valid product size"); + Alert.alert("Error", "Please enter a valid size"); return; } setPendingChanges(prev => ({ ...prev, - [editDialog.product.id]: { - price: price !== editDialog.product.price ? price : undefined, - marketPrice: marketPrice !== editDialog.product.marketPrice ? marketPrice : undefined, - flashPrice: flashPrice !== editDialog.product.flashPrice ? flashPrice : undefined, - productQuantity: productQuantity !== editDialog.product.productQuantity ? productQuantity : undefined, + [editDialog.sku.id]: { + price: price !== parseFloat(editDialog.sku.price) ? price : undefined, + marketPrice: marketPrice !== parseFloat(editDialog.sku.marketPrice || '0') ? marketPrice : undefined, + flashPrice: flashPrice !== parseFloat(editDialog.sku.flashPrice || '0') ? flashPrice : undefined, + productQuantity: productQuantity !== (editDialog.sku.productQuantity || 1) ? productQuantity : undefined, }, })); - setEditDialog({ open: false, product: null, tempPrice: "", tempMarketPrice: "", tempFlashPrice: "", tempProductQuantity: "" }); + setEditDialog({ open: false, sku: null, productName: "", tempPrice: "", tempMarketPrice: "", tempFlashPrice: "", tempProductQuantity: "" }); }; - // Handle save all changes const handleSave = () => { - const updates = Object.entries(pendingChanges).map(([productId, change]) => { - const update: any = { productId: parseInt(productId) }; + const updates = Object.entries(pendingChanges).map(([skuId, change]) => { + const sku = allSkus.find(s => s.id === parseInt(skuId)); + const update: any = { productId: sku?.productId }; if (change.price !== undefined) update.price = change.price; if (change.marketPrice !== undefined) update.marketPrice = change.marketPrice; if (change.flashPrice !== undefined) update.flashPrice = change.flashPrice; @@ -297,13 +303,10 @@ export default function PricesOverview() { ); }; - - const changeCount = Object.keys(pendingChanges).length; return ( - {/* Stores filter, save button, and menu */} - {/* Content */} {productsLoading || storesLoading ? ( @@ -358,10 +360,11 @@ export default function PricesOverview() { ) : ( ( - )} - {/* Edit Dialog */} setEditDialog({ ...editDialog, open: false, tempFlashPrice: "", tempMarketPrice: "", tempPrice: "", tempProductQuantity: "" })}> - {editDialog.product?.name} + {editDialog.productName} + {editDialog.sku?.displayName || editDialog.sku?.name || ''} Our Price @@ -413,13 +416,13 @@ export default function PricesOverview() { - Product Size + Size setEditDialog({ ...editDialog, tempProductQuantity: text })} keyboardType="decimal-pad" - placeholder="Enter product size" + placeholder="Enter size" /> @@ -432,7 +435,6 @@ export default function PricesOverview() { - {/* Menu Dialog */} setShowMenu(false)}> @@ -452,8 +454,6 @@ export default function PricesOverview() { - - ); -} \ No newline at end of file +} diff --git a/apps/admin-ui/app/(drawer)/products/add.tsx b/apps/admin-ui/app/(drawer)/products/add.tsx index 33b9a71..2e6e461 100644 --- a/apps/admin-ui/app/(drawer)/products/add.tsx +++ b/apps/admin-ui/app/(drawer)/products/add.tsx @@ -1,73 +1,88 @@ -import React from 'react'; -import { Alert } from 'react-native'; -import { AppContainer, ImageUploaderNeoPayload } from 'common-ui'; -import ProductForm from '@/src/components/ProductForm'; -import { trpc } from '@/src/trpc-client'; -import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore'; +import React from 'react' +import { Alert } from 'react-native' +import { AppContainer, ImageUploaderNeoPayload } from 'common-ui' +import ProductForm from '@/src/components/ProductForm' +import { trpc } from '@/src/trpc-client' +import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore' export default function AddProduct() { - const createProduct = trpc.admin.product.createProduct.useMutation(); + const createProduct = trpc.admin.product.createProduct.useMutation() const { refetch: refetchProducts } = trpc.admin.product.getProducts.useQuery(undefined, { enabled: false, - }); - const { upload, isUploading } = useUploadToObjectStorage(); + }) + const { upload, isUploading } = useUploadToObjectStorage() - const handleSubmit = async (values: any, images: ImageUploaderNeoPayload[]) => { + const handleSubmit = async (values: any, variantImages: ImageUploaderNeoPayload[][], _deletedImageKeys?: string[]) => { try { - let uploadUrls: string[] = []; + const allBlobs: { blob: Blob; mimeType: string }[] = [] + const imageCounts: number[] = variantImages.map((imgs) => imgs.length) - if (images.length > 0) { - const blobs = await Promise.all( - images.map(async (img) => { - const response = await fetch(img.url); - const blob = await response.blob(); - return { blob, mimeType: img.mimeType || 'image/jpeg' }; - }) - ); - - const result = await upload({ images: blobs, contextString: 'product_info' }); - uploadUrls = result.presignedUrls; + for (const imgs of variantImages) { + for (const img of imgs) { + const response = await fetch(img.url) + const blob = await response.blob() + allBlobs.push({ blob, mimeType: img.mimeType || 'image/jpeg' }) + } } + let allUploadUrls: string[] = [] + if (allBlobs.length > 0) { + const result = await upload({ images: allBlobs, contextString: 'product_info' }) + allUploadUrls = result.presignedUrls + } + + let urlCursor = 0 + const skus = values.variants.map((variant: any, vIndex: number) => { + const count = imageCounts[vIndex] + const variantUrls = allUploadUrls.slice(urlCursor, urlCursor + count) + urlCursor += count + + return { + name: variant.name || null, + price: parseFloat(variant.price), + marketPrice: variant.marketPrice ? parseFloat(variant.marketPrice) : undefined, + images: variantUrls, + isFlashAvailable: variant.isFlashAvailable || false, + flashPrice: variant.flashPrice ? parseFloat(variant.flashPrice) : undefined, + features: variant.attributes.map((attr: any) => ({ + featureName: attr.featureName, + featureValue: attr.featureValue, + })), + } + }) + await createProduct.mutateAsync({ name: values.name, - shortDescription: values.shortDescription, - longDescription: values.longDescription, - unitId: parseInt(values.unitId), - storeId: parseInt(values.storeId), - price: parseFloat(values.price), - marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined, + shortDescription: values.shortDescription || undefined, + longDescription: values.longDescription || undefined, + storeId: values.storeId, incrementStep: 1, - productQuantity: values.productQuantity || 1, - isSuspended: values.isSuspended || false, - isFlashAvailable: values.isFlashAvailable || false, - flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : undefined, - uploadUrls, - tagIds: values.tagIds || [], - }); + skus, + }) - await refetchProducts(); - Alert.alert('Success', 'Product created successfully!'); + await refetchProducts() + Alert.alert('Success', 'Product created successfully!') } catch (error: any) { - Alert.alert('Error', error.message || 'Failed to create product'); + Alert.alert('Error', error.message || 'Failed to create product') } - }; + } const initialValues = { - name: '', - shortDescription: '', - longDescription: '', - unitId: 0, - price: '', - storeId: 1, - marketPrice: '', - deals: [{ quantity: '', price: '', validTill: new Date() }], - tagIds: [], - isSuspended: false, - isFlashAvailable: false, - flashPrice: '', - productQuantity: 1, - }; + name: '', + shortDescription: '', + longDescription: '', + storeId: 1, + variants: [ + { + name: '', + price: '', + marketPrice: '', + isFlashAvailable: false, + flashPrice: '', + attributes: [{ featureName: 'quantity', featureValue: '' }], + }, + ], + } return ( @@ -78,5 +93,5 @@ export default function AddProduct() { isLoading={createProduct.isPending || isUploading} /> - ); + ) } diff --git a/apps/admin-ui/app/(drawer)/products/add_old.tsx b/apps/admin-ui/app/(drawer)/products/add_old.tsx new file mode 100644 index 0000000..33b9a71 --- /dev/null +++ b/apps/admin-ui/app/(drawer)/products/add_old.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { Alert } from 'react-native'; +import { AppContainer, ImageUploaderNeoPayload } from 'common-ui'; +import ProductForm from '@/src/components/ProductForm'; +import { trpc } from '@/src/trpc-client'; +import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore'; + +export default function AddProduct() { + const createProduct = trpc.admin.product.createProduct.useMutation(); + const { refetch: refetchProducts } = trpc.admin.product.getProducts.useQuery(undefined, { + enabled: false, + }); + const { upload, isUploading } = useUploadToObjectStorage(); + + const handleSubmit = async (values: any, images: ImageUploaderNeoPayload[]) => { + try { + let uploadUrls: string[] = []; + + if (images.length > 0) { + const blobs = await Promise.all( + images.map(async (img) => { + const response = await fetch(img.url); + const blob = await response.blob(); + return { blob, mimeType: img.mimeType || 'image/jpeg' }; + }) + ); + + const result = await upload({ images: blobs, contextString: 'product_info' }); + uploadUrls = result.presignedUrls; + } + + await createProduct.mutateAsync({ + name: values.name, + shortDescription: values.shortDescription, + longDescription: values.longDescription, + unitId: parseInt(values.unitId), + storeId: parseInt(values.storeId), + price: parseFloat(values.price), + marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined, + incrementStep: 1, + productQuantity: values.productQuantity || 1, + isSuspended: values.isSuspended || false, + isFlashAvailable: values.isFlashAvailable || false, + flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : undefined, + uploadUrls, + tagIds: values.tagIds || [], + }); + + await refetchProducts(); + Alert.alert('Success', 'Product created successfully!'); + } catch (error: any) { + Alert.alert('Error', error.message || 'Failed to create product'); + } + }; + + const initialValues = { + name: '', + shortDescription: '', + longDescription: '', + unitId: 0, + price: '', + storeId: 1, + marketPrice: '', + deals: [{ quantity: '', price: '', validTill: new Date() }], + tagIds: [], + isSuspended: false, + isFlashAvailable: false, + flashPrice: '', + productQuantity: 1, + }; + + return ( + + + + ); +} diff --git a/apps/admin-ui/app/(drawer)/products/detail/[id].tsx b/apps/admin-ui/app/(drawer)/products/detail/[id].tsx index cba7c26..842b448 100644 --- a/apps/admin-ui/app/(drawer)/products/detail/[id].tsx +++ b/apps/admin-ui/app/(drawer)/products/detail/[id].tsx @@ -149,9 +149,8 @@ export default function ProductDetail() { refetch(); }); - const toggleOutOfStock = trpc.admin.product.toggleOutOfStock.useMutation(); - const product = productData?.product; + const defaultSku = product?.skus?.[0] const handleEdit = () => { router.push(`/products/edit?id=${productId}` as any); @@ -188,9 +187,9 @@ export default function ProductDetail() { {/* Hero Section */} - {product.images && product.images.length > 0 ? ( + {defaultSku?.images && defaultSku.images.length > 0 ? ( {product.name} - { - toggleOutOfStock.mutate({ id: productId }, { - onSuccess: () => Alert.alert('Success', 'Stock status updated'), - onError: (err) => Alert.alert('Error', err.message) - }); - }} - activeOpacity={0.9} - > - - - {product.isOutOfStock ? 'Out of Stock' : 'In Stock'} - - - - ₹{product.price} - / {product.unit?.shortNotation} - {product.marketPrice && ( - - ₹{product.marketPrice} - - )} - - - {/* Increment Step Info */} - - - - Increment: {product.incrementStep || 1} - - - + + + {product.skus?.length ?? 0} Variant{(product.skus?.length ?? 0) !== 1 ? 's' : ''} + + + {/* Quick Stats Row */} @@ -287,29 +255,10 @@ export default function ProductDetail() { {reviewsData?.reviews.length || 0} Reviews - {/* - { - toggleOutOfStock.mutate({ id: productId }, { - onSuccess: () => Alert.alert('Success', 'Stock status updated'), - onError: (err) => Alert.alert('Error', err.message) - }); - }} - activeOpacity={0.9} - > - - - {product.isOutOfStock ? 'Out of Stock' : 'In Stock'} - - - - Stock - */} + + {product.incrementStep || 1} + Step + @@ -331,45 +280,69 @@ export default function ProductDetail() { {product.longDescription || "No detailed description available for this product."} - + - {/* Availability */} - - - - - - - Availability - + {/* Variants Section */} + + + + + + + Variants + + {product.skus?.length ?? 0} + + - - This product is currently {product.isOutOfStock ? 'out of stock' : 'in stock'}. - + {product.skus?.map((sku) => ( + + {/* Attributes */} + {sku.features?.map((f) => ( + + {f.featureName} + {f.featureValue} + + ))} - { - toggleOutOfStock.mutate({ id: productId }, { - onSuccess: () => { - Alert.alert('Success', 'Stock status updated'); - refetch(); - }, - onError: (err) => Alert.alert('Error', err.message) - }); - }} - activeOpacity={0.8} - style={tw`bg-gray-100 px-4 py-2 rounded-full border border-gray-200 self-start`} - > - - Mark as {product.isOutOfStock ? 'In Stock' : 'Out of Stock'} - - - - + {/* Pricing row */} + + + ₹{sku.price} + {sku.marketPrice && ( + ₹{sku.marketPrice} + )} + + + {sku.isFlashAvailable && ( + + Flash ₹{sku.flashPrice} + + )} + + + {sku.isOutOfStock ? 'Out of Stock' : 'In Stock'} + + + + - {/* Special Deals */} + {/* SKU Images */} + {sku.images && sku.images.length > 0 && ( + + {sku.images.map((url, idx) => ( + + ))} + + )} + + ))} + + + + {/* Special Deals */} {product.deals && product.deals.length > 0 && ( - + (null); - const { data: product, isLoading: isFetching, refetch } = trpc.admin.product.getProductById.useQuery( + const { data: productResponse, isLoading: isFetching, refetch } = trpc.admin.product.getProductById.useQuery( { id: productId }, { enabled: !!productId } ); @@ -24,50 +24,119 @@ export default function EditProduct() { useManualRefresh(() => refetch()); - const handleSubmit = async (values: any, images: ImageUploaderNeoPayload[], imagesToDelete: string[]) => { - try { - // New images have mimeType !== null, existing images have mimeType === null - const newImages = images.filter(img => img.mimeType !== null); - let uploadUrls: string[] = []; + const productData = productResponse?.product; - if (newImages.length > 0) { - const blobs = await Promise.all( - newImages.map(async (img) => { - const response = await fetch(img.url); - const blob = await response.blob(); - return { blob, mimeType: img.mimeType || 'image/jpeg' }; - }) - ); - - const result = await upload({ images: blobs, contextString: 'product_info' }); - uploadUrls = result.presignedUrls; + const initialValues = useMemo(() => { + if (!productData) { + return { + name: '', + shortDescription: '', + longDescription: '', + storeId: 0, + variants: [], } + } + return { + name: productData.name, + shortDescription: productData.shortDescription || '', + longDescription: productData.longDescription || '', + storeId: productData.storeId || 1, + variants: (productData.skus || []).map((sku) => ({ + name: sku.name || '', + price: sku.price || '', + marketPrice: sku.marketPrice || '', + isFlashAvailable: sku.isFlashAvailable || false, + flashPrice: sku.flashPrice || '', + attributes: (sku.features || []).map((f) => ({ + featureName: f.featureName, + featureValue: f.featureValue, + })), + })), + } + }, [productData]) + + const existingVariantImages = useMemo(() => { + if (!productData) return [] + return (productData.skus || []).map((sku) => + (sku.images || []).map((url) => ({ imgUrl: url, mimeType: null } as ImageUploaderNeoItem)) + ) + }, [productData]) + + const existingVariantImageKeys = useMemo(() => { + if (!productData) return [] + return (productData.skus || []).map((sku) => + (sku.imageKeys || []).map(String) + ) + }, [productData]) + + const handleSubmit = async (values: any, variantImages: ImageUploaderNeoPayload[][], deletedImageKeys: string[]) => { + try { + const allBlobs: { blob: Blob; mimeType: string }[] = [] + const imageCounts: number[] = variantImages.map((imgs) => + imgs.filter((img) => img.mimeType !== null).length + ) + + for (const imgs of variantImages) { + for (const img of imgs) { + if (img.mimeType === null) continue // existing image, skip + const response = await fetch(img.url) + const blob = await response.blob() + allBlobs.push({ blob, mimeType: img.mimeType || 'image/jpeg' }) + } + } + + let allUploadUrls: string[] = [] + if (allBlobs.length > 0) { + const result = await upload({ images: allBlobs, contextString: 'product_info' }) + allUploadUrls = result.presignedUrls + } + + // Build SKU inputs with images (existing + new) + let urlCursor = 0 + const skus = values.variants.map((variant: any, vIndex: number) => { + const count = imageCounts[vIndex] + const newUrls = allUploadUrls.slice(urlCursor, urlCursor + count) + urlCursor += count + + // Existing images (mimeType === null) stay as-is + const existingUrls = variantImages[vIndex] + ?.filter((img) => img.mimeType === null) + .map((img) => img.url) || [] + + const allUrls = [...existingUrls, ...newUrls] + + return { + name: variant.name || null, + price: parseFloat(variant.price), + marketPrice: variant.marketPrice ? parseFloat(variant.marketPrice) : undefined, + images: allUrls, + isFlashAvailable: variant.isFlashAvailable || false, + flashPrice: variant.flashPrice ? parseFloat(variant.flashPrice) : undefined, + features: variant.attributes.map((attr: any) => ({ + featureName: attr.featureName, + featureValue: attr.featureValue, + })), + } + }) await updateProduct.mutateAsync({ id: productId, name: values.name, - shortDescription: values.shortDescription, - longDescription: values.longDescription, - unitId: parseInt(values.unitId), - storeId: parseInt(values.storeId), - price: parseFloat(values.price), - marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined, + shortDescription: values.shortDescription || undefined, + longDescription: values.longDescription || undefined, + storeId: values.storeId, incrementStep: 1, - productQuantity: values.productQuantity || 1, - isSuspended: values.isSuspended || false, - isFlashAvailable: values.isFlashAvailable || false, - flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : null, - uploadUrls, - imagesToDelete, - tagIds: values.tagIds || [], - }); + skus, + deletedImageKeys, + newImageUrls: allUploadUrls, + } as any) - await refetch(); - await refetchProducts(); - Alert.alert('Success', 'Product updated successfully!'); - productFormRef.current?.clearImages(); + await refetch() + await refetchProducts() + Alert.alert('Success', 'Product updated successfully!') + productFormRef.current?.clearImages() } catch (error: any) { - Alert.alert('Error', error.message || 'Failed to update product'); + Alert.alert('Error', error.message || 'Failed to update product') } }; @@ -81,7 +150,7 @@ export default function EditProduct() { ); } - if (!product) { + if (!productData) { return ( @@ -91,34 +160,6 @@ export default function EditProduct() { ); } - const productData = product.product; - - const existingImages: ImageUploaderNeoItem[] = (productData.images || []).map((url) => ({ - imgUrl: url, - mimeType: null, - })); - const existingImageKeys = productData.imageKeys || []; - - const initialValues = { - name: productData.name, - shortDescription: productData.shortDescription || '', - longDescription: productData.longDescription || '', - unitId: productData.unitId, - storeId: productData.storeId || 1, - price: productData.price.toString(), - marketPrice: productData.marketPrice?.toString() || '', - deals: productData.deals?.map(deal => ({ - quantity: deal.quantity, - price: deal.price, - validTill: deal.validTill ? new Date(deal.validTill) : null, - })) || [{ quantity: '', price: '', validTill: null }], - tagIds: productData.tags?.map((tag: any) => tag.id) || [], - isSuspended: productData.isSuspended || false, - isFlashAvailable: productData.isFlashAvailable || false, - flashPrice: productData.flashPrice?.toString() || '', - productQuantity: productData.productQuantity || 1, - }; - return ( ); diff --git a/apps/admin-ui/app/(drawer)/products/index.tsx b/apps/admin-ui/app/(drawer)/products/index.tsx index eea72b4..a7125fd 100644 --- a/apps/admin-ui/app/(drawer)/products/index.tsx +++ b/apps/admin-ui/app/(drawer)/products/index.tsx @@ -1,15 +1,19 @@ import React, { useState, useMemo } from 'react'; -import { View, ScrollView, TouchableOpacity, Alert, RefreshControl } from 'react-native'; +import { View, ScrollView, TouchableOpacity, RefreshControl } from 'react-native'; import { Image } from 'expo-image'; import { useRouter } from 'expo-router'; import MaterialIcons from '@expo/vector-icons/MaterialIcons'; -import { AppContainer, MyText, tw, MyButton, useManualRefresh, MyTextInput, SearchBar, useMarkDataFetchers } from 'common-ui'; +import { AppContainer, MyText, tw, MyButton, useManualRefresh, SearchBar, useMarkDataFetchers } from 'common-ui'; import { trpc } from '@/src/trpc-client'; -import type { AdminProduct } from '@packages/shared'; +import type { AdminSku } from '@packages/shared'; type FilterType = 'all' | 'in-stock' | 'out-of-stock'; +function getDefaultSku(product: { skus: AdminSku[] }): AdminSku | null { + return product.skus?.[0] ?? null +} + export default function Products() { const router = useRouter(); const [searchTerm, setSearchTerm] = useState(''); @@ -18,8 +22,6 @@ export default function Products() { const { data: productsData, isLoading, error, refetch } = trpc.admin.product.getProducts.useQuery(); - const toggleOutOfStockMutation = trpc.admin.product.toggleOutOfStock.useMutation(); - useManualRefresh(refetch); useMarkDataFetchers(() => { @@ -36,12 +38,14 @@ export default function Products() { const filteredProducts = useMemo(() => { return products.filter(product => { + const defaultSku = getDefaultSku(product) + const matchesSearch = product.name.toLowerCase().includes(searchTerm.toLowerCase()) || (product.shortDescription?.toLowerCase().includes(searchTerm.toLowerCase())); const matchesFilter = activeFilter === 'all' || - (activeFilter === 'in-stock' && !product.isOutOfStock) || - (activeFilter === 'out-of-stock' && product.isOutOfStock); + (activeFilter === 'in-stock' && !defaultSku?.isOutOfStock) || + (activeFilter === 'out-of-stock' && defaultSku?.isOutOfStock); return matchesSearch && matchesFilter; }); @@ -51,34 +55,6 @@ export default function Products() { router.push(`/products/edit?id=${productId}` as any); }; - - - // const handleToggleStock = (product: any) => { - const handleToggleStock = (product: Pick) => { - const action = product.isOutOfStock ? 'mark as in stock' : 'mark as out of stock'; - Alert.alert( - 'Update Stock Status', - `Are you sure you want to ${action} "${product.name}"?`, - [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Confirm', - onPress: () => { - toggleOutOfStockMutation.mutate({ id: product.id }, { - onSuccess: (data) => { - Alert.alert('Success', data.message); - refetch(); // Refresh the list - }, - onError: (error: any) => { - Alert.alert('Error', error.message || 'Failed to update stock status'); - }, - }); - }, - }, - ] - ); - }; - const handleViewDetails = (productId: number) => { router.push(`/products/detail/${productId}` as any); }; @@ -120,8 +96,8 @@ export default function Products() { ); } - const inStockCount = products.filter(p => !p.isOutOfStock).length; - const outOfStockCount = products.filter(p => p.isOutOfStock).length; + const inStockCount = products.filter(p => getDefaultSku(p) && !getDefaultSku(p)!.isOutOfStock).length; + const outOfStockCount = products.filter(p => getDefaultSku(p)?.isOutOfStock).length; return ( @@ -184,12 +160,17 @@ export default function Products() { ) : ( - {filteredProducts.map(product => ( + {filteredProducts.map(product => { + const defaultSku = getDefaultSku(product) + const skuImages = defaultSku?.images ?? null + const isOut = defaultSku?.isOutOfStock ?? false + + return ( {/* Product Image */} - {product.images && product.images.length > 0 ? ( + {skuImages && skuImages.length > 0 ? ( @@ -206,9 +187,9 @@ export default function Products() { {product.name} - - - {product.isOutOfStock ? 'Out' : 'In'} + + + {isOut ? 'Out' : 'In'} @@ -220,16 +201,9 @@ export default function Products() { )} - - - ₹{product.price} - - {product.marketPrice && ( - - ₹{product.marketPrice} - - )} - + + {product.skus?.length ?? 0} Variants + {/* Action Buttons */} @@ -249,20 +223,11 @@ export default function Products() { Edit - - handleToggleStock(product)} - style={tw`flex-1 ${product.isOutOfStock ? 'bg-green-500' : 'bg-orange-500'} p-3 rounded-lg flex-row items-center justify-center`} - > - - - {product.isOutOfStock ? 'Stock' : 'Out'} - - - ))} + ) + })} )} diff --git a/apps/admin-ui/components/ProductsSelector.tsx b/apps/admin-ui/components/ProductsSelector.tsx index 6894032..30eba0b 100644 --- a/apps/admin-ui/components/ProductsSelector.tsx +++ b/apps/admin-ui/components/ProductsSelector.tsx @@ -4,22 +4,17 @@ import BottomDropdown, { DropdownOption } from 'common-ui/src/components/bottom- import { trpc } from '../src/trpc-client'; import { tw } from 'common-ui'; -interface Product { +interface SkuSummary { id: number; - name: string; - price: number; - unit?: string; - shortDescription?: string | null; - isOutOfStock?: boolean; - isSuspended?: boolean; - storeId?: number | null; - unitNotation?: string; + productId: number; + productName: string; + label: string; } interface Group { id: number; groupName: string; - products: Product[]; + products: { id: number }[]; } interface ProductsSelectorProps { @@ -30,8 +25,8 @@ interface ProductsSelectorProps { placeholder?: string; disabled?: boolean; error?: boolean; - isDisabled?: (product: Product) => boolean; - labelFormat?: (product: Product) => string; + isDisabled?: (product: SkuSummary) => boolean; + labelFormat?: (product: SkuSummary) => string; groups?: Group[]; selectedGroupIds?: number[]; onGroupChange?: (groupIds: number[]) => void; @@ -53,85 +48,78 @@ export default function ProductsSelector({ selectedGroupIds = [], onGroupChange, }: ProductsSelectorProps) { - const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery({}); - const products = productsData?.products || []; + const { data: skusData } = trpc.common.product.getAllSkusSummary.useQuery({}); + const allSkus: SkuSummary[] = skusData?.skus || []; const [searchQuery, setSearchQuery] = useState(''); - // Format product label: name (unit) (₹price) - const formatProductLabel = (product: Product): string => { - if (labelFormat) { - return labelFormat(product); - } - const unit = product.unit ? ` (${product.unit})` : ''; - const price = ` (₹${product.price})`; - return `${product.name}${unit}${price}`; - }; - // Handle group selection changes const handleGroupChange = (newGroupIds: number[]) => { if (!onGroupChange) return; const previousGroupIds = selectedGroupIds; - // Find which groups were added and which were removed const addedGroups = newGroupIds.filter(id => !previousGroupIds.includes(id)); const removedGroups = previousGroupIds.filter(id => !newGroupIds.includes(id)); - // Get current selected products - let currentProducts = Array.isArray(value) ? [...value] : value ? [value] : []; + let currentSkus = Array.isArray(value) ? [...value] : value ? [value] : []; - // Add products from newly selected groups - const addedProducts = addedGroups.flatMap(groupId => { + const addedSkus = addedGroups.flatMap(groupId => { const group = groups.find(g => g.id === groupId); - return group?.products.map(p => p.id) || []; + if (!group) return []; + const productIds = new Set(group.products.map(p => p.id)); + return allSkus.filter(s => productIds.has(s.productId)).map(s => s.id); }); - // Remove products from deselected groups - const removedProducts = removedGroups.flatMap(groupId => { + const removedSkus = removedGroups.flatMap(groupId => { const group = groups.find(g => g.id === groupId); - return group?.products.map(p => p.id) || []; + if (!group) return []; + const productIds = new Set(group.products.map(p => p.id)); + return allSkus.filter(s => productIds.has(s.productId)).map(s => s.id); }); - // Update product list: add new ones, remove deselected group ones - currentProducts = [...new Set([...currentProducts, ...addedProducts])]; - currentProducts = currentProducts.filter(id => !removedProducts.includes(id)); + currentSkus = [...new Set([...currentSkus, ...addedSkus])]; + currentSkus = currentSkus.filter(id => !removedSkus.includes(id)); onGroupChange(newGroupIds); if (multiple) { - onChange(currentProducts.length > 0 ? currentProducts : []); + onChange(currentSkus.length > 0 ? currentSkus : []); } else { - onChange(currentProducts.length > 0 ? currentProducts[0] : 0); + onChange(currentSkus.length > 0 ? currentSkus[0] : 0); } }; // Filter products based on search query - const filteredProducts = useMemo(() => { - if (!searchQuery.trim()) return products; + const filteredSkus = useMemo(() => { + if (!searchQuery.trim()) return allSkus; const query = searchQuery.toLowerCase(); - return products.filter(product => - product.name.toLowerCase().includes(query) || - (product.shortDescription && product.shortDescription.toLowerCase().includes(query)) || - (product.unit && product.unit.toLowerCase().includes(query)) + return allSkus.filter(sku => + sku.label.toLowerCase().includes(query) || + sku.productName.toLowerCase().includes(query) ); - }, [products, searchQuery]); + }, [allSkus, searchQuery]); // Build dropdown options const productOptions: DropdownOption[] = useMemo(() => { - return filteredProducts.map((product) => { - const isFromGroup = selectedGroupIds.length > 0 && groups.some(group => - selectedGroupIds.includes(group.id) && group.products.some(p => p.id === product.id) - ); + return filteredSkus.map((sku) => { + const isFromGroup = selectedGroupIds.length > 0 && groups.some(group => { + if (!selectedGroupIds.includes(group.id)) return false; + return group.products.some(p => p.id === sku.productId); + }); - const isProductDisabled = isDisabled ? isDisabled(product as Product) : false; + const isProductDisabled = isDisabled ? isDisabled(sku) : false; + + const displayLabel = labelFormat + ? labelFormat(sku) + : sku.label; return { - label: `${formatProductLabel(product as Product)}${isFromGroup ? ' (from group)' : ''}`, - value: product.id.toString(), + label: `${displayLabel}${isFromGroup ? ' (from group)' : ''}`, + value: sku.id.toString(), disabled: isProductDisabled, }; }); - }, [filteredProducts, selectedGroupIds, groups, isDisabled, labelFormat]); + }, [filteredSkus, selectedGroupIds, groups, isDisabled, labelFormat]); // Build group options if groups are provided const groupOptions: DropdownOption[] = useMemo(() => { @@ -143,7 +131,6 @@ export default function ProductsSelector({ return ( - {/* Groups selector (if groups are provided and showGroups is true) */} {showGroups && groups.length > 0 && ( id.toString())} - onValueChange={(value) => { - const selectedValues = Array.isArray(value) ? value : typeof value === 'string' ? [value] : []; + onValueChange={(selectedValue) => { + const selectedValues = Array.isArray(selectedValue) ? selectedValue : typeof selectedValue === 'string' ? [selectedValue] : []; const newGroupIds = selectedValues.map(v => parseInt(v as string)); handleGroupChange(newGroupIds); }} @@ -162,7 +149,6 @@ export default function ProductsSelector({ )} - {/* Products selector */} ({ name: snippet.name || '', groupIds: snippet.groupIds || [], - productIds: snippet.productIds || [], + skuIds: snippet.skuIds || [], validTill: snippet.validTill || undefined, })) as VendorSnippet[]; @@ -48,7 +48,7 @@ export default function SlotForm({ deliveryTime: initialDeliveryTime || (slotData?.slot?.deliveryTime ? new Date(slotData.slot.deliveryTime) : null), freezeTime: initialFreezeTime || (slotData?.slot?.freezeTime ? new Date(slotData.slot.freezeTime) : null), selectedGroupIds: initialGroupIds.length > 0 ? initialGroupIds : (slotData?.slot?.groupIds || []), - selectedProductIds: initialProductIds.length > 0 ? initialProductIds : (slotData?.slot?.products?.map((p: any) => p.id) || []), + selectedSkuIds: initialProductIds.length > 0 ? initialProductIds : (slotData?.slot?.products?.map((p: any) => p.id) || []), vendorSnippetList: vendorSnippetsFromSlot, }; @@ -58,15 +58,8 @@ export default function SlotForm({ const isEditMode = !!slotId; const isPending = isCreating || isUpdating; - // Fetch groups const { data: groupsData } = trpc.admin.product.getGroups.useQuery(); - - - - - - const handleFormSubmit = (values: typeof initialValues) => { if (!values.deliveryTime || !values.freezeTime) { Alert.alert('Error', 'Please fill all fields'); @@ -78,23 +71,22 @@ export default function SlotForm({ return; } - const slotData = { + const slotInputData = { deliveryTime: values.deliveryTime.toISOString(), freezeTime: values.freezeTime.toISOString(), isActive: initialIsActive, groupIds: values.selectedGroupIds, - productIds: values.selectedProductIds, + skuIds: values.selectedSkuIds, vendorSnippets: values.vendorSnippetList.map((snippet: VendorSnippet) => ({ name: snippet.name, - productIds: snippet.productIds, + skuIds: snippet.skuIds, validTill: snippet.validTill, })), }; - if (isEditMode && slotId) { updateSlot( - { id: slotId, ...slotData }, + { id: slotId, ...slotInputData }, { onSuccess: () => { Alert.alert('Success', 'Slot updated successfully!'); @@ -109,12 +101,10 @@ export default function SlotForm({ ); } else { createSlot( - slotData, + slotInputData, { onSuccess: () => { Alert.alert('Success', 'Slot created successfully!'); - // Reset form - // Formik will handle reset onSlotAdded?.(); }, onError: (error: any) => { @@ -131,12 +121,11 @@ export default function SlotForm({ onSubmit={handleFormSubmit} > {({ handleSubmit, values, setFieldValue }) => { - // Map groups data to match ProductsSelector types (convert price from string to number) const mappedGroups = (groupsData?.groups || []).map(group => ({ ...group, products: group.products.map(product => ({ ...product, - price: parseFloat(product.price as unknown as string) || 0, + id: product.id, })), })); @@ -168,8 +157,8 @@ export default function SlotForm({ setFieldValue('selectedProductIds', newProductIds)} + value={values.selectedSkuIds} + onChange={(newSkuIds) => setFieldValue('selectedSkuIds', newSkuIds)} groups={mappedGroups} selectedGroupIds={values.selectedGroupIds} onGroupChange={(newGroupIds) => setFieldValue('selectedGroupIds', newGroupIds)} @@ -196,19 +185,19 @@ export default function SlotForm({ setFieldValue(`vendorSnippetList.${index}.productIds`, newProductIds)} + value={snippet.skuIds || []} + onChange={(newSkuIds) => setFieldValue(`vendorSnippetList.${index}.skuIds`, newSkuIds)} groups={mappedGroups.filter(group => values.selectedGroupIds.includes(group.id) ).map(group => ({ ...group, - products: group.products.filter(p => values.selectedProductIds.includes(p.id)) + products: group.products.filter((p: any) => values.selectedSkuIds.includes(p.id)) }))} selectedGroupIds={snippet.groupIds || []} onGroupChange={(newGroupIds) => setFieldValue(`vendorSnippetList.${index}.groupIds`, newGroupIds)} label="Select Products" placeholder="Select products for snippet" - isDisabled={(product) => !values.selectedProductIds.includes(product.id)} + isDisabled={(sku) => !values.selectedSkuIds.includes(sku.id)} /> ))} push({ name: '', groupIds: [], productIds: [], validTill: '' })} + onPress={() => push({ name: '', groupIds: [], skuIds: [], validTill: '' })} style={tw`bg-blue-500 px-4 py-3 rounded-lg items-center`} > Add Vendor Snippet @@ -244,4 +233,4 @@ export default function SlotForm({ )}} ); -} \ No newline at end of file +} diff --git a/apps/admin-ui/src/components/ProductForm.tsx b/apps/admin-ui/src/components/ProductForm.tsx index ac8d4e3..7b660ca 100644 --- a/apps/admin-ui/src/components/ProductForm.tsx +++ b/apps/admin-ui/src/components/ProductForm.tsx @@ -1,262 +1,296 @@ -import React, { useState, useEffect, useCallback, forwardRef, useImperativeHandle, useMemo } from 'react'; -import { View, TouchableOpacity } from 'react-native'; -import { Formik, FieldArray } from 'formik'; -import * as Yup from 'yup'; -import { MyTextInput, BottomDropdown, MyText, useTheme, DatePicker, tw, useFocusCallback, Checkbox, ImageUploaderNeo, ImageUploaderNeoItem, ImageUploaderNeoPayload } from 'common-ui'; -import MaterialIcons from '@expo/vector-icons/MaterialIcons'; -import { trpc } from '../trpc-client'; +import React, { useState, useImperativeHandle, forwardRef } from 'react' +import { View, TouchableOpacity, ScrollView } from 'react-native' +import { Formik, FieldArray } from 'formik' +import { MyTextInput, BottomDropdown, MyText, tw, ImageUploaderNeo, ImageUploaderNeoItem, ImageUploaderNeoPayload, Checkbox } from 'common-ui' +import MaterialIcons from '@expo/vector-icons/MaterialIcons' +import { trpc } from '../trpc-client' -interface ProductFormData { - name: string; - shortDescription: string; - longDescription: string; - unitId: number; - storeId: number; - price: string; - marketPrice: string; - isSuspended: boolean; - isFlashAvailable: boolean; - flashPrice: string; - deals: Deal[]; - tagIds: number[]; - productQuantity: number; +interface Attribute { + featureName: string + featureValue: string } -interface Deal { - quantity: string; - price: string; - validTill: Date | null; +interface Variant { + name: string + price: string + marketPrice: string + isFlashAvailable: boolean + flashPrice: string + attributes: Attribute[] +} + +interface ProductFormData { + name: string + shortDescription: string + longDescription: string + storeId: number + variants: Variant[] } export interface ProductFormRef { - clearImages: () => void; + clearImages: () => void } interface ProductFormProps { - mode: 'create' | 'edit'; - initialValues: ProductFormData; - onSubmit: (values: ProductFormData, images: ImageUploaderNeoPayload[], imagesToDelete: string[]) => void; - isLoading: boolean; - existingImages?: ImageUploaderNeoItem[]; - existingImageKeys?: string[]; + mode: 'create' | 'edit' + initialValues: ProductFormData + onSubmit: (values: ProductFormData, variantImages: ImageUploaderNeoPayload[][], deletedImageKeys: string[]) => void + isLoading: boolean + existingVariantImages?: ImageUploaderNeoItem[][] + existingVariantImageKeys?: string[][] } -const unitOptions = [ - { label: 'Kg', value: 1 }, - { label: 'Litre', value: 2 }, - { label: 'Dozen', value: 3 }, - { label: 'Unit Piece', value: 4 }, -]; +const defaultAttribute = (): Attribute => ({ featureName: 'quantity', featureValue: '' }) + +const defaultVariant = (): Variant => ({ + name: '', + price: '', + marketPrice: '', + isFlashAvailable: false, + flashPrice: '', + attributes: [defaultAttribute()], +}) const ProductForm = forwardRef(({ mode, initialValues, onSubmit, isLoading, - existingImages:existingImagesRaw, - existingImageKeys = [], + existingVariantImages = [], + existingVariantImageKeys = [], }, ref) => { - const { theme } = useTheme(); - const [images, setImages] = useState([]); + const [variantImages, setVariantImages] = useState(() => + initialValues.variants.length > 0 + ? initialValues.variants.map((_, i) => existingVariantImages[i] || []) + : [[]] + ) - const existingImages = existingImagesRaw || [] - // Sync images state when existingImages prop changes (e.g., when async query data arrives) - useEffect(() => { - setImages(existingImages); - }, [existingImagesRaw]); + useImperativeHandle(ref, () => ({ + clearImages: () => setVariantImages(initialValues.variants.map(() => [])), + }), [initialValues.variants]) - const { data: storesData } = trpc.common.getStoresSummary.useQuery(); - const storeOptions = storesData?.stores.map(store => ({ + const { data: storesData } = trpc.common.getStoresSummary.useQuery() + const storeOptions = storesData?.stores.map((store) => ({ label: store.name, value: store.id, - })) || []; - - const { data: tagsData } = trpc.admin.product.getProductTags.useQuery(); - const tagOptions = tagsData?.tags.map(tag => ({ - label: tag.tagName, - value: tag.id.toString(), - })) || []; - - // Build signed URL -> S3 key mapping for existing images - const signedUrlToKey = useMemo(() => { - const map: Record = {}; - existingImages.forEach((img, i) => { - if (existingImageKeys[i]) { - map[img.imgUrl] = existingImageKeys[i]; - } - }); - return map; - }, [existingImages, existingImageKeys]); + })) || [] return ( { - // New images have mimeType set, existing images have mimeType === null - const newImages = images.filter(img => img.mimeType !== null); - const deletedImageKeys = existingImages - .filter(existing => !images.some(current => current.imgUrl === existing.imgUrl)) - .map(deleted => signedUrlToKey[deleted.imgUrl]) - .filter(Boolean); - - onSubmit( - values, - newImages.map(img => ({ url: img.imgUrl, mimeType: img.mimeType })), - deletedImageKeys, - ); + const images = variantImages.map((imgs) => + imgs.map((img) => ({ url: img.imgUrl, mimeType: img.mimeType })) + ) + const deletedKeys: string[] = [] + if (mode === 'edit') { + variantImages.forEach((currentImgs, vIndex) => { + const existing = existingVariantImages[vIndex] || [] + existing.forEach((existingImg) => { + if (!currentImgs.some((cur) => cur.imgUrl === existingImg.imgUrl)) { + const key = existingVariantImageKeys[vIndex]?.[existingVariantImages[vIndex]?.indexOf(existingImg)] + if (key) deletedKeys.push(key) + } + }) + }) + } + onSubmit(values, images, deletedKeys) }} enableReinitialize > - {({ handleChange, handleSubmit, values, setFieldValue, resetForm }) => { - const clearForm = useCallback(() => { - setImages([]); - resetForm(); - }, [resetForm]); + {({ handleChange, handleSubmit, values, setFieldValue }) => ( + + + + + setFieldValue('storeId', value)} + placeholder="Select store" + style={{ marginBottom: 16 }} + /> - useFocusCallback(clearForm); + + {({ push, remove }) => ( + + + Variants + { + push(defaultVariant()) + setVariantImages((prev) => [...prev, []]) + }} + style={tw`bg-blue-500 px-3 py-1 rounded-lg flex-row items-center`} + > + + Add Variant + + - useImperativeHandle(ref, () => ({ - clearImages: clearForm, - }), [clearForm]); + {values.variants.map((variant, vIndex) => ( + + + Variant {vIndex + 1} + {values.variants.length > 1 && ( + { + remove(vIndex) + setVariantImages((prev) => prev.filter((_, i) => i !== vIndex)) + }} + > + + + )} + - const submit = () => handleSubmit(); + + {({ push: pushAttr, remove: removeAttr }) => ( + + + Attributes + pushAttr(defaultAttribute())} + style={tw`bg-gray-200 px-2 py-0.5 rounded flex-row items-center`} + > + + Add + + + {variant.attributes.map((attr, aIndex) => ( + + + + + + + + {variant.attributes.length > 1 && ( + removeAttr(aIndex)}> + + + )} + + ))} + + )} + - return ( - - - - + + + + + + + + - setImages(prev => [...prev, ...payloads.map(p => ({ imgUrl: p.url, mimeType: p.mimeType }))])} - onImageRemove={(payload) => setImages(prev => prev.filter(img => img.imgUrl !== payload.url))} - allowMultiple={true} - /> + + { + setFieldValue(`variants.${vIndex}.isFlashAvailable`, !variant.isFlashAvailable) + if (variant.isFlashAvailable) setFieldValue(`variants.${vIndex}.flashPrice`, '') + }} + style={tw`mr-3`} + /> + Flash Available + - setFieldValue('unitId', value)} - placeholder="Select unit" - style={{ marginBottom: 16 }} - /> - setFieldValue('productQuantity', text)} - style={{ marginBottom: 16 }} - /> - setFieldValue('storeId', value)} - placeholder="Select store" - style={{ marginBottom: 16 }} - /> - id.toString())} - options={tagOptions} - onValueChange={(value) => setFieldValue('tagIds', (value as string[]).map(id => parseInt(id)))} - multiple={true} - placeholder="Select tags" - style={{ marginBottom: 16 }} - /> - - + {variant.isFlashAvailable && ( + + )} - - setFieldValue('isSuspended', !values.isSuspended)} - style={tw`mr-3`} - /> - Suspend Product - + + setVariantImages((prev) => { + const next = [...prev] + next[vIndex] = [...(next[vIndex] || []), ...payloads.map((p) => ({ imgUrl: p.url, mimeType: p.mimeType }))] + return next + }) + } + onImageRemove={(payload) => + setVariantImages((prev) => { + const next = [...prev] + next[vIndex] = (next[vIndex] || []).filter((img) => img.imgUrl !== payload.url) + return next + }) + } + allowMultiple={true} + /> + + ))} + + )} + - - { - setFieldValue('isFlashAvailable', !values.isFlashAvailable); - if (values.isFlashAvailable) setFieldValue('flashPrice', ''); - }} - style={tw`mr-3`} - /> - Flash Available - - - {values.isFlashAvailable && ( - - )} - - - - {isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Product' : 'Update Product')} - - - - ); - }} + handleSubmit()} + disabled={isLoading} + style={tw`px-4 py-3 rounded-lg shadow-lg items-center mt-4 ${isLoading ? 'bg-gray-400' : 'bg-blue-500'}`} + > + + {isLoading ? 'Creating...' : 'Create Product'} + + + + )} - ); -}); + ) +}) -ProductForm.displayName = 'ProductForm'; +ProductForm.displayName = 'ProductForm' -export default ProductForm; +export default ProductForm diff --git a/apps/admin-ui/src/components/ProductForm_old.tsx b/apps/admin-ui/src/components/ProductForm_old.tsx new file mode 100644 index 0000000..ac8d4e3 --- /dev/null +++ b/apps/admin-ui/src/components/ProductForm_old.tsx @@ -0,0 +1,262 @@ +import React, { useState, useEffect, useCallback, forwardRef, useImperativeHandle, useMemo } from 'react'; +import { View, TouchableOpacity } from 'react-native'; +import { Formik, FieldArray } from 'formik'; +import * as Yup from 'yup'; +import { MyTextInput, BottomDropdown, MyText, useTheme, DatePicker, tw, useFocusCallback, Checkbox, ImageUploaderNeo, ImageUploaderNeoItem, ImageUploaderNeoPayload } from 'common-ui'; +import MaterialIcons from '@expo/vector-icons/MaterialIcons'; +import { trpc } from '../trpc-client'; + +interface ProductFormData { + name: string; + shortDescription: string; + longDescription: string; + unitId: number; + storeId: number; + price: string; + marketPrice: string; + isSuspended: boolean; + isFlashAvailable: boolean; + flashPrice: string; + deals: Deal[]; + tagIds: number[]; + productQuantity: number; +} + +interface Deal { + quantity: string; + price: string; + validTill: Date | null; +} + +export interface ProductFormRef { + clearImages: () => void; +} + +interface ProductFormProps { + mode: 'create' | 'edit'; + initialValues: ProductFormData; + onSubmit: (values: ProductFormData, images: ImageUploaderNeoPayload[], imagesToDelete: string[]) => void; + isLoading: boolean; + existingImages?: ImageUploaderNeoItem[]; + existingImageKeys?: string[]; +} + +const unitOptions = [ + { label: 'Kg', value: 1 }, + { label: 'Litre', value: 2 }, + { label: 'Dozen', value: 3 }, + { label: 'Unit Piece', value: 4 }, +]; + +const ProductForm = forwardRef(({ + mode, + initialValues, + onSubmit, + isLoading, + existingImages:existingImagesRaw, + existingImageKeys = [], +}, ref) => { + const { theme } = useTheme(); + const [images, setImages] = useState([]); + + const existingImages = existingImagesRaw || [] + // Sync images state when existingImages prop changes (e.g., when async query data arrives) + useEffect(() => { + setImages(existingImages); + }, [existingImagesRaw]); + + const { data: storesData } = trpc.common.getStoresSummary.useQuery(); + const storeOptions = storesData?.stores.map(store => ({ + label: store.name, + value: store.id, + })) || []; + + const { data: tagsData } = trpc.admin.product.getProductTags.useQuery(); + const tagOptions = tagsData?.tags.map(tag => ({ + label: tag.tagName, + value: tag.id.toString(), + })) || []; + + // Build signed URL -> S3 key mapping for existing images + const signedUrlToKey = useMemo(() => { + const map: Record = {}; + existingImages.forEach((img, i) => { + if (existingImageKeys[i]) { + map[img.imgUrl] = existingImageKeys[i]; + } + }); + return map; + }, [existingImages, existingImageKeys]); + + return ( + { + // New images have mimeType set, existing images have mimeType === null + const newImages = images.filter(img => img.mimeType !== null); + const deletedImageKeys = existingImages + .filter(existing => !images.some(current => current.imgUrl === existing.imgUrl)) + .map(deleted => signedUrlToKey[deleted.imgUrl]) + .filter(Boolean); + + onSubmit( + values, + newImages.map(img => ({ url: img.imgUrl, mimeType: img.mimeType })), + deletedImageKeys, + ); + }} + enableReinitialize + > + {({ handleChange, handleSubmit, values, setFieldValue, resetForm }) => { + const clearForm = useCallback(() => { + setImages([]); + resetForm(); + }, [resetForm]); + + useFocusCallback(clearForm); + + useImperativeHandle(ref, () => ({ + clearImages: clearForm, + }), [clearForm]); + + const submit = () => handleSubmit(); + + return ( + + + + + + setImages(prev => [...prev, ...payloads.map(p => ({ imgUrl: p.url, mimeType: p.mimeType }))])} + onImageRemove={(payload) => setImages(prev => prev.filter(img => img.imgUrl !== payload.url))} + allowMultiple={true} + /> + + setFieldValue('unitId', value)} + placeholder="Select unit" + style={{ marginBottom: 16 }} + /> + setFieldValue('productQuantity', text)} + style={{ marginBottom: 16 }} + /> + setFieldValue('storeId', value)} + placeholder="Select store" + style={{ marginBottom: 16 }} + /> + id.toString())} + options={tagOptions} + onValueChange={(value) => setFieldValue('tagIds', (value as string[]).map(id => parseInt(id)))} + multiple={true} + placeholder="Select tags" + style={{ marginBottom: 16 }} + /> + + + + + setFieldValue('isSuspended', !values.isSuspended)} + style={tw`mr-3`} + /> + Suspend Product + + + + { + setFieldValue('isFlashAvailable', !values.isFlashAvailable); + if (values.isFlashAvailable) setFieldValue('flashPrice', ''); + }} + style={tw`mr-3`} + /> + Flash Available + + + {values.isFlashAvailable && ( + + )} + + + + {isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Product' : 'Update Product')} + + + + ); + }} + + ); +}); + +ProductForm.displayName = 'ProductForm'; + +export default ProductForm; diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts index 0c3c366..0eb8a5d 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts @@ -35,6 +35,7 @@ import { } from '@/src/dbService' import type { AdminProduct, + AdminProductWithRelations, AdminSpecialDeal, AdminProductGroupsResult, AdminProductGroupResponse, @@ -70,7 +71,12 @@ export const productRouter = router({ const productsWithSignedUrls = await Promise.all( products.map(async (product) => ({ ...product, - images: await generateSignedUrlsFromS3Urls((product.images as string[]) || []), + skus: await Promise.all( + product.skus.map(async (sku) => ({ + ...sku, + images: await generateSignedUrlsFromS3Urls((sku.images as string[]) || []), + })) + ), })) ) @@ -135,7 +141,12 @@ export const productRouter = router({ const productWithSignedUrls = { ...product, - images: await generateSignedUrlsFromS3Urls((product.images as string[]) || []), + skus: await Promise.all( + product.skus.map(async (sku) => ({ + ...sku, + images: await generateSignedUrlsFromS3Urls((sku.images as string[]) || []), + })) + ), } return { @@ -221,72 +232,71 @@ export const productRouter = router({ name: z.string().min(1, 'Name is required'), shortDescription: z.string().optional(), longDescription: z.string().optional(), - unitId: z.number().min(1, 'Unit is required'), storeId: z.number().min(1, 'Store is required'), - price: z.number().positive('Price must be positive'), - marketPrice: z.number().optional(), incrementStep: z.number().optional().default(1), - productQuantity: z.union([z.number(), z.string()]).optional().default(1), - isSuspended: z.boolean().optional().default(false), - isFlashAvailable: z.boolean().optional().default(false), - flashPrice: z.number().optional(), - uploadUrls: z.array(z.string()).optional().default([]), - deals: z.array(z.object({ - quantity: z.number(), - price: z.number(), - validTill: z.string(), - })).optional(), - tagIds: z.array(z.number()).optional().default([]), + skus: z.array(z.object({ + name: z.string().optional().nullable(), + price: z.number().positive('Price must be positive'), + marketPrice: z.number().optional().nullable(), + images: z.array(z.string()).optional().default([]), + isFlashAvailable: z.boolean().optional().default(false), + flashPrice: z.number().optional().nullable(), + features: z.array(z.object({ + featureName: z.string().min(1, 'Attribute name is required'), + featureValue: z.string().min(1, 'Value is required'), + })).min(1, 'At least one feature is required'), + })).min(1, 'At least one SKU is required'), })) - .mutation(async ({ input }): Promise<{ product: AdminProduct; deals: AdminSpecialDeal[]; message: string }> => { - const { name, shortDescription, longDescription, unitId, storeId, price, marketPrice, incrementStep, productQuantity, isSuspended, isFlashAvailable, flashPrice, uploadUrls, deals, tagIds } = input + .mutation(async ({ input }): Promise<{ product: AdminProductWithRelations; message: string }> => { + const { name, shortDescription, longDescription, storeId, incrementStep, skus } = input const existingProduct = await checkProductExistsByName(name.trim()) if (existingProduct) { throw new ApiError('A product with this name already exists', 400) } - const unitExists = await checkUnitExists(unitId) - if (!unitExists) { - throw new ApiError('Invalid unit ID', 400) - } + const allUploadUrls: string[] = skus.flatMap((sku) => sku.images) - const imageKeys = uploadUrls.map(url => extractKeyFromPresignedUrl(url)) + const skuInputs = skus.map((sku) => ({ + name: sku.name ?? null, + price: sku.price, + marketPrice: sku.marketPrice ?? null, + images: sku.images.map((url) => extractKeyFromPresignedUrl(url)), + isFlashAvailable: sku.isFlashAvailable, + flashPrice: sku.flashPrice ?? null, + features: sku.features.map((f) => ({ + featureName: f.featureName, + featureValue: f.featureValue, + })), + })) const newProduct = await createProductInDb({ name, shortDescription, longDescription, - unitId, storeId, - price: price.toString(), - marketPrice: marketPrice?.toString(), incrementStep, - productQuantity: productQuantity as any, - isSuspended, - isFlashAvailable, - flashPrice: flashPrice?.toString(), - images: imageKeys, - }) + skus: skuInputs, + } as any) - let createdDeals: AdminSpecialDeal[] = [] - if (deals && deals.length > 0) { - createdDeals = await createSpecialDealsForProduct(newProduct.id, deals) - } - - if (tagIds.length > 0) { - await replaceProductTags(newProduct.id, tagIds) - } - - if (uploadUrls.length > 0) { - await Promise.all(uploadUrls.map(url => claimUploadUrl(url))) + if (allUploadUrls.length > 0) { + await Promise.all(allUploadUrls.map((url) => claimUploadUrl(url))) } await scheduleStoreInitialization() + const productWithSignedUrls = { + ...newProduct, + skus: await Promise.all( + newProduct.skus.map(async (sku) => ({ + ...sku, + images: await generateSignedUrlsFromS3Urls((sku.images as string[]) || []), + })) + ), + } + return { - product: newProduct, - deals: createdDeals, + product: productWithSignedUrls, message: 'Product created successfully', } }), @@ -297,83 +307,76 @@ export const productRouter = router({ name: z.string().min(1, 'Name is required'), shortDescription: z.string().optional(), longDescription: z.string().optional(), - unitId: z.number().min(1, 'Unit is required'), storeId: z.number().min(1, 'Store is required'), - price: z.number().positive('Price must be positive'), - marketPrice: z.number().optional(), incrementStep: z.number().optional().default(1), - productQuantity: z.union([z.number(), z.string()]).optional().default(1), - isSuspended: z.boolean().optional().default(false), - isFlashAvailable: z.boolean().optional().default(false), - flashPrice: z.number().nullable().optional(), - uploadUrls: z.array(z.string()).optional().default([]), - imagesToDelete: z.array(z.string()).optional().default([]), - deals: z.array(z.object({ - quantity: z.number(), - price: z.number(), - validTill: z.string(), - })).optional(), - tagIds: z.array(z.number()).optional().default([]), + skus: z.array(z.object({ + name: z.string().optional().nullable(), + price: z.number().positive('Price must be positive'), + marketPrice: z.number().optional().nullable(), + images: z.array(z.string()).optional().default([]), + isFlashAvailable: z.boolean().optional().default(false), + flashPrice: z.number().optional().nullable(), + features: z.array(z.object({ + featureName: z.string().min(1, 'Attribute name is required'), + featureValue: z.string().min(1, 'Value is required'), + })).min(1, 'At least one feature is required'), + })).min(1, 'At least one SKU is required'), + deletedImageKeys: z.array(z.string()).optional().default([]), + newImageUrls: z.array(z.string()).optional().default([]), })) - .mutation(async ({ input }): Promise<{ product: AdminProduct; message: string }> => { - const { id, name, shortDescription, longDescription, unitId, storeId, price, marketPrice, incrementStep, productQuantity, isSuspended, isFlashAvailable, flashPrice, uploadUrls, imagesToDelete, deals, tagIds } = input + .mutation(async ({ input }): Promise<{ product: AdminProductWithRelations; message: string }> => { + const { id, name, shortDescription, longDescription, storeId, incrementStep, skus, deletedImageKeys, newImageUrls } = input - const unitExists = await checkUnitExists(unitId) - if (!unitExists) { - throw new ApiError('Invalid unit ID', 400) + if (deletedImageKeys.length > 0) { + await deleteImageUtil({ keys: deletedImageKeys }) } - const currentImages = await getProductImagesById(id) - if (!currentImages) { - throw new ApiError('Product not found', 404) - } + const allUploadUrls: string[] = skus.flatMap((sku) => sku.images) - let updatedImages = currentImages || [] - if (imagesToDelete.length > 0) { - const imagesToRemove = updatedImages.filter(img => imagesToDelete.includes(img)) - await deleteImageUtil({ keys: imagesToRemove }) - updatedImages = updatedImages.filter(img => !imagesToRemove.includes(img)) - } - - const newImageKeys = uploadUrls.map(url => extractKeyFromPresignedUrl(url)) - const finalImages = [...updatedImages, ...newImageKeys] + const skuInputs = skus.map((sku) => ({ + name: sku.name ?? null, + price: sku.price, + marketPrice: sku.marketPrice ?? null, + images: sku.images.map((url) => extractKeyFromPresignedUrl(url)), + isFlashAvailable: sku.isFlashAvailable, + flashPrice: sku.flashPrice ?? null, + features: sku.features.map((f) => ({ + featureName: f.featureName, + featureValue: f.featureValue, + })), + })) const updatedProduct = await updateProductInDb(id, { name, shortDescription, longDescription, - unitId, storeId, - price: price.toString(), - marketPrice: marketPrice?.toString(), incrementStep, - productQuantity: productQuantity as any, - isSuspended, - isFlashAvailable, - flashPrice: flashPrice?.toString() ?? null, - images: finalImages, - }) + skus: skuInputs, + } as any) if (!updatedProduct) { throw new ApiError('Product not found', 404) } - if (deals && deals.length > 0) { - await updateProductDeals(id, deals) - } - - if (tagIds.length > 0) { - await replaceProductTags(id, tagIds) - } - - if (uploadUrls.length > 0) { - await Promise.all(uploadUrls.map(url => claimUploadUrl(url))) + if (newImageUrls.length > 0) { + await Promise.all(newImageUrls.map((url) => claimUploadUrl(url))) } await scheduleStoreInitialization() + const productWithSignedUrls = { + ...updatedProduct, + skus: await Promise.all( + updatedProduct.skus.map(async (sku) => ({ + ...sku, + images: await generateSignedUrlsFromS3Urls((sku.images as string[]) || []), + })) + ), + } + return { - product: updatedProduct, + product: productWithSignedUrls, message: 'Product updated successfully', } }), diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/slots.ts b/apps/backend/src/trpc/apis/admin-apis/apis/slots.ts index dcfbfdf..2de0549 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/slots.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/slots.ts @@ -46,10 +46,10 @@ const createSlotSchema = z.object({ deliveryTime: z.string(), freezeTime: z.string(), isActive: z.boolean().optional(), - productIds: z.array(z.number()).optional(), + skuIds: z.array(z.number()).optional(), vendorSnippets: z.array(z.object({ name: z.string().min(1), - productIds: z.array(z.number().int().positive()).min(1), + skuIds: z.array(z.number().int().positive()).min(1), validTill: z.string().optional(), })).optional(), groupIds: z.array(z.number()).optional(), @@ -64,10 +64,10 @@ const updateSlotSchema = z.object({ deliveryTime: z.string(), freezeTime: z.string(), isActive: z.boolean().optional(), - productIds: z.array(z.number()).optional(), + skuIds: z.array(z.number()).optional(), vendorSnippets: z.array(z.object({ name: z.string().min(1), - productIds: z.array(z.number().int().positive()).min(1), + skuIds: z.array(z.number().int().positive()).min(1), validTill: z.string().optional(), })).optional(), groupIds: z.array(z.number()).optional(), @@ -192,7 +192,7 @@ export const slotsRouter = router({ .input( z.object({ slotId: z.number(), - productIds: z.array(z.number()), + skuIds: z.array(z.number()), }) ) .mutation(async ({ input, ctx }): Promise => { @@ -200,12 +200,12 @@ export const slotsRouter = router({ throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" }); } - const { slotId, productIds } = input; + const { slotId, skuIds } = input; - if (!Array.isArray(productIds)) { + if (!Array.isArray(skuIds)) { throw new TRPCError({ code: "BAD_REQUEST", - message: "productIds must be an array", + message: "skuIds must be an array", }); } @@ -282,7 +282,7 @@ export const slotsRouter = router({ throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" }); } - const { deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds } = input; + const { deliveryTime, freezeTime, isActive, skuIds, vendorSnippets: snippets, groupIds } = input; // Validate required fields if (!deliveryTime || !freezeTime) { @@ -293,7 +293,7 @@ export const slotsRouter = router({ deliveryTime, freezeTime, isActive, - productIds, + skuIds, vendorSnippets: snippets, groupIds, }) @@ -445,7 +445,7 @@ export const slotsRouter = router({ throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" }); } try{ - const { id, deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds } = input; + const { id, deliveryTime, freezeTime, isActive, skuIds, vendorSnippets: snippets, groupIds } = input; if (!deliveryTime || !freezeTime) { throw new ApiError("Delivery time and orders close time are required", 400); @@ -456,7 +456,7 @@ export const slotsRouter = router({ deliveryTime, freezeTime, isActive, - productIds, + skuIds, vendorSnippets: snippets, groupIds, }) diff --git a/apps/backend/src/trpc/apis/common-apis/common.ts b/apps/backend/src/trpc/apis/common-apis/common.ts index 64cac9b..2869025 100644 --- a/apps/backend/src/trpc/apis/common-apis/common.ts +++ b/apps/backend/src/trpc/apis/common-apis/common.ts @@ -3,6 +3,7 @@ import { getSuspendedProductIds, getNextDeliveryDateWithCapacity, getStoresSummary, + getAllSkusSummary as getAllSkusSummaryInDb, } from '@/src/dbService' import { generateSignedUrlsFromS3Urls, generateSignedUrlFromS3Url } from '@/src/lib/s3-client' import { getAllProducts as getAllProductsFromCache } from '@/src/stores/product-store' @@ -81,6 +82,12 @@ export const commonRouter = router({ return response; }), + getAllSkusSummary: publicProcedure + .query(async () => { + const skus = await getAllSkusSummaryInDb() + return { skus } + }), + /* // Old implementation - moved to common-trpc-index.ts: getStoresSummary: publicProcedure diff --git a/packages/db_helper_sqlite/index.ts b/packages/db_helper_sqlite/index.ts index 96826c2..789b16d 100644 --- a/packages/db_helper_sqlite/index.ts +++ b/packages/db_helper_sqlite/index.ts @@ -233,7 +233,9 @@ export { getProductById as getUserProductByIdBasic, createProductReview as createUserProductReview, getAllProductsWithUnits, + getAllSkusSummary, type ProductSummaryData, + type SkuSummary, } from './src/user-apis/product' export { diff --git a/packages/db_helper_sqlite/src/admin-apis/order.ts b/packages/db_helper_sqlite/src/admin-apis/order.ts index 221a67a..7dd8ac7 100644 --- a/packages/db_helper_sqlite/src/admin-apis/order.ts +++ b/packages/db_helper_sqlite/src/admin-apis/order.ts @@ -116,9 +116,10 @@ export async function getOrderDetails(orderId: number): Promise ({ id: item.id, - name: item.product.name, + name: item.sku.product?.name ?? 'Unknown', + skuName: item.sku.name ?? null, quantity: item.quantity, - productSize: item.product.productQuantity, + productSize: 1, price: item.price, - unit: item.product.unit?.shortNotation, + unit: (item.sku.features || []).map((f: any) => f.featureValue).join(' '), amount: parseFloat(item.price.toString()) * parseFloat(item.quantity || '0'), isPackaged: item.is_packaged, isPackageVerified: item.is_package_verified, + features: (item.sku.features || []).map((f: any) => ({ + featureName: f.featureName, + featureValue: f.featureValue, + })), })), payment: orderData.payment ? { @@ -341,9 +347,10 @@ export async function getSlotOrders(slotId: string): Promise ({ id: item.id, - name: item.product.name, + name: item.sku.product?.name ?? 'Unknown', + skuName: item.sku.name ?? null, quantity: parseFloat(item.quantity), price: parseFloat(item.price.toString()), amount: parseFloat(item.quantity) * parseFloat(item.price.toString()), - unit: item.product.unit?.shortNotation || '', + unit: (item.sku.features || []).map((f: any) => f.featureValue).join(' '), isPackaged: item.is_packaged, isPackageVerified: item.is_package_verified, + features: (item.sku.features || []).map((f: any) => ({ + featureName: f.featureName, + featureValue: f.featureValue, + })), })) const paymentMode: 'COD' | 'Online' = order.isCod ? 'COD' : 'Online' @@ -501,9 +513,10 @@ export async function getAllOrders(input: GetAllOrdersInput): Promise ({ id: item.id, - name: item.product.name, + name: item.sku.product?.name ?? 'Unknown', + skuName: item.sku.name ?? null, quantity: parseFloat(item.quantity), price: parseFloat(item.price.toString()), amount: parseFloat(item.quantity) * parseFloat(item.price.toString()), - unit: item.product.unit?.shortNotation || '', - productSize: item.product.productQuantity, + unit: (item.sku.features || []).map((f: any) => f.featureValue).join(' '), + productSize: 1, isPackaged: item.is_packaged, isPackageVerified: item.is_package_verified, + features: (item.sku.features || []).map((f: any) => ({ + featureName: f.featureName, + featureValue: f.featureValue, + })), })) .sort((first: any, second: any) => first.id - second.id) diff --git a/packages/db_helper_sqlite/src/admin-apis/product.ts b/packages/db_helper_sqlite/src/admin-apis/product.ts index ba7da13..c2f5c10 100644 --- a/packages/db_helper_sqlite/src/admin-apis/product.ts +++ b/packages/db_helper_sqlite/src/admin-apis/product.ts @@ -1,6 +1,9 @@ +// @ts-nocheck -- temporary: file partially updated for SKU migration; remaining functions to be fixed later import { db } from '../db/db_index' import { productInfo, + productSkus, + skuFeatures, units, specialDeals, deliverySlotInfo, @@ -22,6 +25,8 @@ import type { AdminProductReview, AdminProductWithDetails, AdminProductWithRelations, + AdminSku, + AdminSkuFeature, AdminSpecialDeal, AdminUnit, AdminUpdateSlotProductsResult, @@ -29,6 +34,8 @@ import type { } from '@packages/shared' type ProductRow = InferSelectModel +type SkuRow = InferSelectModel +type SkuFeatureRow = InferSelectModel type UnitRow = InferSelectModel type StoreRow = InferSelectModel type SpecialDealRow = InferSelectModel @@ -64,19 +71,32 @@ const mapProduct = (product: ProductRow): AdminProduct => ({ name: product.name, shortDescription: product.shortDescription ?? null, longDescription: product.longDescription ?? null, - unitId: product.unitId, - price: String(product.price ?? '0'), - marketPrice: product.marketPrice ? String(product.marketPrice) : null, - images: getStringArray(product.images), - imageKeys: getStringArray(product.images), - isOutOfStock: product.isOutOfStock, - isSuspended: product.isSuspended, - isFlashAvailable: product.isFlashAvailable, - flashPrice: product.flashPrice ? String(product.flashPrice) : null, - createdAt: product.createdAt, - incrementStep: product.incrementStep, - productQuantity: product.productQuantity, storeId: product.storeId, + incrementStep: product.incrementStep, + createdAt: product.createdAt, +}) + +const mapSkuFeature = (feature: SkuFeatureRow): AdminSkuFeature => ({ + id: feature.id, + skuId: feature.skuId, + featureName: feature.featureName, + featureValue: feature.featureValue, +}) + +const mapSku = (sku: SkuRow, features: SkuFeatureRow[] = []): AdminSku => ({ + id: sku.id, + productId: sku.productId, + name: sku.name ?? null, + price: String(sku.price ?? '0'), + marketPrice: sku.marketPrice ? String(sku.marketPrice) : null, + images: getStringArray(sku.images), + imageKeys: getStringArray(sku.images), + isOutOfStock: sku.isOutOfStock, + isSuspended: sku.isSuspended, + isFlashAvailable: sku.isFlashAvailable, + flashPrice: sku.flashPrice ? String(sku.flashPrice) : null, + createdAt: sku.createdAt, + features: features.map(mapSkuFeature), }) const mapSpecialDeal = (deal: SpecialDealRow): AdminSpecialDeal => ({ @@ -98,19 +118,26 @@ const mapTagInfo = (tag: ProductTagInfoRow): AdminProductTagInfo => ({ }) export async function getAllProducts(): Promise { - type ProductWithRelationsRow = ProductRow & { unit: UnitRow; store: StoreRow | null } + type ProductWithRelationsRow = ProductRow & { + store: StoreRow | null + skus: Array + } const products = await db.query.productInfo.findMany({ orderBy: productInfo.name, with: { - unit: true, store: true, + skus: { + with: { + features: true, + }, + }, }, }) as ProductWithRelationsRow[] return products.map((product) => ({ ...mapProduct(product), - unit: mapUnit(product.unit), store: product.store ? mapStore(product.store) : null, + skus: product.skus.map((sku) => mapSku(sku, sku.features)), })) } @@ -118,7 +145,12 @@ export async function getProductById(id: number): Promise sku.id) + + const deals = skuIds.length > 0 + ? await db.query.specialDeals.findMany({ + where: inArray(specialDeals.skuId, skuIds), + orderBy: specialDeals.quantity, + }) + : [] const productTagsData = await db.query.productTags.findMany({ where: eq(productTags.productId, id), @@ -140,7 +176,8 @@ export async function getProductById(id: number): Promise mapSku(sku, sku.features)), deals: deals.map(mapSpecialDeal), tags: productTagsData.map((tag) => mapTagInfo(tag.tag)), } @@ -162,46 +199,150 @@ export async function deleteProduct(id: number): Promise { type ProductInfoInsert = InferInsertModel type ProductInfoUpdate = Partial -export async function createProduct(input: ProductInfoInsert): Promise { - const productQuantityRaw = (input as any).productQuantity - const productQuantity = typeof productQuantityRaw === 'string' - ? Number(productQuantityRaw) - : productQuantityRaw +export async function createProduct(input: CreateProductInput): Promise { + if (!input.skus || input.skus.length === 0) { + throw new Error('At least one SKU is required') + } - const safeProductQuantity = typeof productQuantity === 'number' && Number.isFinite(productQuantity) - ? productQuantity - : 1 + const featuresHaveQuantity = input.skus.every((sku) => + sku.features.some((f) => f.featureName === 'quantity') + ) + if (!featuresHaveQuantity) { + throw new Error('Every SKU must have a quantity feature') + } + + const { skus, ...productData } = input const [product] = await db.insert(productInfo).values({ - ...input, - productQuantity: safeProductQuantity, + name: productData.name, + shortDescription: productData.shortDescription ?? null, + longDescription: productData.longDescription ?? null, + storeId: productData.storeId ?? null, + incrementStep: productData.incrementStep ?? 1, }).returning() - return mapProduct(product) + + const skuRows = await db.insert(productSkus).values( + skus.map((sku) => ({ + productId: product.id, + name: sku.name ?? null, + price: String(sku.price), + marketPrice: sku.marketPrice != null ? String(sku.marketPrice) : null, + images: sku.images ?? null, + isFlashAvailable: sku.isFlashAvailable ?? false, + flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null, + })) + ).returning() + + for (let i = 0; i < skuRows.length; i++) { + const skuRow = skuRows[i] + const sku = skus[i] + await db.insert(skuFeatures).values( + sku.features.map((f) => ({ + skuId: skuRow.id, + featureName: f.featureName, + featureValue: f.featureValue, + })) + ) + } + + const createdSkus = await db.query.productSkus.findMany({ + where: eq(productSkus.productId, product.id), + with: { features: true }, + }) + + return { + ...mapProduct(product), + store: null, + skus: createdSkus.map((s) => mapSku(s, s.features)), + } } -export async function updateProduct(id: number, updates: ProductInfoUpdate): Promise { - const productQuantityRaw = (updates as any).productQuantity - const productQuantity = typeof productQuantityRaw === 'string' - ? Number(productQuantityRaw) - : productQuantityRaw - const safeUpdates = typeof productQuantityRaw === 'undefined' - ? updates - : { - ...updates, - productQuantity: typeof productQuantity === 'number' && Number.isFinite(productQuantity) - ? productQuantity - : 1, - } +export async function updateProduct(id: number, input: any): Promise { + const product = await db.query.productInfo.findFirst({ + where: eq(productInfo.id, id), + }) - const [product] = await db.update(productInfo) - .set(safeUpdates) - .where(eq(productInfo.id, id)) - .returning() if (!product) { return null } - return mapProduct(product) + const { skus, ...productData } = input + + await db.update(productInfo) + .set({ + name: productData.name, + shortDescription: productData.shortDescription ?? null, + longDescription: productData.longDescription ?? null, + storeId: productData.storeId ?? null, + incrementStep: productData.incrementStep ?? 1, + }) + .where(eq(productInfo.id, id)) + + if (skus !== undefined) { + if (skus.length === 0) { + throw new Error('At least one SKU is required') + } + + const featuresHaveQuantity = skus.every((sku: any) => + sku.features.some((f: any) => f.featureName === 'quantity') + ) + if (!featuresHaveQuantity) { + throw new Error('Every SKU must have a quantity feature') + } + + const existingSkuIds = await db.query.productSkus.findMany({ + where: eq(productSkus.productId, id), + columns: { id: true }, + }).then((skus) => skus.map((sku) => sku.id)) + + if (existingSkuIds.length > 0) { + await db.delete(skuFeatures).where(inArray(skuFeatures.skuId, existingSkuIds)) + await db.delete(productSkus).where(inArray(productSkus.id, existingSkuIds)) + } + + const skuRows = await db.insert(productSkus).values( + skus.map((sku: any) => ({ + productId: id, + name: sku.name ?? null, + price: String(sku.price), + marketPrice: sku.marketPrice != null ? String(sku.marketPrice) : null, + images: sku.images ?? null, + isFlashAvailable: sku.isFlashAvailable ?? false, + flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null, + })) + ).returning() + + for (let i = 0; i < skuRows.length; i++) { + const sku = skus[i] + await db.insert(skuFeatures).values( + sku.features.map((f: any) => ({ + skuId: skuRows[i].id, + featureName: f.featureName, + featureValue: f.featureValue, + })) + ) + } + } + + const updatedProduct = await db.query.productInfo.findFirst({ + where: eq(productInfo.id, id), + with: { + store: true, + skus: { + with: { features: true }, + }, + }, + }) + + if (!updatedProduct) { + return null + } + + return { + ...mapProduct(updatedProduct), + store: updatedProduct.store ? mapStore(updatedProduct.store) : null, + skus: updatedProduct.skus.map((s) => mapSku(s, s.features)), + } } export async function toggleProductOutOfStock(id: number): Promise { diff --git a/packages/db_helper_sqlite/src/admin-apis/slots.ts b/packages/db_helper_sqlite/src/admin-apis/slots.ts index 68afef0..fe2cc78 100644 --- a/packages/db_helper_sqlite/src/admin-apis/slots.ts +++ b/packages/db_helper_sqlite/src/admin-apis/slots.ts @@ -2,6 +2,8 @@ import { db } from '../db/db_index' import { deliverySlotInfo, productInfo, + productSkus, + skuFeatures, vendorSnippets, productGroupInfo, } from '../db/schema' @@ -20,7 +22,7 @@ import { coerceDate } from '../lib/date' type SlotSnippetInput = { name: string - productIds: number[] + skuIds: number[] validTill?: string } @@ -34,7 +36,7 @@ const getNumberArray = (value: unknown): number[] => { return value.map((item) => Number(item)) } -const normalizeProductIds = (value: unknown): number[] => { +const normalizeSkuIds = (value: unknown): number[] => { if (!Array.isArray(value)) return [] const ids = value .map((item) => Number(item)) @@ -52,18 +54,18 @@ const chunkArray = (items: T[], size: number): T[][] => { return chunks } -const PRODUCT_ID_CHUNK_SIZE = 40 +const SKU_ID_CHUNK_SIZE = 40 -const fetchExistingProductIds = async (tx: any, productIds: number[]) => { +const fetchExistingSkuIds = async (tx: any, skuIds: number[]) => { const existingIds = new Set() - const chunks = chunkArray(productIds, PRODUCT_ID_CHUNK_SIZE) + const chunks = chunkArray(skuIds, SKU_ID_CHUNK_SIZE) for (const chunk of chunks) { if (chunk.length === 0) continue - const products = await tx.query.productInfo.findMany({ - where: inArray(productInfo.id, chunk), + const skus = await tx.query.productSkus.findMany({ + where: inArray(productSkus.id, chunk), columns: { id: true }, }) - products.forEach((product: { id: number }) => existingIds.add(product.id)) + skus.forEach((sku: { id: number }) => existingIds.add(sku.id)) } return existingIds } @@ -79,17 +81,19 @@ const mapDeliverySlot = (slot: typeof deliverySlotInfo.$inferSelect): AdminDeliv groupIds: slot.groupIds, }) -const mapSlotProductSummary = (product: { id: number; name: string; images: unknown }): AdminSlotProductSummary => ({ - id: product.id, - name: product.name, - images: getStringArray(product.images), +const mapSlotSkuSummary = (sku: { id: number; images: unknown; name: string | null; product: { name: string } | null; features: Array<{ featureName: string; featureValue: string }> }): AdminSlotProductSummary => ({ + id: sku.id, + name: sku.product?.name ?? 'Unknown', + images: getStringArray(sku.images), + skuName: sku.name ?? null, + features: (sku.features || []).map((f) => ({ featureName: f.featureName, featureValue: f.featureValue })), }) const mapVendorSnippet = (snippet: typeof vendorSnippets.$inferSelect): AdminVendorSnippet => ({ id: snippet.id, snippetCode: snippet.snippetCode, slotId: snippet.slotId ?? null, - productIds: snippet.productIds || [], + skuIds: snippet.skuIds || [], isPermanent: snippet.isPermanent, validTill: coerceDate(snippet.validTill), createdAt: coerceDate(snippet.createdAt) ?? new Date(0), @@ -103,37 +107,33 @@ export async function getActiveSlotsWithProducts(limit: number = 20): Promise() + // Get all unique SKU IDs from all slots + const allSkuIds = new Set() for (const slot of slots) { - for (const productId of (slot.productIds || [])) { - allProductIds.add(productId) + for (const skuId of (slot.skuIds || [])) { + allSkuIds.add(skuId) } } - // Fetch all products in one query - const productIdsArray = Array.from(allProductIds) - const productIdsSet = new Set(productIdsArray) - // const productsData = productIdsArray.length > 0 - // ? await db.query.productInfo.findMany({ - // where: inArray(productInfo.id, productIdsArray), - // columns: { id: true, name: true, images: true }, - // }) - // : [] + // Fetch all SKUs in one query + const skuIdsArray = Array.from(allSkuIds) + const skuIdsSet = new Set(skuIdsArray) - let productsData = await db.query.productInfo.findMany({}); - productsData = productsData.filter(item => productIdsSet.has(item.id)) + let skusData = await db.query.productSkus.findMany({ + with: { features: true, product: true }, + }) + skusData = skusData.filter((item: any) => skuIdsSet.has(item.id)) // Create a map for quick lookup - const productMap = new Map(productsData.map(p => [p.id, p])) + const skuMap = new Map(skusData.map((s: any) => [s.id, s])) return slots.map((slot) => ({ ...mapDeliverySlot(slot), deliverySequence: getNumberArray(slot.deliverySequence), - products: (slot.productIds || []) - .map(productId => productMap.get(productId)) + products: (slot.skuIds || []) + .map((skuId: number) => skuMap.get(skuId)) .filter((p): p is NonNullable => p != null) - .map(product => mapSlotProductSummary(product)), + .map((sku: any) => mapSlotSkuSummary(sku)), })) } @@ -155,7 +155,7 @@ export async function staleSlotsCleanup(): Promise { // Clear productIds for all slots older than threshold const result = await db .update(deliverySlotInfo) - .set({ productIds: [] }) + .set({ skuIds: [] }) .where(eq(deliverySlotInfo.id, threshold)) return 1 @@ -193,26 +193,21 @@ export async function getSlotByIdWithRelations(id: number): Promise 0 - // ? await db.query.productInfo.findMany({ - // where: inArray(productInfo.id, productIds), - // columns: { id: true, name: true, images: true }, - // }) - // : [] - let productsData = productIds.length > 0 - ? await db.query.productInfo.findMany({ - columns: { id: true, name: true, images: true }, + // Fetch SKUs for this slot + const skuIds = slot.skuIds || [] + const skuIdSet = new Set(skuIds) + let skusData = skuIds.length > 0 + ? await db.query.productSkus.findMany({ + with: { features: true, product: true }, + columns: { id: true, images: true, name: true }, }) : [] - productsData = productsData.filter(item => productIdSet.has(item.id)) + skusData = skusData.filter((item: any) => skuIdSet.has(item.id)) return { ...mapDeliverySlot(slot), deliverySequence: getNumberArray(slot.deliverySequence), groupIds: getNumberArray(slot.groupIds), - products: productsData.map(product => mapSlotProductSummary(product)), + products: skusData.map((sku: any) => mapSlotSkuSummary(sku)), vendorSnippets: slot.vendorSnippets.map(mapVendorSnippet), } } @@ -221,21 +216,21 @@ export async function createSlotWithRelations(input: { deliveryTime: string freezeTime: string isActive?: boolean - productIds?: number[] + skuIds?: number[] vendorSnippets?: SlotSnippetInput[] groupIds?: number[] }): Promise { - const { deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds } = input + const { deliveryTime, freezeTime, isActive, skuIds, vendorSnippets: snippets, groupIds } = input - const normalizedProductIds = normalizeProductIds(productIds) + const normalizedSkuIds = normalizeSkuIds(skuIds) const result = await db.transaction(async (tx) => { - // Validate product IDs if provided - if (normalizedProductIds.length > 0) { - const existingIds = await fetchExistingProductIds(tx, normalizedProductIds) - const missingIds = normalizedProductIds.filter((productId) => !existingIds.has(productId)) + // Validate SKU IDs if provided + if (normalizedSkuIds.length > 0) { + const existingIds = await fetchExistingSkuIds(tx, normalizedSkuIds) + const missingIds = normalizedSkuIds.filter((skuId) => !existingIds.has(skuId)) if (missingIds.length > 0) { - throw new Error(`Invalid product IDs: ${missingIds.join(', ')}`) + throw new Error(`Invalid SKU IDs: ${missingIds.join(', ')}`) } } @@ -246,18 +241,18 @@ export async function createSlotWithRelations(input: { freezeTime: new Date(freezeTime), isActive: isActive !== undefined ? isActive : true, groupIds: groupIds !== undefined ? groupIds : [], - productIds: normalizedProductIds, + skuIds: normalizedSkuIds, }) .returning() let createdSnippets: AdminVendorSnippet[] = [] if (snippets && snippets.length > 0) { for (const snippet of snippets) { - const products = await tx.query.productInfo.findMany({ - where: inArray(productInfo.id, snippet.productIds), + const skus = await tx.query.productSkus.findMany({ + where: inArray(productSkus.id, snippet.skuIds), }) - if (products.length !== snippet.productIds.length) { - throw new Error(`One or more invalid product IDs in snippet "${snippet.name}"`) + if (skus.length !== snippet.skuIds.length) { + throw new Error(`One or more invalid SKU IDs in snippet "${snippet.name}"`) } const existingSnippet = await tx.query.vendorSnippets.findFirst({ @@ -270,7 +265,7 @@ export async function createSlotWithRelations(input: { const [createdSnippet] = await tx.insert(vendorSnippets).values({ snippetCode: snippet.name, slotId: newSlot.id, - productIds: snippet.productIds, + skuIds: snippet.skuIds, validTill: snippet.validTill ? new Date(snippet.validTill) : undefined, }).returning() @@ -293,11 +288,11 @@ export async function updateSlotWithRelations(input: { deliveryTime: string freezeTime: string isActive?: boolean - productIds?: number[] + skuIds?: number[] vendorSnippets?: SlotSnippetInput[] groupIds?: number[] }): Promise { - const { id, deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds } = input + const { id, deliveryTime, freezeTime, isActive, skuIds, vendorSnippets: snippets, groupIds } = input let validGroupIds = groupIds if (groupIds && groupIds.length > 0) { @@ -308,15 +303,15 @@ export async function updateSlotWithRelations(input: { validGroupIds = existingGroups.map((group: { id: number }) => group.id) } - const normalizedProductIds = productIds !== undefined ? normalizeProductIds(productIds) : undefined + const normalizedSkuIds = skuIds !== undefined ? normalizeSkuIds(skuIds) : undefined const result = await db.transaction(async (tx) => { - // Validate product IDs if provided - if (normalizedProductIds !== undefined && normalizedProductIds.length > 0) { - const existingIds = await fetchExistingProductIds(tx, normalizedProductIds) - const missingIds = normalizedProductIds.filter((productId) => !existingIds.has(productId)) + // Validate SKU IDs if provided + if (normalizedSkuIds !== undefined && normalizedSkuIds.length > 0) { + const existingIds = await fetchExistingSkuIds(tx, normalizedSkuIds) + const missingIds = normalizedSkuIds.filter((skuId) => !existingIds.has(skuId)) if (missingIds.length > 0) { - throw new Error(`Invalid product IDs: ${missingIds.join(', ')}`) + throw new Error(`Invalid SKU IDs: ${missingIds.join(', ')}`) } } @@ -327,7 +322,7 @@ export async function updateSlotWithRelations(input: { freezeTime: new Date(freezeTime), isActive: isActive !== undefined ? isActive : true, groupIds: validGroupIds !== undefined ? validGroupIds : [], - ...(normalizedProductIds !== undefined && { productIds: normalizedProductIds }), + ...(normalizedSkuIds !== undefined && { skuIds: normalizedSkuIds }), }) .where(eq(deliverySlotInfo.id, id)) .returning() @@ -339,11 +334,11 @@ export async function updateSlotWithRelations(input: { let createdSnippets: AdminVendorSnippet[] = [] if (snippets && snippets.length > 0) { for (const snippet of snippets) { - const products = await tx.query.productInfo.findMany({ - where: inArray(productInfo.id, snippet.productIds), + const skus = await tx.query.productSkus.findMany({ + where: inArray(productSkus.id, snippet.skuIds), }) - if (products.length !== snippet.productIds.length) { - throw new Error(`One or more invalid product IDs in snippet "${snippet.name}"`) + if (skus.length !== snippet.skuIds.length) { + throw new Error(`One or more invalid SKU IDs in snippet "${snippet.name}"`) } const existingSnippet = await tx.query.vendorSnippets.findFirst({ @@ -356,7 +351,7 @@ export async function updateSlotWithRelations(input: { const [createdSnippet] = await tx.insert(vendorSnippets).values({ snippetCode: snippet.name, slotId: id, - productIds: snippet.productIds, + skuIds: snippet.skuIds, validTill: snippet.validTill ? new Date(snippet.validTill) : undefined, }).returning() diff --git a/packages/db_helper_sqlite/src/user-apis/product.ts b/packages/db_helper_sqlite/src/user-apis/product.ts index 308a44f..3373d19 100644 --- a/packages/db_helper_sqlite/src/user-apis/product.ts +++ b/packages/db_helper_sqlite/src/user-apis/product.ts @@ -254,3 +254,34 @@ export async function getNextDeliveryDateWithCapacity(productId: number): Promis return null } + +export interface SkuSummary { + id: number + productId: number + productName: string + label: string + images: unknown +} + +export async function getAllSkusSummary(): Promise { + const skus = await db.query.productSkus.findMany({ + with: { + features: true, + product: { + columns: { name: true }, + }, + }, + }) + + return skus.map((sku) => { + const featureValues = (sku.features || []).map((f) => f.featureValue) + const label = [sku.product?.name ?? 'Unknown', ...featureValues].join(' ') + return { + id: sku.id, + productId: sku.productId, + productName: sku.product?.name ?? 'Unknown', + label, + images: sku.images, + } + }) +} diff --git a/packages/shared/types/admin.ts b/packages/shared/types/admin.ts index 909019b..809df52 100644 --- a/packages/shared/types/admin.ts +++ b/packages/shared/types/admin.ts @@ -5,7 +5,7 @@ export interface Banner { name: string; imageUrl: string; description: string | null; - productIds: number[] | null; + skuIds: number[] | null; redirectUrl: string | null; serialNum: number | null; isActive: boolean; @@ -47,7 +47,7 @@ export interface Coupon { discountPercent: string | null; flatDiscount: string | null; minOrder: string | null; - productIds: number[] | null; + skuIds: number[] | null; maxValue: string | null; isApplyForAll: boolean; validTill: Date | null; @@ -170,6 +170,8 @@ export interface AdminOrderDetailsItem { amount: number; isPackaged: boolean; isPackageVerified: boolean; + skuName?: string | null; + features?: { featureName: string; featureValue: string }[]; } export interface AdminOrderDetailsPayment { @@ -248,6 +250,8 @@ export interface AdminSlotOrderItem { unit: string; isPackaged: boolean; isPackageVerified: boolean; + skuName?: string | null; + features?: { featureName: string; featureValue: string }[]; } export interface AdminSlotOrder { @@ -287,6 +291,8 @@ export interface AdminOrderListItemProduct { productSize: number; isPackaged: boolean; isPackageVerified: boolean; + skuName?: string | null; + features?: { featureName: string; featureValue: string }[]; } export interface AdminOrderListItem { @@ -354,32 +360,89 @@ export interface AdminUnit { fullName: string; } +export interface AdminSkuVariant { + id: number + skuId: number + name: string + value: string + unitId: number | null + sortOrder: number +} + +export interface AdminSku { + id: number + productId: number + productName?: string + skuCode: string | null + displayName: string + unitId: number + unit?: AdminUnit + productQuantity: number + incrementStep: number + price: string + marketPrice: string | null + images: string[] | null + imageKeys: string[] | null + isOutOfStock: boolean + isSuspended: boolean + isFlashAvailable: boolean + flashPrice: string | null + isComboOnly: boolean + sortOrder: number + isDefault: boolean + createdAt: Date + variants: AdminSkuVariant[] +} + export interface AdminProduct { id: number; name: string; shortDescription: string | null; longDescription: string | null; - unitId: number; - price: string; - marketPrice: string | null; - images: string[] | null; - imageKeys: string[] | null; - isOutOfStock: boolean; - isSuspended: boolean; - isFlashAvailable: boolean; - flashPrice: string | null; - createdAt: Date; - incrementStep: number; - productQuantity: number; storeId: number | null; + incrementStep: number; + createdAt: Date; } export interface AdminProductWithRelations extends AdminProduct { - unit: AdminUnit; store: Store | null; + skus: AdminSku[]; } -export interface AdminProductTagInfo { +export interface CreateSkuVariantInput { + name: string + value: string + unitId?: number | null + sortOrder?: number +} + +export interface CreateSkuInput { + skuCode?: string | null + displayName: string + unitId: number + productQuantity?: number + incrementStep?: number + price: number | string + marketPrice?: number | string | null + images?: string[] | null + isOutOfStock?: boolean + isSuspended?: boolean + isFlashAvailable?: boolean + flashPrice?: number | string | null + isComboOnly?: boolean + sortOrder?: number + isDefault?: boolean + variants?: CreateSkuVariantInput[] +} + +export interface CreateProductInput { + name: string + shortDescription?: string | null + longDescription?: string | null + storeId: number + incrementStep?: number + skus: CreateSkuInput[] +} id: number; tagName: string; tagDescription: string | null; @@ -515,13 +578,15 @@ export interface AdminSlotProductSummary { id: number; name: string; images: string[] | null; + skuName?: string | null; + features?: { featureName: string; featureValue: string }[]; } export interface AdminVendorSnippet { id: number; snippetCode: string; slotId: number | null; - productIds: number[]; + skuIds: number[]; isPermanent: boolean; validTill: Date | null; createdAt: Date; @@ -613,7 +678,7 @@ export interface AdminUpdateSlotCapacityResult { export interface AdminVendorSnippetCreateInput { snippetCode: string; slotId?: number; - productIds: number[]; + skuIds: number[]; validTill?: string; isPermanent: boolean; } @@ -621,7 +686,7 @@ export interface AdminVendorSnippetCreateInput { export interface AdminVendorSnippetUpdateInput { snippetCode?: string; slotId?: number; - productIds?: number[]; + skuIds?: number[]; validTill?: string | null; isPermanent?: boolean; } @@ -664,7 +729,7 @@ export interface AdminVendorSnippetOrdersResult { id: number; snippetCode: string; slotId: number | null; - productIds: number[]; + skuIds: number[]; validTill?: string; createdAt: string; isPermanent: boolean; From 5c2b3aaa67d986cd88f51e780992637d9269b62f Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:19:07 +0530 Subject: [PATCH 03/73] mostly functional apps --- apps/backend/src/lib/post-order-handler.ts | 6 +- apps/backend/src/sqliteImporter.ts | 2 +- apps/backend/src/stores/product-store.ts | 95 ++------ .../src/trpc/apis/common-apis/common.ts | 6 +- .../src/trpc/apis/user-apis/apis/order.ts | 32 +-- .../flash-delivery/(products)/index.tsx | 2 +- apps/user-ui/components/BannerCarousel.tsx | 8 +- .../components/PaymentAndOrderComponent.tsx | 6 +- apps/user-ui/components/ProductCard.tsx | 2 +- apps/user-ui/components/ProductDetail.tsx | 4 +- apps/user-ui/components/SlotSpecificView.tsx | 2 +- apps/user-ui/components/cart-page.tsx | 36 +-- apps/user-ui/components/checkout-page.tsx | 6 +- apps/user-ui/components/floating-cart-bar.tsx | 30 +-- apps/user-ui/hooks/cart-query-hooks.tsx | 34 +-- .../src/components/AddToCartDialog.tsx | 2 +- .../drizzle/0002_sku_split.sql | 13 +- packages/db_helper_sqlite/index.ts | 4 +- .../src/admin-apis/product.ts | 12 +- .../src/stores/store-helpers.ts | 209 +++++++++--------- .../db_helper_sqlite/src/user-apis/order.ts | 48 ++-- .../db_helper_sqlite/src/user-apis/product.ts | 96 ++++---- .../db_helper_sqlite/src/user-apis/slots.ts | 27 +-- .../db_helper_sqlite/src/user-apis/stores.ts | 137 ++++++------ packages/shared/types/user.ts | 2 +- 25 files changed, 395 insertions(+), 426 deletions(-) diff --git a/apps/backend/src/lib/post-order-handler.ts b/apps/backend/src/lib/post-order-handler.ts index 29dd536..13abcd0 100644 --- a/apps/backend/src/lib/post-order-handler.ts +++ b/apps/backend/src/lib/post-order-handler.ts @@ -48,7 +48,9 @@ const formatOrderMessageWithFullData = (ordersData: any[]): string => { message += '📦 Items:\n'; order.orderItems?.forEach((item: any) => { - message += ` • ${item.product?.name || 'Unknown'} • ${item.product.productQuantity}${item.product.unit?.shortNotation}x${item.quantity}\n`; + const sku = item.sku + const features = (sku?.features || []).map((f: any) => f.featureValue).join(' ') + message += ` • ${sku?.product?.name || 'Unknown'} ${features} x${item.quantity}\n`; }); message += `\n💰 Total: ₹${order.totalAmount}\n`; @@ -87,7 +89,7 @@ const formatCancellationMessage = (orderData: any, cancellationData: Cancellatio 📞 Phone: ${orderData.address?.phone || 'N/A'} 📦 Items: -${orderData.orderItems?.map((item: any) => ` • ${item.product?.name || 'Unknown'} x${item.quantity}`).join('\n') || ' N/A'} +${orderData.orderItems?.map((item: any) => ` • ${item.sku?.product?.name || 'Unknown'} x${item.quantity}`).join('\n') || ' N/A'} 💰 Total: ₹${orderData.totalAmount} 💳 Refund: ${orderData.refundStatus === 'na' ? 'N/A (COD)' : orderData.refundStatus || 'Pending'} diff --git a/apps/backend/src/sqliteImporter.ts b/apps/backend/src/sqliteImporter.ts index c245555..d685475 100644 --- a/apps/backend/src/sqliteImporter.ts +++ b/apps/backend/src/sqliteImporter.ts @@ -282,7 +282,7 @@ export { type OrderWithFullData, type OrderWithCancellationData, // Common API helpers - getSuspendedProductIds, + getSuspendedSkuIds, getNextDeliveryDateWithCapacity, getStoresSummary, healthCheck, diff --git a/apps/backend/src/stores/product-store.ts b/apps/backend/src/stores/product-store.ts index 4c981b5..2b500bf 100644 --- a/apps/backend/src/stores/product-store.ts +++ b/apps/backend/src/stores/product-store.ts @@ -76,18 +76,18 @@ export async function initializeProducts(): Promise { const allDeliverySlots = await getAllDeliverySlotsForCache() const deliverySlotsMap = new Map() for (const slot of allDeliverySlots) { - if (!deliverySlotsMap.has(slot.productId)) - deliverySlotsMap.set(slot.productId, []) - deliverySlotsMap.get(slot.productId)!.push(slot) + if (!deliverySlotsMap.has(slot.skuId)) + deliverySlotsMap.set(slot.skuId, []) + deliverySlotsMap.get(slot.skuId)!.push(slot) } // Fetch all special deals const allSpecialDeals = await getAllSpecialDealsForCache() const specialDealsMap = new Map() for (const deal of allSpecialDeals) { - if (!specialDealsMap.has(deal.productId)) - specialDealsMap.set(deal.productId, []) - specialDealsMap.get(deal.productId)!.push(deal) + if (!specialDealsMap.has(deal.skuId)) + specialDealsMap.set(deal.skuId, []) + specialDealsMap.get(deal.skuId)!.push(deal) } // Fetch all product tags @@ -153,70 +153,11 @@ export async function initializeProducts(): Promise { export async function getProductById(id: number): Promise { try { - // const key = `product:${id}` - // const data = await redisClient.get(key) - // if (!data) return null - // return JSON.parse(data) as Product - - const product = await getProductByIdFromDb(id) - if (!product) return null - - const signedImages = scaffoldAssetUrl( - (product.images as string[]) || [] - ) - - // Fetch store info - const allStores = await getAllStoresForCache() - const store = product.storeId - ? allStores.find(s => s.id === product.storeId) || null - : null - - // Fetch delivery slots for this product - const allDeliverySlots = await getAllDeliverySlotsForCache() - const productSlots = allDeliverySlots.filter(s => s.productId === id) - - // Fetch special deals for this product - const allSpecialDeals = await getAllSpecialDealsForCache() - const productDeals = allSpecialDeals.filter(d => d.productId === id) - - // Fetch product tags for this product - const allProductTags = await getAllProductTagsForCache() - const productTagNames = allProductTags - .filter(t => t.productId === id) - .map(t => t.tagName) - - return { - id: product.id, - name: product.name, - shortDescription: product.shortDescription, - longDescription: product.longDescription, - price: product.price.toString(), - marketPrice: product.marketPrice?.toString() || null, - unitNotation: product.unit.shortNotation, - images: signedImages, - isOutOfStock: product.isOutOfStock, - store: store - ? { id: store.id, name: store.name, description: store.description } - : null, - incrementStep: product.incrementStep, - productQuantity: product.productQuantity, - isFlashAvailable: product.isFlashAvailable, - flashPrice: product.flashPrice?.toString() || null, - deliverySlots: productSlots.map((s) => ({ - id: s.id, - deliveryTime: s.deliveryTime, - freezeTime: s.freezeTime, - isCapacityFull: s.isCapacityFull, - })), - specialDeals: productDeals.map((d) => ({ - quantity: d.quantity.toString(), - price: d.price.toString(), - validTill: d.validTill, - })), - productTags: productTagNames, - } + const allProducts = await getAllProducts() + const product = allProducts.find(p => p.id === id) + return product || null } catch (error) { - console.error(`Error getting product ${id}:`, error) + console.error('Error getting product by ID:', error) return null } } @@ -250,17 +191,17 @@ export async function getAllProducts(): Promise { const allDeliverySlots = await getAllDeliverySlotsForCache() const deliverySlotsMap = new Map() for (const slot of allDeliverySlots) { - if (!deliverySlotsMap.has(slot.productId)) - deliverySlotsMap.set(slot.productId, []) - deliverySlotsMap.get(slot.productId)!.push(slot) + if (!deliverySlotsMap.has(slot.skuId)) + deliverySlotsMap.set(slot.skuId, []) + deliverySlotsMap.get(slot.skuId)!.push(slot) } const allSpecialDeals = await getAllSpecialDealsForCache() const specialDealsMap = new Map() for (const deal of allSpecialDeals) { - if (!specialDealsMap.has(deal.productId)) - specialDealsMap.set(deal.productId, []) - specialDealsMap.get(deal.productId)!.push(deal) + if (!specialDealsMap.has(deal.skuId)) + specialDealsMap.set(deal.skuId, []) + specialDealsMap.get(deal.skuId)!.push(deal) } const allProductTags = await getAllProductTagsForCache() @@ -281,7 +222,7 @@ export async function getAllProducts(): Promise { : null const deliverySlots = deliverySlotsMap.get(product.id) || [] const specialDeals = specialDealsMap.get(product.id) || [] - const productTags = productTagsMap.get(product.id) || [] + const productTags = productTagsMap.get(product.productId) || [] products.push({ id: product.id, @@ -290,7 +231,7 @@ export async function getAllProducts(): Promise { longDescription: product.longDescription, price: product.price.toString(), marketPrice: product.marketPrice?.toString() || null, - unitNotation: product.unitShortNotation, + unitNotation: product.unitNotation, images: signedImages, isOutOfStock: product.isOutOfStock, store: store diff --git a/apps/backend/src/trpc/apis/common-apis/common.ts b/apps/backend/src/trpc/apis/common-apis/common.ts index 2869025..b9522fc 100644 --- a/apps/backend/src/trpc/apis/common-apis/common.ts +++ b/apps/backend/src/trpc/apis/common-apis/common.ts @@ -1,6 +1,6 @@ import { router, publicProcedure } from '@/src/trpc/trpc-index' import { - getSuspendedProductIds, + getSuspendedSkuIds, getNextDeliveryDateWithCapacity, getStoresSummary, getAllSkusSummary as getAllSkusSummaryInDb, @@ -30,10 +30,10 @@ export async function scaffoldProducts() { .where(eq(productInfo.isSuspended, true)); */ - const suspendedProductIds = new Set(await getSuspendedProductIds()); + const suspendedSkuIds = new Set(await getSuspendedSkuIds()); // Filter out suspended products - products = products.filter(product => !suspendedProductIds.has(product.id)); + products = products.filter(product => !suspendedSkuIds.has(product.id)); // Format products to match the expected response structure const formattedProducts = await Promise.all( diff --git a/apps/backend/src/trpc/apis/user-apis/apis/order.ts b/apps/backend/src/trpc/apis/user-apis/apis/order.ts index 1df4cd4..32548fc 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/order.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/order.ts @@ -46,7 +46,7 @@ import type { const placeOrderUtil = async (params: { userId: number; selectedItems: Array<{ - productId: number; + skuId: number; quantity: number; slotId: number | null; }>; @@ -86,7 +86,7 @@ const placeOrderUtil = async (params: { const ordersBySlot = new Map< number | null, Array<{ - productId: number; + skuId: number; quantity: number; slotId: number | null; product: Awaited>; @@ -94,9 +94,9 @@ const placeOrderUtil = async (params: { >(); for (const item of selectedItems) { - const product = await getOrderProductById(item.productId); + const product = await getOrderProductById(item.skuId); if (!product) { - throw new ApiError(`Product ${item.productId} not found`, 400); + throw new ApiError(`Product ${item.skuId} not found`, 400); } if (!ordersBySlot.has(item.slotId)) { @@ -107,9 +107,9 @@ const placeOrderUtil = async (params: { if (params.isFlash) { for (const item of selectedItems) { - const product = await getOrderProductById(item.productId); + const product = await getOrderProductById(item.skuId); if (!product?.isFlashAvailable) { - throw new ApiError(`Product ${item.productId} is not available for flash delivery`, 400); + throw new ApiError(`Product ${item.skuId} is not available for flash delivery`, 400); } } } @@ -198,7 +198,7 @@ const placeOrderUtil = async (params: { return { orderId: 0, - productId: item.productId, + skuId: item.skuId, quantity: item.quantity.toString(), price: priceString, discountedPrice: priceString, @@ -225,7 +225,7 @@ const placeOrderUtil = async (params: { await deleteUserCartItemsForOrder( userId, - selectedItems.map((item) => item.productId) + selectedItems.map((item) => item.skuId) ); if (appliedCoupon && createdOrders.length > 0) { @@ -252,7 +252,7 @@ export const orderRouter = router({ z.object({ selectedItems: z.array( z.object({ - productId: z.number().int().positive(), + skuId: z.number().int().positive(), quantity: z.number().int().positive(), slotId: z.union([z.number().int(), z.null()]), }) @@ -373,13 +373,14 @@ export const orderRouter = router({ const items = await Promise.all( order.orderItems.map(async (item) => { - const signedImages = item.product.images + const signedImages = item.sku?.images ? scaffoldAssetUrl( - item.product.images as string[] + item.sku.images as string[] ) : []; return { - productName: item.product.name, + productName: item.sku?.product?.name || 'Unknown', + skuName: item.sku?.name || null, quantity: parseFloat(item.quantity), price: parseFloat(item.price.toString()), discountedPrice: parseFloat( @@ -512,13 +513,14 @@ export const orderRouter = router({ const items = await Promise.all( order.orderItems.map(async (item) => { - const signedImages = item.product.images + const signedImages = item.sku?.images ? scaffoldAssetUrl( - item.product.images as string[] + item.sku.images as string[] ) : []; return { - productName: item.product.name, + productName: item.sku?.product?.name || 'Unknown', + skuName: item.sku?.name || null, quantity: parseFloat(item.quantity), price: parseFloat(item.price.toString()), discountedPrice: parseFloat( diff --git a/apps/user-ui/app/(drawer)/(tabs)/flash-delivery/(products)/index.tsx b/apps/user-ui/app/(drawer)/(tabs)/flash-delivery/(products)/index.tsx index 8143020..8bd2651 100644 --- a/apps/user-ui/app/(drawer)/(tabs)/flash-delivery/(products)/index.tsx +++ b/apps/user-ui/app/(drawer)/(tabs)/flash-delivery/(products)/index.tsx @@ -26,7 +26,7 @@ const FlashAddToCartDialog = () => { React.useEffect(() => { if (isOpen && product) { addToCart.mutate( - { productId: product.id, quantity: 1, slotId: 0 }, + { skuId: product.id, quantity: 1, slotId: 0 }, { onSuccess: () => { clearAddedFlashProduct(); diff --git a/apps/user-ui/components/BannerCarousel.tsx b/apps/user-ui/components/BannerCarousel.tsx index 893f8b0..6d0a811 100644 --- a/apps/user-ui/components/BannerCarousel.tsx +++ b/apps/user-ui/components/BannerCarousel.tsx @@ -12,7 +12,7 @@ interface Banner { name: string; imageUrl: string; description?: string | null; - productIds?: number[] | null; + skuIds?: number[] | null; redirectUrl?: string | null; serialNum?: number | null; isActive: boolean; @@ -61,13 +61,13 @@ export default function BannerCarousel() { if (error || !banners || banners.length === 0) return null; const handleBannerPress = (banner: Banner) => { - if (banner.productIds && banner.productIds.length > 0) { + if (banner.skuIds && banner.skuIds.length > 0) { // Navigate to the first product's detail page - router.push(`/(drawer)/(tabs)/home/product-detail/${banner.productIds[0]}`); + router.push(`/(drawer)/(tabs)/home/product-detail/${banner.skuIds[0]}`); } else if (banner.redirectUrl) { // Handle external URL - could open in browser or handle deep links } - // If no productIds or redirectUrl, banner is just for display + // If no skuIds or redirectUrl, banner is just for display }; const handleScroll = (event: NativeSyntheticEvent) => { diff --git a/apps/user-ui/components/PaymentAndOrderComponent.tsx b/apps/user-ui/components/PaymentAndOrderComponent.tsx index 12578f7..a088e46 100644 --- a/apps/user-ui/components/PaymentAndOrderComponent.tsx +++ b/apps/user-ui/components/PaymentAndOrderComponent.tsx @@ -128,10 +128,10 @@ const PaymentAndOrderComponent: React.FC = ({ const availableItems = cartItems .filter(item => { - if (productSlotsMap[item.productId]?.isOutOfStock) return false; + if (productSlotsMap[item.skuId]?.isOutOfStock) return false; // For flash delivery, check if product supports flash delivery if (isFlashDelivery) { - return flashEligibleProductIds.has(item.productId); + return flashEligibleProductIds.has(item.skuId); } // For regular delivery, only include items with assigned slots return selectedSlots[item.id]; @@ -150,7 +150,7 @@ const PaymentAndOrderComponent: React.FC = ({ selectedItems: availableItems.map(itemId => { const item = cartItems.find(cartItem => cartItem.id === itemId); return { - productId: item.productId, + skuId: item.skuId, quantity: item.quantity, slotId: isFlashDelivery ? null : selectedSlots[itemId] }; diff --git a/apps/user-ui/components/ProductCard.tsx b/apps/user-ui/components/ProductCard.tsx index 8ee25d1..6d3e401 100644 --- a/apps/user-ui/components/ProductCard.tsx +++ b/apps/user-ui/components/ProductCard.tsx @@ -78,7 +78,7 @@ const ProductCard: React.FC = ({ }) || {}; // Find current quantity from cart data - const cartItem = cartData?.items?.find((cartItem: any) => cartItem.productId === item.id); + const cartItem = cartData?.items?.find((cartItem: any) => cartItem.skuId === item.id); const quantity = cartItem?.quantity || 0; // Get slots data from central store diff --git a/apps/user-ui/components/ProductDetail.tsx b/apps/user-ui/components/ProductDetail.tsx index d4ce7a5..02205ca 100644 --- a/apps/user-ui/components/ProductDetail.tsx +++ b/apps/user-ui/components/ProductDetail.tsx @@ -83,7 +83,7 @@ const ProductDetail: React.FC = ({ productId, isFlashDeliver }, [slotsData, productDetail]) // Find current quantity from cart data - const cartItem = productDetail ? cartData?.data?.items?.find((item: any) => item.productId === productDetail.id) : null; + const cartItem = productDetail ? cartData?.data?.items?.find((item: any) => item.skuId === productDetail.id) : null; const quantity = cartItem?.quantity || 0; const handleQuantityChange = (newQuantity: number) => { @@ -143,7 +143,7 @@ const ProductDetail: React.FC = ({ productId, isFlashDeliver }; const handleSlotAddToCart = (productId: number, selectedSlotId: number) => { - const cartItem = cartData.data?.items?.find((item: any) => item.productId === productId); + const cartItem = cartData.data?.items?.find((item: any) => item.skuId === productId); setIsLoadingDialogOpen(true); if (cartItem) { removeFromCart.mutate( diff --git a/apps/user-ui/components/SlotSpecificView.tsx b/apps/user-ui/components/SlotSpecificView.tsx index 7798640..f4977e8 100644 --- a/apps/user-ui/components/SlotSpecificView.tsx +++ b/apps/user-ui/components/SlotSpecificView.tsx @@ -252,7 +252,7 @@ const CompactProductCard = ({ refetchCart: true, }, cartType); - const cartItem = cartData?.items?.find((cartItem: any) => cartItem.productId === item.id); + const cartItem = cartData?.items?.find((cartItem: any) => cartItem.skuId === item.id); const quantity = cartItem?.quantity || 0; const isOutOfStock = productSlotsMap[item.id]?.isOutOfStock; diff --git a/apps/user-ui/components/cart-page.tsx b/apps/user-ui/components/cart-page.tsx index 0bb4723..1b4b897 100644 --- a/apps/user-ui/components/cart-page.tsx +++ b/apps/user-ui/components/cart-page.tsx @@ -49,7 +49,7 @@ export default function CartPage({ isFlashDelivery = false }: CartPageProps) { } = useGetCart({ refetchOnWindowFocus: true }, cartType); // Extract product IDs from cart items - const productIds = cartData?.items.map(item => item.productId) || []; + const productIds = cartData?.items.map(item => item.skuId) || []; // Get cart slots for the products in cart const { data: slotsData, refetch: refetchSlots, isLoading: isSlotsLoading } = trpc.user.cart.getCartSlots.useQuery( @@ -106,9 +106,9 @@ export default function CartPage({ isFlashDelivery = false }: CartPageProps) { const baseTotalPrice = useMemo( () => cartItems - .filter((item) => !productSlotsMap[item.productId]?.isOutOfStock) + .filter((item) => !productSlotsMap[item.skuId]?.isOutOfStock) .reduce((sum, item) => { - const product = productsById[item.productId]; + const product = productsById[item.skuId]; const price = product?.price || 0; return sum + price * (quantities[item.id] || item.quantity); }, 0), @@ -208,9 +208,9 @@ export default function CartPage({ isFlashDelivery = false }: CartPageProps) { ); const totalPrice = cartItems - .filter((item) => !productSlotsMap[item.productId]?.isOutOfStock) + .filter((item) => !productSlotsMap[item.skuId]?.isOutOfStock) .reduce((sum, item) => { - const product = productsById[item.productId]; + const product = productsById[item.skuId]; const quantity = quantities[item.id] || item.quantity; const price = isFlashDelivery ? (product?.flashPrice ?? product?.price ?? 0) : (product?.price || 0); return sum + Number(price) * quantity; @@ -282,7 +282,7 @@ export default function CartPage({ isFlashDelivery = false }: CartPageProps) { const finalTotalWithDelivery = finalTotal + deliveryCharge; - const hasAvailableItems = cartItems.some(item => !productSlotsMap[item.productId]?.isOutOfStock); + const hasAvailableItems = cartItems.some(item => !productSlotsMap[item.skuId]?.isOutOfStock); useEffect(() => { const initial: Record = {}; @@ -308,7 +308,7 @@ export default function CartPage({ isFlashDelivery = false }: CartPageProps) { if (isFlashDelivery) { newSelectedSlots[item.id] = 0; } else { - const productSlots = slotsData?.[item.productId]; + const productSlots = slotsData?.[item.skuId]; if (!productSlots || productSlots.length === 0) return; const now = dayjs(); @@ -416,11 +416,11 @@ export default function CartPage({ isFlashDelivery = false }: CartPageProps) { <> {cartItems.map((item, index) => { - const productSlots = getAvailableSlotsForProduct(item.productId); + const productSlots = getAvailableSlotsForProduct(item.skuId); const selectedSlotForItem = selectedSlots[item.id]; - const isFlashEligible = isFlashDelivery ? flashEligibleProductIds.has(item.productId) : true; - const product = productsById[item.productId]; - const productSlotInfo = productSlotsMap[item.productId]; + const isFlashEligible = isFlashDelivery ? flashEligibleProductIds.has(item.skuId) : true; + const product = productsById[item.skuId]; + const productSlotInfo = productSlotsMap[item.skuId]; // const isAvailable = (productSlots.length > 0 || isFlashDelivery) && !item.product?.isOutOfStock && isFlashEligible; let isAvailable = true; @@ -687,7 +687,7 @@ export default function CartPage({ isFlashDelivery = false }: CartPageProps) { {productSlotInfo?.isOutOfStock ? "Out of Stock" - : isFlashDelivery && !flashEligibleProductIds.has(item.productId) + : isFlashDelivery && !flashEligibleProductIds.has(item.skuId) ? "Not available for flash delivery. Please remove" : "No delivery slots available"} @@ -919,10 +919,10 @@ export default function CartPage({ isFlashDelivery = false }: CartPageProps) { onPress={() => { const availableItems = cartItems .filter(item => { - if (productSlotsMap[item.productId]?.isOutOfStock) return false; + if (productSlotsMap[item.skuId]?.isOutOfStock) return false; if (isFlashDelivery) { // Check if product supports flash delivery - return flashEligibleProductIds.has(item.productId); + return flashEligibleProductIds.has(item.skuId); } return selectedSlots[item.id]; // Regular delivery requires slot selection }) @@ -930,8 +930,8 @@ export default function CartPage({ isFlashDelivery = false }: CartPageProps) { if (availableItems.length === 0) { // Determine why no items are available - const outOfStockItems = cartItems.filter(item => productSlotsMap[item.productId]?.isOutOfStock); - const inStockItems = cartItems.filter(item => !productSlotsMap[item.productId]?.isOutOfStock); + const outOfStockItems = cartItems.filter(item => productSlotsMap[item.skuId]?.isOutOfStock); + const inStockItems = cartItems.filter(item => !productSlotsMap[item.skuId]?.isOutOfStock); let errorTitle = "Cannot Proceed"; let errorMessage = ""; @@ -943,7 +943,7 @@ export default function CartPage({ isFlashDelivery = false }: CartPageProps) { } else if (isFlashDelivery) { // Check if any items are flash-eligible const flashEligibleItems = inStockItems.filter(item => - flashEligibleProductIds.has(item.productId) + flashEligibleProductIds.has(item.skuId) ); if (flashEligibleItems.length === 0) { errorTitle = "1 Hr Delivery Unavailable"; @@ -970,7 +970,7 @@ export default function CartPage({ isFlashDelivery = false }: CartPageProps) { // Check if there are items without slots (for regular delivery) if (!isFlashDelivery && availableItems.length < cartItems.length) { - const itemsWithoutSlots = cartItems.filter(item => !selectedSlots[item.id] && !productSlotsMap[item.productId]?.isOutOfStock); + const itemsWithoutSlots = cartItems.filter(item => !selectedSlots[item.id] && !productSlotsMap[item.skuId]?.isOutOfStock); if (itemsWithoutSlots.length > 0) { Alert.alert( "Delivery Slot Required", diff --git a/apps/user-ui/components/checkout-page.tsx b/apps/user-ui/components/checkout-page.tsx index f0ec290..637a5cc 100644 --- a/apps/user-ui/components/checkout-page.tsx +++ b/apps/user-ui/components/checkout-page.tsx @@ -90,7 +90,7 @@ const CheckoutPage: React.FC = ({ isFlashDelivery = false }) const selectedItems = cartItems.filter(item => { // For flash delivery, check if product supports flash delivery if (isFlashDelivery) { - return flashEligibleProductIds.has(item.productId); + return flashEligibleProductIds.has(item.skuId); } // For regular delivery, only include items with assigned slots return selectedSlots[item.id]; @@ -132,10 +132,10 @@ const CheckoutPage: React.FC = ({ isFlashDelivery = false }) const totalPrice = selectedItems - .filter((item) => !productSlotsMap[item.productId]?.isOutOfStock) + .filter((item) => !productSlotsMap[item.skuId]?.isOutOfStock) .reduce( (sum, item) => { - const product = productsById[item.productId]; + const product = productsById[item.skuId]; const price = isFlashDelivery ? (product?.flashPrice ?? product?.price ?? 0) : (product?.price || 0); return sum + price * item.quantity; }, diff --git a/apps/user-ui/components/floating-cart-bar.tsx b/apps/user-ui/components/floating-cart-bar.tsx index 044e055..717278e 100644 --- a/apps/user-ui/components/floating-cart-bar.tsx +++ b/apps/user-ui/components/floating-cart-bar.tsx @@ -122,17 +122,17 @@ const FloatingCartBar: React.FC = ({ const itemsToUpdate = cartItems.filter(item => { if (isFlashDelivery || !item.slotId) return false; - const availableSlots = productSlotsMap[item.productId]?.slots || []; + const availableSlots = productSlotsMap[item.skuId]?.slots || []; const isSlotAvailable = availableSlots.some((slot) => slot.id === item.slotId); return !isSlotAvailable; }); itemsToUpdate.forEach((item) => { - const availableSlots = productSlotsMap[item.productId]?.slots || []; + const availableSlots = productSlotsMap[item.skuId]?.slots || []; if (availableSlots.length > 0 && !isFlashDelivery) { const nearestSlotId = availableSlots[0].id; removeFromCart.mutate({ itemId: item.id }); - addToCartHook.addToCart(item.productId, item.quantity, nearestSlotId); + addToCartHook.addToCart(item.skuId, item.quantity, nearestSlotId); } }); }, []); @@ -143,7 +143,7 @@ const FloatingCartBar: React.FC = ({ // Calculate total cart value and free delivery info const totalCartValue = cartItems.reduce( (sum, item) => { - const product = productsById[item.productId]; + const product = productsById[item.skuId]; const basePrice = product?.price ?? 0; const price = isFlashDelivery ? (product?.flashPrice ?? basePrice) : basePrice; return sum + price * item.quantity; @@ -268,20 +268,20 @@ const FloatingCartBar: React.FC = ({ - {formatQuantity(productsById[item.productId]?.productQuantity || 1, productsById[item.productId]?.unitNotation || '').display} + {formatQuantity(productsById[item.skuId]?.productQuantity || 1, productsById[item.skuId]?.unitNotation || '').display} = ({ updateCartItem.mutate({ itemId: item.id, quantity: value }); } }} - step={productsById[item.productId]?.incrementStep || 1} + step={productsById[item.skuId]?.incrementStep || 1} showUnits={true} - // unit={productsById[item.productId]?.unitNotation} + // unit={productsById[item.skuId]?.unitNotation} /> - {item.slotId && slotsData && productSlotsMap[item.productId] && ( + {item.slotId && slotsData && productSlotsMap[item.skuId] && ( { + options={(productSlotsMap[item.skuId]?.slots || []).map((slot) => { return { label: slot ? formatTimeRange(slot.deliveryTime) : "N/A", value: slot.id, @@ -313,7 +313,7 @@ const FloatingCartBar: React.FC = ({ const newSlot = slotsData.slots.find(s => s.id === val); if (!newSlot) return; - const productId = item.productId; + const productId = item.skuId; const quantity = item.quantity; const itemId = item.id; const slotId = typeof val === 'number' ? val : Number(val); @@ -340,7 +340,7 @@ const FloatingCartBar: React.FC = ({ )} ₹{(() => { - const product = productsById[item.productId]; + const product = productsById[item.skuId]; const basePrice = product?.price ?? 0; const price = isFlashDelivery ? (product?.flashPrice ?? basePrice) : basePrice; return price * item.quantity; diff --git a/apps/user-ui/hooks/cart-query-hooks.tsx b/apps/user-ui/hooks/cart-query-hooks.tsx index b63bfda..09b7003 100644 --- a/apps/user-ui/hooks/cart-query-hooks.tsx +++ b/apps/user-ui/hooks/cart-query-hooks.tsx @@ -13,7 +13,7 @@ const getCartStorageKey = (cartType: CartType = "regular"): string => { interface LocalCartItem { id: number; - productId: number; + skuId: number; quantity: number; slotId: number; addedAt: string; @@ -35,7 +35,7 @@ interface ProductSummary { export interface CartItem { id: number; - productId: number; + skuId: number; quantity: number; addedAt: string; subtotal: number; @@ -66,7 +66,7 @@ interface UseGetCartReturn { } interface AddToCartVariables { - productId: number; + skuId: number; quantity: number; slotId: number; } @@ -94,8 +94,8 @@ interface UseAddToCartReturn { isLoading: boolean; error: Error | null; data: LocalCartItem[] | undefined; - addToCart: (productId: number, quantity?: number, slotId?: number, onSettled?: (data: LocalCartItem[] | undefined, error: Error | null) => void) => void; - addToCartAsync: (productId: number, quantity?: number, slotId?: number) => Promise; + addToCart: (skuId: number, quantity?: number, slotId?: number, onSettled?: (data: LocalCartItem[] | undefined, error: Error | null) => void) => void; + addToCartAsync: (skuId: number, quantity?: number, slotId?: number) => Promise; } interface UseUpdateCartItemReturn { @@ -135,9 +135,9 @@ const getNextCartItemId = (items: LocalCartItem[]): number => { return maxId + 1; }; -const addToLocalCart = async (productId: number, quantity: number, slotId: number | undefined, cartType: CartType = "regular"): Promise => { +const addToLocalCart = async (skuId: number, quantity: number, slotId: number | undefined, cartType: CartType = "regular"): Promise => { const items = await getLocalCart(cartType); - const existingIndex = items.findIndex(item => item.productId === productId); + const existingIndex = items.findIndex(item => item.skuId === skuId); if (existingIndex >= 0) { items[existingIndex].quantity += quantity; @@ -148,7 +148,7 @@ const addToLocalCart = async (productId: number, quantity: number, slotId: numbe const newId = getNextCartItemId(items); const cartItem: LocalCartItem = { id: newId, - productId, + skuId, quantity, slotId: slotId ?? 0, addedAt: new Date().toISOString(), @@ -211,14 +211,14 @@ export function useGetCart(options: UseGetCartOptions = {}, cartType: CartType = const items: CartItem[] = cartItems .map((cartItem): CartItem | null => { - const productBasic = productMap[cartItem.productId]; - const productAvailability = productSlotsMap[cartItem.productId]; + const productBasic = productMap[cartItem.skuId]; + const productAvailability = productSlotsMap[cartItem.skuId]; if (!productBasic || !productAvailability) return null; return { id: cartItem.id, - productId: cartItem.productId, + skuId: cartItem.skuId, quantity: cartItem.quantity, addedAt: cartItem.addedAt, subtotal: Number(productBasic.price) * cartItem.quantity, @@ -256,8 +256,8 @@ export function useAddToCart(options: MutationOptions = useMutation({ - mutationFn: async ({ productId, quantity, slotId }: AddToCartVariables): Promise => { - return await addToLocalCart(productId, quantity, slotId, cartType); + mutationFn: async ({ skuId, quantity, slotId }: AddToCartVariables): Promise => { + return await addToLocalCart(skuId, quantity, slotId, cartType); }, onSuccess: (data: LocalCartItem[], variables: AddToCartVariables) => { queryClient.invalidateQueries({ queryKey: [`local-cart-${cartType}`] }); @@ -274,11 +274,11 @@ export function useAddToCart(options: MutationOptions void): void => { + const addToCart = (skuId: number, quantity = 1, slotId?: number, onSettled?: (data: LocalCartItem[] | undefined, error: Error | null) => void): void => { if (slotId == null) { throw new Error('slotId is required for adding to cart'); } - mutation.mutate({ productId, quantity, slotId }, { + mutation.mutate({ skuId, quantity, slotId }, { onSettled: (data: LocalCartItem[] | undefined, error: Error | null) => { onSettled?.(data, error); } @@ -292,11 +292,11 @@ export function useAddToCart(options: MutationOptions => { + addToCartAsync: (skuId: number, quantity = 1, slotId?: number): Promise => { if (slotId == null) { throw new Error('slotId is required for adding to cart'); } - return mutation.mutateAsync({ productId, quantity, slotId }); + return mutation.mutateAsync({ skuId, quantity, slotId }); }, }; } diff --git a/apps/user-ui/src/components/AddToCartDialog.tsx b/apps/user-ui/src/components/AddToCartDialog.tsx index 9d72357..9bb9bd2 100644 --- a/apps/user-ui/src/components/AddToCartDialog.tsx +++ b/apps/user-ui/src/components/AddToCartDialog.tsx @@ -132,7 +132,7 @@ export default function AddToCartDialog() { } else { const slotId = selectedSlotId ?? availableSlotIds[0] ?? 0; addToCart.mutate( - { productId: product.id, quantity, slotId }, + { skuId: product.id, quantity, slotId }, { onSuccess: () => clearAddedToCartProduct() } ); } diff --git a/packages/db_helper_sqlite/drizzle/0002_sku_split.sql b/packages/db_helper_sqlite/drizzle/0002_sku_split.sql index fcc01de..6d7c60b 100644 --- a/packages/db_helper_sqlite/drizzle/0002_sku_split.sql +++ b/packages/db_helper_sqlite/drizzle/0002_sku_split.sql @@ -222,7 +222,18 @@ ALTER TABLE `coupons` RENAME COLUMN `product_ids` TO `sku_ids`; ALTER TABLE `reserved_coupons` RENAME COLUMN `product_ids` TO `sku_ids`; ALTER TABLE `vendor_snippets` RENAME COLUMN `product_ids` TO `sku_ids`; --- 8. Clean up helper table. +-- 8. Update popularItems in key_val_store from product IDs to SKU IDs. +UPDATE `key_val_store` +SET `value` = ( + SELECT json_group_array(`m`.`sku_id`) + FROM json_each(`key_val_store`.`value`) AS `je` + JOIN `__product_to_sku` `m` ON `m`.`product_id` = `je`.`value` +) +WHERE `key` = 'popularItems' + AND `value` IS NOT NULL + AND `value` LIKE '[%'; + +-- 9. Clean up helper table. DROP TABLE `__product_to_sku`; -- PRAGMA foreign_keys=ON; diff --git a/packages/db_helper_sqlite/index.ts b/packages/db_helper_sqlite/index.ts index 789b16d..703feb7 100644 --- a/packages/db_helper_sqlite/index.ts +++ b/packages/db_helper_sqlite/index.ts @@ -311,7 +311,7 @@ export { cancelOrderTransaction as cancelUserOrderTransaction, updateOrderNotes as updateUserOrderNotes, getRecentlyDeliveredOrderIds as getUserRecentlyDeliveredOrderIds, - getProductIdsFromOrders as getUserProductIdsFromOrders, + getSkuIdsFromOrders as getUserProductIdsFromOrders, getProductsForRecentOrders as getUserProductsForRecentOrders, // Post-order handler helpers getOrdersByIdsWithFullData, @@ -364,7 +364,7 @@ export { // Common API Helpers export { - getSuspendedProductIds, + getSuspendedSkuIds, getNextDeliveryDateWithCapacity, } from './src/user-apis/product' diff --git a/packages/db_helper_sqlite/src/admin-apis/product.ts b/packages/db_helper_sqlite/src/admin-apis/product.ts index c2f5c10..a8102b6 100644 --- a/packages/db_helper_sqlite/src/admin-apis/product.ts +++ b/packages/db_helper_sqlite/src/admin-apis/product.ts @@ -792,12 +792,12 @@ export async function updateProductPrices(updates: Array<{ } const productIds = updates.map((update) => update.productId) - const existingProducts = await db.query.productInfo.findMany({ - where: inArray(productInfo.id, productIds), + const existingSkus = await db.query.productSkus.findMany({ + where: inArray(productSkus.id, productIds), columns: { id: true }, }) as Array<{ id: number }> - const existingIds = new Set(existingProducts.map((product: { id: number }) => product.id)) + const existingIds = new Set(existingSkus.map((sku: { id: number }) => sku.id)) const invalidIds = productIds.filter((id) => !existingIds.has(id)) if (invalidIds.length > 0) { @@ -806,7 +806,7 @@ export async function updateProductPrices(updates: Array<{ const updatePromises = updates.map((update) => { const { productId, price, marketPrice, flashPrice, isFlashAvailable } = update - const updateData: Partial> = {} + const updateData: any = {} if (price !== undefined) updateData.price = price.toString() if (marketPrice !== undefined) updateData.marketPrice = marketPrice === null ? null : marketPrice.toString() @@ -814,9 +814,9 @@ export async function updateProductPrices(updates: Array<{ if (isFlashAvailable !== undefined) updateData.isFlashAvailable = isFlashAvailable return db - .update(productInfo) + .update(productSkus) .set(updateData) - .where(eq(productInfo.id, productId)) + .where(eq(productSkus.id, productId)) }) await Promise.all(updatePromises) diff --git a/packages/db_helper_sqlite/src/stores/store-helpers.ts b/packages/db_helper_sqlite/src/stores/store-helpers.ts index 4629d5c..90f5701 100644 --- a/packages/db_helper_sqlite/src/stores/store-helpers.ts +++ b/packages/db_helper_sqlite/src/stores/store-helpers.ts @@ -5,7 +5,8 @@ import { db } from '../db/db_index' import { homeBanners, productInfo, - units, + productSkus, + skuFeatures, deliverySlotInfo, specialDeals, storeInfo, @@ -24,7 +25,7 @@ export interface BannerData { name: string imageUrl: string | null serialNum: number | null - productIds: number[] | null + skuIds: number[] | null createdAt: Date } @@ -41,7 +42,9 @@ export async function getAllBannersForCache(): Promise { export interface ProductBasicData { id: number + productId: number name: string + skuName: string | null shortDescription: string | null longDescription: string | null price: string @@ -49,7 +52,7 @@ export interface ProductBasicData { images: unknown isOutOfStock: boolean storeId: number | null - unitShortNotation: string + unitNotation: string incrementStep: number productQuantity: number isFlashAvailable: boolean @@ -63,7 +66,7 @@ export interface StoreBasicData { } export interface DeliverySlotData { - productId: number + skuId: number id: number deliveryTime: Date freezeTime: Date @@ -71,44 +74,64 @@ export interface DeliverySlotData { } export interface SpecialDealData { - productId: number + skuId: number quantity: string price: string validTill: Date } +export async function getAllSpecialDealsForCache(): Promise { + const results = await db + .select({ + skuId: specialDeals.skuId, + quantity: specialDeals.quantity, + price: specialDeals.price, + validTill: specialDeals.validTill, + }) + .from(specialDeals) + .where(gt(specialDeals.validTill, sql`CURRENT_TIMESTAMP`)) + + return results.map((deal) => ({ + ...deal, + quantity: String(deal.quantity ?? '0'), + price: String(deal.price ?? '0'), + })) +} + export interface ProductTagData { productId: number tagName: string } export async function getAllProductsForCache(): Promise { - const results = await db - .select({ - id: productInfo.id, - name: productInfo.name, - shortDescription: productInfo.shortDescription, - longDescription: productInfo.longDescription, - price: productInfo.price, - marketPrice: productInfo.marketPrice, - images: productInfo.images, - isOutOfStock: productInfo.isOutOfStock, - storeId: productInfo.storeId, - unitShortNotation: units.shortNotation, - incrementStep: productInfo.incrementStep, - productQuantity: productInfo.productQuantity, - isFlashAvailable: productInfo.isFlashAvailable, - flashPrice: productInfo.flashPrice, - }) - .from(productInfo) - .innerJoin(units, eq(productInfo.unitId, units.id)) + const skus = await db.query.productSkus.findMany({ + with: { + product: true, + features: true, + }, + }) - return results.map((product) => ({ - ...product, - price: String(product.price ?? '0'), - marketPrice: product.marketPrice ? String(product.marketPrice) : null, - flashPrice: product.flashPrice ? String(product.flashPrice) : null, - })) + return skus.map((sku) => { + const features = sku.features || [] + return { + id: sku.id, + productId: sku.productId, + name: sku.product?.name ?? 'Unknown', + skuName: sku.name ?? null, + shortDescription: sku.product?.shortDescription ?? null, + longDescription: sku.product?.longDescription ?? null, + price: String(sku.price ?? '0'), + marketPrice: sku.marketPrice ? String(sku.marketPrice) : null, + images: sku.images, + isOutOfStock: sku.isOutOfStock, + storeId: sku.product?.storeId ?? null, + unitNotation: features.map((f) => f.featureValue).join(' '), + incrementStep: sku.product?.incrementStep ?? 1, + productQuantity: 1, + isFlashAvailable: sku.isFlashAvailable, + flashPrice: sku.flashPrice ? String(sku.flashPrice) : null, + } + }) } export async function getAllStoresForCache(): Promise { @@ -126,13 +149,12 @@ export async function getAllDeliverySlotsForCache(): Promise ), }) - // Flatten slots with their product IDs const result: DeliverySlotData[] = [] for (const slot of slots) { - const productIds = slot.productIds || [] - for (const productId of productIds) { + const skuIds = slot.skuIds || [] + for (const skuId of skuIds) { result.push({ - productId, + skuId, id: slot.id, deliveryTime: slot.deliveryTime, freezeTime: slot.freezeTime, @@ -144,24 +166,6 @@ export async function getAllDeliverySlotsForCache(): Promise return result } -export async function getAllSpecialDealsForCache(): Promise { - const results = await db - .select({ - productId: specialDeals.productId, - quantity: specialDeals.quantity, - price: specialDeals.price, - validTill: specialDeals.validTill, - }) - .from(specialDeals) - .where(gt(specialDeals.validTill, sql`CURRENT_TIMESTAMP`)) - - return results.map((deal) => ({ - ...deal, - quantity: String(deal.quantity ?? '0'), - price: String(deal.price ?? '0'), - })) -} - export async function getAllProductTagsForCache(): Promise { return db .select({ @@ -224,23 +228,26 @@ export interface SlotWithProductsData { isCapacityFull: boolean products: Array<{ id: number + productId: number name: string + skuName: string | null productQuantity: number shortDescription: string | null price: string marketPrice: string | null - unit: { shortNotation: string } | null + unitNotation: string store: { id: number; name: string; description: string | null } | null images: unknown isOutOfStock: boolean storeId: number | null + isFlashAvailable: boolean + flashPrice: string | null }> } export async function getAllSlotsWithProductsForCache(): Promise { const now = new Date() - // Get all active future slots const slots = await db.query.deliverySlotInfo.findMany({ where: and( eq(deliverySlotInfo.isActive, true), @@ -249,68 +256,64 @@ export async function getAllSlotsWithProductsForCache(): Promise() + const allSkuIds = new Set() for (const slot of slots) { - for (const productId of (slot.productIds || [])) { - allProductIds.add(productId) + for (const skuId of (slot.skuIds || [])) { + allSkuIds.add(skuId) } } - // Fetch all products in one query - const productIdsArray = Array.from(allProductIds) - const productIdSet = new Set(productIdsArray); - // const productsData = productIdsArray.length > 0 - // ? await db.query.productInfo.findMany({ - // where: inArray(productInfo.id, productIdsArray), - // with: { - // unit: true, - // store: true, - // }, - // }) - // : [] - let productsData = productIdsArray.length > 0 - ? await db.query.productInfo.findMany({ - // where: inArray(productInfo.id, productIdsArray), - with: { - unit: true, - store: true, + const skuIdsArray = Array.from(allSkuIds) + const skuIdSet = new Set(skuIdsArray) + + let skusData: any[] = [] + if (skuIdsArray.length > 0) { + skusData = await db.query.productSkus.findMany({ + with: { + product: { + with: { store: true }, }, - }) - : [] + features: true, + }, + }) + skusData = skusData.filter((item: any) => skuIdSet.has(item.id)) + } - productsData = productsData.filter(item => productIdSet.has(item.id)) + const skuMap = new Map(skusData.map((s: any) => [s.id, s])) - // Create a map for quick lookup - const productMap = new Map(productsData.map(p => [p.id, p])) - - // Build the result - return slots.map(slot => ({ + return slots.map((slot) => ({ id: slot.id, deliveryTime: slot.deliveryTime, freezeTime: slot.freezeTime, isActive: slot.isActive, isCapacityFull: slot.isCapacityFull, - products: (slot.productIds || []) - .map(productId => productMap.get(productId)) + products: (slot.skuIds || []) + .map((skuId: number) => skuMap.get(skuId)) .filter((p): p is NonNullable => p != null) - .map(product => ({ - id: product.id, - name: product.name, - productQuantity: product.productQuantity, - shortDescription: product.shortDescription, - price: String(product.price ?? '0'), - marketPrice: product.marketPrice ? String(product.marketPrice) : null, - unit: product.unit ? { shortNotation: product.unit.shortNotation } : null, - store: product.store ? { - id: product.store.id, - name: product.store.name, - description: product.store.description - } : null, - images: product.images, - isOutOfStock: product.isOutOfStock, - storeId: product.storeId, - })), + .map((sku: any) => { + const features = sku.features || [] + return { + id: sku.id, + productId: sku.productId, + name: sku.product?.name ?? 'Unknown', + skuName: sku.name ?? null, + productQuantity: 1, + shortDescription: sku.product?.shortDescription ?? null, + price: String(sku.price ?? '0'), + marketPrice: sku.marketPrice ? String(sku.marketPrice) : null, + unitNotation: features.map((f: any) => f.featureValue).join(' '), + store: sku.product?.store ? { + id: sku.product.store.id, + name: sku.product.store.name, + description: sku.product.store.description + } : null, + images: sku.images, + isOutOfStock: sku.isOutOfStock, + storeId: sku.product?.storeId ?? null, + isFlashAvailable: sku.isFlashAvailable, + flashPrice: sku.flashPrice ? String(sku.flashPrice) : null, + } + }), })) as SlotWithProductsData[] } diff --git a/packages/db_helper_sqlite/src/user-apis/order.ts b/packages/db_helper_sqlite/src/user-apis/order.ts index 81d31c6..acc4bc9 100644 --- a/packages/db_helper_sqlite/src/user-apis/order.ts +++ b/packages/db_helper_sqlite/src/user-apis/order.ts @@ -5,6 +5,7 @@ import { orderStatus, addresses, productInfo, + productSkus, paymentInfoTable, coupons, couponUsage, @@ -258,9 +259,9 @@ export async function getAddressByIdAndUser( }) } -export async function getProductById(productId: number) { - return db.query.productInfo.findFirst({ - where: eq(productInfo.id, productId), +export async function getProductById(skuId: number) { + return db.query.productSkus.findFirst({ + where: eq(productSkus.id, skuId), }) } @@ -341,12 +342,12 @@ export async function placeOrderTransaction(params: { export async function deleteCartItemsForOrder( userId: number, - productIds: number[] + skuIds: number[] ): Promise { await db.delete(cartItems).where( and( eq(cartItems.userId, userId), - inArray(cartItems.productId, productIds) + inArray(cartItems.skuId, skuIds) ) ) } @@ -375,10 +376,16 @@ export async function getOrdersWithRelations( with: { orderItems: { with: { - product: { + sku: { + with: { + product: { + columns: { + name: true, + }, + }, + }, columns: { id: true, - name: true, images: true, }, }, @@ -451,10 +458,16 @@ export async function getOrderByIdWithRelations( with: { orderItems: { with: { - product: { + sku: { + with: { + product: { + columns: { + name: true, + }, + }, + }, columns: { id: true, - name: true, images: true, }, }, @@ -610,15 +623,15 @@ export async function getRecentlyDeliveredOrderIds( return recentOrders.map((order) => order.id) } -export async function getProductIdsFromOrders( +export async function getSkuIdsFromOrders( orderIds: number[] ): Promise { const orderItemsResult = await db - .select({ productId: orderItems.productId }) + .select({ skuId: orderItems.skuId }) .from(orderItems) .where(inArray(orderItems.orderId, orderIds)) - return [...new Set(orderItemsResult.map((item) => item.productId))] + return [...new Set(orderItemsResult.map((item) => item.skuId))] } export interface RecentProductData { @@ -714,14 +727,17 @@ export async function getOrdersByIdsWithFullData( }, orderItems: { with: { - product: { + sku: { columns: { name: true, - productQuantity: true, + price: true, }, with: { - unit: true - } + product: { + columns: { name: true }, + }, + features: true, + }, }, }, }, diff --git a/packages/db_helper_sqlite/src/user-apis/product.ts b/packages/db_helper_sqlite/src/user-apis/product.ts index 3373d19..900b7e4 100644 --- a/packages/db_helper_sqlite/src/user-apis/product.ts +++ b/packages/db_helper_sqlite/src/user-apis/product.ts @@ -1,5 +1,5 @@ import { db } from '../db/db_index' -import { deliverySlotInfo, productInfo, productReviews, productTags, specialDeals, storeInfo, units, users } from '../db/schema' +import { deliverySlotInfo, productInfo, productSkus, productReviews, productTags, specialDeals, storeInfo, units, users } from '../db/schema' import { and, desc, eq, gt, sql } from 'drizzle-orm' import type { UserProductDetailData, UserProductReview } from '@packages/shared' @@ -8,42 +8,27 @@ const getStringArray = (value: unknown): string[] | null => { return value.map((item) => String(item)) } -export async function getProductDetailById(productId: number): Promise { - const productData = await db - .select({ - id: productInfo.id, - name: productInfo.name, - shortDescription: productInfo.shortDescription, - longDescription: productInfo.longDescription, - price: productInfo.price, - marketPrice: productInfo.marketPrice, - images: productInfo.images, - isOutOfStock: productInfo.isOutOfStock, - storeId: productInfo.storeId, - unitShortNotation: units.shortNotation, - incrementStep: productInfo.incrementStep, - productQuantity: productInfo.productQuantity, - isFlashAvailable: productInfo.isFlashAvailable, - flashPrice: productInfo.flashPrice, - }) - .from(productInfo) - .innerJoin(units, eq(productInfo.unitId, units.id)) - .where(eq(productInfo.id, productId)) - .limit(1) +export async function getProductDetailById(skuId: number): Promise { + const sku = await db.query.productSkus.findFirst({ + where: eq(productSkus.id, skuId), + with: { + product: true, + features: true, + }, + }) - if (productData.length === 0) { + if (!sku) { return null } - const product = productData[0] + const features = sku.features || [] + const product = sku.product - const storeData = product.storeId ? await db.query.storeInfo.findFirst({ + const storeData = product?.storeId ? await db.query.storeInfo.findFirst({ where: eq(storeInfo.id, product.storeId), columns: { id: true, name: true, description: true }, }) : null - // Note: deliverySlots are now fetched from cache in the frontend via useSlots() - // This avoids expensive database joins on every product detail view const specialDealsData = await db .select({ quantity: specialDeals.quantity, @@ -53,32 +38,32 @@ export async function getProductDetailById(productId: number): Promise f.featureValue).join(' '), + images: getStringArray(sku.images), + isOutOfStock: sku.isOutOfStock, store: storeData ? { id: storeData.id, name: storeData.name, description: storeData.description ?? null, } : null, - incrementStep: product.incrementStep, - productQuantity: product.productQuantity, - isFlashAvailable: product.isFlashAvailable, - flashPrice: product.flashPrice?.toString() || null, - deliverySlots: [], // Fetched from cache in frontend via useSlots() + incrementStep: product?.incrementStep ?? 1, + productQuantity: 1, + isFlashAvailable: sku.isFlashAvailable, + flashPrice: sku.flashPrice?.toString() || null, + deliverySlots: [], specialDeals: specialDealsData.map((deal) => ({ quantity: String(deal.quantity ?? '0'), price: String(deal.price ?? '0'), @@ -126,9 +111,9 @@ export async function getProductReviews(productId: number, limit: number, offset } } -export async function getProductById(productId: number) { - return db.query.productInfo.findFirst({ - where: eq(productInfo.id, productId), +export async function getProductById(skuId: number) { + return db.query.productSkus.findFirst({ + where: eq(productSkus.id, skuId), }) } @@ -221,20 +206,20 @@ export async function getAllProductsWithUnits(tagId?: number): Promise { - const suspendedProducts = await db - .select({ id: productInfo.id }) - .from(productInfo) - .where(eq(productInfo.isSuspended, true)) +export async function getSuspendedSkuIds(): Promise { + const suspendedSkus = await db + .select({ id: productSkus.id }) + .from(productSkus) + .where(eq(productSkus.isSuspended, true)) - return suspendedProducts.map(sp => sp.id) + return suspendedSkus.map(sp => sp.id) } /** * Get next delivery date for a product (with capacity check) * This version filters by both isActive AND isCapacityFull */ -export async function getNextDeliveryDateWithCapacity(productId: number): Promise { +export async function getNextDeliveryDateWithCapacity(skuId: number): Promise { const slots = await db.query.deliverySlotInfo.findMany({ where: and( eq(deliverySlotInfo.isActive, true), @@ -244,10 +229,9 @@ export async function getNextDeliveryDateWithCapacity(productId: number): Promis orderBy: desc(deliverySlotInfo.deliveryTime), }) - // Find the first slot that contains this product for (const slot of slots) { - const productIds = slot.productIds || [] - if (productIds.includes(productId)) { + const skuIds = slot.skuIds || [] + if (skuIds.includes(skuId)) { return slot.deliveryTime } } diff --git a/packages/db_helper_sqlite/src/user-apis/slots.ts b/packages/db_helper_sqlite/src/user-apis/slots.ts index 4863a46..8ad89ce 100644 --- a/packages/db_helper_sqlite/src/user-apis/slots.ts +++ b/packages/db_helper_sqlite/src/user-apis/slots.ts @@ -1,5 +1,5 @@ import { db } from '../db/db_index' -import { deliverySlotInfo, productInfo } from '../db/schema' +import { deliverySlotInfo, productSkus } from '../db/schema' import { asc, eq } from 'drizzle-orm' import type { InferSelectModel } from 'drizzle-orm' import type { UserDeliverySlot, UserSlotAvailability } from '@packages/shared' @@ -27,20 +27,17 @@ export async function getActiveSlotsList(): Promise { } export async function getProductAvailability(): Promise { - const products = await db - .select({ - id: productInfo.id, - name: productInfo.name, - isOutOfStock: productInfo.isOutOfStock, - isFlashAvailable: productInfo.isFlashAvailable, - }) - .from(productInfo) - .where(eq(productInfo.isSuspended, false)) + const skus = await db.query.productSkus.findMany({ + where: eq(productSkus.isSuspended, false), + with: { + product: { columns: { name: true } }, + }, + }) - return products.map((product) => ({ - id: product.id, - name: product.name, - isOutOfStock: product.isOutOfStock, - isFlashAvailable: product.isFlashAvailable, + return skus.map((sku) => ({ + id: sku.id, + name: sku.product?.name ?? 'Unknown', + isOutOfStock: sku.isOutOfStock, + isFlashAvailable: sku.isFlashAvailable, })) } diff --git a/packages/db_helper_sqlite/src/user-apis/stores.ts b/packages/db_helper_sqlite/src/user-apis/stores.ts index 076b4ce..9383afb 100644 --- a/packages/db_helper_sqlite/src/user-apis/stores.ts +++ b/packages/db_helper_sqlite/src/user-apis/stores.ts @@ -1,22 +1,10 @@ import { db } from '../db/db_index' -import { productInfo, storeInfo, units } from '../db/schema' -import { and, eq, sql } from 'drizzle-orm' +import { productInfo, productSkus, storeInfo } from '../db/schema' +import { and, eq, inArray, sql } from 'drizzle-orm' import type { InferSelectModel } from 'drizzle-orm' import type { UserStoreDetailData, UserStoreProductData, UserStoreSummaryData, StoreSummary } from '@packages/shared' type StoreRow = InferSelectModel -type StoreProductRow = { - id: number - name: string - shortDescription: string | null - price: string | null - marketPrice: string | null - images: unknown - isOutOfStock: boolean - incrementStep: number - unitShortNotation: string - productQuantity: number -} const getStringArray = (value: unknown): string[] | null => { if (!Array.isArray(value)) return null @@ -24,32 +12,59 @@ const getStringArray = (value: unknown): string[] | null => { } export async function getStoreSummaries(): Promise { + // Count SKUs per store, filtering by suspended SKUs const storesData = await db .select({ id: storeInfo.id, name: storeInfo.name, description: storeInfo.description, imageUrl: storeInfo.imageUrl, - productCount: sql`count(${productInfo.id})`.as('productCount'), + productCount: sql`count(${productSkus.id})`.as('productCount'), }) .from(storeInfo) .leftJoin( productInfo, - and(eq(productInfo.storeId, storeInfo.id), eq(productInfo.isSuspended, false)) + eq(productInfo.storeId, storeInfo.id) + ) + .leftJoin( + productSkus, + and( + eq(productSkus.productId, productInfo.id), + eq(productSkus.isSuspended, false) + ) ) .groupBy(storeInfo.id) const storesWithDetails = await Promise.all( storesData.map(async (store) => { - const sampleProducts = await db - .select({ - id: productInfo.id, - name: productInfo.name, - images: productInfo.images, - }) - .from(productInfo) - .where(and(eq(productInfo.storeId, store.id), eq(productInfo.isSuspended, false))) - .limit(3) + let sampleProducts: any[] = [] + // Get sample SKUs from this store + if (store.productCount > 0) { + const storeProductIds = await db + .select({ id: productInfo.id }) + .from(productInfo) + .where(eq(productInfo.storeId, store.id)) + + const productIdArr = storeProductIds.map((p) => p.id) + if (productIdArr.length > 0) { + const skus = await db.query.productSkus.findMany({ + where: and( + inArray(productSkus.productId, productIdArr), + eq(productSkus.isSuspended, false) + ), + with: { + product: { columns: { name: true } }, + }, + columns: { id: true, images: true, name: true }, + limit: 3, + }) + sampleProducts = skus.map((sku) => ({ + id: sku.id, + name: sku.product?.name ?? sku.name ?? 'Unknown', + images: getStringArray(sku.images), + })) + } + } return { id: store.id, @@ -57,11 +72,7 @@ export async function getStoreSummaries(): Promise { description: store.description ?? null, imageUrl: store.imageUrl ?? null, productCount: store.productCount || 0, - sampleProducts: sampleProducts.map((product) => ({ - id: product.id, - name: product.name, - images: getStringArray(product.images), - })), + sampleProducts, } }) ) @@ -84,36 +95,42 @@ export async function getStoreDetail(storeId: number): Promise ({ - id: product.id, - name: product.name, - shortDescription: product.shortDescription ?? null, - price: String(product.price ?? '0'), - marketPrice: product.marketPrice ? String(product.marketPrice) : null, - incrementStep: product.incrementStep, - unit: product.unitShortNotation, - unitNotation: product.unitShortNotation, - images: getStringArray(product.images), - isOutOfStock: product.isOutOfStock, - productQuantity: product.productQuantity, - })) + const productIdArr = storeProductIds.map((p) => p.id) + + const skus = productIdArr.length > 0 + ? await db.query.productSkus.findMany({ + where: and( + inArray(productSkus.productId, productIdArr), + eq(productSkus.isSuspended, false) + ), + with: { + product: true, + features: true, + }, + }) + : [] + + const products: UserStoreProductData[] = skus.map((sku) => { + const features = sku.features || [] + return { + id: sku.id, + name: sku.product?.name ?? 'Unknown', + shortDescription: sku.product?.shortDescription ?? null, + price: String(sku.price ?? '0'), + marketPrice: sku.marketPrice ? String(sku.marketPrice) : null, + incrementStep: sku.product?.incrementStep ?? 1, + unit: features.map((f) => f.featureValue).join(' '), + unitNotation: features.map((f) => f.featureValue).join(' '), + images: getStringArray(sku.images), + isOutOfStock: sku.isOutOfStock, + productQuantity: 1, + } + }) return { store: { @@ -126,10 +143,6 @@ export async function getStoreDetail(storeId: number): Promise { return db.query.storeInfo.findMany({ columns: { diff --git a/packages/shared/types/user.ts b/packages/shared/types/user.ts index f45bd16..2c19277 100644 --- a/packages/shared/types/user.ts +++ b/packages/shared/types/user.ts @@ -121,7 +121,7 @@ export interface UserBanner { name: string; imageUrl: string; description: string | null; - productIds: number[] | null; + skuIds: number[] | null; redirectUrl: string | null; serialNum: number | null; isActive: boolean; From f6bcc23ca2284713834f1b7311d4173722b95f7d Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:47:35 +0530 Subject: [PATCH 04/73] fully functional --- .../app/(drawer)/coupons/edit/[id].tsx | 2 +- .../app/(drawer)/dashboard-banners/create.tsx | 4 +- .../(drawer)/dashboard-banners/edit/[id].tsx | 16 +-- .../app/(drawer)/dashboard-banners/index.tsx | 2 +- .../app/(drawer)/slots/slot-details.tsx | 4 +- .../app/(drawer)/vendor-snippets/index.tsx | 4 +- apps/admin-ui/components/BannerForm.tsx | 8 +- .../admin-ui/components/VendorSnippetForm.tsx | 16 +-- apps/admin-ui/src/api-hooks/banner.api.ts | 6 +- apps/admin-ui/src/components/CouponForm.tsx | 2 +- apps/admin-ui/types/vendor-snippets.ts | 4 +- apps/backend/package.json | 2 +- apps/backend/src/sqliteImporter.ts | 1 + apps/backend/src/stores/banner-store.ts | 8 +- .../src/trpc/apis/admin-apis/apis/banner.ts | 28 ++-- .../src/trpc/apis/admin-apis/apis/coupon.ts | 30 ++-- .../apis/admin-apis/apis/vendor-snippets.ts | 50 +++---- .../src/trpc/apis/user-apis/apis/coupon.ts | 8 +- apps/backend/wrangler-commands.md | 3 + apps/user-ui/components/ProductCard.tsx | 2 +- apps/user-ui/components/ProductDetail.tsx | 6 +- apps/user-ui/components/SlotSpecificView.tsx | 2 +- apps/user-ui/components/cart-page.tsx | 7 +- apps/user-ui/components/floating-cart-bar.tsx | 7 +- .../src/components/AddToCartDialog.tsx | 2 +- .../drizzle/0002_sku_split.sql | 6 +- packages/db_helper_sqlite/index.ts | 1 + .../db_helper_sqlite/src/admin-apis/banner.ts | 12 +- .../db_helper_sqlite/src/admin-apis/coupon.ts | 30 ++-- .../db_helper_sqlite/src/admin-apis/order.ts | 8 +- .../src/admin-apis/product.ts | 133 ++++++++++++++++++ .../src/admin-apis/vendor-snippets.ts | 25 ++-- .../src/helper_methods/coupon.ts | 32 ++--- .../src/stores/store-helpers.ts | 4 +- .../db_helper_sqlite/src/user-apis/coupon.ts | 6 +- .../db_helper_sqlite/src/user-apis/product.ts | 2 +- .../db_helper_sqlite/src/user-apis/stores.ts | 4 +- packages/shared/types/banner.types.ts | 2 +- scripts/s3-cleaner.js | 124 ++++++++++++++++ 39 files changed, 436 insertions(+), 177 deletions(-) create mode 100644 scripts/s3-cleaner.js diff --git a/apps/admin-ui/app/(drawer)/coupons/edit/[id].tsx b/apps/admin-ui/app/(drawer)/coupons/edit/[id].tsx index cde58f3..1320290 100644 --- a/apps/admin-ui/app/(drawer)/coupons/edit/[id].tsx +++ b/apps/admin-ui/app/(drawer)/coupons/edit/[id].tsx @@ -84,7 +84,7 @@ export default function EditCoupon() { maxValue: coupon.maxValue ? parseFloat(coupon.maxValue) : undefined, validTill: coupon.validTill ? dayjs(coupon.validTill).format('YYYY-MM-DD') : undefined, maxLimitForUser: coupon.maxLimitForUser || undefined, - productIds: coupon.productIds, + skuIds: coupon.skuIds, isReservedCoupon: false, // Normal coupons }; diff --git a/apps/admin-ui/app/(drawer)/dashboard-banners/create.tsx b/apps/admin-ui/app/(drawer)/dashboard-banners/create.tsx index dbacfa1..ba4d0d9 100644 --- a/apps/admin-ui/app/(drawer)/dashboard-banners/create.tsx +++ b/apps/admin-ui/app/(drawer)/dashboard-banners/create.tsx @@ -15,7 +15,7 @@ export default function CreateBanner() { name: '', imageUrl: '', description: '', - productIds: [], + skuIds: [], redirectUrl: '', // serialNum removed - assigned automatically by backend }; @@ -38,7 +38,7 @@ export default function CreateBanner() { name: values.name, imageUrl, description: values.description || undefined, - productIds: values.productIds.length > 0 ? values.productIds : [], + skuIds: values.skuIds.length > 0 ? values.skuIds : [], redirectUrl: values.redirectUrl || undefined, }); diff --git a/apps/admin-ui/app/(drawer)/dashboard-banners/edit/[id].tsx b/apps/admin-ui/app/(drawer)/dashboard-banners/edit/[id].tsx index 6615f05..4e86bbc 100644 --- a/apps/admin-ui/app/(drawer)/dashboard-banners/edit/[id].tsx +++ b/apps/admin-ui/app/(drawer)/dashboard-banners/edit/[id].tsx @@ -12,7 +12,7 @@ interface Banner { name: string; imageUrl: string; description?: string; - productIds?: number[]; + skuIds?: number[]; redirectUrl?: string; serialNum: number; isActive: boolean; @@ -45,12 +45,12 @@ export default function EditBanner() { if (bannerData) { - // Handle data format compatibility (productId -> productIds migration) + // Handle data format compatibility (productId -> skuIds migration) const processedBanner = { ...bannerData, - productIds: Array.isArray(bannerData.productIds) - ? bannerData.productIds - : (bannerData.productIds ? [bannerData.productIds] : []) + skuIds: Array.isArray(bannerData.skuIds) + ? bannerData.skuIds + : (bannerData.skuIds ? [bannerData.skuIds] : []) }; setBanner(processedBanner); @@ -74,14 +74,14 @@ export default function EditBanner() { name: banner.name, imageUrl: banner.imageUrl, description: banner.description || '', - productIds: banner.productIds || [], + skuIds: banner.skuIds || [], redirectUrl: banner.redirectUrl || '', // serialNum removed - handled automatically by backend } : { name: '', imageUrl: '', description: '', - productIds: [], + skuIds: [], redirectUrl: '', // serialNum removed - handled automatically by backend }; @@ -99,7 +99,7 @@ export default function EditBanner() { name: values.name, imageUrl: imageUrl || banner.imageUrl, description: values.description || undefined, - productIds: values.productIds.length > 0 ? values.productIds : [], + skuIds: values.skuIds.length > 0 ? values.skuIds : [], redirectUrl: values.redirectUrl || undefined, }); diff --git a/apps/admin-ui/app/(drawer)/dashboard-banners/index.tsx b/apps/admin-ui/app/(drawer)/dashboard-banners/index.tsx index 29bc085..9b04ad4 100644 --- a/apps/admin-ui/app/(drawer)/dashboard-banners/index.tsx +++ b/apps/admin-ui/app/(drawer)/dashboard-banners/index.tsx @@ -10,7 +10,7 @@ interface Banner { name: string; imageUrl: string; description: string | null; - productIds: number[] | null; + skuIds: number[] | null; redirectUrl: string | null; serialNum: number | null; isActive: boolean; diff --git a/apps/admin-ui/app/(drawer)/slots/slot-details.tsx b/apps/admin-ui/app/(drawer)/slots/slot-details.tsx index 2369c70..08a6287 100644 --- a/apps/admin-ui/app/(drawer)/slots/slot-details.tsx +++ b/apps/admin-ui/app/(drawer)/slots/slot-details.tsx @@ -113,7 +113,7 @@ export default function SlotDetails() { { - const snippetProducts = products.filter(p => snippet.productIds.includes(p.id)); + const snippetProducts = products.filter(p => snippet.skuIds.includes(p.id)); setDialogProducts(snippetProducts); setDialogOpen(true); }} @@ -121,7 +121,7 @@ export default function SlotDetails() { > - {snippet.productIds.length} products + {snippet.skuIds.length} products diff --git a/apps/admin-ui/app/(drawer)/vendor-snippets/index.tsx b/apps/admin-ui/app/(drawer)/vendor-snippets/index.tsx index f34cbfa..9c1ab52 100644 --- a/apps/admin-ui/app/(drawer)/vendor-snippets/index.tsx +++ b/apps/admin-ui/app/(drawer)/vendor-snippets/index.tsx @@ -192,7 +192,7 @@ const SnippetItem = ({ > - {snippet.productIds.length} Items + {snippet.skuIds.length} Items @@ -279,7 +279,7 @@ const handleViewProducts = (products: VendorSnippetProduct[]) => { snippetCode: snippet.snippetCode, slotId: snippet.slotId || 0, // Convert null to number for form isPermanent: snippet.isPermanent, - productIds: snippet.productIds, + skuIds: snippet.skuIds, validTill: snippet.validTill, createdAt: snippet.createdAt, }; diff --git a/apps/admin-ui/components/BannerForm.tsx b/apps/admin-ui/components/BannerForm.tsx index 8b3258b..033b299 100644 --- a/apps/admin-ui/components/BannerForm.tsx +++ b/apps/admin-ui/components/BannerForm.tsx @@ -14,7 +14,7 @@ export interface BannerFormData { name: string; imageUrl: string; description: string; - productIds: number[]; + skuIds: number[]; redirectUrl: string; // serialNum removed - will be assigned automatically by backend } @@ -32,7 +32,7 @@ interface BannerFormProps { const validationSchema = Yup.object().shape({ name: Yup.string().trim().required('Banner name is required').max(255), description: Yup.string().max(500), - productIds: Yup.array() + skuIds: Yup.array() .of(Yup.number()) .optional(), redirectUrl: Yup.string() @@ -177,10 +177,10 @@ export default function BannerForm({ { const selectedValues = Array.isArray(value) ? value : [value]; - setFieldValue('productIds', selectedValues.map(v => Number(v))); + setFieldValue('skuIds', selectedValues.map(v => Number(v))); }} multiple={true} label="Select Products" diff --git a/apps/admin-ui/components/VendorSnippetForm.tsx b/apps/admin-ui/components/VendorSnippetForm.tsx index 2148a77..0d0b2fa 100644 --- a/apps/admin-ui/components/VendorSnippetForm.tsx +++ b/apps/admin-ui/components/VendorSnippetForm.tsx @@ -34,7 +34,7 @@ const VendorSnippetForm: React.FC = ({ snippetCode: snippet?.snippetCode || '', slotId: snippet?.slotId?.toString() || '', isPermanent: snippet?.isPermanent || false, - productIds: snippet?.productIds?.map(id => id.toString()) || [], + skuIds: snippet?.skuIds?.map(id => id.toString()) || [], validTill: snippet?.validTill ? new Date(snippet.validTill) : null, }, validate: (values) => { @@ -55,8 +55,8 @@ const VendorSnippetForm: React.FC = ({ errors.slotId = 'Slot selection is required'; } - if (values.productIds.length === 0) { - errors.productIds = 'At least one product must be selected'; + if (values.skuIds.length === 0) { + errors.skuIds = 'At least one product must be selected'; } return errors; @@ -68,7 +68,7 @@ const VendorSnippetForm: React.FC = ({ snippetCode: values.snippetCode, slotId: values.isPermanent ? undefined : parseInt(values.slotId || '0'), isPermanent: values.isPermanent, - productIds: values.productIds.map(id => parseInt(id)), + skuIds: values.skuIds.map(id => parseInt(id)), validTill: values.validTill ? values.validTill.toISOString() : undefined, }; @@ -180,15 +180,15 @@ const VendorSnippetForm: React.FC = ({ {/* Product Selection */} parseInt(id))} - onChange={(selectedProductIds) => formik.setFieldValue('productIds', (selectedProductIds as number[]).map(id => id.toString()))} + value={formik.values.skuIds.map(id => parseInt(id))} + onChange={(selectedProductIds) => formik.setFieldValue('skuIds', (selectedProductIds as number[]).map(id => id.toString()))} multiple={true} label="Select Products" placeholder="Select products" labelFormat={(product) => `${product.name} (${product.unit})`} /> - {formik.errors.productIds && formik.touched.productIds && ( - {formik.errors.productIds} + {formik.errors.skuIds && formik.touched.skuIds && ( + {formik.errors.skuIds} )} diff --git a/apps/admin-ui/src/api-hooks/banner.api.ts b/apps/admin-ui/src/api-hooks/banner.api.ts index 70bddb3..cdcefbe 100644 --- a/apps/admin-ui/src/api-hooks/banner.api.ts +++ b/apps/admin-ui/src/api-hooks/banner.api.ts @@ -4,7 +4,7 @@ export interface Banner { name: string; imageUrl: string; description?: string; - productId?: number; + skuIds?: number[]; redirectUrl?: string; serialNum: number; isActive: boolean; @@ -16,11 +16,11 @@ export interface CreateBannerPayload { name: string; imageUrl: string; description?: string; - productId?: number; + skuIds?: number[]; redirectUrl?: string; serialNum: number; } export interface UpdateBannerPayload extends Partial { isActive?: boolean; -} \ No newline at end of file +} diff --git a/apps/admin-ui/src/components/CouponForm.tsx b/apps/admin-ui/src/components/CouponForm.tsx index 0568404..9251c54 100644 --- a/apps/admin-ui/src/components/CouponForm.tsx +++ b/apps/admin-ui/src/components/CouponForm.tsx @@ -105,7 +105,7 @@ export default function CouponForm({ onSubmit, isLoading, initialValues }: Coupo maxValue: undefined, validTill: undefined, maxLimitForUser: undefined, - productIds: undefined, + skuIds: undefined, applicableUsers: [], applicableProducts: [], exclusiveApply: false, diff --git a/apps/admin-ui/types/vendor-snippets.ts b/apps/admin-ui/types/vendor-snippets.ts index c62341e..0271492 100644 --- a/apps/admin-ui/types/vendor-snippets.ts +++ b/apps/admin-ui/types/vendor-snippets.ts @@ -8,7 +8,7 @@ export interface VendorSnippet { snippetCode: string; slotId: number | null; isPermanent: boolean; - productIds: number[]; + skuIds: number[]; products: VendorSnippetProduct[]; validTill: string | null; createdAt: string; @@ -28,7 +28,7 @@ export interface VendorSnippetForm { snippetCode: string; slotId: number; isPermanent: boolean; - productIds: number[]; + skuIds: number[]; validTill: string | null; createdAt: string; } \ No newline at end of file diff --git a/apps/backend/package.json b/apps/backend/package.json index ab217f8..8ae1681 100755 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -15,7 +15,7 @@ "deploy:dev": "wrangler deploy --config wrangler.dev.toml", "wrangler:dev": "wrangler dev worker.ts --config wrangler.toml", "wrangler:deploy": "wrangler deploy worker.ts --config wrangler.toml", - "pull_db": "wrangler d1 export freshyo-dev --config wrangler.prod.toml --remote --output ./dumps/latest.sql && bash ./scripts/populate_localdb.sh", + "pull_db": "rm -rf .wrangler/state/v3/d1/* && wrangler d1 export freshyo-dev --config wrangler.prod.toml --remote --output ./dumps/latest.sql && bash ./scripts/populate_localdb.sh", "docker:build": "cd .. && docker buildx build --platform linux/amd64 -t mohdshafiuddin54/health_petal:latest --progress=plain -f backend/Dockerfile .", "docker:push": "docker push mohdshafiuddin54/health_petal:latest" }, diff --git a/apps/backend/src/sqliteImporter.ts b/apps/backend/src/sqliteImporter.ts index d685475..b55fb03 100644 --- a/apps/backend/src/sqliteImporter.ts +++ b/apps/backend/src/sqliteImporter.ts @@ -72,6 +72,7 @@ export { createSpecialDealsForProduct, updateProductDeals, replaceProductTags, + mergeSkus, toggleProductOutOfStock, updateSlotProducts, getSlotProductIds, diff --git a/apps/backend/src/stores/banner-store.ts b/apps/backend/src/stores/banner-store.ts index 4d36f8c..5a5ed7a 100644 --- a/apps/backend/src/stores/banner-store.ts +++ b/apps/backend/src/stores/banner-store.ts @@ -13,7 +13,7 @@ interface Banner { name: string imageUrl: string | null serialNum: number | null - productIds: number[] | null + skuIds: number[] | null createdAt: Date } @@ -46,7 +46,7 @@ export async function initializeBannerStore(): Promise { // name: banner.name, // imageUrl: signedImageUrl, // serialNum: banner.serialNum, - // productIds: banner.productIds, + // skuIds: banner.skuIds, // createdAt: banner.createdAt, // } // @@ -78,7 +78,7 @@ export async function getBannerById(id: number): Promise { name: banner.name, imageUrl: signedImageUrl, serialNum: banner.serialNum, - productIds: banner.productIds, + skuIds: banner.skuIds, createdAt: banner.createdAt, } } catch (error) { @@ -121,7 +121,7 @@ export async function getAllBanners(): Promise { name: banner.name, imageUrl: signedImageUrl, serialNum: banner.serialNum, - productIds: banner.productIds, + skuIds: banner.skuIds, createdAt: banner.createdAt, } }) diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/banner.ts b/apps/backend/src/trpc/apis/admin-apis/apis/banner.ts index 47a071b..c3daf25 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/banner.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/banner.ts @@ -26,7 +26,7 @@ export const bannerRouter = router({ // Old implementation - direct DB query: // const banners = await db.query.homeBanners.findMany({ // orderBy: desc(homeBanners.createdAt), // Order by creation date instead - // Removed product relationship since we now use productIds array + // Removed product relationship since we now use skuIds array // }); @@ -37,16 +37,16 @@ export const bannerRouter = router({ return { ...banner, imageUrl: banner.imageUrl ? scaffoldAssetUrl(banner.imageUrl) : banner.imageUrl, - // Ensure productIds is always an array - productIds: banner.productIds || [], + // Ensure skuIds is always an array + skuIds: banner.skuIds || [], }; } catch (error) { console.error(`Failed to generate signed URL for banner ${banner.id}:`, error); return { ...banner, imageUrl: banner.imageUrl, // Keep original on error - // Ensure productIds is always an array - productIds: banner.productIds || [], + // Ensure skuIds is always an array + skuIds: banner.skuIds || [], }; } }) @@ -74,7 +74,7 @@ export const bannerRouter = router({ // Old implementation - direct DB query: const banner = await db.query.homeBanners.findFirst({ where: eq(homeBanners.id, input.id), - // Removed product relationship since we now use productIds array + // Removed product relationship since we now use skuIds array }); */ @@ -89,9 +89,9 @@ export const bannerRouter = router({ // Keep original imageUrl on error } - // Ensure productIds is always an array (handle migration compatibility) - if (!banner.productIds) { - banner.productIds = []; + // Ensure skuIds is always an array (handle migration compatibility) + if (!banner.skuIds) { + banner.skuIds = []; } } @@ -104,7 +104,7 @@ export const bannerRouter = router({ name: z.string().min(1), imageUrl: z.string().url(), description: z.string().optional(), - productIds: z.array(z.number()).optional(), + skuIds: z.array(z.number()).optional(), redirectUrl: z.string().url().optional(), // serialNum removed completely })) @@ -116,7 +116,7 @@ export const bannerRouter = router({ name: input.name, imageUrl: imageUrl, description: input.description ?? null, - productIds: input.productIds || [], + skuIds: input.skuIds || [], redirectUrl: input.redirectUrl ?? null, serialNum: 999, // Default value, not used isActive: false, // Default to inactive @@ -129,7 +129,7 @@ export const bannerRouter = router({ name: input.name, imageUrl: imageUrl, description: input.description, - productIds: input.productIds || [], + skuIds: input.skuIds || [], redirectUrl: input.redirectUrl, serialNum: 999, // Default value, not used isActive: false, // Default to inactive @@ -153,7 +153,7 @@ export const bannerRouter = router({ name: z.string().min(1).optional(), imageUrl: z.string().url().optional(), description: z.string().optional(), - productIds: z.array(z.number()).optional(), + skuIds: z.array(z.number()).optional(), redirectUrl: z.string().url().optional(), serialNum: z.number().nullable().optional(), isActive: z.boolean().optional(), @@ -181,7 +181,7 @@ export const bannerRouter = router({ /* // Old implementation - direct DB query: const { id, ...updateData } = input; - const incomingProductIds = input.productIds; + const incomingProductIds = input.skuIds; // Extract S3 key from presigned URL if imageUrl is provided const processedData = { ...updateData, diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/coupon.ts b/apps/backend/src/trpc/apis/admin-apis/apis/coupon.ts index 70767c1..e8e9bfd 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/coupon.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/coupon.ts @@ -29,7 +29,7 @@ const createCouponBodySchema = z.object({ flatDiscount: z.number().optional(), minOrder: z.number().optional(), targetUser: z.number().optional(), - productIds: z.array(z.number()).optional().nullable(), + skuIds: z.array(z.number()).optional().nullable(), applicableUsers: z.array(z.number()).optional(), applicableProducts: z.array(z.number()).optional(), maxValue: z.number().optional(), @@ -49,7 +49,7 @@ export const couponRouter = router({ create: protectedProcedure .input(createCouponBodySchema) .mutation(async ({ input, ctx }): Promise => { - const { couponCode, isUserBased, discountPercent, flatDiscount, minOrder, productIds, applicableUsers, applicableProducts, maxValue, isApplyForAll, validTill, maxLimitForUser, exclusiveApply } = input; + const { couponCode, isUserBased, discountPercent, flatDiscount, minOrder, skuIds, applicableUsers, applicableProducts, maxValue, isApplyForAll, validTill, maxLimitForUser, exclusiveApply } = input; // Validation: ensure at least one discount type is provided if ((!discountPercent && !flatDiscount) || (discountPercent && flatDiscount)) { @@ -101,7 +101,7 @@ export const couponRouter = router({ discountPercent: discountPercent?.toString(), flatDiscount: flatDiscount?.toString(), minOrder: minOrder?.toString(), - productIds: productIds || null, + skuIds: skuIds || null, createdBy: staffUserId, maxValue: maxValue?.toString(), isApplyForAll: isApplyForAll || false, @@ -121,7 +121,7 @@ export const couponRouter = router({ discountPercent: discountPercent?.toString(), flatDiscount: flatDiscount?.toString(), minOrder: minOrder?.toString(), - productIds: productIds || null, + skuIds: skuIds || null, createdBy: staffUserId, maxValue: maxValue?.toString(), isApplyForAll: isApplyForAll || false, @@ -145,9 +145,9 @@ export const couponRouter = router({ // Insert applicable products if (applicableProducts && applicableProducts.length > 0) { await db.insert(couponApplicableProducts).values( - applicableProducts.map(productId => ({ + applicableProducts.map(skuId => ({ couponId: coupon.id, - productId, + skuId, })) ); } @@ -185,7 +185,7 @@ export const couponRouter = router({ return { ...result, - productIds: (result.productIds as number[]) || undefined, + skuIds: (result.skuIds as number[]) || undefined, applicableUsers: result.applicableUsers.map((au: any) => au.user), applicableProducts: result.applicableProducts.map((ap: any) => ap.product), }; @@ -221,7 +221,7 @@ export const couponRouter = router({ if (updates.maxLimitForUser !== undefined) updateData.maxLimitForUser = updates.maxLimitForUser; if (updates.exclusiveApply !== undefined) updateData.exclusiveApply = updates.exclusiveApply; if (updates.isInvalidated !== undefined) updateData.isInvalidated = updates.isInvalidated; - if (updates.productIds !== undefined) updateData.productIds = updates.productIds; + if (updates.skuIds !== undefined) updateData.skuIds = updates.skuIds; // Using dbService helper (new implementation) const coupon = await updateCouponWithRelations( @@ -260,9 +260,9 @@ export const couponRouter = router({ await db.delete(couponApplicableProducts).where(eq(couponApplicableProducts.couponId, id)); if (updates.applicableProducts.length > 0) { await db.insert(couponApplicableProducts).values( - updates.applicableProducts.map(productId => ({ + updates.applicableProducts.map(skuId => ({ couponId: id, - productId, + skuId, })) ); } @@ -405,7 +405,7 @@ export const couponRouter = router({ createReservedCoupon: protectedProcedure .input(createCouponBodySchema) .mutation(async ({ input, ctx }): Promise => { - const { couponCode, isUserBased, discountPercent, flatDiscount, minOrder, productIds, applicableProducts, maxValue, validTill, maxLimitForUser, exclusiveApply } = input; + const { couponCode, isUserBased, discountPercent, flatDiscount, minOrder, skuIds, applicableProducts, maxValue, validTill, maxLimitForUser, exclusiveApply } = input; // Validation: ensure at least one discount type is provided if ((!discountPercent && !flatDiscount) || (discountPercent && flatDiscount)) { @@ -434,7 +434,7 @@ export const couponRouter = router({ discountPercent: discountPercent?.toString(), flatDiscount: flatDiscount?.toString(), minOrder: minOrder?.toString(), - productIds, + skuIds, maxValue: maxValue?.toString(), validTill: validTill ? dayjs(validTill).toDate() : undefined, maxLimitForUser, @@ -452,7 +452,7 @@ export const couponRouter = router({ discountPercent: discountPercent?.toString(), flatDiscount: flatDiscount?.toString(), minOrder: minOrder?.toString(), - productIds, + skuIds, maxValue: maxValue?.toString(), validTill: validTill ? dayjs(validTill).toDate() : undefined, maxLimitForUser, @@ -465,9 +465,9 @@ export const couponRouter = router({ // Insert applicable products if provided if (applicableProducts && applicableProducts.length > 0) { await db.insert(couponApplicableProducts).values( - applicableProducts.map(productId => ({ + applicableProducts.map(skuId => ({ couponId: coupon.id, - productId, + skuId, })) ); } diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/vendor-snippets.ts b/apps/backend/src/trpc/apis/admin-apis/apis/vendor-snippets.ts index 9b400ee..196671a 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/vendor-snippets.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/vendor-snippets.ts @@ -32,7 +32,7 @@ import type { const createSnippetSchema = z.object({ snippetCode: z.string().min(1, "Snippet code is required"), slotId: z.number().optional(), - productIds: z.array(z.number().int().positive()).min(1, "At least one product is required"), + skuIds: z.array(z.number().int().positive()).min(1, "At least one product is required"), validTill: z.string().optional(), isPermanent: z.boolean().default(false) }); @@ -41,7 +41,7 @@ const updateSnippetSchema = z.object({ id: z.number().int().positive(), updates: createSnippetSchema.partial().extend({ snippetCode: z.string().min(1).optional(), - productIds: z.array(z.number().int().positive()).optional(), + skuIds: z.array(z.number().int().positive()).optional(), isPermanent: z.boolean().default(false) }), }); @@ -50,7 +50,7 @@ export const vendorSnippetsRouter = router({ create: protectedProcedure .input(createSnippetSchema) .mutation(async ({ input, ctx }): Promise => { - const { snippetCode, slotId, productIds, validTill, isPermanent } = input; + const { snippetCode, slotId, skuIds, validTill, isPermanent } = input; // Get staff user ID from auth middleware const staffUserId = ctx.staffUser?.id; @@ -65,8 +65,8 @@ export const vendorSnippetsRouter = router({ } } - const products = await getProductsByIdsInDb(productIds) - if (products.length !== productIds.length) { + const products = await getProductsByIdsInDb(skuIds) + if (products.length !== skuIds.length) { throw new Error("One or more invalid product IDs") } @@ -78,7 +78,7 @@ export const vendorSnippetsRouter = router({ const result = await createVendorSnippetInDb({ snippetCode, slotId, - productIds, + skuIds, isPermanent, validTill: validTill ? new Date(validTill) : undefined, }) @@ -97,9 +97,9 @@ export const vendorSnippetsRouter = router({ // Validate products exist const products = await db.query.productInfo.findMany({ - where: inArray(productInfo.id, productIds), + where: inArray(productInfo.id, skuIds), }); - if (products.length !== productIds.length) { + if (products.length !== skuIds.length) { throw new Error("One or more invalid product IDs"); } @@ -114,7 +114,7 @@ export const vendorSnippetsRouter = router({ const result = await db.insert(vendorSnippets).values({ snippetCode, slotId, - productIds, + skuIds, isPermanent, validTill: validTill ? new Date(validTill) : undefined, }).returning(); @@ -134,7 +134,7 @@ export const vendorSnippetsRouter = router({ const snippetsWithProducts = await Promise.all( result.map(async (snippet) => { - const products = await getProductsByIdsInDb(snippet.productIds) + const products = await getProductsByIdsInDb(snippet.skuIds) return { ...snippet, @@ -156,7 +156,7 @@ export const vendorSnippetsRouter = router({ const snippetsWithProducts = await Promise.all( result.map(async (snippet) => { const products = await db.query.productInfo.findMany({ - where: inArray(productInfo.id, snippet.productIds), + where: inArray(productInfo.id, snippet.skuIds), columns: { id: true, name: true }, }); @@ -226,9 +226,9 @@ export const vendorSnippetsRouter = router({ } } - if (updates.productIds) { - const products = await getProductsByIdsInDb(updates.productIds) - if (products.length !== updates.productIds.length) { + if (updates.skuIds) { + const products = await getProductsByIdsInDb(updates.skuIds) + if (products.length !== updates.skuIds.length) { throw new Error('One or more invalid product IDs') } } @@ -269,11 +269,11 @@ export const vendorSnippetsRouter = router({ } // Validate products if being updated - if (updates.productIds) { + if (updates.skuIds) { const products = await db.query.productInfo.findMany({ - where: inArray(productInfo.id, updates.productIds), + where: inArray(productInfo.id, updates.skuIds), }); - if (products.length !== updates.productIds.length) { + if (products.length !== updates.skuIds.length) { throw new Error("One or more invalid product IDs"); } } @@ -404,14 +404,14 @@ export const vendorSnippetsRouter = router({ const status = order.orderStatus; if (status[0].isCancelled) return false; const orderProductIds = order.orderItems.map(item => item.productId); - return snippet.productIds.some(productId => orderProductIds.includes(productId)); + return snippet.skuIds.some(productId => orderProductIds.includes(productId)); }); // Format the response const formattedOrders = filteredOrders.map(order => { // Filter orderItems to only include products attached to the snippet const attachedOrderItems = order.orderItems.filter(item => - snippet.productIds.includes(item.productId) + snippet.skuIds.includes(item.productId) ); const products = attachedOrderItems.map(item => ({ @@ -439,7 +439,7 @@ export const vendorSnippetsRouter = router({ sequence: order.slot.deliverySequence, } : null, products, - matchedProducts: snippet.productIds, // All snippet products are considered matched + matchedProducts: snippet.skuIds, // All snippet products are considered matched snippetCode: snippet.snippetCode, }; }); @@ -451,7 +451,7 @@ export const vendorSnippetsRouter = router({ id: snippet.id, snippetCode: snippet.snippetCode, slotId: snippet.slotId, - productIds: snippet.productIds, + skuIds: snippet.skuIds, validTill: snippet.validTill?.toISOString(), createdAt: snippet.createdAt.toISOString(), isPermanent: snippet.isPermanent, @@ -589,14 +589,14 @@ export const vendorSnippetsRouter = router({ const status = order.orderStatus; if (status[0]?.isCancelled) return false; const orderProductIds = order.orderItems.map(item => item.productId); - return snippet.productIds.some(productId => orderProductIds.includes(productId)); + return snippet.skuIds.some(productId => orderProductIds.includes(productId)); }); // Format the response const formattedOrders = filteredOrders.map(order => { // Filter orderItems to only include products attached to the snippet const attachedOrderItems = order.orderItems.filter(item => - snippet.productIds.includes(item.productId) + snippet.skuIds.includes(item.productId) ); const products = attachedOrderItems.map(item => ({ @@ -624,7 +624,7 @@ export const vendorSnippetsRouter = router({ sequence: order.slot.deliverySequence, } : null, products, - matchedProducts: snippet.productIds, + matchedProducts: snippet.skuIds, snippetCode: snippet.snippetCode, }; }); @@ -636,7 +636,7 @@ export const vendorSnippetsRouter = router({ id: snippet.id, snippetCode: snippet.snippetCode, slotId: snippet.slotId, - productIds: snippet.productIds, + skuIds: snippet.skuIds, validTill: snippet.validTill?.toISOString(), createdAt: snippet.createdAt ? snippet.createdAt.toISOString() : new Date(0).toISOString(), isPermanent: snippet.isPermanent, diff --git a/apps/backend/src/trpc/apis/user-apis/apis/coupon.ts b/apps/backend/src/trpc/apis/user-apis/apis/coupon.ts index c196fc5..a7b66c3 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/coupon.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/coupon.ts @@ -87,10 +87,10 @@ export const userCouponRouter = router({ }), getProductCoupons: protectedProcedure - .input(z.object({ productId: z.number().int().positive() })) + .input(z.object({ skuId: z.number().int().positive() })) .query(async ({ input, ctx }): Promise => { const userId = ctx.user.userId; - const { productId } = input; + const { skuId } = input; // Get all active, non-expired coupons const allCoupons = await getUserActiveCouponsWithRelationsInDb(userId) @@ -129,7 +129,7 @@ export const userCouponRouter = router({ const userApplicable = !coupon.isUserBased || applicableUsers.some(au => au.userId === userId); const applicableProducts = coupon.applicableProducts || []; - const productApplicable = applicableProducts.length === 0 || applicableProducts.some(ap => ap.productId === productId); + const productApplicable = applicableProducts.length === 0 || applicableProducts.some(ap => ap.skuId === skuId); return userApplicable && productApplicable; }); @@ -248,7 +248,7 @@ export const userCouponRouter = router({ discountPercent: reservedCoupon.discountPercent, flatDiscount: reservedCoupon.flatDiscount, minOrder: reservedCoupon.minOrder, - productIds: reservedCoupon.productIds, + skuIds: reservedCoupon.skuIds, maxValue: reservedCoupon.maxValue, isApplyForAll: false, validTill: reservedCoupon.validTill, diff --git a/apps/backend/wrangler-commands.md b/apps/backend/wrangler-commands.md index 25e25a9..40425d1 100644 --- a/apps/backend/wrangler-commands.md +++ b/apps/backend/wrangler-commands.md @@ -5,3 +5,6 @@ --file dumps/latest.sql --remote # run a single file +wrangler d1 execute freshyo-dev \ + --config wrangler.dev.toml \ + --file ../../packages/db_helper_sqlite/drizzle/0002_sku_split.sql \ No newline at end of file diff --git a/apps/user-ui/components/ProductCard.tsx b/apps/user-ui/components/ProductCard.tsx index 6d3e401..91dcad6 100644 --- a/apps/user-ui/components/ProductCard.tsx +++ b/apps/user-ui/components/ProductCard.tsx @@ -222,7 +222,7 @@ const ProductCard: React.FC = ({ )} - Quantity: {formatQuantity(item.productQuantity || 1, item.unitNotation).display} + Quantity: {item.unitNotation} {showDeliveryInfo && displayDeliveryDate && ( diff --git a/apps/user-ui/components/ProductDetail.tsx b/apps/user-ui/components/ProductDetail.tsx index 02205ca..0feed66 100644 --- a/apps/user-ui/components/ProductDetail.tsx +++ b/apps/user-ui/components/ProductDetail.tsx @@ -278,7 +278,7 @@ const ProductDetail: React.FC = ({ productId, isFlashDeliver ₹{productDetail.price} - / {formatQuantity(productDetail.productQuantity || 1, productDetail.unitNotation).display} + / {productDetail.unitNotation} {/* Show market price discount if available */} {productDetail.marketPrice && ( @@ -295,7 +295,7 @@ const ProductDetail: React.FC = ({ productId, isFlashDeliver {productAvailability?.isFlashAvailable && productDetail.flashPrice && productDetail.flashPrice !== productDetail.price && ( - 1 Hr Delivery: ₹{productDetail.flashPrice} / {formatQuantity(productDetail.productQuantity || 1, productDetail.unitNotation).display} + 1 Hr Delivery: ₹{productDetail.flashPrice} / {productDetail.unitNotation} )} @@ -462,7 +462,7 @@ const ProductDetail: React.FC = ({ productId, isFlashDeliver {productDetail.specialDeals.map((deal: { quantity: string; price: string; validTill: string }, index: number) => ( - Buy {deal.quantity} {formatQuantity(parseFloat(deal.quantity), productDetail.unitNotation).display} + Buy {deal.quantity} • {productDetail.unitNotation} ₹{deal.price} ))} diff --git a/apps/user-ui/components/SlotSpecificView.tsx b/apps/user-ui/components/SlotSpecificView.tsx index f4977e8..5e796a7 100644 --- a/apps/user-ui/components/SlotSpecificView.tsx +++ b/apps/user-ui/components/SlotSpecificView.tsx @@ -317,7 +317,7 @@ const CompactProductCard = ({ {item.marketPrice && Number(item.marketPrice) > Number(item.price) && ( ₹{item.marketPrice} )} - Quantity: {formatQuantity(item.productQuantity || 1, item.unit || item.unitNotation).display} + Quantity: {item.unit || item.unitNotation} diff --git a/apps/user-ui/components/cart-page.tsx b/apps/user-ui/components/cart-page.tsx index 1b4b897..c4202bb 100644 --- a/apps/user-ui/components/cart-page.tsx +++ b/apps/user-ui/components/cart-page.tsx @@ -461,12 +461,7 @@ export default function CartPage({ isFlashDelivery = false }: CartPageProps) { {(() => { - const qty = product?.productQuantity || 1; - const unit = product?.unitNotation || ''; - if (unit?.toLowerCase() === 'kg' && qty < 1) { - return `${Math.round(qty * 1000)}g`; - } - return `${qty}${unit}`; + return unit; })()} diff --git a/apps/user-ui/components/floating-cart-bar.tsx b/apps/user-ui/components/floating-cart-bar.tsx index 717278e..905ba43 100644 --- a/apps/user-ui/components/floating-cart-bar.tsx +++ b/apps/user-ui/components/floating-cart-bar.tsx @@ -60,12 +60,12 @@ const formatTimeRange = (deliveryTime: string | Date) => { }; // Product name component with quantity -const ProductNameWithQuantity = ({ name, productQuantity, unitNotation }: { name: string; productQuantity: number; unitNotation: string }) => { +const ProductNameWithQuantity = ({ name, unitNotation }: { name: string; unitNotation: string }) => { const truncatedName = name.length > 25 ? name.substring(0, 25) + '...' : name; const unit = unitNotation ? ` ${unitNotation}` : ''; return ( - {truncatedName} ({productQuantity}{unit}) + {truncatedName} ({unit}) ); }; @@ -272,7 +272,7 @@ const FloatingCartBar: React.FC = ({ style={tw`w-8 h-8 rounded-lg bg-slate-50 border border-slate-100`} /> - {formatQuantity(productsById[item.skuId]?.productQuantity || 1, productsById[item.skuId]?.unitNotation || '').display} + {productsById[item.skuId]?.unitNotation || ''} @@ -280,7 +280,6 @@ const FloatingCartBar: React.FC = ({ Select Delivery Slot {product?.name && ( - {product.name} ({product.productQuantity}{product.unitNotation ? ` ${product.unitNotation}` : ''}) + {product.name} ({product.unitNotation ? ` ${product.unitNotation}` : ''}) )} diff --git a/packages/db_helper_sqlite/drizzle/0002_sku_split.sql b/packages/db_helper_sqlite/drizzle/0002_sku_split.sql index 6d7c60b..aa127e2 100644 --- a/packages/db_helper_sqlite/drizzle/0002_sku_split.sql +++ b/packages/db_helper_sqlite/drizzle/0002_sku_split.sql @@ -52,11 +52,7 @@ INSERT INTO `sku_features` (`sku_id`, `feature_name`, `feature_value`) SELECT `ps`.`id`, 'quantity', - CASE - WHEN CAST(`pi`.`product_quantity` AS INTEGER) = `pi`.`product_quantity` - THEN CAST(CAST(`pi`.`product_quantity` AS INTEGER) AS TEXT) - ELSE CAST(`pi`.`product_quantity` AS TEXT) - END || COALESCE(`u`.`short_notation`, '') + (CAST(`pi`.`product_quantity` AS TEXT)) || COALESCE(`u`.`short_notation`, '') FROM `product_info` `pi` JOIN `product_skus` `ps` ON `ps`.`product_id` = `pi`.`id` LEFT JOIN `units` `u` ON `u`.`id` = `pi`.`unit_id`; diff --git a/packages/db_helper_sqlite/index.ts b/packages/db_helper_sqlite/index.ts index 703feb7..f40fb44 100644 --- a/packages/db_helper_sqlite/index.ts +++ b/packages/db_helper_sqlite/index.ts @@ -82,6 +82,7 @@ export { createSpecialDealsForProduct, updateProductDeals, replaceProductTags, + mergeSkus, toggleProductOutOfStock, updateSlotProducts, getSlotProductIds, diff --git a/packages/db_helper_sqlite/src/admin-apis/banner.ts b/packages/db_helper_sqlite/src/admin-apis/banner.ts index 76cdd32..6413bd6 100644 --- a/packages/db_helper_sqlite/src/admin-apis/banner.ts +++ b/packages/db_helper_sqlite/src/admin-apis/banner.ts @@ -8,7 +8,7 @@ export interface Banner { name: string imageUrl: string description: string | null - productIds: number[] | null + skuIds: number[] | null redirectUrl: string | null serialNum: number | null isActive: boolean @@ -28,7 +28,7 @@ export async function getBanners(): Promise { name: banner.name, imageUrl: banner.imageUrl, description: banner.description, - productIds: banner.productIds || [], + skuIds: banner.skuIds || [], redirectUrl: banner.redirectUrl, serialNum: banner.serialNum, isActive: banner.isActive, @@ -49,7 +49,7 @@ export async function getBannerById(id: number): Promise { name: banner.name, imageUrl: banner.imageUrl, description: banner.description, - productIds: banner.productIds || [], + skuIds: banner.skuIds || [], redirectUrl: banner.redirectUrl, serialNum: banner.serialNum, isActive: banner.isActive, @@ -65,7 +65,7 @@ export async function createBanner(input: CreateBannerInput): Promise { name: input.name, imageUrl: input.imageUrl, description: input.description, - productIds: input.productIds || [], + skuIds: input.skuIds || [], redirectUrl: input.redirectUrl, serialNum: input.serialNum, isActive: input.isActive, @@ -76,7 +76,7 @@ export async function createBanner(input: CreateBannerInput): Promise { name: banner.name, imageUrl: banner.imageUrl, description: banner.description, - productIds: banner.productIds || [], + skuIds: banner.skuIds || [], redirectUrl: banner.redirectUrl, serialNum: banner.serialNum, isActive: banner.isActive, @@ -101,7 +101,7 @@ export async function updateBanner(id: number, input: UpdateBannerInput): Promis name: banner.name, imageUrl: banner.imageUrl, description: banner.description, - productIds: banner.productIds || [], + skuIds: banner.skuIds || [], redirectUrl: banner.redirectUrl, serialNum: banner.serialNum, isActive: banner.isActive, diff --git a/packages/db_helper_sqlite/src/admin-apis/coupon.ts b/packages/db_helper_sqlite/src/admin-apis/coupon.ts index 9b917d4..2e173b3 100644 --- a/packages/db_helper_sqlite/src/admin-apis/coupon.ts +++ b/packages/db_helper_sqlite/src/admin-apis/coupon.ts @@ -9,7 +9,7 @@ export interface Coupon { discountPercent: string | null flatDiscount: string | null minOrder: string | null - productIds: number[] | null + skuIds: number[] | null maxValue: string | null isApplyForAll: boolean validTill: Date | null @@ -51,7 +51,9 @@ export async function getAllCoupons( }, applicableProducts: { with: { - product: true, + sku: { + with: { product: true }, + }, }, }, }, @@ -77,7 +79,9 @@ export async function getCouponById(id: number): Promise { }, applicableProducts: { with: { - product: true, + sku: { + with: { product: true }, + }, }, }, }, @@ -90,7 +94,7 @@ export interface CreateCouponInput { discountPercent?: string flatDiscount?: string minOrder?: string - productIds?: number[] | null + skuIds?: number[] | null maxValue?: string isApplyForAll: boolean validTill?: Date @@ -111,7 +115,7 @@ export async function createCouponWithRelations( discountPercent: input.discountPercent, flatDiscount: input.flatDiscount, minOrder: input.minOrder, - productIds: input.productIds, + skuIds: input.skuIds, createdBy: input.createdBy, maxValue: input.maxValue, isApplyForAll: input.isApplyForAll, @@ -131,9 +135,9 @@ export async function createCouponWithRelations( if (applicableProducts && applicableProducts.length > 0) { await tx.insert(couponApplicableProducts).values( - applicableProducts.map(productId => ({ + applicableProducts.map(skuId => ({ couponId: coupon.id, - productId, + skuId, })) ) } @@ -148,7 +152,7 @@ export interface UpdateCouponInput { discountPercent?: string flatDiscount?: string minOrder?: string - productIds?: number[] | null + skuIds?: number[] | null maxValue?: string isApplyForAll?: boolean validTill?: Date | null @@ -187,9 +191,9 @@ export async function updateCouponWithRelations( await tx.delete(couponApplicableProducts).where(eq(couponApplicableProducts.couponId, id)) if (applicableProducts.length > 0) { await tx.insert(couponApplicableProducts).values( - applicableProducts.map(productId => ({ + applicableProducts.map(skuId => ({ couponId: id, - productId, + skuId, })) ) } @@ -319,7 +323,7 @@ export async function createReservedCouponWithProducts( discountPercent: input.discountPercent, flatDiscount: input.flatDiscount, minOrder: input.minOrder, - productIds: input.productIds, + skuIds: input.skuIds, maxValue: input.maxValue, validTill: input.validTill, maxLimitForUser: input.maxLimitForUser, @@ -329,9 +333,9 @@ export async function createReservedCouponWithProducts( if (applicableProducts && applicableProducts.length > 0) { await tx.insert(couponApplicableProducts).values( - applicableProducts.map(productId => ({ + applicableProducts.map(skuId => ({ couponId: coupon.id, - productId, + skuId, })) ) } diff --git a/packages/db_helper_sqlite/src/admin-apis/order.ts b/packages/db_helper_sqlite/src/admin-apis/order.ts index 7dd8ac7..259a68b 100644 --- a/packages/db_helper_sqlite/src/admin-apis/order.ts +++ b/packages/db_helper_sqlite/src/admin-apis/order.ts @@ -605,7 +605,7 @@ export async function rebalanceSlots(slotIds: number[]): Promise { let newTotal = order.orderItems.reduce((acc: number, item: any) => { - const latestPrice = +item.product.price + const latestPrice = +item.sku.price const amount = latestPrice * Number(item.quantity) return acc + amount }, 0) order.orderItems.forEach((item: any) => { - item.price = item.product.price - item.discountedPrice = item.product.price + item.price = item.sku.price + item.discountedPrice = item.sku.price }) const coupon = order.couponUsages[0]?.coupon diff --git a/packages/db_helper_sqlite/src/admin-apis/product.ts b/packages/db_helper_sqlite/src/admin-apis/product.ts index a8102b6..1275a67 100644 --- a/packages/db_helper_sqlite/src/admin-apis/product.ts +++ b/packages/db_helper_sqlite/src/admin-apis/product.ts @@ -14,6 +14,14 @@ import { productTagInfo, users, storeInfo, + cartItems, + orderItems, + coupons, + reservedCoupons, + vendorSnippets, + homeBanners, + keyValStore, + couponApplicableProducts, } from '../db/schema' import { and, desc, eq, inArray, sql } from 'drizzle-orm' import type { InferInsertModel, InferSelectModel } from 'drizzle-orm' @@ -969,3 +977,128 @@ export async function replaceProductTags(productId: number, tagIds: number[]): P await db.insert(productTags).values(tagAssociations) } + +export async function mergeSkus(fromSkuId: number, toSkuId: number) { + if (fromSkuId === toSkuId) { + return { fromSkuId, toSkuId, message: 'SKUs are the same, no merge needed', counts: {} } + } + + const fromSku = await db.query.productSkus.findFirst({ where: eq(productSkus.id, fromSkuId) }) + if (!fromSku) throw new Error(`SKU ${fromSkuId} not found`) + + const toSku = await db.query.productSkus.findFirst({ where: eq(productSkus.id, toSkuId) }) + if (!toSku) throw new Error(`SKU ${toSkuId} not found`) + + const counts: Record = {} + + // 1. order_items — direct update + const orderItemsResult = await db.update(orderItems) + .set({ skuId: toSkuId }) + .where(eq(orderItems.skuId, fromSkuId)) + counts.orderItems = orderItemsResult.changes ?? 0 + + // 2. special_deals — direct update + const specialDealsResult = await db.update(specialDeals) + .set({ skuId: toSkuId }) + .where(eq(specialDeals.skuId, fromSkuId)) + counts.specialDeals = specialDealsResult.changes ?? 0 + + // 3. cart_items — delete all with fromSkuId + const cartResult = await db.delete(cartItems) + .where(eq(cartItems.skuId, fromSkuId)) + counts.cartItems = cartResult.changes ?? 0 + + // 4. coupon_applicable_products — update, but handle unique constraint + // First delete rows where (coupon_id, toSkuId) already exists + const existingCapRows = await db.query.couponApplicableProducts.findMany({ + where: eq(couponApplicableProducts.skuId, toSkuId), + columns: { couponId: true }, + }) + const existingCouponIds = new Set(existingCapRows.map((r) => r.couponId)) + + if (existingCouponIds.size > 0) { + const dupResult = await db.delete(couponApplicableProducts) + .where( + and( + eq(couponApplicableProducts.skuId, fromSkuId), + inArray(couponApplicableProducts.couponId, Array.from(existingCouponIds)) + ) + ) + counts.couponDedupDeleted = dupResult.changes ?? 0 + } + + // Now update remaining rows + const capResult = await db.update(couponApplicableProducts) + .set({ skuId: toSkuId }) + .where(eq(couponApplicableProducts.skuId, fromSkuId)) + counts.couponApplicable = capResult.changes ?? 0 + + // 5. JSON arrays — remap fromSkuId to toSkuId + const jsonTables: Array<{ table: any; column: string; name: string }> = [ + { table: deliverySlotInfo, column: 'skuIds', name: 'deliverySlotInfo' }, + { table: homeBanners, column: 'skuIds', name: 'homeBanners' }, + { table: coupons, column: 'skuIds', name: 'coupons' }, + { table: reservedCoupons, column: 'skuIds', name: 'reservedCoupons' }, + { table: vendorSnippets, column: 'skuIds', name: 'vendorSnippets' }, + ] + + for (const { table, column, name } of jsonTables) { + const rows = await db.select({ id: table.id, ids: table[column] }).from(table) + let updated = 0 + for (const row of rows) { + const ids: number[] = (row.ids as number[]) || [] + if (!ids.includes(fromSkuId)) continue + const newIds = ids.map((id) => (id === fromSkuId ? toSkuId : id)) + const deduped = [...new Set(newIds)] + if (deduped.length !== ids.length || deduped.some((id, i) => id !== ids[i])) { + await db.update(table).set({ [column]: deduped } as any).where(eq(table.id, row.id)) + updated++ + } + } + counts[name] = updated + } + + // popularItems in key_val_store + const kvRow = await db.query.keyValStore.findFirst({ + where: eq(keyValStore.key, 'popularItems'), + }) + if (kvRow && kvRow.value) { + try { + const arr: number[] = JSON.parse(kvRow.value) + if (arr.includes(fromSkuId)) { + const newArr = [...new Set(arr.map((id) => (id === fromSkuId ? toSkuId : id)))] + await db.update(keyValStore) + .set({ value: JSON.stringify(newArr) }) + .where(eq(keyValStore.key, 'popularItems')) + counts.popularItems = 1 + } + } catch { /* value not valid JSON, skip */ } + } + + // 6. Delete SKU features and the SKU itself + const featuresResult = await db.delete(skuFeatures).where(eq(skuFeatures.skuId, fromSkuId)) + counts.skuFeatures = featuresResult.changes ?? 0 + + const skuResult = await db.delete(productSkus).where(eq(productSkus.id, fromSkuId)) + counts.productSkus = skuResult.changes ?? 0 + + // 7. Delete orphaned product + const remainingSkus = await db.query.productSkus.findMany({ + where: eq(productSkus.productId, fromSku.productId), + columns: { id: true }, + }) + + let orphanedProductId: number | undefined + if (remainingSkus.length === 0) { + await db.delete(productInfo).where(eq(productInfo.id, fromSku.productId)) + orphanedProductId = fromSku.productId + counts.orphanedProduct = 1 + } + + return { + fromSkuId, + toSkuId, + orphanedProductId, + counts, + } +} diff --git a/packages/db_helper_sqlite/src/admin-apis/vendor-snippets.ts b/packages/db_helper_sqlite/src/admin-apis/vendor-snippets.ts index c52e105..b32107c 100644 --- a/packages/db_helper_sqlite/src/admin-apis/vendor-snippets.ts +++ b/packages/db_helper_sqlite/src/admin-apis/vendor-snippets.ts @@ -1,5 +1,5 @@ import { db } from '../db/db_index' -import { vendorSnippets, deliverySlotInfo, productInfo, orders, orderItems, orderStatus } from '../db/schema' +import { vendorSnippets, deliverySlotInfo, productInfo, productSkus, orders, orderItems, orderStatus } from '../db/schema' import { desc, eq, inArray } from 'drizzle-orm' import type { InferSelectModel } from 'drizzle-orm' import type { @@ -19,7 +19,7 @@ const mapVendorSnippet = (snippet: VendorSnippetRow): AdminVendorSnippet => ({ id: snippet.id, snippetCode: snippet.snippetCode, slotId: snippet.slotId ?? null, - productIds: snippet.productIds || [], + skuIds: snippet.skuIds || [], isPermanent: snippet.isPermanent, validTill: coerceDate(snippet.validTill), createdAt: coerceDate(snippet.createdAt) ?? new Date(0), @@ -91,14 +91,14 @@ export async function getAllVendorSnippets(): Promise { const [result] = await db.insert(vendorSnippets).values({ snippetCode: input.snippetCode, slotId: input.slotId, - productIds: input.productIds, + skuIds: input.skuIds, isPermanent: input.isPermanent, validTill: input.validTill, }).returning() @@ -109,7 +109,7 @@ export async function createVendorSnippet(input: { export async function updateVendorSnippet(id: number, updates: { snippetCode?: string slotId?: number | null - productIds?: number[] + skuIds?: number[] isPermanent?: boolean validTill?: Date | null }): Promise { @@ -129,14 +129,17 @@ export async function deleteVendorSnippet(id: number): Promise { - const products = await db.query.productInfo.findMany({ - where: inArray(productInfo.id, productIds), - columns: { id: true, name: true }, +export async function getProductsByIds(skuIds: number[]): Promise { + const skus = await db.query.productSkus.findMany({ + where: inArray(productSkus.id, skuIds), + with: { product: { columns: { name: true } } }, + columns: { id: true }, }) - const prods = products.map(mapProductSummary) - return prods + return skus.map((sku) => ({ + id: sku.id, + name: sku.product?.name ?? 'Unknown', + })) as AdminVendorSnippetProduct[] } export async function getVendorSlotById(slotId: number): Promise { diff --git a/packages/db_helper_sqlite/src/helper_methods/coupon.ts b/packages/db_helper_sqlite/src/helper_methods/coupon.ts index 4320252..be1c187 100644 --- a/packages/db_helper_sqlite/src/helper_methods/coupon.ts +++ b/packages/db_helper_sqlite/src/helper_methods/coupon.ts @@ -9,7 +9,7 @@ export interface Coupon { discountPercent: string | null; flatDiscount: string | null; minOrder: string | null; - productIds: number[] | null; + skuIds: number[] | null; maxValue: string | null; isApplyForAll: boolean; validTill: Date | null; @@ -252,7 +252,7 @@ export interface CreateCouponInput { discountPercent?: string; flatDiscount?: string; minOrder?: string; - productIds?: number[] | null; + skuIds?: number[] | null; maxValue?: string; isApplyForAll: boolean; validTill?: Date; @@ -274,7 +274,7 @@ export async function createCouponWithRelations( discountPercent: input.discountPercent, flatDiscount: input.flatDiscount, minOrder: input.minOrder, - productIds: input.productIds, + skuIds: input.skuIds, createdBy: input.createdBy, maxValue: input.maxValue, isApplyForAll: input.isApplyForAll, @@ -296,9 +296,9 @@ export async function createCouponWithRelations( // Insert applicable products if (applicableProducts && applicableProducts.length > 0) { await tx.insert(couponApplicableProducts).values( - applicableProducts.map(productId => ({ + applicableProducts.map(skuId => ({ couponId: coupon.id, - productId, + skuId, })) ); } @@ -310,7 +310,7 @@ export async function createCouponWithRelations( discountPercent: coupon.discountPercent, flatDiscount: coupon.flatDiscount, minOrder: coupon.minOrder, - productIds: coupon.productIds, + skuIds: coupon.skuIds, maxValue: coupon.maxValue, isApplyForAll: coupon.isApplyForAll, validTill: coupon.validTill, @@ -329,7 +329,7 @@ export interface UpdateCouponInput { discountPercent?: string; flatDiscount?: string; minOrder?: string; - productIds?: number[] | null; + skuIds?: number[] | null; maxValue?: string; isApplyForAll?: boolean; validTill?: Date | null; @@ -371,9 +371,9 @@ export async function updateCouponWithRelations( await tx.delete(couponApplicableProducts).where(eq(couponApplicableProducts.couponId, id)); if (applicableProducts.length > 0) { await tx.insert(couponApplicableProducts).values( - applicableProducts.map(productId => ({ + applicableProducts.map(skuId => ({ couponId: id, - productId, + skuId, })) ); } @@ -386,7 +386,7 @@ export async function updateCouponWithRelations( discountPercent: coupon.discountPercent, flatDiscount: coupon.flatDiscount, minOrder: coupon.minOrder, - productIds: coupon.productIds, + skuIds: coupon.skuIds, maxValue: coupon.maxValue, isApplyForAll: coupon.isApplyForAll, validTill: coupon.validTill, @@ -442,7 +442,7 @@ export async function generateCancellationCoupon( discountPercent: coupon.discountPercent, flatDiscount: coupon.flatDiscount, minOrder: coupon.minOrder, - productIds: coupon.productIds, + skuIds: coupon.skuIds, maxValue: coupon.maxValue, isApplyForAll: coupon.isApplyForAll, validTill: coupon.validTill, @@ -461,7 +461,7 @@ export interface CreateReservedCouponInput { discountPercent?: string; flatDiscount?: string; minOrder?: string; - productIds?: number[] | null; + skuIds?: number[] | null; maxValue?: string; validTill?: Date; maxLimitForUser?: number; @@ -480,7 +480,7 @@ export async function createReservedCouponWithProducts( discountPercent: input.discountPercent, flatDiscount: input.flatDiscount, minOrder: input.minOrder, - productIds: input.productIds, + skuIds: input.skuIds, maxValue: input.maxValue, validTill: input.validTill, maxLimitForUser: input.maxLimitForUser, @@ -491,9 +491,9 @@ export async function createReservedCouponWithProducts( // Insert applicable products if provided if (applicableProducts && applicableProducts.length > 0) { await tx.insert(couponApplicableProducts).values( - applicableProducts.map(productId => ({ + applicableProducts.map(skuId => ({ couponId: coupon.id, - productId, + skuId, })) ); } @@ -577,7 +577,7 @@ export async function createCouponForUser( discountPercent: coupon.discountPercent, flatDiscount: coupon.flatDiscount, minOrder: coupon.minOrder, - productIds: coupon.productIds, + skuIds: coupon.skuIds, maxValue: coupon.maxValue, isApplyForAll: coupon.isApplyForAll, validTill: coupon.validTill, diff --git a/packages/db_helper_sqlite/src/stores/store-helpers.ts b/packages/db_helper_sqlite/src/stores/store-helpers.ts index 90f5701..d0905db 100644 --- a/packages/db_helper_sqlite/src/stores/store-helpers.ts +++ b/packages/db_helper_sqlite/src/stores/store-helpers.ts @@ -125,7 +125,7 @@ export async function getAllProductsForCache(): Promise { images: sku.images, isOutOfStock: sku.isOutOfStock, storeId: sku.product?.storeId ?? null, - unitNotation: features.map((f) => f.featureValue).join(' '), + unitNotation: features.map((f) => f.featureValue.replace(/\.0(?=\D|$)/g, '')).join(' '), incrementStep: sku.product?.incrementStep ?? 1, productQuantity: 1, isFlashAvailable: sku.isFlashAvailable, @@ -301,7 +301,7 @@ export async function getAllSlotsWithProductsForCache(): Promise f.featureValue).join(' '), + unitNotation: features.map((f: any) => f.featureValue.replace(/\.0(?=\D|$)/g, '')).join(' '), store: sku.product?.store ? { id: sku.product.store.id, name: sku.product.store.name, diff --git a/packages/db_helper_sqlite/src/user-apis/coupon.ts b/packages/db_helper_sqlite/src/user-apis/coupon.ts index 61b11a2..48f2349 100644 --- a/packages/db_helper_sqlite/src/user-apis/coupon.ts +++ b/packages/db_helper_sqlite/src/user-apis/coupon.ts @@ -23,7 +23,7 @@ const mapCoupon = (coupon: CouponRow): UserCoupon => ({ discountPercent: coupon.discountPercent ? coupon.discountPercent.toString() : null, flatDiscount: coupon.flatDiscount ? coupon.flatDiscount.toString() : null, minOrder: coupon.minOrder ? coupon.minOrder.toString() : null, - productIds: coupon.productIds, + skuIds: coupon.skuIds, maxValue: coupon.maxValue ? coupon.maxValue.toString() : null, isApplyForAll: coupon.isApplyForAll, validTill: coupon.validTill ?? null, @@ -51,7 +51,7 @@ const mapApplicableUser = (applicable: CouponApplicableUserRow): UserCouponAppli const mapApplicableProduct = (applicable: CouponApplicableProductRow): UserCouponApplicableProduct => ({ id: applicable.id, couponId: applicable.couponId, - productId: applicable.productId, + skuId: applicable.skuId, }) const mapCouponWithRelations = (coupon: CouponRow & { @@ -119,7 +119,7 @@ export async function redeemReservedCoupon(userId: number, reservedCoupon: Reser discountPercent: reservedCoupon.discountPercent, flatDiscount: reservedCoupon.flatDiscount, minOrder: reservedCoupon.minOrder, - productIds: reservedCoupon.productIds, + skuIds: reservedCoupon.skuIds, maxValue: reservedCoupon.maxValue, isApplyForAll: false, validTill: reservedCoupon.validTill, diff --git a/packages/db_helper_sqlite/src/user-apis/product.ts b/packages/db_helper_sqlite/src/user-apis/product.ts index 900b7e4..17d8194 100644 --- a/packages/db_helper_sqlite/src/user-apis/product.ts +++ b/packages/db_helper_sqlite/src/user-apis/product.ts @@ -51,7 +51,7 @@ export async function getProductDetailById(skuId: number): Promise f.featureValue).join(' '), + unitNotation: features.map((f) => f.featureValue.replace(/\.0(?=\D|$)/g, '')).join(' '), images: getStringArray(sku.images), isOutOfStock: sku.isOutOfStock, store: storeData ? { diff --git a/packages/db_helper_sqlite/src/user-apis/stores.ts b/packages/db_helper_sqlite/src/user-apis/stores.ts index 9383afb..d5d2a25 100644 --- a/packages/db_helper_sqlite/src/user-apis/stores.ts +++ b/packages/db_helper_sqlite/src/user-apis/stores.ts @@ -124,8 +124,8 @@ export async function getStoreDetail(storeId: number): Promise f.featureValue).join(' '), - unitNotation: features.map((f) => f.featureValue).join(' '), + unit: features.map((f) => f.featureValue.replace(/\.0(?=\D|$)/g, '')).join(' '), + unitNotation: features.map((f) => f.featureValue.replace(/\.0(?=\D|$)/g, '')).join(' '), images: getStringArray(sku.images), isOutOfStock: sku.isOutOfStock, productQuantity: 1, diff --git a/packages/shared/types/banner.types.ts b/packages/shared/types/banner.types.ts index a010158..0b8e548 100644 --- a/packages/shared/types/banner.types.ts +++ b/packages/shared/types/banner.types.ts @@ -8,7 +8,7 @@ export interface Banner { name: string; imageUrl: string; description: string | null; - productIds: number[] | null; + skuIds: number[] | null; redirectUrl: string | null; serialNum: number | null; isActive: boolean; diff --git a/scripts/s3-cleaner.js b/scripts/s3-cleaner.js new file mode 100644 index 0000000..d60e8f2 --- /dev/null +++ b/scripts/s3-cleaner.js @@ -0,0 +1,124 @@ +#!/usr/bin/env bun +// s3-cleaner.js — Delete old versioned cache folders from S3/R2 +// Usage: bun s3-cleaner.js +// Example: bun s3-cleaner.js 235 +// Deletes all objects under api-cache/v-0/ through api-cache/v-234/ + +// ============================================================ +// CREDENTIALS — replace with your actual values +// ============================================================ +const S3_ACCESS_KEY_ID = 'YOUR_ACCESS_KEY_ID' +const S3_SECRET_ACCESS_KEY = 'YOUR_SECRET_ACCESS_KEY' +const S3_REGION = 'auto' // 'us-east-1' for AWS, 'auto' for R2 +const S3_ENDPOINT = 'https://your-account.r2.cloudflarestorage.com' // S3 or R2 endpoint +const S3_BUCKET = 'your-bucket-name' +const API_CACHE_KEY = 'api-cache' // matches API_CACHE_KEY env var in backend + +// ============================================================ + +const threshold = parseInt(process.argv[2]) +if (!threshold || isNaN(threshold) || threshold <= 0) { + console.error('Usage: bun s3-cleaner.js ') + console.error('Example: bun s3-cleaner.js 235') + console.error(' Deletes all v-{n} folders where n < 235') + process.exit(1) +} + +const cachePrefix = API_CACHE_KEY.endsWith('/') ? API_CACHE_KEY : `${API_CACHE_KEY}/` +const versionPattern = new RegExp(`^${cachePrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}v-(\\d+)/`) + +console.log(`🧹 S3 Cache Cleaner — keeping v-${threshold}+, deleting v-0 through v-${threshold - 1}`) +console.log(` Bucket: ${S3_BUCKET}`) +console.log(` Prefix: ${cachePrefix}`) + +const s3 = new Bun.S3Client({ + accessKeyId: S3_ACCESS_KEY_ID, + secretAccessKey: S3_SECRET_ACCESS_KEY, + region: S3_REGION, + endpoint: S3_ENDPOINT, + bucket: S3_BUCKET, +}) + +async function listAllObjects(prefix) { + const allKeys = [] + let continuationToken + + do { + const options = { prefix, maxKeys: 1000 } + if (continuationToken) options.continuationToken = continuationToken + + const result = await s3.list(options) + for (const obj of result.contents) { + allKeys.push(obj.key) + } + continuationToken = result.nextContinuationToken + + process.stdout.write(`\r Listed ${allKeys.length} objects...`) + } while (continuationToken) + + console.log('') + return allKeys +} + +async function deleteObjects(keys) { + let deleted = 0 + const batchSize = 1000 + + for (let i = 0; i < keys.length; i += batchSize) { + const batch = keys.slice(i, i + batchSize) + const objects = batch.map((key) => ({ key })) + + const result = await s3.deleteObjects({ objects }) + deleted += result.deleted?.length ?? 0 + + process.stdout.write(`\r Deleted ${deleted}/${keys.length}...`) + } + + console.log('') + return deleted +} + +// ============================================================ +// MAIN +// ============================================================ +try { + console.log('\n📋 Listing objects...') + const allObjects = await listAllObjects(cachePrefix) + + const toDelete = [] + const versionsSeen = new Set() + for (const key of allObjects) { + const match = key.match(versionPattern) + if (match) { + const ver = parseInt(match[1]) + if (ver < threshold) { + toDelete.push(key) + } + versionsSeen.add(ver) + } + } + + const sortedVersions = [...versionsSeen].sort((a, b) => a - b) + + if (sortedVersions.length === 0) { + console.log('\n✅ No cache objects found.') + process.exit(0) + } + + console.log(`\n📊 Found versions: v-${sortedVersions[0]} through v-${sortedVersions[sortedVersions.length - 1]}`) + const oldVersionCount = sortedVersions.filter((v) => v < threshold).length + console.log(` To delete: ${toDelete.length} objects across ${oldVersionCount} version(s)`) + console.log(` To keep: v-${threshold}+`) + + if (toDelete.length === 0) { + console.log('\n✅ Nothing to delete.') + process.exit(0) + } + + console.log('\n❗ Proceeding with deletion...') + const count = await deleteObjects(toDelete) + console.log(`\n✅ Done — deleted ${count} objects.`) +} catch (error) { + console.error(`\n❌ Error: ${error.message}`) + process.exit(1) +} From 648a0a5d2864630c7e47b888a960e1fd61798099 Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:54:46 +0530 Subject: [PATCH 05/73] enh --- apps/admin-ui/app/(drawer)/products/edit.tsx | 2 + apps/admin-ui/src/components/ProductForm.tsx | 1 + apps/backend/src/sqliteImporter.ts | 6 +- .../src/trpc/apis/admin-apis/apis/product.ts | 2 + .../src/trpc/apis/admin-apis/apis/slots.ts | 2 +- .../apis/admin-apis/apis/vendor-snippets.ts | 28 ++--- apps/user-ui/components/cart-page.tsx | 1 + .../src/components/AddToCartDialog.tsx | 4 +- packages/db_helper_sqlite/index.ts | 6 +- .../src/admin-apis/product.ts | 110 ++++++++++-------- .../src/admin-apis/vendor-snippets.ts | 15 ++- packages/db_helper_sqlite/src/db/porter.ts | 12 +- .../db_helper_sqlite/src/user-apis/cart.ts | 72 ++++++------ .../db_helper_sqlite/src/user-apis/order.ts | 66 ++++++----- .../db_helper_sqlite/src/user-apis/product.ts | 46 ++++---- packages/shared/types/admin.ts | 18 +-- 16 files changed, 207 insertions(+), 184 deletions(-) diff --git a/apps/admin-ui/app/(drawer)/products/edit.tsx b/apps/admin-ui/app/(drawer)/products/edit.tsx index e343794..9cf0a7d 100644 --- a/apps/admin-ui/app/(drawer)/products/edit.tsx +++ b/apps/admin-ui/app/(drawer)/products/edit.tsx @@ -42,6 +42,7 @@ export default function EditProduct() { longDescription: productData.longDescription || '', storeId: productData.storeId || 1, variants: (productData.skus || []).map((sku) => ({ + id: sku.id, name: sku.name || '', price: sku.price || '', marketPrice: sku.marketPrice || '', @@ -106,6 +107,7 @@ export default function EditProduct() { const allUrls = [...existingUrls, ...newUrls] return { + id: variant.id, name: variant.name || null, price: parseFloat(variant.price), marketPrice: variant.marketPrice ? parseFloat(variant.marketPrice) : undefined, diff --git a/apps/admin-ui/src/components/ProductForm.tsx b/apps/admin-ui/src/components/ProductForm.tsx index 7b660ca..b87a346 100644 --- a/apps/admin-ui/src/components/ProductForm.tsx +++ b/apps/admin-ui/src/components/ProductForm.tsx @@ -11,6 +11,7 @@ interface Attribute { } interface Variant { + id?: number name: string price: string marketPrice: string diff --git a/apps/backend/src/sqliteImporter.ts b/apps/backend/src/sqliteImporter.ts index b55fb03..1e9fc4d 100644 --- a/apps/backend/src/sqliteImporter.ts +++ b/apps/backend/src/sqliteImporter.ts @@ -69,11 +69,11 @@ export { checkProductExistsByName, checkUnitExists, getProductImagesById, - createSpecialDealsForProduct, - updateProductDeals, + createSpecialDealsForSku, + updateSkuDeals, replaceProductTags, mergeSkus, - toggleProductOutOfStock, + toggleSkuOutOfStock, updateSlotProducts, getSlotProductIds, getSlotsProductIds, diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts index 0eb8a5d..55eccc6 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts @@ -310,6 +310,7 @@ export const productRouter = router({ storeId: z.number().min(1, 'Store is required'), incrementStep: z.number().optional().default(1), skus: z.array(z.object({ + id: z.number().optional(), name: z.string().optional().nullable(), price: z.number().positive('Price must be positive'), marketPrice: z.number().optional().nullable(), @@ -334,6 +335,7 @@ export const productRouter = router({ const allUploadUrls: string[] = skus.flatMap((sku) => sku.images) const skuInputs = skus.map((sku) => ({ + id: sku.id, name: sku.name ?? null, price: sku.price, marketPrice: sku.marketPrice ?? null, diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/slots.ts b/apps/backend/src/trpc/apis/admin-apis/apis/slots.ts index 2de0549..c2e622c 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/slots.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/slots.ts @@ -209,7 +209,7 @@ export const slotsRouter = router({ }); } - const result = await updateSlotProductsInDb(String(slotId), productIds.map(String)) + const result = await updateSlotProductsInDb(String(slotId), skuIds.map(String)) /* // Old implementation - direct DB queries: diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/vendor-snippets.ts b/apps/backend/src/trpc/apis/admin-apis/apis/vendor-snippets.ts index 196671a..d077771 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/vendor-snippets.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/vendor-snippets.ts @@ -403,7 +403,7 @@ export const vendorSnippetsRouter = router({ const filteredOrders = matchingOrders.filter(order => { const status = order.orderStatus; if (status[0].isCancelled) return false; - const orderProductIds = order.orderItems.map(item => item.productId); + const orderProductIds = order.orderItems.map(item => item.skuId); return snippet.skuIds.some(productId => orderProductIds.includes(productId)); }); @@ -411,17 +411,17 @@ export const vendorSnippetsRouter = router({ const formattedOrders = filteredOrders.map(order => { // Filter orderItems to only include products attached to the snippet const attachedOrderItems = order.orderItems.filter(item => - snippet.skuIds.includes(item.productId) + snippet.skuIds.includes(item.skuId) ); const products = attachedOrderItems.map(item => ({ orderItemId: item.id, - productId: item.productId, - productName: item.product.name, + productId: item.skuId, + productName: item.sku?.product?.name || 'Unknown', quantity: parseFloat(item.quantity), - productSize: item.product.productQuantity, + productSize: 1, price: parseFloat((item.price ?? 0).toString()), - unit: item.product.unit?.shortNotation || 'unit', + unit: (item.sku?.features || []).map((f: any) => f.featureValue).join(' ') || 'unit', subtotal: parseFloat((item.price ?? 0).toString()) * parseFloat(item.quantity), is_packaged: item.is_packaged, is_package_verified: item.is_package_verified, @@ -488,9 +488,9 @@ export const vendorSnippetsRouter = router({ orderDate: order.createdAt ? order.createdAt.toISOString() : new Date(0).toISOString(), totalQuantity: order.orderItems.reduce((sum, item) => sum + parseFloat(item.quantity || '0'), 0), products: order.orderItems.map(item => ({ - name: item.product.name, + name: item.sku?.product?.name || 'Unknown', quantity: parseFloat(item.quantity || '0'), - unit: item.product.unit?.shortNotation || 'unit', + unit: (item.sku?.features || []).map((f: any) => f.featureValue).join(' ') || 'unit', })), })) }), @@ -588,7 +588,7 @@ export const vendorSnippetsRouter = router({ const filteredOrders = matchingOrders.filter(order => { const status = order.orderStatus; if (status[0]?.isCancelled) return false; - const orderProductIds = order.orderItems.map(item => item.productId); + const orderProductIds = order.orderItems.map(item => item.skuId); return snippet.skuIds.some(productId => orderProductIds.includes(productId)); }); @@ -596,18 +596,18 @@ export const vendorSnippetsRouter = router({ const formattedOrders = filteredOrders.map(order => { // Filter orderItems to only include products attached to the snippet const attachedOrderItems = order.orderItems.filter(item => - snippet.skuIds.includes(item.productId) + snippet.skuIds.includes(item.skuId) ); const products = attachedOrderItems.map(item => ({ orderItemId: item.id, - productId: item.productId, - productName: item.product.name, + productId: item.skuId, + productName: item.sku?.product?.name || 'Unknown', quantity: parseFloat(item.quantity), price: parseFloat((item.price ?? 0).toString()), - unit: item.product.unit?.shortNotation || 'unit', + unit: (item.sku?.features || []).map((f: any) => f.featureValue).join(' ') || 'unit', subtotal: parseFloat((item.price ?? 0).toString()) * parseFloat(item.quantity), - productSize: item.product.productQuantity, + productSize: 1, is_packaged: item.is_packaged, is_package_verified: item.is_package_verified, })); diff --git a/apps/user-ui/components/cart-page.tsx b/apps/user-ui/components/cart-page.tsx index c4202bb..af73daa 100644 --- a/apps/user-ui/components/cart-page.tsx +++ b/apps/user-ui/components/cart-page.tsx @@ -461,6 +461,7 @@ export default function CartPage({ isFlashDelivery = false }: CartPageProps) { {(() => { + const unit = product?.unitNotation || ''; return unit; })()} diff --git a/apps/user-ui/src/components/AddToCartDialog.tsx b/apps/user-ui/src/components/AddToCartDialog.tsx index 2f04705..b7f2d49 100644 --- a/apps/user-ui/src/components/AddToCartDialog.tsx +++ b/apps/user-ui/src/components/AddToCartDialog.tsx @@ -67,7 +67,7 @@ export default function AddToCartDialog() { // Pre-select cart's slotId and quantity if item is already in cart useEffect(() => { if (isOpen && product) { - const cartItem = cartData?.items?.find((item: any) => item.productId === product.id); + const cartItem = cartData?.items?.find((item: any) => item.skuId === product.id); const cartQuantity = cartItem?.quantity || 0; // Set quantity: 0 → 1, >1 → keep as is @@ -109,7 +109,7 @@ export default function AddToCartDialog() { .filter((slot) => dayjs(slot.freezeTime).isAfter(dayjs())); // Find cart item for this product - const cartItem = cartData?.items?.find((item: any) => item.productId === product?.id); + const cartItem = cartData?.items?.find((item: any) => item.skuId === product?.id); // Determine if updating existing item (quantity > 1 means it's an update) const isUpdate = (cartItem?.quantity || 0) >= 1; diff --git a/packages/db_helper_sqlite/index.ts b/packages/db_helper_sqlite/index.ts index f40fb44..d275d72 100644 --- a/packages/db_helper_sqlite/index.ts +++ b/packages/db_helper_sqlite/index.ts @@ -79,11 +79,11 @@ export { checkProductExistsByName, checkUnitExists, getProductImagesById, - createSpecialDealsForProduct, - updateProductDeals, + createSpecialDealsForSku, + updateSkuDeals, replaceProductTags, mergeSkus, - toggleProductOutOfStock, + toggleSkuOutOfStock, updateSlotProducts, getSlotProductIds, getSlotsProductIds, diff --git a/packages/db_helper_sqlite/src/admin-apis/product.ts b/packages/db_helper_sqlite/src/admin-apis/product.ts index 1275a67..35637d2 100644 --- a/packages/db_helper_sqlite/src/admin-apis/product.ts +++ b/packages/db_helper_sqlite/src/admin-apis/product.ts @@ -109,7 +109,7 @@ const mapSku = (sku: SkuRow, features: SkuFeatureRow[] = []): AdminSku => ({ const mapSpecialDeal = (deal: SpecialDealRow): AdminSpecialDeal => ({ id: deal.id, - productId: deal.productId, + skuId: deal.skuId, quantity: String(deal.quantity ?? '0'), price: String(deal.price ?? '0'), validTill: deal.validTill, @@ -298,37 +298,54 @@ export async function updateProduct(id: number, input: any): Promise skus.map((sku) => sku.id)) + }) + const existingSkuIdSet = new Set(existingSkus.map((s) => s.id)) - if (existingSkuIds.length > 0) { - await db.delete(skuFeatures).where(inArray(skuFeatures.skuId, existingSkuIds)) - await db.delete(productSkus).where(inArray(productSkus.id, existingSkuIds)) - } + for (const sku of skus) { + if (sku.id != null && existingSkuIdSet.has(sku.id)) { + // Update existing SKU + await db.update(productSkus) + .set({ + name: sku.name ?? null, + price: String(sku.price), + marketPrice: sku.marketPrice != null ? String(sku.marketPrice) : null, + images: sku.images ?? null, + isFlashAvailable: sku.isFlashAvailable ?? false, + flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null, + }) + .where(eq(productSkus.id, sku.id)) - const skuRows = await db.insert(productSkus).values( - skus.map((sku: any) => ({ - productId: id, - name: sku.name ?? null, - price: String(sku.price), - marketPrice: sku.marketPrice != null ? String(sku.marketPrice) : null, - images: sku.images ?? null, - isFlashAvailable: sku.isFlashAvailable ?? false, - flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null, - })) - ).returning() + await db.delete(skuFeatures).where(eq(skuFeatures.skuId, sku.id)) + await db.insert(skuFeatures).values( + sku.features.map((f: any) => ({ + skuId: sku.id, + featureName: f.featureName, + featureValue: f.featureValue, + })) + ) + } else { + // Insert new SKU + const [newSku] = await db.insert(productSkus).values({ + productId: id, + name: sku.name ?? null, + price: String(sku.price), + marketPrice: sku.marketPrice != null ? String(sku.marketPrice) : null, + images: sku.images ?? null, + isFlashAvailable: sku.isFlashAvailable ?? false, + flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null, + }).returning() - for (let i = 0; i < skuRows.length; i++) { - const sku = skus[i] - await db.insert(skuFeatures).values( - sku.features.map((f: any) => ({ - skuId: skuRows[i].id, - featureName: f.featureName, - featureValue: f.featureValue, - })) - ) + await db.insert(skuFeatures).values( + sku.features.map((f: any) => ({ + skuId: newSku.id, + featureName: f.featureName, + featureValue: f.featureValue, + })) + ) + } } } @@ -353,9 +370,9 @@ export async function updateProduct(id: number, input: any): Promise { - const product = await db.query.productInfo.findFirst({ - where: eq(productInfo.id, id), +export async function toggleSkuOutOfStock(id: number): Promise { + const product = await db.query.productSkus.findFirst({ + where: eq(productSkus.id, id), }) if (!product) { @@ -363,18 +380,18 @@ export async function toggleProductOutOfStock(id: number): Promise { @@ -386,16 +403,15 @@ export async function updateSlotProducts(slotId: string, productIds: string[]): throw new Error(`Slot ${slotId} not found`) } - const currentProductIds = slot.productIds || [] - const newProductIds = productIds.map((id: string) => parseInt(id)) + const currentSkuIds = slot.skuIds || [] + const newSkuIds = productIds.map((id: string) => parseInt(id)) - // Simply update the productIds array await db.update(deliverySlotInfo) - .set({ productIds: newProductIds }) + .set({ skuIds: newSkuIds }) .where(eq(deliverySlotInfo.id, parseInt(slotId))) - const productsToAdd = newProductIds.filter((id: number) => !currentProductIds.includes(id)) - const productsToRemove = currentProductIds.filter((id: number) => !newProductIds.includes(id)) + const productsToAdd = newSkuIds.filter((id: number) => !currentSkuIds.includes(id)) + const productsToRemove = currentSkuIds.filter((id: number) => !newSkuIds.includes(id)) return { message: 'Slot products updated successfully', @@ -409,7 +425,7 @@ export async function getSlotProductIds(slotId: string): Promise { where: eq(deliverySlotInfo.id, parseInt(slotId)), }) - return slot?.productIds || [] + return slot?.skuIds || [] } export async function getAllUnits(): Promise { @@ -573,7 +589,7 @@ export async function getSlotsProductIds(slotIds: number[]): Promise = {} for (const slot of slots) { - result[slot.id] = slot.productIds || [] + result[slot.id] = slot.skuIds || [] } slotIds.forEach((slotId) => { @@ -856,8 +872,8 @@ export async function checkUnitExists(unitId: number): Promise { } export async function getProductImagesById(productId: number): Promise { - const product = await db.query.productInfo.findFirst({ - where: eq(productInfo.id, productId), + const product = await db.query.productSkus.findFirst({ + where: eq(productSkus.id, productId), columns: { images: true }, }) @@ -874,7 +890,7 @@ export interface CreateSpecialDealInput { validTill: string | Date } -export async function createSpecialDealsForProduct( +export async function createSpecialDealsForSku( productId: number, deals: CreateSpecialDealInput[] ): Promise { @@ -897,17 +913,17 @@ export async function createSpecialDealsForProduct( return createdDeals.map(mapSpecialDeal) } -export async function updateProductDeals( +export async function updateSkuDeals( productId: number, deals: CreateSpecialDealInput[] ): Promise { if (deals.length === 0) { - await db.delete(specialDeals).where(eq(specialDeals.productId, productId)) + await db.delete(specialDeals).where(eq(specialDeals.skuId, productId)) return } const existingDeals = await db.query.specialDeals.findMany({ - where: eq(specialDeals.productId, productId), + where: eq(specialDeals.skuId, productId), }) const existingDealsMap = new Map( diff --git a/packages/db_helper_sqlite/src/admin-apis/vendor-snippets.ts b/packages/db_helper_sqlite/src/admin-apis/vendor-snippets.ts index b32107c..07a58ef 100644 --- a/packages/db_helper_sqlite/src/admin-apis/vendor-snippets.ts +++ b/packages/db_helper_sqlite/src/admin-apis/vendor-snippets.ts @@ -156,9 +156,10 @@ export async function getVendorOrdersBySlotId(slotId: number) { with: { orderItems: { with: { - product: { + sku: { with: { - unit: true, + product: true, + features: true, }, }, }, @@ -177,9 +178,10 @@ export async function getVendorOrders() { user: true, orderItems: { with: { - product: { + sku: { with: { - unit: true, + product: true, + features: true, }, }, }, @@ -193,9 +195,10 @@ export async function getOrderItemsByOrderIds(orderIds: number[]) { return await db.query.orderItems.findMany({ where: inArray(orderItems.orderId, orderIds), with: { - product: { + sku: { with: { - unit: true, + product: true, + features: true, }, }, }, diff --git a/packages/db_helper_sqlite/src/db/porter.ts b/packages/db_helper_sqlite/src/db/porter.ts index fcb94f9..76c08a6 100644 --- a/packages/db_helper_sqlite/src/db/porter.ts +++ b/packages/db_helper_sqlite/src/db/porter.ts @@ -5,7 +5,7 @@ import { db } from '@/src/db/db_index' import { userDetails, - productInfo, + productSkus, productTagInfo, complaints, } from '@/src/db/schema' @@ -43,21 +43,21 @@ async function migrateUserDetails() { } async function migrateProductInfo() { - console.log('Migrating productInfo...') - const products = await db.select().from(productInfo).where(not(isNull(productInfo.images))) + console.log('Migrating productSkus...') + const products = await db.select().from(productSkus).where(not(isNull(productSkus.images))) console.log(`Found ${products.length} product records with images`) for (const product of products) { if (product.images && Array.isArray(product.images)) { const cleanedUrls = cleanImageUrls(product.images) - await db.update(productInfo) + await db.update(productSkus) .set({ images: cleanedUrls }) - .where(eq(productInfo.id, product.id)) + .where(eq(productSkus.id, product.id)) } } - console.log('productInfo migration completed') + console.log('productSkus migration completed') } async function migrateProductTagInfo() { diff --git a/packages/db_helper_sqlite/src/user-apis/cart.ts b/packages/db_helper_sqlite/src/user-apis/cart.ts index 563122e..5ae13f2 100644 --- a/packages/db_helper_sqlite/src/user-apis/cart.ts +++ b/packages/db_helper_sqlite/src/user-apis/cart.ts @@ -1,5 +1,5 @@ import { db } from '../db/db_index' -import { cartItems, productInfo, units } from '../db/schema' +import { cartItems, productSkus } from '../db/schema' import { and, eq, sql } from 'drizzle-orm' import type { UserCartItem } from '@packages/shared' @@ -9,55 +9,51 @@ const getStringArray = (value: unknown): string[] => { } export async function getCartItemsWithProducts(userId: number): Promise { - const cartItemsWithProducts = await db - .select({ - cartId: cartItems.id, - productId: productInfo.id, - productName: productInfo.name, - productPrice: productInfo.price, - productImages: productInfo.images, - productQuantity: productInfo.productQuantity, - isOutOfStock: productInfo.isOutOfStock, - unitShortNotation: units.shortNotation, - quantity: cartItems.quantity, - addedAt: cartItems.addedAt, - }) - .from(cartItems) - .innerJoin(productInfo, eq(cartItems.productId, productInfo.id)) - .innerJoin(units, eq(productInfo.unitId, units.id)) - .where(eq(cartItems.userId, userId)) + const cartItemsWithProducts = await db.query.cartItems.findMany({ + where: eq(cartItems.userId, userId), + with: { + sku: { + with: { + product: true, + features: true, + }, + }, + }, + }) return cartItemsWithProducts.map((item) => { - const priceValue = item.productPrice ?? '0' + const sku = item.sku + const features = sku?.features || [] + const priceValue = sku?.price ?? '0' const quantityValue = item.quantity ?? '0' return { - id: item.cartId, - productId: item.productId, + id: item.id, + skuId: item.skuId, quantity: parseFloat(quantityValue), addedAt: item.addedAt, - product: { - id: item.productId, - name: item.productName, - price: priceValue.toString(), - productQuantity: item.productQuantity, - unit: item.unitShortNotation, - isOutOfStock: item.isOutOfStock, - images: getStringArray(item.productImages), - }, - subtotal: parseFloat(priceValue.toString()) * parseFloat(quantityValue), + product: { + id: sku?.id ?? 0, + name: sku?.product?.name ?? 'Unknown', + price: String(priceValue), + productQuantity: 1, + unit: features.map((f) => f.featureValue).join(' '), + isOutOfStock: sku?.isOutOfStock ?? false, + images: getStringArray(sku?.images), + }, + subtotal: parseFloat(String(priceValue)) * parseFloat(quantityValue), } }) } -export async function getProductById(productId: number) { - return db.query.productInfo.findFirst({ - where: eq(productInfo.id, productId), +export async function getProductById(skuId: number) { + return db.query.productSkus.findFirst({ + where: eq(productSkus.id, skuId), }) } -export async function getCartItemByUserProduct(userId: number, productId: number) { +export async function getCartItemByUserProduct(userId: number, skuId: number) { return db.query.cartItems.findFirst({ - where: and(eq(cartItems.userId, userId), eq(cartItems.productId, productId)), + where: and(eq(cartItems.userId, userId), eq(cartItems.skuId, skuId)), }) } @@ -69,10 +65,10 @@ export async function incrementCartItemQuantity(itemId: number, quantity: number .where(eq(cartItems.id, itemId)) } -export async function insertCartItem(userId: number, productId: number, quantity: number): Promise { +export async function insertCartItem(userId: number, skuId: number, quantity: number): Promise { await db.insert(cartItems).values({ userId, - productId, + skuId, quantity: quantity.toString(), }) } diff --git a/packages/db_helper_sqlite/src/user-apis/order.ts b/packages/db_helper_sqlite/src/user-apis/order.ts index acc4bc9..52ded76 100644 --- a/packages/db_helper_sqlite/src/user-apis/order.ts +++ b/packages/db_helper_sqlite/src/user-apis/order.ts @@ -649,32 +649,32 @@ export async function getProductsForRecentOrders( productIds: number[], limit: number ): Promise { - const results = await db - .select({ - id: productInfo.id, - name: productInfo.name, - shortDescription: productInfo.shortDescription, - price: productInfo.price, - images: productInfo.images, - isOutOfStock: productInfo.isOutOfStock, - unitShortNotation: units.shortNotation, - incrementStep: productInfo.incrementStep, - }) - .from(productInfo) - .innerJoin(units, eq(productInfo.unitId, units.id)) - .where( - and( - inArray(productInfo.id, productIds), - eq(productInfo.isSuspended, false) - ) - ) - .orderBy(desc(productInfo.createdAt)) - .limit(limit) + const skus = await db.query.productSkus.findMany({ + where: and( + inArray(productSkus.id, productIds), + eq(productSkus.isSuspended, false) + ), + with: { + product: true, + features: true, + }, + orderBy: desc(productSkus.createdAt), + limit, + }) - return results.map((product) => ({ - ...product, - price: String(product.price ?? '0'), - })) + return skus.map((sku) => { + const features = sku.features || [] + return { + id: sku.id, + name: sku.product?.name ?? 'Unknown', + shortDescription: sku.product?.shortDescription ?? null, + price: String(sku.price ?? '0'), + images: sku.images, + isOutOfStock: sku.isOutOfStock, + unitShortNotation: features.map((f) => f.featureValue).join(' '), + incrementStep: sku.product?.incrementStep ?? 1, + } + }) } // ============================================================================ @@ -697,8 +697,10 @@ export interface OrderWithFullData { } | null orderItems: Array<{ quantity: string - product: { - name: string + sku: { + product: { + name: string + } | null } | null }> slot: { @@ -778,9 +780,13 @@ export async function getOrderByIdWithFullData( }, orderItems: { with: { - product: { - columns: { - name: true, + sku: { + with: { + product: { + columns: { + name: true, + }, + }, }, }, }, diff --git a/packages/db_helper_sqlite/src/user-apis/product.ts b/packages/db_helper_sqlite/src/user-apis/product.ts index 17d8194..6c30982 100644 --- a/packages/db_helper_sqlite/src/user-apis/product.ts +++ b/packages/db_helper_sqlite/src/user-apis/product.ts @@ -1,5 +1,5 @@ import { db } from '../db/db_index' -import { deliverySlotInfo, productInfo, productSkus, productReviews, productTags, specialDeals, storeInfo, units, users } from '../db/schema' +import { deliverySlotInfo, productInfo, productSkus, productReviews, productTags, specialDeals, storeInfo, users } from '../db/schema' import { and, desc, eq, gt, sql } from 'drizzle-orm' import type { UserProductDetailData, UserProductReview } from '@packages/shared' @@ -177,30 +177,32 @@ export async function getAllProductsWithUnits(tagId?: number): Promise 0) { - whereCondition = inArray(productInfo.id, productIds) + whereCondition = inArray(productSkus.productId, productIds) } - const results = await db - .select({ - id: productInfo.id, - name: productInfo.name, - shortDescription: productInfo.shortDescription, - price: productInfo.price, - marketPrice: productInfo.marketPrice, - images: productInfo.images, - isOutOfStock: productInfo.isOutOfStock, - unitShortNotation: units.shortNotation, - productQuantity: productInfo.productQuantity, - }) - .from(productInfo) - .innerJoin(units, eq(productInfo.unitId, units.id)) - .where(whereCondition) + const skus = await db.query.productSkus.findMany({ + where: whereCondition, + with: { + product: true, + features: true, + }, + }) - return results.map((product) => ({ - ...product, - price: String(product.price ?? '0'), - marketPrice: product.marketPrice ? String(product.marketPrice) : null, - })) + return skus.map((sku) => { + const features = sku.features || [] + return { + id: sku.product?.id ?? 0, + name: sku.product?.name ?? 'Unknown', + skuId: sku.id, + skuName: sku.name ?? null, + shortDescription: sku.product?.shortDescription ?? null, + price: String(sku.price ?? '0'), + marketPrice: sku.marketPrice ? String(sku.marketPrice) : null, + images: sku.images, + isOutOfStock: sku.isOutOfStock, + features: features.map((f) => ({ featureName: f.featureName, featureValue: f.featureValue })), + } + }) } /** diff --git a/packages/shared/types/admin.ts b/packages/shared/types/admin.ts index 809df52..72e64a6 100644 --- a/packages/shared/types/admin.ts +++ b/packages/shared/types/admin.ts @@ -372,13 +372,7 @@ export interface AdminSkuVariant { export interface AdminSku { id: number productId: number - productName?: string - skuCode: string | null - displayName: string - unitId: number - unit?: AdminUnit - productQuantity: number - incrementStep: number + name: string | null price: string marketPrice: string | null images: string[] | null @@ -387,11 +381,9 @@ export interface AdminSku { isSuspended: boolean isFlashAvailable: boolean flashPrice: string | null - isComboOnly: boolean - sortOrder: number - isDefault: boolean createdAt: Date - variants: AdminSkuVariant[] + features: AdminSkuFeature[] +} } export interface AdminProduct { @@ -443,6 +435,8 @@ export interface CreateProductInput { incrementStep?: number skus: CreateSkuInput[] } + +export interface AdminProductTagInfo { id: number; tagName: string; tagDescription: string | null; @@ -465,7 +459,7 @@ export interface AdminProductTagWithProducts extends AdminProductTagInfo { export interface AdminSpecialDeal { id: number; - productId: number; + skuId: number; quantity: string; price: string; validTill: Date; From 588cbe00f358df1c74bcd72348ea5f2cb7804884 Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:08:00 +0530 Subject: [PATCH 06/73] enh --- .commandcode/taste/taste.md | 4 + .../manage-orders/orders/index_old.tsx | 801 ------------------ .../app/(drawer)/products/add_old.tsx | 82 -- .../src/components/ProductForm_old.tsx | 262 ------ 4 files changed, 4 insertions(+), 1145 deletions(-) create mode 100644 .commandcode/taste/taste.md delete mode 100644 apps/admin-ui/app/(drawer)/manage-orders/orders/index_old.tsx delete mode 100644 apps/admin-ui/app/(drawer)/products/add_old.tsx delete mode 100644 apps/admin-ui/src/components/ProductForm_old.tsx diff --git a/.commandcode/taste/taste.md b/.commandcode/taste/taste.md new file mode 100644 index 0000000..f562cac --- /dev/null +++ b/.commandcode/taste/taste.md @@ -0,0 +1,4 @@ +# Taste (Continuously Learned by [CommandCode][cmd]) + +[cmd]: https://commandcode.ai/ + diff --git a/apps/admin-ui/app/(drawer)/manage-orders/orders/index_old.tsx b/apps/admin-ui/app/(drawer)/manage-orders/orders/index_old.tsx deleted file mode 100644 index fd17855..0000000 --- a/apps/admin-ui/app/(drawer)/manage-orders/orders/index_old.tsx +++ /dev/null @@ -1,801 +0,0 @@ -import React, { useState , useEffect } from 'react'; -import { View, TouchableOpacity, Alert, TextInput, ActivityIndicator, Linking } from 'react-native'; -import { AppContainer, MyText, tw, MyFlatList, BottomDialog, BottomDropdown, Checkbox, theme, MyTextInput } from 'common-ui'; -import { trpc } from '@/src/trpc-client'; -import { useRouter, useLocalSearchParams } from 'expo-router'; -import dayjs from 'dayjs'; -import MaterialIcons from '@expo/vector-icons/MaterialIcons'; -import { Entypo } from '@expo/vector-icons'; -import CancelOrderDialog from '@/components/CancelOrderDialog'; -import { OrderOptionsMenu } from '@/components/OrderOptionsMenu'; -import * as Location from 'expo-location'; - -const AdminNotesForm = ({ orderId, existingNotes, onClose, refetch }: { orderId: string; existingNotes?: string | null; onClose: () => void; refetch: () => void }) => { - const [notesText, setNotesText] = useState(existingNotes || ''); - const updateNotesMutation = trpc.admin.order.updateNotes.useMutation(); - - return ( - - Admin Notes - - - { - updateNotesMutation.mutate( - { orderId: parseInt(orderId), adminNotes: notesText }, - { - onSuccess: () => { - onClose(); - Alert.alert('Success', 'Notes updated successfully'); - refetch(); - }, - onError: (error: any) => { - Alert.alert('Error', error.message || 'Failed to update notes'); - }, - } - ); - }} - > - Save - - - - ); -}; - - -interface OrderType { - id: number; - orderId: string; - readableId: number; - customerName: string | null; - customerMobile?: string | null; - address: string; - addressId: number; - latitude: number | null; - longitude: number | null; - totalAmount: number; - deliveryCharge: number; - items: { - id?: number; - name: string; - quantity: number; - price: number; - amount: number; - unit: string; - isPackaged?: boolean; - isPackageVerified?: boolean; - productSize: number; - }[]; - createdAt: string; - deliveryTime: string | null; - status: 'pending' | 'delivered' | 'cancelled'; - isPackaged: boolean; - isDelivered: boolean; - isCod: boolean; - isFlashDelivery: boolean; - couponCode?: string; - couponDescription?: string; - discountAmount?: number; - adminNotes?: string | null; - userNotes?: string | null; - userNegativityScore?: number; -} - -const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }) => { - const id = order.orderId; - const router = useRouter(); - const [menuOpen, setMenuOpen] = useState(false); - const [itemsDialogOpen, setItemsDialogOpen] = useState(false); - const [notesDialogOpen, setNotesDialogOpen] = useState(false); - const [cancelDialogOpen, setCancelDialogOpen] = useState(false); - const [userNotesDialogOpen, setUserNotesDialogOpen] = useState(false); - const [adminNotesDialogOpen, setAdminNotesDialogOpen] = useState(false); - const [updatingItems, setUpdatingItems] = useState>(new Set()); - - const updatePackagedMutation = trpc.admin.order.updatePackaged.useMutation(); - const updateDeliveredMutation = trpc.admin.order.updateDelivered.useMutation(); - const updateItemPackagingMutation = trpc.admin.order.updateOrderItemPackaging.useMutation(); - - const handleOrderPress = () => { - router.push(`/manage-orders/order-details/${order.orderId}` as any); - }; - - const handleMenuOption = () => { - setMenuOpen(false); - router.push(`/manage-orders/order-details/${order.orderId}` as any); - }; - - const handleMarkPackaged = (isPackaged: boolean) => { - updatePackagedMutation.mutate( - { orderId: order.orderId.toString(), isPackaged }, - { - onSuccess: () => { - refetch(); - }, - } - ); - }; - - const handleMarkDelivered = (isDelivered: boolean) => { - updateDeliveredMutation.mutate( - { orderId: order.orderId.toString(), isDelivered }, - { - onSuccess: () => { - refetch(); - }, - } - ); - }; - - const handleItemPackagingToggle = (itemId: number, field: 'isPackaged' | 'isPackageVerified', value: boolean) => { - setUpdatingItems(prev => new Set(prev).add(itemId)); - - updateItemPackagingMutation.mutate( - { orderItemId: itemId, [field]: value }, - { - onSuccess: () => { - setUpdatingItems(prev => { - const newSet = new Set(prev); - newSet.delete(itemId); - return newSet; - }); - refetch(); - }, - onError: (error: any) => { - setUpdatingItems(prev => { - const newSet = new Set(prev); - newSet.delete(itemId); - return newSet; - }); - Alert.alert("Error", error.message || "Failed to update packaging status"); - }, - } - ); - }; - - return ( - <> - - {/* Header Section */} - - - - - 0 ? 'text-yellow-600' : 'text-gray-900')}`}> - {order.customerName || order.customerMobile || 'Unknown Customer'} - - - #{order.readableId} - - {order.isFlashDelivery && ( - - - FLASH - - )} - - - - - {dayjs(order.createdAt).format('MMM D, h:mm A')} - - {order.userNegativityScore && order.userNegativityScore > 0 && ( - - - Negative Customer - - )} - - - - setMenuOpen(true)} - style={tw`p-2 -mr-2 -mt-2 rounded-full`} - > - - - - - - {/* Main Content */} - - {/* Status Badges */} - - {/* - {order.status} - */} - {/* {order.isCod && ( - - COD - - )} */} - - Packaged - handleMarkPackaged(!order.isPackaged)} - onPress={() => {}} - size={18} - fillColor={theme.colors.gray500} - checkColor="#FFFFFF" - /> - - - Delivered - handleMarkDelivered(!order.isDelivered)} - size={18} - fillColor="#10B981" - checkColor="#FFFFFF" - /> - - {order.status === 'cancelled' && ( - - CANCELLED - - )} - - - {/* Delivery Info */} - - - - Delivery Address - - {order.address} - - - - - {order.isFlashDelivery ? "1 Hr Delivery:" : "Slot:"} {order.isFlashDelivery ? dayjs(order.createdAt).add(30, 'minutes').format('MMM D, h:mm A') : order.deliveryTime ? dayjs(order.deliveryTime).format("ddd, MMM D • h:mm A") : 'Not scheduled'} - - - {order.isFlashDelivery && ( - - - - 1 Hour Delivery • High Priority - - - )} - - - - {/* Items Summary & Total */} - - - - setItemsDialogOpen(true)} - style={tw`flex-row items-center py-2 px-3 bg-blue-50 rounded-lg flex-1 mr-3`} - > - - - {order.items.length} {order.items.length === 1 ? 'item' : 'items'} - - {order.isFlashDelivery && ( - - - - )} - - - Total: - ₹{order.totalAmount} - - - - - - {/* Coupons */} - {order.couponCode && ( - - Applied Coupons - - - {order.couponCode} - - {order.couponDescription && ( - - {order.couponDescription} - - )} - {order.discountAmount && ( - - Discount: ₹{order.discountAmount} - - )} - - - )} - - {/* Notes Section */} - - {order.userNotes && ( - setUserNotesDialogOpen(true)} - > - - - User Notes - - - )} - {order.adminNotes && ( - setNotesDialogOpen(true)} - > - - - Admin Notes - - - )} - - - {/* Footer / Delivery Charge */} - {order.deliveryCharge > 0 && ( - - - Delivery Charge - ₹{order.deliveryCharge} - - - )} - - - - setMenuOpen(false)} - order={{ - id: order.id, - readableId: order.readableId, - isPackaged: order.isPackaged, - isDelivered: order.isDelivered, - isFlashDelivery: order.isFlashDelivery, - address: order.address, - addressId: order.addressId, - adminNotes: order.adminNotes, - userNotes: order.userNotes, - latitude: order.latitude, - longitude: order.longitude, - status: order.status, - }} - onViewDetails={handleMenuOption} - onTogglePackaged={() => handleMarkPackaged(!order.isPackaged)} - onToggleDelivered={() => handleMarkDelivered(!order.isDelivered)} - onOpenAdminNotes={() => { - setMenuOpen(false); - setNotesDialogOpen(true); - }} - onCancelOrder={() => { - setMenuOpen(false); - setCancelDialogOpen(true); - }} - onAttachLocation={() => refetch()} - onWhatsApp={() => {}} - onDial={() => {}} - /> - - setItemsDialogOpen(false)}> - - - - Order Items - - {order.isFlashDelivery && ( - - - FLASH - - )} - - - Total: ₹{order.totalAmount} - - {order.items.map((item, idx) => ( - - - - {item.quantity * item.productSize } {item.unit} - - - {item.name.length > 30 ? `${item.name.substring(0, 30)}...` : item.name} - - {item.isPackaged !== undefined && item.isPackageVerified !== undefined && ( - <> - - pkg - handleItemPackagingToggle(item.id!, 'isPackaged', !item.isPackaged)} - size={18} - fillColor={updatingItems.has(item.id!) ? "#F59E0B" : "#10B981"} - checkColor="#FFFFFF" - /> - - - verf - handleItemPackagingToggle(item.id!, 'isPackageVerified', !item.isPackageVerified)} - size={18} - fillColor={updatingItems.has(item.id!) ? "#F59E0B" : "#10B981"} - checkColor="#FFFFFF" - /> - - {updatingItems.has(item.id!) && ( - - )} - - )} - - - ))} - - - - setNotesDialogOpen(false)}> - setNotesDialogOpen(false)} refetch={refetch} /> - - - setCancelDialogOpen(false)} - onSuccess={refetch} - /> - - setUserNotesDialogOpen(false)}> - - - User Notes - - - - {order.userNotes} - - - - - - setAdminNotesDialogOpen(false)}> - - - Admin Notes - - - - {order.adminNotes} - - - - - - ); - }; - -export default function Orders() { - const router = useRouter(); - const { filter } = useLocalSearchParams<{ filter?: string }>(); - const [selectedSlot, setSelectedSlot] = useState(null); - const [selectedSlotType, setSelectedSlotType] = useState<'slot' | 'flash' | null>(null); - const [packagedFilter, setPackagedFilter] = useState<'all' | 'packaged' | 'not_packaged'>('all'); - const [packagedChecked, setPackagedChecked] = useState(false); - const [notPackagedChecked, setNotPackagedChecked] = useState(false); - const [deliveredFilter, setDeliveredFilter] = useState<'all' | 'delivered' | 'not_delivered'>('all'); - const [deliveredChecked, setDeliveredChecked] = useState(false); - const [notDeliveredChecked, setNotDeliveredChecked] = useState(false); - const [cancellationFilter, setCancellationFilter] = useState<'all' | 'cancelled' | 'not_cancelled'>('all'); - const [cancelledChecked, setCancelledChecked] = useState(false); - const [notCancelledChecked, setNotCancelledChecked] = useState(false); - const [flashDeliveryFilter, setFlashDeliveryFilter] = useState<'all' | 'flash' | 'regular'>('all'); - const [flashChecked, setFlashChecked] = useState(false); - const [regularChecked, setRegularChecked] = useState(false); - const [filterDialogOpen, setFilterDialogOpen] = useState(false); - - // Handle initial filter from URL params - useEffect(() => { - if (filter === 'flash') { - setSelectedSlotType('flash'); - setFlashDeliveryFilter('flash'); - setFlashChecked(true); - setRegularChecked(false); - } - }, [filter]); - const { data: slotsData } = trpc.admin.slots.getAll.useQuery(); - const { data, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage, refetch } = trpc.admin.order.getAll.useInfiniteQuery( - { - limit: 20, - slotId: selectedSlotType === 'slot' ? selectedSlot : null, - packagedFilter, - deliveredFilter, - cancellationFilter, - flashDeliveryFilter: selectedSlotType === 'flash' ? 'flash' : flashDeliveryFilter - }, - { - getNextPageParam: (lastPage) => lastPage?.nextCursor, - } - ); - - const orders = data?.pages.flatMap(page => page?.orders) || []; - - if (isLoading) { - return ( - - - Loading orders... - - ); - } - - const slotOptions = [ - { label: '⚡ Flash Deliveries', value: 'flash' }, - ...(slotsData?.slots?.map(slot => ({ - label: dayjs(slot.deliveryTime).format('ddd DD MMM, h:mm a'), - value: slot.id.toString(), - })) || []) - ]; - - - return ( - <> - item!.orderId} - renderItem={({ item }) => item ? : null} - onEndReached={() => { - if (hasNextPage && !isFetchingNextPage) { - fetchNextPage(); - } - }} - onEndReachedThreshold={0.5} - onRefresh={() => refetch()} - ListHeaderComponent={ - <> - - - { - if (val === 'flash') { - setSelectedSlotType('flash'); - setSelectedSlot(null); - setFlashDeliveryFilter('flash'); - // Reset other filters when switching to flash - setPackagedFilter('all'); - setPackagedChecked(false); - setNotPackagedChecked(false); - setDeliveredFilter('all'); - setDeliveredChecked(false); - setNotDeliveredChecked(false); - setCancellationFilter('all'); - setCancelledChecked(false); - setNotCancelledChecked(false); - } else { - setSelectedSlotType('slot'); - setSelectedSlot(val ? Number(val) : null); - setFlashDeliveryFilter('all'); - } - }} - placeholder="All slots" - /> - - setFilterDialogOpen(true)} - style={tw`p-2`} - > - - - - {!isLoading && selectedSlotType && ( - - - {selectedSlotType === 'flash' - ? `${orders.length} Flash delivery orders` - : `${orders.length} Orders in slot` - } - - - )} - - } - ListFooterComponent={ - isFetchingNextPage ? ( - - - Loading more... - - ) : null - } - /> - - setFilterDialogOpen(false)}> - - - Packaged Status - - { - const newValue = !packagedChecked; - setPackagedChecked(newValue); - if (newValue && notPackagedChecked) { - setPackagedFilter('all'); - } else if (newValue) { - setPackagedFilter('packaged'); - } else if (notPackagedChecked) { - setPackagedFilter('not_packaged'); - } else { - setPackagedFilter('all'); - } - }} - /> - Packaged - - - { - const newValue = !notPackagedChecked; - setNotPackagedChecked(newValue); - if (packagedChecked && newValue) { - setPackagedFilter('all'); - } else if (newValue) { - setPackagedFilter('not_packaged'); - } else if (packagedChecked) { - setPackagedFilter('packaged'); - } else { - setPackagedFilter('all'); - } - }} - /> - Not Packaged - - - - Delivered Status - - { - const newValue = !deliveredChecked; - setDeliveredChecked(newValue); - if (newValue && notDeliveredChecked) { - setDeliveredFilter('all'); - } else if (newValue) { - setDeliveredFilter('delivered'); - } else if (notDeliveredChecked) { - setDeliveredFilter('not_delivered'); - } else { - setDeliveredFilter('all'); - } - }} - /> - Delivered - - - { - const newValue = !notDeliveredChecked; - setNotDeliveredChecked(newValue); - if (deliveredChecked && newValue) { - setDeliveredFilter('all'); - } else if (newValue) { - setDeliveredFilter('not_delivered'); - } else if (deliveredChecked) { - setDeliveredFilter('delivered'); - } else { - setDeliveredFilter('all'); - } - }} - /> - Not Delivered - - - - Cancellation Status - - { - const newValue = !cancelledChecked; - setCancelledChecked(newValue); - if (newValue && notCancelledChecked) { - setCancellationFilter('all'); - } else if (newValue) { - setCancellationFilter('cancelled'); - } else if (notCancelledChecked) { - setCancellationFilter('not_cancelled'); - } else { - setCancellationFilter('all'); - } - }} - /> - Cancelled - - - { - const newValue = !notCancelledChecked; - setNotCancelledChecked(newValue); - if (cancelledChecked && newValue) { - setCancellationFilter('all'); - } else if (newValue) { - setCancellationFilter('not_cancelled'); - } else if (cancelledChecked) { - setCancellationFilter('cancelled'); - } else { - setCancellationFilter('all'); - } - }} - /> - Not Cancelled - - - - Delivery Type - - { - const newValue = !flashChecked; - setFlashChecked(newValue); - if (newValue && regularChecked) { - setFlashDeliveryFilter('all'); - } else if (newValue) { - setFlashDeliveryFilter('flash'); - } else if (regularChecked) { - setFlashDeliveryFilter('regular'); - } else { - setFlashDeliveryFilter('all'); - } - }} - /> - ⚡ 1 Hr Delivery - - - { - const newValue = !regularChecked; - setRegularChecked(newValue); - if (flashChecked && newValue) { - setFlashDeliveryFilter('all'); - } else if (newValue) { - setFlashDeliveryFilter('regular'); - } else if (flashChecked) { - setFlashDeliveryFilter('flash'); - } else { - setFlashDeliveryFilter('all'); - } - }} - /> - Regular Delivery - - - - - - ); -} diff --git a/apps/admin-ui/app/(drawer)/products/add_old.tsx b/apps/admin-ui/app/(drawer)/products/add_old.tsx deleted file mode 100644 index 33b9a71..0000000 --- a/apps/admin-ui/app/(drawer)/products/add_old.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import React from 'react'; -import { Alert } from 'react-native'; -import { AppContainer, ImageUploaderNeoPayload } from 'common-ui'; -import ProductForm from '@/src/components/ProductForm'; -import { trpc } from '@/src/trpc-client'; -import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore'; - -export default function AddProduct() { - const createProduct = trpc.admin.product.createProduct.useMutation(); - const { refetch: refetchProducts } = trpc.admin.product.getProducts.useQuery(undefined, { - enabled: false, - }); - const { upload, isUploading } = useUploadToObjectStorage(); - - const handleSubmit = async (values: any, images: ImageUploaderNeoPayload[]) => { - try { - let uploadUrls: string[] = []; - - if (images.length > 0) { - const blobs = await Promise.all( - images.map(async (img) => { - const response = await fetch(img.url); - const blob = await response.blob(); - return { blob, mimeType: img.mimeType || 'image/jpeg' }; - }) - ); - - const result = await upload({ images: blobs, contextString: 'product_info' }); - uploadUrls = result.presignedUrls; - } - - await createProduct.mutateAsync({ - name: values.name, - shortDescription: values.shortDescription, - longDescription: values.longDescription, - unitId: parseInt(values.unitId), - storeId: parseInt(values.storeId), - price: parseFloat(values.price), - marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined, - incrementStep: 1, - productQuantity: values.productQuantity || 1, - isSuspended: values.isSuspended || false, - isFlashAvailable: values.isFlashAvailable || false, - flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : undefined, - uploadUrls, - tagIds: values.tagIds || [], - }); - - await refetchProducts(); - Alert.alert('Success', 'Product created successfully!'); - } catch (error: any) { - Alert.alert('Error', error.message || 'Failed to create product'); - } - }; - - const initialValues = { - name: '', - shortDescription: '', - longDescription: '', - unitId: 0, - price: '', - storeId: 1, - marketPrice: '', - deals: [{ quantity: '', price: '', validTill: new Date() }], - tagIds: [], - isSuspended: false, - isFlashAvailable: false, - flashPrice: '', - productQuantity: 1, - }; - - return ( - - - - ); -} diff --git a/apps/admin-ui/src/components/ProductForm_old.tsx b/apps/admin-ui/src/components/ProductForm_old.tsx deleted file mode 100644 index ac8d4e3..0000000 --- a/apps/admin-ui/src/components/ProductForm_old.tsx +++ /dev/null @@ -1,262 +0,0 @@ -import React, { useState, useEffect, useCallback, forwardRef, useImperativeHandle, useMemo } from 'react'; -import { View, TouchableOpacity } from 'react-native'; -import { Formik, FieldArray } from 'formik'; -import * as Yup from 'yup'; -import { MyTextInput, BottomDropdown, MyText, useTheme, DatePicker, tw, useFocusCallback, Checkbox, ImageUploaderNeo, ImageUploaderNeoItem, ImageUploaderNeoPayload } from 'common-ui'; -import MaterialIcons from '@expo/vector-icons/MaterialIcons'; -import { trpc } from '../trpc-client'; - -interface ProductFormData { - name: string; - shortDescription: string; - longDescription: string; - unitId: number; - storeId: number; - price: string; - marketPrice: string; - isSuspended: boolean; - isFlashAvailable: boolean; - flashPrice: string; - deals: Deal[]; - tagIds: number[]; - productQuantity: number; -} - -interface Deal { - quantity: string; - price: string; - validTill: Date | null; -} - -export interface ProductFormRef { - clearImages: () => void; -} - -interface ProductFormProps { - mode: 'create' | 'edit'; - initialValues: ProductFormData; - onSubmit: (values: ProductFormData, images: ImageUploaderNeoPayload[], imagesToDelete: string[]) => void; - isLoading: boolean; - existingImages?: ImageUploaderNeoItem[]; - existingImageKeys?: string[]; -} - -const unitOptions = [ - { label: 'Kg', value: 1 }, - { label: 'Litre', value: 2 }, - { label: 'Dozen', value: 3 }, - { label: 'Unit Piece', value: 4 }, -]; - -const ProductForm = forwardRef(({ - mode, - initialValues, - onSubmit, - isLoading, - existingImages:existingImagesRaw, - existingImageKeys = [], -}, ref) => { - const { theme } = useTheme(); - const [images, setImages] = useState([]); - - const existingImages = existingImagesRaw || [] - // Sync images state when existingImages prop changes (e.g., when async query data arrives) - useEffect(() => { - setImages(existingImages); - }, [existingImagesRaw]); - - const { data: storesData } = trpc.common.getStoresSummary.useQuery(); - const storeOptions = storesData?.stores.map(store => ({ - label: store.name, - value: store.id, - })) || []; - - const { data: tagsData } = trpc.admin.product.getProductTags.useQuery(); - const tagOptions = tagsData?.tags.map(tag => ({ - label: tag.tagName, - value: tag.id.toString(), - })) || []; - - // Build signed URL -> S3 key mapping for existing images - const signedUrlToKey = useMemo(() => { - const map: Record = {}; - existingImages.forEach((img, i) => { - if (existingImageKeys[i]) { - map[img.imgUrl] = existingImageKeys[i]; - } - }); - return map; - }, [existingImages, existingImageKeys]); - - return ( - { - // New images have mimeType set, existing images have mimeType === null - const newImages = images.filter(img => img.mimeType !== null); - const deletedImageKeys = existingImages - .filter(existing => !images.some(current => current.imgUrl === existing.imgUrl)) - .map(deleted => signedUrlToKey[deleted.imgUrl]) - .filter(Boolean); - - onSubmit( - values, - newImages.map(img => ({ url: img.imgUrl, mimeType: img.mimeType })), - deletedImageKeys, - ); - }} - enableReinitialize - > - {({ handleChange, handleSubmit, values, setFieldValue, resetForm }) => { - const clearForm = useCallback(() => { - setImages([]); - resetForm(); - }, [resetForm]); - - useFocusCallback(clearForm); - - useImperativeHandle(ref, () => ({ - clearImages: clearForm, - }), [clearForm]); - - const submit = () => handleSubmit(); - - return ( - - - - - - setImages(prev => [...prev, ...payloads.map(p => ({ imgUrl: p.url, mimeType: p.mimeType }))])} - onImageRemove={(payload) => setImages(prev => prev.filter(img => img.imgUrl !== payload.url))} - allowMultiple={true} - /> - - setFieldValue('unitId', value)} - placeholder="Select unit" - style={{ marginBottom: 16 }} - /> - setFieldValue('productQuantity', text)} - style={{ marginBottom: 16 }} - /> - setFieldValue('storeId', value)} - placeholder="Select store" - style={{ marginBottom: 16 }} - /> - id.toString())} - options={tagOptions} - onValueChange={(value) => setFieldValue('tagIds', (value as string[]).map(id => parseInt(id)))} - multiple={true} - placeholder="Select tags" - style={{ marginBottom: 16 }} - /> - - - - - setFieldValue('isSuspended', !values.isSuspended)} - style={tw`mr-3`} - /> - Suspend Product - - - - { - setFieldValue('isFlashAvailable', !values.isFlashAvailable); - if (values.isFlashAvailable) setFieldValue('flashPrice', ''); - }} - style={tw`mr-3`} - /> - Flash Available - - - {values.isFlashAvailable && ( - - )} - - - - {isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Product' : 'Update Product')} - - - - ); - }} - - ); -}); - -ProductForm.displayName = 'ProductForm'; - -export default ProductForm; From 1b62c6add49c9b9539cbc9d94d8ed7be043ed91c Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:59:34 +0530 Subject: [PATCH 07/73] enh --- apps/backend/src/sqliteImporter.ts | 1 - .../src/trpc/apis/admin-apis/apis/product.ts | 63 +++---------------- packages/db_helper_sqlite/index.ts | 1 - .../src/admin-apis/product.ts | 1 - packages/shared/types/admin.ts | 10 +-- 5 files changed, 10 insertions(+), 66 deletions(-) diff --git a/apps/backend/src/sqliteImporter.ts b/apps/backend/src/sqliteImporter.ts index 1e9fc4d..9b756cd 100644 --- a/apps/backend/src/sqliteImporter.ts +++ b/apps/backend/src/sqliteImporter.ts @@ -73,7 +73,6 @@ export { updateSkuDeals, replaceProductTags, mergeSkus, - toggleSkuOutOfStock, updateSlotProducts, getSlotProductIds, getSlotsProductIds, diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts index 55eccc6..0027ecc 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts @@ -7,7 +7,6 @@ import { getAllProducts as getAllProductsInDb, getProductById as getProductByIdInDb, deleteProduct as deleteProductInDb, - toggleProductOutOfStock as toggleProductOutOfStockInDb, updateSlotProducts as updateSlotProductsInDb, getSlotProductIds as getSlotProductIdsInDb, getSlotsProductIds as getSlotsProductIdsInDb, @@ -21,11 +20,9 @@ import { checkProductExistsByName, checkUnitExists, createProduct as createProductInDb, - createSpecialDealsForProduct, replaceProductTags, getProductImagesById, updateProduct as updateProductInDb, - updateProductDeals, checkProductTagExistsByName, createProductTag as createProductTagInDb, updateProductTag as updateProductTagInDb, @@ -44,7 +41,6 @@ import type { AdminProductListResponse, AdminProductResponse, AdminDeleteProductResult, - AdminToggleOutOfStockResult, AdminUpdateSlotProductsResult, AdminSlotProductIdsResult, AdminSlotsProductIdsResult, @@ -187,46 +183,6 @@ export const productRouter = router({ } }), - toggleOutOfStock: protectedProcedure - .input(z.object({ - id: z.number(), - })) - .mutation(async ({ input }): Promise => { - const { id } = input; - - const updatedProduct = await toggleProductOutOfStockInDb(id) - - /* - // Old implementation - direct DB queries: - const product = await db.query.productInfo.findFirst({ - where: eq(productInfo.id, id), - }); - - if (!product) { - throw new ApiError("Product not found", 404); - } - - const [updatedProduct] = await db - .update(productInfo) - .set({ - isOutOfStock: !product.isOutOfStock, - }) - .where(eq(productInfo.id, id)) - .returning(); - */ - - if (!updatedProduct) { - throw new ApiError('Product not found', 404) - } - - await scheduleStoreInitialization() - - return { - product: updatedProduct, - message: `Product marked as ${updatedProduct.isOutOfStock ? 'out of stock' : 'in stock'}`, - } - }), - createProduct: protectedProcedure .input(z.object({ name: z.string().min(1, 'Name is required'), @@ -386,16 +342,16 @@ export const productRouter = router({ updateSlotProducts: protectedProcedure .input(z.object({ slotId: z.string(), - productIds: z.array(z.string()), + skuIds: z.array(z.string()), })) .mutation(async ({ input }): Promise => { - const { slotId, productIds } = input; + const { slotId, skuIds } = input; - if (!Array.isArray(productIds)) { - throw new ApiError("productIds must be an array", 400); + if (!Array.isArray(skuIds)) { + throw new ApiError("skuIds must be an array", 400); } - const result = await updateSlotProductsInDb(slotId, productIds) + const result = await updateSlotProductsInDb(slotId, skuIds) /* // Old implementation - direct DB queries: @@ -460,7 +416,7 @@ export const productRouter = router({ .query(async ({ input }): Promise => { const { slotId } = input; - const productIds = await getSlotProductIdsInDb(slotId) + const skuIds = await getSlotProductIdsInDb(slotId) /* // Old implementation - direct DB queries: @@ -474,12 +430,7 @@ export const productRouter = router({ const productIds = associations.map(assoc => assoc.productId); return { - productIds, - }; - */ - - return { - productIds, + skuIds, } }), diff --git a/packages/db_helper_sqlite/index.ts b/packages/db_helper_sqlite/index.ts index d275d72..8d0013c 100644 --- a/packages/db_helper_sqlite/index.ts +++ b/packages/db_helper_sqlite/index.ts @@ -83,7 +83,6 @@ export { updateSkuDeals, replaceProductTags, mergeSkus, - toggleSkuOutOfStock, updateSlotProducts, getSlotProductIds, getSlotsProductIds, diff --git a/packages/db_helper_sqlite/src/admin-apis/product.ts b/packages/db_helper_sqlite/src/admin-apis/product.ts index 35637d2..ee35557 100644 --- a/packages/db_helper_sqlite/src/admin-apis/product.ts +++ b/packages/db_helper_sqlite/src/admin-apis/product.ts @@ -370,7 +370,6 @@ export async function updateProduct(id: number, input: any): Promise { const product = await db.query.productSkus.findFirst({ where: eq(productSkus.id, id), }) diff --git a/packages/shared/types/admin.ts b/packages/shared/types/admin.ts index 72e64a6..1c784e2 100644 --- a/packages/shared/types/admin.ts +++ b/packages/shared/types/admin.ts @@ -466,7 +466,8 @@ export interface AdminSpecialDeal { } export interface AdminProductWithDetails extends AdminProduct { - unit: AdminUnit; + store: Store | null; + skus: AdminSku[]; deals: AdminSpecialDeal[]; tags: AdminProductTagInfo[]; } @@ -484,11 +485,6 @@ export interface AdminDeleteProductResult { message: string; } -export interface AdminToggleOutOfStockResult { - product: AdminProduct; - message: string; -} - export interface AdminUpdateSlotProductsResult { message: string; added: number; @@ -496,7 +492,7 @@ export interface AdminUpdateSlotProductsResult { } export interface AdminSlotProductIdsResult { - productIds: number[]; + skuIds: number[]; } export type AdminSlotsProductIdsResult = Record; From 6193104e6f65c453d287ae12e0f11583534705ce Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:20:04 +0530 Subject: [PATCH 08/73] enh --- .../app/(drawer)/prices-overview/index.tsx | 18 +++--------------- .../db_helper_sqlite/src/user-apis/banners.ts | 2 +- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/apps/admin-ui/app/(drawer)/prices-overview/index.tsx b/apps/admin-ui/app/(drawer)/prices-overview/index.tsx index 1a192b9..16991a6 100644 --- a/apps/admin-ui/app/(drawer)/prices-overview/index.tsx +++ b/apps/admin-ui/app/(drawer)/prices-overview/index.tsx @@ -43,7 +43,7 @@ const SkuItemComponent: React.FC = ({ const displayPrice = change.price !== undefined ? change.price : sku.price; const displayMarketPrice = change.marketPrice !== undefined ? change.marketPrice : sku.marketPrice; const displayFlashPrice = change.flashPrice !== undefined ? change.flashPrice : sku.flashPrice; - const displayProductQuantity = change.productQuantity !== undefined ? change.productQuantity : sku.productQuantity; + const displayUnit = (sku.features || []).map((f: any) => f.featureValue).join(' ') || '—'; return ( @@ -130,7 +130,7 @@ const SkuItemComponent: React.FC = ({ Size - {displayProductQuantity ? `${displayProductQuantity}${sku.unit?.shortNotation || ''}` : "N/A"} + {displayUnit} openEditDialog(sku, productName)} style={tw`ml-1`}> @@ -145,7 +145,6 @@ interface PendingChange { price?: number; marketPrice?: number | null; flashPrice?: number | null; - productQuantity?: number | null; isFlashAvailable?: boolean; } @@ -156,7 +155,6 @@ interface EditDialogState { tempPrice: string; tempMarketPrice: string; tempFlashPrice: string; - tempProductQuantity: string; } export default function PricesOverview() { @@ -170,7 +168,6 @@ export default function PricesOverview() { tempPrice: "", tempMarketPrice: "", tempFlashPrice: "", - tempProductQuantity: "", }); const [showMenu, setShowMenu] = useState(false); @@ -233,7 +230,6 @@ export default function PricesOverview() { tempPrice: (change.price ?? sku.price)?.toString() || "", tempMarketPrice: (change.marketPrice ?? sku.marketPrice)?.toString() || "", tempFlashPrice: (change.flashPrice ?? sku.flashPrice)?.toString() || "", - tempProductQuantity: (change.productQuantity ?? sku.productQuantity)?.toString() || "", }); }; @@ -241,7 +237,6 @@ export default function PricesOverview() { const price = parseFloat(editDialog.tempPrice); const marketPrice = editDialog.tempMarketPrice ? parseFloat(editDialog.tempMarketPrice) : null; const flashPrice = editDialog.tempFlashPrice ? parseFloat(editDialog.tempFlashPrice) : null; - const productQuantity = editDialog.tempProductQuantity ? parseFloat(editDialog.tempProductQuantity) : null; if (isNaN(price) || price <= 0) { Alert.alert("Error", "Please enter a valid price"); @@ -258,7 +253,6 @@ export default function PricesOverview() { return; } - if (editDialog.tempProductQuantity && (isNaN(productQuantity!) || productQuantity! <= 0)) { Alert.alert("Error", "Please enter a valid size"); return; } @@ -269,21 +263,18 @@ export default function PricesOverview() { price: price !== parseFloat(editDialog.sku.price) ? price : undefined, marketPrice: marketPrice !== parseFloat(editDialog.sku.marketPrice || '0') ? marketPrice : undefined, flashPrice: flashPrice !== parseFloat(editDialog.sku.flashPrice || '0') ? flashPrice : undefined, - productQuantity: productQuantity !== (editDialog.sku.productQuantity || 1) ? productQuantity : undefined, }, })); - setEditDialog({ open: false, sku: null, productName: "", tempPrice: "", tempMarketPrice: "", tempFlashPrice: "", tempProductQuantity: "" }); }; const handleSave = () => { const updates = Object.entries(pendingChanges).map(([skuId, change]) => { const sku = allSkus.find(s => s.id === parseInt(skuId)); - const update: any = { productId: sku?.productId }; + const update: any = { productId: sku?.id }; if (change.price !== undefined) update.price = change.price; if (change.marketPrice !== undefined) update.marketPrice = change.marketPrice; if (change.flashPrice !== undefined) update.flashPrice = change.flashPrice; - if (change.productQuantity !== undefined) update.productQuantity = change.productQuantity; if (change.isFlashAvailable !== undefined) update.isFlashAvailable = change.isFlashAvailable; return update; }); @@ -377,7 +368,6 @@ export default function PricesOverview() { /> )} - setEditDialog({ ...editDialog, open: false, tempFlashPrice: "", tempMarketPrice: "", tempPrice: "", tempProductQuantity: "" })}> {editDialog.productName} {editDialog.sku?.displayName || editDialog.sku?.name || ''} @@ -419,8 +409,6 @@ export default function PricesOverview() { Size setEditDialog({ ...editDialog, tempProductQuantity: text })} keyboardType="decimal-pad" placeholder="Enter size" /> diff --git a/packages/db_helper_sqlite/src/user-apis/banners.ts b/packages/db_helper_sqlite/src/user-apis/banners.ts index 5aeb02b..3d95589 100644 --- a/packages/db_helper_sqlite/src/user-apis/banners.ts +++ b/packages/db_helper_sqlite/src/user-apis/banners.ts @@ -11,7 +11,7 @@ const mapBanner = (banner: BannerRow): UserBanner => ({ name: banner.name, imageUrl: banner.imageUrl, description: banner.description ?? null, - productIds: banner.productIds ?? null, + skuIds: banner.skuIds ?? null, redirectUrl: banner.redirectUrl ?? null, serialNum: banner.serialNum ?? null, isActive: banner.isActive, From 0cbd24341fc94c554430f0b6f5a8c127726fb97d Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:18:42 +0530 Subject: [PATCH 09/73] enh --- .../app/(drawer)/prices-overview/index.tsx | 5 +- apps/admin-ui/app/(drawer)/products/add.tsx | 7 + apps/admin-ui/app/(drawer)/products/edit.tsx | 8 + apps/admin-ui/src/components/ProductForm.tsx | 63 ++++++++ apps/backend/index.ts | 2 - apps/backend/src/lib/automatedJobs.ts | 137 ------------------ apps/backend/src/sqliteImporter.ts | 1 - apps/backend/src/stores/product-store.ts | 29 ++++ .../src/trpc/apis/admin-apis/apis/product.ts | 16 +- .../drizzle/0002_sku_split.sql | 13 ++ packages/db_helper_sqlite/index.ts | 3 +- .../src/admin-apis/product.ts | 130 +++++++++++------ .../db_helper_sqlite/src/admin-apis/store.ts | 123 ++++++++-------- packages/db_helper_sqlite/src/db/schema.ts | 24 +++ .../src/lib/automated-jobs.ts | 19 +-- .../db_helper_sqlite/src/lib/delete-orders.ts | 33 +++-- .../db_helper_sqlite/src/lib/run-batched.ts | 39 +++++ .../src/stores/store-helpers.ts | 37 +++++ .../db_helper_sqlite/src/user-apis/order.ts | 111 +++++++------- .../db_helper_sqlite/src/user-apis/product.ts | 69 ++++++--- .../db_helper_sqlite/src/user-apis/stores.ts | 98 +++++-------- packages/shared/types/admin.ts | 17 ++- packages/shared/types/user.ts | 11 ++ 23 files changed, 571 insertions(+), 424 deletions(-) delete mode 100644 apps/backend/src/lib/automatedJobs.ts create mode 100644 packages/db_helper_sqlite/src/lib/run-batched.ts diff --git a/apps/admin-ui/app/(drawer)/prices-overview/index.tsx b/apps/admin-ui/app/(drawer)/prices-overview/index.tsx index 16991a6..e9ed777 100644 --- a/apps/admin-ui/app/(drawer)/prices-overview/index.tsx +++ b/apps/admin-ui/app/(drawer)/prices-overview/index.tsx @@ -253,10 +253,6 @@ export default function PricesOverview() { return; } - Alert.alert("Error", "Please enter a valid size"); - return; - } - setPendingChanges(prev => ({ ...prev, [editDialog.sku.id]: { @@ -368,6 +364,7 @@ export default function PricesOverview() { /> )} + setEditDialog({ ...editDialog, open: false, tempFlashPrice: "", tempMarketPrice: "", tempPrice: "" })}> {editDialog.productName} {editDialog.sku?.displayName || editDialog.sku?.name || ''} diff --git a/apps/admin-ui/app/(drawer)/products/add.tsx b/apps/admin-ui/app/(drawer)/products/add.tsx index 2e6e461..d5e07fa 100644 --- a/apps/admin-ui/app/(drawer)/products/add.tsx +++ b/apps/admin-ui/app/(drawer)/products/add.tsx @@ -48,6 +48,9 @@ export default function AddProduct() { featureName: attr.featureName, featureValue: attr.featureValue, })), + comboItems: (variant.comboItems || []).map((ci: any) => ({ + skuId: typeof ci.skuId === 'string' ? parseInt(ci.skuId) : ci.skuId, + })), } }) @@ -56,6 +59,7 @@ export default function AddProduct() { shortDescription: values.shortDescription || undefined, longDescription: values.longDescription || undefined, storeId: values.storeId, + productType: values.productType, incrementStep: 1, skus, }) @@ -72,14 +76,17 @@ export default function AddProduct() { shortDescription: '', longDescription: '', storeId: 1, + productType: 'item' as const, variants: [ { + id: undefined as number | undefined, name: '', price: '', marketPrice: '', isFlashAvailable: false, flashPrice: '', attributes: [{ featureName: 'quantity', featureValue: '' }], + comboItems: [] as { skuId: number | string }[], }, ], } diff --git a/apps/admin-ui/app/(drawer)/products/edit.tsx b/apps/admin-ui/app/(drawer)/products/edit.tsx index 9cf0a7d..98d1474 100644 --- a/apps/admin-ui/app/(drawer)/products/edit.tsx +++ b/apps/admin-ui/app/(drawer)/products/edit.tsx @@ -41,6 +41,7 @@ export default function EditProduct() { shortDescription: productData.shortDescription || '', longDescription: productData.longDescription || '', storeId: productData.storeId || 1, + productType: (productData.productType as 'item' | 'combo') || 'item', variants: (productData.skus || []).map((sku) => ({ id: sku.id, name: sku.name || '', @@ -52,6 +53,9 @@ export default function EditProduct() { featureName: f.featureName, featureValue: f.featureValue, })), + comboItems: (sku.comboItems || []).map((ci: any) => ({ + skuId: ci.skuId.toString(), + })), })), } }, [productData]) @@ -118,6 +122,9 @@ export default function EditProduct() { featureName: attr.featureName, featureValue: attr.featureValue, })), + comboItems: (variant.comboItems || []).map((ci: any) => ({ + skuId: typeof ci.skuId === 'string' ? parseInt(ci.skuId) : ci.skuId, + })), } }) @@ -127,6 +134,7 @@ export default function EditProduct() { shortDescription: values.shortDescription || undefined, longDescription: values.longDescription || undefined, storeId: values.storeId, + productType: values.productType, incrementStep: 1, skus, deletedImageKeys, diff --git a/apps/admin-ui/src/components/ProductForm.tsx b/apps/admin-ui/src/components/ProductForm.tsx index b87a346..ba85a0b 100644 --- a/apps/admin-ui/src/components/ProductForm.tsx +++ b/apps/admin-ui/src/components/ProductForm.tsx @@ -18,6 +18,7 @@ interface Variant { isFlashAvailable: boolean flashPrice: string attributes: Attribute[] + comboItems: { skuId: number | string }[] } interface ProductFormData { @@ -25,6 +26,7 @@ interface ProductFormData { shortDescription: string longDescription: string storeId: number + productType: 'item' | 'combo' variants: Variant[] } @@ -44,12 +46,14 @@ interface ProductFormProps { const defaultAttribute = (): Attribute => ({ featureName: 'quantity', featureValue: '' }) const defaultVariant = (): Variant => ({ + id: undefined, name: '', price: '', marketPrice: '', isFlashAvailable: false, flashPrice: '', attributes: [defaultAttribute()], + comboItems: [], }) const ProductForm = forwardRef(({ @@ -76,6 +80,12 @@ const ProductForm = forwardRef(({ value: store.id, })) || [] + const { data: skusData } = trpc.common.product.getAllSkusSummary.useQuery({}) + const skuOptions = (skusData?.skus || []).map((sku) => ({ + label: sku.label, + value: sku.id.toString(), + })) + return ( (({ style={{ marginBottom: 16 }} /> + setFieldValue('productType', value)} + placeholder="Select product type" + style={{ marginBottom: 16 }} + /> + {({ push, remove }) => ( @@ -271,6 +294,46 @@ const ProductForm = forwardRef(({ } allowMultiple={true} /> + + {values.productType === 'combo' && ( + + + Included Items + { + const items = variant.comboItems || [] + items.push({ skuId: '' }) + setFieldValue(`variants.${vIndex}.comboItems`, items) + }} + style={tw`bg-gray-200 px-2 py-0.5 rounded flex-row items-center`} + > + + Add + + + {variant.comboItems?.map((ci, cIndex) => ( + + + setFieldValue(`variants.${vIndex}.comboItems.${cIndex}.skuId`, val)} + placeholder="Select SKU" + /> + + { + const items = variant.comboItems.filter((_, i) => i !== cIndex) + setFieldValue(`variants.${vIndex}.comboItems`, items) + }} + > + + + + ))} + + )} ))} diff --git a/apps/backend/index.ts b/apps/backend/index.ts index 3a828a5..01e61d5 100755 --- a/apps/backend/index.ts +++ b/apps/backend/index.ts @@ -5,11 +5,9 @@ import { createApp } from '@/src/app' // import signedUrlCache from '@/src/lib/signed-url-cache'; import { seed } from '@/src/lib/seed'; import '@/src/jobs/jobs-index'; -import { startAutomatedJobs } from '@/src/lib/automatedJobs'; seed() initFunc() -startAutomatedJobs() // signedUrlCache.loadFromDisk(); // Disabled for Workers compatibility diff --git a/apps/backend/src/lib/automatedJobs.ts b/apps/backend/src/lib/automatedJobs.ts deleted file mode 100644 index abdece9..0000000 --- a/apps/backend/src/lib/automatedJobs.ts +++ /dev/null @@ -1,137 +0,0 @@ -// import * as cron from 'node-cron'; -const cron:any = {} -import { toggleFlashDeliveryForItems, toggleKeyVal } from '@/src/dbService'; -import { CONST_KEYS } from '@/src/lib/const-keys' -import { computeConstants } from '@/src/lib/const-store' - - -const MUTTON_ITEMS = [ - 12, //Lamb mutton - 14, // Mutton Boti - 35, //Mutton Kheema - 84, //Mutton Brain - 4, //Mutton - 86, //Mutton Chops - 87, //Mutton Soup bones - 85 //Mutton paya -]; - - - -export const startAutomatedJobs = () => { - // Job to disable flash delivery for mutton at 12 PM daily - cron.schedule('0 12 * * *', async () => { - try { - console.log('Disabling flash delivery for products at 12 PM'); - await toggleFlashDeliveryForItems(false, MUTTON_ITEMS); - console.log('Flash delivery disabled successfully'); - } catch (error) { - console.error('Error disabling flash delivery:', error); - } - }); - - // Job to enable flash delivery for mutton at 6 AM daily - cron.schedule('0 6 * * *', async () => { - try { - console.log('Enabling flash delivery for products at 5 AM'); - await toggleFlashDeliveryForItems(true, MUTTON_ITEMS); - console.log('Flash delivery enabled successfully'); - } catch (error) { - console.error('Error enabling flash delivery:', error); - } - }); - - // Job to disable flash delivery feature at 9 PM daily - cron.schedule('0 21 * * *', async () => { - try { - console.log('Disabling flash delivery feature at 9 PM'); - await toggleKeyVal(CONST_KEYS.isFlashDeliveryEnabled, false); - await computeConstants(); // Refresh Redis cache - console.log('Flash delivery feature disabled successfully'); - } catch (error) { - console.error('Error disabling flash delivery feature:', error); - } - }); - - // Job to enable flash delivery feature at 6 AM daily - cron.schedule('0 6 * * *', async () => { - try { - console.log('Enabling flash delivery feature at 6 AM'); - await toggleKeyVal(CONST_KEYS.isFlashDeliveryEnabled, true); - await computeConstants(); // Refresh Redis cache - console.log('Flash delivery feature enabled successfully'); - } catch (error) { - console.error('Error enabling flash delivery feature:', error); - } - }); - - console.log('Automated jobs scheduled'); -}; - -/* -// Old implementation - direct DB queries: -import { db } from '@/src/db/db_index' -import { productInfo, keyValStore } from '@/src/db/schema' -import { inArray, eq } from 'drizzle-orm'; - -// Job to disable flash delivery for mutton at 12 PM daily -cron.schedule('0 12 * * *', async () => { - try { - console.log('Disabling flash delivery for products at 12 PM'); - await db - .update(productInfo) - .set({ isFlashAvailable: false }) - .where(inArray(productInfo.id, MUTTON_ITEMS)); - console.log('Flash delivery disabled successfully'); - } catch (error) { - console.error('Error disabling flash delivery:', error); - } -}); - -// Job to enable flash delivery for mutton at 6 AM daily -cron.schedule('0 6 * * *', async () => { - try { - console.log('Enabling flash delivery for products at 5 AM'); - await db - .update(productInfo) - .set({ isFlashAvailable: true }) - .where(inArray(productInfo.id, MUTTON_ITEMS)); - console.log('Flash delivery enabled successfully'); - } catch (error) { - console.error('Error enabling flash delivery:', error); - } -}); - -// Job to disable flash delivery feature at 9 PM daily -cron.schedule('0 21 * * *', async () => { - try { - console.log('Disabling flash delivery feature at 9 PM'); - await db - .update(keyValStore) - .set({ value: false }) - .where(eq(keyValStore.key, CONST_KEYS.isFlashDeliveryEnabled)); - await computeConstants(); // Refresh Redis cache - console.log('Flash delivery feature disabled successfully'); - } catch (error) { - console.error('Error disabling flash delivery feature:', error); - } -}); - -// Job to enable flash delivery feature at 6 AM daily -cron.schedule('0 6 * * *', async () => { - try { - console.log('Enabling flash delivery feature at 6 AM'); - await db - .update(keyValStore) - .set({ value: true }) - .where(eq(keyValStore.key, CONST_KEYS.isFlashDeliveryEnabled)); - await computeConstants(); // Refresh Redis cache - console.log('Flash delivery feature enabled successfully'); - } catch (error) { - console.error('Error enabling flash delivery feature:', error); - } -}); -*/ - -// Optional: Call on import if desired, or export and call in main app -// startAutomatedJobs(); diff --git a/apps/backend/src/sqliteImporter.ts b/apps/backend/src/sqliteImporter.ts index 9b756cd..c4bec49 100644 --- a/apps/backend/src/sqliteImporter.ts +++ b/apps/backend/src/sqliteImporter.ts @@ -273,7 +273,6 @@ export { type SlotWithProductsData, type UserNegativityData, // Automated Jobs - toggleFlashDeliveryForItems, toggleKeyVal, getAllKeyValStore, // Post-order handler helpers diff --git a/apps/backend/src/stores/product-store.ts b/apps/backend/src/stores/product-store.ts index 2b500bf..47f16b1 100644 --- a/apps/backend/src/stores/product-store.ts +++ b/apps/backend/src/stores/product-store.ts @@ -6,11 +6,13 @@ import { getAllSpecialDealsForCache, getAllProductTagsForCache, getProductById as getProductByIdFromDb, + getAllProductCombosForCache, type ProductBasicData, type StoreBasicData, type DeliverySlotData, type SpecialDealData, type ProductTagData, + type ProductComboCacheData, } from '@/src/dbService' import { scaffoldAssetUrl } from '@/src/lib/s3-client' @@ -33,6 +35,15 @@ interface Product { deliverySlots: Array<{ id: number; deliveryTime: Date; freezeTime: Date; isCapacityFull: boolean }> specialDeals: Array<{ quantity: string; price: string; validTill: Date }> productTags: string[] + productType: string + comboItems: Array<{ + skuId: number + skuName: string | null + unitNotation: string + productName: string + images: string[] | null + price: string + }> } export async function initializeProducts(): Promise { @@ -213,6 +224,14 @@ export async function getAllProducts(): Promise { } const products: Product[] = [] + const allProductCombos = await getAllProductCombosForCache() + const productCombosMap = new Map() + for (const comboItem of allProductCombos) { + if (!productCombosMap.has(comboItem.comboSkuId)) + productCombosMap.set(comboItem.comboSkuId, []) + productCombosMap.get(comboItem.comboSkuId)!.push(comboItem) + } + for (const product of productsData) { const signedImages = scaffoldAssetUrl( (product.images as string[]) || [] @@ -223,6 +242,14 @@ export async function getAllProducts(): Promise { const deliverySlots = deliverySlotsMap.get(product.id) || [] const specialDeals = specialDealsMap.get(product.id) || [] const productTags = productTagsMap.get(product.productId) || [] + const comboItems = (productCombosMap.get(product.id) || []).map((ci) => ({ + skuId: ci.skuId, + skuName: ci.skuName, + unitNotation: ci.unitNotation, + productName: ci.productName, + images: ci.images ? scaffoldAssetUrl((ci.images as string[]) || []) : null, + price: ci.price, + })) products.push({ id: product.id, @@ -253,6 +280,8 @@ export async function getAllProducts(): Promise { validTill: d.validTill, })), productTags: productTags, + productType: product.productType || 'item', + comboItems: comboItems, }) } diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts index 0027ecc..de15fd7 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts @@ -190,6 +190,7 @@ export const productRouter = router({ longDescription: z.string().optional(), storeId: z.number().min(1, 'Store is required'), incrementStep: z.number().optional().default(1), + productType: z.enum(['item', 'combo']).optional().default('item'), skus: z.array(z.object({ name: z.string().optional().nullable(), price: z.number().positive('Price must be positive'), @@ -201,10 +202,13 @@ export const productRouter = router({ featureName: z.string().min(1, 'Attribute name is required'), featureValue: z.string().min(1, 'Value is required'), })).min(1, 'At least one feature is required'), + comboItems: z.array(z.object({ + skuId: z.number().int().positive(), + })).optional(), })).min(1, 'At least one SKU is required'), })) .mutation(async ({ input }): Promise<{ product: AdminProductWithRelations; message: string }> => { - const { name, shortDescription, longDescription, storeId, incrementStep, skus } = input + const { name, shortDescription, longDescription, storeId, incrementStep, productType, skus } = input const existingProduct = await checkProductExistsByName(name.trim()) if (existingProduct) { @@ -224,6 +228,7 @@ export const productRouter = router({ featureName: f.featureName, featureValue: f.featureValue, })), + comboItems: (sku.comboItems || []).map((ci) => ({ skuId: ci.skuId })), })) const newProduct = await createProductInDb({ @@ -232,6 +237,7 @@ export const productRouter = router({ longDescription, storeId, incrementStep, + productType, skus: skuInputs, } as any) @@ -265,6 +271,7 @@ export const productRouter = router({ longDescription: z.string().optional(), storeId: z.number().min(1, 'Store is required'), incrementStep: z.number().optional().default(1), + productType: z.enum(['item', 'combo']).optional(), skus: z.array(z.object({ id: z.number().optional(), name: z.string().optional().nullable(), @@ -277,12 +284,15 @@ export const productRouter = router({ featureName: z.string().min(1, 'Attribute name is required'), featureValue: z.string().min(1, 'Value is required'), })).min(1, 'At least one feature is required'), + comboItems: z.array(z.object({ + skuId: z.number().int().positive(), + })).optional(), })).min(1, 'At least one SKU is required'), deletedImageKeys: z.array(z.string()).optional().default([]), newImageUrls: z.array(z.string()).optional().default([]), })) .mutation(async ({ input }): Promise<{ product: AdminProductWithRelations; message: string }> => { - const { id, name, shortDescription, longDescription, storeId, incrementStep, skus, deletedImageKeys, newImageUrls } = input + const { id, name, shortDescription, longDescription, storeId, incrementStep, productType, skus, deletedImageKeys, newImageUrls } = input if (deletedImageKeys.length > 0) { await deleteImageUtil({ keys: deletedImageKeys }) @@ -302,6 +312,7 @@ export const productRouter = router({ featureName: f.featureName, featureValue: f.featureValue, })), + comboItems: (sku.comboItems || []).map((ci) => ({ skuId: ci.skuId })), })) const updatedProduct = await updateProductInDb(id, { @@ -310,6 +321,7 @@ export const productRouter = router({ longDescription, storeId, incrementStep, + productType, skus: skuInputs, } as any) diff --git a/packages/db_helper_sqlite/drizzle/0002_sku_split.sql b/packages/db_helper_sqlite/drizzle/0002_sku_split.sql index aa127e2..ef64ebb 100644 --- a/packages/db_helper_sqlite/drizzle/0002_sku_split.sql +++ b/packages/db_helper_sqlite/drizzle/0002_sku_split.sql @@ -232,5 +232,18 @@ WHERE `key` = 'popularItems' -- 9. Clean up helper table. DROP TABLE `__product_to_sku`; +-- 10. Add product_type column and product_combos table for combo products. +ALTER TABLE `product_info` ADD COLUMN `product_type` text DEFAULT 'item'; + +CREATE TABLE `product_combos` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `combo_sku_id` integer NOT NULL, + `sku_id` integer NOT NULL, + FOREIGN KEY (`combo_sku_id`) REFERENCES `product_skus`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`sku_id`) REFERENCES `product_skus`(`id`) ON UPDATE no action ON DELETE no action +); + +CREATE UNIQUE INDEX `unique_combo_sku_item` ON `product_combos` (`combo_sku_id`,`sku_id`); + -- PRAGMA foreign_keys=ON; PRAGMA defer_foreign_keys = off; diff --git a/packages/db_helper_sqlite/index.ts b/packages/db_helper_sqlite/index.ts index 8d0013c..fce2a2b 100644 --- a/packages/db_helper_sqlite/index.ts +++ b/packages/db_helper_sqlite/index.ts @@ -331,11 +331,13 @@ export { getAllDeliverySlotsForCache, getAllSpecialDealsForCache, getAllProductTagsForCache, + getAllProductCombosForCache, type ProductBasicData, type StoreBasicData, type DeliverySlotData, type SpecialDealData, type ProductTagData, + type ProductComboCacheData, // Product Tag Store getAllTagsForCache, getAllTagProductMappings, @@ -352,7 +354,6 @@ export { // Automated Jobs Helpers export { - toggleFlashDeliveryForItems, toggleKeyVal, getAllKeyValStore, } from './src/lib/automated-jobs' diff --git a/packages/db_helper_sqlite/src/admin-apis/product.ts b/packages/db_helper_sqlite/src/admin-apis/product.ts index ee35557..89b2800 100644 --- a/packages/db_helper_sqlite/src/admin-apis/product.ts +++ b/packages/db_helper_sqlite/src/admin-apis/product.ts @@ -4,6 +4,7 @@ import { productInfo, productSkus, skuFeatures, + productCombos, units, specialDeals, deliverySlotInfo, @@ -24,6 +25,7 @@ import { couponApplicableProducts, } from '../db/schema' import { and, desc, eq, inArray, sql } from 'drizzle-orm' +import { runBatched } from '../lib/run-batched' import type { InferInsertModel, InferSelectModel } from 'drizzle-orm' import type { AdminProduct, @@ -82,6 +84,7 @@ const mapProduct = (product: ProductRow): AdminProduct => ({ storeId: product.storeId, incrementStep: product.incrementStep, createdAt: product.createdAt, + productType: product.productType as 'item' | 'combo', }) const mapSkuFeature = (feature: SkuFeatureRow): AdminSkuFeature => ({ @@ -91,7 +94,7 @@ const mapSkuFeature = (feature: SkuFeatureRow): AdminSkuFeature => ({ featureValue: feature.featureValue, }) -const mapSku = (sku: SkuRow, features: SkuFeatureRow[] = []): AdminSku => ({ +const mapSku = (sku: SkuRow, features: SkuFeatureRow[] = [], comboItems: any[] = []): AdminSku => ({ id: sku.id, productId: sku.productId, name: sku.name ?? null, @@ -105,6 +108,7 @@ const mapSku = (sku: SkuRow, features: SkuFeatureRow[] = []): AdminSku => ({ flashPrice: sku.flashPrice ? String(sku.flashPrice) : null, createdAt: sku.createdAt, features: features.map(mapSkuFeature), + comboItems: comboItems, }) const mapSpecialDeal = (deal: SpecialDealRow): AdminSpecialDeal => ({ @@ -128,7 +132,7 @@ const mapTagInfo = (tag: ProductTagInfoRow): AdminProductTagInfo => ({ export async function getAllProducts(): Promise { type ProductWithRelationsRow = ProductRow & { store: StoreRow | null - skus: Array + skus: Array } const products = await db.query.productInfo.findMany({ orderBy: productInfo.name, @@ -137,6 +141,11 @@ export async function getAllProducts(): Promise { skus: { with: { features: true, + comboItems: { + with: { + sku: { with: { product: true, features: true } }, + }, + }, }, }, }, @@ -145,7 +154,17 @@ export async function getAllProducts(): Promise { return products.map((product) => ({ ...mapProduct(product), store: product.store ? mapStore(product.store) : null, - skus: product.skus.map((sku) => mapSku(sku, sku.features)), + skus: product.skus.map((sku) => { + const comboItems = (sku.comboItems || []).map((ci: any) => ({ + skuId: ci.skuId, + skuName: ci.sku?.name ?? null, + features: (ci.sku?.features || []).map((f: any) => ({ featureName: f.featureName, featureValue: f.featureValue })), + productName: ci.sku?.product?.name ?? 'Unknown', + images: getStringArray(ci.sku?.images), + price: String(ci.sku?.price ?? '0'), + })) + return mapSku(sku, sku.features, comboItems) + }), })) } @@ -157,6 +176,11 @@ export async function getProductById(id: number): Promise + }) as Array + + const skusWithCombos = product.skus.map((sku) => { + const comboItems = (sku.comboItems || []).map((ci: any) => ({ + skuId: ci.skuId, + skuName: ci.sku?.name ?? null, + features: (ci.sku?.features || []).map((f) => ({ featureName: f.featureName, featureValue: f.featureValue })), + productName: ci.sku?.product?.name ?? 'Unknown', + images: getStringArray(ci.sku?.images), + price: String(ci.sku?.price ?? '0'), + })) + return mapSku(sku, sku.features, comboItems) + }) return { ...mapProduct(product), store: product.store ? mapStore(product.store) : null, - skus: product.skus.map((sku) => mapSku(sku, sku.features)), + skus: skusWithCombos, deals: deals.map(mapSpecialDeal), tags: productTagsData.map((tag) => mapTagInfo(tag.tag)), } @@ -227,6 +263,7 @@ export async function createProduct(input: CreateProductInput): Promise 0) { + await db.insert(productCombos).values( + sku.comboItems.map((ci: any) => ({ + comboSkuId: skuRow.id, + skuId: ci.skuId, + })) + ) + } } const createdSkus = await db.query.productSkus.findMany({ @@ -283,6 +329,7 @@ export async function updateProduct(id: number, input: any): Promise 0) { + await db.insert(productCombos).values( + sku.comboItems.map((ci: any) => ({ + comboSkuId: sku.id, + skuId: ci.skuId, + })) + ) + } } else { // Insert new SKU const [newSku] = await db.insert(productSkus).values({ @@ -370,29 +427,6 @@ export async function updateProduct(id: number, input: any): Promise { const slot = await db.query.deliverySlotInfo.findFirst({ where: eq(deliverySlotInfo.id, parseInt(slotId)), @@ -815,35 +849,39 @@ export async function updateProductPrices(updates: Array<{ } const productIds = updates.map((update) => update.productId) - const existingSkus = await db.query.productSkus.findMany({ - where: inArray(productSkus.id, productIds), - columns: { id: true }, - }) as Array<{ id: number }> - const existingIds = new Set(existingSkus.map((sku: { id: number }) => sku.id)) + // Validate all SKU IDs exist (in chunks to avoid large IN clauses) + const existingSkuChunks = await runBatched(db, productIds, 10, async (tx, chunk) => { + return tx.query.productSkus.findMany({ + where: inArray(productSkus.id, chunk), + columns: { id: true }, + }) + }) + const existingIds = new Set(existingSkuChunks.flat().map((sku: { id: number }) => sku.id)) const invalidIds = productIds.filter((id) => !existingIds.has(id)) if (invalidIds.length > 0) { return { updatedCount: 0, invalidIds } } - const updatePromises = updates.map((update) => { - const { productId, price, marketPrice, flashPrice, isFlashAvailable } = update - const updateData: any = {} + // Apply updates in chunks inside a single transaction + await runBatched(db, updates, 10, async (tx, chunk) => { + for (const update of chunk) { + const { productId, price, marketPrice, flashPrice, isFlashAvailable } = update + const updateData: any = {} - if (price !== undefined) updateData.price = price.toString() - if (marketPrice !== undefined) updateData.marketPrice = marketPrice === null ? null : marketPrice.toString() - if (flashPrice !== undefined) updateData.flashPrice = flashPrice === null ? null : flashPrice.toString() - if (isFlashAvailable !== undefined) updateData.isFlashAvailable = isFlashAvailable + if (price !== undefined) updateData.price = price.toString() + if (marketPrice !== undefined) updateData.marketPrice = marketPrice === null ? null : marketPrice.toString() + if (flashPrice !== undefined) updateData.flashPrice = flashPrice === null ? null : flashPrice.toString() + if (isFlashAvailable !== undefined) updateData.isFlashAvailable = isFlashAvailable - return db - .update(productSkus) - .set(updateData) - .where(eq(productSkus.id, productId)) + await tx + .update(productSkus) + .set(updateData) + .where(eq(productSkus.id, productId)) + } }) - await Promise.all(updatePromises) - return { updatedCount: updates.length, invalidIds: [] } } diff --git a/packages/db_helper_sqlite/src/admin-apis/store.ts b/packages/db_helper_sqlite/src/admin-apis/store.ts index d4442ec..650c288 100644 --- a/packages/db_helper_sqlite/src/admin-apis/store.ts +++ b/packages/db_helper_sqlite/src/admin-apis/store.ts @@ -1,6 +1,7 @@ import { db } from '../db/db_index' import { storeInfo, productInfo } from '../db/schema' import { eq, inArray } from 'drizzle-orm' +import { runBatched } from '../lib/run-batched' export interface Store { id: number @@ -44,32 +45,36 @@ export async function createStore( input: CreateStoreInput, products?: number[] ): Promise { - const [newStore] = await db - .insert(storeInfo) - .values({ - name: input.name, - description: input.description, - imageUrl: input.imageUrl, - owner: input.owner, - }) - .returning() + return db.transaction(async (tx) => { + const [newStore] = await tx + .insert(storeInfo) + .values({ + name: input.name, + description: input.description, + imageUrl: input.imageUrl, + owner: input.owner, + }) + .returning() - if (products && products.length > 0) { - await db - .update(productInfo) - .set({ storeId: newStore.id }) - .where(inArray(productInfo.id, products)) - } + if (products && products.length > 0) { + await runBatched(tx, products, 10, async (t, chunk) => { + await t + .update(productInfo) + .set({ storeId: newStore.id }) + .where(inArray(productInfo.id, chunk)) + }) + } - return { - id: newStore.id, - name: newStore.name, - description: newStore.description, - imageUrl: newStore.imageUrl, - owner: newStore.owner, - createdAt: newStore.createdAt, - // updatedAt: newStore.updatedAt, - } + return { + id: newStore.id, + name: newStore.name, + description: newStore.description, + imageUrl: newStore.imageUrl, + owner: newStore.owner, + createdAt: newStore.createdAt, + // updatedAt: newStore.updatedAt, + } + }) } export interface UpdateStoreInput { @@ -84,42 +89,46 @@ export async function updateStore( input: UpdateStoreInput, products?: number[] ): Promise { - const [updatedStore] = await db - .update(storeInfo) - .set({ - ...input, - // updatedAt: new Date(), - }) - .where(eq(storeInfo.id, id)) - .returning() + return db.transaction(async (tx) => { + const [updatedStore] = await tx + .update(storeInfo) + .set({ + ...input, + // updatedAt: new Date(), + }) + .where(eq(storeInfo.id, id)) + .returning() - if (!updatedStore) { - throw new Error('Store not found') - } - - if (products !== undefined) { - await db - .update(productInfo) - .set({ storeId: null }) - .where(eq(productInfo.storeId, id)) - - if (products.length > 0) { - await db - .update(productInfo) - .set({ storeId: id }) - .where(inArray(productInfo.id, products)) + if (!updatedStore) { + throw new Error('Store not found') } - } - return { - id: updatedStore.id, - name: updatedStore.name, - description: updatedStore.description, - imageUrl: updatedStore.imageUrl, - owner: updatedStore.owner, - createdAt: updatedStore.createdAt, - // updatedAt: updatedStore.updatedAt, - } + if (products !== undefined) { + await tx + .update(productInfo) + .set({ storeId: null }) + .where(eq(productInfo.storeId, id)) + + if (products.length > 0) { + await runBatched(tx, products, 10, async (t, chunk) => { + await t + .update(productInfo) + .set({ storeId: id }) + .where(inArray(productInfo.id, chunk)) + }) + } + } + + return { + id: updatedStore.id, + name: updatedStore.name, + description: updatedStore.description, + imageUrl: updatedStore.imageUrl, + owner: updatedStore.owner, + createdAt: updatedStore.createdAt, + // updatedAt: updatedStore.updatedAt, + } + }) } export async function deleteStore(id: number): Promise<{ message: string }> { diff --git a/packages/db_helper_sqlite/src/db/schema.ts b/packages/db_helper_sqlite/src/db/schema.ts index 14dfc7f..d76a524 100644 --- a/packages/db_helper_sqlite/src/db/schema.ts +++ b/packages/db_helper_sqlite/src/db/schema.ts @@ -65,11 +65,13 @@ const staffRoleValues = ['super_admin', 'admin', 'marketer', 'delivery_staff'] a const staffPermissionValues = ['crud_product', 'make_coupon', 'crud_staff_users'] as const const uploadStatusValues = ['pending', 'claimed'] as const const paymentStatusValues = ['pending', 'success', 'cod', 'failed'] as const +const productTypeValues = ['item', 'combo'] as const export const staffRoleEnum = (name: string) => text(name, { enum: staffRoleValues }) export const staffPermissionEnum = (name: string) => text(name, { enum: staffPermissionValues }) export const uploadStatusEnum = (name: string) => text(name, { enum: uploadStatusValues }) export const paymentStatusEnum = (name: string) => text(name, { enum: paymentStatusValues }) +export const productTypeEnum = (name: string) => text(name, { enum: productTypeValues }) export const users = sqliteTable('users', { id: integer().primaryKey({ autoIncrement: true }), @@ -192,6 +194,7 @@ export const productInfo = sqliteTable('product_info', { storeId: integer('store_id').references(() => storeInfo.id), incrementStep: real('increment_step').notNull().default(1), createdAt: timestampText('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), + productType: productTypeEnum('product_type').notNull().default('item'), }) export const productSkus = sqliteTable('product_skus', { @@ -217,6 +220,14 @@ export const skuFeatures = sqliteTable('sku_features', { unq_sku_feature_name: uniqueIndex('unique_sku_feature_name').on(t.skuId, t.featureName), })) +export const productCombos = sqliteTable('product_combos', { + id: integer().primaryKey({ autoIncrement: true }), + comboSkuId: integer('combo_sku_id').notNull().references(() => productSkus.id), + skuId: integer('sku_id').notNull().references(() => productSkus.id), +}, (t) => ({ + unq_combo_sku: uniqueIndex('unique_combo_sku_item').on(t.comboSkuId, t.skuId), +})) + export const productGroupInfo = sqliteTable('product_group_info', { id: integer().primaryKey({ autoIncrement: true }), groupName: text('group_name').notNull(), @@ -581,12 +592,25 @@ export const productSkusRelations = relations(productSkus, ({ one, many }) => ({ orderItems: many(orderItems), cartItems: many(cartItems), applicableCoupons: many(couponApplicableProducts), + comboItems: many(productCombos, { relationName: 'comboSku' }), })) export const skuFeaturesRelations = relations(skuFeatures, ({ one }) => ({ sku: one(productSkus, { fields: [skuFeatures.skuId], references: [productSkus.id] }), })) +export const productCombosRelations = relations(productCombos, ({ one }) => ({ + comboSku: one(productSkus, { + fields: [productCombos.comboSkuId], + references: [productSkus.id], + relationName: 'comboSku', + }), + sku: one(productSkus, { + fields: [productCombos.skuId], + references: [productSkus.id], + }), +})) + export const productTagInfoRelations = relations(productTagInfo, ({ many }) => ({ products: many(productTags), })) diff --git a/packages/db_helper_sqlite/src/lib/automated-jobs.ts b/packages/db_helper_sqlite/src/lib/automated-jobs.ts index 0a89a48..9eb33e2 100644 --- a/packages/db_helper_sqlite/src/lib/automated-jobs.ts +++ b/packages/db_helper_sqlite/src/lib/automated-jobs.ts @@ -1,23 +1,8 @@ import { db } from '../db/db_index' -import { productInfo, keyValStore } from '../db/schema' -import { inArray, eq } from 'drizzle-orm' +import { keyValStore } from '../db/schema' +import { eq } from 'drizzle-orm' import { castConstValue } from '../lib/const-keys' -/** - * Toggle flash delivery availability for specific products - * @param isAvailable - Whether flash delivery should be available - * @param productIds - Array of product IDs to update - */ -export async function toggleFlashDeliveryForItems( - isAvailable: boolean, - productIds: number[] -): Promise { - await db - .update(productInfo) - .set({ isFlashAvailable: isAvailable }) - .where(inArray(productInfo.id, productIds)) -} - /** * Update key-value store * @param key - The key to update diff --git a/packages/db_helper_sqlite/src/lib/delete-orders.ts b/packages/db_helper_sqlite/src/lib/delete-orders.ts index b3e91d5..f2595f4 100644 --- a/packages/db_helper_sqlite/src/lib/delete-orders.ts +++ b/packages/db_helper_sqlite/src/lib/delete-orders.ts @@ -1,6 +1,7 @@ import { db } from '../db/db_index' import { orders, orderItems, orderStatus, payments, refunds, couponUsage, complaints } from '../db/schema' import { inArray } from 'drizzle-orm' +import { runBatched } from './run-batched' /** * Delete orders and all their related records @@ -13,26 +14,28 @@ export async function deleteOrdersWithRelations(orderIds: number[]): Promise { + // Delete child records first (in correct order to avoid FK constraint errors) - // 1. Delete coupon usage records - await db.delete(couponUsage).where(inArray(couponUsage.orderId, orderIds)) + // 1. Delete coupon usage records + await tx.delete(couponUsage).where(inArray(couponUsage.orderId, chunk)) - // 2. Delete complaints related to these orders - await db.delete(complaints).where(inArray(complaints.orderId, orderIds)) + // 2. Delete complaints related to these orders + await tx.delete(complaints).where(inArray(complaints.orderId, chunk)) - // 3. Delete refunds - await db.delete(refunds).where(inArray(refunds.orderId, orderIds)) + // 3. Delete refunds + await tx.delete(refunds).where(inArray(refunds.orderId, chunk)) - // 4. Delete payments - await db.delete(payments).where(inArray(payments.orderId, orderIds)) + // 4. Delete payments + await tx.delete(payments).where(inArray(payments.orderId, chunk)) - // 5. Delete order status records - await db.delete(orderStatus).where(inArray(orderStatus.orderId, orderIds)) + // 5. Delete order status records + await tx.delete(orderStatus).where(inArray(orderStatus.orderId, chunk)) - // 6. Delete order items - await db.delete(orderItems).where(inArray(orderItems.orderId, orderIds)) + // 6. Delete order items + await tx.delete(orderItems).where(inArray(orderItems.orderId, chunk)) - // 7. Finally delete the orders themselves - await db.delete(orders).where(inArray(orders.id, orderIds)) + // 7. Finally delete the orders themselves + await tx.delete(orders).where(inArray(orders.id, chunk)) + }) } diff --git a/packages/db_helper_sqlite/src/lib/run-batched.ts b/packages/db_helper_sqlite/src/lib/run-batched.ts new file mode 100644 index 0000000..c036feb --- /dev/null +++ b/packages/db_helper_sqlite/src/lib/run-batched.ts @@ -0,0 +1,39 @@ +/** Extracts the transaction handle type from any Drizzle db. */ +type TxOf = DB extends { + transaction: (cb: (tx: infer Tx) => any, ...args: any[]) => any; +} + ? Tx + : never; + +/** + * Runs `runChunk` sequentially over `items` inside a single transaction, + * passing at most `n` items per call. All chunks commit together, or the + * whole thing rolls back. + * + * @param db Drizzle database instance. + * @param items Full array of values (e.g. product ids). + * @param n Max items per batch (keep under SQLite's 999 var limit). + * @param runChunk Async fn that runs your query for one chunk, using `tx`. + */ +export async function runBatched< + DB extends { transaction: (cb: (tx: any) => any, ...args: any[]) => any }, + T, + R, +>( + db: DB, + items: readonly T[], + n: number, + runChunk: (tx: TxOf, chunk: T[]) => Promise, +): Promise { + if (n < 1) throw new RangeError("`n` must be >= 1"); + + const result = await db.transaction(async (tx: TxOf) => { + const out: R[] = []; + for (let i = 0; i < items.length; i += n) { + out.push(await runChunk(tx, items.slice(i, i + n))); + } + return out; + }); + + return result as R[]; +} diff --git a/packages/db_helper_sqlite/src/stores/store-helpers.ts b/packages/db_helper_sqlite/src/stores/store-helpers.ts index d0905db..6f3aeb2 100644 --- a/packages/db_helper_sqlite/src/stores/store-helpers.ts +++ b/packages/db_helper_sqlite/src/stores/store-helpers.ts @@ -57,6 +57,7 @@ export interface ProductBasicData { productQuantity: number isFlashAvailable: boolean flashPrice: string | null + productType: string } export interface StoreBasicData { @@ -130,6 +131,7 @@ export async function getAllProductsForCache(): Promise { productQuantity: 1, isFlashAvailable: sku.isFlashAvailable, flashPrice: sku.flashPrice ? String(sku.flashPrice) : null, + productType: sku.product?.productType ?? 'item', } }) } @@ -176,6 +178,41 @@ export async function getAllProductTagsForCache(): Promise { .innerJoin(productTagInfo, eq(productTags.tagId, productTagInfo.id)) } +// ============================================================================ +// PRODUCT COMBO STORE HELPERS +// ============================================================================ + +export interface ProductComboCacheData { + comboSkuId: number + skuId: number + productName: string + skuName: string | null + images: unknown + unitNotation: string + price: string +} + +export async function getAllProductCombosForCache(): Promise { + const results = await db.query.productCombos.findMany({ + with: { + sku: { with: { product: true, features: true } }, + }, + }) + + return results.map((ci) => { + const features = ci.sku?.features || [] + return { + comboSkuId: ci.comboSkuId, + skuId: ci.skuId, + productName: ci.sku?.product?.name ?? 'Unknown', + skuName: ci.sku?.name ?? null, + images: ci.sku?.images, + unitNotation: features.map((f) => f.featureValue).join(' '), + price: String(ci.sku?.price ?? '0'), + } + }) +} + // ============================================================================ // PRODUCT TAG STORE HELPERS // ============================================================================ diff --git a/packages/db_helper_sqlite/src/user-apis/order.ts b/packages/db_helper_sqlite/src/user-apis/order.ts index 52ded76..2e1ff77 100644 --- a/packages/db_helper_sqlite/src/user-apis/order.ts +++ b/packages/db_helper_sqlite/src/user-apis/order.ts @@ -22,6 +22,7 @@ import type { UserRecentProduct, } from '@packages/shared' import { coerceDate } from '../lib/date' +import { runBatched } from '../lib/run-batched' export interface OrderItemInput { productId: number @@ -626,12 +627,16 @@ export async function getRecentlyDeliveredOrderIds( export async function getSkuIdsFromOrders( orderIds: number[] ): Promise { - const orderItemsResult = await db - .select({ skuId: orderItems.skuId }) - .from(orderItems) - .where(inArray(orderItems.orderId, orderIds)) + if (orderIds.length === 0) return [] - return [...new Set(orderItemsResult.map((item) => item.skuId))] + const skuChunks = await runBatched(db, orderIds, 10, async (tx, chunk) => { + return tx + .select({ skuId: orderItems.skuId }) + .from(orderItems) + .where(inArray(orderItems.orderId, chunk)) + }) + + return [...new Set(skuChunks.flat().map((item) => item.skuId))] } export interface RecentProductData { @@ -649,19 +654,26 @@ export async function getProductsForRecentOrders( productIds: number[], limit: number ): Promise { - const skus = await db.query.productSkus.findMany({ - where: and( - inArray(productSkus.id, productIds), - eq(productSkus.isSuspended, false) - ), - with: { - product: true, - features: true, - }, - orderBy: desc(productSkus.createdAt), - limit, + if (productIds.length === 0) return [] + + const skuChunks = await runBatched(db, productIds, 10, async (tx, chunk) => { + return tx.query.productSkus.findMany({ + where: and( + inArray(productSkus.id, chunk), + eq(productSkus.isSuspended, false) + ), + with: { + product: true, + features: true, + }, + }) }) + const skus = skuChunks + .flat() + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) + .slice(0, limit) + return skus.map((sku) => { const features = sku.features || [] return { @@ -711,48 +723,49 @@ export interface OrderWithFullData { export async function getOrdersByIdsWithFullData( orderIds: number[] ): Promise { - console.log('getting orders byid') + if (orderIds.length === 0) return [] - const ordersResp = await db.query.orders.findMany({ - where: inArray(orders.id, orderIds), - with: { - address: { - columns: { - name: true, - addressLine1: true, - addressLine2: true, - city: true, - state: true, - pincode: true, - phone: true, + const orderChunks = await runBatched(db, orderIds, 10, async (tx, chunk) => { + return tx.query.orders.findMany({ + where: inArray(orders.id, chunk), + with: { + address: { + columns: { + name: true, + addressLine1: true, + addressLine2: true, + city: true, + state: true, + pincode: true, + phone: true, + }, }, - }, - orderItems: { - with: { - sku: { - columns: { - name: true, - price: true, - }, - with: { - product: { - columns: { name: true }, + orderItems: { + with: { + sku: { + columns: { + name: true, + price: true, + }, + with: { + product: { + columns: { name: true }, + }, + features: true, }, - features: true, }, }, }, - }, - slot: { - columns: { - deliveryTime: true, + slot: { + columns: { + deliveryTime: true, + }, }, }, - }, - }) - // as Promise + }) + }) - return ordersResp as OrderWithFullData[]; + return orderChunks.flat() as OrderWithFullData[] } export interface OrderWithCancellationData extends OrderWithFullData { diff --git a/packages/db_helper_sqlite/src/user-apis/product.ts b/packages/db_helper_sqlite/src/user-apis/product.ts index 6c30982..3ac89b9 100644 --- a/packages/db_helper_sqlite/src/user-apis/product.ts +++ b/packages/db_helper_sqlite/src/user-apis/product.ts @@ -1,5 +1,5 @@ import { db } from '../db/db_index' -import { deliverySlotInfo, productInfo, productSkus, productReviews, productTags, specialDeals, storeInfo, users } from '../db/schema' +import { deliverySlotInfo, productInfo, productCombos, productSkus, productReviews, productTags, specialDeals, storeInfo, users } from '../db/schema' import { and, desc, eq, gt, sql } from 'drizzle-orm' import type { UserProductDetailData, UserProductReview } from '@packages/shared' @@ -44,6 +44,25 @@ export async function getProductDetailById(skuId: number): Promise { + const ciFeatures = ci.sku?.features || [] + return { + skuId: ci.skuId, + skuName: ci.sku?.name ?? null, + unitNotation: ciFeatures.map((f) => f.featureValue.replace(/\.0(?=\D|$)/g, '')).join(' '), + productName: ci.sku?.product?.name ?? 'Unknown', + images: getStringArray(ci.sku?.images), + price: String(ci.sku?.price ?? '0'), + } + }) + return { id: sku.id, name: product?.name ?? 'Unknown', @@ -69,6 +88,8 @@ export async function getProductDetailById(skuId: number): Promise { - let productIds: number[] | null = null + const taggedProductIdSet = new Set() - // If tagId is provided, get products that have this tag + // If tagId is provided, get product IDs that have this tag if (tagId) { const taggedProducts = await db .select({ productId: productTags.productId }) .from(productTags) .where(eq(productTags.tagId, tagId)) - productIds = taggedProducts.map(tp => tp.productId) - } - - let whereCondition = undefined - - // Filter by product IDs if tag filtering is applied - if (productIds && productIds.length > 0) { - whereCondition = inArray(productSkus.productId, productIds) + for (const tp of taggedProducts) { + taggedProductIdSet.add(tp.productId) + } } const skus = await db.query.productSkus.findMany({ - where: whereCondition, with: { product: true, features: true, }, }) - return skus.map((sku) => { - const features = sku.features || [] - return { - id: sku.product?.id ?? 0, - name: sku.product?.name ?? 'Unknown', - skuId: sku.id, - skuName: sku.name ?? null, - shortDescription: sku.product?.shortDescription ?? null, - price: String(sku.price ?? '0'), - marketPrice: sku.marketPrice ? String(sku.marketPrice) : null, + return skus + .filter((sku) => { + if (!tagId) return true + return taggedProductIdSet.has(sku.productId) + }) + .map((sku) => { + const features = sku.features || [] + return { + id: sku.product?.id ?? 0, + name: sku.product?.name ?? 'Unknown', + skuId: sku.id, + skuName: sku.name ?? null, + shortDescription: sku.product?.shortDescription ?? null, + price: String(sku.price ?? '0'), + marketPrice: sku.marketPrice ? String(sku.marketPrice) : null, images: sku.images, isOutOfStock: sku.isOutOfStock, + unitShortNotation: features.map((f) => f.featureValue.replace(/\.0(?=\D|$)/g, '')).join(' '), + productQuantity: 1, features: features.map((f) => ({ featureName: f.featureName, featureValue: f.featureValue })), } }) diff --git a/packages/db_helper_sqlite/src/user-apis/stores.ts b/packages/db_helper_sqlite/src/user-apis/stores.ts index d5d2a25..894e664 100644 --- a/packages/db_helper_sqlite/src/user-apis/stores.ts +++ b/packages/db_helper_sqlite/src/user-apis/stores.ts @@ -1,6 +1,6 @@ import { db } from '../db/db_index' import { productInfo, productSkus, storeInfo } from '../db/schema' -import { and, eq, inArray, sql } from 'drizzle-orm' +import { and, asc, eq, inArray } from 'drizzle-orm' import type { InferSelectModel } from 'drizzle-orm' import type { UserStoreDetailData, UserStoreProductData, UserStoreSummaryData, StoreSummary } from '@packages/shared' @@ -12,72 +12,44 @@ const getStringArray = (value: unknown): string[] | null => { } export async function getStoreSummaries(): Promise { - // Count SKUs per store, filtering by suspended SKUs - const storesData = await db - .select({ - id: storeInfo.id, - name: storeInfo.name, - description: storeInfo.description, - imageUrl: storeInfo.imageUrl, - productCount: sql`count(${productSkus.id})`.as('productCount'), - }) - .from(storeInfo) - .leftJoin( - productInfo, - eq(productInfo.storeId, storeInfo.id) - ) - .leftJoin( - productSkus, - and( - eq(productSkus.productId, productInfo.id), - eq(productSkus.isSuspended, false) - ) - ) - .groupBy(storeInfo.id) + const storesData = await db.select({ + id: storeInfo.id, + name: storeInfo.name, + description: storeInfo.description, + imageUrl: storeInfo.imageUrl, + }).from(storeInfo) - const storesWithDetails = await Promise.all( - storesData.map(async (store) => { - let sampleProducts: any[] = [] - // Get sample SKUs from this store - if (store.productCount > 0) { - const storeProductIds = await db - .select({ id: productInfo.id }) - .from(productInfo) - .where(eq(productInfo.storeId, store.id)) + const skus = await db.query.productSkus.findMany({ + where: eq(productSkus.isSuspended, false), + with: { product: true }, + orderBy: asc(productSkus.id), + }) - const productIdArr = storeProductIds.map((p) => p.id) - if (productIdArr.length > 0) { - const skus = await db.query.productSkus.findMany({ - where: and( - inArray(productSkus.productId, productIdArr), - eq(productSkus.isSuspended, false) - ), - with: { - product: { columns: { name: true } }, - }, - columns: { id: true, images: true, name: true }, - limit: 3, - }) - sampleProducts = skus.map((sku) => ({ - id: sku.id, - name: sku.product?.name ?? sku.name ?? 'Unknown', - images: getStringArray(sku.images), - })) - } - } + const skusByStore = new Map() + for (const sku of skus) { + const storeId = sku.product?.storeId + if (storeId == null) continue + if (!skusByStore.has(storeId)) skusByStore.set(storeId, []) + skusByStore.get(storeId)!.push(sku) + } - return { - id: store.id, - name: store.name, - description: store.description ?? null, - imageUrl: store.imageUrl ?? null, - productCount: store.productCount || 0, - sampleProducts, - } - }) - ) + return storesData.map((store) => { + const storeSkus = skusByStore.get(store.id) || [] + const sampleProducts = storeSkus.slice(0, 3).map((sku) => ({ + id: sku.id, + name: sku.product?.name ?? sku.name ?? 'Unknown', + images: getStringArray(sku.images), + })) - return storesWithDetails + return { + id: store.id, + name: store.name, + description: store.description ?? null, + imageUrl: store.imageUrl ?? null, + productCount: storeSkus.length, + sampleProducts, + } + }) } export async function getStoreDetail(storeId: number): Promise { diff --git a/packages/shared/types/admin.ts b/packages/shared/types/admin.ts index 1c784e2..dea702f 100644 --- a/packages/shared/types/admin.ts +++ b/packages/shared/types/admin.ts @@ -360,13 +360,13 @@ export interface AdminUnit { fullName: string; } -export interface AdminSkuVariant { - id: number - skuId: number - name: string - value: string - unitId: number | null - sortOrder: number +export interface AdminProductComboItem { + skuId: number; + skuName: string | null; + features: AdminSkuFeature[]; + productName: string; + images: string[] | null; + price: string; } export interface AdminSku { @@ -383,7 +383,7 @@ export interface AdminSku { flashPrice: string | null createdAt: Date features: AdminSkuFeature[] -} + comboItems: AdminProductComboItem[] } export interface AdminProduct { @@ -394,6 +394,7 @@ export interface AdminProduct { storeId: number | null; incrementStep: number; createdAt: Date; + productType: 'item' | 'combo'; } export interface AdminProductWithRelations extends AdminProduct { diff --git a/packages/shared/types/user.ts b/packages/shared/types/user.ts index 2c19277..43506bb 100644 --- a/packages/shared/types/user.ts +++ b/packages/shared/types/user.ts @@ -286,6 +286,15 @@ export interface UserProductSpecialDeal { validTill: Date; } +export interface UserProductComboItem { + skuId: number; + skuName: string | null; + unitNotation: string; + productName: string; + images: string[] | null; + price: string; +} + export interface UserProductDetailData { id: number; name: string; @@ -303,6 +312,8 @@ export interface UserProductDetailData { flashPrice: string | null; deliverySlots: UserProductDeliverySlot[]; specialDeals: UserProductSpecialDeal[]; + productType: string; + comboItems: UserProductComboItem[]; } export interface UserProductDetail extends UserProductDetailData { From db2ee44a5f61f618c72d3b699c9e5285721c9a92 Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:31:29 +0530 Subject: [PATCH 10/73] enh --- .commandcode/taste/taste.md | 9 ++++- APIS_TO_REMOVE.md | 4 -- apps/backend/src/dbService.ts | 2 - apps/backend/src/postgresImporter.ts | 1 - apps/backend/src/sqliteImporter.ts | 1 - apps/backend/src/stores/slot-store.ts | 4 +- .../src/trpc/apis/admin-apis/apis/product.ts | 27 ------------- packages/db_helper_postgres/index.ts | 1 - .../src/admin-apis/product.ts | 8 ---- packages/db_helper_sqlite/index.ts | 1 - .../src/admin-apis/product.ts | 8 ---- .../db_helper_sqlite/src/user-apis/order.ts | 24 +++++++---- packages/shared/types/admin.ts | 11 +++-- packages/shared/types/user.ts | 6 +-- packages/ui/shared-types.ts | 19 --------- .../ui/src/common-api-hooks/product.api.tsx | 40 ------------------- 16 files changed, 35 insertions(+), 131 deletions(-) delete mode 100644 APIS_TO_REMOVE.md delete mode 100644 packages/ui/src/common-api-hooks/product.api.tsx diff --git a/.commandcode/taste/taste.md b/.commandcode/taste/taste.md index f562cac..eb6348c 100644 --- a/.commandcode/taste/taste.md +++ b/.commandcode/taste/taste.md @@ -1,4 +1,9 @@ -# Taste (Continuously Learned by [CommandCode][cmd]) +# Taste -[cmd]: https://commandcode.ai/ +- Prefers that the agent not run typechecks; instead, read the project's AGENTS.md for instructions on what to do/not do before finalizing work. Confidence: 0.9 +- Prefers detailed analysis and explicit error/mistake checking before executing git operations (e.g., merges), rather than just performing the action. Confidence: 0.6 + +- When removing unused code (e.g., an unused tRPC procedure), prefers a complete cleanup: also remove internal helper methods used only by it, associated types, re-exports, orphaned hook/API files and their shared types, and even commented-out imports, leaving zero remaining references. Confidence: 0.9 + +- Before removing or deciding on code, likes to first verify where it is actually used across the codebase (asks questions like "where is X used"), rather than removing based on assumption. Confidence: 0.4 diff --git a/APIS_TO_REMOVE.md b/APIS_TO_REMOVE.md deleted file mode 100644 index b0a1f25..0000000 --- a/APIS_TO_REMOVE.md +++ /dev/null @@ -1,4 +0,0 @@ -- trpc.user.tags.getTagsByStore — apps/backend/src/trpc/apis/user-apis/apis/tags.ts -- trpc.common.product.getAllProductsSummary — apps/backend/src/trpc/apis/common-apis/common.ts -- remove slots from products cache -- remove redundant product details like name, description etc from the slots api diff --git a/apps/backend/src/dbService.ts b/apps/backend/src/dbService.ts index 46aeed8..ccf32ee 100644 --- a/apps/backend/src/dbService.ts +++ b/apps/backend/src/dbService.ts @@ -51,9 +51,7 @@ export type { AdminProductListResponse, AdminProductResponse, AdminDeleteProductResult, - AdminToggleOutOfStockResult, AdminUpdateSlotProductsResult, - AdminSlotProductIdsResult, AdminSlotsProductIdsResult, AdminProductReview, AdminProductReviewWithSignedUrls, diff --git a/apps/backend/src/postgresImporter.ts b/apps/backend/src/postgresImporter.ts index 2cd2cd7..7296535 100644 --- a/apps/backend/src/postgresImporter.ts +++ b/apps/backend/src/postgresImporter.ts @@ -64,7 +64,6 @@ // replaceProductTags, // toggleProductOutOfStock, // updateSlotProducts, -// getSlotProductIds, // getSlotsProductIds, // getAllUnits, // getAllProductTags, diff --git a/apps/backend/src/sqliteImporter.ts b/apps/backend/src/sqliteImporter.ts index c4bec49..b813fb7 100644 --- a/apps/backend/src/sqliteImporter.ts +++ b/apps/backend/src/sqliteImporter.ts @@ -74,7 +74,6 @@ export { replaceProductTags, mergeSkus, updateSlotProducts, - getSlotProductIds, getSlotsProductIds, getAllUnits, getAllProductTags, diff --git a/apps/backend/src/stores/slot-store.ts b/apps/backend/src/stores/slot-store.ts index 02c6bb5..80c1590 100644 --- a/apps/backend/src/stores/slot-store.ts +++ b/apps/backend/src/stores/slot-store.ts @@ -49,7 +49,7 @@ async function transformSlotToStoreSlot(slot: SlotWithProductsData): Promise { shortDescription: product.shortDescription, price: product.price.toString(), marketPrice: product.marketPrice?.toString() || null, - unit: product.unit?.shortNotation || null, + unit: product.unitNotation || null, images: scaffoldAssetUrl( (product.images as string[]) || [] ), diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts index de15fd7..309fce9 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts @@ -8,7 +8,6 @@ import { getProductById as getProductByIdInDb, deleteProduct as deleteProductInDb, updateSlotProducts as updateSlotProductsInDb, - getSlotProductIds as getSlotProductIdsInDb, getSlotsProductIds as getSlotsProductIdsInDb, getProductReviews as getProductReviewsInDb, respondToReview as respondToReviewInDb, @@ -42,7 +41,6 @@ import type { AdminProductResponse, AdminDeleteProductResult, AdminUpdateSlotProductsResult, - AdminSlotProductIdsResult, AdminSlotsProductIdsResult, AdminUpdateProductPricesResult, } from '@packages/shared' @@ -421,31 +419,6 @@ export const productRouter = router({ } }), - getSlotProductIds: protectedProcedure - .input(z.object({ - slotId: z.string(), - })) - .query(async ({ input }): Promise => { - const { slotId } = input; - - const skuIds = await getSlotProductIdsInDb(slotId) - - /* - // Old implementation - direct DB queries: - const associations = await db.query.productSlots.findMany({ - where: eq(productSlots.slotId, parseInt(slotId)), - columns: { - productId: true, - }, - }); - - const productIds = associations.map(assoc => assoc.productId); - - return { - skuIds, - } - }), - getSlotsProductIds: protectedProcedure .input(z.object({ slotIds: z.array(z.number()), diff --git a/packages/db_helper_postgres/index.ts b/packages/db_helper_postgres/index.ts index f51bba1..fe62a34 100644 --- a/packages/db_helper_postgres/index.ts +++ b/packages/db_helper_postgres/index.ts @@ -85,7 +85,6 @@ export { replaceProductTags, toggleProductOutOfStock, updateSlotProducts, - getSlotProductIds, getSlotsProductIds, getAllUnits, getAllProductTags, diff --git a/packages/db_helper_postgres/src/admin-apis/product.ts b/packages/db_helper_postgres/src/admin-apis/product.ts index 9a9bf6f..7f6f61f 100644 --- a/packages/db_helper_postgres/src/admin-apis/product.ts +++ b/packages/db_helper_postgres/src/admin-apis/product.ts @@ -229,14 +229,6 @@ export async function updateSlotProducts(slotId: string, productIds: string[]): } } -export async function getSlotProductIds(slotId: string): Promise { - const slot = await db.query.deliverySlotInfo.findFirst({ - where: eq(deliverySlotInfo.id, parseInt(slotId)), - }) - - return slot?.productIds || [] -} - export async function getAllUnits(): Promise { const allUnits = await db.query.units.findMany({ orderBy: units.shortNotation, diff --git a/packages/db_helper_sqlite/index.ts b/packages/db_helper_sqlite/index.ts index fce2a2b..ce79c6a 100644 --- a/packages/db_helper_sqlite/index.ts +++ b/packages/db_helper_sqlite/index.ts @@ -84,7 +84,6 @@ export { replaceProductTags, mergeSkus, updateSlotProducts, - getSlotProductIds, getSlotsProductIds, getAllUnits, getAllProductTags, diff --git a/packages/db_helper_sqlite/src/admin-apis/product.ts b/packages/db_helper_sqlite/src/admin-apis/product.ts index 89b2800..f29b117 100644 --- a/packages/db_helper_sqlite/src/admin-apis/product.ts +++ b/packages/db_helper_sqlite/src/admin-apis/product.ts @@ -453,14 +453,6 @@ export async function updateSlotProducts(slotId: string, productIds: string[]): } } -export async function getSlotProductIds(slotId: string): Promise { - const slot = await db.query.deliverySlotInfo.findFirst({ - where: eq(deliverySlotInfo.id, parseInt(slotId)), - }) - - return slot?.skuIds || [] -} - export async function getAllUnits(): Promise { const allUnits = await db.query.units.findMany({ orderBy: units.shortNotation, diff --git a/packages/db_helper_sqlite/src/user-apis/order.ts b/packages/db_helper_sqlite/src/user-apis/order.ts index 2e1ff77..cd88f08 100644 --- a/packages/db_helper_sqlite/src/user-apis/order.ts +++ b/packages/db_helper_sqlite/src/user-apis/order.ts @@ -82,16 +82,19 @@ export interface OrderWithRelations { createdAt: Date orderItems: Array<{ id: number - productId: number + skuId: number quantity: string price: string discountedPrice: string | null is_packaged: boolean - product: { + sku: { id: number - name: string + name: string | null images: unknown - } + product: { + name: string + } | null + } | null }> slot: { deliveryTime: Date @@ -127,16 +130,19 @@ export interface OrderDetailWithRelations { createdAt: Date orderItems: Array<{ id: number - productId: number + skuId: number quantity: string price: string discountedPrice: string | null is_packaged: boolean - product: { + sku: { id: number - name: string + name: string | null images: unknown - } + product: { + name: string + } | null + } | null }> slot: { deliveryTime: Date @@ -387,6 +393,7 @@ export async function getOrdersWithRelations( }, columns: { id: true, + name: true, images: true, }, }, @@ -469,6 +476,7 @@ export async function getOrderByIdWithRelations( }, columns: { id: true, + name: true, images: true, }, }, diff --git a/packages/shared/types/admin.ts b/packages/shared/types/admin.ts index dea702f..3607c58 100644 --- a/packages/shared/types/admin.ts +++ b/packages/shared/types/admin.ts @@ -360,6 +360,13 @@ export interface AdminUnit { fullName: string; } +export interface AdminSkuFeature { + id: number; + skuId: number; + featureName: string; + featureValue: string; +} + export interface AdminProductComboItem { skuId: number; skuName: string | null; @@ -492,10 +499,6 @@ export interface AdminUpdateSlotProductsResult { removed: number; } -export interface AdminSlotProductIdsResult { - skuIds: number[]; -} - export type AdminSlotsProductIdsResult = Record; export interface AdminProductReview { diff --git a/packages/shared/types/user.ts b/packages/shared/types/user.ts index 43506bb..e835cd9 100644 --- a/packages/shared/types/user.ts +++ b/packages/shared/types/user.ts @@ -145,7 +145,7 @@ export interface UserCartProduct { export interface UserCartItem { id: number; - productId: number; + skuId: number; quantity: number; addedAt: Date; product: UserCartProduct; @@ -496,7 +496,7 @@ export interface UserCouponApplicableUser { export interface UserCouponApplicableProduct { id: number; couponId: number; - productId: number; + skuId: number; } export interface UserCoupon { @@ -506,7 +506,7 @@ export interface UserCoupon { discountPercent: string | null; flatDiscount: string | null; minOrder: string | null; - productIds: unknown; + skuIds: unknown; maxValue: string | null; isApplyForAll: boolean; validTill: Date | null; diff --git a/packages/ui/shared-types.ts b/packages/ui/shared-types.ts index 04dd4f0..6716c9b 100755 --- a/packages/ui/shared-types.ts +++ b/packages/ui/shared-types.ts @@ -210,25 +210,6 @@ export interface token_user { gender: string; } -export interface ProductSummary { - id: number; - name: string; - shortDescription?: string; - price: number; - unit: string; - isOutOfStock: boolean; - nextDeliveryDate: string | null; - images: string[]; -} - -export interface GetSlotsProductIdsPayload { - slotIds: number[]; -} - -export interface GetSlotsProductIdsResponse { - [slotId: number]: number[]; -} - export interface Order { id: string; orderId: string; diff --git a/packages/ui/src/common-api-hooks/product.api.tsx b/packages/ui/src/common-api-hooks/product.api.tsx deleted file mode 100644 index 4c684c6..0000000 --- a/packages/ui/src/common-api-hooks/product.api.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import axios from "../services/axios"; -import type { ProductSummary, GetSlotsProductIdsPayload, GetSlotsProductIdsResponse } from '../../shared-types'; - -export interface GetProductsSummaryResponse { - products: ProductSummary[]; - count: number; -} - -const getAllProductsSummaryApi = async (): Promise => { - - const response = await axios.get('/cm/products/summary'); - - return response.data; -}; - -export const useGetAllProductsSummary = () => { - return useQuery({ - queryKey: ['products-summary'], - // queryFn: getAllProductsSummaryApi, - queryFn: async () => { - const response = await axios.get('/cm/products/summary'); - - return response.data; - } - }); -}; - -const getSlotsProductIdsApi = async (payload: GetSlotsProductIdsPayload): Promise => { - const response = await axios.post('/av/products/slots/product-ids', payload); - return response.data; -}; - -export const useGetSlotsProductIds = (slotIds: number[]) => { - return useQuery({ - queryKey: ['slots-product-ids', slotIds], - queryFn: () => getSlotsProductIdsApi({ slotIds }), - enabled: slotIds.length > 0, - }); -}; \ No newline at end of file From 6d67a1e759e1dbe86550b14f78db9001ae3d4311 Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:02:39 +0530 Subject: [PATCH 11/73] enh --- apps/admin-ui/app/(drawer)/products/add.tsx | 4 +++ apps/admin-ui/app/(drawer)/products/edit.tsx | 4 +++ apps/admin-ui/src/components/ProductForm.tsx | 26 +++++++++++++++++-- .../src/trpc/apis/admin-apis/apis/product.ts | 16 +++++++++--- .../drizzle/0002_sku_split.sql | 4 ++- .../src/admin-apis/product.ts | 8 ++++++ packages/db_helper_sqlite/src/db/schema.ts | 4 ++- .../db_helper_sqlite/src/user-apis/product.ts | 2 +- packages/shared/types/admin.ts | 13 ++++++---- 9 files changed, 67 insertions(+), 14 deletions(-) diff --git a/apps/admin-ui/app/(drawer)/products/add.tsx b/apps/admin-ui/app/(drawer)/products/add.tsx index d5e07fa..294bdd4 100644 --- a/apps/admin-ui/app/(drawer)/products/add.tsx +++ b/apps/admin-ui/app/(drawer)/products/add.tsx @@ -44,6 +44,8 @@ export default function AddProduct() { images: variantUrls, isFlashAvailable: variant.isFlashAvailable || false, flashPrice: variant.flashPrice ? parseFloat(variant.flashPrice) : undefined, + isOffer: variant.isOffer || false, + isComboOnly: variant.isComboOnly || false, features: variant.attributes.map((attr: any) => ({ featureName: attr.featureName, featureValue: attr.featureValue, @@ -85,6 +87,8 @@ export default function AddProduct() { marketPrice: '', isFlashAvailable: false, flashPrice: '', + isOffer: false, + isComboOnly: false, attributes: [{ featureName: 'quantity', featureValue: '' }], comboItems: [] as { skuId: number | string }[], }, diff --git a/apps/admin-ui/app/(drawer)/products/edit.tsx b/apps/admin-ui/app/(drawer)/products/edit.tsx index 98d1474..1012943 100644 --- a/apps/admin-ui/app/(drawer)/products/edit.tsx +++ b/apps/admin-ui/app/(drawer)/products/edit.tsx @@ -49,6 +49,8 @@ export default function EditProduct() { marketPrice: sku.marketPrice || '', isFlashAvailable: sku.isFlashAvailable || false, flashPrice: sku.flashPrice || '', + isOffer: sku.isOffer || false, + isComboOnly: sku.isComboOnly || false, attributes: (sku.features || []).map((f) => ({ featureName: f.featureName, featureValue: f.featureValue, @@ -118,6 +120,8 @@ export default function EditProduct() { images: allUrls, isFlashAvailable: variant.isFlashAvailable || false, flashPrice: variant.flashPrice ? parseFloat(variant.flashPrice) : undefined, + isOffer: variant.isOffer || false, + isComboOnly: variant.isComboOnly || false, features: variant.attributes.map((attr: any) => ({ featureName: attr.featureName, featureValue: attr.featureValue, diff --git a/apps/admin-ui/src/components/ProductForm.tsx b/apps/admin-ui/src/components/ProductForm.tsx index ba85a0b..85e5697 100644 --- a/apps/admin-ui/src/components/ProductForm.tsx +++ b/apps/admin-ui/src/components/ProductForm.tsx @@ -6,7 +6,7 @@ import MaterialIcons from '@expo/vector-icons/MaterialIcons' import { trpc } from '../trpc-client' interface Attribute { - featureName: string + featureName: string | null featureValue: string } @@ -17,6 +17,8 @@ interface Variant { marketPrice: string isFlashAvailable: boolean flashPrice: string + isOffer: boolean + isComboOnly: boolean attributes: Attribute[] comboItems: { skuId: number | string }[] } @@ -52,6 +54,8 @@ const defaultVariant = (): Variant => ({ marketPrice: '', isFlashAvailable: false, flashPrice: '', + isOffer: false, + isComboOnly: false, attributes: [defaultAttribute()], comboItems: [], }) @@ -210,7 +214,7 @@ const ProductForm = forwardRef(({ @@ -265,6 +269,24 @@ const ProductForm = forwardRef(({ Flash Available + + setFieldValue(`variants.${vIndex}.isOffer`, !variant.isOffer)} + style={tw`mr-3`} + /> + Offer SKU + + + + setFieldValue(`variants.${vIndex}.isComboOnly`, !variant.isComboOnly)} + style={tw`mr-3`} + /> + Combo Only SKU + + {variant.isFlashAvailable && ( extractKeyFromPresignedUrl(url)), isFlashAvailable: sku.isFlashAvailable, flashPrice: sku.flashPrice ?? null, + isOffer: sku.isOffer, + isComboOnly: sku.isComboOnly, features: sku.features.map((f) => ({ - featureName: f.featureName, + featureName: f.featureName?.trim() || null, featureValue: f.featureValue, })), comboItems: (sku.comboItems || []).map((ci) => ({ skuId: ci.skuId })), @@ -278,8 +282,10 @@ export const productRouter = router({ images: z.array(z.string()).optional().default([]), isFlashAvailable: z.boolean().optional().default(false), flashPrice: z.number().optional().nullable(), + isOffer: z.boolean().optional().default(false), + isComboOnly: z.boolean().optional().default(false), features: z.array(z.object({ - featureName: z.string().min(1, 'Attribute name is required'), + featureName: z.string().nullable().optional(), featureValue: z.string().min(1, 'Value is required'), })).min(1, 'At least one feature is required'), comboItems: z.array(z.object({ @@ -306,8 +312,10 @@ export const productRouter = router({ images: sku.images.map((url) => extractKeyFromPresignedUrl(url)), isFlashAvailable: sku.isFlashAvailable, flashPrice: sku.flashPrice ?? null, + isOffer: sku.isOffer, + isComboOnly: sku.isComboOnly, features: sku.features.map((f) => ({ - featureName: f.featureName, + featureName: f.featureName?.trim() || null, featureValue: f.featureValue, })), comboItems: (sku.comboItems || []).map((ci) => ({ skuId: ci.skuId })), diff --git a/packages/db_helper_sqlite/drizzle/0002_sku_split.sql b/packages/db_helper_sqlite/drizzle/0002_sku_split.sql index ef64ebb..2d30e73 100644 --- a/packages/db_helper_sqlite/drizzle/0002_sku_split.sql +++ b/packages/db_helper_sqlite/drizzle/0002_sku_split.sql @@ -16,6 +16,8 @@ CREATE TABLE `product_skus` ( `is_suspended` integer DEFAULT false NOT NULL, `is_flash_available` integer DEFAULT false NOT NULL, `flash_price` text, + `is_offer` integer DEFAULT false NOT NULL, + `is_combo_only` integer DEFAULT false NOT NULL, `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action ); @@ -23,7 +25,7 @@ CREATE TABLE `product_skus` ( CREATE TABLE `sku_features` ( `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, `sku_id` integer NOT NULL, - `feature_name` text NOT NULL, + `feature_name` text, `feature_value` text NOT NULL, FOREIGN KEY (`sku_id`) REFERENCES `product_skus`(`id`) ON UPDATE no action ON DELETE no action ); diff --git a/packages/db_helper_sqlite/src/admin-apis/product.ts b/packages/db_helper_sqlite/src/admin-apis/product.ts index f29b117..83d0308 100644 --- a/packages/db_helper_sqlite/src/admin-apis/product.ts +++ b/packages/db_helper_sqlite/src/admin-apis/product.ts @@ -106,6 +106,8 @@ const mapSku = (sku: SkuRow, features: SkuFeatureRow[] = [], comboItems: any[] = isSuspended: sku.isSuspended, isFlashAvailable: sku.isFlashAvailable, flashPrice: sku.flashPrice ? String(sku.flashPrice) : null, + isOffer: sku.isOffer, + isComboOnly: sku.isComboOnly, createdAt: sku.createdAt, features: features.map(mapSkuFeature), comboItems: comboItems, @@ -275,6 +277,8 @@ export async function createProduct(input: CreateProductInput): Promise productSkus.id), - featureName: text('feature_name').notNull(), + featureName: text('feature_name'), featureValue: text('feature_value').notNull(), }, (t) => ({ unq_sku_feature_name: uniqueIndex('unique_sku_feature_name').on(t.skuId, t.featureName), diff --git a/packages/db_helper_sqlite/src/user-apis/product.ts b/packages/db_helper_sqlite/src/user-apis/product.ts index 3ac89b9..3fbb777 100644 --- a/packages/db_helper_sqlite/src/user-apis/product.ts +++ b/packages/db_helper_sqlite/src/user-apis/product.ts @@ -181,7 +181,7 @@ export interface ProductSummaryData { isOutOfStock: boolean unitShortNotation: string productQuantity: number - features: { featureName: string; featureValue: string }[] + features: { featureName: string | null; featureValue: string }[] } export async function getAllProductsWithUnits(tagId?: number): Promise { diff --git a/packages/shared/types/admin.ts b/packages/shared/types/admin.ts index 3607c58..f1b4932 100644 --- a/packages/shared/types/admin.ts +++ b/packages/shared/types/admin.ts @@ -171,7 +171,7 @@ export interface AdminOrderDetailsItem { isPackaged: boolean; isPackageVerified: boolean; skuName?: string | null; - features?: { featureName: string; featureValue: string }[]; + features?: { featureName: string | null; featureValue: string }[]; } export interface AdminOrderDetailsPayment { @@ -251,7 +251,7 @@ export interface AdminSlotOrderItem { isPackaged: boolean; isPackageVerified: boolean; skuName?: string | null; - features?: { featureName: string; featureValue: string }[]; + features?: { featureName: string | null; featureValue: string }[]; } export interface AdminSlotOrder { @@ -292,7 +292,7 @@ export interface AdminOrderListItemProduct { isPackaged: boolean; isPackageVerified: boolean; skuName?: string | null; - features?: { featureName: string; featureValue: string }[]; + features?: { featureName: string | null; featureValue: string }[]; } export interface AdminOrderListItem { @@ -363,7 +363,7 @@ export interface AdminUnit { export interface AdminSkuFeature { id: number; skuId: number; - featureName: string; + featureName: string | null; featureValue: string; } @@ -388,6 +388,8 @@ export interface AdminSku { isSuspended: boolean isFlashAvailable: boolean flashPrice: string | null + isOffer: boolean + isComboOnly: boolean createdAt: Date features: AdminSkuFeature[] comboItems: AdminProductComboItem[] @@ -429,6 +431,7 @@ export interface CreateSkuInput { isSuspended?: boolean isFlashAvailable?: boolean flashPrice?: number | string | null + isOffer?: boolean isComboOnly?: boolean sortOrder?: number isDefault?: boolean @@ -573,7 +576,7 @@ export interface AdminSlotProductSummary { name: string; images: string[] | null; skuName?: string | null; - features?: { featureName: string; featureValue: string }[]; + features?: { featureName: string | null; featureValue: string }[]; } export interface AdminVendorSnippet { From b5cdc53e50b4ccdcdf395f2d675b541a595498cb Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Sun, 2 Aug 2026 10:03:21 +0530 Subject: [PATCH 12/73] enh --- apps/admin-ui/app/(drawer)/products/add.tsx | 22 +++ apps/admin-ui/app/(drawer)/products/edit.tsx | 22 +++ apps/admin-ui/src/components/ProductForm.tsx | 84 ++++++++- apps/backend/src/lib/post-order-handler.ts | 9 +- .../apis/admin-apis/apis/vendor-snippets.ts | 14 +- .../src/trpc/apis/common-apis/common.ts | 3 +- .../src/trpc/apis/user-apis/apis/product.ts | 18 ++ .../app/(drawer)/(tabs)/order-again/index.tsx | 173 +++++++++--------- apps/user-ui/components/ProductCard.tsx | 4 +- packages/db_helper_sqlite/index.ts | 12 ++ .../db_helper_sqlite/src/admin-apis/order.ts | 13 +- .../db_helper_sqlite/src/lib/sku-features.ts | 44 +++++ .../src/stores/store-helpers.ts | 13 +- .../db_helper_sqlite/src/user-apis/cart.ts | 5 +- .../db_helper_sqlite/src/user-apis/order.ts | 5 +- .../db_helper_sqlite/src/user-apis/product.ts | 75 +++++++- .../db_helper_sqlite/src/user-apis/stores.ts | 7 +- 17 files changed, 400 insertions(+), 123 deletions(-) create mode 100644 packages/db_helper_sqlite/src/lib/sku-features.ts diff --git a/apps/admin-ui/app/(drawer)/products/add.tsx b/apps/admin-ui/app/(drawer)/products/add.tsx index 294bdd4..2a4427c 100644 --- a/apps/admin-ui/app/(drawer)/products/add.tsx +++ b/apps/admin-ui/app/(drawer)/products/add.tsx @@ -14,6 +14,28 @@ export default function AddProduct() { const handleSubmit = async (values: any, variantImages: ImageUploaderNeoPayload[][], _deletedImageKeys?: string[]) => { try { + for (const variant of values.variants) { + const price = parseFloat(variant.price) + if (isNaN(price) || price <= 0) { + Alert.alert('Error', 'Please enter a valid price for every variant') + return + } + } + + const seenSignatures = new Set() + for (const variant of values.variants) { + const attributes = variant.attributes || [] + const signature = attributes + .map((a: any) => `${(a.featureName ?? '').trim().toLowerCase()}::${a.featureValue.trim().toLowerCase()}`) + .sort() + .join('|') + if (seenSignatures.has(signature)) { + Alert.alert('Error', 'Two variants have the same attributes') + return + } + seenSignatures.add(signature) + } + const allBlobs: { blob: Blob; mimeType: string }[] = [] const imageCounts: number[] = variantImages.map((imgs) => imgs.length) diff --git a/apps/admin-ui/app/(drawer)/products/edit.tsx b/apps/admin-ui/app/(drawer)/products/edit.tsx index 1012943..4ef6f28 100644 --- a/apps/admin-ui/app/(drawer)/products/edit.tsx +++ b/apps/admin-ui/app/(drawer)/products/edit.tsx @@ -78,6 +78,28 @@ export default function EditProduct() { const handleSubmit = async (values: any, variantImages: ImageUploaderNeoPayload[][], deletedImageKeys: string[]) => { try { + for (const variant of values.variants) { + const price = parseFloat(variant.price) + if (isNaN(price) || price <= 0) { + Alert.alert('Error', 'Please enter a valid price for every variant') + return + } + } + + const seenSignatures = new Set() + for (const variant of values.variants) { + const attributes = variant.attributes || [] + const signature = attributes + .map((a: any) => `${(a.featureName ?? '').trim().toLowerCase()}::${a.featureValue.trim().toLowerCase()}`) + .sort() + .join('|') + if (seenSignatures.has(signature)) { + Alert.alert('Error', 'Two variants have the same attributes') + return + } + seenSignatures.add(signature) + } + const allBlobs: { blob: Blob; mimeType: string }[] = [] const imageCounts: number[] = variantImages.map((imgs) => imgs.filter((img) => img.mimeType !== null).length diff --git a/apps/admin-ui/src/components/ProductForm.tsx b/apps/admin-ui/src/components/ProductForm.tsx index 85e5697..8d0db2c 100644 --- a/apps/admin-ui/src/components/ProductForm.tsx +++ b/apps/admin-ui/src/components/ProductForm.tsx @@ -1,6 +1,7 @@ import React, { useState, useImperativeHandle, forwardRef } from 'react' -import { View, TouchableOpacity, ScrollView } from 'react-native' +import { View, TouchableOpacity, ScrollView, Alert } from 'react-native' import { Formik, FieldArray } from 'formik' +import * as Yup from 'yup' import { MyTextInput, BottomDropdown, MyText, tw, ImageUploaderNeo, ImageUploaderNeoItem, ImageUploaderNeoPayload, Checkbox } from 'common-ui' import MaterialIcons from '@expo/vector-icons/MaterialIcons' import { trpc } from '../trpc-client' @@ -60,6 +61,60 @@ const defaultVariant = (): Variant => ({ comboItems: [], }) +const variantSignature = (attributes: Attribute[]): string => + attributes + .map((a) => `${(a.featureName ?? '').trim().toLowerCase()}::${a.featureValue.trim().toLowerCase()}`) + .sort() + .join('|') + +const productValidationSchema = Yup.object().shape({ + name: Yup.string().required('Product name is required'), + storeId: Yup.number().required('Store is required').min(1, 'Store is required'), + productType: Yup.string().oneOf(['item', 'combo'], 'Product type is required').required('Product type is required'), + variants: Yup.array() + .min(1, 'At least one variant is required') + .of( + Yup.object().shape({ + price: Yup.number() + .typeError('Price must be a number') + .positive('Price must be a positive number') + .required('Price is required'), + marketPrice: Yup.number() + .typeError('Market price must be a number') + .min(0, 'Market price cannot be negative') + .nullable() + .transform((value, originalValue) => (originalValue === '' ? null : value)) + .optional(), + flashPrice: Yup.number() + .typeError('Flash price must be a number') + .min(0, 'Flash price cannot be negative') + .nullable() + .transform((value, originalValue) => (originalValue === '' ? null : value)) + .optional(), + attributes: Yup.array() + .min(1, 'At least one attribute is required') + .of( + Yup.object().shape({ + featureValue: Yup.string().required('Value is required'), + }) + ), + }) + ) + .test('unique-variants', 'Two variants have the same attributes', function (variants) { + if (!Array.isArray(variants)) return true + const seen = new Set() + for (const variant of variants) { + const attrs = variant?.attributes || [] + const signature = variantSignature(attrs as Attribute[]) + if (seen.has(signature)) { + return this.createError({ message: 'Two variants have the same attributes' }) + } + seen.add(signature) + } + return true + }), +}) + const ProductForm = forwardRef(({ mode, initialValues, @@ -93,6 +148,7 @@ const ProductForm = forwardRef(({ return ( { const images = variantImages.map((imgs) => imgs.map((img) => ({ url: img.imgUrl, mimeType: img.mimeType })) @@ -113,7 +169,8 @@ const ProductForm = forwardRef(({ }} enableReinitialize > - {({ handleChange, handleSubmit, values, setFieldValue }) => ( + {({ handleChange, handleSubmit, values, setFieldValue, validateForm }) => { + return ( (({ handleSubmit()} + onPress={async () => { + const validationErrors = await validateForm() + if (Object.keys(validationErrors).length > 0) { + const variantsError = validationErrors.variants + const firstVariantErrors = Array.isArray(variantsError) + ? (variantsError[0] as Record | undefined) || {} + : {} + const message = (firstVariantErrors.price as string | undefined) + || (firstVariantErrors.marketPrice as string | undefined) + || (firstVariantErrors.flashPrice as string | undefined) + || (firstVariantErrors.attributes as string | undefined) + || validationErrors.name + || validationErrors.storeId + || variantsError + Alert.alert('Check your form', String(message || 'Please fix the highlighted fields')) + return + } + handleSubmit() + }} disabled={isLoading} style={tw`px-4 py-3 rounded-lg shadow-lg items-center mt-4 ${isLoading ? 'bg-gray-400' : 'bg-blue-500'}`} > @@ -372,7 +447,8 @@ const ProductForm = forwardRef(({ - )} + ) + }} ) }) diff --git a/apps/backend/src/lib/post-order-handler.ts b/apps/backend/src/lib/post-order-handler.ts index 13abcd0..b06e504 100644 --- a/apps/backend/src/lib/post-order-handler.ts +++ b/apps/backend/src/lib/post-order-handler.ts @@ -1,6 +1,8 @@ import { getOrdersByIdsWithFullData, getOrderByIdWithFullData, + composeSkuName, + composeUnitNotation, } from '@/src/dbService' import { sendTelegramMessage } from '@/src/lib/telegram-service' import { queueDataPusher } from '@/src/lib/queue-data-pusher' @@ -49,10 +51,11 @@ const formatOrderMessageWithFullData = (ordersData: any[]): string => { message += '📦 Items:\n'; order.orderItems?.forEach((item: any) => { const sku = item.sku - const features = (sku?.features || []).map((f: any) => f.featureValue).join(' ') - message += ` • ${sku?.product?.name || 'Unknown'} ${features} x${item.quantity}\n`; + const features = sku?.features || [] + const name = composeSkuName(sku?.product?.name || 'Unknown', features) + const unit = composeUnitNotation(features) + message += ` • ${name} ${unit} x${item.quantity}\n`; }); - message += `\n💰 Total: ₹${order.totalAmount}\n`; message += `🚚 Delivery: ${ diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/vendor-snippets.ts b/apps/backend/src/trpc/apis/admin-apis/apis/vendor-snippets.ts index d077771..1ffc792 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/vendor-snippets.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/vendor-snippets.ts @@ -16,6 +16,8 @@ import { getVendorOrders as getVendorOrdersInDb, updateVendorOrderItemPackaging as updateVendorOrderItemPackagingInDb, getSlotsAfterDate as getSlotsAfterDateInDb, + composeSkuName, + composeUnitNotation, } from '@/src/dbService' import type { AdminVendorSnippet, @@ -417,11 +419,11 @@ export const vendorSnippetsRouter = router({ const products = attachedOrderItems.map(item => ({ orderItemId: item.id, productId: item.skuId, - productName: item.sku?.product?.name || 'Unknown', + productName: composeSkuName(item.sku?.product?.name || 'Unknown', item.sku?.features || []), quantity: parseFloat(item.quantity), productSize: 1, price: parseFloat((item.price ?? 0).toString()), - unit: (item.sku?.features || []).map((f: any) => f.featureValue).join(' ') || 'unit', + unit: composeUnitNotation(item.sku?.features || []) || 'unit', subtotal: parseFloat((item.price ?? 0).toString()) * parseFloat(item.quantity), is_packaged: item.is_packaged, is_package_verified: item.is_package_verified, @@ -488,9 +490,9 @@ export const vendorSnippetsRouter = router({ orderDate: order.createdAt ? order.createdAt.toISOString() : new Date(0).toISOString(), totalQuantity: order.orderItems.reduce((sum, item) => sum + parseFloat(item.quantity || '0'), 0), products: order.orderItems.map(item => ({ - name: item.sku?.product?.name || 'Unknown', + name: composeSkuName(item.sku?.product?.name || 'Unknown', item.sku?.features || []), quantity: parseFloat(item.quantity || '0'), - unit: (item.sku?.features || []).map((f: any) => f.featureValue).join(' ') || 'unit', + unit: composeUnitNotation(item.sku?.features || []) || 'unit', })), })) }), @@ -602,10 +604,10 @@ export const vendorSnippetsRouter = router({ const products = attachedOrderItems.map(item => ({ orderItemId: item.id, productId: item.skuId, - productName: item.sku?.product?.name || 'Unknown', + productName: composeSkuName(item.sku?.product?.name || 'Unknown', item.sku?.features || []), quantity: parseFloat(item.quantity), price: parseFloat((item.price ?? 0).toString()), - unit: (item.sku?.features || []).map((f: any) => f.featureValue).join(' ') || 'unit', + unit: composeUnitNotation(item.sku?.features || []) || 'unit', subtotal: parseFloat((item.price ?? 0).toString()) * parseFloat(item.quantity), productSize: 1, is_packaged: item.is_packaged, diff --git a/apps/backend/src/trpc/apis/common-apis/common.ts b/apps/backend/src/trpc/apis/common-apis/common.ts index b9522fc..be00330 100644 --- a/apps/backend/src/trpc/apis/common-apis/common.ts +++ b/apps/backend/src/trpc/apis/common-apis/common.ts @@ -54,7 +54,8 @@ export async function scaffoldProducts() { isFlashAvailable: product.isFlashAvailable, nextDeliveryDate: nextDeliveryDate ? nextDeliveryDate.toISOString() : null, images: product.images, - flashPrice: product.flashPrice + flashPrice: product.flashPrice, + productType: product.productType || 'item' }; }) ); diff --git a/apps/backend/src/trpc/apis/user-apis/apis/product.ts b/apps/backend/src/trpc/apis/user-apis/apis/product.ts index d65d225..be8145a 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/product.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/product.ts @@ -9,6 +9,7 @@ import { getUserProductReviews as getUserProductReviewsInDb, getUserProductByIdBasic as getUserProductByIdBasicInDb, createUserProductReview as createUserProductReviewInDb, + getOffersAndCombos as getOffersAndCombosInDb, } from '@/src/dbService' import type { UserProductDetail, @@ -181,4 +182,21 @@ export const productRouter = router({ return transformedProducts }), + getOffersPage: publicProcedure + .query(async () => { + const data = await getOffersAndCombosInDb(); + + const signImages = (products: typeof data.combos) => + products.map((product) => ({ + ...product, + images: scaffoldAssetUrl((product.images as string[]) || []), + })); + + return { + success: true, + combos: signImages(data.combos), + offers: signImages(data.offers), + }; + }), + }); diff --git a/apps/user-ui/app/(drawer)/(tabs)/order-again/index.tsx b/apps/user-ui/app/(drawer)/(tabs)/order-again/index.tsx index 7bd04bc..b18b9dd 100644 --- a/apps/user-ui/app/(drawer)/(tabs)/order-again/index.tsx +++ b/apps/user-ui/app/(drawer)/(tabs)/order-again/index.tsx @@ -17,17 +17,81 @@ import FloatingCartBar from "@/components/floating-cart-bar"; import TabLayoutWrapper from "@/components/TabLayoutWrapper"; const { width: screenWidth } = Dimensions.get("window"); -const itemWidth = (screenWidth - 48) / 2; +const itemWidth = screenWidth * 0.45; -export default function OrderAgain() { +const rowListContent = { paddingBottom: 16 }; + +interface OffersRowProps { + title: string; + subtitle: string; + products: any[]; + onProductPress: (id: number) => void; +} + +const OffersRow = ({ title, subtitle, products, onProductPress }: OffersRowProps) => { + const renderItem = ({ item }: { item: any }) => ( + + onProductPress(item.id)} + showDeliveryInfo={false} + useAddToCartDialog={true} + miniView={true} + /> + + ); + + return ( + + + + {title} + + + {subtitle} + + + + {products.length === 0 ? ( + + + + No {title.toLowerCase()} available right now + + + ) : ( + + item.id.toString()} + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={rowListContent} + renderItem={renderItem} + removeClippedSubviews={true} + /> + + + )} + + ); +}; + +export default function Offers() { const router = useRouter(); - const { data: recentProductsData, isLoading, error, refetch } = - trpc.user.order.getRecentlyOrderedProducts.useQuery({ - limit: 20, - }); + const { data, isLoading, error, refetch } = + trpc.user.product.getOffersPage.useQuery(); - const recentProducts = recentProductsData?.products || []; + const combos = data?.combos || []; + const offers = data?.offers || []; useManualRefresh(() => { refetch(); @@ -41,9 +105,9 @@ export default function OrderAgain() { return ( - + - Loading your recent orders... + Loading offers... @@ -57,90 +121,33 @@ export default function OrderAgain() { Oops! - Failed to load recent orders + Failed to load offers ); } + const handleProductPress = (id: number) => { + router.push(`/(drawer)/(tabs)/order-again/product-detail/${id}`); + }; + return ( - - - - - - - - - Order Again - - - - Reorder your favorite items quickly - - - - + - - - - Recently Ordered - - - - {recentProducts.length === 0 ? ( - - - - - - No recent orders - - - Items you've ordered recently will appear here - - - ) : ( - - - {recentProducts.map((item, index) => ( - - - router.push( - `/(drawer)/(tabs)/order-again/product-detail/${item.id}` - ) - } - showDeliveryInfo={false} - useAddToCartDialog={true} - /> - - ))} - - - )} - + diff --git a/apps/user-ui/components/ProductCard.tsx b/apps/user-ui/components/ProductCard.tsx index 91dcad6..4c16022 100644 --- a/apps/user-ui/components/ProductCard.tsx +++ b/apps/user-ui/components/ProductCard.tsx @@ -222,7 +222,9 @@ const ProductCard: React.FC = ({ )} - Quantity: {item.unitNotation} + {item.productType !== 'combo' && ( + Quantity: {item.unitNotation} + )} {showDeliveryInfo && displayDeliveryDate && ( diff --git a/packages/db_helper_sqlite/index.ts b/packages/db_helper_sqlite/index.ts index ce79c6a..53b73f6 100644 --- a/packages/db_helper_sqlite/index.ts +++ b/packages/db_helper_sqlite/index.ts @@ -233,8 +233,11 @@ export { createProductReview as createUserProductReview, getAllProductsWithUnits, getAllSkusSummary, + getOffersAndCombos, type ProductSummaryData, type SkuSummary, + type OffersPageData, + type OffersPageProductData, } from './src/user-apis/product' export { @@ -377,6 +380,15 @@ export { deleteOrdersWithRelations, } from './src/lib/delete-orders' +// SKU Features Helper +export { + cleanFeatureValue, + splitQuantityFeature, + composeUnitNotation, + composeSkuName, + type SkuFeatureLike, +} from './src/lib/sku-features' + // Upload URL Helpers export { createUploadUrlStatus, diff --git a/packages/db_helper_sqlite/src/admin-apis/order.ts b/packages/db_helper_sqlite/src/admin-apis/order.ts index 259a68b..3165739 100644 --- a/packages/db_helper_sqlite/src/admin-apis/order.ts +++ b/packages/db_helper_sqlite/src/admin-apis/order.ts @@ -28,6 +28,7 @@ import type { } from '@packages/shared' import type { InferSelectModel } from 'drizzle-orm' import { coerceDate } from '../lib/date' +import { composeSkuName, composeUnitNotation } from '../lib/sku-features' const isPaymentStatus = (value: string): value is PaymentStatus => value === 'pending' || value === 'success' || value === 'cod' || value === 'failed' @@ -236,12 +237,12 @@ export async function getOrderDetails(orderId: number): Promise ({ id: item.id, - name: item.sku.product?.name ?? 'Unknown', + name: composeSkuName(item.sku.product?.name ?? 'Unknown', item.sku.features || []), skuName: item.sku.name ?? null, quantity: item.quantity, productSize: 1, price: item.price, - unit: (item.sku.features || []).map((f: any) => f.featureValue).join(' '), + unit: composeUnitNotation(item.sku.features || []), amount: parseFloat(item.price.toString()) * parseFloat(item.quantity || '0'), isPackaged: item.is_packaged, isPackageVerified: item.is_package_verified, @@ -375,12 +376,12 @@ export async function getSlotOrders(slotId: string): Promise ({ id: item.id, - name: item.sku.product?.name ?? 'Unknown', + name: composeSkuName(item.sku.product?.name ?? 'Unknown', item.sku.features || []), skuName: item.sku.name ?? null, quantity: parseFloat(item.quantity), price: parseFloat(item.price.toString()), amount: parseFloat(item.quantity) * parseFloat(item.price.toString()), - unit: (item.sku.features || []).map((f: any) => f.featureValue).join(' '), + unit: composeUnitNotation(item.sku.features || []), isPackaged: item.is_packaged, isPackageVerified: item.is_package_verified, features: (item.sku.features || []).map((f: any) => ({ @@ -545,12 +546,12 @@ export async function getAllOrders(input: GetAllOrdersInput): Promise ({ id: item.id, - name: item.sku.product?.name ?? 'Unknown', + name: composeSkuName(item.sku.product?.name ?? 'Unknown', item.sku.features || []), skuName: item.sku.name ?? null, quantity: parseFloat(item.quantity), price: parseFloat(item.price.toString()), amount: parseFloat(item.quantity) * parseFloat(item.price.toString()), - unit: (item.sku.features || []).map((f: any) => f.featureValue).join(' '), + unit: composeUnitNotation(item.sku.features || []), productSize: 1, isPackaged: item.is_packaged, isPackageVerified: item.is_package_verified, diff --git a/packages/db_helper_sqlite/src/lib/sku-features.ts b/packages/db_helper_sqlite/src/lib/sku-features.ts new file mode 100644 index 0000000..483933a --- /dev/null +++ b/packages/db_helper_sqlite/src/lib/sku-features.ts @@ -0,0 +1,44 @@ +export interface SkuFeatureLike { + featureName?: string | null + featureValue: string +} + +export const cleanFeatureValue = (value: string): string => + value.replace(/\.0(?=\D|$)/g, '') + +export function splitQuantityFeature(features: SkuFeatureLike[]): { + quantity: string + others: string[] +} { + if (!features || features.length === 0) { + return { quantity: '', others: [] } + } + + const quantityFeature = features.find((f) => f.featureName === 'quantity') + const otherFeatures = features.filter((f) => f.featureName !== 'quantity') + + return { + quantity: quantityFeature ? cleanFeatureValue(quantityFeature.featureValue) : '', + others: otherFeatures.map((f) => cleanFeatureValue(f.featureValue)), + } +} + +/** + * Builds the unit notation for a SKU: the quantity feature value only. + * Falls back to joining all feature values when no quantity feature exists. + */ +export function composeUnitNotation(features: SkuFeatureLike[]): string { + const { quantity, others } = splitQuantityFeature(features) + if (quantity) return quantity + if (others.length > 0) return others.join(' ') + return '' +} + +/** + * Builds the display name for a SKU: the product name followed by the + * values of all non-quantity features (values only). + */ +export function composeSkuName(baseName: string, features: SkuFeatureLike[]): string { + const { others } = splitQuantityFeature(features) + return [baseName, ...others].filter(Boolean).join(' ') +} diff --git a/packages/db_helper_sqlite/src/stores/store-helpers.ts b/packages/db_helper_sqlite/src/stores/store-helpers.ts index 6f3aeb2..a0c7b29 100644 --- a/packages/db_helper_sqlite/src/stores/store-helpers.ts +++ b/packages/db_helper_sqlite/src/stores/store-helpers.ts @@ -15,6 +15,7 @@ import { userIncidents, } from '../db/schema' import { eq, and, gt, sql, isNotNull, asc, inArray } from 'drizzle-orm' +import { composeSkuName, composeUnitNotation } from '../lib/sku-features' // ============================================================================ // BANNER STORE HELPERS @@ -117,7 +118,7 @@ export async function getAllProductsForCache(): Promise { return { id: sku.id, productId: sku.productId, - name: sku.product?.name ?? 'Unknown', + name: composeSkuName(sku.product?.name ?? 'Unknown', features), skuName: sku.name ?? null, shortDescription: sku.product?.shortDescription ?? null, longDescription: sku.product?.longDescription ?? null, @@ -126,7 +127,7 @@ export async function getAllProductsForCache(): Promise { images: sku.images, isOutOfStock: sku.isOutOfStock, storeId: sku.product?.storeId ?? null, - unitNotation: features.map((f) => f.featureValue.replace(/\.0(?=\D|$)/g, '')).join(' '), + unitNotation: composeUnitNotation(features), incrementStep: sku.product?.incrementStep ?? 1, productQuantity: 1, isFlashAvailable: sku.isFlashAvailable, @@ -204,10 +205,10 @@ export async function getAllProductCombosForCache(): Promise f.featureValue).join(' '), + unitNotation: composeUnitNotation(features), price: String(ci.sku?.price ?? '0'), } }) @@ -332,13 +333,13 @@ export async function getAllSlotsWithProductsForCache(): Promise f.featureValue.replace(/\.0(?=\D|$)/g, '')).join(' '), + unitNotation: composeUnitNotation(features), store: sku.product?.store ? { id: sku.product.store.id, name: sku.product.store.name, diff --git a/packages/db_helper_sqlite/src/user-apis/cart.ts b/packages/db_helper_sqlite/src/user-apis/cart.ts index 5ae13f2..521626d 100644 --- a/packages/db_helper_sqlite/src/user-apis/cart.ts +++ b/packages/db_helper_sqlite/src/user-apis/cart.ts @@ -2,6 +2,7 @@ import { db } from '../db/db_index' import { cartItems, productSkus } from '../db/schema' import { and, eq, sql } from 'drizzle-orm' import type { UserCartItem } from '@packages/shared' +import { composeSkuName, composeUnitNotation } from '../lib/sku-features' const getStringArray = (value: unknown): string[] => { if (!Array.isArray(value)) return [] @@ -33,10 +34,10 @@ export async function getCartItemsWithProducts(userId: number): Promise f.featureValue).join(' '), + unit: composeUnitNotation(features), isOutOfStock: sku?.isOutOfStock ?? false, images: getStringArray(sku?.images), }, diff --git a/packages/db_helper_sqlite/src/user-apis/order.ts b/packages/db_helper_sqlite/src/user-apis/order.ts index cd88f08..341034f 100644 --- a/packages/db_helper_sqlite/src/user-apis/order.ts +++ b/packages/db_helper_sqlite/src/user-apis/order.ts @@ -23,6 +23,7 @@ import type { } from '@packages/shared' import { coerceDate } from '../lib/date' import { runBatched } from '../lib/run-batched' +import { composeSkuName, composeUnitNotation } from '../lib/sku-features' export interface OrderItemInput { productId: number @@ -686,12 +687,12 @@ export async function getProductsForRecentOrders( const features = sku.features || [] return { id: sku.id, - name: sku.product?.name ?? 'Unknown', + name: composeSkuName(sku.product?.name ?? 'Unknown', features), shortDescription: sku.product?.shortDescription ?? null, price: String(sku.price ?? '0'), images: sku.images, isOutOfStock: sku.isOutOfStock, - unitShortNotation: features.map((f) => f.featureValue).join(' '), + unitShortNotation: composeUnitNotation(features), incrementStep: sku.product?.incrementStep ?? 1, } }) diff --git a/packages/db_helper_sqlite/src/user-apis/product.ts b/packages/db_helper_sqlite/src/user-apis/product.ts index 3fbb777..6548d9b 100644 --- a/packages/db_helper_sqlite/src/user-apis/product.ts +++ b/packages/db_helper_sqlite/src/user-apis/product.ts @@ -2,6 +2,7 @@ import { db } from '../db/db_index' import { deliverySlotInfo, productInfo, productCombos, productSkus, productReviews, productTags, specialDeals, storeInfo, users } from '../db/schema' import { and, desc, eq, gt, sql } from 'drizzle-orm' import type { UserProductDetailData, UserProductReview } from '@packages/shared' +import { composeSkuName, composeUnitNotation } from '../lib/sku-features' const getStringArray = (value: unknown): string[] | null => { if (!Array.isArray(value)) return null @@ -56,8 +57,8 @@ export async function getProductDetailById(skuId: number): Promise f.featureValue.replace(/\.0(?=\D|$)/g, '')).join(' '), - productName: ci.sku?.product?.name ?? 'Unknown', + unitNotation: composeUnitNotation(ciFeatures), + productName: composeSkuName(ci.sku?.product?.name ?? 'Unknown', ciFeatures), images: getStringArray(ci.sku?.images), price: String(ci.sku?.price ?? '0'), } @@ -65,12 +66,12 @@ export async function getProductDetailById(skuId: number): Promise f.featureValue.replace(/\.0(?=\D|$)/g, '')).join(' '), + unitNotation: composeUnitNotation(features), images: getStringArray(sku.images), isOutOfStock: sku.isOutOfStock, store: storeData ? { @@ -215,7 +216,7 @@ export async function getAllProductsWithUnits(tagId?: number): Promise f.featureValue.replace(/\.0(?=\D|$)/g, '')).join(' '), + unitShortNotation: composeUnitNotation(features), productQuantity: 1, features: features.map((f) => ({ featureName: f.featureName, featureValue: f.featureValue })), } @@ -296,3 +297,65 @@ export async function getAllSkusSummary(): Promise { } }) } + +export interface OffersPageProductData { + id: number + name: string + price: string + marketPrice: string | null + unitNotation: string + images: unknown + isOutOfStock: boolean + incrementStep: number +} + +export interface OffersPageData { + combos: OffersPageProductData[] + offers: OffersPageProductData[] +} + +const mapOffersPageProduct = (sku: { + id: number + price: string | null + marketPrice: string | null + images: unknown + isOutOfStock: boolean + product: { name: string; incrementStep: number | null } | null + features: Array<{ featureValue: string }> +}): OffersPageProductData => { + const features = sku.features || [] + return { + id: sku.id, + name: composeSkuName(sku.product?.name ?? 'Unknown', features), + price: String(sku.price ?? '0'), + marketPrice: sku.marketPrice ? String(sku.marketPrice) : null, + unitNotation: composeUnitNotation(features), + images: sku.images, + isOutOfStock: sku.isOutOfStock, + incrementStep: sku.product?.incrementStep ?? 1, + } +} + +export async function getOffersAndCombos(): Promise { + const skus = await db.query.productSkus.findMany({ + where: eq(productSkus.isSuspended, false), + with: { + product: true, + features: true, + }, + }) + + const combos: OffersPageProductData[] = [] + const offers: OffersPageProductData[] = [] + + for (const sku of skus) { + if (sku.product?.productType === 'combo') { + combos.push(mapOffersPageProduct(sku)) + } + if (sku.isOffer) { + offers.push(mapOffersPageProduct(sku)) + } + } + + return { combos, offers } +} diff --git a/packages/db_helper_sqlite/src/user-apis/stores.ts b/packages/db_helper_sqlite/src/user-apis/stores.ts index 894e664..dd9019a 100644 --- a/packages/db_helper_sqlite/src/user-apis/stores.ts +++ b/packages/db_helper_sqlite/src/user-apis/stores.ts @@ -3,6 +3,7 @@ import { productInfo, productSkus, storeInfo } from '../db/schema' import { and, asc, eq, inArray } from 'drizzle-orm' import type { InferSelectModel } from 'drizzle-orm' import type { UserStoreDetailData, UserStoreProductData, UserStoreSummaryData, StoreSummary } from '@packages/shared' +import { composeSkuName, composeUnitNotation } from '../lib/sku-features' type StoreRow = InferSelectModel @@ -91,13 +92,13 @@ export async function getStoreDetail(storeId: number): Promise f.featureValue.replace(/\.0(?=\D|$)/g, '')).join(' '), - unitNotation: features.map((f) => f.featureValue.replace(/\.0(?=\D|$)/g, '')).join(' '), + unit: composeUnitNotation(features), + unitNotation: composeUnitNotation(features), images: getStringArray(sku.images), isOutOfStock: sku.isOutOfStock, productQuantity: 1, From 0eab909eb1bf824957649e3532a039ed9a14f1ab Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Sun, 2 Aug 2026 11:12:16 +0530 Subject: [PATCH 13/73] enh --- apps/admin-ui/app/(drawer)/dashboard/index.tsx | 2 +- apps/admin-ui/eas.json | 4 ++++ apps/backend/src/stores/product-store.ts | 2 ++ apps/user-ui/components/ProductDetail.tsx | 9 +++++---- apps/user-ui/eas.json | 4 ++++ packages/db_helper_sqlite/src/user-apis/product.ts | 1 + packages/shared/types/user.ts | 1 + 7 files changed, 18 insertions(+), 5 deletions(-) diff --git a/apps/admin-ui/app/(drawer)/dashboard/index.tsx b/apps/admin-ui/app/(drawer)/dashboard/index.tsx index a3de344..c7f8d4f 100644 --- a/apps/admin-ui/app/(drawer)/dashboard/index.tsx +++ b/apps/admin-ui/app/(drawer)/dashboard/index.tsx @@ -74,7 +74,7 @@ export default function Dashboard() { const menuItems: MenuItem[] = [ { - title: 'Manage Orderss', + title: 'Manage Orders', icon: 'shopping-bag', description: 'View and manage customer orders', route: '/(drawer)/manage-orders', diff --git a/apps/admin-ui/eas.json b/apps/admin-ui/eas.json index ce4aa05..c1b701a 100755 --- a/apps/admin-ui/eas.json +++ b/apps/admin-ui/eas.json @@ -8,6 +8,10 @@ "distribution": "internal", "channel": "development" }, + "dev": { + "distribution": "internal", + "channel": "dev" + }, "preview": { "distribution": "internal", "channel": "preview" diff --git a/apps/backend/src/stores/product-store.ts b/apps/backend/src/stores/product-store.ts index 47f16b1..73f7cd4 100644 --- a/apps/backend/src/stores/product-store.ts +++ b/apps/backend/src/stores/product-store.ts @@ -19,6 +19,7 @@ import { scaffoldAssetUrl } from '@/src/lib/s3-client' // Uniform Product Type (matches getProductDetails return) interface Product { id: number + productId: number name: string shortDescription: string | null longDescription: string | null @@ -253,6 +254,7 @@ export async function getAllProducts(): Promise { products.push({ id: product.id, + productId: product.productId, name: product.name, shortDescription: product.shortDescription, longDescription: product.longDescription, diff --git a/apps/user-ui/components/ProductDetail.tsx b/apps/user-ui/components/ProductDetail.tsx index 0feed66..a4db708 100644 --- a/apps/user-ui/components/ProductDetail.tsx +++ b/apps/user-ui/components/ProductDetail.tsx @@ -166,10 +166,11 @@ const ProductDetail: React.FC = ({ productId, isFlashDeliver const loadReviews = async (reset = false) => { if (reviewsLoading || (!hasMore && !reset)) return; + if (!productDetail?.productId) return; setReviewsLoading(true); try { const { reviews: newReviews, hasMore: newHasMore } = await trpcClient.user.product.getProductReviews.query({ - productId: Number(productId), + productId: productDetail.productId, limit: 10, offset: reset ? 0 : reviewsOffset, }); @@ -195,10 +196,10 @@ const ProductDetail: React.FC = ({ productId, isFlashDeliver }; React.useEffect(() => { - if (productDetail?.id) { + if (productDetail?.productId) { loadReviews(true); } - }, [productDetail?.id]); + }, [productDetail?.productId]); // Set the store header title with product name const setStoreHeaderTitle = useStoreHeaderStore((state) => state.setTitle); @@ -477,7 +478,7 @@ const ProductDetail: React.FC = ({ productId, isFlashDeliver {/* Review Form - Moved above or keep below? Usually users want to read reviews first, but if few reviews, writing is good. The original had form then reviews. I will keep format but make it nicer. */} - + diff --git a/apps/user-ui/eas.json b/apps/user-ui/eas.json index a9e57d3..5cbedc9 100755 --- a/apps/user-ui/eas.json +++ b/apps/user-ui/eas.json @@ -13,6 +13,10 @@ "channel": "preview", "autoIncrement": true }, + "dev": { + "channel": "dev", + "autoIncrement": true + }, "production": { "autoIncrement": true, "channel": "production" diff --git a/packages/db_helper_sqlite/src/user-apis/product.ts b/packages/db_helper_sqlite/src/user-apis/product.ts index 6548d9b..7dd8935 100644 --- a/packages/db_helper_sqlite/src/user-apis/product.ts +++ b/packages/db_helper_sqlite/src/user-apis/product.ts @@ -66,6 +66,7 @@ export async function getProductDetailById(skuId: number): Promise Date: Mon, 3 Aug 2026 20:39:40 +0530 Subject: [PATCH 14/73] enh --- apps/admin-ui/package.json | 2 +- apps/backend/scripts/populate_localdb.sh | 10 ++- apps/backend/wrangler-commands.md | 50 +++++++++++- apps/backend/wrangler.dev.toml | 8 +- apps/user-ui/eas.json | 1 + apps/user-ui/package.json | 2 +- packages/ui/package.json | 2 +- scripts/s3-cleaner.js | 26 ++++--- scripts/s3-sync.js | 99 ++++++++++++++++++++++++ 9 files changed, 178 insertions(+), 22 deletions(-) create mode 100644 scripts/s3-sync.js diff --git a/apps/admin-ui/package.json b/apps/admin-ui/package.json index e1691b7..a991b79 100644 --- a/apps/admin-ui/package.json +++ b/apps/admin-ui/package.json @@ -19,7 +19,7 @@ "@react-navigation/elements": "^2.3.8", "@react-navigation/material-top-tabs": "^7.4.11", "@react-navigation/native": "^7.1.6", - "@tanstack/react-query": "^5.85.9", + "@tanstack/react-query": "^5.100.0", "@trpc/client": "^11.6.0", "@trpc/react-query": "^11.6.0", "axios": "^1.11.0", diff --git a/apps/backend/scripts/populate_localdb.sh b/apps/backend/scripts/populate_localdb.sh index 101c70b..e0c2761 100755 --- a/apps/backend/scripts/populate_localdb.sh +++ b/apps/backend/scripts/populate_localdb.sh @@ -3,8 +3,14 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" DUMP_FILE="$ROOT_DIR/dumps/latest.sql" -WRANGLER_CONFIG="$ROOT_DIR/wrangler.dev.toml" -DB_NAME="freshyo-dev" + +if [ "${1:-}" = "--dev" ]; then + WRANGLER_CONFIG="$ROOT_DIR/wrangler.dev.toml" + DB_NAME="freshyo-backend-dev" +else + WRANGLER_CONFIG="$ROOT_DIR/wrangler.prod.toml" + DB_NAME="freshyo-dev" +fi if [ ! -f "$DUMP_FILE" ]; then echo "Dump file not found: $DUMP_FILE" diff --git a/apps/backend/wrangler-commands.md b/apps/backend/wrangler-commands.md index 40425d1..5844c56 100644 --- a/apps/backend/wrangler-commands.md +++ b/apps/backend/wrangler-commands.md @@ -7,4 +7,52 @@ wrangler d1 execute freshyo-dev \ --config wrangler.dev.toml \ - --file ../../packages/db_helper_sqlite/drizzle/0002_sku_split.sql \ No newline at end of file + --file ../../packages/db_helper_sqlite/drizzle/0002_sku_split.sql + +# ============================================================================ +# Importing `wrangler d1 export` dumps locally (--local) +# ============================================================================ + +## Why it can fail with `no such table: main.` + +`wrangler d1 export` writes tables in `sqlite_master` rowid order, which is the +order tables were *historically created*. Any migration that **drops & re-creates** +a table (e.g. the SKU split does `DROP TABLE product_info` + rename) pushes that +parent table to the END of the dump. + +So the dump can create + populate a child table (e.g. `product_skus`, which has +`FOREIGN KEY (product_id) REFERENCES product_info(id)`) BEFORE its parent +(`product_info`) exists. Loading into an empty DB then fails with: + + no such table: main.product_info + +## Why the usual fixes DON'T work + +- `PRAGMA foreign_keys=OFF` — is a **no-op inside a transaction**, and + `wrangler d1 execute --local` runs the whole file as ONE transaction. +- `PRAGMA defer_foreign_keys=TRUE` (already at the top of every export) — only + defers *row-level* FK checks, not the "parent table doesn't exist" error. + +The only real fix is **ordering** the tables. + +## How to check after every export + + grep -nE '^CREATE TABLE|REFERENCES' dumps/.sql + +For every `REFERENCES x(...)`, confirm the `CREATE TABLE "x"` line appears +BEFORE the referencing table's `CREATE TABLE`. + +## How to hand-fix + +Cut the parent table's block (`CREATE TABLE ...` + all its `INSERT ...` lines) +and paste it ABOVE the child table's block. Then verify it loads cleanly: + + sqlite3 :memory: "PRAGMA foreign_keys=ON; BEGIN; .read dumps/.sql; COMMIT;" + +This should exit 0 with no error. (Example already applied to `latest_1.sql`: +`product_info` was moved above `product_skus`.) + +## When to re-check + +After ANY new `wrangler d1 export`, especially once a migration that re-creates +tables has been applied. This is a general trap, not specific to one dump. diff --git a/apps/backend/wrangler.dev.toml b/apps/backend/wrangler.dev.toml index 99b12c3..bd1f08b 100644 --- a/apps/backend/wrangler.dev.toml +++ b/apps/backend/wrangler.dev.toml @@ -8,10 +8,10 @@ routes = [ [[d1_databases]] binding = "DB" -#database_name = "freshyo-backend-dev" -#database_id = "6b93ddc9-9b24-4cfc-9320-e81aec38887a" -database_name = "freshyo-dev" -database_id = "3cad440f-dc14-4baa-ab05-04eb08e206de" +database_name = "freshyo-backend-dev" +database_id = "0814d709-5278-4311-8978-c36c0f05875d" +#database_name = "freshyo-dev" +#database_id = "3cad440f-dc14-4baa-ab05-04eb08e206de" migrations_dir="../../packages/db_helper_sqlite/drizzle" migrations_pattern="migration.sql" [durable_objects] diff --git a/apps/user-ui/eas.json b/apps/user-ui/eas.json index 5cbedc9..77a244f 100755 --- a/apps/user-ui/eas.json +++ b/apps/user-ui/eas.json @@ -14,6 +14,7 @@ "autoIncrement": true }, "dev": { + "distribution": "internal", "channel": "dev", "autoIncrement": true }, diff --git a/apps/user-ui/package.json b/apps/user-ui/package.json index b28bfb2..a809d30 100644 --- a/apps/user-ui/package.json +++ b/apps/user-ui/package.json @@ -19,7 +19,7 @@ "@react-navigation/drawer": "^7.3.9", "@react-navigation/elements": "^2.3.8", "@react-navigation/native": "^7.1.6", - "@tanstack/react-query": "^5.85.9", + "@tanstack/react-query": "^5.100.0", "@trpc/client": "^11.6.0", "@trpc/react-query": "^11.6.0", "axios": "^1.11.0", diff --git a/packages/ui/package.json b/packages/ui/package.json index 3d5e06f..9aed9e2 100755 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -12,7 +12,7 @@ "@react-navigation/drawer": "^7.5.8", "@react-navigation/elements": "^2.3.8", "@react-navigation/native": "^7.1.6", - "@tanstack/react-query": "^5.85.9", + "@tanstack/react-query": "^5.100.0", "axios": "^1.11.0", "buffer": "^6.0.3", "dayjs": "^1.11.18", diff --git a/scripts/s3-cleaner.js b/scripts/s3-cleaner.js index d60e8f2..30893b3 100644 --- a/scripts/s3-cleaner.js +++ b/scripts/s3-cleaner.js @@ -7,11 +7,11 @@ // ============================================================ // CREDENTIALS — replace with your actual values // ============================================================ -const S3_ACCESS_KEY_ID = 'YOUR_ACCESS_KEY_ID' -const S3_SECRET_ACCESS_KEY = 'YOUR_SECRET_ACCESS_KEY' -const S3_REGION = 'auto' // 'us-east-1' for AWS, 'auto' for R2 -const S3_ENDPOINT = 'https://your-account.r2.cloudflarestorage.com' // S3 or R2 endpoint -const S3_BUCKET = 'your-bucket-name' +const S3_ACCESS_KEY_ID = '8fab47503efb9547b50e4fb317e35cc7' +const S3_SECRET_ACCESS_KEY = '47c2eb5636843cf568dda7ad0959a3e42071303f26dbdff94bd45a3c33dcd950' +const S3_REGION = 'apac' // 'us-east-1' for AWS, 'auto' for R2 +const S3_ENDPOINT = 'https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com' // S3 or R2 endpoint +const S3_BUCKET = 'meatfarmer' const API_CACHE_KEY = 'api-cache' // matches API_CACHE_KEY env var in backend // ============================================================ @@ -62,14 +62,16 @@ async function listAllObjects(prefix) { async function deleteObjects(keys) { let deleted = 0 - const batchSize = 1000 + const concurrency = 20 - for (let i = 0; i < keys.length; i += batchSize) { - const batch = keys.slice(i, i + batchSize) - const objects = batch.map((key) => ({ key })) - - const result = await s3.deleteObjects({ objects }) - deleted += result.deleted?.length ?? 0 + for (let i = 0; i < keys.length; i += concurrency) { + const batch = keys.slice(i, i + concurrency) + await Promise.all( + batch.map(async (key) => { + await s3.delete(key) + deleted++ + }) + ) process.stdout.write(`\r Deleted ${deleted}/${keys.length}...`) } diff --git a/scripts/s3-sync.js b/scripts/s3-sync.js new file mode 100644 index 0000000..cf137d9 --- /dev/null +++ b/scripts/s3-sync.js @@ -0,0 +1,99 @@ +#!/usr/bin/env bun +// s3-sync.js — Ensure all objects in the source bucket exist in the dest bucket +// Usage: bun s3-sync.js +// Lists all objects in SOURCE and DEST, then copies only the objects that +// are missing from DEST. Direction is one-way: SOURCE -> DEST. + +// ============================================================ +// CREDENTIALS — replace with your actual values +// ============================================================ +const SOURCE = { + accessKeyId: '8fab47503efb9547b50e4fb317e35cc7', + secretAccessKey: '47c2eb5636843cf568dda7ad0959a3e42071303f26dbdff94bd45a3c33dcd950', + region: 'auto', // 'us-east-1' for AWS, 'auto' for R2 + endpoint: 'https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com', // S3 or R2 endpoint + bucket: 'meatfarmer', +} + +const DEST = { + accessKeyId: '8fab47503efb9547b50e4fb317e35cc7', + secretAccessKey: '47c2eb5636843cf568dda7ad0959a3e42071303f26dbdff94bd45a3c33dcd950', + region: 'auto', + endpoint: 'https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com', + bucket: 'meatfarmer-dev', +} +// ============================================================ + +const CONCURRENCY = 20 + +if (!SOURCE.endpoint || !SOURCE.bucket || !DEST.endpoint || !DEST.bucket) { + console.error('Fill in the SOURCE and DEST credentials at the top of the script first.') + process.exit(1) +} + +const source = new Bun.S3Client(SOURCE) +const dest = new Bun.S3Client(DEST) + +async function listAllObjects(s3) { + const allKeys = [] + let continuationToken + + do { + const options = { maxKeys: 1000 } + if (continuationToken) options.continuationToken = continuationToken + + const result = await s3.list(options) + for (const obj of result.contents) { + allKeys.push(obj.key) + } + continuationToken = result.nextContinuationToken + + process.stdout.write(`\r Listed ${allKeys.length} objects...`) + } while (continuationToken) + + console.log('') + return allKeys +} + +// ============================================================ +// MAIN +// ============================================================ +try { + console.log(`🔁 S3 Sync — ${SOURCE.bucket} -> ${DEST.bucket}`) + + console.log('\n📋 Listing SOURCE...') + const sourceKeys = await listAllObjects(source) + + console.log('\n📋 Listing DEST...') + const destKeys = await listAllObjects(dest) + + const destSet = new Set(destKeys) + const toCopy = sourceKeys.filter((key) => !destSet.has(key)) + + console.log(`\n📊 SOURCE objects: ${sourceKeys.length}`) + console.log(` DEST objects: ${destKeys.length}`) + console.log(` To copy: ${toCopy.length} (present in SOURCE, missing in DEST)`) + + if (toCopy.length === 0) { + console.log('\n✅ DEST already has everything from SOURCE.') + process.exit(0) + } + + console.log('\n🚚 Copying missing objects...') + let copied = 0 + for (let i = 0; i < toCopy.length; i += CONCURRENCY) { + const batch = toCopy.slice(i, i + CONCURRENCY) + await Promise.all( + batch.map(async (key) => { + await dest.write(key, source.file(key)) + copied++ + }) + ) + process.stdout.write(`\r Copied ${copied}/${toCopy.length}...`) + } + + console.log(`\n✅ Done — copied ${copied} objects to ${DEST.bucket}.`) +} catch (error) { + console.error(`\n❌ Error: ${error.message}`) + process.exit(1) +} From 9bfe057c49a8b9800e732da7e451f50f0208093e Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:03:55 +0530 Subject: [PATCH 15/73] enh --- .commandcode/settings.json | 9 + .commandcode/taste/taste.md | 9 +- .commandcode/taste/taste/taste.md | 11 ++ apps/admin-ui/app/(drawer)/products/add.tsx | 11 +- apps/admin-ui/app/(drawer)/products/edit.tsx | 9 + apps/admin-ui/src/components/ProductForm.tsx | 46 ++++- .../src/trpc/apis/admin-apis/apis/product.ts | 4 + .../src/trpc/apis/common-apis/common.ts | 29 +++- .../app/(drawer)/(tabs)/home/index.tsx | 162 +++++++++++++++++- .../app/(drawer)/(tabs)/order-again/index.tsx | 4 +- apps/user-ui/app/(drawer)/_layout.tsx | 6 +- .../components/CheckoutAddressSelector.tsx | 30 +++- apps/user-ui/components/ProductDetail.tsx | 8 +- apps/user-ui/components/SlotSpecificView.tsx | 4 +- apps/user-ui/components/cart-page.tsx | 5 +- apps/user-ui/components/floating-cart-bar.tsx | 11 +- apps/user-ui/components/icons/OffersIcon.tsx | 29 ++++ .../src/components/AddToCartDialog.tsx | 2 +- .../src/admin-apis/product.ts | 28 +++ .../src/stores/store-helpers.ts | 15 +- .../db_helper_sqlite/src/user-apis/cart.ts | 48 +++--- .../db_helper_sqlite/src/user-apis/product.ts | 4 +- 22 files changed, 427 insertions(+), 57 deletions(-) create mode 100644 .commandcode/settings.json create mode 100644 .commandcode/taste/taste/taste.md create mode 100644 apps/user-ui/components/icons/OffersIcon.tsx diff --git a/.commandcode/settings.json b/.commandcode/settings.json new file mode 100644 index 0000000..b8291e3 --- /dev/null +++ b/.commandcode/settings.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Shell(npx tsc --noEmit --skipLibCheck src/trpc/apis/common-apis/common.ts 2 >& 1)" + ], + "deny": [], + "defaultMode": "default" + } +} \ No newline at end of file diff --git a/.commandcode/taste/taste.md b/.commandcode/taste/taste.md index eb6348c..1e0e882 100644 --- a/.commandcode/taste/taste.md +++ b/.commandcode/taste/taste.md @@ -1,9 +1,2 @@ # Taste - -- Prefers that the agent not run typechecks; instead, read the project's AGENTS.md for instructions on what to do/not do before finalizing work. Confidence: 0.9 - -- Prefers detailed analysis and explicit error/mistake checking before executing git operations (e.g., merges), rather than just performing the action. Confidence: 0.6 - -- When removing unused code (e.g., an unused tRPC procedure), prefers a complete cleanup: also remove internal helper methods used only by it, associated types, re-exports, orphaned hook/API files and their shared types, and even commented-out imports, leaving zero remaining references. Confidence: 0.9 - -- Before removing or deciding on code, likes to first verify where it is actually used across the codebase (asks questions like "where is X used"), rather than removing based on assumption. Confidence: 0.4 +See [taste/taste.md](taste/taste.md) diff --git a/.commandcode/taste/taste/taste.md b/.commandcode/taste/taste/taste.md new file mode 100644 index 0000000..84ef180 --- /dev/null +++ b/.commandcode/taste/taste/taste.md @@ -0,0 +1,11 @@ +# Taste +- Prefers that the agent not run typechecks; instead, read the project's AGENTS.md for instructions on what to do/not do before finalizing work. Confidence: 0.9 +- Prefers detailed analysis and explicit error/mistake checking before executing git operations (e.g., merges), rather than just performing the action. Confidence: 0.6 +- When removing unused code (e.g., an unused tRPC procedure), prefers a complete cleanup: also remove internal helper methods used only by it, associated types, re-exports, orphaned hook/API files and their shared types, and even commented-out imports, leaving zero remaining references. Confidence: 0.9 +- Before removing or deciding on code, likes to first verify where it is actually used across the codebase (asks questions like "where is X used"), rather than removing based on assumption. Confidence: 0.4 +- Prefers light/pastel colors for randomized or accent UI elements (e.g., tabs, chips). Confidence: 0.7 +- Wants the agent to come up with a plan first before implementing code changes. Confidence: 0.9 +- Appreciates being asked clarifying design questions with concrete options during planning. Confidence: 0.7 +- Prefers embedding related data into existing cache files over creating or calling separate API endpoints. Confidence: 0.9 +- Prefers using icons from Hicons (rather than custom/hand-authored SVGs or other icon libraries). Confidence: 0.9 +- When a feature or page is repurposed, wants file names, route names, and code references renamed to match the new purpose (not just UI content/icons). Confidence: 0.8 diff --git a/apps/admin-ui/app/(drawer)/products/add.tsx b/apps/admin-ui/app/(drawer)/products/add.tsx index 2a4427c..df118c3 100644 --- a/apps/admin-ui/app/(drawer)/products/add.tsx +++ b/apps/admin-ui/app/(drawer)/products/add.tsx @@ -25,6 +25,13 @@ export default function AddProduct() { const seenSignatures = new Set() for (const variant of values.variants) { const attributes = variant.attributes || [] + const hasQuantity = attributes.some( + (a: any) => (a.featureName ?? '').trim().toLowerCase() === 'quantity' + ) + if (!hasQuantity) { + Alert.alert('Error', 'Every SKU must have a quantity feature') + return + } const signature = attributes .map((a: any) => `${(a.featureName ?? '').trim().toLowerCase()}::${a.featureValue.trim().toLowerCase()}`) .sort() @@ -68,6 +75,7 @@ export default function AddProduct() { flashPrice: variant.flashPrice ? parseFloat(variant.flashPrice) : undefined, isOffer: variant.isOffer || false, isComboOnly: variant.isComboOnly || false, + isSuspended: variant.isSuspended || false, features: variant.attributes.map((attr: any) => ({ featureName: attr.featureName, featureValue: attr.featureValue, @@ -111,7 +119,8 @@ export default function AddProduct() { flashPrice: '', isOffer: false, isComboOnly: false, - attributes: [{ featureName: 'quantity', featureValue: '' }], + isSuspended: false, + attributes: [{ featureName: '', featureValue: '' }], comboItems: [] as { skuId: number | string }[], }, ], diff --git a/apps/admin-ui/app/(drawer)/products/edit.tsx b/apps/admin-ui/app/(drawer)/products/edit.tsx index 4ef6f28..a6e06d2 100644 --- a/apps/admin-ui/app/(drawer)/products/edit.tsx +++ b/apps/admin-ui/app/(drawer)/products/edit.tsx @@ -51,6 +51,7 @@ export default function EditProduct() { flashPrice: sku.flashPrice || '', isOffer: sku.isOffer || false, isComboOnly: sku.isComboOnly || false, + isSuspended: sku.isSuspended || false, attributes: (sku.features || []).map((f) => ({ featureName: f.featureName, featureValue: f.featureValue, @@ -89,6 +90,13 @@ export default function EditProduct() { const seenSignatures = new Set() for (const variant of values.variants) { const attributes = variant.attributes || [] + const hasQuantity = attributes.some( + (a: any) => (a.featureName ?? '').trim().toLowerCase() === 'quantity' + ) + if (!hasQuantity) { + Alert.alert('Error', 'Every SKU must have a quantity feature') + return + } const signature = attributes .map((a: any) => `${(a.featureName ?? '').trim().toLowerCase()}::${a.featureValue.trim().toLowerCase()}`) .sort() @@ -144,6 +152,7 @@ export default function EditProduct() { flashPrice: variant.flashPrice ? parseFloat(variant.flashPrice) : undefined, isOffer: variant.isOffer || false, isComboOnly: variant.isComboOnly || false, + isSuspended: variant.isSuspended || false, features: variant.attributes.map((attr: any) => ({ featureName: attr.featureName, featureValue: attr.featureValue, diff --git a/apps/admin-ui/src/components/ProductForm.tsx b/apps/admin-ui/src/components/ProductForm.tsx index 8d0db2c..336c706 100644 --- a/apps/admin-ui/src/components/ProductForm.tsx +++ b/apps/admin-ui/src/components/ProductForm.tsx @@ -20,6 +20,7 @@ interface Variant { flashPrice: string isOffer: boolean isComboOnly: boolean + isSuspended: boolean attributes: Attribute[] comboItems: { skuId: number | string }[] } @@ -46,7 +47,7 @@ interface ProductFormProps { existingVariantImageKeys?: string[][] } -const defaultAttribute = (): Attribute => ({ featureName: 'quantity', featureValue: '' }) +const defaultAttribute = (): Attribute => ({ featureName: '', featureValue: '' }) const defaultVariant = (): Variant => ({ id: undefined, @@ -57,6 +58,7 @@ const defaultVariant = (): Variant => ({ flashPrice: '', isOffer: false, isComboOnly: false, + isSuspended: false, attributes: [defaultAttribute()], comboItems: [], }) @@ -112,6 +114,19 @@ const productValidationSchema = Yup.object().shape({ seen.add(signature) } return true + }) + .test('quantity-feature', 'Every SKU must have a quantity feature', function (variants) { + if (!Array.isArray(variants)) return true + for (const variant of variants) { + const attrs = variant?.attributes || [] + const hasQuantity = attrs.some( + (a) => ((a as Attribute)?.featureName ?? '').trim().toLowerCase() === 'quantity' + ) + if (!hasQuantity) { + return this.createError({ message: 'Every SKU must have a quantity feature' }) + } + } + return true }), }) @@ -289,6 +304,13 @@ const ProductForm = forwardRef(({ )} ))} + pushAttr(defaultAttribute())} + style={tw`bg-gray-200 px-2 py-0.5 rounded flex-row items-center self-start`} + > + + Add Feature + )} @@ -344,6 +366,17 @@ const ProductForm = forwardRef(({ Combo Only SKU + {mode === 'edit' && ( + + setFieldValue(`variants.${vIndex}.isSuspended`, !variant.isSuspended)} + style={tw`mr-3`} + /> + Suspend SKU + + )} + {variant.isFlashAvailable && ( (({ )} ))} + + { + push(defaultVariant()) + setVariantImages((prev) => [...prev, []]) + }} + style={tw`bg-blue-500 px-3 py-2 rounded-lg flex-row items-center justify-center mb-4`} + > + + Add Variant + )} diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts index 4e4f69f..8d99e3e 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts @@ -198,6 +198,7 @@ export const productRouter = router({ flashPrice: z.number().optional().nullable(), isOffer: z.boolean().optional().default(false), isComboOnly: z.boolean().optional().default(false), + isSuspended: z.boolean().optional().default(false), features: z.array(z.object({ featureName: z.string().nullable().optional(), featureValue: z.string().min(1, 'Value is required'), @@ -226,6 +227,7 @@ export const productRouter = router({ flashPrice: sku.flashPrice ?? null, isOffer: sku.isOffer, isComboOnly: sku.isComboOnly, + isSuspended: sku.isSuspended, features: sku.features.map((f) => ({ featureName: f.featureName?.trim() || null, featureValue: f.featureValue, @@ -284,6 +286,7 @@ export const productRouter = router({ flashPrice: z.number().optional().nullable(), isOffer: z.boolean().optional().default(false), isComboOnly: z.boolean().optional().default(false), + isSuspended: z.boolean().optional().default(false), features: z.array(z.object({ featureName: z.string().nullable().optional(), featureValue: z.string().min(1, 'Value is required'), @@ -314,6 +317,7 @@ export const productRouter = router({ flashPrice: sku.flashPrice ?? null, isOffer: sku.isOffer, isComboOnly: sku.isComboOnly, + isSuspended: sku.isSuspended, features: sku.features.map((f) => ({ featureName: f.featureName?.trim() || null, featureValue: f.featureValue, diff --git a/apps/backend/src/trpc/apis/common-apis/common.ts b/apps/backend/src/trpc/apis/common-apis/common.ts index be00330..5f945b9 100644 --- a/apps/backend/src/trpc/apis/common-apis/common.ts +++ b/apps/backend/src/trpc/apis/common-apis/common.ts @@ -4,8 +4,10 @@ import { getNextDeliveryDateWithCapacity, getStoresSummary, getAllSkusSummary as getAllSkusSummaryInDb, + getAllTagsForCache, + getAllTagProductMappings, } from '@/src/dbService' -import { generateSignedUrlsFromS3Urls, generateSignedUrlFromS3Url } from '@/src/lib/s3-client' +import { generateSignedUrlsFromS3Urls, generateSignedUrlFromS3Url, scaffoldAssetUrl } from '@/src/lib/s3-client' import { getAllProducts as getAllProductsFromCache } from '@/src/stores/product-store' import { getDashboardTags as getDashboardTagsFromCache } from '@/src/stores/product-tag-store' @@ -60,9 +62,34 @@ export async function scaffoldProducts() { }) ); + // Fetch all product tags with their mapped product ids + const [allTags, tagMappings] = await Promise.all([ + getAllTagsForCache(), + getAllTagProductMappings(), + ]) + + const productIdsByTag = new Map() + for (const mapping of tagMappings) { + if (!productIdsByTag.has(mapping.tagId)) { + productIdsByTag.set(mapping.tagId, []) + } + productIdsByTag.get(mapping.tagId)!.push(mapping.productId) + } + + const tags = allTags.map((tag: { id: number; tagName: string; tagDescription: string | null; imageUrl: string | null; isDashboardTag: boolean; relatedStores: unknown }) => ({ + id: tag.id, + tagName: tag.tagName, + tagDescription: tag.tagDescription, + imageUrl: tag.imageUrl ? scaffoldAssetUrl(tag.imageUrl) : null, + isDashboardTag: tag.isDashboardTag, + relatedStores: (tag.relatedStores as number[]) || [], + productIds: productIdsByTag.get(tag.id) || [], + })) + return { products: formattedProducts, count: formattedProducts.length, + tags, }; } diff --git a/apps/user-ui/app/(drawer)/(tabs)/home/index.tsx b/apps/user-ui/app/(drawer)/(tabs)/home/index.tsx index bd26514..3ae8a63 100755 --- a/apps/user-ui/app/(drawer)/(tabs)/home/index.tsx +++ b/apps/user-ui/app/(drawer)/(tabs)/home/index.tsx @@ -1,5 +1,5 @@ import React, { useState, useCallback, useMemo, memo } from "react"; -import { View, Dimensions, Image, RefreshControl } from "react-native"; +import { View, Dimensions, Image, RefreshControl, ScrollView } from "react-native"; import { LinearGradient } from "expo-linear-gradient"; import { useRouter } from "expo-router"; import { @@ -58,6 +58,20 @@ const staticStyles = { slotsListContent: { paddingBottom: 24 }, }; +// Light/pastel color pairs for the Explore Products tabs. +const TAG_COLORS = [ + { bg: '#FFE4E6', border: '#FECDD3', text: '#BE123C' }, // rose + { bg: '#FEF3C7', border: '#FDE68A', text: '#B45309' }, // amber + { bg: '#DCFCE7', border: '#BBF7D0', text: '#15803D' }, // green + { bg: '#DBEAFE', border: '#BFDBFE', text: '#1D4ED8' }, // blue + { bg: '#EDE9FE', border: '#DDD6FE', text: '#6D28D9' }, // violet + { bg: '#FFE4CC', border: '#FFD6B0', text: '#C2410C' }, // orange + { bg: '#CFFAFE', border: '#A5F3FC', text: '#0E7490' }, // cyan + { bg: '#FCE7F3', border: '#FBCFE8', text: '#BE185D' }, // pink +]; + +const getTagColor = (id: number) => TAG_COLORS[id % TAG_COLORS.length]; + interface RenderStoreProps { item: any; } @@ -196,6 +210,61 @@ const PopularProductItem = memo(({ item, onPress }: PopularProductItemProps) => ); }); +interface ExploreTabProps { + tag: any; + isSelected: boolean; + onPress: () => void; +} + +const ExploreTab = memo(({ tag, isSelected, onPress }: ExploreTabProps) => { + const color = getTagColor(tag.id); + const productCount = tag.productIds?.length || 0; + + return ( + + + {tag.tagName} ({productCount}) + + + ); +}); + +interface ExploreProductItemProps { + item: any; + onPress: (id: number) => void; +} + +const ExploreProductItem = memo(({ item, onPress }: ExploreProductItemProps) => { + const handlePress = useCallback(() => onPress(item.id), [item.id, onPress]); + + return ( + + + + ); +}); + interface SlotItemProps { item: any; } @@ -229,6 +298,10 @@ interface ListHeaderProps { popularProducts: any[]; sortedSlots: any[]; onProductPress: (id: number) => void; + dashboardTags: any[]; + activeTagId: number | null; + activeTagProducts: any[]; + onSelectTag: (id: number) => void; } const ListHeader = memo(({ @@ -238,6 +311,10 @@ const ListHeader = memo(({ popularProducts, sortedSlots, onProductPress, + dashboardTags, + activeTagId, + activeTagProducts, + onSelectTag, }: ListHeaderProps) => { const handleLayout = useCallback((event: any) => { const { y, height } = event.nativeEvent.layout; @@ -248,6 +325,10 @@ const ListHeader = memo(({ ), [onProductPress]); + const renderExploreItem = useCallback(({ item }: { item: any }) => ( + + ), [onProductPress]); + const renderSlotItem = useCallback(({ item }: { item: any }) => ( ), []); @@ -323,6 +404,55 @@ const ListHeader = memo(({ /> + {dashboardTags.length > 0 && ( + + + Explore Products + Browse by category + + + {dashboardTags.map((tag) => ( + onSelectTag(tag.id)} + /> + ))} + + {activeTagProducts.length > 0 ? ( + + item.id.toString()} + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={staticStyles.popularListContent} + renderItem={renderExploreItem} + removeClippedSubviews={true} + /> + + + ) : ( + + + No products in this category yet + + + )} + + )} + {sortedSlots.length > 0 && ( @@ -386,6 +516,10 @@ export default function Dashboard() { const { data: slotsData } = useSlots(); const products = productsData?.products || []; + const dashboardTags = productsData?.tags || []; + + const [selectedTagId, setSelectedTagId] = useState(null); + const activeTagId = selectedTagId ?? dashboardTags[0]?.id ?? null; React.useEffect(() => { @@ -446,6 +580,26 @@ export default function Dashboard() { .filter((product): product is NonNullable => product != null); }, [popularItemIds, products]); + const activeTagProducts = useMemo(() => { + if (activeTagId == null) return []; + const activeTag = dashboardTags.find((tag: any) => tag.id === activeTagId); + if (!activeTag) return []; + + return products + .filter((product: any) => activeTag.productIds?.includes(product.id) ?? false) + .sort((a: any, b: any) => { + const slotA = getQuickestSlot(a.id) + const slotB = getQuickestSlot(b.id) + + const aOutOfStock = Boolean(productSlotsMap[a.id]?.isOutOfStock) || !slotA + const bOutOfStock = Boolean(productSlotsMap[b.id]?.isOutOfStock) || !slotB + + if (aOutOfStock && !bOutOfStock) return 1 + if (!aOutOfStock && bOutOfStock) return -1 + return 0 + }); + }, [activeTagId, dashboardTags, products, getQuickestSlot, productSlotsMap]); + const handleRefresh = useCallback(async () => { setIsRefreshing(true); try { @@ -499,8 +653,12 @@ export default function Dashboard() { popularProducts={popularProducts} sortedSlots={sortedSlots} onProductPress={handleProductPress} + dashboardTags={dashboardTags} + activeTagId={activeTagId} + activeTagProducts={activeTagProducts} + onSelectTag={setSelectedTagId} /> - ), [gradientHeight, handleGradientLayout, storesData, popularProducts, sortedSlots, handleProductPress]); + ), [gradientHeight, handleGradientLayout, storesData, popularProducts, sortedSlots, handleProductPress, dashboardTags, activeTagId, activeTagProducts]); const searchBarContainerStyle = useMemo(() => [ tw`w-full px-4 pt-4 pb-2`, diff --git a/apps/user-ui/app/(drawer)/(tabs)/order-again/index.tsx b/apps/user-ui/app/(drawer)/(tabs)/order-again/index.tsx index b18b9dd..5428556 100644 --- a/apps/user-ui/app/(drawer)/(tabs)/order-again/index.tsx +++ b/apps/user-ui/app/(drawer)/(tabs)/order-again/index.tsx @@ -19,7 +19,7 @@ import TabLayoutWrapper from "@/components/TabLayoutWrapper"; const { width: screenWidth } = Dimensions.get("window"); const itemWidth = screenWidth * 0.45; -const rowListContent = { paddingBottom: 16 }; +const rowListContent = { paddingBottom: 16, paddingHorizontal: 16 }; interface OffersRowProps { title: string; @@ -75,7 +75,7 @@ const OffersRow = ({ title, subtitle, products, onProductPress }: OffersRowProps colors={["transparent", "rgba(0,0,0,0.08)"]} start={{ x: 0, y: 0.5 }} end={{ x: 1, y: 0.5 }} - style={tw`absolute right-0 top-0 bottom-4 w-12 rounded-l-xl`} + style={tw`absolute right-4 top-0 bottom-4 w-12 rounded-l-xl`} pointerEvents="none" /> diff --git a/apps/user-ui/app/(drawer)/_layout.tsx b/apps/user-ui/app/(drawer)/_layout.tsx index d90fdd6..240176b 100755 --- a/apps/user-ui/app/(drawer)/_layout.tsx +++ b/apps/user-ui/app/(drawer)/_layout.tsx @@ -11,7 +11,7 @@ import { useAuth } from "@/src/contexts/AuthContext"; import { tw, theme, MyTouchableOpacity, MyText } from "common-ui"; import HomeIcon from "@/components/icons/HomeIcon"; import StoresIcon from "@/components/icons/StoresIcon"; -import OrderAgainIcon from "@/components/icons/OrderAgainIcon"; +import OffersIcon from "@/components/icons/OffersIcon"; import MeIcon from "@/components/icons/MeIcon"; import { useAppStore } from "@/src/store/appStore"; @@ -97,9 +97,9 @@ export default function Layout() { ( - + ), }} /> diff --git a/apps/user-ui/components/CheckoutAddressSelector.tsx b/apps/user-ui/components/CheckoutAddressSelector.tsx index 6a4129e..4d5c0a2 100644 --- a/apps/user-ui/components/CheckoutAddressSelector.tsx +++ b/apps/user-ui/components/CheckoutAddressSelector.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef } from 'react'; -import { View, Text, TouchableOpacity, ScrollView, Alert } from 'react-native'; +import { View, Text, TouchableOpacity, ScrollView, Alert, NativeSyntheticEvent, NativeScrollEvent } from 'react-native'; import { tw, BottomDialog, RawBottomDialog } from 'common-ui'; import { useQueryClient } from '@tanstack/react-query'; import AddressForm from '@/src/components/AddressForm'; @@ -14,6 +14,8 @@ interface AddressSelectorProps { onAddressSelect: (addressId: number) => void; } +const CARD_WIDTH = 300; // 288 (w-72) + 12 (mr-3) + const CheckoutAddressSelector: React.FC = ({ selectedAddress, onAddressSelect, @@ -21,6 +23,7 @@ const CheckoutAddressSelector: React.FC = ({ const [showAddAddress, setShowAddAddress] = useState(false); const [editingLocationAddressId, setEditingLocationAddressId] = useState(null); const [locationLoading, setLocationLoading] = useState(false); + const [currentIndex, setCurrentIndex] = useState(0); const queryClient = useQueryClient(); const scrollViewRef = useRef(null); const { isAuthenticated } = useAuth(); @@ -70,9 +73,15 @@ const CheckoutAddressSelector: React.FC = ({ // Reset scroll to left when address is selected const resetScrollToLeft = () => { + setCurrentIndex(0); scrollViewRef.current?.scrollTo({ x: 0, y: 0, animated: true }); }; + const handleScroll = (event: NativeSyntheticEvent) => { + const index = Math.round(event.nativeEvent.contentOffset.x / CARD_WIDTH); + setCurrentIndex(index); + }; + const handleAttachLocation = async (address: any) => { setEditingLocationAddressId(address.id); setLocationLoading(true); @@ -145,6 +154,11 @@ const CheckoutAddressSelector: React.FC = ({ horizontal showsHorizontalScrollIndicator={false} style={tw`pb-2`} + onScroll={handleScroll} + onMomentumScrollEnd={handleScroll} + scrollEventThrottle={16} + decelerationRate="fast" + snapToInterval={CARD_WIDTH} > {sortedAddresses.map((address) => ( = ({ )} + {/* Pagination Dots */} + {sortedAddresses.length > 1 && ( + + {sortedAddresses.map((_, index: number) => ( + + ))} + + )} + {/* Attach Location for selected address - outside the white box */} {selectedAddress && (() => { const selectedAddr = sortedAddresses.find(a => a.id === selectedAddress); diff --git a/apps/user-ui/components/ProductDetail.tsx b/apps/user-ui/components/ProductDetail.tsx index a4db708..1fde5da 100644 --- a/apps/user-ui/components/ProductDetail.tsx +++ b/apps/user-ui/components/ProductDetail.tsx @@ -279,7 +279,9 @@ const ProductDetail: React.FC = ({ productId, isFlashDeliver ₹{productDetail.price} - / {productDetail.unitNotation} + {productDetail.productType !== 'combo' && ( + / {productDetail.unitNotation} + )} {/* Show market price discount if available */} {productDetail.marketPrice && ( @@ -296,7 +298,7 @@ const ProductDetail: React.FC = ({ productId, isFlashDeliver {productAvailability?.isFlashAvailable && productDetail.flashPrice && productDetail.flashPrice !== productDetail.price && ( - 1 Hr Delivery: ₹{productDetail.flashPrice} / {productDetail.unitNotation} + 1 Hr Delivery: ₹{productDetail.flashPrice}{productDetail.productType !== 'combo' ? ` / ${productDetail.unitNotation}` : ''} )} @@ -463,7 +465,7 @@ const ProductDetail: React.FC = ({ productId, isFlashDeliver {productDetail.specialDeals.map((deal: { quantity: string; price: string; validTill: string }, index: number) => ( - Buy {deal.quantity} • {productDetail.unitNotation} + Buy {deal.quantity}{productDetail.productType !== 'combo' ? ` • ${productDetail.unitNotation}` : ''} ₹{deal.price} ))} diff --git a/apps/user-ui/components/SlotSpecificView.tsx b/apps/user-ui/components/SlotSpecificView.tsx index 5e796a7..2e1fd4e 100644 --- a/apps/user-ui/components/SlotSpecificView.tsx +++ b/apps/user-ui/components/SlotSpecificView.tsx @@ -317,7 +317,9 @@ const CompactProductCard = ({ {item.marketPrice && Number(item.marketPrice) > Number(item.price) && ( ₹{item.marketPrice} )} - Quantity: {item.unit || item.unitNotation} + {item.productType !== 'combo' && ( + Quantity: {item.unit || item.unitNotation} + )} diff --git a/apps/user-ui/components/cart-page.tsx b/apps/user-ui/components/cart-page.tsx index af73daa..29c1abf 100644 --- a/apps/user-ui/components/cart-page.tsx +++ b/apps/user-ui/components/cart-page.tsx @@ -460,10 +460,7 @@ export default function CartPage({ isFlashDelivery = false }: CartPageProps) { {product?.name} - {(() => { - const unit = product?.unitNotation || ''; - return unit; - })()} + {product?.productType !== 'combo' ? (product?.unitNotation || '') : ''} diff --git a/apps/user-ui/components/floating-cart-bar.tsx b/apps/user-ui/components/floating-cart-bar.tsx index 905ba43..f9c6c03 100644 --- a/apps/user-ui/components/floating-cart-bar.tsx +++ b/apps/user-ui/components/floating-cart-bar.tsx @@ -60,12 +60,14 @@ const formatTimeRange = (deliveryTime: string | Date) => { }; // Product name component with quantity -const ProductNameWithQuantity = ({ name, unitNotation }: { name: string; unitNotation: string }) => { +const ProductNameWithQuantity = ({ name, unitNotation, productType }: { name: string; unitNotation: string; productType?: string }) => { const truncatedName = name.length > 25 ? name.substring(0, 25) + '...' : name; const unit = unitNotation ? ` ${unitNotation}` : ''; return ( - {truncatedName} ({unit}) + {truncatedName} {productType !== 'combo' && ( + ({unit}) + )} ); }; @@ -272,7 +274,9 @@ const FloatingCartBar: React.FC = ({ style={tw`w-8 h-8 rounded-lg bg-slate-50 border border-slate-100`} /> - {productsById[item.skuId]?.unitNotation || ''} + {productsById[item.skuId]?.productType !== 'combo' && ( + {productsById[item.skuId]?.unitNotation || ''} + )} @@ -281,6 +285,7 @@ const FloatingCartBar: React.FC = ({ = ({ focused, size, color }) => { + if (focused) { + // Selected state SVG (filled offer tag) + return ( + + + + ); + } else { + // Unselected state SVG (outlined offer tag) + return ( + + + + + ); + } +}; + +export default OffersIcon; diff --git a/apps/user-ui/src/components/AddToCartDialog.tsx b/apps/user-ui/src/components/AddToCartDialog.tsx index b7f2d49..a3acab8 100644 --- a/apps/user-ui/src/components/AddToCartDialog.tsx +++ b/apps/user-ui/src/components/AddToCartDialog.tsx @@ -152,7 +152,7 @@ export default function AddToCartDialog() { Select Delivery Slot {product?.name && ( - {product.name} ({product.unitNotation ? ` ${product.unitNotation}` : ''}) + {product.name}{product.productType !== 'combo' && product.unitNotation ? ` (${product.unitNotation})` : ''} )} diff --git a/packages/db_helper_sqlite/src/admin-apis/product.ts b/packages/db_helper_sqlite/src/admin-apis/product.ts index 83d0308..6d75c13 100644 --- a/packages/db_helper_sqlite/src/admin-apis/product.ts +++ b/packages/db_helper_sqlite/src/admin-apis/product.ts @@ -279,6 +279,7 @@ export async function createProduct(input: CreateProductInput): Promise s.id)) + const skusBeingSuspended = skus.filter( + (sku: any) => sku.isSuspended && sku.id != null && existingSkuIdSet.has(sku.id) + ) + if (skusBeingSuspended.length > 0) { + const suspendingIds = skusBeingSuspended.map((sku: any) => sku.id) + const comboMemberships = await db.query.productCombos.findMany({ + where: inArray(productCombos.skuId, suspendingIds), + columns: { comboSkuId: true }, + }) + const comboIds = Array.from(new Set(comboMemberships.map((c) => c.comboSkuId))) + + if (comboIds.length > 0) { + const combos = await db.query.productSkus.findMany({ + where: inArray(productSkus.id, comboIds), + columns: { id: true, isSuspended: true }, + }) + const activeComboIds = combos.filter((c) => !c.isSuspended).map((c) => c.id) + if (activeComboIds.length > 0) { + throw new Error( + `Cannot suspend SKU(s): they are members of combo(s) ${activeComboIds.join(', ')} that are not suspended` + ) + } + } + } + for (const sku of skus) { if (sku.id != null && existingSkuIdSet.has(sku.id)) { // Update existing SKU @@ -368,6 +394,7 @@ export async function updateProduct(id: number, input: any): Promise { const skus = await db.query.productSkus.findMany({ + where: eq(productSkus.isSuspended, false), with: { product: true, features: true, @@ -200,7 +201,16 @@ export async function getAllProductCombosForCache(): Promise { + const suspendedSkuIds = new Set( + (await db + .select({ id: productSkus.id }) + .from(productSkus) + .where(eq(productSkus.isSuspended, true))).map((r) => r.id) + ) + + return results + .filter((ci) => !suspendedSkuIds.has(ci.comboSkuId)) + .map((ci) => { const features = ci.sku?.features || [] return { comboSkuId: ci.comboSkuId, @@ -307,6 +317,7 @@ export async function getAllSlotsWithProductsForCache(): Promise 0) { skusData = await db.query.productSkus.findMany({ + where: eq(productSkus.isSuspended, false), with: { product: { with: { store: true }, diff --git a/packages/db_helper_sqlite/src/user-apis/cart.ts b/packages/db_helper_sqlite/src/user-apis/cart.ts index 521626d..fc396ab 100644 --- a/packages/db_helper_sqlite/src/user-apis/cart.ts +++ b/packages/db_helper_sqlite/src/user-apis/cart.ts @@ -22,33 +22,35 @@ export async function getCartItemsWithProducts(userId: number): Promise { - const sku = item.sku - const features = sku?.features || [] - const priceValue = sku?.price ?? '0' - const quantityValue = item.quantity ?? '0' - return { - id: item.id, - skuId: item.skuId, - quantity: parseFloat(quantityValue), - addedAt: item.addedAt, - product: { - id: sku?.id ?? 0, - name: composeSkuName(sku?.product?.name ?? 'Unknown', features), - price: String(priceValue), - productQuantity: 1, - unit: composeUnitNotation(features), - isOutOfStock: sku?.isOutOfStock ?? false, - images: getStringArray(sku?.images), - }, - subtotal: parseFloat(String(priceValue)) * parseFloat(quantityValue), - } - }) + return cartItemsWithProducts + .filter((item) => item.sku && !item.sku.isSuspended) + .map((item) => { + const sku = item.sku + const features = sku?.features || [] + const priceValue = sku?.price ?? '0' + const quantityValue = item.quantity ?? '0' + return { + id: item.id, + skuId: item.skuId, + quantity: parseFloat(quantityValue), + addedAt: item.addedAt, + product: { + id: sku?.id ?? 0, + name: composeSkuName(sku?.product?.name ?? 'Unknown', features), + price: String(priceValue), + productQuantity: 1, + unit: composeUnitNotation(features), + isOutOfStock: sku?.isOutOfStock ?? false, + images: getStringArray(sku?.images), + }, + subtotal: parseFloat(String(priceValue)) * parseFloat(quantityValue), + } + }) } export async function getProductById(skuId: number) { return db.query.productSkus.findFirst({ - where: eq(productSkus.id, skuId), + where: and(eq(productSkus.id, skuId), eq(productSkus.isSuspended, false)), }) } diff --git a/packages/db_helper_sqlite/src/user-apis/product.ts b/packages/db_helper_sqlite/src/user-apis/product.ts index 7dd8935..cd5ae46 100644 --- a/packages/db_helper_sqlite/src/user-apis/product.ts +++ b/packages/db_helper_sqlite/src/user-apis/product.ts @@ -11,7 +11,7 @@ const getStringArray = (value: unknown): string[] | null => { export async function getProductDetailById(skuId: number): Promise { const sku = await db.query.productSkus.findFirst({ - where: eq(productSkus.id, skuId), + where: and(eq(productSkus.id, skuId), eq(productSkus.isSuspended, false)), with: { product: true, features: true, @@ -202,6 +202,7 @@ export async function getAllProductsWithUnits(tagId?: number): Promise { const skus = await db.query.productSkus.findMany({ + where: eq(productSkus.isSuspended, false), with: { features: true, product: { From db6cc71bdb24cee10a1f656600abebb98304be13 Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:00:41 +0530 Subject: [PATCH 16/73] Update taste.md --- .commandcode/taste/taste/taste.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.commandcode/taste/taste/taste.md b/.commandcode/taste/taste/taste.md index 84ef180..a61ab62 100644 --- a/.commandcode/taste/taste/taste.md +++ b/.commandcode/taste/taste/taste.md @@ -4,6 +4,7 @@ - When removing unused code (e.g., an unused tRPC procedure), prefers a complete cleanup: also remove internal helper methods used only by it, associated types, re-exports, orphaned hook/API files and their shared types, and even commented-out imports, leaving zero remaining references. Confidence: 0.9 - Before removing or deciding on code, likes to first verify where it is actually used across the codebase (asks questions like "where is X used"), rather than removing based on assumption. Confidence: 0.4 - Prefers light/pastel colors for randomized or accent UI elements (e.g., tabs, chips). Confidence: 0.7 +- Prefers minimal, flat tab/navigation UI: active state shown with a thick underline, no pill/chip backgrounds or rounded fills; communicates desired visual styles by sharing reference images/screenshots. Confidence: 0.8 - Wants the agent to come up with a plan first before implementing code changes. Confidence: 0.9 - Appreciates being asked clarifying design questions with concrete options during planning. Confidence: 0.7 - Prefers embedding related data into existing cache files over creating or calling separate API endpoints. Confidence: 0.9 From 9a47ff1a9b33f17a19b7932b9b73d05fca1f89d8 Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:32:47 +0530 Subject: [PATCH 17/73] enh --- apps/backend/package.json | 1 + apps/fallback-ui/package.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/backend/package.json b/apps/backend/package.json index 8ae1681..d89bbcf 100755 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -16,6 +16,7 @@ "wrangler:dev": "wrangler dev worker.ts --config wrangler.toml", "wrangler:deploy": "wrangler deploy worker.ts --config wrangler.toml", "pull_db": "rm -rf .wrangler/state/v3/d1/* && wrangler d1 export freshyo-dev --config wrangler.prod.toml --remote --output ./dumps/latest.sql && bash ./scripts/populate_localdb.sh", + "pull_dev_db": "rm -rf .wrangler/state/v3/d1/* && wrangler d1 export freshyo-backend-dev --config wrangler.dev.toml --remote --output ./dumps/latest.sql && bash ./scripts/populate_localdb.sh --dev", "docker:build": "cd .. && docker buildx build --platform linux/amd64 -t mohdshafiuddin54/health_petal:latest --progress=plain -f backend/Dockerfile .", "docker:push": "docker push mohdshafiuddin54/health_petal:latest" }, diff --git a/apps/fallback-ui/package.json b/apps/fallback-ui/package.json index e77d495..427394f 100644 --- a/apps/fallback-ui/package.json +++ b/apps/fallback-ui/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@radix-ui/react-slot": "^1.1.2", - "@tanstack/react-query": "^5.59.16", + "@tanstack/react-query": "^5.100.0", "@tanstack/react-router": "^1.92.8", "@tanstack/router-devtools": "^1.92.8", "@trpc/client": "^11.6.0", From fa56fd4a8240bd0bbc97850d6503a36843c4fee2 Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:06:27 +0530 Subject: [PATCH 18/73] enh --- .../app/(drawer)/(tabs)/home/index.tsx | 320 ++++++++++-------- apps/user-ui/components/ProductCard.tsx | 38 ++- apps/user-ui/components/TabLayoutWrapper.tsx | 8 +- 3 files changed, 199 insertions(+), 167 deletions(-) diff --git a/apps/user-ui/app/(drawer)/(tabs)/home/index.tsx b/apps/user-ui/app/(drawer)/(tabs)/home/index.tsx index 3ae8a63..59bc521 100755 --- a/apps/user-ui/app/(drawer)/(tabs)/home/index.tsx +++ b/apps/user-ui/app/(drawer)/(tabs)/home/index.tsx @@ -9,8 +9,7 @@ import { useMarkDataFetchers, LoadingDialog, MyTouchableOpacity, - MyText, SearchBar, useStatusBarStore, - colors + MyText, SearchBar } from "common-ui"; import dayjs from "dayjs"; @@ -35,8 +34,8 @@ dayjs.extend(relativeTime); const { width: screenWidth } = Dimensions.get("window"); const itemWidth = screenWidth * 0.45; +const heroItemWidth = (screenWidth - 72) / 3; const gridItemWidth = (screenWidth - 48) / 2; -const headerColor = colors.secondaryPink; const formatTimeRange = (deliveryTime: string) => { const time = dayjs(deliveryTime); @@ -58,16 +57,15 @@ const staticStyles = { slotsListContent: { paddingBottom: 24 }, }; -// Light/pastel color pairs for the Explore Products tabs. const TAG_COLORS = [ - { bg: '#FFE4E6', border: '#FECDD3', text: '#BE123C' }, // rose - { bg: '#FEF3C7', border: '#FDE68A', text: '#B45309' }, // amber - { bg: '#DCFCE7', border: '#BBF7D0', text: '#15803D' }, // green - { bg: '#DBEAFE', border: '#BFDBFE', text: '#1D4ED8' }, // blue - { bg: '#EDE9FE', border: '#DDD6FE', text: '#6D28D9' }, // violet - { bg: '#FFE4CC', border: '#FFD6B0', text: '#C2410C' }, // orange - { bg: '#CFFAFE', border: '#A5F3FC', text: '#0E7490' }, // cyan - { bg: '#FCE7F3', border: '#FBCFE8', text: '#BE185D' }, // pink + { pageBg: '#FFF8F9', bg: '#FFF1F2', border: '#FECDD3', text: '#9F1239', dot: '#E11D48' }, // rose + { pageBg: '#FFFCF1', bg: '#FFFBEB', border: '#FDE68A', text: '#92400E', dot: '#D97706' }, // amber + { pageBg: '#F6FEF9', bg: '#F0FDF4', border: '#BBF7D0', text: '#166534', dot: '#16A34A' }, // green + { pageBg: '#F5FAFF', bg: '#EFF6FF', border: '#BFDBFE', text: '#1E3A8A', dot: '#2563EB' }, // blue + { pageBg: '#FAF8FF', bg: '#F5F3FF', border: '#DDD6FE', text: '#5B21B6', dot: '#7C3AED' }, // violet + { pageBg: '#FFF9F2', bg: '#FFF7ED', border: '#FFD6B0', text: '#9A3412', dot: '#EA580C' }, // orange + { pageBg: '#F3FEFF', bg: '#ECFEFF', border: '#A5F3FC', text: '#155E75', dot: '#0891B2' }, // cyan + { pageBg: '#FFF7FB', bg: '#FDF2F8', border: '#FBCFE8', text: '#9D174D', dot: '#DB2777' }, // pink ]; const getTagColor = (id: number) => TAG_COLORS[id % TAG_COLORS.length]; @@ -217,28 +215,34 @@ interface ExploreTabProps { } const ExploreTab = memo(({ tag, isSelected, onPress }: ExploreTabProps) => { - const color = getTagColor(tag.id); - const productCount = tag.productIds?.length || 0; - return ( - - {tag.tagName} ({productCount}) - + + + {tag.tagName} + + + ); }); @@ -252,14 +256,15 @@ const ExploreProductItem = memo(({ item, onPress }: ExploreProductItemProps) => const handlePress = useCallback(() => onPress(item.id), [item.id, onPress]); return ( - + ); @@ -316,19 +321,20 @@ const ListHeader = memo(({ activeTagProducts, onSelectTag, }: ListHeaderProps) => { + const [showAllActiveProducts, setShowAllActiveProducts] = useState(false); const handleLayout = useCallback((event: any) => { const { y, height } = event.nativeEvent.layout; onGradientLayout(y + height); }, [onGradientLayout]); + React.useEffect(() => { + setShowAllActiveProducts(false); + }, [activeTagId]); + const renderPopularItem = useCallback(({ item }: { item: any }) => ( ), [onProductPress]); - const renderExploreItem = useCallback(({ item }: { item: any }) => ( - - ), [onProductPress]); - const renderSlotItem = useCallback(({ item }: { item: any }) => ( ), []); @@ -337,25 +343,92 @@ const ListHeader = memo(({ tw`absolute left-0 right-0 shadow-lg`, { height: gradientHeight + 32, zIndex: -1 } ], [gradientHeight]); + const activeColor = activeTagId == null ? null : getTagColor(activeTagId); + const pageTint = activeColor?.pageBg ?? '#FFFFFF'; + const visibleActiveTagProducts = showAllActiveProducts ? activeTagProducts : activeTagProducts.slice(0, 6); + const hasMoreActiveTagProducts = activeTagProducts.length > visibleActiveTagProducts.length; return ( <> - + + {dashboardTags.length > 0 && ( + + + {dashboardTags.map((tag) => ( + onSelectTag(tag.id)} + /> + ))} + + {activeTagProducts.length > 0 ? ( + + + {visibleActiveTagProducts.map((product: any) => ( + + ))} + + {hasMoreActiveTagProducts && ( + setShowAllActiveProducts(true)} + > + Show More + + )} + + ) : ( + + + No products in this category yet + + + )} + + )} + + + + + + + {storesData?.stores && storesData.stores.length > 0 && ( - + - + Our Stores - Fresh from our locations + Fresh from our locations @@ -369,13 +442,7 @@ const ListHeader = memo(({ )} - - - - - - @@ -404,55 +471,6 @@ const ListHeader = memo(({ /> - {dashboardTags.length > 0 && ( - - - Explore Products - Browse by category - - - {dashboardTags.map((tag) => ( - onSelectTag(tag.id)} - /> - ))} - - {activeTagProducts.length > 0 ? ( - - item.id.toString()} - horizontal - showsHorizontalScrollIndicator={false} - contentContainerStyle={staticStyles.popularListContent} - renderItem={renderExploreItem} - removeClippedSubviews={true} - /> - - - ) : ( - - - No products in this category yet - - - )} - - )} - {sortedSlots.length > 0 && ( @@ -497,7 +515,6 @@ export default function Dashboard() { const [displayedProducts, setDisplayedProducts] = useState([]); const [hasMore, setHasMore] = useState(true); const [isLoadingMore, setIsLoadingMore] = useState(false); - const { backgroundColor } = useStatusBarStore(); const { getQuickestSlot } = useProductSlotIdentifier(); const productSlotsMap = useCentralSlotStore((state) => state.productSlotsMap); const refetchProducts = useCentralProductStore((state) => state.refetchProducts); @@ -660,10 +677,16 @@ export default function Dashboard() { /> ), [gradientHeight, handleGradientLayout, storesData, popularProducts, sortedSlots, handleProductPress, dashboardTags, activeTagId, activeTagProducts]); + const pageTint = activeTagId == null ? '#FFFFFF' : getTagColor(activeTagId).pageBg; + const pageTintStyle = useMemo(() => [ + tw`flex-1`, + { backgroundColor: pageTint } + ], [pageTint]); + const searchBarContainerStyle = useMemo(() => [ - tw`w-full px-4 pt-4 pb-2`, - { backgroundColor } - ], [backgroundColor]); + tw`w-full px-4 pt-4 pb-0`, + { backgroundColor: pageTint } + ], [pageTint]); const listContentContainerStyle = useMemo(() => [ tw`pb-24`, @@ -695,55 +718,58 @@ export default function Dashboard() { displayedProducts.forEach(product => str += `${product.id}-`) // console.log(str) return ( - - - { }} - onPress={handleSearchPress} - editable={false} - containerStyle={tw`bg-white`} - onSubmitEditing={() => { - if (inputQuery.trim()) { - router.push(`/(drawer)/(tabs)/home/search-results?q=${encodeURIComponent(inputQuery.trim())}`); - } - }} - returnKeyType="search" - /> - - - item.id.toString()} - numColumns={2} - contentContainerStyle={listContentContainerStyle} - columnWrapperStyle={staticStyles.columnWrapper} - renderItem={renderProductItem} - ListHeaderComponent={listHeader} - refreshControl={ - + + + { }} + onPress={handleSearchPress} + editable={false} + containerStyle={tw`bg-white`} + onSubmitEditing={() => { + if (inputQuery.trim()) { + router.push(`/(drawer)/(tabs)/home/search-results?q=${encodeURIComponent(inputQuery.trim())}`); + } + }} + returnKeyType="search" /> - } - ListEmptyComponent={ - - No products available - - } - removeClippedSubviews={true} - maxToRenderPerBatch={10} - windowSize={5} - initialNumToRender={10} - updateCellsBatchingPeriod={50} - /> + - - - - + item.id.toString()} + numColumns={2} + style={{ backgroundColor: pageTint }} + contentContainerStyle={listContentContainerStyle} + columnWrapperStyle={staticStyles.columnWrapper} + renderItem={renderProductItem} + ListHeaderComponent={listHeader} + refreshControl={ + + } + ListEmptyComponent={ + + No products available + + } + removeClippedSubviews={true} + maxToRenderPerBatch={10} + windowSize={5} + initialNumToRender={10} + updateCellsBatchingPeriod={50} + /> + + + + + + ); diff --git a/apps/user-ui/components/ProductCard.tsx b/apps/user-ui/components/ProductCard.tsx index 4c16022..549705c 100644 --- a/apps/user-ui/components/ProductCard.tsx +++ b/apps/user-ui/components/ProductCard.tsx @@ -24,6 +24,7 @@ interface ProductCardProps { onPress?: () => void; showDeliveryInfo?: boolean; miniView?: boolean; + variant?: 'default' | 'hero'; nullIfNotAvailable?: boolean; containerComp?: React.ComponentType | React.JSXElementConstructor; useAddToCartDialog?: boolean; @@ -42,10 +43,12 @@ const ProductCard: React.FC = ({ onPress, showDeliveryInfo = true, miniView = false, + variant = 'default', nullIfNotAvailable = false, containerComp: ContainerComp = React.Fragment, useAddToCartDialog = false, }) => { + const isHero = variant === 'hero'; const imageUri = item.images?.[0] const [imageStatus, setImageStatus] = React.useState<'loading' | 'loaded' | 'error'>('loading') const [imageError, setImageError] = React.useState(null) @@ -152,7 +155,8 @@ const ProductCard: React.FC = ({ {/* TODO: Navigate to product detail */})} @@ -162,7 +166,7 @@ const ProductCard: React.FC = ({ { setImageStatus('loading') setImageError(null) @@ -194,36 +198,36 @@ const ProductCard: React.FC = ({ )} {miniView && ( - + {quantity > 0 ? ( ) : ( handleQuantityChange(1)} - activeOpacity={0.8} - > - - - )} + style={isHero ? tw`w-7 h-7 rounded-full bg-white items-center justify-center shadow-md` : tw`w-8 h-8 rounded-full bg-white items-center justify-center shadow-md`} + onPress={() => handleQuantityChange(1)} + activeOpacity={0.8} + > + + + )} )} - - + + {item.name} - - ₹{item.price} + + ₹{item.price} {item.marketPrice && Number(item.marketPrice) > Number(item.price) && ( - ₹{item.marketPrice} + ₹{item.marketPrice} )} - + {item.productType !== 'combo' && ( - Quantity: {item.unitNotation} + Quantity: {item.unitNotation} )} diff --git a/apps/user-ui/components/TabLayoutWrapper.tsx b/apps/user-ui/components/TabLayoutWrapper.tsx index 7292a0b..44d411b 100644 --- a/apps/user-ui/components/TabLayoutWrapper.tsx +++ b/apps/user-ui/components/TabLayoutWrapper.tsx @@ -1,15 +1,17 @@ import React from 'react'; +import type { StyleProp, ViewStyle } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { tw } from 'common-ui'; interface TabLayoutWrapperProps { children: React.ReactNode; + style?: StyleProp; } -export default function TabLayoutWrapper({ children }: TabLayoutWrapperProps) { +export default function TabLayoutWrapper({ children, style }: TabLayoutWrapperProps) { return ( - + {children} ); -} \ No newline at end of file +} From b41980736a51dc2463ea0e6637ee434fa98f04ba Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:51:52 +0530 Subject: [PATCH 19/73] old apis removal --- .commandcode/taste/taste/taste.md | 1 + apps/backend/src/dbService.ts | 7 - apps/backend/src/postgresImporter.ts | 12 - apps/backend/src/sqliteImporter.ts | 12 - .../src/trpc/apis/user-apis/apis/auth.ts | 26 -- .../src/trpc/apis/user-apis/apis/banners.ts | 9 - .../src/trpc/apis/user-apis/apis/cart.ts | 258 +----------------- .../src/trpc/apis/user-apis/apis/coupon.ts | 51 ---- .../src/trpc/apis/user-apis/apis/order.ts | 62 ----- .../src/trpc/apis/user-apis/apis/product.ts | 20 +- .../src/trpc/apis/user-apis/apis/slots.ts | 37 +-- .../src/trpc/apis/user-apis/apis/stores.ts | 20 -- .../src/trpc/apis/user-apis/apis/tags.ts | 28 -- .../apis/user-apis/apis/user-trpc-index.ts | 6 - .../app/(drawer)/(tabs)/home/index.tsx | 101 +++++-- packages/db_helper_postgres/index.ts | 15 - .../db_helper_postgres/src/user-apis/cart.ts | 95 ------- .../db_helper_postgres/src/user-apis/order.ts | 74 +---- packages/db_helper_sqlite/index.ts | 15 - .../db_helper_sqlite/src/user-apis/cart.ts | 98 ------- .../db_helper_sqlite/src/user-apis/order.ts | 90 +----- packages/shared/types/user.ts | 60 ---- 22 files changed, 92 insertions(+), 1005 deletions(-) delete mode 100644 apps/backend/src/trpc/apis/user-apis/apis/tags.ts delete mode 100644 packages/db_helper_postgres/src/user-apis/cart.ts delete mode 100644 packages/db_helper_sqlite/src/user-apis/cart.ts diff --git a/.commandcode/taste/taste/taste.md b/.commandcode/taste/taste/taste.md index a61ab62..39d3aab 100644 --- a/.commandcode/taste/taste/taste.md +++ b/.commandcode/taste/taste/taste.md @@ -10,3 +10,4 @@ - Prefers embedding related data into existing cache files over creating or calling separate API endpoints. Confidence: 0.9 - Prefers using icons from Hicons (rather than custom/hand-authored SVGs or other icon libraries). Confidence: 0.9 - When a feature or page is repurposed, wants file names, route names, and code references renamed to match the new purpose (not just UI content/icons). Confidence: 0.8 +- Wants analysis findings and plans persisted in a markdown file before any implementation begins, with explicit confirmation that implementation is paused until instructed. Confidence: 0.8 diff --git a/apps/backend/src/dbService.ts b/apps/backend/src/dbService.ts index ccf32ee..3a870cc 100644 --- a/apps/backend/src/dbService.ts +++ b/apps/backend/src/dbService.ts @@ -98,9 +98,6 @@ export type { UserAddressDeleteResponse, UserBanner, UserBannersResponse, - UserCartProduct, - UserCartItem, - UserCartResponse, UserComplaint, UserComplaintsResponse, UserRaiseComplaintResponse, @@ -122,7 +119,6 @@ export type { UserCreateReviewResponse, UserSlotProduct, UserSlotWithProducts, - UserSlotData, UserSlotAvailability, UserDeliverySlot, UserSlotsResponse, @@ -136,7 +132,6 @@ export type { UserAuthResult, UserOtpVerifyResponse, UserPasswordUpdateResponse, - UserProfileResponse, UserDeleteAccountResponse, UserCouponUsage, UserCouponApplicableUser, @@ -156,8 +151,6 @@ export type { UserOrderDetail, UserCancelOrderResponse, UserUpdateNotesResponse, - UserRecentProduct, - UserRecentProductsResponse, // Store types StoreSummary, StoresSummaryResponse, diff --git a/apps/backend/src/postgresImporter.ts b/apps/backend/src/postgresImporter.ts index 7296535..3645f61 100644 --- a/apps/backend/src/postgresImporter.ts +++ b/apps/backend/src/postgresImporter.ts @@ -157,15 +157,6 @@ // hasOngoingOrdersForAddress, // // User - Banners // getUserActiveBanners, -// // User - Cart -// getUserCartItemsWithProducts, -// getUserProductById, -// getUserCartItemByUserProduct, -// incrementUserCartItemQuantity, -// insertUserCartItem, -// updateUserCartItemQuantity, -// deleteUserCartItem, -// clearUserCart, // // User - Complaint // getUserComplaints, // createUserComplaint, @@ -235,9 +226,6 @@ // getUserOrderBasic, // cancelUserOrderTransaction, // updateUserOrderNotes, -// getUserRecentlyDeliveredOrderIds, -// getUserProductIdsFromOrders, -// getUserProductsForRecentOrders, // // Store Helpers // getAllBannersForCache, // getAllProductsForCache, diff --git a/apps/backend/src/sqliteImporter.ts b/apps/backend/src/sqliteImporter.ts index b813fb7..111fac3 100644 --- a/apps/backend/src/sqliteImporter.ts +++ b/apps/backend/src/sqliteImporter.ts @@ -168,15 +168,6 @@ export { hasOngoingOrdersForAddress, // User - Banners getUserActiveBanners, - // User - Cart - getUserCartItemsWithProducts, - getUserProductById, - getUserCartItemByUserProduct, - incrementUserCartItemQuantity, - insertUserCartItem, - updateUserCartItemQuantity, - deleteUserCartItem, - clearUserCart, // User - Complaint getUserComplaints, createUserComplaint, @@ -246,9 +237,6 @@ export { getUserOrderBasic, cancelUserOrderTransaction, updateUserOrderNotes, - getUserRecentlyDeliveredOrderIds, - getUserProductIdsFromOrders, - getUserProductsForRecentOrders, // Store Helpers getAllBannersForCache, getAllProductsForCache, diff --git a/apps/backend/src/trpc/apis/user-apis/apis/auth.ts b/apps/backend/src/trpc/apis/user-apis/apis/auth.ts index 97b81db..6de039d 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/auth.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/auth.ts @@ -24,7 +24,6 @@ import type { UserAuthResponse, UserOtpVerifyResponse, UserPasswordUpdateResponse, - UserProfileResponse, UserDeleteAccountResponse, } from '@packages/shared' @@ -396,31 +395,6 @@ export const authRouter = router({ } }), - getProfile: protectedProcedure - .query(async ({ ctx }): Promise => { - const userId = ctx.user.userId; - - if (!userId) { - throw new ApiError('User not authenticated', 401); - } - - const user = await getUserAuthByIdInDb(userId) - - if (!user) { - throw new ApiError('User not found', 404); - } - - return { - success: true, - data: { - id: user.id, - name: user.name, - email: user.email, - mobile: user.mobile, - }, - } - }), - deleteAccount: protectedProcedure .input(z.object({ mobile: z.string().min(10, 'Mobile number is required'), diff --git a/apps/backend/src/trpc/apis/user-apis/apis/banners.ts b/apps/backend/src/trpc/apis/user-apis/apis/banners.ts index 960e3b6..6b30311 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/banners.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/banners.ts @@ -1,4 +1,3 @@ -import { publicProcedure, router } from '@/src/trpc/trpc-index' import { scaffoldAssetUrl } from '@/src/lib/s3-client' import { getUserActiveBanners as getUserActiveBannersInDb } from '@/src/dbService' import type { UserBannersResponse } from '@packages/shared' @@ -23,11 +22,3 @@ export async function scaffoldBanners(): Promise { banners: bannersWithSignedUrls, } } - -export const bannerRouter = router({ - getBanners: publicProcedure - .query(async () => { - const response = await scaffoldBanners(); - return response; - }), -}); diff --git a/apps/backend/src/trpc/apis/user-apis/apis/cart.ts b/apps/backend/src/trpc/apis/user-apis/apis/cart.ts index 3d09b52..ea1899a 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/cart.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/cart.ts @@ -1,264 +1,8 @@ -import { router, protectedProcedure, publicProcedure } from '@/src/trpc/trpc-index' +import { router, publicProcedure } from '@/src/trpc/trpc-index' import { z } from 'zod' -import { ApiError } from '@/src/lib/api-error' -import { scaffoldAssetUrl } from '@/src/lib/s3-client' import { getMultipleProductsSlots } from '@/src/stores/slot-store' -import { - getUserCartItemsWithProducts as getUserCartItemsWithProductsInDb, - getUserProductById as getUserProductByIdInDb, - getUserCartItemByUserProduct as getUserCartItemByUserProductInDb, - incrementUserCartItemQuantity as incrementUserCartItemQuantityInDb, - insertUserCartItem as insertUserCartItemInDb, - updateUserCartItemQuantity as updateUserCartItemQuantityInDb, - deleteUserCartItem as deleteUserCartItemInDb, - clearUserCart as clearUserCartInDb, -} from '@/src/dbService' -import type { UserCartResponse } from '@packages/shared' - -const getCartData = async (userId: number): Promise => { - const cartItemsWithProducts = await getUserCartItemsWithProductsInDb(userId) - - /* - // Old implementation - direct DB queries: - const cartItemsWithProducts = await db - .select({ - cartId: cartItems.id, - productId: productInfo.id, - productName: productInfo.name, - productPrice: productInfo.price, - productImages: productInfo.images, - productQuantity: productInfo.productQuantity, - isOutOfStock: productInfo.isOutOfStock, - unitShortNotation: units.shortNotation, - quantity: cartItems.quantity, - addedAt: cartItems.addedAt, - }) - .from(cartItems) - .innerJoin(productInfo, eq(cartItems.productId, productInfo.id)) - .innerJoin(units, eq(productInfo.unitId, units.id)) - .where(eq(cartItems.userId, userId)); - */ - - const cartWithSignedUrls = cartItemsWithProducts.map((item) => ({ - ...item, - product: { - ...item.product, - images: scaffoldAssetUrl(item.product.images || []), - }, - })) - - const totalAmount = cartWithSignedUrls.reduce((sum, item) => sum + item.subtotal, 0) - - return { - items: cartWithSignedUrls, - totalItems: cartWithSignedUrls.length, - totalAmount, - } -} export const cartRouter = router({ - getCart: protectedProcedure - .query(async ({ ctx }): Promise => { - const userId = ctx.user.userId; - return await getCartData(userId); - }), - - addToCart: protectedProcedure - .input(z.object({ - productId: z.number().int().positive(), - quantity: z.number().int().positive(), - })) - .mutation(async ({ input, ctx }): Promise => { - const userId = ctx.user.userId; - const { productId, quantity } = input; - - // Validate input - if (!productId || !quantity || quantity <= 0) { - throw new ApiError("Product ID and positive quantity required", 400); - } - - // Check if product exists - const product = await getUserProductByIdInDb(productId) - - if (!product) { - throw new ApiError('Product not found', 404) - } - - const existingItem = await getUserCartItemByUserProductInDb(userId, productId) - - if (existingItem) { - await incrementUserCartItemQuantityInDb(existingItem.id, quantity) - } else { - await insertUserCartItemInDb(userId, productId, quantity) - } - - /* - // Old implementation - direct DB queries: - const product = await db.query.productInfo.findFirst({ - where: eq(productInfo.id, productId), - }); - - if (!product) { - throw new ApiError("Product not found", 404); - } - - const existingItem = await db.query.cartItems.findFirst({ - where: and(eq(cartItems.userId, userId), eq(cartItems.productId, productId)), - }); - - if (existingItem) { - await db.update(cartItems) - .set({ - quantity: sql`${cartItems.quantity} + ${quantity}`, - }) - .where(eq(cartItems.id, existingItem.id)); - } else { - await db.insert(cartItems).values({ - userId, - productId, - quantity: quantity.toString(), - }); - } - */ - - // Return updated cart - return await getCartData(userId) - }), - - updateCartItem: protectedProcedure - .input(z.object({ - itemId: z.number().int().positive(), - quantity: z.number().int().min(0), - })) - .mutation(async ({ input, ctx }): Promise => { - const userId = ctx.user.userId; - const { itemId, quantity } = input; - - if (!quantity || quantity <= 0) { - throw new ApiError("Positive quantity required", 400); - } - - const updated = await updateUserCartItemQuantityInDb(userId, itemId, quantity) - - /* - // Old implementation - direct DB queries: - const [updatedItem] = await db.update(cartItems) - .set({ quantity: quantity.toString() }) - .where(and( - eq(cartItems.id, itemId), - eq(cartItems.userId, userId) - )) - .returning(); - - if (!updatedItem) { - throw new ApiError("Cart item not found", 404); - } - */ - - if (!updated) { - throw new ApiError('Cart item not found', 404) - } - - // Return updated cart - return await getCartData(userId) - }), - - removeFromCart: protectedProcedure - .input(z.object({ - itemId: z.number().int().positive(), - })) - .mutation(async ({ input, ctx }): Promise => { - const userId = ctx.user.userId; - const { itemId } = input; - - const deleted = await deleteUserCartItemInDb(userId, itemId) - - /* - // Old implementation - direct DB queries: - const [deletedItem] = await db.delete(cartItems) - .where(and( - eq(cartItems.id, itemId), - eq(cartItems.userId, userId) - )) - .returning(); - - if (!deletedItem) { - throw new ApiError("Cart item not found", 404); - } - */ - - if (!deleted) { - throw new ApiError('Cart item not found', 404) - } - - // Return updated cart - return await getCartData(userId) - }), - - clearCart: protectedProcedure - .mutation(async ({ ctx }): Promise => { - const userId = ctx.user.userId; - - await clearUserCartInDb(userId) - - /* - // Old implementation - direct DB query: - await db.delete(cartItems).where(eq(cartItems.userId, userId)); - */ - - return { - items: [], - totalItems: 0, - totalAmount: 0, - message: "Cart cleared successfully", - } - }), - - // Original DB-based getCartSlots (commented out) - // getCartSlots: publicProcedure - // .input(z.object({ - // productIds: z.array(z.number().int().positive()) - // })) - // .query(async ({ input }) => { - // const { productIds } = input; - // - // if (productIds.length === 0) { - // return {}; - // } - // - // // Get slots for these products where freeze time is after current time - // const slotsData = await db - // .select({ - // productId: productSlots.productId, - // slotId: deliverySlotInfo.id, - // deliveryTime: deliverySlotInfo.deliveryTime, - // freezeTime: deliverySlotInfo.freezeTime, - // isActive: deliverySlotInfo.isActive, - // }) - // .from(productSlots) - // .innerJoin(deliverySlotInfo, eq(productSlots.slotId, deliverySlotInfo.id)) - // .where(and( - // inArray(productSlots.productId, productIds), - // gt(deliverySlotInfo.freezeTime, sql`NOW()`), - // eq(deliverySlotInfo.isActive, true) - // )); - // - // // Group by productId - // const result: Record = {}; - // slotsData.forEach(slot => { - // if (!result[slot.productId]) { - // result[slot.productId] = []; - // } - // result[slot.productId].push({ - // id: slot.slotId, - // deliveryTime: slot.deliveryTime, - // freezeTime: slot.freezeTime, - // }); - // }); - // - // return result; - // }), - // Cache-based getCartSlots getCartSlots: publicProcedure .input(z.object({ diff --git a/apps/backend/src/trpc/apis/user-apis/apis/coupon.ts b/apps/backend/src/trpc/apis/user-apis/apis/coupon.ts index a7b66c3..8f807e3 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/coupon.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/coupon.ts @@ -86,57 +86,6 @@ export const userCouponRouter = router({ } }), - getProductCoupons: protectedProcedure - .input(z.object({ skuId: z.number().int().positive() })) - .query(async ({ input, ctx }): Promise => { - const userId = ctx.user.userId; - const { skuId } = input; - - // Get all active, non-expired coupons - const allCoupons = await getUserActiveCouponsWithRelationsInDb(userId) - - /* - // Old implementation - direct DB queries: - const allCoupons = await db.query.coupons.findMany({ - where: and( - eq(coupons.isInvalidated, false), - or( - isNull(coupons.validTill), - gt(coupons.validTill, new Date()) - ) - ), - with: { - usages: { - where: eq(couponUsage.userId, userId) - }, - applicableUsers: { - with: { - user: true - } - }, - applicableProducts: { - with: { - product: true - } - }, - } - }); - */ - - // Filter to only coupons applicable to current user and product - const applicableCoupons = allCoupons.filter(coupon => { - const applicableUsers = coupon.applicableUsers || []; - const userApplicable = !coupon.isUserBased || applicableUsers.some(au => au.userId === userId); - - const applicableProducts = coupon.applicableProducts || []; - const productApplicable = applicableProducts.length === 0 || applicableProducts.some(ap => ap.skuId === skuId); - - return userApplicable && productApplicable; - }); - - return { success: true, data: applicableCoupons }; - }), - getMyCoupons: protectedProcedure .query(async ({ ctx }): Promise => { const userId = ctx.user.userId; diff --git a/apps/backend/src/trpc/apis/user-apis/apis/order.ts b/apps/backend/src/trpc/apis/user-apis/apis/order.ts index 32548fc..5e2874b 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/order.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/order.ts @@ -13,9 +13,6 @@ import { getUserOrderByIdWithRelations, getUserOrderCount, getUserOrdersWithRelations, - getUserProductIdsFromOrders, - getUserProductsForRecentOrders, - getUserRecentlyDeliveredOrderIds, getUserSlotCapacityStatus, orders, orderItems, @@ -25,7 +22,6 @@ import { updateUserOrderNotes, validateAndGetUserCoupon, } from "@/src/dbService"; -import { getNextDeliveryDate } from "@/src/trpc/apis/common-apis/common"; import { scaffoldAssetUrl } from "@/src/lib/s3-client"; import { ApiError } from "@/src/lib/api-error"; import { @@ -34,13 +30,11 @@ import { } from "@/src/lib/notif-job"; import { CONST_KEYS, getConstant, getConstants } from "@/src/lib/const-store"; import { publishFormattedOrder, publishCancellation } from "@/src/lib/post-order-handler"; -import { getSlotById } from "@/src/stores/slot-store"; import type { UserOrdersResponse, UserOrderDetail, UserCancelOrderResponse, UserUpdateNotesResponse, - UserRecentProductsResponse, } from "@/src/dbService"; const placeOrderUtil = async (params: { @@ -664,60 +658,4 @@ export const orderRouter = router({ return { success: true, message: "Notes updated successfully" }; }), - - getRecentlyOrderedProducts: protectedProcedure - .input( - z - .object({ - limit: z.number().min(1).max(50).default(20), - }) - .optional() - ) - .query(async ({ input, ctx }): Promise => { - const { limit = 20 } = input || {}; - const userId = ctx.user.userId; - - const thirtyDaysAgo = new Date(); - thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); - - const recentOrderIds = await getUserRecentlyDeliveredOrderIds(userId, 10, thirtyDaysAgo); - - if (recentOrderIds.length === 0) { - return { success: true, products: [] }; - } - - const productIds = await getUserProductIdsFromOrders(recentOrderIds); - - if (productIds.length === 0) { - return { success: true, products: [] }; - } - - const productsWithUnits = await getUserProductsForRecentOrders(productIds, limit); - - const formattedProducts = await Promise.all( - productsWithUnits.map(async (product) => { - const nextDeliveryDate = await getNextDeliveryDate(product.id); - return { - id: product.id, - name: product.name, - shortDescription: product.shortDescription, - price: product.price, - unit: product.unitShortNotation, - incrementStep: product.incrementStep, - isOutOfStock: product.isOutOfStock, - nextDeliveryDate: nextDeliveryDate - ? nextDeliveryDate.toISOString() - : null, - images: scaffoldAssetUrl( - (product.images as string[]) || [] - ), - }; - }) - ); - - return { - success: true, - products: formattedProducts, - }; - }), }); diff --git a/apps/backend/src/trpc/apis/user-apis/apis/product.ts b/apps/backend/src/trpc/apis/user-apis/apis/product.ts index be8145a..e7fd996 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/product.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/product.ts @@ -2,7 +2,7 @@ import { router, publicProcedure, protectedProcedure } from '@/src/trpc/trpc-ind import { z } from 'zod' import { claimUploadUrl, extractKeyFromPresignedUrl, scaffoldAssetUrl } from '@/src/lib/s3-client' import { ApiError } from '@/src/lib/api-error' -import { getProductById as getProductByIdFromCache, getAllProducts as getAllProductsFromCache } from '@/src/stores/product-store' +import { getProductById as getProductByIdFromCache } from '@/src/stores/product-store' import dayjs from 'dayjs' import { getUserProductDetailById as getUserProductDetailByIdInDb, @@ -164,24 +164,6 @@ export const productRouter = router({ return { success: true, review: newReview } }), - - getAllProductsSummary: publicProcedure - .query(async (): Promise => { - // Get all products from cache - const allCachedProducts = await getAllProductsFromCache(); - - // Transform the cached products to match the expected summary format - // (with empty deliverySlots and specialDeals arrays for summary view) - const transformedProducts: UserProductDetail[] = allCachedProducts.map(product => ({ - ...product, - images: product.images || [], - deliverySlots: [], - specialDeals: [], - })) - - return transformedProducts - }), - getOffersPage: publicProcedure .query(async () => { const data = await getOffersAndCombosInDb(); diff --git a/apps/backend/src/trpc/apis/user-apis/apis/slots.ts b/apps/backend/src/trpc/apis/user-apis/apis/slots.ts index 265f42e..a938534 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/slots.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/slots.ts @@ -1,30 +1,8 @@ import { router, publicProcedure } from "@/src/trpc/trpc-index" -import { z } from "zod" -import { getAllSlots as getAllSlotsFromCache, getSlotById as getSlotByIdFromCache } from "@/src/stores/slot-store" +import { getAllSlots as getAllSlotsFromCache } from "@/src/stores/slot-store" import dayjs from 'dayjs' import { getUserActiveSlotsList as getUserActiveSlotsListInDb, getUserProductAvailability as getUserProductAvailabilityInDb } from '@/src/dbService' -import type { UserSlotData, UserSlotsListResponse, UserSlotsWithProductsResponse } from '@packages/shared' - -// Helper method to get formatted slot data by ID -async function getSlotData(slotId: number) { - const slot = await getSlotByIdFromCache(slotId); - - if (!slot) { - return null; - } - - const currentTime = new Date(); - if (dayjs(slot.freezeTime).isBefore(currentTime)) { - return null; - } - - return { - deliveryTime: slot.deliveryTime, - freezeTime: slot.freezeTime, - slotId: slot.id, - products: slot.products.filter((product) => !product.isOutOfStock), - }; -} +import type { UserSlotsListResponse, UserSlotsWithProductsResponse } from '@packages/shared' export async function scaffoldSlotsWithProducts(): Promise { const allSlots = await getAllSlotsFromCache(); @@ -82,15 +60,4 @@ export const slotsRouter = router({ count: slots.length, } }), - - getSlotsWithProducts: publicProcedure.query(async (): Promise => { - const response = await scaffoldSlotsWithProducts(); - return response; - }), - - getSlotById: publicProcedure - .input(z.object({ slotId: z.number() })) - .query(async ({ input }): Promise => { - return await getSlotData(input.slotId); - }), }); diff --git a/apps/backend/src/trpc/apis/user-apis/apis/stores.ts b/apps/backend/src/trpc/apis/user-apis/apis/stores.ts index fe2c751..aa03238 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/stores.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/stores.ts @@ -1,5 +1,3 @@ -import { router, publicProcedure } from '@/src/trpc/trpc-index' -import { z } from 'zod' import { scaffoldAssetUrl } from '@/src/lib/s3-client' import { ApiError } from '@/src/lib/api-error' import { getTagsByStoreId } from '@/src/stores/product-tag-store' @@ -175,21 +173,3 @@ export async function scaffoldStoreWithProducts(storeId: number): Promise => { - const response = await scaffoldStores(); - return response; - }), - - getStoreWithProducts: publicProcedure - .input(z.object({ - storeId: z.number(), - })) - .query(async ({ input }): Promise => { - const { storeId } = input; - const response = await scaffoldStoreWithProducts(storeId); - return response; - }), -}); diff --git a/apps/backend/src/trpc/apis/user-apis/apis/tags.ts b/apps/backend/src/trpc/apis/user-apis/apis/tags.ts deleted file mode 100644 index d21b229..0000000 --- a/apps/backend/src/trpc/apis/user-apis/apis/tags.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { router, publicProcedure } from '@/src/trpc/trpc-index'; -import { z } from 'zod'; -import { getTagsByStoreId } from '@/src/stores/product-tag-store'; -import { ApiError } from '@/src/lib/api-error'; - -export const tagsRouter = router({ - getTagsByStore: publicProcedure - .input(z.object({ - storeId: z.number(), - })) - .query(async ({ input }) => { - const { storeId } = input; - - // Get tags from cache that are related to this store - const tags = await getTagsByStoreId(storeId); - - - return { - tags: tags.map(tag => ({ - id: tag.id, - tagName: tag.tagName, - tagDescription: tag.tagDescription, - imageUrl: tag.imageUrl, - productIds: tag.productIds, - })), - }; - }), -}); diff --git a/apps/backend/src/trpc/apis/user-apis/apis/user-trpc-index.ts b/apps/backend/src/trpc/apis/user-apis/apis/user-trpc-index.ts index 52e6531..bb1d70a 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/user-trpc-index.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/user-trpc-index.ts @@ -1,7 +1,6 @@ import { router } from '@/src/trpc/trpc-index'; import { addressRouter } from '@/src/trpc/apis/user-apis/apis/address'; import { authRouter } from '@/src/trpc/apis/user-apis/apis/auth'; -import { bannerRouter } from '@/src/trpc/apis/user-apis/apis/banners'; import { cartRouter } from '@/src/trpc/apis/user-apis/apis/cart'; import { complaintRouter } from '@/src/trpc/apis/user-apis/apis/complaint'; import { orderRouter } from '@/src/trpc/apis/user-apis/apis/order'; @@ -10,14 +9,11 @@ import { slotsRouter } from '@/src/trpc/apis/user-apis/apis/slots'; import { userRouter as userDataRouter } from '@/src/trpc/apis/user-apis/apis/user'; import { userCouponRouter } from '@/src/trpc/apis/user-apis/apis/coupon'; import { paymentRouter } from '@/src/trpc/apis/user-apis/apis/payments'; -import { storesRouter } from '@/src/trpc/apis/user-apis/apis/stores'; import { fileUploadRouter } from '@/src/trpc/apis/user-apis/apis/file-upload'; -import { tagsRouter } from '@/src/trpc/apis/user-apis/apis/tags'; export const userRouter = router({ address: addressRouter, auth: authRouter, - banner: bannerRouter, cart: cartRouter, complaint: complaintRouter, order: orderRouter, @@ -26,9 +22,7 @@ export const userRouter = router({ user: userDataRouter, coupon: userCouponRouter, payment: paymentRouter, - stores: storesRouter, fileUpload: fileUploadRouter, - tags: tagsRouter, }); export type UserRouter = typeof userRouter; diff --git a/apps/user-ui/app/(drawer)/(tabs)/home/index.tsx b/apps/user-ui/app/(drawer)/(tabs)/home/index.tsx index 59bc521..e277b1a 100755 --- a/apps/user-ui/app/(drawer)/(tabs)/home/index.tsx +++ b/apps/user-ui/app/(drawer)/(tabs)/home/index.tsx @@ -247,6 +247,29 @@ const ExploreTab = memo(({ tag, isSelected, onPress }: ExploreTabProps) => { ); }); +interface ExploreTabsRowProps { + dashboardTags: any[]; + activeTagId: number | null; + onSelectTag: (id: number) => void; +} + +const ExploreTabsRow = memo(({ dashboardTags, activeTagId, onSelectTag }: ExploreTabsRowProps) => ( + + {dashboardTags.map((tag) => ( + onSelectTag(tag.id)} + /> + ))} + +)); + interface ExploreProductItemProps { item: any; onPress: (id: number) => void; @@ -307,6 +330,7 @@ interface ListHeaderProps { activeTagId: number | null; activeTagProducts: any[]; onSelectTag: (id: number) => void; + onTabsSectionLayout: (layout: { y: number; height: number }) => void; } const ListHeader = memo(({ @@ -320,6 +344,7 @@ const ListHeader = memo(({ activeTagId, activeTagProducts, onSelectTag, + onTabsSectionLayout, }: ListHeaderProps) => { const [showAllActiveProducts, setShowAllActiveProducts] = useState(false); const handleLayout = useCallback((event: any) => { @@ -360,25 +385,20 @@ const ListHeader = memo(({ {dashboardTags.length > 0 && ( { + const { y, height } = event.nativeEvent.layout; + onTabsSectionLayout({ y, height }); + }} style={[ tw`mx-4 mb-2 rounded-[28px] px-3 pt-3 pb-2`, { backgroundColor: pageTint }, ]} > - - {dashboardTags.map((tag) => ( - onSelectTag(tag.id)} - /> - ))} - + {activeTagProducts.length > 0 ? ( @@ -515,6 +535,9 @@ export default function Dashboard() { const [displayedProducts, setDisplayedProducts] = useState([]); const [hasMore, setHasMore] = useState(true); const [isLoadingMore, setIsLoadingMore] = useState(false); + const [searchBarHeight, setSearchBarHeight] = useState(0); + const [tabsSectionLayout, setTabsSectionLayout] = useState({ y: 0, height: 0 }); + const [showStickyTabs, setShowStickyTabs] = useState(false); const { getQuickestSlot } = useProductSlotIdentifier(); const productSlotsMap = useCentralSlotStore((state) => state.productSlotsMap); const refetchProducts = useCentralProductStore((state) => state.refetchProducts); @@ -657,6 +680,19 @@ export default function Dashboard() { router.push("/(drawer)/(tabs)/home/search-results"); }, [router]); + const handleTabsSectionLayout = useCallback((layout: { y: number; height: number }) => { + setTabsSectionLayout(layout); + }, []); + + const handleListScroll = useCallback((event: any) => { + const scrollY = event.nativeEvent.contentOffset.y; + const stickyStart = tabsSectionLayout.y; + const stickyEnd = tabsSectionLayout.y + tabsSectionLayout.height - 56; + const shouldShowStickyTabs = tabsSectionLayout.height > 0 && scrollY > stickyStart && scrollY < stickyEnd; + + setShowStickyTabs((current) => current === shouldShowStickyTabs ? current : shouldShowStickyTabs); + }, [tabsSectionLayout]); + const renderProductItem = useCallback(({ item }: { item: any }) => ( // @@ -674,8 +710,9 @@ export default function Dashboard() { activeTagId={activeTagId} activeTagProducts={activeTagProducts} onSelectTag={setSelectedTagId} + onTabsSectionLayout={handleTabsSectionLayout} /> - ), [gradientHeight, handleGradientLayout, storesData, popularProducts, sortedSlots, handleProductPress, dashboardTags, activeTagId, activeTagProducts]); + ), [gradientHeight, handleGradientLayout, storesData, popularProducts, sortedSlots, handleProductPress, dashboardTags, activeTagId, activeTagProducts, handleTabsSectionLayout]); const pageTint = activeTagId == null ? '#FFFFFF' : getTagColor(activeTagId).pageBg; const pageTintStyle = useMemo(() => [ @@ -720,7 +757,10 @@ export default function Dashboard() { return ( - + setSearchBarHeight(event.nativeEvent.layout.height)} + > { }} @@ -741,6 +781,8 @@ export default function Dashboard() { keyExtractor={(item) => item.id.toString()} numColumns={2} style={{ backgroundColor: pageTint }} + onScroll={handleListScroll} + scrollEventThrottle={16} contentContainerStyle={listContentContainerStyle} columnWrapperStyle={staticStyles.columnWrapper} renderItem={renderProductItem} @@ -765,6 +807,33 @@ export default function Dashboard() { updateCellsBatchingPeriod={50} /> + {showStickyTabs && dashboardTags.length > 0 && ( + + + + + + )} + diff --git a/packages/db_helper_postgres/index.ts b/packages/db_helper_postgres/index.ts index fe62a34..09b1a4b 100644 --- a/packages/db_helper_postgres/index.ts +++ b/packages/db_helper_postgres/index.ts @@ -202,18 +202,6 @@ export { getActiveBanners as getUserActiveBanners, } from './src/user-apis/banners'; -export { - // User Cart - getCartItemsWithProducts as getUserCartItemsWithProducts, - getProductById as getUserProductById, - getCartItemByUserProduct as getUserCartItemByUserProduct, - incrementCartItemQuantity as incrementUserCartItemQuantity, - insertCartItem as insertUserCartItem, - updateCartItemQuantity as updateUserCartItemQuantity, - deleteCartItem as deleteUserCartItem, - clearUserCart, -} from './src/user-apis/cart'; - export { // User Complaint getUserComplaints as getUserComplaints, @@ -308,9 +296,6 @@ export { getOrderBasic as getUserOrderBasic, cancelOrderTransaction as cancelUserOrderTransaction, updateOrderNotes as updateUserOrderNotes, - getRecentlyDeliveredOrderIds as getUserRecentlyDeliveredOrderIds, - getProductIdsFromOrders as getUserProductIdsFromOrders, - getProductsForRecentOrders as getUserProductsForRecentOrders, // Post-order handler helpers getOrdersByIdsWithFullData, getOrderByIdWithFullData, diff --git a/packages/db_helper_postgres/src/user-apis/cart.ts b/packages/db_helper_postgres/src/user-apis/cart.ts deleted file mode 100644 index 211699c..0000000 --- a/packages/db_helper_postgres/src/user-apis/cart.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { db } from '../db/db_index' -import { cartItems, productInfo, units } from '../db/schema' -import { and, eq, sql } from 'drizzle-orm' -import type { UserCartItem } from '@packages/shared' - -const getStringArray = (value: unknown): string[] => { - if (!Array.isArray(value)) return [] - return value.map((item) => String(item)) -} - -export async function getCartItemsWithProducts(userId: number): Promise { - const cartItemsWithProducts = await db - .select({ - cartId: cartItems.id, - productId: productInfo.id, - productName: productInfo.name, - productPrice: productInfo.price, - productImages: productInfo.images, - productQuantity: productInfo.productQuantity, - isOutOfStock: productInfo.isOutOfStock, - unitShortNotation: units.shortNotation, - quantity: cartItems.quantity, - addedAt: cartItems.addedAt, - }) - .from(cartItems) - .innerJoin(productInfo, eq(cartItems.productId, productInfo.id)) - .innerJoin(units, eq(productInfo.unitId, units.id)) - .where(eq(cartItems.userId, userId)) - - return cartItemsWithProducts.map((item) => ({ - id: item.cartId, - productId: item.productId, - quantity: parseFloat(item.quantity), - addedAt: item.addedAt, - product: { - id: item.productId, - name: item.productName, - price: item.productPrice.toString(), - productQuantity: item.productQuantity, - unit: item.unitShortNotation, - isOutOfStock: item.isOutOfStock, - images: getStringArray(item.productImages), - }, - subtotal: parseFloat(item.productPrice.toString()) * parseFloat(item.quantity), - })) -} - -export async function getProductById(productId: number) { - return db.query.productInfo.findFirst({ - where: eq(productInfo.id, productId), - }) -} - -export async function getCartItemByUserProduct(userId: number, productId: number) { - return db.query.cartItems.findFirst({ - where: and(eq(cartItems.userId, userId), eq(cartItems.productId, productId)), - }) -} - -export async function incrementCartItemQuantity(itemId: number, quantity: number): Promise { - await db.update(cartItems) - .set({ - quantity: sql`${cartItems.quantity} + ${quantity}`, - }) - .where(eq(cartItems.id, itemId)) -} - -export async function insertCartItem(userId: number, productId: number, quantity: number): Promise { - await db.insert(cartItems).values({ - userId, - productId, - quantity: quantity.toString(), - }) -} - -export async function updateCartItemQuantity(userId: number, itemId: number, quantity: number) { - const [updatedItem] = await db.update(cartItems) - .set({ quantity: quantity.toString() }) - .where(and(eq(cartItems.id, itemId), eq(cartItems.userId, userId))) - .returning({ id: cartItems.id }) - - return !!updatedItem -} - -export async function deleteCartItem(userId: number, itemId: number): Promise { - const [deletedItem] = await db.delete(cartItems) - .where(and(eq(cartItems.id, itemId), eq(cartItems.userId, userId))) - .returning({ id: cartItems.id }) - - return !!deletedItem -} - -export async function clearUserCart(userId: number): Promise { - await db.delete(cartItems).where(eq(cartItems.userId, userId)) -} diff --git a/packages/db_helper_postgres/src/user-apis/order.ts b/packages/db_helper_postgres/src/user-apis/order.ts index cc0f922..eb9d8e6 100644 --- a/packages/db_helper_postgres/src/user-apis/order.ts +++ b/packages/db_helper_postgres/src/user-apis/order.ts @@ -14,11 +14,10 @@ import { userDetails, deliverySlotInfo, } from '../db/schema' -import { and, eq, inArray, desc, gte, lte } from 'drizzle-orm' +import { and, eq, inArray, desc, lte } from 'drizzle-orm' import type { UserOrderSummary, UserOrderDetail, - UserRecentProduct, } from '@packages/shared' export interface OrderItemInput { @@ -552,77 +551,6 @@ export async function updateOrderNotes( .where(eq(orders.id, orderId)) } -export async function getRecentlyDeliveredOrderIds( - userId: number, - limit: number, - since: Date -): Promise { - const recentOrders = await db - .select({ id: orders.id }) - .from(orders) - .innerJoin(orderStatus, eq(orders.id, orderStatus.orderId)) - .where( - and( - eq(orders.userId, userId), - eq(orderStatus.isDelivered, true), - gte(orders.createdAt, since) - ) - ) - .orderBy(desc(orders.createdAt)) - .limit(limit) - - return recentOrders.map((order) => order.id) -} - -export async function getProductIdsFromOrders( - orderIds: number[] -): Promise { - const orderItemsResult = await db - .select({ productId: orderItems.productId }) - .from(orderItems) - .where(inArray(orderItems.orderId, orderIds)) - - return [...new Set(orderItemsResult.map((item) => item.productId))] -} - -export interface RecentProductData { - id: number - name: string - shortDescription: string | null - price: string - images: unknown - isOutOfStock: boolean - unitShortNotation: string - incrementStep: number -} - -export async function getProductsForRecentOrders( - productIds: number[], - limit: number -): Promise { - return db - .select({ - id: productInfo.id, - name: productInfo.name, - shortDescription: productInfo.shortDescription, - price: productInfo.price, - images: productInfo.images, - isOutOfStock: productInfo.isOutOfStock, - unitShortNotation: units.shortNotation, - incrementStep: productInfo.incrementStep, - }) - .from(productInfo) - .innerJoin(units, eq(productInfo.unitId, units.id)) - .where( - and( - inArray(productInfo.id, productIds), - eq(productInfo.isSuspended, false) - ) - ) - .orderBy(desc(productInfo.createdAt)) - .limit(limit) -} - // ============================================================================ // Post-Order Handler Helpers (for Telegram notifications) // ============================================================================ diff --git a/packages/db_helper_sqlite/index.ts b/packages/db_helper_sqlite/index.ts index 53b73f6..bc6aad0 100644 --- a/packages/db_helper_sqlite/index.ts +++ b/packages/db_helper_sqlite/index.ts @@ -201,18 +201,6 @@ export { getActiveBanners as getUserActiveBanners, } from './src/user-apis/banners' -export { - // User Cart - getCartItemsWithProducts as getUserCartItemsWithProducts, - getProductById as getUserProductById, - getCartItemByUserProduct as getUserCartItemByUserProduct, - incrementCartItemQuantity as incrementUserCartItemQuantity, - insertCartItem as insertUserCartItem, - updateCartItemQuantity as updateUserCartItemQuantity, - deleteCartItem as deleteUserCartItem, - clearUserCart, -} from './src/user-apis/cart' - export { // User Complaint getUserComplaints as getUserComplaints, @@ -312,9 +300,6 @@ export { getOrderBasic as getUserOrderBasic, cancelOrderTransaction as cancelUserOrderTransaction, updateOrderNotes as updateUserOrderNotes, - getRecentlyDeliveredOrderIds as getUserRecentlyDeliveredOrderIds, - getSkuIdsFromOrders as getUserProductIdsFromOrders, - getProductsForRecentOrders as getUserProductsForRecentOrders, // Post-order handler helpers getOrdersByIdsWithFullData, getOrderByIdWithFullData, diff --git a/packages/db_helper_sqlite/src/user-apis/cart.ts b/packages/db_helper_sqlite/src/user-apis/cart.ts deleted file mode 100644 index fc396ab..0000000 --- a/packages/db_helper_sqlite/src/user-apis/cart.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { db } from '../db/db_index' -import { cartItems, productSkus } from '../db/schema' -import { and, eq, sql } from 'drizzle-orm' -import type { UserCartItem } from '@packages/shared' -import { composeSkuName, composeUnitNotation } from '../lib/sku-features' - -const getStringArray = (value: unknown): string[] => { - if (!Array.isArray(value)) return [] - return value.map((item) => String(item)) -} - -export async function getCartItemsWithProducts(userId: number): Promise { - const cartItemsWithProducts = await db.query.cartItems.findMany({ - where: eq(cartItems.userId, userId), - with: { - sku: { - with: { - product: true, - features: true, - }, - }, - }, - }) - - return cartItemsWithProducts - .filter((item) => item.sku && !item.sku.isSuspended) - .map((item) => { - const sku = item.sku - const features = sku?.features || [] - const priceValue = sku?.price ?? '0' - const quantityValue = item.quantity ?? '0' - return { - id: item.id, - skuId: item.skuId, - quantity: parseFloat(quantityValue), - addedAt: item.addedAt, - product: { - id: sku?.id ?? 0, - name: composeSkuName(sku?.product?.name ?? 'Unknown', features), - price: String(priceValue), - productQuantity: 1, - unit: composeUnitNotation(features), - isOutOfStock: sku?.isOutOfStock ?? false, - images: getStringArray(sku?.images), - }, - subtotal: parseFloat(String(priceValue)) * parseFloat(quantityValue), - } - }) -} - -export async function getProductById(skuId: number) { - return db.query.productSkus.findFirst({ - where: and(eq(productSkus.id, skuId), eq(productSkus.isSuspended, false)), - }) -} - -export async function getCartItemByUserProduct(userId: number, skuId: number) { - return db.query.cartItems.findFirst({ - where: and(eq(cartItems.userId, userId), eq(cartItems.skuId, skuId)), - }) -} - -export async function incrementCartItemQuantity(itemId: number, quantity: number): Promise { - await db.update(cartItems) - .set({ - quantity: sql`${cartItems.quantity} + ${quantity}`, - }) - .where(eq(cartItems.id, itemId)) -} - -export async function insertCartItem(userId: number, skuId: number, quantity: number): Promise { - await db.insert(cartItems).values({ - userId, - skuId, - quantity: quantity.toString(), - }) -} - -export async function updateCartItemQuantity(userId: number, itemId: number, quantity: number) { - const [updatedItem] = await db.update(cartItems) - .set({ quantity: quantity.toString() }) - .where(and(eq(cartItems.id, itemId), eq(cartItems.userId, userId))) - .returning({ id: cartItems.id }) - - return !!updatedItem -} - -export async function deleteCartItem(userId: number, itemId: number): Promise { - const [deletedItem] = await db.delete(cartItems) - .where(and(eq(cartItems.id, itemId), eq(cartItems.userId, userId))) - .returning({ id: cartItems.id }) - - return !!deletedItem -} - -export async function clearUserCart(userId: number): Promise { - await db.delete(cartItems).where(eq(cartItems.userId, userId)) -} diff --git a/packages/db_helper_sqlite/src/user-apis/order.ts b/packages/db_helper_sqlite/src/user-apis/order.ts index 341034f..8456968 100644 --- a/packages/db_helper_sqlite/src/user-apis/order.ts +++ b/packages/db_helper_sqlite/src/user-apis/order.ts @@ -15,11 +15,10 @@ import { userDetails, deliverySlotInfo, } from '../db/schema' -import { and, eq, inArray, desc, gte, sql } from 'drizzle-orm' +import { and, eq, inArray, desc, sql } from 'drizzle-orm' import type { UserOrderSummary, UserOrderDetail, - UserRecentProduct, } from '@packages/shared' import { coerceDate } from '../lib/date' import { runBatched } from '../lib/run-batched' @@ -611,93 +610,6 @@ export async function updateOrderNotes( .where(eq(orders.id, orderId)) } -export async function getRecentlyDeliveredOrderIds( - userId: number, - limit: number, - since: Date -): Promise { - const recentOrders = await db - .select({ id: orders.id }) - .from(orders) - .innerJoin(orderStatus, eq(orders.id, orderStatus.orderId)) - .where( - and( - eq(orders.userId, userId), - eq(orderStatus.isDelivered, true), - gte(orders.createdAt, since) - ) - ) - .orderBy(desc(orders.createdAt)) - .limit(limit) - - return recentOrders.map((order) => order.id) -} - -export async function getSkuIdsFromOrders( - orderIds: number[] -): Promise { - if (orderIds.length === 0) return [] - - const skuChunks = await runBatched(db, orderIds, 10, async (tx, chunk) => { - return tx - .select({ skuId: orderItems.skuId }) - .from(orderItems) - .where(inArray(orderItems.orderId, chunk)) - }) - - return [...new Set(skuChunks.flat().map((item) => item.skuId))] -} - -export interface RecentProductData { - id: number - name: string - shortDescription: string | null - price: string - images: unknown - isOutOfStock: boolean - unitShortNotation: string - incrementStep: number -} - -export async function getProductsForRecentOrders( - productIds: number[], - limit: number -): Promise { - if (productIds.length === 0) return [] - - const skuChunks = await runBatched(db, productIds, 10, async (tx, chunk) => { - return tx.query.productSkus.findMany({ - where: and( - inArray(productSkus.id, chunk), - eq(productSkus.isSuspended, false) - ), - with: { - product: true, - features: true, - }, - }) - }) - - const skus = skuChunks - .flat() - .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) - .slice(0, limit) - - return skus.map((sku) => { - const features = sku.features || [] - return { - id: sku.id, - name: composeSkuName(sku.product?.name ?? 'Unknown', features), - shortDescription: sku.product?.shortDescription ?? null, - price: String(sku.price ?? '0'), - images: sku.images, - isOutOfStock: sku.isOutOfStock, - unitShortNotation: composeUnitNotation(features), - incrementStep: sku.product?.incrementStep ?? 1, - } - }) -} - // ============================================================================ // Post-Order Handler Helpers (for Telegram notifications) // ============================================================================ diff --git a/packages/shared/types/user.ts b/packages/shared/types/user.ts index 019ca2e..1efc02b 100644 --- a/packages/shared/types/user.ts +++ b/packages/shared/types/user.ts @@ -133,32 +133,6 @@ export interface UserBannersResponse { banners: UserBanner[]; } -export interface UserCartProduct { - id: number; - name: string; - price: string; - productQuantity: number; - unit: string; - isOutOfStock: boolean; - images: string[]; -} - -export interface UserCartItem { - id: number; - skuId: number; - quantity: number; - addedAt: Date; - product: UserCartProduct; - subtotal: number; -} - -export interface UserCartResponse { - items: UserCartItem[]; - totalItems: number; - totalAmount: number; - message?: string; -} - export interface UserComplaint { id: number; complaintBody: string; @@ -367,13 +341,6 @@ export interface UserSlotWithProducts { products: UserSlotProduct[]; } -export interface UserSlotData { - slotId: number; - deliveryTime: Date; - freezeTime: Date; - products: UserSlotProduct[]; -} - export interface UserSlotAvailability { id: number; name: string; @@ -464,16 +431,6 @@ export interface UserPasswordUpdateResponse { message: string; } -export interface UserProfileResponse { - success: boolean; - data: { - id: number; - name: string | null; - email: string | null; - mobile: string | null; - }; -} - export interface UserDeleteAccountResponse { success: boolean; message: string; @@ -638,20 +595,3 @@ export interface UserUpdateNotesResponse { success: boolean; message: string; } - -export interface UserRecentProduct { - id: number; - name: string; - shortDescription: string | null; - price: string; - images: string[]; - isOutOfStock: boolean; - unit: string; - incrementStep: number; - nextDeliveryDate: string | null; -} - -export interface UserRecentProductsResponse { - success: boolean; - products: UserRecentProduct[]; -} From 0ca746d3c6e2b5b835c184b45c9675ff3e3e72cc Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:40:19 +0530 Subject: [PATCH 20/73] enh --- .../manage-orders/order-details/[id].tsx | 4 +- .../(drawer)/manage-orders/orders/index.tsx | 2 +- .../app/(drawer)/prices-overview/index.tsx | 9 - apps/backend/src/lib/cloud_cache.ts | 60 +++++- apps/backend/src/sqliteImporter.ts | 1 + apps/backend/src/stores/product-store.ts | 2 + .../src/trpc/apis/admin-apis/apis/product.ts | 15 +- .../src/trpc/apis/admin-apis/apis/slots.ts | 27 ++- .../apis/common-apis/common-trpc-index.ts | 6 + .../src/trpc/apis/common-apis/common.ts | 14 +- .../src/trpc/apis/user-apis/apis/slots.ts | 30 +-- apps/backend/src/trpc/router.ts | 3 +- apps/backend/wrangler-commands.md | 10 +- apps/backend/wrangler.dev.toml | 2 +- apps/user-ui/components/ProductDetail.tsx | 49 +++++ apps/user-ui/hooks/cart-query-hooks.tsx | 10 +- apps/user-ui/src/hooks/prominent-api-hooks.ts | 99 +++++++++- apps/user-ui/src/store/centralProductStore.ts | 5 +- apps/user-ui/src/store/centralSlotStore.ts | 24 ++- .../web-ui/src/components/AddToCartDialog.tsx | 4 +- apps/web-ui/src/hooks/prominent-api-hooks.ts | 93 ++++++++- .../drizzle/0002_sku_split.sql | 42 ++-- packages/db_helper_sqlite/index.ts | 6 + .../db_helper_sqlite/src/admin-apis/const.ts | 66 +++++++ .../src/admin-apis/product.ts | 182 +++++++++++++----- packages/db_helper_sqlite/src/db/schema.ts | 22 ++- .../db_helper_sqlite/src/lib/const-keys.ts | 8 + .../src/stores/store-helpers.ts | 71 +++++-- .../db_helper_sqlite/src/user-apis/product.ts | 63 +++--- .../db_helper_sqlite/src/user-apis/slots.ts | 19 +- .../db_helper_sqlite/src/user-apis/stores.ts | 23 +-- packages/shared/index.ts | 1 + packages/shared/types/user.ts | 29 +-- 33 files changed, 770 insertions(+), 231 deletions(-) diff --git a/apps/admin-ui/app/(drawer)/manage-orders/order-details/[id].tsx b/apps/admin-ui/app/(drawer)/manage-orders/order-details/[id].tsx index 3851941..43c45c4 100644 --- a/apps/admin-ui/app/(drawer)/manage-orders/order-details/[id].tsx +++ b/apps/admin-ui/app/(drawer)/manage-orders/order-details/[id].tsx @@ -463,8 +463,8 @@ export default function OrderDetails() { {item.name} - {Number(item.quantity) * item.productSize} {item.unit} × ₹{item.price} - + {Number(item.quantity)} x {item.productSize}{item.unit} × ₹{item.price} + void } - {item.quantity * item.productSize } {item.unit} + {item.quantity} x {item.productSize}{item.unit} {item.name.length > 30 ? `${item.name.substring(0, 30)}...` : item.name} diff --git a/apps/admin-ui/app/(drawer)/prices-overview/index.tsx b/apps/admin-ui/app/(drawer)/prices-overview/index.tsx index e9ed777..eb39369 100644 --- a/apps/admin-ui/app/(drawer)/prices-overview/index.tsx +++ b/apps/admin-ui/app/(drawer)/prices-overview/index.tsx @@ -402,15 +402,6 @@ export default function PricesOverview() { /> - - Size - - - `v-${version}/${path}` +const buildAvailabilityPath = (version: number) => `av-${version}/${CACHE_FILENAMES.availability}` + +const buildSlotsPath = (version: number) => `slots/v-${version}/${CACHE_FILENAMES.slots}` + function constructCacheUrl(path: string, version: number): string { return `${getAssetsDomain()}${getApiCacheKey()}/${buildCachePath(path, version)}` } +function constructAvailabilityUrl(version: number): string { + return `${getAssetsDomain()}${buildAvailabilityPath(version)}` +} + +function constructSlotsUrl(version: number): string { + return `${getAssetsDomain()}${buildSlotsPath(version)}` +} + export interface CreateAllCacheFilesResult { cacheVersion: number products: string essentialConsts: string stores: string - slots: string + slotsVersion: number + availabilityVersion: number banners: string individualStores: string[] } @@ -37,14 +50,16 @@ export async function createAllCacheFiles(): Promise productsKey, essentialConstsKey, storesKey, - slotsKey, + slotsVersion, + availabilityVersion, bannersKey, individualStoreKeys, ] = await Promise.all([ createProductsFileInternal(cacheVersion), createEssentialConstsFileInternal(cacheVersion), createStoresFileInternal(cacheVersion), - createSlotsFileInternal(cacheVersion), + createSlotsCacheFile(), + createAvailabilityCacheFile(), createBannersFileInternal(cacheVersion), createAllStoresFilesInternal(cacheVersion), ]) @@ -56,7 +71,8 @@ export async function createAllCacheFiles(): Promise constructCacheUrl(CACHE_FILENAMES.products, cacheVersion), constructCacheUrl(CACHE_FILENAMES.essentialConsts, cacheVersion), constructCacheUrl(CACHE_FILENAMES.stores, cacheVersion), - constructCacheUrl(CACHE_FILENAMES.slots, cacheVersion), + constructSlotsUrl(slotsVersion), + constructAvailabilityUrl(availabilityVersion), constructCacheUrl(CACHE_FILENAMES.banners, cacheVersion), ...stores.map((store) => constructCacheUrl(`stores/${store.id}.json`, cacheVersion)), ] @@ -76,7 +92,8 @@ export async function createAllCacheFiles(): Promise products: productsKey, essentialConsts: essentialConstsKey, stores: storesKey, - slots: slotsKey, + slotsVersion, + availabilityVersion, banners: bannersKey, individualStores: individualStoreKeys, } @@ -98,6 +115,23 @@ async function createProductsFileInternal(version: number): Promise { } +export async function createAvailabilityCacheFile(): Promise { + const version = await incrementAvailabilityVersionNum() + const availabilityData = await scaffoldAvailability() + const jsonContent = JSON.stringify(availabilityData, null, 2) + const buffer = Buffer.from(jsonContent, 'utf-8') + const filePath = buildAvailabilityPath(version) + + console.log(filePath) + await imageUploadS3( + buffer, + 'application/json', + filePath + ) + + return version +} + async function createEssentialConstsFileInternal(version: number): Promise { const essentialConstsData = await scaffoldEssentialConsts() const jsonContent = JSON.stringify(essentialConstsData, null, 2) @@ -120,15 +154,21 @@ async function createStoresFileInternal(version: number): Promise { ) } -async function createSlotsFileInternal(version: number): Promise { +export async function createSlotsCacheFile(): Promise { + const version = await incrementSlotsVersionNum() const slotsData = await scaffoldSlotsWithProducts() const jsonContent = JSON.stringify(slotsData, null, 2) const buffer = Buffer.from(jsonContent, 'utf-8') - return await imageUploadS3( + const filePath = buildSlotsPath(version) + + console.log(filePath) + await imageUploadS3( buffer, 'application/json', - `${getApiCacheKey()}/${buildCachePath(CACHE_FILENAMES.slots, version)}` + filePath ) + + return version } async function createBannersFileInternal(version: number): Promise { diff --git a/apps/backend/src/sqliteImporter.ts b/apps/backend/src/sqliteImporter.ts index 111fac3..322fc09 100644 --- a/apps/backend/src/sqliteImporter.ts +++ b/apps/backend/src/sqliteImporter.ts @@ -240,6 +240,7 @@ export { // Store Helpers getAllBannersForCache, getAllProductsForCache, + getAvailabilityForCache, getAllStoresForCache, getAllDeliverySlotsForCache, getAllSpecialDealsForCache, diff --git a/apps/backend/src/stores/product-store.ts b/apps/backend/src/stores/product-store.ts index 73f7cd4..db04482 100644 --- a/apps/backend/src/stores/product-store.ts +++ b/apps/backend/src/stores/product-store.ts @@ -44,6 +44,7 @@ interface Product { productName: string images: string[] | null price: string + isOffer: boolean }> } @@ -250,6 +251,7 @@ export async function getAllProducts(): Promise { productName: ci.productName, images: ci.images ? scaffoldAssetUrl((ci.images as string[]) || []) : null, price: ci.price, + isOffer: ci.isOffer, })) products.push({ diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts index 8d99e3e..eac06d3 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { ApiError } from '@/src/lib/api-error' import { generateSignedUrlsFromS3Urls, scaffoldAssetUrl, claimUploadUrl, extractKeyFromPresignedUrl, deleteImageUtil } from '@/src/lib/s3-client' import { scheduleStoreInitialization } from '@/src/stores/store-initializer' +import { createAvailabilityCacheFile } from '@/src/lib/cloud_cache' import { getAllProducts as getAllProductsInDb, getProductById as getProductByIdInDb, @@ -196,6 +197,7 @@ export const productRouter = router({ images: z.array(z.string()).optional().default([]), isFlashAvailable: z.boolean().optional().default(false), flashPrice: z.number().optional().nullable(), + isOutOfStock: z.boolean().optional().default(false), isOffer: z.boolean().optional().default(false), isComboOnly: z.boolean().optional().default(false), isSuspended: z.boolean().optional().default(false), @@ -225,6 +227,7 @@ export const productRouter = router({ images: sku.images.map((url) => extractKeyFromPresignedUrl(url)), isFlashAvailable: sku.isFlashAvailable, flashPrice: sku.flashPrice ?? null, + isOutOfStock: sku.isOutOfStock, isOffer: sku.isOffer, isComboOnly: sku.isComboOnly, isSuspended: sku.isSuspended, @@ -284,6 +287,7 @@ export const productRouter = router({ images: z.array(z.string()).optional().default([]), isFlashAvailable: z.boolean().optional().default(false), flashPrice: z.number().optional().nullable(), + isOutOfStock: z.boolean().optional().default(false), isOffer: z.boolean().optional().default(false), isComboOnly: z.boolean().optional().default(false), isSuspended: z.boolean().optional().default(false), @@ -315,6 +319,7 @@ export const productRouter = router({ images: sku.images.map((url) => extractKeyFromPresignedUrl(url)), isFlashAvailable: sku.isFlashAvailable, flashPrice: sku.flashPrice ?? null, + isOutOfStock: sku.isOutOfStock, isOffer: sku.isOffer, isComboOnly: sku.isComboOnly, isSuspended: sku.isSuspended, @@ -842,11 +847,13 @@ export const productRouter = router({ }; */ - if (result.invalidIds.length > 0) { - throw new ApiError(`Invalid product IDs: ${result.invalidIds.join(', ')}`, 400) - } + if (result.invalidIds.length > 0) { + throw new ApiError(`Invalid product IDs: ${result.invalidIds.join(', ')}`, 400) + } - await scheduleStoreInitialization() + await createAvailabilityCacheFile().catch((err) => { + console.error('Failed to regenerate availability cache after price update:', err) + }) return { message: `Updated prices for ${result.updatedCount} product(s)`, diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/slots.ts b/apps/backend/src/trpc/apis/admin-apis/apis/slots.ts index c2e622c..7c7932d 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/slots.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/slots.ts @@ -6,6 +6,7 @@ import { getAppUrl } from "@/src/lib/env-exporter" // import redisClient from "@/src/lib/redis-client" // import { getSlotSequenceKey } from "@/src/lib/redisKeyGetters" import { scheduleStoreInitialization } from '@/src/stores/store-initializer' +import { createSlotsCacheFile } from '@/src/lib/cloud_cache' import { getActiveSlotsWithProducts as getActiveSlotsWithProductsInDb, getActiveSlots as getActiveSlotsInDb, @@ -266,7 +267,9 @@ export const slotsRouter = router({ }; */ - await scheduleStoreInitialization() + await createSlotsCacheFile().catch((err) => { + console.error('Failed to regenerate slots cache after product update:', err) + }) return { message: result.message, @@ -360,8 +363,10 @@ export const slotsRouter = router({ }); */ - // Reinitialize stores to reflect changes (outside transaction) - await scheduleStoreInitialization() + // Regenerate slots cache file (availability/products stay as-is) + await createSlotsCacheFile().catch((error) => { + console.error('Failed to regenerate slots cache after slot create:', error) + }) // Fire and forget: cleanup stale product slot associations staleSlotsCleanup().catch((error) => { @@ -548,8 +553,10 @@ export const slotsRouter = router({ throw new ApiError('Slot not found', 404) } - // Reinitialize stores to reflect changes (outside transaction) - await scheduleStoreInitialization() + // Regenerate slots cache file (availability/products stay as-is) + await createSlotsCacheFile().catch((error) => { + console.error('Failed to regenerate slots cache after slot update:', error) + }) return result } @@ -587,8 +594,10 @@ export const slotsRouter = router({ throw new ApiError('Slot not found', 404) } - // Reinitialize stores to reflect changes - await scheduleStoreInitialization() + // Regenerate slots cache file (availability/products stay as-is) + await createSlotsCacheFile().catch((error) => { + console.error('Failed to regenerate slots cache after slot delete:', error) + }) return { message: 'Slot deleted successfully', @@ -736,7 +745,9 @@ export const slotsRouter = router({ throw new ApiError('Slot not found', 404) } - await scheduleStoreInitialization() + await createSlotsCacheFile().catch((error) => { + console.error('Failed to regenerate slots cache after capacity update:', error) + }) return result }), diff --git a/apps/backend/src/trpc/apis/common-apis/common-trpc-index.ts b/apps/backend/src/trpc/apis/common-apis/common-trpc-index.ts index 69d9449..912d23c 100644 --- a/apps/backend/src/trpc/apis/common-apis/common-trpc-index.ts +++ b/apps/backend/src/trpc/apis/common-apis/common-trpc-index.ts @@ -4,6 +4,8 @@ import { getStoresSummary, healthCheck, getCacheVersion, + getAvailabilityVersionNum, + getSlotsVersionNum, } from '@/src/dbService' import type { StoresSummaryResponse } from '@packages/shared' import { polygon as turfPolygon, point as turfPoint } from '@turf/helpers'; @@ -21,6 +23,8 @@ const polygon = turfPolygon(mbnrGeoJson.features[0].geometry.coordinates); export async function scaffoldEssentialConsts() { const consts = await getAllConstValues(); const cacheVersion = await getCacheVersion() + const availabilityVersionNum = await getAvailabilityVersionNum() + const slotsVersionNum = await getSlotsVersionNum() return { freeDeliveryThreshold: consts[CONST_KEYS.freeDeliveryThreshold] ?? 200, @@ -40,6 +44,8 @@ export async function scaffoldEssentialConsts() { assetsDomain: getAssetsDomain(), apiCacheKey: getApiCacheKey(), cacheVersion, + availabilityVersionNum, + slotsVersionNum, }; } diff --git a/apps/backend/src/trpc/apis/common-apis/common.ts b/apps/backend/src/trpc/apis/common-apis/common.ts index 5f945b9..2f2cece 100644 --- a/apps/backend/src/trpc/apis/common-apis/common.ts +++ b/apps/backend/src/trpc/apis/common-apis/common.ts @@ -6,6 +6,7 @@ import { getAllSkusSummary as getAllSkusSummaryInDb, getAllTagsForCache, getAllTagProductMappings, + getAvailabilityForCache, } from '@/src/dbService' import { generateSignedUrlsFromS3Urls, generateSignedUrlFromS3Url, scaffoldAssetUrl } from '@/src/lib/s3-client' import { getAllProducts as getAllProductsFromCache } from '@/src/stores/product-store' @@ -45,18 +46,14 @@ export async function scaffoldProducts() { id: product.id, name: product.name, shortDescription: product.shortDescription, - price: parseFloat(product.price), - marketPrice: product.marketPrice ? parseFloat(product.marketPrice) : null, unit: product.unitNotation, unitNotation: product.unitNotation, incrementStep: product.incrementStep, productQuantity: product.productQuantity, storeId: product.store?.id || null, isOutOfStock: product.isOutOfStock, - isFlashAvailable: product.isFlashAvailable, nextDeliveryDate: nextDeliveryDate ? nextDeliveryDate.toISOString() : null, images: product.images, - flashPrice: product.flashPrice, productType: product.productType || 'item' }; }) @@ -93,6 +90,15 @@ export async function scaffoldProducts() { }; } +export async function scaffoldAvailability() { + const availability = await getAvailabilityForCache() + + return { + availability, + count: availability.length, + }; +} + export const commonRouter = router({ getDashboardTags: publicProcedure .query(async () => { diff --git a/apps/backend/src/trpc/apis/user-apis/apis/slots.ts b/apps/backend/src/trpc/apis/user-apis/apis/slots.ts index a938534..169f3e5 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/slots.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/slots.ts @@ -17,28 +17,16 @@ export async function scaffoldSlotsWithProducts(): Promise ({ - id: product.id, - name: product.name, - isOutOfStock: product.isOutOfStock, - isFlashAvailable: product.isFlashAvailable, - })); - */ - return { - slots: validSlots, + slots: validSlots.map((slot) => ({ + id: slot.id, + deliveryTime: slot.deliveryTime, + freezeTime: slot.freezeTime, + products: (slot.products || []).map((product) => ({ + id: product.id, + images: product.images, + })), + })), productAvailability, count: validSlots.length, }; diff --git a/apps/backend/src/trpc/router.ts b/apps/backend/src/trpc/router.ts index f7ccecc..24cd8cd 100644 --- a/apps/backend/src/trpc/router.ts +++ b/apps/backend/src/trpc/router.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { adminRouter } from '@/src/trpc/apis/admin-apis/apis/admin-trpc-index' import { userRouter } from '@/src/trpc/apis/user-apis/apis/user-trpc-index' import { commonApiRouter } from '@/src/trpc/apis/common-apis/common-trpc-index' -import { scaffoldProducts } from './apis/common-apis/common'; +import { scaffoldProducts, scaffoldAvailability } from './apis/common-apis/common'; import { scaffoldStores, scaffoldStoreWithProducts } from './apis/user-apis/apis/stores'; import { scaffoldSlotsWithProducts } from './apis/user-apis/apis/slots'; import { scaffoldEssentialConsts } from './apis/common-apis/common-trpc-index'; @@ -26,6 +26,7 @@ export const appRouter = router({ export type AppRouter = typeof appRouter; export type AllProductsApiType = Awaited>; +export type AvailabilityApiType = Awaited>; export type StoresApiType = Awaited>; export type SlotsApiType = Awaited>; export type EssentialConstsApiType = Awaited>; diff --git a/apps/backend/wrangler-commands.md b/apps/backend/wrangler-commands.md index 5844c56..14927e8 100644 --- a/apps/backend/wrangler-commands.md +++ b/apps/backend/wrangler-commands.md @@ -49,10 +49,16 @@ and paste it ABOVE the child table's block. Then verify it loads cleanly: sqlite3 :memory: "PRAGMA foreign_keys=ON; BEGIN; .read dumps/.sql; COMMIT;" -This should exit 0 with no error. (Example already applied to `latest_1.sql`: -`product_info` was moved above `product_skus`.) +This should exit 0 with no error. (Example already applied to `latest_1.sql` and +`local_8_aug.sql`: `product_info` was moved above `product_skus`.) ## When to re-check After ANY new `wrangler d1 export`, especially once a migration that re-creates tables has been applied. This is a general trap, not specific to one dump. + +> The SKU-split migration re-creates these tables in this historical order: +> `product_skus`, `sku_features`, `product_market_stats`, `product_info`, +> `cart_items`, `order_items`, `product_combos`. Exports put `product_info` +> AFTER its child `product_skus` — every fresh export needs `product_info` +> moved above `product_skus` (or the whole chain checked) before local import. diff --git a/apps/backend/wrangler.dev.toml b/apps/backend/wrangler.dev.toml index bd1f08b..1048178 100644 --- a/apps/backend/wrangler.dev.toml +++ b/apps/backend/wrangler.dev.toml @@ -9,7 +9,7 @@ routes = [ [[d1_databases]] binding = "DB" database_name = "freshyo-backend-dev" -database_id = "0814d709-5278-4311-8978-c36c0f05875d" +database_id = "a976d1fb-c6a7-4948-b303-81c6433ed265" #database_name = "freshyo-dev" #database_id = "3cad440f-dc14-4baa-ab05-04eb08e206de" migrations_dir="../../packages/db_helper_sqlite/drizzle" diff --git a/apps/user-ui/components/ProductDetail.tsx b/apps/user-ui/components/ProductDetail.tsx index 1fde5da..9f4d6a1 100644 --- a/apps/user-ui/components/ProductDetail.tsx +++ b/apps/user-ui/components/ProductDetail.tsx @@ -2,6 +2,7 @@ import React, { useState, useMemo } from 'react'; import { View, ScrollView, Alert, Dimensions, FlatList, RefreshControl } from 'react-native'; import { useRouter } from 'expo-router'; import { ImageCarousel, tw, BottomDialog, useManualRefresh, useMarkDataFetchers, LoadingDialog, ImageUploader, MyText, MyTextInput, MyTouchableOpacity, Quantifier } from 'common-ui'; +import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; import usePickImage from 'common-ui/src/components/use-pick-image'; import { theme } from 'common-ui/src/theme'; @@ -377,6 +378,54 @@ const ProductDetail: React.FC = ({ productId, isFlashDeliver + {/* Combo Items */} + {productDetail.productType === 'combo' && productDetail.comboItems && productDetail.comboItems.length > 0 && ( + + + + + + + Included Items + + + {productDetail.comboItems.map((comboItem, index) => ( + + + {comboItem.images?.[0] ? ( + + ) : ( + + )} + + + + + {comboItem.productName} + + {comboItem.isOffer && ( + + OFFER + + )} + + + {comboItem.unitNotation || comboItem.skuName || ''} + + + + ))} + + + )} + {/* Delivery Slots */} diff --git a/apps/user-ui/hooks/cart-query-hooks.tsx b/apps/user-ui/hooks/cart-query-hooks.tsx index 09b7003..ca8ac5e 100644 --- a/apps/user-ui/hooks/cart-query-hooks.tsx +++ b/apps/user-ui/hooks/cart-query-hooks.tsx @@ -1,4 +1,4 @@ -import { useAllProducts } from '@/src/hooks/prominent-api-hooks'; +import { useCentralProductStore } from '@/src/store/centralProductStore'; import { useCentralSlotStore } from '@/src/store/centralSlotStore'; import { Alert } from 'react-native'; import { useQuery, useMutation, useQueryClient, UseQueryResult, UseMutationResult } from '@tanstack/react-query'; @@ -184,7 +184,7 @@ const clearLocalCart = async (cartType: CartType = "regular"): Promise => }; export function useGetCart(options: UseGetCartOptions = {}, cartType: CartType = "regular"): UseGetCartReturn { - const { data: products } = useAllProducts(); + const productsById = useCentralProductStore((state) => state.productsById); const productSlotsMap = useCentralSlotStore((state) => state.productSlotsMap); const query: UseQueryResult = useQuery({ @@ -193,7 +193,7 @@ export function useGetCart(options: UseGetCartOptions = {}, cartType: CartType = const cartItems = await getLocalCart(cartType); const productMap: Record> = Object.fromEntries( - products?.products?.map((p) => [ + Object.values(productsById).map((p) => [ p.id, { id: p.id, @@ -206,7 +206,7 @@ export function useGetCart(options: UseGetCartOptions = {}, cartType: CartType = productQuantity: p.productQuantity, unitNotation: p.unitNotation, }, - ]) ?? [] + ]) ); const items: CartItem[] = cartItems @@ -236,7 +236,7 @@ export function useGetCart(options: UseGetCartOptions = {}, cartType: CartType = }; }, refetchOnWindowFocus: options?.refetchOnWindowFocus ?? true, - enabled: (options?.enabled ?? true) && !!products, + enabled: (options?.enabled ?? true) && Object.keys(productsById).length > 0, }); return { diff --git a/apps/user-ui/src/hooks/prominent-api-hooks.ts b/apps/user-ui/src/hooks/prominent-api-hooks.ts index b6c23f4..aada9f8 100644 --- a/apps/user-ui/src/hooks/prominent-api-hooks.ts +++ b/apps/user-ui/src/hooks/prominent-api-hooks.ts @@ -1,7 +1,8 @@ +import React from 'react' import { useQuery } from '@tanstack/react-query' import axios from 'axios' import { trpc } from '@/src/trpc-client' -import { AllProductsApiType, StoresApiType, SlotsApiType, EssentialConstsApiType, BannersApiType, StoreWithProductsApiType } from "@backend/trpc/router"; +import { AllProductsApiType, StoresApiType, SlotsApiType, EssentialConstsApiType, BannersApiType, StoreWithProductsApiType, AvailabilityApiType } from "@backend/trpc/router"; import { CACHE_FILENAMES } from "@packages/shared"; // Local useGetEssentialConsts hook @@ -18,6 +19,19 @@ type SlotsResponse = SlotsApiType; type EssentialConstsResponse = EssentialConstsApiType; type BannersResponse = BannersApiType; type StoreWithProductsResponse = StoreWithProductsApiType; +type AvailabilityResponse = AvailabilityApiType; + +type BaseProduct = AllProductsApiType['products'][number] +type AvailabilityEntry = AvailabilityApiType['availability'][number] + +export type MergedProduct = BaseProduct & { + price: number + marketPrice: number | null + flashPrice: string | null + isFlashAvailable: boolean + isOutOfStock: boolean + isSuspended: boolean +} function useCacheUrl(filename: string): string | null { const { data: essentialConsts } = useGetEssentialConsts() @@ -33,11 +47,37 @@ function useCacheUrl(filename: string): string | null { return `${assetsDomain}${apiCacheKey}/v-${cacheVersion}/${filename}` } +function useAvailabilityCacheUrl(): string | null { + const { data: essentialConsts } = useGetEssentialConsts() + + const assetsDomain = essentialConsts?.assetsDomain + const availabilityVersionNum = essentialConsts?.availabilityVersionNum + + if (!assetsDomain || availabilityVersionNum === undefined || availabilityVersionNum === null) { + return null + } + + return `${assetsDomain}av-${availabilityVersionNum}/${CACHE_FILENAMES.availability}` +} + +function useSlotsCacheUrl(): string | null { + const { data: essentialConsts } = useGetEssentialConsts() + + const assetsDomain = essentialConsts?.assetsDomain + const slotsVersionNum = essentialConsts?.slotsVersionNum + + if (!assetsDomain || slotsVersionNum === undefined || slotsVersionNum === null) { + return null + } + + return `${assetsDomain}slots/v-${slotsVersionNum}/${CACHE_FILENAMES.slots}` +} + export function useAllProducts() { const cacheUrl = useCacheUrl(CACHE_FILENAMES.products) + const { data: availabilityData } = useAvailability() - - return useQuery({ + const productsQuery = useQuery({ queryKey: ['all-products', cacheUrl], queryFn: async () => { if (!cacheUrl) { @@ -49,6 +89,57 @@ export function useAllProducts() { staleTime: 60000, // 1 minute enabled: !!cacheUrl, }) + + const mergedProducts = React.useMemo(() => { + const rawProducts = productsQuery.data?.products || [] + const availabilityById: Record = {} + availabilityData?.availability?.forEach((entry: AvailabilityEntry) => { + availabilityById[entry.id] = entry + }) + + return rawProducts.map((product) => { + const availability = availabilityById[product.id] + return { + ...product, + price: availability ? Number(availability.price) : 0, + marketPrice: availability?.marketPrice != null ? Number(availability.marketPrice) : null, + flashPrice: availability?.flashPrice ?? null, + isFlashAvailable: availability?.isFlashAvailable ?? false, + isOutOfStock: availability?.isOutOfStock ?? false, + isSuspended: availability?.isSuspended ?? false, + } + }) + }, [productsQuery.data, availabilityData]) + + const mergedData = React.useMemo(() => { + if (!productsQuery.data) return undefined + return { + ...productsQuery.data, + products: mergedProducts, + } as ProductsResponse & { products: MergedProduct[] } + }, [productsQuery.data, mergedProducts]) + + return { + ...productsQuery, + data: mergedData, + } +} + +export function useAvailability() { + const cacheUrl = useAvailabilityCacheUrl() + + return useQuery({ + queryKey: ['availability', cacheUrl], + queryFn: async () => { + if (!cacheUrl) { + throw new Error('Cache URL not available') + } + const response = await axios.get(cacheUrl) + return response.data + }, + staleTime: 60000, // 1 minute + enabled: !!cacheUrl, + }) } export function useStores() { @@ -69,7 +160,7 @@ export function useStores() { } export function useSlots() { - const cacheUrl = useCacheUrl(CACHE_FILENAMES.slots) + const cacheUrl = useSlotsCacheUrl() return useQuery({ queryKey: ['slots', cacheUrl], diff --git a/apps/user-ui/src/store/centralProductStore.ts b/apps/user-ui/src/store/centralProductStore.ts index a1b1b59..d228eda 100644 --- a/apps/user-ui/src/store/centralProductStore.ts +++ b/apps/user-ui/src/store/centralProductStore.ts @@ -1,9 +1,8 @@ import { create } from 'zustand' import { useEffect } from 'react' -import { useAllProducts } from '@/src/hooks/prominent-api-hooks' -import { AllProductsApiType } from '@backend/trpc/router' +import { useAllProducts, type MergedProduct } from '@/src/hooks/prominent-api-hooks' -type Product = AllProductsApiType['products'][number] +export type Product = MergedProduct interface CentralProductState { products: Product[] diff --git a/apps/user-ui/src/store/centralSlotStore.ts b/apps/user-ui/src/store/centralSlotStore.ts index 483c680..fa72172 100644 --- a/apps/user-ui/src/store/centralSlotStore.ts +++ b/apps/user-ui/src/store/centralSlotStore.ts @@ -1,22 +1,24 @@ import { create } from 'zustand'; -import { useSlots } from '@/src/hooks/prominent-api-hooks'; +import { useSlots, useAvailability } from '@/src/hooks/prominent-api-hooks'; import { useEffect } from 'react'; -import { SlotsApiType } from "@backend/trpc/router"; +import { SlotsApiType, AvailabilityApiType } from "@backend/trpc/router"; type Slot = SlotsApiType['slots'][number]; type ProductAvailability = SlotsApiType['productAvailability'][number]; +type AvailabilityEntry = AvailabilityApiType['availability'][number]; interface ProductSlotInfo { slots: Slot[]; isOutOfStock: boolean; isFlashAvailable: boolean; + isSuspended: boolean; } interface CentralSlotState { slots: Slot[]; productSlotsMap: Record; refetchSlots: (() => Promise) | null; - setSlotsData: (slots: Slot[], productAvailability: ProductAvailability[]) => void; + setSlotsData: (slots: Slot[], productAvailability: ProductAvailability[], availability: AvailabilityEntry[]) => void; clearSlotsData: () => void; setRefetchSlots: (refetch: () => Promise) => void; } @@ -25,15 +27,20 @@ export const useCentralSlotStore = create((set) => ({ slots: [], productSlotsMap: {}, refetchSlots: null, - setSlotsData: (slots, productAvailability) => { + setSlotsData: (slots, productAvailability, availability) => { const productSlotsMap: Record = {}; + const availabilityById: Record = {}; + availability.forEach((entry) => { + availabilityById[entry.id] = entry; + }); // First, create entries for ALL products from productAvailability productAvailability.forEach((product) => { productSlotsMap[product.id] = { slots: [], - isOutOfStock: product.isOutOfStock, - isFlashAvailable: product.isFlashAvailable, + isOutOfStock: availabilityById[product.id]?.isOutOfStock ?? false, + isFlashAvailable: availabilityById[product.id]?.isFlashAvailable ?? false, + isSuspended: availabilityById[product.id]?.isSuspended ?? false, }; }); @@ -54,14 +61,15 @@ export const useCentralSlotStore = create((set) => ({ export function useInitializeCentralSlotStore() { const { data: slotsData, refetch } = useSlots(); + const { data: availabilityData } = useAvailability(); const setSlotsData = useCentralSlotStore((state) => state.setSlotsData); const setRefetchSlots = useCentralSlotStore((state) => state.setRefetchSlots); useEffect(() => { if (slotsData?.slots) { - setSlotsData(slotsData.slots, slotsData.productAvailability || []); + setSlotsData(slotsData.slots, slotsData.productAvailability || [], availabilityData?.availability || []); } - }, [slotsData, setSlotsData]); + }, [slotsData, availabilityData, setSlotsData]); useEffect(() => { setRefetchSlots(async () => { diff --git a/apps/web-ui/src/components/AddToCartDialog.tsx b/apps/web-ui/src/components/AddToCartDialog.tsx index 6477f83..c1de297 100644 --- a/apps/web-ui/src/components/AddToCartDialog.tsx +++ b/apps/web-ui/src/components/AddToCartDialog.tsx @@ -4,6 +4,7 @@ import { BottomDialog, p, div, Quantifier } from 'web-components' import { useSlots } from '../hooks/prominent-api-hooks' import { useAddToCart, useUpdateCartItem, useRemoveFromCart, useGetCart } from '../hooks/cart-query-hooks' import { useCartStore } from '../lib/stores/cart-store' +import { useCentralSlotStore } from '../lib/stores/central-slot-store' import { ShoppingCart, Truck, Zap, X } from 'lucide-react' import dayjs from 'dayjs' @@ -29,6 +30,7 @@ export default function AddToCartDialog() { const { data: slotsData } = useSlots() const { data: cartData } = useGetCart() + const productSlotsMap = useCentralSlotStore((state) => state.productSlotsMap) const isFlashDeliveryEnabled = true const addToCart = useAddToCart('regular') @@ -76,7 +78,7 @@ export default function AddToCartDialog() { const cartItem = cartData?.items?.find((item: any) => item.productId === product?.id) const isUpdate = (cartItem?.quantity || 0) >= 1 - const productAvailability = slotsData?.productAvailability?.find((pa: any) => pa.id === product?.id) + const productAvailability = productSlotsMap[product?.id] const showFlashOption = productAvailability?.isFlashAvailable === true && isFlashDeliveryEnabled const handleAddToCart = () => { diff --git a/apps/web-ui/src/hooks/prominent-api-hooks.ts b/apps/web-ui/src/hooks/prominent-api-hooks.ts index 820ec54..fba16bb 100644 --- a/apps/web-ui/src/hooks/prominent-api-hooks.ts +++ b/apps/web-ui/src/hooks/prominent-api-hooks.ts @@ -1,8 +1,10 @@ +import { useMemo } from 'react' import { useQuery } from '@tanstack/react-query' import axios from 'axios' import { trpc } from '../lib/trpc-client' import type { AllProductsApiType, + AvailabilityApiType, StoresApiType, SlotsApiType, EssentialConstsApiType, @@ -23,6 +25,17 @@ type StoresResponse = StoresApiType type SlotsResponse = SlotsApiType type BannersResponse = BannersApiType type StoreWithProductsResponse = StoreWithProductsApiType +type AvailabilityResponse = AvailabilityApiType + +type BaseProduct = AllProductsApiType['products'][number] +type AvailabilityEntry = AvailabilityApiType['availability'][number] + +export type MergedProduct = BaseProduct & { + price: number + marketPrice: number | null + flashPrice: string | null + isFlashAvailable: boolean +} function useCacheUrl(filename: string): string | null { const { data: essentialConsts } = useGetEssentialConsts() @@ -43,10 +56,37 @@ function useCacheUrl(filename: string): string | null { return `${assetsDomain}${apiCacheKey}/v-${cacheVersion}/${filename}` } +function useAvailabilityCacheUrl(): string | null { + const { data: essentialConsts } = useGetEssentialConsts() + + const assetsDomain = essentialConsts?.assetsDomain + const availabilityVersionNum = essentialConsts?.availabilityVersionNum + + if (!assetsDomain || availabilityVersionNum === undefined || availabilityVersionNum === null) { + return null + } + + return `${assetsDomain}av-${availabilityVersionNum}/${CACHE_FILENAMES.availability}` +} + +function useSlotsCacheUrl(): string | null { + const { data: essentialConsts } = useGetEssentialConsts() + + const assetsDomain = essentialConsts?.assetsDomain + const slotsVersionNum = essentialConsts?.slotsVersionNum + + if (!assetsDomain || slotsVersionNum === undefined || slotsVersionNum === null) { + return null + } + + return `${assetsDomain}slots/v-${slotsVersionNum}/${CACHE_FILENAMES.slots}` +} + export function useAllProducts() { const cacheUrl = useCacheUrl(CACHE_FILENAMES.products) + const { data: availabilityData } = useAvailability() - return useQuery({ + const productsQuery = useQuery({ queryKey: ['all-products', cacheUrl], queryFn: async () => { if (!cacheUrl) { @@ -58,6 +98,55 @@ export function useAllProducts() { staleTime: 60000, enabled: !!cacheUrl, }) + + const mergedProducts = useMemo(() => { + const rawProducts = productsQuery.data?.products || [] + const availabilityById: Record = {} + availabilityData?.availability?.forEach((entry: AvailabilityEntry) => { + availabilityById[entry.id] = entry + }) + + return rawProducts.map((product) => { + const availability = availabilityById[product.id] + return { + ...product, + price: availability ? Number(availability.price) : 0, + marketPrice: availability?.marketPrice != null ? Number(availability.marketPrice) : null, + flashPrice: availability?.flashPrice ?? null, + isFlashAvailable: availability?.isFlashAvailable ?? false, + } + }) + }, [productsQuery.data, availabilityData]) + + const mergedData = useMemo(() => { + if (!productsQuery.data) return undefined + return { + ...productsQuery.data, + products: mergedProducts, + } as ProductsResponse & { products: MergedProduct[] } + }, [productsQuery.data, mergedProducts]) + + return { + ...productsQuery, + data: mergedData, + } +} + +export function useAvailability() { + const cacheUrl = useAvailabilityCacheUrl() + + return useQuery({ + queryKey: ['availability', cacheUrl], + queryFn: async () => { + if (!cacheUrl) { + throw new Error('Cache URL not available') + } + const response = await axios.get(cacheUrl) + return response.data + }, + staleTime: 60000, + enabled: !!cacheUrl, + }) } export function useStores() { @@ -78,7 +167,7 @@ export function useStores() { } export function useSlots() { - const cacheUrl = useCacheUrl(CACHE_FILENAMES.slots) + const cacheUrl = useSlotsCacheUrl() return useQuery({ queryKey: ['slots', cacheUrl], diff --git a/packages/db_helper_sqlite/drizzle/0002_sku_split.sql b/packages/db_helper_sqlite/drizzle/0002_sku_split.sql index 2d30e73..84ef397 100644 --- a/packages/db_helper_sqlite/drizzle/0002_sku_split.sql +++ b/packages/db_helper_sqlite/drizzle/0002_sku_split.sql @@ -9,13 +9,7 @@ CREATE TABLE `product_skus` ( `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, `product_id` integer NOT NULL, `name` text, - `price` text NOT NULL, - `market_price` text, `images` text, - `is_out_of_stock` integer DEFAULT false NOT NULL, - `is_suspended` integer DEFAULT false NOT NULL, - `is_flash_available` integer DEFAULT false NOT NULL, - `flash_price` text, `is_offer` integer DEFAULT false NOT NULL, `is_combo_only` integer DEFAULT false NOT NULL, `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, @@ -34,19 +28,12 @@ CREATE UNIQUE INDEX `unique_sku_feature_name` ON `sku_features` (`sku_id`,`featu -- 2. Migrate each existing product into one default SKU plus a mandatory quantity feature. INSERT INTO `product_skus` ( - `product_id`, `name`, `price`, `market_price`, `images`, `is_out_of_stock`, - `is_suspended`, `is_flash_available`, `flash_price`, `created_at` + `product_id`, `name`, `images`, `created_at` ) SELECT `id`, NULL, - `price`, - `market_price`, `images`, - `is_out_of_stock`, - `is_suspended`, - `is_flash_available`, - `flash_price`, `created_at` FROM `product_info`; @@ -59,6 +46,33 @@ FROM `product_info` `pi` JOIN `product_skus` `ps` ON `ps`.`product_id` = `pi`.`id` LEFT JOIN `units` `u` ON `u`.`id` = `pi`.`unit_id`; +-- 2b. Create product_market_stats to hold pricing/flash/stock per SKU, and backfill it. +CREATE TABLE `product_market_stats` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `sku_id` integer NOT NULL, + `market_price` text, + `our_price` text NOT NULL, + `is_flash_available` integer DEFAULT false NOT NULL, + `flash_price` text, + `is_out_of_stock` integer DEFAULT false NOT NULL, + `is_suspended` integer DEFAULT false NOT NULL, + FOREIGN KEY (`sku_id`) REFERENCES `product_skus`(`id`) ON UPDATE no action ON DELETE no action +); + +CREATE UNIQUE INDEX `product_market_stats_sku_id_unique` ON `product_market_stats` (`sku_id`); + +INSERT INTO `product_market_stats` (`sku_id`, `market_price`, `our_price`, `is_flash_available`, `flash_price`, `is_out_of_stock`, `is_suspended`) +SELECT + `ps`.`id`, + `pi`.`market_price`, + `pi`.`price`, + `pi`.`is_flash_available`, + `pi`.`flash_price`, + `pi`.`is_out_of_stock`, + `pi`.`is_suspended` +FROM `product_info` `pi` +JOIN `product_skus` `ps` ON `ps`.`product_id` = `pi`.`id`; + -- 3. Build a product_id -> sku_id mapping for downstream tables. CREATE TABLE `__product_to_sku` ( `product_id` integer PRIMARY KEY, diff --git a/packages/db_helper_sqlite/index.ts b/packages/db_helper_sqlite/index.ts index bc6aad0..813cb73 100644 --- a/packages/db_helper_sqlite/index.ts +++ b/packages/db_helper_sqlite/index.ts @@ -32,6 +32,10 @@ export { upsertConstants, getCacheVersion, incrementCacheVersion, + getAvailabilityVersionNum, + incrementAvailabilityVersionNum, + getSlotsVersionNum, + incrementSlotsVersionNum, } from './src/admin-apis/const' export { @@ -314,12 +318,14 @@ export { type BannerData, // Product Store getAllProductsForCache, + getAvailabilityForCache, getAllStoresForCache, getAllDeliverySlotsForCache, getAllSpecialDealsForCache, getAllProductTagsForCache, getAllProductCombosForCache, type ProductBasicData, + type AvailabilityCacheData, type StoreBasicData, type DeliverySlotData, type SpecialDealData, diff --git a/packages/db_helper_sqlite/src/admin-apis/const.ts b/packages/db_helper_sqlite/src/admin-apis/const.ts index 6ffd507..194a34a 100644 --- a/packages/db_helper_sqlite/src/admin-apis/const.ts +++ b/packages/db_helper_sqlite/src/admin-apis/const.ts @@ -76,3 +76,69 @@ export async function incrementCacheVersion(): Promise { return nextValue }) } + +const AVAILABILITY_VERSION_KEY = CONST_KEYS.availabilityVersionNum + +export async function getAvailabilityVersionNum(): Promise { + const record = await db.query.keyValStore.findFirst({ + where: eq(keyValStore.key, AVAILABILITY_VERSION_KEY), + columns: { value: true }, + }) + + return record ? parseCacheVersion(record.value) : 0 +} + +export async function incrementAvailabilityVersionNum(): Promise { + return db.transaction(async (tx) => { + const existing = await tx.query.keyValStore.findFirst({ + where: eq(keyValStore.key, AVAILABILITY_VERSION_KEY), + columns: { value: true }, + }) + + const nextValue = parseCacheVersion(existing?.value) + 1 + + if (existing) { + await tx.update(keyValStore) + .set({ value: nextValue +'' }) + .where(eq(keyValStore.key, AVAILABILITY_VERSION_KEY)) + } else { + await tx.insert(keyValStore) + .values({ key: AVAILABILITY_VERSION_KEY, value: nextValue+'' }) + } + + return nextValue + }) +} + +const SLOTS_VERSION_KEY = CONST_KEYS.slotsVersionNum + +export async function getSlotsVersionNum(): Promise { + const record = await db.query.keyValStore.findFirst({ + where: eq(keyValStore.key, SLOTS_VERSION_KEY), + columns: { value: true }, + }) + + return record ? parseCacheVersion(record.value) : 0 +} + +export async function incrementSlotsVersionNum(): Promise { + return db.transaction(async (tx) => { + const existing = await tx.query.keyValStore.findFirst({ + where: eq(keyValStore.key, SLOTS_VERSION_KEY), + columns: { value: true }, + }) + + const nextValue = parseCacheVersion(existing?.value) + 1 + + if (existing) { + await tx.update(keyValStore) + .set({ value: nextValue +'' }) + .where(eq(keyValStore.key, SLOTS_VERSION_KEY)) + } else { + await tx.insert(keyValStore) + .values({ key: SLOTS_VERSION_KEY, value: nextValue+'' }) + } + + return nextValue + }) +} diff --git a/packages/db_helper_sqlite/src/admin-apis/product.ts b/packages/db_helper_sqlite/src/admin-apis/product.ts index 6d75c13..fa833f9 100644 --- a/packages/db_helper_sqlite/src/admin-apis/product.ts +++ b/packages/db_helper_sqlite/src/admin-apis/product.ts @@ -1,7 +1,7 @@ -// @ts-nocheck -- temporary: file partially updated for SKU migration; remaining functions to be fixed later import { db } from '../db/db_index' import { productInfo, + productMarketStats, productSkus, skuFeatures, productCombos, @@ -45,7 +45,42 @@ import type { type ProductRow = InferSelectModel type SkuRow = InferSelectModel +type MarketStatsRow = InferSelectModel type SkuFeatureRow = InferSelectModel + +interface CreateSkuFeatureInput { + featureName?: string | null + featureValue: string +} + +interface CreateComboItemInput { + skuId: number +} + +interface CreateSkuInput { + name?: string | null + price: number + marketPrice?: number | null + images?: string[] | null + isFlashAvailable?: boolean + flashPrice?: number | null + isOutOfStock?: boolean + isSuspended?: boolean + isOffer?: boolean + isComboOnly?: boolean + features: CreateSkuFeatureInput[] + comboItems?: CreateComboItemInput[] +} + +interface CreateProductInput { + name: string + shortDescription?: string | null + longDescription?: string | null + storeId?: number | null + incrementStep?: number + productType?: 'item' | 'combo' + skus: CreateSkuInput[] +} type UnitRow = InferSelectModel type StoreRow = InferSelectModel type SpecialDealRow = InferSelectModel @@ -94,18 +129,23 @@ const mapSkuFeature = (feature: SkuFeatureRow): AdminSkuFeature => ({ featureValue: feature.featureValue, }) -const mapSku = (sku: SkuRow, features: SkuFeatureRow[] = [], comboItems: any[] = []): AdminSku => ({ +const mapSku = ( + sku: SkuRow, + features: SkuFeatureRow[] = [], + comboItems: any[] = [], + marketStats: MarketStatsRow | null = null +): AdminSku => ({ id: sku.id, productId: sku.productId, name: sku.name ?? null, - price: String(sku.price ?? '0'), - marketPrice: sku.marketPrice ? String(sku.marketPrice) : null, + price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0', + marketPrice: marketStats?.marketPrice ? String(marketStats.marketPrice) : null, images: getStringArray(sku.images), imageKeys: getStringArray(sku.images), - isOutOfStock: sku.isOutOfStock, - isSuspended: sku.isSuspended, - isFlashAvailable: sku.isFlashAvailable, - flashPrice: sku.flashPrice ? String(sku.flashPrice) : null, + isOutOfStock: marketStats?.isOutOfStock ?? false, + isSuspended: marketStats?.isSuspended ?? false, + isFlashAvailable: marketStats?.isFlashAvailable ?? false, + flashPrice: marketStats?.flashPrice ? String(marketStats.flashPrice) : null, isOffer: sku.isOffer, isComboOnly: sku.isComboOnly, createdAt: sku.createdAt, @@ -134,7 +174,7 @@ const mapTagInfo = (tag: ProductTagInfoRow): AdminProductTagInfo => ({ export async function getAllProducts(): Promise { type ProductWithRelationsRow = ProductRow & { store: StoreRow | null - skus: Array + skus: Array } const products = await db.query.productInfo.findMany({ orderBy: productInfo.name, @@ -143,9 +183,10 @@ export async function getAllProducts(): Promise { skus: { with: { features: true, + marketStats: true, comboItems: { with: { - sku: { with: { product: true, features: true } }, + sku: { with: { product: true, features: true, marketStats: true } }, }, }, }, @@ -163,9 +204,9 @@ export async function getAllProducts(): Promise { features: (ci.sku?.features || []).map((f: any) => ({ featureName: f.featureName, featureValue: f.featureValue })), productName: ci.sku?.product?.name ?? 'Unknown', images: getStringArray(ci.sku?.images), - price: String(ci.sku?.price ?? '0'), + price: ci.sku?.marketStats?.ourPrice ? String(ci.sku.marketStats.ourPrice) : '0', })) - return mapSku(sku, sku.features, comboItems) + return mapSku(sku, sku.features, comboItems, sku.marketStats) }), })) } @@ -178,9 +219,10 @@ export async function getProductById(id: number): Promise ({ skuId: ci.skuId, skuName: ci.sku?.name ?? null, - features: (ci.sku?.features || []).map((f) => ({ featureName: f.featureName, featureValue: f.featureValue })), + features: (ci.sku?.features || []).map((f: any) => ({ featureName: f.featureName, featureValue: f.featureValue })), productName: ci.sku?.product?.name ?? 'Unknown', images: getStringArray(ci.sku?.images), - price: String(ci.sku?.price ?? '0'), + price: ci.sku?.marketStats?.ourPrice ? String(ci.sku.marketStats.ourPrice) : '0', })) - return mapSku(sku, sku.features, comboItems) + return mapSku(sku, sku.features, comboItems, sku.marketStats) }) return { @@ -272,20 +314,26 @@ export async function createProduct(input: CreateProductInput): Promise ({ productId: product.id, name: sku.name ?? null, - price: String(sku.price), - marketPrice: sku.marketPrice != null ? String(sku.marketPrice) : null, images: sku.images ?? null, - isFlashAvailable: sku.isFlashAvailable ?? false, - flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null, isOffer: sku.isOffer ?? false, isComboOnly: sku.isComboOnly ?? false, - isSuspended: sku.isSuspended ?? false, })) ).returning() for (let i = 0; i < skuRows.length; i++) { const skuRow = skuRows[i] const sku = skus[i] + + await db.insert(productMarketStats).values({ + skuId: skuRow.id, + marketPrice: sku.marketPrice != null ? String(sku.marketPrice) : null, + ourPrice: sku.price != null ? String(sku.price) : '0', + isFlashAvailable: sku.isFlashAvailable ?? false, + flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null, + isOutOfStock: sku.isOutOfStock ?? false, + isSuspended: sku.isSuspended ?? false, + }) + await db.insert(skuFeatures).values( sku.features.map((f) => ({ skuId: skuRow.id, @@ -306,13 +354,13 @@ export async function createProduct(input: CreateProductInput): Promise mapSku(s, s.features)), + skus: createdSkus.map((s) => mapSku(s, s.features, [], s.marketStats)), } } @@ -368,11 +416,11 @@ export async function updateProduct(id: number, input: any): Promise c.comboSkuId))) if (comboIds.length > 0) { - const combos = await db.query.productSkus.findMany({ - where: inArray(productSkus.id, comboIds), - columns: { id: true, isSuspended: true }, + const combos = await db.query.productMarketStats.findMany({ + where: inArray(productMarketStats.skuId, comboIds), + columns: { skuId: true, isSuspended: true }, }) - const activeComboIds = combos.filter((c) => !c.isSuspended).map((c) => c.id) + const activeComboIds = combos.filter((c) => !c.isSuspended).map((c) => c.skuId) if (activeComboIds.length > 0) { throw new Error( `Cannot suspend SKU(s): they are members of combo(s) ${activeComboIds.join(', ')} that are not suspended` @@ -387,17 +435,35 @@ export async function updateProduct(id: number, input: any): Promise ({ @@ -421,16 +487,21 @@ export async function updateProduct(id: number, input: any): Promise ({ skuId: newSku.id, @@ -447,7 +518,7 @@ export async function updateProduct(id: number, input: any): Promise mapSku(s, s.features)), + skus: updatedProduct.skus.map((s) => mapSku(s, s.features, [], s.marketStats)), } } @@ -898,15 +969,32 @@ export async function updateProductPrices(updates: Array<{ const { productId, price, marketPrice, flashPrice, isFlashAvailable } = update const updateData: any = {} - if (price !== undefined) updateData.price = price.toString() + if (price !== undefined) updateData.ourPrice = price.toString() if (marketPrice !== undefined) updateData.marketPrice = marketPrice === null ? null : marketPrice.toString() if (flashPrice !== undefined) updateData.flashPrice = flashPrice === null ? null : flashPrice.toString() if (isFlashAvailable !== undefined) updateData.isFlashAvailable = isFlashAvailable - await tx - .update(productSkus) - .set(updateData) - .where(eq(productSkus.id, productId)) + if (Object.keys(updateData).length === 0) continue + + const existingMarketStats = await tx.query.productMarketStats.findFirst({ + where: eq(productMarketStats.skuId, productId), + columns: { id: true }, + }) + + if (existingMarketStats) { + await tx + .update(productMarketStats) + .set(updateData) + .where(eq(productMarketStats.skuId, productId)) + } else { + await tx.insert(productMarketStats).values({ + skuId: productId, + ourPrice: updateData.ourPrice ?? '0', + marketPrice: updateData.marketPrice ?? null, + flashPrice: updateData.flashPrice ?? null, + isFlashAvailable: updateData.isFlashAvailable ?? false, + }) + } } }) @@ -956,7 +1044,7 @@ export interface CreateSpecialDealInput { } export async function createSpecialDealsForSku( - productId: number, + skuId: number, deals: CreateSpecialDealInput[] ): Promise { if (deals.length === 0) { @@ -964,7 +1052,7 @@ export async function createSpecialDealsForSku( } const dealInserts = deals.map((deal) => ({ - productId, + skuId, quantity: deal.quantity.toString(), price: deal.price.toString(), validTill: new Date(deal.validTill), @@ -1025,7 +1113,7 @@ export async function updateSkuDeals( if (dealsToAdd.length > 0) { const dealInserts = dealsToAdd.map((deal) => ({ - productId, + skuId: productId, quantity: deal.quantity.toString(), price: deal.price.toString(), validTill: new Date(deal.validTill), diff --git a/packages/db_helper_sqlite/src/db/schema.ts b/packages/db_helper_sqlite/src/db/schema.ts index eeb6c1e..a767840 100644 --- a/packages/db_helper_sqlite/src/db/schema.ts +++ b/packages/db_helper_sqlite/src/db/schema.ts @@ -201,18 +201,23 @@ export const productSkus = sqliteTable('product_skus', { id: integer().primaryKey({ autoIncrement: true }), productId: integer('product_id').notNull().references(() => productInfo.id), name: text(), - price: numericText('price').notNull(), - marketPrice: numericText('market_price'), images: jsonText('images'), - isOutOfStock: integer('is_out_of_stock', { mode: 'boolean' }).notNull().default(false), - isSuspended: integer('is_suspended', { mode: 'boolean' }).notNull().default(false), - isFlashAvailable: integer('is_flash_available', { mode: 'boolean' }).notNull().default(false), - flashPrice: numericText('flash_price'), isOffer: integer('is_offer', { mode: 'boolean' }).notNull().default(false), isComboOnly: integer('is_combo_only', { mode: 'boolean' }).notNull().default(false), createdAt: timestampText('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), }) +export const productMarketStats = sqliteTable('product_market_stats', { + id: integer().primaryKey({ autoIncrement: true }), + skuId: integer('sku_id').notNull().references(() => productSkus.id).unique(), + marketPrice: numericText('market_price'), + ourPrice: numericText('our_price').notNull(), + isFlashAvailable: integer('is_flash_available', { mode: 'boolean' }).notNull().default(false), + flashPrice: numericText('flash_price'), + isOutOfStock: integer('is_out_of_stock', { mode: 'boolean' }).notNull().default(false), + isSuspended: integer('is_suspended', { mode: 'boolean' }).notNull().default(false), +}) + export const skuFeatures = sqliteTable('sku_features', { id: integer().primaryKey({ autoIncrement: true }), skuId: integer('sku_id').notNull().references(() => productSkus.id), @@ -590,6 +595,7 @@ export const productInfoRelations = relations(productInfo, ({ one, many }) => ({ export const productSkusRelations = relations(productSkus, ({ one, many }) => ({ product: one(productInfo, { fields: [productSkus.productId], references: [productInfo.id] }), features: many(skuFeatures), + marketStats: one(productMarketStats), specialDeals: many(specialDeals), orderItems: many(orderItems), cartItems: many(cartItems), @@ -597,6 +603,10 @@ export const productSkusRelations = relations(productSkus, ({ one, many }) => ({ comboItems: many(productCombos, { relationName: 'comboSku' }), })) +export const productMarketStatsRelations = relations(productMarketStats, ({ one }) => ({ + sku: one(productSkus, { fields: [productMarketStats.skuId], references: [productSkus.id] }), +})) + export const skuFeaturesRelations = relations(skuFeatures, ({ one }) => ({ sku: one(productSkus, { fields: [skuFeatures.skuId], references: [productSkus.id] }), })) diff --git a/packages/db_helper_sqlite/src/lib/const-keys.ts b/packages/db_helper_sqlite/src/lib/const-keys.ts index 60e81e2..2c61b25 100644 --- a/packages/db_helper_sqlite/src/lib/const-keys.ts +++ b/packages/db_helper_sqlite/src/lib/const-keys.ts @@ -13,6 +13,8 @@ export const CONST_KEYS = { readableOrderId: 'readableOrderId', versionNum: 'versionNum', cacheVersion: 'cache_version', + availabilityVersionNum: 'availability_version_num', + slotsVersionNum: 'slots_version_num', playStoreUrl: 'playStoreUrl', appStoreUrl: 'appStoreUrl', popularItems: 'popularItems', @@ -37,6 +39,8 @@ export const CONST_LABELS: Record = { readableOrderId: 'Readable Order ID', versionNum: 'Version Number', 'cache_version': 'Cache Version', + availability_version_num: 'Availability Cache Version', + slots_version_num: 'Slots Cache Version', playStoreUrl: 'Play Store URL', appStoreUrl: 'App Store URL', popularItems: 'Popular Items', @@ -67,6 +71,8 @@ export const CONST_TYPES: Record = { readableOrderId: 'number', versionNum: 'string', 'cache_version': 'number', + availability_version_num: 'number', + slots_version_num: 'number', playStoreUrl: 'string', appStoreUrl: 'string', popularItems: 'string', @@ -91,6 +97,8 @@ export const CONST_VISIBILITY: Record = { readableOrderId: false, versionNum: true, 'cache_version': false, + availability_version_num: false, + slots_version_num: false, playStoreUrl: true, appStoreUrl: true, popularItems: true, diff --git a/packages/db_helper_sqlite/src/stores/store-helpers.ts b/packages/db_helper_sqlite/src/stores/store-helpers.ts index 1404b17..03758b6 100644 --- a/packages/db_helper_sqlite/src/stores/store-helpers.ts +++ b/packages/db_helper_sqlite/src/stores/store-helpers.ts @@ -5,6 +5,7 @@ import { db } from '../db/db_index' import { homeBanners, productInfo, + productMarketStats, productSkus, skuFeatures, deliverySlotInfo, @@ -61,6 +62,16 @@ export interface ProductBasicData { productType: string } +export interface AvailabilityCacheData { + id: number + price: string + marketPrice: string | null + flashPrice: string | null + isFlashAvailable: boolean + isOutOfStock: boolean + isSuspended: boolean +} + export interface StoreBasicData { id: number name: string @@ -107,15 +118,18 @@ export interface ProductTagData { export async function getAllProductsForCache(): Promise { const skus = await db.query.productSkus.findMany({ - where: eq(productSkus.isSuspended, false), with: { product: true, features: true, + marketStats: true, }, }) - return skus.map((sku) => { + return skus + .filter((sku) => !sku.marketStats?.isSuspended) + .map((sku) => { const features = sku.features || [] + const marketStats = sku.marketStats return { id: sku.id, productId: sku.productId, @@ -123,21 +137,37 @@ export async function getAllProductsForCache(): Promise { skuName: sku.name ?? null, shortDescription: sku.product?.shortDescription ?? null, longDescription: sku.product?.longDescription ?? null, - price: String(sku.price ?? '0'), - marketPrice: sku.marketPrice ? String(sku.marketPrice) : null, + price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0', + marketPrice: marketStats?.marketPrice ? String(marketStats.marketPrice) : null, images: sku.images, - isOutOfStock: sku.isOutOfStock, + isOutOfStock: marketStats?.isOutOfStock ?? false, storeId: sku.product?.storeId ?? null, unitNotation: composeUnitNotation(features), incrementStep: sku.product?.incrementStep ?? 1, productQuantity: 1, - isFlashAvailable: sku.isFlashAvailable, - flashPrice: sku.flashPrice ? String(sku.flashPrice) : null, + isFlashAvailable: marketStats?.isFlashAvailable ?? false, + flashPrice: marketStats?.flashPrice ? String(marketStats.flashPrice) : null, productType: sku.product?.productType ?? 'item', } }) } +export async function getAvailabilityForCache(): Promise { + const stats = await db.query.productMarketStats.findMany({}) + + return stats + .filter((stat) => !stat.isSuspended) + .map((stat) => ({ + id: stat.skuId, + price: stat.ourPrice ? String(stat.ourPrice) : '0', + marketPrice: stat.marketPrice ? String(stat.marketPrice) : null, + flashPrice: stat.flashPrice ? String(stat.flashPrice) : null, + isFlashAvailable: stat.isFlashAvailable, + isOutOfStock: stat.isOutOfStock, + isSuspended: stat.isSuspended, + })) +} + export async function getAllStoresForCache(): Promise { return db.query.storeInfo.findMany({ columns: { id: true, name: true, description: true }, @@ -192,20 +222,21 @@ export interface ProductComboCacheData { images: unknown unitNotation: string price: string + isOffer: boolean } export async function getAllProductCombosForCache(): Promise { const results = await db.query.productCombos.findMany({ with: { - sku: { with: { product: true, features: true } }, + sku: { with: { product: true, features: true, marketStats: true } }, }, }) const suspendedSkuIds = new Set( (await db - .select({ id: productSkus.id }) - .from(productSkus) - .where(eq(productSkus.isSuspended, true))).map((r) => r.id) + .select({ id: productMarketStats.skuId }) + .from(productMarketStats) + .where(eq(productMarketStats.isSuspended, true))).map((r) => r.id) ) return results @@ -219,7 +250,8 @@ export async function getAllProductCombosForCache(): Promise 0) { skusData = await db.query.productSkus.findMany({ - where: eq(productSkus.isSuspended, false), with: { product: { with: { store: true }, }, features: true, + marketStats: true, }, }) - skusData = skusData.filter((item: any) => skuIdSet.has(item.id)) + skusData = skusData.filter((item: any) => skuIdSet.has(item.id) && !item.marketStats?.isSuspended) } const skuMap = new Map(skusData.map((s: any) => [s.id, s])) @@ -341,6 +373,7 @@ export async function getAllSlotsWithProductsForCache(): Promise => p != null) .map((sku: any) => { const features = sku.features || [] + const marketStats = sku.marketStats return { id: sku.id, productId: sku.productId, @@ -348,8 +381,8 @@ export async function getAllSlotsWithProductsForCache(): Promise { export async function getProductDetailById(skuId: number): Promise { const sku = await db.query.productSkus.findFirst({ - where: and(eq(productSkus.id, skuId), eq(productSkus.isSuspended, false)), + where: eq(productSkus.id, skuId), with: { product: true, features: true, + marketStats: true, }, }) if (!sku) { return null } + if (sku.marketStats?.isSuspended) { + return null + } const features = sku.features || [] const product = sku.product + const marketStats = sku.marketStats const storeData = product?.storeId ? await db.query.storeInfo.findFirst({ where: eq(storeInfo.id, product.storeId), @@ -48,7 +53,7 @@ export async function getProductDetailById(skuId: number): Promise ({ quantity: String(deal.quantity ?? '0'), @@ -202,30 +208,32 @@ export async function getAllProductsWithUnits(tagId?: number): Promise { + if (sku.marketStats?.isSuspended) return false if (!tagId) return true return taggedProductIdSet.has(sku.productId) }) .map((sku) => { const features = sku.features || [] + const marketStats = sku.marketStats return { id: sku.product?.id ?? 0, name: composeSkuName(sku.product?.name ?? 'Unknown', features), skuId: sku.id, skuName: sku.name ?? null, shortDescription: sku.product?.shortDescription ?? null, - price: String(sku.price ?? '0'), - marketPrice: sku.marketPrice ? String(sku.marketPrice) : null, + price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0', + marketPrice: marketStats?.marketPrice ? String(marketStats.marketPrice) : null, images: sku.images, - isOutOfStock: sku.isOutOfStock, + isOutOfStock: marketStats?.isOutOfStock ?? false, unitShortNotation: composeUnitNotation(features), productQuantity: 1, features: features.map((f) => ({ featureName: f.featureName, featureValue: f.featureValue })), @@ -238,9 +246,9 @@ export async function getAllProductsWithUnits(tagId?: number): Promise { const suspendedSkus = await db - .select({ id: productSkus.id }) - .from(productSkus) - .where(eq(productSkus.isSuspended, true)) + .select({ id: productMarketStats.skuId }) + .from(productMarketStats) + .where(eq(productMarketStats.isSuspended, true)) return suspendedSkus.map(sp => sp.id) } @@ -279,16 +287,18 @@ export interface SkuSummary { export async function getAllSkusSummary(): Promise { const skus = await db.query.productSkus.findMany({ - where: eq(productSkus.isSuspended, false), with: { features: true, + marketStats: true, product: { columns: { name: true }, }, }, }) - return skus.map((sku) => { + return skus + .filter((sku) => !sku.marketStats?.isSuspended) + .map((sku) => { const featureValues = (sku.features || []).map((f) => f.featureValue) const label = [sku.product?.name ?? 'Unknown', ...featureValues].join(' ') return { @@ -319,10 +329,12 @@ export interface OffersPageData { const mapOffersPageProduct = (sku: { id: number - price: string | null - marketPrice: string | null + marketStats: { + ourPrice: string | null + marketPrice: string | null + isOutOfStock: boolean + } | null images: unknown - isOutOfStock: boolean product: { name: string; incrementStep: number | null } | null features: Array<{ featureValue: string }> }): OffersPageProductData => { @@ -330,21 +342,21 @@ const mapOffersPageProduct = (sku: { return { id: sku.id, name: composeSkuName(sku.product?.name ?? 'Unknown', features), - price: String(sku.price ?? '0'), - marketPrice: sku.marketPrice ? String(sku.marketPrice) : null, + price: sku.marketStats?.ourPrice ? String(sku.marketStats.ourPrice) : '0', + marketPrice: sku.marketStats?.marketPrice ? String(sku.marketStats.marketPrice) : null, unitNotation: composeUnitNotation(features), images: sku.images, - isOutOfStock: sku.isOutOfStock, + isOutOfStock: sku.marketStats?.isOutOfStock ?? false, incrementStep: sku.product?.incrementStep ?? 1, } } export async function getOffersAndCombos(): Promise { const skus = await db.query.productSkus.findMany({ - where: eq(productSkus.isSuspended, false), with: { product: true, features: true, + marketStats: true, }, }) @@ -352,6 +364,7 @@ export async function getOffersAndCombos(): Promise { const offers: OffersPageProductData[] = [] for (const sku of skus) { + if (sku.marketStats?.isSuspended) continue if (sku.product?.productType === 'combo') { combos.push(mapOffersPageProduct(sku)) } diff --git a/packages/db_helper_sqlite/src/user-apis/slots.ts b/packages/db_helper_sqlite/src/user-apis/slots.ts index 8ad89ce..f631ffd 100644 --- a/packages/db_helper_sqlite/src/user-apis/slots.ts +++ b/packages/db_helper_sqlite/src/user-apis/slots.ts @@ -1,5 +1,5 @@ import { db } from '../db/db_index' -import { deliverySlotInfo, productSkus } from '../db/schema' +import { deliverySlotInfo, productMarketStats } from '../db/schema' import { asc, eq } from 'drizzle-orm' import type { InferSelectModel } from 'drizzle-orm' import type { UserDeliverySlot, UserSlotAvailability } from '@packages/shared' @@ -27,17 +27,16 @@ export async function getActiveSlotsList(): Promise { } export async function getProductAvailability(): Promise { - const skus = await db.query.productSkus.findMany({ - where: eq(productSkus.isSuspended, false), - with: { - product: { columns: { name: true } }, + const stats = await db.query.productMarketStats.findMany({ + where: eq(productMarketStats.isSuspended, false), + columns: { + skuId: true, + isOutOfStock: true, }, }) - return skus.map((sku) => ({ - id: sku.id, - name: sku.product?.name ?? 'Unknown', - isOutOfStock: sku.isOutOfStock, - isFlashAvailable: sku.isFlashAvailable, + return stats.map((stat) => ({ + id: stat.skuId, + isOutOfStock: stat.isOutOfStock, })) } diff --git a/packages/db_helper_sqlite/src/user-apis/stores.ts b/packages/db_helper_sqlite/src/user-apis/stores.ts index dd9019a..b31680d 100644 --- a/packages/db_helper_sqlite/src/user-apis/stores.ts +++ b/packages/db_helper_sqlite/src/user-apis/stores.ts @@ -21,13 +21,13 @@ export async function getStoreSummaries(): Promise { }).from(storeInfo) const skus = await db.query.productSkus.findMany({ - where: eq(productSkus.isSuspended, false), - with: { product: true }, + with: { product: true, marketStats: true }, orderBy: asc(productSkus.id), }) + const activeSkus = skus.filter((sku) => !sku.marketStats?.isSuspended) const skusByStore = new Map() - for (const sku of skus) { + for (const sku of activeSkus) { const storeId = sku.product?.storeId if (storeId == null) continue if (!skusByStore.has(storeId)) skusByStore.set(storeId, []) @@ -77,30 +77,31 @@ export async function getStoreDetail(storeId: number): Promise 0 ? await db.query.productSkus.findMany({ - where: and( - inArray(productSkus.productId, productIdArr), - eq(productSkus.isSuspended, false) - ), + where: inArray(productSkus.productId, productIdArr), with: { product: true, features: true, + marketStats: true, }, }) : [] - const products: UserStoreProductData[] = skus.map((sku) => { + const products: UserStoreProductData[] = skus + .filter((sku) => !sku.marketStats?.isSuspended) + .map((sku) => { const features = sku.features || [] + const marketStats = sku.marketStats return { id: sku.id, name: composeSkuName(sku.product?.name ?? 'Unknown', features), shortDescription: sku.product?.shortDescription ?? null, - price: String(sku.price ?? '0'), - marketPrice: sku.marketPrice ? String(sku.marketPrice) : null, + price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0', + marketPrice: marketStats?.marketPrice ? String(marketStats.marketPrice) : null, incrementStep: sku.product?.incrementStep ?? 1, unit: composeUnitNotation(features), unitNotation: composeUnitNotation(features), images: getStringArray(sku.images), - isOutOfStock: sku.isOutOfStock, + isOutOfStock: marketStats?.isOutOfStock ?? false, productQuantity: 1, } }) diff --git a/packages/shared/index.ts b/packages/shared/index.ts index a502ff8..79d2335 100644 --- a/packages/shared/index.ts +++ b/packages/shared/index.ts @@ -2,6 +2,7 @@ export const CACHE_FILENAMES = { products: 'products.json', stores: 'stores.json', slots: 'slots.json', + availability: 'availability.json', essentialConsts: 'essential-consts.json', banners: 'banners.json', } as const diff --git a/packages/shared/types/user.ts b/packages/shared/types/user.ts index 1efc02b..e98bd3f 100644 --- a/packages/shared/types/user.ts +++ b/packages/shared/types/user.ts @@ -267,6 +267,7 @@ export interface UserProductComboItem { productName: string; images: string[] | null; price: string; + isOffer: boolean; } export interface UserProductDetailData { @@ -320,32 +321,34 @@ export interface UserCreateReviewResponse { export interface UserSlotProduct { id: number; - name: string; - shortDescription: string | null; - productQuantity: number; - price: string; - marketPrice: string | null; - unit: string | null; - images: string[]; - isOutOfStock: boolean; - storeId: number | null; - nextDeliveryDate: Date; + images: string[] | null; } export interface UserSlotWithProducts { id: number; deliveryTime: Date; freezeTime: Date; - isActive: boolean; - isCapacityFull: boolean; products: UserSlotProduct[]; } export interface UserSlotAvailability { id: number; - name: string; isOutOfStock: boolean; +} + +export interface UserAvailabilityEntry { + id: number; + price: string; + marketPrice: string | null; + flashPrice: string | null; isFlashAvailable: boolean; + isOutOfStock: boolean; + isSuspended: boolean; +} + +export interface UserAvailabilityResponse { + availability: UserAvailabilityEntry[]; + count: number; } export interface UserDeliverySlot { From 6a0bc18a8d4671d22f9a75f8e4fd4e1606832f52 Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:59:45 +0530 Subject: [PATCH 21/73] enh --- .commandcode/settings.json | 5 +- .../app/(drawer)/product-tags/_layout.tsx | 1 + .../app/(drawer)/product-tags/add.tsx | 3 + .../app/(drawer)/product-tags/edit/index.tsx | 7 + .../app/(drawer)/product-tags/index.tsx | 17 +- .../app/(drawer)/product-tags/order.tsx | 369 ++++++++++++++++ apps/admin-ui/src/components/TagForm.tsx | 214 +++++++++- apps/backend/src/lib/const-keys.ts | 4 + apps/backend/src/sqliteImporter.ts | 1 + .../src/trpc/apis/admin-apis/apis/product.ts | 24 +- .../src/trpc/apis/common-apis/common.ts | 39 +- apps/backend/wrangler.dev.toml | 2 +- .../app/(drawer)/(tabs)/home/index.tsx | 402 ++++++++++-------- .../drizzle/0002_sku_split.sql | 3 + packages/db_helper_sqlite/index.ts | 1 + .../src/admin-apis/product.ts | 24 ++ packages/db_helper_sqlite/src/db/schema.ts | 1 + .../src/stores/store-helpers.ts | 2 + packages/shared/types/admin.ts | 2 + 19 files changed, 909 insertions(+), 212 deletions(-) create mode 100644 apps/admin-ui/app/(drawer)/product-tags/order.tsx diff --git a/.commandcode/settings.json b/.commandcode/settings.json index b8291e3..187814d 100644 --- a/.commandcode/settings.json +++ b/.commandcode/settings.json @@ -1,7 +1,10 @@ { "permissions": { "allow": [ - "Shell(npx tsc --noEmit --skipLibCheck src/trpc/apis/common-apis/common.ts 2 >& 1)" + "Shell(npx tsc --noEmit --skipLibCheck src/trpc/apis/common-apis/common.ts 2 >& 1)", + "Shell(npx tsc --noEmit 2 >& 1)", + "Shell(grep:*)", + "Shell(npx tsc --noEmit -p packages/shared/tsconfig.json 2 >& 1)" ], "deny": [], "defaultMode": "default" diff --git a/apps/admin-ui/app/(drawer)/product-tags/_layout.tsx b/apps/admin-ui/app/(drawer)/product-tags/_layout.tsx index 4181014..9b37411 100644 --- a/apps/admin-ui/app/(drawer)/product-tags/_layout.tsx +++ b/apps/admin-ui/app/(drawer)/product-tags/_layout.tsx @@ -6,6 +6,7 @@ export default function Layout() { + ); } \ No newline at end of file diff --git a/apps/admin-ui/app/(drawer)/product-tags/add.tsx b/apps/admin-ui/app/(drawer)/product-tags/add.tsx index 8de7bd7..32b8c34 100644 --- a/apps/admin-ui/app/(drawer)/product-tags/add.tsx +++ b/apps/admin-ui/app/(drawer)/product-tags/add.tsx @@ -11,6 +11,7 @@ interface TagFormData { tagDescription: string; isDashboardTag: boolean; relatedStores: number[]; + productIds: number[]; } export default function AddTag() { @@ -45,6 +46,7 @@ export default function AddTag() { imageUrl, isDashboardTag: values.isDashboardTag, relatedStores: values.relatedStores, + productIds: values.productIds, uploadUrls, }) @@ -66,6 +68,7 @@ export default function AddTag() { tagDescription: '', isDashboardTag: false, relatedStores: [], + productIds: [], }; return ( diff --git a/apps/admin-ui/app/(drawer)/product-tags/edit/index.tsx b/apps/admin-ui/app/(drawer)/product-tags/edit/index.tsx index f9e0e62..28d7d63 100644 --- a/apps/admin-ui/app/(drawer)/product-tags/edit/index.tsx +++ b/apps/admin-ui/app/(drawer)/product-tags/edit/index.tsx @@ -11,6 +11,7 @@ interface TagFormData { tagDescription: string; isDashboardTag: boolean; relatedStores: number[]; + productIds: number[]; existingImageUrl?: string; } @@ -58,6 +59,7 @@ export default function EditTag() { imageUrl, isDashboardTag: values.isDashboardTag, relatedStores: values.relatedStores, + productIds: values.productIds, uploadUrls, }) @@ -95,11 +97,16 @@ export default function EditTag() { } const tag = tagData.tag; + const tagProductIds = tag.productIds || (tag.products || []).map((p: any) => p.productId); + // Order by the saved sortOrder (fall back to the join order if empty). + const orderedProductIds = (tag.sortOrder || []).filter((id: number) => tagProductIds.includes(id)); + const remainingProductIds = tagProductIds.filter((id: number) => !orderedProductIds.includes(id)); const initialValues: TagFormData = { tagName: tag.tagName, tagDescription: tag.tagDescription || '', isDashboardTag: tag.isDashboardTag, relatedStores: Array.isArray(tag.relatedStores) ? tag.relatedStores : [], + productIds: [...orderedProductIds, ...remainingProductIds], existingImageUrl: tag.imageUrl || undefined, }; diff --git a/apps/admin-ui/app/(drawer)/product-tags/index.tsx b/apps/admin-ui/app/(drawer)/product-tags/index.tsx index d2b6d8f..d520c2b 100644 --- a/apps/admin-ui/app/(drawer)/product-tags/index.tsx +++ b/apps/admin-ui/app/(drawer)/product-tags/index.tsx @@ -53,11 +53,18 @@ const TagItem: React.FC = ({ item, onDeleteSuccess }) => ( interface TagHeaderProps { onAddNewTag: () => void; + onOrderTags: () => void; } -const TagHeader: React.FC = ({ onAddNewTag }) => ( +const TagHeader: React.FC = ({ onAddNewTag, onOrderTags }) => ( - Product Tags + + + Tag Orders + { + router.push('/product-tags/order'); + }; + if (isLoading) { @@ -127,7 +138,7 @@ export default function ProductTags() { refreshControl={ } - ListHeaderComponent={} + ListHeaderComponent={} contentContainerStyle={tw`pb-4`} ListEmptyComponent={ diff --git a/apps/admin-ui/app/(drawer)/product-tags/order.tsx b/apps/admin-ui/app/(drawer)/product-tags/order.tsx new file mode 100644 index 0000000..7b542f4 --- /dev/null +++ b/apps/admin-ui/app/(drawer)/product-tags/order.tsx @@ -0,0 +1,369 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + View, + Alert, + ActivityIndicator, + Dimensions, + StyleSheet, +} from 'react-native'; +import { TouchableOpacity } from 'react-native-gesture-handler'; +import { Image } from 'expo-image'; +import DraggableFlatList, { + ScaleDecorator, +} from 'react-native-draggable-flatlist'; +import { + AppContainer, + MyText, + tw, + MyTouchableOpacity, +} from 'common-ui'; +import { useRouter } from 'expo-router'; +import { trpc } from '../../../src/trpc-client'; +import MaterialIcons from '@expo/vector-icons/MaterialIcons'; +import { useQueryClient } from '@tanstack/react-query'; + +const { width: screenWidth } = Dimensions.get('window'); +const itemWidth = screenWidth - 48; +const itemHeight = 80; + +interface Tag { + id: number; + tagName: string; + imageUrl: string | null; +} + +interface TagItemProps { + item: Tag; + drag: () => void; + isActive: boolean; +} + +const TagItem: React.FC = ({ item, drag, isActive }) => { + return ( + + + {/* Drag Handle */} + + + + + {/* Tag Image */} + {item.imageUrl ? ( + + ) : ( + + + + )} + + {/* Tag Info */} + + + {item.tagName} + + + + + ); +}; + +export default function TagOrders() { + const router = useRouter(); + const queryClient = useQueryClient(); + const [tags, setTags] = useState([]); + const [hasChanges, setHasChanges] = useState(false); + + // Get current order from constants + const { data: constants, isLoading: isLoadingConstants, error: constantsError } = trpc.admin.const.getConstants.useQuery(); + const { data: tagsData, isLoading: isLoadingTags, error: tagsError } = trpc.admin.product.getProductTags.useQuery(); + const updateConstants = trpc.admin.const.updateConstants.useMutation(); + + // Initialize tags from the tagsOrder constant + useEffect(() => { + if (tagsData?.tags) { + const tagsOrderConstant = constants?.find(c => c.key === 'tagsOrder'); + + let orderedIds: number[] = []; + + if (tagsOrderConstant) { + const value = tagsOrderConstant.value; + + if (Array.isArray(value)) { + orderedIds = value.map((id: any) => parseInt(id)); + } else if (typeof value === 'string') { + orderedIds = value.split(',').map((id: string) => parseInt(id.trim())).filter(id => !isNaN(id)); + } + } + + // Create tag map for quick lookup + const tagMap = new Map(tagsData.tags.map(t => [t.id, t])); + + // Sort tags based on order, tags not in order go to end + const sortedTags: Tag[] = []; + + // First add tags in the specified order + for (const id of orderedIds) { + const tag = tagMap.get(id); + if (tag) { + sortedTags.push({ + id: tag.id, + tagName: tag.tagName, + imageUrl: tag.imageUrl || null, + }); + tagMap.delete(id); + } + } + + // Then add remaining tags (not in order yet) + for (const tag of tagMap.values()) { + sortedTags.push({ + id: tag.id, + tagName: tag.tagName, + imageUrl: tag.imageUrl || null, + }); + } + + setTags(sortedTags); + } + }, [constants, tagsData]); + + const handleDragEnd = useCallback(({ data }: { data: Tag[] }) => { + setTags(data); + setHasChanges(true); + }, []); + + const renderItem = useCallback(({ item, drag, isActive }: { item: Tag; drag: () => void; isActive: boolean }) => { + return ( + + ); + }, []); + + const handleSave = () => { + const tagIds = tags.map(t => t.id); + + updateConstants.mutate( + { + constants: [{ + key: 'tagsOrder', + value: tagIds + }] + }, + { + onSuccess: () => { + setHasChanges(false); + Alert.alert('Success', 'Tag order updated successfully!'); + queryClient.invalidateQueries({ queryKey: ['const.getConstants'] }); + }, + onError: (error) => { + Alert.alert('Error', 'Failed to update tag order. Please try again.'); + console.error('Update tag order error:', error); + } + } + ); + }; + + // Show loading state while data is being fetched + if (isLoadingConstants || isLoadingTags) { + return ( + + + + router.back()} + style={tw`p-2 -ml-4`} + > + + + Tag Orders + + + + + + {isLoadingConstants ? 'Loading order...' : 'Loading tags...'} + + + + + ); + } + + // Show error state if queries failed + if (constantsError || tagsError) { + return ( + + + + router.back()} + style={tw`p-2 -ml-4`} + > + + + Tag Orders + + + + + Error + + {constantsError ? 'Failed to load order' : 'Failed to load tags'} + + router.back()} + style={tw`mt-6 bg-blue-600 px-6 py-3 rounded-full`} + > + Go Back + + + + + ); + } + + return ( + + {/* Header */} + + router.back()} + style={tw`p-2 -ml-4`} + > + + + + Tag Orders + + + + {updateConstants.isPending ? 'Saving...' : 'Save'} + + + + + {/* Content */} + {tags.length === 0 ? ( + + + + No tags available + + + ) : ( + + + + Long press and drag to reorder • {tags.length} items + + + + + item.id.toString()} + onDragEnd={handleDragEnd} + showsVerticalScrollIndicator={true} + contentContainerStyle={{ paddingBottom: 20 }} + containerStyle={tw`flex-1`} + keyboardShouldPersistTaps="handled" + activationDistance={10} + /> + + + )} + + ); +} + +const styles = StyleSheet.create({ + item: { + width: itemWidth, + height: 60, + backgroundColor: 'white', + borderRadius: 8, + borderWidth: 1, + borderColor: '#e5e7eb', + padding: 10, + flexDirection: 'row', + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.1, + shadowRadius: 2, + elevation: 2, + marginVertical: 4, + }, + activeItem: { + shadowColor: '#3b82f6', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 8, + borderColor: '#3b82f6', + transform: [{ scale: 1.02 }], + }, + dragHandle: { + marginRight: 8, + padding: 2, + }, + image: { + width: 30, + height: 30, + borderRadius: 6, + marginRight: 10, + }, + placeholderImage: { + width: 30, + height: 30, + borderRadius: 6, + backgroundColor: '#f3f4f6', + marginRight: 10, + alignItems: 'center', + justifyContent: 'center', + }, + info: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + name: { + fontSize: 13, + color: '#111827', + fontWeight: '500', + flex: 1, + marginRight: 4, + }, +}); diff --git a/apps/admin-ui/src/components/TagForm.tsx b/apps/admin-ui/src/components/TagForm.tsx index 481a3f0..6e4e769 100644 --- a/apps/admin-ui/src/components/TagForm.tsx +++ b/apps/admin-ui/src/components/TagForm.tsx @@ -1,9 +1,14 @@ import React, { useState, useEffect, forwardRef, useCallback } from 'react'; -import { View, TouchableOpacity } from 'react-native'; +import { View, TouchableOpacity, StyleSheet } from 'react-native'; import { Formik } from 'formik'; import * as Yup from 'yup'; import { MyTextInput, MyText, Checkbox, ImageUploaderNeo, tw, useFocusCallback, BottomDropdown, type ImageUploaderNeoItem, type ImageUploaderNeoPayload } from 'common-ui'; import MaterialIcons from '@expo/vector-icons/MaterialIcons'; +import { TouchableOpacity as GHTouchableOpacity } from 'react-native-gesture-handler'; +import DraggableFlatList, { ScaleDecorator } from 'react-native-draggable-flatlist'; +import { Image } from 'expo-image'; +import ProductsSelector from '@/components/ProductsSelector'; +import { trpc } from '@/src/trpc-client'; interface StoreOption { id: number; @@ -15,6 +20,7 @@ interface TagFormData { tagDescription: string; isDashboardTag: boolean; relatedStores: number[]; + productIds: number[]; } interface TagFormProps { @@ -26,6 +32,13 @@ interface TagFormProps { stores?: StoreOption[]; } +interface SelectedProduct { + skuId: number; + productId: number; + label: string; + imageUrl?: string | null; +} + const TagForm = forwardRef(({ mode, initialValues, @@ -37,10 +50,35 @@ const TagForm = forwardRef(({ const [images, setImages] = useState([]) const [removedExisting, setRemovedExisting] = useState(false) const [isDashboardTagChecked, setIsDashboardTagChecked] = useState(Boolean(initialValues.isDashboardTag)); + const [selectedProducts, setSelectedProducts] = useState([]); const existingImageUrl = existingImageUrlRaw || '' const stores = storesRaw || [] + const { data: skusData } = trpc.common.product.getAllSkusSummary.useQuery(); + const allSkus: SelectedProduct[] = (skusData?.skus || []).map((sku: any) => ({ + skuId: sku.id, + productId: sku.productId, + label: sku.label, + imageUrl: sku.images?.[0] || null, + })); + + // Build the ordered list from initialValues.productIds (product ids, in sortOrder). + useEffect(() => { + const ordered: SelectedProduct[] = []; + const skuMap = new Map(); + for (const sku of allSkus) { + skuMap.set(sku.productId, sku); + } + for (const productId of initialValues.productIds || []) { + const sku = skuMap.get(productId); + if (sku) ordered.push(sku); + else ordered.push({ skuId: 0, productId, label: `Product #${productId}`, imageUrl: null }); + } + setSelectedProducts(ordered); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialValues.productIds, skusData]); + // Update checkbox when initial values change useEffect(() => { setIsDashboardTagChecked(Boolean(initialValues.isDashboardTag)); @@ -51,7 +89,6 @@ const TagForm = forwardRef(({ } setRemovedExisting(false) }, [existingImageUrlRaw, initialValues.isDashboardTag]); - const validationSchema = Yup.object().shape({ tagName: Yup.string() @@ -62,21 +99,74 @@ const TagForm = forwardRef(({ .max(500, 'Description must be less than 500 characters'), }); + // When a product is picked in the selector, add it to the ordered list (if not already present). + const handleProductSelect = (value: number | number[]) => { + const skuIds = Array.isArray(value) ? value : [value]; + setSelectedProducts((prev) => { + const next = [...prev]; + for (const skuId of skuIds) { + if (next.some((p) => p.skuId === skuId)) continue; + const sku = allSkus.find((s) => s.skuId === skuId); + if (sku) next.push(sku); + } + return next; + }); + }; + + const handleDragEnd = useCallback(({ data }: { data: SelectedProduct[] }) => { + setSelectedProducts(data); + }, []); + + const handleRemoveProduct = (productId: number) => { + setSelectedProducts((prev) => prev.filter((p) => p.productId !== productId)); + }; + + const renderSelectedItem = useCallback(({ item, drag, isActive }: { item: SelectedProduct; drag: () => void; isActive: boolean }) => ( + + + + + + {item.imageUrl ? ( + + ) : ( + + + + )} + + {item.label} + + handleRemoveProduct(item.productId)} style={styles.removeButton}> + + + + + ), []); + return ( onSubmit(values, images, removedExisting)} + onSubmit={(values) => onSubmit({ ...values, productIds: selectedProducts.map((p) => p.productId) }, images, removedExisting)} enableReinitialize > - {({ handleChange, handleSubmit, values, setFieldValue, errors, touched, setFieldValue: formikSetFieldValue, resetForm }) => { - // Clear form when screen comes into focus - const clearForm = useCallback(() => { - setImages([]) - setRemovedExisting(false) - setIsDashboardTagChecked(false); - resetForm(); - }, [resetForm]); + {({ handleChange, handleSubmit, values, setFieldValue, errors, touched, resetForm }) => { + // Clear form when screen comes into focus (create mode only — edit keeps its loaded products) + const clearForm = useCallback(() => { + setImages([]) + setRemovedExisting(false) + setIsDashboardTagChecked(false); + if (mode === 'create') { + setSelectedProducts([]); + } + resetForm(); + }, [resetForm, mode]); useFocusCallback(clearForm); @@ -106,7 +196,6 @@ const TagForm = forwardRef(({ Tag Image {mode === 'edit' ? '(Upload new to replace)' : '(Optional)'} - { @@ -132,7 +221,7 @@ const TagForm = forwardRef(({ onPress={() => { const newValue = !isDashboardTagChecked; setIsDashboardTagChecked(newValue); - formikSetFieldValue('isDashboardTag', newValue); + setFieldValue('isDashboardTag', newValue); }} /> Mark as Dashboard Tag @@ -153,12 +242,53 @@ const TagForm = forwardRef(({ }))} onValueChange={(selectedValues) => { const numericValues = (selectedValues as string[]).map(v => parseInt(v)); - formikSetFieldValue('relatedStores', numericValues); + setFieldValue('relatedStores', numericValues); }} multiple={true} /> + {/* Products Section: selector + reorderable list */} + + + Products + + + + {selectedProducts.length > 0 && ( + + + Long press and drag to reorder • {selectedProducts.length} items + + + )} + {selectedProducts.length > 0 ? ( + item.productId.toString()} + onDragEnd={handleDragEnd} + showsVerticalScrollIndicator={false} + contentContainerStyle={{ paddingBottom: 20 }} + keyboardShouldPersistTaps="handled" + activationDistance={10} + /> + ) : ( + + + No products selected yet + + )} + + + handleSubmit()} disabled={isLoading} @@ -175,6 +305,62 @@ const TagForm = forwardRef(({ ); }); +const styles = StyleSheet.create({ + item: { + backgroundColor: 'white', + borderRadius: 8, + borderWidth: 1, + borderColor: '#e5e7eb', + padding: 10, + flexDirection: 'row', + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.1, + shadowRadius: 2, + elevation: 2, + marginVertical: 4, + }, + activeItem: { + shadowColor: '#3b82f6', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 8, + borderColor: '#3b82f6', + transform: [{ scale: 1.02 }], + }, + dragHandle: { + marginRight: 8, + padding: 2, + }, + image: { + width: 30, + height: 30, + borderRadius: 6, + marginRight: 10, + }, + placeholderImage: { + width: 30, + height: 30, + borderRadius: 6, + backgroundColor: '#f3f4f6', + marginRight: 10, + alignItems: 'center', + justifyContent: 'center', + }, + name: { + flex: 1, + fontSize: 13, + color: '#111827', + fontWeight: '500', + marginRight: 4, + }, + removeButton: { + padding: 2, + }, +}); + TagForm.displayName = 'TagForm'; export default TagForm; diff --git a/apps/backend/src/lib/const-keys.ts b/apps/backend/src/lib/const-keys.ts index 7ea1dbf..0f710db 100644 --- a/apps/backend/src/lib/const-keys.ts +++ b/apps/backend/src/lib/const-keys.ts @@ -17,6 +17,7 @@ export const CONST_KEYS = { appStoreUrl: 'appStoreUrl', popularItems: 'popularItems', allItemsOrder: 'allItemsOrder', + tagsOrder: 'tagsOrder', isFlashDeliveryEnabled: 'isFlashDeliveryEnabled', supportMobile: 'supportMobile', supportEmail: 'supportEmail', @@ -41,6 +42,7 @@ export const CONST_LABELS: Record = { appStoreUrl: 'App Store URL', popularItems: 'Popular Items', allItemsOrder: 'All Items Order', + tagsOrder: 'Tags Order', isFlashDeliveryEnabled: 'Enable Flash Delivery', supportMobile: 'Support Mobile', supportEmail: 'Support Email', @@ -71,6 +73,7 @@ export const CONST_TYPES: Record = { appStoreUrl: 'string', popularItems: 'string', allItemsOrder: 'string', + tagsOrder: 'string', isFlashDeliveryEnabled: 'boolean', supportMobile: 'string', supportEmail: 'string', @@ -95,6 +98,7 @@ export const CONST_VISIBILITY: Record = { appStoreUrl: true, popularItems: true, allItemsOrder: true, + tagsOrder: false, isFlashDeliveryEnabled: true, supportMobile: true, supportEmail: true, diff --git a/apps/backend/src/sqliteImporter.ts b/apps/backend/src/sqliteImporter.ts index 322fc09..eb74ffa 100644 --- a/apps/backend/src/sqliteImporter.ts +++ b/apps/backend/src/sqliteImporter.ts @@ -72,6 +72,7 @@ export { createSpecialDealsForSku, updateSkuDeals, replaceProductTags, + replaceTagProducts, mergeSkus, updateSlotProducts, getSlotsProductIds, diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts index eac06d3..3657d87 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts @@ -21,6 +21,7 @@ import { checkUnitExists, createProduct as createProductInDb, replaceProductTags, + replaceTagProducts, getProductImagesById, updateProduct as updateProductInDb, checkProductTagExistsByName, @@ -29,6 +30,7 @@ import { deleteProductTag as deleteProductTagInDb, getAllProductTagInfos as getAllProductTagInfosInDb, getProductTagInfoById as getProductTagInfoByIdInDb, + getProductTagById as getProductTagByIdInDb, } from '@/src/dbService' import type { AdminProduct, @@ -880,8 +882,8 @@ export const productRouter = router({ getProductTagById: protectedProcedure .input(z.object({ id: z.number() })) - .query(async ({ input }): Promise<{ tag: { id: number; tagName: string; tagDescription: string | null; imageUrl: string | null; isDashboardTag: boolean; relatedStores: unknown; createdAt: Date }; message: string }> => { - const tag = await getProductTagInfoByIdInDb(input.id) + .query(async ({ input }): Promise<{ tag: { id: number; tagName: string; tagDescription: string | null; imageUrl: string | null; isDashboardTag: boolean; relatedStores: unknown; sortOrder: number[]; createdAt: Date; products: Array<{ productId: number; tagId: number; assignedAt: Date; product: any }>; productIds: number[] }; message: string }> => { + const tag = await getProductTagByIdInDb(input.id) if (!tag) { throw new ApiError('Tag not found', 404) @@ -905,10 +907,11 @@ export const productRouter = router({ imageUrl: z.string().optional().nullable(), isDashboardTag: z.boolean().optional().default(false), relatedStores: z.array(z.number()).optional().default([]), + productIds: z.array(z.number()).optional().default([]), uploadUrls: z.array(z.string()).optional().default([]), })) - .mutation(async ({ input }): Promise<{ tag: { id: number; tagName: string; tagDescription: string | null; imageUrl: string | null; isDashboardTag: boolean; relatedStores: unknown; createdAt: Date }; message: string }> => { - const { tagName, tagDescription, imageUrl, isDashboardTag, relatedStores, uploadUrls } = input + .mutation(async ({ input }): Promise<{ tag: { id: number; tagName: string; tagDescription: string | null; imageUrl: string | null; isDashboardTag: boolean; relatedStores: unknown; sortOrder: number[]; createdAt: Date }; message: string }> => { + const { tagName, tagDescription, imageUrl, isDashboardTag, relatedStores, productIds, uploadUrls } = input const existingTag = await checkProductTagExistsByName(tagName.trim()) if (existingTag) { @@ -921,8 +924,11 @@ export const productRouter = router({ imageUrl: imageUrl ?? null, isDashboardTag, relatedStores, + sortOrder: productIds, }) + await replaceTagProducts(createdTag.id, productIds) + if (uploadUrls.length > 0) { await Promise.all(uploadUrls.map((url) => claimUploadUrl(url))) } @@ -948,10 +954,11 @@ export const productRouter = router({ imageUrl: z.string().optional().nullable(), isDashboardTag: z.boolean().optional(), relatedStores: z.array(z.number()).optional(), + productIds: z.array(z.number()).optional(), uploadUrls: z.array(z.string()).optional().default([]), })) - .mutation(async ({ input }): Promise<{ tag: { id: number; tagName: string; tagDescription: string | null; imageUrl: string | null; isDashboardTag: boolean; relatedStores: unknown; createdAt: Date }; message: string }> => { - const { id, tagName, tagDescription, imageUrl, isDashboardTag, relatedStores, uploadUrls } = input + .mutation(async ({ input }): Promise<{ tag: { id: number; tagName: string; tagDescription: string | null; imageUrl: string | null; isDashboardTag: boolean; relatedStores: unknown; sortOrder: number[]; createdAt: Date }; message: string }> => { + const { id, tagName, tagDescription, imageUrl, isDashboardTag, relatedStores, productIds, uploadUrls } = input const currentTag = await getProductTagInfoByIdInDb(id) @@ -971,8 +978,13 @@ export const productRouter = router({ imageUrl: imageUrl ?? undefined, isDashboardTag, relatedStores, + sortOrder: productIds, }) + if (productIds !== undefined) { + await replaceTagProducts(id, productIds) + } + if (uploadUrls.length > 0) { await Promise.all(uploadUrls.map((url) => claimUploadUrl(url))) } diff --git a/apps/backend/src/trpc/apis/common-apis/common.ts b/apps/backend/src/trpc/apis/common-apis/common.ts index 2f2cece..fb39a72 100644 --- a/apps/backend/src/trpc/apis/common-apis/common.ts +++ b/apps/backend/src/trpc/apis/common-apis/common.ts @@ -11,6 +11,8 @@ import { import { generateSignedUrlsFromS3Urls, generateSignedUrlFromS3Url, scaffoldAssetUrl } from '@/src/lib/s3-client' import { getAllProducts as getAllProductsFromCache } from '@/src/stores/product-store' import { getDashboardTags as getDashboardTagsFromCache } from '@/src/stores/product-tag-store' +import { getConstant } from '@/src/lib/const-store' +import { CONST_KEYS } from '@/src/lib/const-keys' // Re-export with original name for backwards compatibility export const getNextDeliveryDate = getNextDeliveryDateWithCapacity @@ -65,6 +67,28 @@ export async function scaffoldProducts() { getAllTagProductMappings(), ]) + // Order tags by the admin-defined tagsOrder (unknown tags go to the end). + const tagsOrderRaw = await getConstant(CONST_KEYS.tagsOrder) + let tagsOrderIds: number[] = [] + if (Array.isArray(tagsOrderRaw)) { + tagsOrderIds = tagsOrderRaw.map((id: any) => parseInt(id)).filter((id: number) => !isNaN(id)) + } else if (typeof tagsOrderRaw === 'string') { + tagsOrderIds = tagsOrderRaw.split(',').map((id: string) => parseInt(id.trim())).filter((id: number) => !isNaN(id)) + } + + const tagsById = new Map(allTags.map((tag: any) => [tag.id, tag])) + const orderedTags: any[] = [] + for (const id of tagsOrderIds) { + const tag = tagsById.get(id) + if (tag) { + orderedTags.push(tag) + tagsById.delete(id) + } + } + for (const tag of tagsById.values()) { + orderedTags.push(tag) + } + const productIdsByTag = new Map() for (const mapping of tagMappings) { if (!productIdsByTag.has(mapping.tagId)) { @@ -73,14 +97,25 @@ export async function scaffoldProducts() { productIdsByTag.get(mapping.tagId)!.push(mapping.productId) } - const tags = allTags.map((tag: { id: number; tagName: string; tagDescription: string | null; imageUrl: string | null; isDashboardTag: boolean; relatedStores: unknown }) => ({ + // Reorder each tag's product ids by the admin-defined sortOrder (unknown products go to the end). + const reorderProductIds = (tagId: number, tagSortOrder: number[] | undefined) => { + const current = productIdsByTag.get(tagId) || [] + if (!Array.isArray(tagSortOrder) || tagSortOrder.length === 0) { + return current + } + const ordered = tagSortOrder.filter((id: number) => current.includes(id)) + const rest = current.filter((id: number) => !ordered.includes(id)) + return [...ordered, ...rest] + } + + const tags = orderedTags.map((tag: { id: number; tagName: string; tagDescription: string | null; imageUrl: string | null; isDashboardTag: boolean; relatedStores: unknown; sortOrder: number[] | null }) => ({ id: tag.id, tagName: tag.tagName, tagDescription: tag.tagDescription, imageUrl: tag.imageUrl ? scaffoldAssetUrl(tag.imageUrl) : null, isDashboardTag: tag.isDashboardTag, relatedStores: (tag.relatedStores as number[]) || [], - productIds: productIdsByTag.get(tag.id) || [], + productIds: reorderProductIds(tag.id, tag.sortOrder ?? undefined), })) return { diff --git a/apps/backend/wrangler.dev.toml b/apps/backend/wrangler.dev.toml index 1048178..b0f643c 100644 --- a/apps/backend/wrangler.dev.toml +++ b/apps/backend/wrangler.dev.toml @@ -9,7 +9,7 @@ routes = [ [[d1_databases]] binding = "DB" database_name = "freshyo-backend-dev" -database_id = "a976d1fb-c6a7-4948-b303-81c6433ed265" +database_id = "b05f2d65-5496-45bc-9780-ad3cd6f83afa" #database_name = "freshyo-dev" #database_id = "3cad440f-dc14-4baa-ab05-04eb08e206de" migrations_dir="../../packages/db_helper_sqlite/drizzle" diff --git a/apps/user-ui/app/(drawer)/(tabs)/home/index.tsx b/apps/user-ui/app/(drawer)/(tabs)/home/index.tsx index e277b1a..39fe454 100755 --- a/apps/user-ui/app/(drawer)/(tabs)/home/index.tsx +++ b/apps/user-ui/app/(drawer)/(tabs)/home/index.tsx @@ -1,5 +1,6 @@ -import React, { useState, useCallback, useMemo, memo } from "react"; +import React, { useState, useCallback, useMemo, memo, useRef } from "react"; import { View, Dimensions, Image, RefreshControl, ScrollView } from "react-native"; +import { TabView, TabBar } from "react-native-tab-view"; import { LinearGradient } from "expo-linear-gradient"; import { useRouter } from "expo-router"; import { @@ -25,7 +26,6 @@ import { useProductSlotIdentifier } from "@/hooks/useProductSlotIdentifier"; import { useCentralSlotStore } from "@/src/store/centralSlotStore"; import { useCentralProductStore } from "@/src/store/centralProductStore"; import FloatingCartBar from "@/components/floating-cart-bar"; -import BannerCarousel from "@/components/BannerCarousel"; import { useUserDetails } from "@/src/contexts/AuthContext"; import TabLayoutWrapper from "@/components/TabLayoutWrapper"; import { useNavigationStore } from "@/src/store/navigationStore"; @@ -37,6 +37,14 @@ const itemWidth = screenWidth * 0.45; const heroItemWidth = (screenWidth - 72) / 3; const gridItemWidth = (screenWidth - 48) / 2; +// Hero product card geometry (used to size the tab scene heights) +const heroCardImageHeight = heroItemWidth * 0.82; +const heroCardTextBlock = 86; +const heroCardHeight = heroCardImageHeight + heroCardTextBlock; +const heroRowHeight = heroCardHeight + 12; // + mb-3 +const TAG_GRID_COLUMNS = 3; +const TAB_BAR_HEIGHT = 48; + const formatTimeRange = (deliveryTime: string) => { const time = dayjs(deliveryTime); const endTime = time.add(1, 'hour'); @@ -53,7 +61,6 @@ const formatTimeRange = (deliveryTime: string) => { const staticStyles = { flatListContent: { gap: 16 }, columnWrapper: { gap: 16, paddingHorizontal: 16 }, - popularListContent: { paddingBottom: 16 }, slotsListContent: { paddingBottom: 24 }, }; @@ -91,15 +98,15 @@ const RenderStore = memo(({ item }: RenderStoreProps) => { activeOpacity={0.7} > {item.signedImageUrl ? ( ) : ( - + )} - + {item.name.replace(/^The\s+/i, "")} @@ -125,7 +132,7 @@ const SlotCard = memo(({ slot }: SlotCardProps) => { { ))} - View all {slot.products.length} items - + + View all {slot.products.length} items + + ); }); -interface PopularProductItemProps { - item: any; - onPress: (id: number) => void; -} - -const PopularProductItem = memo(({ item, onPress }: PopularProductItemProps) => { - const handlePress = useCallback(() => onPress(item.id), [item.id, onPress]); - - return ( - - - - ); -}); - interface ExploreTabProps { tag: any; isSelected: boolean; @@ -226,7 +213,7 @@ const ExploreTab = memo(({ tag, isSelected, onPress }: ExploreTabProps) => { style={[ tw`text-base tracking-tight`, { - color: isSelected ? '#111827' : '#64748B', + color: isSelected ? theme.colors.brand600 : '#64748B', fontWeight: isSelected ? '700' : '500', }, ]} @@ -239,7 +226,7 @@ const ExploreTab = memo(({ tag, isSelected, onPress }: ExploreTabProps) => { height: 4, borderRadius: 999, marginTop: 8, - backgroundColor: isSelected ? '#111827' : 'transparent', + backgroundColor: isSelected ? theme.colors.brand500 : 'transparent', }} /> @@ -247,28 +234,141 @@ const ExploreTab = memo(({ tag, isSelected, onPress }: ExploreTabProps) => { ); }); -interface ExploreTabsRowProps { +interface TagTabViewProps { + dashboardTags: any[]; + activeTagId: number | null; + productsByTagId: Record; + onSelectTag: (id: number) => void; + onProductPress: (id: number) => void; +} + +const TagTabView = memo(({ + dashboardTags, + activeTagId, + productsByTagId, + onSelectTag, + onProductPress, +}: TagTabViewProps) => { + const [expandedByTagId, setExpandedByTagId] = useState>({}); + + const routes = useMemo( + () => dashboardTags.map((tag) => ({ key: String(tag.id), title: tag.tagName })), + [dashboardTags] + ); + + const index = useMemo(() => { + const idx = dashboardTags.findIndex((tag) => tag.id === activeTagId); + return idx < 0 ? 0 : idx; + }, [dashboardTags, activeTagId]); + + // Height: fit the max visible product rows across all tags (default 6 per tag). + const sceneHeight = useMemo(() => { + let maxRows = 1; + for (const tag of dashboardTags) { + const count = (productsByTagId[tag.id] || []).length; + const visible = expandedByTagId[tag.id] ? count : Math.min(count, 6); + const rows = Math.max(1, Math.ceil(visible / TAG_GRID_COLUMNS)); + maxRows = Math.max(maxRows, rows); + } + return maxRows * heroRowHeight + 16; + }, [dashboardTags, productsByTagId, expandedByTagId]); + + const renderTabBar = useCallback((props: any) => ( + + ), []); + + const renderScene = useCallback(({ route }: any) => { + const tagId = Number(route.key); + const products = productsByTagId[tagId] || []; + const expanded = !!expandedByTagId[tagId]; + const visible = expanded ? products : products.slice(0, 6); + const hasMore = products.length > 6 && !expanded; + + return ( + + {products.length > 0 ? ( + + {visible.map((product: any) => ( + + ))} + + ) : ( + + + No products in this category yet + + + )} + {hasMore && ( + setExpandedByTagId((prev) => ({ ...prev, [tagId]: true }))} + > + Show More + + )} + + ); + }, [productsByTagId, expandedByTagId, onProductPress]); + + return ( + onSelectTag(Number(routes[i].key))} + renderTabBar={renderTabBar} + renderScene={renderScene} + swipeEnabled + lazy + style={{ height: TAB_BAR_HEIGHT + sceneHeight }} + /> + ); +}); + +interface StickyTabsRowProps { dashboardTags: any[]; activeTagId: number | null; onSelectTag: (id: number) => void; } -const ExploreTabsRow = memo(({ dashboardTags, activeTagId, onSelectTag }: ExploreTabsRowProps) => ( - - {dashboardTags.map((tag) => ( - onSelectTag(tag.id)} - /> - ))} - -)); +const StickyTabsRow = memo(({ dashboardTags, activeTagId, onSelectTag }: StickyTabsRowProps) => { + const scrollRef = useRef(null); + + const handleSelect = (id: number) => { + onSelectTag(id); + const idx = dashboardTags.findIndex((tag) => tag.id === id); + if (idx >= 0) { + scrollRef.current?.scrollTo({ x: idx * 96, animated: true }); + } + }; + + return ( + + {dashboardTags.map((tag) => ( + handleSelect(tag.id)} /> + ))} + + ); +}); interface ExploreProductItemProps { item: any; @@ -323,12 +423,11 @@ interface ListHeaderProps { gradientHeight: number; onGradientLayout: (height: number) => void; storesData: any; - popularProducts: any[]; sortedSlots: any[]; onProductPress: (id: number) => void; dashboardTags: any[]; activeTagId: number | null; - activeTagProducts: any[]; + productsByTagId: Record; onSelectTag: (id: number) => void; onTabsSectionLayout: (layout: { y: number; height: number }) => void; } @@ -337,29 +436,19 @@ const ListHeader = memo(({ gradientHeight, onGradientLayout, storesData, - popularProducts, sortedSlots, onProductPress, dashboardTags, activeTagId, - activeTagProducts, + productsByTagId, onSelectTag, onTabsSectionLayout, }: ListHeaderProps) => { - const [showAllActiveProducts, setShowAllActiveProducts] = useState(false); const handleLayout = useCallback((event: any) => { const { y, height } = event.nativeEvent.layout; onGradientLayout(y + height); }, [onGradientLayout]); - React.useEffect(() => { - setShowAllActiveProducts(false); - }, [activeTagId]); - - const renderPopularItem = useCallback(({ item }: { item: any }) => ( - - ), [onProductPress]); - const renderSlotItem = useCallback(({ item }: { item: any }) => ( ), []); @@ -370,16 +459,14 @@ const ListHeader = memo(({ ], [gradientHeight]); const activeColor = activeTagId == null ? null : getTagColor(activeTagId); const pageTint = activeColor?.pageBg ?? '#FFFFFF'; - const visibleActiveTagProducts = showAllActiveProducts ? activeTagProducts : activeTagProducts.slice(0, 6); - const hasMoreActiveTagProducts = activeTagProducts.length > visibleActiveTagProducts.length; return ( <> - + @@ -390,59 +477,29 @@ const ListHeader = memo(({ onTabsSectionLayout({ y, height }); }} style={[ - tw`mx-4 mb-2 rounded-[28px] px-3 pt-3 pb-2`, - { backgroundColor: pageTint }, + tw`mx-4 mb-2 rounded-[28px] px-3 pt-3 pb-[30px]`, + { backgroundColor: theme.colors.brand25 }, ]} > - - {activeTagProducts.length > 0 ? ( - - - {visibleActiveTagProducts.map((product: any) => ( - - ))} - - {hasMoreActiveTagProducts && ( - setShowAllActiveProducts(true)} - > - Show More - - )} - - ) : ( - - - No products in this category yet - - - )} )} - - - - {storesData?.stores && storesData.stores.length > 0 && ( - + @@ -467,36 +524,15 @@ const ListHeader = memo(({ - - Popular Items - Trending fresh picks just for you - - - - item.id.toString()} - horizontal - showsHorizontalScrollIndicator={false} - contentContainerStyle={staticStyles.popularListContent} - renderItem={renderPopularItem} - removeClippedSubviews={true} - /> - - - {sortedSlots.length > 0 && ( - Upcoming Delivery Slots - Plan your fresh deliveries ahead + + + Upcoming Delivery Slots + + Plan your fresh deliveries ahead - All Available Products - Browse our complete selection + + + All Available Products + + Browse our complete selection @@ -587,21 +626,6 @@ export default function Dashboard() { setHasMore(products.length > 10) }, [productsData, productSlotsMap]); - const popularItemIds = useMemo(() => { - const popularItems = essentialConsts?.popularItems; - if (!popularItems) return []; - - if (Array.isArray(popularItems)) { - return popularItems.map((id: any) => parseInt(id)).filter((id: number) => !isNaN(id)); - } else if (typeof popularItems === 'string') { - return popularItems - .split(',') - .map((id: string) => parseInt(id.trim())) - .filter((id: number) => !isNaN(id)); - } - return []; - }, [essentialConsts?.popularItems]); - const sortedSlots = useMemo(() => { if (!slotsData?.slots) return []; const now = dayjs(); @@ -614,31 +638,43 @@ export default function Dashboard() { }); }, [slotsData]); - const popularProducts = useMemo(() => { - return popularItemIds - .map(id => products.find(product => product.id === id)) - .filter((product): product is NonNullable => product != null); - }, [popularItemIds, products]); + const productsByTagId = useMemo(() => { + const map: Record = {}; + for (const tag of dashboardTags) { + const productById = new Map(); + for (const product of products) { + productById.set(product.id, product); + } - const activeTagProducts = useMemo(() => { - if (activeTagId == null) return []; - const activeTag = dashboardTags.find((tag: any) => tag.id === activeTagId); - if (!activeTag) return []; + // tag.productIds is already in the admin-curated order (backend sorts by sortOrder). + const orderedIds = (tag.productIds || []).filter((id: number) => productById.has(id)); + const ordered: any[] = []; + const rest: any[] = []; + for (const id of orderedIds) { + const product = productById.get(id); + const isOutOfStock = Boolean(productSlotsMap[id]?.isOutOfStock) || !getQuickestSlot(id); + if (isOutOfStock) rest.push(product); + else ordered.push(product); + } - return products - .filter((product: any) => activeTag.productIds?.includes(product.id) ?? false) - .sort((a: any, b: any) => { - const slotA = getQuickestSlot(a.id) - const slotB = getQuickestSlot(b.id) + // Products in the tag's productIds but not in the curated order (added later) — availability sort. + const seen = new Set(orderedIds); + const extra = products + .filter((product: any) => (tag.productIds?.includes(product.id) ?? false) && !seen.has(product.id)) + .sort((a: any, b: any) => { + const slotA = getQuickestSlot(a.id) + const slotB = getQuickestSlot(b.id) + const aOutOfStock = Boolean(productSlotsMap[a.id]?.isOutOfStock) || !slotA + const bOutOfStock = Boolean(productSlotsMap[b.id]?.isOutOfStock) || !slotB + if (aOutOfStock && !bOutOfStock) return 1 + if (!aOutOfStock && bOutOfStock) return -1 + return 0 + }); - const aOutOfStock = Boolean(productSlotsMap[a.id]?.isOutOfStock) || !slotA - const bOutOfStock = Boolean(productSlotsMap[b.id]?.isOutOfStock) || !slotB - - if (aOutOfStock && !bOutOfStock) return 1 - if (!aOutOfStock && bOutOfStock) return -1 - return 0 - }); - }, [activeTagId, dashboardTags, products, getQuickestSlot, productSlotsMap]); + map[tag.id] = [...ordered, ...rest, ...extra]; + } + return map; + }, [dashboardTags, products, getQuickestSlot, productSlotsMap]); const handleRefresh = useCallback(async () => { setIsRefreshing(true); @@ -703,26 +739,25 @@ export default function Dashboard() { gradientHeight={gradientHeight} onGradientLayout={handleGradientLayout} storesData={storesData} - popularProducts={popularProducts} sortedSlots={sortedSlots} onProductPress={handleProductPress} dashboardTags={dashboardTags} activeTagId={activeTagId} - activeTagProducts={activeTagProducts} + productsByTagId={productsByTagId} onSelectTag={setSelectedTagId} onTabsSectionLayout={handleTabsSectionLayout} /> - ), [gradientHeight, handleGradientLayout, storesData, popularProducts, sortedSlots, handleProductPress, dashboardTags, activeTagId, activeTagProducts, handleTabsSectionLayout]); + ), [gradientHeight, handleGradientLayout, storesData, sortedSlots, handleProductPress, dashboardTags, activeTagId, productsByTagId, handleTabsSectionLayout]); const pageTint = activeTagId == null ? '#FFFFFF' : getTagColor(activeTagId).pageBg; const pageTintStyle = useMemo(() => [ tw`flex-1`, - { backgroundColor: pageTint } + { backgroundColor: '#FFFFFF' } ], [pageTint]); const searchBarContainerStyle = useMemo(() => [ tw`w-full px-4 pt-4 pb-0`, - { backgroundColor: pageTint } + { backgroundColor: '#FFFFFF' } ], [pageTint]); const listContentContainerStyle = useMemo(() => [ @@ -751,11 +786,8 @@ export default function Dashboard() { ); } - let str = '' - displayedProducts.forEach(product => str += `${product.id}-`) - // console.log(str) return ( - + item.id.toString()} numColumns={2} - style={{ backgroundColor: pageTint }} + style={{ backgroundColor: '#FFFFFF' }} onScroll={handleListScroll} scrollEventThrottle={16} contentContainerStyle={listContentContainerStyle} @@ -791,8 +823,8 @@ export default function Dashboard() { } ListEmptyComponent={ @@ -814,7 +846,7 @@ export default function Dashboard() { tw`absolute left-0 right-0 z-20 px-4 pb-2`, { top: searchBarHeight, - backgroundColor: pageTint, + backgroundColor: '#FFFFFF', elevation: 8, }, ]} @@ -822,10 +854,10 @@ export default function Dashboard() { - ({ imageUrl: tag.imageUrl ?? null, isDashboardTag: tag.isDashboardTag, relatedStores: tag.relatedStores, + sortOrder: tag.sortOrder ?? [], createdAt: tag.createdAt, }) @@ -587,6 +588,7 @@ export async function getAllProductTags(): Promise assignment.productId), })) } @@ -616,6 +618,7 @@ export interface CreateProductTagInput { imageUrl?: string | null isDashboardTag?: boolean relatedStores?: number[] + sortOrder?: number[] } export async function createProductTag(input: CreateProductTagInput): Promise { @@ -625,11 +628,13 @@ export async function createProductTag(input: CreateProductTagInput): Promise assignment.productId), } } @@ -666,6 +672,7 @@ export interface UpdateProductTagInput { imageUrl?: string | null isDashboardTag?: boolean relatedStores?: number[] + sortOrder?: number[] } export async function updateProductTag(tagId: number, input: UpdateProductTagInput): Promise { @@ -675,6 +682,7 @@ export async function updateProductTag(tagId: number, input: UpdateProductTagInp ...(input.imageUrl !== undefined && { imageUrl: input.imageUrl }), ...(input.isDashboardTag !== undefined && { isDashboardTag: input.isDashboardTag }), ...(input.relatedStores !== undefined && { relatedStores: input.relatedStores }), + ...(input.sortOrder !== undefined && { sortOrder: input.sortOrder }), }).where(eq(productTagInfo.id, tagId)).returning() const fullTag = await db.query.productTagInfo.findFirst({ @@ -696,6 +704,7 @@ export async function updateProductTag(tagId: number, input: UpdateProductTagInp assignedAt: assignment.assignedAt, product: mapProduct(assignment.product), })) || [], + productIds: fullTag?.products.map((assignment: ProductTagRow) => assignment.productId) || [], } } @@ -1147,6 +1156,21 @@ export async function replaceProductTags(productId: number, tagIds: number[]): P await db.insert(productTags).values(tagAssociations) } +export async function replaceTagProducts(tagId: number, productIds: number[]): Promise { + await db.delete(productTags).where(eq(productTags.tagId, tagId)) + + if (productIds.length === 0) { + return + } + + const productAssociations = productIds.map((productId) => ({ + productId, + tagId, + })) + + await db.insert(productTags).values(productAssociations) +} + export async function mergeSkus(fromSkuId: number, toSkuId: number) { if (fromSkuId === toSkuId) { return { fromSkuId, toSkuId, message: 'SKUs are the same, no merge needed', counts: {} } diff --git a/packages/db_helper_sqlite/src/db/schema.ts b/packages/db_helper_sqlite/src/db/schema.ts index a767840..e3ad403 100644 --- a/packages/db_helper_sqlite/src/db/schema.ts +++ b/packages/db_helper_sqlite/src/db/schema.ts @@ -291,6 +291,7 @@ export const productTagInfo = sqliteTable('product_tag_info', { imageUrl: text('image_url'), isDashboardTag: integer('is_dashboard_tag', { mode: 'boolean' }).notNull().default(false), relatedStores: jsonText('related_stores').$defaultFn(() => []), + sortOrder: jsonText('sort_order').$defaultFn(() => []), createdAt: timestampText('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), }) diff --git a/packages/db_helper_sqlite/src/stores/store-helpers.ts b/packages/db_helper_sqlite/src/stores/store-helpers.ts index 03758b6..5bfa5d0 100644 --- a/packages/db_helper_sqlite/src/stores/store-helpers.ts +++ b/packages/db_helper_sqlite/src/stores/store-helpers.ts @@ -267,6 +267,7 @@ export interface TagBasicData { imageUrl: string | null isDashboardTag: boolean relatedStores: unknown + sortOrder: number[] | null } export interface TagProductMapping { @@ -283,6 +284,7 @@ export async function getAllTagsForCache(): Promise { imageUrl: productTagInfo.imageUrl, isDashboardTag: productTagInfo.isDashboardTag, relatedStores: productTagInfo.relatedStores, + sortOrder: productTagInfo.sortOrder, }) .from(productTagInfo) } diff --git a/packages/shared/types/admin.ts b/packages/shared/types/admin.ts index f1b4932..f653d6d 100644 --- a/packages/shared/types/admin.ts +++ b/packages/shared/types/admin.ts @@ -454,6 +454,7 @@ export interface AdminProductTagInfo { imageUrl: string | null; isDashboardTag: boolean; relatedStores: unknown; + sortOrder: number[]; createdAt: Date; } @@ -466,6 +467,7 @@ export interface AdminProductTagAssignment { export interface AdminProductTagWithProducts extends AdminProductTagInfo { products: AdminProductTagAssignment[]; + productIds: number[]; } export interface AdminSpecialDeal { From 8e2e863b84633b6852c2af5d577b9f16475b9851 Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:49:42 +0530 Subject: [PATCH 22/73] enh --- .../app/(drawer)/(tabs)/home/index.tsx | 213 +++++------------- 1 file changed, 58 insertions(+), 155 deletions(-) diff --git a/apps/user-ui/app/(drawer)/(tabs)/home/index.tsx b/apps/user-ui/app/(drawer)/(tabs)/home/index.tsx index 39fe454..65c7a83 100755 --- a/apps/user-ui/app/(drawer)/(tabs)/home/index.tsx +++ b/apps/user-ui/app/(drawer)/(tabs)/home/index.tsx @@ -1,5 +1,5 @@ -import React, { useState, useCallback, useMemo, memo, useRef } from "react"; -import { View, Dimensions, Image, RefreshControl, ScrollView } from "react-native"; +import React, { useState, useCallback, useMemo, memo } from "react"; +import { View, Dimensions, Image, RefreshControl } from "react-native"; import { TabView, TabBar } from "react-native-tab-view"; import { LinearGradient } from "expo-linear-gradient"; import { useRouter } from "expo-router"; @@ -39,7 +39,7 @@ const gridItemWidth = (screenWidth - 48) / 2; // Hero product card geometry (used to size the tab scene heights) const heroCardImageHeight = heroItemWidth * 0.82; -const heroCardTextBlock = 86; +const heroCardTextBlock = 92; const heroCardHeight = heroCardImageHeight + heroCardTextBlock; const heroRowHeight = heroCardHeight + 12; // + mb-3 const TAG_GRID_COLUMNS = 3; @@ -60,9 +60,9 @@ const formatTimeRange = (deliveryTime: string) => { const staticStyles = { flatListContent: { gap: 16 }, - columnWrapper: { gap: 16, paddingHorizontal: 16 }, + columnWrapper: { justifyContent: 'space-between', paddingHorizontal: 16 }, slotsListContent: { paddingBottom: 24 }, -}; +} as const; const TAG_COLORS = [ { pageBg: '#FFF8F9', bg: '#FFF1F2', border: '#FECDD3', text: '#9F1239', dot: '#E11D48' }, // rose @@ -195,45 +195,6 @@ const SlotCard = memo(({ slot }: SlotCardProps) => { ); }); -interface ExploreTabProps { - tag: any; - isSelected: boolean; - onPress: () => void; -} - -const ExploreTab = memo(({ tag, isSelected, onPress }: ExploreTabProps) => { - return ( - - - - {tag.tagName} - - - - - ); -}); - interface TagTabViewProps { dashboardTags: any[]; activeTagId: number | null; @@ -261,17 +222,18 @@ const TagTabView = memo(({ return idx < 0 ? 0 : idx; }, [dashboardTags, activeTagId]); - // Height: fit the max visible product rows across all tags (default 6 per tag). + // Height: fit the active tag's visible product rows (default 6 per tag), + // plus room for the "Show More" button when it has more products. + const activeTagKey = routes[index]?.key; const sceneHeight = useMemo(() => { - let maxRows = 1; - for (const tag of dashboardTags) { - const count = (productsByTagId[tag.id] || []).length; - const visible = expandedByTagId[tag.id] ? count : Math.min(count, 6); - const rows = Math.max(1, Math.ceil(visible / TAG_GRID_COLUMNS)); - maxRows = Math.max(maxRows, rows); - } - return maxRows * heroRowHeight + 16; - }, [dashboardTags, productsByTagId, expandedByTagId]); + const tagId = Number(activeTagKey); + const count = (productsByTagId[tagId]?.length) || 0; + const expanded = expandedByTagId[tagId]; + const visible = expanded ? count : Math.min(count, 6); + const rows = Math.max(1, Math.ceil(visible / TAG_GRID_COLUMNS)); + const showMore = count > 6 && !expanded; + return rows * heroRowHeight + 24 + (showMore ? 52 : 0); + }, [activeTagKey, productsByTagId, expandedByTagId]); const renderTabBar = useCallback((props: any) => ( void; -} - -const StickyTabsRow = memo(({ dashboardTags, activeTagId, onSelectTag }: StickyTabsRowProps) => { - const scrollRef = useRef(null); - - const handleSelect = (id: number) => { - onSelectTag(id); - const idx = dashboardTags.findIndex((tag) => tag.id === id); - if (idx >= 0) { - scrollRef.current?.scrollTo({ x: idx * 96, animated: true }); - } - }; - - return ( - - {dashboardTags.map((tag) => ( - handleSelect(tag.id)} /> - ))} - - ); -}); - interface ExploreProductItemProps { item: any; onPress: (id: number) => void; @@ -408,14 +339,17 @@ const ProductItem = memo(({ item, onPress }: ProductItemProps) => { const handlePress = useCallback(() => onPress(item.id), [item.id, onPress]); return ( - + + + ); }); @@ -429,7 +363,6 @@ interface ListHeaderProps { activeTagId: number | null; productsByTagId: Record; onSelectTag: (id: number) => void; - onTabsSectionLayout: (layout: { y: number; height: number }) => void; } const ListHeader = memo(({ @@ -442,7 +375,6 @@ const ListHeader = memo(({ activeTagId, productsByTagId, onSelectTag, - onTabsSectionLayout, }: ListHeaderProps) => { const handleLayout = useCallback((event: any) => { const { y, height } = event.nativeEvent.layout; @@ -472,10 +404,6 @@ const ListHeader = memo(({ {dashboardTags.length > 0 && ( { - const { y, height } = event.nativeEvent.layout; - onTabsSectionLayout({ y, height }); - }} style={[ tw`mx-4 mb-2 rounded-[28px] px-3 pt-3 pb-[30px]`, { backgroundColor: theme.colors.brand25 }, @@ -549,8 +477,8 @@ const ListHeader = memo(({ )} - - + + @@ -572,11 +500,8 @@ export default function Dashboard() { const [isLoadingDialogOpen, setIsLoadingDialogOpen] = useState(false); const [gradientHeight, setGradientHeight] = useState(0); const [displayedProducts, setDisplayedProducts] = useState([]); + const [visibleCount, setVisibleCount] = useState(21); const [hasMore, setHasMore] = useState(true); - const [isLoadingMore, setIsLoadingMore] = useState(false); - const [searchBarHeight, setSearchBarHeight] = useState(0); - const [tabsSectionLayout, setTabsSectionLayout] = useState({ y: 0, height: 0 }); - const [showStickyTabs, setShowStickyTabs] = useState(false); const { getQuickestSlot } = useProductSlotIdentifier(); const productSlotsMap = useCentralSlotStore((state) => state.productSlotsMap); const refetchProducts = useCentralProductStore((state) => state.refetchProducts); @@ -608,7 +533,7 @@ export default function Dashboard() { return } - const initialBatch = products + const allSorted = products .filter(p => typeof p.id === "number") .sort((a, b) => { const slotA = getQuickestSlot(a.id) @@ -622,10 +547,19 @@ export default function Dashboard() { return 0 }) - setDisplayedProducts(initialBatch) - setHasMore(products.length > 10) + setDisplayedProducts(allSorted) + setVisibleCount(21) + setHasMore(allSorted.length > 21) }, [productsData, productSlotsMap]); + const handleShowMore = useCallback(() => { + setVisibleCount((prev) => { + const next = prev + 21; + setHasMore(next < displayedProducts.length); + return next; + }); + }, [displayedProducts.length]); + const sortedSlots = useMemo(() => { if (!slotsData?.slots) return []; const now = dayjs(); @@ -716,19 +650,6 @@ export default function Dashboard() { router.push("/(drawer)/(tabs)/home/search-results"); }, [router]); - const handleTabsSectionLayout = useCallback((layout: { y: number; height: number }) => { - setTabsSectionLayout(layout); - }, []); - - const handleListScroll = useCallback((event: any) => { - const scrollY = event.nativeEvent.contentOffset.y; - const stickyStart = tabsSectionLayout.y; - const stickyEnd = tabsSectionLayout.y + tabsSectionLayout.height - 56; - const shouldShowStickyTabs = tabsSectionLayout.height > 0 && scrollY > stickyStart && scrollY < stickyEnd; - - setShowStickyTabs((current) => current === shouldShowStickyTabs ? current : shouldShowStickyTabs); - }, [tabsSectionLayout]); - const renderProductItem = useCallback(({ item }: { item: any }) => ( // @@ -745,9 +666,8 @@ export default function Dashboard() { activeTagId={activeTagId} productsByTagId={productsByTagId} onSelectTag={setSelectedTagId} - onTabsSectionLayout={handleTabsSectionLayout} /> - ), [gradientHeight, handleGradientLayout, storesData, sortedSlots, handleProductPress, dashboardTags, activeTagId, productsByTagId, handleTabsSectionLayout]); + ), [gradientHeight, handleGradientLayout, storesData, sortedSlots, handleProductPress, dashboardTags, activeTagId, productsByTagId]); const pageTint = activeTagId == null ? '#FFFFFF' : getTagColor(activeTagId).pageBg; const pageTintStyle = useMemo(() => [ @@ -791,7 +711,6 @@ export default function Dashboard() { setSearchBarHeight(event.nativeEvent.layout.height)} > item.id.toString()} - numColumns={2} + numColumns={3} style={{ backgroundColor: '#FFFFFF' }} - onScroll={handleListScroll} - scrollEventThrottle={16} contentContainerStyle={listContentContainerStyle} columnWrapperStyle={staticStyles.columnWrapper} renderItem={renderProductItem} ListHeaderComponent={listHeader} + ListFooterComponent={ + hasMore ? ( + + + Show More + + + ) : null + } refreshControl={ - {showStickyTabs && dashboardTags.length > 0 && ( - - - - - - )} - From 1729b028d00aedbb1608cd50520ca5ce2fe91a50 Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:45:19 +0530 Subject: [PATCH 23/73] enh --- .../(tabs)/flash-delivery/_layout.tsx | 15 +-- .../app/(drawer)/(tabs)/home/index.tsx | 6 +- apps/user-ui/components/WebViewWrapper.tsx | 15 +-- apps/user-ui/src/hooks/prominent-api-hooks.ts | 115 ++++++++++++++++-- 4 files changed, 110 insertions(+), 41 deletions(-) diff --git a/apps/user-ui/app/(drawer)/(tabs)/flash-delivery/_layout.tsx b/apps/user-ui/app/(drawer)/(tabs)/flash-delivery/_layout.tsx index a9e39dc..edefde9 100644 --- a/apps/user-ui/app/(drawer)/(tabs)/flash-delivery/_layout.tsx +++ b/apps/user-ui/app/(drawer)/(tabs)/flash-delivery/_layout.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, ActivityIndicator } from 'react-native'; +import { View } from 'react-native'; import { Slot } from 'expo-router'; import { trpc } from '@/src/trpc-client'; import { MyText, MyTouchableOpacity, tw, AppContainer } from 'common-ui'; @@ -9,18 +9,7 @@ import { useGetEssentialConsts } from '@/src/hooks/prominent-api-hooks'; export default function FlashDeliveryBaseLayout() { const router = useRouter(); - const { data: essentialConsts, isLoading } = useGetEssentialConsts(); - - if (isLoading) { - return ( - - - - Loading... - - - ); - } + const { data: essentialConsts } = useGetEssentialConsts(); const isFlashDeliveryEnabled = essentialConsts?.isFlashDeliveryEnabled ?? true; diff --git a/apps/user-ui/app/(drawer)/(tabs)/home/index.tsx b/apps/user-ui/app/(drawer)/(tabs)/home/index.tsx index 65c7a83..41c0c7f 100755 --- a/apps/user-ui/app/(drawer)/(tabs)/home/index.tsx +++ b/apps/user-ui/app/(drawer)/(tabs)/home/index.tsx @@ -514,7 +514,7 @@ export default function Dashboard() { error, } = useAllProducts(); - const { data: essentialConsts, isLoading: isLoadingConsts, error: constsError, refetch: refetchConsts } = useGetEssentialConsts(); + const { data: essentialConsts, error: constsError, refetch: refetchConsts } = useGetEssentialConsts(); const { data: storesData, refetch: refetchStores } = useStores(); const { data: slotsData } = useSlots(); @@ -685,11 +685,11 @@ export default function Dashboard() { staticStyles.flatListContent ], []); - if (isLoading || isLoadingConsts) { + if (isLoading) { return ( - {isLoading ? 'Loading products...' : 'Loading app settings...'} + Loading products... ); diff --git a/apps/user-ui/components/WebViewWrapper.tsx b/apps/user-ui/components/WebViewWrapper.tsx index 4c33cd9..8293643 100644 --- a/apps/user-ui/components/WebViewWrapper.tsx +++ b/apps/user-ui/components/WebViewWrapper.tsx @@ -1,9 +1,9 @@ import React, { useState } from 'react'; -import { View, ActivityIndicator } from 'react-native'; +import { View } from 'react-native'; import { WebView } from 'react-native-webview'; import { trpc } from '@/src/trpc-client'; import { useGetEssentialConsts } from '@/src/hooks/prominent-api-hooks'; -import { theme, MyText, MyTouchableOpacity } from 'common-ui'; +import { MyTouchableOpacity } from 'common-ui'; import MaterialIcons from '@expo/vector-icons/MaterialIcons'; interface WebViewWrapperProps { @@ -11,18 +11,9 @@ interface WebViewWrapperProps { } export default function WebViewWrapper({ children }: WebViewWrapperProps) { - const { data: constsData, isLoading } = useGetEssentialConsts(); + const { data: constsData } = useGetEssentialConsts(); const [isClosed, setIsClosed] = useState(false); - if (isLoading) { - return ( - - - Loading... - - ); - } - const webviewHtml = constsData?.webviewHtml; const isWebviewClosable = constsData?.isWebviewClosable; diff --git a/apps/user-ui/src/hooks/prominent-api-hooks.ts b/apps/user-ui/src/hooks/prominent-api-hooks.ts index aada9f8..c0c9a38 100644 --- a/apps/user-ui/src/hooks/prominent-api-hooks.ts +++ b/apps/user-ui/src/hooks/prominent-api-hooks.ts @@ -1,9 +1,10 @@ -import React from 'react' +import React, { useEffect, useState } from 'react' import { useQuery } from '@tanstack/react-query' import axios from 'axios' import { trpc } from '@/src/trpc-client' import { AllProductsApiType, StoresApiType, SlotsApiType, EssentialConstsApiType, BannersApiType, StoreWithProductsApiType, AvailabilityApiType } from "@backend/trpc/router"; import { CACHE_FILENAMES } from "@packages/shared"; +import { StorageServiceCasual } from 'common-ui'; // Local useGetEssentialConsts hook export const useGetEssentialConsts = () => { @@ -13,6 +14,70 @@ export const useGetEssentialConsts = () => { return { ...query, refetch: query.refetch } } +// --------------------------------------------------------------------------- +// Persisted cache helpers — store fetched JSON keyed by its versioned URL. +// Only refetch + repersist when the version (URL) changes. +// --------------------------------------------------------------------------- + +interface PersistedCache { + version: string + data: T +} + +const CACHE_STORAGE_KEYS = { + products: 'cache:products', + stores: 'cache:stores', + slots: 'cache:slots', + banners: 'cache:banners', + availability: 'cache:availability', + storeProducts: (storeId: number) => `cache:store:${storeId}`, +} as const + +async function readPersistedCache(key: string): Promise | null> { + const raw = await StorageServiceCasual.getItem(key) + if (!raw) return null + try { + return JSON.parse(raw) as PersistedCache + } catch { + return null + } +} + +async function writePersistedCache(key: string, version: string, data: T): Promise { + await StorageServiceCasual.setItem(key, JSON.stringify({ version, data })) +} + +/** + * Loads the persisted cache for `storageKey` and reports whether its version + * matches the current `version`. Returns { initialData, isReady }. + * - version matches → serve persisted data, no network refetch needed. + * - version differs → signal to refetch (caller's query fetches + repersists). + */ +function usePersistedCache(storageKey: string, version: string | null) { + const [initialData, setInitialData] = useState(undefined) + const [isReady, setIsReady] = useState(false) + + useEffect(() => { + let cancelled = false + setIsReady(false) + setInitialData(undefined) + if (!version) { + setIsReady(true) + return + } + readPersistedCache(storageKey).then((persisted) => { + if (cancelled) return + if (persisted && persisted.version === version) { + setInitialData(persisted.data) + } + setIsReady(true) + }) + return () => { cancelled = true } + }, [storageKey, version]) + + return { initialData, isReady } +} + type ProductsResponse = AllProductsApiType; type StoresResponse = StoresApiType; type SlotsResponse = SlotsApiType; @@ -76,18 +141,22 @@ function useSlotsCacheUrl(): string | null { export function useAllProducts() { const cacheUrl = useCacheUrl(CACHE_FILENAMES.products) const { data: availabilityData } = useAvailability() + const version = cacheUrl ?? '' + const { initialData, isReady } = usePersistedCache(CACHE_STORAGE_KEYS.products, version) const productsQuery = useQuery({ - queryKey: ['all-products', cacheUrl], + queryKey: ['all-products', version], queryFn: async () => { if (!cacheUrl) { throw new Error('Cache URL not available') } const response = await axios.get(cacheUrl) + await writePersistedCache(CACHE_STORAGE_KEYS.products, version, response.data) return response.data }, staleTime: 60000, // 1 minute - enabled: !!cacheUrl, + enabled: !!cacheUrl && isReady, + placeholderData: initialData, }) const mergedProducts = React.useMemo(() => { @@ -127,71 +196,87 @@ export function useAllProducts() { export function useAvailability() { const cacheUrl = useAvailabilityCacheUrl() + const version = cacheUrl ?? '' + const { initialData, isReady } = usePersistedCache(CACHE_STORAGE_KEYS.availability, version) return useQuery({ - queryKey: ['availability', cacheUrl], + queryKey: ['availability', version], queryFn: async () => { if (!cacheUrl) { throw new Error('Cache URL not available') } const response = await axios.get(cacheUrl) + await writePersistedCache(CACHE_STORAGE_KEYS.availability, version, response.data) return response.data }, staleTime: 60000, // 1 minute - enabled: !!cacheUrl, + enabled: !!cacheUrl && isReady, + placeholderData: initialData, }) } export function useStores() { const cacheUrl = useCacheUrl(CACHE_FILENAMES.stores) + const version = cacheUrl ?? '' + const { initialData, isReady } = usePersistedCache(CACHE_STORAGE_KEYS.stores, version) return useQuery({ - queryKey: ['stores', cacheUrl], + queryKey: ['stores', version], queryFn: async () => { if (!cacheUrl) { throw new Error('Cache URL not available') } const response = await axios.get(cacheUrl) + await writePersistedCache(CACHE_STORAGE_KEYS.stores, version, response.data) return response.data }, staleTime: 60000, // 1 minute - enabled: !!cacheUrl, + enabled: !!cacheUrl && isReady, + placeholderData: initialData, }) } export function useSlots() { const cacheUrl = useSlotsCacheUrl() + const version = cacheUrl ?? '' + const { initialData, isReady } = usePersistedCache(CACHE_STORAGE_KEYS.slots, version) return useQuery({ - queryKey: ['slots', cacheUrl], + queryKey: ['slots', version], queryFn: async () => { if (!cacheUrl) { throw new Error('Cache URL not available') } const response = await axios.get(cacheUrl+'?v=123') + await writePersistedCache(CACHE_STORAGE_KEYS.slots, version, response.data) return response.data }, staleTime: 60000, // 1 minute - enabled: !!cacheUrl, + enabled: !!cacheUrl && isReady, + placeholderData: initialData, }) } export function useBanners() { const cacheUrl = useCacheUrl(CACHE_FILENAMES.banners) + const version = cacheUrl ?? '' + const { initialData, isReady } = usePersistedCache(CACHE_STORAGE_KEYS.banners, version) return useQuery({ - queryKey: ['banners', cacheUrl], + queryKey: ['banners', version], queryFn: async () => { if (!cacheUrl) { throw new Error('Cache URL not available') } const response = await axios.get(cacheUrl) + await writePersistedCache(CACHE_STORAGE_KEYS.banners, version, response.data) return response.data }, staleTime: 60000, // 1 minute - enabled: !!cacheUrl, + enabled: !!cacheUrl && isReady, + placeholderData: initialData, }) } @@ -205,17 +290,21 @@ export function useStoreWithProducts(storeId: number) { const cacheUrl = assetsDomain && apiCacheKey ? `${assetsDomain}${apiCacheKey}/v-${cacheVersion}/stores/${storeId}.json` : null + const version = cacheUrl ?? '' + const { initialData, isReady } = usePersistedCache(CACHE_STORAGE_KEYS.storeProducts(storeId), version) return useQuery({ - queryKey: ['store-with-products', storeId, cacheUrl], + queryKey: ['store-with-products', storeId, version], queryFn: async () => { if (!cacheUrl) { throw new Error('Cache URL not available') } const response = await axios.get(cacheUrl) + await writePersistedCache(CACHE_STORAGE_KEYS.storeProducts(storeId), version, response.data) return response.data }, staleTime: 60000, // 1 minute - enabled: !!cacheUrl, + enabled: !!cacheUrl && isReady, + placeholderData: initialData, }) } From df84e4a6c9c13f7f3370d82c71e482b75b05212b Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:25:14 +0530 Subject: [PATCH 24/73] enh --- apps/backend/src/lib/env-exporter.ts | 7 ++++ apps/backend/src/lib/otp-utils.ts | 40 ++++++++++++------- .../src/trpc/apis/user-apis/apis/auth.ts | 2 +- apps/backend/wrangler.dev.toml | 4 ++ apps/backend/wrangler.prod.toml | 4 ++ 5 files changed, 41 insertions(+), 16 deletions(-) diff --git a/apps/backend/src/lib/env-exporter.ts b/apps/backend/src/lib/env-exporter.ts index 5a09676..2c85aa5 100755 --- a/apps/backend/src/lib/env-exporter.ts +++ b/apps/backend/src/lib/env-exporter.ts @@ -1,4 +1,6 @@ +import type { KVNamespace } from '@cloudflare/workers-types' + // Old env loading (Node only) // export const appUrl = process.env.APP_URL as string; // @@ -109,6 +111,11 @@ export const getRazorpaySecret = () => getRuntimeEnv().RAZORPAY_SECRET as string export const getOtpSenderAuthToken = () => getRuntimeEnv().OTP_SENDER_AUTH_TOKEN as string +export const getOtpKvNamespace = () => { + const env = getRuntimeEnv() + return (env.freshyo_otp || env.freshyo_otp_dev) as KVNamespace | undefined +} + export const getMinOrderValue = () => Number(getRuntimeEnv().MIN_ORDER_VALUE as string) export const getDeliveryCharge = () => Number(getRuntimeEnv().DELIVERY_CHARGE as string) diff --git a/apps/backend/src/lib/otp-utils.ts b/apps/backend/src/lib/otp-utils.ts index 1d19148..d7b82be 100644 --- a/apps/backend/src/lib/otp-utils.ts +++ b/apps/backend/src/lib/otp-utils.ts @@ -1,26 +1,37 @@ import { ApiError } from '@/src/lib/api-error' -import { getOtpSenderAuthToken } from '@/src/lib/env-exporter' +import { getOtpSenderAuthToken, getOtpKvNamespace } from '@/src/lib/env-exporter' +import type { KVNamespace } from '@cloudflare/workers-types' -const otpStore = new Map(); +const OTP_TTL_SECONDS = 300 + +const otpKey = (phone: string) => `otp:${phone}` + +const getKv = (): KVNamespace => { + const kv = getOtpKvNamespace() + if (!kv) { + throw new ApiError('OTP service not configured', 500) + } + return kv +} const setOtpCreds = (phone: string, verificationId: string) => { - otpStore.set(phone, verificationId); -}; + return getKv().put(otpKey(phone), verificationId, { expirationTtl: OTP_TTL_SECONDS }) +} -export function getOtpCreds(mobile: string) { - const authKey = otpStore.get(mobile); +export async function getOtpCreds(mobile: string) { + const authKey = await getKv().get(otpKey(mobile)) return authKey || null; } +const clearOtpCreds = (phone: string) => { + return getKv().delete(otpKey(phone)) +} + export const sendOtp = async (phone: string) => { if (!phone) { throw new ApiError("Phone number is required", 400); } - if (phone === '9676651496') { - setOtpCreds(phone, 'DEV_BYPASS_VERIFICATION_ID'); - return { success: true, message: 'OTP sent successfully (dev bypass)' }; - } const reqUrl = `https://cpaas.messagecentral.com/verification/v3/send?countryCode=91&flowType=SMS&mobileNumber=${phone}&timeout=300`; const resp = await fetch(reqUrl, { headers: { @@ -29,9 +40,10 @@ export const sendOtp = async (phone: string) => { method: "POST", }); const data = await resp.json(); - + + console.log({data}) if (data.message === "SUCCESS") { - setOtpCreds(phone, data.data.verificationId); + await setOtpCreds(phone, data.data.verificationId); return { success: true, message: "OTP sent successfully", verificationId: data.data.verificationId }; } if (data.message === "REQUEST_ALREADY_EXISTS") { @@ -42,9 +54,6 @@ export const sendOtp = async (phone: string) => { }; export async function verifyOtpUtil(mobile: string, otp: string, verifId: string):Promise { - if (mobile === '9676651496') { - return otp === '1234'; - } const reqUrl = `https://cpaas.messagecentral.com/verification/v3/validateOtp?&verificationId=${verifId}&code=${otp}`; const resp = await fetch(reqUrl, { method: "GET", @@ -56,6 +65,7 @@ export async function verifyOtpUtil(mobile: string, otp: string, verifId: string const rawData = await resp.json(); if (rawData.data?.verificationStatus === "VERIFICATION_COMPLETED") { // delete the verificationId from the local storage + await clearOtpCreds(mobile); return true; } return false; diff --git a/apps/backend/src/trpc/apis/user-apis/apis/auth.ts b/apps/backend/src/trpc/apis/user-apis/apis/auth.ts index 6de039d..af5a4e1 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/auth.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/auth.ts @@ -224,7 +224,7 @@ export const authRouter = router({ otp: z.string(), })) .mutation(async ({ input, ctx }): Promise => { - const verificationId = getOtpCreds(input.mobile); + const verificationId = await getOtpCreds(input.mobile); if (!verificationId) { throw new ApiError("OTP not sent or expired", 400); } diff --git a/apps/backend/wrangler.dev.toml b/apps/backend/wrangler.dev.toml index b0f643c..dfba29c 100644 --- a/apps/backend/wrangler.dev.toml +++ b/apps/backend/wrangler.dev.toml @@ -105,3 +105,7 @@ crons = [ [build] upload_source_maps = true + +[[kv_namespaces]] +binding="freshyo_otp_dev" +id="6f6dbada52584d7fb744c9c85bd0c1e5" \ No newline at end of file diff --git a/apps/backend/wrangler.prod.toml b/apps/backend/wrangler.prod.toml index 14446a5..6eec64a 100644 --- a/apps/backend/wrangler.prod.toml +++ b/apps/backend/wrangler.prod.toml @@ -98,3 +98,7 @@ crons = ["0 16 * * *", "0 1 * * *"] [build] upload_source_maps = true + +[[kv_namespaces]] +binding="freshyo_otp" +id="aba74be264df45c0b17757fe01c136e5" \ No newline at end of file From 25f3fb099c6ca6b8b1e34df9240641e45a78c282 Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:26:49 +0530 Subject: [PATCH 25/73] enh --- .commandcode/taste/taste/taste.md | 2 ++ apps/user-ui/src/hooks/prominent-api-hooks.ts | 12 +++++------ .../db_helper_sqlite/src/admin-apis/order.ts | 13 ++++++++---- .../db_helper_sqlite/src/user-apis/order.ts | 21 ++++++++++++++++++- .../db_helper_sqlite/src/user-apis/product.ts | 21 ++++++++++++++++++- 5 files changed, 57 insertions(+), 12 deletions(-) diff --git a/.commandcode/taste/taste/taste.md b/.commandcode/taste/taste/taste.md index 39d3aab..8a46220 100644 --- a/.commandcode/taste/taste/taste.md +++ b/.commandcode/taste/taste/taste.md @@ -11,3 +11,5 @@ - Prefers using icons from Hicons (rather than custom/hand-authored SVGs or other icon libraries). Confidence: 0.9 - When a feature or page is repurposed, wants file names, route names, and code references renamed to match the new purpose (not just UI content/icons). Confidence: 0.8 - Wants analysis findings and plans persisted in a markdown file before any implementation begins, with explicit confirmation that implementation is paused until instructed. Confidence: 0.8 +- When changing cache or storage keys, wants both the read and write paths verified to use the same single source of truth (e.g., a shared `CACHE_STORAGE_KEYS` constant). Confidence: 0.8 +- In React Native with react-native-paper's `Text` component (wrapped as `MyText`), prefers avoiding mixed string/expression children; use template literals to produce a single string child to prevent "Text strings must be rendered within a component" warnings. Confidence: 0.8 diff --git a/apps/user-ui/src/hooks/prominent-api-hooks.ts b/apps/user-ui/src/hooks/prominent-api-hooks.ts index c0c9a38..f1f5cf7 100644 --- a/apps/user-ui/src/hooks/prominent-api-hooks.ts +++ b/apps/user-ui/src/hooks/prominent-api-hooks.ts @@ -25,12 +25,12 @@ interface PersistedCache { } const CACHE_STORAGE_KEYS = { - products: 'cache:products', - stores: 'cache:stores', - slots: 'cache:slots', - banners: 'cache:banners', - availability: 'cache:availability', - storeProducts: (storeId: number) => `cache:store:${storeId}`, + products: 'cache_products', + stores: 'cache_stores', + slots: 'cache_slots', + banners: 'cache_banners', + availability: 'cache_availability', + storeProducts: (storeId: number) => `cache_store_${storeId}`, } as const async function readPersistedCache(key: string): Promise | null> { diff --git a/packages/db_helper_sqlite/src/admin-apis/order.ts b/packages/db_helper_sqlite/src/admin-apis/order.ts index 3165739..66b5d41 100644 --- a/packages/db_helper_sqlite/src/admin-apis/order.ts +++ b/packages/db_helper_sqlite/src/admin-apis/order.ts @@ -606,7 +606,11 @@ export async function rebalanceSlots(slotIds: number[]): Promise { let newTotal = order.orderItems.reduce((acc: number, item: any) => { - const latestPrice = +item.sku.price + const latestPrice = +(item.sku?.marketStats?.ourPrice ?? 0) const amount = latestPrice * Number(item.quantity) return acc + amount }, 0) order.orderItems.forEach((item: any) => { - item.price = item.sku.price - item.discountedPrice = item.sku.price + const latestPrice = item.sku?.marketStats?.ourPrice + item.price = latestPrice + item.discountedPrice = latestPrice }) const coupon = order.couponUsages[0]?.coupon diff --git a/packages/db_helper_sqlite/src/user-apis/order.ts b/packages/db_helper_sqlite/src/user-apis/order.ts index 8456968..aa82550 100644 --- a/packages/db_helper_sqlite/src/user-apis/order.ts +++ b/packages/db_helper_sqlite/src/user-apis/order.ts @@ -267,9 +267,28 @@ export async function getAddressByIdAndUser( } export async function getProductById(skuId: number) { - return db.query.productSkus.findFirst({ + const sku = await db.query.productSkus.findFirst({ where: eq(productSkus.id, skuId), + with: { + marketStats: true, + product: true, + }, }) + + if (!sku) { + return null + } + + const marketStats = sku.marketStats + + return { + ...sku, + price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0', + marketPrice: marketStats?.marketPrice ? String(marketStats.marketPrice) : null, + flashPrice: marketStats?.flashPrice ? String(marketStats.flashPrice) : null, + isFlashAvailable: marketStats?.isFlashAvailable ?? false, + isOutOfStock: marketStats?.isOutOfStock ?? false, + } } export async function checkUserSuspended(userId: number): Promise { diff --git a/packages/db_helper_sqlite/src/user-apis/product.ts b/packages/db_helper_sqlite/src/user-apis/product.ts index 36ee830..d49ac3c 100644 --- a/packages/db_helper_sqlite/src/user-apis/product.ts +++ b/packages/db_helper_sqlite/src/user-apis/product.ts @@ -141,9 +141,28 @@ export async function getProductReviews(productId: number, limit: number, offset } export async function getProductById(skuId: number) { - return db.query.productSkus.findFirst({ + const sku = await db.query.productSkus.findFirst({ where: eq(productSkus.id, skuId), + with: { + marketStats: true, + product: true, + }, }) + + if (!sku) { + return null + } + + const marketStats = sku.marketStats + + return { + ...sku, + price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0', + marketPrice: marketStats?.marketPrice ? String(marketStats.marketPrice) : null, + flashPrice: marketStats?.flashPrice ? String(marketStats.flashPrice) : null, + isFlashAvailable: marketStats?.isFlashAvailable ?? false, + isOutOfStock: marketStats?.isOutOfStock ?? false, + } } export async function createProductReview( From e5d9de33d701ed99d3bd92fe2238a12fd47f3037 Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:26:20 +0530 Subject: [PATCH 26/73] enh --- .commandcode/settings.json | 3 +- .commandcode/taste/taste/taste.md | 6 + test-plan-neo.md | 1489 +++++++++++++++++++++++++++++ 3 files changed, 1497 insertions(+), 1 deletion(-) create mode 100644 test-plan-neo.md diff --git a/.commandcode/settings.json b/.commandcode/settings.json index 187814d..185d922 100644 --- a/.commandcode/settings.json +++ b/.commandcode/settings.json @@ -4,7 +4,8 @@ "Shell(npx tsc --noEmit --skipLibCheck src/trpc/apis/common-apis/common.ts 2 >& 1)", "Shell(npx tsc --noEmit 2 >& 1)", "Shell(grep:*)", - "Shell(npx tsc --noEmit -p packages/shared/tsconfig.json 2 >& 1)" + "Shell(npx tsc --noEmit -p packages/shared/tsconfig.json 2 >& 1)", + "Shell(cp:*)" ], "deny": [], "defaultMode": "default" diff --git a/.commandcode/taste/taste/taste.md b/.commandcode/taste/taste/taste.md index 8a46220..dca9b0e 100644 --- a/.commandcode/taste/taste/taste.md +++ b/.commandcode/taste/taste/taste.md @@ -1,6 +1,7 @@ # Taste - Prefers that the agent not run typechecks; instead, read the project's AGENTS.md for instructions on what to do/not do before finalizing work. Confidence: 0.9 - Prefers detailed analysis and explicit error/mistake checking before executing git operations (e.g., merges), rather than just performing the action. Confidence: 0.6 +- Do not run `git stash` or other destructive git operations that would discard or modify working-tree changes without explicit permission. Confidence: 0.9 - When removing unused code (e.g., an unused tRPC procedure), prefers a complete cleanup: also remove internal helper methods used only by it, associated types, re-exports, orphaned hook/API files and their shared types, and even commented-out imports, leaving zero remaining references. Confidence: 0.9 - Before removing or deciding on code, likes to first verify where it is actually used across the codebase (asks questions like "where is X used"), rather than removing based on assumption. Confidence: 0.4 - Prefers light/pastel colors for randomized or accent UI elements (e.g., tabs, chips). Confidence: 0.7 @@ -13,3 +14,8 @@ - Wants analysis findings and plans persisted in a markdown file before any implementation begins, with explicit confirmation that implementation is paused until instructed. Confidence: 0.8 - When changing cache or storage keys, wants both the read and write paths verified to use the same single source of truth (e.g., a shared `CACHE_STORAGE_KEYS` constant). Confidence: 0.8 - In React Native with react-native-paper's `Text` component (wrapped as `MyText`), prefers avoiding mixed string/expression children; use template literals to produce a single string child to prevent "Text strings must be rendered within a component" warnings. Confidence: 0.8 +- When requesting test plans, expects exhaustive coverage across all relevant apps/screens/routes, including every functionality and every edge case. Confidence: 0.9 +- Prefers test case documentation to be structured with preconditions, steps, explicit "things to test" checklist, and expected results. Confidence: 0.7 +- Prefers test plans written for a non-technical audience, using plain-language, click-by-click instructions ("tap this", "type that", "check there") rather than technical terms or API names. Confidence: 0.9 +- When updating or creating a document, prefers the agent first compare it against existing source documents, identify missing items or gaps, and add them in the same established format. Confidence: 0.9 +- Dislikes nested headers in mobile/drawer navigation; prefers a single, shared header (e.g., the drawer header) and relies on device back buttons or gestures for returning to previous screens. Confidence: 0.9 diff --git a/test-plan-neo.md b/test-plan-neo.md new file mode 100644 index 0000000..4d3f07f --- /dev/null +++ b/test-plan-neo.md @@ -0,0 +1,1489 @@ +# Freshyo — Test Plan for Non-Technical Testers + +This plan tells you exactly what to do, step by step, and what you should see. You don't need to know any technical details. Just follow the steps and tick the tests that pass. + +**How to use this document** +- Each test has: + - **What you need** — the setup before you start. + - **Steps** — exactly what to tap/type/do. + - **Things to test** — the checklist of things you must try. + - **Expected** — what must happen for the test to PASS. +- If anything in **Expected** does not happen, that test has **FAILED** — note it down and tell the developer. +- Test on a phone/emulator with internet. Keep the app updated to the latest build. + +--- + +# PART 1 — ADMIN APP (the staff app, used by the shop team) + +## 1.1 Logging in + +### 1.1.1 Login with correct details +- **What you need:** Admin login (mobile + password) given by the developer. +- **Steps:** 1. Open the Admin app. 2. Type your mobile number. 3. Type your password. 4. Tap "Login". +- **Things to test:** Logging in with the correct mobile and password. +- **Expected:** The home screen (Dashboard) opens. + +### 1.1.2 Login with wrong password +- **What you need:** Admin app open on the login screen. +- **Steps:** 1. Type a wrong password. 2. Tap "Login". +- **Things to test:** What happens when the password is wrong. +- **Expected:** A red error message appears. You stay on the login screen. You are NOT logged in. + +### 1.1.3 Login with empty fields +- **What you need:** Login screen open. +- **Steps:** 1. Leave everything blank. 2. Tap "Login". +- **Things to test:** Tapping Login with nothing filled in. +- **Expected:** An error message appears telling you to fill the fields. Nothing else happens. + +### 1.1.4 Logout +- **What you need:** You are logged in. +- **Steps:** 1. Open the menu (side panel). 2. Tap "Logout". +- **Things to test:** Logging out from the menu. +- **Expected:** You go back to the login screen. + +### 1.1.5 Jump straight to a page without login +- **What you need:** You are logged out. +- **Steps:** 1. Ask the developer for a direct link to any page. 2. Open it. +- **Things to test:** Opening a protected page without being logged in. +- **Expected:** You are sent to the login screen first. + +## 1.2 Dashboard (home screen) + +### 1.2.1 Dashboard shows numbers +- **What you need:** Some orders, users and products exist. +- **Steps:** 1. Log in. 2. Look at the main screen. +- **Things to test:** The dashboard loads and shows the summary boxes. +- **Expected:** You see boxes with numbers: total orders, total users, total products. + +### 1.2.2 Refresh the dashboard +- **What you need:** Dashboard is open. +- **Steps:** 1. Put your finger on the screen and pull down. 2. Let go. +- **Things to test:** Pull-to-refresh. +- **Expected:** The numbers reload (may flicker). No error appears. + +### 1.2.3 Dashboard when there is no data +- **What you need:** A brand-new account with no data. +- **Steps:** 1. Log in with an empty account. +- **Things to test:** Dashboard with zero data. +- **Expected:** The numbers show 0. The screen does not crash. + +## 1.3 Products + +### 1.3.1 Product list +- **What you need:** At least 1 product exists. +- **Steps:** 1. Open the menu. 2. Tap "Products". +- **Things to test:** The product list loads and shows items. +- **Expected:** A list of products appears with pictures and names. + +### 1.3.2 Search products +- **What you need:** Many products exist. +- **Steps:** 1. Go to Products. 2. Type part of a product name in the search box. +- **Things to test:** Typing in the search box filters the list. +- **Expected:** Only matching products remain in the list. + +### 1.3.3 Add a normal product +- **What you need:** Admin login. +- **Steps:** 1. Go to Products. 2. Tap "Add Product". 3. Type a product name. 4. Add a photo. 5. Set a price. 6. Tap "Save". +- **Things to test:** Adding a product with all fields filled; the photo uploads. +- **Expected:** A success message appears. The new product is in the list. + +### 1.3.4 Add product without a name +- **What you need:** Add Product page open. +- **Steps:** 1. Leave the name empty. 2. Tap "Save". +- **Things to test:** Saving with a required field missing. +- **Expected:** A message tells you the name is required. Nothing is saved. + +### 1.3.5 Add a combo product +- **What you need:** Admin login. +- **Steps:** 1. Go to Products. 2. Tap "Add Product". 3. Choose "Combo" type. 4. Pick 2 or more items that make up the combo. 5. Set the combo price. 6. Tap "Save". +- **Things to test:** Adding a combo made of multiple items. +- **Expected:** The combo appears in the list. + +### 1.3.6 Edit a product +- **What you need:** A product exists. +- **Steps:** 1. Go to Products. 2. Tap a product. 3. Tap "Edit". 4. Change the name or price. 5. Tap "Save". +- **Things to test:** Changing a product's name and price. +- **Expected:** Success message. The product shows the new name/price. + +### 1.3.7 Delete a product +- **What you need:** A product exists. +- **Steps:** 1. Go to Products. 2. Tap a product. 3. Tap "Delete". 4. Confirm "Yes". +- **Things to test:** Deleting a product and the confirmation box. +- **Expected:** Success message. The product disappears from the list. + +### 1.3.8 View product details +- **What you need:** A product exists. +- **Steps:** 1. Go to Products. 2. Tap a product. +- **Things to test:** The details page shows all product information. +- **Expected:** You see the product's photos, prices, sizes, reviews and tags. + +### 1.3.9 Update many prices at once +- **What you need:** Products exist. +- **Steps:** 1. Open menu. 2. Tap "Prices Overview". 3. Change some prices in the table. 4. Tap "Save". +- **Things to test:** Changing several prices in one go. +- **Expected:** Success message. Prices are updated. + +### 1.3.10 Add a product with a name that already exists +- **What you need:** A product with the same name exists. +- **Steps:** 1. Go to Products. 2. Tap "Add Product". 3. Type the same name as an existing product. 4. Tap "Save". +- **Things to test:** Duplicate product names. +- **Expected:** An error message says a product with this name already exists. Nothing is saved. + +### 1.3.11 Add a product with zero or negative price +- **What you need:** Add Product page open. +- **Steps:** 1. Enter a price of 0 or a negative number (e.g. -10). 2. Tap "Save". +- **Things to test:** Invalid price values. +- **Expected:** A validation error says the price must be a positive number. Nothing is saved. + +### 1.3.12 Add a product with no SKU features +- **What you need:** Add Product page open. +- **Steps:** 1. Fill the product name and store. 2. Add a SKU but no feature (no size/quantity). 3. Tap "Save". +- **Things to test:** A SKU with no features. +- **Expected:** A validation error says at least one feature is required. + +### 1.3.13 Edit a product — replace the photo +- **What you need:** A product with a photo exists. +- **Steps:** 1. Open the product. 2. Tap "Edit". 3. Upload a new photo in place of the old one. 4. Tap "Save". +- **Things to test:** Replacing a product photo. +- **Expected:** The new photo is saved. The old photo is removed (not left behind). + +### 1.3.14 Add a product with a negative market price or flash price +- **What you need:** Add Product page open. +- **Steps:** 1. Enter a negative market price (e.g. -10). 2. Try to save. 3. Repeat with a negative flash price. +- **Things to test:** Negative market/flash prices. +- **Expected:** A validation error says the price cannot be negative. Nothing is saved. + +## 1.4 Product Tags (labels like "Chicken", "Bestseller") + +### 1.4.1 Tag list +- **What you need:** At least 1 tag exists. +- **Steps:** 1. Open menu. 2. Tap "Product Tags". +- **Things to test:** The tag list loads with cards. +- **Expected:** Cards appear, each with a tag name, picture (if any) and a green "Dashboard Tag" mark (if it is one). + +### 1.4.2 Add a tag +- **What you need:** Admin login. +- **Steps:** 1. Go to Product Tags. 2. Tap "Add New Tag". 3. Type a name. 4. (Optional) add a picture. 5. (Optional) tick "Mark as Dashboard Tag". 6. (Optional) pick related stores. 7. Tap "Create Tag". +- **Things to test:** Adding a tag with and without a picture; ticking the Dashboard Tag box; picking stores. +- **Expected:** Success message. The tag appears in the list. + +### 1.4.3 Add a tag with a name that already exists +- **What you need:** A tag with the same name exists. +- **Steps:** 1. Go to Product Tags. 2. Tap "Add New Tag". 3. Type the same name as an existing tag. 4. Tap "Create Tag". +- **Things to test:** Duplicate tag names. +- **Expected:** An error message says a tag with this name already exists. + +### 1.4.4 Add products to a tag +- **What you need:** At least 2 products exist. +- **Steps:** 1. Go to Product Tags. 2. Tap "Add New Tag". 3. Type a name. 4. Scroll to "Products". 5. Tap the product selector and pick 2–3 products. 6. Check the products appear as a list under it. 7. Tap "Create Tag". +- **Things to test:** Selecting products for a tag; the selected products appearing in a list in the order picked. +- **Expected:** The chosen products show as a list, in the order you picked them. + +### 1.4.5 Reorder products inside a tag +- **What you need:** You are on the Add/Edit Tag page with products added. +- **Steps:** 1. Press and hold the drag handle (six dots) of a product. 2. Drag it up or down. 3. Let go. 4. Tap Save/Create. +- **Things to test:** Dragging a product to a new position; the order is kept after saving and reopening. +- **Expected:** The product moves to the new position. After saving and reopening, the order is the same. + +### 1.4.6 Remove a product from a tag +- **What you need:** Tag page has products. +- **Steps:** 1. Tap the "X" on a product in the list. +- **Things to test:** Removing one product from the tag. +- **Expected:** The product disappears from the list. + +### 1.4.7 Edit a tag — products come back automatically +- **What you need:** A tag that already has products. +- **Steps:** 1. Go to Product Tags. 2. Tap the three dots on a tag. 3. Tap "Edit Tag". +- **Things to test:** That the products already in the tag are pre-filled when editing. +- **Expected:** The tag's products are already filled in the list, in the saved order. + +### 1.4.8 Delete a tag +- **What you need:** A tag exists. +- **Steps:** 1. Go to Product Tags. 2. Tap the three dots on a tag. 3. Tap "Delete". 4. Confirm. +- **Things to test:** Deleting a tag and the confirmation box. +- **Expected:** Success message. The tag disappears. + +### 1.4.9 Tag Orders page — reorder the tags themselves +- **What you need:** At least 2 tags exist. +- **Steps:** 1. Go to Product Tags. 2. Tap "Tag Orders". 3. Press and hold the drag handle of a tag. 4. Drag it to a new position. 5. Tap "Save". +- **Things to test:** Dragging tags into a new order and saving. +- **Expected:** Success message. The order is saved. (The customer app will show tabs in this order.) + +### 1.4.10 Tag Orders page — no tags +- **What you need:** No tags exist. +- **Steps:** 1. Go to Product Tags. 2. Tap "Tag Orders". +- **Things to test:** The page with zero tags. +- **Expected:** A message "No tags available" appears. + +### 1.4.11 Tag Orders — Save button before any change +- **What you need:** Tag Orders page open. +- **Steps:** 1. Look at the "Save" button without dragging anything. +- **Things to test:** The Save button state before any change. +- **Expected:** The Save button is greyed out / cannot be tapped. + +## 1.5 Product Groups + +### 1.5.1 Create a group +- **What you need:** Products exist. +- **Steps:** 1. Open menu. 2. Tap "Product Groupings". 3. Tap "Create". 4. Type a group name. 5. Pick products. 6. Tap "Save". +- **Things to test:** Creating a group with a name and products. +- **Expected:** Success message. Group appears in the list. + +### 1.5.2 Create group with duplicate name +- **What you need:** A group with the same name exists. +- **Steps:** 1. Create a group with the same name again. +- **Things to test:** Duplicate group names. +- **Expected:** Error message. + +### 1.5.3 Edit and delete a group +- **What you need:** A group exists. +- **Steps:** 1. Tap the group. 2. Edit the name. 3. Save. 4. Then delete it and confirm. +- **Things to test:** Editing then deleting a group. +- **Expected:** Edit saves; delete removes it from the list. + +## 1.6 Stores + +### 1.6.1 Store list +- **What you need:** Stores exist. +- **Steps:** 1. Open menu. 2. Tap "Stores". +- **Things to test:** The store list loads. +- **Expected:** Cards with store names and photos. + +### 1.6.2 Add a store +- **What you need:** Admin login. +- **Steps:** 1. Go to Stores. 2. Tap "Add". 3. Type a name. 4. Add a photo. 5. Type a description. 6. Tap "Save". +- **Things to test:** Adding a store with photo and description. +- **Expected:** Success message. Store appears. + +### 1.6.3 Edit a store +- **What you need:** A store exists. +- **Steps:** 1. Tap the store. 2. Tap "Edit". 3. Change the name. 4. Save. +- **Things to test:** Editing a store's name. +- **Expected:** New name is saved. + +### 1.6.4 Delete a store +- **What you need:** A store exists. +- **Steps:** 1. Tap the store. 2. Tap "Delete". 3. Confirm. +- **Things to test:** Deleting a store and the confirmation box. +- **Expected:** Store disappears. + +### 1.6.5 Add a store without a photo +- **What you need:** On Add Store page. +- **Steps:** 1. Fill name only, no photo. 2. Save. +- **Things to test:** Adding a store with no photo. +- **Expected:** Store is created; a placeholder icon shows instead of a photo. + +## 1.7 Delivery Slots (delivery times) + +### 1.7.1 Slot list +- **What you need:** Slots exist. +- **Steps:** 1. Open menu. 2. Tap "Slots". +- **Things to test:** The slot list loads. +- **Expected:** A list of slots with delivery time, order-close time and capacity. + +### 1.7.2 Add a slot +- **What you need:** Admin login. +- **Steps:** 1. Go to Slots. 2. Tap "Add". 3. Pick a delivery time (future). 4. Pick an order-close time (before delivery). 5. Pick products for this slot. 6. Tap "Save". +- **Things to test:** Adding a slot with future times and products. +- **Expected:** Success message. Slot appears in the list. + +### 1.7.3 Add a slot with a past delivery time +- **What you need:** Add Slot page open. +- **Steps:** 1. Pick a delivery time in the past. 2. Try to save. +- **Things to test:** Saving a slot with a past time. +- **Expected:** An error tells you the time must be in the future. + +### 1.7.4 Add a slot where close time is after delivery time +- **What you need:** Add Slot page open. +- **Steps:** 1. Set close time later than delivery time. 2. Try to save. +- **Things to test:** Close time after delivery time. +- **Expected:** An error appears. + +### 1.7.5 Edit a slot +- **What you need:** A slot exists. +- **Steps:** 1. Tap the slot. 2. Edit the time or products. 3. Save. +- **Things to test:** Editing a slot's time and products. +- **Expected:** Saved. (The customer app will show the change within a minute.) + +### 1.7.6 Delete a slot +- **What you need:** A slot exists. +- **Steps:** 1. Tap the slot. 2. Delete. 3. Confirm. +- **Things to test:** Deleting a slot and the confirmation box. +- **Expected:** Slot disappears. + +### 1.7.7 Turn slot capacity off +- **What you need:** A slot exists. +- **Steps:** 1. Tap the slot. 2. Find the "Capacity Full" switch. 3. Turn it on. 4. Check it stays on. +- **Things to test:** Marking a slot as full. +- **Expected:** The slot is now marked full and customers can no longer pick it. + +### 1.7.8 Copy (replicate) an existing slot +- **What you need:** A slot exists. +- **Steps:** 1. On the Slots page, find the "Replicate"/"Copy" option on a slot. 2. Tap it. 3. Change the delivery time to a new future time. 4. Tap "Save". +- **Things to test:** Copying a slot and saving it as a new one. +- **Expected:** The Add Slot form opens pre-filled with the copied slot's products and times. Saving creates a NEW slot (the original is unchanged). + +## 1.8 Manage Orders + +### 1.8.1 Orders list +- **What you need:** At least 1 order exists. +- **Steps:** 1. Open menu. 2. Tap "Manage Orders". 3. Tap "Orders". +- **Things to test:** The orders list loads with cards. +- **Expected:** Cards appear with customer name, order number, time, slot and total. + +### 1.8.2 Filter by slot +- **What you need:** More than 1 slot with orders. +- **Steps:** 1. On Orders, tap the "Select Slot" dropdown at top. 2. Pick one slot. +- **Things to test:** Filtering orders by a slot. +- **Expected:** Only orders for that slot remain. + +### 1.8.3 Flash orders filter +- **What you need:** At least 1 flash (1-hour) order. +- **Steps:** 1. Tap the dropdown. 2. Pick "⚡ Flash Deliveries". +- **Things to test:** The flash filter. +- **Expected:** Only flash orders remain. + +### 1.8.4 Packaged / Delivered filters +- **What you need:** Orders exist. +- **Steps:** 1. Tap the filter icon (funnel). 2. Tick "Packaged". 3. Tap Done. +- **Things to test:** Each status filter (Packaged, Delivered, Cancelled, Delivery Type). +- **Expected:** Only matching orders show for each filter. + +### 1.8.5 Mark an order as packaged +- **What you need:** An order exists. +- **Steps:** 1. On an order card, tap the "Packaged" checkbox. +- **Things to test:** Ticking the packaged box on the card. +- **Expected:** The box gets a tick. + +### 1.8.6 Mark an order as delivered +- **What you need:** An order exists. +- **Steps:** 1. On an order card, tap the "Delivered" checkbox. +- **Things to test:** Ticking the delivered box on the card. +- **Expected:** The box gets a tick. + +### 1.8.7 Items count on card +- **What you need:** An order with 3 items. +- **Steps:** 1. Look at the card. +- **Things to test:** The item count text. +- **Expected:** It says "3 items". + +### 1.8.8 Order menu (three dots) +- **What you need:** An order exists. +- **Steps:** 1. Tap the three dots on a card. +- **Things to test:** The menu opens with all options: View Details, Packaged, Delivered, Admin Notes, Cancel Order, Attach Location, WhatsApp, Dial. +- **Expected:** Menu opens and every option works when tapped. + +### 1.8.9 Open order details +- **What you need:** An order exists. +- **Steps:** 1. Tap an order card. +- **Things to test:** The order details page shows everything. +- **Expected:** Full order page: items, quantities, prices, address, status, notes. + +### 1.8.10 Toggle item packaging inside an order +- **What you need:** Order has items. +- **Steps:** 1. Open order details. 2. Tap "pkg" checkbox on an item. +- **Things to test:** The pkg and verf checkboxes on each item. +- **Expected:** Box ticks. Same for "verf". + +### 1.8.11 Remove delivery charge +- **What you need:** An order with delivery charge. +- **Steps:** 1. Open order details. 2. Find "Remove Delivery Charge". 3. Tap it. +- **Things to test:** Removing the delivery charge. +- **Expected:** Charge becomes zero; total drops. + +### 1.8.12 Add admin notes +- **What you need:** An order exists. +- **Steps:** 1. Open order details. 2. Tap "Admin Notes". 3. Type a note. 4. Tap "Save". +- **Things to test:** Adding and saving a note. +- **Expected:** Success message. Note shows when reopened. + +### 1.8.13 Cancel an order with a reason +- **What you need:** A non-cancelled order. +- **Steps:** 1. Open the three-dot menu. 2. Tap "Cancel Order". 3. Type a reason. 4. Confirm. +- **Things to test:** Cancelling with a reason; the reason is required. +- **Expected:** Order shows as Cancelled. + +### 1.8.14 Delivery sequence page +- **What you need:** A slot exists. +- **Steps:** 1. Open menu. 2. Tap "Manage Orders". 3. Tap "Delivery Sequences". 4. Pick a slot. +- **Things to test:** Viewing and reordering the delivery sequence. +- **Expected:** The delivery order of orders shows. You can reorder and save. + +### 1.8.15 Rebalance orders +- **What you need:** 2 slots with orders. +- **Steps:** 1. Open menu. 2. Tap "Rebalance Orders". 3. Pick source and target slots. 4. Tap "Rebalance". +- **Things to test:** Moving orders between slots. +- **Expected:** Orders move; success message. + +### 1.8.16 Loading more orders +- **What you need:** 50+ orders. +- **Steps:** 1. On Orders, scroll to the bottom. +- **Things to test:** Infinite scroll. +- **Expected:** More orders load automatically. + +### 1.8.17 Cancel an order that is already cancelled or delivered +- **What you need:** A cancelled order (or a delivered order). +- **Steps:** 1. Open the order. 2. Try to cancel it again. +- **Things to test:** Cancelling an already-cancelled or delivered order. +- **Expected:** An error appears or the cancel option is disabled. The order state does not change. + +### 1.8.18 Delivery sequence — bulk unassign and reassign orders +- **What you need:** A slot with orders assigned to delivery staff. +- **Steps:** 1. Open Delivery Sequences. 2. Select several orders. 3. Tap "Unassign". 4. Select the orders again. 5. Assign them to a different staff member. 6. Save. +- **Things to test:** Unassigning multiple orders at once and moving them to another staff member. +- **Expected:** Unassigned orders leave the staff member's list. After reassigning and saving, they appear under the new staff member. + +## 1.9 Coupons (discount codes) + +### 1.9.1 Coupon list +- **What you need:** Coupons exist. +- **Steps:** 1. Open menu. 2. Tap "Coupons". +- **Things to test:** The coupon list loads. +- **Expected:** Coupons listed with code and discount. + +### 1.9.2 Create a percentage coupon +- **What you need:** Admin login. +- **Steps:** 1. Go to Coupons. 2. Tap "Create". 3. Type a code. 4. Choose Percentage. 5. Enter 10 (for 10%). 6. Pick which products it applies to. 7. Pick an expiry date. 8. Save. +- **Things to test:** Creating a percentage coupon with products and expiry. +- **Expected:** Success message. Coupon in the list. + +### 1.9.3 Create a coupon with discount over 100% +- **What you need:** Create Coupon page. +- **Steps:** 1. Choose Percentage. 2. Enter 150. 3. Try to save. +- **Things to test:** An invalid discount value. +- **Expected:** An error says the discount is invalid. + +### 1.9.4 Edit and delete a coupon +- **What you need:** A coupon exists. +- **Steps:** 1. Tap the coupon. 2. Edit the discount. 3. Save. 4. Then delete and confirm. +- **Things to test:** Editing then deleting a coupon. +- **Expected:** Edit saves; delete removes it. + +### 1.9.5 Reserved coupons +- **What you need:** Admin login. +- **Steps:** 1. Go to Coupons. 2. Tap "Reserved Coupons". 3. Create one and assign it to a user. +- **Things to test:** Creating a reserved coupon for a specific user. +- **Expected:** The coupon shows as reserved for that user. + +### 1.9.6 Create a coupon with BOTH percentage and flat discount +- **What you need:** Create Coupon page open. +- **Steps:** 1. Fill in a percentage discount AND a flat amount. 2. Try to save. +- **Things to test:** Both discount types at once. +- **Expected:** An error says you can only have one discount type. Nothing is saved. + +### 1.9.7 Create a coupon with NO discount +- **What you need:** Create Coupon page open. +- **Steps:** 1. Leave both percentage and flat discount empty. 2. Try to save. +- **Things to test:** No discount at all. +- **Expected:** An error says a discount is required. Nothing is saved. + +### 1.9.8 Coupon code format and auto-uppercase +- **What you need:** Create Coupon page open. +- **Steps:** 1. Type a code with spaces or symbols (e.g. "SAVE 20!"). 2. Type a code in lowercase (e.g. "save10"). +- **Things to test:** Invalid characters and lowercase codes. +- **Expected:** Invalid characters are rejected with an error. A valid code is auto-uppercased when saved. + +### 1.9.9 Create a coupon with a code that already exists +- **What you need:** A coupon with the same code exists. +- **Steps:** 1. Create a coupon with the same code as an existing one. +- **Things to test:** Duplicate coupon codes. +- **Expected:** An error says the coupon code already exists. + +### 1.9.10 User-based coupon without users and without "apply to all" +- **What you need:** Create Coupon page open. +- **Steps:** 1. Mark the coupon as user-based. 2. Do NOT pick any users. 3. Do NOT tick "apply to all". 4. Try to save. +- **Things to test:** User-based coupon with no recipients. +- **Expected:** An error asks you to add users or enable apply-to-all. + +### 1.9.11 User-based coupon AND "apply to all" both on +- **What you need:** Create Coupon page open. +- **Steps:** 1. Mark the coupon as user-based. 2. Pick users. 3. ALSO tick "apply to all". 4. Try to save. +- **Things to test:** Conflicting coupon audience settings. +- **Expected:** An error says the coupon cannot be both user-based and apply-to-all. + +## 1.10 Users + +### 1.10.1 User list +- **What you need:** Users exist. +- **Steps:** 1. Open menu. 2. Tap "Users" or "User Management". +- **Things to test:** The user list loads. +- **Expected:** Users listed with names and numbers. + +### 1.10.2 Open a user's details +- **What you need:** A user exists. +- **Steps:** 1. Tap a user. +- **Things to test:** The user details page. +- **Expected:** Profile, orders and incidents show. + +### 1.10.3 Suspend a user +- **What you need:** A user exists. +- **Steps:** 1. Open the user. 2. Tap "Suspend". 3. Confirm. +- **Things to test:** Suspending a user. +- **Expected:** User is suspended. (That user can no longer log in to the customer app.) + +### 1.10.4 Unsuspend a user +- **What you need:** A suspended user. +- **Steps:** 1. Open the user. 2. Tap "Unsuspend". +- **Things to test:** Unsuspending a user. +- **Expected:** User can log in again. + +### 1.10.5 Add an incident to a user +- **What you need:** A user exists. +- **Steps:** 1. Open the user. 2. Tap "Add Incident". 3. Type a note. 4. Save. +- **Things to test:** Adding an incident note. +- **Expected:** The incident shows in the user's history. + +### 1.10.6 Send a notification +- **What you need:** Users exist. +- **Steps:** 1. Open menu. 2. Tap "Send Notifications". 3. Pick users. 4. Type a message. 5. Send. +- **Things to test:** Sending a push notification. +- **Expected:** Success message. The customers receive a phone notification. + +### 1.10.7 Create a user by mobile number +- **What you need:** Admin login. +- **Steps:** 1. Go to Users. 2. Tap "Create User". 3. Type a valid mobile number. 4. Tap "Save". +- **Things to test:** Creating a user by mobile only. +- **Expected:** Success message. The user appears in the user list. + +### 1.10.8 Staff with a limited role +- **What you need:** A staff account with a limited role (ask the developer to set one up). +- **Steps:** 1. Log in with the limited-role staff account. 2. Open the menu and try to open every section. 3. Try an action you should NOT be allowed to do. +- **Things to test:** What a limited-role staff member can and cannot see/do. +- **Expected:** The staff member sees only the sections their role allows. Tapping a restricted section shows an error or hides it — never lets them in. + +## 1.11 Banners (big pictures on the customer home) + +### 1.11.1 Banner list +- **What you need:** Banners exist. +- **Steps:** 1. Open menu. 2. Tap "Dashboard Banners". +- **Things to test:** The banner list loads. +- **Expected:** Banners listed. + +### 1.11.2 Create a banner +- **What you need:** Admin login. +- **Steps:** 1. Go to Banners. 2. Tap "Create". 3. Add a picture. 4. Link a product (optional). 5. Save. +- **Things to test:** Creating a banner with a picture and a linked product. +- **Expected:** Success message. Banner in the list. + +### 1.11.3 Edit and delete a banner +- **What you need:** A banner exists. +- **Steps:** 1. Tap the banner. 2. Edit. 3. Save. 4. Then delete and confirm. +- **Things to test:** Editing then deleting a banner. +- **Expected:** Edit saves; delete removes it. + +### 1.11.4 Banner order (position) +- **What you need:** At least 2 banners exist. +- **Steps:** 1. Open Dashboard Banners. 2. Set position 1 on one banner and position 2 on another. 3. Save. 4. Wait up to 1 minute. 5. Open the customer app Home. +- **Things to test:** Banner positions 1–4. +- **Expected:** Banners appear on the customer home in the order of their positions. Setting a position that's taken moves the other banner. + +### 1.11.5 Create a banner with a name that already exists +- **What you need:** A banner with the same name exists. +- **Steps:** 1. Create a banner with the same name as an existing one. +- **Things to test:** Duplicate banner names. +- **Expected:** An error says a banner with this name already exists. + +## 1.12 Customize App (settings) + +### 1.12.1 Change a setting +- **What you need:** Admin login. +- **Steps:** 1. Open menu. 2. Tap "Customize App". 3. Change a value (e.g. delivery charge). 4. Save. +- **Things to test:** Changing and saving a setting. +- **Expected:** Success message. Value saved. + +### 1.12.2 Popular items order +- **What you need:** Products exist. +- **Steps:** 1. Go to Customize App. 2. Tap "Popular Items". 3. Drag items into a new order. 4. Save. +- **Things to test:** Dragging items to reorder. +- **Expected:** Order saved. (Customer app home shows those products.) + +### 1.12.3 All items order +- **What you need:** Products exist. +- **Steps:** 1. Go to Customize App. 2. Tap "All Items Order". 3. Drag items. 4. Save. +- **Things to test:** Dragging items to reorder. +- **Expected:** Order saved. + +## 1.13 Complaints (customer issues) + +### 1.13.1 Complaint list +- **What you need:** Complaints exist. +- **Steps:** 1. Open menu. 2. Tap "Complaints". +- **Things to test:** The complaint list loads. +- **Expected:** Complaints listed with status. + +### 1.13.2 Resolve a complaint +- **What you need:** A pending complaint. +- **Steps:** 1. Tap the complaint. 2. Tap "Resolve". +- **Things to test:** Resolving a complaint. +- **Expected:** Status changes to Resolved. + +### 1.13.3 Reply to a complaint (chat) +- **What you need:** An open complaint with messages. +- **Steps:** 1. Open the complaint. 2. Type a reply to the customer. 3. Send. +- **Things to test:** Two-way complaint chat. +- **Expected:** The reply is saved. When the customer opens the complaint, they see your reply. + +## 1.14 Vendor Snippets + +### 1.14.1 Snippet list +- **What you need:** Snippets exist. +- **Steps:** 1. Open menu. 2. Tap "Vendor Snippets". +- **Things to test:** The snippet list loads. +- **Expected:** Snippets listed. + +### 1.14.2 Create a snippet +- **What you need:** Admin login. +- **Steps:** 1. Go to Vendor Snippets. 2. Tap "Create". 3. Type a code. 4. Pick products. 5. Set valid-until date. 6. Save. +- **Things to test:** Creating a snippet with code and products. +- **Expected:** Success. Snippet in list. + +### 1.14.3 Create snippet with a code that already exists +- **What you need:** Same code exists. +- **Steps:** 1. Create with the same code. +- **Things to test:** Duplicate snippet codes. +- **Expected:** Error message. + +### 1.14.4 Edit and delete a snippet +- **What you need:** A snippet exists. +- **Steps:** 1. Tap the snippet. 2. Edit. 3. Save. 4. Then delete and confirm. +- **Things to test:** Editing then deleting a snippet. +- **Expected:** Edit saves; delete removes it. + +## 1.15 General checks on every admin page + +### 1.15.1 Loading sign +- **What you need:** Any page. +- **Steps:** 1. Open a page with slow internet (switch to slow network). +- **Things to test:** The loading indicator on a slow connection. +- **Expected:** A spinner shows while loading, then the page appears. + +### 1.15.2 Empty page +- **What you need:** A page with no data. +- **Steps:** 1. Open any list page with nothing in it. +- **Things to test:** Every list page with zero items. +- **Expected:** A friendly "No ... yet" message shows. No crash. + +### 1.15.3 Server error +- **What you need:** Developer switches the server off. +- **Steps:** 1. Open any page. +- **Things to test:** The error screen and the Retry button. +- **Expected:** An error screen or red message shows with a "Retry" button. Tapping Retry works when the server is back. + +### 1.15.4 Double-tap save +- **What you need:** Any Create page. +- **Steps:** 1. Fill the form. 2. Tap Save twice very fast. +- **Things to test:** Double-tapping Save. +- **Expected:** Only one item is created (no duplicates). + +### 1.15.5 Red warning text +- **What you need:** Any screen. +- **Steps:** 1. Watch the bottom of the screen for red/yellow warning boxes while using the app. +- **Things to test:** No developer warnings on any screen. +- **Expected:** No warnings like "Text strings must be rendered..." appear. + +### 1.15.6 Session expired while using the app +- **What you need:** You are logged in. Ask the developer to expire your login session. +- **Steps:** 1. Use the app after the session has expired (open a page or tap an action). +- **Things to test:** What happens when the login expires mid-use. +- **Expected:** The app signs you out and returns you to the login screen with a message. No crash. + +### 1.15.7 Leaving a form with unsaved changes +- **What you need:** Any admin form (e.g. Add/Edit Product). +- **Steps:** 1. Type or change something in the form. 2. Tap the back button / navigate away WITHOUT saving. +- **Things to test:** Navigating away with unsaved changes. +- **Expected:** The app warns you that you have unsaved changes (or you return to the form). You are not silently taken away losing your work. + +--- + +# PART 2 — CUSTOMER APP (the app customers use) + +## 2.1 Login / Sign up + +### 2.1.1 Login with OTP +- **What you need:** Your mobile number. +- **Steps:** 1. Open the app. 2. Type your mobile number. 3. Tap "Get OTP". 4. Enter the OTP you receive by SMS. 5. Tap "Verify". +- **Things to test:** Receiving the OTP, entering it, and getting logged in. +- **Expected:** You are logged in and land on the Home screen. + +### 2.1.2 Wrong OTP +- **What you need:** OTP screen open. +- **Steps:** 1. Type a wrong OTP. 2. Tap Verify. +- **Things to test:** Entering an incorrect OTP. +- **Expected:** Error message "Invalid OTP". You can try again. + +### 2.1.3 Resend OTP +- **What you need:** OTP sent, not entered. +- **Steps:** 1. Wait for the resend timer. 2. Tap "Resend OTP". +- **Things to test:** The resend timer and resend button. +- **Expected:** A new OTP arrives. + +### 2.1.4 Register a new account +- **What you need:** A mobile number never used. +- **Steps:** 1. On login, tap "New user / Register". 2. Enter your name and number. 3. Verify OTP. +- **Things to test:** Creating a brand-new account. +- **Expected:** Account created. You are logged in. + +### 2.1.5 Register with a number already used +- **What you need:** An existing number. +- **Steps:** 1. Try to register with a number already registered. +- **Things to test:** Registering with a used number. +- **Expected:** A message says the number is already registered (use login). + +### 2.1.6 Change password +- **What you need:** Logged in. +- **Steps:** 1. Go to Me. 2. Tap "Change Password". 3. Enter old and new password. 4. Save. +- **Things to test:** Changing the password with old + new. +- **Expected:** Success. Old password no longer works. + +### 2.1.7 Delete account +- **What you need:** Logged in. +- **Steps:** 1. Go to Me. 2. Tap "Delete Account". 3. Confirm. +- **Things to test:** Deleting the account and the confirmation. +- **Expected:** Account deleted. You are logged out. + +### 2.1.8 Delete account — wrong mobile number +- **What you need:** Logged in, on the Delete Account screen. +- **Steps:** 1. When asked to confirm your mobile number, type a DIFFERENT number. 2. Tap Confirm. +- **Things to test:** Confirming deletion with the wrong number. +- **Expected:** An error says the number does not match. The account is NOT deleted. + +### 2.1.9 Login with email + password +- **What you need:** An account that has a password set. +- **Steps:** 1. On the login screen, switch to "Password" login. 2. Type your email. 3. Type your password. 4. Tap Login. +- **Things to test:** Logging in with email and password. +- **Expected:** You are logged in and land on Home. + +### 2.1.10 Login with mobile + password +- **What you need:** An account with a password. +- **Steps:** 1. On password login, type your mobile number instead of email. 2. Type your password. 3. Tap Login. +- **Things to test:** Logging in with mobile and password. +- **Expected:** Login succeeds. + +### 2.1.11 Register with email + password +- **What you need:** A fresh email and mobile. +- **Steps:** 1. Tap "Register". 2. Enter name, email, mobile and a password (6+ characters). 3. Accept the terms. 4. Tap Register. +- **Things to test:** Registering with full details. +- **Expected:** Account created. You are logged in and land on Home. + +### 2.1.12 Register — weak password or mismatched confirm +- **What you need:** Registration form open. +- **Steps:** 1. Enter a password shorter than 6 characters. 2. Try again entering different passwords in the two password boxes. +- **Things to test:** Weak and mismatched passwords. +- **Expected:** A validation error appears. Nothing is submitted. + +### 2.1.13 Terms checkbox blocks login/register +- **What you need:** Login or Register screen open. +- **Steps:** 1. Fill in valid details but do NOT tick the Terms checkbox. 2. Tap Login/Register. +- **Things to test:** Proceeding without accepting terms. +- **Expected:** The button is disabled or an error asks you to accept the terms. + +### 2.1.14 OTP — reuse a code already used +- **What you need:** You logged in once with an OTP. +- **Steps:** 1. Log out. 2. Try to log in again with the SAME OTP code. +- **Things to test:** Reusing an OTP. +- **Expected:** An error says the OTP is not valid or has expired. The code can only be used once. + +### 2.1.15 OTP — verify without requesting one +- **What you need:** Login screen open. +- **Steps:** 1. Type any 4-digit code without ever tapping "Get OTP". +- **Things to test:** Verifying an OTP that was never sent. +- **Expected:** An error says "OTP not sent or expired". + +### 2.1.16 OTP — expired code +- **What you need:** An OTP sent more than 5 minutes ago (or ask the developer to expire it). +- **Steps:** 1. Enter the old OTP. 2. Tap Verify. +- **Things to test:** Using an expired OTP. +- **Expected:** An error says the OTP is not valid or has expired. You must request a new one. + +### 2.1.17 OTP resend cooldown survives closing the app +- **What you need:** An OTP was just sent. +- **Steps:** 1. Close the app completely. 2. Reopen it. 3. Look at the resend button. +- **Things to test:** The resend timer after restarting the app. +- **Expected:** The resend cooldown is still running (button still disabled with a timer). You cannot resend before it finishes. + +### 2.1.18 Google sign-in button +- **What you need:** Login screen open. +- **Steps:** 1. Tap the "Sign in with Google" button. +- **Things to test:** The Google sign-in button. +- **Expected:** The app does not crash. (If Google sign-in is not ready yet, the button may do nothing or show a "coming soon" message — that is acceptable for now.) + +### 2.1.19 Session expired while using the app +- **What you need:** Logged in. Ask the developer to expire your login. +- **Steps:** 1. Use the app after the session has expired. +- **Things to test:** What happens when the login expires mid-use. +- **Expected:** You are signed out and returned to the login screen. No crash. + +## 2.2 Home screen + +### 2.2.1 App opens fast without a loading screen +- **What you need:** You have used the app before (data saved on phone). +- **Steps:** 1. Close the app completely. 2. Open it again. +- **Things to test:** The app start speed with saved data. +- **Expected:** The home screen appears right away. You do NOT see "Loading app settings…" for a long time. + +### 2.2.2 First ever open (no saved data) +- **What you need:** Fresh install. +- **Steps:** 1. Install the app fresh. 2. Open it. +- **Things to test:** The very first launch with no saved data. +- **Expected:** Home appears. Products do NOT show as "out of stock" during loading, then settle to correct status. + +### 2.2.3 Search bar +- **What you need:** Products exist. +- **Steps:** 1. Tap the search bar at top. 2. Type a product name. 3. Tap search. +- **Things to test:** Searching for a product. +- **Expected:** A search results page opens with matching products. + +### 2.2.4 Our Stores section +- **What you need:** Stores exist. +- **Steps:** 1. Scroll the home screen. 2. Look at "Our Stores". +- **Things to test:** The stores grid and tapping a store. +- **Expected:** Store logos in a grid (4 per row). Tapping one opens the Stores tab. + +### 2.2.5 Banner pictures +- **What you need:** Banners exist. +- **Steps:** 1. Look at the top of home. +- **Things to test:** The banner slideshow and dots; tapping a banner. +- **Expected:** Big pictures slide automatically with dots. Tapping one opens a product. + +### 2.2.6 Explore Products tabs +- **What you need:** Tags exist. +- **Steps:** 1. Scroll to "Explore Products". 2. Look at the tabs. +- **Things to test:** The tab row order; sliding it left/right. +- **Expected:** Tabs appear in the order set by the admin (see test 1.4.9). You can slide the row left-right. + +### 2.2.7 Products inside a tab are in admin order +- **What you need:** A tag with an ordered product list. +- **Steps:** 1. Tap a tab. 2. Compare the products with the order set in admin (test 1.4.5). +- **Things to test:** The product order inside a tab; out-of-stock at the end. +- **Expected:** Products appear in the same order. Out-of-stock ones are at the end. + +### 2.2.8 Tab with no products +- **What you need:** An empty tag exists. +- **Steps:** 1. Tap that tab. +- **Things to test:** An empty tab. +- **Expected:** "No products in this category yet" shows. + +### 2.2.9 Swipe between tabs +- **What you need:** 2+ tags. +- **Steps:** 1. Swipe left/right on the products area. +- **Things to test:** Swiping between tabs. +- **Expected:** The tab switches and its products show. + +### 2.2.10 Sticky tabs while scrolling +- **What you need:** Enough content to scroll. +- **Steps:** 1. Scroll the home page down until Explore Products is off the top. +- **Things to test:** The tabs sticking to the top while scrolling. +- **Expected:** A row of tabs sticks to the top of the screen while you scroll. + +### 2.2.11 Upcoming Delivery Slots +- **What you need:** Slots exist. +- **Steps:** 1. Scroll to "Upcoming Delivery Slots". +- **Things to test:** The slots rail shows future slots only. +- **Expected:** Cards with delivery time and "Order By" time. Only future slots. + +### 2.2.12 "Closing Soon" badge +- **What you need:** A slot closing within 4 hours. +- **Steps:** 1. Look at that slot's card. +- **Things to test:** The closing-soon badge. +- **Expected:** An amber "CLOSING SOON" badge shows. + +### 2.2.13 All Available Products grid +- **What you need:** Products exist. +- **Steps:** 1. Scroll to "All Available Products". +- **Things to test:** The 2-column grid; out-of-stock placement. +- **Expected:** A 2-column grid. Out-of-stock products are at the bottom. + +### 2.2.14 Product card +- **What you need:** A product exists. +- **Steps:** 1. Look at a product card. +- **Things to test:** What a product card shows. +- **Expected:** Photo, name, price and quantity unit show. There is an add-to-cart button. + +### 2.2.15 Pull to refresh +- **What you need:** Home open. +- **Steps:** 1. Pull the screen down from the top and release. +- **Things to test:** Pull-to-refresh on home. +- **Expected:** A spinner shows and data reloads. + +### 2.2.16 Open a product +- **What you need:** A product exists. +- **Steps:** 1. Tap a product card. +- **Things to test:** Navigating to product detail. +- **Expected:** The product detail page opens. + +### 2.2.17 Product prices are real (never zero) +- **What you need:** Products exist. +- **Steps:** 1. Look at every product card on Home, in categories, and in the full list. +- **Things to test:** Prices on every product card. +- **Expected:** Every card shows the correct, real price — never zero and never blank. + +### 2.2.18 Flash badge on flash-eligible products +- **What you need:** A flash-eligible product exists (1-hour delivery). +- **Steps:** 1. Look at that product's card on Home. +- **Things to test:** The flash badge. +- **Expected:** The card shows a "1 Hour"/flash badge or option. + +## 2.3 Stores + +### 2.3.1 Stores list +- **What you need:** Stores exist. +- **Steps:** 1. Tap the "Stores" tab at the bottom. +- **Things to test:** The stores tab. +- **Expected:** Stores listed. + +### 2.3.2 Store detail +- **What you need:** A store exists. +- **Steps:** 1. Tap a store. +- **Things to test:** The store detail page. +- **Expected:** Store header, its products, and tag chips (labels) show. + +### 2.3.3 Filter store products by tag +- **What you need:** Store has tags. +- **Steps:** 1. On store detail, tap a tag chip. +- **Things to test:** Filtering by a tag chip. +- **Expected:** Only products of that tag remain. + +### 2.3.4 Clear the tag filter +- **What you need:** A tag filter is active. +- **Steps:** 1. Tap "Clear". +- **Things to test:** Clearing the tag filter. +- **Expected:** All store products show again. + +## 2.4 Product detail + +### 2.4.1 Product page +- **What you need:** A product exists. +- **Steps:** 1. Open any product. +- **Things to test:** Everything shown on the product page. +- **Expected:** Photos, price, old price (if any) with discount %, unit and quantity show. + +### 2.4.2 Add to cart +- **What you need:** Product in stock. +- **Steps:** 1. On product page, tap "Add to Cart". 2. Choose quantity. +- **Things to test:** Adding to cart with a chosen quantity. +- **Expected:** A confirmation dialog shows. The floating cart bar count increases. + +### 2.4.3 Out-of-stock product +- **What you need:** An out-of-stock product exists. +- **Steps:** 1. Open it. 2. Try to add to cart. +- **Things to test:** Adding an out-of-stock product. +- **Expected:** The button is disabled or an error shows. + +### 2.4.4 Reviews +- **What you need:** A product with reviews. +- **Steps:** 1. Scroll to Reviews. +- **Things to test:** The reviews section. +- **Expected:** Ratings and text show. + +### 2.4.5 Write a review +- **What you need:** You bought the product. +- **Steps:** 1. Tap "Write Review". 2. Pick stars (1–5). 3. Type text. 4. Submit. +- **Things to test:** Writing and submitting a review. +- **Expected:** Success. Review appears in the list. + +### 2.4.6 Review without stars +- **What you need:** Review form open. +- **Steps:** 1. Tap Submit without picking stars. +- **Things to test:** Submitting a review with no rating. +- **Expected:** Error asking for a rating. + +### 2.4.7 Compare price (selling vs original) +- **What you need:** A product with an original (higher) price. +- **Steps:** 1. Open the product. 2. Look at the price area. +- **Things to test:** The original price display. +- **Expected:** The selling price is shown, and the original price is shown crossed out with the discount %. + +### 2.4.8 "Get in 1 Hour" moves item to flash cart +- **What you need:** A flash-eligible product. +- **Steps:** 1. Open the product. 2. Tap "Get in 1 Hour". +- **Things to test:** Moving a product to the flash cart. +- **Expected:** The item is added to the flash (1-hour) cart and you are taken to the flash cart. The regular cart is not affected. + +### 2.4.9 "Buy Now" goes straight to checkout +- **What you need:** A product in stock. +- **Steps:** 1. Open the product. 2. Tap "Buy Now". +- **Things to test:** The Buy Now shortcut. +- **Expected:** You are taken to checkout with just that item. + +### 2.4.10 Quantity offers ("buy more, save") +- **What you need:** A product with quantity-based offers. +- **Steps:** 1. Open the product. 2. Look for offers. +- **Things to test:** Bulk/quantity offers. +- **Expected:** The offers are listed (e.g. "Buy 2 for ₹X"). Choosing a quantity that matches applies the offer price. + +### 2.4.11 Combo product contents +- **What you need:** A combo product exists. +- **Steps:** 1. Open the combo product. +- **Things to test:** The combo's included items. +- **Expected:** The items inside the combo are listed with images, and an "OFFER"/combo badge is shown. + +## 2.5 Cart + +### 2.5.1 Cart page +- **What you need:** Items in cart. +- **Steps:** 1. Tap the cart icon / floating cart bar. +- **Things to test:** The cart page contents. +- **Expected:** Items, quantities and total show. + +### 2.5.2 Change quantity +- **What you need:** An item in cart. +- **Steps:** 1. Tap the + button. +- **Things to test:** Increasing quantity. +- **Expected:** Quantity and total update. + +### 2.5.3 Remove an item +- **What you need:** An item in cart. +- **Steps:** 1. Tap the remove/trash on an item. +- **Things to test:** Removing an item. +- **Expected:** Item disappears; total updates. + +### 2.5.4 Empty cart +- **What you need:** Items in cart. +- **Steps:** 1. Tap "Clear Cart". 2. Confirm. +- **Things to test:** Clearing the whole cart. +- **Expected:** Cart is empty. + +### 2.5.5 Empty cart message +- **What you need:** No items. +- **Steps:** 1. Open cart with nothing in it. +- **Things to test:** The empty cart state. +- **Expected:** "Your cart is empty" message. + +### 2.5.6 Floating cart bar +- **What you need:** Items in cart. +- **Steps:** 1. Look at the bottom of the home screen. +- **Things to test:** The floating cart bar. +- **Expected:** A bar shows the item count. Tapping it opens the cart. + +### 2.5.7 Choose a delivery slot for each item +- **What you need:** At least 2 items in the regular cart. +- **Steps:** 1. Open the cart. 2. Tap the slot selector on one item and pick a slot. 3. Pick a DIFFERENT slot on another item. +- **Things to test:** Per-item slot selection. +- **Expected:** Each item can carry its own delivery slot. Only future, non-full slots are shown. + +### 2.5.8 Item with no delivery slot +- **What you need:** A product that has no upcoming slots (ask the developer to set one up). +- **Steps:** 1. Add that product to the cart. 2. Open the cart. +- **Things to test:** An item with no available slot. +- **Expected:** The item shows "No delivery slots available". Checkout is blocked or asks you to remove the item. + +### 2.5.9 Bill breakdown in cart +- **What you need:** Items in cart, one with a coupon. +- **Steps:** 1. Open the cart. 2. Look at the bill section. +- **Things to test:** Item total, discount, delivery fee, to-pay. +- **Expected:** The numbers add up: Item Total − Discount + Delivery Fee = To Pay. Delivery is free (₹0) when the total is above the free-delivery threshold. + +### 2.5.10 Quantity limits (minimum/maximum per product) +- **What you need:** A product with a set step size (e.g. 500g steps). +- **Steps:** 1. In the cart, keep tapping + on that item. 2. Try to go below the minimum quantity. +- **Things to test:** Quantity step size and limits. +- **Expected:** The quantity only changes by the product's step size, and cannot go below the minimum or above the maximum allowed. The price updates with each step. + +## 2.6 Checkout + +### 2.6.1 Checkout page +- **What you need:** Cart + address. +- **Steps:** 1. On cart, tap "Checkout". +- **Things to test:** The checkout page sections. +- **Expected:** Delivery address, delivery slot, coupons and totals show. + +### 2.6.2 Address cards with dots +- **What you need:** 2+ saved addresses. +- **Steps:** 1. Look at "Delivery Address". 2. Slide the address cards left/right. +- **Things to test:** The dots under the address cards and sliding. +- **Expected:** Small dots under the cards; the active dot moves as you slide. + +### 2.6.3 Only one address — no dots +- **What you need:** Exactly 1 saved address. +- **Steps:** 1. Look at "Delivery Address". +- **Things to test:** One address = no dots. +- **Expected:** No dots show. + +### 2.6.4 Select an address +- **What you need:** 2+ addresses. +- **Steps:** 1. Tap an address card. +- **Things to test:** Selecting an address. +- **Expected:** It gets a blue border and a tick. The list scrolls back to start. + +### 2.6.5 Add a new address +- **What you need:** Checkout open. +- **Steps:** 1. Tap "+ Add New". 2. Fill the form. 3. Save. +- **Things to test:** Adding an address from checkout. +- **Expected:** Dialog closes. New address is selected automatically. + +### 2.6.6 Attach current location +- **What you need:** An address without location. +- **Steps:** 1. Tap "+ Attach Current Location". 2. Allow location permission. +- **Things to test:** Attaching GPS location to an address. +- **Expected:** "Attaching…" then success. Location saved. + +### 2.6.7 Apply a coupon +- **What you need:** An eligible coupon exists. +- **Steps:** 1. Tap "Apply Coupon". 2. Pick a coupon. +- **Things to test:** Applying a coupon. +- **Expected:** Discount shows in the total. + +### 2.6.8 Order with Cash on Delivery +- **What you need:** Cart + address + slot. +- **Steps:** 1. Choose COD. 2. Tap "Place Order". +- **Things to test:** Placing a COD order. +- **Expected:** Success screen with order confirmation. + +### 2.6.9 Order with card/UPI (Razorpay) +- **What you need:** Cart + address + slot. +- **Steps:** 1. Choose Online Payment. 2. Pay. +- **Things to test:** Placing an online-payment order. +- **Expected:** Payment screen opens; after paying, success screen. + +### 2.6.10 Place order with no address +- **What you need:** No saved address. +- **Steps:** 1. Try to place an order. +- **Things to test:** Ordering without an address. +- **Expected:** Error "select an address". + +### 2.6.11 Place order with a full slot +- **What you need:** A slot marked full. +- **Steps:** 1. Try to place an order in the full slot. +- **Things to test:** Ordering in a full slot. +- **Expected:** Error "slot full". You can pick another slot. + +### 2.6.12 Place order with an out-of-stock item +- **What you need:** An item went out of stock. +- **Steps:** 1. Try to place the order. +- **Things to test:** Ordering with an out-of-stock item. +- **Expected:** Error about the out-of-stock item. + +### 2.6.13 Delivery instructions field +- **What you need:** Checkout open with an address selected. +- **Steps:** 1. Find the "Delivery instructions" box. 2. Type a note (e.g. "Call before delivery"). 3. Place the order. +- **Things to test:** Adding delivery instructions. +- **Expected:** The note is saved and shown on the order details. + +### 2.6.14 Place order without selecting a slot +- **What you need:** A cart item with no slot selected. +- **Steps:** 1. Try to place the order. +- **Things to test:** Ordering with no slot. +- **Expected:** The app prompts you to select a delivery slot. The order is not placed. + +## 2.7 My Orders + +### 2.7.1 Orders list +- **What you need:** You have orders. +- **Steps:** 1. Go to Me. 2. Tap "My Orders". +- **Things to test:** The orders list. +- **Expected:** Orders listed with status. + +### 2.7.2 Order details +- **What you need:** An order exists. +- **Steps:** 1. Tap an order. +- **Things to test:** The order detail page. +- **Expected:** Items, status, address and notes show. + +### 2.7.3 Cancel an order (allowed time) +- **What you need:** A recent order. +- **Steps:** 1. Open the order. 2. Tap "Cancel". 3. Pick a reason. 4. Confirm. +- **Things to test:** Cancelling within the allowed time. +- **Expected:** Order becomes Cancelled. + +### 2.7.4 Cancel an order (too late) +- **What you need:** An order past its close time. +- **Steps:** 1. Try to cancel it. +- **Things to test:** Cancelling after the allowed time. +- **Expected:** Cancellation is disabled or an error shows. + +### 2.7.5 Add a note to an order +- **What you need:** An order exists. +- **Steps:** 1. Open the order. 2. Tap "Add Note". 3. Type. 4. Save. +- **Things to test:** Adding a note. +- **Expected:** Note saved and shows. + +### 2.7.6 "Order again" hint on home +- **What you need:** You have past orders. +- **Steps:** 1. Look at the home screen for the "Order again / Next order" box. +- **Things to test:** The order-again box. +- **Expected:** Past products show. Tapping one opens the product. + +### 2.7.7 Reorder a past order +- **What you need:** A past order exists. +- **Steps:** 1. Open the order. 2. Tap "Reorder" / "Order Again". +- **Things to test:** Reordering a past order. +- **Expected:** The order's items are added back to the cart. You can then check out. + +### 2.7.8 Cancel an order that is already cancelled +- **What you need:** A cancelled order. +- **Steps:** 1. Open the cancelled order. 2. Try to cancel it again. +- **Things to test:** Cancelling twice. +- **Expected:** The cancel option is disabled or an error appears. The order stays cancelled. + +## 2.8 Offers tab + +### 2.8.1 Offers tab exists +- **What you need:** Any build. +- **Steps:** 1. Look at the bottom menu. +- **Things to test:** The bottom menu tab. +- **Expected:** There is a tab with a price-tag icon labelled "Offers". + +### 2.8.2 Combos on Offers page +- **What you need:** Combos exist. +- **Steps:** 1. Tap the "Offers" tab. +- **Things to test:** The Combos row. +- **Expected:** A "Combos" row with products shows. + +### 2.8.3 Offers on Offers page +- **What you need:** Offers exist. +- **Steps:** 1. Scroll down on the Offers page. +- **Things to test:** The Offers row. +- **Expected:** An "Offers" row shows. + +### 2.8.4 Empty offers +- **What you need:** No combos/offers. +- **Steps:** 1. Open Offers tab. +- **Things to test:** Empty rows. +- **Expected:** "No combos available right now" / "No offers available right now" messages. + +### 2.8.5 Open a product from Offers +- **What you need:** A product exists. +- **Steps:** 1. Tap a product in a row. +- **Things to test:** Opening a product from the Offers page. +- **Expected:** Product detail opens. Back returns to Offers. + +## 2.9 Flash (1-Hour) Delivery + +### 2.9.1 Flash tab when enabled +- **What you need:** Admin has flash enabled. +- **Steps:** 1. Tap the centre "1 Hr Delivery" button. +- **Things to test:** The flash page opens fast. +- **Expected:** Flash page opens immediately — no long loading spinner. + +### 2.9.2 Flash tab when disabled +- **What you need:** Admin disabled flash. +- **Steps:** 1. Tap the "1 Hr Delivery" button. +- **Things to test:** The flash disabled screen. +- **Expected:** "1 Hr Delivery Unavailable" screen with a button to go to scheduled delivery. + +### 2.9.3 Flash products +- **What you need:** Flash products exist. +- **Steps:** 1. On flash page, look at products. +- **Things to test:** The flash product list. +- **Expected:** Flash product list shows. + +### 2.9.4 Flash cart and checkout +- **What you need:** Flash items. +- **Steps:** 1. Add a flash product to cart. 2. Checkout. +- **Things to test:** The flash cart and checkout flow. +- **Expected:** Flash flow works with a 30-minute delivery time. + +### 2.9.5 Flash order success +- **What you need:** Order placed. +- **Steps:** 1. Complete a flash order. +- **Things to test:** The flash success screen. +- **Expected:** Success screen. + +## 2.10 Me tab + +### 2.10.1 Profile +- **What you need:** Logged in. +- **Steps:** 1. Tap "Me" tab. +- **Things to test:** The profile info. +- **Expected:** Your name and number show. + +### 2.10.2 Edit profile +- **What you need:** Logged in. +- **Steps:** 1. Tap "Edit Profile". 2. Change name. 3. Save. +- **Things to test:** Editing the profile. +- **Expected:** New name saved. + +### 2.10.3 My Addresses +- **What you need:** —. +- **Steps:** 1. Tap "Addresses". 2. Add, edit, delete, set default. +- **Things to test:** Add, edit, delete and set-default for addresses. +- **Expected:** Each action works; one address is marked default. + +### 2.10.4 My Coupons +- **What you need:** Coupons exist. +- **Steps:** 1. Tap "Coupons". +- **Things to test:** The coupon list and redeeming a reserved coupon. +- **Expected:** Your coupons listed. A reserved coupon can be redeemed once. + +### 2.10.5 My Complaints +- **What you need:** —. +- **Steps:** 1. Tap "Complaints". 2. Raise a new complaint. 3. Submit. +- **Things to test:** Raising a complaint. +- **Expected:** It appears in the list as Open. + +### 2.10.6 Terms & About +- **What you need:** —. +- **Steps:** 1. Tap "Terms" and "About". +- **Things to test:** The Terms and About pages. +- **Expected:** Pages open with text. + +## 2.11 Search and Slot view + +### 2.11.1 Search with results +- **What you need:** Products exist. +- **Steps:** 1. Search for a real product name. +- **Things to test:** Searching for a real product. +- **Expected:** Matching products show. Tapping opens product. + +### 2.11.2 Search with no results +- **What you need:** —. +- **Steps:** 1. Search for "zzzzzz". +- **Things to test:** Searching for a nonsense word. +- **Expected:** "No results" message. + +### 2.11.3 Slot view +- **What you need:** A slot exists. +- **Steps:** 1. From home, tap a slot card. +- **Things to test:** The slot view page. +- **Expected:** The slot's products show with add-to-cart. + +## 2.12 Offline and saving data on the phone + +### 2.12.1 Data saved after first load +- **What you need:** You opened the app once with internet. +- **Steps:** 1. Close the app. 2. Turn off internet (aeroplane mode). 3. Open the app. +- **Things to test:** Offline launch with saved data. +- **Expected:** Products, stores and slots still show (from the phone's saved copy). + +### 2.12.2 No "invalid key" errors +- **What you need:** Any usage. +- **Steps:** 1. Watch the screen/log for red errors mentioning "Invalid key". +- **Things to test:** No storage errors. +- **Expected:** No such errors. + +### 2.12.3 Update after admin changes +- **What you need:** Admin changed something (e.g. a price). +- **Steps:** 1. Keep the app open. 2. Wait up to 1 minute. +- **Things to test:** The app picking up an admin change automatically. +- **Expected:** The app picks up the change by itself (only the changed part reloads). + +### 2.12.4 Server is down +- **What you need:** Developer stops the server. +- **Steps:** 1. Open the app with saved data. +- **Things to test:** App behaviour when the server is down. +- **Expected:** Saved data still shows. An error screen appears only where new data is needed. + +### 2.12.5 Slow network +- **What you need:** Switch to 2G/slow. +- **Steps:** 1. Browse the app. +- **Things to test:** App behaviour on a slow connection. +- **Expected:** Loading signs appear; no crash; things load when network allows. + +### 2.12.6 App with no data at all +- **What you need:** Empty database. +- **Steps:** 1. Browse every tab. +- **Things to test:** Every screen with zero data. +- **Expected:** Empty messages everywhere; no crash. + +### 2.12.7 Rapid tapping +- **What you need:** Any screen. +- **Steps:** 1. Tap buttons very fast, switch tabs fast. +- **Things to test:** Rapid taps and tab switching. +- **Expected:** No crash, no double navigation. + +### 2.12.8 Back button +- **What you need:** You navigated deep. +- **Steps:** 1. Press the back button several times. +- **Things to test:** Back navigation. +- **Expected:** Goes back one screen at a time, correctly. + +## 2.13 Notifications & security checks + +### 2.13.1 Push notification permission +- **What you need:** A fresh install (or reinstall). +- **Steps:** 1. Open the app. 2. When the phone asks for notification permission, tap "Allow". +- **Things to test:** Allowing push notifications. +- **Expected:** The app continues normally. Later, order updates can arrive as notifications. + +### 2.13.2 Push notification permission denied +- **What you need:** A fresh install (or reinstall). +- **Steps:** 1. When the phone asks for notification permission, tap "Don't Allow". +- **Things to test:** Denying push notifications. +- **Expected:** The app does not crash and still works. No error loop. + +### 2.13.3 Old-version notice (webview overlay) +- **What you need:** Ask the developer to make the backend send an update notice. +- **Steps:** 1. Open the app. 2. Look for a full-screen notice about updating the app. +- **Things to test:** The update-notice screen. +- **Expected:** The notice appears full-screen. The close button (if shown) closes it. The app does not crash. + +### 2.13.4 You cannot see another user's order +- **What you need:** Two customer accounts with orders. +- **Steps:** 1. Ask the developer for the order link of the OTHER account. 2. Open it while logged in as your account. +- **Things to test:** Opening someone else's order. +- **Expected:** An error appears or you are blocked. You never see the other user's order details. + +### 2.13.5 Expired or used-up coupon at checkout +- **What you need:** An expired coupon (ask the developer to set one up). +- **Steps:** 1. Try to apply the expired coupon at checkout. +- **Things to test:** Expired/used-up coupons. +- **Expected:** The coupon is rejected with a clear reason. The discount is not applied. + +### 2.13.6 Odd text in search (no crash) +- **What you need:** Home screen. +- **Steps:** 1. In search, type something like `