enh
This commit is contained in:
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
10705
apps/backend/dumps/old1.sql
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
BIN
apps/info-site/public/favicon.png
Normal file
BIN
apps/info-site/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
BIN
apps/info-site/public/freshyo-logo.png
Normal file
BIN
apps/info-site/public/freshyo-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 45 KiB |
|
|
@ -4,27 +4,23 @@ 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
|
||||
a.back-link(href="/privacy-policy") ← Back to Privacy Policy
|
||||
|
||||
span.warning-icon !
|
||||
span.warning-icon ⚠️
|
||||
h1 Delete Account
|
||||
p To delete your account and personal data, please verify your mobile number. This action cannot be undone.
|
||||
|
||||
|
|
@ -33,19 +29,16 @@ html(lang="en")
|
|||
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
|
||||
button.btn.btn-danger(type="submit") 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
.hero-content
|
||||
span.hero-badge Freshness Delivered, Smiles Picked Up
|
||||
h1 Fresh Meat, Fruits & Veggies
|
||||
p Experience the true taste of nature. 100% organic, fresh products delivered to your doorstep.
|
||||
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")
|
||||
span Download App
|
||||
span.icon-sm →
|
||||
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' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -4,16 +4,18 @@ 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)")
|
||||
|
|
@ -32,11 +34,22 @@ html(lang="en")
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 |
BIN
apps/web-ui/public/favicon.png
Normal file
BIN
apps/web-ui/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
{/* 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">
|
||||
Apply Coupon
|
||||
Coupons & Offers
|
||||
</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}
|
||||
<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"
|
||||
>
|
||||
<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 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>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom Action Bar */}
|
||||
<div className="rounded-2xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 rounded-xl bg-yellow-50 p-3">
|
||||
<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>
|
||||
|
||||
<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="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}
|
||||
<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="font-bold text-brand-600">
|
||||
₹{price}
|
||||
<p className="text-sm text-gray-500">
|
||||
{filteredProducts.length} items
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Quantifier
|
||||
value={qty}
|
||||
setValue={(v) =>
|
||||
setSelectedQty((prev) => ({ ...prev, [product.id]: v }))
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
{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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
514
apps/web-ui/src/routes/home.index.tsx
Normal file
514
apps/web-ui/src/routes/home.index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 [showAllSlots, setShowAllSlots] = useState(false)
|
||||
const [currentImageIndex, setCurrentImageIndex] = useState(0)
|
||||
|
||||
const { data: reviews } = trpc.user.product.getProductReviews.useQuery(
|
||||
{ productId },
|
||||
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) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<p>Product not found</p>
|
||||
</AppContainer>
|
||||
const handleBuyNow = () => {
|
||||
if (!productDetail) return
|
||||
addToFlashCart.mutate(
|
||||
{ productId: productDetail.id, quantity: 1, storeId: productDetail.storeId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
navigate({ to: '/flash/cart' })
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const price = product.discountedPrice ?? product.price
|
||||
const imageUrl = product.images?.[0]
|
||||
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 (
|
||||
<AppLayout>
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<p className="text-gray-500">Loading product details...</p>
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
<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>
|
||||
|
||||
{/* Image Carousel */}
|
||||
<div className="relative bg-white shadow-sm">
|
||||
<div className="aspect-square w-full overflow-hidden">
|
||||
{images.length > 0 ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={product.name}
|
||||
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="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>
|
||||
{/* 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 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"
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
<ShoppingCart className="h-4 w-4" />
|
||||
{addToCart.isPending ? 'Adding...' : 'Add to Cart'}
|
||||
</MyButton>
|
||||
{productAvailability?.isOutOfStock ? 'Unavailable' : 'Add to Cart'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 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" />
|
||||
))}
|
||||
{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>
|
||||
<p className="text-sm text-gray-600">{review.comment}</p>
|
||||
</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>
|
||||
)}
|
||||
</AppContainer>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'],
|
||||
// Debounce the search query for automatic search
|
||||
const debouncedQuery = useDebounce(inputQuery, 300)
|
||||
|
||||
// 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,
|
||||
}),
|
||||
[products]
|
||||
)
|
||||
includeScore: true,
|
||||
shouldSort: true,
|
||||
})
|
||||
|
||||
const results = useMemo(() => {
|
||||
if (!q) return products.slice(0, 20)
|
||||
return fuse.search(q).map((r) => r.item)
|
||||
}, [q, fuse, products])
|
||||
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 } })}
|
||||
/>
|
||||
|
||||
<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"
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
</AppContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
||||
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
|
||||
export const Route = createFileRoute('/home')({
|
||||
component: HomeLayout,
|
||||
})
|
||||
}, [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 />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,11 +7,17 @@ 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>
|
||||
{isExactOrdersPath ? (
|
||||
<>
|
||||
<p className="font-bold mb-4 text-xl">
|
||||
My Orders
|
||||
</p>
|
||||
|
|
@ -64,6 +70,11 @@ function OrdersPage() {
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Render child routes (order detail) when not on exact /me/orders path */
|
||||
<Outlet />
|
||||
)}
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,29 +171,38 @@ 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" />
|
||||
</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>
|
||||
<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>
|
||||
<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=""> */}
|
||||
<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}
|
||||
|
|
@ -155,7 +217,6 @@ function SlotViewPage() {
|
|||
navigate({ to: "/slot-view", search: { slotId } })
|
||||
}
|
||||
/>
|
||||
{/* </div> */}
|
||||
</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;
|
||||
|
|
|
|||
1
package-lock.json
generated
1
package-lock.json
generated
|
|
@ -256,6 +256,7 @@
|
|||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"mime": "^1.6.0",
|
||||
"pug": "^3.0.2"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
export const SearchBar = forwardRef<HTMLInputElement, SearchBarProps>(
|
||||
function SearchBar({
|
||||
placeholder = 'Search...',
|
||||
onSearch,
|
||||
debounceMs = 300,
|
||||
className,
|
||||
value: controlledValue,
|
||||
onChange,
|
||||
}: SearchBarProps) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue