Compare commits

..

3 commits

Author SHA1 Message Date
shafi54
b41980736a old apis removal 2026-08-05 22:51:52 +05:30
shafi54
fa56fd4a82 enh 2026-08-04 23:06:27 +05:30
shafi54
9a47ff1a9b enh 2026-08-04 22:32:47 +05:30
26 changed files with 278 additions and 1158 deletions

View file

@ -10,3 +10,4 @@
- Prefers embedding related data into existing cache files over creating or calling separate API endpoints. Confidence: 0.9 - 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 - 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 - 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

View file

@ -16,6 +16,7 @@
"wrangler:dev": "wrangler dev worker.ts --config wrangler.toml", "wrangler:dev": "wrangler dev worker.ts --config wrangler.toml",
"wrangler:deploy": "wrangler deploy 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_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: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" "docker:push": "docker push mohdshafiuddin54/health_petal:latest"
}, },

View file

@ -98,9 +98,6 @@ export type {
UserAddressDeleteResponse, UserAddressDeleteResponse,
UserBanner, UserBanner,
UserBannersResponse, UserBannersResponse,
UserCartProduct,
UserCartItem,
UserCartResponse,
UserComplaint, UserComplaint,
UserComplaintsResponse, UserComplaintsResponse,
UserRaiseComplaintResponse, UserRaiseComplaintResponse,
@ -122,7 +119,6 @@ export type {
UserCreateReviewResponse, UserCreateReviewResponse,
UserSlotProduct, UserSlotProduct,
UserSlotWithProducts, UserSlotWithProducts,
UserSlotData,
UserSlotAvailability, UserSlotAvailability,
UserDeliverySlot, UserDeliverySlot,
UserSlotsResponse, UserSlotsResponse,
@ -136,7 +132,6 @@ export type {
UserAuthResult, UserAuthResult,
UserOtpVerifyResponse, UserOtpVerifyResponse,
UserPasswordUpdateResponse, UserPasswordUpdateResponse,
UserProfileResponse,
UserDeleteAccountResponse, UserDeleteAccountResponse,
UserCouponUsage, UserCouponUsage,
UserCouponApplicableUser, UserCouponApplicableUser,
@ -156,8 +151,6 @@ export type {
UserOrderDetail, UserOrderDetail,
UserCancelOrderResponse, UserCancelOrderResponse,
UserUpdateNotesResponse, UserUpdateNotesResponse,
UserRecentProduct,
UserRecentProductsResponse,
// Store types // Store types
StoreSummary, StoreSummary,
StoresSummaryResponse, StoresSummaryResponse,

View file

@ -157,15 +157,6 @@
// hasOngoingOrdersForAddress, // hasOngoingOrdersForAddress,
// // User - Banners // // User - Banners
// getUserActiveBanners, // getUserActiveBanners,
// // User - Cart
// getUserCartItemsWithProducts,
// getUserProductById,
// getUserCartItemByUserProduct,
// incrementUserCartItemQuantity,
// insertUserCartItem,
// updateUserCartItemQuantity,
// deleteUserCartItem,
// clearUserCart,
// // User - Complaint // // User - Complaint
// getUserComplaints, // getUserComplaints,
// createUserComplaint, // createUserComplaint,
@ -235,9 +226,6 @@
// getUserOrderBasic, // getUserOrderBasic,
// cancelUserOrderTransaction, // cancelUserOrderTransaction,
// updateUserOrderNotes, // updateUserOrderNotes,
// getUserRecentlyDeliveredOrderIds,
// getUserProductIdsFromOrders,
// getUserProductsForRecentOrders,
// // Store Helpers // // Store Helpers
// getAllBannersForCache, // getAllBannersForCache,
// getAllProductsForCache, // getAllProductsForCache,

View file

@ -168,15 +168,6 @@ export {
hasOngoingOrdersForAddress, hasOngoingOrdersForAddress,
// User - Banners // User - Banners
getUserActiveBanners, getUserActiveBanners,
// User - Cart
getUserCartItemsWithProducts,
getUserProductById,
getUserCartItemByUserProduct,
incrementUserCartItemQuantity,
insertUserCartItem,
updateUserCartItemQuantity,
deleteUserCartItem,
clearUserCart,
// User - Complaint // User - Complaint
getUserComplaints, getUserComplaints,
createUserComplaint, createUserComplaint,
@ -246,9 +237,6 @@ export {
getUserOrderBasic, getUserOrderBasic,
cancelUserOrderTransaction, cancelUserOrderTransaction,
updateUserOrderNotes, updateUserOrderNotes,
getUserRecentlyDeliveredOrderIds,
getUserProductIdsFromOrders,
getUserProductsForRecentOrders,
// Store Helpers // Store Helpers
getAllBannersForCache, getAllBannersForCache,
getAllProductsForCache, getAllProductsForCache,

View file

@ -24,7 +24,6 @@ import type {
UserAuthResponse, UserAuthResponse,
UserOtpVerifyResponse, UserOtpVerifyResponse,
UserPasswordUpdateResponse, UserPasswordUpdateResponse,
UserProfileResponse,
UserDeleteAccountResponse, UserDeleteAccountResponse,
} from '@packages/shared' } from '@packages/shared'
@ -396,31 +395,6 @@ export const authRouter = router({
} }
}), }),
getProfile: protectedProcedure
.query(async ({ ctx }): Promise<UserProfileResponse> => {
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 deleteAccount: protectedProcedure
.input(z.object({ .input(z.object({
mobile: z.string().min(10, 'Mobile number is required'), mobile: z.string().min(10, 'Mobile number is required'),

View file

@ -1,4 +1,3 @@
import { publicProcedure, router } from '@/src/trpc/trpc-index'
import { scaffoldAssetUrl } from '@/src/lib/s3-client' import { scaffoldAssetUrl } from '@/src/lib/s3-client'
import { getUserActiveBanners as getUserActiveBannersInDb } from '@/src/dbService' import { getUserActiveBanners as getUserActiveBannersInDb } from '@/src/dbService'
import type { UserBannersResponse } from '@packages/shared' import type { UserBannersResponse } from '@packages/shared'
@ -23,11 +22,3 @@ export async function scaffoldBanners(): Promise<UserBannersResponse> {
banners: bannersWithSignedUrls, banners: bannersWithSignedUrls,
} }
} }
export const bannerRouter = router({
getBanners: publicProcedure
.query(async () => {
const response = await scaffoldBanners();
return response;
}),
});

View file

@ -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 { 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 { 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<UserCartResponse> => {
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({ export const cartRouter = router({
getCart: protectedProcedure
.query(async ({ ctx }): Promise<UserCartResponse> => {
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<UserCartResponse> => {
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<UserCartResponse> => {
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<UserCartResponse> => {
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<UserCartResponse> => {
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<number, any[]> = {};
// 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 // Cache-based getCartSlots
getCartSlots: publicProcedure getCartSlots: publicProcedure
.input(z.object({ .input(z.object({

View file

@ -86,57 +86,6 @@ export const userCouponRouter = router({
} }
}), }),
getProductCoupons: protectedProcedure
.input(z.object({ skuId: z.number().int().positive() }))
.query(async ({ input, ctx }): Promise<UserEligibleCouponsResponse> => {
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 getMyCoupons: protectedProcedure
.query(async ({ ctx }): Promise<UserMyCouponsResponse> => { .query(async ({ ctx }): Promise<UserMyCouponsResponse> => {
const userId = ctx.user.userId; const userId = ctx.user.userId;

View file

@ -13,9 +13,6 @@ import {
getUserOrderByIdWithRelations, getUserOrderByIdWithRelations,
getUserOrderCount, getUserOrderCount,
getUserOrdersWithRelations, getUserOrdersWithRelations,
getUserProductIdsFromOrders,
getUserProductsForRecentOrders,
getUserRecentlyDeliveredOrderIds,
getUserSlotCapacityStatus, getUserSlotCapacityStatus,
orders, orders,
orderItems, orderItems,
@ -25,7 +22,6 @@ import {
updateUserOrderNotes, updateUserOrderNotes,
validateAndGetUserCoupon, validateAndGetUserCoupon,
} from "@/src/dbService"; } from "@/src/dbService";
import { getNextDeliveryDate } from "@/src/trpc/apis/common-apis/common";
import { scaffoldAssetUrl } from "@/src/lib/s3-client"; import { scaffoldAssetUrl } from "@/src/lib/s3-client";
import { ApiError } from "@/src/lib/api-error"; import { ApiError } from "@/src/lib/api-error";
import { import {
@ -34,13 +30,11 @@ import {
} from "@/src/lib/notif-job"; } from "@/src/lib/notif-job";
import { CONST_KEYS, getConstant, getConstants } from "@/src/lib/const-store"; import { CONST_KEYS, getConstant, getConstants } from "@/src/lib/const-store";
import { publishFormattedOrder, publishCancellation } from "@/src/lib/post-order-handler"; import { publishFormattedOrder, publishCancellation } from "@/src/lib/post-order-handler";
import { getSlotById } from "@/src/stores/slot-store";
import type { import type {
UserOrdersResponse, UserOrdersResponse,
UserOrderDetail, UserOrderDetail,
UserCancelOrderResponse, UserCancelOrderResponse,
UserUpdateNotesResponse, UserUpdateNotesResponse,
UserRecentProductsResponse,
} from "@/src/dbService"; } from "@/src/dbService";
const placeOrderUtil = async (params: { const placeOrderUtil = async (params: {
@ -664,60 +658,4 @@ export const orderRouter = router({
return { success: true, message: "Notes updated successfully" }; 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<UserRecentProductsResponse> => {
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,
};
}),
}); });

View file

@ -2,7 +2,7 @@ import { router, publicProcedure, protectedProcedure } from '@/src/trpc/trpc-ind
import { z } from 'zod' import { z } from 'zod'
import { claimUploadUrl, extractKeyFromPresignedUrl, scaffoldAssetUrl } from '@/src/lib/s3-client' import { claimUploadUrl, extractKeyFromPresignedUrl, scaffoldAssetUrl } from '@/src/lib/s3-client'
import { ApiError } from '@/src/lib/api-error' 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 dayjs from 'dayjs'
import { import {
getUserProductDetailById as getUserProductDetailByIdInDb, getUserProductDetailById as getUserProductDetailByIdInDb,
@ -164,24 +164,6 @@ export const productRouter = router({
return { success: true, review: newReview } return { success: true, review: newReview }
}), }),
getAllProductsSummary: publicProcedure
.query(async (): Promise<UserProductDetail[]> => {
// 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 getOffersPage: publicProcedure
.query(async () => { .query(async () => {
const data = await getOffersAndCombosInDb(); const data = await getOffersAndCombosInDb();

View file

@ -1,30 +1,8 @@
import { router, publicProcedure } from "@/src/trpc/trpc-index" import { router, publicProcedure } from "@/src/trpc/trpc-index"
import { z } from "zod" import { getAllSlots as getAllSlotsFromCache } from "@/src/stores/slot-store"
import { getAllSlots as getAllSlotsFromCache, getSlotById as getSlotByIdFromCache } from "@/src/stores/slot-store"
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { getUserActiveSlotsList as getUserActiveSlotsListInDb, getUserProductAvailability as getUserProductAvailabilityInDb } from '@/src/dbService' import { getUserActiveSlotsList as getUserActiveSlotsListInDb, getUserProductAvailability as getUserProductAvailabilityInDb } from '@/src/dbService'
import type { UserSlotData, UserSlotsListResponse, UserSlotsWithProductsResponse } from '@packages/shared' import type { 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),
};
}
export async function scaffoldSlotsWithProducts(): Promise<UserSlotsWithProductsResponse> { export async function scaffoldSlotsWithProducts(): Promise<UserSlotsWithProductsResponse> {
const allSlots = await getAllSlotsFromCache(); const allSlots = await getAllSlotsFromCache();
@ -82,15 +60,4 @@ export const slotsRouter = router({
count: slots.length, count: slots.length,
} }
}), }),
getSlotsWithProducts: publicProcedure.query(async (): Promise<UserSlotsWithProductsResponse> => {
const response = await scaffoldSlotsWithProducts();
return response;
}),
getSlotById: publicProcedure
.input(z.object({ slotId: z.number() }))
.query(async ({ input }): Promise<UserSlotData | null> => {
return await getSlotData(input.slotId);
}),
}); });

View file

@ -1,5 +1,3 @@
import { router, publicProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod'
import { scaffoldAssetUrl } from '@/src/lib/s3-client' import { scaffoldAssetUrl } from '@/src/lib/s3-client'
import { ApiError } from '@/src/lib/api-error' import { ApiError } from '@/src/lib/api-error'
import { getTagsByStoreId } from '@/src/stores/product-tag-store' import { getTagsByStoreId } from '@/src/stores/product-tag-store'
@ -175,21 +173,3 @@ export async function scaffoldStoreWithProducts(storeId: number): Promise<UserSt
})), })),
} }
} }
export const storesRouter = router({
getStores: publicProcedure
.query(async (): Promise<UserStoresResponse> => {
const response = await scaffoldStores();
return response;
}),
getStoreWithProducts: publicProcedure
.input(z.object({
storeId: z.number(),
}))
.query(async ({ input }): Promise<UserStoreDetail> => {
const { storeId } = input;
const response = await scaffoldStoreWithProducts(storeId);
return response;
}),
});

View file

@ -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,
})),
};
}),
});

View file

@ -1,7 +1,6 @@
import { router } from '@/src/trpc/trpc-index'; import { router } from '@/src/trpc/trpc-index';
import { addressRouter } from '@/src/trpc/apis/user-apis/apis/address'; import { addressRouter } from '@/src/trpc/apis/user-apis/apis/address';
import { authRouter } from '@/src/trpc/apis/user-apis/apis/auth'; 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 { cartRouter } from '@/src/trpc/apis/user-apis/apis/cart';
import { complaintRouter } from '@/src/trpc/apis/user-apis/apis/complaint'; import { complaintRouter } from '@/src/trpc/apis/user-apis/apis/complaint';
import { orderRouter } from '@/src/trpc/apis/user-apis/apis/order'; 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 { userRouter as userDataRouter } from '@/src/trpc/apis/user-apis/apis/user';
import { userCouponRouter } from '@/src/trpc/apis/user-apis/apis/coupon'; import { userCouponRouter } from '@/src/trpc/apis/user-apis/apis/coupon';
import { paymentRouter } from '@/src/trpc/apis/user-apis/apis/payments'; 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 { fileUploadRouter } from '@/src/trpc/apis/user-apis/apis/file-upload';
import { tagsRouter } from '@/src/trpc/apis/user-apis/apis/tags';
export const userRouter = router({ export const userRouter = router({
address: addressRouter, address: addressRouter,
auth: authRouter, auth: authRouter,
banner: bannerRouter,
cart: cartRouter, cart: cartRouter,
complaint: complaintRouter, complaint: complaintRouter,
order: orderRouter, order: orderRouter,
@ -26,9 +22,7 @@ export const userRouter = router({
user: userDataRouter, user: userDataRouter,
coupon: userCouponRouter, coupon: userCouponRouter,
payment: paymentRouter, payment: paymentRouter,
stores: storesRouter,
fileUpload: fileUploadRouter, fileUpload: fileUploadRouter,
tags: tagsRouter,
}); });
export type UserRouter = typeof userRouter; export type UserRouter = typeof userRouter;

View file

@ -12,7 +12,7 @@
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-slot": "^1.1.2", "@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/react-router": "^1.92.8",
"@tanstack/router-devtools": "^1.92.8", "@tanstack/router-devtools": "^1.92.8",
"@trpc/client": "^11.6.0", "@trpc/client": "^11.6.0",

View file

@ -9,8 +9,7 @@ import {
useMarkDataFetchers, useMarkDataFetchers,
LoadingDialog, LoadingDialog,
MyTouchableOpacity, MyTouchableOpacity,
MyText, SearchBar, useStatusBarStore, MyText, SearchBar
colors
} from "common-ui"; } from "common-ui";
import dayjs from "dayjs"; import dayjs from "dayjs";
@ -35,8 +34,8 @@ dayjs.extend(relativeTime);
const { width: screenWidth } = Dimensions.get("window"); const { width: screenWidth } = Dimensions.get("window");
const itemWidth = screenWidth * 0.45; const itemWidth = screenWidth * 0.45;
const heroItemWidth = (screenWidth - 72) / 3;
const gridItemWidth = (screenWidth - 48) / 2; const gridItemWidth = (screenWidth - 48) / 2;
const headerColor = colors.secondaryPink;
const formatTimeRange = (deliveryTime: string) => { const formatTimeRange = (deliveryTime: string) => {
const time = dayjs(deliveryTime); const time = dayjs(deliveryTime);
@ -58,16 +57,15 @@ const staticStyles = {
slotsListContent: { paddingBottom: 24 }, slotsListContent: { paddingBottom: 24 },
}; };
// Light/pastel color pairs for the Explore Products tabs.
const TAG_COLORS = [ const TAG_COLORS = [
{ bg: '#FFE4E6', border: '#FECDD3', text: '#BE123C' }, // rose { pageBg: '#FFF8F9', bg: '#FFF1F2', border: '#FECDD3', text: '#9F1239', dot: '#E11D48' }, // rose
{ bg: '#FEF3C7', border: '#FDE68A', text: '#B45309' }, // amber { pageBg: '#FFFCF1', bg: '#FFFBEB', border: '#FDE68A', text: '#92400E', dot: '#D97706' }, // amber
{ bg: '#DCFCE7', border: '#BBF7D0', text: '#15803D' }, // green { pageBg: '#F6FEF9', bg: '#F0FDF4', border: '#BBF7D0', text: '#166534', dot: '#16A34A' }, // green
{ bg: '#DBEAFE', border: '#BFDBFE', text: '#1D4ED8' }, // blue { pageBg: '#F5FAFF', bg: '#EFF6FF', border: '#BFDBFE', text: '#1E3A8A', dot: '#2563EB' }, // blue
{ bg: '#EDE9FE', border: '#DDD6FE', text: '#6D28D9' }, // violet { pageBg: '#FAF8FF', bg: '#F5F3FF', border: '#DDD6FE', text: '#5B21B6', dot: '#7C3AED' }, // violet
{ bg: '#FFE4CC', border: '#FFD6B0', text: '#C2410C' }, // orange { pageBg: '#FFF9F2', bg: '#FFF7ED', border: '#FFD6B0', text: '#9A3412', dot: '#EA580C' }, // orange
{ bg: '#CFFAFE', border: '#A5F3FC', text: '#0E7490' }, // cyan { pageBg: '#F3FEFF', bg: '#ECFEFF', border: '#A5F3FC', text: '#155E75', dot: '#0891B2' }, // cyan
{ bg: '#FCE7F3', border: '#FBCFE8', text: '#BE185D' }, // pink { pageBg: '#FFF7FB', bg: '#FDF2F8', border: '#FBCFE8', text: '#9D174D', dot: '#DB2777' }, // pink
]; ];
const getTagColor = (id: number) => TAG_COLORS[id % TAG_COLORS.length]; const getTagColor = (id: number) => TAG_COLORS[id % TAG_COLORS.length];
@ -217,32 +215,61 @@ interface ExploreTabProps {
} }
const ExploreTab = memo(({ tag, isSelected, onPress }: ExploreTabProps) => { const ExploreTab = memo(({ tag, isSelected, onPress }: ExploreTabProps) => {
const color = getTagColor(tag.id);
const productCount = tag.productIds?.length || 0;
return ( return (
<MyTouchableOpacity <MyTouchableOpacity
onPress={onPress} onPress={onPress}
activeOpacity={0.8} activeOpacity={0.75}
style={[ style={tw`px-2 pt-2 pb-1`}
tw`px-4 py-2.5 rounded-full border`,
isSelected
? { backgroundColor: color.bg, borderColor: color.text }
: { backgroundColor: '#FFFFFF', borderColor: color.border },
]}
> >
<View style={tw`items-center`}>
<MyText <MyText
style={[ style={[
tw`text-sm font-semibold`, tw`text-base tracking-tight`,
{ color: color.text }, {
color: isSelected ? '#111827' : '#64748B',
fontWeight: isSelected ? '700' : '500',
},
]} ]}
> >
{tag.tagName} ({productCount}) {tag.tagName}
</MyText> </MyText>
<View
style={{
width: '100%',
height: 4,
borderRadius: 999,
marginTop: 8,
backgroundColor: isSelected ? '#111827' : 'transparent',
}}
/>
</View>
</MyTouchableOpacity> </MyTouchableOpacity>
); );
}); });
interface ExploreTabsRowProps {
dashboardTags: any[];
activeTagId: number | null;
onSelectTag: (id: number) => void;
}
const ExploreTabsRow = memo(({ dashboardTags, activeTagId, onSelectTag }: ExploreTabsRowProps) => (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={tw`gap-3 py-1 px-1`}
>
{dashboardTags.map((tag) => (
<ExploreTab
key={tag.id}
tag={tag}
isSelected={activeTagId === tag.id}
onPress={() => onSelectTag(tag.id)}
/>
))}
</ScrollView>
));
interface ExploreProductItemProps { interface ExploreProductItemProps {
item: any; item: any;
onPress: (id: number) => void; onPress: (id: number) => void;
@ -252,14 +279,15 @@ const ExploreProductItem = memo(({ item, onPress }: ExploreProductItemProps) =>
const handlePress = useCallback(() => onPress(item.id), [item.id, onPress]); const handlePress = useCallback(() => onPress(item.id), [item.id, onPress]);
return ( return (
<View style={tw`mr-4`}> <View style={tw`mb-3`}>
<ProductCard <ProductCard
item={item} item={item}
itemWidth={itemWidth} itemWidth={heroItemWidth}
onPress={handlePress} onPress={handlePress}
showDeliveryInfo={false} showDeliveryInfo={false}
useAddToCartDialog={true} useAddToCartDialog={true}
miniView={true} miniView={true}
variant="hero"
/> />
</View> </View>
); );
@ -302,6 +330,7 @@ interface ListHeaderProps {
activeTagId: number | null; activeTagId: number | null;
activeTagProducts: any[]; activeTagProducts: any[];
onSelectTag: (id: number) => void; onSelectTag: (id: number) => void;
onTabsSectionLayout: (layout: { y: number; height: number }) => void;
} }
const ListHeader = memo(({ const ListHeader = memo(({
@ -315,20 +344,22 @@ const ListHeader = memo(({
activeTagId, activeTagId,
activeTagProducts, activeTagProducts,
onSelectTag, onSelectTag,
onTabsSectionLayout,
}: ListHeaderProps) => { }: ListHeaderProps) => {
const [showAllActiveProducts, setShowAllActiveProducts] = useState(false);
const handleLayout = useCallback((event: any) => { const handleLayout = useCallback((event: any) => {
const { y, height } = event.nativeEvent.layout; const { y, height } = event.nativeEvent.layout;
onGradientLayout(y + height); onGradientLayout(y + height);
}, [onGradientLayout]); }, [onGradientLayout]);
React.useEffect(() => {
setShowAllActiveProducts(false);
}, [activeTagId]);
const renderPopularItem = useCallback(({ item }: { item: any }) => ( const renderPopularItem = useCallback(({ item }: { item: any }) => (
<PopularProductItem item={item} onPress={onProductPress} /> <PopularProductItem item={item} onPress={onProductPress} />
), [onProductPress]); ), [onProductPress]);
const renderExploreItem = useCallback(({ item }: { item: any }) => (
<ExploreProductItem item={item} onPress={onProductPress} />
), [onProductPress]);
const renderSlotItem = useCallback(({ item }: { item: any }) => ( const renderSlotItem = useCallback(({ item }: { item: any }) => (
<SlotItem item={item} /> <SlotItem item={item} />
), []); ), []);
@ -337,25 +368,87 @@ const ListHeader = memo(({
tw`absolute left-0 right-0 shadow-lg`, tw`absolute left-0 right-0 shadow-lg`,
{ height: gradientHeight + 32, zIndex: -1 } { height: gradientHeight + 32, zIndex: -1 }
], [gradientHeight]); ], [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 ( return (
<> <>
<View onLayout={handleLayout}> <View onLayout={handleLayout} style={{ backgroundColor: pageTint }}>
<LinearGradient <LinearGradient
colors={[headerColor, headerColor]} colors={[pageTint, pageTint]}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0.5 }} end={{ x: 1, y: 0.5 }}
style={gradientStyle} style={gradientStyle}
/> />
{dashboardTags.length > 0 && (
<View
onLayout={(event) => {
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 },
]}
>
<ExploreTabsRow
dashboardTags={dashboardTags}
activeTagId={activeTagId}
onSelectTag={onSelectTag}
/>
{activeTagProducts.length > 0 ? (
<View style={[tw`mt-4 relative`, { backgroundColor: pageTint }]}>
<View style={tw`flex-row flex-wrap justify-between`}>
{visibleActiveTagProducts.map((product: any) => (
<ExploreProductItem
key={product.id}
item={product}
onPress={onProductPress}
/>
))}
</View>
{hasMoreActiveTagProducts && (
<MyTouchableOpacity
style={tw`self-center mt-1 mb-2 px-5 py-2.5 rounded-full bg-gray-900`}
activeOpacity={0.85}
onPress={() => setShowAllActiveProducts(true)}
>
<MyText style={tw`text-white text-sm font-semibold`}>Show More</MyText>
</MyTouchableOpacity>
)}
</View>
) : (
<View style={tw`py-6 items-center`}>
<MyText style={tw`text-sm text-gray-500 font-medium`}>
No products in this category yet
</MyText>
</View>
)}
</View>
)}
</View>
<View style={tw`py-4`}>
<BannerCarousel />
</View>
<View
style={[
tw`rounded-t-3xl px-4`,
{ backgroundColor: pageTint },
]}
>
{storesData?.stores && storesData.stores.length > 0 && ( {storesData?.stores && storesData.stores.length > 0 && (
<View style={tw`px-4 pb-2`}> <View style={tw`mt-4 mb-5 rounded-[28px] bg-white/70 border border-white px-3 pt-4 pb-2`}>
<View style={tw`flex-row items-center justify-between mb-4 px-1`}> <View style={tw`flex-row items-center justify-between mb-4 px-1`}>
<View> <View>
<MyText style={tw`text-xl font-extrabold text-white tracking-tight drop-shadow-md text-neutral-800`}> <MyText style={tw`text-xl font-extrabold text-gray-900 tracking-tight`}>
Our Stores Our Stores
</MyText> </MyText>
<MyText style={tw`text-xs font-medium mt-0.5 text-neutral-800`}>Fresh from our locations</MyText> <MyText style={tw`text-xs font-medium mt-0.5 text-gray-500`}>Fresh from our locations</MyText>
</View> </View>
</View> </View>
<View style={tw`pb-2`}> <View style={tw`pb-2`}>
@ -369,13 +462,7 @@ const ListHeader = memo(({
</View> </View>
</View> </View>
)} )}
</View>
<View style={tw`py-4`}>
<BannerCarousel />
</View>
<View style={tw`bg-white rounded-t-3xl px-4`}>
<View style={tw`py-2`}> <View style={tw`py-2`}>
<NextOrderGlimpse /> <NextOrderGlimpse />
</View> </View>
@ -404,55 +491,6 @@ const ListHeader = memo(({
/> />
</View> </View>
{dashboardTags.length > 0 && (
<View style={tw`mb-4`}>
<View style={tw`px-1 mb-2`}>
<MyText style={tw`text-2xl font-extrabold text-gray-900 tracking-tight`}>Explore Products</MyText>
<MyText style={tw`text-sm text-gray-500 font-medium mt-1`}>Browse by category</MyText>
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={tw`gap-2 py-2`}
>
{dashboardTags.map((tag) => (
<ExploreTab
key={tag.id}
tag={tag}
isSelected={activeTagId === tag.id}
onPress={() => onSelectTag(tag.id)}
/>
))}
</ScrollView>
{activeTagProducts.length > 0 ? (
<View style={tw`mt-3 relative`}>
<MyFlatList
data={activeTagProducts}
keyExtractor={(item) => item.id.toString()}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={staticStyles.popularListContent}
renderItem={renderExploreItem}
removeClippedSubviews={true}
/>
<LinearGradient
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`}
pointerEvents="none"
/>
</View>
) : (
<View style={tw`py-6 items-center`}>
<MyText style={tw`text-sm text-gray-500 font-medium`}>
No products in this category yet
</MyText>
</View>
)}
</View>
)}
{sortedSlots.length > 0 && ( {sortedSlots.length > 0 && (
<View style={tw`mt-2 mb-4`}> <View style={tw`mt-2 mb-4`}>
<View style={tw`flex-row items-center justify-between px-1 mb-6`}> <View style={tw`flex-row items-center justify-between px-1 mb-6`}>
@ -497,7 +535,9 @@ export default function Dashboard() {
const [displayedProducts, setDisplayedProducts] = useState<any[]>([]); const [displayedProducts, setDisplayedProducts] = useState<any[]>([]);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false);
const { backgroundColor } = useStatusBarStore(); const [searchBarHeight, setSearchBarHeight] = useState(0);
const [tabsSectionLayout, setTabsSectionLayout] = useState({ y: 0, height: 0 });
const [showStickyTabs, setShowStickyTabs] = useState(false);
const { getQuickestSlot } = useProductSlotIdentifier(); const { getQuickestSlot } = useProductSlotIdentifier();
const productSlotsMap = useCentralSlotStore((state) => state.productSlotsMap); const productSlotsMap = useCentralSlotStore((state) => state.productSlotsMap);
const refetchProducts = useCentralProductStore((state) => state.refetchProducts); const refetchProducts = useCentralProductStore((state) => state.refetchProducts);
@ -640,6 +680,19 @@ export default function Dashboard() {
router.push("/(drawer)/(tabs)/home/search-results"); router.push("/(drawer)/(tabs)/home/search-results");
}, [router]); }, [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 }) => ( const renderProductItem = useCallback(({ item }: { item: any }) => (
<ProductItem item={item} onPress={handleProductPress} /> <ProductItem item={item} onPress={handleProductPress} />
// <Image style={{ width: 150, height: 235 }} source={{ uri: item.images[0]}} /> // <Image style={{ width: 150, height: 235 }} source={{ uri: item.images[0]}} />
@ -657,13 +710,20 @@ export default function Dashboard() {
activeTagId={activeTagId} activeTagId={activeTagId}
activeTagProducts={activeTagProducts} activeTagProducts={activeTagProducts}
onSelectTag={setSelectedTagId} 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(() => [
tw`flex-1`,
{ backgroundColor: pageTint }
], [pageTint]);
const searchBarContainerStyle = useMemo(() => [ const searchBarContainerStyle = useMemo(() => [
tw`w-full px-4 pt-4 pb-2`, tw`w-full px-4 pt-4 pb-0`,
{ backgroundColor } { backgroundColor: pageTint }
], [backgroundColor]); ], [pageTint]);
const listContentContainerStyle = useMemo(() => [ const listContentContainerStyle = useMemo(() => [
tw`pb-24`, tw`pb-24`,
@ -695,8 +755,12 @@ export default function Dashboard() {
displayedProducts.forEach(product => str += `${product.id}-`) displayedProducts.forEach(product => str += `${product.id}-`)
// console.log(str) // console.log(str)
return ( return (
<TabLayoutWrapper> <TabLayoutWrapper style={{ backgroundColor: pageTint }}>
<View style={searchBarContainerStyle}> <View style={pageTintStyle}>
<View
style={searchBarContainerStyle}
onLayout={(event) => setSearchBarHeight(event.nativeEvent.layout.height)}
>
<SearchBar <SearchBar
value={""} value={""}
onChangeText={() => { }} onChangeText={() => { }}
@ -716,6 +780,9 @@ export default function Dashboard() {
data={displayedProducts} data={displayedProducts}
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
numColumns={2} numColumns={2}
style={{ backgroundColor: pageTint }}
onScroll={handleListScroll}
scrollEventThrottle={16}
contentContainerStyle={listContentContainerStyle} contentContainerStyle={listContentContainerStyle}
columnWrapperStyle={staticStyles.columnWrapper} columnWrapperStyle={staticStyles.columnWrapper}
renderItem={renderProductItem} renderItem={renderProductItem}
@ -740,11 +807,39 @@ export default function Dashboard() {
updateCellsBatchingPeriod={50} updateCellsBatchingPeriod={50}
/> />
{showStickyTabs && dashboardTags.length > 0 && (
<View
pointerEvents="box-none"
style={[
tw`absolute left-0 right-0 z-20 px-4 pb-2`,
{
top: searchBarHeight,
backgroundColor: pageTint,
elevation: 8,
},
]}
>
<View
style={[
tw`rounded-[28px] px-3 pt-2 pb-1`,
{ backgroundColor: pageTint },
]}
>
<ExploreTabsRow
dashboardTags={dashboardTags}
activeTagId={activeTagId}
onSelectTag={setSelectedTagId}
/>
</View>
</View>
)}
<LoadingDialog open={isLoadingDialogOpen} message="Adding to cart..." /> <LoadingDialog open={isLoadingDialogOpen} message="Adding to cart..." />
<AddToCartDialog /> <AddToCartDialog />
<View style={tw`absolute bottom-2 left-4 right-4`}> <View style={tw`absolute bottom-2 left-4 right-4`}>
<FloatingCartBar /> <FloatingCartBar />
</View> </View>
</View>
</TabLayoutWrapper> </TabLayoutWrapper>
); );
} }

View file

@ -24,6 +24,7 @@ interface ProductCardProps {
onPress?: () => void; onPress?: () => void;
showDeliveryInfo?: boolean; showDeliveryInfo?: boolean;
miniView?: boolean; miniView?: boolean;
variant?: 'default' | 'hero';
nullIfNotAvailable?: boolean; nullIfNotAvailable?: boolean;
containerComp?: React.ComponentType<any> | React.JSXElementConstructor<any>; containerComp?: React.ComponentType<any> | React.JSXElementConstructor<any>;
useAddToCartDialog?: boolean; useAddToCartDialog?: boolean;
@ -42,10 +43,12 @@ const ProductCard: React.FC<ProductCardProps> = ({
onPress, onPress,
showDeliveryInfo = true, showDeliveryInfo = true,
miniView = false, miniView = false,
variant = 'default',
nullIfNotAvailable = false, nullIfNotAvailable = false,
containerComp: ContainerComp = React.Fragment, containerComp: ContainerComp = React.Fragment,
useAddToCartDialog = false, useAddToCartDialog = false,
}) => { }) => {
const isHero = variant === 'hero';
const imageUri = item.images?.[0] const imageUri = item.images?.[0]
const [imageStatus, setImageStatus] = React.useState<'loading' | 'loaded' | 'error'>('loading') const [imageStatus, setImageStatus] = React.useState<'loading' | 'loaded' | 'error'>('loading')
const [imageError, setImageError] = React.useState<string | null>(null) const [imageError, setImageError] = React.useState<string | null>(null)
@ -152,7 +155,8 @@ const ProductCard: React.FC<ProductCardProps> = ({
<ContainerComp> <ContainerComp>
<MyTouchableOpacity <MyTouchableOpacity
style={[ style={[
tw`bg-white rounded-2xl overflow-hidden border border-gray-200 pb-2`, tw`bg-white overflow-hidden border border-gray-200`,
isHero ? tw`rounded-xl pb-1.5` : tw`rounded-2xl pb-2`,
{ width: itemWidth }, { width: itemWidth },
]} ]}
onPress={onPress || (() => {/* TODO: Navigate to product detail */})} onPress={onPress || (() => {/* TODO: Navigate to product detail */})}
@ -162,7 +166,7 @@ const ProductCard: React.FC<ProductCardProps> = ({
<RnImage <RnImage
source={{ uri: imageUri }} source={{ uri: imageUri }}
// source={{uri: 'https://pub-6bf1fbc4048a4cbaa533ddbb13bf9de6.r2.dev/product-images/1763796113884-0'}} // source={{uri: 'https://pub-6bf1fbc4048a4cbaa533ddbb13bf9de6.r2.dev/product-images/1763796113884-0'}}
style={{ width: "100%", height: itemWidth, resizeMode: "cover" }} style={{ width: "100%", height: isHero ? itemWidth * 0.82 : itemWidth, resizeMode: "cover" }}
onLoadStart={() => { onLoadStart={() => {
setImageStatus('loading') setImageStatus('loading')
setImageError(null) setImageError(null)
@ -194,36 +198,36 @@ const ProductCard: React.FC<ProductCardProps> = ({
</View> </View>
)} )}
{miniView && ( {miniView && (
<View style={tw`absolute bottom-2 right-2`}> <View style={isHero ? tw`absolute bottom-1.5 right-1.5` : tw`absolute bottom-2 right-2`}>
{quantity > 0 ? ( {quantity > 0 ? (
<MiniQuantifier value={quantity} onChange={handleQuantityChange} step={item.incrementStep} /> <MiniQuantifier value={quantity} onChange={handleQuantityChange} step={item.incrementStep} />
) : ( ) : (
<MyTouchableOpacity <MyTouchableOpacity
style={tw`w-8 h-8 rounded-full bg-white items-center justify-center shadow-md`} 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)} onPress={() => handleQuantityChange(1)}
activeOpacity={0.8} activeOpacity={0.8}
> >
<CartIcon focused={false} size={16} color="#2E90FA" /> <CartIcon focused={false} size={isHero ? 14 : 16} color="#2E90FA" />
</MyTouchableOpacity> </MyTouchableOpacity>
)} )}
</View> </View>
)} )}
</View> </View>
<View style={tw`px-3 pt-3`}> <View style={isHero ? tw`px-2 pt-2` : tw`px-3 pt-3`}>
<MyText style={tw`text-gray-900 font-bold text-sm mb-1`} numberOfLines={2}> <MyText style={isHero ? tw`text-gray-900 font-semibold text-xs mb-1 leading-4` : tw`text-gray-900 font-bold text-sm mb-1`} numberOfLines={2}>
{item.name} {item.name}
</MyText> </MyText>
<View style={tw`flex-row items-baseline mb-2`}> <View style={isHero ? tw`flex-row items-baseline mb-1` : tw`flex-row items-baseline mb-2`}>
<MyText style={tw`text-brand500 font-bold text-base`}>{item.price}</MyText> <MyText style={isHero ? tw`text-brand500 font-bold text-sm` : tw`text-brand500 font-bold text-base`}>{item.price}</MyText>
{item.marketPrice && Number(item.marketPrice) > Number(item.price) && ( {item.marketPrice && Number(item.marketPrice) > Number(item.price) && (
<MyText style={tw`text-gray-400 text-xs ml-2 line-through`}>{item.marketPrice}</MyText> <MyText style={isHero ? tw`text-gray-400 text-[10px] ml-1.5 line-through` : tw`text-gray-400 text-xs ml-2 line-through`}>{item.marketPrice}</MyText>
)} )}
</View> </View>
<View style={tw`flex-row items-center mb-2`}> <View style={isHero ? tw`flex-row items-center mb-1` : tw`flex-row items-center mb-2`}>
{item.productType !== 'combo' && ( {item.productType !== 'combo' && (
<MyText style={tw`text-gray-500 text-xs font-medium`}>Quantity: <MyText style={tw`text-[#f81260] font-semibold`}>{item.unitNotation}</MyText></MyText> <MyText style={isHero ? tw`text-gray-500 text-[10px] font-medium` : tw`text-gray-500 text-xs font-medium`}>Quantity: <MyText style={tw`text-[#f81260] font-semibold`}>{item.unitNotation}</MyText></MyText>
)} )}
</View> </View>

View file

@ -1,14 +1,16 @@
import React from 'react'; import React from 'react';
import type { StyleProp, ViewStyle } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { tw } from 'common-ui'; import { tw } from 'common-ui';
interface TabLayoutWrapperProps { interface TabLayoutWrapperProps {
children: React.ReactNode; children: React.ReactNode;
style?: StyleProp<ViewStyle>;
} }
export default function TabLayoutWrapper({ children }: TabLayoutWrapperProps) { export default function TabLayoutWrapper({ children, style }: TabLayoutWrapperProps) {
return ( return (
<SafeAreaView edges={['top']} style={tw`flex-1 bg-white`}> <SafeAreaView edges={['top']} style={[tw`flex-1 bg-white`, style]}>
{children} {children}
</SafeAreaView> </SafeAreaView>
); );

View file

@ -202,18 +202,6 @@ export {
getActiveBanners as getUserActiveBanners, getActiveBanners as getUserActiveBanners,
} from './src/user-apis/banners'; } 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 { export {
// User Complaint // User Complaint
getUserComplaints as getUserComplaints, getUserComplaints as getUserComplaints,
@ -308,9 +296,6 @@ export {
getOrderBasic as getUserOrderBasic, getOrderBasic as getUserOrderBasic,
cancelOrderTransaction as cancelUserOrderTransaction, cancelOrderTransaction as cancelUserOrderTransaction,
updateOrderNotes as updateUserOrderNotes, updateOrderNotes as updateUserOrderNotes,
getRecentlyDeliveredOrderIds as getUserRecentlyDeliveredOrderIds,
getProductIdsFromOrders as getUserProductIdsFromOrders,
getProductsForRecentOrders as getUserProductsForRecentOrders,
// Post-order handler helpers // Post-order handler helpers
getOrdersByIdsWithFullData, getOrdersByIdsWithFullData,
getOrderByIdWithFullData, getOrderByIdWithFullData,

View file

@ -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<UserCartItem[]> {
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<void> {
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<void> {
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<boolean> {
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<void> {
await db.delete(cartItems).where(eq(cartItems.userId, userId))
}

View file

@ -14,11 +14,10 @@ import {
userDetails, userDetails,
deliverySlotInfo, deliverySlotInfo,
} from '../db/schema' } from '../db/schema'
import { and, eq, inArray, desc, gte, lte } from 'drizzle-orm' import { and, eq, inArray, desc, lte } from 'drizzle-orm'
import type { import type {
UserOrderSummary, UserOrderSummary,
UserOrderDetail, UserOrderDetail,
UserRecentProduct,
} from '@packages/shared' } from '@packages/shared'
export interface OrderItemInput { export interface OrderItemInput {
@ -552,77 +551,6 @@ export async function updateOrderNotes(
.where(eq(orders.id, orderId)) .where(eq(orders.id, orderId))
} }
export async function getRecentlyDeliveredOrderIds(
userId: number,
limit: number,
since: Date
): Promise<number[]> {
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<number[]> {
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<RecentProductData[]> {
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) // Post-Order Handler Helpers (for Telegram notifications)
// ============================================================================ // ============================================================================

View file

@ -201,18 +201,6 @@ export {
getActiveBanners as getUserActiveBanners, getActiveBanners as getUserActiveBanners,
} from './src/user-apis/banners' } 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 { export {
// User Complaint // User Complaint
getUserComplaints as getUserComplaints, getUserComplaints as getUserComplaints,
@ -312,9 +300,6 @@ export {
getOrderBasic as getUserOrderBasic, getOrderBasic as getUserOrderBasic,
cancelOrderTransaction as cancelUserOrderTransaction, cancelOrderTransaction as cancelUserOrderTransaction,
updateOrderNotes as updateUserOrderNotes, updateOrderNotes as updateUserOrderNotes,
getRecentlyDeliveredOrderIds as getUserRecentlyDeliveredOrderIds,
getSkuIdsFromOrders as getUserProductIdsFromOrders,
getProductsForRecentOrders as getUserProductsForRecentOrders,
// Post-order handler helpers // Post-order handler helpers
getOrdersByIdsWithFullData, getOrdersByIdsWithFullData,
getOrderByIdWithFullData, getOrderByIdWithFullData,

View file

@ -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<UserCartItem[]> {
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<void> {
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<void> {
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<boolean> {
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<void> {
await db.delete(cartItems).where(eq(cartItems.userId, userId))
}

View file

@ -15,11 +15,10 @@ import {
userDetails, userDetails,
deliverySlotInfo, deliverySlotInfo,
} from '../db/schema' } from '../db/schema'
import { and, eq, inArray, desc, gte, sql } from 'drizzle-orm' import { and, eq, inArray, desc, sql } from 'drizzle-orm'
import type { import type {
UserOrderSummary, UserOrderSummary,
UserOrderDetail, UserOrderDetail,
UserRecentProduct,
} from '@packages/shared' } from '@packages/shared'
import { coerceDate } from '../lib/date' import { coerceDate } from '../lib/date'
import { runBatched } from '../lib/run-batched' import { runBatched } from '../lib/run-batched'
@ -611,93 +610,6 @@ export async function updateOrderNotes(
.where(eq(orders.id, orderId)) .where(eq(orders.id, orderId))
} }
export async function getRecentlyDeliveredOrderIds(
userId: number,
limit: number,
since: Date
): Promise<number[]> {
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<number[]> {
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<RecentProductData[]> {
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) // Post-Order Handler Helpers (for Telegram notifications)
// ============================================================================ // ============================================================================

View file

@ -133,32 +133,6 @@ export interface UserBannersResponse {
banners: UserBanner[]; 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 { export interface UserComplaint {
id: number; id: number;
complaintBody: string; complaintBody: string;
@ -367,13 +341,6 @@ export interface UserSlotWithProducts {
products: UserSlotProduct[]; products: UserSlotProduct[];
} }
export interface UserSlotData {
slotId: number;
deliveryTime: Date;
freezeTime: Date;
products: UserSlotProduct[];
}
export interface UserSlotAvailability { export interface UserSlotAvailability {
id: number; id: number;
name: string; name: string;
@ -464,16 +431,6 @@ export interface UserPasswordUpdateResponse {
message: string; message: string;
} }
export interface UserProfileResponse {
success: boolean;
data: {
id: number;
name: string | null;
email: string | null;
mobile: string | null;
};
}
export interface UserDeleteAccountResponse { export interface UserDeleteAccountResponse {
success: boolean; success: boolean;
message: string; message: string;
@ -638,20 +595,3 @@ export interface UserUpdateNotesResponse {
success: boolean; success: boolean;
message: string; 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[];
}