This commit is contained in:
shafi54 2026-09-07 23:42:50 +05:30
parent 0bfd43c172
commit 4aa7d8a1d4
15 changed files with 1790 additions and 346 deletions

View file

@ -45,7 +45,12 @@
"Shell(done:*)", "Shell(done:*)",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && echo \"=== product.ts trpc import line & usage count ===\" && sed -n '1,40p' apps/backend/src/trpc/apis/admin-apis/apis/product.ts && echo \"=== count occurrences of each beyond import ===\" && for fn in checkUnitExists getProductImagesById replaceProductTags; do echo \"$fn: total=$(grep -c \"$fn\" apps/backend/src/trpc/apis/admin-apis/apis/product.ts)\"; done)", "Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && echo \"=== product.ts trpc import line & usage count ===\" && sed -n '1,40p' apps/backend/src/trpc/apis/admin-apis/apis/product.ts && echo \"=== count occurrences of each beyond import ===\" && for fn in checkUnitExists getProductImagesById replaceProductTags; do echo \"$fn: total=$(grep -c \"$fn\" apps/backend/src/trpc/apis/admin-apis/apis/product.ts)\"; done)",
"Shell(node -e const fs = require(\"fs\"); const parse = (p) => { const src = fs.readFileSync(p,\"utf8\"); const names = new Set(); // export { a, b as c, ... } from / re-export lists const re = /\\bexport\\s*\\{([^}]*)\\}/g; let m; while ((m = re.exec(src))) { const body = m[1]; for (let line of body.split(\",\")) { line = line.trim().replace(/\\/\\/.*$/,\"\").trim(); if (!line) continue; const asMatch = line.match(/^(.+?)\\s+as\\s+(.+)$/); if (asMatch) names.add(asMatch[2].trim()); else if (line.match(/^[A-Za-z_$][\\w$]*$/)) names.add(line); } } return names; }; const sqlite = parse(\"packages/db_helper_sqlite/index.ts\"); const pg = parse(\"packages/db_helper_postgres/index.ts\"); const onlySqlite = [...sqlite].filter(x=>!pg.has(x)).sort(); const onlyPg = [...pg].filter(x=>!sqlite.has(x)).sort(); console.log(\"ONLY IN SQLITE INDEX:\", onlySqlite.join(\", \")); console.log(\"ONLY IN POSTGRES INDEX:\", onlyPg.join(\", \")); )", "Shell(node -e const fs = require(\"fs\"); const parse = (p) => { const src = fs.readFileSync(p,\"utf8\"); const names = new Set(); // export { a, b as c, ... } from / re-export lists const re = /\\bexport\\s*\\{([^}]*)\\}/g; let m; while ((m = re.exec(src))) { const body = m[1]; for (let line of body.split(\",\")) { line = line.trim().replace(/\\/\\/.*$/,\"\").trim(); if (!line) continue; const asMatch = line.match(/^(.+?)\\s+as\\s+(.+)$/); if (asMatch) names.add(asMatch[2].trim()); else if (line.match(/^[A-Za-z_$][\\w$]*$/)) names.add(line); } } return names; }; const sqlite = parse(\"packages/db_helper_sqlite/index.ts\"); const pg = parse(\"packages/db_helper_postgres/index.ts\"); const onlySqlite = [...sqlite].filter(x=>!pg.has(x)).sort(); const onlyPg = [...pg].filter(x=>!sqlite.has(x)).sort(); console.log(\"ONLY IN SQLITE INDEX:\", onlySqlite.join(\", \")); console.log(\"ONLY IN POSTGRES INDEX:\", onlyPg.join(\", \")); )",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo/packages/db_helper_postgres npx tsc --noEmit > /tmp/pg_tsc.log 2>&1 echo \"tsc exit: $?\" echo \"=== error count ===\"; grep -c \"error TS\" /tmp/pg_tsc.log echo \"=== errors touching shared types / banner / coupon / store / const / complaint / staff-user ===\" grep -E \"admin-apis/(banner|coupon|store|const|complaint|staff-user|slots|vendor-snippets|order)\\.ts\" /tmp/pg_tsc.log | head -20 echo \"=== total error files ===\" grep \"error TS\" /tmp/pg_tsc.log | grep -oE \"^[^(]+\" | sort -u)" "Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo/packages/db_helper_postgres npx tsc --noEmit > /tmp/pg_tsc.log 2>&1 echo \"tsc exit: $?\" echo \"=== error count ===\"; grep -c \"error TS\" /tmp/pg_tsc.log echo \"=== errors touching shared types / banner / coupon / store / const / complaint / staff-user ===\" grep -E \"admin-apis/(banner|coupon|store|const|complaint|staff-user|slots|vendor-snippets|order)\\.ts\" /tmp/pg_tsc.log | head -20 echo \"=== total error files ===\" grep \"error TS\" /tmp/pg_tsc.log | grep -oE \"^[^(]+\" | sort -u)",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo/apps/web-ui && npx tsc --noEmit > /tmp/me_tsc.txt 2>&1; echo \"exit=$?\"; wc -l /tmp/me_tsc.txt; head -20 /tmp/me_tsc.txt)",
"Shell(sleep:*)",
"Shell(for p in /me /me/orders /me/addresses /me/coupons /me/complaints /me/edit-profile /me/terms; do code=$(curl -s -o /dev/null -w \"%{http_code}\" \"http://localhost:4175$p\"); echo \"$p -> $code\"; done)",
"Shell(curl -s http://localhost:4175/me)",
"Shell(pkill -f \"vite dev\" 2>/dev/null; pkill -f \"web-ui\" 2>/dev/null; echo \"stopped\")"
], ],
"deny": [], "deny": [],
"defaultMode": "default" "defaultMode": "default"

View file

@ -30,7 +30,7 @@
- Prefers detailed technical documentation of system architecture, data models, flows, and integrations in markdown format. Confidence: 0.8 - Prefers detailed technical documentation of system architecture, data models, flows, and integrations in markdown format. Confidence: 0.8
- Wants edge cases explicitly enumerated when documenting or analyzing existing code/systems. Confidence: 0.8 - Wants edge cases explicitly enumerated when documenting or analyzing existing code/systems. Confidence: 0.8
- Values end-to-end analysis of features (architecture, data model, flows, integrations, and edge cases) when asked to explain how something works. Confidence: 0.7 - Values end-to-end analysis of features (architecture, data model, flows, integrations, and edge cases) when asked to explain how something works. Confidence: 0.7
- When creating a clone/mirror of an app on a different platform (e.g., a web version of a React Native app), expects EXACT replication — functionality and looks must match the reference app with zero changes ("not even a slight change is acceptable"). The tech stack is inherited from the existing clone, not the original. Confidence: 0.9 - When creating a clone/mirror of an app on a different platform (e.g., a web version of a React Native app), expects EXACT replication — functionality and looks must match the reference app with zero changes ("not even a slight change is acceptable"). The tech stack is inherited from the existing clone, not the original. The exactness requirement applies per section and down to every sub-page: naming a section of the RN app (e.g., "the 'me' section and all its sub pages") means the entire subtree must be an exact replica of the mobile screens, and he will flag remaining gaps ("I see a lot of gaps") if any sub-screen diverges — so the agent should audit every sub-page, not just the section hub, against the RN source of truth. Confidence: 0.95
- Prefers unifying parallel data structures into a single instance rather than maintaining separate ones (e.g., one cart for flash and regular items instead of separate flash/regular carts). Confidence: 0.9 - Prefers unifying parallel data structures into a single instance rather than maintaining separate ones (e.g., one cart for flash and regular items instead of separate flash/regular carts). Confidence: 0.9
- Prefers using sentinel values in existing fields (e.g., slotId = 0) to distinguish special-case items rather than creating separate fields or structures. Confidence: 0.9 - Prefers using sentinel values in existing fields (e.g., slotId = 0) to distinguish special-case items rather than creating separate fields or structures. Confidence: 0.9
- Prefers handling special-case logic locally in the relevant component/file rather than globally or via parallel flows. Confidence: 0.85 - Prefers handling special-case logic locally in the relevant component/file rather than globally or via parallel flows. Confidence: 0.85

View file

@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import type { FlashDeliveryProps } from '@packages/shared' import type { FlashDeliveryProps } from '@packages/shared'
import { useNavigate } from '@tanstack/react-router' import { useNavigate, useLocation } from '@tanstack/react-router'
import { useGetCart, useUpdateCartItem, useRemoveFromCart } from '../hooks/cart-query-hooks' import { useGetCart, useUpdateCartItem, useRemoveFromCart } from '../hooks/cart-query-hooks'
import { useAllProducts } from '../hooks/prominent-api-hooks' import { useAllProducts } from '../hooks/prominent-api-hooks'
import { useGetEssentialConsts } from '../hooks/prominent-api-hooks' import { useGetEssentialConsts } from '../hooks/prominent-api-hooks'
@ -27,6 +27,8 @@ const formatTimeRange = (deliveryTime: string | Date) => {
export function FloatingCartBar({ isFlashDelivery = false }: FlashDeliveryProps) { export function FloatingCartBar({ isFlashDelivery = false }: FlashDeliveryProps) {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
const isCartPage = location.pathname === '/cart' || location.pathname === '/flash/cart' || location.pathname.startsWith('/me')
const [isExpanded, setIsExpanded] = useState(false) const [isExpanded, setIsExpanded] = useState(false)
const [quantities, setQuantities] = useState<Record<number, number>>({}) const [quantities, setQuantities] = useState<Record<number, number>>({})
const cartType = isFlashDelivery ? 'flash' : 'regular' const cartType = isFlashDelivery ? 'flash' : 'regular'
@ -78,7 +80,7 @@ export function FloatingCartBar({ isFlashDelivery = false }: FlashDeliveryProps)
/* ---------- Desktop: slide-over panel ---------- */ /* ---------- Desktop: slide-over panel ---------- */
const slideOver = ( const slideOver = (
<div className="pointer-events-auto flex h-full w-full max-w-md flex-col border-l border-gray-200 bg-white"> <div className="pointer-events-auto flex h-full w-full max-w-[85vw] flex-col border-l border-gray-200 bg-white">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-5"> <div className="flex items-center justify-between border-b border-gray-100 px-6 py-5">
<div> <div>
@ -147,7 +149,7 @@ export function FloatingCartBar({ isFlashDelivery = false }: FlashDeliveryProps)
className="h-14 w-14 shrink-0 rounded-lg border border-gray-100 bg-gray-50 object-cover" className="h-14 w-14 shrink-0 rounded-lg border border-gray-100 bg-gray-50 object-cover"
/> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold text-gray-900"> <p className="text-sm font-bold text-gray-900 break-words">
{productsById[item.skuId]?.name || ''} {productsById[item.skuId]?.name || ''}
</p> </p>
<p className="text-xs font-medium text-gray-500"> <p className="text-xs font-medium text-gray-500">
@ -222,7 +224,7 @@ export function FloatingCartBar({ isFlashDelivery = false }: FlashDeliveryProps)
return ( return (
<> <>
{/* Mobile: compact bottom bar */} {/* Mobile: compact bottom bar */}
<div className="fixed bottom-18 left-3 right-3 z-40 md:hidden"> <div className={`fixed bottom-18 left-3 right-3 z-[60] md:hidden ${isExpanded || isCartPage ? 'hidden' : ''}`}>
<button <button
onClick={() => itemCount > 0 && setIsExpanded(true)} onClick={() => itemCount > 0 && setIsExpanded(true)}
className="flex w-full items-center justify-between rounded-xl px-4 py-3 shadow-lg" className="flex w-full items-center justify-between rounded-xl px-4 py-3 shadow-lg"
@ -250,7 +252,7 @@ export function FloatingCartBar({ isFlashDelivery = false }: FlashDeliveryProps)
{/* Desktop: slide-over */} {/* Desktop: slide-over */}
<div <div
className={`fixed inset-0 z-50 transition-opacity duration-300 ${ className={`fixed inset-0 z-[70] transition-opacity duration-300 ${
isExpanded ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0' isExpanded ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'
}`} }`}
> >

View file

@ -0,0 +1,252 @@
import React, { useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { BottomDialog } from 'web-components'
import { trpc } from '../../lib/trpc-client'
import ComplaintForm from '../ComplaintForm'
import { MoreVertical, ChevronRight, Eye, StickyNote, AlertTriangle, XCircle } from 'lucide-react'
interface MeOrderMenuProps {
orderId: number
postActionHandler?: () => void
}
/**
* Port of user-ui's OrderMenu (the 3-dot menu on each order card):
* View Details / Edit Notes / Raise Complaint / Cancel Order.
* Uses web BottomDialog for the menu and nested actions.
*/
function ActionRow({
icon,
iconBg,
iconColor,
title,
subtitle,
onClick,
}: {
icon: React.ReactNode
iconBg: string
iconColor: string
title: string
subtitle: string
onClick: () => void
}) {
return (
<button
onClick={onClick}
className="mb-3 flex w-full flex-row items-center rounded-xl border border-gray-100 bg-white p-4 text-left shadow-sm transition-colors hover:bg-gray-50"
>
<div className={`mr-4 flex h-10 w-10 items-center justify-center rounded-full ${iconBg}`}>
<span className={iconColor}>{icon}</span>
</div>
<div className="flex-1">
<p className="text-base font-semibold text-gray-900">{title}</p>
<p className="text-xs text-gray-500">{subtitle}</p>
</div>
<ChevronRight className="h-6 w-6 text-gray-400" />
</button>
)
}
export function MeOrderMenu({ orderId, postActionHandler }: MeOrderMenuProps) {
const navigate = useNavigate()
const [open, setOpen] = useState(false)
const [editNotesDialogOpen, setEditNotesDialogOpen] = useState(false)
const [editNotes, setEditNotes] = useState('')
const [complaintDialogOpen, setComplaintDialogOpen] = useState(false)
const [cancelDialogOpen, setCancelDialogOpen] = useState(false)
const [cancelReason, setCancelReason] = useState('')
const cancelOrderMutation = trpc.user.order.cancelOrder.useMutation()
const updateNotesMutation = trpc.user.order.updateUserNotes.useMutation({
onSuccess: () => {
alert('Notes updated successfully')
setEditNotesDialogOpen(false)
setEditNotes('')
postActionHandler?.()
},
onError: (error: any) => {
alert(error.message || 'Failed to update notes')
},
})
const handleEditNotes = async () => {
try {
await updateNotesMutation.mutateAsync({
id: orderId,
userNotes: editNotes.trim(),
})
} catch (error) {
// handled in mutation
}
}
const handleCancelOrder = async () => {
if (!cancelReason.trim()) {
alert('Please enter a reason for cancellation')
return
}
try {
await cancelOrderMutation.mutateAsync({ id: orderId, reason: cancelReason })
alert('Order cancelled successfully')
setCancelDialogOpen(false)
setCancelReason('')
postActionHandler?.()
} catch (error: any) {
alert(error.message || 'Failed to cancel order')
}
}
const handleComplaintClose = () => {
setComplaintDialogOpen(false)
postActionHandler?.()
}
return (
<>
<button
onClick={() => setOpen(true)}
aria-label="Order options"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white shadow-sm transition-colors hover:bg-gray-50"
>
<MoreVertical className="h-[18px] w-[18px] text-slate-600" />
</button>
{/* Menu Dialog */}
<BottomDialog open={open} onClose={() => setOpen(false)}>
<div className="px-4 pb-8 pt-2">
<div className="mb-6 flex items-center justify-center flex-col">
<div className="mb-4 h-1.5 w-12 rounded-full bg-gray-200" />
<p className="text-lg font-bold text-gray-900">Order Options</p>
<p className="text-sm text-gray-500">Select an action for Order #{orderId}</p>
</div>
<ActionRow
icon={<Eye className="h-5 w-5" />}
iconBg="bg-gray-50"
iconColor="text-gray-600"
title="View Details"
subtitle="View complete order information"
onClick={() => {
setOpen(false)
navigate({ to: '/me/orders/$id', params: { id: String(orderId) } })
}}
/>
<ActionRow
icon={<StickyNote className="h-5 w-5" />}
iconBg="bg-blue-50"
iconColor="text-blue-600"
title="Edit Notes"
subtitle="Add special instructions"
onClick={() => {
setOpen(false)
setEditNotes('')
setEditNotesDialogOpen(true)
}}
/>
<ActionRow
icon={<AlertTriangle className="h-5 w-5" />}
iconBg="bg-yellow-50"
iconColor="text-yellow-600"
title="Raise Complaint"
subtitle="Report an issue with this order"
onClick={() => {
setOpen(false)
setComplaintDialogOpen(true)
}}
/>
<ActionRow
icon={<XCircle className="h-5 w-5" />}
iconBg="bg-red-50"
iconColor="text-red-600"
title="Cancel Order"
subtitle="Request order cancellation"
onClick={() => {
setOpen(false)
setCancelDialogOpen(true)
}}
/>
</div>
</BottomDialog>
{/* Edit Notes Dialog */}
<BottomDialog open={editNotesDialogOpen} onClose={() => setEditNotesDialogOpen(false)}>
<div className="p-6">
<div className="mb-4 flex flex-row items-center justify-between">
<p className="text-xl font-bold text-gray-900">Edit Instructions</p>
<button onClick={() => setEditNotesDialogOpen(false)}>
<span className="text-gray-400"></span>
</button>
</div>
<textarea
className="mb-6 min-h-32 w-full rounded-xl border border-gray-200 bg-gray-50 p-4 text-base text-gray-800"
value={editNotes}
onChange={(e) => setEditNotes(e.target.value)}
placeholder="Add special delivery instructions here..."
rows={4}
/>
<button
className={`w-full rounded-xl bg-blue-600 py-4 text-center font-bold text-white shadow-sm ${
updateNotesMutation.isPending ? 'opacity-70' : ''
}`}
onClick={handleEditNotes}
disabled={updateNotesMutation.isPending}
>
{updateNotesMutation.isPending ? 'Saving...' : 'Save Instructions'}
</button>
</div>
</BottomDialog>
{/* Cancel Order Dialog */}
<BottomDialog open={cancelDialogOpen} onClose={() => setCancelDialogOpen(false)}>
<div className="p-6">
<div className="mb-4 flex flex-row items-center justify-between">
<p className="text-xl font-bold text-gray-900">Cancel Order</p>
<button onClick={() => setCancelDialogOpen(false)}>
<span className="text-gray-400"></span>
</button>
</div>
<div className="mb-4 rounded-xl border border-red-100 bg-red-50 p-4">
<p className="text-sm text-red-800">
Are you sure you want to cancel this order? This action cannot be undone.
</p>
</div>
<p className="mb-2 font-medium text-gray-700">Reason for cancellation</p>
<textarea
className="mb-6 min-h-24 w-full rounded-xl border border-gray-200 bg-gray-50 p-4 text-base text-gray-800"
value={cancelReason}
onChange={(e) => setCancelReason(e.target.value)}
placeholder="Please tell us why you are cancelling..."
rows={3}
/>
<button
className={`flex w-full items-center justify-center rounded-xl bg-red-600 py-4 text-center font-bold text-white shadow-sm ${
cancelOrderMutation.isPending ? 'opacity-70' : ''
}`}
onClick={handleCancelOrder}
disabled={cancelOrderMutation.isPending}
>
{cancelOrderMutation.isPending ? 'Processing...' : 'Confirm Cancellation'}
</button>
</div>
</BottomDialog>
{/* Raise Complaint Dialog */}
<BottomDialog open={complaintDialogOpen} onClose={handleComplaintClose}>
<ComplaintForm
open={complaintDialogOpen}
onClose={handleComplaintClose}
orderId={orderId}
/>
</BottomDialog>
</>
)
}

View file

@ -0,0 +1,38 @@
import React from 'react'
import { useNavigate } from '@tanstack/react-router'
import { ChevronLeft } from 'lucide-react'
interface MeScreenHeaderProps {
title: string
subtitle?: string
right?: React.ReactNode
}
/**
* Replicates the RN user-ui stack headers for /me sub pages:
* white bar, back chevron, bold title + optional subtitle, optional right slot.
* Mobile-only (`md:hidden`) on desktop the global AppLayout topbar already
* supplies the bar, so this prevents stacked duplicate headers.
*/
export function MeScreenHeader({ title, subtitle, right }: MeScreenHeaderProps) {
const navigate = useNavigate()
return (
<div className="flex flex-row items-center justify-between border-b border-gray-100 bg-white px-4 py-4 md:hidden">
<div className="flex min-w-0 flex-row items-center">
<button
onClick={() => navigate({ to: '/me' })}
aria-label="Go back"
className="-ml-2 mr-1 p-2"
>
<ChevronLeft className="h-6 w-6 text-gray-800" />
</button>
<div className="min-w-0">
<p className="truncate text-lg font-bold text-gray-900">{title}</p>
{subtitle && <p className="text-xs text-gray-400">{subtitle}</p>}
</div>
</div>
{right && <div className="flex shrink-0 items-center">{right}</div>}
</div>
)
}

View file

@ -0,0 +1,200 @@
import React, { useState, useRef, useCallback } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { trpc } from '../../lib/trpc-client'
import { useGetEssentialConsts } from '../../hooks/prominent-api-hooks'
import { useAuth } from '../../lib/auth-context'
import { orderStatusManipulator } from '../../lib/string-manipulators'
import dayjs from 'dayjs'
import { Loader2, ChevronRight } from 'lucide-react'
import type { UserOrderSummary } from '@packages/shared'
/**
* Port of user-ui's NextOrderGlimpse: a horizontal snap-scroll of amber
* "Upcoming Order" cards shown on the /me hub below the header.
*/
interface OrderCardProps {
order: UserOrderSummary
supportMobile?: string | null
onPress: () => void
}
function formatRange(startIso: string | undefined) {
const startTime = dayjs(startIso)
const endTime = startTime.add(1, 'hour')
return `${startTime.format('DD MMM, hh:mm A')} - ${endTime.format('hh:mm A')}`
}
function OrderCard({ order, supportMobile, onPress }: OrderCardProps) {
return (
<button
onClick={onPress}
className="mr-6 w-[88vw] max-w-[95%] shrink-0 snap-start text-left md:w-[380px] md:max-w-none"
>
<div className="rounded-2xl border-2 border-amber-300 bg-gradient-to-r from-amber-50 to-orange-50 p-4">
<div className="mb-3 flex flex-row items-center justify-between">
<div className="flex flex-row items-center">
<div className="mr-2 h-2 w-2 rounded-full bg-amber-500" />
<p className="text-[10px] font-bold uppercase tracking-wider text-amber-600">
Upcoming Order
</p>
</div>
{order.isFlashDelivery && (
<div className="flex flex-row items-center rounded-full bg-amber-200 px-2 py-0.5">
<span className="mr-0.5 text-[10px]"></span>
<p className="text-[8px] font-black uppercase text-amber-700">Flash</p>
</div>
)}
</div>
<div className="mb-3 flex flex-row items-start justify-between">
<div>
<p className="text-lg font-extrabold text-gray-900">#{order.orderId}</p>
<p className="mt-0.5 text-xs text-gray-500">
{order.items.length} {order.items.length === 1 ? 'item' : 'items'} {order.totalAmount}
</p>
</div>
<div className="rounded-lg border border-amber-200 bg-white px-2 py-1">
<p className="text-[10px] font-bold uppercase text-amber-700">
{orderStatusManipulator(order.deliveryStatus)}
</p>
</div>
</div>
<div className="flex flex-row items-center">
<span className="mr-1.5 text-amber-600">{order.isFlashDelivery ? '⚡' : '🕐'}</span>
<p className="text-xs font-medium text-amber-800">
{order.isFlashDelivery
? '30-Min Delivery'
: formatRange(order.deliveryDate || order.orderDate)}
</p>
</div>
{order.items.length > 0 && (
<div className="mt-3 flex flex-row items-center border-t border-amber-200 pt-3">
<div className="flex flex-row">
{order.items.slice(0, 3).map((item, idx) => (
<div
key={idx}
className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-lg border border-amber-200 bg-white"
style={{ marginLeft: idx > 0 ? -8 : 0 }}
>
{item.image ? (
<img src={item.image} alt="" className="h-full w-full rounded-lg object-cover" />
) : null}
</div>
))}
{order.items.length > 3 && (
<div className="ml-2 flex h-8 w-8 items-center justify-center rounded-lg border border-amber-200 bg-amber-100">
<p className="text-[9px] font-bold text-amber-700">+{order.items.length - 3}</p>
</div>
)}
</div>
<p className="ml-2 text-xs font-medium text-amber-700">Track Order</p>
<ChevronRight className="ml-auto h-3.5 w-3.5 text-amber-600" />
</div>
)}
{supportMobile && (
<a
href={`tel:${supportMobile}`}
className="mt-3 flex flex-row items-center border-t border-amber-200 pt-3"
>
<span className="mr-1.5 text-amber-600">📞</span>
<p className="text-xs font-medium text-amber-700">
Need help? Call {supportMobile}
</p>
</a>
)}
</div>
</button>
)
}
export function NextOrderGlimpse() {
const navigate = useNavigate()
const { user } = useAuth()
const [currentIndex, setCurrentIndex] = useState(0)
const scrollerRef = useRef<HTMLDivElement>(null)
const { data: ordersData, isLoading } = trpc.user.order.getOrders.useQuery(
{ page: 1, pageSize: 50 },
{ enabled: !!user }
)
const { data: essentialConsts } = useGetEssentialConsts()
const upcomingOrders = (ordersData?.data || [])
.filter((order) => {
if (order.orderStatus.toLowerCase() === 'cancelled') return false
if (order.deliveryStatus.toLowerCase() === 'success') return false
return true
})
.sort((a, b) => {
if (a.isFlashDelivery && !b.isFlashDelivery) return -1
if (!a.isFlashDelivery && b.isFlashDelivery) return 1
if (a.isFlashDelivery && b.isFlashDelivery) {
return dayjs(b.createdAt).diff(dayjs(a.createdAt))
}
if (a.deliveryDate && b.deliveryDate) {
return dayjs(a.deliveryDate).diff(dayjs(b.deliveryDate))
}
return 0
})
const handleScroll = useCallback(() => {
const el = scrollerRef.current
if (!el) return
const index = Math.round(el.scrollLeft / (el.clientWidth || 1))
setCurrentIndex(Math.min(Math.max(index, 0), Math.max(upcomingOrders.length - 1, 0)))
}, [upcomingOrders.length])
if (!user) return null
if (isLoading) {
return (
<div className="mb-4 px-6">
<div className="flex items-center justify-center rounded-2xl border border-gray-100 bg-white p-4">
<Loader2 className="h-5 w-5 animate-spin text-blue-500" />
</div>
</div>
)
}
if (upcomingOrders.length === 0) return null
const goToOrder = (order: UserOrderSummary) => {
navigate({ to: '/me/orders/$id', params: { id: String(order.id) } })
}
return (
<div className="mb-4">
<div
ref={scrollerRef}
onScroll={handleScroll}
className="flex snap-x snap-mandatory flex-row overflow-x-auto scrollbar-hide px-6"
>
{upcomingOrders.map((order) => (
<OrderCard
key={order.id}
order={order}
supportMobile={essentialConsts?.supportMobile}
onPress={() => goToOrder(order)}
/>
))}
</div>
{upcomingOrders.length > 1 && (
<div className="mt-2 flex flex-row justify-center">
{upcomingOrders.map((_, index) => (
<div
key={index}
className={`mx-1 h-2 w-2 rounded-full ${
index === currentIndex ? 'bg-amber-500' : 'bg-amber-200'
}`}
/>
))}
</div>
)}
</div>
)
}

View file

@ -1,65 +1,257 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { trpc } from '../lib/trpc-client' import { trpc } from '../lib/trpc-client'
import { p, MyButton, AppContainer } from 'web-components'
import { MapPin, Plus } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { MeScreenHeader } from '../components/me/MeScreenHeader'
import { AddressForm } from '../components/AddressForm'
import { BottomDialog } from 'web-components'
import type { UserAddress } from '@packages/shared'
import { MapPin, Plus, AlertTriangle } from 'lucide-react'
export const Route = createFileRoute('/me/addresses')({ component: AddressesPage }) export const Route = createFileRoute('/me/addresses')({ component: AddressesPage })
function AddressesPage() { // Address view — required core of the shared UserAddress + optional geo fields
const utils = trpc.useUtils() type Address = Pick<
const { data } = trpc.user.address.getUserAddresses.useQuery() UserAddress,
const deleteMutation = trpc.user.address.deleteAddress.useMutation({ 'id' | 'name' | 'phone' | 'addressLine1' | 'addressLine2' | 'city' | 'state' | 'pincode' | 'isDefault'
onSuccess: () => utils.user.address.getUserAddresses.invalidate(), > & {
}) latitude?: number | null
const addresses = data?.data || [] longitude?: number | null
googleMapsUrl?: string | null
}
const formatAddress = (addr: Address) => {
return `${addr.addressLine1}${addr.addressLine2 ? `, ${addr.addressLine2}` : ''}, ${addr.city}, ${addr.state} - ${addr.pincode}`
}
function AddressCard({
address,
onEdit,
onDelete,
onSetDefault,
isDeleting,
}: {
address: Address
onEdit: (address: Address) => void
onDelete: (id: number) => void
onSetDefault: (id: number) => void
isDeleting?: boolean
}) {
return ( return (
<AppContainer> <div className="mb-3 rounded-lg border border-gray-100 bg-white p-4 shadow-sm">
<p className="font-bold mb-4 text-xl"> <div className="mb-2 flex flex-row items-start justify-between">
My Addresses
</p>
{addresses.length === 0 ? (
<div className="flex flex-col items-center gap-4 py-20">
<MapPin className="h-12 w-12 text-gray-300" />
<p className="text-gray-500">No addresses saved</p>
</div>
) : (
<div className="flex flex-col gap-3">
{addresses.map((addr: any) => (
<div
key={addr.id}
className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm"
>
<div className="flex items-start justify-between">
<div className="flex-1"> <div className="flex-1">
<p className="font-semibold">{addr.name}</p> <div className="mb-1 flex flex-row items-center">
<p className="text-sm text-gray-600"> <p className="mr-2 text-lg font-semibold text-gray-800">{address.name}</p>
{addr.addressLine1} {address.isDefault && (
{addr.addressLine2 ? `, ${addr.addressLine2}` : ''} <span className="rounded-full bg-green-100 px-2 py-1">
</p> <p className="text-xs font-medium text-green-700">Default</p>
<p className="text-sm text-gray-600">
{addr.city}, {addr.state} - {addr.pincode}
</p>
<p className="text-sm text-gray-500">{addr.phone}</p>
{addr.isDefault && (
<span className="mt-1 inline-block rounded-full bg-brand-100 px-2 py-0.5 text-xs text-brand-700">
Default
</span> </span>
)} )}
</div> </div>
<p className="mb-1 text-sm text-gray-600">📞 {address.phone}</p>
<p className="text-sm text-gray-700">📍 {formatAddress(address)}</p>
</div>
</div>
<div className="mt-3 flex flex-row justify-end">
{!address.isDefault && (
<button <button
onClick={() => deleteMutation.mutate({ id: addr.id })} onClick={() => onSetDefault(address.id)}
className="text-sm text-red-500 hover:text-red-700" className="mr-2 rounded bg-gray-500 px-3 py-2 transition-colors hover:bg-gray-600"
> >
Delete <p className="text-xs font-medium text-white">Set Default</p>
</button>
)}
<button
onClick={() => onEdit(address)}
className="mr-2 rounded bg-blue-500 px-3 py-2 transition-colors hover:bg-blue-600"
>
<p className="text-xs font-medium text-white">Edit</p>
</button>
<button
onClick={() => onDelete(address.id)}
disabled={isDeleting}
className={`rounded bg-red-500 px-3 py-2 transition-colors hover:bg-red-600 ${
isDeleting ? 'opacity-50' : ''
}`}
>
<p className="text-xs font-medium text-white">
{isDeleting ? 'Deleting...' : 'Delete'}
</p>
</button> </button>
</div> </div>
</div> </div>
)
}
function AddressesPage() {
const [modalVisible, setModalVisible] = useState(false)
const [editingAddress, setEditingAddress] = useState<Address | null>(null)
const [deletingId, setDeletingId] = useState<number | null>(null)
const { data, isLoading, error, refetch } = trpc.user.address.getUserAddresses.useQuery()
const addresses = (data?.data || []) as Address[]
const updateAddressMutation = trpc.user.address.updateAddress.useMutation()
const deleteAddressMutation = trpc.user.address.deleteAddress.useMutation()
const handleAddAddress = () => {
setEditingAddress(null)
setModalVisible(true)
}
const handleEditAddress = (address: Address) => {
setEditingAddress(address)
setModalVisible(true)
}
const handleDeleteAddress = (id: number) => {
const ok = window.confirm('Are you sure you want to delete this address?')
if (!ok) return
setDeletingId(id)
deleteAddressMutation.mutate(
{ id },
{
onSuccess: () => {
setDeletingId(null)
refetch()
alert('Address deleted successfully')
},
onError: (error: any) => {
setDeletingId(null)
alert(error.message || 'Failed to delete address')
},
}
)
}
const handleSetDefault = (id: number) => {
const addressToUpdate = addresses.find((addr) => addr.id === id)
if (!addressToUpdate) return
updateAddressMutation.mutate(
{
id: addressToUpdate.id,
name: addressToUpdate.name,
phone: addressToUpdate.phone,
addressLine1: addressToUpdate.addressLine1,
addressLine2: addressToUpdate.addressLine2 || undefined,
city: addressToUpdate.city,
state: addressToUpdate.state,
pincode: addressToUpdate.pincode,
isDefault: true,
},
{
onSuccess: () => {
refetch()
alert('Default address updated')
},
onError: (error: any) => {
alert(error.message || 'Failed to update default address')
},
}
)
}
const handleAddressSubmit = () => {
setModalVisible(false)
setEditingAddress(null)
refetch()
}
return (
<div className="min-h-screen bg-gray-50">
<MeScreenHeader title="My Addresses" />
{isLoading ? (
<div className="flex min-h-[60vh] flex-1 items-center justify-center">
<p className="text-gray-600">Loading addresses...</p>
</div>
) : error ? (
<div className="flex min-h-[60vh] flex-1 flex-col items-center justify-center p-4">
<AlertTriangle className="h-12 w-12 text-red-500" />
<p className="mt-2 mb-4 text-center text-red-600">Failed to load addresses. Please try again.</p>
<button
onClick={() => refetch()}
className="rounded bg-blue-500 px-4 py-2 text-white"
>
Retry
</button>
</div>
) : (
<div className="mx-auto w-full max-w-2xl">
{/* Header row */}
<div className="flex flex-row items-center justify-between p-4 pb-2">
<p className="text-2xl font-bold text-gray-800">My Addresses</p>
<button
onClick={handleAddAddress}
aria-label="Add address"
className="rounded-full bg-blue-500 p-2 transition-colors hover:bg-blue-600"
>
<Plus className="h-6 w-6 text-white" />
</button>
</div>
{addresses.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<MapPin className="h-16 w-16 text-gray-300" />
<p className="mb-2 mt-4 text-center text-gray-500">No addresses found</p>
<p className="mb-6 text-center text-gray-400">Add your first delivery address</p>
<button
onClick={handleAddAddress}
className="rounded-lg bg-blue-500 px-6 py-3 font-medium text-white transition-colors hover:bg-blue-600"
>
Add Address
</button>
</div>
) : (
<div className="p-4">
{addresses.map((address) => (
<AddressCard
key={address.id}
address={address}
onEdit={handleEditAddress}
onDelete={handleDeleteAddress}
onSetDefault={handleSetDefault}
isDeleting={deletingId === address.id}
/>
))} ))}
</div> </div>
)} )}
</AppContainer> </div>
)}
{/* Add/Edit bottom sheet */}
<BottomDialog open={modalVisible} onClose={() => setModalVisible(false)}>
<div className="w-full">
<AddressForm
onSuccess={handleAddressSubmit}
initialValues={
editingAddress
? {
id: editingAddress.id,
name: editingAddress.name,
phone: editingAddress.phone,
addressLine1: editingAddress.addressLine1,
addressLine2: editingAddress.addressLine2 || '',
city: editingAddress.city,
state: editingAddress.state,
pincode: editingAddress.pincode,
isDefault: editingAddress.isDefault,
latitude: editingAddress.googleMapsUrl
? undefined
: editingAddress.latitude ?? undefined,
longitude: editingAddress.googleMapsUrl
? undefined
: editingAddress.longitude ?? undefined,
googleMapsUrl: editingAddress.googleMapsUrl ?? undefined,
}
: undefined
}
isEdit={!!editingAddress}
/>
</div>
</BottomDialog>
</div>
) )
} }

View file

@ -1,11 +1,9 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router' import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { trpc } from '../lib/trpc-client' import { trpc } from '../lib/trpc-client'
import { p, AppContainer, div } from 'web-components' import { MeScreenHeader } from '../components/me/MeScreenHeader'
import { Phone, Mail, Headphones, CheckCircle, Clock, AlertCircle, ThumbsUp, MessageSquare } from 'lucide-react' import { Phone, Mail, Headphones, Clock, AlertCircle, ThumbsUp } from 'lucide-react'
import { useGetEssentialConsts } from '../hooks/prominent-api-hooks' import { useGetEssentialConsts } from '../hooks/prominent-api-hooks'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Dialog } from '../components/Dialog'
import { useState } from 'react'
import type { ComplaintItemProps } from '@packages/shared' import type { ComplaintItemProps } from '@packages/shared'
export const Route = createFileRoute('/me/complaints')({ component: ComplaintsPage }) export const Route = createFileRoute('/me/complaints')({ component: ComplaintsPage })
@ -75,33 +73,35 @@ function ComplaintsPage() {
const { data, isLoading, error, refetch } = trpc.user.complaint.getAll.useQuery() const { data, isLoading, error, refetch } = trpc.user.complaint.getAll.useQuery()
const complaints = data?.complaints || [] const complaints = data?.complaints || []
const { data: constsData } = useGetEssentialConsts() const { data: constsData } = useGetEssentialConsts()
const [showContactDialog, setShowContactDialog] = useState(false)
if (isLoading) { if (isLoading) {
return ( return (
<AppContainer> <div className="min-h-screen bg-gray-50">
<div className="flex min-h-full flex-1 items-center justify-center bg-gray-50"> <MeScreenHeader title="Complaints" />
<div className="flex min-h-[60vh] flex-1 items-center justify-center bg-gray-50">
<p className="font-medium text-gray-500">Loading complaints...</p> <p className="font-medium text-gray-500">Loading complaints...</p>
</div> </div>
</AppContainer> </div>
) )
} }
if (error) { if (error) {
return ( return (
<AppContainer> <div className="min-h-screen bg-gray-50">
<div className="flex min-h-full flex-1 flex-col items-center justify-center bg-gray-50"> <MeScreenHeader title="Complaints" />
<div className="flex min-h-[60vh] flex-1 flex-col items-center justify-center bg-gray-50">
<AlertCircle className="mb-4 h-12 w-12 text-red-500" /> <AlertCircle className="mb-4 h-12 w-12 text-red-500" />
<p className="text-lg font-bold text-gray-900">Oops!</p> <p className="text-lg font-bold text-gray-900">Oops!</p>
<p className="mt-2 text-gray-500">Failed to load complaints</p> <p className="mt-2 text-gray-500">Failed to load complaints</p>
</div> </div>
</AppContainer> </div>
) )
} }
return ( return (
<AppContainer> <div className="min-h-screen bg-gray-50">
<div className="min-h-full flex-1 bg-gray-50"> <MeScreenHeader title="Complaints" />
<div className="flex flex-col bg-gray-50">
{/* Support Header */} {/* Support Header */}
<div className="bg-brand-600 px-4 py-5"> <div className="bg-brand-600 px-4 py-5">
<div className="mb-4 flex flex-row items-center"> <div className="mb-4 flex flex-row items-center">
@ -171,6 +171,6 @@ function ComplaintsPage() {
)} )}
</div> </div>
</div> </div>
</AppContainer> </div>
) )
} }

View file

@ -1,7 +1,7 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { trpc } from '../lib/trpc-client' import { trpc } from '../lib/trpc-client'
import { useState } from 'react' import { useState } from 'react'
import { p, MyButton, AppContainer, div } from 'web-components' import { MeScreenHeader } from '../components/me/MeScreenHeader'
import { Ticket, AlertCircle } from 'lucide-react' import { Ticket, AlertCircle } from 'lucide-react'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import type { UserCouponDisplay } from '@packages/shared' import type { UserCouponDisplay } from '@packages/shared'
@ -116,27 +116,31 @@ function CouponsPage() {
if (isLoading) { if (isLoading) {
return ( return (
<AppContainer> <div className="min-h-screen bg-gray-50">
<div className="flex flex-1 items-center justify-center"> <MeScreenHeader title="My Coupons" />
<div className="flex min-h-[60vh] flex-1 items-center justify-center">
<p className="text-gray-600">Loading coupons...</p> <p className="text-gray-600">Loading coupons...</p>
</div> </div>
</AppContainer> </div>
) )
} }
if (error) { if (error) {
return ( return (
<AppContainer> <div className="min-h-screen bg-gray-50">
<div className="flex flex-1 flex-col items-center justify-center p-4"> <MeScreenHeader title="My Coupons" />
<div className="flex min-h-[60vh] flex-1 flex-col items-center justify-center p-4">
<AlertCircle className="mb-2 h-12 w-12 text-red-500" /> <AlertCircle className="mb-2 h-12 w-12 text-red-500" />
<p className="text-center text-red-600">Failed to load coupons. Please try again.</p> <p className="text-center text-red-600">Failed to load coupons. Please try again.</p>
</div> </div>
</AppContainer> </div>
) )
} }
return ( return (
<AppContainer> <div className="min-h-screen bg-gray-50">
<MeScreenHeader title="My Coupons" />
<div className="mx-auto w-full max-w-2xl">
<div className="flex-1 p-4"> <div className="flex-1 p-4">
{/* Add Coupon Card */} {/* Add Coupon Card */}
<div className="mb-6 rounded-lg border border-gray-100 bg-white p-4 shadow-sm"> <div className="mb-6 rounded-lg border border-gray-100 bg-white p-4 shadow-sm">
@ -174,6 +178,7 @@ function CouponsPage() {
emptyMessage="No general coupons available" emptyMessage="No general coupons available"
/> />
</div> </div>
</AppContainer> </div>
</div>
) )
} }

View file

@ -1,10 +1,11 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router' import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useState } from 'react' import { useRef, useState } from 'react'
import { useAuth } from '../lib/auth-context' import { useAuth } from '../lib/auth-context'
import { trpc } from '../lib/trpc-client' import { trpc } from '../lib/trpc-client'
import { p, AppContainer, div } from 'web-components' import { MeScreenHeader } from '../components/me/MeScreenHeader'
import { AlertCircle, LogOut, Trash2, User, Mail, Phone, X } from 'lucide-react' import { useUploadToObjectStorage } from '../hooks/useUploadToObjectStorage'
import { Dialog } from '../components/Dialog' import { ProfileImage, BottomDialog } from 'web-components'
import { AlertCircle, LogOut, Trash2, Camera, X, Loader2 } from 'lucide-react'
export const Route = createFileRoute('/me/edit-profile')({ component: EditProfilePage }) export const Route = createFileRoute('/me/edit-profile')({ component: EditProfilePage })
@ -13,8 +14,18 @@ function EditProfilePage() {
const { user, logout, loginWithToken } = useAuth() const { user, logout, loginWithToken } = useAuth()
const [name, setName] = useState(user?.name || '') const [name, setName] = useState(user?.name || '')
const [email, setEmail] = useState(user?.email || '') const [email, setEmail] = useState(user?.email || '')
const [mobile, setMobile] = useState(user?.mobile || '')
const [profileImageUri, setProfileImageUri] = useState<string | null>(user?.profileImage || null)
const [profileImageFile, setProfileImageFile] = useState<File | null>(null)
const [errors, setErrors] = useState<Record<string, string>>({})
const [showDeleteModal, setShowDeleteModal] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false)
const [showPasswordDialog, setShowPasswordDialog] = useState(false)
const [enteredMobile, setEnteredMobile] = useState('') const [enteredMobile, setEnteredMobile] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const fileInputRef = useRef<HTMLInputElement>(null)
const { uploadSingle, isUploading } = useUploadToObjectStorage()
const updateMutation = trpc.user.auth.updateProfile.useMutation({ const updateMutation = trpc.user.auth.updateProfile.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
@ -25,16 +36,85 @@ function EditProfilePage() {
}, },
}) })
const updatePasswordMutation = trpc.user.auth.updatePassword.useMutation({
onSuccess: () => {
alert('Password updated successfully')
setShowPasswordDialog(false)
setPassword('')
setConfirmPassword('')
},
onError: (error: any) => {
alert(error.message || 'Failed to update password')
},
})
const deleteMutation = trpc.user.auth.deleteAccount.useMutation({ const deleteMutation = trpc.user.auth.deleteAccount.useMutation({
onSuccess: () => { onSuccess: () => {
setShowDeleteModal(false) setShowDeleteModal(false)
logout() logout()
}, },
onError: (error: any) => {
alert(error.message || 'Failed to delete account')
},
}) })
const handleSubmit = (e: React.FormEvent) => { const validate = (): boolean => {
const next: Record<string, string> = {}
if (!name.trim()) next.name = 'Name is required'
else if (name.trim().length < 2) next.name = 'Name must be at least 2 characters'
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!email.trim()) next.email = 'Email is required'
else if (!emailRegex.test(email.trim())) next.email = 'Please enter a valid email address'
const cleanMobile = mobile.replace(/\D/g, '')
if (!mobile.trim()) next.mobile = 'Mobile number is required'
else if (cleanMobile.length !== 10 || !/^[6-9]/.test(cleanMobile))
next.mobile = 'Please enter a valid 10-digit mobile number'
setErrors(next)
return Object.keys(next).length === 0
}
const handleImageSelect = (file: File | undefined) => {
if (!file) return
if (!file.type.startsWith('image/')) return
setProfileImageFile(file)
setProfileImageUri(URL.createObjectURL(file))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
updateMutation.mutate({ name, email }) if (!validate()) return
try {
let profileImageUrl: string | undefined
if (profileImageFile) {
const { key } = await uploadSingle(profileImageFile, profileImageFile.type || 'image/jpeg', 'profile')
profileImageUrl = key
}
updateMutation.mutate({
name: name.trim(),
email: email.trim().toLowerCase(),
mobile: mobile.replace(/\D/g, ''),
...(profileImageUrl ? { profileImageUrl } : {}),
})
} catch (error: any) {
alert(error.message || 'Failed to upload profile image')
}
}
const handleUpdatePassword = () => {
if (password !== confirmPassword) {
alert('Passwords do not match')
return
}
if (password.length < 6) {
alert('Password must be at least 6 characters')
return
}
updatePasswordMutation.mutate({ password })
} }
const handleDeleteAccount = () => { const handleDeleteAccount = () => {
@ -49,8 +129,14 @@ function EditProfilePage() {
deleteMutation.mutate({ mobile: enteredMobile.trim() }) deleteMutation.mutate({ mobile: enteredMobile.trim() })
} }
const fieldClass = (hasError?: string) =>
`w-full rounded-lg border bg-gray-50 px-4 py-3 text-sm focus:outline-none ${
hasError ? 'border-red-500' : 'border-gray-200 focus:border-blue-500'
}`
return ( return (
<AppContainer> <div className="min-h-screen bg-gray-50">
<MeScreenHeader title="Edit Profile" />
<div className="flex min-h-full flex-col px-4 py-8"> <div className="flex min-h-full flex-col px-4 py-8">
{/* Header */} {/* Header */}
<div className="mb-8 mt-4 text-center"> <div className="mb-8 mt-4 text-center">
@ -60,62 +146,93 @@ function EditProfilePage() {
{/* Profile Form */} {/* Profile Form */}
<form onSubmit={handleSubmit} className="flex flex-col gap-4"> <form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="mb-2 flex flex-col items-center">
<div className="relative">
<ProfileImage uri={profileImageUri} size={100} />
<button
type="button"
onClick={() => fileInputRef.current?.click()}
aria-label="Change profile picture"
className="absolute -bottom-1 -right-1 flex h-8 w-8 items-center justify-center rounded-full border-2 border-white bg-brand-500 text-white shadow-sm transition-colors hover:bg-brand-600"
>
<Camera className="h-4 w-4" />
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleImageSelect(e.target.files?.[0])}
/>
</div>
</div>
{/* Name Field */} {/* Name Field */}
<div className="rounded-lg border border-gray-100 bg-white p-4 shadow-sm"> <div className="rounded-lg border border-gray-100 bg-white p-4 shadow-sm">
<label className="mb-2 flex items-center gap-2 text-sm font-medium text-gray-700"> <label className="mb-2 block text-sm font-medium text-gray-700">Full Name</label>
<User className="h-4 w-4" />
Full Name
</label>
<input <input
type="text" type="text"
placeholder="Enter your name" placeholder="Enter your name"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
className="w-full rounded-lg border border-gray-200 px-4 py-3 text-sm focus:border-blue-500 focus:outline-none" className={fieldClass(errors.name)}
/> />
{errors.name && <p className="mt-1 text-sm text-red-500">{errors.name}</p>}
</div> </div>
{/* Email Field */} {/* Email Field */}
<div className="rounded-lg border border-gray-100 bg-white p-4 shadow-sm"> <div className="rounded-lg border border-gray-100 bg-white p-4 shadow-sm">
<label className="mb-2 flex items-center gap-2 text-sm font-medium text-gray-700"> <label className="mb-2 block text-sm font-medium text-gray-700">Email Address</label>
<Mail className="h-4 w-4" />
Email Address
</label>
<input <input
type="email" type="email"
placeholder="Enter your email" placeholder="Enter your email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-lg border border-gray-200 px-4 py-3 text-sm focus:border-blue-500 focus:outline-none" className={fieldClass(errors.email)}
/> />
{errors.email && <p className="mt-1 text-sm text-red-500">{errors.email}</p>}
</div> </div>
{/* Mobile Field (Disabled) */} {/* Mobile Field */}
<div className="rounded-lg border border-gray-100 bg-white p-4 shadow-sm"> <div className="rounded-lg border border-gray-100 bg-white p-4 shadow-sm">
<label className="mb-2 flex items-center gap-2 text-sm font-medium text-gray-700"> <label className="mb-2 block text-sm font-medium text-gray-700">Mobile Number</label>
<Phone className="h-4 w-4" />
Mobile Number
</label>
<input <input
type="tel" type="tel"
value={user?.mobile || ''} value={mobile}
disabled maxLength={10}
className="w-full cursor-not-allowed rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-500" onChange={(e) => {
const clean = e.target.value.replace(/\D/g, '')
if (clean.length <= 10) setMobile(clean)
}}
className={fieldClass(errors.mobile)}
/> />
<p className="mt-1 text-xs text-gray-400">Mobile number cannot be changed</p> {errors.mobile && <p className="mt-1 text-sm text-red-500">{errors.mobile}</p>}
</div> </div>
{/* Save Button */} {/* Save Button */}
<button <button
type="submit" type="submit"
disabled={updateMutation.isPending} disabled={updateMutation.isPending || isUploading}
className={`mt-2 w-full rounded-lg px-4 py-3 font-semibold text-white transition-colors ${ className={`mt-2 w-full rounded-lg px-4 py-3 font-semibold text-white transition-colors ${
updateMutation.isPending updateMutation.isPending || isUploading
? 'cursor-not-allowed bg-gray-400' ? 'cursor-not-allowed bg-gray-400'
: 'bg-blue-600 hover:bg-blue-700' : 'bg-brand-600 hover:bg-brand-700'
}`} }`}
> >
{updateMutation.isPending ? 'Saving...' : 'Save Changes'} {isUploading
? 'Uploading...'
: updateMutation.isPending
? 'Updating...'
: 'Update Profile'}
</button>
{/* Update Password */}
<button
type="button"
onClick={() => setShowPasswordDialog(true)}
className="w-full rounded-lg bg-brand-600 px-4 py-3 font-semibold text-white transition-colors hover:bg-brand-700"
>
Update Password
</button> </button>
</form> </form>
@ -124,7 +241,7 @@ function EditProfilePage() {
{/* Logout Button */} {/* Logout Button */}
<button <button
onClick={logout} onClick={logout}
className="flex w-full items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-3 font-medium text-red-600 transition-colors hover:bg-red-50" className="flex w-full items-center justify-center gap-2 rounded-lg border border-gray-200 bg-gray-100 px-4 py-3 font-medium text-red-600 transition-colors hover:bg-gray-200"
> >
<LogOut className="h-4 w-4" /> <LogOut className="h-4 w-4" />
Logout Logout
@ -141,8 +258,50 @@ function EditProfilePage() {
</div> </div>
</div> </div>
{/* Update Password Dialog */}
<BottomDialog open={showPasswordDialog} onClose={() => setShowPasswordDialog(false)}>
<div className="p-4">
<p className="mb-4 text-lg font-bold text-gray-900">Update Password</p>
<input
type="password"
placeholder="New Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mb-3 w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-blue-500 focus:outline-none"
/>
<input
type="password"
placeholder="Confirm Password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="mb-6 w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-blue-500 focus:outline-none"
/>
<div className="flex gap-3">
<button
onClick={() => setShowPasswordDialog(false)}
className="flex-1 rounded-lg border border-gray-200 bg-gray-100 px-4 py-3 font-medium text-gray-700 transition-colors hover:bg-gray-200"
>
Cancel
</button>
<button
onClick={handleUpdatePassword}
disabled={updatePasswordMutation.isPending}
className={`flex flex-1 items-center justify-center rounded-lg bg-brand-600 px-4 py-3 font-medium text-white transition-colors hover:bg-brand-700 ${
updatePasswordMutation.isPending ? 'cursor-not-allowed opacity-70' : ''
}`}
>
{updatePasswordMutation.isPending ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
'Update'
)}
</button>
</div>
</div>
</BottomDialog>
{/* Delete Account Modal */} {/* Delete Account Modal */}
<Dialog open={showDeleteModal} onClose={() => setShowDeleteModal(false)}> <BottomDialog open={showDeleteModal} onClose={() => setShowDeleteModal(false)}>
<div className="p-4"> <div className="p-4">
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2 text-red-600"> <div className="flex items-center gap-2 text-red-600">
@ -186,17 +345,21 @@ function EditProfilePage() {
<button <button
onClick={confirmDeleteAccount} onClick={confirmDeleteAccount}
disabled={deleteMutation.isPending || !enteredMobile.trim()} disabled={deleteMutation.isPending || !enteredMobile.trim()}
className={`flex-1 rounded-lg px-4 py-3 font-medium text-white transition-colors ${ className={`flex flex-1 items-center justify-center rounded-lg px-4 py-3 font-medium text-white transition-colors ${
deleteMutation.isPending || !enteredMobile.trim() deleteMutation.isPending || !enteredMobile.trim()
? 'cursor-not-allowed bg-red-400' ? 'cursor-not-allowed bg-red-400'
: 'bg-red-600 hover:bg-red-700' : 'bg-red-600 hover:bg-red-700'
}`} }`}
> >
{deleteMutation.isPending ? 'Deleting...' : 'Delete Forever'} {deleteMutation.isPending ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
'Delete Forever'
)}
</button> </button>
</div> </div>
</div> </div>
</Dialog> </BottomDialog>
</AppContainer> </div>
) )
} }

View file

@ -2,7 +2,7 @@ import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
import { trpc } from '../lib/trpc-client' import { trpc } from '../lib/trpc-client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { ChevronLeft, CreditCard, CheckCircle, Zap, Edit2, Tag, XCircle, X, AlertCircle, Loader2 } from 'lucide-react' import { ChevronLeft, CreditCard, CheckCircle, Zap, Edit2, Tag, XCircle, X, AlertCircle, Loader2 } from 'lucide-react'
import { Dialog } from '../components/Dialog' import { BottomDialog } from 'web-components'
import ComplaintForm from '../components/ComplaintForm' import ComplaintForm from '../components/ComplaintForm'
import { orderStatusManipulator } from '../lib/string-manipulators' import { orderStatusManipulator } from '../lib/string-manipulators'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@ -164,7 +164,7 @@ function OrderDetailPage() {
<div className="flex-1 overflow-y-auto p-4 pb-12"> <div className="flex-1 overflow-y-auto p-4 pb-12">
{/* 1 Hr Delivery Banner */} {/* 1 Hr Delivery Banner */}
{order.isFlashDelivery && ( {order.isFlashDelivery && (
<div className="mb-4 rounded-2xl border border-amber-200 bg-linear-to-r from-amber-50 to-yellow-50 p-4"> <div className="mb-4 rounded-2xl border border-amber-200 bg-gradient-to-r from-amber-50 to-yellow-50 p-4">
<div className="flex flex-row items-center"> <div className="flex flex-row items-center">
<Zap className="h-6 w-6 text-amber-600" /> <Zap className="h-6 w-6 text-amber-600" />
<div className="ml-3 flex-1"> <div className="ml-3 flex-1">
@ -391,7 +391,7 @@ function OrderDetailPage() {
</div> </div>
{/* Cancel Order Dialog */} {/* Cancel Order Dialog */}
<Dialog open={cancelDialogOpen} onClose={() => setCancelDialogOpen(false)}> <BottomDialog open={cancelDialogOpen} onClose={() => setCancelDialogOpen(false)}>
<div className="p-6"> <div className="p-6">
<div className="mb-4 flex flex-row items-center justify-between"> <div className="mb-4 flex flex-row items-center justify-between">
<p className="text-xl font-bold text-gray-900">Cancel Order</p> <p className="text-xl font-bold text-gray-900">Cancel Order</p>
@ -429,10 +429,10 @@ function OrderDetailPage() {
)} )}
</button> </button>
</div> </div>
</Dialog> </BottomDialog>
{/* Raise Complaint Dialog */} {/* Raise Complaint Dialog */}
<Dialog open={complaintDialogOpen} onClose={() => setComplaintDialogOpen(false)}> <BottomDialog open={complaintDialogOpen} onClose={() => setComplaintDialogOpen(false)}>
<ComplaintForm <ComplaintForm
open={complaintDialogOpen} open={complaintDialogOpen}
onClose={() => { onClose={() => {
@ -441,7 +441,7 @@ function OrderDetailPage() {
}} }}
orderId={order.id} orderId={order.id}
/> />
</Dialog> </BottomDialog>
</div> </div>
) )
} }

View file

@ -1,80 +1,456 @@
import { createFileRoute, useNavigate, Outlet, useLocation } from '@tanstack/react-router' import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { trpc } from '../lib/trpc-client' import { trpc } from '../lib/trpc-client'
import { p, AppContainer, div } from 'web-components' import { useCallback, useEffect, useState } from 'react'
import { Package, ChevronRight } from 'lucide-react' import { MeOrderMenu } from '../components/me/MeOrderMenu'
import { MeScreenHeader } from '../components/me/MeScreenHeader'
import { orderStatusManipulator } from '../lib/string-manipulators'
import dayjs from 'dayjs'
import { AlertCircle, Package } from 'lucide-react'
// Matches packages/ui (RN common-ui) REFUND_STATUS values used by user-ui.
const REFUND_STATUS = {
PENDING: 'pending',
NOT_APPLICABLE: 'na',
PROCESSING: 'initiated',
SUCCESS: 'success',
} as const
export const Route = createFileRoute('/me/orders')({ component: OrdersPage }) export const Route = createFileRoute('/me/orders')({ component: OrdersPage })
function OrdersPage() { const PAGE_SIZE = 10
const navigate = useNavigate()
const location = useLocation()
const { data } = trpc.user.order.getOrders.useQuery({ page: 1 })
const orders = data?.data || []
// Check if we're on the exact /me/orders path (not a child route like /me/orders/123) type StatusStyle = {
const isExactOrdersPath = location.pathname === '/me/orders' bg: string
text: string
iconColor: string
icon: React.ReactNode
label: string
}
function getStatusStyle(status: string): StatusStyle {
const s = status.toLowerCase()
switch (s) {
case 'delivered':
case 'success':
case 'completed':
return { bg: 'bg-emerald-50', text: 'text-emerald-700', iconColor: '#059669', icon: <span></span>, label: 'Delivered' }
case 'cancelled':
case 'failed':
return { bg: 'bg-rose-50', text: 'text-rose-700', iconColor: '#E11D48', icon: <span></span>, label: 'Cancelled' }
case 'pending':
case 'payment_pending':
return { bg: 'bg-amber-50', text: 'text-amber-700', iconColor: '#D97706', icon: <span>🕐</span>, label: 'Pending' }
case 'packaged':
return { bg: 'bg-brand-50', text: 'text-brand-700', iconColor: '#1570EF', icon: <span>📦</span>, label: 'Packaged' }
case 'processing':
case 'confirmed':
case 'shipped':
return { bg: 'bg-brand-50', text: 'text-brand-700', iconColor: '#1570EF', icon: <span>🚚</span>, label: s.charAt(0).toUpperCase() + s.slice(1) }
default:
return { bg: 'bg-slate-50', text: 'text-slate-700', iconColor: '#64748B', icon: <span>i</span>, label: status }
}
}
function getRefundStyle(status: string): { bg: string; text: string; icon: React.ReactNode; color: string } {
switch (status) {
case REFUND_STATUS.SUCCESS:
return { bg: 'bg-emerald-50', text: 'text-emerald-700', icon: <span></span>, color: '#059669' }
case REFUND_STATUS.PROCESSING:
return { bg: 'bg-brand-50', text: 'text-brand-700', icon: <span></span>, color: '#1570EF' }
case REFUND_STATUS.PENDING:
return { bg: 'bg-amber-50', text: 'text-amber-700', icon: <span>🕐</span>, color: '#D97706' }
default:
return { bg: 'bg-slate-50', text: 'text-slate-700', icon: <span>i</span>, color: '#64748B' }
}
}
function OrderCard({
item,
getStatusStyle,
getRefundStyle,
onPress,
onViewMoreItems,
refetch,
}: {
item: any
getStatusStyle: (s: string) => StatusStyle
getRefundStyle: (s: string) => { bg: string; text: string; icon: React.ReactNode; color: string }
onPress: (orderId: number) => void
onViewMoreItems: (orderId: number) => void
refetch: () => void
}) {
const mainStatus = getStatusStyle(item.orderStatus)
const deliveryStatus = getStatusStyle(item.deliveryStatus)
const totalAmount = item.totalAmount
return ( return (
<AppContainer> <button
{isExactOrdersPath ? ( onClick={() => onPress(item.id)}
<> className="mb-6 w-full overflow-hidden rounded-[28px] border border-slate-100 bg-white text-left shadow-sm transition-colors hover:bg-slate-50/50"
<p className="font-bold mb-4 text-xl">
My Orders
</p>
{orders.length === 0 ? (
<div className="flex flex-col items-center gap-4 py-20">
<Package className="h-12 w-12 text-gray-300" />
<p className="text-gray-500">No orders yet</p>
</div>
) : (
<div className="flex flex-col gap-3">
{orders.map((order: any) => (
<div
key={order.id}
onClick={() =>
navigate({ to: '/me/orders/$id', params: { id: String(order.id) } })
}
className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm"
> >
<div className="flex items-center justify-between"> {/* Top Header: Order ID and Status */}
<div className="flex flex-row items-center justify-between border-b border-slate-100 bg-slate-50/50 px-5 py-4">
<div> <div>
<p className="font-semibold text-sm"> <div className="flex flex-row items-center">
Order #{order.id} <span className="mr-2 h-2 w-2 rounded-full bg-brand-500" />
</p> <p className="text-[10px] font-bold uppercase tracking-tighter text-slate-400">
<p className="text-xs text-gray-500"> Order Reference
{order.createdAt
? new Date(order.createdAt).toLocaleDateString()
: ''}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <p className="mt-0.5 text-base font-extrabold text-slate-900">#{item.orderId}</p>
<span </div>
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
order.status === 'delivered' <div className="flex flex-row items-center gap-2">
? 'bg-green-100 text-green-700' {item.orderStatus.toLowerCase() === 'cancelled' && (
: order.status === 'cancelled' <span className={`flex flex-row items-center rounded-full border px-3 py-1.5 ${mainStatus.bg} border-rose-100`}>
? 'bg-red-100 text-red-700' <span style={{ color: mainStatus.iconColor }} className="mr-1.5 text-[11px]">
: 'bg-yellow-100 text-yellow-700' {mainStatus.icon}
</span>
<span className={`text-[11px] font-bold uppercase tracking-wide ${mainStatus.text}`}>
{orderStatusManipulator(mainStatus.label)}
</span>
</span>
)}
{item.isFlashDelivery && (
<span className="flex flex-row items-center rounded-full border border-amber-200 bg-amber-100 px-2 py-1">
<span className="mr-1 text-[10px]"></span>
<span className="text-[10px] font-black uppercase text-amber-700">Flash</span>
</span>
)}
</div>
</div>
<div className="p-5">
{/* Order Date & Quick Stats */}
<div className="mb-5 flex flex-row items-start justify-between">
<div className="flex flex-1 flex-col gap-3">
{(item.deliveryDate || item.isFlashDelivery) && (
<div className="flex flex-row items-center">
<div
className={`mr-3 flex h-9 w-9 items-center justify-center rounded-xl ${
item.isFlashDelivery ? 'bg-amber-50' : 'bg-brand-50'
}`} }`}
> >
{order.status} <span className="text-[16px]">{item.isFlashDelivery ? '⚡' : '🕐'}</span>
</span> </div>
<ChevronRight className="h-4 w-4 text-gray-400" /> <div>
<p
className={`text-[10px] font-bold uppercase ${
item.isFlashDelivery ? 'text-amber-700' : 'text-brand-700'
}`}
>
{item.isFlashDelivery ? '1 Hr Delivery' : 'Delivery Time'}
</p>
<p
className={`text-sm font-extrabold ${
item.isFlashDelivery ? 'text-amber-900' : 'text-brand-900'
}`}
>
{item.isFlashDelivery
? dayjs(item.createdAt || item.orderDate).add(30, 'minutes').format('DD MMM, hh:mm A')
: (() => {
const startTime = dayjs(item.deliveryDate)
const endTime = startTime.add(1, 'hour')
return `${startTime.format('DD MMM, hh:mm A')} - ${endTime.format('hh:mm A')}`
})()}
</p>
{item.isFlashDelivery && (
<p className="mt-1 text-[9px] font-bold uppercase text-amber-600"> 30-Min Delivery</p>
)}
</div> </div>
</div> </div>
<p className="mt-1 text-xs text-gray-400"> )}
Total: {order.totalAmount || 0}
<div className="flex flex-row items-center">
<div className="mr-3 flex h-9 w-9 items-center justify-center rounded-xl bg-slate-100">
<span className="text-[16px]">📅</span>
</div>
<div>
<p className="text-[10px] font-bold uppercase text-slate-400">Placed On</p>
<p className="text-sm font-semibold text-slate-800">
{dayjs(item.orderDate).format('DD MMM YYYY, hh:mm A')}
</p> </p>
</div> </div>
</div>
</div>
<MeOrderMenu orderId={item.id} postActionHandler={refetch} />
</div>
{/* Items Section */}
<div className="mb-5 rounded-2xl border border-slate-100 bg-slate-50/50 p-3">
<div className="mb-3 flex flex-row items-center justify-between px-1">
<p className="text-[11px] font-bold uppercase tracking-wider text-slate-500">Items Summary</p>
<p className="text-[11px] font-bold text-brand-600">
{item.items.length} {item.items.length === 1 ? 'Item' : 'Items'}
</p>
</div>
{item.items.slice(0, 2).map((product: any, pIndex: number) => (
<div key={pIndex} className="mb-3 flex flex-row items-center last:mb-0">
<div className="relative">
<img
src={product.image || undefined}
alt=""
className="h-12 w-12 rounded-xl border border-slate-200 bg-white object-cover"
/>
<div className="absolute -right-1.5 -top-1.5 flex h-5 w-5 items-center justify-center rounded-full border-2 border-white bg-brand-500">
<p className="text-[10px] font-bold text-white">{product.quantity}</p>
</div>
</div>
<div className="ml-4 min-w-0 flex-1">
<p className="truncate text-sm font-bold text-slate-800">{product.productName}</p>
<p className="mt-0.5 text-xs font-medium text-slate-500">
Unit Price: {product.price}
</p>
</div>
<p className="ml-2 text-sm font-extrabold text-slate-900">{product.amount}</p>
</div>
))} ))}
{item.items.length > 2 && (
<button
onClick={(e) => {
e.stopPropagation()
onViewMoreItems(item.id)
}}
className="mt-2 w-full border-t border-slate-100 pt-2 text-center"
>
<p className="text-xs font-bold text-brand-600">
+ {item.items.length - 2} more {item.items.length - 2 === 1 ? 'item' : 'items'} in this order
</p>
</button>
)}
</div>
{/* Delivery Status - Single Line */}
<div className="mb-5 flex flex-row items-center justify-between">
<div className="flex min-w-0 flex-1 flex-row items-center">
<span
className="mr-2 rounded-lg p-1.5"
style={{ backgroundColor: deliveryStatus.iconColor + '15' }}
>
<span className="text-[14px]">{item.isFlashDelivery ? '⚡' : '🚚'}</span>
</span>
<p className="mr-2 shrink-0 text-[10px] font-bold uppercase text-slate-400">
{item.isFlashDelivery ? '1 Hr Delivery:' : 'Shipping Status:'}
</p>
<p className={`truncate text-xs font-bold ${deliveryStatus.text}`}>
{orderStatusManipulator(item.deliveryStatus)}
</p>
</div>
{item.isFlashDelivery && (
<span className="ml-2 shrink-0 rounded-full border border-amber-200 bg-amber-100 px-2 py-0.5">
<p className="text-[8px] font-black uppercase text-amber-700"> Fast</p>
</span>
)}
</div>
{/* User Notes */}
{item.userNotes && (
<div className="mb-5">
<div className="flex flex-row items-start rounded-2xl border border-amber-100 bg-amber-50/50 p-3">
<span className="mt-0.5 text-[16px]">📝</span>
<div className="ml-3 flex-1">
<p className="text-[10px] font-bold uppercase text-amber-700">Your Instructions</p>
<p className="mt-0.5 line-clamp-2 text-xs font-medium text-amber-900">
{item.userNotes}
</p>
</div>
</div>
</div> </div>
)} )}
</>
) : ( {/* Cancellation Info */}
/* Render child routes (order detail) when not on exact /me/orders path */ {item.cancelReason && (
<Outlet /> <div className="mb-5 rounded-2xl border border-rose-100 bg-rose-50 p-4">
<div className="mb-2 flex flex-row items-center">
<span className="text-[16px]"></span>
<p className="ml-2 text-xs font-bold text-rose-800">Cancellation Details</p>
</div>
<p className="text-xs font-medium leading-relaxed text-rose-600">{item.cancelReason}</p>
{item.refundStatus && item.refundStatus !== REFUND_STATUS.NOT_APPLICABLE && (
<div className="mt-3 flex flex-row items-center justify-between border-t border-rose-100 pt-3">
<p className="text-[10px] font-bold uppercase text-rose-700">Refund Status</p>
<span className={`flex flex-row items-center rounded-lg border border-rose-200 bg-white px-2 py-1 ${getRefundStyle(item.refundStatus).bg}`}>
<span className="mr-1.5 text-[12px]" style={{ color: getRefundStyle(item.refundStatus).color }}>
{getRefundStyle(item.refundStatus).icon}
</span>
<span
className={`text-[10px] font-bold uppercase ${getRefundStyle(item.refundStatus).text}`}
>
{item.refundStatus}
</span>
</span>
</div>
)} )}
</AppContainer> </div>
)}
{/* Footer: Price and CTA */}
<div className="flex flex-row items-center justify-between border-t border-slate-100 pt-5">
<div>
<p className="text-[10px] font-bold uppercase tracking-wider text-slate-400">Amount to Pay</p>
<p className="text-2xl font-black text-slate-900">{totalAmount}</p>
{item.discountAmount ? (
<div className="mt-1 flex flex-row items-center self-start rounded-full bg-emerald-50 px-2 py-0.5">
<span className="mr-1 text-[10px]">🏷</span>
<p className="text-[10px] font-bold text-emerald-700">Saved {item.discountAmount}</p>
</div>
) : null}
{item.deliveryCharge > 0 && (
<div className="mt-1 flex flex-row items-center">
<span className="mr-1 text-[12px]">🚚</span>
<p className="text-[10px] font-medium text-slate-600">Delivery: {item.deliveryCharge}</p>
</div>
)}
</div>
<div className="flex flex-row items-center rounded-2xl bg-brand-600 px-5 py-3 shadow-lg">
<p className="mr-1 text-sm font-bold text-white">View Details</p>
<span className="text-[16px] text-white"></span>
</div>
</div>
</div>
</button>
)
}
function OrdersPage() {
const navigate = useNavigate()
// Infinite scroll state
const [allOrders, setAllOrders] = useState<any[]>([])
const [currentPage, setCurrentPage] = useState<number>(1)
const [isLoadingMore, setIsLoadingMore] = useState<boolean>(false)
const [hasNextPage, setHasNextPage] = useState<boolean>(true)
const [loadMoreError, setLoadMoreError] = useState<string | null>(null)
const { data: ordersData, isLoading, error, refetch } = trpc.user.order.getOrders.useQuery({
page: currentPage,
pageSize: PAGE_SIZE,
})
// Handle data accumulation for infinite scroll
useEffect(() => {
if (ordersData?.data) {
if (currentPage === 1) {
setAllOrders(ordersData.data)
} else {
setAllOrders((prev) => [...prev, ...ordersData.data])
}
const totalPages = ordersData.pagination?.totalPages || 1
setHasNextPage(currentPage < totalPages)
setIsLoadingMore(false)
setLoadMoreError(null)
}
}, [ordersData, currentPage])
// Handle errors during infinite scroll loading
useEffect(() => {
if (error && currentPage > 1) {
setIsLoadingMore(false)
setLoadMoreError('Failed to load more orders. Please try again.')
}
}, [error, currentPage])
const loadMoreOrders = useCallback(() => {
if (!isLoadingMore && hasNextPage && !isLoading) {
setIsLoadingMore(true)
setCurrentPage((prev) => prev + 1)
}
}, [isLoadingMore, hasNextPage, isLoading])
return (
<div className="min-h-screen bg-gray-50">
<MeScreenHeader title="My Orders" />
{isLoading && currentPage === 1 && (
<div className="flex min-h-[60vh] flex-1 items-center justify-center bg-gray-50">
<p className="font-medium text-gray-500">Loading your orders...</p>
</div>
)}
{error && currentPage === 1 && !isLoading && (
<div className="flex min-h-[60vh] flex-1 flex-col items-center justify-center bg-gray-50 p-6">
<div className="mb-4 rounded-full bg-white p-6 shadow-sm">
<AlertCircle className="h-12 w-12 text-red-500" />
</div>
<p className="mt-2 text-xl font-bold text-gray-900">Unable to load orders</p>
<p className="mt-2 mb-6 text-center text-gray-500">Please check your connection and try again</p>
<button
onClick={() => refetch()}
className="rounded-full bg-blue-600 px-8 py-3 font-bold text-white shadow-md transition-colors hover:bg-blue-700"
>
Retry
</button>
</div>
)}
{!isLoading && !error && (
<div className="mx-auto w-full max-w-2xl px-4 py-6">
{allOrders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20">
<div className="mb-4 rounded-full bg-white p-6 shadow-sm">
<Package className="h-16 w-16 text-gray-200" />
</div>
<p className="mt-2 text-lg font-bold text-gray-900">No orders yet</p>
<p className="mt-2 text-center text-gray-500">Your order history will appear here</p>
<button
onClick={() => navigate({ to: '/home' })}
className="mt-6 rounded-full bg-blue-600 px-6 py-3 font-bold text-white shadow-md transition-colors hover:bg-blue-700"
>
Start Shopping
</button>
</div>
) : (
<>
{allOrders.map((order) => (
<OrderCard
key={order.id}
item={order}
getStatusStyle={getStatusStyle}
getRefundStyle={getRefundStyle}
onPress={(orderId) => navigate({ to: '/me/orders/$id', params: { id: String(orderId) } })}
onViewMoreItems={(orderId) =>
navigate({ to: '/me/orders/$id', params: { id: String(orderId) } })
}
refetch={refetch}
/>
))}
{/* Footer / infinite scroll sentinel */}
<div className="flex flex-col items-center py-4">
{isLoadingMore && <p className="text-sm text-gray-500">Loading more orders...</p>}
{loadMoreError && (
<div className="flex flex-col items-center gap-2">
<p className="mb-2 text-sm text-red-500">{loadMoreError}</p>
<button
onClick={() => {
setLoadMoreError(null)
loadMoreOrders()
}}
className="rounded-lg bg-blue-500 px-4 py-2 text-sm font-medium text-white"
>
Retry
</button>
</div>
)}
{!hasNextPage && allOrders.length > 0 && (
<p className="text-xs text-gray-400">End of list</p>
)}
{hasNextPage && !isLoadingMore && !loadMoreError && (
<button onClick={loadMoreOrders} className="rounded-lg bg-gray-200 px-5 py-2 text-sm font-medium text-gray-700">
Load more
</button>
)}
</div>
</>
)}
</div>
)}
</div>
) )
} }

View file

@ -1,57 +1,183 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { p, AppContainer } from 'web-components' import { MeScreenHeader } from '../components/me/MeScreenHeader'
export const Route = createFileRoute('/me/terms')({ component: TermsPage }) export const Route = createFileRoute('/me/terms')({ component: TermsPage })
// Verbatim content ported from apps/user-ui/components/TermsAndConditions.tsx
// (source of truth for the me/terms page).
function TermsPage() { function TermsPage() {
return ( return (
<AppContainer> <div className="min-h-screen bg-gray-50">
<p className="font-bold mb-6 text-2xl"> <MeScreenHeader title="Terms & Conditions" />
Terms & Conditions <div className="mx-auto w-full max-w-2xl p-4 pb-8">
<div className="rounded-2xl bg-white p-5 text-sm leading-relaxed text-gray-600 shadow-sm">
{/* Header */}
<p className="text-base font-bold text-gray-900">FRESHYO Legal Policies</p>
<p className="mb-4 text-xs text-gray-500">Last Updated: [Add Date]</p>
<p className="mb-4">
By accessing or using the FRESHYO mobile application ("App"), you agree to the following
policies. If you do not agree, please do not use the App.
</p> </p>
<div className="prose prose-sm max-w-none text-gray-600"> {/* A. TERMS & CONDITIONS */}
<p className="font-semibold mb-2 mt-4 text-gray-900"> <p className="mb-2 text-base font-bold text-gray-900">A. TERMS &amp; CONDITIONS</p>
1. Acceptance of Terms
</p> <p className="mb-1 text-sm font-semibold text-gray-800">1. About FRESHYO</p>
<p className="mb-4"> <p className="mb-4">
By using Freshyo, you agree to these terms. If you do not agree, please FRESHYO is a grocery and fresh meat delivery application serving customers only within
do not use our service. Mahabubnagar Town, Telangana, India.
</p> </p>
<p className="font-semibold mb-2 mt-4 text-gray-900"> <p className="mb-1 text-sm font-semibold text-gray-800">2. Eligibility</p>
2. Orders and Payments
</p>
<p className="mb-4"> <p className="mb-4">
All orders are subject to availability. We reserve the right to cancel Users must be 18 years or older to place orders. By registering, you confirm the accuracy
any order. Payments are collected at the time of delivery (COD). of the information provided.
</p> </p>
<p className="font-semibold mb-2 mt-4 text-gray-900"> <p className="mb-1 text-sm font-semibold text-gray-800">3. Service Area</p>
3. Delivery Policy
</p>
<p className="mb-4"> <p className="mb-4">
Delivery times are estimates. We strive to deliver within the promised Services are limited strictly to Mahabubnagar Town. Orders outside the service area may be
time window but delays may occur due to unforeseen circumstances. cancelled without notice.
</p> </p>
<p className="font-semibold mb-2 mt-4 text-gray-900"> <p className="mb-1 text-sm font-semibold text-gray-800">4. Account Registration</p>
4. Returns and Refunds
</p>
<p className="mb-4"> <p className="mb-4">
If you are not satisfied with the quality of your order, please contact Registration requires a valid mobile number and OTP verification. Users are responsible for
us within 24 hours of delivery. Refunds will be processed after quality all activity under their account. FRESHYO reserves the right to suspend or terminate
assessment. accounts for misuse.
</p> </p>
<p className="font-semibold mb-2 mt-4 text-gray-900"> <p className="mb-1 text-sm font-semibold text-gray-800">5. Orders</p>
5. Privacy
</p>
<p className="mb-4"> <p className="mb-4">
We respect your privacy. Your personal information is used only for Placing an order constitutes an offer to purchase. FRESHYO may accept or reject orders
order processing and delivery purposes. based on availability, pricing errors, or operational reasons.
</p>
<p className="mb-1 text-sm font-semibold text-gray-800">6. Pricing &amp; Payment</p>
<p className="mb-4">
Prices are displayed in INR and include applicable taxes unless stated otherwise. Only Cash
on Delivery (COD) is accepted. Full payment must be made at the time of delivery. Failure to
pay may result in order cancellation and account restrictions.
</p>
<p className="mb-1 text-sm font-semibold text-gray-800">7. Delivery</p>
<p className="mb-4">
Delivery timelines are estimates and may vary. Users must ensure availability at the
delivery address. Incorrect address or unavailability may lead to cancellation.
</p>
<p className="mb-1 text-sm font-semibold text-gray-800">8. Fresh Meat &amp; Perishables</p>
<p className="mb-4">
Fresh meat and perishable items are non-returnable and non-refundable except for: wrong item
delivered, spoiled or damaged item at delivery. Issues must be reported within 2 hours with
photos.
</p>
<p className="mb-1 text-sm font-semibold text-gray-800">9. User Conduct</p>
<p className="mb-4">
Users must not: provide false information, abuse staff, misuse the App, or engage in illegal
activity. Violations may result in permanent account termination.
</p>
<p className="mb-1 text-sm font-semibold text-gray-800">10. Intellectual Property</p>
<p className="mb-4">All content, branding, and app design belong exclusively to FRESHYO.</p>
<p className="mb-1 text-sm font-semibold text-gray-800">11. Limitation of Liability</p>
<p className="mb-4">
Liability is limited to the value of the order placed. Product images are indicative only.
</p>
<p className="mb-1 text-sm font-semibold text-gray-800">12. Force Majeure</p>
<p className="mb-4">
FRESHYO is not liable for delays caused by events beyond reasonable control.
</p>
<p className="mb-1 text-sm font-semibold text-gray-800">13. Governing Law</p>
<p className="mb-4">
Governed by Indian law. Jurisdiction: Courts of Telangana.
</p>
{/* B. PRIVACY POLICY */}
<p className="mb-2 text-base font-bold text-gray-900">B. PRIVACY POLICY</p>
<p className="mb-4">FRESHYO respects your privacy and is committed to protecting your data.</p>
<p className="mb-1 text-sm font-semibold text-gray-800">1. Information We Collect</p>
<p className="mb-4">
Name, mobile number, delivery address, order history, app usage data (basic analytics).
</p>
<p className="mb-1 text-sm font-semibold text-gray-800">2. How We Use Information</p>
<p className="mb-4">
To process and deliver orders, to communicate order updates, to improve app experience, for
customer support.
</p>
<p className="mb-1 text-sm font-semibold text-gray-800">3. Data Sharing</p>
<p className="mb-4">
Data is shared only with delivery partners for order fulfillment. We do not sell or rent
personal data to third parties.
</p>
<p className="mb-1 text-sm font-semibold text-gray-800">4. Data Security</p>
<p className="mb-4">
We use reasonable security measures to protect your data. However, no system is 100%
secure.
</p>
<p className="mb-1 text-sm font-semibold text-gray-800">5. Data Retention</p>
<p className="mb-4">
Data is retained only as long as necessary for business and legal purposes.
</p>
<p className="mb-1 text-sm font-semibold text-gray-800">6. User Rights</p>
<p className="mb-4">
Users may request: data correction, account deletion (subject to legal and operational
requirements).
</p>
<p className="mb-1 text-sm font-semibold text-gray-800">7. Children's Privacy</p>
<p className="mb-4">FRESHYO does not knowingly collect data from users under 18.</p>
{/* C. REFUND & CANCELLATION POLICY */}
<p className="mb-2 text-base font-bold text-gray-900">C. REFUND &amp; CANCELLATION POLICY</p>
<p className="mb-1 text-sm font-semibold text-gray-800">1. Order Cancellation</p>
<p className="mb-4">
Orders can be cancelled before dispatch. Once dispatched, cancellation may not be allowed,
especially for perishables.
</p>
<p className="mb-1 text-sm font-semibold text-gray-800">2. Refund Policy</p>
<p className="mb-4">
Since payments are Cash on Delivery, refunds (if applicable) will be handled via: cash
adjustment, replacement, or store credit (at FRESHYO's discretion).
</p>
<p className="mb-1 text-sm font-semibold text-gray-800">3. Non-Refundable Items</p>
<p className="mb-4">
Fresh meat and perishable groceries except in cases of damage, spoilage, or wrong delivery.
</p>
<p className="mb-1 text-sm font-semibold text-gray-800">4. Reporting Issues</p>
<p className="mb-4">
Issues must be reported within 2 hours of delivery. Supporting photos are mandatory.
</p>
{/* D. CONTACT INFORMATION */}
<p className="mb-2 text-base font-bold text-gray-900">D. CONTACT INFORMATION</p>
<p className="mb-1">📧 Email: support@freshyo.in</p>
<p className="mb-4">📞 Customer Support: 8688182552</p>
{/* E. ACCEPTANCE */}
<p className="mb-2 text-base font-bold text-gray-900">E. ACCEPTANCE</p>
<p className="mb-4">
By registering or placing an order, you confirm that you have read, understood, and agreed
to all policies above.
</p> </p>
</div> </div>
</AppContainer> </div>
</div>
) )
} }

View file

@ -1,22 +1,48 @@
import { createFileRoute, useNavigate, Outlet, useLocation } from '@tanstack/react-router' import { createFileRoute, useNavigate, Outlet, useLocation } from '@tanstack/react-router'
import { useAuth } from '../lib/auth-context' import { useAuth } from '../lib/auth-context'
import { MyButton, ProfileImage } from 'web-components' import { MyButton } from 'web-components'
import { AppLayout } from '../components/AppLayout' import { AppLayout } from '../components/AppLayout'
import { NextOrderGlimpse } from '../components/me/NextOrderGlimpse'
import { import {
Package, Package,
MapPin, ShoppingCart,
Ticket, Ticket,
MessageSquare, MapPin,
User, User,
Info, MessageSquare,
FileText, FileText,
LogOut, LogOut,
ShoppingCart,
ChevronRight, ChevronRight,
Settings,
} from 'lucide-react' } from 'lucide-react'
import React from 'react'
export const Route = createFileRoute('/me')({ component: MePage }) export const Route = createFileRoute('/me')({ component: MePage })
interface MenuItem {
title: string
icon: React.ComponentType<{ size?: number | string; color?: string }>
color: string
to: string
subtitle: string
}
interface MenuSection {
title: string
items: MenuItem[]
}
function MenuIcon({ icon: Icon, color }: { icon: MenuItem['icon']; color: string }) {
return (
<div
className="mr-4 flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl"
style={{ backgroundColor: `${color}15` }}
>
<Icon size={24} color={color} />
</div>
)
}
function MePage() { function MePage() {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
@ -40,33 +66,69 @@ function MePage() {
) )
} }
const menuItems = [ const menuSections: MenuSection[] = [
{ {
section: 'Shopping & Activity', title: 'Shopping & Activity',
items: [ items: [
{ icon: Package, label: 'My Orders', to: '/me/orders' }, {
{ icon: ShoppingCart, label: 'My Cart', to: '/cart' }, title: 'My Orders',
{ icon: Ticket, label: 'Coupons', to: '/me/coupons' }, icon: Package,
color: '#1570EF',
to: '/me/orders',
subtitle: 'Orders history & tracking',
},
{
title: 'My Cart',
icon: ShoppingCart,
color: '#10B981',
to: '/cart',
subtitle: 'Items ready for checkout',
},
{
title: 'Coupons',
icon: Ticket,
color: '#8B5CF6',
to: '/me/coupons',
subtitle: 'View active offers & rewards',
},
], ],
}, },
{ {
section: 'Saved Information', title: 'Saved Information',
items: [ items: [
{ icon: MapPin, label: 'Addresses', to: '/me/addresses' }, {
{ icon: User, label: 'Profile Settings', to: '/me/edit-profile' }, title: 'Addresses',
icon: MapPin,
color: '#F59E0B',
to: '/me/addresses',
subtitle: 'Manage delivery locations',
},
{
title: 'Profile Settings',
icon: User,
color: '#3B82F6',
to: '/me/edit-profile',
subtitle: 'Update your personal details',
},
], ],
}, },
{ {
section: 'Support', title: 'Support',
items: [ items: [
{ icon: MessageSquare, label: 'Help & Complaints', to: '/me/complaints' }, {
{ icon: FileText, label: 'Terms & Conditions', to: '/me/terms' }, title: 'Help & Complaints',
], icon: MessageSquare,
color: '#EF4444',
to: '/me/complaints',
subtitle: 'Talk to our customer care',
}, },
{ {
section: 'About', title: 'Terms & Conditions',
items: [ icon: FileText,
{ icon: Info, label: 'About Us', to: '/me/about' }, color: '#6B7280',
to: '/me/terms',
subtitle: 'Read our legal policies',
},
], ],
}, },
] ]
@ -74,62 +136,68 @@ function MePage() {
return ( return (
<AppLayout> <AppLayout>
{isExactMePath ? ( {isExactMePath ? (
<div className="mx-auto w-full max-w-5xl px-4 py-6 pb-24 md:px-8 md:pb-12"> <div className="min-h-screen bg-gray-50">
{/* Profile Header */} <div className="mx-auto w-full max-w-2xl">
<div className="mb-6"> <div className="flex flex-row items-center justify-between px-6 pb-10 pt-8">
<p className="text-[11px] font-black uppercase tracking-[0.22em] text-brand-600">
Your Account
</p>
<h1 className="display-2 mt-1 text-gray-900">My Freshyo</h1>
</div>
<div className="mb-8 flex items-center gap-4 rounded-xl border border-gray-200 bg-white p-6">
<ProfileImage uri={user.profileImage} size={64} />
<div> <div>
<p className="text-lg font-extrabold text-gray-900"> <p className="text-3xl font-extrabold tracking-tight text-gray-900">My Account</p>
{user.name || 'User'} <p className="mt-1 text-sm text-gray-400">Manage your freshyo experience</p>
</p>
<p className="text-sm text-gray-500">{user.mobile}</p>
</div> </div>
<button
onClick={() => navigate({ to: '/me/edit-profile' })}
aria-label="Settings"
className="rounded-2xl bg-gray-100 p-3 transition-colors hover:bg-gray-200"
>
<Settings size={22} color="#1F2937" />
</button>
</div> </div>
{/* Menu */} <NextOrderGlimpse />
<div className="grid gap-6 md:grid-cols-2">
{menuItems.map((section) => ( <div className="px-6">
<div key={section.section}> {menuSections.map((section, sIndex) => (
<p className="mb-2 text-[11px] font-black uppercase tracking-[0.18em] text-gray-400"> <div key={sIndex} className="mb-8">
{section.section} <p className="mb-4 px-2 text-sm font-bold uppercase tracking-[2px] text-gray-400">
{section.title}
</p> </p>
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white"> <div className="overflow-hidden rounded-[32px] border border-gray-100 bg-white shadow-sm">
{section.items.map((item) => ( {section.items.map((item, iIndex) => (
<button <button
key={item.label} key={iIndex}
onClick={() => navigate({ to: item.to as any })} onClick={() => navigate({ to: item.to as any })}
className="flex w-full items-center gap-3 border-b border-gray-100 px-5 py-4 text-left transition-colors last:border-b-0 hover:bg-gray-50" className={`flex w-full flex-row items-center p-4 text-left transition-colors hover:bg-gray-50 ${
iIndex !== section.items.length - 1 ? 'border-b border-gray-50' : ''
}`}
> >
<item.icon className="h-5 w-5 text-brand-600" /> <MenuIcon icon={item.icon} color={item.color} />
<p className="flex-1 text-sm font-semibold text-gray-800">{item.label}</p> <div className="min-w-0 flex-1">
<ChevronRight className="h-4 w-4 text-gray-300" /> <p className="text-base font-semibold text-gray-800">{item.title}</p>
<p className="mt-0.5 text-xs text-gray-400">{item.subtitle}</p>
</div>
<div className="ml-2 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-50">
<ChevronRight size={16} color="#94A3B8" />
</div>
</button> </button>
))} ))}
</div> </div>
</div> </div>
))} ))}
</div>
{/* Logout */} {/* Logout row */}
<div className="mt-8 flex flex-col items-center gap-4"> <button
<MyButton
onClick={logout} onClick={logout}
variant="red" className="mt-2 flex w-full flex-row items-center justify-center rounded-[24px] border border-red-50 bg-white p-5 shadow-sm transition-colors hover:bg-red-50"
className="bg-red-600 text-white hover:bg-red-700" >
textContent="Logout" <LogOut size={22} color="#F43F5E" className="mr-2" />
/> <p className="text-base font-bold text-rose-500">Log Out from Freshyo</p>
<p className="text-xs text-gray-400"> </button>
Version 1.0.0
<p className="mt-12 text-center text-[10px] uppercase tracking-[3px] text-gray-300">
Version 1.2.0 Freshyo App
</p> </p>
</div> </div>
</div> </div>
</div>
) : ( ) : (
/* Render child routes when not on exact /me path */ /* Render child routes when not on exact /me path */
<Outlet /> <Outlet />

View file

@ -1466,3 +1466,20 @@ Planned edits: package.json name web-ui->admin-web + add sonner, @dnd-kit/core,
[2026-09-06 01:00:00] backend admin-apis image-URL pattern: replicate user-ui scaffoldAssetUrl in admin-apis/product.ts (9 sites) + admin-apis/complaint.ts (1 site), replacing generateSignedUrlsFromS3Urls. Collapse redundant await/Promise.all wrappers, drop unused import, rename signed* locals. Response field names unchanged (no frontend impact). Write/upload paths untouched. No migration. [2026-09-06 01:00:00] backend admin-apis image-URL pattern: replicate user-ui scaffoldAssetUrl in admin-apis/product.ts (9 sites) + admin-apis/complaint.ts (1 site), replacing generateSignedUrlsFromS3Urls. Collapse redundant await/Promise.all wrappers, drop unused import, rename signed* locals. Response field names unchanged (no frontend impact). Write/upload paths untouched. No migration.
[2026-09-06 01:05:00] VERIFIED admin image-URL unification: backend tsc 0 errors (= baseline); only remaining generateSignedUrlsFromS3Urls refs in admin-apis are inside commented-out blocks (product.ts old getProductById + old reviews query), left untouched as history. Live admin product/complaint image fields now return permanent CDN scaffoldAssetUrl URLs like user-apis; response field names unchanged. [2026-09-06 01:05:00] VERIFIED admin image-URL unification: backend tsc 0 errors (= baseline); only remaining generateSignedUrlsFromS3Urls refs in admin-apis are inside commented-out blocks (product.ts old getProductById + old reviews query), left untouched as history. Live admin product/complaint image fields now return permanent CDN scaffoldAssetUrl URLs like user-apis; response field names unchanged.
[2026-09-06 09:15:00] WEB-UI ME SECTION — EXACT REPLICA of user-ui me screens. Ported RN layouts/cards/dialogs/colors; added mobile-only stack headers to /me subpages (desktop keeps global shell); removed invented "About" menu entry from hub.
NEW FILES:
- apps/web-ui/src/components/me/MeScreenHeader.tsx — mobile-only (md:hidden) back-chevron + title bar replicating RN stack headers.
- apps/web-ui/src/components/me/NextOrderGlimpse.tsx — port of RN amber "Upcoming Order" horizontal snap carousel (filters active orders, flash-first sort, pagination dots, tel: support).
- apps/web-ui/src/components/me/MeOrderMenu.tsx — port of RN OrderMenu 3-dot action sheet (View Details / Edit Notes / Raise Complaint / Cancel Order) using web BottomDialog.
REWRITES (route → RN parity):
- me.tsx — hub: gray-50 bg; "My Account" 3xl header + subtitle + gear → edit-profile; NextOrderGlimpse below; three rounded-[32px] section cards w/ per-item colored 15%-alpha icon tiles (orders brand600, cart #10B981, coupons #8B5CF6, addresses #F59E0B, profile #3B82F6, complaints #EF4444, terms #6B7280) + chevron circles; white rounded-[24px] "Log Out from Freshyo" card; "Version 1.2.0 • Freshyo App" footer. Removed profile-card + About section.
- me.orders.tsx — full RN order card (rounded-[28px], ORDER REFERENCE header w/ blue dot, cancelled/FLASH pills, delivery + placed-on rows, 3-dot menu, item thumbnails w/ qty bubbles, +N more, shipping status, notes/cancel panels, total + Saved ₹ + delivery, View Details); page-1/10 infinite scroll with Load-more + loading/retry/end-of-list footer; empty/error states; local REFUND_STATUS const matching packages/ui (na/initiated/success/pending); mobile header "My Orders".
- me.orders.$id.tsx — Dialog → BottomDialog for cancel + complaint sheets; bg-linear → bg-gradient fix; header/colors aligned to RN detail.
- me.addresses.tsx — RN address cards w/ Default badge + 📞/📍, Set Default(Edit/Delete actions; + FAB header; empty state w/ MapPin + Add; loading/error+retry; add/edit via web BottomDialog + existing web AddressForm; window.confirm + alert parity for delete/set-default.
- me.coupons.tsx — wrapped w/ gray-50 + mobile header "My Coupons"; loading/error headers; content identical.
- me.complaints.tsx — full-bleed gray-50 page + mobile header "Complaints"; loading/error aligned; removed AppContainer confinement so brand "Need Help?" header is full-width.
- me.edit-profile.tsx — RN parity: centered avatar + camera upload (FileReader → useUploadToObjectStorage 'profile' context, ProfileImage), labeled Full Name/Email/Mobile fields w/ gray-50 bg + inline validation, brand "Update Profile", brand "Update Password" BottomDialog (match+length validation), gray Logout row, red "Delete Me and My Data" + BottomDialog confirm (⚠️ title + mobile entry + Cancel/Delete Forever). updateProfile now sends mobile + optional profileImageUrl (was name/email only).
- me.terms.tsx — replaced invented stub with verbatim FRESHYO legal content (sections AE) from apps/user-ui/components/TermsAndConditions.tsx inside a white card + mobile header.
NO LONGER LINKED: /me/about (hub About entry removed; RN has no About screen). Route file me.about.tsx left in place but no nav entry points to it.
NOTES: no backend/migration changes; colors/tokens reused from web-ui theme.