This commit is contained in:
shafi54 2026-05-13 00:04:23 +05:30
parent 5d7f6b7aab
commit 01f6b88392
33 changed files with 14274 additions and 76363 deletions

File diff suppressed because it is too large Load diff

10705
apps/backend/dumps/old1.sql Normal file

File diff suppressed because it is too large Load diff

View file

@ -14,7 +14,7 @@ export const createApp = () => {
// CORS middleware
app.use(cors({
origin: ['http://localhost:5174', 'http://localhost:4174', 'https://ui.freshyo.in', 'https://webui.freshyo.in', 'https://app.freshyo.in'],
origin: ['http://localhost:5174', 'http://localhost:4174', 'https://ui.freshyo.in', 'https://www.freshyo.in', 'https://webui.freshyo.in', 'https://app.freshyo.in'],
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowHeaders: ['Origin', 'X-Requested-With', 'Content-Type', 'Accept', 'Authorization', 'Caller-Interface'],
credentials: true,

View file

@ -9,6 +9,7 @@
},
"dependencies": {
"express": "^4.18.2",
"mime": "^1.6.0",
"pug": "^3.0.2"
}
}

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

View file

@ -4,48 +4,41 @@ html(lang="en")
meta(charset="utf-8")
meta(name="viewport" content="width=device-width, initial-scale=1")
title= title
link(rel="icon" type="image/png" href="/favicon.png")
link(rel="stylesheet" href="/css/styles.css")
body
.app-container
// Navigation
nav.nav
a.nav-logo(href="/")
svg(width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2")
path(d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z")
path(d="M12 6v6l4 2")
img(src="/freshyo-logo.png" alt="Freshyo" width="36" height="36")
span Freshyo
ul.nav-links
li: a(href="/privacy-policy") Back to Policy
// Main Content
.app-content
.delete-card
a.back-link(href="/privacy-policy")
span ← Back to Privacy Policy
span.warning-icon !
a.back-link(href="/privacy-policy") ← Back to Privacy Policy
span.warning-icon ⚠️
h1 Delete Account
p To delete your account and personal data, please verify your mobile number. This action cannot be undone.
form(action="/delete-account" method="POST")
.form-group
label.form-label(for="mobile") Mobile Number
input#mobile.form-input(type="tel" name="mobile" placeholder="+91 99999 99999" required minlength="10")
button.btn.btn-danger(type="submit")
span Delete My Account
// Footer
button.btn.btn-danger(type="submit") Delete My Account
footer.footer
.footer-content
.footer-brand
svg(width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2")
path(d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z")
img(src="/freshyo-logo.png" alt="Freshyo" width="32" height="32")
span Freshyo
.footer-links
a(href="#") For Farmers
a(href="#") For Consumers
a(href="/privacy-policy") Privacy Policy
a(href="/delete-account") Delete Account
p.footer-copyright © #{year} Freshyo Inc.
p.footer-copyright © #{year} Freshyo Inc. All rights reserved.

View file

@ -4,6 +4,9 @@ html(lang="en")
meta(charset="utf-8")
meta(name="viewport" content="width=device-width, initial-scale=1")
title= title
link(rel="icon" type="image/png" href="/favicon.png")
link(rel="preconnect" href="https://fonts.googleapis.com")
link(rel="preconnect" href="https://fonts.gstatic.com" crossorigin)
link(rel="stylesheet" href="/css/styles.css")
body
@ -11,83 +14,140 @@ html(lang="en")
// Navigation
nav.nav
a.nav-logo(href="/")
svg(width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2")
path(d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z")
path(d="M12 6v6l4 2")
img(src="/freshyo-logo.png" alt="Freshyo" width="40" height="40")
span Freshyo
ul.nav-links
li: a(href="#features") Features
li: a(href="#stats") Stats
li: a(href="/privacy-policy") Privacy
// Main Content
.app-content
// Hero Section
section.hero
span.hero-badge Fresh & Healthy
h1 Fresh Meat, Fruits & Veggies
p Experience the true taste of nature. 100% organic, fresh products delivered to your doorstep.
.hero-actions
a.btn.btn-primary(href="/qr-based-download")
span Download App
span.icon-sm →
a.btn.btn-secondary(href="#features") Learn More
.hero-content
span.hero-badge Freshness Delivered, Smiles Picked Up
h1 Fresh Meat, Fruits & Veggies
p.hero-subtitle Experience the true taste of nature. 100% fresh products delivered straight to your doorstep with care and quality.
.hero-actions
a.btn.btn-primary(href="/qr-based-download") Get the App
a.btn.btn-secondary(href="#features") Learn More
// Social Proof
.social-proof
.avatars
img.avatar(src="/freshyo-logo.png" alt="Farmer")
img.avatar(src="/freshyo-logo.png" alt="Customer")
img.avatar(src="/freshyo-logo.png" alt="Partner")
span.avatar-count +500
p.social-text Join 10,000+ happy families
// Features Section
section.section-header#features
h2 Why Choose Freshyo?
p Building an ecosystem where freshness meets fairness
p.section-subtitle Quality you can trust, freshness you can taste
.feature-grid
.feature-item(data-aos="fade-up")
.feature-icon
svg(width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2")
.feature-card
.feature-icon-wrapper
svg(width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2")
path(d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z")
path(d="M12 6v6l4 2")
.feature-content
h3 Farm Fresh & Healthy
p Nutrient-rich products straight from nature, packed with health and vitality
.feature-item(data-aos="fade-up")
.feature-icon
svg(width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2")
.feature-card
.feature-icon-wrapper
svg(width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2")
rect(x="1" y="3" width="15" height="13")
polygon(points="16 8 20 8 23 11 23 16 16 16 16 8")
circle cx="5.5" cy="18.5" r="2.5"
circle cx="18.5" cy="18.5" r="2.5"
circle(cx="5.5" cy="18.5" r="2.5")
circle(cx="18.5" cy="18.5" r="2.5")
.feature-content
h3 Trusted Delivery
p Farm to your doorstep with care. Freshness guaranteed, no preservatives
.feature-item(data-aos="fade-up")
.feature-icon
svg(width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2")
.feature-card
.feature-icon-wrapper
svg(width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2")
path(d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z")
.feature-content
h3 Quality Assured
p Every product vetted for quality. If it's not good enough for our family, it's not for yours
// Stats Section
.stats-row
.stat-item
span.stat-value 500+
span.stat-label Farmers
.stat-item
span.stat-value 10K+
span.stat-label Families
.stat-item
span.stat-value 100%
span.stat-label Organic
.stats-section#stats
h2.stats-title Our Growing Family
.stats-grid
.stat-card
.stat-value(data-target="500") 0
span.stat-suffix +
span.stat-label Happy Farmers
.stat-card
.stat-value(data-target="10000") 0
span.stat-suffix +
span.stat-label Families Served
.stat-card
.stat-value(data-target="100") 0
span.stat-suffix %
span.stat-label Organic Products
// CTA Section
section.cta-section
.cta-content
h2 Ready to Experience Freshness?
p Download the app and get fresh products delivered to your door
a.btn.btn-white(href="/qr-based-download") Download Now
// Footer
footer.footer
.footer-content
.footer-brand
svg(width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2")
path(d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z")
img(src="/freshyo-logo.png" alt="Freshyo" width="32" height="32")
span Freshyo
.footer-links
a(href="#") For Farmers
a(href="#") For Consumers
a(href="/privacy-policy") Privacy Policy
a(href="/delete-account") Delete Account
p.footer-copyright © #{year} Freshyo Inc.
p.footer-copyright © #{year} Freshyo Inc. All rights reserved.
script.
// Stats counter animation
const statsObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const valueEl = entry.target.querySelector('.stat-value');
const target = parseInt(valueEl.dataset.target);
animateValue(valueEl, 0, target, 2000);
statsObserver.unobserve(entry.target);
}
});
}, { threshold: 0.5 });
document.querySelectorAll('.stat-card').forEach(card => {
statsObserver.observe(card);
});
function animateValue(element, start, end, duration) {
const startTime = performance.now();
function update(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
const current = Math.floor(start + (end - start) * easeOutQuart);
element.textContent = current.toLocaleString();
if (progress < 1) requestAnimationFrame(update);
}
requestAnimationFrame(update);
}
// Smooth scroll
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function(e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute('href'));
if (target) target.scrollIntoView({ behavior: 'smooth' });
});
});

View file

@ -4,31 +4,27 @@ html(lang="en")
meta(charset="utf-8")
meta(name="viewport" content="width=device-width, initial-scale=1")
title= title
link(rel="icon" type="image/png" href="/favicon.png")
link(rel="stylesheet" href="/css/styles.css")
body
.app-container
// Navigation
nav.nav
a.nav-logo(href="/")
svg(width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2")
path(d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z")
path(d="M12 6v6l4 2")
img(src="/freshyo-logo.png" alt="Freshyo" width="36" height="36")
span Freshyo
ul.nav-links
li: a(href="/") Home
li: a(href="/privacy-policy" class="active") Privacy
li: a(href="/privacy-policy") Privacy
// Main Content
.app-content
.policy-content
a.back-link(href="/")
span ← Back to Home
a.back-link(href="/") ← Back to Home
h1 Privacy Policy
p.last-updated Last Updated: December 18, 2025
p At Freshyo, we value your trust and are committed to protecting your personal information. This Privacy Policy explains how we collect, use, and safeguard your data when you use our website and services.
p.lead At Freshyo, we value your trust and are committed to protecting your personal information. This Privacy Policy explains how we collect, use, and safeguard your data.
h2 1. Information We Collect
p We collect information that you provide directly to us, such as when you create an account, place an order, or contact customer support. This may include your name, email address, phone number, and delivery address.
@ -51,16 +47,14 @@ html(lang="en")
p You have the right to request the deletion of your personal data. If you wish to delete your account:
a.btn.btn-danger(href="/delete-account" style="margin-top: 1rem; display: inline-flex;") Delete My Account
// Footer
footer.footer
.footer-content
.footer-brand
svg(width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2")
path(d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z")
img(src="/freshyo-logo.png" alt="Freshyo" width="32" height="32")
span Freshyo
.footer-links
a(href="#") For Farmers
a(href="#") For Consumers
a(href="/privacy-policy") Privacy Policy
a(href="/delete-account") Delete Account
p.footer-copyright © #{year} Freshyo Inc.
p.footer-copyright © #{year} Freshyo Inc. All rights reserved.

View file

@ -4,17 +4,19 @@ html(lang="en")
meta(charset="utf-8")
meta(name="viewport" content="width=device-width, initial-scale=1")
title= title
link(rel="stylesheet", href="/css/styles.css")
link(rel="icon" type="image/png" href="/favicon.png")
link(rel="stylesheet" href="/css/styles.css")
body
.download-container
.download-card
a.back-link(href="/")
span ← Back to Home
a.back-link(href="/") ← Back to Home
img(src="/freshyo-logo.png" alt="Freshyo" width="80" height="80")
h1 Download Freshyo
p Experience the true taste of nature on your device
p.tagline Freshness Delivered, Smiles Picked Up
p Choose your platform to get started
.download-options
a.download-option.android(href="intent://play.google.com/store/apps/details?id=in.freshyo.app#Intent;scheme=https;package=com.android.vending;end;", onclick="handleAndroidClick(event)")
.download-icon.android
@ -23,7 +25,7 @@ html(lang="en")
.download-info
h3 Android
p Google Play Store
a.download-option.ios(href="https://apps.apple.com/in/app/freshyo/id6756889077", onclick="handleIOSClick(event)")
.download-icon.ios
svg(width="24" height="24" viewBox="0 0 24 24" fill="currentColor")
@ -31,12 +33,23 @@ html(lang="en")
.download-info
h3 iOS
p Apple App Store
p.mt-lg(style="font-size: 0.75rem; color: var(--gray-500);") If you are not redirected automatically, tap above
a.download-option.browser(href="https://app.freshyo.in" onclick="handleBrowserClick(event)")
.download-icon.browser
svg(width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2")
circle(cx="12" cy="12" r="10")
path(d="M2 12h20")
path(d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z")
.download-info
h3 Use in Browser
p No download required
p.mt-lg(style="font-size: 0.875rem; color: var(--gray-500);") Not redirected? Tap an option above
script.
const ANDROID_URL = 'intent://play.google.com/store/apps/details?id=in.freshyo.app#Intent;scheme=https;package=com.android.vending;end;';
const IOS_URL = 'https://apps.apple.com/in/app/freshyo/id6756889077';
const BROWSER_URL = 'https://app.freshyo.in';
function detectOS() {
const userAgent = navigator.userAgent || navigator.vendor || window.opera;
@ -47,12 +60,8 @@ html(lang="en")
function redirectToStore() {
const os = detectOS();
if (os === 'android') {
window.location.href = ANDROID_URL;
} else if (os === 'ios') {
window.location.href = IOS_URL;
}
if (os === 'android') window.location.href = ANDROID_URL;
else if (os === 'ios') window.location.href = IOS_URL;
}
function handleAndroidClick(event) {
@ -65,4 +74,9 @@ html(lang="en")
window.location.href = IOS_URL;
}
function handleBrowserClick(event) {
event.preventDefault();
window.location.href = BROWSER_URL;
}
window.addEventListener('load', redirectToStore);

View file

@ -4,38 +4,33 @@ html(lang="en")
meta(charset="utf-8")
meta(name="viewport" content="width=device-width, initial-scale=1")
title= title
link(rel="icon" type="image/png" href="/favicon.png")
link(rel="stylesheet" href="/css/styles.css")
body
.app-container
// Navigation
nav.nav
a.nav-logo(href="/")
svg(width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2")
path(d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z")
path(d="M12 6v6l4 2")
img(src="/freshyo-logo.png" alt="Freshyo" width="36" height="36")
span Freshyo
ul.nav-links
li: a(href="/") Back to Home
// Main Content
.app-content
.success-card
span.success-icon ✓
h1 Request Received
p Your request to delete your account has been submitted successfully. Your data will be permanently removed within 7 days.
p Your account deletion request has been submitted. Your data will be permanently removed within 7 days.
a.btn.btn-primary(href="/") Back to Home
// Footer
footer.footer
.footer-content
.footer-brand
svg(width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2")
path(d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z")
img(src="/freshyo-logo.png" alt="Freshyo" width="32" height="32")
span Freshyo
.footer-links
a(href="#") For Farmers
a(href="#") For Consumers
a(href="/privacy-policy") Privacy Policy
a(href="/delete-account") Delete Account
p.footer-copyright © #{year} Freshyo Inc.
p.footer-copyright © #{year} Freshyo Inc. All rights reserved.

View file

@ -1,9 +1,9 @@
{
"short_name": "TanStack App",
"name": "Create TanStack App Sample",
"short_name": "FreshYo App",
"name": "FreshYo - Freshness Delivered Smiles Picked Up",
"icons": [
{
"src": "favicon.ico",
"src": "favicon.png",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View file

@ -81,9 +81,7 @@ export function BottomNavigation() {
>
<div
className={`flex h-14 w-14 items-center justify-center rounded-full border-4 border-white shadow-lg transition-all ${
active
? 'bg-gradient-to-br from-brand-400 to-brand-600'
: 'bg-gradient-to-br from-gray-400 to-gray-600'
'bg-gradient-to-br from-brand-400 to-brand-600'
}`}
>
<span className={active ? 'text-white' : 'text-white/80'}>{tab.icon}</span>

View file

@ -80,7 +80,7 @@ export function FloatingCartBar({ isFlashDelivery = false }: FloatingCartBarProp
<>
{/* Collapsed Bar */}
<div
className="fixed bottom-20 left-4 right-4 z-40 rounded-lg border shadow-2xl"
className="fixed bottom-18 left-4 right-4 z-40 rounded-lg border shadow-2xl"
style={{
backgroundColor: cartBarColor,
borderColor: cartBarBorderColor,
@ -131,7 +131,7 @@ export function FloatingCartBar({ isFlashDelivery = false }: FloatingCartBarProp
</div>
<div
className="rounded-2xl bg-white px-3 py-2 shadow-lg"
className="rounded-2xl bg-white px-3 py-2 shadow-lg mr-2"
onClick={(e) => {
e.stopPropagation()
navigate({

View file

@ -26,11 +26,9 @@ export function PaymentAndOrderComponent({
const [userNotes, setUserNotes] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [paymentMethod, setPaymentMethod] = useState<'online' | 'cod'>('cod')
const [selectedCouponId, setSelectedCouponId] = useState<number | null>(null)
const { data: productsData } = useAllProducts()
const { data: constsData } = useGetEssentialConsts()
const { data: couponsRaw } = trpc.user.coupon.getEligible.useQuery()
const products = productsData?.products || []
const productsById: Record<number, any> = {}
@ -44,62 +42,13 @@ export function PaymentAndOrderComponent({
return sum + price * item.quantity
}, 0)
// Process eligible coupons
const eligibleCoupons = useMemo(() => {
if (!couponsRaw?.data) return []
return couponsRaw.data.map((coupon: any) => {
let isEligible = true
let ineligibilityReason = ''
if (coupon.maxLimitForUser && coupon.usages.length >= coupon.maxLimitForUser) {
isEligible = false
ineligibilityReason = 'Usage limit exceeded'
}
if (coupon.minOrder && parseFloat(coupon.minOrder) > totalPrice) {
isEligible = false
ineligibilityReason = `Min order ₹${coupon.minOrder}`
}
return {
id: coupon.id,
code: coupon.couponCode,
discountType: coupon.discountPercent ? 'percentage' : 'flat',
discountValue: parseFloat(coupon.discountPercent || coupon.flatDiscount || '0'),
maxValue: coupon.maxValue ? parseFloat(coupon.maxValue) : undefined,
minOrder: coupon.minOrder ? parseFloat(coupon.minOrder) : undefined,
description: '',
exclusiveApply: coupon.exclusiveApply,
isEligible,
ineligibilityReason: isEligible ? undefined : ineligibilityReason,
}
}).filter((coupon: any) => coupon.ineligibilityReason !== 'Usage limit exceeded')
}, [couponsRaw, totalPrice])
const selectedCoupons = useMemo(
() => (selectedCouponId ? eligibleCoupons?.filter((coupon: any) => coupon.id === selectedCouponId) : []),
[eligibleCoupons, selectedCouponId]
)
const discountAmount = useMemo(
() =>
selectedCoupons?.reduce(
(sum: number, coupon: any) =>
sum +
(coupon.discountType === 'percentage'
? Math.min((totalPrice * coupon.discountValue) / 100, coupon.maxValue || Infinity)
: Math.min(coupon.discountValue, coupon.maxValue || totalPrice)),
0
) || 0,
[selectedCoupons, totalPrice]
)
const finalTotal = totalPrice - discountAmount
const deliveryCharge = useMemo(() => {
const threshold = isFlashDelivery ? constsData?.flashFreeDeliveryThreshold : constsData?.freeDeliveryThreshold
const charge = isFlashDelivery ? constsData?.flashDeliveryCharge : constsData?.deliveryCharge
return finalTotal < threshold ? charge : 0
}, [finalTotal, constsData, isFlashDelivery])
return totalPrice < threshold ? charge : 0
}, [totalPrice, constsData, isFlashDelivery])
const finalTotalWithDelivery = finalTotal + deliveryCharge
const finalTotalWithDelivery = totalPrice + deliveryCharge
const placeOrderMutation = trpc.user.order.placeOrder.useMutation({
onSuccess: (data) => {
@ -144,7 +93,6 @@ export function PaymentAndOrderComponent({
})),
addressId: selectedAddress,
paymentMethod: paymentMethod,
couponId: selectedCouponId || undefined,
userNotes: userNotes,
isFlashDelivery: isFlashDelivery,
}
@ -249,16 +197,6 @@ export function PaymentAndOrderComponent({
</p>
</div>
{/* Discount */}
{discountAmount > 0 && (
<div className="mb-2 flex items-center justify-between">
<p className="text-gray-500">Product Discount</p>
<p className="font-medium text-green-600">
-{discountAmount.toFixed(2)}
</p>
</div>
)}
{/* Delivery Fee */}
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-1">
@ -282,11 +220,11 @@ export function PaymentAndOrderComponent({
const threshold = isFlashDelivery
? constsData?.flashFreeDeliveryThreshold
: constsData?.freeDeliveryThreshold
return threshold > 0 && finalTotal < threshold ? (
return threshold > 0 && totalPrice < threshold ? (
<div className="mb-2 flex items-center gap-2 rounded-lg bg-blue-50 p-2.5">
<ShoppingBag className="h-4 w-4 text-blue-600" />
<p className="font-medium flex-1 text-xs text-blue-700">
Add products worth {(threshold - finalTotal).toFixed(0)} for free delivery
Add products worth {(threshold - totalPrice).toFixed(0)} for free delivery
</p>
</div>
) : null
@ -306,73 +244,39 @@ export function PaymentAndOrderComponent({
</div>
{/* Savings Banner */}
{(discountAmount > 0 || deliveryCharge === 0) && (
{deliveryCharge === 0 && (
<div className="mt-4 flex items-center justify-center gap-1.5 rounded-lg bg-green-50 p-2">
<Star className="h-4 w-4 text-green-600" />
<p className="font-bold text-xs text-green-700">
You saved
{(discountAmount + (deliveryCharge === 0 ? (isFlashDelivery ? constsData?.flashDeliveryCharge : constsData?.deliveryCharge) : 0)).toFixed(2)}{' '}
on this order
You saved {(isFlashDelivery ? constsData?.flashDeliveryCharge : constsData?.deliveryCharge)?.toFixed(2)} on delivery
</p>
</div>
)}
</div>
{/* Coupon Selection */}
{eligibleCoupons.length > 0 && (
<div className="mb-4 rounded-2xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center gap-2">
<Tag className="h-5 w-5 text-brand-500" />
<p className="font-bold text-lg text-gray-900">
Apply Coupon
</p>
</div>
<div className="space-y-2">
{eligibleCoupons.map((coupon: any) => (
<div
key={coupon.id}
onClick={() => setSelectedCouponId(selectedCouponId === coupon.id ? null : coupon.id)}
className={`flex items-center justify-between rounded-xl border p-3 transition-all ${
selectedCouponId === coupon.id
? 'border-brand-500 bg-blue-50'
: coupon.isEligible
? 'border-gray-200 hover:border-gray-300'
: 'border-gray-100 bg-gray-50 opacity-50'
}`}
disabled={!coupon.isEligible}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="font-bold text-sm text-gray-900">
{coupon.code}
</p>
{!coupon.isEligible && (
<span className="rounded bg-red-100 px-1.5 py-0.5 text-xs text-red-600">
{coupon.ineligibilityReason}
</span>
)}
</div>
<p className="text-xs text-gray-500">
{coupon.discountType === 'percentage'
? `${coupon.discountValue}% off`
: `${coupon.discountValue} off`}
{coupon.maxValue ? ` up to ₹${coupon.maxValue}` : ''}
{coupon.minOrder ? ` | Min order ₹${coupon.minOrder}` : ''}
</p>
</div>
{selectedCouponId === coupon.id && (
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-brand-500">
<svg className="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
)}
</div>
))}
</div>
{/* Download App for Coupons */}
<div className="mb-4 rounded-2xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center gap-2">
<Tag className="h-5 w-5 text-brand-500" />
<p className="font-bold text-lg text-gray-900">
Coupons & Offers
</p>
</div>
)}
<p className="text-sm text-gray-600 mb-3">
Download our app to unlock exclusive coupons and special offers!
</p>
<a
href={constsData?.playStoreUrl || '#'}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-xl bg-brand-500 px-4 py-2 text-sm font-bold text-white hover:bg-brand-600 transition-colors"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Download App
</a>
</div>
{/* Bottom Action Bar */}
<div className="rounded-2xl border border-gray-100 bg-white p-5 shadow-sm">

View file

@ -96,7 +96,7 @@ export function ProductCard({
return (
<div
className="flex max-w-75 flex-col overflow-hidden rounded-2xl bg-white pb-2 border border-gray-300"
className="flex max-w-[300px] flex-col overflow-hidden rounded-2xl bg-white pb-2 border border-gray-300"
onClick={onPress}
>
{/* Image Container */}

View file

@ -19,6 +19,7 @@ import { Route as FlashRouteImport } from './routes/flash'
import { Route as CheckoutRouteImport } from './routes/checkout'
import { Route as CartRouteImport } from './routes/cart'
import { Route as IndexRouteImport } from './routes/index'
import { Route as HomeIndexRouteImport } from './routes/home.index'
import { Route as StoresStoreIdRouteImport } from './routes/stores.$storeId'
import { Route as MeTermsRouteImport } from './routes/me.terms'
import { Route as MeOrdersRouteImport } from './routes/me.orders'
@ -89,6 +90,11 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const HomeIndexRoute = HomeIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => HomeRoute,
} as any)
const StoresStoreIdRoute = StoresStoreIdRouteImport.update({
id: '/$storeId',
path: '/$storeId',
@ -212,6 +218,7 @@ export interface FileRoutesByFullPath {
'/me/orders': typeof MeOrdersRouteWithChildren
'/me/terms': typeof MeTermsRoute
'/stores/$storeId': typeof StoresStoreIdRouteWithChildren
'/home/': typeof HomeIndexRoute
'/flash/product/$id': typeof FlashProductIdRoute
'/home/product/$id': typeof HomeProductIdRoute
'/me/orders/$id': typeof MeOrdersIdRoute
@ -222,7 +229,6 @@ export interface FileRoutesByTo {
'/cart': typeof CartRoute
'/checkout': typeof CheckoutRoute
'/flash': typeof FlashRouteWithChildren
'/home': typeof HomeRouteWithChildren
'/login': typeof LoginRoute
'/me': typeof MeRouteWithChildren
'/register': typeof RegisterRoute
@ -243,6 +249,7 @@ export interface FileRoutesByTo {
'/me/orders': typeof MeOrdersRouteWithChildren
'/me/terms': typeof MeTermsRoute
'/stores/$storeId': typeof StoresStoreIdRouteWithChildren
'/home': typeof HomeIndexRoute
'/flash/product/$id': typeof FlashProductIdRoute
'/home/product/$id': typeof HomeProductIdRoute
'/me/orders/$id': typeof MeOrdersIdRoute
@ -275,6 +282,7 @@ export interface FileRoutesById {
'/me/orders': typeof MeOrdersRouteWithChildren
'/me/terms': typeof MeTermsRoute
'/stores/$storeId': typeof StoresStoreIdRouteWithChildren
'/home/': typeof HomeIndexRoute
'/flash/product/$id': typeof FlashProductIdRoute
'/home/product/$id': typeof HomeProductIdRoute
'/me/orders/$id': typeof MeOrdersIdRoute
@ -308,6 +316,7 @@ export interface FileRouteTypes {
| '/me/orders'
| '/me/terms'
| '/stores/$storeId'
| '/home/'
| '/flash/product/$id'
| '/home/product/$id'
| '/me/orders/$id'
@ -318,7 +327,6 @@ export interface FileRouteTypes {
| '/cart'
| '/checkout'
| '/flash'
| '/home'
| '/login'
| '/me'
| '/register'
@ -339,6 +347,7 @@ export interface FileRouteTypes {
| '/me/orders'
| '/me/terms'
| '/stores/$storeId'
| '/home'
| '/flash/product/$id'
| '/home/product/$id'
| '/me/orders/$id'
@ -370,6 +379,7 @@ export interface FileRouteTypes {
| '/me/orders'
| '/me/terms'
| '/stores/$storeId'
| '/home/'
| '/flash/product/$id'
| '/home/product/$id'
| '/me/orders/$id'
@ -461,6 +471,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/home/': {
id: '/home/'
path: '/'
fullPath: '/home/'
preLoaderRoute: typeof HomeIndexRouteImport
parentRoute: typeof HomeRoute
}
'/stores/$storeId': {
id: '/stores/$storeId'
path: '/$storeId'
@ -618,6 +635,7 @@ interface HomeRouteChildren {
HomeCheckoutRoute: typeof HomeCheckoutRoute
HomeOrderSuccessRoute: typeof HomeOrderSuccessRoute
HomeSearchRoute: typeof HomeSearchRoute
HomeIndexRoute: typeof HomeIndexRoute
HomeProductIdRoute: typeof HomeProductIdRoute
}
@ -626,6 +644,7 @@ const HomeRouteChildren: HomeRouteChildren = {
HomeCheckoutRoute: HomeCheckoutRoute,
HomeOrderSuccessRoute: HomeOrderSuccessRoute,
HomeSearchRoute: HomeSearchRoute,
HomeIndexRoute: HomeIndexRoute,
HomeProductIdRoute: HomeProductIdRoute,
}

View file

@ -1,106 +1,350 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
import {
p,
MyButton,
Quantifier,
div,
} from 'web-components'
import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router'
import { useState, useMemo } from 'react'
import { p, div, MiniQuantifier } from 'web-components'
import { useAllProducts, useStores } from '../hooks/prominent-api-hooks'
import { useCentralProductStore } from '../lib/stores/central-product-store'
import { useCentralSlotStore } from '../lib/stores/central-slot-store'
import { useAddToCart } from '../hooks/cart-query-hooks'
import { useAddToCart, useGetCart, useUpdateCartItem, useRemoveFromCart } from '../hooks/cart-query-hooks'
import { AppLayout } from '../components/AppLayout'
import { ShoppingCart, Zap } from 'lucide-react'
import { Store, Grid3X3, ChevronLeft, ShoppingCart, Zap } from 'lucide-react'
export const Route = createFileRoute('/flash')({ component: FlashDeliveryPage })
export const Route = createFileRoute('/flash')({
component: FlashDeliveryPage,
})
function FlashDeliveryPage() {
const navigate = useNavigate()
const products = useCentralProductStore((s) => s.products)
const productSlotsMap = useCentralSlotStore((s) => s.productSlotsMap)
const [selectedQty, setSelectedQty] = useState<Record<number, number>>({})
const search = useSearch({ from: '/flash' }) as { storeId?: string }
const storeId = search.storeId ? Number(search.storeId) : undefined
const { data: storesData } = useStores()
const { productsById } = useCentralProductStore()
const productSlotsMap = useCentralSlotStore((state) => state.productSlotsMap)
const stores = storesData?.stores || []
const addToCart = useAddToCart('flash')
const { data: cartData } = useGetCart('flash')
const flashProducts = products.filter(
(p) => productSlotsMap[p.id]?.isFlashAvailable && !productSlotsMap[p.id]?.isOutOfStock
)
// Get flash products from central store
const allFlashProducts = useMemo(() => {
return Object.values(productsById).filter(
(product: any) =>
product &&
productSlotsMap[product.id]?.isFlashAvailable &&
!productSlotsMap[product.id]?.isOutOfStock
)
}, [productsById, productSlotsMap])
const handleAddToCart = (product: any) => {
const qty = selectedQty[product.id] || 1
// Filter by store if selected
const filteredProducts = storeId
? allFlashProducts.filter((p: any) => p.storeId === storeId)
: allFlashProducts
const handleAddToCart = (productId: number) => {
const item = filteredProducts.find((p: any) => p.id === productId)
addToCart.mutate(
{ productId: product.id, quantity: qty, storeId: product.storeId },
{ onSuccess: () => navigate({ to: '/flash/cart' }) }
{ productId, quantity: 1, storeId: item?.storeId },
{
onSuccess: () => {
alert(`Added ${item?.name || 'item'} for 1 Hr Delivery`)
},
}
)
}
return (
<AppLayout isFlashDelivery={true}>
<div className="p-4">
<div className="mb-4 flex items-center gap-2">
<Zap className="h-6 w-6 text-yellow-500" />
<p className="font-bold text-xl">
1 Hr Delivery
</p>
</div>
<div className="mb-4 rounded-xl bg-yellow-50 p-3">
<p className="text-sm text-yellow-800">
Get these products delivered within 1 hour! Only available for select items.
</p>
</div>
<div className="grid grid-cols-2 gap-3">
{flashProducts.map((product) => {
const price = product.discountedPrice ?? product.price
const qty = selectedQty[product.id] || 1
return (
<div
key={product.id}
className="rounded-xl border border-gray-100 bg-white p-3 shadow-sm"
<div className="min-h-screen bg-gray-50">
{/* Header - Flash Delivery Style */}
<div className="sticky top-0 z-20 border-b border-gray-200 bg-white px-4 py-3">
<div className="flex items-center gap-2">
{/* Back Button */}
<button
onClick={() => navigate({ to: '/home' })}
className="p-1 hover:bg-gray-100 rounded-full"
>
<div className="mb-2 aspect-square w-full overflow-hidden rounded-lg bg-gray-100">
{product.images?.[0] && (
<img
src={product.images[0].uri}
alt={product.name}
className="h-full w-full object-cover"
/>
)}
</div>
<p className="font-semibold text-sm">
{product.name}
<ChevronLeft className="h-6 w-6 text-gray-700" />
</button>
{/* Flash Delivery Title */}
<div className="flex items-center gap-2">
<Zap className="h-6 w-6 text-[#f81260]" fill="#f81260" />
<p className="text-lg font-bold text-[#f81260]">
Delivery within 1 hour
</p>
<p className="font-bold text-brand-600">
{price}
</div>
</div>
</div>
<div className="flex flex-row">
{/* StoreSidebar - Fixed width on left for both mobile and desktop */}
<div className="w-20 shrink-0 md:w-24">
<StoreSidebar
stores={stores}
storeId={storeId}
onStoreSelect={(newStoreId) =>
navigate({
to: '/flash',
search: { storeId: newStoreId },
})
}
onAllSelect={() =>
navigate({ to: '/flash' })
}
/>
</div>
{/* Products Grid */}
<div className="flex-1 p-4">
{/* Info Banner */}
<div className="mb-4 rounded-xl bg-yellow-50 p-3 border border-yellow-200">
<p className="text-sm text-yellow-800">
Get these products delivered within 1 hour! Only available for select items.
</p>
<div className="mt-2 flex items-center gap-2">
<Quantifier
value={qty}
setValue={(v) =>
setSelectedQty((prev) => ({ ...prev, [product.id]: v }))
</div>
<div className="mb-4 flex items-center justify-between">
<p className="font-bold text-xl text-gray-900">
{storeId
? stores.find((s: any) => s.id === storeId)?.name ||
'Store Products'
: 'All Products'}
</p>
<p className="text-sm text-gray-500">
{filteredProducts.length} items
</p>
</div>
<div className="grid grid-cols-2 gap-3 sm:gap-4 md:grid-cols-3 lg:grid-cols-4">
{filteredProducts.map((product: any) => (
<CompactProductCard
key={product.id}
item={product}
handleAddToCart={handleAddToCart}
onPress={() =>
navigate({
to: '/flash/product/$id',
params: { id: String(product.id) },
})
}
/>
</div>
<MyButton
fullWidth
onClick={() => handleAddToCart(product)}
className="mt-2 flex items-center justify-center gap-1 bg-brand-500 text-white text-xs"
disabled={addToCart.isPending}
>
<ShoppingCart className="h-3 w-3" />
Add
</MyButton>
))}
</div>
)
})}
</div>
{flashProducts.length === 0 && (
<div className="py-20 text-center">
<p className="text-gray-500">No flash delivery products available</p>
{filteredProducts.length === 0 && (
<div className="py-10 text-center">
<p className="font-medium text-gray-400">
{storeId
? 'No flash delivery products from this store.'
: 'No flash delivery products available.'}
</p>
</div>
)}
</div>
</div>
)}
</div>
</div>
</AppLayout>
)
}
interface StoreSidebarProps {
stores: any[];
storeId?: number;
onStoreSelect: (storeId: number) => void;
onAllSelect: () => void;
}
function StoreSidebar({
stores,
storeId,
onStoreSelect,
onAllSelect,
}: StoreSidebarProps) {
return (
<div className="sticky top-[73px] z-10 h-[calc(100vh-73px)] w-full overflow-y-auto border-r border-gray-200 bg-white p-2 md:top-0 md:h-auto md:p-3">
<div className="flex flex-col gap-2 md:gap-3">
{/* All Products Item */}
<div
onClick={onAllSelect}
className={`flex flex-col items-center rounded-2xl p-2 md:p-3 ${
!storeId
? 'bg-gradient-to-br from-[#f81260] to-[#d10f4f] text-white shadow-lg'
: 'border border-gray-100 bg-white text-gray-500'
}`}
>
<div
className={`mb-1 flex h-8 w-8 items-center justify-center rounded-full border md:h-10 md:w-10 ${
!storeId ? 'border-white/30 bg-white/20' : 'bg-gray-50'
}`}
>
<Grid3X3
className={`h-4 w-4 md:h-5 md:w-5 ${!storeId ? 'text-white' : 'text-gray-500'}`}
/>
</div>
<p
className={`text-center text-[10px] font-bold ${!storeId ? 'text-white' : 'text-gray-500'}`}
>
ALL
</p>
</div>
<div className="h-px bg-gray-200 my-1" />
{/* Store Items */}
{stores.map((store: any) => {
const isActive = storeId === store.id;
return (
<div
key={store.id}
onClick={() => onStoreSelect(store.id)}
className={`flex flex-col items-center rounded-2xl p-2 ${
isActive
? 'bg-gradient-to-br from-[#f81260] to-[#d10f4f] text-white shadow-lg'
: 'border border-gray-100 bg-white text-gray-500'
}`}
>
<div
className={`mb-1 md:mb-2 flex h-10 w-10 items-center justify-center overflow-hidden rounded-full border-2 md:h-12 md:w-12 ${
isActive
? 'border-white bg-white'
: 'border-gray-100 bg-gray-50'
}`}
>
{store.signedImageUrl ? (
<img
src={store.signedImageUrl}
alt={store.name}
className="h-full w-full object-cover"
/>
) : (
<Store
className={`h-5 w-5 md:h-6 md:w-6 ${isActive ? 'text-[#f81260]' : 'text-gray-400'}`}
/>
)}
</div>
<p
className={`w-full text-center text-[10px] leading-tight ${
isActive
? 'font-bold text-white'
: 'font-medium text-gray-500'
}`}
>
{store.name.replace(/^The\s+/i, '')}
</p>
</div>
);
})}
</div>
</div>
);
}
const formatQuantity = (
quantity: number,
unit: string,
): { value: string; display: string } => {
if (unit?.toLowerCase() === 'kg' && quantity < 1) {
return {
value: `${Math.round(quantity * 1000)} g`,
display: `${Math.round(quantity * 1000)}g`,
};
}
return { value: `${quantity} ${unit}(s)`, display: `${quantity}${unit}` };
};
interface CompactProductCardProps {
item: any;
handleAddToCart: (productId: number) => void;
onPress?: () => void;
}
function CompactProductCard({
item,
handleAddToCart,
onPress,
}: CompactProductCardProps) {
const { data: cartData } = useGetCart('flash');
const productSlotsMap = useCentralSlotStore((state) => state.productSlotsMap);
const updateCartItem = useUpdateCartItem('flash');
const removeFromCart = useRemoveFromCart('flash');
const cartItem = cartData?.items?.find(
(cartItem: any) => cartItem.productId === item.id,
);
const quantity = cartItem?.quantity || 0;
const isOutOfStock = productSlotsMap[item.id]?.isOutOfStock;
const handleQuantityChange = (newQuantity: number) => {
if (newQuantity === 0 && cartItem) {
removeFromCart.mutate(cartItem.id);
} else if (newQuantity === 1 && !cartItem) {
handleAddToCart(item.id);
} else if (cartItem) {
updateCartItem.mutate({ productId: cartItem.id, quantity: newQuantity });
}
};
const price = item.flashPrice ?? item.price
return (
<div
onClick={onPress}
className="mb-2 overflow-hidden rounded-lg border border-gray-100 bg-white shadow-sm"
>
<div className="relative">
<img
src={item.images?.[0]}
alt={item.name}
className="aspect-square w-full object-cover"
/>
{isOutOfStock && (
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<p className="text-xs font-bold text-white">Out of Stock</p>
</div>
)}
<div className="absolute bottom-2 right-2">
{quantity > 0 ? (
<MiniQuantifier
value={quantity}
setValue={handleQuantityChange}
step={item.incrementStep}
/>
) : (
<div
className="flex h-8 w-8 items-center justify-center rounded-full bg-white shadow-md"
onClick={(e) => {
e.stopPropagation();
handleQuantityChange(1);
}}
>
<ShoppingCart className="h-4 w-4 text-[#f81260]" />
</div>
)}
</div>
</div>
<div className="p-2">
<p className="font-medium mb-1 text-xs text-gray-900">{item.name}</p>
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-baseline">
<p className="font-bold text-sm text-[#f81260]">{price}</p>
{item.marketPrice && Number(item.marketPrice) > Number(item.price) && (
<p className="ml-1 text-xs text-gray-400 line-through">
{item.marketPrice}
</p>
)}
<p className="ml-1 text-xs text-gray-600">
Qty:{" "}
<span className="font-semibold text-[#f81260]">
{formatQuantity(item.productQuantity || 1, item.unit || item.unitNotation).display}
</span>
</p>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,514 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useState, useEffect, useMemo, useRef } from 'react'
import dayjs from 'dayjs'
import {
p,
div,
SearchBar,
} from 'web-components'
import {
useAllProducts,
useStores,
useBanners,
useSlots,
useGetEssentialConsts,
} from '../hooks/prominent-api-hooks'
import { useCartStore } from '../lib/stores/cart-store'
import { useCentralSlotStore } from '../lib/stores/central-slot-store'
import { useCentralProductStore } from '../lib/stores/central-product-store'
import { AppLayout } from '../components/AppLayout'
import { ProductCard } from '../components/ProductCard'
import AddToCartDialog from '../components/AddToCartDialog'
import { useProductSlotIdentifier } from '../hooks/useProductSlotIdentifier'
import { usePopulateCentralStores } from '../hooks/usePopulateCentralStores'
import { Store, ImageOff } from 'lucide-react'
// Scroll Indicator Component
function ScrollIndicator({
containerRef,
itemCount,
itemWidth
}: {
containerRef: React.RefObject<HTMLDivElement>
itemCount: number
itemWidth: number
}) {
const [activeIndex, setActiveIndex] = useState(0)
useEffect(() => {
const container = containerRef.current
if (!container) return
const handleScroll = () => {
const scrollLeft = container.scrollLeft
const maxScroll = container.scrollWidth - container.clientWidth
const scrollProgress = maxScroll > 0 ? scrollLeft / maxScroll : 0
const totalDots = Math.min(itemCount, 5)
const newIndex = Math.round(scrollProgress * (totalDots - 1))
setActiveIndex(Math.min(newIndex, totalDots - 1))
}
container.addEventListener('scroll', handleScroll, { passive: true })
handleScroll()
return () => container.removeEventListener('scroll', handleScroll)
}, [containerRef, itemCount])
const totalDots = Math.min(itemCount, 5)
if (totalDots <= 1) return null
return (
<div className="mt-3 flex justify-center gap-1.5">
{Array.from({ length: totalDots }).map((_, i) => (
<div
key={i}
className={`h-1.5 rounded-full transition-all duration-300 ${
i === activeIndex
? 'w-4 bg-brand-500'
: 'w-1.5 bg-gray-300'
}`}
/>
))}
</div>
)
}
export const Route = createFileRoute('/home/')({ component: HomePage })
function HomePage() {
const navigate = useNavigate()
const { data: productsData } = useAllProducts()
const { data: storesData } = useStores()
const { data: bannersData } = useBanners()
const { data: slotsData } = useSlots()
const { data: essentialConsts } = useGetEssentialConsts()
const { setAddedToCartProduct } = useCartStore()
const { getQuickestSlot } = useProductSlotIdentifier()
const productSlotsMap = useCentralSlotStore((state) => state.productSlotsMap)
// Refs for scrollable sections
const popularScrollRef = useRef<HTMLDivElement>(null)
const slotsScrollRef = useRef<HTMLDivElement>(null)
// Populate central stores with slots and product data
usePopulateCentralStores()
const stores = storesData?.stores || []
// const banners = bannersData?.banners || []
const allProducts = productsData?.products || []
const slots = slotsData?.slots || []
// Sort products: in-stock first, then by slot availability
const sortedProducts = useMemo(() => {
return [...allProducts]
.filter((p) => typeof p.id === 'number')
.sort((a, b) => {
const slotA = getQuickestSlot(a.id)
const slotB = getQuickestSlot(b.id)
if (slotA && !slotB) return -1
if (!slotA && slotB) return 1
const aOutOfStock = productSlotsMap[a.id]?.isOutOfStock
const bOutOfStock = productSlotsMap[b.id]?.isOutOfStock
if (aOutOfStock && !bOutOfStock) return 1
if (!aOutOfStock && bOutOfStock) return -1
return 0
})
}, [allProducts, productSlotsMap, getQuickestSlot])
// Get popular products from essential consts
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 popularProducts = useMemo(() => {
return popularItemIds
.map((id) => allProducts.find((product) => product.id === id))
.filter((product): product is NonNullable<typeof product> => product != null)
}, [popularItemIds, allProducts])
// Sort slots by delivery time
const sortedSlots = useMemo(() => {
const now = dayjs()
return [...slots]
.filter((slot) => dayjs(slot.deliveryTime).isAfter(now))
.sort((a, b) => {
const deliveryDiff = dayjs(a.deliveryTime).diff(dayjs(b.deliveryTime))
if (deliveryDiff !== 0) return deliveryDiff
return dayjs(a.freezeTime).diff(dayjs(b.freezeTime))
})
}, [slots])
const handleProductPress = (id: number) => {
navigate({
to: '/home/product/$id',
params: { id: String(id) },
})
}
const handleAddToCart = (product: any) => {
setAddedToCartProduct({ productId: product.id, product })
}
return (
<AppLayout>
<div className="min-h-screen bg-white pb-24">
{/* Search Bar */}
<div className="sticky top-0 z-10 border-b border-gray-100 bg-white/95 px-4 pb-3 pt-4 backdrop-blur-sm">
<SearchBar
placeholder="Search products here..."
onClick={() => navigate({ to: '/home/search' })}
/>
</div>
<div className="px-4">
{/* Banner Carousel - Commented out */}
{/* {banners.length > 0 && (
<div className="mb-6 mt-4 overflow-hidden rounded-xl">
<BannerCarousel banners={banners} />
</div>
)} */}
{/* Download App Banner */}
<div className="mb-6 mt-4 rounded-xl bg-gradient-to-r from-brand-500 to-brand-600 p-4 shadow-lg">
<div className="flex items-center justify-between">
<div className="flex-1">
<p className="text-lg font-bold text-white">Get the FreshYo App</p>
<p className="text-sm text-white/80 mt-1">Download for exclusive offers & faster checkout</p>
</div>
<a
href={essentialConsts?.playStoreUrl || '#'}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-lg bg-white px-4 py-2 text-sm font-bold text-brand-600 shadow-md hover:bg-gray-50 transition-colors"
>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 20.5V3.5C3 2.91 3.34 2.39 3.84 2.15L13.69 12L3.84 21.85C3.34 21.6 3 21.09 3 20.5ZM16.81 15.12L6.05 21.34L14.54 12.85L16.81 15.12ZM20.16 10.81C20.5 11.08 20.75 11.5 20.75 12C20.75 12.5 20.53 12.9 20.18 13.18L17.89 14.5L15.39 12L17.89 9.5L20.16 10.81ZM6.05 2.66L16.81 8.88L14.54 11.15L6.05 2.66Z"/>
</svg>
Get App
</a>
</div>
</div>
{/* Stores Section */}
{stores.length > 0 && (
<div className="mb-6">
<div className="mb-4 flex items-center justify-between">
<div>
<p className="font-bold text-xl text-gray-900">
Our Stores
</p>
<p className="mt-0.5 text-xs text-gray-500">
Fresh from our locations
</p>
</div>
</div>
<div className="flex flex-wrap gap-4">
{stores.map((store: any) => (
<div key={store.id}>
<StoreCard
store={store}
onClick={() =>
navigate({
to: '/stores/$storeId',
params: { storeId: String(store.id) },
})
}
/>
</div>
))}
</div>
</div>
)}
{/* Popular Items Section */}
{popularProducts.length > 0 && (
<div className="mb-6">
<div className="mb-4">
<p className="font-bold text-xl text-gray-900">
Popular Items
</p>
<p className="mt-0.5 text-sm text-gray-500">
Trending fresh picks just for you
</p>
</div>
<div
ref={popularScrollRef}
className="scrollbar-hide -mx-4 flex gap-4 overflow-x-auto px-4 pb-2"
>
{popularProducts.map((product) => (
<div key={product.id} className="w-40 shrink-0">
<ProductCard
item={product}
onPress={() => handleProductPress(product.id)}
showDeliveryInfo={false}
miniView={true}
useAddToCartDialog={true}
/>
</div>
))}
</div>
<ScrollIndicator
containerRef={popularScrollRef}
itemCount={popularProducts.length}
itemWidth={160}
/>
</div>
)}
{/* Upcoming Delivery Slots Section */}
{sortedSlots.length > 0 && (
<div className="mb-6">
<div className="mb-4">
<p className="font-bold text-xl text-gray-900">
Upcoming Delivery Slots
</p>
<p className="mt-0.5 text-sm text-gray-500">
Plan your fresh deliveries ahead
</p>
</div>
<div
ref={slotsScrollRef}
className="scrollbar-hide -mx-4 flex gap-4 overflow-x-auto px-4 pb-2"
>
{sortedSlots.slice(0, 5).map((slot) => (
<SlotCard key={slot.id} slot={slot} />
))}
</div>
<ScrollIndicator
containerRef={slotsScrollRef}
itemCount={sortedSlots.slice(0, 5).length}
itemWidth={280}
/>
</div>
)}
{/* All Products Section */}
<div className="rounded-t-3xl bg-white pt-4">
<div className="mb-4">
<p className="font-bold text-xl text-gray-900">
All Available Products
</p>
<p className="mt-0.5 text-sm text-gray-500">
Browse our complete selection
</p>
</div>
{/* Responsive Grid for Products - 2 columns on mobile */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4">
{sortedProducts.map((product) => (
<ProductCard
key={product.id}
item={product}
onPress={() => handleProductPress(product.id)}
showDeliveryInfo={true}
miniView={false}
useAddToCartDialog={true}
/>
))}
</div>
{sortedProducts.length === 0 && (
<div className="py-8 text-center">
<p className="text-gray-500">No products available</p>
</div>
)}
</div>
</div>
<AddToCartDialog />
</div>
</AppLayout>
)
}
function BannerCarousel({ banners }: { banners: any[] }) {
const [index, setIndex] = useState(0)
const images = banners.map((b: any) => b.imageUrl).filter(Boolean)
useEffect(() => {
if (images.length <= 1) return
const timer = setInterval(() => {
setIndex((i) => (i + 1) % images.length)
}, 4000)
return () => clearInterval(timer)
}, [images.length])
if (images.length === 0) return null
return (
<div className="flex justify-center">
<div className="group relative inline-block">
<img
src={images[index]}
alt="Banner"
className="max-h-[420px] w-auto max-w-full rounded-xl object-contain transition-all duration-500"
/>
{images.length > 1 && (
<>
<button
onClick={() => setIndex((i) => (i - 1 + images.length) % images.length)}
className="absolute left-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition-all hover:bg-black/70"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
onClick={() => setIndex((i) => (i + 1) % images.length)}
className="absolute right-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition-all hover:bg-black/70"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
<div className="absolute bottom-3 left-1/2 z-10 flex -translate-x-1/2 gap-1.5">
{images.map((_: any, i: number) => (
<button
key={i}
onClick={() => setIndex(i)}
className={`h-2 rounded-full transition-all ${
i === index ? 'w-6 bg-white' : 'w-2 bg-white/50'
}`}
/>
))}
</div>
</>
)}
</div>
</div>
)
}
function StoreCard({ store, onClick }: { store: any; onClick: () => void }) {
return (
<div
onClick={onClick}
className="flex flex-col items-center"
>
<div className="mb-2 flex h-16 w-16 items-center justify-center overflow-hidden rounded-2xl border-2 border-white/30 bg-gray-100 shadow-lg">
{store.signedImageUrl ? (
<img
src={store.signedImageUrl}
alt={store.name}
className="h-full w-full object-cover"
/>
) : (
<Store className="h-7 w-7 text-gray-400" />
)}
</div>
<p className="font-bold text-center text-xs tracking-wide text-gray-800">
{store.name.replace(/^The\s+/i, '')}
</p>
</div>
)
}
function SlotCard({ slot }: { slot: any }) {
const navigate = useNavigate()
const now = dayjs()
const freezeTime = dayjs(slot.freezeTime)
const isClosingSoon = freezeTime.diff(now, 'hour') < 4 && freezeTime.isAfter(now)
const formatTimeRange = (deliveryTime: string) => {
const time = dayjs(deliveryTime)
const endTime = time.add(1, 'hour')
const startPeriod = time.format('A')
const endPeriod = endTime.format('A')
if (startPeriod === endPeriod) {
return `${time.format('h')}-${endTime.format('h')} ${startPeriod}`
} else {
return `${time.format('h:mm')} ${startPeriod} - ${endTime.format('h:mm')} ${endPeriod}`
}
}
return (
<div
onClick={() => navigate({ to: '/slot-view', search: { slotId: slot.id } })}
className={`min-w-70 shrink-0 cursor-pointer rounded-3xl border border-slate-100 bg-white p-5 shadow-xl ${
isClosingSoon ? 'border-l-4 border-l-amber-400' : 'border-l-4 border-l-brand-500'
}`}
>
<div className="mb-4 flex flex-row items-start justify-end">
{isClosingSoon && (
<div className="flex flex-row items-center rounded-md border border-amber-100 bg-amber-50 px-2 py-0.5">
<div className="mr-1 h-1 w-1 rounded-full bg-amber-500" />
<p className="text-[10px] font-bold text-amber-700">CLOSING SOON</p>
</div>
)}
</div>
<div className="mb-5 flex flex-row justify-between">
<div className="mr-4 flex-1">
<div className="mb-1.5 flex flex-row items-center">
<div className="mr-1.5 rounded-md bg-brand-50 p-1">
<svg className="h-3 w-3 text-brand-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
</div>
<p className="text-[10px] font-bold uppercase text-brand-700">Delivery At</p>
</div>
<p className="font-bold text-sm text-slate-900">
{formatTimeRange(slot.deliveryTime)}
</p>
<p className="text-[11px] font-bold text-slate-500">
{dayjs(slot.deliveryTime).format('ddd, MMM DD')}
</p>
</div>
<div className="flex-1">
<div className="mb-1.5 flex flex-row items-center">
<div className="mr-1.5 rounded-md bg-amber-50 p-1">
<svg className="h-3 w-3 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p className="text-[10px] font-bold uppercase text-amber-700">Order By</p>
</div>
<p className="font-bold text-sm text-slate-900">
{dayjs(slot.freezeTime).format('h:mm A')}
</p>
<p className="text-[11px] font-bold text-slate-500">
{dayjs(slot.freezeTime).format('ddd, MMM DD')}
</p>
</div>
</div>
<div className="flex flex-row items-center">
<div className="mr-3 flex flex-row">
{slot.products?.slice(0, 3).map((p: any, i: number) => (
<div
key={p.id}
className={`h-8 w-8 overflow-hidden rounded-full border-2 border-white bg-slate-100 ${i > 0 ? '-ml-3' : ''}`}
>
{p.images?.[0] ? (
<img src={p.images[0]} alt="" className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center">
<ImageOff className="h-3.5 w-3.5 text-slate-400" />
</div>
)}
</div>
))}
</div>
<p className="text-[11px] font-bold text-brand-600">
View all {slot.products?.length || 0} items
</p>
<svg className="ml-1 h-4 w-4 text-brand-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
)
}

View file

@ -1,114 +1,438 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
import { useState, useMemo, useEffect } from 'react'
import { p, div, MiniQuantifier, AppContainer } from 'web-components'
import { useCentralProductStore } from '../lib/stores/central-product-store'
import { useCentralSlotStore } from '../lib/stores/central-slot-store'
import { trpc } from '../lib/trpc-client'
import { useAddToCart } from '../hooks/cart-query-hooks'
import { useState } from 'react'
import { p, MyButton, Quantifier, AppContainer } from 'web-components'
import { ShoppingCart, Star } from 'lucide-react'
import { useAddToCart, useGetCart, useUpdateCartItem, useRemoveFromCart } from '../hooks/cart-query-hooks'
import { useSlots } from '../hooks/prominent-api-hooks'
import { useCartStore } from '../lib/stores/cart-store'
import { useProductSlotIdentifier } from '../hooks/useProductSlotIdentifier'
import dayjs from 'dayjs'
import { ChevronLeft, Star, Zap, Truck, Store, Package, Plus, Clock, AlertCircle } from 'lucide-react'
import { Dialog } from '../components/Dialog'
import { FloatingCartBar } from '../components/FloatingCartBar'
import { AppLayout } from '../components/AppLayout'
export const Route = createFileRoute('/home/product/$id')({
component: ProductDetailPage,
})
const formatQuantity = (quantity: number, unit: string): string => {
if (unit?.toLowerCase() === 'kg' && quantity < 1) {
return `${Math.round(quantity * 1000)}g`
}
return `${quantity}${unit}`
}
function ProductDetailPage() {
const { id } = Route.useParams()
const { id } = useParams({ from: '/home/product/$id' })
const productId = Number(id)
const navigate = useNavigate()
const [quantity, setQuantity] = useState(1)
const productsById = useCentralProductStore((s) => s.productsById)
const product = productsById[productId]
const addToCart = useAddToCart('regular')
const { data: reviews } = trpc.user.product.getProductReviews.useQuery(
{ productId },
const [showAllSlots, setShowAllSlots] = useState(false)
const [currentImageIndex, setCurrentImageIndex] = useState(0)
const { data: productDetail, isLoading, error, refetch } = trpc.user.product.getProductDetails.useQuery(
{ id: String(productId) },
{ enabled: !!productId }
)
const { data: slotsData } = useSlots()
const { data: cartData } = useGetCart('regular')
const productSlotsMap = useCentralSlotStore((state) => state.productSlotsMap)
const { getQuickestSlot } = useProductSlotIdentifier()
const { setAddedToCartProduct } = useCartStore()
const addToCart = useAddToCart('regular')
const addToFlashCart = useAddToCart('flash')
const updateCartItem = useUpdateCartItem('regular')
const removeFromCart = useRemoveFromCart('regular')
const productAvailability = useMemo(() => {
if (!productDetail) return null
return productSlotsMap[productDetail.id]
}, [productDetail, productSlotsMap])
const sortedDeliverySlots = useMemo(() => {
if (!slotsData?.slots || !productDetail) return []
const productSlots = slotsData.slots.filter((slot: any) =>
slot.products?.some((p: any) => p.id === productDetail.id)
)
return productSlots.sort((a: any, b: any) => {
const deliveryDiff = new Date(a.deliveryTime).getTime() - new Date(b.deliveryTime).getTime()
if (deliveryDiff !== 0) return deliveryDiff
return new Date(a.freezeTime).getTime() - new Date(b.freezeTime).getTime()
})
}, [slotsData, productDetail])
const cartItem = productDetail
? cartData?.items?.find((item: any) => item.productId === productDetail.id)
: null
const quantity = cartItem?.quantity || 0
const discountPercentage = productDetail?.marketPrice
? Math.round(((Number(productDetail.marketPrice) - Number(productDetail.price)) / Number(productDetail.marketPrice)) * 100)
: 0
const handleQuantityChange = (newQuantity: number) => {
if (!productDetail) return
if (newQuantity === 0 && cartItem) {
removeFromCart.mutate(cartItem.id)
} else if (newQuantity === 1 && !cartItem) {
handleAddToCart()
} else if (cartItem) {
updateCartItem.mutate({ productId: cartItem.id, quantity: newQuantity })
}
}
const handleAddToCart = () => {
if (!product) return
if (!productDetail) return
const slotId = getQuickestSlot(productDetail.id)
if (!slotId) {
alert('No available delivery slot for this product')
return
}
addToCart.mutate(
{ productId: product.id, quantity, storeId: product.storeId },
{ onSuccess: () => navigate({ to: '/cart' }) }
{ productId: productDetail.id, quantity: 1, slotId, storeId: productDetail.storeId },
{
onSuccess: () => {
const slot = slotsData?.slots?.find((s: any) => s.id === slotId)
const deliveryTime = slot?.deliveryTime
? dayjs(slot.deliveryTime).format('ddd, DD MMM • h:mm A')
: ''
alert(`Added ${productDetail.name} for delivery at ${deliveryTime}`)
},
}
)
}
if (!product) {
const handleBuyNow = () => {
if (!productDetail) return
addToFlashCart.mutate(
{ productId: productDetail.id, quantity: 1, storeId: productDetail.storeId },
{
onSuccess: () => {
navigate({ to: '/flash/cart' })
},
}
)
}
const handleSlotAddToCart = (selectedSlotId: number) => {
if (!productDetail) return
if (cartItem) {
removeFromCart.mutate(
{ itemId: cartItem.id },
{
onSuccess: () => {
addToCart.mutate(
{ productId: productDetail.id, quantity: cartItem.quantity + 1, slotId: selectedSlotId, storeId: productDetail.storeId }
)
},
}
)
} else {
addToCart.mutate(
{ productId: productDetail.id, quantity: 1, slotId: selectedSlotId, storeId: productDetail.storeId }
)
}
setShowAllSlots(false)
}
if (isLoading) {
return (
<AppContainer>
<p>Product not found</p>
</AppContainer>
<AppLayout>
<div className="flex min-h-screen items-center justify-center">
<p className="text-gray-500">Loading product details...</p>
</div>
</AppLayout>
)
}
const price = product.discountedPrice ?? product.price
const imageUrl = product.images?.[0]
if (error || !productDetail) {
return (
<AppLayout>
<div className="flex min-h-screen flex-col items-center justify-center">
<AlertCircle className="h-12 w-12 text-red-500" />
<p className="mt-4 text-lg font-bold text-gray-900">Oops!</p>
<p className="text-gray-500">Product not found or error loading</p>
</div>
</AppLayout>
)
}
const images = productDetail.images || []
return (
<AppContainer>
{imageUrl && (
<div className="mb-4 aspect-square w-full overflow-hidden rounded-xl bg-gray-100">
<img
src={imageUrl}
alt={product.name}
className="h-full w-full object-cover"
/>
<AppLayout>
<div className="min-h-screen bg-gray-50 pb-20">
{/* Back Button */}
<div className="fixed left-4 top-4 z-30">
<button
onClick={() => navigate({ to: '/home' })}
className="flex h-10 w-10 items-center justify-center rounded-full bg-white/90 shadow-md backdrop-blur-sm"
>
<ChevronLeft className="h-6 w-6 text-gray-700" />
</button>
</div>
)}
<p className="font-bold mb-1 text-xl">
{product.name}
</p>
<p className="mb-2 text-sm text-gray-500">
{product.unitValue}{product.unit}
</p>
<div className="mb-4 flex items-baseline gap-2">
<p className="font-bold text-2xl text-brand-600">
{price}
</p>
{product.discountedPrice && (
<p className="text-sm text-gray-400 line-through">
{product.price}
</p>
)}
</div>
{product.description && (
<p className="mb-4 text-gray-600">{product.description}</p>
)}
<div className="mb-6">
<Quantifier value={quantity} setValue={setQuantity} max={10} />
</div>
<MyButton
fullWidth
onClick={handleAddToCart}
disabled={addToCart.isPending}
className="flex items-center justify-center gap-2 bg-brand-500 text-white"
>
<ShoppingCart className="h-4 w-4" />
{addToCart.isPending ? 'Adding...' : 'Add to Cart'}
</MyButton>
{/* Reviews */}
{reviews?.data && reviews.data.length > 0 && (
<div className="mt-8">
<p className="font-bold mb-3 text-lg">
Reviews
</p>
{reviews.data.map((review: any, i: number) => (
<div key={i} className="mb-3 rounded-lg border border-gray-100 p-3">
<div className="mb-1 flex items-center gap-1">
{Array.from({ length: review.rating || 5 }).map((_, j) => (
<Star key={j} className="h-3 w-3 fill-yellow-400 text-yellow-400" />
))}
{/* Image Carousel */}
<div className="relative bg-white shadow-sm">
<div className="aspect-square w-full overflow-hidden">
{images.length > 0 ? (
<img
src={images[currentImageIndex]}
alt={productDetail.name}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-gray-100">
<Package className="h-20 w-20 text-gray-300" />
</div>
<p className="text-sm text-gray-600">{review.comment}</p>
)}
</div>
{/* Image Pagination Dots */}
{images.length > 1 && (
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 gap-2">
{images.map((_, idx) => (
<button
key={idx}
onClick={() => setCurrentImageIndex(idx)}
className={`h-2 rounded-full transition-all ${
idx === currentImageIndex ? 'w-6 bg-brand-500' : 'w-2 bg-gray-300'
}`}
/>
))}
</div>
))}
)}
</div>
)}
</AppContainer>
<div className="px-4 py-4">
{/* Product Info Card */}
<div className="mb-4 rounded-2xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-2 flex items-start justify-between">
<h1 className="flex-1 text-2xl font-bold text-gray-900">{productDetail.name}</h1>
<div className="flex gap-2">
{productAvailability?.isFlashAvailable && (
<div className="flex items-center gap-1 rounded-full bg-pink-100 px-3 py-1">
<Zap className="h-3 w-3 text-pink-600" fill="#EC4899" />
<span className="text-xs font-bold text-pink-700">1 Hr Delivery</span>
</div>
)}
{productAvailability?.isOutOfStock && (
<div className="rounded-full bg-red-100 px-3 py-1">
<span className="text-xs font-bold text-red-700">Out of Stock</span>
</div>
)}
</div>
</div>
<p className="mb-4 text-base leading-6 text-gray-500">{productDetail.shortDescription}</p>
{/* Price Section */}
<div className="mb-4">
<div className="flex items-end">
<span className="text-3xl font-bold text-gray-900">{productDetail.price}</span>
<span className="mb-1 ml-1 text-lg text-gray-500">
/ {formatQuantity(productDetail.productQuantity || 1, productDetail.unitNotation)}
</span>
{productDetail.marketPrice && (
<div className="mb-1 ml-3 flex items-center">
<span className="mr-2 text-base text-gray-400 line-through">{productDetail.marketPrice}</span>
<span className="rounded bg-green-100 px-2 py-0.5 text-xs font-bold text-green-700">
{discountPercentage}% OFF
</span>
</div>
)}
</div>
{/* Flash Price */}
{productAvailability?.isFlashAvailable && productDetail.flashPrice && productDetail.flashPrice !== productDetail.price && (
<div className="mt-1">
<span className="text-lg font-bold text-pink-600">
1 Hr Delivery: {productDetail.flashPrice} / {formatQuantity(productDetail.productQuantity || 1, productDetail.unitNotation)}
</span>
</div>
)}
</div>
{/* Action Buttons */}
<div className="flex gap-3">
{quantity > 0 ? (
<div className="flex-1">
<MiniQuantifier
value={quantity}
setValue={handleQuantityChange}
step={productDetail.incrementStep || 1}
unit={productDetail.unitNotation}
/>
</div>
) : (
<button
onClick={() => {
if (productAvailability?.isOutOfStock) return
setAddedToCartProduct({ productId: productDetail.id, product: productDetail })
}}
disabled={productAvailability?.isOutOfStock}
className={`flex-1 rounded-xl border py-3.5 font-bold transition-colors ${
productAvailability?.isOutOfStock
? 'border-gray-300 text-gray-400'
: 'border-brand-500 text-brand-500 hover:bg-brand-50'
}`}
>
{productAvailability?.isOutOfStock ? 'Unavailable' : 'Add to Cart'}
</button>
)}
{productAvailability?.isFlashAvailable ? (
<button
onClick={handleBuyNow}
disabled={productAvailability?.isOutOfStock}
className={`flex-1 rounded-xl py-3.5 font-bold shadow-md transition-colors ${
productAvailability?.isOutOfStock
? 'bg-gray-300 text-gray-500'
: 'bg-pink-50 text-pink-600 hover:bg-pink-100'
}`}
>
{productAvailability?.isOutOfStock ? 'Out of Stock' : 'Get in 1 Hour'}
</button>
) : (
<div className="flex-1" />
)}
</div>
</div>
{/* Delivery Slots */}
<div className="mb-4 rounded-2xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-4 flex items-center">
<div className="mr-3 flex h-8 w-8 items-center justify-center rounded-full bg-blue-50">
<Truck className="h-4 w-4 text-blue-500" />
</div>
<h2 className="text-lg font-bold text-gray-900">Available Slots</h2>
</div>
{sortedDeliverySlots.length === 0 ? (
<p className="italic text-gray-400">No delivery slots available currently</p>
) : (
<>
{sortedDeliverySlots.slice(0, 2).map((slot: any, index: number) => (
<button
key={index}
onClick={() => handleSlotAddToCart(slot.id)}
disabled={productAvailability?.isOutOfStock}
className="mb-3 flex w-full items-start rounded-xl border border-gray-100 bg-gray-50 p-3 text-left transition-colors hover:bg-gray-100"
>
<Truck className="mt-0.5 h-5 w-5 text-blue-500" />
<div className="ml-3 flex-1">
<p className="text-sm font-bold text-gray-900">
{dayjs(slot.deliveryTime).format('ddd, DD MMM • h:mm A')}
</p>
<p className="mt-1 text-xs text-gray-500">
Orders Close: {dayjs(slot.freezeTime).format('h:mm A')}
</p>
</div>
<Plus className="mt-0.5 h-5 w-5 text-blue-500" />
</button>
))}
{sortedDeliverySlots.length > 2 && (
<button
onClick={() => setShowAllSlots(true)}
className="w-full py-2 text-center text-sm font-bold text-brand-500"
>
View All {sortedDeliverySlots.length} Slots
</button>
)}
</>
)}
</div>
{/* Description */}
<div className="mb-4 rounded-2xl border border-gray-100 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-lg font-bold text-gray-900">About the Product</h2>
{!productDetail.longDescription ? (
<p className="italic text-gray-400">No detailed description available.</p>
) : (
<p className="leading-6 text-gray-600">{productDetail.longDescription}</p>
)}
{productDetail.store && (
<div className="mt-6 flex items-center border-t border-gray-100 pt-4">
<div className="mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-gray-100">
<Store className="h-5 w-5 text-gray-600" />
</div>
<div>
<p className="text-xs font-bold uppercase text-gray-500">Sourced From</p>
<p className="font-bold text-gray-900">{productDetail.store.name}</p>
</div>
</div>
)}
</div>
{/* Package Deals */}
{productDetail.specialDeals && productDetail.specialDeals.length > 0 && (
<div className="mb-4 rounded-2xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-4 flex items-center">
<div className="mr-3 flex h-8 w-8 items-center justify-center rounded-full bg-amber-50">
<Package className="h-4 w-4 text-amber-500" />
</div>
<h2 className="text-lg font-bold text-gray-900">Bulk Savings</h2>
</div>
{productDetail.specialDeals.map((deal: any, index: number) => (
<div
key={index}
className="mb-2 flex items-center justify-between rounded-xl border border-amber-100 bg-amber-50 p-3"
>
<span className="font-medium text-amber-900">
Buy {deal.quantity} {formatQuantity(parseFloat(deal.quantity), productDetail.unitNotation)}
</span>
<span className="text-lg font-bold text-amber-900">{deal.price}</span>
</div>
))}
</div>
)}
</div>
{/* All Slots Dialog */}
<Dialog open={showAllSlots} onClose={() => setShowAllSlots(false)} title="All Delivery Slots">
<div className="max-h-[60vh] overflow-y-auto p-4">
{sortedDeliverySlots.map((slot: any, index: number) => (
<button
key={index}
onClick={() => handleSlotAddToCart(slot.id)}
disabled={productAvailability?.isOutOfStock}
className="mb-3 flex w-full items-start rounded-xl border border-gray-100 bg-gray-50 p-4 text-left transition-colors hover:bg-gray-100"
>
<Truck className="mt-0.5 h-5 w-5 text-blue-500" />
<div className="ml-3 flex-1">
<p className="text-base font-bold text-gray-900">
{dayjs(slot.deliveryTime).format('ddd, DD MMM • h:mm A')}
</p>
<p className="mt-1 text-sm text-gray-500">
Orders Close: {dayjs(slot.freezeTime).format('h:mm A')}
</p>
</div>
<Plus className="mt-0.5 h-6 w-6 text-blue-500" />
</button>
))}
</div>
<div className="border-t border-gray-100 p-4">
<button
onClick={() => setShowAllSlots(false)}
className="w-full rounded-xl bg-gray-900 py-3.5 font-bold text-white"
>
Close
</button>
</div>
</Dialog>
{/* Floating Cart Bar */}
<FloatingCartBar />
</div>
</AppLayout>
)
}
}

View file

@ -1,8 +1,12 @@
import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router'
import { useState, useMemo } from 'react'
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
import Fuse from 'fuse.js'
import { useCentralProductStore } from '../lib/stores/central-product-store'
import { p, SearchBar, AppContainer, div } from 'web-components'
import { useAllProducts } from '../hooks/prominent-api-hooks'
import { p, SearchBar, div } from 'web-components'
import { ProductCard } from '../components/ProductCard'
import { SearchX, Loader2, ChevronLeft } from 'lucide-react'
import { AppLayout } from '../components/AppLayout'
import { useCartStore } from '../lib/stores/cart-store'
export const Route = createFileRoute('/home/search')({
component: SearchPage,
@ -11,61 +15,166 @@ export const Route = createFileRoute('/home/search')({
}),
})
// Debounce hook for search
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => {
clearTimeout(timer)
}
}, [value, delay])
return debouncedValue
}
function SearchPage() {
const { q } = Route.useSearch()
const navigate = useNavigate()
const products = useCentralProductStore((s) => s.products)
const { q } = useSearch({ from: '/home/search' })
const [inputQuery, setInputQuery] = useState(q)
const searchInputRef = useRef<HTMLInputElement>(null)
const { setAddedToCartProduct } = useCartStore()
const fuse = useMemo(
() =>
new Fuse(products, {
keys: ['name', 'category', 'description'],
threshold: 0.3,
}),
[products]
)
// Debounce the search query for automatic search
const debouncedQuery = useDebounce(inputQuery, 300)
const results = useMemo(() => {
if (!q) return products.slice(0, 20)
return fuse.search(q).map((r) => r.item)
}, [q, fuse, products])
// Focus search input on mount
useEffect(() => {
requestAnimationFrame(() => {
searchInputRef.current?.focus()
})
}, [])
const { data: productsData, isLoading, error, refetch } = useAllProducts()
const allProducts = productsData?.products || []
// Client-side search filtering using Fuse.js
const products = useMemo(() => {
if (!debouncedQuery.trim()) return allProducts
const fuse = new Fuse(allProducts, {
keys: ['name', 'shortDescription'],
threshold: 0.3,
includeScore: true,
shouldSort: true,
})
const fuseResults = fuse.search(debouncedQuery)
return fuseResults.map((result) => result.item)
}, [allProducts, debouncedQuery])
const handleSearch = useCallback(() => {
// Search is now automatic via debounce, but keep this for manual submit
// Update URL with search query
navigate({
to: '/home/search',
search: { q: inputQuery },
})
}, [inputQuery, navigate])
const handleProductPress = (id: number) => {
navigate({
to: '/home/product/$id',
params: { id: String(id) },
})
}
const handleAddToCart = (product: any) => {
setAddedToCartProduct({ productId: product.id, product })
}
if (isLoading) {
return (
<AppLayout>
<div className="flex min-h-full flex-1 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-brand-500" />
<p className="ml-2 font-medium text-gray-500">Loading products...</p>
</div>
</AppLayout>
)
}
if (error) {
return (
<AppLayout>
<div className="flex min-h-full flex-1 flex-col items-center justify-center px-4">
<SearchX className="h-16 w-16 text-gray-300" />
<p className="mt-4 text-lg font-medium text-gray-900">Failed to load products</p>
<button
onClick={() => refetch()}
className="mt-4 rounded-lg bg-brand-500 px-4 py-2 font-medium text-white"
>
Retry
</button>
</div>
</AppLayout>
)
}
return (
<AppContainer>
<SearchBar
placeholder="Search products..."
value={q}
onChange={(val) => navigate({ to: '/home/search', search: { q: val } })}
onSearch={(val) => navigate({ to: '/home/search', search: { q: val } })}
/>
<AppLayout>
<div className="flex min-h-full flex-1 flex-col bg-gray-50">
{/* Search Header */}
<div className="sticky top-0 z-10 border-b border-gray-100 bg-white px-4 pb-3 pt-4">
<div className="flex items-center gap-3">
<button
onClick={() => navigate({ to: '/home' })}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gray-100 hover:bg-gray-200"
>
<ChevronLeft className="h-5 w-5 text-gray-700" />
</button>
<div className="flex-1">
<SearchBar
ref={searchInputRef}
placeholder="Search products..."
value={inputQuery}
onChange={setInputQuery}
onSearch={handleSearch}
/>
</div>
</div>
<div className="mt-3 flex flex-row items-center justify-between">
<p className="text-lg font-bold text-gray-900">
{debouncedQuery ? `Search Results for "${debouncedQuery}"` : 'All Products'}
</p>
<p className="text-sm text-gray-500">{products.length} items</p>
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
{results.map((product) => (
<div
key={product.id}
onClick={() =>
navigate({ to: '/home/product/$id', params: { id: String(product.id) } })
}
className="rounded-xl border border-gray-100 bg-white p-3 shadow-sm"
>
<div className="mb-2 aspect-square w-full overflow-hidden rounded-lg bg-gray-100">
{product.images?.[0] && (
<img
src={product.images[0].uri}
alt={product.name}
className="h-full w-full object-cover"
/>
{/* Products Grid */}
<div className="flex-1 p-4">
{products.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center py-12 px-4">
<SearchX className="h-16 w-16 text-gray-300" />
<p className="mt-4 text-center text-lg font-medium text-gray-500">
No products found
</p>
{debouncedQuery && (
<p className="mt-2 text-center text-gray-400">
Try adjusting your search for "{debouncedQuery}"
</p>
)}
</div>
<p className="font-semibold text-sm">
{product.name}
</p>
<p className="font-bold mt-1 text-brand-600">
{product.discountedPrice ?? product.price}
</p>
</div>
))}
) : (
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4">
{products.map((product: any) => (
<ProductCard
key={product.id}
item={product}
onPress={() => handleProductPress(product.id)}
showDeliveryInfo={false}
miniView={true}
useAddToCartDialog={true}
/>
))}
</div>
)}
</div>
</div>
</AppContainer>
</AppLayout>
)
}

View file

@ -1,422 +1,9 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useState, useEffect, useMemo } from 'react'
import dayjs from 'dayjs'
import {
p,
div,
SearchBar,
} from 'web-components'
import {
useAllProducts,
useStores,
useBanners,
useSlots,
useGetEssentialConsts,
} from '../hooks/prominent-api-hooks'
import { useCartStore } from '../lib/stores/cart-store'
import { useCentralSlotStore } from '../lib/stores/central-slot-store'
import { useCentralProductStore } from '../lib/stores/central-product-store'
import { AppLayout } from '../components/AppLayout'
import { ProductCard } from '../components/ProductCard'
import AddToCartDialog from '../components/AddToCartDialog'
import { useProductSlotIdentifier } from '../hooks/useProductSlotIdentifier'
import { usePopulateCentralStores } from '../hooks/usePopulateCentralStores'
import { Store, ImageOff } from 'lucide-react'
import { createFileRoute, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/home')({ component: HomePage })
export const Route = createFileRoute('/home')({
component: HomeLayout,
})
function HomePage() {
const navigate = useNavigate()
const { data: productsData } = useAllProducts()
const { data: storesData } = useStores()
const { data: bannersData } = useBanners()
const { data: slotsData } = useSlots()
const { data: essentialConsts } = useGetEssentialConsts()
const { setAddedToCartProduct } = useCartStore()
const { getQuickestSlot } = useProductSlotIdentifier()
const productSlotsMap = useCentralSlotStore((state) => state.productSlotsMap)
// Populate central stores with slots and product data
usePopulateCentralStores()
const stores = storesData?.stores || []
const banners = bannersData?.banners || []
const allProducts = productsData?.products || []
const slots = slotsData?.slots || []
// Sort products: in-stock first, then by slot availability
const sortedProducts = useMemo(() => {
return [...allProducts]
.filter((p) => typeof p.id === 'number')
.sort((a, b) => {
const slotA = getQuickestSlot(a.id)
const slotB = getQuickestSlot(b.id)
if (slotA && !slotB) return -1
if (!slotA && slotB) return 1
const aOutOfStock = productSlotsMap[a.id]?.isOutOfStock
const bOutOfStock = productSlotsMap[b.id]?.isOutOfStock
if (aOutOfStock && !bOutOfStock) return 1
if (!aOutOfStock && bOutOfStock) return -1
return 0
})
}, [allProducts, productSlotsMap, getQuickestSlot])
// Get popular products from essential consts
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 popularProducts = useMemo(() => {
return popularItemIds
.map((id) => allProducts.find((product) => product.id === id))
.filter((product): product is NonNullable<typeof product> => product != null)
}, [popularItemIds, allProducts])
// Sort slots by delivery time
const sortedSlots = useMemo(() => {
const now = dayjs()
return [...slots]
.filter((slot) => dayjs(slot.deliveryTime).isAfter(now))
.sort((a, b) => {
const deliveryDiff = dayjs(a.deliveryTime).diff(dayjs(b.deliveryTime))
if (deliveryDiff !== 0) return deliveryDiff
return dayjs(a.freezeTime).diff(dayjs(b.freezeTime))
})
}, [slots])
const handleProductPress = (id: number) => {
navigate({
to: '/home/product/$id',
params: { id: String(id) },
})
}
const handleAddToCart = (product: any) => {
setAddedToCartProduct({ productId: product.id, product })
}
return (
<AppLayout>
<div className="min-h-screen bg-white pb-24">
{/* Search Bar */}
<div className="sticky top-0 z-10 border-b border-gray-100 bg-white/95 px-4 pb-3 pt-4 backdrop-blur-sm">
<SearchBar
placeholder="Search products here..."
onSearch={(q) => navigate({ to: '/home/search', search: { q } })}
/>
</div>
<div className="px-4">
{/* Banner Carousel */}
{banners.length > 0 && (
<div className="mb-6 mt-4 overflow-hidden rounded-xl">
<BannerCarousel banners={banners} />
</div>
)}
{/* Stores Section */}
{stores.length > 0 && (
<div className="mb-6">
<div className="mb-4 flex items-center justify-between">
<div>
<p className="font-bold text-xl text-gray-900">
Our Stores
</p>
<p className="mt-0.5 text-xs text-gray-500">
Fresh from our locations
</p>
</div>
</div>
<div className="flex flex-wrap gap-4">
{stores.map((store: any) => (
<div key={store.id}>
<StoreCard
store={store}
onClick={() =>
navigate({
to: '/stores/$storeId',
params: { storeId: String(store.id) },
})
}
/>
</div>
))}
</div>
</div>
)}
{/* Popular Items Section */}
{popularProducts.length > 0 && (
<div className="mb-6">
<div className="mb-4">
<p className="font-bold text-xl text-gray-900">
Popular Items
</p>
<p className="mt-0.5 text-sm text-gray-500">
Trending fresh picks just for you
</p>
</div>
<div className="grid gap-4" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))' }}>
{popularProducts.map((product) => (
<ProductCard
key={product.id}
item={product}
onPress={() => handleProductPress(product.id)}
showDeliveryInfo={false}
miniView={true}
useAddToCartDialog={true}
/>
))}
</div>
</div>
)}
{/* Upcoming Delivery Slots Section */}
{sortedSlots.length > 0 && (
<div className="mb-6">
<div className="mb-4">
<p className="font-bold text-xl text-gray-900">
Upcoming Delivery Slots
</p>
<p className="mt-0.5 text-sm text-gray-500">
Plan your fresh deliveries ahead
</p>
</div>
<div className="scrollbar-hide -mx-4 flex gap-4 overflow-x-auto px-4 pb-2">
{sortedSlots.slice(0, 5).map((slot) => (
<SlotCard key={slot.id} slot={slot} />
))}
</div>
</div>
)}
{/* All Products Section */}
<div className="rounded-t-3xl bg-white pt-4">
<div className="mb-4">
<p className="font-bold text-xl text-gray-900">
All Available Products
</p>
<p className="mt-0.5 text-sm text-gray-500">
Browse our complete selection
</p>
</div>
{/* Responsive Grid for Products - evenly distributed */}
<div className="grid gap-4" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))' }}>
{sortedProducts.map((product) => (
<ProductCard
key={product.id}
item={product}
onPress={() => handleProductPress(product.id)}
showDeliveryInfo={true}
miniView={false}
useAddToCartDialog={true}
/>
))}
</div>
{sortedProducts.length === 0 && (
<div className="py-8 text-center">
<p className="text-gray-500">No products available</p>
</div>
)}
</div>
</div>
<AddToCartDialog />
</div>
</AppLayout>
)
}
function BannerCarousel({ banners }: { banners: any[] }) {
const [index, setIndex] = useState(0)
const images = banners.map((b: any) => b.imageUrl).filter(Boolean)
useEffect(() => {
if (images.length <= 1) return
const timer = setInterval(() => {
setIndex((i) => (i + 1) % images.length)
}, 4000)
return () => clearInterval(timer)
}, [images.length])
if (images.length === 0) return null
return (
<div className="flex justify-center">
<div className="group relative inline-block">
<img
src={images[index]}
alt="Banner"
className="max-h-[420px] w-auto max-w-full rounded-xl object-contain transition-all duration-500"
/>
{images.length > 1 && (
<>
<button
onClick={() => setIndex((i) => (i - 1 + images.length) % images.length)}
className="absolute left-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition-all hover:bg-black/70"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
onClick={() => setIndex((i) => (i + 1) % images.length)}
className="absolute right-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition-all hover:bg-black/70"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
<div className="absolute bottom-3 left-1/2 z-10 flex -translate-x-1/2 gap-1.5">
{images.map((_: any, i: number) => (
<button
key={i}
onClick={() => setIndex(i)}
className={`h-2 rounded-full transition-all ${
i === index ? 'w-6 bg-white' : 'w-2 bg-white/50'
}`}
/>
))}
</div>
</>
)}
</div>
</div>
)
}
function StoreCard({ store, onClick }: { store: any; onClick: () => void }) {
return (
<div
onClick={onClick}
className="flex flex-col items-center"
>
<div className="mb-2 flex h-16 w-16 items-center justify-center overflow-hidden rounded-2xl border-2 border-white/30 bg-gray-100 shadow-lg">
{store.signedImageUrl ? (
<img
src={store.signedImageUrl}
alt={store.name}
className="h-full w-full object-cover"
/>
) : (
<Store className="h-7 w-7 text-gray-400" />
)}
</div>
<p className="font-bold text-center text-xs tracking-wide text-gray-800">
{store.name.replace(/^The\s+/i, '')}
</p>
</div>
)
}
function SlotCard({ slot }: { slot: any }) {
const navigate = useNavigate()
const now = dayjs()
const freezeTime = dayjs(slot.freezeTime)
const isClosingSoon = freezeTime.diff(now, 'hour') < 4 && freezeTime.isAfter(now)
const formatTimeRange = (deliveryTime: string) => {
const time = dayjs(deliveryTime)
const endTime = time.add(1, 'hour')
const startPeriod = time.format('A')
const endPeriod = endTime.format('A')
if (startPeriod === endPeriod) {
return `${time.format('h')}-${endTime.format('h')} ${startPeriod}`
} else {
return `${time.format('h:mm')} ${startPeriod} - ${endTime.format('h:mm')} ${endPeriod}`
}
}
return (
<div
onClick={() => navigate({ to: '/slot-view', search: { slotId: slot.id } })}
className={`min-w-70 shrink-0 cursor-pointer rounded-3xl border border-slate-100 bg-white p-5 shadow-xl ${
isClosingSoon ? 'border-l-4 border-l-amber-400' : 'border-l-4 border-l-brand-500'
}`}
>
<div className="mb-4 flex flex-row items-start justify-end">
{isClosingSoon && (
<div className="flex flex-row items-center rounded-md border border-amber-100 bg-amber-50 px-2 py-0.5">
<div className="mr-1 h-1 w-1 rounded-full bg-amber-500" />
<p className="text-[10px] font-bold text-amber-700">CLOSING SOON</p>
</div>
)}
</div>
<div className="mb-5 flex flex-row justify-between">
<div className="mr-4 flex-1">
<div className="mb-1.5 flex flex-row items-center">
<div className="mr-1.5 rounded-md bg-brand-50 p-1">
<svg className="h-3 w-3 text-brand-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
</div>
<p className="text-[10px] font-bold uppercase text-brand-700">Delivery At</p>
</div>
<p className="font-bold text-sm text-slate-900">
{formatTimeRange(slot.deliveryTime)}
</p>
<p className="text-[11px] font-bold text-slate-500">
{dayjs(slot.deliveryTime).format('ddd, MMM DD')}
</p>
</div>
<div className="flex-1">
<div className="mb-1.5 flex flex-row items-center">
<div className="mr-1.5 rounded-md bg-amber-50 p-1">
<svg className="h-3 w-3 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p className="text-[10px] font-bold uppercase text-amber-700">Order By</p>
</div>
<p className="font-bold text-sm text-slate-900">
{dayjs(slot.freezeTime).format('h:mm A')}
</p>
<p className="text-[11px] font-bold text-slate-500">
{dayjs(slot.freezeTime).format('ddd, MMM DD')}
</p>
</div>
</div>
<div className="flex flex-row items-center">
<div className="mr-3 flex flex-row">
{slot.products?.slice(0, 3).map((p: any, i: number) => (
<div
key={p.id}
className={`h-8 w-8 overflow-hidden rounded-full border-2 border-white bg-slate-100 ${i > 0 ? '-ml-3' : ''}`}
>
{p.images?.[0] ? (
<img src={p.images[0]} alt="" className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center">
<ImageOff className="h-3.5 w-3.5 text-slate-400" />
</div>
)}
</div>
))}
</div>
<p className="text-[11px] font-bold text-brand-600">
View all {slot.products?.length || 0} items
</p>
<svg className="ml-1 h-4 w-4 text-brand-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
)
function HomeLayout() {
return <Outlet />
}

View file

@ -3,7 +3,8 @@ import { useState, useEffect, useRef } from 'react'
import { useForm, Controller } from 'react-hook-form'
import { useAuth } from '../lib/auth-context'
import { trpc } from '../lib/trpc-client'
import { p, MyButton, pInput, div } from 'web-components'
import { useGetEssentialConsts } from '../hooks/prominent-api-hooks'
import { p, MyButton, pInput as PInput, div } from 'web-components'
export const Route = createFileRoute('/login')({ component: LoginPage })
@ -16,6 +17,7 @@ interface LoginFormInputs {
function LoginPage() {
const { loginWithToken } = useAuth()
const navigate = useNavigate()
const { data: constsData } = useGetEssentialConsts()
const [step, setStep] = useState<'mobile' | 'choice' | 'otp' | 'password'>('mobile')
const [selectedMobile, setSelectedMobile] = useState('')
const [canResend, setCanResend] = useState(false)
@ -140,7 +142,7 @@ function LoginPage() {
control={control}
name="mobile"
render={({ field: { onChange, value } }) => (
<pInput
<PInput
placeholder="Enter your mobile number"
value={value}
onChange={(e) => {
@ -182,7 +184,7 @@ function LoginPage() {
>
<p className="font-medium text-gray-500">Back</p>
</div>
<div
<button
onClick={() => sendOtpMutation.mutate({ mobile: selectedMobile })}
disabled={!canResend}
>
@ -191,7 +193,7 @@ function LoginPage() {
>
{canResend ? 'Resend OTP' : `Resend in ${resendCountdown}s`}
</p>
</div>
</button>
</div>
</div>
)}
@ -201,7 +203,7 @@ function LoginPage() {
control={control}
name="password"
render={({ field: { onChange, value } }) => (
<pInput
<PInput
placeholder="Enter your password"
value={value}
onChange={(e) => onChange(e.target.value)}
@ -244,6 +246,27 @@ function LoginPage() {
</div>
)}
</div>
{/* Download App Banner */}
<div className="mt-6 rounded-xl bg-gradient-to-r from-brand-500 to-brand-600 p-4 shadow-lg">
<div className="flex items-center justify-between">
<div className="flex-1">
<p className="text-lg font-bold text-white">Get the FreshYo App</p>
<p className="text-sm text-white/80 mt-1">Download for exclusive offers & faster checkout</p>
</div>
<a
href={constsData?.playStoreUrl || '#'}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-lg bg-white px-4 py-2 text-sm font-bold text-brand-600 shadow-md hover:bg-gray-50 transition-colors"
>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 20.5V3.5C3 2.91 3.34 2.39 3.84 2.15L13.69 12L3.84 21.85C3.34 21.6 3 21.09 3 20.5ZM16.81 15.12L6.05 21.34L14.54 12.85L16.81 15.12ZM20.16 10.81C20.5 11.08 20.75 11.5 20.75 12C20.75 12.5 20.53 12.9 20.18 13.18L17.89 14.5L15.39 12L17.89 9.5L20.16 10.81ZM6.05 2.66L16.81 8.88L14.54 11.15L6.05 2.66Z"/>
</svg>
Get App
</a>
</div>
</div>
</div>
</div>
)

View file

@ -1,4 +1,4 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { createFileRoute, useNavigate, Outlet, useLocation } from '@tanstack/react-router'
import { trpc } from '../lib/trpc-client'
import { p, AppContainer, div } from 'web-components'
import { Package, ChevronRight } from 'lucide-react'
@ -7,62 +7,73 @@ export const Route = createFileRoute('/me/orders')({ component: OrdersPage })
function OrdersPage() {
const navigate = useNavigate()
const location = useLocation()
const { data } = trpc.user.order.getOrders.useQuery({ page: 1, limit: 20 })
const orders = data?.data || []
// Check if we're on the exact /me/orders path (not a child route like /me/orders/123)
const isExactOrdersPath = location.pathname === '/me/orders'
return (
<AppContainer>
<p className="font-bold mb-4 text-xl">
My Orders
</p>
{isExactOrdersPath ? (
<>
<p className="font-bold mb-4 text-xl">
My Orders
</p>
{orders.length === 0 ? (
<div className="flex flex-col items-center gap-4 py-20">
<Package className="h-12 w-12 text-gray-300" />
<p className="text-gray-500">No orders yet</p>
</div>
) : (
<div className="flex flex-col gap-3">
{orders.map((order: any) => (
<div
key={order.id}
onClick={() =>
navigate({ to: '/me/orders/$id', params: { id: String(order.id) } })
}
className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm"
>
<div className="flex items-center justify-between">
<div>
<p className="font-semibold text-sm">
Order #{order.id}
</p>
<p className="text-xs text-gray-500">
{order.createdAt
? new Date(order.createdAt).toLocaleDateString()
: ''}
</p>
</div>
<div className="flex items-center gap-2">
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
order.status === 'delivered'
? 'bg-green-100 text-green-700'
: order.status === 'cancelled'
? 'bg-red-100 text-red-700'
: 'bg-yellow-100 text-yellow-700'
}`}
>
{order.status}
</span>
<ChevronRight className="h-4 w-4 text-gray-400" />
</div>
</div>
<p className="mt-1 text-xs text-gray-400">
Total: {order.totalAmount || 0}
</p>
{orders.length === 0 ? (
<div className="flex flex-col items-center gap-4 py-20">
<Package className="h-12 w-12 text-gray-300" />
<p className="text-gray-500">No orders yet</p>
</div>
))}
</div>
) : (
<div className="flex flex-col gap-3">
{orders.map((order: any) => (
<div
key={order.id}
onClick={() =>
navigate({ to: '/me/orders/$id', params: { id: String(order.id) } })
}
className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm"
>
<div className="flex items-center justify-between">
<div>
<p className="font-semibold text-sm">
Order #{order.id}
</p>
<p className="text-xs text-gray-500">
{order.createdAt
? new Date(order.createdAt).toLocaleDateString()
: ''}
</p>
</div>
<div className="flex items-center gap-2">
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
order.status === 'delivered'
? 'bg-green-100 text-green-700'
: order.status === 'cancelled'
? 'bg-red-100 text-red-700'
: 'bg-yellow-100 text-yellow-700'
}`}
>
{order.status}
</span>
<ChevronRight className="h-4 w-4 text-gray-400" />
</div>
</div>
<p className="mt-1 text-xs text-gray-400">
Total: {order.totalAmount || 0}
</p>
</div>
))}
</div>
)}
</>
) : (
/* Render child routes (order detail) when not on exact /me/orders path */
<Outlet />
)}
</AppContainer>
)

View file

@ -26,7 +26,8 @@ import {
} from "../hooks/cart-query-hooks";
import { usePopulateCentralProductStore } from "../hooks/usePopulateCentralProductStore";
import { AppLayout } from "../components/AppLayout";
import { Truck, Store, Grid3X3, ChevronLeft, ShoppingCart } from "lucide-react";
import { Truck, Store, Grid3X3, ChevronLeft, ShoppingCart, Clock, ChevronDown } from "lucide-react";
import { Dialog } from "../components/Dialog";
export const Route = createFileRoute("/slot-view")({
component: SlotViewPage,
@ -45,6 +46,7 @@ function SlotViewPage() {
const { productsById } = useCentralProductStore();
const productSlotsMap = useCentralSlotStore((state) => state.productSlotsMap);
const { data: storesData } = useStores();
const [showSlotDialog, setShowSlotDialog] = useState(false);
// Populate central product store with products data
usePopulateCentralProductStore();
@ -54,6 +56,11 @@ function SlotViewPage() {
// Find the specific slot from cached data
const slot = slotsData?.slots?.find((s: any) => s.id === slotId);
// Find earliest slot for pre-selection
const earliestSlot = slotsData?.slots
?.filter((s: any) => dayjs(s.deliveryTime).isAfter(dayjs()))
.sort((a: any, b: any) => dayjs(a.deliveryTime).diff(dayjs(b.deliveryTime)))[0];
const addToCart = useAddToCart("regular");
const { data: cartData } = useGetCart("regular");
@ -70,6 +77,34 @@ function SlotViewPage() {
}
};
const formatFullDisplay = (deliveryTime: string) => {
const time = dayjs(deliveryTime);
const endTime = time.add(1, "hour");
const startPeriod = time.format("A");
const endPeriod = endTime.format("A");
let timeRange;
if (startPeriod === endPeriod) {
timeRange = `${time.format("h")}-${endTime.format("h")} ${startPeriod}`;
} else {
timeRange = `${time.format("h:mm")} ${startPeriod} - ${endTime.format("h:mm")} ${endPeriod}`;
}
return `${time.format("ddd, DD MMM ")}${timeRange}`;
};
// Get current selected slot display
const getCurrentSlotDisplay = () => {
if (slotId) {
const s = slotsData?.slots?.find((s: any) => s.id === slotId);
return s ? formatFullDisplay(s.deliveryTime as any) : 'Select time';
}
if (earliestSlot) {
return formatFullDisplay(earliestSlot.deliveryTime as any);
}
return 'Select time';
};
const handleAddToCart = (productId: number) => {
const item = filteredProducts.find((p) => p.id === productId);
const deliveryTime = slot?.deliveryTime
@ -87,6 +122,14 @@ function SlotViewPage() {
);
};
const handleSlotChange = (newSlotId: number) => {
navigate({
to: "/slot-view",
search: { slotId: newSlotId, storeId },
});
setShowSlotDialog(false);
};
// Get product details from central store using slot product IDs
const slotProducts =
slot?.products
@ -102,6 +145,16 @@ function SlotViewPage() {
? slotProducts.filter((p: any) => p.storeId === storeId)
: slotProducts;
// Transform slots for display
const slotOptions = slotsData?.slots
?.filter((s: any) => dayjs(s.deliveryTime).isAfter(dayjs()))
.sort((a: any, b: any) => dayjs(a.deliveryTime).diff(dayjs(b.deliveryTime)))
.map((s: any) => ({
id: s.id,
deliveryTime: formatFullDisplay(s.deliveryTime),
closeTime: dayjs(s.freezeTime).format("h:mm A"),
})) || [];
if (!slot) {
return (
<AppLayout>
@ -118,44 +171,52 @@ function SlotViewPage() {
return (
<AppLayout>
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="sticky top-0 z-10 border-b border-gray-200 bg-white px-4 py-3">
<div className="flex items-center gap-3">
<div
{/* Header with Dropdown - Matching Mobile App */}
<div className="sticky top-0 z-20 border-b border-gray-200 bg-white px-4 py-3">
<div className="flex items-center gap-2">
{/* Back Button */}
<button
onClick={() => navigate({ to: "/home" })}
className="p-2"
className="p-1 hover:bg-gray-100 rounded-full"
>
<ChevronLeft className="h-6 w-6 text-gray-700" />
</div>
<div>
<p className="font-bold text-lg text-gray-900">
{dayjs(slot.deliveryTime).format("ddd, DD MMM")}
</p>
<p className="text-sm text-brand-600">
{formatTimeRange(slot.deliveryTime as any)}
</p>
</button>
{/* Delivery Time Dropdown */}
<div className="flex-1">
<button
onClick={() => setShowSlotDialog(true)}
className="flex w-full items-center justify-between rounded-lg border border-brand-200 bg-brand-50 px-3 py-2"
>
<div className="flex-1 text-left">
<p className="text-xs font-bold uppercase text-brand-500">Delivery Time</p>
<p className="text-sm font-bold text-brand-900">
{getCurrentSlotDisplay()}
</p>
</div>
<ChevronDown className="h-5 w-5 text-brand-500" />
</button>
</div>
</div>
</div>
<div className="flex md:flex-row">
<div className="w-24 sticky">
{/* <div className=""> */}
<StoreSidebar
stores={stores}
slotId={slotId}
storeId={storeId}
onStoreSelect={(newStoreId) =>
navigate({
to: "/slot-view",
search: { slotId, storeId: newStoreId },
})
}
onAllSelect={() =>
navigate({ to: "/slot-view", search: { slotId } })
}
/>
{/* </div> */}
<div className="flex flex-row">
{/* StoreSidebar - Fixed width on left for both mobile and desktop */}
<div className="w-20 shrink-0 md:w-24">
<StoreSidebar
stores={stores}
slotId={slotId}
storeId={storeId}
onStoreSelect={(newStoreId) =>
navigate({
to: "/slot-view",
search: { slotId, storeId: newStoreId },
})
}
onAllSelect={() =>
navigate({ to: "/slot-view", search: { slotId } })
}
/>
</div>
{/* Products Grid */}
@ -172,7 +233,7 @@ function SlotViewPage() {
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div className="grid grid-cols-2 gap-3 sm:gap-4 md:grid-cols-3 lg:grid-cols-4">
{filteredProducts.map((product: any) => (
<CompactProductCard
key={product.id}
@ -199,24 +260,35 @@ function SlotViewPage() {
)}
</div>
</div>
{/* Slot Selection Dialog */}
<Dialog open={showSlotDialog} onClose={() => setShowSlotDialog(false)} title="Select Delivery Time">
<div className="max-h-[60vh] overflow-y-auto p-4">
{slotOptions.map((slotOption) => (
<button
key={slotOption.id}
onClick={() => handleSlotChange(slotOption.id)}
className={`mb-2 w-full rounded-lg border p-3 text-left transition-colors ${
slotOption.id === (slotId || earliestSlot?.id)
? 'border-brand-500 bg-brand-50'
: 'border-gray-200 bg-white hover:bg-gray-50'
}`}
>
<p className="font-medium text-gray-900">
Delivery: {slotOption.deliveryTime}
</p>
<p className="mt-1 text-xs text-gray-500">
Orders Close at: {slotOption.closeTime}
</p>
</button>
))}
</div>
</Dialog>
</div>
</AppLayout>
);
}
const formatQuantity = (
quantity: number,
unit: string,
): { value: string; display: string } => {
if (unit?.toLowerCase() === "kg" && quantity < 1) {
return {
value: `${Math.round(quantity * 1000)} g`,
display: `${Math.round(quantity * 1000)}g`,
};
}
return { value: `${quantity} ${unit}(s)`, display: `${quantity}${unit}` };
};
interface StoreSidebarProps {
stores: any[];
slotId?: number;
@ -233,24 +305,24 @@ function StoreSidebar({
onAllSelect,
}: StoreSidebarProps) {
return (
<div className="w-full border-b border-gray-200 bg-white p-4 md:w-24 md:border-b-0 md:border-r">
<div className="flex gap-3 overflow-x-auto md:flex-col md:gap-3">
<div className="sticky top-[73px] z-10 h-[calc(100vh-73px)] w-full overflow-y-auto border-r border-gray-200 bg-white p-2 md:top-0 md:h-auto md:p-3">
<div className="flex flex-col gap-2 md:gap-3">
{/* All Products Item */}
<div
onClick={onAllSelect}
className={`flex flex-col items-center rounded-2xl p-3 ${
className={`flex flex-col items-center rounded-2xl p-2 md:p-3 ${
!storeId
? "bg-gradient-to-br from-brand-400 to-brand-600 text-white shadow-lg"
: "border border-gray-100 bg-white text-gray-500"
}`}
>
<div
className={`mb-1 flex h-10 w-10 items-center justify-center rounded-full border ${
className={`mb-1 flex h-8 w-8 items-center justify-center rounded-full border md:h-10 md:w-10 ${
!storeId ? "border-white/30 bg-white/20" : "bg-gray-50"
}`}
>
<Grid3X3
className={`h-5 w-5 ${!storeId ? "text-white" : "text-gray-500"}`}
className={`h-4 w-4 md:h-5 md:w-5 ${!storeId ? "text-white" : "text-gray-500"}`}
/>
</div>
<p
@ -260,7 +332,7 @@ function StoreSidebar({
</p>
</div>
<div className="hidden h-px bg-gray-200 md:my-1 md:block" />
<div className="h-px bg-gray-200 my-1" />
{/* Store Items */}
{stores.map((store: any) => {
@ -277,7 +349,7 @@ function StoreSidebar({
}`}
>
<div
className={`mb-2 flex h-12 w-12 items-center justify-center overflow-hidden rounded-full border-2 ${
className={`mb-1 md:mb-2 flex h-10 w-10 items-center justify-center overflow-hidden rounded-full border-2 md:h-12 md:w-12 ${
isActive
? "border-white bg-white"
: "border-gray-100 bg-gray-50"
@ -291,52 +363,7 @@ function StoreSidebar({
/>
) : (
<Store
className={`h-6 w-6 ${isActive ? "text-brand-500" : "text-gray-400"}`}
/>
)}
</div>
<p
className={`w-full text-center text-[10px] leading-tight ${
isActive
? "font-bold text-white"
: "font-medium text-gray-500"
}`}
>
{store.name.replace(/^The\s+/i, "")}
</p>
</div>
);
})}
{stores.map((store: any) => {
const isActive = storeId === store.id;
return (
<div
key={store.id}
onClick={() => onStoreSelect(store.id)}
className={`flex flex-col items-center rounded-2xl p-2 ${
isActive
? "bg-gradient-to-br from-brand-400 to-brand-600 text-white shadow-lg"
: "border border-gray-100 bg-white text-gray-500"
}`}
>
<div
className={`mb-2 flex h-12 w-12 items-center justify-center overflow-hidden rounded-full border-2 ${
isActive
? "border-white bg-white"
: "border-gray-100 bg-gray-50"
}`}
>
{store.signedImageUrl ? (
<img
src={store.signedImageUrl}
alt={store.name}
className="h-full w-full object-cover"
/>
) : (
<Store
className={`h-6 w-6 ${isActive ? "text-brand-500" : "text-gray-400"}`}
className={`h-5 w-5 md:h-6 md:w-6 ${isActive ? "text-brand-500" : "text-gray-400"}`}
/>
)}
</div>
@ -357,6 +384,19 @@ function StoreSidebar({
);
}
const formatQuantity = (
quantity: number,
unit: string,
): { value: string; display: string } => {
if (unit?.toLowerCase() === "kg" && quantity < 1) {
return {
value: `${Math.round(quantity * 1000)} g`,
display: `${Math.round(quantity * 1000)}g`,
};
}
return { value: `${quantity} ${unit}(s)`, display: `${quantity}${unit}` };
};
interface CompactProductCardProps {
item: any;
handleAddToCart: (productId: number) => void;
@ -462,4 +502,4 @@ function CompactProductCard({
</div>
</div>
);
}
}

1
package-lock.json generated
View file

@ -256,6 +256,7 @@
"version": "1.0.0",
"dependencies": {
"express": "^4.18.2",
"mime": "^1.6.0",
"pug": "^3.0.2"
}
},

View file

@ -67,9 +67,9 @@ const isDevMode = Constants.executionEnvironment !== "standalone";
// const BASE_API_URL = 'http://192.168.1.5:8787';
// const BASE_API_URL = 'http://192.168.100.109:8787';
// let BASE_API_URL = "https://raw.freshyo.in";
// let BASE_API_URL = "https://worker.freshyo.in";
let BASE_API_URL = "https://worker.freshyo.in";
// let BASE_API_URL = "https://freshyo.technocracy.ovh";
let BASE_API_URL = 'http://192.168.100.111:8787';
// let BASE_API_URL = 'http://192.168.100.111:8787';
// let BASE_API_URL = 'http://192.168.29.176:4000';
// if(isDevMode) {

View file

@ -23,7 +23,7 @@ export function pInput({
...props
}: pInputProps) {
const inputClasses = cn(
'flex w-full rounded-md border border-input bg-background px-3 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
'flex w-full rounded-md border border-input bg-background px-3 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
shrunkPadding ? 'py-1.5' : 'py-2',
error && 'border-destructive',
className
@ -34,7 +34,7 @@ export function pInput({
return (
<div style={{ ...(fullWidth ? { width: '100%' } : {}), ...style }}>
{topLabel && (
<p weight="medium" className="mb-1 text-sm text-gray-500">
<p className="mb-1 text-sm text-gray-500 font-medium">
{topLabel}
</p>
)}

View file

@ -1,4 +1,4 @@
import React, { useState, useCallback, useRef } from 'react'
import React, { useState, useCallback, useRef, forwardRef } from 'react'
import { cn } from '../lib/utils'
import { Search, X } from 'lucide-react'
@ -9,16 +9,21 @@ interface SearchBarProps {
className?: string
value?: string
onChange?: (value: string) => void
onClick?: () => void
}
export function SearchBar({
placeholder = 'Search...',
onSearch,
debounceMs = 300,
className,
value: controlledValue,
onChange,
}: SearchBarProps) {
export const SearchBar = forwardRef<HTMLInputElement, SearchBarProps>(
function SearchBar({
placeholder = 'Search...',
onSearch,
debounceMs = 300,
className,
value: controlledValue,
onChange,
onClick,
}: SearchBarProps,
ref
) {
const [internalValue, setInternalValue] = useState('')
const timerRef = useRef<ReturnType<typeof setTimeout>>(null)
@ -42,6 +47,24 @@ export function SearchBar({
onSearch?.('')
}, [onSearch, setValue])
// If onClick is provided, render as a clickable button that navigates to search
if (onClick) {
return (
<div
onClick={onClick}
className={cn(
'flex w-full cursor-pointer flex-row items-center rounded-xl border border-gray-200 bg-white px-4 py-3 shadow-sm',
className
)}
>
<span className="min-w-0 flex-1 text-sm text-gray-400">
{value || placeholder}
</span>
<Search className="h-5 w-5 shrink-0 text-brand-500" />
</div>
)
}
return (
<div
className={cn(
@ -50,6 +73,7 @@ export function SearchBar({
)}
>
<input
ref={ref}
type="text"
value={value}
onChange={handleChange}
@ -67,4 +91,4 @@ export function SearchBar({
<Search className="h-5 w-5 shrink-0 text-brand-500" />
</div>
)
}
})