DEAD_CODE_CLEAN #7

Merged
shafi merged 30 commits from DEAD_CODE_CLEAN into master 2026-09-14 04:32:52 +00:00
100 changed files with 23509 additions and 74 deletions
Showing only changes of commit 0bfd43c172 - Show all commits

View file

@ -30,6 +30,7 @@
- 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
- 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
- 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 handling special-case logic locally in the relevant component/file rather than globally or via parallel flows. Confidence: 0.85

19
apps/admin-web/.cta.json Normal file
View file

@ -0,0 +1,19 @@
{
"projectName": "web-ui",
"mode": "file-router",
"typescript": true,
"tailwind": true,
"packageManager": "bun",
"git": true,
"install": true,
"intent": true,
"addOnOptions": {},
"includeExamples": false,
"envVarValues": {},
"routerOnly": false,
"version": 1,
"framework": "react",
"chosenAddOns": [
"nitro"
]
}

15
apps/admin-web/README.md Normal file
View file

@ -0,0 +1,15 @@
# Freshyo Admin Web
Web clone of `apps/admin-ui` — same screens, same functionality, same looks —
built on the exact stack of `apps/web-ui` (TanStack Start + Vite + Tailwind v4 +
tRPC + TanStack Router).
```bash
npm install
npm run dev # http://localhost:4175
npm run build # production build
npm run typecheck
```
Backend: same as admin-ui (`https://devapi.freshyo.in`); override with
`VITE_API_URL` env var.

View file

@ -0,0 +1,9 @@
[2026-09-06 00:10:00] admin-web integration fixes batch: (1) dashboard.notifications.tsx SearchableSelect(number[] mismatch) -> MultiSelect (matches original multi BottomDropdown); (2) TagMenu.tsx router.history.push string nav -> typed navigate({to search}); (3) dashboard.orders.$id.tsx user tap /dashboard/users -> /dashboard/users/$id params nav (route exists); (4) dashboard.users.$id.tsx order nav as-any -> typed params nav; (5) remove temp as-any on now-registered navigate literals (dashboard.index, orders hub, slots, banners, stores, prices, products.edit/groupings casts if tsc-clean); (6) regen routeTree.gen.ts via build.
[2026-09-06 00:20:00] admin-web README: replaced TanStack boilerplate with Freshyo Admin Web run/build notes (npm install/dev/build, port 4175, VITE_API_URL override).
[2026-09-06 00:20:00] COMPLETED apps/admin-web: exact web clone of apps/admin-ui on apps/web-ui stack (TanStack Start + Vite + Tailwind v4 + tRPC + TanStack Router, port 4175, worker admin-web). 38 routes + 21 components + infra, tsc clean, vite build clean, SSR smoke 200s.
Routes: index gate, login, dashboard shell (sidebar/header/titles/refresh) + hub, complaints, coupons(+new,+$id/edit), customize-app(+popular,+ordering), banners(+new,+$id/edit), orders(+list,+sequence,+$id), prices, product-groupings(+new,+$id/edit), product-tags(+new,+edit?tagId,+order), products(+new,+edit?id,+$id), rebalance, notifications, slots(+new?baseslot,+$id/edit,+detail?slotId), stores(+new,+edit?id), users(+$id), vendor-snippets.
New local primitives: MultiSelect, SearchableSelect, DateTimeInput, DateInput (mirror RN BottomDropdown-multi/search + DateTimePickerMod/DatePicker APIs).
Verification: tsc 0 errors; 54/54 file pairs tRPC-procedure parity (2 apparent gaps proven dead code in original: updateAddressCoords declaration, refund commented block); build ok; dev SSR / /login /dashboard /dashboard/coupons 200, /dashboard/orders/sequence 307 redirect, 0 error refs.
Known platform deltas (no web equivalent): Alert.alert->window.alert, destructive confirms->window.confirm, pull-to-refresh->header refresh button, infinite scroll->Load-more buttons, expo-image-picker->file inputs, native date/time pickers->date+time inputs, haptics/blur/share-target->dropped or navigator.share w/ clipboard fallback, push-notification token acquisition dropped (send flow intact).

6596
apps/admin-web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,60 @@
{
"name": "admin-web",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "npm run build && wrangler dev",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"deploy": "npm run build && wrangler deploy",
"cf-typegen": "wrangler types"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-devtools": "^0.10.2",
"@tanstack/react-query": "^5.100.0",
"@tanstack/react-query-devtools": "^5.100.0",
"@tanstack/react-router": "^1.167.0",
"@tanstack/react-router-devtools": "^1.166.0",
"@tanstack/react-router-ssr-query": "^1.166.0",
"@tanstack/react-start": "^1.167.0",
"@tanstack/router-plugin": "^1.167.0",
"@trpc/client": "^11.17.0",
"@trpc/react-query": "^11.17.0",
"@trpc/server": "^11.17.0",
"axios": "^1.16.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.20",
"date-fns": "^4.1.0",
"formik": "^2.4.9",
"fuse.js": "^7.3.0",
"jwt-decode": "^4.0.0",
"lucide-react": "^0.400.0",
"nitro": "npm:nitro-nightly@latest",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sonner": "^1.7.4",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.1.18",
"yup": "^1.7.1",
"zustand": "^5.0.13"
},
"devDependencies": {
"@cloudflare/vite-plugin": "^1.36.3",
"@tanstack/devtools-vite": "^0.6.0",
"@types/node": "^22.10.2",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.0.1",
"typescript": "^5.8.3",
"vite": "^8.0.0",
"vitest": "^4.1.5",
"wrangler": "^4.90.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

View file

@ -0,0 +1,25 @@
{
"short_name": "Freshyo",
"name": "Freshyo",
"icons": [
{
"src": "favicon.png",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/png"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#0033CC",
"background_color": "#ffffff"
}

View file

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View file

@ -0,0 +1,4 @@
import { hydrateRoot } from 'react-dom/client'
import { StartClient } from '@tanstack/react-start/client'
hydrateRoot(document, <StartClient />)

View file

@ -0,0 +1,225 @@
import { useRef, useState, type ChangeEvent } from 'react'
import { Formik } from 'formik'
import * as Yup from 'yup'
import { p as P, pInput as PInput, ImageUploader } from 'web-components'
import ProductsSelector from './ProductsSelector'
import { trpc } from '@/lib/trpc-client'
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStorage'
export interface BannerFormData {
name: string
imageUrl: string
description: string
skuIds: number[]
redirectUrl: string
// serialNum removed - will be assigned automatically by backend
}
interface BannerFormProps {
initialValues: BannerFormData
onSubmit: (values: BannerFormData, imageUrl?: string) => Promise<void> | void
onCancel: () => void
submitButtonText?: string
isSubmitting?: boolean
existingImageUrl?: string
mode?: 'create' | 'edit'
}
const validationSchema = Yup.object().shape({
name: Yup.string().trim().required('Banner name is required').max(255),
description: Yup.string().max(500),
skuIds: Yup.array()
.of(Yup.number())
.optional(),
redirectUrl: Yup.string()
.url('Please enter a valid URL')
.optional(),
// serialNum validation removed - assigned automatically by backend
})
export default function BannerForm({
initialValues,
onSubmit,
onCancel,
submitButtonText = 'Create Banner',
isSubmitting = false,
existingImageUrl,
mode = 'create',
}: BannerFormProps) {
const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([])
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
const { uploadSingle } = useUploadToObjectStorage()
// Fetch products for dropdown
const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery()
const products = productsData?.products || []
const handleImagePick = () => {
fileInputRef.current?.click()
}
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || [])
if (files.length === 0) {
setSelectedImages([])
setDisplayImages([])
return
}
const file = files[0]
setSelectedImages([{ blob: file, mimeType: file.type || 'image/jpeg' }])
setDisplayImages([{ uri: URL.createObjectURL(file) }])
e.target.value = ''
}
const handleRemoveImage = (uri: string) => {
const index = displayImages.findIndex(img => img.uri === uri)
if (index !== -1) {
const newDisplay = displayImages.filter((_, i) => i !== index)
const newFiles = selectedImages.filter((_, i) => i !== index)
setDisplayImages(newDisplay)
setSelectedImages(newFiles)
}
}
const handleFormikSubmit = async (values: BannerFormData) => {
try {
let imageUrl: string | undefined
if (selectedImages.length > 0) {
const { blob, mimeType } = selectedImages[0]
const { presignedUrl } = await uploadSingle(blob, mimeType, 'store')
imageUrl = presignedUrl
}
await onSubmit(values, imageUrl)
} catch (error) {
console.error('Upload error:', error)
window.alert('Error: Failed to upload image')
}
}
return (
<Formik
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={handleFormikSubmit}
>
{({
handleChange,
handleBlur,
handleSubmit,
values,
errors,
touched,
isValid,
dirty,
setFieldValue,
}) => (
<div className="flex-1">
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
className="hidden"
/>
<div className="flex-1 overflow-auto px-6 py-6">
<PInput
topLabel="Banner Name"
placeholder="Enter banner name"
value={values.name}
onChange={handleChange('name')}
onBlur={handleBlur('name')}
style={{ marginBottom: errors.name && touched.name ? 8 : 16 }}
/>
{errors.name && touched.name && (
<P className="text-red-500 text-xs mb-2 -mt-2">{errors.name}</P>
)}
<div className="mb-4">
<P className="text-sm font-bold text-gray-700 mb-3 uppercase tracking-wider">Banner Image</P>
<ImageUploader
images={displayImages}
existingImageUrls={existingImageUrl ? [existingImageUrl] : []}
onAddImage={handleImagePick}
onRemoveImage={handleRemoveImage}
onRemoveExistingImage={() => {
// Handle removing existing image in edit mode
}}
allowMultiple={false}
/>
</div>
<PInput
topLabel="Description"
placeholder="Enter banner description (optional)"
value={values.description}
onChange={handleChange('description')}
onBlur={handleBlur('description')}
multiline
numberOfLines={3}
style={{ marginBottom: errors.description && touched.description ? 8 : 16 }}
/>
{errors.description && touched.description && (
<P className="text-red-500 text-xs mb-2 -mt-2">{errors.description}</P>
)}
<div className="mb-4">
<ProductsSelector
value={values.skuIds}
onChange={(value) => {
const selectedValues = Array.isArray(value) ? value : [value]
setFieldValue('skuIds', selectedValues.map(v => Number(v)))
}}
multiple={true}
label="Select Products"
placeholder="Select products for banner (optional)"
labelFormat={(product) => `${product.productName} (₹${product.price})`}
/>
</div>
<PInput
topLabel="Redirect URL (Optional)"
placeholder="https://example.com/redirect"
value={values.redirectUrl}
onChange={handleChange('redirectUrl')}
onBlur={handleBlur('redirectUrl')}
type="url"
style={{ marginBottom: errors.redirectUrl && touched.redirectUrl ? 8 : 16 }}
/>
{errors.redirectUrl && touched.redirectUrl && (
<P className="text-red-500 text-xs mb-2 -mt-2">{errors.redirectUrl}</P>
)}
{/* Action Buttons */}
<div className="flex flex-row gap-4 mb-8">
<button
type="button"
onClick={onCancel}
disabled={isSubmitting}
className="flex-1 bg-gray-100 rounded-lg py-4 items-center text-gray-700 font-semibold disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={() => handleSubmit()}
disabled={isSubmitting || !isValid || !dirty}
className={`flex-1 rounded-lg py-4 items-center text-white font-semibold disabled:opacity-70 ${
isSubmitting || !isValid || !dirty
? 'bg-blue-400'
: 'bg-blue-600'
}`}
>
{isSubmitting ? 'Saving...' : submitButtonText}
</button>
</div>
</div>
</div>
)}
</Formik>
)
}

View file

@ -0,0 +1,85 @@
import React, { useState } from 'react'
import type { OrderDialogBaseProps } from '@packages/shared'
import { p as P, BottomDialog, pInput as PInput } from 'web-components'
import { trpc } from '@/lib/trpc-client'
import { XCircle } from 'lucide-react'
interface CancelOrderDialogProps extends OrderDialogBaseProps {
onSuccess?: () => void
}
export default function CancelOrderDialog({ orderId, open, onClose, onSuccess }: CancelOrderDialogProps) {
const [cancelReason, setCancelReason] = useState('')
const cancelOrderMutation = trpc.admin.order.cancelOrder.useMutation()
const handleCancel = () => {
if (!cancelReason.trim()) {
window.alert('Error: Please enter a cancellation reason')
return
}
const confirmed = window.confirm(
'Cancel Order? Are you sure you want to cancel this order? This action cannot be undone.'
)
if (!confirmed) return
cancelOrderMutation.mutate(
{ orderId, reason: cancelReason },
{
onSuccess: () => {
onClose()
setCancelReason('')
onSuccess?.()
},
onError: (error: any) => {
window.alert(`Error: ${error.message || 'Failed to cancel order'}`)
},
}
)
}
return (
<BottomDialog open={open} onClose={onClose}>
<div className="p-6">
<div className="items-center mb-6 flex flex-col">
<div className="w-12 h-12 bg-red-100 rounded-full items-center justify-center mb-3 flex">
<XCircle className="h-6 w-6 text-red-600" />
</div>
<P className="text-xl font-bold text-gray-900 text-center">
Cancel Order
</P>
<P className="text-gray-500 text-center mt-2 text-sm leading-5">
This will cancel the order and mark it as cancelled by admin. A refund record will be created.
</P>
</div>
<PInput
topLabel="Cancellation Reason *"
value={cancelReason}
onChange={(e) => setCancelReason(e.target.value)}
placeholder="Enter reason for cancellation..."
multiline
className="h-24"
/>
<div className="flex flex-row gap-3 mt-6">
<button
type="button"
onClick={onClose}
className="flex-1 bg-gray-100 py-3.5 rounded-xl items-center text-gray-700 font-bold"
>
Keep Order
</button>
<button
type="button"
onClick={handleCancel}
disabled={cancelOrderMutation.isPending || !cancelReason.trim()}
className={`flex-1 bg-red-500 py-3.5 rounded-xl items-center shadow-sm text-white font-bold disabled:opacity-50 ${cancelOrderMutation.isPending ? 'opacity-50' : ''}`}
>
{cancelOrderMutation.isPending ? 'Cancelling...' : 'Cancel Order'}
</button>
</div>
</div>
</BottomDialog>
)
}

View file

@ -0,0 +1,558 @@
import { useState, useEffect, type ChangeEvent } from 'react'
import { Formik } from 'formik'
import * as Yup from 'yup'
import { pInput as PInput, MyButton, AppContainer, Checkbox, BottomDialog, p as P, MyFlatList } from 'web-components'
import DateTimeInput from './DateTimeInput'
import { trpc } from '@/lib/trpc-client'
// Local copy of common-ui/shared-types CreateCouponPayload (web cannot import
// the RN common-ui package; @packages/shared does not export this type).
export interface CreateCouponPayload {
couponCodes: string[]
discountPercent?: number
flatDiscount?: number
minOrder?: number
maxValue?: number
validTill?: string
maxLimitForUser?: number
isApplyForAll: boolean
isUserBased: boolean
targetUsers?: number[]
productIds?: number[]
skuIds?: number[]
applicableUsers?: number[]
applicableProducts?: number[]
exclusiveApply?: boolean
}
const USERS_PAGE_SIZE = 10
interface CouponFormValues extends CreateCouponPayload {
isReservedCoupon?: boolean
couponCodes: string[]
skuIds?: number[]
}
interface CouponFormProps {
onSubmit: (values: CouponFormValues) => void
isLoading: boolean
initialValues?: Partial<CouponFormValues>
}
const couponValidationSchema = Yup.object().shape({
isReservedCoupon: Yup.boolean().optional(),
couponCodes: Yup.array().of(
Yup.string()
.required('Code is required')
.min(3, 'Code must be at least 3 characters')
.max(50, 'Code cannot exceed 50 characters')
.matches(/^[A-Z0-9_-]+$/, 'Code can only contain uppercase letters, numbers, underscores, and hyphens')
),
discountPercent: Yup.number()
.min(0, 'Must be positive')
.max(100, 'Cannot exceed 100%')
.optional(),
flatDiscount: Yup.number()
.min(0, 'Must be positive')
.optional(),
minOrder: Yup.number().min(0, 'Must be positive').optional(),
maxValue: Yup.number().min(0, 'Must be positive').optional(),
validTill: Yup.date().optional(),
maxLimitForUser: Yup.number().min(1, 'Must be at least 1').optional(),
exclusiveApply: Yup.boolean().optional(),
isUserBased: Yup.boolean(),
isApplyForAll: Yup.boolean(),
applicableUsers: Yup.array().of(Yup.number()).optional(),
}).test('discount-validation', 'Must provide exactly one discount type with valid value', function(value) {
const { discountPercent, flatDiscount } = value
const hasPercent = discountPercent !== undefined && discountPercent > 0
const hasFlat = flatDiscount !== undefined && flatDiscount > 0
if (hasPercent && hasFlat) {
return this.createError({ message: 'Cannot have both percentage and flat discount' })
}
if (!hasPercent && !hasFlat) {
return this.createError({ message: 'Must provide either percentage or flat discount' })
}
return true
}).test('codes-required', 'At least one coupon code is required', function(value: any) {
const codes = (value.couponCodes || []).filter((c: string) => c && c.trim())
if (codes.length === 0) {
return this.createError({ path: 'couponCodes', message: 'Add at least one coupon code' })
}
return true
}).test('codes-unique', 'Coupon codes must be unique', function(value: any) {
const codes = (value.couponCodes || []).map((c: string) => c.trim()).filter(Boolean)
if (new Set(codes).size !== codes.length) {
return this.createError({ path: 'couponCodes', message: 'Coupon codes must be unique' })
}
return true
})
export default function CouponForm({ onSubmit, isLoading, initialValues }: CouponFormProps) {
// User dropdown states
const [userSearchQuery, setUserSearchQuery] = useState('')
const [userOffset, setUserOffset] = useState(0)
const [allUsers, setAllUsers] = useState<{ id: number; name: string; mobile: string | null }[]>([])
const [hasMoreUsers, setHasMoreUsers] = useState(true)
const [usersDropdownOpen, setUsersDropdownOpen] = useState(false)
// Add-multiple dialog states (for pattern codes like ABC12)
const [multiDialogOpen, setMultiDialogOpen] = useState(false)
const [multiCount, setMultiCount] = useState('')
const { data: usersData, isFetching: isFetchingUsers } = trpc.admin.coupon.getUsersMiniInfo.useQuery(
{ search: userSearchQuery, limit: USERS_PAGE_SIZE, offset: userOffset },
{ enabled: usersDropdownOpen }
)
useEffect(() => {
if (usersData?.users) {
if (userOffset === 0) {
setAllUsers(usersData.users)
} else {
setAllUsers(prev => [...prev, ...usersData.users])
}
setHasMoreUsers(usersData.users.length === USERS_PAGE_SIZE)
}
}, [usersData, userOffset])
useEffect(() => {
setUserOffset(0)
setHasMoreUsers(true)
}, [userSearchQuery])
useEffect(() => {
if (usersDropdownOpen) {
setUserOffset(0)
setAllUsers([])
setHasMoreUsers(true)
setUserSearchQuery('')
}
}, [usersDropdownOpen])
// User search functionality will be inside Formik
const defaultValues: CouponFormValues = {
couponCodes: [''],
isUserBased: false,
isApplyForAll: false,
targetUsers: [],
discountPercent: undefined,
flatDiscount: undefined,
minOrder: undefined,
maxValue: undefined,
validTill: undefined,
maxLimitForUser: undefined,
skuIds: undefined,
applicableUsers: [],
applicableProducts: [],
exclusiveApply: false,
isReservedCoupon: false,
}
return (
<Formik<CouponFormValues>
initialValues={(initialValues || defaultValues) as CouponFormValues}
validationSchema={couponValidationSchema}
onSubmit={onSubmit}
>
{({ values, errors, touched, setFieldValue, handleSubmit }) => {
const toggleUserSelection = (userId: number) => {
const current = values.applicableUsers || []
const newSelection = current.includes(userId)
? current.filter(id => id !== userId)
: [...current, userId]
setFieldValue('applicableUsers', newSelection)
}
const isReserved = (values as any).isReservedCoupon
// Detect pattern codes like ABC12 (ends in digits) for bulk generation.
// Continue numbering from the highest code with the same prefix.
const patternCode = (values.couponCodes || []).find(
(c: string) => /^[A-Z0-9_-]*\d+$/.test(c.trim())
)
const patternPrefix = patternCode ? patternCode.replace(/\d+$/, '') : ''
const patternStart = (values.couponCodes || []).reduce((max, c) => {
const trimmed = c.trim()
if (!patternPrefix || !trimmed.startsWith(patternPrefix)) return max
const m = trimmed.match(/\d+$/)
if (!m) return max
return Math.max(max, parseInt(m[0], 10))
}, patternCode ? parseInt((patternCode.match(/\d+$/) || ['0'])[0], 10) : 0)
const handleAddMultiple = () => {
const count = parseInt(multiCount, 10)
if (!count || count < 1 || count > 100) return
// Start from the next number after the highest existing code
const newCodes = Array.from(
{ length: count },
(_, i) => `${patternPrefix}${patternStart + 1 + i}`
)
const existing = (values.couponCodes || []).filter((c: string) => c && c.trim())
setFieldValue('couponCodes', [...existing, ...newCodes])
setMultiDialogOpen(false)
setMultiCount('')
}
return (
<AppContainer>
{/* Is Reserved Coupon Checkbox */}
<div className="mb-4 flex flex-row items-center">
<Checkbox
checked={(values as any).isReservedCoupon || false}
onPress={() => setFieldValue('isReservedCoupon', !(values as any).isReservedCoupon)}
/>
<P className="ml-2 text-sm font-medium text-gray-700">Is Reserved Coupon</P>
</div>
{/* Coupon Codes */}
<div className="mb-4">
<P className="text-base font-bold mb-1 block">Coupon Codes</P>
<P className="text-xs text-gray-500 mb-2 block">
Each code creates a separate coupon with the same settings.
</P>
{(values.couponCodes || []).map((code: string, index: number) => {
const codeError = Array.isArray(errors.couponCodes)
? (errors.couponCodes as any)[index]
: errors.couponCodes
return (
<div key={index} className="flex flex-row items-center mb-2">
<div className="flex-1">
<PInput
topLabel={`Code ${index + 1}`}
placeholder="e.g., SECRET10"
value={code}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const next = [...(values.couponCodes || [])]
next[index] = e.target.value.toUpperCase()
setFieldValue('couponCodes', next)
}}
error={!!codeError}
/>
{codeError && <P className="text-red-500 text-xs mt-1 block">{codeError}</P>}
</div>
<button
type="button"
onClick={() => {
const next = (values.couponCodes || []).filter((_: string, i: number) => i !== index)
setFieldValue('couponCodes', next.length > 0 ? next : [''])
}}
className="ml-2 p-2 text-red-500 font-bold text-lg"
aria-label={`Remove code ${index + 1}`}
>
</button>
</div>
)
})}
{(errors.couponCodes as any)?.message && (
<P className="text-red-500 text-xs mb-2 block">{(errors.couponCodes as any).message}</P>
)}
<div className="mt-2 flex flex-row self-start">
<button
type="button"
onClick={() => setFieldValue('couponCodes', [...(values.couponCodes || []), ''])}
className="py-2 px-3 bg-blue-50 rounded-lg border border-blue-200 text-blue-600 font-medium"
>
+ Add Code
</button>
{patternCode && (
<button
type="button"
onClick={() => setMultiDialogOpen(true)}
className="ml-2 py-2 px-3 bg-green-50 rounded-lg border border-green-200 text-green-600 font-medium"
>
Add Multiple
</button>
)}
</div>
</div>
{/* Discount Type Selection */}
<P className="text-base font-bold mb-2 block">
Discount Type *
</P>
<div className="flex flex-row mb-4">
<button
type="button"
onClick={() => {
setFieldValue('discountPercent', values.discountPercent || 0)
setFieldValue('flatDiscount', undefined)
}}
className={`flex-1 p-3 border rounded-lg mr-2 ${
values.discountPercent !== undefined ? 'border-blue-500' : 'border-gray-300'
}`}
>
<P className="block text-center">Percentage</P>
</button>
<button
type="button"
onClick={() => {
setFieldValue('flatDiscount', values.flatDiscount || 0)
setFieldValue('discountPercent', undefined)
}}
className={`flex-1 p-3 border rounded-lg ${
values.flatDiscount !== undefined ? 'border-blue-500' : 'border-gray-300'
}`}
>
<P className="block text-center">Flat Amount</P>
</button>
</div>
{/* Discount Value */}
{values.discountPercent !== undefined && (
<div className="mb-4">
<PInput
topLabel="Discount Percentage *"
placeholder="e.g., 10"
value={values.discountPercent?.toString() || ''}
onChange={(e) => setFieldValue('discountPercent', parseFloat(e.target.value) || 0)}
type="number"
inputMode="numeric"
error={!!(touched.discountPercent && errors.discountPercent)}
/>
</div>
)}
{values.flatDiscount !== undefined && (
<div className="mb-4">
<PInput
topLabel="Flat Discount Amount *"
placeholder="e.g., 50"
value={values.flatDiscount?.toString() || ''}
onChange={(e) => setFieldValue('flatDiscount', parseFloat(e.target.value) || 0)}
type="number"
inputMode="numeric"
error={!!(touched.flatDiscount && errors.flatDiscount)}
/>
</div>
)}
{/* Minimum Order */}
<div className="mb-4">
<PInput
topLabel="Minimum Order Amount"
placeholder="e.g., 100"
value={values.minOrder?.toString() || ''}
onChange={(e) => setFieldValue('minOrder', parseFloat(e.target.value) || undefined)}
type="number"
inputMode="numeric"
error={!!(touched.minOrder && errors.minOrder)}
/>
</div>
{/* Maximum Discount Value - only for percentage discounts */}
{values.flatDiscount === undefined && (
<div className="mb-4">
<PInput
topLabel="Maximum Discount Value"
placeholder="e.g., 200"
value={values.maxValue?.toString() || ''}
onChange={(e) => setFieldValue('maxValue', parseFloat(e.target.value) || undefined)}
type="number"
inputMode="numeric"
error={!!(touched.maxValue && errors.maxValue)}
/>
</div>
)}
{/* Validity Period */}
<div className="mb-4">
<P className="text-base mb-2 block">Valid Till</P>
<DateTimeInput
value={values.validTill ? new Date(values.validTill) : null}
setValue={(date) => {
setFieldValue('validTill', date?.toISOString())
}}
/>
</div>
{/* Usage Limit */}
<div className="mb-4">
<PInput
topLabel="Max Uses Per User"
placeholder="e.g., 5"
value={values.maxLimitForUser?.toString() || ''}
onChange={(e) => setFieldValue('maxLimitForUser', parseInt(e.target.value) || undefined)}
type="number"
inputMode="numeric"
error={!!(touched.maxLimitForUser && errors.maxLimitForUser)}
/>
</div>
{/* Exclusive Apply */}
<div className="mb-4">
<P className="text-base mb-2 block">Exclusive Apply</P>
<button
type="button"
onClick={() => setFieldValue('exclusiveApply', !values.exclusiveApply)}
className="flex flex-row items-center"
>
<div className={`w-5 h-5 border-2 border-gray-300 rounded mr-3 flex items-center justify-center ${values.exclusiveApply ? 'bg-blue-500 border-blue-500' : ''}`}>
{values.exclusiveApply && <P className="text-white text-center"></P>}
</div>
<P className="text-gray-700">Exclusive coupon (cannot be combined with other coupons)</P>
</button>
</div>
{/* Target Audience */}
<P className={`text-base font-bold mb-2 block ${isReserved ? 'text-gray-400' : ''}`}>
Target Audience {isReserved ? '(Disabled for Reserved Coupons)' : ''}
</P>
<div className="flex flex-row mb-4">
<button
type="button"
disabled={isReserved}
onClick={isReserved ? undefined : () => {
setFieldValue('isApplyForAll', true)
setFieldValue('isUserBased', false)
setFieldValue('targetUsers', [])
}}
className={`flex-1 p-3 border rounded-lg mr-2 ${
values.isApplyForAll ? 'border-blue-500' : 'border-gray-300'
} ${isReserved ? 'opacity-50' : ''}`}
>
<P className="block text-center" style={{ color: isReserved ? '#9CA3AF' : '#000' }}>All Users</P>
</button>
<button
type="button"
disabled={isReserved}
onClick={isReserved ? undefined : () => {
setFieldValue('isUserBased', true)
setFieldValue('isApplyForAll', false)
}}
className={`flex-1 p-3 border rounded-lg ${
values.isUserBased ? 'border-blue-500' : 'border-gray-300'
} ${isReserved ? 'opacity-50' : ''}`}
>
<P className="block text-center" style={{ color: isReserved ? '#9CA3AF' : '#000' }}>Specific User</P>
</button>
</div>
{/* Applicable User Selection */}
<div className="mb-4">
<P className={`text-base mb-2 block ${isReserved ? 'text-gray-400' : ''}`}>Applicable Users (Optional)</P>
<button
type="button"
disabled={isReserved}
onClick={isReserved ? undefined : () => setUsersDropdownOpen(true)}
className={`border border-gray-300 rounded p-3 bg-white w-full text-left ${isReserved ? 'opacity-50' : ''}`}
>
<P className={isReserved ? 'text-gray-400' : 'text-gray-700'}>
{values.applicableUsers?.length ? `${values.applicableUsers.length} users selected` : 'Select users...'}
</P>
</button>
</div>
<BottomDialog open={usersDropdownOpen} onClose={() => setUsersDropdownOpen(false)}>
<div className="p-4">
<P className="text-lg font-semibold mb-4 block">Select Applicable Users</P>
<PInput
placeholder="Search users by name or mobile..."
value={userSearchQuery}
onChange={(e) => setUserSearchQuery(e.target.value)}
/>
<div className="overflow-auto" style={{ maxHeight: 240 }}>
<MyFlatList
data={allUsers}
keyExtractor={(item) => item.id.toString()}
renderItem={(item) => (
<button
type="button"
onClick={() => toggleUserSelection(item.id)}
className="flex flex-row items-center p-3 border-b border-gray-200 w-full text-left"
>
<span onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={values.applicableUsers?.includes(item.id) || false}
onPress={() => toggleUserSelection(item.id)}
/>
</span>
<P className="ml-3 text-base">{item.mobile} - {item.name}</P>
</button>
)}
ListFooterComponent={
<>
{isFetchingUsers ? <P className="text-center p-3 block">Loading...</P> : null}
{hasMoreUsers && !isFetchingUsers ? (
<button
type="button"
onClick={() => setUserOffset(prev => prev + USERS_PAGE_SIZE)}
className="w-full p-3 text-center text-blue-600 font-medium"
>
Load more
</button>
) : null}
</>
}
/>
</div>
<button
type="button"
onClick={() => setUsersDropdownOpen(false)}
className="mt-4 bg-blue-500 p-3 rounded text-white text-center font-semibold w-full"
>
Done
</button>
</div>
</BottomDialog>
{/* Add Multiple Coupons Dialog */}
<BottomDialog open={multiDialogOpen} onClose={() => setMultiDialogOpen(false)}>
<div className="p-4">
<P className="text-lg font-semibold mb-1 block">Add Multiple Coupons</P>
<P className="text-sm text-gray-500 mb-4 block">
Generate {patternCode ? `${patternPrefix}${patternStart + 1}, ${patternPrefix}${patternStart + 2}, ...` : 'sequential'} codes from your pattern
</P>
<PInput
topLabel="How many?"
placeholder="e.g., 10"
value={multiCount}
onChange={(e) => setMultiCount(e.target.value)}
type="number"
inputMode="numeric"
/>
<div className="flex flex-row mt-4">
<button
type="button"
onClick={() => {
setMultiDialogOpen(false)
setMultiCount('')
}}
className="flex-1 p-3 rounded bg-gray-100 mr-2 text-gray-700 text-center font-semibold"
>
Cancel
</button>
<button
type="button"
onClick={handleAddMultiple}
className="flex-1 p-3 rounded bg-green-500 text-white text-center font-semibold"
>
Generate
</button>
</div>
</div>
</BottomDialog>
{/* Submit Button */}
<MyButton
onClick={() => handleSubmit()}
disabled={isLoading}
textContent={isLoading ? 'Creating...' : 'Create Coupon'}
/>
</AppContainer>
)
}}
</Formik>
)
}

View file

@ -0,0 +1,31 @@
import { p as P } from 'web-components'
interface DateInputProps {
value: Date | null
setValue: (d: Date | null) => void
showLabel?: boolean
placeholder?: string
}
function toDateStr(d: Date | null): string {
if (!d) return ''
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
export default function DateInput({ value, setValue, showLabel = true, placeholder = 'Select Date' }: DateInputProps) {
return (
<div className="w-full">
{showLabel ? <P className="text-xs mb-1">{placeholder}</P> : null}
<input
type="date"
value={toDateStr(value)}
onChange={(e) => setValue(e.target.value ? new Date(`${e.target.value}T00:00:00`) : null)}
placeholder={placeholder}
className="w-full rounded border border-gray-300 px-2 py-2 text-sm font-medium text-gray-800"
/>
</div>
)
}

View file

@ -0,0 +1,80 @@
import { p as P } from 'web-components'
interface DateTimeInputProps {
value: Date | null
setValue: (d: Date | null) => void
showLabels?: boolean
timeOnly?: boolean
}
function toDateStr(d: Date | null): string {
if (!d) return ''
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function toTimeStr(d: Date | null): string {
if (!d) return ''
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
export default function DateTimeInput({ value, setValue, showLabels = true, timeOnly = false }: DateTimeInputProps) {
const handleDateChange = (dateStr: string) => {
if (!dateStr) {
setValue(null)
return
}
const base = value ? new Date(value) : new Date()
const [y, m, day] = dateStr.split('-').map((n) => parseInt(n, 10))
base.setFullYear(y, m - 1, day)
setValue(base)
}
const handleTimeChange = (timeStr: string) => {
if (!timeStr) return
const base = value ? new Date(value) : new Date()
const [h, min] = timeStr.split(':').map((n) => parseInt(n, 10))
base.setHours(h, min, 0, 0)
setValue(base)
}
if (timeOnly) {
return (
<div className="w-full mb-4">
{showLabels ? <P className="text-xs mb-1">Select Time</P> : null}
<input
type="time"
value={toTimeStr(value)}
onChange={(e) => handleTimeChange(e.target.value)}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm font-medium text-gray-800"
/>
</div>
)
}
return (
<div className="w-full flex flex-row items-stretch mb-4">
<div className="w-1/2">
{showLabels ? <P className="text-xs mb-1">Select Date</P> : null}
<input
type="date"
value={toDateStr(value)}
onChange={(e) => handleDateChange(e.target.value)}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm font-medium text-gray-800"
/>
</div>
<div className="w-2" />
<div className="w-1/2">
{showLabels ? <P className="text-xs mb-1">Select Time</P> : null}
<input
type="time"
value={toTimeStr(value)}
onChange={(e) => handleTimeChange(e.target.value)}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm font-medium text-gray-800"
/>
</div>
</div>
)
}

View file

@ -0,0 +1,151 @@
import { useMemo, useState } from 'react'
import { BottomDialog, Checkbox, SearchBar, p as P } from 'web-components'
import { Check, ChevronDown } from 'lucide-react'
export interface MultiSelectOption {
label: string
value: string | number
disabled?: boolean
}
interface MultiSelectProps {
label?: string
topLabel?: string
options: MultiSelectOption[]
value: (string | number)[]
onValueChange: (v: (string | number)[]) => void
placeholder?: string
disabled?: boolean
onSearch?: (q: string) => void
error?: boolean
className?: string
testID?: string
// Tolerated for screen compatibility (RN BottomDropdown leftovers) — ignored on web
multiple?: boolean
triggerComponent?: any
}
export function MultiSelect({
label,
topLabel,
options,
value,
onValueChange,
placeholder,
disabled = false,
onSearch,
error = false,
className,
testID,
}: MultiSelectProps) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const selectedSet = useMemo(() => new Set(value.map((v) => String(v))), [value])
const displayText = useMemo(() => {
if (value.length === 0) return placeholder ?? label ?? 'Select...'
if (value.length === 1) {
const found = options.find((o) => String(o.value) === String(value[0]))
return found ? found.label : (placeholder ?? label ?? 'Select...')
}
return `${value.length} selected`
}, [value, options, placeholder, label])
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return options
return options.filter((o) => o.label.toLowerCase().includes(q))
}, [options, query])
const toggle = (optionValue: string | number) => {
const key = String(optionValue)
if (selectedSet.has(key)) {
onValueChange(value.filter((v) => String(v) !== key))
} else {
onValueChange([...value, optionValue])
}
}
const heading = topLabel ?? label
return (
<div className={className} data-testid={testID}>
{heading ? (
<P className="mb-1 text-sm text-gray-500 font-medium">{heading}</P>
) : null}
<button
type="button"
disabled={disabled}
onClick={() => setOpen(true)}
className={`flex w-full items-center justify-between rounded-md border bg-white px-3 py-2 text-sm text-left disabled:cursor-not-allowed disabled:opacity-50 ${error ? 'border-red-500' : 'border-gray-300'}`}
>
<span className={`truncate ${value.length === 0 ? 'text-gray-400' : 'text-gray-800'}`}>
{displayText}
</span>
<ChevronDown className="h-4 w-4 shrink-0 text-gray-400" />
</button>
<BottomDialog open={open} onClose={() => setOpen(false)}>
<div className="p-4">
<P className="text-lg font-semibold mb-4">{heading ?? placeholder ?? 'Select'}</P>
<SearchBar
placeholder="Search..."
value={query}
onChange={(q) => {
setQuery(q)
onSearch?.(q)
}}
onSearch={onSearch}
className="mb-3"
/>
<div className="overflow-y-auto" style={{ maxHeight: 320 }}>
{filtered.map((option) => {
const checked = selectedSet.has(String(option.value))
return (
<div
key={String(option.value)}
onClick={() => {
if (!option.disabled) toggle(option.value)
}}
className={`flex flex-row items-center p-3 border-b border-gray-100 ${option.disabled ? 'opacity-50' : 'cursor-pointer hover:bg-gray-50'}`}
>
<span onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={checked}
onPress={() => {
if (!option.disabled) toggle(option.value)
}}
/>
</span>
<span className="ml-3 text-sm text-gray-800 flex-1">{option.label}</span>
{checked ? <Check className="h-4 w-4 text-blue-600" /> : null}
</div>
)
})}
{filtered.length === 0 ? (
<P className="text-center text-gray-500 text-sm p-4">No options found</P>
) : null}
</div>
<div className="flex flex-row gap-2 mt-4">
<button
type="button"
onClick={() => onValueChange([])}
className="flex-1 p-3 rounded-lg bg-gray-100 text-gray-700 text-center font-semibold text-sm"
>
Clear
</button>
<button
type="button"
onClick={() => setOpen(false)}
className="flex-1 p-3 rounded-lg bg-blue-500 text-white text-center font-semibold text-sm"
>
Done
</button>
</div>
</div>
</BottomDialog>
</div>
)
}
export default MultiSelect

View file

@ -0,0 +1,70 @@
import { useState, type FC } from 'react'
import { p as P, MyButton, pInput as PInput } from 'web-components'
import { trpc } from '@/lib/trpc-client'
interface OrderNotesFormProps {
orderId: number
initialNotes?: string
onSuccess?: () => void
onCancel?: () => void
}
export const OrderNotesForm: FC<OrderNotesFormProps> = ({
orderId,
initialNotes = '',
onSuccess,
onCancel,
}) => {
const [notes, setNotes] = useState(initialNotes)
const updateNotesMutation = trpc.admin.order.updateNotes.useMutation()
const handleSubmit = async () => {
if (!notes.trim()) {
window.alert('Error: Please enter some notes')
return
}
try {
await updateNotesMutation.mutateAsync({
orderId,
adminNotes: notes.trim(),
})
window.alert('Success: Notes updated successfully')
onSuccess?.()
} catch (error) {
window.alert('Error: Failed to update notes')
}
}
return (
<div className="p-5">
<P className="text-lg font-bold mb-4">
Add Order Notes
</P>
<PInput
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Enter notes for this order..."
multiline
numberOfLines={4}
className="min-h-24"
/>
<div className="flex flex-row justify-between mt-5 gap-3">
<MyButton
variant="blue"
className="bg-gray-400 hover:bg-gray-500"
textContent="Cancel"
onClick={onCancel}
/>
<MyButton
variant="blue"
textContent={updateNotesMutation.isPending ? 'Saving...' : 'Save Notes'}
onClick={handleSubmit}
disabled={updateNotesMutation.isPending}
/>
</div>
</div>
)
}

View file

@ -0,0 +1,327 @@
import React from 'react'
import { p as P, BottomDialog, Checkbox } from 'web-components'
import { trpc } from '@/lib/trpc-client'
import { ChevronRight, Eye, Pencil, XCircle, MapPin, Map, MessageCircle, Phone } from 'lucide-react'
interface OrderOptionsMenuProps {
open: boolean
onClose: () => void
order: {
id: number
readableId: number
isPackaged: boolean
isDelivered: boolean
isFlashDelivery?: boolean
address: string
addressId: number
adminNotes?: string | null
userNotes?: string | null
latitude?: number | null
longitude?: number | null
status?: string
}
onViewDetails: () => void
onTogglePackaged: () => void
onToggleDelivered: () => void
onOpenAdminNotes: () => void
onCancelOrder: () => void
onAttachLocation: () => void
onWhatsApp: () => void
onDial: () => void
}
function getCurrentPosition(): Promise<GeolocationPosition> {
return new Promise((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error('Geolocation is not supported by this browser'))
return
}
navigator.geolocation.getCurrentPosition(resolve, reject, { enableHighAccuracy: true })
})
}
export function OrderOptionsMenu({
open,
onClose,
order,
onViewDetails,
onTogglePackaged,
onToggleDelivered,
onOpenAdminNotes,
onCancelOrder,
onAttachLocation,
onWhatsApp,
onDial,
}: OrderOptionsMenuProps) {
const updateAddressCoordsMutation = trpc.admin.order.updateAddressCoords.useMutation()
const handleAttachLocation = async () => {
try {
const location = await getCurrentPosition()
const { latitude, longitude } = location.coords
await updateAddressCoordsMutation.mutateAsync({
addressId: order.addressId,
latitude,
longitude,
})
window.alert('Success: Location attached to address successfully.')
onAttachLocation()
} catch (error: any) {
if (error?.code === 1) {
window.alert('Permission Denied: Location permission is required to attach coordinates.')
return
}
window.alert('Error: Failed to attach location. Please try again.')
}
}
const extractPhone = (address: string) => {
const phoneMatch = address.match(/Phone: (\d+)/)
return phoneMatch ? phoneMatch[1] : null
}
const handleWhatsApp = () => {
const phone = extractPhone(order.address)
if (phone) {
window.open(`https://wa.me/91${phone}`, '_blank')
} else {
window.alert('No phone number found')
}
}
const handleDial = () => {
const phone = extractPhone(order.address)
if (phone) {
window.open(`tel:${phone}`, '_blank')
} else {
window.alert('No phone number found')
}
}
const handleOpenInMaps = () => {
if (order.latitude && order.longitude) {
const url = `https://www.google.com/maps/search/?api=1&query=${order.latitude},${order.longitude}`
window.open(url, '_blank')
} else {
window.alert('No location coordinates available')
}
}
const hasCoordinates = order.latitude !== null && order.latitude !== undefined &&
order.longitude !== null && order.longitude !== undefined
return (
<BottomDialog open={open} onClose={onClose}>
<div style={{ maxHeight: '70vh' }} className="overflow-hidden flex flex-col">
<div className="overflow-auto grow">
<div className="pb-8 pt-2 px-4">
<div className="items-center mb-6 flex flex-col">
<div className="w-12 h-1.5 bg-gray-200 rounded-full mb-4" />
<P className="text-lg font-bold text-gray-900">
Order #{order.readableId}
</P>
<P className="text-sm text-gray-500">
Select an action to perform
</P>
</div>
<button
type="button"
onClick={() => {
onViewDetails()
onClose()
}}
className="flex flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm w-full text-left"
>
<div className="w-10 h-10 rounded-full bg-purple-50 items-center justify-center mr-4 flex shrink-0">
<Eye className="h-5 w-5 text-purple-600" />
</div>
<div>
<P className="font-semibold text-gray-800 text-base block">
View Details
</P>
<P className="text-gray-500 text-xs block">
See full order information
</P>
</div>
<ChevronRight className="h-6 w-6 text-gray-400 ml-auto" />
</button>
<div className="flex flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm">
<button type="button" onClick={onTogglePackaged} className="p-1" aria-label="Toggle packaged">
<Checkbox
checked={order.isPackaged}
onPress={onTogglePackaged}
fillColor={order.isPackaged ? '#10B981' : '#1570EF'}
/>
</button>
<button type="button" onClick={onTogglePackaged} className="ml-3 flex-1 text-left">
<P className="font-semibold text-gray-800 text-base block">
Packaged
</P>
<P className="text-gray-500 text-xs block">
{order.isPackaged ? 'Mark as not packaged' : 'Mark as packaged'}
</P>
</button>
</div>
<div className="flex flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm">
<button type="button" onClick={onToggleDelivered} className="p-1" aria-label="Toggle delivered">
<Checkbox
checked={order.isDelivered}
onPress={onToggleDelivered}
fillColor={order.isDelivered ? '#10B981' : '#1570EF'}
/>
</button>
<button type="button" onClick={onToggleDelivered} className="ml-3 flex-1 text-left">
<P className="font-semibold text-gray-800 text-base block">
Delivered
</P>
<P className="text-gray-500 text-xs block">
{order.isDelivered ? 'Mark as not delivered' : 'Mark as delivered'}
</P>
</button>
</div>
<button
type="button"
onClick={() => {
onOpenAdminNotes()
onClose()
}}
className="flex flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm w-full text-left"
>
<div className="w-10 h-10 rounded-full bg-yellow-50 items-center justify-center mr-4 flex shrink-0">
<Pencil className="h-5 w-5 text-yellow-600" />
</div>
<div>
<P className="font-semibold text-gray-800 text-base block">
Admin Notes
</P>
<P className="text-gray-500 text-xs block">
{order.adminNotes ? 'Edit existing notes' : 'Add admin notes'}
</P>
</div>
<ChevronRight className="h-6 w-6 text-gray-400 ml-auto" />
</button>
{order.status !== 'cancelled' && (
<button
type="button"
onClick={() => {
onCancelOrder()
onClose()
}}
className="flex flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm w-full text-left"
>
<div className="w-10 h-10 rounded-full bg-red-50 items-center justify-center mr-4 flex shrink-0">
<XCircle className="h-5 w-5 text-red-600" />
</div>
<div>
<P className="font-semibold text-red-700 text-base block">
Cancel Order
</P>
<P className="text-red-500 text-xs block">
Cancel and provide reason
</P>
</div>
<ChevronRight className="h-6 w-6 text-gray-400 ml-auto" />
</button>
)}
<button
type="button"
onClick={() => {
handleAttachLocation()
onClose()
}}
disabled={updateAddressCoordsMutation.isPending}
className="flex flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm w-full text-left disabled:opacity-50"
>
<div className="w-10 h-10 rounded-full bg-orange-50 items-center justify-center mr-4 flex shrink-0">
<MapPin className="h-5 w-5 text-orange-600" />
</div>
<div>
<P className="font-semibold text-gray-800 text-base block">
Attach Location
</P>
<P className="text-gray-500 text-xs block">
Save GPS coordinates to address
</P>
</div>
<ChevronRight className="h-6 w-6 text-gray-400 ml-auto" />
</button>
{hasCoordinates && (
<button
type="button"
onClick={() => {
handleOpenInMaps()
onClose()
}}
className="flex flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm w-full text-left"
>
<div className="w-10 h-10 rounded-full bg-blue-50 items-center justify-center mr-4 flex shrink-0">
<Map className="h-5 w-5 text-blue-600" />
</div>
<div>
<P className="font-semibold text-gray-800 text-base block">
Open in Maps
</P>
<P className="text-gray-500 text-xs block">
View delivery location on Google Maps
</P>
</div>
<ChevronRight className="h-6 w-6 text-gray-400 ml-auto" />
</button>
)}
<button
type="button"
onClick={() => {
handleWhatsApp()
onClose()
}}
className="flex flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm w-full text-left"
>
<div className="w-10 h-10 rounded-full bg-green-50 items-center justify-center mr-4 flex shrink-0">
<MessageCircle className="h-5 w-5 text-green-600" />
</div>
<div>
<P className="font-semibold text-gray-800 text-base block">
Message On WhatsApp
</P>
<P className="text-gray-500 text-xs block">
Send message via WhatsApp
</P>
</div>
<ChevronRight className="h-6 w-6 text-gray-400 ml-auto" />
</button>
<button
type="button"
onClick={() => {
handleDial()
onClose()
}}
className="flex flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm w-full text-left"
>
<div className="w-10 h-10 rounded-full bg-green-50 items-center justify-center mr-4 flex shrink-0">
<Phone className="h-5 w-5 text-green-600" />
</div>
<div>
<P className="font-semibold text-gray-800 text-base block">
Dial Mobile Number
</P>
<P className="text-gray-500 text-xs block">
Call customer directly
</P>
</div>
<ChevronRight className="h-6 w-6 text-gray-400 ml-auto" />
</button>
</div>
</div>
</div>
</BottomDialog>
)
}

View file

@ -0,0 +1,659 @@
import { useState, useImperativeHandle, forwardRef, useMemo } from 'react'
import { Formik, FieldArray } from 'formik'
import * as Yup from 'yup'
import { pInput as PInput, Dropdown, p as P, Checkbox, BottomDialog, ImageUploaderNeo } from 'web-components'
import type { ImageUploaderNeoItem, ImageUploaderNeoPayload } from 'web-components'
import SearchableSelect from './SearchableSelect'
import { Plus, Trash2, X, RotateCcw, Info } from 'lucide-react'
import { trpc } from '@/lib/trpc-client'
import type { CreateComboItemInput, CreateSkuInput } from '@packages/shared'
import type { SkuFeatureLike } from '@packages/shared'
// Feature input shape — single source: shared SkuFeatureLike
type Attribute = SkuFeatureLike
// Variant form state — anchored to the shared CreateSkuInput (numeric prices,
// same field names). Images are managed separately via variantImages state.
type Variant = Omit<
CreateSkuInput,
'name' | 'price' | 'marketPrice' | 'flashPrice' | 'images' | 'features' | 'comboItems'
> & {
id?: number
name: string
price?: number
marketPrice: number | null
flashPrice: number | null
isFlashAvailable: boolean
isOffer: boolean
isComboOnly: boolean
isSuspended: boolean
features: Attribute[]
comboItems: CreateComboItemInput[]
}
interface ProductFormData {
name: string
shortDescription: string
longDescription: string
storeId: number
productType: 'item' | 'combo'
variants: Variant[]
}
export interface ProductFormRef {
clearImages: () => void
}
interface ProductFormProps {
mode: 'create' | 'edit'
initialValues: ProductFormData
onSubmit: (values: ProductFormData, variantImages: ImageUploaderNeoPayload[][], deletedImageKeys: string[]) => void
isLoading: boolean
existingVariantImages?: ImageUploaderNeoItem[][]
existingVariantImageKeys?: string[][]
}
// Web rebuild of common-ui InfoDialog: info icon button + BottomDialog + p
function InfoDialog({ message, className }: { message?: string; className?: string }) {
const [open, setOpen] = useState(false)
return (
<>
<button type="button" onClick={() => setOpen(true)} className={className} aria-label="More info">
<Info className="h-5 w-5 text-gray-500" />
</button>
<BottomDialog open={open} onClose={() => setOpen(false)}>
<div className="py-4 px-2">
<P className="text-gray-700 text-sm leading-6">{message ?? ''}</P>
</div>
</BottomDialog>
</>
)
}
const defaultAttribute = (): Attribute => ({ featureName: '', featureValue: '' })
// The quantity feature is auto-added as the first attribute of every SKU.
const quantityAttribute = (): Attribute => ({ featureName: 'quantity', featureValue: '' })
const isQuantityFeature = (attr: Attribute): boolean =>
(attr.featureName ?? '').trim().toLowerCase() === 'quantity'
const defaultVariant = (): Variant => ({
id: undefined,
name: '',
price: undefined,
marketPrice: null,
isFlashAvailable: false,
flashPrice: null,
isOffer: false,
isComboOnly: false,
isDeleted: false,
isSuspended: false,
features: [quantityAttribute()],
comboItems: [],
})
const variantSignature = (attributes: Attribute[]): string =>
attributes
.map((a) => `${(a.featureName ?? '').trim().toLowerCase()}::${a.featureValue.trim().toLowerCase()}`)
.sort()
.join('|')
const productValidationSchema = Yup.object().shape({
name: Yup.string().required('Product name is required'),
storeId: Yup.number().required('Store is required').min(1, 'Store is required'),
productType: Yup.string().oneOf(['item', 'combo'], 'Product type is required').required('Product type is required'),
variants: Yup.array()
.min(1, 'At least one variant is required')
.of(
Yup.object().shape({
price: Yup.number()
.typeError('Price must be a number')
.positive('Price must be a positive number')
.required('Price is required'),
marketPrice: Yup.number()
.typeError('Market price must be a number')
.min(0, 'Market price cannot be negative')
.nullable()
.transform((value, originalValue) => (originalValue === '' ? null : value))
.optional(),
flashPrice: Yup.number()
.typeError('Flash price must be a number')
.min(0, 'Flash price cannot be negative')
.nullable()
.transform((value, originalValue) => (originalValue === '' ? null : value))
.optional(),
features: Yup.array()
.min(1, 'At least one attribute is required')
.of(
Yup.object().shape({
featureValue: Yup.string().required('Value is required'),
})
),
})
)
.test('unique-variants', 'Two variants have the same attributes', function (variants) {
if (!Array.isArray(variants)) return true
const seen = new Set<string>()
for (const variant of variants) {
const attrs = variant?.features || []
const signature = variantSignature(attrs as Attribute[])
if (seen.has(signature)) {
return this.createError({ message: 'Two variants have the same attributes' })
}
seen.add(signature)
}
return true
})
.test('quantity-feature', 'Each SKU must have exactly one quantity feature', function (variants) {
if (!Array.isArray(variants)) return true
for (const variant of variants) {
const attrs = variant?.features || []
const quantityCount = attrs.filter(
(a) => ((a as Attribute)?.featureName ?? '').trim().toLowerCase() === 'quantity'
).length
if (quantityCount === 0) {
return this.createError({ message: 'Every SKU must have a quantity feature' })
}
if (quantityCount > 1) {
return this.createError({ message: 'Each SKU can have only one quantity feature' })
}
}
return true
}),
})
// Collect every string message from a Formik errors tree (objects, arrays, strings).
// Variant-level errors (e.g. 'Each SKU can have only one quantity feature') can get
// hidden by Formik when index-level errors are also present, so we walk everything.
const collectErrorMessages = (errors: unknown, depth = 0): string[] => {
if (depth > 6) return []
if (typeof errors === 'string') return [errors]
if (Array.isArray(errors)) return errors.flatMap((e) => collectErrorMessages(e, depth + 1))
if (errors && typeof errors === 'object') {
return Object.values(errors).flatMap((e) => collectErrorMessages(e, depth + 1))
}
return []
}
const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
mode,
initialValues,
onSubmit,
isLoading,
existingVariantImages = [],
existingVariantImageKeys = [],
}, ref) => {
const [variantImages, setVariantImages] = useState<ImageUploaderNeoItem[][]>(() =>
initialValues.variants.length > 0
? initialValues.variants.map((_, i) => existingVariantImages[i] || [])
: [[]]
)
useImperativeHandle(ref, () => ({
clearImages: () => setVariantImages(initialValues.variants.map(() => [])),
}), [initialValues.variants])
const { data: storesData } = trpc.common.getStoresSummary.useQuery()
const storeOptions = storesData?.stores.map((store) => ({
label: store.name,
value: store.id,
})) || []
const { data: skusData } = trpc.common.product.getAllSkusSummary.useQuery()
const skuOptions = (skusData?.skus || []).map((sku) => ({
label: sku.label,
value: sku.id,
}))
// Make sure every SKU starts with a 'quantity' feature (auto-added at the
// beginning if the provided initial values don't have one).
const formInitialValues = useMemo(() => {
return {
...initialValues,
variants: (initialValues.variants || []).map((v) => {
const hasQuantity = (v.features || []).some(
(a) => (a.featureName ?? '').trim().toLowerCase() === 'quantity'
)
if (hasQuantity) return v
return { ...v, features: [quantityAttribute(), ...(v.features || [])] }
}),
}
}, [initialValues])
return (
<Formik
initialValues={formInitialValues}
validationSchema={productValidationSchema}
onSubmit={(values) => {
// Normalize feature names before sending to backend: the mandatory
// 'quantity' feature must always go out lowercase (case-insensitive
// matching means 'Quantity'/'QUANTITY' are treated the same), and
// names are trimmed like the backend does.
// TextInputs emit strings — coerce price fields to the numeric types
// the backend zod schema requires (price required; market/flash nullable).
const toNumber = (value: unknown): number | null => {
if (value == null || value === '') return null
const n = Number(value)
return Number.isFinite(n) ? n : null
}
const normalizedValues: ProductFormData = {
...values,
variants: values.variants.map((v) => ({
...v,
price: toNumber(v.price) ?? undefined,
marketPrice: toNumber(v.marketPrice),
flashPrice: toNumber(v.flashPrice),
features: v.features.map((a) => ({
...a,
featureName: isQuantityFeature(a) ? 'quantity' : (a.featureName ?? '').trim() || null,
})),
})),
}
const images = variantImages.map((imgs) =>
imgs.map((img) => ({ url: img.imgUrl, mimeType: img.mimeType }))
)
const deletedKeys: string[] = []
if (mode === 'edit') {
variantImages.forEach((currentImgs, vIndex) => {
const existing = existingVariantImages[vIndex] || []
existing.forEach((existingImg) => {
if (!currentImgs.some((cur) => cur.imgUrl === existingImg.imgUrl)) {
const key = existingVariantImageKeys[vIndex]?.[existingVariantImages[vIndex]?.indexOf(existingImg)]
if (key) deletedKeys.push(key)
}
})
})
}
onSubmit(normalizedValues, images, deletedKeys)
}}
enableReinitialize
>
{({ handleChange, handleSubmit, values, setFieldValue, validateForm }) => {
return (
<div className="flex-1 overflow-auto pb-10">
<PInput
topLabel="Product Name"
placeholder="Enter product name"
value={values.name}
onChange={handleChange('name')}
style={{ marginBottom: 16 }}
/>
<PInput
topLabel="Short Description"
placeholder="Enter short description"
multiline
numberOfLines={2}
value={values.shortDescription}
onChange={handleChange('shortDescription')}
style={{ marginBottom: 16 }}
/>
<PInput
topLabel="Long Description"
placeholder="Enter detailed description"
multiline
numberOfLines={4}
value={values.longDescription}
onChange={handleChange('longDescription')}
style={{ marginBottom: 16 }}
/>
<div className="mb-4">
<P className="mb-1 text-sm text-gray-500 font-medium">Store</P>
<Dropdown
label="Select store"
value={values.storeId}
options={storeOptions}
onValueChange={(value) => setFieldValue('storeId', Number(value))}
className="w-full"
/>
</div>
<div className="mb-4">
<P className="mb-1 text-sm text-gray-500 font-medium">Product Type</P>
<Dropdown
label="Select product type"
value={values.productType}
options={[
{ label: 'Item', value: 'item' },
{ label: 'Combo', value: 'combo' },
]}
onValueChange={(value) => setFieldValue('productType', value as 'item' | 'combo')}
className="w-full"
/>
</div>
<FieldArray name="variants">
{({ push, remove }) => (
<div>
<div className="flex flex-row justify-between items-center mb-3">
<P className="text-lg font-bold text-gray-800">Variants</P>
<button
type="button"
onClick={() => {
push(defaultVariant())
setVariantImages((prev) => [...prev, []])
}}
className="bg-blue-500 px-3 py-1 rounded-lg flex flex-row items-center text-white font-semibold"
>
<Plus className="h-4 w-4" />
<span className="ml-1">Add Variant</span>
</button>
</div>
{values.variants.map((variant, vIndex) => {
const isExistingSku = variant.id != null
const isMarkedDeleted = !!variant.isDeleted
return (
<div key={vIndex} className={`border border-gray-300 rounded-xl p-4 mb-4 ${isMarkedDeleted ? 'border-red-300 bg-red-50 opacity-80' : ''}`}>
<div className="flex flex-row justify-between items-center mb-3">
<div className="flex flex-row items-center">
<P className="font-bold text-gray-700">Variant {vIndex + 1}</P>
{isMarkedDeleted && (
<div className="ml-2 px-2 py-0.5 rounded-full bg-red-100 border border-red-200">
<P className="text-xs font-bold text-red-700 uppercase">Deleted</P>
</div>
)}
</div>
{(values.variants.length > 1 || isExistingSku) && (
<button
type="button"
onClick={() => {
if (isExistingSku) {
// Mark existing SKU as deleted (sent to backend on save).
setFieldValue(`variants.${vIndex}.isDeleted`, !isMarkedDeleted)
} else {
remove(vIndex)
setVariantImages((prev) => prev.filter((_, i) => i !== vIndex))
}
}}
aria-label={isMarkedDeleted ? 'Restore variant' : 'Delete variant'}
>
{isMarkedDeleted ? (
<RotateCcw className="h-5 w-5 text-green-500" />
) : (
<Trash2 className="h-5 w-5 text-red-500" />
)}
</button>
)}
</div>
<PInput
topLabel="SKU Name (optional)"
placeholder="Overrides the auto-generated name"
value={variant.name ?? ''}
onChange={handleChange(`variants.${vIndex}.name`)}
style={{ marginBottom: 12 }}
/>
<FieldArray name={`variants.${vIndex}.features`}>
{({ push: pushAttr, remove: removeAttr }) => (
<div className="mb-3">
<div className="flex flex-row justify-between items-center mb-2">
<P className="font-medium text-gray-600">Attributes</P>
<button
type="button"
onClick={() => pushAttr(defaultAttribute())}
className="bg-gray-200 px-2 py-0.5 rounded flex flex-row items-center text-gray-600 text-xs"
>
<Plus className="h-3.5 w-3.5 text-gray-600" />
<span className="ml-0.5">Add</span>
</button>
</div>
{variant.features.map((attr, aIndex) => {
const quantityCount = variant.features.filter(isQuantityFeature).length
// Quantity features are locked (non-editable), but if there
// are duplicates (e.g. 'quantity' + 'Quantity') the user must
// be able to delete the extras to fix the form.
const canDelete = variant.features.length > 1 && (!isQuantityFeature(attr) || quantityCount > 1)
return (
<div key={aIndex} className="flex flex-row items-center mb-2 gap-2">
<div className="flex-1">
<PInput
placeholder="Name"
value={attr.featureName ?? ''}
onChange={(e) =>
setFieldValue(
`variants.${vIndex}.features.${aIndex}.featureName`,
e.target.value.trim().toLowerCase() === 'quantity' ? 'quantity' : e.target.value
)
}
disabled={isQuantityFeature(attr)}
className={isQuantityFeature(attr) ? 'text-gray-400' : undefined}
/>
</div>
<div className="flex-1">
<PInput
placeholder={isQuantityFeature(attr) ? 'e.g. 0.5 kg' : 'Value'}
value={attr.featureValue}
onChange={handleChange(`variants.${vIndex}.features.${aIndex}.featureValue`)}
/>
</div>
{canDelete && (
<button type="button" onClick={() => removeAttr(aIndex)} aria-label="Remove attribute">
<X className="h-4 w-4 text-red-500" />
</button>
)}
</div>
)
})}
<div className="flex flex-row items-center self-start">
<button
type="button"
onClick={() => pushAttr(defaultAttribute())}
className="bg-gray-200 px-2 py-0.5 rounded flex flex-row items-center text-gray-600 text-xs"
>
<Plus className="h-3.5 w-3.5 text-gray-600" />
<span className="ml-0.5">Add Feature</span>
</button>
<InfoDialog
message="Quantity is mandatory feature. Add other features optionally with name. Features become part of name — ex: a feature with Mini as value becomes <ItemName> Mini"
className="ml-1"
/>
</div>
</div>
)}
</FieldArray>
<div className="flex flex-row gap-2 mb-3">
<div className="flex-1">
<PInput
topLabel="Market Price"
placeholder="MRP"
type="number"
inputMode="numeric"
value={variant.marketPrice?.toString() ?? ''}
onChange={handleChange(`variants.${vIndex}.marketPrice`)}
/>
</div>
<div className="flex-1">
<PInput
topLabel="Our Price"
placeholder="Selling price"
type="number"
inputMode="numeric"
value={variant.price?.toString() ?? ''}
onChange={handleChange(`variants.${vIndex}.price`)}
/>
</div>
</div>
<div className="flex flex-row items-center mb-3">
<Checkbox
checked={variant.isFlashAvailable}
onPress={() => {
setFieldValue(`variants.${vIndex}.isFlashAvailable`, !variant.isFlashAvailable)
if (variant.isFlashAvailable) setFieldValue(`variants.${vIndex}.flashPrice`, null)
}}
/>
<P className="text-gray-700 font-medium ml-3">Flash Available</P>
</div>
<div className="flex flex-row items-center mb-3">
<Checkbox
checked={variant.isOffer}
onPress={() => setFieldValue(`variants.${vIndex}.isOffer`, !variant.isOffer)}
/>
<P className="text-gray-700 font-medium ml-3">Offer SKU</P>
</div>
{!(values.productType === 'combo' && (variant.comboItems?.length || 0) > 0) && (
<div className="flex flex-row items-center mb-3">
<Checkbox
checked={variant.isComboOnly}
onPress={() => setFieldValue(`variants.${vIndex}.isComboOnly`, !variant.isComboOnly)}
/>
<P className="text-gray-700 font-medium ml-3">Combo Only SKU</P>
</div>
)}
{mode === 'edit' && (
<div className="flex flex-row items-center mb-3">
<Checkbox
checked={variant.isSuspended}
onPress={() => setFieldValue(`variants.${vIndex}.isSuspended`, !variant.isSuspended)}
/>
<P className="text-gray-700 font-medium ml-3">Suspend SKU</P>
</div>
)}
{variant.isFlashAvailable && (
<PInput
topLabel="Flash Price"
placeholder="Enter flash price"
type="number"
inputMode="numeric"
value={variant.flashPrice?.toString() ?? ''}
onChange={handleChange(`variants.${vIndex}.flashPrice`)}
style={{ marginBottom: 12 }}
/>
)}
<ImageUploaderNeo
images={variantImages[vIndex] || []}
onImageAdd={(payloads) =>
setVariantImages((prev) => {
const next = [...prev]
next[vIndex] = [...(next[vIndex] || []), ...payloads.map((pl) => ({ imgUrl: pl.url, mimeType: pl.mimeType }))]
return next
})
}
onImageRemove={(payload) =>
setVariantImages((prev) => {
const next = [...prev]
next[vIndex] = (next[vIndex] || []).filter((img) => img.imgUrl !== payload.url)
return next
})
}
allowMultiple={true}
/>
{values.productType === 'combo' && (
<div className="mt-3 pt-3 border-t border-gray-100">
<div className="flex flex-row justify-between items-center mb-2">
<P className="font-medium text-gray-600">Included Items</P>
<button
type="button"
onClick={() => {
const items = variant.comboItems || []
items.push({ skuId: 0 })
setFieldValue(`variants.${vIndex}.comboItems`, items)
}}
className="bg-gray-200 px-2 py-0.5 rounded flex flex-row items-center text-gray-600 text-xs"
>
<Plus className="h-3.5 w-3.5 text-gray-600" />
<span className="ml-0.5">Add</span>
</button>
</div>
{variant.comboItems?.map((ci, cIndex) => (
<div key={cIndex} className="flex flex-row items-center gap-2 mb-2">
<div className="flex-1">
<SearchableSelect
label="SKU"
value={ci.skuId}
options={skuOptions.map((opt: { label: string; value: number }) => ({
...opt,
// Disable SKUs already picked in another row of this combo.
disabled: (variant.comboItems || []).some(
(item, idx) => idx !== cIndex && String(item.skuId) === String(opt.value)
),
}))}
onValueChange={(val) => {
const current = variant.comboItems || []
const isDuplicate = current.some(
(item, idx) => idx !== cIndex && String(item.skuId) === String(val)
)
if (isDuplicate) {
window.alert('Duplicate: This product is already in the combo')
return
}
setFieldValue(`variants.${vIndex}.comboItems.${cIndex}.skuId`, Number(val))
}}
placeholder="Select SKU"
/>
</div>
<button
type="button"
onClick={() => {
const items = variant.comboItems.filter((_, i) => i !== cIndex)
setFieldValue(`variants.${vIndex}.comboItems`, items)
}}
aria-label="Remove combo item"
>
<X className="h-4 w-4 text-red-500" />
</button>
</div>
))}
</div>
)}
</div>
)
})}
<button
type="button"
onClick={() => {
push(defaultVariant())
setVariantImages((prev) => [...prev, []])
}}
className="bg-blue-500 px-3 py-2 rounded-lg flex flex-row items-center justify-center mb-4 text-white font-semibold w-full"
>
<Plus className="h-4 w-4" />
<span className="ml-1">Add Variant</span>
</button>
</div>
)}
</FieldArray>
<button
type="button"
onClick={async () => {
const validationErrors = await validateForm()
if (Object.keys(validationErrors).length > 0) {
const variantsMessages = collectErrorMessages(validationErrors.variants)
const allMessages = collectErrorMessages(validationErrors)
const message = variantsMessages[0] || allMessages[0] || 'Please fix the highlighted fields'
window.alert(`Check your form: ${String(message)}`)
return
}
handleSubmit()
}}
disabled={isLoading}
className={`px-4 py-3 rounded-lg shadow-lg items-center mt-4 text-white text-lg font-bold w-full disabled:opacity-70 ${isLoading ? 'bg-gray-400' : 'bg-blue-500'}`}
>
{(() => {
if (mode === 'edit') {
return isLoading ? 'Saving...' : 'Save Changes'
}
return isLoading ? 'Creating...' : 'Create Product'
})()}
</button>
</div>
)
}}
</Formik>
)
})
ProductForm.displayName = 'ProductForm'
export default ProductForm

View file

@ -0,0 +1,140 @@
import { type FC } from 'react'
import { useFormik } from 'formik'
import { p as P, pInput as PInput } from 'web-components'
import ProductsSelector from './ProductsSelector'
import { trpc } from '@/lib/trpc-client'
import { X } from 'lucide-react'
import type { AdminProductGroup as SharedAdminProductGroup } from '@packages/shared'
// Serialized product group (string date, loosely typed products) anchored to shared
export type ProductGroup = Omit<SharedAdminProductGroup, 'createdAt' | 'products'> & {
createdAt: string
products: any[]
}
interface ProductGroupFormProps {
group?: ProductGroup | null
onClose: () => void
onSuccess: () => void
}
const ProductGroupForm: FC<ProductGroupFormProps> = ({
group,
onClose,
onSuccess,
}) => {
// Fetch products
const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery()
const createGroup = trpc.admin.product.createGroup.useMutation()
const updateGroup = trpc.admin.product.updateGroup.useMutation()
const isEditing = !!group
const products = productsData?.products || []
const formik = useFormik({
initialValues: {
group_name: group?.groupName || '',
description: group?.description || '',
product_ids: group?.products?.map(p => p.id) || [],
},
validate: (values) => {
const errors: {[key: string]: string} = {}
if (!values.group_name.trim()) {
errors.group_name = 'Group name is required'
}
return errors
},
onSubmit: async (values) => {
try {
if (isEditing) {
await updateGroup.mutateAsync({
id: group.id,
group_name: values.group_name,
description: values.description,
product_ids: values.product_ids,
})
} else {
await createGroup.mutateAsync({
group_name: values.group_name,
description: values.description,
product_ids: values.product_ids,
})
}
onSuccess()
} catch (error: any) {
window.alert(`Error: ${error.message || 'Failed to save group'}`)
}
},
})
return (
<div className="flex-1 bg-white">
{/* Header */}
<div className="flex flex-row items-center justify-between p-6 border-b border-gray-200">
<P className="text-xl font-bold text-gray-900">
{isEditing ? 'Edit Product Group' : 'Create Product Group'}
</P>
<button type="button" onClick={onClose} className="p-2" aria-label="Close">
<X className="h-6 w-6 text-gray-500" />
</button>
</div>
<div className="flex-1 overflow-auto px-6 py-4">
{/* Group Name */}
<PInput
topLabel="Group Name *"
placeholder="Enter group name"
value={formik.values.group_name}
onChange={(e) => formik.setFieldValue('group_name', e.target.value)}
style={{ marginBottom: 16 }}
/>
{formik.errors.group_name && (
<P className="text-red-500 text-sm mt-1 mb-4">{formik.errors.group_name}</P>
)}
{/* Description */}
<PInput
topLabel="Description"
placeholder="Enter description (optional)"
multiline
numberOfLines={3}
value={formik.values.description}
onChange={(e) => formik.setFieldValue('description', e.target.value)}
style={{ marginBottom: 16 }}
/>
{/* Products Selection */}
<ProductsSelector
value={formik.values.product_ids}
onChange={(value) => formik.setFieldValue('product_ids', value as number[])}
multiple={true}
label="Products"
placeholder="Select products"
labelFormat={(product) => product.label}
/>
{/* Actions */}
<div className="flex flex-row gap-4">
<div
onClick={onClose}
className="flex-1 bg-gray-200 rounded-lg py-4 items-center text-center text-gray-700 font-medium cursor-pointer"
>
Cancel
</div>
<div
onClick={() => formik.handleSubmit()}
className="flex-1 bg-brand-600 rounded-lg py-4 items-center text-center text-white font-medium cursor-pointer"
>
{createGroup.isPending || updateGroup.isPending ? 'Saving...' : 'Save'}
</div>
</div>
</div>
</div>
)
}
export default ProductGroupForm

View file

@ -0,0 +1,39 @@
import type { FC } from 'react'
import { BottomDialog, p as P } from 'web-components'
import type { IdName } from '@packages/shared'
type VendorSnippetProduct = IdName
interface ProductListDialogProps {
open: boolean
onClose: () => void
products: VendorSnippetProduct[]
}
export const ProductListDialog: FC<ProductListDialogProps> = ({
open,
onClose,
products,
}) => {
return (
<BottomDialog open={open} onClose={onClose}>
<div className="pt-4 pb-8">
<P className="text-lg font-bold mb-4 text-slate-900 block">
Products ({products.length})
</P>
<div className="overflow-auto" style={{ maxHeight: 400 }}>
{products.map((product, index) => (
<div
key={product.id}
className={`py-3 border-b border-slate-100 ${index === products.length - 1 ? 'border-b-0' : ''}`}
>
<P className="text-sm text-slate-800 font-medium">
{product.name}
</P>
</div>
))}
</div>
</div>
</BottomDialog>
)
}

View file

@ -0,0 +1,177 @@
import React, { useState, useMemo } from 'react'
import { trpc } from '@/lib/trpc-client'
import type { SkuSummary as SharedSkuSummary } from '@packages/shared'
import MultiSelect from './MultiSelect'
import SearchableSelect from './SearchableSelect'
// Selector works with skus that may not have images resolved yet
type SkuSummary = Omit<SharedSkuSummary, 'images'>
interface Group {
id: number
groupName: string
products: { id: number }[]
}
interface ProductsSelectorProps {
value: number | number[]
onChange: (value: number | number[]) => void
multiple?: boolean
label?: string
placeholder?: string
disabled?: boolean
error?: boolean
isDisabled?: (product: SkuSummary) => boolean
labelFormat?: (product: SkuSummary) => string
groups?: Group[]
selectedGroupIds?: number[]
onGroupChange?: (groupIds: number[]) => void
showGroups?: boolean
}
export default function ProductsSelector({
value,
onChange,
multiple = true,
label = 'Select Products',
placeholder = 'Select products',
disabled = false,
error = false,
isDisabled,
labelFormat,
groups = [],
showGroups = true,
selectedGroupIds = [],
onGroupChange,
}: ProductsSelectorProps) {
const { data: skusData } = trpc.common.product.getAllSkusSummary.useQuery()
const allSkus: SkuSummary[] = skusData?.skus || []
const [searchQuery, setSearchQuery] = useState('')
// Handle group selection changes
const handleGroupChange = (newGroupIds: number[]) => {
if (!onGroupChange) return
const previousGroupIds = selectedGroupIds
const addedGroups = newGroupIds.filter(id => !previousGroupIds.includes(id))
const removedGroups = previousGroupIds.filter(id => !newGroupIds.includes(id))
let currentSkus = Array.isArray(value) ? [...value] : value ? [value] : []
const addedSkus = addedGroups.flatMap(groupId => {
const group = groups.find(g => g.id === groupId)
if (!group) return []
const productIds = new Set(group.products.map(p => p.id))
return allSkus.filter(s => productIds.has(s.productId)).map(s => s.id)
})
const removedSkus = removedGroups.flatMap(groupId => {
const group = groups.find(g => g.id === groupId)
if (!group) return []
const productIds = new Set(group.products.map(p => p.id))
return allSkus.filter(s => productIds.has(s.productId)).map(s => s.id)
})
currentSkus = [...new Set([...currentSkus, ...addedSkus])]
currentSkus = currentSkus.filter(id => !removedSkus.includes(id))
onGroupChange(newGroupIds)
if (multiple) {
onChange(currentSkus.length > 0 ? currentSkus : [])
} else {
onChange(currentSkus.length > 0 ? currentSkus[0] : 0)
}
}
// Filter products based on search query
const filteredSkus = useMemo(() => {
if (!searchQuery.trim()) return allSkus
const query = searchQuery.toLowerCase()
return allSkus.filter(sku =>
sku.label.toLowerCase().includes(query) ||
sku.productName.toLowerCase().includes(query)
)
}, [allSkus, searchQuery])
// Build dropdown options
const productOptions = useMemo(() => {
return filteredSkus.map((sku) => {
const isFromGroup = selectedGroupIds.length > 0 && groups.some(group => {
if (!selectedGroupIds.includes(group.id)) return false
return group.products.some(p => p.id === sku.productId)
})
const isProductDisabled = isDisabled ? isDisabled(sku) : false
const displayLabel = labelFormat
? labelFormat(sku)
: sku.label
return {
label: `${displayLabel}${isFromGroup ? ' (from group)' : ''}`,
value: sku.id.toString(),
disabled: isProductDisabled,
}
})
}, [filteredSkus, selectedGroupIds, groups, isDisabled, labelFormat])
// Build group options if groups are provided
const groupOptions = useMemo(() => {
return groups.map(group => ({
label: group.groupName,
value: group.id.toString(),
}))
}, [groups])
return (
<div className="w-full">
{showGroups && groups.length > 0 && (
<div className="mb-4">
<MultiSelect
testID="product-groups-dropdown"
label="Select Product Groups"
options={groupOptions}
value={selectedGroupIds.map(id => id.toString())}
onValueChange={(selectedValue) => {
const selectedValues = Array.isArray(selectedValue) ? selectedValue : typeof selectedValue === 'string' ? [selectedValue] : []
const newGroupIds = selectedValues.map(v => parseInt(v as string))
handleGroupChange(newGroupIds)
}}
placeholder="Select product groups (optional)"
/>
</div>
)}
{multiple ? (
<MultiSelect
label={label}
options={productOptions}
value={Array.isArray(value) ? value.map(id => id.toString()) : []}
onValueChange={(selectedValue) => {
const selectedValues = Array.isArray(selectedValue) ? selectedValue : typeof selectedValue === 'string' ? [selectedValue] : []
onChange(selectedValues.map(v => Number(v)))
}}
placeholder={placeholder}
disabled={disabled}
error={error}
onSearch={setSearchQuery}
/>
) : (
<SearchableSelect
label={label}
options={productOptions}
value={value ? value.toString() : ''}
onValueChange={(selectedValue) => {
onChange(Number(selectedValue))
}}
placeholder={placeholder}
disabled={disabled}
error={error}
onSearch={setSearchQuery}
/>
)}
</div>
)
}

View file

@ -0,0 +1,114 @@
import { useMemo, useState } from 'react'
import { BottomDialog, SearchBar, p as P } from 'web-components'
import { Check, ChevronDown } from 'lucide-react'
export interface SearchableSelectOption {
label: string
value: string | number
disabled?: boolean
}
interface SearchableSelectProps {
label: string
options: SearchableSelectOption[]
value: string | number
onValueChange: (v: string | number) => void
onSearch?: (q: string) => void
placeholder?: string
disabled?: boolean
error?: boolean
className?: string
testID?: string
// Tolerated for screen compatibility (RN BottomDropdown leftover) — ignored on web
triggerComponent?: any
}
export function SearchableSelect({
label,
options,
value,
onValueChange,
onSearch,
placeholder,
disabled = false,
error = false,
className,
testID,
}: SearchableSelectProps) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const selectedLabel = useMemo(() => {
const found = options.find((o) => String(o.value) === String(value))
return found ? found.label : null
}, [options, value])
const displayText = selectedLabel ?? placeholder ?? label
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return options
return options.filter((o) => o.label.toLowerCase().includes(q))
}, [options, query])
const handleSelect = (optionValue: string | number) => {
onValueChange(optionValue)
setOpen(false)
}
return (
<div className={className} data-testid={testID}>
<P className="mb-1 text-sm text-gray-500 font-medium">{label}</P>
<button
type="button"
disabled={disabled}
onClick={() => setOpen(true)}
className={`flex w-full items-center justify-between rounded-md border bg-white px-3 py-2 text-sm text-left disabled:cursor-not-allowed disabled:opacity-50 ${error ? 'border-red-500' : 'border-gray-300'}`}
>
<span className={`truncate ${selectedLabel ? 'text-gray-800' : 'text-gray-400'}`}>
{displayText}
</span>
<ChevronDown className="h-4 w-4 shrink-0 text-gray-400" />
</button>
<BottomDialog open={open} onClose={() => setOpen(false)}>
<div className="p-4">
<P className="text-lg font-semibold mb-4">{label}</P>
<SearchBar
placeholder="Search..."
value={query}
onChange={(q) => {
setQuery(q)
onSearch?.(q)
}}
onSearch={onSearch}
className="mb-3"
/>
<div className="overflow-y-auto" style={{ maxHeight: 320 }}>
{filtered.map((option) => {
const selected = String(option.value) === String(value)
return (
<button
key={String(option.value)}
type="button"
disabled={option.disabled}
onClick={() => handleSelect(option.value)}
className={`flex flex-row items-center w-full p-3 border-b border-gray-100 text-left ${option.disabled ? 'opacity-50' : 'cursor-pointer hover:bg-gray-50'} ${selected ? 'bg-blue-50' : ''}`}
>
<span className={`text-sm flex-1 ${selected ? 'font-semibold text-blue-700' : 'text-gray-800'}`}>
{option.label}
</span>
{selected ? <Check className="h-4 w-4 text-blue-600" /> : null}
</button>
)
})}
{filtered.length === 0 ? (
<P className="text-center text-gray-500 text-sm p-4">No options found</P>
) : null}
</div>
</div>
</BottomDialog>
</div>
)
}
export default SearchableSelect

View file

@ -0,0 +1,229 @@
import React from 'react'
import type { SlotSnippetInput } from '@packages/shared'
import { Formik, FieldArray } from 'formik'
import DateTimeInput from './DateTimeInput'
import { p as P, pInput as PInput } from 'web-components'
import { trpc } from '@/lib/trpc-client'
import ProductsSelector from './ProductsSelector'
// Snippet form row — single source in @packages/shared (complete payload)
type VendorSnippet = SlotSnippetInput
interface SlotFormProps {
onSlotAdded?: () => void
initialDeliveryTime?: Date | null
initialFreezeTime?: Date | null
initialIsActive?: boolean
slotId?: number
initialProductIds?: number[]
initialGroupIds?: number[]
}
export default function SlotForm({
onSlotAdded,
initialDeliveryTime,
initialFreezeTime,
initialIsActive = true,
slotId,
initialProductIds = [],
initialGroupIds = [],
}: SlotFormProps) {
const { data: slotData } = trpc.admin.slots.getSlotById.useQuery(
{ id: slotId! },
{ enabled: !!slotId }
)
const vendorSnippetsFromSlot = (slotData?.slot?.vendorSnippets || []).map((snippet: any) => ({
name: snippet.name || '',
groupIds: snippet.groupIds || [],
skuIds: snippet.skuIds || [],
validTill: snippet.validTill || null,
})) as VendorSnippet[]
const initialValues = {
deliveryTime: initialDeliveryTime || (slotData?.slot?.deliveryTime ? new Date(slotData.slot.deliveryTime) : null),
freezeTime: initialFreezeTime || (slotData?.slot?.freezeTime ? new Date(slotData.slot.freezeTime) : null),
selectedGroupIds: initialGroupIds.length > 0 ? initialGroupIds : (slotData?.slot?.groupIds || []),
selectedSkuIds: initialProductIds.length > 0 ? initialProductIds : (slotData?.slot?.products?.map((p: any) => p.id) || []),
vendorSnippetList: vendorSnippetsFromSlot,
}
const { mutate: createSlot, isPending: isCreating } = trpc.admin.slots.createSlot.useMutation()
const { mutate: updateSlot, isPending: isUpdating } = trpc.admin.slots.updateSlot.useMutation()
const isEditMode = !!slotId
const isPending = isCreating || isUpdating
const { data: groupsData } = trpc.admin.product.getGroups.useQuery()
const handleFormSubmit = (values: typeof initialValues) => {
if (!values.deliveryTime || !values.freezeTime) {
window.alert('Error: Please fill all fields')
return
}
if (values.freezeTime > values.deliveryTime) {
window.alert('Error: Freeze time must be before or equal to delivery time')
return
}
const slotInputData = {
deliveryTime: values.deliveryTime.toISOString(),
freezeTime: values.freezeTime.toISOString(),
isActive: initialIsActive,
groupIds: values.selectedGroupIds,
skuIds: values.selectedSkuIds,
vendorSnippets: values.vendorSnippetList.map((snippet: VendorSnippet) => ({
name: snippet.name,
skuIds: snippet.skuIds,
validTill: snippet.validTill ?? undefined,
})),
}
if (isEditMode && slotId) {
updateSlot(
{ id: slotId, ...slotInputData },
{
onSuccess: () => {
window.alert('Success: Slot updated successfully!')
onSlotAdded?.()
},
onError: (error: any) => {
console.log({msg: JSON.stringify(error.message)})
window.alert(`Error: ${error.message || 'Failed to update slot'}`)
},
}
)
} else {
createSlot(
slotInputData,
{
onSuccess: () => {
window.alert('Success: Slot created successfully!')
onSlotAdded?.()
},
onError: (error: any) => {
window.alert(`Error: ${error.message || 'Failed to create slot'}`)
},
}
)
}
}
return (
<Formik
initialValues={initialValues}
onSubmit={handleFormSubmit}
>
{({ handleSubmit, values, setFieldValue }) => {
const mappedGroups = (groupsData?.groups || []).map(group => ({
...group,
products: group.products.map(product => ({
...product,
id: product.id,
})),
}))
return (
<div className="mb-4">
<P className="text-xl font-bold mb-6 text-center block">
{isEditMode ? 'Edit Slot' : 'Create New Slot'}
</P>
<div className="mb-4">
<P className="text-lg font-semibold mb-2 block">Delivery Date & Time</P>
<DateTimeInput
value={values.deliveryTime}
setValue={(value) => setFieldValue('deliveryTime', value)}
/>
</div>
<div className="mb-4">
<P className="text-lg font-semibold mb-2 block">Freeze Date & Time</P>
<DateTimeInput
value={values.freezeTime}
setValue={(value) => setFieldValue('freezeTime', value)}
/>
</div>
<div className="mb-4">
<ProductsSelector
value={values.selectedSkuIds}
onChange={(newSkuIds) => setFieldValue('selectedSkuIds', newSkuIds)}
groups={mappedGroups}
selectedGroupIds={values.selectedGroupIds}
onGroupChange={(newGroupIds) => setFieldValue('selectedGroupIds', newGroupIds)}
label="Select Products"
placeholder="Select products for this slot"
/>
</div>
{/* Vendor Snippets */}
<FieldArray name="vendorSnippetList">
{({ push, remove }) => (
<div className="mb-4">
<P className="text-lg font-semibold mb-4 block">Vendor Snippets</P>
{values.vendorSnippetList.map((snippet: VendorSnippet, index: number) => (
<div key={index} className="bg-gray-50 p-4 rounded-lg mb-4">
<div className="mb-4">
<PInput
topLabel="Snippet Name *"
placeholder="Enter snippet name"
value={snippet.name}
onChange={(e) => setFieldValue(`vendorSnippetList.${index}.name`, e.target.value)}
/>
</div>
<div className="mb-4">
<ProductsSelector
value={snippet.skuIds || []}
onChange={(newSkuIds) => setFieldValue(`vendorSnippetList.${index}.skuIds`, newSkuIds)}
groups={mappedGroups.filter(group =>
values.selectedGroupIds.includes(group.id)
).map(group => ({
...group,
products: group.products.filter((prod: any) => values.selectedSkuIds.includes(prod.id))
}))}
selectedGroupIds={snippet.groupIds || []}
onGroupChange={(newGroupIds) => setFieldValue(`vendorSnippetList.${index}.groupIds`, newGroupIds)}
label="Select Products"
placeholder="Select products for snippet"
isDisabled={(sku) => !values.selectedSkuIds.includes(sku.id)}
/>
</div>
<button
type="button"
onClick={() => remove(index)}
className="bg-red-500 px-4 py-2 rounded-lg self-end text-white font-medium"
>
Remove Snippet
</button>
</div>
))}
<button
type="button"
onClick={() => push({ name: '', groupIds: [], skuIds: [], validTill: '' })}
className="bg-blue-500 px-4 py-3 rounded-lg items-center text-white font-medium w-full"
>
Add Vendor Snippet
</button>
</div>
)}
</FieldArray>
<button
type="button"
data-testid="create-slot-button"
aria-label="create-slot-button"
onClick={() => handleSubmit()}
disabled={isPending}
className={`${isPending ? 'bg-brand-100' : 'bg-brand-500'} p-3 rounded-lg items-center mt-6 pb-4 text-white text-base font-bold w-full disabled:opacity-70`}
>
{isPending ? (isEditMode ? 'Updating...' : 'Creating...') : (isEditMode ? 'Update Slot' : 'Create Slot')}
</button>
</div>
)}}
</Formik>
)
}

View file

@ -0,0 +1,156 @@
import { useState, type FC } from 'react'
import { p as P, BottomDialog } from 'web-components'
import { SuccessToast } from '@/services/toaster'
import type { VendorSnippet } from '@/types/vendor-snippets'
import { Eye, Pencil, Link, Trash2, MoreVertical } from 'lucide-react'
import type { CSSProperties } from 'react'
export interface SnippetMenuOption {
title: string
icon: 'eye' | 'edit' | 'link' | 'trash'
onPress: () => void
disabled?: boolean
}
export interface SnippetMenuProps {
snippet: VendorSnippet
onEdit: (snippet: VendorSnippet) => void
onDelete: (id: number) => void
onViewOrders: (snippetCode: string) => void
triggerStyle?: any
iconSize?: number
iconColor?: string
}
const iconMap = {
eye: Eye,
edit: Pencil,
link: Link,
trash: Trash2,
}
export const SnippetMenu: FC<SnippetMenuProps> = ({
snippet,
onEdit,
onDelete,
onViewOrders,
triggerStyle = 'p-2 rounded-full bg-gray-50',
iconSize = 16,
iconColor = '#6B7280',
}) => {
const [isOpen, setIsOpen] = useState(false)
const handleOpenMenu = () => {
setIsOpen(true)
}
const handleCloseMenu = () => {
setIsOpen(false)
}
const handleViewOrders = () => {
setIsOpen(false)
onViewOrders(snippet.snippetCode)
}
const handleEdit = () => {
setIsOpen(false)
onEdit(snippet)
}
const handleDelete = () => {
setIsOpen(false)
const confirmed = window.confirm(
'Delete Snippet? Are you sure you want to delete this vendor snippet?'
)
if (confirmed) {
onDelete(snippet.id)
}
}
const handleCopyUrl = async () => {
setIsOpen(false)
if (!snippet.accessUrl) {
window.alert('Error: No URL available to copy')
return
}
await navigator.clipboard.writeText(snippet.accessUrl)
SuccessToast('URL copied to clipboard')
}
const options: SnippetMenuOption[] = [
{
title: 'View Orders',
icon: 'eye',
onPress: handleViewOrders,
},
{
title: 'Edit',
icon: 'edit',
onPress: handleEdit,
},
{
title: 'Copy URL',
icon: 'link',
onPress: handleCopyUrl,
},
{
title: 'Delete',
icon: 'trash',
onPress: handleDelete,
},
]
const handleOptionPress = (option: SnippetMenuOption) => {
if (option.disabled) return
option.onPress()
}
const triggerClassName = typeof triggerStyle === 'string' ? triggerStyle : 'p-2 rounded-full bg-gray-50'
const triggerStyleObj: CSSProperties | undefined = typeof triggerStyle === 'object' ? triggerStyle : undefined
return (
<>
{/* Menu Trigger */}
<button
type="button"
onClick={handleOpenMenu}
className={triggerClassName}
style={triggerStyleObj}
aria-label="Snippet options"
>
<MoreVertical size={iconSize} color={iconColor} />
</button>
{/* Menu Dialog */}
<BottomDialog open={isOpen} onClose={handleCloseMenu}>
<div className="p-6">
<P className="text-lg font-bold text-gray-800 mb-6 block">
Snippet Options
</P>
{options.map((option, index) => {
const Icon = iconMap[option.icon]
return (
<button
key={`${snippet.id}-${index}`}
type="button"
onClick={() => handleOptionPress(option)}
disabled={option.disabled}
className={`flex flex-row items-center p-4 bg-gray-50 rounded-lg w-full text-left ${index < options.length - 1 ? 'mb-3' : ''} ${option.disabled ? 'opacity-50' : ''}`}
>
<Icon
size={20}
color={option.disabled ? '#9CA3AF' : '#6B7280'}
/>
<P className={`font-medium ml-3 ${option.disabled ? 'text-gray-400' : 'text-gray-800'}`}>
{option.title}
</P>
</button>
)
})}
</div>
</BottomDialog>
</>
)
}

View file

@ -0,0 +1,156 @@
import { Fragment, type FC } from 'react'
import { p as P, AppContainer } from 'web-components'
import { ShoppingCart } from 'lucide-react'
import type {
AdminVendorSnippetOrderProduct,
AdminVendorSnippetOrderSummary as SharedSnippetOrderSummary,
} from '@packages/shared'
// Line item + order summary anchored to the shared vendor-order contract
// (string totalAmount + any[] sequence at this view layer)
type OrderProduct = Pick<
AdminVendorSnippetOrderProduct,
'productId' | 'productName' | 'quantity' | 'price' | 'unit' | 'subtotal'
>
type SnippetOrder = Omit<SharedSnippetOrderSummary, 'totalAmount' | 'slotInfo' | 'products'> & {
totalAmount: string
slotInfo: {
time: string
sequence: any[]
} | null
products: OrderProduct[]
}
interface SnippetOrdersViewProps {
orders: SnippetOrder[]
snippetCode: string
onClose: () => void
}
const OrderCard: FC<{ order: SnippetOrder; index: number }> = ({ order, index }) => {
const orderDate = new Date(order.orderDate)
const slotTime = order.slotInfo ? new Date(order.slotInfo.time) : null
return (
<div className="py-6">
{/* Order Header */}
<div className="flex flex-row justify-between items-start mb-3">
<div className="flex-1">
<P className="font-bold text-gray-800 text-lg block">{order.orderId}</P>
<P className="text-sm text-gray-600 block">{order.customerName}</P>
</div>
<div className="items-end text-right">
<P className="font-bold text-green-600 block">{order.totalAmount}</P>
<P className="text-xs text-gray-500 block">
{orderDate.toLocaleDateString()}
</P>
</div>
</div>
{/* Slot Info */}
{slotTime && (
<div className="mb-3 p-2 bg-blue-50 rounded-lg">
<P className="text-sm text-blue-800 font-medium">
Delivery: {slotTime.toLocaleString()}
</P>
</div>
)}
{/* Products */}
<div className="mb-3">
<P className="text-sm font-semibold text-gray-700 mb-2 block">Products:</P>
{order.products.map((product, index) => {
const isMatched = order.matchedProducts.includes(product.productId)
return (
<div
key={index}
className={`flex flex-row justify-between items-center py-1 ${isMatched ? 'bg-yellow-50 px-2 rounded' : ''}`}
>
<div className="flex-1">
<P className={`text-sm block ${isMatched ? 'font-semibold text-yellow-800' : 'text-gray-700'}`}>
{product.productName}
{isMatched && ' ⭐'}
</P>
<P className="text-xs text-gray-500 block">
{product.quantity} {product.unit} × {product.price}
</P>
</div>
<P className={`text-sm font-medium ${isMatched ? 'text-yellow-800' : 'text-gray-700'}`}>
{product.subtotal.toFixed(2)}
</P>
</div>
)
})}
</div>
{/* Matched Products Summary */}
<div className="p-2 bg-yellow-50 rounded-lg">
<P className="text-xs text-yellow-700">
Matched {order.matchedProducts.length} product(s) from snippet &quot;{order.snippetCode}&quot;
</P>
</div>
</div>
)
}
const SnippetOrdersView: FC<SnippetOrdersViewProps> = ({
orders,
snippetCode,
onClose,
}) => {
const totalOrders = orders.length
const totalRevenue = orders.reduce((sum, order) => sum + parseFloat(order.totalAmount), 0)
return (
<AppContainer>
<div className="flex-1 bg-gray-50">
{/* Header */}
<div className="px-6 py-4 bg-white border-b border-gray-200">
<div className="flex flex-row justify-between items-center">
<div className="flex-1">
<P className="text-xl font-bold text-gray-800 block">Orders for &quot;{snippetCode}&quot;</P>
<P className="text-gray-600 mt-1 block">
{totalOrders} order{totalOrders !== 1 ? 's' : ''} Total: {totalRevenue.toFixed(2)}
</P>
</div>
<button
type="button"
onClick={onClose}
className="p-2 text-gray-500 text-lg"
aria-label="Close"
>
</button>
</div>
</div>
{/* Orders List */}
<div className="flex-1 overflow-auto px-6">
{orders.length === 0 ? (
<div className="flex-1 flex flex-col justify-center items-center py-12">
<ShoppingCart className="h-16 w-16 text-gray-300" />
<P className="text-gray-500 text-lg font-semibold mt-4">No matching orders</P>
<P className="text-gray-400 text-center mt-2">
No orders found that match this snippet&apos;s criteria
</P>
</div>
) : (
<div className="py-4">
{orders.map((order, index) => (
<Fragment key={index}>
<OrderCard order={order} index={index} />
{index < orders.length - 1 && (
<div className="h-px bg-slate-200 w-full" />
)}
</Fragment>
))}
</div>
)}
</div>
</div>
</AppContainer>
)
}
export default SnippetOrdersView

View file

@ -0,0 +1,197 @@
import { useState, useEffect, useMemo, useRef, type ChangeEvent } from 'react'
import { Formik } from 'formik'
import * as Yup from 'yup'
import { pInput as PInput, Dropdown, p as P, ImageUploader } from 'web-components'
import ProductsSelector from './ProductsSelector'
import { trpc } from '@/lib/trpc-client'
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStorage'
import type { CreateStoreInput } from '@packages/shared'
// Store form values — required description + product selection on top of CreateStoreInput
export type StoreFormData = Omit<CreateStoreInput, 'description'> & {
description: string
products: number[]
}
interface StoreFormProps {
mode: 'create' | 'edit'
initialValues: StoreFormData
onSubmit: (values: StoreFormData) => void
isLoading: boolean
storeId?: number
}
const validationSchema = Yup.object().shape({
name: Yup.string().required('Name is required'),
description: Yup.string(),
imageUrl: Yup.string(),
owner: Yup.number().required('Owner is required'),
products: Yup.array().of(Yup.number()),
})
function StoreForm({ mode, initialValues, onSubmit, isLoading, storeId }: StoreFormProps) {
const { data: staffData } = trpc.admin.staffUser.getStaff.useQuery()
const { data: productsData } = trpc.admin.product.getProducts.useQuery()
const [formInitialValues, setFormInitialValues] = useState<StoreFormData>(initialValues)
const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([])
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
// For edit mode, pre-select SKUs belonging to this store's products
const initialSelectedProducts = useMemo(() => {
if (mode !== 'edit' || !productsData?.products) return []
return productsData.products
.filter(p => p.storeId === storeId)
.flatMap(p => (p.skus || []).map(sku => sku.id))
}, [mode, productsData?.products, storeId])
useEffect(() => {
setFormInitialValues({
...initialValues,
products: initialSelectedProducts,
})
}, [initialValues, initialSelectedProducts])
const existingImageUrls = useMemo(
() => (formInitialValues.imageUrl ? [formInitialValues.imageUrl] : []),
[formInitialValues.imageUrl]
)
const staffOptions = staffData?.staff.map((staff: { id: number; name: string }) => ({
label: staff.name,
value: staff.id,
})) || []
const { uploadSingle, isUploading } = useUploadToObjectStorage()
const handleImagePick = () => {
fileInputRef.current?.click()
}
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || [])
if (files.length === 0) {
setSelectedImages([])
setDisplayImages([])
return
}
const file = files[0]
setSelectedImages([{ blob: file, mimeType: file.type || 'image/jpeg' }])
setDisplayImages([{ uri: URL.createObjectURL(file) }])
e.target.value = ''
}
const handleRemoveImage = (uri: string) => {
const index = displayImages.findIndex(img => img.uri === uri)
if (index !== -1) {
const newDisplay = displayImages.filter((_, i) => i !== index)
const newFiles = selectedImages.filter((_, i) => i !== index)
setDisplayImages(newDisplay)
setSelectedImages(newFiles)
}
}
return (
<Formik
initialValues={formInitialValues}
validationSchema={validationSchema}
onSubmit={onSubmit}
enableReinitialize
>
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched }) => {
const submit = async () => {
try {
let imageUrl: string | undefined
if (selectedImages.length > 0) {
const { blob, mimeType } = selectedImages[0]
const { presignedUrl } = await uploadSingle(blob, mimeType, 'store')
imageUrl = presignedUrl
}
onSubmit({ ...values, imageUrl })
} catch (error) {
console.error('Upload error:', error)
window.alert('Error: Failed to upload image')
}
}
return (
<div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
className="hidden"
/>
<PInput
topLabel="Store Name"
placeholder="Enter store name"
value={values.name}
onChange={handleChange('name')}
error={!!(touched.name && errors.name)}
style={{ marginBottom: 16 }}
/>
<PInput
topLabel="Description"
placeholder="Enter store description"
multiline
numberOfLines={3}
value={values.description}
onChange={handleChange('description')}
error={!!(touched.description && errors.description)}
style={{ marginBottom: 16 }}
/>
<div className="mb-4">
<P className="mb-1 text-sm text-gray-500 font-medium">Owner</P>
<Dropdown
label="Select owner"
value={values.owner}
options={staffOptions}
onValueChange={(value) => setFieldValue('owner', Number(value))}
className="mb-4"
/>
</div>
<ProductsSelector
value={values.products || []}
onChange={(value) => setFieldValue('products', value)}
multiple={true}
label="Products"
placeholder="Select products"
isDisabled={(sku) => sku.storeId !== null && sku.storeId !== storeId}
labelFormat={(sku) => `${sku.productName} - ₹${sku.price}`}
/>
<div className="mb-6">
<P className="text-sm font-bold text-gray-700 mb-3 uppercase tracking-wider block">Store Image</P>
<ImageUploader
images={displayImages}
existingImageUrls={existingImageUrls}
onAddImage={handleImagePick}
onRemoveImage={handleRemoveImage}
onRemoveExistingImage={() =>
setFormInitialValues((prev) => ({
...prev,
imageUrl: undefined,
}))
}
allowMultiple={false}
/>
</div>
<button
type="button"
onClick={submit}
disabled={isLoading || isUploading}
className={`px-4 py-2 rounded-lg shadow-lg items-center mt-2 text-white text-lg font-bold w-full disabled:opacity-70 ${isLoading || isUploading ? 'bg-gray-400' : 'bg-blue-500'}`}
>
{isUploading ? 'Uploading...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Store' : 'Update Store')}
</button>
</div>
)
}}
</Formik>
)
}
export default StoreForm

View file

@ -0,0 +1,345 @@
import { useState, useEffect, forwardRef, useCallback, type CSSProperties } from 'react'
import { Formik } from 'formik'
import * as Yup from 'yup'
import { pInput as PInput, p as P, Checkbox, ImageUploaderNeo } from 'web-components'
import type { ImageUploaderNeoItem, ImageUploaderNeoPayload } from 'web-components'
import MultiSelect from './MultiSelect'
import { GripVertical, X, Package, Image as ImageIcon } from 'lucide-react'
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
import type { DragEndEvent } from '@dnd-kit/core'
import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import ProductsSelector from './ProductsSelector'
import { trpc } from '@/lib/trpc-client'
import useFocusCallback from '@/lib/refresh-context'
import type { IdName } from '@packages/shared'
type StoreOption = IdName
export interface TagFormData {
tagName: string
tagDescription: string
isDashboardTag: boolean
relatedStores: number[]
productIds: number[]
}
interface TagFormProps {
mode: 'create' | 'edit'
initialValues: TagFormData
existingImageUrl?: string
onSubmit: (values: TagFormData, images: ImageUploaderNeoItem[], removedExisting: boolean) => void
isLoading: boolean
stores?: StoreOption[]
}
interface SelectedProduct {
skuId: number
productId: number
label: string
imageUrl?: string | null
}
const itemStyle: CSSProperties = {
backgroundColor: 'white',
borderRadius: 8,
borderWidth: 1,
borderColor: '#e5e7eb',
borderStyle: 'solid',
padding: 10,
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.1)',
marginTop: 4,
marginBottom: 4,
}
function SortableProductRow({ item, onRemove }: { item: SelectedProduct; onRemove: (productId: number) => void }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item.productId })
const style: CSSProperties = {
...itemStyle,
transform: CSS.Transform.toString(transform),
transition,
...(isDragging
? { borderColor: '#3b82f6', boxShadow: '0 4px 8px rgba(59, 130, 246, 0.3)' }
: {}),
}
return (
<div ref={setNodeRef} style={style}>
<button
type="button"
{...attributes}
{...listeners}
aria-label="Drag to reorder"
className="mr-2 p-0.5 cursor-grab active:cursor-grabbing touch-none"
>
<GripVertical size={24} color={isDragging ? '#3b82f6' : '#9ca3af'} />
</button>
{item.imageUrl ? (
<img src={item.imageUrl} alt={item.label} style={{ width: 30, height: 30, borderRadius: 6, marginRight: 10, objectFit: 'cover' }} />
) : (
<div style={{ width: 30, height: 30, borderRadius: 6, backgroundColor: '#f3f4f6', marginRight: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<ImageIcon size={24} color="#9ca3af" />
</div>
)}
<P className="flex-1 text-sm text-gray-900 font-medium mr-1 truncate">
{item.label}
</P>
<button type="button" onClick={() => onRemove(item.productId)} className="p-0.5" aria-label={`Remove ${item.label}`}>
<X size={18} color="#9ca3af" />
</button>
</div>
)
}
const TagForm = forwardRef<any, TagFormProps>(({
mode,
initialValues,
existingImageUrl: existingImageUrlRaw,
onSubmit,
isLoading,
stores: storesRaw,
}, ref) => {
const [images, setImages] = useState<ImageUploaderNeoItem[]>([])
const [removedExisting, setRemovedExisting] = useState(false)
const [isDashboardTagChecked, setIsDashboardTagChecked] = useState<boolean>(Boolean(initialValues.isDashboardTag))
const [selectedProducts, setSelectedProducts] = useState<SelectedProduct[]>([])
const sensors = useSensors(useSensor(PointerSensor))
const existingImageUrl = existingImageUrlRaw || ''
const stores = storesRaw || []
const { data: skusData } = trpc.common.product.getAllSkusSummary.useQuery()
const allSkus: SelectedProduct[] = (skusData?.skus || []).map((sku: any) => ({
skuId: sku.id,
productId: sku.productId,
label: sku.label,
imageUrl: sku.images?.[0] || null,
}))
// Build the ordered list from initialValues.productIds (product ids, in sortOrder).
useEffect(() => {
const ordered: SelectedProduct[] = []
const skuMap = new Map<number, SelectedProduct>()
for (const sku of allSkus) {
skuMap.set(sku.productId, sku)
}
for (const productId of initialValues.productIds || []) {
const sku = skuMap.get(productId)
if (sku) ordered.push(sku)
else ordered.push({ skuId: 0, productId, label: `Product #${productId}`, imageUrl: null })
}
setSelectedProducts(ordered)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialValues.productIds, skusData])
// Update checkbox when initial values change
useEffect(() => {
setIsDashboardTagChecked(Boolean(initialValues.isDashboardTag))
if (existingImageUrl) {
setImages([{ imgUrl: existingImageUrl, mimeType: null }])
} else {
setImages([])
}
setRemovedExisting(false)
}, [existingImageUrlRaw, initialValues.isDashboardTag])
const validationSchema = Yup.object().shape({
tagName: Yup.string()
.required('Tag name is required')
.min(1, 'Tag name must be at least 1 character')
.max(100, 'Tag name must be less than 100 characters'),
tagDescription: Yup.string()
.max(500, 'Description must be less than 500 characters'),
})
// When a product is picked in the selector, add it to the ordered list (if not already present).
const handleProductSelect = (value: number | number[]) => {
const skuIds = Array.isArray(value) ? value : [value]
setSelectedProducts((prev) => {
const next = [...prev]
for (const skuId of skuIds) {
if (next.some((prod) => prod.skuId === skuId)) continue
const sku = allSkus.find((s) => s.skuId === skuId)
if (sku) next.push(sku)
}
return next
})
}
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
setSelectedProducts((prev) => {
const oldIndex = prev.findIndex((prod) => prod.productId === active.id)
const newIndex = prev.findIndex((prod) => prod.productId === over.id)
if (oldIndex === -1 || newIndex === -1) return prev
return arrayMove(prev, oldIndex, newIndex)
})
}, [])
const handleRemoveProduct = useCallback((productId: number) => {
setSelectedProducts((prev) => prev.filter((prod) => prod.productId !== productId))
}, [])
return (
<Formik
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={(values) => onSubmit({ ...values, productIds: selectedProducts.map((prod) => prod.productId) }, images, removedExisting)}
enableReinitialize
>
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched, resetForm }) => {
// Clear form when screen comes into focus (create mode only — edit keeps its loaded products)
const clearForm = useCallback(() => {
setImages([])
setRemovedExisting(false)
setIsDashboardTagChecked(false)
if (mode === 'create') {
setSelectedProducts([])
}
resetForm()
}, [resetForm, mode])
useFocusCallback(clearForm)
return (
<div className="p-4">
<PInput
topLabel="Tag Name"
placeholder="Enter tag name"
value={values.tagName}
onChange={handleChange('tagName')}
style={{ marginBottom: 16 }}
/>
{errors.tagName && touched.tagName ? (
<P className="text-red-500 text-sm -mt-2 mb-4 block">{errors.tagName}</P>
) : null}
<PInput
topLabel="Description (Optional)"
placeholder="Enter tag description"
multiline
numberOfLines={3}
value={values.tagDescription}
onChange={handleChange('tagDescription')}
style={{ marginBottom: 16 }}
/>
{errors.tagDescription && touched.tagDescription ? (
<P className="text-red-500 text-sm -mt-2 mb-4 block">{errors.tagDescription}</P>
) : null}
{/* Image Upload Section */}
<div className="mb-6">
<P className="text-lg font-bold mb-2 text-gray-800 block">
Tag Image {mode === 'edit' ? '(Upload new to replace)' : '(Optional)'}
</P>
<ImageUploaderNeo
images={images}
onImageAdd={(payload: ImageUploaderNeoPayload[]) => {
setImages((prev) => [...prev, ...payload.map((img) => ({
imgUrl: img.url,
mimeType: img.mimeType,
}))])
}}
onImageRemove={(payload) => {
if (payload.mimeType === null) {
setRemovedExisting(true)
}
setImages((prev) => prev.filter((item) => item.imgUrl !== payload.url))
}}
allowMultiple={false}
/>
</div>
{/* Dashboard Tag Checkbox */}
<div className="flex flex-row items-center mb-6">
<Checkbox
checked={isDashboardTagChecked}
onPress={() => {
const newValue = !isDashboardTagChecked
setIsDashboardTagChecked(newValue)
setFieldValue('isDashboardTag', newValue)
}}
/>
<P className="ml-3 text-gray-800">Mark as Dashboard Tag</P>
</div>
{/* Related Stores Dropdown */}
<div className="mb-6">
<P className="text-lg font-bold mb-2 text-gray-800 block">
Related Stores
</P>
<MultiSelect
label="Select Related Stores"
placeholder="Select stores..."
value={values.relatedStores.map(id => id.toString())}
options={stores.map(store => ({
label: store.name,
value: store.id.toString(),
}))}
onValueChange={(selectedValues) => {
const numericValues = (selectedValues as string[]).map(v => parseInt(v))
setFieldValue('relatedStores', numericValues)
}}
/>
</div>
{/* Products Section: selector + reorderable list */}
<div className="mb-6">
<P className="text-lg font-bold mb-2 text-gray-800 block">
Products
</P>
<ProductsSelector
value={[]}
onChange={handleProductSelect}
multiple
label="Select Products"
placeholder="Search and select products..."
showGroups={false}
/>
<div className="mt-4">
{selectedProducts.length > 0 && (
<div className="bg-blue-50 px-4 py-2 mb-2 rounded-lg">
<P className="text-blue-700 text-xs text-center block">
Drag to reorder {selectedProducts.length} items
</P>
</div>
)}
{selectedProducts.length > 0 ? (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={selectedProducts.map((prod) => prod.productId)} strategy={verticalListSortingStrategy}>
{selectedProducts.map((prod) => (
<SortableProductRow key={prod.productId} item={prod} onRemove={handleRemoveProduct} />
))}
</SortableContext>
</DndContext>
) : (
<div className="bg-gray-50 p-6 rounded-xl border border-gray-200 border-dashed flex flex-col items-center justify-center">
<Package size={32} color="#9CA3AF" />
<P className="text-gray-500 mt-2 text-sm">No products selected yet</P>
</div>
)}
</div>
</div>
<button
type="button"
onClick={() => handleSubmit()}
disabled={isLoading}
className={`px-4 py-3 rounded-lg shadow-lg items-center text-white text-lg font-bold w-full disabled:opacity-70 ${isLoading ? 'bg-gray-400' : 'bg-blue-500'}`}
>
{isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Tag' : 'Update Tag')}
</button>
</div>
)
}}
</Formik>
)
})
TagForm.displayName = 'TagForm'
export default TagForm

View file

@ -0,0 +1,122 @@
import { useState, type FC } from 'react'
import { p as P, BottomDialog } from 'web-components'
import { useNavigate } from '@tanstack/react-router'
import { trpc } from '@/lib/trpc-client'
import { Pencil, Trash2, MoreVertical } from 'lucide-react'
import type { CSSProperties } from 'react'
export interface TagMenuProps {
tagId: number
onDeleteSuccess?: () => void
triggerStyle?: any
iconSize?: number
iconColor?: string
}
export const TagMenu: FC<TagMenuProps> = ({
tagId,
onDeleteSuccess,
triggerStyle = 'p-2 rounded-full bg-gray-50',
iconSize = 16,
iconColor = '#6B7280',
}) => {
const [isOpen, setIsOpen] = useState(false)
const navigate = useNavigate()
const deleteTag = trpc.admin.product.deleteProductTag.useMutation()
const handleOpenMenu = () => {
setIsOpen(true)
}
const handleCloseMenu = () => {
setIsOpen(false)
}
const handleEditTag = () => {
setIsOpen(false)
navigate({ to: '/dashboard/product-tags/edit', search: { tagId: String(tagId) } })
}
const handleDeleteTag = () => {
setIsOpen(false)
const confirmed = window.confirm(
'Delete Tag? Are you sure you want to delete this tag? This action cannot be undone.'
)
if (confirmed) {
performDelete()
}
}
const performDelete = () => {
deleteTag.mutate({ id: tagId }, {
onSuccess: () => {
window.alert('Success: Tag deleted successfully')
onDeleteSuccess?.()
},
onError: (error: any) => {
const errorMessage = error.message || 'Failed to delete tag'
window.alert(`Error: ${errorMessage}`)
},
})
}
const options = [
{
title: 'Edit Tag',
icon: Pencil,
onPress: handleEditTag,
},
{
title: 'Delete Tag',
icon: Trash2,
onPress: handleDeleteTag,
},
]
const triggerClassName = typeof triggerStyle === 'string' ? triggerStyle : 'p-2 rounded-full bg-gray-50'
const triggerStyleObj: CSSProperties | undefined = typeof triggerStyle === 'object' ? triggerStyle : undefined
return (
<>
{/* Menu Trigger */}
<button
type="button"
onClick={handleOpenMenu}
className={triggerClassName}
style={triggerStyleObj}
aria-label="Tag options"
>
<MoreVertical size={iconSize} color={iconColor} />
</button>
{/* Menu Dialog */}
<BottomDialog open={isOpen} onClose={handleCloseMenu}>
<div className="p-6">
<P className="text-lg font-bold text-gray-800 mb-6 block">
Tag Options
</P>
{options.map((option, index) => {
const Icon = option.icon
return (
<button
key={`${tagId}-${index}`}
type="button"
onClick={option.onPress}
className={`flex flex-row items-center p-4 bg-gray-50 rounded-lg w-full text-left ${index < options.length - 1 ? 'mb-3' : ''}`}
>
<Icon
size={20}
color="#6B7280"
/>
<P className="text-gray-800 font-medium ml-3">
{option.title}
</P>
</button>
)
})}
</div>
</BottomDialog>
</>
)
}

View file

@ -0,0 +1,207 @@
import React, { useState } from 'react'
import { p as P, BottomDialog, pInput as PInput } from 'web-components'
import { trpc } from '@/lib/trpc-client'
import { TriangleAlert, Info, Plus, Calendar, User, ShoppingCart, CircleCheck } from 'lucide-react'
import dayjs from 'dayjs'
function UserIncidentDialog({
userId,
orderId,
open,
onClose,
onSuccess
}: {
userId: number
orderId: number | null
open: boolean
onClose: () => void
onSuccess?: () => void
}) {
const [adminComment, setAdminComment] = useState('')
const [negativityScore, setNegativityScore] = useState('')
const addIncidentMutation = trpc.admin.user.addUserIncident.useMutation({
onSuccess: () => {
window.alert('Success: Incident added successfully')
setAdminComment('')
setNegativityScore('')
onClose()
onSuccess?.()
},
onError: (error: any) => {
window.alert(`Error: ${error.message || 'Failed to add incident'}`)
},
})
const handleAddIncident = () => {
const score = negativityScore ? parseInt(negativityScore) : undefined
if (!adminComment.trim() && !negativityScore) {
window.alert('Error: Please enter a comment or negativity score')
return
}
addIncidentMutation.mutate({
userId,
orderId: orderId || undefined,
adminComment: adminComment || undefined,
negativityScore: score,
})
}
return (
<BottomDialog open={open} onClose={onClose}>
<div className="p-6">
<div className="items-center mb-6 flex flex-col">
<div className="w-12 h-12 bg-amber-100 rounded-full items-center justify-center mb-3 flex">
<TriangleAlert className="h-6 w-6 text-amber-600" />
</div>
<P className="text-xl font-bold text-gray-900 text-center">
Add User Incident
</P>
<P className="text-gray-500 text-center mt-2 text-sm leading-5">
Record an incident for this user. This will be visible in their profile.
</P>
</div>
<PInput
topLabel="Admin Comment"
value={adminComment}
onChange={(e) => setAdminComment(e.target.value)}
placeholder="Enter details about the incident..."
multiline
className="h-24"
/>
<PInput
topLabel="Negativity Score (Optional)"
value={negativityScore}
onChange={(e) => setNegativityScore(e.target.value)}
placeholder="0"
type="number"
inputMode="numeric"
className="mt-4"
/>
<div className="bg-amber-50 p-4 rounded-xl border border-amber-100 mb-6 mt-4 flex flex-row items-start">
<Info className="h-5 w-5 text-amber-600 mt-0.5 shrink-0" />
<P className="text-sm text-amber-800 ml-2 flex-1 leading-5">
Higher negativity scores indicate more serious incidents (e.g., repeated cancellations, abusive behavior).
</P>
</div>
<div className="flex flex-row gap-3">
<button
type="button"
onClick={onClose}
className="flex-1 bg-gray-100 py-3.5 rounded-xl items-center text-gray-700 font-bold"
>
Cancel
</button>
<button
type="button"
onClick={handleAddIncident}
disabled={addIncidentMutation.isPending}
className={`flex-1 bg-amber-500 py-3.5 rounded-xl items-center shadow-sm text-white font-bold disabled:opacity-50 ${addIncidentMutation.isPending ? 'opacity-50' : ''}`}
>
{addIncidentMutation.isPending ? 'Adding...' : 'Add Incident'}
</button>
</div>
</div>
</BottomDialog>
)
}
export function UserIncidentsView({ userId, orderId }: { userId: number; orderId: number | null }) {
const [incidentDialogOpen, setIncidentDialogOpen] = useState(false)
const { data: incidentsData, refetch: refetchIncidents } = trpc.admin.user.getUserIncidents.useQuery(
{ userId },
{ enabled: !!userId }
)
return (
<>
<div className="bg-amber-50 p-5 rounded-2xl shadow-sm mb-4 border border-amber-100">
<div className="flex flex-row items-center justify-between mb-4">
<P className="text-base font-bold text-amber-900">
User Incidents
</P>
<button
type="button"
onClick={() => setIncidentDialogOpen(true)}
className="flex flex-row items-center bg-amber-200 px-3 py-1.5 rounded-lg"
>
<Plus className="h-4 w-4 text-amber-700" />
<P className="text-xs font-bold text-amber-800 ml-1">Add Incident</P>
</button>
</div>
{incidentsData?.incidents && incidentsData.incidents.length > 0 ? (
<div className="space-y-3">
{incidentsData.incidents.map((incident: any, index: number) => (
<div
key={incident.id}
className={`bg-white p-4 rounded-xl border border-amber-200 ${index === incidentsData.incidents.length - 1 ? 'mb-0' : 'mb-3'}`}
>
<div className="flex flex-row justify-between items-start mb-2">
<div className="flex flex-row items-center">
<Calendar className="h-3.5 w-3.5 text-gray-500" />
<P className="text-xs text-gray-600 ml-1">
{dayjs(incident.dateAdded).format('MMM DD, YYYY • h:mm A')}
</P>
</div>
{incident.negativityScore && (
<div className="px-2 py-1 bg-red-100 rounded-md">
<P className="text-xs font-bold text-red-700">
Score: {incident.negativityScore}
</P>
</div>
)}
</div>
{incident.adminComment && (
<div className="mt-2">
<P className="text-sm text-gray-900 leading-5 block">
{incident.adminComment}
</P>
</div>
)}
<div className="flex flex-row items-center mt-2 pt-2 border-t border-gray-100">
<User className="h-3 w-3 text-gray-400" />
<P className="text-xs text-gray-500 ml-1">
Added by {incident.addedBy}
</P>
{incident.orderId && (
<>
<ShoppingCart className="h-3 w-3 text-gray-400 ml-3" />
<P className="text-xs text-gray-500 ml-1">
Order #{incident.orderId}
</P>
</>
)}
</div>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center py-6">
<CircleCheck className="h-8 w-8 text-amber-600" />
<P className="text-sm text-amber-700 mt-2">
No incidents recorded for this user
</P>
</div>
)}
</div>
<UserIncidentDialog
orderId={orderId}
userId={userId}
open={incidentDialogOpen}
onClose={() => setIncidentDialogOpen(false)}
onSuccess={refetchIncidents}
/>
</>
)
}

View file

@ -0,0 +1,225 @@
import { useEffect, type FC } from 'react'
import { useFormik } from 'formik'
import { p as P, Dropdown, pInput as PInput, Checkbox } from 'web-components'
import ProductsSelector from './ProductsSelector'
import DateInput from './DateInput'
import { trpc } from '@/lib/trpc-client'
import type { VendorSnippetForm as VendorSnippetFormType } from '@/types/vendor-snippets'
interface VendorSnippetFormProps {
snippet?: VendorSnippetFormType | null
onClose: () => void
onSuccess: () => void
showIsPermanentCheckbox?: boolean
}
const VendorSnippetForm: FC<VendorSnippetFormProps> = ({
snippet,
onClose,
onSuccess,
showIsPermanentCheckbox = false,
}) => {
// Fetch slots
const { data: slotsData } = trpc.user.slots.getSlots.useQuery()
const createSnippet = trpc.admin.vendorSnippets.create.useMutation()
const updateSnippet = trpc.admin.vendorSnippets.update.useMutation()
const isEditing = !!snippet
const formik = useFormik({
initialValues: {
snippetCode: snippet?.snippetCode || '',
slotId: snippet?.slotId?.toString() || '',
isPermanent: snippet?.isPermanent || false,
skuIds: snippet?.skuIds?.map(id => id.toString()) || [],
validTill: snippet?.validTill ? new Date(snippet.validTill) : null,
},
validate: (values) => {
const errors: {[key: string]: string} = {}
if (!values.snippetCode.trim()) {
errors.snippetCode = 'Snippet code is required'
}
// Validate snippet code only contains alphanumeric characters and underscore
const snippetCodeRegex = /^[a-zA-Z0-9_]+$/
if (values.snippetCode && !snippetCodeRegex.test(values.snippetCode)) {
errors.snippetCode = 'Snippet code can only contain letters, numbers, and underscores'
}
// Only require slotId if isPermanent is false
if (!values.isPermanent && !values.slotId) {
errors.slotId = 'Slot selection is required'
}
if (values.skuIds.length === 0) {
errors.skuIds = 'At least one product must be selected'
}
return errors
},
onSubmit: async (values) => {
try {
const submitData = {
snippetCode: values.snippetCode,
slotId: values.isPermanent ? undefined : parseInt(values.slotId || '0'),
isPermanent: values.isPermanent,
skuIds: values.skuIds.map(id => parseInt(id)),
validTill: values.validTill ? values.validTill.toISOString() : undefined,
}
if (isEditing && snippet) {
await updateSnippet.mutateAsync({
id: snippet.id,
updates: submitData,
})
window.alert('Success: Vendor snippet updated successfully')
} else {
await createSnippet.mutateAsync(submitData)
window.alert('Success: Vendor snippet created successfully')
}
onSuccess()
onClose()
} catch (error: any) {
window.alert(`Error: ${error.message || 'Failed to save vendor snippet'}`)
}
},
})
// Generate unique snippet code if creating new (only on mount)
useEffect(() => {
if (!isEditing && !formik.values.snippetCode) {
const timestamp = Date.now()
const random = Math.random().toString(36).substring(2, 8)
formik.setFieldValue('snippetCode', `VS_${timestamp}_${random}`)
}
}, [isEditing]) // Removed formik.values.snippetCode from deps
const slotOptions = slotsData?.slots.map(slot => ({
label: new Date(slot.deliveryTime).toLocaleString(),
value: slot.id.toString(),
})) || []
return (
<div className="flex-1 bg-white">
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex flex-row justify-between items-center">
<P className="text-xl font-bold text-gray-800">
{isEditing ? 'Edit Vendor Snippet' : 'Create Vendor Snippet'}
</P>
<button
type="button"
onClick={onClose}
className="p-2 text-gray-500 text-lg"
aria-label="Close"
>
</button>
</div>
</div>
<div className="flex-1 overflow-auto px-6">
<div className="py-6 space-y-6">
{/* Snippet Code */}
<div>
<PInput
topLabel="Snippet Code"
value={formik.values.snippetCode}
onChange={formik.handleChange('snippetCode')}
placeholder="Enter snippet code"
error={!!formik.errors.snippetCode && !!formik.touched.snippetCode}
/>
{formik.errors.snippetCode && formik.touched.snippetCode && (
<P className="text-red-500 text-sm mt-1">{formik.errors.snippetCode}</P>
)}
</div>
{/* Is Permanent Checkbox */}
<div>
<button
type="button"
onClick={() => formik.setFieldValue('isPermanent', !formik.values.isPermanent)}
className="flex flex-row items-center mb-2"
>
<Checkbox
checked={formik.values.isPermanent}
onPress={() => formik.setFieldValue('isPermanent', !formik.values.isPermanent)}
/>
<P className="text-gray-700 font-medium ml-3">Is Permanent?</P>
</button>
<P className="text-sm text-gray-500 block">
Check if this snippet is permanent and not tied to a specific delivery slot
</P>
</div>
{/* Slot Selection - Only show if not permanent */}
{!formik.values.isPermanent && (
<div>
<P className="text-gray-700 font-medium mb-2 block">Delivery Slot</P>
<Dropdown
label="Select Slot"
value={formik.values.slotId}
options={slotOptions}
onValueChange={(value) => formik.setFieldValue('slotId', String(value))}
className="w-full"
/>
{formik.errors.slotId && formik.touched.slotId && (
<P className="text-red-500 text-sm mt-1">{formik.errors.slotId}</P>
)}
</div>
)}
{/* Product Selection */}
<div>
<ProductsSelector
value={formik.values.skuIds.map(id => parseInt(id))}
onChange={(selectedProductIds) => formik.setFieldValue('skuIds', (selectedProductIds as number[]).map(id => id.toString()))}
multiple={true}
label="Select Products"
placeholder="Select products"
labelFormat={(product) => product.label}
/>
{formik.errors.skuIds && formik.touched.skuIds && (
<P className="text-red-500 text-sm mt-1">{formik.errors.skuIds}</P>
)}
</div>
{/* Valid Till Date */}
<div>
<P className="text-gray-700 font-medium mb-2 block">Valid Till (Optional)</P>
<DateInput
value={formik.values.validTill}
setValue={(date) => formik.setFieldValue('validTill', date)}
placeholder="Select expiry date"
showLabel={false}
/>
<P className="text-sm text-gray-500 mt-1 block">
Leave empty for no expiry
</P>
</div>
</div>
</div>
{/* Submit Button */}
<div className="px-6 py-4 border-t border-gray-200">
<button
type="button"
onClick={() => formik.handleSubmit()}
disabled={formik.isSubmitting}
className={`bg-blue-500 py-3 rounded-lg w-full text-white text-center font-semibold disabled:opacity-50 ${formik.isSubmitting ? 'opacity-50' : ''}`}
>
{formik.isSubmitting
? 'Saving...'
: isEditing ? 'Update Snippet' : 'Create Snippet'
}
</button>
</div>
</div>
)
}
export default VendorSnippetForm

View file

@ -0,0 +1,112 @@
import React, { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import { useNavigate, useLocation } from '@tanstack/react-router'
import queryClient from '../../lib/query-client'
import { EventBus, FORCE_LOGOUT_EVENT } from '../../lib/event-bus'
import { trpc } from '../../lib/trpc-client'
import { saveJWT, getJWT, deleteJWT } from '../../hooks/useJWT'
import type { IdName } from '@packages/shared'
type Staff = IdName
interface StaffAuthContextType {
isLoggedIn: boolean
isLoading: boolean
staff: Staff | null
login: (name: string, password: string) => Promise<void>
logout: () => void
isLoggingIn: boolean
loginError: string | null
}
const StaffAuthContext = createContext<StaffAuthContextType | undefined>(undefined)
export const StaffAuthProvider = ({ children }: { children: ReactNode }) => {
const [isLoggedIn, setIsLoggedIn] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [staff, setStaff] = useState<Staff | null>(null)
const [loginError, setLoginError] = useState<string | null>(null)
const loginMutation = trpc.admin.staffUser.login.useMutation()
const navigate = useNavigate()
const location = useLocation()
useEffect(() => {
const checkAuth = async () => {
const token = await getJWT()
if (token) {
setIsLoggedIn(true)
} else {
setIsLoggedIn(false)
if (!location.pathname.includes('login')) {
navigate({ to: '/login' })
}
}
setIsLoading(false)
}
checkAuth()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const login = async (name: string, password: string) => {
setLoginError(null)
loginMutation.mutate(
{ name, password },
{
onSuccess: async (data) => {
await saveJWT(data.token)
setStaff(data.staff)
setIsLoggedIn(true)
navigate({ to: '/dashboard' })
},
onError: (error: any) => {
setLoginError(error.message || 'Login failed')
},
},
)
}
const logout = async () => {
await deleteJWT()
setIsLoggedIn(false)
setStaff(null)
queryClient.clear()
navigate({ to: '/login' })
}
useEffect(() => {
const subscription = EventBus.addListener(FORCE_LOGOUT_EVENT, () => {
console.log('force logout event received')
logout()
})
return () => {
subscription.remove()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<StaffAuthContext.Provider
value={{
isLoggedIn,
isLoading,
staff,
login,
logout,
isLoggingIn: loginMutation.isPending,
loginError,
}}
>
{children}
</StaffAuthContext.Provider>
)
}
export const useStaffAuth = () => {
const context = useContext(StaffAuthContext)
if (!context) {
throw new Error('useStaffAuth must be used within a StaffAuthProvider')
}
return context
}

View file

@ -0,0 +1,15 @@
import { StorageService } from 'web-components'
export const JWT_KEY = 'jwt_token'
export async function saveJWT(token: string) {
await StorageService.setItem(JWT_KEY, token)
}
export async function getJWT() {
return await StorageService.getItem(JWT_KEY)
}
export async function deleteJWT() {
await StorageService.removeItem(JWT_KEY)
}

View file

@ -0,0 +1,105 @@
import { useState } from 'react'
import { trpc } from '../lib/trpc-client'
import type { ContextString, UploadInput, UploadBatchInput, UploadResult } from '@packages/shared'
// Verbatim port of admin-ui useUploadToObjectStore (trpc import path only).
export function useUploadToObjectStorage() {
const [isUploading, setIsUploading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const [progress, setProgress] = useState<{ completed: number; total: number } | null>(null)
const generateUploadUrls = trpc.common.generateUploadUrls.useMutation()
const upload = async (input: UploadBatchInput): Promise<UploadResult> => {
setIsUploading(true)
setError(null)
setProgress({ completed: 0, total: input.images.length })
try {
const { images, contextString } = input
if (images.length === 0) {
return { keys: [], presignedUrls: [] }
}
const mimeTypes = images.map((img) => img.mimeType)
const { uploadUrls } = await generateUploadUrls.mutateAsync({
contextString,
mimeTypes,
})
if (uploadUrls.length !== images.length) {
throw new Error(`Expected ${images.length} URLs, got ${uploadUrls.length}`)
}
const uploadPromises = images.map(async (image, index) => {
const presignedUrl = uploadUrls[index]
const { blob, mimeType } = image
const response = await fetch(presignedUrl, {
method: 'PUT',
body: blob,
headers: { 'Content-Type': mimeType },
})
if (!response.ok) {
throw new Error(`Upload ${index + 1} failed with status ${response.status}`)
}
setProgress((prev) => (prev ? { ...prev, completed: prev.completed + 1 } : null))
return {
key: extractKeyFromPresignedUrl(presignedUrl),
presignedUrl,
}
})
const results = await Promise.all(uploadPromises)
return {
keys: results.map((r) => r.key),
presignedUrls: results.map((r) => r.presignedUrl),
}
} catch (err) {
const uploadError = err instanceof Error ? err : new Error('Upload failed')
setError(uploadError)
throw uploadError
} finally {
setIsUploading(false)
setProgress(null)
}
}
const uploadSingle = async (
blob: Blob,
mimeType: string,
contextString: ContextString,
): Promise<{ key: string; presignedUrl: string }> => {
const result = await upload({
images: [{ blob, mimeType }],
contextString,
})
return {
key: result.keys[0],
presignedUrl: result.presignedUrls[0],
}
}
return {
upload,
uploadSingle,
isUploading,
error,
progress,
isPending: generateUploadUrls.isPending,
}
}
function extractKeyFromPresignedUrl(url: string): string {
const u = new URL(url)
let rawKey = u.pathname.replace(/^\/+/, '')
rawKey = rawKey.split('/').slice(1).join('/')
return decodeURIComponent(rawKey)
}
export type { UploadInput }

View file

@ -0,0 +1,33 @@
// Web replacement for react-native's DeviceEventEmitter + shared event names.
// API-compatible subset: emit(event), addListener(event, cb) -> { remove() }.
export const REFRESH_EVENT = 'refresh_event'
export const FORCE_LOGOUT_EVENT = 'forceLogout'
type Listener = (...args: any[]) => void
const listeners = new Map<string, Set<Listener>>()
export const EventBus = {
emit(event: string, ...args: any[]) {
listeners.get(event)?.forEach((cb) => {
try {
cb(...args)
} catch (e) {
console.error(`EventBus listener error for ${event}`, e)
}
})
},
addListener(event: string, cb: Listener) {
let set = listeners.get(event)
if (!set) {
set = new Set()
listeners.set(event, set)
}
set.add(cb)
return {
remove() {
listeners.get(event)?.delete(cb)
},
}
},
}

View file

@ -0,0 +1,26 @@
import { create } from 'zustand'
// Verbatim port of common-ui useNavigationTarget (only dep: zustand).
interface NavigationTargetState {
navigationTarget: string | null
setNavigationTarget: (target: string | null) => void
getNavigationTarget: () => string | null
}
const useNavigationTargetStore = create<NavigationTargetState>((set, get) => ({
navigationTarget: null,
setNavigationTarget: (target: string | null) => {
set({ navigationTarget: target })
},
getNavigationTarget: () => {
const target = get().navigationTarget
if (target) {
set({ navigationTarget: null })
}
return target
},
}))
export function useNavigationTarget() {
return useNavigationTargetStore()
}

View file

@ -0,0 +1,19 @@
import { QueryClient } from '@tanstack/react-query'
// Module-level singleton — same options as admin-ui (retry: 3).
let queryClient: QueryClient | undefined
export function getQueryClient() {
if (!queryClient) {
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 3,
},
},
})
}
return queryClient
}
export default getQueryClient()

View file

@ -0,0 +1,62 @@
import React, { createContext, useContext, useEffect, useRef } from 'react'
import { useLocation } from '@tanstack/react-router'
import { EventBus, REFRESH_EVENT } from './event-bus'
import { getQueryClient } from './query-client'
// Web port of packages/ui refresh-context + useManualRefresh +
// useMarkDataFetchers + useFocusCallback. Navigation focus (expo) is
// approximated with route-pathname changes; manual refresh uses the bus.
const RefreshContext = createContext<{ refreshAll: () => void } | null>(null)
export const RefreshProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const refreshAll = () => {
getQueryClient().invalidateQueries()
EventBus.emit(REFRESH_EVENT)
}
return <RefreshContext.Provider value={{ refreshAll }}>{children}</RefreshContext.Provider>
}
export const useRefresh = () => {
const context = useContext(RefreshContext)
if (!context) throw new Error('useRefresh must be used within RefreshProvider')
return context
}
export function useManualRefresh(callback: () => void) {
const ref = useRef(callback)
ref.current = callback
useEffect(() => {
const sub = EventBus.addListener(REFRESH_EVENT, () => ref.current())
return () => sub.remove()
}, [])
}
// Refetch on 2nd+ visit to the current route (mirrors useMarkDataFetchers:
// skip first focus, refetch on subsequent focuses).
export function useMarkDataFetchers(callback: () => void) {
const location = useLocation()
const ref = useRef(callback)
ref.current = callback
const visitsRef = useRef<Record<string, number>>({})
useEffect(() => {
const key = location.pathname + location.searchStr
const count = visitsRef.current[key] ?? 0
visitsRef.current[key] = count + 1
if (count >= 1) {
ref.current()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname, location.searchStr])
}
// Run callback when the window regains focus (web analogue of screen focus).
export default function useFocusCallback(callback: () => void) {
const ref = useRef(callback)
ref.current = callback
useEffect(() => {
const onFocus = () => ref.current()
window.addEventListener('focus', onFocus)
return () => window.removeEventListener('focus', onFocus)
}, [])
}

View file

@ -0,0 +1,60 @@
// Exact copy of theme.colors from packages/ui (theme-colors.ts), which the
// web bundle cannot import (RN code). Keeps admin-web visuals identical.
export const colors = {
brand25: '#F5FAFF',
brand50: '#EFF8FF',
brand100: '#D1E9FF',
brand200: '#B2DDFF',
brand300: '#84CAFF',
brand400: '#53B1FD',
brand500: '#2E90FA',
brand600: '#1570EF',
brand700: '#175CD3',
brand800: '#1849A9',
brand900: '#194185',
blue1: '#2E90FA',
blue2: '#fff0f6',
pink1: '#2E90FA',
pink2: '#fff0f6',
blue3: '#ECF4FF',
red1: '#D84343',
green1: '#4CAF50',
green2: '#C8F4D3',
black1: '#000000',
black2: '#1A1C1E',
white1: '#FFFFFF',
gray1: '#fdfdfd',
gray2: '#f2f2f2',
gray3: '#F5F5F5',
gray4: '#6C7278',
gray5: '#ced4da',
yellow1: '#FFB74D',
yellow2: '#FFE3AD',
purple25: '#FAFAFF',
purple50: '#F4F3FF',
purple100: '#EBE9FE',
purple200: '#D9D6FE',
purple300: '#BDB4FE',
purple400: '#9B8AFB',
purple500: '#7A5AF8',
purple600: '#6938EF',
purple700: '#5925DC',
purple800: '#4A1FB8',
purple900: '#3E1C96',
gray25: '#FDFDFD',
gray50: '#FAFAFA',
gray100: '#F5F5F5',
gray200: '#E9EAEB',
gray300: '#D5D7DA',
gray400: '#A4A7AE',
gray500: '#717680',
gray600: '#535862',
gray700: '#414651',
gray800: '#252B37',
gray900: '#101828',
secondaryPink: '#fff',
}
export const theme = { colors }
export type ThemeColor = keyof typeof colors

View file

@ -0,0 +1,39 @@
import { createTRPCReact } from '@trpc/react-query'
import { createTRPCClient, httpBatchLink } from '@trpc/client'
import type { AppRouter } from '@backend/trpc/router'
import { getJWT } from '../hooks/useJWT'
import { EventBus, FORCE_LOGOUT_EVENT } from './event-bus'
// Same backend as admin-ui (BASE_API_URL from common-ui); overridable via env.
export const BASE_API_URL =
(import.meta.env?.VITE_API_URL as string | undefined) || 'https://devapi.freshyo.in'
export const trpc = createTRPCReact<AppRouter>()
export function getTrpcClient() {
return createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: `${BASE_API_URL}/api/trpc`,
headers: async () => {
const token = await getJWT()
return token ? { Authorization: `Bearer ${token}` } : {}
},
fetch: async (url, options) => {
const response = await fetch(url, options)
if (response.status === 401) {
const data = await response.clone().json().catch(() => ({}))
let code = ''
if (data[0]?.error || data?.error) {
code = data[0]?.error?.message || data?.error?.message
}
if (code === 'UNAUTHORIZED') {
EventBus.emit(FORCE_LOGOUT_EVENT)
}
}
return response
},
}),
],
})
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,19 @@
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
export function getRouter() {
const router = createTanStackRouter({
routeTree,
scrollRestoration: true,
defaultPreload: 'intent',
defaultPreloadStaleTime: 0,
})
return router
}
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof getRouter>
}
}

View file

@ -0,0 +1,64 @@
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { TanStackDevtools } from '@tanstack/react-devtools'
import { QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { Toaster } from 'sonner'
import { trpc, getTrpcClient } from '../lib/trpc-client'
import queryClient from '../lib/query-client'
import { StaffAuthProvider } from '../components/context/staff-auth-context'
import { RefreshProvider } from '../lib/refresh-context'
import appCss from '../styles.css?url'
const trpcClient = getTrpcClient()
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: 'utf-8' },
{
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
{ title: 'Freshyo Admin' },
],
links: [
{ rel: 'stylesheet', href: appCss },
{ rel: 'icon', type: 'image/png', href: '/favicon.png' },
],
}),
shellComponent: RootDocument,
})
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<QueryClientProvider client={queryClient}>
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<StaffAuthProvider>
<RefreshProvider>
<div id="app">{children}</div>
<Toaster position="top-center" richColors closeButton />
<ReactQueryDevtools />
</RefreshProvider>
</StaffAuthProvider>
</trpc.Provider>
</QueryClientProvider>
<TanStackDevtools
config={{ position: 'bottom-right' }}
plugins={[
{
name: 'Tanstack Router',
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
<Scripts />
</body>
</html>
)
}

View file

@ -0,0 +1,152 @@
import React, { useState, useEffect } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { AppContainer, p as MyText } from 'web-components'
import { ArrowLeft } from 'lucide-react'
import BannerForm, { type BannerFormData } from '../components/BannerForm'
import { trpc } from '@/lib/trpc-client'
import { ErrorToast } from '@/services/toaster'
export const Route = createFileRoute('/dashboard/banners/$id/edit')({
component: EditBanner,
})
function EditBanner() {
const { id } = Route.useParams()
const bannerId = id as string
const [isLoading, setIsLoading] = useState(true)
const [isSubmitting, setIsSubmitting] = useState(false)
// Load real banner data from API
const { data: bannerData } = trpc.admin.banner.getBanner.useQuery({
id: parseInt(bannerId)
})
const { refetch: refetchBanners } = trpc.admin.banner.getBanners.useQuery(undefined, {
enabled: false,
})
const [banner, setBanner] = useState<typeof bannerData>(undefined)
useEffect(() => {
const loadBanner = async () => {
try {
setIsLoading(true)
if (bannerData) {
// Handle data format compatibility (productId -> skuIds migration)
const processedBanner = {
...bannerData,
skuIds: Array.isArray(bannerData.skuIds)
? bannerData.skuIds
: (bannerData.skuIds ? [bannerData.skuIds] : [])
}
setBanner(processedBanner)
} else {
ErrorToast('Banner not found')
window.history.back()
}
} catch (err) {
console.error('Failed to load banner:', err)
ErrorToast('Failed to load banner data')
window.history.back()
} finally {
setIsLoading(false)
}
}
loadBanner()
}, [bannerId, bannerData])
const initialValues: BannerFormData = banner ? {
name: banner.name,
imageUrl: banner.imageUrl,
description: banner.description || '',
skuIds: banner.skuIds || [],
redirectUrl: banner.redirectUrl || '',
// serialNum removed - handled automatically by backend
} : {
name: '',
imageUrl: '',
description: '',
skuIds: [],
redirectUrl: '',
// serialNum removed - handled automatically by backend
}
const updateBannerMutation = trpc.admin.banner.updateBanner.useMutation()
const handleSubmit = async (values: BannerFormData, imageUrl?: string) => {
if (!banner) return
setIsSubmitting(true)
try {
await updateBannerMutation.mutateAsync({
id: banner.id,
name: values.name,
imageUrl: imageUrl || banner.imageUrl,
description: values.description || undefined,
skuIds: values.skuIds.length > 0 ? values.skuIds : [],
redirectUrl: values.redirectUrl || undefined,
})
await refetchBanners()
window.alert('Success: Banner updated successfully')
window.history.back()
} catch (err) {
ErrorToast('Failed to update banner. Please try again.')
} finally {
setIsSubmitting(false)
}
}
const handleCancel = () => {
window.history.back()
}
if (isLoading) {
return (
<AppContainer>
<div className="flex flex-1 flex-col items-center justify-center bg-white">
<MyText className="text-gray-600">Loading banner...</MyText>
</div>
</AppContainer>
)
}
if (!banner) {
return (
<AppContainer>
<div className="flex flex-1 flex-col items-center justify-center bg-white">
<MyText className="text-red-600">Banner not found</MyText>
</div>
</AppContainer>
)
}
return (
<AppContainer>
<div className="flex-1 bg-white">
{/* Header */}
<div className="flex flex-row items-center justify-between border-b border-gray-200 px-6 py-4">
<button onClick={handleCancel} className="p-2" aria-label="Go back">
<ArrowLeft size={24} color="#374151" />
</button>
<MyText className="text-xl font-bold text-gray-900">Edit Banner</MyText>
<div className="w-10" />
</div>
<BannerForm
initialValues={initialValues}
onSubmit={handleSubmit}
onCancel={handleCancel}
submitButtonText="Update Banner"
isSubmitting={isSubmitting}
existingImageUrl={banner.imageUrl}
/>
</div>
</AppContainer>
)
}

View file

@ -0,0 +1,83 @@
import React, { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { AppContainer, p as MyText } from 'web-components'
import { ArrowLeft } from 'lucide-react'
import BannerForm, { type BannerFormData } from '../components/BannerForm'
import { trpc } from '@/lib/trpc-client'
import { ErrorToast } from '@/services/toaster'
export const Route = createFileRoute('/dashboard/banners/new')({
component: CreateBanner,
})
function CreateBanner() {
const [isSubmitting, setIsSubmitting] = useState(false)
const initialValues: BannerFormData = {
name: '',
imageUrl: '',
description: '',
skuIds: [],
redirectUrl: '',
// serialNum removed - assigned automatically by backend
}
const createBannerMutation = trpc.admin.banner.createBanner.useMutation()
const { refetch: refetchBanners } = trpc.admin.banner.getBanners.useQuery(undefined, {
enabled: false,
})
const handleSubmit = async (values: BannerFormData, imageUrl?: string) => {
if (!imageUrl) {
ErrorToast('Image is required')
return
}
setIsSubmitting(true)
try {
await createBannerMutation.mutateAsync({
name: values.name,
imageUrl,
description: values.description || undefined,
skuIds: values.skuIds.length > 0 ? values.skuIds : [],
redirectUrl: values.redirectUrl || undefined,
})
await refetchBanners()
window.alert('Success: Banner created successfully')
window.history.back()
} catch (err) {
ErrorToast('Failed to create banner. Please try again.')
} finally {
setIsSubmitting(false)
}
}
const handleCancel = () => {
window.history.back()
}
return (
<AppContainer>
<div className="flex-1 bg-white">
{/* Header */}
<div className="flex flex-row items-center justify-between border-b border-gray-200 px-6 py-4">
<button onClick={handleCancel} className="p-2" aria-label="Go back">
<ArrowLeft size={24} color="#374151" />
</button>
<MyText className="text-xl font-bold text-gray-900">Create Banner</MyText>
<div className="w-10" />
</div>
<BannerForm
initialValues={initialValues}
onSubmit={handleSubmit}
onCancel={handleCancel}
submitButtonText="Create Banner"
isSubmitting={isSubmitting}
/>
</div>
</AppContainer>
)
}

View file

@ -0,0 +1,432 @@
import React, { useState } from 'react'
import { createFileRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router'
import { AppContainer, p as MyText } from 'web-components'
import { trpc } from '@/lib/trpc-client'
import { ErrorToast } from '@/services/toaster'
import { useManualRefresh } from '@/lib/refresh-context'
import { Image as ImageIcon, Plus, Pencil, Trash2, CircleCheck, CircleDot, Hourglass, X } from 'lucide-react'
import type { Banner as SharedBanner } from '@packages/shared'
export const Route = createFileRoute('/dashboard/banners')({
component: DashboardBanners,
})
// Serialized banner row (API JSON carries string dates) anchored to the shared shape
type Banner = Omit<SharedBanner, 'createdAt' | 'lastUpdated'> & {
createdAt: string,
lastUpdated: string
}
function DashboardBanners() {
const location = useLocation()
const navigate = useNavigate()
const [refreshing, setRefreshing] = useState(false)
// Edit mode state
const [editMode, setEditMode] = useState(false)
const [selectedSlot, setSelectedSlot] = useState<number | null>(null)
const [slotAssignments, setSlotAssignments] = useState<{ [slot: number]: number | null }>({})
const [originalAssignments, setOriginalAssignments] = useState<{ [slot: number]: number | null }>({})
const [saving, setSaving] = useState(false)
// Real API calls
const { data: bannersData, isLoading, error, refetch } = trpc.admin.banner.getBanners.useQuery()
const deleteBannerMutation = trpc.admin.banner.deleteBanner.useMutation()
const updateBannerMutation = trpc.admin.banner.updateBanner.useMutation()
const emptySlotMutation = trpc.admin.banner.updateBanner.useMutation()
const banners = bannersData?.banners || []
// Initialize slot assignments when banners load
React.useEffect(() => {
const assignments: { [slot: number]: number | null } = { 1: null, 2: null, 3: null, 4: null }
banners.forEach((banner) => {
if (banner.serialNum && banner.serialNum >= 1 && banner.serialNum <= 4) {
assignments[banner.serialNum] = banner.id
}
})
setSlotAssignments(assignments)
setOriginalAssignments({ ...assignments })
}, [bannersData])
const onRefresh = async () => {
setRefreshing(true)
await refetch()
setRefreshing(false)
}
useManualRefresh(() => {
onRefresh()
})
if (location.pathname !== '/dashboard/banners') {
return <Outlet />
}
// Slot and edit mode handlers
const handleSlotClick = (slotNumber: number) => {
setSelectedSlot(slotNumber)
setEditMode(true)
}
const handleBannerSelect = (bannerId: number) => {
if (!editMode || selectedSlot === null) return
// Remove banner from any existing slot
const newAssignments = { ...slotAssignments }
Object.keys(newAssignments).forEach((slot) => {
if (newAssignments[parseInt(slot)] === bannerId) {
newAssignments[parseInt(slot)] = null
}
})
// Assign banner to selected slot
newAssignments[selectedSlot] = bannerId
setSlotAssignments(newAssignments)
}
const handleSave = async () => {
setSaving(true)
try {
// Get banners that need to be updated
const bannersToUpdate: { id: number, serialNum: number | null }[] = []
// Clear serial numbers for banners no longer in slots
banners.forEach((banner) => {
const currentSlot = banner.serialNum
const assignedSlot = Object.keys(slotAssignments).find((slot) =>
slotAssignments[parseInt(slot)] === banner.id
)
if (currentSlot !== (assignedSlot ? parseInt(assignedSlot) : null)) {
bannersToUpdate.push({
id: banner.id,
serialNum: assignedSlot ? parseInt(assignedSlot) : null
})
}
})
// Update banners that gained slots
Object.keys(slotAssignments).forEach((slot) => {
const slotNum = parseInt(slot)
const bannerId = slotAssignments[slotNum]
if (bannerId) {
const banner = banners.find((b) => b.id === bannerId)
if (banner && banner.serialNum !== slotNum) {
bannersToUpdate.push({
id: bannerId,
serialNum: slotNum
})
}
}
})
// Execute updates
await Promise.all(
bannersToUpdate.map(({ id, serialNum }) =>
updateBannerMutation.mutateAsync({ id, serialNum })
)
)
setOriginalAssignments({ ...slotAssignments })
setEditMode(false)
setSelectedSlot(null)
await refetch()
window.alert('Success: Slot assignments saved successfully')
} catch (err) {
ErrorToast('Failed to save slot assignments')
} finally {
setSaving(false)
}
}
const handleCancel = () => {
setSlotAssignments({ ...originalAssignments })
setEditMode(false)
setSelectedSlot(null)
}
const handleEmptySlot = async () => {
if (!selectedSlot || !slotAssignments[selectedSlot]) return
const bannerId = slotAssignments[selectedSlot]
const banner = banners.find((b) => b.id === bannerId)
if (!banner) return
try {
// Update banner's serialNum to null to empty the slot
await emptySlotMutation.mutateAsync({
id: banner.id,
serialNum: null
})
// Update local state
setSlotAssignments((prev) => ({
...prev,
[selectedSlot]: null
}))
// Update original assignments for cancel functionality
setOriginalAssignments((prev) => ({
...prev,
[selectedSlot]: null
}))
window.alert(`Success: Slot ${selectedSlot} has been emptied`)
} catch (err) {
ErrorToast('Failed to empty slot')
}
}
// Helper function to get banner name by ID
const getBannerNameById = (id: number | null) => {
if (!id) return null
const banner = banners.find((b) => b.id === id)
return banner ? banner.name : null
}
const handleEdit = (banner: Banner) => {
navigate({ to: '/dashboard/banners/$id/edit', params: { id: String(banner.id) } })
}
const handleDelete = (id: number) => {
if (!window.confirm('Delete Banner: Are you sure you want to delete this banner?')) return
;(async () => {
try {
await deleteBannerMutation.mutateAsync({ id })
refetch()
window.alert('Success: Banner deleted')
} catch (err) {
ErrorToast('Failed to delete banner')
}
})()
}
const handleCreate = () => {
navigate({ to: '/dashboard/banners/new' })
}
if (isLoading) {
return (
<AppContainer>
<div className="flex flex-1 flex-col items-center justify-center bg-white">
<MyText className="text-gray-600">Loading banners...</MyText>
</div>
</AppContainer>
)
}
if (error) {
return (
<AppContainer>
<div className="flex flex-1 flex-col items-center justify-center bg-white">
<MyText className="text-red-600">Error loading banners</MyText>
</div>
</AppContainer>
)
}
return (
<AppContainer>
<div className="relative flex-1 bg-gray-50">
<div className="max-h-[calc(100vh-160px)] flex-1 overflow-y-auto pb-24">
{/* Slots Row */}
<div className="mb-6 px-4">
<MyText className="mb-3 block text-sm font-medium text-gray-700">
{editMode ? `Select banner for Slot ${selectedSlot}` : 'Banner Slots'}
</MyText>
<div className="flex flex-row justify-between">
{[1, 2, 3, 4].map((slotNum) => {
const assignedBannerId = slotAssignments[slotNum]
const bannerName = getBannerNameById(assignedBannerId)
const isSelected = editMode && selectedSlot === slotNum
return (
<button
key={slotNum}
onClick={() => handleSlotClick(slotNum)}
className={`mx-1 flex-1 rounded-lg border-2 p-3 ${isSelected
? 'border-blue-500 bg-blue-50'
: assignedBannerId
? 'border-green-300 bg-green-50'
: 'border-gray-300 bg-white'
}`}
>
<span className="items-center">
<MyText className="mb-1 block text-xs text-gray-500">Slot {slotNum}</MyText>
<MyText
className={`block text-center text-xs font-medium ${assignedBannerId ? 'text-gray-900' : 'text-gray-400'
}`}
numberOfLines={2}
>
{bannerName || 'Empty'}
</MyText>
</span>
</button>
)
})}
</div>
{/* Action buttons in edit mode */}
{editMode && (
<div className="mt-4 gap-3">
{/* Save/Cancel buttons */}
<div className="flex flex-row gap-3">
<button
onClick={handleCancel}
disabled={saving}
className={`flex-1 items-center rounded-lg bg-gray-500 px-4 py-3 ${saving ? 'opacity-50' : ''
}`}
>
<MyText className="font-semibold text-white">Cancel</MyText>
</button>
<button
onClick={handleSave}
disabled={saving}
className={`flex-1 items-center rounded-lg bg-blue-600 px-4 py-3 ${saving ? 'opacity-50' : ''
}`}
>
<MyText className="font-semibold text-white">
{saving ? 'Saving...' : 'Save Changes'}
</MyText>
</button>
</div>
{/* Empty Slot button */}
<button
onClick={handleEmptySlot}
disabled={!slotAssignments[selectedSlot || 0] || emptySlotMutation.isPending}
className={`mt-3 w-full items-center rounded-lg bg-red-500 px-4 py-3 ${(!slotAssignments[selectedSlot || 0] || emptySlotMutation.isPending) ? 'opacity-50' : ''
}`}
>
<span className="flex flex-row items-center justify-center">
{emptySlotMutation.isPending ? (
<Hourglass size={16} color="white" />
) : (
<X size={16} color="white" />
)}
<MyText className="ml-2 font-semibold text-white">
{emptySlotMutation.isPending ? 'Emptying...' : 'Empty Slot'}
</MyText>
</span>
</button>
</div>
)}
</div>
{/* Banners List */}
<div className="px-4 pb-8">
{editMode && (
<div className="mb-4 rounded-lg border border-blue-200 bg-blue-50 p-3">
<MyText className="block text-center text-sm text-blue-800">
Tap a banner below to assign it to Slot {selectedSlot}
</MyText>
</div>
)}
{banners.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center py-8">
<span className="mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-slate-50">
<ImageIcon size={48} color="#94A3B8" />
</span>
<MyText className="block text-xl font-black tracking-tight text-slate-900">No Banners Yet</MyText>
<MyText className="mt-2 block px-8 text-center font-medium text-slate-500">
Start by creating your first banner using the button above.
</MyText>
</div>
) : (
banners.map((banner) => {
const isAssignedToSlot = Object.values(slotAssignments).includes(banner.id)
const isAssignedToSelectedSlot = slotAssignments[selectedSlot || 0] === banner.id
const canInteract = editMode
return (
<div
key={banner.id}
onClick={canInteract ? () => handleBannerSelect(banner.id) : undefined}
role={canInteract ? 'button' : undefined}
tabIndex={canInteract ? 0 : undefined}
onKeyDown={canInteract ? (e) => { if (e.key === 'Enter') handleBannerSelect(banner.id) } : undefined}
className={`mb-3 w-full rounded-xl border bg-white p-4 text-left shadow-sm ${canInteract ? 'cursor-pointer' : ''} ${canInteract && isAssignedToSelectedSlot
? 'border-blue-500 bg-blue-50'
: canInteract && isAssignedToSlot
? 'border-green-300 bg-green-50'
: canInteract
? 'border-gray-200'
: 'border-gray-100'
}`}
>
<span className="flex flex-row items-center">
<img
src={banner.imageUrl}
className="mr-3 h-16 w-16 rounded-lg object-cover"
alt={banner.name}
/>
<span className="flex-1">
<span className="mb-1 flex flex-row items-center justify-between">
<MyText className="font-semibold text-gray-900" numberOfLines={1}>
{banner.name}
</MyText>
<span className="flex flex-row items-center">
{canInteract && isAssignedToSelectedSlot && (
<span className="mr-2">
<CircleCheck size={16} color="#3B82F6" />
</span>
)}
{canInteract && isAssignedToSlot && !isAssignedToSelectedSlot && (
<span className="mr-2">
<CircleDot size={16} color="#10B981" />
</span>
)}
<span className={`mr-2 h-2 w-2 rounded-full ${banner.isActive ? 'bg-green-500' : 'bg-gray-400'
}`} />
{!editMode && (
<>
<button onClick={(e) => { e.stopPropagation(), handleEdit(banner as Banner) }} className="mr-1 p-1" aria-label="Edit banner">
<Pencil size={16} color="#64748B" />
</button>
<button onClick={(e) => { e.stopPropagation(), handleDelete(banner.id) }} className="p-1" aria-label="Delete banner">
<Trash2 size={16} color="#EF4444" />
</button>
</>
)}
</span>
</span>
{banner.description && (
<MyText className="mt-1 block text-sm text-gray-500" numberOfLines={2}>
{banner.description}
</MyText>
)}
<span className="mt-2 flex flex-row items-center justify-between">
<MyText className="text-xs text-gray-400">
Created: {new Date(banner.createdAt).toLocaleDateString()}
</MyText>
{banner.serialNum && banner.serialNum >= 1 && banner.serialNum <= 4 && (
<MyText className="text-xs font-medium text-blue-600">
Slot {banner.serialNum}
</MyText>
)}
</span>
</span>
</span>
</div>
)
})
)}
</div>
</div>
{/* Floating Action Button */}
<button
onClick={handleCreate}
className="fixed bottom-6 right-6 flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 shadow-lg"
aria-label="Add new banner"
>
<Plus size={24} color="white" />
</button>
</div>
</AppContainer>
)
}

View file

@ -0,0 +1,314 @@
import React, { useState, useCallback, useMemo } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { CircleAlert, Reply, User as UserIcon, ShoppingBag, Inbox } from 'lucide-react'
import { p as MyText, MyFlatList, MyButton, ConfirmationDialog } from 'web-components'
import { useMarkDataFetchers } from '@/lib/refresh-context'
import { trpc } from '@/lib/trpc-client'
export const Route = createFileRoute('/dashboard/complaints')({
component: Complaints,
})
function Complaints() {
const navigate = useNavigate()
const [dialogOpen, setDialogOpen] = useState(false)
const [selectedComplaintId, setSelectedComplaintId] = useState<number | null>(null)
const [viewerOpen, setViewerOpen] = useState(false)
const [viewerImages, setViewerImages] = useState<string[]>([])
const [viewerIndex, setViewerIndex] = useState(0)
const {
data,
isLoading,
isError,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
refetch,
} = trpc.admin.complaint.getAll.useInfiniteQuery(
{ limit: 20 },
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
)
useMarkDataFetchers(() => {
refetch()
})
const resolveComplaint = trpc.admin.complaint.resolve.useMutation()
const complaints = useMemo(() => {
const allComplaints = data?.pages.flatMap((page) => page.complaints) || []
return allComplaints.filter(
(complaint, index, self) =>
index === self.findIndex((c) => c.id === complaint.id)
)
}, [data])
const handleLoadMore = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage])
const handleUserPress = useCallback((userId: number) => {
navigate({ to: '/dashboard/users/$id', params: { id: String(userId) } })
}, [navigate])
const handleOrderPress = useCallback((orderId: number) => {
navigate({ to: '/dashboard/orders/$id', params: { id: String(orderId) } })
}, [navigate])
const handleMarkResolved = (id: number) => {
setSelectedComplaintId(id)
setDialogOpen(true)
}
const handleConfirmResolve = (response?: string) => {
if (!selectedComplaintId) return
resolveComplaint.mutate(
{ id: String(selectedComplaintId), response: response ?? '' },
{
onSuccess: () => {
window.alert('Success: Complaint marked as resolved')
refetch()
setDialogOpen(false)
setSelectedComplaintId(null)
},
onError: (error: any) => {
window.alert(`Error: ${error.message || 'Failed to resolve complaint'}`)
setDialogOpen(false)
setSelectedComplaintId(null)
},
}
)
}
const openImageViewer = (images: string[], index: number) => {
setViewerImages(images)
setViewerIndex(index)
setViewerOpen(true)
}
if (isLoading) {
return (
<div className="flex flex-1 items-center justify-center bg-gray-50">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
<MyText className="mt-4 text-gray-500">Loading complaints...</MyText>
</div>
)
}
if (isError) {
return (
<div className="flex flex-1 items-center justify-center bg-gray-50 p-6">
<CircleAlert size={48} color="#EF4444" />
<MyText className="mt-4 text-lg font-bold text-gray-900">Error</MyText>
<MyText className="mb-6 mt-2 text-center text-gray-500">
{error?.message || 'Failed to load complaints'}
</MyText>
<button
onClick={() => refetch()}
className="rounded-full bg-blue-600 px-6 py-3"
>
<MyText className="font-semibold text-white">Retry</MyText>
</button>
</div>
)
}
return (
<div className="flex-1 bg-gray-50">
<div className="px-4 py-4">
<MyFlatList
data={complaints}
keyExtractor={(item) => item.id.toString()}
renderItem={(item) => (
<div className="mb-4 rounded-2xl border border-gray-100 bg-white p-4 shadow-sm">
<div className="mb-2 flex-row flex items-start justify-between">
<MyText className="text-lg font-bold text-gray-900">
Complaint #{item.id}
</MyText>
<span
className={`rounded-full px-2.5 py-1 ${
item.status === 'resolved'
? 'border border-green-200 bg-green-100'
: 'border border-amber-200 bg-amber-100'
}`}
>
<MyText
className={`text-xs font-semibold ${
item.status === 'resolved' ? 'text-green-700' : 'text-amber-700'
}`}
>
{item.status === 'resolved' ? 'Resolved' : 'Pending'}
</MyText>
</span>
</div>
<MyText className="mb-3 text-base leading-5 text-gray-700">
{item.text}
</MyText>
{item.response && (
<div className="mb-3 rounded-xl border border-blue-100 bg-blue-50 p-3">
<div className="mb-1 flex-row flex items-center">
<Reply size={16} color="#2563EB" />
<MyText className="ml-1 text-xs font-bold text-blue-700">
Admin Reply
</MyText>
</div>
<MyText className="text-sm leading-5 text-gray-700">
{item.response}
</MyText>
</div>
)}
{item.images && item.images.length > 0 && (
<div className="mb-3">
<MyText className="mb-2 text-sm font-semibold text-gray-700">
Attached Images:
</MyText>
<div className="flex-row flex flex-wrap gap-2">
{item.images.map((imageUri: string, index: number) => (
<img
key={index}
src={imageUri}
alt={`Complaint attachment ${index + 1}`}
onClick={() => openImageViewer(item.images as string[], index)}
className="h-16 w-16 cursor-pointer rounded-lg border border-gray-200 object-cover"
/>
))}
</div>
</div>
)}
<div className="mb-3 flex-row flex items-center gap-2">
<UserIcon size={14} color="#6B7280" />
<button
onClick={() => item.userId && handleUserPress(item.userId)}
>
<MyText className="text-sm text-blue-600 underline">
{item.userName || item.userMobile || 'Unknown User'}
</MyText>
</button>
{item.orderId && (
<>
<MyText className="text-sm text-gray-400">|</MyText>
<ShoppingBag size={14} color="#6B7280" />
<button
onClick={() => item.orderId && handleOrderPress(item.orderId)}
>
<MyText className="text-sm text-blue-600 underline">
Order #{item.orderId}
</MyText>
</button>
</>
)}
</div>
{item.status === 'pending' && (
<button
onClick={() => handleMarkResolved(item.id)}
className="mt-2 items-center rounded-xl bg-blue-500 py-3 shadow-sm"
>
<MyText className="font-semibold text-white">
Resolve Complaint
</MyText>
</button>
)}
</div>
)}
ListEmptyComponent={
<div className="flex flex-1 items-center justify-center py-20">
<div className="mb-4 rounded-full bg-white p-6 shadow-sm">
<Inbox size={48} color="#D1D5DB" />
</div>
<MyText className="text-lg font-bold text-gray-900">
No complaints
</MyText>
<MyText className="mt-2 text-center text-gray-500">
All complaints will appear here
</MyText>
</div>
}
ListFooterComponent={
isFetchingNextPage ? (
<div className="flex-row flex items-center justify-center py-4">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
<MyText className="ml-2 text-gray-500">Loading more...</MyText>
</div>
) : hasNextPage ? (
<div className="flex justify-center py-4">
<MyButton textContent="Load more" onClick={handleLoadMore} variant="blue" />
</div>
) : null
}
/>
</div>
<ConfirmationDialog
open={dialogOpen}
positiveAction={handleConfirmResolve}
commentNeeded={true}
negativeAction={() => {
setDialogOpen(false)
setSelectedComplaintId(null)
}}
title="Resolve Complaint"
message="Add admin notes for this resolution:"
confirmText="Resolve"
cancelText="Cancel"
isLoading={resolveComplaint.isPending}
/>
{viewerOpen && viewerImages.length > 0 && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/90" onClick={() => setViewerOpen(false)}>
<button
onClick={() => setViewerOpen(false)}
className="absolute right-4 top-4 text-white hover:text-gray-300"
aria-label="Close viewer"
>
</button>
{viewerImages.length > 1 && (
<button
onClick={(e) => {
e.stopPropagation()
setViewerIndex((i) => Math.max(0, i - 1))
}}
disabled={viewerIndex === 0}
className="absolute left-4 text-white disabled:opacity-30"
aria-label="Previous image"
>
</button>
)}
<img
src={viewerImages[viewerIndex]}
alt="Complaint attachment viewer"
className="max-h-[90vh] max-w-[90vw] object-contain"
onClick={(e) => e.stopPropagation()}
/>
{viewerImages.length > 1 && (
<button
onClick={(e) => {
e.stopPropagation()
setViewerIndex((i) => Math.min(viewerImages.length - 1, i + 1))
}}
disabled={viewerIndex === viewerImages.length - 1}
className="absolute right-4 text-white disabled:opacity-30"
aria-label="Next image"
>
</button>
)}
<div className="absolute bottom-4 text-sm text-white">
{viewerIndex + 1} / {viewerImages.length}
</div>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,126 @@
import React from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { AppContainer, p as MyText } from 'web-components'
import CouponForm from '../components/CouponForm'
import { trpc } from '@/lib/trpc-client'
import { ErrorToast } from '@/services/toaster'
import dayjs from 'dayjs'
// Ported from common-ui/shared-types (CreateCouponPayload) — @packages/shared
// does not export it, so the shape is copied here to keep behavior identical.
interface CreateCouponPayload {
couponCodes: string[],
discountPercent?: number,
flatDiscount?: number,
minOrder?: number,
maxValue?: number,
validTill?: string,
maxLimitForUser?: number,
isApplyForAll: boolean,
isUserBased: boolean,
targetUsers?: number[],
productIds?: number[],
skuIds?: number[],
applicableUsers?: number[],
applicableProducts?: number[],
exclusiveApply?: boolean
}
export const Route = createFileRoute('/dashboard/coupons/$id/edit')({
component: EditCoupon,
})
function EditCoupon() {
const { id } = Route.useParams()
const couponId = parseInt(id as string)
const { data: coupon, isLoading, refetch } = trpc.admin.coupon.getById.useQuery({ id: couponId })
const { refetch: refetchCoupons } = trpc.admin.coupon.getAll.useInfiniteQuery(
{ limit: 20, search: '' },
{
enabled: false,
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
)
const { refetch: refetchReservedCoupons } = trpc.admin.coupon.getReservedCoupons.useInfiniteQuery(
{ limit: 20, search: '' },
{
enabled: false,
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
)
const updateCoupon = trpc.admin.coupon.update.useMutation()
const handleUpdateCoupon = (values: CreateCouponPayload & { isReservedCoupon?: boolean }) => {
// Transform targetUsers array to targetUser for backend compatibility
const { targetUsers, ...rest } = values
const updates = {
...rest,
targetUser: targetUsers?.[0] || undefined,
}
// For flat discounts, send the discount value as maxValue too
if (updates.flatDiscount !== undefined) {
updates.maxValue = updates.flatDiscount
}
updateCoupon.mutate({ id: couponId, updates }, {
onSuccess: async () => {
await refetch()
await refetchCoupons()
await refetchReservedCoupons()
window.alert('Success: Coupon updated successfully')
window.history.back()
},
onError: (error: any) => {
ErrorToast(error.message || 'Failed to update coupon')
},
})
}
if (isLoading) {
return (
<AppContainer>
<div className="flex flex-1 flex-col items-center justify-center">
<MyText>Loading...</MyText>
</div>
</AppContainer>
)
}
if (!coupon) {
return (
<AppContainer>
<div className="flex flex-1 flex-col items-center justify-center">
<MyText>Coupon not found</MyText>
</div>
</AppContainer>
)
}
// Transform coupon data for form (targetUser to targetUsers array)
const initialValues: Partial<CreateCouponPayload & { isReservedCoupon?: boolean }> = {
couponCodes: coupon.couponCode ? [coupon.couponCode] : [''],
isUserBased: coupon.isUserBased,
isApplyForAll: coupon.isApplyForAll,
targetUsers: coupon.targetUser ? [coupon.targetUser.id] : [],
discountPercent: coupon.discountPercent ? parseFloat(coupon.discountPercent) : undefined,
flatDiscount: coupon.flatDiscount ? parseFloat(coupon.flatDiscount) : undefined,
minOrder: coupon.minOrder ? parseFloat(coupon.minOrder) : undefined,
maxValue: coupon.maxValue ? parseFloat(coupon.maxValue) : undefined,
validTill: coupon.validTill ? dayjs(coupon.validTill).format('YYYY-MM-DD') : undefined,
maxLimitForUser: coupon.maxLimitForUser || undefined,
skuIds: coupon.skuIds,
isReservedCoupon: false, // Normal coupons
}
return (
<AppContainer>
<CouponForm
initialValues={initialValues}
onSubmit={handleUpdateCoupon}
isLoading={updateCoupon.isPending}
/>
</AppContainer>
)
}

View file

@ -0,0 +1,70 @@
import React from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { AppContainer } from 'web-components'
import CouponForm from '../components/CouponForm'
import { trpc } from '@/lib/trpc-client'
import { ErrorToast } from '@/services/toaster'
export const Route = createFileRoute('/dashboard/coupons/new')({
component: CreateCoupon,
})
function CreateCoupon() {
const createCoupon = trpc.admin.coupon.create.useMutation()
const createReservedCoupon = trpc.admin.coupon.createReservedCoupon.useMutation()
const { refetch: refetchCoupons } = trpc.admin.coupon.getAll.useInfiniteQuery(
{ limit: 20, search: '' },
{
enabled: false,
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
)
const { refetch: refetchReservedCoupons } = trpc.admin.coupon.getReservedCoupons.useInfiniteQuery(
{ limit: 20, search: '' },
{
enabled: false,
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
)
const handleCreateCoupon = async (values: any) => {
const { isReservedCoupon, couponCodes, ...rest } = values
// Transform targetUsers array to targetUser for backend compatibility
const payload = {
...rest,
targetUser: rest.targetUsers?.[0] || undefined,
}
delete payload.targetUsers
// For flat discounts, send the discount value as maxValue too
if (payload.flatDiscount !== undefined) {
payload.maxValue = payload.flatDiscount
}
const codes = (couponCodes || []).map((c: string) => c.trim()).filter(Boolean)
if (codes.length === 0) {
ErrorToast('At least one coupon code is required')
return
}
const mutation = isReservedCoupon ? createReservedCoupon : createCoupon
try {
await mutation.mutateAsync({ ...payload, couponCodes: codes })
await refetchCoupons()
await refetchReservedCoupons()
window.alert(`Success: ${codes.length} coupon${codes.length > 1 ? 's' : ''} created successfully`)
window.history.back()
} catch (error: any) {
ErrorToast(error.message || 'Failed to create coupons')
}
}
return (
<AppContainer>
<CouponForm
onSubmit={handleCreateCoupon}
isLoading={createCoupon.isPending || createReservedCoupon.isPending}
/>
</AppContainer>
)
}

View file

@ -0,0 +1,509 @@
import React, { useState, useCallback, useMemo } from 'react'
import { createFileRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router'
import { Percent, IndianRupee, Pencil, Trash2, TicketPercent, CircleAlert, Loader2, Users, Package, UserCheck, User, ListFilter, Plus } from 'lucide-react'
import { p as MyText, MyButton, SearchBar, MyFlatList } from 'web-components'
import { useManualRefresh, useMarkDataFetchers } from '@/lib/refresh-context'
import { trpc } from '@/lib/trpc-client'
import { ErrorToast } from '@/services/toaster'
import { MultiSelect } from '@/components/MultiSelect'
export const Route = createFileRoute('/dashboard/coupons')({
component: Coupons,
})
const CouponItem = ({ item, onDelete }: { item: any, onDelete: (id: number) => void }) => {
const navigate = useNavigate()
const getCouponStatus = (coupon: any) => {
if (coupon.isInvalidated) return 'inactive'
if (coupon.validTill && new Date(coupon.validTill) <= new Date()) return 'expired'
return 'active'
}
const status = getCouponStatus(item)
const getBorderColor = () => {
if (status === 'active') return 'border-green-500'
if (status === 'expired') return 'border-yellow-500'
return 'border-red-500'
}
const getBgColor = () => {
if (status === 'active') return 'bg-green-100'
if (status === 'expired') return 'bg-yellow-100'
return 'bg-red-100'
}
const getTextColor = () => {
if (status === 'active') return 'text-green-600'
if (status === 'expired') return 'text-yellow-600'
return 'text-red-600'
}
const getIconColor = () => {
if (status === 'active') return '#10b981'
if (status === 'expired') return '#f59e0b'
return '#ef4444'
}
return (
<div className={`mb-4 rounded-2xl border-l-4 bg-white p-4 shadow-lg ${getBorderColor()}`}>
<div className="mb-3 flex flex-row items-center">
<span className={`mr-3 flex h-10 w-10 items-center justify-center rounded-full ${getBgColor()}`}>
{item.discountPercent ? (
<Percent size={20} color={getIconColor()} />
) : (
<IndianRupee size={20} color={getIconColor()} />
)}
</span>
<div className="flex-1">
<MyText className="block text-lg font-bold text-gray-800">{item.couponCode}</MyText>
<MyText className="block text-sm text-gray-500">ID: {item.id}</MyText>
</div>
<span className={`rounded-full px-2 py-1 ${getBgColor()}`}>
<MyText className={`text-xs font-semibold ${getTextColor()}`}>
{status === 'active' ? 'Active' : status === 'expired' ? 'Expired' : 'Inactive'}
</MyText>
</span>
</div>
<div className="mb-3 rounded-lg bg-gray-50 p-3">
<MyText className="mb-1 block text-base font-semibold text-gray-800">
Discount: {item.discountPercent ? `${item.discountPercent}% off` : item.flatDiscount ? `${item.flatDiscount} off` : 'N/A'}
</MyText>
<div className="flex flex-row justify-between">
<MyText className="text-sm text-gray-600">Min Order: {item.minOrder ? `${item.minOrder}` : 'None'}</MyText>
<MyText className="text-sm text-gray-600">Max: {item.maxValue ? `${item.maxValue}` : 'None'}</MyText>
</div>
<MyText className="mt-1 block text-sm text-gray-600">
Valid Till: {item.validTill ? new Date(item.validTill).toLocaleDateString() : 'No expiry'}
</MyText>
</div>
<div className="mb-3">
<MyText className="mb-1 flex items-center gap-1 text-sm text-gray-700">
<Users size={14} color="#6b7280" /> Target: {item.isApplyForAll ? 'All Users' : item.applicableUsers?.length > 0 ? `${item.applicableUsers.length} Users` : 'All Users'}
</MyText>
{item.applicableProducts && item.applicableProducts.length > 0 && (
<MyText className="flex items-center gap-1 text-sm text-gray-700">
<Package size={14} color="#6b7280" /> Products: {item.applicableProducts.length} selected
</MyText>
)}
</div>
<div className="mt-3 flex flex-row gap-2">
<button
onClick={() => navigate({ to: '/dashboard/coupons/$id/edit', params: { id: String(item.id) } })}
className="flex flex-1 flex-row items-center justify-center rounded-lg bg-blue-500 p-3 shadow-md"
>
<Pencil size={16} color="white" />
<MyText className="ml-1 text-center font-semibold text-white">Edit</MyText>
</button>
<button
onClick={() => onDelete(item.id)}
className="flex flex-1 flex-row items-center justify-center rounded-lg bg-red-500 p-3 shadow-md"
>
<Trash2 size={16} color="white" />
<MyText className="ml-1 text-center font-semibold text-white">Delete</MyText>
</button>
</div>
</div>
)
}
const ReservedCouponItem = ({ item }: { item: any }) => {
const getStatus = () => {
if (item.isRedeemed) return 'redeemed'
if (item.validTill && new Date(item.validTill) <= new Date()) return 'expired'
return 'active'
}
const status = getStatus()
const getBorderColor = () => {
if (status === 'active') return 'border-green-500'
if (status === 'expired') return 'border-yellow-500'
return 'border-blue-500'
}
const getBgColor = () => {
if (status === 'active') return 'bg-green-100'
if (status === 'expired') return 'bg-yellow-100'
return 'bg-blue-100'
}
const getTextColor = () => {
if (status === 'active') return 'text-green-600'
if (status === 'expired') return 'text-yellow-600'
return 'text-blue-600'
}
const getIconColor = () => {
if (status === 'active') return '#10b981'
if (status === 'expired') return '#f59e0b'
return '#3b82f6'
}
return (
<div className={`mb-4 rounded-2xl border-l-4 bg-white p-4 shadow-lg ${getBorderColor()}`}>
<div className="mb-3 flex flex-row items-center">
<span className={`mr-3 flex h-10 w-10 items-center justify-center rounded-full ${getBgColor()}`}>
{item.discountPercent ? (
<Percent size={20} color={getIconColor()} />
) : (
<IndianRupee size={20} color={getIconColor()} />
)}
</span>
<div className="flex-1">
<MyText className="block text-lg font-bold text-gray-800">{item.secretCode}</MyText>
<MyText className="block text-sm text-gray-500">Coupon: {item.couponCode}</MyText>
</div>
<span className={`rounded-full px-2 py-1 ${getBgColor()}`}>
<MyText className={`text-xs font-semibold ${getTextColor()}`}>
{status === 'active' ? 'Active' : status === 'expired' ? 'Expired' : 'Redeemed'}
</MyText>
</span>
</div>
<div className="mb-3 rounded-lg bg-gray-50 p-3">
<MyText className="mb-1 block text-base font-semibold text-gray-800">
Discount: {item.discountPercent ? `${item.discountPercent}% off` : item.flatDiscount ? `${item.flatDiscount} off` : 'N/A'}
</MyText>
<div className="flex flex-row justify-between">
<MyText className="text-sm text-gray-600">Min Order: {item.minOrder ? `${item.minOrder}` : 'None'}</MyText>
<MyText className="text-sm text-gray-600">Max: {item.maxValue ? `${item.maxValue}` : 'None'}</MyText>
</div>
<MyText className="mt-1 block text-sm text-gray-600">
Valid Till: {item.validTill ? new Date(item.validTill).toLocaleDateString() : 'No expiry'}
</MyText>
</div>
{item.isRedeemed && item.redeemedUser && (
<div className="mb-3">
<MyText className="flex items-center gap-1 text-sm text-gray-700">
<UserCheck size={14} color="#6b7280" /> Redeemed by: {item.redeemedUser.name || 'Unknown'} ({item.redeemedUser.mobile})
</MyText>
<MyText className="block text-sm text-gray-600">
Redeemed on: {item.redeemedAt ? new Date(item.redeemedAt).toLocaleDateString() : 'N/A'}
</MyText>
</div>
)}
<div className="mt-3 flex flex-row items-center justify-between">
<MyText className="flex items-center gap-1 text-sm text-gray-700">
<User size={14} color="#6b7280" /> Created by: {item.creator?.name || 'Unknown'}
</MyText>
</div>
</div>
)
}
const GeneralTab = () => {
const [refreshing, setRefreshing] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [statusFilters, setStatusFilters] = useState<string[]>([])
const {
data,
fetchNextPage,
hasNextPage,
isLoading,
isFetchingNextPage,
error,
refetch,
} = trpc.admin.coupon.getAll.useInfiniteQuery(
{ limit: 20, search: searchQuery },
{ getNextPageParam: (lastPage) => lastPage.nextCursor }
)
const coupons = useMemo(() => data?.pages.flatMap((page) => page.coupons) || [], [data])
const getCouponStatus = (coupon: any) => {
if (coupon.isInvalidated) return 'inactive'
if (coupon.validTill && new Date(coupon.validTill) <= new Date()) return 'expired'
return 'active'
}
const filteredCoupons = useMemo(() => {
let filtered = coupons
if (statusFilters.length > 0) {
filtered = filtered.filter((coupon) => statusFilters.includes(getCouponStatus(coupon)))
}
return filtered
}, [coupons, statusFilters])
const handleRefresh = useCallback(async () => {
setRefreshing(true)
await refetch()
setRefreshing(false)
}, [refetch])
useManualRefresh(() => { refetch() })
useMarkDataFetchers(() => {
refetch()
})
const deleteCoupon = trpc.admin.coupon.delete.useMutation()
const handleDeleteCoupon = (id: number) => {
if (!window.confirm('Delete Coupon: Are you sure you want to delete this coupon?')) return
deleteCoupon.mutate({ id }, {
onSuccess: () => {
window.alert('Success: Coupon deleted successfully')
refetch()
},
onError: (error: any) => {
ErrorToast(error.message || 'Failed to delete coupon')
},
})
}
if (isLoading) {
return (
<div className="flex flex-1 flex-col items-center justify-center bg-white">
<Loader2 size={32} color="#3b82f6" className="mb-4 animate-spin" />
<MyText className="text-lg font-semibold text-gray-600">Loading Coupons...</MyText>
</div>
)
}
if (error) {
return (
<div className="flex flex-1 flex-col items-center justify-center bg-white p-4">
<CircleAlert size={32} color="#ef4444" className="mb-4" />
<MyText className="mb-2 block text-center text-lg font-semibold text-red-600">Failed to load coupons</MyText>
<MyButton onClick={() => refetch()} variant="red">
<MyText className="font-semibold text-white">Retry</MyText>
</MyButton>
</div>
)
}
return (
<div className="flex flex-1 flex-col">
<div className="flex flex-row items-center px-4 pt-2">
<div className="mr-2 flex-1">
<SearchBar
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search coupons..."
/>
</div>
<MultiSelect
label="Filter by Status"
value={statusFilters}
options={[
{ label: 'Active', value: 'active' },
{ label: 'Expired', value: 'expired' },
{ label: 'Inactive', value: 'inactive' },
]}
onValueChange={(value) => setStatusFilters(value as string[])}
multiple={true}
triggerComponent={({ onClick }: any) => (
<button onClick={onClick} className="p-2" aria-label="Filter by status">
<ListFilter size={24} color="#6b7280" />
</button>
)}
/>
</div>
<MyFlatList
data={filteredCoupons}
keyExtractor={(item) => item.id.toString()}
renderItem={(item) => <CouponItem item={item} onDelete={handleDeleteCoupon} />}
onRefresh={handleRefresh}
refreshing={refreshing}
className="px-4 pb-4"
ListFooterComponent={
<div className="flex flex-col items-center py-4">
{isFetchingNextPage ? (
<MyText className="text-gray-600">Loading more...</MyText>
) : hasNextPage ? (
<MyButton variant="blue" onClick={() => fetchNextPage()}>
Load more
</MyButton>
) : null}
</div>
}
ListEmptyComponent={
<div className="flex flex-1 flex-col items-center justify-center py-20">
<TicketPercent size={40} color="#9ca3af" className="mb-4" />
<MyText className="mb-2 block text-xl font-semibold text-gray-600">No Coupons Found</MyText>
{searchQuery ? (
<MyButton onClick={() => setSearchQuery('')} variant="blue" className="mt-4 bg-gray-500">
<MyText className="font-semibold text-white">Clear Search</MyText>
</MyButton>
) : null}
</div>
}
/>
</div>
)
}
const ReservedTab = () => {
const [refreshing, setRefreshing] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [statusFilters, setStatusFilters] = useState<string[]>([])
const {
data,
isLoading,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
error,
refetch
} = trpc.admin.coupon.getReservedCoupons.useInfiniteQuery(
{ limit: 20, search: searchQuery },
{ getNextPageParam: (lastPage) => lastPage.nextCursor }
)
const coupons = useMemo(() => data?.pages.flatMap((page) => page.coupons) || [], [data])
const getStatus = (coupon: any) => {
if (coupon.isRedeemed) return 'redeemed'
if (coupon.validTill && new Date(coupon.validTill) <= new Date()) return 'expired'
return 'active'
}
const filteredCoupons = useMemo(() => {
let filtered = coupons
if (statusFilters.length > 0) {
filtered = filtered.filter((coupon) => statusFilters.includes(getStatus(coupon)))
}
return filtered
}, [coupons, statusFilters])
const handleRefresh = useCallback(async () => {
setRefreshing(true)
await refetch()
setRefreshing(false)
}, [refetch])
useManualRefresh(() => { refetch() })
useMarkDataFetchers(() => {
refetch()
})
if (isLoading) {
return (
<div className="flex flex-1 flex-col items-center justify-center bg-white">
<Loader2 size={32} color="#3b82f6" className="mb-4 animate-spin" />
<MyText className="text-lg font-semibold text-gray-600">Loading Reserved Coupons...</MyText>
</div>
)
}
if (error) {
return (
<div className="flex flex-1 flex-col items-center justify-center bg-white p-4">
<CircleAlert size={32} color="#ef4444" className="mb-4" />
<MyText className="mb-2 block text-center text-lg font-semibold text-red-600">Failed to load reserved coupons</MyText>
<MyButton onClick={() => refetch()} variant="red">
<MyText className="font-semibold text-white">Retry</MyText>
</MyButton>
</div>
)
}
return (
<div className="flex flex-1 flex-col">
<div className="flex flex-row items-center px-4 py-2">
<div className="mr-2 flex-1">
<SearchBar
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search reserved coupons..."
/>
</div>
<MultiSelect
label="Filter by Status"
value={statusFilters}
options={[
{ label: 'Active', value: 'active' },
{ label: 'Expired', value: 'expired' },
{ label: 'Redeemed', value: 'redeemed' },
]}
onValueChange={(value) => setStatusFilters(value as string[])}
multiple={true}
triggerComponent={({ onClick }: any) => (
<button onClick={onClick} className="p-2" aria-label="Filter by status">
<ListFilter size={24} color="#6b7280" />
</button>
)}
/>
</div>
<MyFlatList
data={filteredCoupons}
keyExtractor={(item) => item.id.toString()}
renderItem={(item) => <ReservedCouponItem item={item} />}
onRefresh={handleRefresh}
refreshing={refreshing}
className="px-4 pb-4"
ListFooterComponent={
<div className="flex flex-col items-center py-4">
{isFetchingNextPage ? (
<MyText className="text-gray-600">Loading more...</MyText>
) : hasNextPage ? (
<MyButton variant="blue" onClick={() => fetchNextPage()}>
Load more
</MyButton>
) : null}
</div>
}
ListEmptyComponent={
<div className="flex flex-1 flex-col items-center justify-center py-20">
<TicketPercent size={40} color="#9ca3af" className="mb-4" />
<MyText className="mb-2 block text-xl font-semibold text-gray-600">No Reserved Coupons Found</MyText>
{searchQuery ? (
<MyButton onClick={() => setSearchQuery('')} variant="blue" className="mt-4 bg-gray-500">
<MyText className="font-semibold text-white">Clear Search</MyText>
</MyButton>
) : null}
</div>
}
/>
</div>
)
}
function Coupons() {
const location = useLocation()
const navigate = useNavigate()
const [tab, setTab] = useState<'general' | 'reserved'>('general')
if (location.pathname !== '/dashboard/coupons') {
return <Outlet />
}
return (
<div className="relative flex min-h-full flex-1 flex-col bg-white">
<div className="flex flex-row border-b border-gray-200 bg-white">
{([
{ key: 'general', title: 'General' },
{ key: 'reserved', title: 'Reserved' },
] as const).map((r) => (
<button
key={r.key}
onClick={() => setTab(r.key)}
className={`flex-1 border-b-2 py-3 text-center text-[15px] font-medium ${tab === r.key ? 'border-[#F83758] text-[#F83758]' : 'border-transparent text-gray-500'}`}
>
{r.title}
</button>
))}
</div>
<div className="flex flex-1 flex-col pb-24">
{tab === 'general' ? <GeneralTab /> : <ReservedTab />}
</div>
<button
onClick={() => navigate({ to: '/dashboard/coupons/new' })}
aria-label="Create coupon"
className="fixed flex h-16 w-16 items-center justify-center rounded-[24px] shadow-lg"
style={{ bottom: 32, right: 24, zIndex: 100, background: 'linear-gradient(135deg,#F83758,#E91E63)' }}
>
<Plus size={32} color="white" />
</button>
</div>
)
}

View file

@ -0,0 +1,301 @@
import React, { useState, useEffect, useCallback } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core'
import { SortableContext, useSortable, arrayMove, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { GripVertical, Image as ImageIcon, XCircle, ChevronLeft, CircleAlert, Package } from 'lucide-react'
import { AppContainer, p as MyText } from 'web-components'
import { trpc } from '@/lib/trpc-client'
import type { StoreProductCard } from '@packages/shared'
import type { DragOrderItemProps } from '@/types/drag-order-item'
export const Route = createFileRoute('/dashboard/customize-app/ordering')({
component: AllItemsOrder,
})
type Product = Pick<StoreProductCard, 'id' | 'name' | 'images' | 'isOutOfStock'>
const itemStyle: React.CSSProperties = {
width: '100%',
height: 60,
backgroundColor: 'white',
borderRadius: 8,
borderWidth: 1,
borderStyle: 'solid',
borderColor: '#e5e7eb',
padding: 10,
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
boxShadow: '0 1px 2px rgba(0,0,0,0.1)',
marginTop: 4,
marginBottom: 4,
}
const activeItemStyle: React.CSSProperties = {
...itemStyle,
borderColor: '#3b82f6',
boxShadow: '0 4px 8px rgba(59,130,246,0.3)',
}
const ProductItem: React.FC<DragOrderItemProps<Product>> = ({
item,
isActive,
}) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item.id })
const active = isActive || isDragging
return (
<div
ref={setNodeRef}
style={{
...(active ? activeItemStyle : itemStyle),
transform: CSS.Translate.toString(transform),
transition,
opacity: item.isOutOfStock ? 0.6 : 1,
zIndex: isDragging ? 50 : undefined,
}}
>
<div {...attributes} {...listeners} style={{ marginRight: 8, padding: 2, cursor: 'grab', touchAction: 'none' }}>
<GripVertical
size={24}
color={active ? '#3b82f6' : '#9ca3af'}
/>
</div>
{item.images?.[0] ? (
<img
src={item.images[0]}
style={{ width: 30, height: 30, borderRadius: 6, marginRight: 10, objectFit: 'cover' }}
alt={item.name}
/>
) : (
<div style={{ width: 30, height: 30, borderRadius: 6, backgroundColor: '#f3f4f6', marginRight: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<ImageIcon size={24} color="#9ca3af" />
</div>
)}
<div style={{ flex: 1, display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<MyText style={{ fontSize: 13, color: '#111827', fontWeight: 500, flex: 1, marginRight: 4 }} numberOfLines={1}>
{item.name.length > 30 ? item.name.substring(0, 30) + '...' : item.name}
</MyText>
{item.isOutOfStock && (
<XCircle size={16} color="#dc2626" />
)}
</div>
</div>
)
}
function AllItemsOrder() {
const queryClient = useQueryClient()
const [products, setProducts] = useState<Product[]>([])
const [hasChanges, setHasChanges] = useState(false)
const { data: constants, isLoading: isLoadingConstants, error: constantsError } = trpc.admin.const.getConstants.useQuery()
const { data: allProducts, isLoading: isLoadingProducts, error: productsError } = trpc.common.product.getAllProductsSummary.useQuery()
const updateConstants = trpc.admin.const.updateConstants.useMutation()
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }))
useEffect(() => {
if (allProducts?.products) {
const allItemsOrderConstant = constants?.find((c) => c.key === 'allItemsOrder')
let orderedIds: number[] = []
if (allItemsOrderConstant) {
const value = allItemsOrderConstant.value
if (Array.isArray(value)) {
orderedIds = value.map((id: any) => parseInt(id))
} else if (typeof value === 'string') {
orderedIds = value.split(',').map((id: string) => parseInt(id.trim())).filter((id) => !isNaN(id))
}
}
const productMap = new Map(allProducts.products.map((p) => [p.id, p]))
const sortedProducts: Product[] = []
for (const id of orderedIds) {
const product = productMap.get(id)
if (product) {
sortedProducts.push({
id: product.id,
name: product.name,
images: product.images || [],
isOutOfStock: product.isOutOfStock || false,
})
productMap.delete(id)
}
}
for (const product of productMap.values()) {
sortedProducts.push({
id: product.id,
name: product.name,
images: product.images || [],
isOutOfStock: product.isOutOfStock || false,
})
}
setProducts(sortedProducts)
}
}, [constants, allProducts])
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event
if (over && active.id !== over.id) {
setProducts((prev) => {
const oldIndex = prev.findIndex((p) => p.id === active.id)
const newIndex = prev.findIndex((p) => p.id === over.id)
return arrayMove(prev, oldIndex, newIndex)
})
setHasChanges(true)
}
}, [])
const handleSave = () => {
const productIds = products.map((p) => p.id)
updateConstants.mutate(
{
constants: [{
key: 'allItemsOrder',
value: productIds,
}],
},
{
onSuccess: () => {
setHasChanges(false)
window.alert('All items order updated successfully!')
queryClient.invalidateQueries({ queryKey: ['const.getConstants'] })
},
onError: (error) => {
window.alert('Failed to update items order. Please try again.')
console.error('Update all items order error:', error)
},
}
)
}
if (isLoadingConstants || isLoadingProducts) {
return (
<AppContainer>
<div className="flex-1 bg-gray-50">
<div className="flex flex-row items-center justify-between border-b border-gray-200 bg-white px-4 py-4">
<button
onClick={() => window.history.back()}
className="-ml-4 p-2"
>
<ChevronLeft size={24} color="#374151" />
</button>
<MyText className="text-xl font-bold text-gray-900">All Items Order</MyText>
<div className="w-16" />
</div>
<div className="flex flex-1 flex-col items-center justify-center p-8">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
<MyText className="mt-4 text-center text-gray-500">
{isLoadingConstants ? 'Loading order...' : 'Loading products...'}
</MyText>
</div>
</div>
</AppContainer>
)
}
if (constantsError || productsError) {
return (
<AppContainer>
<div className="flex-1 bg-gray-50">
<div className="flex flex-row items-center justify-between border-b border-gray-200 bg-white px-4 py-4">
<button
onClick={() => window.history.back()}
className="-ml-4 p-2"
>
<ChevronLeft size={24} color="#374151" />
</button>
<MyText className="text-xl font-bold text-gray-900">All Items Order</MyText>
<div className="w-16" />
</div>
<div className="flex flex-1 flex-col items-center justify-center p-8">
<CircleAlert size={64} color="#ef4444" />
<MyText className="mt-4 text-lg font-bold text-gray-900">Error</MyText>
<MyText className="mt-2 text-center text-gray-500">
{constantsError ? 'Failed to load order' : 'Failed to load products'}
</MyText>
<button
onClick={() => window.history.back()}
className="mt-6 rounded-full bg-blue-600 px-6 py-3"
>
<MyText className="font-semibold text-white">Go Back</MyText>
</button>
</div>
</div>
</AppContainer>
)
}
return (
<div className="flex-1 bg-gray-50">
<div className="flex flex-row items-center justify-between border-b border-gray-200 bg-white px-4 py-4">
<button
onClick={() => window.history.back()}
className="-ml-4 p-2"
>
<ChevronLeft size={24} color="#374151" />
</button>
<MyText className="text-xl font-bold text-gray-900">All Items Order</MyText>
<button
onClick={handleSave}
disabled={!hasChanges || updateConstants.isPending}
className={`rounded-lg px-4 py-2 ${
hasChanges && !updateConstants.isPending
? 'bg-blue-600'
: 'bg-gray-300'
}`}
>
<MyText className={`font-semibold ${
hasChanges && !updateConstants.isPending
? 'text-white'
: 'text-gray-500'
}`}>
{updateConstants.isPending ? 'Saving...' : 'Save'}
</MyText>
</button>
</div>
{products.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center p-8">
<Package size={64} color="#e5e7eb" />
<MyText className="mt-4 text-center text-lg text-gray-500">
No products available
</MyText>
</div>
) : (
<div className="flex flex-1 flex-col">
<div className="mx-4 mb-2 mt-2 rounded-lg bg-blue-50 px-4 py-2">
<MyText className="text-center text-xs text-blue-700">
Long press and drag to reorder {products.length} items
</MyText>
</div>
<div className="flex-1 px-3 pb-5">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={products.map((p) => p.id)} strategy={verticalListSortingStrategy}>
{products.map((product) => (
<ProductItem key={product.id} item={product} drag={() => {}} isActive={false} />
))}
</SortableContext>
</DndContext>
</div>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,413 @@
import React, { useState, useEffect } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core'
import { SortableContext, useSortable, arrayMove, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { GripVertical, Image as ImageIcon, Trash2, ChevronLeft, CircleAlert, Package, Plus } from 'lucide-react'
import { AppContainer, p as MyText, BottomDialog } from 'web-components'
import ProductsSelector from '../components/ProductsSelector'
import { trpc } from '@/lib/trpc-client'
import type { StoreProductCard } from '@packages/shared'
import type { DragOrderItemProps } from '@/types/drag-order-item'
export const Route = createFileRoute('/dashboard/customize-app/popular')({
component: CustomizePopularItems,
})
type PopularProduct = Omit<
StoreProductCard,
'price' | 'marketPrice' | 'unitNotation' | 'images'
> & {
price: number
marketPrice: number | null
images: string[] | null
storeId: number | null
nextDeliveryDate: string | null
}
interface ProductItemProps extends DragOrderItemProps<PopularProduct> {
onDelete: (id: number) => void
}
const ProductItem: React.FC<ProductItemProps> = ({
item,
isActive,
onDelete,
}) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item.id })
const active = isActive || isDragging
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Translate.toString(transform),
transition,
opacity: isDragging ? 0.9 : 1,
zIndex: isDragging ? 50 : undefined,
marginLeft: 16,
marginRight: 16,
marginTop: 8,
marginBottom: 8,
}}
>
<div
className={`bg-white p-4 rounded-xl border ${
active
? 'shadow-xl border-blue-500 z-50'
: 'shadow-sm border-gray-100'
}`}
>
<div className="flex flex-row items-center">
<div {...attributes} {...listeners} className="mr-4" style={{ cursor: 'grab', touchAction: 'none' }}>
<GripVertical
size={24}
color={active ? '#3b82f6' : '#9ca3af'}
/>
</div>
{item.images?.[0] ? (
<img
src={item.images[0]}
className="mr-4 h-12 w-12 rounded-lg object-cover"
alt={item.name}
/>
) : (
<div className="mr-4 flex h-12 w-12 items-center justify-center rounded-lg bg-gray-100">
<ImageIcon size={20} color="#9ca3af" />
</div>
)}
<div className="flex-1">
<MyText className="text-base font-semibold text-gray-900" numberOfLines={2}>
{item.name}
</MyText>
<MyText className="mt-1 text-sm font-bold text-green-600">
{item.price}
</MyText>
</div>
<button
onClick={() => onDelete(item.id)}
className="ml-2 p-2"
>
<Trash2 size={20} color="#ef4444" />
</button>
</div>
</div>
</div>
)
}
function CustomizePopularItems() {
const queryClient = useQueryClient()
const [popularProducts, setPopularProducts] = useState<PopularProduct[]>([])
const [hasChanges, setHasChanges] = useState(false)
const [showAddDialog, setShowAddDialog] = useState(false)
const [selectedProductIds, setSelectedProductIds] = useState<number[]>([])
const { data: constants, isLoading: isLoadingConstants, error: constantsError } = trpc.admin.const.getConstants.useQuery()
const { data: allProducts, isLoading: isLoadingProducts, error: productsError } = trpc.common.product.getAllProductsSummary.useQuery()
const updateConstants = trpc.admin.const.updateConstants.useMutation()
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }))
useEffect(() => {
if (constants && allProducts?.products) {
const popularItemsConstant = constants.find((c) => c.key === 'popularItems')
let popularIds: number[] = []
if (popularItemsConstant) {
const value = popularItemsConstant.value
if (Array.isArray(value)) {
popularIds = value.map((id: any) => parseInt(id))
} else if (typeof value === 'string') {
popularIds = value.split(',').map((id: string) => parseInt(id.trim())).filter((id) => !isNaN(id))
}
const popularProds: PopularProduct[] = []
for (const id of popularIds) {
const product = allProducts.products.find((p) => p.id === id)
if (product) {
popularProds.push(product as PopularProduct)
}
}
setPopularProducts(popularProds)
}
}
}, [constants, allProducts])
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (over && active.id !== over.id) {
setPopularProducts((prev) => {
const oldIndex = prev.findIndex((p) => p.id === active.id)
const newIndex = prev.findIndex((p) => p.id === over.id)
return arrayMove(prev, oldIndex, newIndex)
})
setHasChanges(true)
}
}
const handleDelete = (productId: number) => {
if (window.confirm('Are you sure you want to remove this product from popular items?')) {
setPopularProducts((prev) => prev.filter((p) => p.id !== productId))
setHasChanges(true)
}
}
const handleAddProduct = () => {
if (selectedProductIds.length > 0) {
const newProducts = selectedProductIds
.map((id) => allProducts?.products.find((p) => p.id === id))
.filter((product): product is NonNullable<typeof product> =>
product !== undefined && !popularProducts.find((p) => p.id === product.id)
)
if (newProducts.length > 0) {
setPopularProducts((prev) => [...prev, ...newProducts as PopularProduct[]])
setHasChanges(true)
}
setSelectedProductIds([])
setShowAddDialog(false)
}
}
const handleSave = () => {
const popularIds = popularProducts.map((p) => p.id)
updateConstants.mutate(
{
constants: [{
key: 'popularItems',
value: popularIds,
}],
},
{
onSuccess: () => {
setHasChanges(false)
window.alert('Popular items updated successfully!')
queryClient.invalidateQueries({ queryKey: ['const.getConstants'] })
},
onError: (error) => {
window.alert('Failed to update popular items. Please try again.')
console.error('Update popular items error:', error)
},
}
)
}
const availableProducts = (allProducts?.products || []).filter(
(product) => !popularProducts.find((p) => p.id === product.id)
)
if (isLoadingConstants || isLoadingProducts) {
return (
<AppContainer>
<div className="flex-1 bg-gray-50">
<div className="flex flex-row items-center justify-between border-b border-gray-200 bg-white px-4 py-4">
<button
onClick={() => window.history.back()}
className="-ml-4 p-2"
>
<ChevronLeft size={24} color="#374151" />
</button>
<MyText className="text-xl font-bold text-gray-900">Popular Items</MyText>
<div className="w-16" />
</div>
<div className="flex flex-1 flex-col items-center justify-center p-8">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
<MyText className="mt-4 text-center text-gray-500">
{isLoadingConstants ? 'Loading constants...' : 'Loading products...'}
</MyText>
</div>
</div>
</AppContainer>
)
}
if (constantsError || productsError) {
return (
<AppContainer>
<div className="flex-1 bg-gray-50">
<div className="flex flex-row items-center justify-between border-b border-gray-200 bg-white px-4 py-4">
<button
onClick={() => window.history.back()}
className="-ml-4 p-2"
>
<ChevronLeft size={24} color="#374151" />
</button>
<MyText className="text-xl font-bold text-gray-900">Popular Items</MyText>
<div className="w-16" />
</div>
<div className="flex flex-1 flex-col items-center justify-center p-8">
<CircleAlert size={64} color="#ef4444" />
<MyText className="mt-4 text-lg font-bold text-gray-900">Error</MyText>
<MyText className="mt-2 text-center text-gray-500">
{constantsError ? 'Failed to load constants' : 'Failed to load products'}
</MyText>
<button
onClick={() => window.history.back()}
className="mt-6 rounded-full bg-blue-600 px-6 py-3"
>
<MyText className="font-semibold text-white">Go Back</MyText>
</button>
</div>
</div>
</AppContainer>
)
}
return (
<div className="relative flex-1 bg-gray-50">
<div className="flex flex-row items-center justify-between border-b border-gray-200 bg-white px-4 py-4">
<button
onClick={() => window.history.back()}
className="-ml-4 p-2"
>
<ChevronLeft size={24} color="#374151" />
</button>
<MyText className="text-xl font-bold text-gray-900">Popular Items</MyText>
<button
onClick={handleSave}
disabled={!hasChanges || updateConstants.isPending}
className={`rounded-lg px-4 py-2 ${
hasChanges && !updateConstants.isPending
? 'bg-blue-600'
: 'bg-gray-300'
}`}
>
<MyText className={`font-semibold ${
hasChanges && !updateConstants.isPending
? 'text-white'
: 'text-gray-500'
}`}>
{updateConstants.isPending ? 'Saving...' : 'Save'}
</MyText>
</button>
</div>
{popularProducts.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center p-8">
<Package size={64} color="#e5e7eb" />
<MyText className="mt-4 text-center text-lg text-gray-500">
No popular items configured
</MyText>
<MyText className="mt-2 text-center text-gray-400">
Add products to display as popular items
</MyText>
</div>
) : (
<div className="flex flex-1 flex-col pb-20">
<div className="bg-blue-50 px-4 py-2 mb-2">
<MyText className="text-center text-xs text-blue-700">
Long press an item to drag and reorder
</MyText>
</div>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={popularProducts.map((p) => p.id)} strategy={verticalListSortingStrategy}>
{popularProducts.map((product) => (
<ProductItem
key={product.id}
item={product}
drag={() => {}}
isActive={false}
onDelete={handleDelete}
/>
))}
</SortableContext>
</DndContext>
</div>
)}
<div className="fixed bottom-12 right-6 z-50">
<button
onClick={() => setShowAddDialog(true)}
className="rounded-full bg-blue-600 p-4 shadow-lg"
>
<Plus size={24} color="white" />
</button>
</div>
<BottomDialog
open={showAddDialog}
onClose={() => {
setShowAddDialog(false)
setSelectedProductIds([])
}}
>
<div className="px-4 pb-8 pt-2">
<div className="mb-6 flex flex-col items-center">
<div className="mb-4 h-1.5 w-12 rounded-full bg-gray-200" />
<MyText className="text-lg font-bold text-gray-900">
Add Popular Items
</MyText>
<MyText className="text-sm text-gray-500">
Select products to add to popular items
</MyText>
</div>
{availableProducts.length === 0 ? (
<div className="flex flex-col items-center py-8">
<Package size={48} color="#e5e7eb" />
<MyText className="mt-4 text-center text-gray-500">
All products are already in popular items
</MyText>
</div>
) : (
<>
<ProductsSelector
value={selectedProductIds}
onChange={(val) => setSelectedProductIds(val as number[])}
multiple={true}
label="Select Products"
placeholder="Choose products..."
labelFormat={(product) => `${product.productName} - ₹${product.price}`}
/>
<div className="mt-6 flex flex-row gap-3">
<button
onClick={() => setShowAddDialog(false)}
className="flex-1 rounded-lg bg-gray-100 p-3"
>
<MyText className="text-center font-semibold text-gray-700">
Cancel
</MyText>
</button>
<button
onClick={handleAddProduct}
disabled={selectedProductIds.length === 0}
className={`flex-1 rounded-lg p-3 ${
selectedProductIds.length > 0 ? 'bg-blue-600' : 'bg-gray-300'
}`}
>
<MyText className="text-center font-semibold text-white">
{selectedProductIds.length > 0
? `Add ${selectedProductIds.length} Product${selectedProductIds.length > 1 ? 's' : ''}`
: 'Add Products'}
</MyText>
</button>
</div>
</>
)}
</div>
</BottomDialog>
</div>
)
}

View file

@ -0,0 +1,271 @@
import React from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { Formik } from 'formik'
import { Pencil, ChevronRight, ArrowDownUp } from 'lucide-react'
import { p as MyText, pInput as MyTextInput, AppContainer, Checkbox } from 'web-components'
import { trpc } from '@/lib/trpc-client'
export const Route = createFileRoute('/dashboard/customize-app')({
component: CustomizeApp,
})
type ConstantFormData = Record<string, any>
const CONST_LABELS: Record<string, string> = {
minRegularOrderValue: 'Minimum Regular Order Value',
freeDeliveryThreshold: 'Free Delivery Threshold',
deliveryCharge: 'Delivery Charge',
flashFreeDeliveryThreshold: 'Flash Free Delivery Threshold',
flashDeliveryCharge: 'Flash Delivery Charge',
platformFeePercent: 'Platform Fee Percent',
taxRate: 'Tax Rate',
minOrderAmountForCoupon: 'Minimum Order Amount for Coupon',
maxCouponDiscount: 'Maximum Coupon Discount',
flashDeliverySlotId: 'Flash Delivery Slot ID',
readableOrderId: 'Readable Order ID',
versionNum: 'Version Number',
playStoreUrl: 'Play Store URL',
appStoreUrl: 'App Store URL',
popularItems: 'Popular Items',
allItemsOrder: 'All Items Order',
isFlashDeliveryEnabled: 'Enable Flash Delivery',
supportMobile: 'Support Mobile',
supportEmail: 'Support Email',
}
const CONST_VISIBILITY: Record<string, boolean> = {
minRegularOrderValue: true,
freeDeliveryThreshold: true,
deliveryCharge: true,
flashFreeDeliveryThreshold: true,
flashDeliveryCharge: true,
platformFeePercent: true,
taxRate: false,
minOrderAmountForCoupon: true,
maxCouponDiscount: false,
flashDeliverySlotId: true,
readableOrderId: false,
versionNum: true,
playStoreUrl: true,
appStoreUrl: true,
popularItems: true,
allItemsOrder: true,
isFlashDeliveryEnabled: true,
supportMobile: true,
supportEmail: true,
tester: false,
}
interface ConstantInputProps {
constantKey: string
value: any
setFieldValue: (field: string, value: any) => void
router: any
}
const ConstantInput: React.FC<ConstantInputProps> = ({ constantKey, value, setFieldValue, router }) => {
const fieldName = constantKey
if (constantKey === 'popularItems') {
return (
<div>
<MyText className="mb-2 text-sm font-medium text-gray-700">
{CONST_LABELS[constantKey] || constantKey}
</MyText>
<button
onClick={() => router.push('/dashboard/customize-app/popular')}
className="flex flex-row items-center justify-center rounded-lg border-2 border-dashed border-blue-200 bg-blue-50 p-4"
>
<Pencil size={20} color="#3b82f6" className="mr-2" />
<MyText className="font-medium text-blue-700">
Manage Popular Items ({Array.isArray(value) ? value.length : 0} items)
</MyText>
<ChevronRight size={20} color="#3b82f6" className="ml-2" />
</button>
</div>
)
}
if (constantKey === 'allItemsOrder') {
return (
<div>
<MyText className="mb-2 text-sm font-medium text-gray-700">
{CONST_LABELS[constantKey] || constantKey}
</MyText>
<button
onClick={() => router.push('/dashboard/customize-app/ordering')}
className="flex flex-row items-center justify-center rounded-lg border-2 border-dashed border-green-200 bg-green-50 p-4"
>
<ArrowDownUp size={20} color="#16a34a" className="mr-2" />
<MyText className="font-medium text-green-700">
Manage All Visible Items ({Array.isArray(value) ? value.length : 0} items)
</MyText>
<ChevronRight size={20} color="#16a34a" className="ml-2" />
</button>
</div>
)
}
if (typeof value === 'boolean') {
return (
<div>
<MyText className="mb-2 text-sm font-medium text-gray-700">
{CONST_LABELS[constantKey] || constantKey}
</MyText>
<div className="flex flex-row items-center">
<Checkbox
checked={value}
onPress={() => setFieldValue(fieldName, !value)}
size={28}
/>
<MyText className="ml-3 text-gray-700">
{value ? 'Enabled' : 'Disabled'}
</MyText>
</div>
</div>
)
}
if (typeof value === 'number') {
return (
<MyTextInput
topLabel={CONST_LABELS[constantKey] || constantKey}
value={value.toString()}
onChange={(e) => {
const numValue = parseFloat(e.target.value)
setFieldValue(fieldName, isNaN(numValue) ? 0 : numValue)
}}
type="number"
placeholder="Enter number"
/>
)
}
if (Array.isArray(value)) {
return (
<MyTextInput
topLabel={CONST_LABELS[constantKey] || constantKey}
value={value.join(', ')}
onChange={(e) => {
const arrayValue = e.target.value.split(',').map((s) => s.trim()).filter((s) => s.length > 0)
setFieldValue(fieldName, arrayValue)
}}
placeholder="Enter comma separated values"
/>
)
}
return (
<MyTextInput
topLabel={CONST_LABELS[constantKey] || constantKey}
value={value}
onChange={(e) => {
setFieldValue(fieldName, e.target.value)
}}
placeholder="Enter value"
/>
)
}
function CustomizeApp() {
const navigate = useNavigate()
const router = {
push: (to: string) => navigate({ to: to as any }),
}
const { data: constants, isLoading: isLoadingConstants, refetch } = trpc.admin.const.getConstants.useQuery()
const { mutate: updateConstants, isPending: isUpdating } = trpc.admin.const.updateConstants.useMutation()
const handleSubmit = (values: ConstantFormData) => {
const changedConstants = (constants || []).filter((constant) => {
const nextValue = values[constant.key]
return JSON.stringify(nextValue) !== JSON.stringify(constant.value)
}).map((constant) => ({
key: constant.key,
value: values[constant.key],
}))
if (changedConstants.length === 0) {
window.alert('No constants were modified.')
return
}
updateConstants(
{ constants: changedConstants },
{
onSuccess: (response) => {
window.alert(`Updated ${response.updatedCount} constants successfully.`)
refetch()
},
onError: (error) => {
window.alert('Failed to update constants. Please try again.')
console.error('Update constants error:', error)
},
}
)
}
if (isLoadingConstants) {
return (
<div className="flex flex-1 items-center justify-center">
<MyText className="text-gray-500">Loading constants...</MyText>
</div>
)
}
if (!constants) {
return (
<div className="flex flex-1 items-center justify-center">
<MyText className="text-gray-500">Failed to load constants</MyText>
</div>
)
}
const initialValues: ConstantFormData = constants.reduce((acc, constant) => {
acc[constant.key] = constant.value ?? ''
return acc
}, {} as ConstantFormData)
return (
<AppContainer>
<div className="flex-1 bg-gray-50 p-4">
<MyText className="mb-4 text-xl font-bold">Customize App Constants</MyText>
<MyText className="mb-6 text-gray-600">
Modify application constants. Changes will take effect immediately.
</MyText>
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
{({ handleSubmit, values, setFieldValue }) => (
<div>
{constants.map((constant) => {
if (!CONST_VISIBILITY[constant.key]) {
return null
}
return (
<div key={constant.key} className="mb-4">
<ConstantInput
constantKey={constant.key}
value={values[constant.key]}
setFieldValue={setFieldValue}
router={router}
/>
</div>
)
})}
<button
onClick={() => handleSubmit()}
disabled={isUpdating}
className={`mt-6 rounded-lg bg-blue-500 p-4 ${isUpdating ? 'opacity-50' : ''}`}
>
<MyText className="text-center font-semibold text-white">
{isUpdating ? 'Updating...' : 'Save Changes'}
</MyText>
</button>
</div>
)}
</Formik>
</div>
</AppContainer>
)
}

View file

@ -0,0 +1,305 @@
import React from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import {
ShoppingBag,
Route as RouteIcon,
CalendarClock,
CirclePlus,
TriangleAlert,
Package,
DollarSign,
Tag,
Shapes,
Store,
TicketPercent,
Settings,
Users,
Megaphone,
ReceiptText,
ChevronRight,
} from 'lucide-react'
import { p as MyText } from 'web-components'
import { trpc } from '../lib/trpc-client'
import { useNavigationTarget } from '../lib/navigation-target'
import { theme } from '../lib/theme'
export const Route = createFileRoute('/dashboard/')({
component: Dashboard,
})
interface MenuItem {
title: string
icon: React.ReactNode
description?: string
route: string
category: 'quick' | 'products' | 'orders' | 'marketing' | 'settings' | 'users'
iconColor?: string
iconBg?: string
badgeCount?: number
onPress?: () => void
testID?: string
}
const MenuItemComponent: React.FC<{ item: MenuItem; go: (item: MenuItem) => void }> = ({
item,
go,
}) => {
return (
<button
onClick={() => go(item)}
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 active:bg-gray-50"
>
<span
className="mr-4 flex h-12 w-12 items-center justify-center rounded-xl"
style={{ backgroundColor: item.iconBg }}
>
{item.icon}
</span>
<span className="flex min-w-0 flex-1 flex-col">
<MyText className="mb-0.5 text-base font-bold text-gray-900">{item.title}</MyText>
{item.description && <MyText className="text-xs text-gray-500">{item.description}</MyText>}
</span>
{item.badgeCount ? (
<span className="rounded-full bg-red-500 px-2 py-1">
<MyText className="text-xs font-bold text-white">{item.badgeCount}</MyText>
</span>
) : null}
<ChevronRight size={24} color="#D1D5DB" className="ml-2" />
</button>
)
}
function Dashboard() {
const navigate = useNavigate()
const { setNavigationTarget } = useNavigationTarget()
const { data: essentialsData } = trpc.admin.user.getEssentials.useQuery()
const go = (item: MenuItem) => {
if (item.onPress) {
item.onPress()
} else {
navigate({ to: item.route as any })
}
}
const handleManageOrdersPress = () => {
setNavigationTarget('/dashboard/orders/list')
navigate({ to: '/dashboard/orders' })
}
const handleDeliverySequencesPress = () => {
setNavigationTarget('/dashboard/orders/sequence')
navigate({ to: '/dashboard/orders' })
}
const menuItems: MenuItem[] = [
{
title: 'Manage Orders',
icon: <ShoppingBag size={24} color="#10B981" />,
description: 'View and manage customer orders',
route: '/dashboard/orders',
category: 'orders',
iconColor: '#10B981',
iconBg: '#D1FAE5',
onPress: handleManageOrdersPress,
},
{
title: 'Delivery Sequences',
icon: <RouteIcon size={24} color="#8B5CF6" />,
description: 'Plan and optimize delivery routes',
route: '/dashboard/orders/sequence',
category: 'orders',
iconColor: '#8B5CF6',
iconBg: '#EDE9FE',
onPress: handleDeliverySequencesPress,
},
{
title: 'Delivery Slots',
icon: <CalendarClock size={24} color="#06B6D4" />,
description: 'Configure delivery times',
route: '/dashboard/slots',
category: 'quick',
iconColor: '#06B6D4',
iconBg: '#CFFAFE',
testID: 'delivery-slots-menu-item',
},
{
title: 'Add Product',
icon: <CirclePlus size={24} color={theme.colors.brand500} />,
description: 'Create a new product listing',
route: '/dashboard/products/new',
category: 'quick',
iconColor: theme.colors.brand500,
iconBg: theme.colors.brand50,
},
{
title: 'Complaints',
icon: <TriangleAlert size={24} color="#F59E0B" />,
description: 'Handle customer complaints',
route: '/dashboard/complaints',
category: 'quick',
iconColor: '#F59E0B',
iconBg: '#FEF3C7',
badgeCount: essentialsData?.unresolvedComplaints,
},
{
title: 'Products',
icon: <Package size={24} color="#6366F1" />,
description: 'Manage product catalog',
route: '/dashboard/products',
category: 'products',
iconColor: '#6366F1',
iconBg: '#E0E7FF',
},
{
title: 'Prices Overview',
icon: <DollarSign size={24} color="#059669" />,
description: 'Update product pricing',
route: '/dashboard/prices',
category: 'products',
iconColor: '#059669',
iconBg: '#ECFDF5',
},
{
title: 'Product Tags',
icon: <Tag size={24} color="#EC4899" />,
description: 'Organize with tags',
route: '/dashboard/product-tags',
category: 'products',
iconColor: '#EC4899',
iconBg: '#FCE7F3',
},
{
title: 'Product Groupings',
icon: <Shapes size={24} color="#8B5CF6" />,
description: 'Group products together',
route: '/dashboard/product-groupings',
category: 'products',
iconColor: '#8B5CF6',
iconBg: '#F3E8FF',
},
{
title: 'Stores',
icon: <Store size={24} color="#14B8A6" />,
description: 'Manage store locations',
route: '/dashboard/stores',
category: 'settings',
iconColor: '#14B8A6',
iconBg: '#CCFBF1',
},
{
title: 'Coupons',
icon: <TicketPercent size={24} color="#F97316" />,
description: 'Create discount coupons',
route: '/dashboard/coupons',
category: 'marketing',
iconColor: '#F97316',
iconBg: '#FFEDD5',
},
{
title: 'App Constants',
icon: <Settings size={24} color="#7C3AED" />,
description: 'Customize app settings',
route: '/dashboard/customize-app',
category: 'settings',
iconColor: '#7C3AED',
iconBg: '#F3E8FF',
},
{
title: 'User Management',
icon: <Users size={24} color="#0EA5E9" />,
description: 'View and manage all users',
route: '/dashboard/users',
category: 'users',
iconColor: '#0EA5E9',
iconBg: '#E0F2FE',
},
{
title: 'Send Notifications',
icon: <Megaphone size={24} color="#8B5CF6" />,
description: 'Send push notifications to users',
route: '/dashboard/notifications',
category: 'users',
iconColor: '#8B5CF6',
iconBg: '#F3E8FF',
},
]
const quickActions = menuItems.filter((item) => item.category === 'quick')
const categories = [
{ key: 'orders', title: 'Orders', icon: <ReceiptText size={18} color={theme.colors.gray600} /> },
{
key: 'products',
title: 'Products & Inventory',
icon: <Package size={18} color={theme.colors.gray600} />,
},
{
key: 'marketing',
title: 'Marketing & Promotions',
icon: <Megaphone size={18} color={theme.colors.gray600} />,
},
{ key: 'users', title: 'User Management', icon: <Users size={18} color={theme.colors.gray600} /> },
{
key: 'settings',
title: 'Settings & Configuration',
icon: <Settings size={18} color={theme.colors.gray600} />,
},
]
return (
<div className="min-h-full flex-1 bg-gray-50">
{/* Quick Actions */}
<div className="mb-4 mt-2 px-6">
<MyText className="mb-4 text-lg font-bold text-gray-900">Quick Actions</MyText>
<div className="flex flex-row flex-wrap gap-3">
{quickActions.map((item) => (
<button
key={`quick-${item.route}`}
data-testid={item.testID}
aria-label={item.testID}
onClick={() => go(item)}
className="flex flex-1 flex-col items-center rounded-xl border border-gray-100 bg-white p-3 shadow-sm transition-colors hover:bg-gray-50 active:bg-gray-50"
>
<span
className="relative mb-2 flex h-10 w-10 items-center justify-center rounded-lg"
style={{ backgroundColor: item.iconBg }}
>
{item.icon}
{item.badgeCount ? (
<span className="absolute -right-1 -top-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1">
<MyText className="text-[10px] font-bold text-white">{item.badgeCount}</MyText>
</span>
) : null}
</span>
<MyText className="text-center text-xs font-bold text-gray-900" numberOfLines={2}>{item.title}</MyText>
</button>
))}
</div>
</div>
{/* Categorized Menu Items */}
<div className="px-6 pb-6">
{categories.map((category) => {
const categoryItems = menuItems.filter((item) => item.category === category.key)
if (categoryItems.length === 0) return null
return (
<div key={category.key} className="mb-6">
<div className="mb-3 flex flex-row items-center">
<span className="mr-3 flex h-8 w-8 items-center justify-center rounded-lg bg-gray-100">
{category.icon}
</span>
<MyText className="text-base font-bold text-gray-700">{category.title}</MyText>
</div>
{categoryItems.map((item) => (
<MenuItemComponent key={`menu-${item.route}`} item={item} go={go} />
))}
</div>
)
})}
</div>
</div>
)
}

View file

@ -0,0 +1,168 @@
import React, { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { ChevronLeft } from 'lucide-react'
import { AppContainer, p as MyText, pInput as MyTextInput } from 'web-components'
import { trpc } from '@/lib/trpc-client'
import type { UserMiniInfo } from '@packages/shared'
import { MultiSelect } from '../components/MultiSelect'
export const Route = createFileRoute('/dashboard/notifications')({
component: SendNotifications,
})
type User = Omit<UserMiniInfo, 'name'> & {
name: string | null
isEligibleForNotif: boolean
}
function SendNotifications() {
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([])
const [title, setTitle] = useState('')
const [message, setMessage] = useState('')
const [searchQuery, setSearchQuery] = useState('')
const { data: usersData, isLoading: isLoadingUsers } = trpc.admin.user.getUsersForNotification.useQuery({
search: searchQuery,
})
const sendNotification = trpc.admin.user.sendNotification.useMutation({
onSuccess: () => {
window.alert('Success: Notification sent successfully!')
setSelectedUserIds([])
setTitle('')
setMessage('')
},
onError: (error: any) => {
window.alert(`Error: ${error.message || 'Failed to send notification'}`)
},
})
const eligibleUsers = usersData?.users.filter((u: User) => u.isEligibleForNotif) || []
const dropdownOptions = eligibleUsers.map((user: User) => ({
label: `${user.mobile || 'No Mobile'}${user.name ? ` - ${user.name}` : ''}`,
value: user.id,
}))
const handleSend = async () => {
if (title.trim().length === 0) {
window.alert('Error: Please enter a title')
return
}
if (message.trim().length === 0) {
window.alert('Error: Please enter a message')
return
}
const isSendingToAll = selectedUserIds.length === 0
if (isSendingToAll) {
const confirmed = window.confirm(
'Send to All Users? This will send the notification to all users with push tokens. Continue?'
)
if (!confirmed) return
}
try {
await sendNotification.mutateAsync({
userIds: selectedUserIds,
title: title.trim(),
text: message.trim(),
})
} catch (error: any) {
window.alert(`Error: ${error.message || 'Failed to send notification'}`)
}
}
const getDisplayText = () => {
if (selectedUserIds.length === 0) return 'All Users'
if (selectedUserIds.length === 1) {
const user = eligibleUsers.find((u: User) => u.id === selectedUserIds[0])
return user ? `${user.mobile}${user.name ? ` - ${user.name}` : ''}` : '1 user selected'
}
return `${selectedUserIds.length} users selected`
}
if (isLoadingUsers) {
return (
<AppContainer>
<div className="flex flex-1 items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
<MyText className="mt-4 text-gray-500">Loading users...</MyText>
</div>
</AppContainer>
)
}
const isSendDisabled = sendNotification.isPending || title.trim().length === 0 || message.trim().length === 0
return (
<AppContainer>
<div className="flex-1 bg-gray-50">
<div className="flex-row flex items-center border-b border-gray-200 bg-white px-4 py-4">
<button
onClick={() => window.history.back()}
className="-ml-4 p-2"
aria-label="Go back"
>
<ChevronLeft size={24} color="#374151" />
</button>
<MyText className="ml-2 text-xl font-bold text-gray-900">Send Notifications</MyText>
</div>
<div className="flex-1 p-4">
<div className="mb-4 rounded-xl border border-gray-100 bg-white p-4 shadow-sm">
<MyText className="mb-3 text-base font-bold text-gray-900">Title</MyText>
<MyTextInput
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter notification title..."
className="text-gray-900"
/>
</div>
<div className="mb-4 rounded-xl border border-gray-100 bg-white p-4 shadow-sm">
<MyText className="mb-3 text-base font-bold text-gray-900">Message</MyText>
<MyTextInput
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Enter notification message..."
multiline
numberOfLines={4}
className="text-gray-900"
/>
</div>
<div className="mb-4 rounded-xl border border-gray-100 bg-white p-4 shadow-sm">
<MyText className="mb-3 text-base font-bold text-gray-900">Select Users (Optional)</MyText>
<MultiSelect
label="Select Users"
value={selectedUserIds}
options={dropdownOptions}
onValueChange={(value) => setSelectedUserIds(value as number[])}
onSearch={(query: string) => setSearchQuery(query)}
/>
<MyText className="mt-2 text-sm text-gray-500">
{getDisplayText()}
</MyText>
<MyText className="mt-1 text-xs text-blue-600">
Leave empty to send to all users
</MyText>
</div>
<button
onClick={handleSend}
disabled={isSendDisabled}
className={`items-center rounded-xl py-4 shadow-sm ${
isSendDisabled ? 'bg-gray-300' : 'bg-blue-600'
}`}
>
<MyText className="text-base font-bold text-white">
{sendNotification.isPending ? 'Sending...' : selectedUserIds.length === 0 ? 'Send to All Users' : 'Send Notification'}
</MyText>
</button>
</div>
</div>
</AppContainer>
)
}

View file

@ -0,0 +1,824 @@
import React, { useState } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { AppContainer, p as MyText, BottomDialog, pInput as MyInput } from 'web-components'
import { trpc } from '@/lib/trpc-client'
import { ErrorToast } from '@/services/toaster'
import { theme } from '@/lib/theme'
import {
Clock,
Zap,
CircleAlert,
Check,
Package,
Truck,
ChevronRight,
User,
Phone,
Mail,
MapPin,
CircleX,
TicketPercent,
StickyNote,
Info,
Percent,
Banknote
} from 'lucide-react'
import dayjs from 'dayjs'
import CancelOrderDialog from '../components/CancelOrderDialog'
import { UserIncidentsView } from '../components/UserIncidentsView'
export const Route = createFileRoute('/dashboard/orders/$id')({
component: OrderDetails,
})
function OrderDetails() {
const { id } = Route.useParams()
const navigate = useNavigate()
const [generateCouponDialogOpen, setGenerateCouponDialogOpen] =
useState(false)
const [initiateRefundDialogOpen, setInitiateRefundDialogOpen] =
useState(false)
const [cancelDialogOpen, setCancelDialogOpen] = useState(false)
const [refundType, setRefundType] = useState<'percent' | 'amount'>('percent')
const [refundValue, setRefundValue] = useState('100')
const [updatingItems, setUpdatingItems] = useState<Set<number>>(new Set())
const {
data: orderData,
isLoading,
error,
refetch,
} = trpc.admin.order.getOrderDetails.useQuery(
{ orderId: id ? parseInt(id) : 0 },
{ enabled: !!id }
)
const generateCouponMutation =
trpc.admin.coupon.generateCancellationCoupon.useMutation({
onSuccess: (coupon) => {
window.alert(
`Success: Refund coupon generated successfully!\n\nCode: ${coupon.couponCode
}\nValue: ${coupon.flatDiscount}\nExpires: ${coupon.validTill
? new Date(coupon.validTill).toLocaleDateString()
: 'N/A'
}`
)
setGenerateCouponDialogOpen(false)
},
onError: (error: any) => {
ErrorToast(
error.message || 'Failed to generate refund coupon'
)
},
})
const initiateRefundMutation = trpc.admin.payments.initiateRefund.useMutation(
{
onSuccess: () => {
window.alert(
'Success: Refund initiated successfully!\n\nAmount: '
// `Refund initiated successfully!\n\nAmount: ₹${result.amount}\nStatus: ${result.status}`
)
setInitiateRefundDialogOpen(false)
},
onError: (error: any) => {
ErrorToast(error.message || 'Failed to initiate refund')
},
}
)
const updateItemPackagingMutation = trpc.admin.order.updateOrderItemPackaging.useMutation({
onSuccess: () => {
// Refetch order details to get updated packaging status
refetch()
},
onError: (error: any) => {
ErrorToast(error.message || 'Failed to update packaging status')
},
})
const removeDeliveryChargeMutation = trpc.admin.order.removeDeliveryCharge.useMutation({
onSuccess: () => {
window.alert('Success: Delivery charge has been removed')
refetch()
},
onError: (error: any) => {
ErrorToast(error.message || 'Failed to remove delivery charge')
},
})
if (isLoading) {
return (
<div className="flex flex-1 flex-col items-center justify-center bg-gray-50">
<MyText className="font-medium text-gray-500">
Loading order details...
</MyText>
</div>
)
}
if (error || !orderData) {
return (
<div className="flex flex-1 flex-col items-center justify-center bg-gray-50 p-6">
<div className="w-full items-center rounded-2xl bg-white p-6 text-center shadow-sm">
<CircleAlert size={48} color="#EF4444" className="mx-auto" />
<MyText className="mb-2 mt-4 block text-xl font-bold text-gray-900">
Oops!
</MyText>
<MyText className="mb-6 block text-center text-gray-500">
{error?.message || 'Failed to load order details'}
</MyText>
<button
onClick={() => window.history.back()}
className="w-full items-center rounded-xl bg-gray-900 px-6 py-3"
>
<MyText className="font-bold text-white">Go Back</MyText>
</button>
</div>
</div>
)
}
const order = orderData
// Calculate subtotal and discount for order summary
const subtotal = order.items.reduce((sum, item) => sum + item.amount, 0)
const discountAmount = order.discountAmount || 0
const handleGenerateCoupon = () => {
generateCouponMutation.mutate({ orderId: order.id })
}
const handleInitiateRefund = () => {
const value = parseFloat(refundValue)
if (isNaN(value) || value <= 0) {
ErrorToast('Please enter a valid refund value')
return
}
const mutationData: any = {
orderId: order.id,
}
if (refundType === 'percent') {
if (value > 100) {
ErrorToast('Refund percentage cannot exceed 100%')
return
}
mutationData.refundPercent = value
} else {
mutationData.refundAmount = value
}
initiateRefundMutation.mutate(mutationData)
setInitiateRefundDialogOpen(false)
}
const handlePackagingToggle = (itemId: number, field: 'isPackaged' | 'isPackageVerified', value: boolean) => {
// Add item to updating set to disable UI
setUpdatingItems((prev) => new Set(prev).add(itemId))
updateItemPackagingMutation.mutate(
{
orderItemId: itemId,
[field]: value,
},
{
onSettled: () => {
// Remove item from updating set
setUpdatingItems((prev) => {
const newSet = new Set(prev)
newSet.delete(itemId)
return newSet
})
},
}
)
}
const getStatusColor = (status: string) => {
switch (status) {
case 'delivered':
return 'text-green-600 bg-green-50 border-green-100'
case 'cancelled':
return 'text-red-600 bg-red-50 border-red-100'
default:
return 'text-yellow-600 bg-yellow-50 border-yellow-100'
}
}
const statusStyle = getStatusColor(order.status)
return (
<AppContainer>
<div className="flex-1 pb-32">
{/* Order ID & Status Card */}
<div className="mb-4 rounded-2xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-4 flex flex-row items-start justify-between">
<div>
<MyText className="mb-1 block text-sm text-gray-500">Order ID</MyText>
<MyText className="block text-2xl font-bold text-gray-900">
#{order.readableId}
</MyText>
</div>
<div className="flex flex-row items-center gap-2">
<span className={`rounded-full border px-3 py-1.5 ${statusStyle}`}>
<MyText
className={`text-xs font-bold uppercase tracking-wider ${statusStyle.split(' ')[0]
}`}
>
{order.status}
</MyText>
</span>
{order.isFlashDelivery && (
<span className="rounded-full border border-amber-200 bg-amber-100 px-2 py-1">
<MyText className="text-[10px] font-black uppercase text-amber-700"></MyText>
</span>
)}
</div>
</div>
<div className={`mb-2 flex flex-row items-center rounded-xl bg-gray-50 p-3 ${order.isFlashDelivery ? 'bg-amber-50' : ''}`}>
<Clock size={20} color={order.isFlashDelivery ? '#D97706' : theme.colors.brand800} />
<MyText className={`ml-2 font-medium ${order.isFlashDelivery ? 'text-amber-800' : 'text-brand-800'}`}>
{order.isFlashDelivery ? '1 Hr Delivery:' : 'Delivery at:'} {order.isFlashDelivery
? dayjs(order.createdAt).add(30, 'minutes').format('MMM DD, YYYY • h:mm A')
: order.slotInfo?.time ? dayjs(order.slotInfo.time).format('MMM DD, YYYY • h:mm A') : 'Not scheduled'}
</MyText>
</div>
{order.isFlashDelivery && (
<div className="flex flex-row items-center rounded-xl border border-amber-200 bg-amber-50 p-3">
<Zap size={18} color="#D97706" />
<MyText className="ml-2 text-sm font-bold text-amber-800">
1 Hr Delivery High Priority
</MyText>
</div>
)}
<div className="flex flex-row items-center rounded-xl bg-gray-50 p-3">
<Clock size={20} color="#6B7280" />
<MyText className="ml-2 font-medium text-gray-600">
Placed on {dayjs(order.createdAt).format('MMM DD, YYYY • h:mm A')}
</MyText>
</div>
</div>
{/* Cancellation Reason */}
{order.status === 'cancelled' && order.cancelReason && (
<div className="mb-4 rounded-2xl border border-red-100 bg-red-50 p-5">
<div className="mb-2 flex flex-row items-center">
<CircleX size={18} color="#DC2626" />
<MyText className="ml-2 text-sm font-bold text-red-800">
Cancellation Reason
</MyText>
</div>
<MyText className="text-sm leading-5 text-red-900">
{order.cancelReason}
</MyText>
</div>
)}
{/* Order Progress (Simplified Timeline) */}
{order.status !== 'cancelled' && (
<div className="mb-4 rounded-2xl border border-gray-100 bg-white p-5 shadow-sm">
<MyText className="mb-4 block text-base font-bold text-gray-900">
Order Status
</MyText>
<div className="flex flex-row items-center justify-between px-2">
{/* Placed */}
<div className="items-center">
<span className="mb-2 flex h-8 w-8 items-center justify-center rounded-full bg-green-100">
<Check size={16} color="#10B981" />
</span>
<MyText className="text-xs font-medium text-gray-900">
Placed
</MyText>
</div>
<div className="-mt-6 mx-2 h-0.5 flex-1 bg-green-200" />
{/* Packaged */}
<div className="items-center">
<span
className={`mb-2 flex h-8 w-8 items-center justify-center rounded-full ${order.isPackaged ? 'bg-green-100' : 'bg-gray-100'
}`}
>
<Package
size={16}
color={order.isPackaged ? '#10B981' : '#9CA3AF'}
/>
</span>
<MyText
className={`text-xs font-medium ${order.isPackaged ? 'text-gray-900' : 'text-gray-400'
}`}
>
Packaged
</MyText>
</div>
<div
className={`-mt-6 mx-2 h-0.5 flex-1 ${order.isPackaged ? 'bg-green-200' : 'bg-gray-100'
}`}
/>
{/* Delivered */}
<div className="items-center">
<span
className={`mb-2 flex h-8 w-8 items-center justify-center rounded-full ${order.isDelivered ? 'bg-green-100' : 'bg-gray-100'
}`}
>
{order.isFlashDelivery && order.isDelivered ? (
<Zap size={16} color="#D97706" />
) : (
<Truck
size={16}
color={order.isDelivered ? '#10B981' : '#9CA3AF'}
/>
)}
</span>
<div className="items-center">
<MyText
className={`text-xs font-medium ${order.isDelivered ? 'text-gray-900' : 'text-gray-400'
}`}
>
{order.isFlashDelivery && order.isDelivered ? 'Flash Delivered' : 'Delivered'}
</MyText>
{order.isFlashDelivery && (
<MyText className="mt-0.5 block text-[9px] font-bold text-amber-600">
30-Min
</MyText>
)}
</div>
</div>
</div>
</div>
)}
{/* Customer Details */}
<button
onClick={() => order.userId && navigate({ to: '/dashboard/users/$id', params: { id: String(order.userId) } })}
className="mb-4 w-full rounded-2xl border border-gray-100 bg-white p-5 text-left shadow-sm"
>
<div className="mb-4 flex flex-row items-center justify-between">
<MyText className="text-base font-bold text-gray-900">
Customer Details
</MyText>
<ChevronRight size={20} color="#6B7280" />
</div>
<div className="mb-4 flex flex-row items-center">
<span className="mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-blue-50">
<User size={20} color="#3B82F6" />
</span>
<div>
<MyText className="block text-sm font-bold text-gray-900">
{order.customerName || 'Unknown User'}
</MyText>
<MyText className="block text-xs text-gray-500">Customer</MyText>
</div>
</div>
<div>
<div className="flex flex-row items-start">
<span className="mt-0.5 w-6">
<Phone size={16} color="#6B7280" />
</span>
<MyText className="flex-1 text-sm text-gray-600">
{order.customerMobile}
</MyText>
</div>
{order.customerEmail && (
<div className="mt-2 flex flex-row items-start">
<span className="mt-0.5 w-6">
<Mail size={16} color="#6B7280" />
</span>
<MyText className="flex-1 text-sm text-gray-600">
{order.customerEmail}
</MyText>
</div>
)}
<div className="mt-2 flex flex-row items-start border-t border-gray-50 pt-3">
<span className="mt-0.5 w-6">
<MapPin size={16} color="#6B7280" />
</span>
<div className="flex-1">
<MyText className="text-sm leading-5 text-gray-600">
{order.address.name} {' '}
{order.address.line1}
{order.address.line2 ? `, ${order.address.line2}` : ''}
{`\n${order.address.city}, ${order.address.state} - ${order.address.pincode}`}
</MyText>
</div>
</div>
</div>
</button>
{/* Order Items */}
<div className="mb-4 rounded-2xl border border-gray-100 bg-white p-5 shadow-sm">
<MyText className="mb-4 block text-base font-bold text-gray-900">
Items Ordered
</MyText>
{order.items.map((item, index) => (
<div
key={index}
className={`flex flex-row items-center border-b border-gray-50 py-3 ${index === order.items.length - 1 ? 'border-b-0' : ''
}`}
>
<span className="mr-3 flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100">
<Package size={14} color="#6B7280" />
</span>
<div className="flex-1">
<MyText className="block text-sm font-bold text-gray-900">
{item.name}
</MyText>
<MyText className="block text-xs text-gray-500">
{Number(item.quantity)} x {item.unit} × {item.price}
</MyText>
<div className="mt-2 flex flex-row items-center gap-3">
<button
className="flex flex-row items-center disabled:opacity-50"
onClick={() => handlePackagingToggle(item.id, 'isPackaged', !item.isPackaged)}
disabled={updatingItems.has(item.id)}
>
<Check
size={16}
color={updatingItems.has(item.id) ? '#F59E0B' : item.isPackaged ? '#10B981' : '#9CA3AF'}
/>
<MyText className={`ml-1 text-xs ${updatingItems.has(item.id) ? 'text-yellow-700' : item.isPackaged ? 'text-green-700' : 'text-gray-500'}`}>
Packaged
</MyText>
</button>
<button
className="flex flex-row items-center disabled:opacity-50"
onClick={() => handlePackagingToggle(item.id, 'isPackageVerified', !item.isPackageVerified)}
disabled={updatingItems.has(item.id)}
>
<Check
size={16}
color={updatingItems.has(item.id) ? '#F59E0B' : item.isPackageVerified ? '#10B981' : '#9CA3AF'}
/>
<MyText className={`ml-1 text-xs ${updatingItems.has(item.id) ? 'text-yellow-700' : item.isPackageVerified ? 'text-green-700' : 'text-gray-500'}`}>
Pkg Verified
</MyText>
</button>
</div>
</div>
<MyText className="text-sm font-bold text-gray-900">
{item.amount}
</MyText>
</div>
))}
<div className="mt-4 border-t border-gray-100 pt-4">
<div className="mb-2 flex flex-row items-center justify-between">
<MyText className="text-base font-bold text-gray-900">
Subtotal ({order.items.length} items)
</MyText>
<MyText className="text-base font-bold text-gray-900">
{subtotal}
</MyText>
</div>
{discountAmount > 0 && (
<div className="mb-2 flex flex-row items-center justify-between">
<MyText className="font-medium text-emerald-600">
Discount
</MyText>
<MyText className="font-medium text-emerald-600">
-{discountAmount}
</MyText>
</div>
)}
{order.deliveryCharge > 0 && (
<div className="mb-2 flex flex-row items-center justify-between">
<div className="flex flex-row items-center">
<MyText className="font-medium text-gray-600">
Delivery Charge
</MyText>
<button
onClick={() => {
if (window.confirm('Remove Delivery Cost: Are you sure you want to remove the delivery cost from this order?')) {
removeDeliveryChargeMutation.mutate({ orderId: order.id })
}
}}
disabled={removeDeliveryChargeMutation.isPending}
className="ml-2 rounded-md bg-red-100 px-2 py-1 disabled:opacity-50"
>
<MyText className="text-xs font-bold text-red-600">
{removeDeliveryChargeMutation.isPending ? 'Removing...' : 'Remove'}
</MyText>
</button>
</div>
<MyText className="font-medium text-gray-600">
{order.deliveryCharge}
</MyText>
</div>
)}
<div className="flex flex-row items-center justify-between border-t border-gray-200 pt-2">
<div className="flex flex-row items-center">
<MyText className={`text-lg font-bold ${order.isFlashDelivery ? 'text-amber-900' : 'text-gray-900'}`}>
Total Amount
</MyText>
{order.isFlashDelivery && (
<span className="ml-2 rounded-full border border-amber-200 bg-amber-100 px-2 py-0.5">
<MyText className="text-[8px] font-black uppercase text-amber-700"> FLASH</MyText>
</span>
)}
</div>
<MyText className={`text-xl font-bold ${order.isFlashDelivery ? 'text-amber-700' : 'text-blue-600'}`}>
{order.totalAmount}
</MyText>
</div>
</div>
</div>
{/* 1 Hr Delivery Priority Notice */}
{order.isFlashDelivery && (
<div className="mb-4 rounded-2xl border border-amber-200 bg-gradient-to-r from-amber-50 to-yellow-50 p-5">
<div className="mb-2 flex flex-row items-center">
<Zap size={24} color="#D97706" />
<MyText className="ml-3 text-lg font-bold text-amber-900">
1 Hr Delivery Order
</MyText>
</div>
<MyText className="block text-sm leading-5 text-amber-800">
This is a high-priority flash delivery order that must be delivered within 1 hr of placement.
</MyText>
<div className="mt-3 rounded-lg border border-amber-300 bg-amber-100 p-3">
<MyText className="text-sm font-semibold text-amber-900">
Expected Delivery: {dayjs(order.createdAt).add(30, 'minutes').format('MMM DD, YYYY • h:mm A')}
</MyText>
</div>
</div>
)}
{/* Admin Notes */}
{order.adminNotes && (
<div className="mb-4 rounded-2xl border border-yellow-100 bg-yellow-50 p-5">
<div className="mb-2 flex flex-row items-center">
<StickyNote size={18} color="#D97706" />
<MyText className="ml-2 text-sm font-bold text-yellow-800">
Admin Notes
</MyText>
</div>
<MyText className="text-sm leading-5 text-yellow-900">
{order.adminNotes}
</MyText>
</div>
)}
{/* User Incidents Section */}
{order.userId && (
<UserIncidentsView
userId={order.userId}
orderId={order.id}
/>
)}
{/* Coupon Applied Section */}
{order.couponCode && (
<div className="mb-4 rounded-2xl border border-emerald-100 bg-emerald-50 p-5 shadow-sm">
<MyText className="mb-3 block text-lg font-bold text-emerald-800">
Coupon Applied
</MyText>
<div className="rounded-xl border border-emerald-200 bg-emerald-100 p-4">
<div className="mb-2 flex flex-row items-center">
<TicketPercent size={20} color="#10B981" />
<MyText className="ml-2 font-bold text-emerald-800">
{order.couponCode}
</MyText>
</div>
<MyText className="block text-sm text-emerald-700">
{order.couponDescription}
</MyText>
<div className="mt-3 flex flex-row items-center justify-between">
<MyText className="font-medium text-emerald-600">
Discount Applied:
</MyText>
<MyText className="text-lg font-bold text-emerald-800">
-{order.discountAmount}
</MyText>
</div>
</div>
</div>
)}
{/* Refund Coupon Section */}
{order.orderStatus?.refundCouponId && (
<div className="mb-4 rounded-2xl border border-blue-100 bg-blue-50 p-5 shadow-sm">
<MyText className="mb-3 block text-lg font-bold text-blue-800">
Refund Coupon
</MyText>
<div className="rounded-xl border border-blue-200 bg-blue-100 p-4">
<div className="mb-2 flex flex-row items-center">
<TicketPercent size={20} color="#2563EB" />
<MyText className="ml-2 font-bold text-blue-800">
{order.couponCode}
</MyText>
</div>
<MyText className="block text-sm text-blue-700">
Generated refund coupon for order cancellation
</MyText>
<div className="mt-3 flex flex-row items-center justify-between">
<MyText className="font-medium text-blue-600">
Value:
</MyText>
<MyText className="text-lg font-bold text-blue-800">
{order.couponData?.discountAmount}
</MyText>
</div>
</div>
</div>
)}
{/* TEMPORARILY HIDDEN: Refund Details Section */}
{/* WARNING: This section contains functional refund and cancellation management features */}
{/* DO NOT REMOVE - This is temporarily commented out for UI simplification */}
{/* When ready to re-enable, simply uncomment the entire block below */}
</div>
<div className="h-32" />
{/* Bottom Action Bar */}
<div className="fixed inset-x-0 bottom-0 border-t border-gray-100 bg-white p-4 pb-4">
{order.status !== 'cancelled' && (
<button
onClick={() => setCancelDialogOpen(true)}
className="mb-3 w-full items-center rounded-xl bg-red-500 py-4 shadow-lg"
>
<MyText className="text-base font-bold text-white">
Cancel Order
</MyText>
</button>
)}
<button
onClick={() => navigate({ to: '/dashboard/orders' })}
className="w-full items-center rounded-xl bg-gray-900 py-4 shadow-lg"
>
<MyText className="text-base font-bold text-white">
Manage Orders
</MyText>
</button>
</div>
{/* Generate Coupon Dialog */}
<BottomDialog
open={generateCouponDialogOpen}
onClose={() => setGenerateCouponDialogOpen(false)}
>
<div className="p-6">
<div className="mb-6 items-center">
<span className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100">
<TicketPercent size={24} color="#10B981" />
</span>
<MyText className="block text-center text-xl font-bold text-gray-900">
Generate Refund Coupon
</MyText>
<MyText className="mt-2 block text-center text-sm leading-5 text-gray-500">
Create a one-time use coupon for the customer equal to the order amount. Valid for 30 days.
</MyText>
</div>
<div className="mb-6 flex flex-row items-start rounded-xl border border-amber-100 bg-amber-50 p-4">
<Info size={20} color="#D97706" className="mt-0.5" />
<MyText className="ml-2 flex-1 text-sm leading-5 text-amber-800">
This only works for online payment orders. COD orders cannot generate refund coupons.
</MyText>
</div>
<div className="flex flex-row gap-3">
<button
className="flex-1 items-center rounded-xl bg-gray-100 py-3.5"
onClick={() => setGenerateCouponDialogOpen(false)}
>
<MyText className="font-bold text-gray-700">Cancel</MyText>
</button>
<button
className="flex-1 items-center rounded-xl bg-emerald-500 py-3.5 shadow-sm disabled:opacity-50"
onClick={handleGenerateCoupon}
disabled={generateCouponMutation.isPending}
>
<MyText className="font-bold text-white">
{generateCouponMutation.isPending
? 'Generating...'
: 'Generate Coupon'}
</MyText>
</button>
</div>
</div>
</BottomDialog>
{/* Initiate Refund Dialog */}
<BottomDialog
open={initiateRefundDialogOpen}
onClose={() => setInitiateRefundDialogOpen(false)}
>
<div className="p-6">
<div className="mb-6 items-center">
<span className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
<Banknote size={24} color="#EF4444" />
</span>
<MyText className="block text-center text-xl font-bold text-gray-900">
Initiate Refund
</MyText>
<MyText className="mt-2 block text-center text-sm text-gray-500">
Process a refund directly to the customer&apos;s source account via Razorpay.
</MyText>
</div>
{/* Refund Type Selection */}
<div className="mb-6">
<MyText className="mb-3 block text-sm font-bold uppercase tracking-wide text-gray-900">
Refund Type
</MyText>
<div className="flex flex-row gap-3">
<button
className={`flex-1 items-center rounded-xl border-2 p-4 ${refundType === 'percent'
? 'border-blue-500 bg-blue-50'
: 'border-gray-100 bg-gray-50'
}`}
onClick={() => setRefundType('percent')}
>
<Percent
size={24}
color={refundType === 'percent' ? '#3B82F6' : '#9CA3AF'}
className="mb-2"
/>
<MyText
className={`text-sm font-bold ${refundType === 'percent' ? 'text-blue-700' : 'text-gray-500'
}`}
>
Percentage
</MyText>
</button>
<button
className={`flex-1 items-center rounded-xl border-2 p-4 ${refundType === 'amount'
? 'border-blue-500 bg-blue-50'
: 'border-gray-100 bg-gray-50'
}`}
onClick={() => setRefundType('amount')}
>
<Banknote
size={24}
color={refundType === 'amount' ? '#3B82F6' : '#9CA3AF'}
className="mb-2"
/>
<MyText
className={`text-sm font-bold ${refundType === 'amount' ? 'text-blue-700' : 'text-gray-500'
}`}
>
Fixed Amount
</MyText>
</button>
</div>
</div>
{/* Refund Value Input */}
<div className="mb-6">
<MyInput
topLabel={`Refund ${refundType === 'percent' ? 'Percentage (%)' : 'Amount (₹)'
}`}
value={refundValue}
onChange={(e) => setRefundValue(e.target.value)}
type="number"
placeholder={refundType === 'percent' ? '100' : '0.00'}
className="bg-white"
/>
</div>
<div className="mb-6 flex flex-row items-start rounded-xl border border-amber-100 bg-amber-50 p-4">
<Info size={20} color="#D97706" className="mt-0.5" />
<MyText className="ml-2 flex-1 text-sm leading-5 text-amber-800">
For COD orders, refunds are processed immediately upon delivery confirmation.
</MyText>
</div>
<div className="flex flex-row gap-3">
<button
className="flex-1 items-center rounded-xl bg-gray-100 py-3.5"
onClick={() => setInitiateRefundDialogOpen(false)}
>
<MyText className="font-bold text-gray-700">Cancel</MyText>
</button>
<button
className="flex-1 items-center rounded-xl bg-red-500 py-3.5 shadow-sm disabled:opacity-50"
onClick={handleInitiateRefund}
disabled={initiateRefundMutation.isPending}
>
<MyText className="font-bold text-white">
{initiateRefundMutation.isPending
? 'Processing...'
: 'Confirm Refund'}
</MyText>
</button>
</div>
</div>
</BottomDialog>
{/* Cancel Order Dialog */}
<CancelOrderDialog
orderId={order.id}
open={cancelDialogOpen}
onClose={() => setCancelDialogOpen(false)}
onSuccess={refetch}
/>
</AppContainer>
)
}

View file

@ -0,0 +1,818 @@
import React, { useState, useEffect } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import {
p as MyText,
pInput as MyInput,
MyButton,
AppContainer,
BottomDialog,
Checkbox,
Dropdown,
SearchBar,
MyFlatList
} from 'web-components'
import { trpc } from '@/lib/trpc-client'
import { ErrorToast } from '@/services/toaster'
import dayjs from 'dayjs'
import {
Zap,
Clock,
TriangleAlert,
MapPin,
CalendarDays,
ShoppingCart,
StickyNote,
ShieldCheck,
EllipsisVertical,
ListFilter,
Loader2
} from 'lucide-react'
import CancelOrderDialog from '../components/CancelOrderDialog'
import { OrderOptionsMenu } from '../components/OrderOptionsMenu'
import type { AdminOrderListItem, AdminOrderListItemProduct } from '@packages/shared'
export const Route = createFileRoute('/dashboard/orders/list')({
validateSearch: (s: Record<string, unknown>) => ({
filter: (s.filter as string) ?? '',
slotId: (s.slotId as string) ?? ''
}),
component: Orders,
})
const AdminNotesForm = ({ orderId, existingNotes, onClose, refetch }: { orderId: string, existingNotes?: string | null, onClose: () => void, refetch: () => void }) => {
const [notesText, setNotesText] = useState(existingNotes || '')
const updateNotesMutation = trpc.admin.order.updateNotes.useMutation()
return (
<div className="p-4">
<MyText className="mb-4 text-lg font-bold">Admin Notes</MyText>
<MyInput
multiline
numberOfLines={4}
value={notesText}
onChange={(e) => setNotesText(e.target.value)}
placeholder="Enter admin notes..."
className="min-h-24"
/>
<div className="mt-4 flex flex-row">
<MyButton
variant="blue"
className="ml-2 flex-1"
disabled={updateNotesMutation.isPending}
onClick={() => {
updateNotesMutation.mutate(
{ orderId: parseInt(orderId), adminNotes: notesText },
{
onSuccess: () => {
onClose()
window.alert('Success: Notes updated successfully')
refetch()
},
onError: (error: any) => {
ErrorToast(error.message || 'Failed to update notes')
},
}
)
}}
>
<MyText className="text-center font-semibold text-white">Save</MyText>
</MyButton>
</div>
</div>
)
}
// Order list row — derived from shared AdminOrderListItem (serialized dates,
// coupon display fields, optional flags)
type OrderItemRow = Omit<
AdminOrderListItemProduct,
'id' | 'skuName' | 'features' | 'isPackaged' | 'isPackageVerified'
> & {
id?: number,
isPackaged?: boolean,
isPackageVerified?: boolean
}
type OrderType = Omit<
AdminOrderListItem,
'customerName' | 'customerMobile' | 'items' | 'createdAt' | 'adminNotes' | 'userNotes' | 'userNegativityScore'
> & {
customerName: string | null,
customerMobile?: string | null,
items: OrderItemRow[],
createdAt: string,
adminNotes?: string | null,
userNotes?: string | null,
userNegativityScore?: number,
couponCode?: string,
couponDescription?: string,
discountAmount?: number
}
const OrderItem = ({ order, refetch }: { order: OrderType, refetch: () => void }) => {
const navigate = useNavigate()
const [menuOpen, setMenuOpen] = useState(false)
const [itemsDialogOpen, setItemsDialogOpen] = useState(false)
const [notesDialogOpen, setNotesDialogOpen] = useState(false)
const [cancelDialogOpen, setCancelDialogOpen] = useState(false)
const [userNotesDialogOpen, setUserNotesDialogOpen] = useState(false)
const [adminNotesDialogOpen, setAdminNotesDialogOpen] = useState(false)
const [updatingItems, setUpdatingItems] = useState<Set<number>>(new Set())
const updatePackagedMutation = trpc.admin.order.updatePackaged.useMutation()
const updateDeliveredMutation = trpc.admin.order.updateDelivered.useMutation()
const updateItemPackagingMutation = trpc.admin.order.updateOrderItemPackaging.useMutation()
const handleOrderPress = () => {
navigate({ to: '/dashboard/orders/$id', params: { id: String(order.orderId) } })
}
const handleMenuOption = () => {
setMenuOpen(false)
navigate({ to: '/dashboard/orders/$id', params: { id: String(order.orderId) } })
}
const handleMarkPackaged = (isPackaged: boolean) => {
updatePackagedMutation.mutate(
{ orderId: order.orderId.toString(), isPackaged },
{
onSuccess: () => {
refetch()
},
}
)
}
const handleMarkDelivered = (isDelivered: boolean) => {
updateDeliveredMutation.mutate(
{ orderId: order.orderId.toString(), isDelivered },
{
onSuccess: () => {
refetch()
},
}
)
}
const handleItemPackagingToggle = (itemId: number, field: 'isPackaged' | 'isPackageVerified', value: boolean) => {
setUpdatingItems((prev) => new Set(prev).add(itemId))
updateItemPackagingMutation.mutate(
{ orderItemId: itemId, [field]: value },
{
onSuccess: () => {
setUpdatingItems((prev) => {
const newSet = new Set(prev)
newSet.delete(itemId)
return newSet
})
refetch()
},
onError: (error: any) => {
setUpdatingItems((prev) => {
const newSet = new Set(prev)
newSet.delete(itemId)
return newSet
})
ErrorToast(error.message || 'Failed to update packaging status')
},
}
)
}
return (
<>
<div
onClick={handleOrderPress}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter') handleOrderPress() }}
className="mx-4 mb-4 flex w-[calc(100%-2rem)] cursor-pointer flex-col overflow-hidden rounded-xl border border-gray-100 bg-white text-left shadow-sm"
>
{/* Header Section */}
<div className="border-b border-gray-100 bg-gray-50/50 p-4">
<div className="flex flex-row items-start justify-between">
<div className="flex-1">
<div className="mb-1 flex flex-row items-center">
<MyText className={`mr-2 text-lg font-bold ${order.status === 'cancelled' ? 'text-red-600' : (order.userNegativityScore && order.userNegativityScore > 0 ? 'text-yellow-600' : 'text-gray-900')}`}>
{order.customerName || order.customerMobile || 'Unknown Customer'}
</MyText>
<span className="mr-2 rounded bg-gray-200 px-2 py-0.5">
<MyText className="text-xs font-medium text-gray-600">#{order.readableId}</MyText>
</span>
{order.isFlashDelivery && (
<span className="flex flex-row items-center rounded-full border border-amber-200 bg-amber-100 px-2 py-0.5">
<Zap size={12} color="#D97706" />
<MyText className="ml-1 text-xs font-bold text-amber-700">FLASH</MyText>
</span>
)}
</div>
<div className="flex flex-row items-center">
<Clock size={12} color="#6B7280" />
<MyText className="ml-1 text-xs text-gray-500">
{dayjs(order.createdAt).format('MMM D, h:mm A')}
</MyText>
{order.userNegativityScore && order.userNegativityScore > 0 && (
<span className="ml-2 flex flex-row items-center">
<TriangleAlert size={14} color="#CA8A04" />
<MyText className="ml-1 text-xs font-semibold text-yellow-600">Negative Customer</MyText>
</span>
)}
</div>
</div>
<button
onClick={(e) => { e.stopPropagation(), setMenuOpen(true) }}
className="-mr-2 -mt-2 rounded-full p-2"
aria-label="Order options"
>
<EllipsisVertical size={16} color="#9CA3AF" />
</button>
</div>
</div>
{/* Main Content */}
<div className="p-4">
{/* Status Badges */}
<div className="mb-4 flex flex-row flex-wrap gap-4">
<span className="flex flex-row items-center gap-1">
<MyText className="text-sm font-semibold text-gray-600">Packaged</MyText>
<Checkbox
checked={order.isPackaged}
onPress={() => {}}
size={18}
fillColor="#6B7280"
checkColor="#FFFFFF"
/>
</span>
<span
className="flex flex-row items-center gap-1"
onClick={(e) => e.stopPropagation()}
>
<MyText className="text-xs font-semibold text-gray-600">Delivered</MyText>
<Checkbox
checked={order.isDelivered}
onPress={() => handleMarkDelivered(!order.isDelivered)}
size={18}
fillColor="#10B981"
checkColor="#FFFFFF"
/>
</span>
{order.status === 'cancelled' && (
<span className="rounded-full border border-red-500 px-2 py-0.5">
<MyText className="text-xs font-bold text-red-600">CANCELLED</MyText>
</span>
)}
</div>
{/* Delivery Info */}
<div className="mb-4 flex flex-row items-start rounded-lg bg-blue-50/50 p-3">
<MapPin size={18} color="#3B82F6" className="mt-0.5" />
<div className="ml-2 flex-1">
<MyText className="mb-0.5 text-xs font-bold uppercase tracking-wide text-blue-800">Delivery Address</MyText>
<MyText className="text-sm leading-5 text-gray-700" numberOfLines={2}>
{order.address}
</MyText>
<div className="mt-2 flex flex-row items-center">
<CalendarDays size={14} color="#6B7280" />
<MyText className="ml-1 text-xs text-gray-600">
{order.isFlashDelivery ? '1 Hr Delivery:' : 'Slot:'} {order.isFlashDelivery ? dayjs(order.createdAt).add(30, 'minutes').format('MMM D, h:mm A') : order.deliveryTime ? dayjs(order.deliveryTime).format('ddd, MMM D • h:mm A') : 'Not scheduled'}
</MyText>
</div>
{order.isFlashDelivery && (
<div className="mt-1 flex flex-row items-center rounded bg-amber-50 px-2 py-1">
<Zap size={12} color="#D97706" />
<MyText className="ml-1 text-xs font-medium text-amber-700">
1 Hour Delivery High Priority
</MyText>
</div>
)}
</div>
</div>
{/* Items Summary & Total */}
<div className="mb-4">
<div className="mb-2">
<div className="flex flex-row items-center justify-between">
<button
onClick={(e) => { e.stopPropagation(), setItemsDialogOpen(true) }}
className="mr-3 flex flex-1 flex-row items-center rounded-lg bg-blue-50 px-3 py-2"
>
<ShoppingCart size={16} color="#3B82F6" />
<MyText className="ml-2 text-sm font-medium text-blue-700">
{`${order.items.length} ${order.items.length === 1 ? 'item' : 'items'}`}
</MyText>
{order.isFlashDelivery && (
<span className="ml-2 rounded-full bg-amber-100 px-1.5 py-0.5">
<Zap size={12} color="#D97706" />
</span>
)}
</button>
<span className="flex flex-row items-center">
<MyText className="mr-2 text-base font-semibold text-gray-900">Total:</MyText>
<MyText className={`text-lg font-bold ${order.isFlashDelivery ? 'text-amber-700' : 'text-gray-900'}`}>{`${order.totalAmount}`}</MyText>
</span>
</div>
</div>
</div>
{/* Coupons */}
{order.couponCode && (
<div className="mb-4">
<MyText className="mb-2 text-xs font-bold uppercase tracking-wide text-gray-500">Applied Coupons</MyText>
<div className="rounded-lg border border-pink-200 bg-pink-50 p-3">
<MyText className="mb-1 text-sm font-medium text-pink-800">
{order.couponCode}
</MyText>
{order.couponDescription && (
<MyText className="mb-2 text-xs text-pink-600">
{order.couponDescription}
</MyText>
)}
{order.discountAmount && (
<MyText className="text-sm font-bold text-pink-800">
{`Discount: ₹${order.discountAmount}`}
</MyText>
)}
</div>
</div>
)}
{/* Notes Section */}
<div className="flex flex-row gap-2">
{order.userNotes && (
<button
onClick={(e) => { e.stopPropagation(), setUserNotesDialogOpen(true) }}
className="flex flex-1 flex-row items-center rounded-lg bg-amber-50 p-3"
>
<StickyNote size={18} color="#D97706" />
<MyText className="ml-2 font-medium text-amber-800">
User Notes
</MyText>
</button>
)}
{order.adminNotes && (
<button
onClick={(e) => { e.stopPropagation(), setAdminNotesDialogOpen(true) }}
className="flex flex-1 flex-row items-center rounded-lg bg-blue-50 p-3"
>
<ShieldCheck size={18} color="#2563EB" />
<MyText className="ml-2 font-medium text-blue-800">
Admin Notes
</MyText>
</button>
)}
</div>
{/* Footer / Delivery Charge */}
{order.deliveryCharge > 0 && (
<div className="border-t border-gray-100 pt-3">
<div className="flex flex-row items-center justify-between">
<MyText className="text-sm text-gray-500">Delivery Charge</MyText>
<MyText className="text-sm text-gray-900">{`${order.deliveryCharge}`}</MyText>
</div>
</div>
)}
</div>
</div>
<OrderOptionsMenu
open={menuOpen}
onClose={() => setMenuOpen(false)}
order={{
id: order.id,
readableId: order.readableId,
isPackaged: order.isPackaged,
isDelivered: order.isDelivered,
isFlashDelivery: order.isFlashDelivery,
address: order.address,
addressId: order.addressId,
adminNotes: order.adminNotes,
userNotes: order.userNotes,
latitude: order.latitude,
longitude: order.longitude,
status: order.status,
}}
onViewDetails={handleMenuOption}
onTogglePackaged={() => handleMarkPackaged(!order.isPackaged)}
onToggleDelivered={() => handleMarkDelivered(!order.isDelivered)}
onOpenAdminNotes={() => {
setMenuOpen(false)
setNotesDialogOpen(true)
}}
onCancelOrder={() => {
setMenuOpen(false)
setCancelDialogOpen(true)
}}
onAttachLocation={() => refetch()}
onWhatsApp={() => {}}
onDial={() => {}}
/>
<BottomDialog open={itemsDialogOpen} onClose={() => setItemsDialogOpen(false)}>
<div className="py-6">
<div className="mb-4 flex flex-row items-center justify-between">
<MyText className="text-lg font-bold text-gray-800">
Order Items
</MyText>
{order.isFlashDelivery && (
<span className="flex flex-row items-center rounded-full border border-amber-200 bg-amber-100 px-2 py-1">
<Zap size={14} color="#D97706" />
<MyText className="ml-1 text-xs font-bold text-amber-700">FLASH</MyText>
</span>
)}
</div>
<MyText className="mb-6 text-sm text-gray-600">
{`Total: ₹${order.totalAmount}`}
</MyText>
{order.items.map((item, idx) => (
<div key={idx} className="border-b border-gray-50 py-2 last:border-0">
<div className="flex flex-row items-center">
<span className="mr-2 items-center justify-center rounded bg-gray-100 px-2 py-1">
<MyText className="text-xs font-bold text-gray-600">{`${item.quantity} x ${item.unit}`}</MyText>
</span>
<MyText className="flex-1 text-sm text-gray-800" numberOfLines={1}>
{item.name.length > 30 ? `${item.name.substring(0, 30)}...` : item.name}
</MyText>
{item.isPackaged !== undefined && item.isPackageVerified !== undefined && (
<>
<span className="mr-3 flex flex-row items-center gap-1">
<MyText className="text-sm font-medium text-gray-600">pkg</MyText>
<Checkbox
checked={item.isPackaged}
onPress={() => handleItemPackagingToggle(item.id!, 'isPackaged', !item.isPackaged)}
size={18}
fillColor={updatingItems.has(item.id!) ? '#F59E0B' : '#10B981'}
checkColor="#FFFFFF"
/>
</span>
<span className="flex flex-row items-center gap-1">
<MyText className="text-sm font-medium text-gray-600">verf</MyText>
<Checkbox
checked={item.isPackageVerified}
onPress={() => handleItemPackagingToggle(item.id!, 'isPackageVerified', !item.isPackageVerified)}
size={18}
fillColor={updatingItems.has(item.id!) ? '#F59E0B' : '#10B981'}
checkColor="#FFFFFF"
/>
</span>
{updatingItems.has(item.id!) && (
<Loader2 size={14} color="#F59E0B" className="ml-1 animate-spin" />
)}
</>
)}
</div>
</div>
))}
</div>
</BottomDialog>
<BottomDialog open={notesDialogOpen} onClose={() => setNotesDialogOpen(false)}>
<AdminNotesForm orderId={order.orderId} existingNotes={order.adminNotes} onClose={() => setNotesDialogOpen(false)} refetch={refetch} />
</BottomDialog>
<CancelOrderDialog
orderId={order.id}
open={cancelDialogOpen}
onClose={() => setCancelDialogOpen(false)}
onSuccess={refetch}
/>
<BottomDialog open={userNotesDialogOpen} onClose={() => setUserNotesDialogOpen(false)}>
<div className="p-6">
<MyText className="mb-4 text-lg font-bold text-gray-800">
User Notes
</MyText>
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
<MyText className="text-sm leading-5 text-amber-900">
{order.userNotes}
</MyText>
</div>
</div>
</BottomDialog>
<BottomDialog open={adminNotesDialogOpen} onClose={() => setAdminNotesDialogOpen(false)}>
<div className="p-6">
<MyText className="mb-4 text-lg font-bold text-gray-800">
Admin Notes
</MyText>
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
<MyText className="text-sm leading-5 text-blue-900">
{order.adminNotes}
</MyText>
</div>
</div>
</BottomDialog>
</>
)
}
function Orders() {
const { filter, slotId } = Route.useSearch()
const [selectedSlot, setSelectedSlot] = useState<number | null>(null)
const [selectedSlotType, setSelectedSlotType] = useState<'slot' | 'flash' | null>(null)
const [packagedFilter, setPackagedFilter] = useState<'all' | 'packaged' | 'not_packaged'>('all')
const [packagedChecked, setPackagedChecked] = useState(false)
const [notPackagedChecked, setNotPackagedChecked] = useState(false)
const [deliveredFilter, setDeliveredFilter] = useState<'all' | 'delivered' | 'not_delivered'>('all')
const [deliveredChecked, setDeliveredChecked] = useState(false)
const [notDeliveredChecked, setNotDeliveredChecked] = useState(false)
const [cancellationFilter, setCancellationFilter] = useState<'all' | 'cancelled' | 'not_cancelled'>('all')
const [cancelledChecked, setCancelledChecked] = useState(false)
const [notCancelledChecked, setNotCancelledChecked] = useState(false)
const [flashDeliveryFilter, setFlashDeliveryFilter] = useState<'all' | 'flash' | 'regular'>('all')
const [flashChecked, setFlashChecked] = useState(false)
const [regularChecked, setRegularChecked] = useState(false)
const [filterDialogOpen, setFilterDialogOpen] = useState(false)
// Handle initial filter from URL params
useEffect(() => {
if (filter === 'flash' || slotId === 'flash') {
setSelectedSlotType('flash')
setFlashDeliveryFilter('flash')
setFlashChecked(true)
setRegularChecked(false)
} else if (slotId && !Number.isNaN(Number(slotId))) {
setSelectedSlotType('slot')
setSelectedSlot(Number(slotId))
}
}, [filter, slotId])
const { data: slotsData } = trpc.admin.slots.getAll.useQuery()
const { data, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage, refetch } = trpc.admin.order.getAll.useInfiniteQuery(
{
limit: 20,
slotId: selectedSlotType === 'slot' ? selectedSlot : null,
packagedFilter,
deliveredFilter,
cancellationFilter,
flashDeliveryFilter: selectedSlotType === 'flash' ? 'flash' : flashDeliveryFilter
},
{
getNextPageParam: (lastPage) => lastPage?.nextCursor,
}
)
const orders = data?.pages.flatMap((page) => page?.orders) || []
if (isLoading) {
return (
<div className="flex flex-1 flex-col items-center justify-center bg-white">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
<MyText className="mt-4 text-gray-600">Loading orders...</MyText>
</div>
)
}
const slotOptions = [
{ label: '⚡ Flash Deliveries', value: 'flash' },
...(slotsData?.slots?.map((slot) => ({
label: dayjs(slot.deliveryTime).format('ddd DD MMM, h:mm a'),
value: slot.id.toString(),
})) || [])
]
return (
<>
<MyFlatList
data={orders}
keyExtractor={(item, index) => String(item?.orderId ?? index)}
renderItem={(item) => item ? <OrderItem order={item as unknown as OrderType} refetch={refetch} /> : null}
onRefresh={() => refetch()}
ListHeaderComponent={
<>
<div className="flex flex-row items-center justify-between bg-white p-4">
<div className="mr-4 flex-1">
<Dropdown
label="Select Slot"
options={slotOptions}
value={selectedSlotType === 'flash' ? 'flash' : (selectedSlot?.toString() || '')}
onValueChange={(val) => {
if (val === 'flash') {
setSelectedSlotType('flash')
setSelectedSlot(null)
setFlashDeliveryFilter('flash')
// Reset other filters when switching to flash
setPackagedFilter('all')
setPackagedChecked(false)
setNotPackagedChecked(false)
setDeliveredFilter('all')
setDeliveredChecked(false)
setNotDeliveredChecked(false)
setCancellationFilter('all')
setCancelledChecked(false)
setNotCancelledChecked(false)
} else {
setSelectedSlotType('slot')
setSelectedSlot(val ? Number(val) : null)
setFlashDeliveryFilter('all')
}
}}
/>
</div>
<button
onClick={() => setFilterDialogOpen(true)}
className="p-2"
aria-label="Open filters"
>
<ListFilter size={24} color="#6b7280" />
</button>
</div>
{!isLoading && selectedSlotType && (
<div className="border-b border-gray-200 bg-gray-50 p-3">
<MyText className="text-center text-gray-600">
{selectedSlotType === 'flash'
? `${orders.length} Flash delivery orders`
: `${orders.length} Orders in slot`
}
</MyText>
</div>
)}
</>
}
ListFooterComponent={
<div className="flex flex-col items-center gap-2 py-4">
{isFetchingNextPage ? (
<div className="flex flex-row items-center justify-center">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
<MyText className="ml-2 text-gray-600">Loading more...</MyText>
</div>
) : hasNextPage ? (
<MyButton variant="blue" onClick={() => fetchNextPage()}>
Load more
</MyButton>
) : null}
</div>
}
/>
<BottomDialog open={filterDialogOpen} onClose={() => setFilterDialogOpen(false)}>
<AppContainer>
<div className="mt-4">
<MyText className="mb-2 text-lg font-semibold">Packaged Status</MyText>
<div className="mb-2 flex flex-row items-center">
<Checkbox
checked={packagedChecked}
onPress={() => {
const newValue = !packagedChecked
setPackagedChecked(newValue)
if (newValue && notPackagedChecked) {
setPackagedFilter('all')
} else if (newValue) {
setPackagedFilter('packaged')
} else if (notPackagedChecked) {
setPackagedFilter('not_packaged')
} else {
setPackagedFilter('all')
}
}}
/>
<MyText className="ml-2">Packaged</MyText>
</div>
<div className="flex flex-row items-center">
<Checkbox
checked={notPackagedChecked}
onPress={() => {
const newValue = !notPackagedChecked
setNotPackagedChecked(newValue)
if (packagedChecked && newValue) {
setPackagedFilter('all')
} else if (newValue) {
setPackagedFilter('not_packaged')
} else if (packagedChecked) {
setPackagedFilter('packaged')
} else {
setPackagedFilter('all')
}
}}
/>
<MyText className="ml-2">Not Packaged</MyText>
</div>
</div>
<div className="mt-6">
<MyText className="mb-2 text-lg font-semibold">Delivered Status</MyText>
<div className="mb-2 flex flex-row items-center">
<Checkbox
checked={deliveredChecked}
onPress={() => {
const newValue = !deliveredChecked
setDeliveredChecked(newValue)
if (newValue && notDeliveredChecked) {
setDeliveredFilter('all')
} else if (newValue) {
setDeliveredFilter('delivered')
} else if (notDeliveredChecked) {
setDeliveredFilter('not_delivered')
} else {
setDeliveredFilter('all')
}
}}
/>
<MyText className="ml-2">Delivered</MyText>
</div>
<div className="flex flex-row items-center">
<Checkbox
checked={notDeliveredChecked}
onPress={() => {
const newValue = !notDeliveredChecked
setNotDeliveredChecked(newValue)
if (deliveredChecked && newValue) {
setDeliveredFilter('all')
} else if (newValue) {
setDeliveredFilter('not_delivered')
} else if (deliveredChecked) {
setDeliveredFilter('delivered')
} else {
setDeliveredFilter('all')
}
}}
/>
<MyText className="ml-2">Not Delivered</MyText>
</div>
</div>
<div className="mt-6">
<MyText className="mb-2 text-lg font-semibold">Cancellation Status</MyText>
<div className="mb-2 flex flex-row items-center">
<Checkbox
checked={cancelledChecked}
onPress={() => {
const newValue = !cancelledChecked
setCancelledChecked(newValue)
if (newValue && notCancelledChecked) {
setCancellationFilter('all')
} else if (newValue) {
setCancellationFilter('cancelled')
} else if (notCancelledChecked) {
setCancellationFilter('not_cancelled')
} else {
setCancellationFilter('all')
}
}}
/>
<MyText className="ml-2">Cancelled</MyText>
</div>
<div className="flex flex-row items-center">
<Checkbox
checked={notCancelledChecked}
onPress={() => {
const newValue = !notCancelledChecked
setNotCancelledChecked(newValue)
if (cancelledChecked && newValue) {
setCancellationFilter('all')
} else if (newValue) {
setCancellationFilter('not_cancelled')
} else if (cancelledChecked) {
setCancellationFilter('cancelled')
} else {
setCancellationFilter('all')
}
}}
/>
<MyText className="ml-2">Not Cancelled</MyText>
</div>
</div>
<div className="mt-6">
<MyText className="mb-2 text-lg font-semibold">Delivery Type</MyText>
<div className="mb-2 flex flex-row items-center">
<Checkbox
checked={flashChecked}
onPress={() => {
const newValue = !flashChecked
setFlashChecked(newValue)
if (newValue && regularChecked) {
setFlashDeliveryFilter('all')
} else if (newValue) {
setFlashDeliveryFilter('flash')
} else if (regularChecked) {
setFlashDeliveryFilter('regular')
} else {
setFlashDeliveryFilter('all')
}
}}
/>
<MyText className="ml-2"> 1 Hr Delivery</MyText>
</div>
<div className="flex flex-row items-center">
<Checkbox
checked={regularChecked}
onPress={() => {
const newValue = !regularChecked
setRegularChecked(newValue)
if (flashChecked && newValue) {
setFlashDeliveryFilter('all')
} else if (newValue) {
setFlashDeliveryFilter('regular')
} else if (flashChecked) {
setFlashDeliveryFilter('flash')
} else {
setFlashDeliveryFilter('all')
}
}}
/>
<MyText className="ml-2">Regular Delivery</MyText>
</div>
</div>
</AppContainer>
</BottomDialog>
</>
)
}

View file

@ -0,0 +1,798 @@
import React, { useState, useEffect, useMemo } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors
} from '@dnd-kit/core'
import type { DragEndEvent } from '@dnd-kit/core'
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
arrayMove
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import {
p as MyText,
BottomDialog,
Checkbox,
Dropdown
} from 'web-components'
import dayjs from 'dayjs'
import { trpc } from '@/lib/trpc-client'
import { ErrorToast } from '@/services/toaster'
import { useManualRefresh, useMarkDataFetchers } from '@/lib/refresh-context'
import {
MapPin,
Map as MapIcon,
EllipsisVertical,
GripVertical,
Save,
ChevronLeft,
CalendarX,
ClipboardList
} from 'lucide-react'
import { OrderOptionsMenu } from '../components/OrderOptionsMenu'
import CancelOrderDialog from '../components/CancelOrderDialog'
import { OrderNotesForm } from '../components/OrderNotesForm'
import { SearchableSelect } from '@/components/SearchableSelect'
export const Route = createFileRoute('/dashboard/orders/sequence')({
validateSearch: (s: Record<string, unknown>) => ({
slotId: (s.slotId as string) ?? ''
}),
component: DeliverySequences,
})
// Define types outside
interface OrderWithSequence {
sequenceId: number,
readableId: number,
isPackaged: boolean,
totalAmount: number,
address: string,
latitude: number | null,
longitude: number | null,
id: number,
isDelivered: boolean,
addressId: number,
adminNotes?: string | null,
customerName?: string | null,
assignedUserName?: string | null
}
interface SlotProgressProps {
slotId: number,
orders: any[]
}
const SlotProgress: React.FC<SlotProgressProps> = ({ slotId, orders }) => {
const delivered = orders.filter((o) => o.isDelivered).length
const undelivered = orders.filter((o) => !o.isDelivered).length
const packaged = orders.filter((o) => o.isPackaged).length
const notPackaged = orders.filter((o) => !o.isPackaged).length
const stats = [
{ label: 'Delivered', value: delivered },
{ label: 'Undelivered', value: undelivered },
{ label: 'Packaged', value: packaged },
{ label: 'Not Packaged', value: notPackaged },
]
return (
<div className="mb-2 border-b border-gray-200 bg-white px-4 py-3">
<MyText className="mb-3 text-sm text-gray-700">
Slot {slotId} Statistics
</MyText>
<div className="flex flex-row flex-wrap">
{stats.map((stat, index) => (
<div key={index} className="w-1/2 p-2">
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
<MyText className="text-lg font-bold text-gray-900">
{stat.value}
</MyText>
<MyText className="text-xs text-gray-500">{stat.label}</MyText>
</div>
</div>
))}
</div>
</div>
)
}
interface OrderItemProps {
item: OrderWithSequence,
isPending: boolean,
setSelectedOrder: (order: OrderWithSequence | null) => void,
setShowOrderMenu: (show: boolean) => void,
selectedOrderIds: number[],
onToggleOrder: (id: number) => void,
assignedUserName?: string | null,
staffData?: { staff?: Array<{ id: number, name: string }> },
setSelectedUserId?: (id: number) => void
}
const OrderItem: React.FC<OrderItemProps> = ({
item,
isPending,
setSelectedOrder,
setShowOrderMenu,
selectedOrderIds,
onToggleOrder,
assignedUserName,
staffData,
setSelectedUserId,
}) => {
const orderItem = item
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging
} = useSortable({ id: orderItem.sequenceId, disabled: isPending })
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Translate.toString(transform),
transition,
opacity: isDragging ? 0.8 : 1,
zIndex: isDragging ? 50 : undefined,
touchAction: 'none'
}}
{...attributes}
{...listeners}
className="mx-4 my-2"
>
<div
className={`rounded-xl border bg-white p-4 ${isDragging ? 'border-blue-500 shadow-xl' : 'border-gray-100 shadow-sm'}`}
>
<div className="flex flex-row items-center">
{/* Checkbox and Drag Handle */}
<div className="mr-4 flex-col items-center">
<Checkbox
checked={selectedOrderIds.includes(orderItem.id)}
onPress={() => onToggleOrder(orderItem.id)}
size={20}
fillColor="#3b82f6"
checkColor="#FFFFFF"
/>
<div className="mt-1">
<GripVertical
size={24}
color={isDragging ? '#3b82f6' : '#9ca3af'}
/>
</div>
</div>
{/* Content */}
<div className="flex-1">
<div className="mb-2 flex flex-row items-start justify-between">
<div className="flex flex-row items-center gap-2">
<MyText className="text-lg font-bold text-gray-800">
#{orderItem.readableId}
</MyText>
<span className="flex flex-row items-center gap-1">
<MyText className="text-xs font-medium text-gray-600">
Pkg
</MyText>
<Checkbox
checked={orderItem.isPackaged}
onPress={() => {}}
size={16}
fillColor="#6B7280"
checkColor="#FFFFFF"
/>
</span>
</div>
<div className="flex flex-row items-center">
<span className="mr-2 rounded-lg border border-green-100 bg-green-50 px-2 py-1">
<MyText className="text-sm font-bold text-green-700">
{orderItem.totalAmount}
</MyText>
</span>
<button
onClick={() => {
setSelectedOrder(orderItem)
setShowOrderMenu(true)
}}
className="p-2"
aria-label="Order options"
>
<EllipsisVertical size={20} color="#6b7280" />
</button>
</div>
</div>
<div className="flex flex-row items-start">
<MapPin size={14} color="#6b7280" className="mr-1 mt-0.5" />
<MyText className="flex-1 text-xs text-gray-500" numberOfLines={2}>
{orderItem.address}
</MyText>
{orderItem.latitude && orderItem.longitude && (
<button
onClick={() => {
window.open(
`https://www.google.com/maps?q=${orderItem.latitude},${orderItem.longitude}`,
'_blank'
)
}}
className="ml-2"
aria-label="Open in maps"
>
<MapIcon size={16} color="#3b82f6" />
</button>
)}
</div>
{assignedUserName && (
<div className="mt-2 flex flex-row items-center">
<MyText className="text-xs text-gray-500">
Assigned to{' '}
</MyText>
<button
onClick={() => {
const assignedUserId = staffData?.staff?.find(
(s: { id: number, name: string }) => s.name === assignedUserName
)?.id
if (assignedUserId && setSelectedUserId) {
setSelectedUserId(assignedUserId)
}
}}
className="underline"
>
<MyText className="text-xs text-blue-600 underline">
{assignedUserName}
</MyText>
</button>
</div>
)}
</div>
</div>
{/* Admin Notes */}
{orderItem.adminNotes && (
<div className="mt-2 rounded-lg border border-yellow-100 bg-yellow-50 p-2">
<MyText className="text-xs leading-4 text-yellow-900">
{orderItem.adminNotes}
</MyText>
</div>
)}
</div>
</div>
)
}
function DeliverySequences() {
const { slotId } = Route.useSearch()
const selectedSlotId = slotId ? Number(slotId) : null
const [localOrderedOrders, setLocalOrderedOrders] = useState<
OrderWithSequence[]
>([])
const [showOrderMenu, setShowOrderMenu] = useState(false)
const [selectedOrder, setSelectedOrder] = useState<OrderWithSequence | null>(
null
)
const [selectedUserId, setSelectedUserId] = useState<number>(-1)
const [selectedOrderIds, setSelectedOrderIds] = useState<number[]>([])
const [hasSequenceChanged, setHasSequenceChanged] = useState(false)
const [cancelDialogOpen, setCancelDialogOpen] = useState(false)
const [notesDialogOpen, setNotesDialogOpen] = useState(false)
const navigate = useNavigate()
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { delay: 200, tolerance: 8 } })
)
const { data: slotsData, refetch: refetchSlots } =
trpc.admin.slots.getAll.useQuery()
const {
data: ordersData,
isLoading: ordersLoading,
refetch: refetchOrders,
} = trpc.admin.order.getSlotOrders.useQuery(
{ slotId: String(selectedSlotId) },
{
enabled: !!selectedSlotId,
}
)
const { data: sequenceData, refetch: refetchSequence } =
trpc.admin.slots.getDeliverySequence.useQuery(
{ id: String(selectedSlotId) },
{
enabled: !!selectedSlotId,
}
)
const { data: staffData } = trpc.admin.staffUser.getStaff.useQuery()
// Auto-select first slot if no slotId provided
useEffect(() => {
if (!slotId && slotsData?.slots && slotsData.slots.length > 0) {
navigate({ to: '/dashboard/orders/sequence', search: { slotId: String(slotsData.slots[0].id) }, replace: true })
}
}, [slotId, slotsData, navigate])
const updateSequenceMutation =
trpc.admin.slots.updateDeliverySequence.useMutation()
const updatePackagedMutation = trpc.admin.order.updatePackaged.useMutation()
const updateDeliveredMutation =
trpc.admin.order.updateDelivered.useMutation()
// Manual refresh functionality
useManualRefresh(() => {
refetchSlots()
refetchOrders()
refetchSequence()
})
useMarkDataFetchers(() => {
refetchSlots()
refetchOrders()
refetchSequence()
})
const slots = slotsData?.slots || []
const orders = ordersData?.data || []
const deliverySequence = sequenceData?.deliverySequence || []
const slotOptions =
slots?.map((slot) => ({
label: dayjs(slot.deliveryTime).format('ddd DD MMM, h:mm a'),
value: slot.id,
})) || []
const userOptions = [
{ label: 'Unassigned', value: -1 },
{ label: 'All Users', value: -2 },
...(staffData?.staff?.map((staff) => ({
label: staff.name,
value: staff.id,
})) || []),
]
// Create ordered orders based on delivery sequence
const computedOrderedOrders = useMemo(() => {
if (orders.length > 0) {
const userSequence =
selectedUserId !== -1
? (deliverySequence as any)?.[String(selectedUserId)] || []
: null
if (selectedUserId !== -1 && userSequence && userSequence.length > 0) {
// Sort orders according to user's sequence
const sequenceMap = new Map(
userSequence.map((id: number, index: number) => [id, index])
)
const ordered = orders
.filter((order) => userSequence.includes(order.id))
.map((order) => ({ ...order, sequenceId: order.id }))
.sort((a, b) => {
const aIndex = sequenceMap.get(a.sequenceId) ?? Infinity
const bIndex = sequenceMap.get(b.sequenceId) ?? Infinity
return aIndex < bIndex ? -1 : aIndex > bIndex ? 1 : 0
})
return ordered
} else if (selectedUserId === -1) {
// Show unassigned orders (not in any user's sequence)
const assignedIds = new Set(
Object.values(deliverySequence as any).flat()
)
const ordered = orders
.filter((order) => !assignedIds.has(order.id))
.map((order) => ({ ...order, sequenceId: order.id }))
.sort((a, b) =>
a.sequenceId < b.sequenceId
? -1
: a.sequenceId > b.sequenceId
? 1
: 0
)
return ordered
} else if (selectedUserId === -2) {
// Show all orders with assignment info
const orderToUserMap = new Map<number, number>()
Object.entries(deliverySequence as any).forEach(([userId, orderIds]) => {
(orderIds as number[]).forEach((orderId) => {
orderToUserMap.set(orderId, parseInt(userId))
})
})
const ordered = orders
.map((order) => {
const assignedUserId = orderToUserMap.get(order.id)
const assignedUser = staffData?.staff?.find(
(s) => s.id === assignedUserId
)
return {
...order,
sequenceId: order.id,
assignedUserName: assignedUser?.name || null,
}
})
.sort((a, b) =>
a.sequenceId < b.sequenceId
? -1
: a.sequenceId > b.sequenceId
? 1
: 0
)
return ordered
} else {
// No sequence for user, show empty
return []
}
} else {
return []
}
}, [ordersData, sequenceData, selectedUserId])
// Sync local state with computed orders
useEffect(() => {
setLocalOrderedOrders(computedOrderedOrders as OrderWithSequence[])
}, [computedOrderedOrders])
const handleDragEnd = ({ data }: { data: OrderWithSequence[] }) => {
if (selectedUserId !== -1 && selectedUserId !== -2) {
setLocalOrderedOrders(data)
setHasSequenceChanged(true)
}
}
const handleDndDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const oldIndex = localOrderedOrders.findIndex((o) => o.sequenceId === active.id)
const newIndex = localOrderedOrders.findIndex((o) => o.sequenceId === over.id)
if (oldIndex === -1 || newIndex === -1) return
handleDragEnd({ data: arrayMove(localOrderedOrders, oldIndex, newIndex) })
}
const handleSaveSequence = () => {
if (!selectedSlotId || selectedUserId === -1) return
const newSequence = { ...(deliverySequence as any) }
newSequence[String(selectedUserId)] = localOrderedOrders.map(
(order) => order.sequenceId
)
updateSequenceMutation.mutate(
{
id: selectedSlotId,
deliverySequence: newSequence,
},
{
onSuccess: () => {
setHasSequenceChanged(false)
window.alert('Success: Delivery sequence updated successfully')
},
onError: (error: any) => {
ErrorToast(
`Failed to update delivery sequence: ${
error.message || 'Unknown error'
}`
)
},
}
)
}
if (!slotsData) {
return (
<div className="flex flex-1 flex-col items-center justify-center bg-gray-50 pt-6">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
<MyText className="mt-4 text-gray-500">Loading slots...</MyText>
</div>
)
}
if (slotsData.slots.length === 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center p-8 pt-6">
<CalendarX size={64} color="#e5e7eb" />
<MyText className="mt-4 text-center text-lg text-gray-500">
No slots available
</MyText>
</div>
)
}
return (
<div className="flex min-h-full flex-1 flex-col bg-gray-50 pt-6">
{selectedSlotId ? (
<>
{/* Header Section */}
<div className="z-10 flex flex-row items-center border-b border-gray-200 bg-white px-4 py-4 shadow-sm">
<button
onClick={() => window.history.back()}
className="p-2"
aria-label="Go back"
>
<ChevronLeft size={24} color="#374151" />
</button>
<div className="mx-2 flex-[2]">
<Dropdown
label="Select Slot"
options={slotOptions}
value={selectedSlotId || ''}
onValueChange={(val) => {
if (val !== '' && val !== null && val !== undefined) {
navigate({ to: '/dashboard/orders/sequence', search: { slotId: String(Number(val)) }, replace: true })
}
}}
/>
</div>
<div className="mx-2 flex-1">
<Dropdown
label="Select User"
options={userOptions}
value={selectedUserId}
onValueChange={(val) => setSelectedUserId(Number(val))}
/>
</div>
</div>
{/* Content Section */}
{ordersLoading ? (
<div className="flex flex-1 flex-col items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
<MyText className="mt-4 text-gray-500">Loading orders...</MyText>
</div>
) : localOrderedOrders.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center p-8">
<ClipboardList size={64} color="#e5e7eb" />
<MyText className="mt-4 text-center text-lg text-gray-500">
No orders found for this slot
</MyText>
</div>
) : (
<div className="flex flex-1 flex-col pb-8">
<SlotProgress slotId={selectedSlotId!} orders={orders} />
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDndDragEnd}
>
<SortableContext
items={localOrderedOrders.map((o) => o.sequenceId)}
strategy={verticalListSortingStrategy}
>
{localOrderedOrders.map((item) => (
<OrderItem
key={item.sequenceId}
item={item}
isPending={updateSequenceMutation.isPending}
setSelectedOrder={setSelectedOrder}
setShowOrderMenu={setShowOrderMenu}
selectedOrderIds={selectedOrderIds}
onToggleOrder={(id) => {
setSelectedOrderIds((prev) =>
prev.includes(id)
? prev.filter((i) => i !== id)
: [...prev, id]
)
}}
assignedUserName={selectedUserId === -2 ? item.assignedUserName : undefined}
staffData={staffData}
setSelectedUserId={setSelectedUserId}
/>
))}
</SortableContext>
</DndContext>
</div>
)}
{/* Assignment panel (replaces FAB-triggered dropdown; SearchableSelect renders its own trigger) */}
{selectedOrderIds.length > 0 && (
<div className="absolute bottom-4 right-4 w-56 rounded-xl bg-white p-3 shadow-lg">
<SearchableSelect
label="Assign To"
options={[
...userOptions.filter((opt) => opt.value !== -1),
{ label: 'None', value: -3 },
]}
value={-1}
onValueChange={(val) => {
const numVal = Number(val)
if (numVal !== -1) {
if (numVal === -3) {
// Unassign selected orders
const newSequence = { ...(deliverySequence as any) }
Object.keys(newSequence).forEach((userId) => {
newSequence[userId] = newSequence[userId].filter(
(id: number) => !selectedOrderIds.includes(id)
)
})
// Save
updateSequenceMutation.mutate(
{
id: selectedSlotId,
deliverySequence: newSequence,
},
{
onSuccess: () => {
setSelectedOrderIds([])
refetchSequence()
window.alert(
'Success: Orders unassigned successfully'
)
},
onError: (error: any) => {
ErrorToast(
`Failed to unassign orders: ${
error.message || 'Unknown error'
}`
)
},
}
)
} else {
// Assign selected orders to the user
// Update deliverySequence map
const newSequence = { ...(deliverySequence as any) }
// Remove from all other users
Object.keys(newSequence).forEach((userId) => {
newSequence[userId] = newSequence[userId].filter(
(id: number) => !selectedOrderIds.includes(id)
)
})
// Add to selected user
if (!newSequence[String(numVal)])
newSequence[String(numVal)] = []
newSequence[String(numVal)] = [
...new Set([
...newSequence[String(numVal)],
...selectedOrderIds,
]),
]
// Save
updateSequenceMutation.mutate(
{
id: selectedSlotId,
deliverySequence: newSequence,
},
{
onSuccess: () => {
setSelectedOrderIds([])
refetchSequence()
window.alert(
'Success: Orders assigned successfully'
)
},
onError: (error: any) => {
ErrorToast(
`Failed to assign orders: ${
error.message || 'Unknown error'
}`
)
},
}
)
}
}
}}
/>
</div>
)}
{/* FAB for Save */}
{hasSequenceChanged && (
<div className="fixed bottom-4 left-4">
<button
onClick={handleSaveSequence}
className="rounded-full bg-blue-600 p-4 shadow-lg disabled:opacity-50"
disabled={updateSequenceMutation.isPending}
aria-label="Save sequence"
>
<Save size={24} color="white" />
</button>
</div>
)}
</>
) : (
<div className="flex flex-1 flex-col items-center justify-center p-8">
<CalendarX size={64} color="#e5e7eb" />
<MyText className="mt-4 text-center text-lg text-gray-500">
No slot selected. Please select a slot to view delivery sequence.
</MyText>
<button
className="mt-6 rounded-full bg-blue-600 px-6 py-3"
onClick={() => window.history.back()}
>
<MyText className="font-bold text-white">Go Back</MyText>
</button>
</div>
)}
{/* Order Menu Dialog */}
<OrderOptionsMenu
open={showOrderMenu}
onClose={() => setShowOrderMenu(false)}
order={{
id: selectedOrder?.id || 0,
readableId: selectedOrder?.readableId || 0,
isPackaged: selectedOrder?.isPackaged || false,
isDelivered: selectedOrder?.isDelivered || false,
address: selectedOrder?.address || '',
addressId: selectedOrder?.addressId || 0,
adminNotes: selectedOrder?.adminNotes || null,
latitude: selectedOrder?.latitude || null,
longitude: selectedOrder?.longitude || null,
}}
onViewDetails={() => {
if (selectedOrder) {
navigate({ to: '/dashboard/orders/$id', params: { id: String(selectedOrder.id) } })
}
setShowOrderMenu(false)
}}
onTogglePackaged={() => {
if (!selectedOrder) return
updatePackagedMutation.mutate(
{ orderId: selectedOrder.id.toString(), isPackaged: !selectedOrder.isPackaged },
{
onSuccess: () => {
refetchOrders()
refetchSequence()
},
onError: () => {
ErrorToast('Failed to update packaged status')
},
}
)
}}
onToggleDelivered={() => {
if (!selectedOrder) return
updateDeliveredMutation.mutate(
{ orderId: selectedOrder.id.toString(), isDelivered: !selectedOrder.isDelivered },
{
onSuccess: () => {
refetchOrders()
refetchSequence()
},
onError: () => {
ErrorToast('Failed to update delivered status')
},
}
)
}}
onOpenAdminNotes={() => {
setShowOrderMenu(false)
setNotesDialogOpen(true)
}}
onCancelOrder={() => {
setShowOrderMenu(false)
setCancelDialogOpen(true)
}}
onAttachLocation={() => {}}
onWhatsApp={() => {}}
onDial={() => {}}
/>
<CancelOrderDialog
orderId={selectedOrder?.id || 0}
open={cancelDialogOpen}
onClose={() => setCancelDialogOpen(false)}
onSuccess={() => {
refetchOrders()
refetchSequence()
}}
/>
<BottomDialog open={notesDialogOpen} onClose={() => setNotesDialogOpen(false)}>
<OrderNotesForm
orderId={selectedOrder?.id || 0}
initialNotes={selectedOrder?.adminNotes || ''}
onSuccess={() => {
refetchOrders()
refetchSequence()
setNotesDialogOpen(false)
}}
onCancel={() => setNotesDialogOpen(false)}
/>
</BottomDialog>
</div>
)
}

View file

@ -0,0 +1,99 @@
import React, { useEffect, useState } from 'react'
import { createFileRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router'
import { ListOrdered, Route as RouteIcon } from 'lucide-react'
import { p as MyText, Dropdown } from 'web-components'
import dayjs from 'dayjs'
import { trpc } from '@/lib/trpc-client'
import { useNavigationTarget } from '@/lib/navigation-target'
import { useMarkDataFetchers } from '@/lib/refresh-context'
export const Route = createFileRoute('/dashboard/orders')({
component: ManageOrders,
})
function ManageOrders() {
const location = useLocation()
const navigate = useNavigate()
const { getNavigationTarget } = useNavigationTarget()
const [selectedSlotId, setSelectedSlotId] = useState<string | null>(null)
const { data: slotsData, refetch } = trpc.admin.slots.getAll.useQuery()
useEffect(() => {
const target = getNavigationTarget()
if (target) {
navigate({ to: target } as any)
}
}, [getNavigationTarget, navigate])
useMarkDataFetchers(() => {
refetch()
})
if (location.pathname !== '/dashboard/orders') {
return <Outlet />
}
// Create slot options with Flash Deliveries as first option
const slotOptions = [
{ label: '⚡ Flash Deliveries', value: 'flash' },
...(slotsData?.slots?.map((slot) => ({
label: dayjs(slot.deliveryTime).format('ddd DD MMM, h:mm a'),
value: slot.id.toString()
})) || [])
]
const menuItems = [
{
title: 'Delivery Sequences',
icon: <RouteIcon size={32} color="white" />,
color: 'bg-purple-500',
onClick: () => {
if (selectedSlotId === 'flash') {
window.alert('Flash Deliveries: Flash deliveries do not have delivery sequences. Use the Orders menu to manage flash deliveries.')
return
}
navigate({ to: '/dashboard/orders/sequence', search: { slotId: selectedSlotId ?? '' } })
},
},
{
title: 'Orders',
icon: <ListOrdered size={32} color="white" />,
color: 'bg-cyan-500',
onClick: () => {
if (selectedSlotId === 'flash') {
navigate({ to: '/dashboard/orders/list', search: { filter: 'flash', slotId: '' } })
} else {
navigate({ to: '/dashboard/orders/list', search: { filter: '', slotId: selectedSlotId ?? '' } })
}
},
},
]
return (
<div className="flex min-h-full flex-1 flex-col bg-white p-6">
<div className="mb-6">
<Dropdown
label="Select Slot"
options={slotOptions}
value={selectedSlotId || ''}
onValueChange={(val) => setSelectedSlotId(String(val))}
className="w-full"
/>
</div>
<div className="grid grid-cols-2 gap-4">
{menuItems.map((item, index) => (
<button
key={index}
onClick={item.onClick}
className={`flex-1 rounded-2xl p-6 text-center shadow-lg ${item.color}`}
>
<span className="flex flex-col items-center">
<span className="mb-2">{item.icon}</span>
<MyText className="text-center text-lg font-bold text-white">{item.title}</MyText>
</span>
</button>
))}
</div>
</div>
)
}

View file

@ -0,0 +1,420 @@
import React, { useState, useEffect, useMemo } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { Save, Pencil, EllipsisVertical, Shuffle } from 'lucide-react'
import {
AppContainer,
p as MyText,
pInput as MyTextInput,
BottomDialog,
Checkbox,
MyFlatList,
} from 'web-components'
import MultiSelect from '@/components/MultiSelect'
import { trpc } from '@/lib/trpc-client'
export const Route = createFileRoute('/dashboard/prices')({
component: PricesOverview,
})
interface SkuItemProps {
sku: any
productName: string
hasChanges: (skuId: number) => boolean
pendingChanges: Record<string, any>
setPendingChanges: React.Dispatch<React.SetStateAction<Record<string, any>>>
openEditDialog: (sku: any, productName: string) => void
}
const SkuItemComponent: React.FC<SkuItemProps> = ({
sku,
productName,
hasChanges,
pendingChanges,
setPendingChanges,
openEditDialog,
}) => {
const changed = hasChanges(sku.id)
const change = pendingChanges[sku.id] || {}
const displayPrice = change.price !== undefined ? change.price : sku.price
const displayMarketPrice = change.marketPrice !== undefined ? change.marketPrice : sku.marketPrice
const displayFlashPrice = change.flashPrice !== undefined ? change.flashPrice : sku.flashPrice
const displayUnit = (sku.features || []).map((f: any) => f.featureValue).join(' ') || '—'
return (
<div className="relative mb-3 rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
<div className="absolute right-2 top-2">
<div
className={`flex h-4 w-4 items-center justify-center rounded-full ${changed ? 'bg-green-500' : 'bg-gray-300'}`}
>
{changed && (
<svg width={10} height={10} viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</div>
</div>
<div className="mb-2 flex flex-row items-center">
<img
src={sku.images?.[0] || 'https://via.placeholder.com/32x32?text=No+Image'}
className="mr-3 h-10 w-10 rounded-lg object-cover"
alt=""
/>
<div className="flex flex-1 flex-row items-center">
<div className="flex-1">
<MyText className="text-base font-medium text-gray-800" numberOfLines={1}>
{productName.length > 20 ? productName.substring(0, 20) + '...' : productName}
</MyText>
<MyText className="text-xs text-gray-500" numberOfLines={1}>
{sku.displayName || sku.name || ''}
</MyText>
</div>
<div className="ml-2 flex flex-row items-center">
<span className="mr-1">
<Checkbox
checked={change.isFlashAvailable ?? sku.isFlashAvailable ?? false}
onPress={() => {
const currentValue = change.isFlashAvailable ?? sku.isFlashAvailable ?? false
setPendingChanges((prev) => ({
...prev,
[sku.id]: {
...prev[sku.id],
isFlashAvailable: !currentValue,
},
}))
}}
/>
</span>
<MyText className="text-sm text-gray-600">Flash</MyText>
</div>
</div>
</div>
<div className="flex flex-row items-center justify-between">
<div className="flex flex-col items-center">
<MyText className="mb-1 text-xs text-gray-500">Our Price</MyText>
<div className="flex flex-row items-center">
<MyText className="text-sm font-bold text-green-600">{displayPrice}</MyText>
<button onClick={() => openEditDialog(sku, productName)} className="ml-1">
<Pencil size={14} color="#6b7280" />
</button>
</div>
</div>
<div className="flex flex-col items-center">
<MyText className="mb-1 text-xs text-gray-500">Market Price</MyText>
<div className="flex flex-row items-center">
<MyText className="text-sm text-gray-600">{displayMarketPrice ? `${displayMarketPrice}` : 'N/A'}</MyText>
<button onClick={() => openEditDialog(sku, productName)} className="ml-1">
<Pencil size={14} color="#6b7280" />
</button>
</div>
</div>
<div className="flex flex-col items-center">
<MyText className="mb-1 text-xs text-gray-500">Flash Price</MyText>
<div className="flex flex-row items-center">
<MyText className="text-sm text-orange-600">{displayFlashPrice ? `${displayFlashPrice}` : 'N/A'}</MyText>
<button onClick={() => openEditDialog(sku, productName)} className="ml-1">
<Pencil size={14} color="#6b7280" />
</button>
</div>
</div>
<div className="flex flex-col items-center">
<MyText className="mb-1 text-xs text-gray-500">Size</MyText>
<div className="flex flex-row items-center">
<MyText className="text-sm text-blue-600">{displayUnit}</MyText>
<button onClick={() => openEditDialog(sku, productName)} className="ml-1">
<Pencil size={14} color="#6b7280" />
</button>
</div>
</div>
</div>
</div>
)
}
interface PendingChange {
price?: number
marketPrice?: number | null
flashPrice?: number | null
isFlashAvailable?: boolean
}
interface EditDialogState {
open: boolean
sku: any
productName: string
tempPrice: string
tempMarketPrice: string
tempFlashPrice: string
}
function PricesOverview() {
const navigate = useNavigate()
const [selectedStores, setSelectedStores] = useState<string[]>([])
const [pendingChanges, setPendingChanges] = useState<Record<number, PendingChange>>({})
const [editDialog, setEditDialog] = useState<EditDialogState>({
open: false,
sku: null,
productName: '',
tempPrice: '',
tempMarketPrice: '',
tempFlashPrice: '',
})
const [showMenu, setShowMenu] = useState(false)
const { data: productsData, isLoading: productsLoading, refetch: refetchProducts } =
trpc.admin.product.getProducts.useQuery()
const { data: storesData, isLoading: storesLoading } =
trpc.admin.store.getStores.useQuery()
const updatePricesMutation = trpc.admin.product.updateProductPrices.useMutation()
const stores = storesData?.stores || []
const allProducts = productsData?.products || []
const sortedStores = useMemo(() =>
[...stores].sort((a, b) => a.name.localeCompare(b.name)),
[stores]
)
const storeOptions = useMemo(() =>
sortedStores.map((store) => ({
label: store.name,
value: store.id.toString(),
})),
[sortedStores]
)
useEffect(() => {
if (stores.length > 0 && selectedStores.length === 0) {
setSelectedStores(stores.map((s) => s.id.toString()))
}
}, [stores, selectedStores])
const allSkus = useMemo(() => {
const skus: any[] = []
for (const product of allProducts) {
if (product.skus && product.skus.length > 0) {
for (const sku of product.skus) {
skus.push({ ...sku, _productName: product.name, _storeId: product.storeId })
}
}
}
return skus
}, [allProducts])
const filteredSkus = useMemo(() => {
if (selectedStores.length === 0) return allSkus
return allSkus.filter((sku) =>
sku._storeId && selectedStores.includes(sku._storeId.toString())
)
}, [allSkus, selectedStores])
const hasChanges = (skuId: number) => !!pendingChanges[skuId]
const openEditDialog = (sku: any, productName: string) => {
const change = pendingChanges[sku.id] || {}
setEditDialog({
open: true,
sku,
productName,
tempPrice: (change.price ?? sku.price)?.toString() || '',
tempMarketPrice: (change.marketPrice ?? sku.marketPrice)?.toString() || '',
tempFlashPrice: (change.flashPrice ?? sku.flashPrice)?.toString() || '',
})
}
const saveEditDialog = () => {
const price = parseFloat(editDialog.tempPrice)
const marketPrice = editDialog.tempMarketPrice ? parseFloat(editDialog.tempMarketPrice) : null
const flashPrice = editDialog.tempFlashPrice ? parseFloat(editDialog.tempFlashPrice) : null
if (isNaN(price) || price <= 0) {
window.alert('Please enter a valid price')
return
}
if (editDialog.tempMarketPrice && (isNaN(marketPrice!) || marketPrice! <= 0)) {
window.alert('Please enter a valid market price')
return
}
if (editDialog.tempFlashPrice && (isNaN(flashPrice!) || flashPrice! <= 0)) {
window.alert('Please enter a valid flash price')
return
}
setPendingChanges((prev) => ({
...prev,
[editDialog.sku.id]: {
price: price !== parseFloat(editDialog.sku.price) ? price : undefined,
marketPrice: marketPrice !== parseFloat(editDialog.sku.marketPrice || '0') ? marketPrice : undefined,
flashPrice: flashPrice !== parseFloat(editDialog.sku.flashPrice || '0') ? flashPrice : undefined,
},
}))
}
const handleSave = () => {
const updates = Object.entries(pendingChanges).map(([skuId, change]) => {
const sku = allSkus.find((s) => s.id === parseInt(skuId))
const update: any = { productId: sku?.id }
if (change.price !== undefined) update.price = change.price
if (change.marketPrice !== undefined) update.marketPrice = change.marketPrice
if (change.flashPrice !== undefined) update.flashPrice = change.flashPrice
if (change.isFlashAvailable !== undefined) update.isFlashAvailable = change.isFlashAvailable
return update
})
updatePricesMutation.mutate(
{ updates },
{
onSuccess: () => {
setPendingChanges({})
refetchProducts()
window.alert('Prices updated successfully')
},
onError: (error: any) => {
window.alert(`Failed to update prices: ${error.message || 'Unknown error'}`)
},
}
)
}
const changeCount = Object.keys(pendingChanges).length
return (
<div className="flex-1 bg-gray-50">
<div className="flex flex-row items-center border-b border-gray-200 bg-white px-4 py-4">
<div className="mr-4 flex-1">
<MultiSelect
label="Filter by Stores"
options={storeOptions}
value={selectedStores}
onValueChange={(value) => setSelectedStores(value as string[])}
placeholder="Select stores"
/>
</div>
<button
className={`mr-3 flex flex-row items-center rounded-lg px-4 py-2 ${
changeCount > 0 && !updatePricesMutation.isPending ? 'bg-blue-600' : 'bg-gray-300'
}`}
onClick={handleSave}
disabled={changeCount === 0 || updatePricesMutation.isPending}
>
{updatePricesMutation.isPending ? (
<span className={`mr-2 h-4 w-4 animate-spin rounded-full border-2 ${changeCount > 0 ? 'border-white/40 border-t-white' : 'border-gray-400/40 border-t-gray-500'}`} />
) : (
<Save
size={20}
color={changeCount > 0 ? 'white' : '#6b7280'}
className="mr-2"
/>
)}
<MyText
className={`font-medium ${changeCount > 0 ? 'text-white' : 'text-gray-500'}`}
>
Save ({changeCount})
</MyText>
</button>
<button
onClick={() => setShowMenu(true)}
className="-mr-2 p-2"
>
<EllipsisVertical size={16} color="#9CA3AF" />
</button>
</div>
{productsLoading || storesLoading ? (
<div className="flex flex-1 flex-col items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
<MyText className="mt-4 text-gray-500">Loading...</MyText>
</div>
) : (
<MyFlatList
data={filteredSkus}
renderItem={(item) => (
<SkuItemComponent
sku={item}
productName={item._productName}
hasChanges={hasChanges}
pendingChanges={pendingChanges}
setPendingChanges={setPendingChanges}
openEditDialog={openEditDialog}
/>
)}
keyExtractor={(item) => item.id.toString()}
className="p-4"
/>
)}
<BottomDialog open={editDialog.open} onClose={() => setEditDialog({ ...editDialog, open: false, tempFlashPrice: '', tempMarketPrice: '', tempPrice: '' })}>
<div className="p-4">
<MyText className="mb-1 text-lg font-bold">{editDialog.productName}</MyText>
<MyText className="mb-4 text-sm text-gray-500">{editDialog.sku?.displayName || editDialog.sku?.name || ''}</MyText>
<div className="mb-4">
<MyText className="mb-1 text-sm font-medium">Our Price</MyText>
<MyTextInput
value={editDialog.tempPrice}
onChange={(e) => setEditDialog({ ...editDialog, tempPrice: e.target.value })}
type="number"
placeholder="Enter price"
/>
</div>
<div className="mb-4">
<MyText className="mb-1 text-sm font-medium">Market Price (Optional)</MyText>
<MyTextInput
value={editDialog.tempMarketPrice}
onChange={(e) => setEditDialog({ ...editDialog, tempMarketPrice: e.target.value })}
type="number"
placeholder="Enter market price"
/>
</div>
<div className="mb-4">
<MyText className="mb-1 text-sm font-medium">Flash Price (Optional)</MyText>
<MyTextInput
value={editDialog.tempFlashPrice}
onChange={(e) => setEditDialog({ ...editDialog, tempFlashPrice: e.target.value })}
type="number"
placeholder="Enter flash price"
/>
</div>
<button
className="w-full items-center rounded-md bg-blue-600 py-3"
onClick={saveEditDialog}
>
<MyText className="font-medium text-white">Update Price</MyText>
</button>
</div>
</BottomDialog>
<BottomDialog open={showMenu} onClose={() => setShowMenu(false)}>
<div className="p-6">
<MyText className="mb-6 text-lg font-bold text-gray-800">
Options
</MyText>
<button
className="flex w-full flex-row items-center rounded-lg bg-gray-50 p-4"
onClick={() => {
navigate({ to: '/dashboard/rebalance' })
setShowMenu(false)
}}
>
<Shuffle size={20} color="#6B7280" />
<MyText className="ml-3 font-medium text-gray-800">
Re-Balance Orders
</MyText>
</button>
</div>
</BottomDialog>
</div>
)
}

View file

@ -0,0 +1,49 @@
import React from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { AppContainer, p as MyText } from 'web-components'
import ProductGroupForm from '../components/ProductGroupForm'
import { trpc } from '@/lib/trpc-client'
export const Route = createFileRoute('/dashboard/product-groupings/$id/edit')({
component: EditProductGroup,
})
function EditProductGroup() {
const { id } = Route.useParams()
const groupId = parseInt(id as string)
const { data: groupsData, isLoading } = trpc.admin.product.getGroups.useQuery()
const groups = groupsData?.groups || []
const group = groups.find((g) => g.id === groupId)
if (isLoading) {
return (
<AppContainer>
<div className="flex flex-1 items-center justify-center">
<MyText>Loading group...</MyText>
</div>
</AppContainer>
)
}
if (!group) {
return (
<AppContainer>
<div className="flex flex-1 items-center justify-center">
<MyText>Group not found</MyText>
</div>
</AppContainer>
)
}
return (
<AppContainer>
<ProductGroupForm
group={group}
onClose={() => window.history.back()}
onSuccess={() => window.history.back()}
/>
</AppContainer>
)
}

View file

@ -0,0 +1,19 @@
import React from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { AppContainer } from 'web-components'
import ProductGroupForm from '../components/ProductGroupForm'
export const Route = createFileRoute('/dashboard/product-groupings/new')({
component: CreateProductGroup,
})
function CreateProductGroup() {
return (
<AppContainer>
<ProductGroupForm
onClose={() => window.history.back()}
onSuccess={() => window.history.back()}
/>
</AppContainer>
)
}

View file

@ -0,0 +1,198 @@
import React, { useState } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { Users, Pencil, Trash2, Package, Clock, Group as GroupIcon, Plus } from 'lucide-react'
import { AppContainer, p as MyText } from 'web-components'
import { useManualRefresh, useMarkDataFetchers } from '@/lib/refresh-context'
import { trpc } from '@/lib/trpc-client'
import { theme } from '@/lib/theme'
import dayjs from 'dayjs'
import type { ProductGroup } from '../components/ProductGroupForm'
export const Route = createFileRoute('/dashboard/product-groupings')({
component: ProductGroupings,
})
const GroupItem = ({
group,
onEdit,
onDelete,
onViewProducts,
index,
}: {
group: ProductGroup
onEdit: (group: ProductGroup) => void
onDelete: (id: number) => void
onViewProducts: (products: any[]) => void
index: number
}) => {
return (
<div>
<div className="px-2 py-6">
<div className="mb-6 flex flex-row items-start justify-between">
<div className="flex flex-1 flex-row items-center">
<div
className="mr-4 flex h-12 w-12 items-center justify-center rounded-2xl"
style={{ backgroundColor: theme.colors.brand50 }}
>
<Users size={24} color={theme.colors.brand600} />
</div>
<div className="flex-1">
<MyText className="text-[10px] font-black uppercase tracking-widest text-slate-400">
Group Name
</MyText>
<MyText className="text-xl font-black text-slate-900" numberOfLines={1}>
{group.groupName}
</MyText>
</div>
</div>
<div className="flex flex-row items-center">
<button
onClick={() => onEdit(group)}
className="mr-2 p-2"
>
<Pencil size={20} color="#64748B" />
</button>
<button
onClick={() => onDelete(group.id)}
className="p-2"
>
<Trash2 size={20} color="#EF4444" />
</button>
</div>
</div>
{group.description && (
<div className="mb-6 rounded-2xl border border-slate-100 bg-slate-50 p-4">
<MyText className="font-medium text-slate-700">
{group.description}
</MyText>
</div>
)}
<div className="flex flex-row items-center justify-between">
<div className="flex flex-row items-center">
<button
onClick={() => onViewProducts(group.products)}
className="mr-4 flex flex-row items-center"
>
<Package size={14} color={theme.colors.brand500} />
<MyText className="ml-1.5 text-xs font-bold text-blue-600">
{group.productCount} Products
</MyText>
</button>
<div className="flex flex-row items-center">
<Clock size={14} color="#94A3B8" />
<MyText className="ml-1.5 text-xs font-bold text-slate-500">
{dayjs(group.createdAt).format('MMM DD, YYYY')}
</MyText>
</div>
</div>
</div>
</div>
</div>
)
}
function ProductGroupings() {
const navigate = useNavigate()
const {
data: groupsData,
isLoading,
error,
refetch,
} = trpc.admin.product.getGroups.useQuery()
const deleteGroup = trpc.admin.product.deleteGroup.useMutation()
const groups = groupsData?.groups || []
const [viewProducts, setViewProducts] = useState<any[] | null>(null)
useManualRefresh(refetch)
useMarkDataFetchers(() => {
refetch()
})
const handleCreate = () => {
navigate({ to: '/dashboard/product-groupings/new' })
}
const handleEdit = (group: ProductGroup) => {
navigate({ to: '/dashboard/product-groupings/$id/edit', params: { id: String(group.id) } })
}
const handleDelete = (id: number) => {
if (window.confirm('Are you sure you want to delete this group?')) {
deleteGroup.mutate(
{ id },
{
onSuccess: () => {
refetch()
},
}
)
}
}
if (isLoading) {
return (
<AppContainer>
<div className="flex flex-1 items-center justify-center">
<MyText className="text-gray-600">Loading product groups...</MyText>
</div>
</AppContainer>
)
}
if (error) {
return (
<AppContainer>
<div className="flex flex-1 items-center justify-center">
<MyText className="text-red-600">Error loading groups</MyText>
</div>
</AppContainer>
)
}
return (
<div className="relative flex-1 bg-white px-4">
<div className="flex-1 overflow-y-auto pb-32">
{groups.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center py-8">
<div className="mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-slate-50">
<GroupIcon size={48} color="#94A3B8" />
</div>
<MyText className="text-xl font-black tracking-tight text-slate-900">
No Groups Yet
</MyText>
<MyText className="mt-2 px-8 text-center font-medium text-slate-500">
Start by creating your first product group using the button below.
</MyText>
</div>
) : (
groups.map((group, index) => (
<React.Fragment key={group.id}>
<GroupItem
group={group as ProductGroup}
index={index}
onEdit={handleEdit}
onDelete={handleDelete}
onViewProducts={setViewProducts}
/>
{index < groups.length - 1 && (
<div className="h-px w-full bg-slate-200" />
)}
</React.Fragment>
))
)}
</div>
<button
onClick={handleCreate}
className="fixed z-[100] flex h-16 w-16 items-center justify-center rounded-[24px] shadow-lg"
style={{ bottom: 32, right: 24, background: 'linear-gradient(135deg, #1570EF, #194185)' }}
aria-label="Create group"
>
<Plus size={32} color="white" />
</button>
</div>
)
}

View file

@ -0,0 +1,118 @@
import React from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { AppContainer, p as MyText, type ImageUploaderNeoItem } from 'web-components'
import TagForm, { type TagFormData as BaseTagFormData } from '../components/TagForm'
import { trpc } from '@/lib/trpc-client'
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStorage'
export const Route = createFileRoute('/dashboard/product-tags/edit')({
validateSearch: (s: Record<string, unknown>) => ({ tagId: (s.tagId as string) ?? '' }),
component: EditTag,
})
type TagFormData = BaseTagFormData & { existingImageUrl?: string }
function EditTag() {
const { tagId } = Route.useSearch()
const tagIdNum = tagId ? parseInt(tagId) : null
const { data: tagData, isLoading: isLoadingTag, error: tagError } = trpc.admin.product.getProductTagById.useQuery(
{ id: tagIdNum || 0 },
{ enabled: !!tagIdNum }
)
const { refetch: refetchTags } = trpc.admin.product.getProductTags.useQuery(undefined, {
enabled: false,
})
const updateTag = trpc.admin.product.updateProductTag.useMutation()
const { data: storesData } = trpc.admin.store.getStores.useQuery()
const { upload, isUploading } = useUploadToObjectStorage()
const handleSubmit = async (values: TagFormData, images: ImageUploaderNeoItem[], removedExisting: boolean) => {
if (!tagIdNum) return
try {
let imageUrl: string | null | undefined
let uploadUrls: string[] = []
const newImage = images.find((image) => image.mimeType !== null)
if (newImage) {
const response = await fetch(newImage.imgUrl)
const blob = await response.blob()
const result = await upload({
images: [{ blob, mimeType: newImage.mimeType || 'image/jpeg' }],
contextString: 'tags',
})
imageUrl = result.keys[0]
uploadUrls = result.presignedUrls
} else if (removedExisting) {
imageUrl = null
}
await updateTag.mutateAsync({
id: tagIdNum,
tagName: values.tagName,
tagDescription: values.tagDescription || undefined,
imageUrl,
isDashboardTag: values.isDashboardTag,
relatedStores: values.relatedStores,
productIds: values.productIds,
uploadUrls,
})
await refetchTags()
window.alert('Tag updated successfully')
window.history.back()
} catch (error: any) {
const errorMessage = error.message || 'Failed to update tag'
window.alert(errorMessage)
}
}
if (isLoadingTag) {
return (
<AppContainer>
<div className="flex flex-1 items-center justify-center bg-gray-50">
<MyText className="text-gray-500">Loading tag...</MyText>
</div>
</AppContainer>
)
}
if (tagError || !tagData?.tag) {
return (
<AppContainer>
<div className="flex flex-1 items-center justify-center bg-gray-50">
<MyText className="text-gray-500">Tag not found</MyText>
</div>
</AppContainer>
)
}
const tag = tagData.tag
const tagProductIds = tag.productIds || (tag.products || []).map((p: any) => p.productId)
const orderedProductIds = (tag.sortOrder || []).filter((id: number) => tagProductIds.includes(id))
const remainingProductIds = tagProductIds.filter((id: number) => !orderedProductIds.includes(id))
const initialValues: TagFormData = {
tagName: tag.tagName,
tagDescription: tag.tagDescription || '',
isDashboardTag: tag.isDashboardTag,
relatedStores: Array.isArray(tag.relatedStores) ? tag.relatedStores : [],
productIds: [...orderedProductIds, ...remainingProductIds],
existingImageUrl: tag.imageUrl || undefined,
}
return (
<AppContainer>
<div className="flex-1 bg-gray-50">
<TagForm
mode="edit"
initialValues={initialValues}
existingImageUrl={tag.imageUrl || undefined}
onSubmit={handleSubmit}
isLoading={updateTag.isPending || isUploading}
stores={storesData?.stores.map((store: { id: number, name: string }) => ({ id: store.id, name: store.name })) || []}
/>
</div>
</AppContainer>
)
}

View file

@ -0,0 +1,77 @@
import React from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { AppContainer, type ImageUploaderNeoItem } from 'web-components'
import TagForm, { type TagFormData } from '../components/TagForm'
import { trpc } from '@/lib/trpc-client'
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStorage'
export const Route = createFileRoute('/dashboard/product-tags/new')({
component: AddTag,
})
function AddTag() {
const createTag = trpc.admin.product.createProductTag.useMutation()
const { refetch: refetchTags } = trpc.admin.product.getProductTags.useQuery(undefined, {
enabled: false,
})
const { data: storesData } = trpc.admin.store.getStores.useQuery()
const { upload, isUploading } = useUploadToObjectStorage()
const handleSubmit = async (values: TagFormData, images: ImageUploaderNeoItem[], _removedExisting: boolean) => {
try {
let imageUrl: string | null | undefined
let uploadUrls: string[] = []
const newImage = images.find((image) => image.mimeType !== null)
if (newImage) {
const response = await fetch(newImage.imgUrl)
const blob = await response.blob()
const result = await upload({
images: [{ blob, mimeType: newImage.mimeType || 'image/jpeg' }],
contextString: 'tags',
})
imageUrl = result.keys[0]
uploadUrls = result.presignedUrls
}
await createTag.mutateAsync({
tagName: values.tagName,
tagDescription: values.tagDescription || undefined,
imageUrl,
isDashboardTag: values.isDashboardTag,
relatedStores: values.relatedStores,
productIds: values.productIds,
uploadUrls,
})
await refetchTags()
window.alert('Tag created successfully')
window.history.back()
} catch (error: any) {
const errorMessage = error.message || 'Failed to create tag'
window.alert(errorMessage)
}
}
const initialValues: TagFormData = {
tagName: '',
tagDescription: '',
isDashboardTag: false,
relatedStores: [],
productIds: [],
}
return (
<AppContainer>
<div className="flex-1 bg-gray-50">
<TagForm
mode="create"
initialValues={initialValues}
onSubmit={handleSubmit}
isLoading={createTag.isPending || isUploading}
stores={storesData?.stores.map((store: { id: number, name: string }) => ({ id: store.id, name: store.name })) || []}
/>
</div>
</AppContainer>
)
}

View file

@ -0,0 +1,286 @@
import React, { useState, useEffect, useCallback } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core'
import { SortableContext, useSortable, arrayMove, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { GripVertical, Tag as TagIcon, ChevronLeft, CircleAlert } from 'lucide-react'
import { AppContainer, p as MyText } from 'web-components'
import { trpc } from '@/lib/trpc-client'
import type { DragOrderItemProps } from '@/types/drag-order-item'
export const Route = createFileRoute('/dashboard/product-tags/order')({
component: TagOrders,
})
interface Tag {
id: number
tagName: string
imageUrl: string | null
}
const itemStyle: React.CSSProperties = {
width: '100%',
height: 60,
backgroundColor: 'white',
borderRadius: 8,
borderWidth: 1,
borderStyle: 'solid',
borderColor: '#e5e7eb',
padding: 10,
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
boxShadow: '0 1px 2px rgba(0,0,0,0.1)',
marginTop: 4,
marginBottom: 4,
}
const activeItemStyle: React.CSSProperties = {
...itemStyle,
borderColor: '#3b82f6',
boxShadow: '0 4px 8px rgba(59,130,246,0.3)',
}
const SortableTagItem: React.FC<DragOrderItemProps<Tag>> = ({ item, isActive }) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item.id })
const active = isActive || isDragging
return (
<div
ref={setNodeRef}
style={{
...(active ? activeItemStyle : itemStyle),
transform: CSS.Translate.toString(transform),
transition,
opacity: isDragging ? 0.9 : 1,
zIndex: isDragging ? 50 : undefined,
}}
>
<div {...attributes} {...listeners} style={{ marginRight: 8, padding: 2, cursor: 'grab', touchAction: 'none' }}>
<GripVertical size={24} color={active ? '#3b82f6' : '#9ca3af'} />
</div>
{item.imageUrl ? (
<img
src={item.imageUrl}
style={{ width: 30, height: 30, borderRadius: 6, marginRight: 10, objectFit: 'cover' }}
alt={item.tagName}
/>
) : (
<div style={{ width: 30, height: 30, borderRadius: 6, backgroundColor: '#f3f4f6', marginRight: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<TagIcon size={24} color="#9ca3af" />
</div>
)}
<div style={{ flex: 1, display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<MyText style={{ fontSize: 13, color: '#111827', fontWeight: 500, flex: 1, marginRight: 4 }} numberOfLines={1}>
{item.tagName}
</MyText>
</div>
</div>
)
}
function TagOrders() {
const queryClient = useQueryClient()
const [tags, setTags] = useState<Tag[]>([])
const [hasChanges, setHasChanges] = useState(false)
const { data: constants, isLoading: isLoadingConstants, error: constantsError } = trpc.admin.const.getConstants.useQuery()
const { data: tagsData, isLoading: isLoadingTags, error: tagsError } = trpc.admin.product.getProductTags.useQuery()
const updateConstants = trpc.admin.const.updateConstants.useMutation()
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }))
useEffect(() => {
if (tagsData?.tags) {
const tagsOrderConstant = constants?.find((c) => c.key === 'tagsOrder')
let orderedIds: number[] = []
if (tagsOrderConstant) {
const value = tagsOrderConstant.value
if (Array.isArray(value)) {
orderedIds = value.map((id: any) => parseInt(id))
} else if (typeof value === 'string') {
orderedIds = value.split(',').map((id: string) => parseInt(id.trim())).filter((id) => !isNaN(id))
}
}
const tagMap = new Map(tagsData.tags.map((t) => [t.id, t]))
const sortedTags: Tag[] = []
for (const id of orderedIds) {
const tag = tagMap.get(id)
if (tag) {
sortedTags.push({
id: tag.id,
tagName: tag.tagName,
imageUrl: tag.imageUrl || null,
})
tagMap.delete(id)
}
}
for (const tag of tagMap.values()) {
sortedTags.push({
id: tag.id,
tagName: tag.tagName,
imageUrl: tag.imageUrl || null,
})
}
setTags(sortedTags)
}
}, [constants, tagsData])
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event
if (over && active.id !== over.id) {
setTags((prev) => {
const oldIndex = prev.findIndex((t) => t.id === active.id)
const newIndex = prev.findIndex((t) => t.id === over.id)
return arrayMove(prev, oldIndex, newIndex)
})
setHasChanges(true)
}
}, [])
const handleSave = () => {
const tagIds = tags.map((t) => t.id)
updateConstants.mutate(
{
constants: [{
key: 'tagsOrder',
value: tagIds,
}],
},
{
onSuccess: () => {
setHasChanges(false)
window.alert('Tag order updated successfully!')
queryClient.invalidateQueries({ queryKey: ['const.getConstants'] })
},
onError: (error) => {
window.alert('Failed to update tag order. Please try again.')
console.error('Update tag order error:', error)
},
}
)
}
if (isLoadingConstants || isLoadingTags) {
return (
<AppContainer>
<div className="flex-1 bg-gray-50">
<div className="flex flex-row items-center justify-between border-b border-gray-200 bg-white px-4 py-4">
<button onClick={() => window.history.back()} className="-ml-4 p-2">
<ChevronLeft size={24} color="#374151" />
</button>
<MyText className="text-xl font-bold text-gray-900">Tag Orders</MyText>
<div className="w-16" />
</div>
<div className="flex flex-1 flex-col items-center justify-center p-8">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
<MyText className="mt-4 text-center text-gray-500">
{isLoadingConstants ? 'Loading order...' : 'Loading tags...'}
</MyText>
</div>
</div>
</AppContainer>
)
}
if (constantsError || tagsError) {
return (
<AppContainer>
<div className="flex-1 bg-gray-50">
<div className="flex flex-row items-center justify-between border-b border-gray-200 bg-white px-4 py-4">
<button onClick={() => window.history.back()} className="-ml-4 p-2">
<ChevronLeft size={24} color="#374151" />
</button>
<MyText className="text-xl font-bold text-gray-900">Tag Orders</MyText>
<div className="w-16" />
</div>
<div className="flex flex-1 flex-col items-center justify-center p-8">
<CircleAlert size={64} color="#ef4444" />
<MyText className="mt-4 text-lg font-bold text-gray-900">Error</MyText>
<MyText className="mt-2 text-center text-gray-500">
{constantsError ? 'Failed to load order' : 'Failed to load tags'}
</MyText>
<button
onClick={() => window.history.back()}
className="mt-6 rounded-full bg-blue-600 px-6 py-3"
>
<MyText className="font-semibold text-white">Go Back</MyText>
</button>
</div>
</div>
</AppContainer>
)
}
return (
<div className="flex-1 bg-gray-50">
<div className="flex flex-row items-center justify-between border-b border-gray-200 bg-white px-4 py-4">
<button
onClick={() => window.history.back()}
className="-ml-4 p-2"
>
<ChevronLeft size={24} color="#374151" />
</button>
<MyText className="text-xl font-bold text-gray-900">Tag Orders</MyText>
<button
onClick={handleSave}
disabled={!hasChanges || updateConstants.isPending}
className={`rounded-lg px-4 py-2 ${
hasChanges && !updateConstants.isPending
? 'bg-blue-600'
: 'bg-gray-300'
}`}
>
<MyText className={`font-semibold ${
hasChanges && !updateConstants.isPending
? 'text-white'
: 'text-gray-500'
}`}>
{updateConstants.isPending ? 'Saving...' : 'Save'}
</MyText>
</button>
</div>
{tags.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center p-8">
<TagIcon size={64} color="#e5e7eb" />
<MyText className="mt-4 text-center text-lg text-gray-500">
No tags available
</MyText>
</div>
) : (
<div className="flex flex-1 flex-col">
<div className="mx-4 mb-2 mt-2 rounded-lg bg-blue-50 px-4 py-2">
<MyText className="text-center text-xs text-blue-700">
Long press and drag to reorder {tags.length} items
</MyText>
</div>
<div className="flex-1 px-3 pb-5">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={tags.map((t) => t.id)} strategy={verticalListSortingStrategy}>
{tags.map((tag) => (
<SortableTagItem key={tag.id} item={tag} drag={() => {}} isActive={false} />
))}
</SortableContext>
</DndContext>
</div>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,142 @@
import React from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { ArrowDownUp, Plus, CircleAlert, Tags, LayoutDashboard } from 'lucide-react'
import { p as MyText, MyFlatList } from 'web-components'
import { useManualRefresh, useMarkDataFetchers } from '@/lib/refresh-context'
import { TagMenu } from '../components/TagMenu'
import { trpc } from '@/lib/trpc-client'
import type { ProductTagCore } from '@packages/shared'
export const Route = createFileRoute('/dashboard/product-tags')({
component: ProductTags,
})
interface TagItemData extends ProductTagCore {
id: number
tagDescription: string | null
imageUrl: string | null
isDashboardTag: boolean
createdAt: string | Date
}
interface TagItemProps {
item: TagItemData
onDeleteSuccess: () => void
}
const TagItem: React.FC<TagItemProps> = ({ item, onDeleteSuccess }) => (
<div className="mb-4 rounded-2xl border border-gray-100 bg-white p-6 shadow-sm">
<div className="mb-4 flex flex-row items-start justify-between">
<div className="flex flex-1 flex-row items-start">
{item.imageUrl && (
<img
src={item.imageUrl}
className="mr-3 mt-1 h-16 w-16 rounded-lg object-cover"
alt={item.tagName}
/>
)}
<div className="flex-1">
<MyText className="text-lg font-bold text-gray-800">{item.tagName}</MyText>
{item.tagDescription && (
<MyText className="mt-1 text-sm text-gray-600">{item.tagDescription}</MyText>
)}
{item.isDashboardTag && (
<div className="mt-2 flex flex-row items-center">
<LayoutDashboard size={14} color="#16A34A" />
<MyText className="ml-1 text-xs text-green-700">Dashboard Tag</MyText>
</div>
)}
</div>
</div>
<TagMenu tagId={item.id} onDeleteSuccess={onDeleteSuccess} />
</div>
</div>
)
interface TagHeaderProps {
onAddNewTag: () => void
onOrderTags: () => void
}
const TagHeader: React.FC<TagHeaderProps> = ({ onAddNewTag, onOrderTags }) => (
<div className="mb-4 flex flex-row items-center justify-between border-b border-gray-200 bg-white p-4">
<button
onClick={onOrderTags}
className="flex flex-row items-center rounded-lg bg-gray-100 px-3 py-2"
>
<ArrowDownUp size={18} color="#374151" />
<MyText className="ml-1.5 font-medium text-gray-800">Tag Orders</MyText>
</button>
<button
onClick={onAddNewTag}
className="flex flex-row items-center rounded-lg bg-blue-500 px-4 py-2"
>
<Plus size={20} color="white" />
<MyText className="ml-2 font-medium text-white">Add New Tag</MyText>
</button>
</div>
)
function ProductTags() {
const navigate = useNavigate()
const { data: tagsData, isLoading, error, refetch } = trpc.admin.product.getProductTags.useQuery()
const tags = tagsData?.tags || []
useManualRefresh(refetch)
useMarkDataFetchers(() => {
refetch()
})
const handleAddNewTag = () => {
navigate({ to: '/dashboard/product-tags/new' })
}
const handleOrderTags = () => {
navigate({ to: '/dashboard/product-tags/order' })
}
if (isLoading) {
return (
<div className="flex flex-1 items-center justify-center bg-gray-50">
<MyText className="text-gray-500">Loading tags...</MyText>
</div>
)
}
if (error) {
return (
<div className="flex flex-1 flex-col items-center justify-center bg-gray-50 p-4">
<CircleAlert size={64} color="#EF4444" />
<MyText className="mt-4 text-lg font-semibold text-red-500">Error</MyText>
<MyText className="mt-2 text-center text-gray-600">{error?.message || 'Failed to load tags'}</MyText>
<button
onClick={() => refetch()}
className="mt-4 rounded-lg bg-blue-500 px-4 py-2"
>
<MyText className="font-medium text-white">Retry</MyText>
</button>
</div>
)
}
return (
<div className="flex-1 bg-gray-50 px-4">
<MyFlatList
data={tags}
renderItem={(item) => <TagItem item={item as TagItemData} onDeleteSuccess={refetch} />}
keyExtractor={(item) => item.id.toString()}
ListHeaderComponent={<TagHeader onAddNewTag={handleAddNewTag} onOrderTags={handleOrderTags} />}
ListEmptyComponent={
<div className="flex flex-1 flex-col items-center justify-center py-12">
<Tags size={64} color="#D1D5DB" />
<MyText className="mt-4 text-lg font-semibold text-gray-500">No tags yet</MyText>
<MyText className="mt-2 text-center text-gray-400">Create your first product tag</MyText>
</div>
}
className="pb-4"
/>
</div>
)
}

View file

@ -0,0 +1,461 @@
import React, { useState, useRef } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import {
Star,
CircleAlert,
ArrowLeft,
Pencil,
FileText,
LayoutGrid,
Tag,
MessageSquareText,
BadgeCheck,
Reply,
X,
MessageCircle,
Quote,
} from 'lucide-react'
import { AppContainer, p as MyText, BottomDialog, ImageUploader, ImageCarousel } from 'web-components'
import { trpc } from '@/lib/trpc-client'
import { useMarkDataFetchers } from '@/lib/refresh-context'
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStorage'
import { Formik } from 'formik'
export const Route = createFileRoute('/dashboard/products/$id')({
component: ProductDetail,
})
interface ReviewResponseFormProps {
reviewId: number
onClose: () => void
}
const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClose }) => {
const [selectedImages, setSelectedImages] = useState<{ blob: Blob, mimeType: string }[]>([])
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
const respondToReview = trpc.admin.product.respondToReview.useMutation()
const { upload } = useUploadToObjectStorage()
const handleFilesSelected = async (files: FileList | null) => {
if (!files || files.length === 0) {
setSelectedImages([])
setDisplayImages([])
return
}
const fileArray = Array.from(files)
const blobArray = fileArray.map((file) => ({ blob: file, mimeType: file.type || 'image/jpeg' }))
setSelectedImages(blobArray)
setDisplayImages(fileArray.map((file) => ({ uri: URL.createObjectURL(file) })))
}
const handleRemoveImage = (uri: string) => {
const index = displayImages.findIndex((img) => img.uri === uri)
if (index !== -1) {
const newDisplay = displayImages.filter((_, i) => i !== index)
const newFiles = selectedImages.filter((_, i) => i !== index)
setDisplayImages(newDisplay)
setSelectedImages(newFiles)
}
}
const handleSubmit = async (adminResponse: string) => {
try {
const { keys, presignedUrls } = await upload({
images: selectedImages,
contextString: 'review',
})
await respondToReview.mutateAsync({
reviewId,
adminResponse,
adminResponseImages: keys,
uploadUrls: presignedUrls,
})
window.alert('Response submitted')
onClose()
setSelectedImages([])
setDisplayImages([])
} catch (error: any) {
window.alert(error.message || 'Failed to submit response.')
}
}
return (
<Formik
initialValues={{ adminResponse: '', images: [] }}
onSubmit={(values) => handleSubmit(values.adminResponse)}
>
{({ handleChange, handleSubmit: formikSubmit, values }) => (
<div>
<textarea
className="mb-4 h-32 w-full rounded-2xl border border-gray-200 bg-gray-50 p-4 text-base text-gray-900 shadow-sm"
placeholder="Write your response here..."
value={values.adminResponse}
onChange={handleChange('adminResponse')}
/>
<div className="mb-6">
<MyText className="mb-3 text-sm font-bold uppercase tracking-wider text-gray-700">Attach Images</MyText>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => handleFilesSelected(e.target.files)}
/>
<ImageUploader
images={displayImages}
existingImageUrls={[]}
onAddImage={() => fileInputRef.current?.click()}
onRemoveImage={handleRemoveImage}
/>
</div>
<button
onClick={() => formikSubmit()}
disabled={respondToReview.isPending}
className="w-full rounded-2xl bg-gradient-to-r from-blue-600 to-blue-800 py-4 shadow-lg"
>
{respondToReview.isPending ? (
<div className="mx-auto h-5 w-5 animate-spin rounded-full border-2 border-white/40 border-t-white" />
) : (
<MyText className="text-lg font-bold text-white">Submit Response</MyText>
)}
</button>
</div>
)}
</Formik>
)
}
function ProductDetail() {
const { id } = Route.useParams()
const navigate = useNavigate()
const productId = parseInt(id as string)
const { data: productData, isLoading, error, refetch } = trpc.admin.product.getProductById.useQuery({ id: productId })
const { data: reviewsData } = trpc.admin.product.getProductReviews.useQuery({ productId })
const [responseDialogOpen, setResponseDialogOpen] = useState(false)
const [selectedReview, setSelectedReview] = useState<any>(null)
useMarkDataFetchers(() => {
refetch()
})
const product = productData?.product
const defaultSku = product?.skus?.[0]
const handleEdit = () => {
navigate({ to: '/dashboard/products/edit', search: { id: String(productId) } })
}
if (isLoading) {
return (
<AppContainer>
<div className="flex flex-1 flex-col items-center justify-center bg-white">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
<MyText className="mt-4 font-medium text-gray-500">Loading...</MyText>
</div>
</AppContainer>
)
}
if (error || !product) {
return (
<AppContainer>
<div className="flex flex-1 flex-col items-center justify-center bg-white">
<CircleAlert size={64} color="#EF4444" />
<MyText className="mt-4 text-xl font-bold text-gray-900">Product Not Found</MyText>
<button onClick={() => window.history.back()} className="mt-6 rounded-full bg-gray-100 px-8 py-3">
<span className="font-semibold text-gray-800">Go Back</span>
</button>
</div>
</AppContainer>
)
}
const averageRating = reviewsData?.reviews.reduce((acc, r) => acc + r.ratings, 0)
? (reviewsData!.reviews.reduce((acc, r) => acc + r.ratings, 0) / reviewsData!.reviews.length).toFixed(1)
: '-'
return (
<div className="flex-1 bg-gray-50">
<div className="flex-1 pb-32">
<div className="relative">
{defaultSku?.images && defaultSku.images.length > 0 ? (
<ImageCarousel
images={defaultSku.images.map((url: string) => ({ uri: url }))}
className="[&_img]:h-80"
/>
) : (
<div className="flex h-80 w-full items-center justify-center bg-gray-200" />
)}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t from-black/30 to-transparent" />
<div className="absolute inset-x-0 top-4 z-10 flex flex-row items-center justify-between px-4">
<button onClick={() => window.history.back()}>
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-black/50 backdrop-blur">
<ArrowLeft size={24} color="white" />
</div>
</button>
<button onClick={handleEdit}>
<div className="flex flex-row items-center overflow-hidden rounded-full bg-black/50 px-4 py-2 backdrop-blur">
<Pencil size={16} color="white" />
<span className="ml-2 font-bold text-white">Edit</span>
</div>
</button>
</div>
</div>
<div className="-mt-8 overflow-hidden rounded-t-[32px] bg-gray-50">
<div className="mb-4 rounded-b-[32px] bg-white px-6 pb-6 pt-8 shadow-sm">
<div className="mb-2 flex flex-row items-start justify-between">
<MyText className="mr-4 flex-1 text-3xl font-extrabold leading-tight text-gray-900">{product.name}</MyText>
</div>
<div className="mt-2 flex flex-row items-end">
<div className="rounded-full border border-blue-100 bg-blue-50 px-3 py-1.5">
<span className="text-sm font-bold text-blue-700">
{product.skus?.length ?? 0} Variant{(product.skus?.length ?? 0) !== 1 ? 's' : ''}
</span>
</div>
</div>
<div className="mt-6 flex flex-row border-t border-gray-100 pt-6">
<div className="flex flex-1 flex-col items-center border-r border-gray-100">
<div className="flex flex-row items-center">
<Star size={20} color="#F59E0B" fill="#F59E0B" />
<span className="ml-1 text-lg font-bold text-gray-900">{averageRating}</span>
</div>
<span className="mt-1 text-xs font-medium uppercase text-gray-400">Rating</span>
</div>
<div className="flex flex-1 flex-col items-center border-r border-gray-100">
<span className="text-lg font-bold text-gray-900">{reviewsData?.reviews.length || 0}</span>
<span className="mt-1 text-xs font-medium uppercase text-gray-400">Reviews</span>
</div>
<div className="flex flex-1 flex-col items-center">
<span className="text-lg font-bold text-gray-900">{product.incrementStep || 1}</span>
<span className="mt-1 text-xs font-medium uppercase text-gray-400">Step</span>
</div>
</div>
</div>
<div className="mb-4 px-4">
<div className="rounded-3xl bg-white p-6 shadow-sm">
<div className="mb-4 flex flex-row items-center">
<div className="mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-blue-50">
<FileText size={22} color="#2563EB" />
</div>
<span className="text-lg font-bold text-gray-900">Description</span>
</div>
{product.shortDescription && (
<p className="mb-3 text-base font-medium leading-relaxed text-gray-900">{product.shortDescription}</p>
)}
<p className="text-base leading-7 text-gray-600">
{product.longDescription || 'No detailed description available for this product.'}
</p>
</div>
</div>
<div className="mb-4 px-4">
<div className="rounded-3xl bg-white p-6 shadow-sm">
<div className="mb-4 flex flex-row items-center">
<div className="mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-green-50">
<LayoutGrid size={22} color="#059669" />
</div>
<span className="text-lg font-bold text-gray-900">Variants</span>
<div className="ml-3 rounded-full bg-gray-100 px-3 py-1">
<span className="text-xs font-bold text-gray-600">{product.skus?.length ?? 0}</span>
</div>
</div>
{product.skus?.map((sku) => (
<div key={sku.id} className="mb-3 rounded-2xl border border-gray-200 p-4">
{sku.features?.map((f) => (
<div key={f.id} className="mb-1 flex flex-row">
<span className="w-24 text-sm font-medium text-gray-500">{f.featureName}</span>
<span className="text-sm font-semibold text-gray-900">{f.featureValue}</span>
</div>
))}
<div className="mt-3 flex flex-row items-end justify-between border-t border-gray-100 pt-3">
<div>
<p className="text-2xl font-black text-gray-900">{sku.price}</p>
{sku.marketPrice && (
<p className="text-sm text-gray-400 line-through">{sku.marketPrice}</p>
)}
</div>
<div className="flex flex-row items-center gap-2">
{sku.isFlashAvailable && (
<div className="rounded-full border border-red-200 bg-red-50 px-2 py-1">
<span className="text-xs font-bold text-red-600">Flash {sku.flashPrice}</span>
</div>
)}
<div className={`rounded-full px-3 py-1 ${sku.isOutOfStock ? 'bg-red-100' : 'bg-green-100'}`}>
<span className={`text-xs font-bold ${sku.isOutOfStock ? 'text-red-700' : 'text-green-700'}`}>
{sku.isOutOfStock ? 'Out of Stock' : 'In Stock'}
</span>
</div>
</div>
</div>
{sku.images && sku.images.length > 0 && (
<div className="mt-3 flex flex-row gap-2 overflow-x-auto">
{sku.images.map((url, idx) => (
<img key={idx} src={url} className="h-16 w-16 rounded-lg bg-gray-100 object-cover" alt="" />
))}
</div>
)}
</div>
))}
</div>
</div>
{product.deals && product.deals.length > 0 && (
<div className="mb-4 px-4">
<div className="rounded-3xl border border-amber-100 bg-gradient-to-br from-amber-50 to-amber-100 p-6 shadow-sm">
<div className="mb-4 flex flex-row items-center">
<div className="mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-amber-100">
<Tag size={22} color="#D97706" />
</div>
<span className="text-lg font-bold text-amber-900">Special Deals</span>
</div>
{product.deals.map((deal, index) => (
<div key={index} className="mb-2 flex flex-row items-center justify-between rounded-2xl border border-amber-200/50 bg-white/60 p-4">
<div>
<p className="text-lg font-bold text-amber-900">Buy {deal.quantity}</p>
<p className="text-xs font-medium text-amber-700">Valid until {new Date(deal.validTill).toLocaleDateString()}</p>
</div>
<div className="flex flex-col items-end">
<p className="text-2xl font-black text-amber-600">{deal.price}</p>
<p className="text-xs font-medium text-amber-700">Total Price</p>
</div>
</div>
))}
</div>
</div>
)}
<div className="mb-8 px-4">
<div className="rounded-3xl bg-white p-6 shadow-sm">
<div className="mb-6 flex flex-row items-center justify-between">
<div className="flex flex-row items-center">
<div className="mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-purple-50">
<MessageSquareText size={22} color="#7C3AED" />
</div>
<span className="text-lg font-bold text-gray-900">Reviews</span>
</div>
<div className="rounded-full bg-gray-100 px-3 py-1">
<span className="text-xs font-bold text-gray-600">{reviewsData?.reviews.length || 0} Total</span>
</div>
</div>
{reviewsData && reviewsData.reviews.length > 0 ? (
reviewsData.reviews.map((review) => (
<div key={review.id} className="mb-6 last:mb-0">
<div className="flex flex-row items-start">
<div className="flex-1 rounded-2xl bg-gray-50 p-4">
<div className="mb-2 flex flex-row items-start justify-between">
<span className="text-base font-bold text-gray-900">{review.userName}</span>
<div className="flex flex-row">
{[1, 2, 3, 4, 5].map((star) => (
<Star key={star} size={14} color="#F59E0B" fill={star <= review.ratings ? '#F59E0B' : 'none'} />
))}
</div>
</div>
<p className="mb-3 leading-relaxed text-gray-700">{review.reviewBody}</p>
{review.signedImageUrls && review.signedImageUrls.length > 0 && (
<div className="mb-3 flex flex-row gap-2 overflow-x-auto">
{review.signedImageUrls.map((url, index) => (
<img key={index} src={url} className="h-16 w-16 rounded-lg bg-gray-200 object-cover" alt="" />
))}
</div>
)}
{review.adminResponse ? (
<div className="mt-3 border-t border-gray-200 pt-3">
<div className="mb-1 flex flex-row items-center">
<BadgeCheck size={14} color="#2563EB" />
<span className="ml-1 text-xs font-bold text-blue-700">Admin Response</span>
</div>
<p className="text-sm leading-relaxed text-gray-600">{review.adminResponse}</p>
{review.signedAdminImageUrls && review.signedAdminImageUrls.length > 0 && (
<div className="mt-2 flex flex-row gap-2 overflow-x-auto">
{review.signedAdminImageUrls.map((url, index) => (
<img key={index} src={url} className="h-12 w-12 rounded-lg bg-white object-cover" alt="" />
))}
</div>
)}
</div>
) : (
<button
onClick={() => {
setSelectedReview(review)
setResponseDialogOpen(true)
}}
className="mt-2 flex flex-row items-center self-end rounded-full border border-gray-200 bg-white px-4 py-2 shadow-sm"
>
<Reply size={16} color="#059669" />
<span className="ml-1 text-xs font-bold text-gray-700">Reply</span>
</button>
)}
</div>
</div>
</div>
))
) : (
<div className="flex flex-col items-center justify-center py-10 opacity-50">
<MessageCircle size={48} color="#9CA3AF" />
<span className="mt-2 font-medium text-gray-400">No reviews yet</span>
</div>
)}
</div>
</div>
</div>
</div>
<BottomDialog open={responseDialogOpen} onClose={() => setResponseDialogOpen(false)}>
<div className="max-h-[700px] overflow-y-auto p-6">
<div className="mb-6 flex flex-row items-center justify-between">
<span className="text-2xl font-bold text-gray-900">Reply to Review</span>
<button onClick={() => setResponseDialogOpen(false)} className="rounded-full bg-gray-100 p-2">
<X size={20} color="#6B7280" />
</button>
</div>
{selectedReview && (
<div>
<div className="mb-6 rounded-2xl border border-gray-100 bg-gray-50 p-4">
<div className="mb-2 flex flex-row items-center">
<Quote size={20} color="#9CA3AF" />
<span className="ml-1 text-xs font-bold uppercase text-gray-500">Replying to {selectedReview.userName}</span>
</div>
<MyText numberOfLines={3} className="border-l-2 border-gray-300 pl-2 text-base italic leading-relaxed text-gray-700">
{selectedReview.reviewBody}
</MyText>
</div>
<ReviewResponseForm
reviewId={selectedReview.id}
onClose={() => {
setResponseDialogOpen(false)
setSelectedReview(null)
}}
/>
</div>
)}
</div>
</BottomDialog>
</div>
)
}

View file

@ -0,0 +1,227 @@
import React, { useRef, useMemo } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { AppContainer, p as MyText, type ImageUploaderNeoItem, type ImageUploaderNeoPayload } from 'web-components'
import ProductForm, { type ProductFormRef } from '../components/ProductForm'
import { trpc } from '@/lib/trpc-client'
import { useManualRefresh } from '@/lib/refresh-context'
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStorage'
export const Route = createFileRoute('/dashboard/products/edit')({
validateSearch: (s: Record<string, unknown>) => ({ id: (s.id as string) ?? '' }),
component: EditProduct,
})
function EditProduct() {
const { id } = Route.useSearch()
const productId = Number(id)
const productFormRef = useRef<ProductFormRef>(null)
const { data: productResponse, isLoading: isFetching, refetch } = trpc.admin.product.getProductById.useQuery(
{ id: productId },
{ enabled: !!productId }
)
const { refetch: refetchProducts } = trpc.admin.product.getProducts.useQuery(undefined, {
enabled: false,
})
const updateProduct = trpc.admin.product.updateProduct.useMutation()
const { upload, isUploading } = useUploadToObjectStorage()
useManualRefresh(() => refetch())
const productData = productResponse?.product
const initialValues = useMemo(() => {
if (!productData) {
return {
name: '',
shortDescription: '',
longDescription: '',
storeId: 0,
productType: 'item' as const,
variants: [],
}
}
return {
name: productData.name,
shortDescription: productData.shortDescription || '',
longDescription: productData.longDescription || '',
storeId: productData.storeId || 1,
productType: (productData.productType as 'item' | 'combo') || 'item',
variants: (productData.skus || []).map((sku) => ({
id: sku.id,
name: sku.name || '',
price: sku.price ? parseFloat(sku.price) : undefined,
marketPrice: sku.marketPrice ? parseFloat(sku.marketPrice) : null,
isFlashAvailable: sku.isFlashAvailable || false,
flashPrice: sku.flashPrice ? parseFloat(sku.flashPrice) : null,
isOffer: sku.isOffer || false,
isComboOnly: sku.isComboOnly || false,
isDeleted: sku.isDeleted || false,
isSuspended: sku.isSuspended || false,
features: (sku.features || []).map((f) => ({
featureName: f.featureName,
featureValue: f.featureValue,
})),
comboItems: (sku.comboItems || []).map((ci: any) => ({
skuId: ci.skuId,
})),
})),
}
}, [productData])
const existingVariantImages = useMemo(() => {
if (!productData) return []
return (productData.skus || []).map((sku) =>
(sku.images || []).map((url) => ({ imgUrl: url, mimeType: null } as ImageUploaderNeoItem))
)
}, [productData])
const existingVariantImageKeys = useMemo(() => {
if (!productData) return []
return (productData.skus || []).map((sku) =>
(sku.imageKeys || []).map(String)
)
}, [productData])
const handleSubmit = async (values: any, variantImages: ImageUploaderNeoPayload[][], deletedImageKeys: string[]) => {
try {
for (const variant of values.variants) {
const price = variant.price
if (price == null || price <= 0) {
window.alert('Please enter a valid price for every variant')
return
}
}
const seenSignatures = new Set<string>()
for (const variant of values.variants) {
const features = variant.features || []
const hasQuantity = features.some(
(a: any) => (a.featureName ?? '').trim().toLowerCase() === 'quantity'
)
if (!hasQuantity) {
window.alert('Every SKU must have a quantity feature')
return
}
const signature = features
.map((a: any) => `${(a.featureName ?? '').trim().toLowerCase()}::${a.featureValue.trim().toLowerCase()}`)
.sort()
.join('|')
if (seenSignatures.has(signature)) {
window.alert('Two variants have the same attributes')
return
}
seenSignatures.add(signature)
}
const allBlobs: { blob: Blob, mimeType: string }[] = []
const imageCounts: number[] = variantImages.map((imgs) =>
imgs.filter((img) => img.mimeType !== null).length
)
for (const imgs of variantImages) {
for (const img of imgs) {
if (img.mimeType === null) continue
const response = await fetch(img.url)
const blob = await response.blob()
allBlobs.push({ blob, mimeType: img.mimeType || 'image/jpeg' })
}
}
let allUploadUrls: string[] = []
if (allBlobs.length > 0) {
const result = await upload({ images: allBlobs, contextString: 'product_info' })
allUploadUrls = result.presignedUrls
}
let urlCursor = 0
const skus = values.variants.map((variant: any, vIndex: number) => {
const count = imageCounts[vIndex]
const newUrls = allUploadUrls.slice(urlCursor, urlCursor + count)
urlCursor += count
const existingUrls = variantImages[vIndex]
?.filter((img) => img.mimeType === null)
.map((img) => img.url) || []
const allUrls = [...existingUrls, ...newUrls]
return {
id: variant.id,
name: variant.name || null,
price: variant.price as number,
marketPrice: variant.marketPrice ?? undefined,
images: allUrls,
isFlashAvailable: variant.isFlashAvailable || false,
flashPrice: variant.flashPrice ?? undefined,
isOffer: variant.isOffer || false,
isComboOnly: variant.isComboOnly || false,
isDeleted: variant.isDeleted || false,
isSuspended: variant.isSuspended || false,
features: variant.features.map((attr: any) => ({
featureName: attr.featureName,
featureValue: attr.featureValue,
})),
comboItems: (variant.comboItems || []).map((ci: any) => ({
skuId: ci.skuId,
})),
}
})
await updateProduct.mutateAsync({
id: productId,
name: values.name,
shortDescription: values.shortDescription || undefined,
longDescription: values.longDescription || undefined,
storeId: values.storeId,
productType: values.productType,
incrementStep: 1,
skus,
deletedImageKeys,
newImageUrls: allUploadUrls,
} as any)
await refetch()
await refetchProducts()
window.alert('Product updated successfully!')
productFormRef.current?.clearImages()
} catch (error: any) {
window.alert(error.message || 'Failed to update product')
}
}
if (isFetching) {
return (
<AppContainer>
<div className="flex flex-1 items-center justify-center">
<MyText className="text-gray-600">Loading product...</MyText>
</div>
</AppContainer>
)
}
if (!productData) {
return (
<AppContainer>
<div className="flex flex-1 items-center justify-center">
<MyText className="text-red-600">Product not found</MyText>
</div>
</AppContainer>
)
}
return (
<AppContainer>
<ProductForm
ref={productFormRef}
mode="edit"
initialValues={initialValues}
onSubmit={handleSubmit}
isLoading={updateProduct.isPending || isUploading}
existingVariantImages={existingVariantImages}
existingVariantImageKeys={existingVariantImageKeys}
/>
</AppContainer>
)
}

View file

@ -0,0 +1,145 @@
import React from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { AppContainer, type ImageUploaderNeoPayload } from 'web-components'
import ProductForm from '../components/ProductForm'
import { trpc } from '@/lib/trpc-client'
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStorage'
export const Route = createFileRoute('/dashboard/products/new')({
component: AddProduct,
})
function AddProduct() {
const createProduct = trpc.admin.product.createProduct.useMutation()
const { refetch: refetchProducts } = trpc.admin.product.getProducts.useQuery(undefined, {
enabled: false,
})
const { upload, isUploading } = useUploadToObjectStorage()
const handleSubmit = async (values: any, variantImages: ImageUploaderNeoPayload[][], _deletedImageKeys?: string[]) => {
try {
for (const variant of values.variants) {
const price = variant.price
if (price == null || price <= 0) {
window.alert('Please enter a valid price for every variant')
return
}
}
const seenSignatures = new Set<string>()
for (const variant of values.variants) {
const features = variant.features || []
const hasQuantity = features.some(
(a: any) => (a.featureName ?? '').trim().toLowerCase() === 'quantity'
)
if (!hasQuantity) {
window.alert('Every SKU must have a quantity feature')
return
}
const signature = features
.map((a: any) => `${(a.featureName ?? '').trim().toLowerCase()}::${a.featureValue.trim().toLowerCase()}`)
.sort()
.join('|')
if (seenSignatures.has(signature)) {
window.alert('Two variants have the same attributes')
return
}
seenSignatures.add(signature)
}
const allBlobs: { blob: Blob, mimeType: string }[] = []
const imageCounts: number[] = variantImages.map((imgs) => imgs.length)
for (const imgs of variantImages) {
for (const img of imgs) {
const response = await fetch(img.url)
const blob = await response.blob()
allBlobs.push({ blob, mimeType: img.mimeType || 'image/jpeg' })
}
}
let allUploadUrls: string[] = []
if (allBlobs.length > 0) {
const result = await upload({ images: allBlobs, contextString: 'product_info' })
allUploadUrls = result.presignedUrls
}
let urlCursor = 0
const skus = values.variants.map((variant: any, vIndex: number) => {
const count = imageCounts[vIndex]
const variantUrls = allUploadUrls.slice(urlCursor, urlCursor + count)
urlCursor += count
return {
name: variant.name || null,
price: variant.price as number,
marketPrice: variant.marketPrice ?? undefined,
images: variantUrls,
isFlashAvailable: variant.isFlashAvailable || false,
flashPrice: variant.flashPrice ?? undefined,
isOffer: variant.isOffer || false,
isComboOnly: variant.isComboOnly || false,
isDeleted: variant.isDeleted || false,
isSuspended: variant.isSuspended || false,
features: variant.features.map((attr: any) => ({
featureName: attr.featureName,
featureValue: attr.featureValue,
})),
comboItems: (variant.comboItems || []).map((ci: any) => ({
skuId: ci.skuId,
})),
}
})
await createProduct.mutateAsync({
name: values.name,
shortDescription: values.shortDescription || undefined,
longDescription: values.longDescription || undefined,
storeId: values.storeId,
productType: values.productType,
incrementStep: 1,
skus,
})
await refetchProducts()
window.alert('Product created successfully!')
} catch (error: any) {
window.alert(error.message || 'Failed to create product')
}
}
const initialValues = {
name: '',
shortDescription: '',
longDescription: '',
storeId: 1,
productType: 'item' as const,
variants: [
{
id: undefined as number | undefined,
name: '',
price: undefined,
marketPrice: null,
isFlashAvailable: false,
flashPrice: null,
isOffer: false,
isComboOnly: false,
isDeleted: false,
isSuspended: false,
features: [{ featureName: 'quantity', featureValue: '' }],
comboItems: [],
},
],
}
return (
<AppContainer>
<ProductForm
mode="create"
initialValues={initialValues}
onSubmit={handleSubmit}
isLoading={createProduct.isPending || isUploading}
/>
</AppContainer>
)
}

View file

@ -0,0 +1,228 @@
import React, { useState, useMemo } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { Image as ImageIcon, Eye, Pencil, Package } from 'lucide-react'
import { AppContainer, p as MyText, MyButton, SearchBar, MyFlatList } from 'web-components'
import { trpc } from '@/lib/trpc-client'
import { useManualRefresh, useMarkDataFetchers } from '@/lib/refresh-context'
import type { AdminSku } from '@packages/shared'
export const Route = createFileRoute('/dashboard/products')({
component: Products,
})
type FilterType = 'all' | 'in-stock' | 'out-of-stock'
type SerializedAdminSku = Omit<AdminSku, 'createdAt'> & { createdAt: string | Date }
function getDefaultSku(product: { skus: SerializedAdminSku[] }): SerializedAdminSku | null {
return product.skus?.[0] ?? null
}
function FilterButton({ filter, label, count, activeFilter, onSelect }: { filter: FilterType, label: string, count: number, activeFilter: FilterType, onSelect: (f: FilterType) => void }) {
return (
<button
onClick={() => onSelect(filter)}
className={`rounded-lg px-4 py-2 ${activeFilter === filter ? 'bg-blue-500' : 'bg-gray-200'}`}
>
<MyText className={`${activeFilter === filter ? 'text-white' : 'text-gray-700'} font-semibold`}>
{label} ({count})
</MyText>
</button>
)
}
function Products() {
const navigate = useNavigate()
const [searchTerm, setSearchTerm] = useState('')
const [activeFilter, setActiveFilter] = useState<FilterType>('all')
const { data: productsData, isLoading, error, refetch } = trpc.admin.product.getProducts.useQuery()
useManualRefresh(refetch)
useMarkDataFetchers(() => {
refetch()
})
const products = productsData?.products || []
const filteredProducts = useMemo(() => {
return products.filter((product) => {
const defaultSku = getDefaultSku(product)
const matchesSearch = product.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(product.shortDescription?.toLowerCase().includes(searchTerm.toLowerCase()))
const matchesFilter = activeFilter === 'all' ||
(activeFilter === 'in-stock' && !defaultSku?.isOutOfStock) ||
(activeFilter === 'out-of-stock' && defaultSku?.isOutOfStock)
return matchesSearch && matchesFilter
})
}, [products, searchTerm, activeFilter])
const handleEdit = (productId: number) => {
navigate({ to: '/dashboard/products/edit', search: { id: String(productId) } })
}
const handleViewDetails = (productId: number) => {
navigate({ to: '/dashboard/products/$id', params: { id: String(productId) } })
}
if (isLoading) {
return (
<AppContainer>
<div className="flex flex-1 items-center justify-center">
<MyText className="text-gray-600">Loading products...</MyText>
</div>
</AppContainer>
)
}
if (error) {
return (
<AppContainer>
<div className="flex flex-1 flex-col items-center justify-center">
<MyText className="text-red-600">Error loading products</MyText>
<button
onClick={() => refetch()}
className="mt-4 rounded-lg bg-blue-500 px-4 py-2"
>
<MyText className="font-semibold text-white">Retry</MyText>
</button>
</div>
</AppContainer>
)
}
const inStockCount = products.filter((p) => getDefaultSku(p) && !getDefaultSku(p)!.isOutOfStock).length
const outOfStockCount = products.filter((p) => getDefaultSku(p)?.isOutOfStock).length
const listHeader = (
<>
<div className="mb-6 flex flex-row items-center justify-between">
<MyText className="text-2xl font-bold text-gray-800">Products</MyText>
<MyButton onClick={() => navigate({ to: '/dashboard/products/new' })}>
Add Product
</MyButton>
</div>
<div className="mb-4">
<SearchBar
value={searchTerm}
onChange={setSearchTerm}
placeholder="Search products..."
className="mb-0"
/>
</div>
<div className="mb-6 flex flex-row gap-2">
<FilterButton filter="all" label="All" count={products.length} activeFilter={activeFilter} onSelect={setActiveFilter} />
<FilterButton filter="in-stock" label="In Stock" count={inStockCount} activeFilter={activeFilter} onSelect={setActiveFilter} />
<FilterButton filter="out-of-stock" label="Out of Stock" count={outOfStockCount} activeFilter={activeFilter} onSelect={setActiveFilter} />
</div>
</>
)
const renderItem = (product: any) => {
const defaultSku = getDefaultSku(product)
const skuImages = defaultSku?.images ?? null
const isOut = defaultSku?.isOutOfStock ?? false
return (
<div key={product.id} className="mb-4 overflow-hidden rounded-2xl bg-white shadow-lg">
{skuImages && skuImages.length > 0 ? (
<img
src={skuImages[0]}
className="h-48 w-full object-cover"
alt={product.name}
/>
) : (
<div className="flex h-48 w-full items-center justify-center bg-gray-200">
<ImageIcon size={48} color="#9CA3AF" />
</div>
)}
<div className="p-4">
<div className="mb-2 flex flex-row items-start justify-between">
<MyText className="mr-2 flex-1 text-lg font-bold text-gray-800" numberOfLines={2}>
{product.name}
</MyText>
<div className="flex flex-row items-center">
<div className={`mr-1 h-3 w-3 rounded-full ${isOut ? 'bg-red-500' : 'bg-green-500'}`} />
<MyText className={`text-sm font-semibold ${isOut ? 'text-red-500' : 'text-green-500'}`}>
{isOut ? 'Out' : 'In'}
</MyText>
</div>
</div>
{product.shortDescription && (
<MyText className="mb-2 text-gray-600" numberOfLines={2}>
{product.shortDescription}
</MyText>
)}
<div className="mb-3 flex flex-row items-center justify-between">
<MyText className="text-sm font-medium text-gray-500">
{product.skus?.length ?? 0} Variants
</MyText>
</div>
<div className="flex flex-row gap-2">
<button
onClick={() => handleViewDetails(product.id)}
className="flex flex-1 flex-row items-center justify-center rounded-lg bg-gray-500 p-3"
>
<Eye size={16} color="white" />
<MyText className="ml-1 font-semibold text-white">View</MyText>
</button>
<button
onClick={() => handleEdit(product.id)}
className="flex flex-1 flex-row items-center justify-center rounded-lg bg-blue-500 p-3"
>
<Pencil size={16} color="white" />
<MyText className="ml-1 font-semibold text-white">Edit</MyText>
</button>
</div>
</div>
</div>
)
}
return (
<AppContainer>
<div className="flex flex-1 flex-col">
<MyFlatList
data={filteredProducts}
renderItem={renderItem}
keyExtractor={(item) => item.id.toString()}
ListHeaderComponent={listHeader}
ListEmptyComponent={
<div className="flex flex-1 flex-col items-center justify-center py-10">
<Package size={64} color="#D1D5DB" />
<MyText className="mt-4 text-center text-lg text-gray-500">
{searchTerm || activeFilter !== 'all' ? 'No products match your filters' : 'No products found'}
</MyText>
<MyText className="mt-2 text-center text-gray-400">
{searchTerm || activeFilter !== 'all' ? 'Try adjusting your search or filters' : 'Start by adding your first product'}
</MyText>
{(searchTerm || activeFilter !== 'all') && (
<button
onClick={() => {
setSearchTerm('')
setActiveFilter('all')
}}
className="mt-4 rounded-lg bg-blue-500 px-4 py-2"
>
<MyText className="font-semibold text-white">Clear Filters</MyText>
</button>
)}
</div>
}
className="pb-4"
/>
</div>
</AppContainer>
)
}

View file

@ -0,0 +1,248 @@
import React, { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { CalendarClock, Truck, Snowflake, ShoppingBasket, RefreshCw } from 'lucide-react'
import { p as MyText, MyFlatList, BottomDialog, Checkbox, div as MyTouchableOpacity } from 'web-components'
import { trpc } from '@/lib/trpc-client'
import dayjs from 'dayjs'
export const Route = createFileRoute('/dashboard/rebalance')({
component: RebalanceOrders,
})
interface SlotItemProps {
item: any
selectedSlots: number[]
toggleSlotSelection: (slotId: number) => void
setDialogProducts: React.Dispatch<React.SetStateAction<any[]>>
setDialogOpen: React.Dispatch<React.SetStateAction<boolean>>
}
const SlotItemComponent: React.FC<SlotItemProps> = ({
item: slot,
selectedSlots,
toggleSlotSelection,
setDialogProducts,
setDialogOpen,
}) => {
const isSelected = selectedSlots.includes(slot.id)
const slotProducts = slot.products?.map((p: any) => p.name).filter(Boolean) || []
const displayProducts = slotProducts.slice(0, 2).join(', ')
const isActive = slot.isActive
const statusColor = isActive ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
const statusText = isActive ? 'Active' : 'Inactive'
return (
<div
onClick={() => toggleSlotSelection(slot.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter') toggleSlotSelection(slot.id)
}}
className="mb-4 cursor-pointer rounded-3xl border border-gray-100 bg-white p-5 text-left shadow-sm"
>
<div className="mb-4 flex-row flex items-center justify-between">
<div className="flex-row flex items-center">
<span className="mr-3" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onPress={() => toggleSlotSelection(slot.id)}
size={24}
/>
</span>
<div
className="mr-3 flex h-10 w-10 items-center justify-center rounded-full"
style={{ backgroundColor: '#fff0f6' }}
>
<CalendarClock size={20} color="#F83758" />
</div>
<div>
<MyText className="text-lg font-bold text-gray-900">Slot #{slot.id}</MyText>
<MyText className="text-xs text-gray-500">ID: {slot.id}</MyText>
</div>
</div>
<div className="flex-row flex items-center">
<span className={`rounded-full px-3 py-1 ${statusColor.split(' ')[0]}`}>
<MyText className={`text-xs font-bold ${statusColor.split(' ')[1]}`}>{statusText}</MyText>
</span>
</div>
</div>
<div className="mb-4 h-[1px] bg-gray-100" />
<div className="flex-row flex flex-wrap">
<div className="mb-4 w-1/2 pr-2">
<div className="mb-1 flex-row flex items-center">
<Truck size={14} color="#6b7280" className="mr-1" />
<MyText className="text-xs font-semibold uppercase tracking-wider text-gray-500">Delivery</MyText>
</div>
<MyText className="text-sm font-medium text-gray-800">
{dayjs(slot.deliveryTime).format('DD MMM, h:mm A')}
</MyText>
</div>
<div className="mb-4 w-1/2 pl-2">
<div className="mb-1 flex-row flex items-center">
<Snowflake size={14} color="#6b7280" className="mr-1" />
<MyText className="text-xs font-semibold uppercase tracking-wider text-gray-500">Freeze</MyText>
</div>
<MyText className="text-sm font-medium text-gray-800">
{dayjs(slot.freezeTime).format('DD MMM, h:mm A')}
</MyText>
</div>
</div>
{slotProducts.length > 0 ? (
<div className="mt-1 rounded-xl bg-gray-50 p-3">
<div className="flex-row flex items-start">
<ShoppingBasket size={16} color="#4b5563" className="mr-2 mt-0.5" />
<div className="flex-1">
<MyText className="mb-0.5 text-xs text-gray-500">Products</MyText>
<div className="flex-row flex flex-wrap items-center">
<MyText className="text-sm leading-5 text-gray-800">{displayProducts}</MyText>
{slotProducts.length > 2 && (
<button
onClick={(e) => {
e.stopPropagation()
setDialogProducts(slotProducts)
setDialogOpen(true)
}}
>
<MyText className="ml-1 text-sm font-semibold" style={{ color: '#2E90FA' }}>
+{slotProducts.length - 2} more
</MyText>
</button>
)}
</div>
</div>
</div>
</div>
) : null}
</div>
)
}
function RebalanceOrders() {
const [selectedSlots, setSelectedSlots] = useState<number[]>([])
const [dialogOpen, setDialogOpen] = useState(false)
const [dialogProducts, setDialogProducts] = useState<any[]>([])
const [refreshing, setRefreshing] = useState(false)
const { data: slotsData, isLoading, refetch: refetchSlots } = trpc.admin.slots.getAll.useQuery()
const upcomingSlots = slotsData?.slots?.filter(slot => dayjs(slot.deliveryTime).isAfter(dayjs())) || []
const handleRefresh = async () => {
setRefreshing(true)
await refetchSlots()
setRefreshing(false)
}
const { mutate: rebalanceSlots } = trpc.admin.order.rebalanceSlots.useMutation({
onSuccess: () => {
refetchSlots()
},
onSettled: () => {
window.alert('Rebalance Complete: Slots have been rebalanced.')
}
})
const toggleSlotSelection = (slotId: number) => {
setSelectedSlots(prev =>
prev.includes(slotId)
? prev.filter(id => id !== slotId)
: [...prev, slotId]
)
}
const handleRebalance = () => {
window.alert('Rebalancing... Please wait while we rebalance the selected slots.')
rebalanceSlots({ slotIds: selectedSlots })
}
if (isLoading) {
return (
<div className="flex flex-1 items-center justify-center bg-white">
<MyText>Loading slots...</MyText>
</div>
)
}
return (
<div className="relative flex-1 bg-white">
<div className="flex-1 p-4">
<MyText className="mb-4 text-xl font-bold text-gray-900">Rebalance Upcoming Slots</MyText>
{selectedSlots.length > 0 && (
<MyText className="mb-4 text-sm text-gray-600">
{selectedSlots.length} slot{selectedSlots.length === 1 ? '' : 's'} selected
</MyText>
)}
{upcomingSlots.length === 0 ? (
<div className="flex flex-1 items-center justify-center">
<MyText className="text-lg text-gray-600">No upcoming slots available for rebalancing.</MyText>
</div>
) : (
<MyFlatList
data={upcomingSlots}
keyExtractor={(item) => item.id.toString()}
renderItem={(item) => (
<SlotItemComponent
item={item}
selectedSlots={selectedSlots}
toggleSlotSelection={toggleSlotSelection}
setDialogProducts={setDialogProducts}
setDialogOpen={setDialogOpen}
/>
)}
onRefresh={handleRefresh}
refreshing={refreshing}
ListEmptyComponent={
<div className="flex flex-1 items-center justify-center">
<MyText className="text-lg text-gray-600">No upcoming slots available for rebalancing.</MyText>
</div>
}
/>
)}
</div>
<MyTouchableOpacity
onClick={handleRebalance}
disabled={selectedSlots.length === 0}
activeOpacity={0.95}
style={{
position: 'fixed',
bottom: 32,
right: 24,
zIndex: 100,
opacity: selectedSlots.length === 0 ? 0.5 : 1,
}}
aria-label="Rebalance selected slots"
>
<span
className="flex h-16 w-16 items-center justify-center shadow-lg"
style={{
borderRadius: 24,
background: selectedSlots.length === 0
? 'linear-gradient(135deg, #9CA3AF, #6B7280)'
: 'linear-gradient(135deg, #F83758, #E91E63)',
}}
>
<RefreshCw size={32} color="white" />
</span>
</MyTouchableOpacity>
<BottomDialog open={dialogOpen} onClose={() => setDialogOpen(false)}>
<div className="p-4">
<MyText className="mb-4 text-lg font-bold">All Products</MyText>
<div className="max-h-80 overflow-y-auto">
{dialogProducts.map((item, index) => (
<div key={index} className="border-b border-gray-200 py-2">
<MyText className="text-base">{item}</MyText>
</div>
))}
</div>
</div>
</BottomDialog>
</div>
)
}

View file

@ -0,0 +1,53 @@
import React from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { p as MyText, AppContainer } from 'web-components'
import { trpc } from '@/lib/trpc-client'
import SlotForm from '../components/SlotForm'
export const Route = createFileRoute('/dashboard/slots/$id/edit')({
component: EditSlot,
})
function EditSlot() {
const { id } = Route.useParams()
const slotId = parseInt(id as string)
const { data: slot, isLoading } = trpc.admin.slots.getSlotById.useQuery({ id: slotId })
const handleSlotUpdated = () => {
window.history.back()
}
if (isLoading) {
return (
<AppContainer>
<div className="flex flex-1 flex-col items-center justify-center">
<MyText>Loading slot...</MyText>
</div>
</AppContainer>
)
}
if (!slot) {
return (
<AppContainer>
<div className="flex flex-1 flex-col items-center justify-center">
<MyText>Slot not found</MyText>
</div>
</AppContainer>
)
}
return (
<AppContainer>
<SlotForm
initialDeliveryTime={new Date(slot.slot.deliveryTime)}
initialFreezeTime={new Date(slot.slot.freezeTime)}
initialIsActive={slot.slot.isActive}
slotId={slot.slot.id}
initialProductIds={slot.slot.products?.map((p: any) => p.id) || []}
onSlotAdded={handleSlotUpdated}
/>
</AppContainer>
)
}

View file

@ -0,0 +1,239 @@
import React, { useState } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { p as MyText, BottomDialog } from 'web-components'
import { trpc } from '@/lib/trpc-client'
import { ErrorToast } from '@/services/toaster'
import { theme } from '@/lib/theme'
import { Code2, ShoppingBag, Link2, Clock, Package, Pencil } from 'lucide-react'
import dayjs from 'dayjs'
export const Route = createFileRoute('/dashboard/slots/detail')({
validateSearch: (s: Record<string, unknown>) => ({
slotId: (s.slotId as string) ?? ''
}),
component: SlotDetails,
})
function SlotDetails() {
const navigate = useNavigate()
const { slotId } = Route.useSearch()
const { data: slotData, isLoading, error } = trpc.admin.slots.getSlotById.useQuery({
id: parseInt(slotId as string),
})
const slot = slotData?.slot
const products = slot?.products || []
const vendorSnippets = slot?.vendorSnippets || []
// Dialog state for snippet products
const [dialogOpen, setDialogOpen] = useState(false)
const [dialogProducts, setDialogProducts] = useState<any[]>([])
if (isLoading) {
return (
<div className="flex flex-1 flex-col items-center justify-center bg-white">
<MyText className="text-gray-600">Loading slot details...</MyText>
</div>
)
}
if (error || !slot) {
return (
<div className="flex flex-1 flex-col items-center justify-center bg-white">
<MyText className="text-red-600">Error loading slot details</MyText>
</div>
)
}
return (
<div className="relative flex-1 bg-white px-4">
<div className="flex-1 overflow-y-auto pb-32">
{/* Header */}
<div className="mb-6">
<MyText className="mb-2 block text-2xl font-black text-gray-900">
Slot #{slot.id}
</MyText>
<div className="flex flex-row items-center justify-between">
<div className="flex-1">
<MyText className="mb-1 block text-sm text-gray-600">
Delivery: {dayjs(slot.deliveryTime).format('MMM DD, YYYY hh:mm A')}
</MyText>
<MyText className="block text-sm text-gray-600">
Freeze: {dayjs(slot.freezeTime).format('MMM DD, YYYY hh:mm A')}
</MyText>
</div>
<span className={`rounded-full px-3 py-1 ${slot.isActive ? 'bg-green-100' : 'bg-red-100'}`}>
<MyText className={`text-xs font-bold ${slot.isActive ? 'text-green-700' : 'text-red-700'}`}>
{slot.isActive ? 'Active' : 'Inactive'}
</MyText>
</span>
</div>
</div>
{/* Vendor Snippets Section */}
<div className="mb-8">
<div className="mb-4 flex flex-row items-center justify-between">
<MyText className="text-lg font-bold text-gray-900">Vendor Snippets</MyText>
<span className="rounded-full bg-gray-100 px-2 py-1">
<MyText className="text-xs font-medium text-gray-600">{vendorSnippets.length} snippets</MyText>
</span>
</div>
{vendorSnippets.length === 0 ? (
<div className="items-center rounded-lg bg-gray-50 p-6 text-center">
<Code2 size={32} color="#9CA3AF" className="mx-auto" />
<MyText className="mt-2 block text-center text-gray-500">No vendor snippets for this slot</MyText>
</div>
) : (
<div className="flex flex-col gap-2">
{vendorSnippets.map((snippet, index) => {
const isExpired = snippet.validTill && dayjs(snippet.validTill).isBefore(dayjs())
return (
<div key={snippet.id} className="rounded-lg bg-gray-50 p-4">
<div className="mb-3 flex flex-row items-center justify-between">
<div className="flex-1">
<MyText className="block font-bold text-gray-900" numberOfLines={1}>
{snippet.snippetCode}
</MyText>
</div>
<div className="flex flex-row items-center">
<span className={`rounded-full px-2 py-1 ${isExpired ? 'bg-red-100' : 'bg-green-100'}`}>
<MyText className={`text-xs font-medium ${isExpired ? 'text-red-700' : 'text-green-700'}`}>
{isExpired ? 'Expired' : 'Active'}
</MyText>
</span>
</div>
</div>
<div className="flex flex-row items-center justify-between">
<div className="flex flex-row items-center">
<button
onClick={() => {
const snippetProducts = products.filter((p) => snippet.skuIds.includes(p.id))
setDialogProducts(snippetProducts)
setDialogOpen(true)
}}
className="mr-4 flex flex-row items-center"
>
<ShoppingBag size={14} color="#94A3B8" />
<MyText className="ml-1 text-xs font-medium text-blue-600 underline">
{snippet.skuIds.length} products
</MyText>
</button>
<button
onClick={async () => {
try {
if (navigator.share) {
await navigator.share({ text: snippet.accessUrl })
} else {
await navigator.clipboard.writeText(snippet.accessUrl)
window.alert('Link copied to clipboard')
}
} catch (err) {
ErrorToast('Failed to share link')
}
}}
className="flex flex-row items-center"
>
<Link2 size={14} color={theme.colors.brand500} />
<MyText className="ml-1 text-xs font-medium text-brand-600">
Share
</MyText>
</button>
</div>
{snippet.validTill && (
<span className="flex flex-row items-center">
<Clock size={14} color="#94A3B8" />
<MyText className="ml-1 text-xs font-medium text-gray-600">
Expires {dayjs(snippet.validTill).format('MMM DD')}
</MyText>
</span>
)}
</div>
{snippet.createdAt && (
<div className="mt-2 border-t border-gray-200 pt-2">
<MyText className="block text-xs text-gray-500">
Created {dayjs(snippet.createdAt).format('MMM DD, YYYY')}
</MyText>
</div>
)}
</div>
)
})}
</div>
)}
</div>
{/* Products Section */}
<div className="mb-8">
<div className="mb-4 flex flex-row items-center justify-between">
<MyText className="text-lg font-bold text-gray-900">Products</MyText>
<span className="rounded-full bg-gray-100 px-2 py-1">
<MyText className="text-xs font-medium text-gray-600">{products.length} items</MyText>
</span>
</div>
{products.length === 0 ? (
<div className="items-center rounded-lg bg-gray-50 p-6 text-center">
<Package size={32} color="#9CA3AF" className="mx-auto" />
<MyText className="mt-2 block text-center text-gray-500">No products in this slot</MyText>
</div>
) : (
<div className="flex flex-row flex-wrap gap-2 py-2">
{products.map((product) => (
<span key={product.id} className="rounded-full border border-blue-200 bg-blue-50 px-3 py-2">
<MyText className="text-sm font-medium text-blue-700" numberOfLines={1}>
{product.name}
</MyText>
</span>
))}
</div>
)}
</div>
</div>
{/* FAB for Edit Slot */}
<button
onClick={() => navigate({ to: '/dashboard/slots/$id/edit', params: { id: String(slot.id) } })}
aria-label="Edit slot"
className="fixed flex h-16 w-16 items-center justify-center rounded-[24px] shadow-lg"
style={{ bottom: 32, right: 24, zIndex: 100, background: 'linear-gradient(135deg,#F83758,#E91E63)' }}
>
<Pencil size={24} color="white" />
</button>
<BottomDialog open={dialogOpen} onClose={() => setDialogOpen(false)}>
<div className="py-4">
<MyText className="mb-4 block text-center text-lg font-semibold">Snippet Products</MyText>
<div className="max-h-80 overflow-y-auto">
{dialogProducts.length === 0 ? (
<div className="items-center px-4 py-8 text-center">
<MyText className="block text-center text-gray-500">
No products found for this snippet
</MyText>
</div>
) : (
dialogProducts.map((product) => (
<div key={product.id} className="border-b border-gray-100 px-4 py-3">
<MyText className="block text-base font-medium text-gray-900">
{product.name}
</MyText>
{product.shortDescription && (
<MyText className="mt-1 block text-sm text-gray-600">
{product.shortDescription}
</MyText>
)}
</div>
))
)}
</div>
</div>
</BottomDialog>
</div>
)
}

View file

@ -0,0 +1,48 @@
import React from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { p as MyText, AppContainer } from 'web-components'
import SlotForm from '../components/SlotForm'
import { trpc } from '@/lib/trpc-client'
export const Route = createFileRoute('/dashboard/slots/new')({
validateSearch: (s: Record<string, unknown>) => ({
baseslot: (s.baseslot as string) ?? ''
}),
component: AddSlot,
})
function AddSlot() {
const { baseslot } = Route.useSearch()
const baseSlotId = baseslot ? parseInt(baseslot as string) : null
const { refetch } = trpc.admin.slots.getAll.useQuery()
const { data: baseSlotData, isLoading } = trpc.admin.slots.getSlotById.useQuery(
{ id: baseSlotId! },
{ enabled: !!baseSlotId }
)
const handleSlotAdded = () => {
refetch()
window.history.back()
}
if (isLoading && baseSlotId) {
return (
<AppContainer>
<div className="flex flex-1 flex-col items-center justify-center">
<MyText>Loading base slot...</MyText>
</div>
</AppContainer>
)
}
return (
<AppContainer>
<SlotForm
onSlotAdded={handleSlotAdded}
initialProductIds={baseSlotData?.slot?.products?.map((p: any) => p.id) || []}
initialGroupIds={baseSlotData?.slot?.groupIds || []}
/>
</AppContainer>
)
}

View file

@ -0,0 +1,307 @@
import React, { useState } from 'react'
import { createFileRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router'
import { CalendarClock, Pencil, EllipsisVertical, Plus, Truck, Snowflake, ShoppingBasket, Copy, X, Package } from 'lucide-react'
import { p as MyText, BottomDialog, Checkbox, MyFlatList } from 'web-components'
import { trpc } from '@/lib/trpc-client'
import { ErrorToast } from '@/services/toaster'
import dayjs from 'dayjs'
export const Route = createFileRoute('/dashboard/slots')({
component: Slots,
})
interface SlotItemProps {
item: any,
setDialogProducts: React.Dispatch<React.SetStateAction<any[]>>,
setDialogOpen: React.Dispatch<React.SetStateAction<boolean>>,
refetch: () => void
}
const SlotItemComponent: React.FC<SlotItemProps> = ({
item: slot,
setDialogProducts,
setDialogOpen,
refetch,
}) => {
const navigate = useNavigate()
const [menuOpen, setMenuOpen] = useState(false)
const slotProducts = slot.products?.map((p: any) => p.name).filter(Boolean) || []
const displayProducts = slotProducts.slice(0, 2).join(', ')
const isActive = slot.isActive
const statusBg = isActive ? 'bg-green-100' : 'bg-red-100'
const statusTextColor = isActive ? 'text-green-700' : 'text-red-700'
const statusText = isActive ? 'Active' : 'Inactive'
const updateSlotCapacity = trpc.admin.slots.updateSlotCapacity.useMutation()
const handleCapacityToggle = () => {
updateSlotCapacity.mutate(
{ slotId: slot.id, isCapacityFull: !slot.isCapacityFull },
{
onSuccess: () => {
setMenuOpen(false)
refetch()
window.alert(
slot.isCapacityFull
? 'Success: Slot capacity reset. It will now be visible to users.'
: 'Success: Slot marked as full capacity. It will be hidden from users.'
)
},
onError: (error: any) => {
ErrorToast(error.message || 'Failed to update slot capacity')
},
}
)
}
return (
<div
onClick={() => navigate({ to: '/dashboard/slots/detail', search: { slotId: String(slot.id) } })}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter') navigate({ to: '/dashboard/slots/detail', search: { slotId: String(slot.id) } }) }}
className="w-full cursor-pointer text-left"
>
<div className="mb-4 rounded-3xl border border-gray-100 bg-white p-5 shadow-sm">
{/* Header: ID and Status */}
<div className="mb-4 flex flex-row items-center justify-between">
<div className="flex flex-row items-center">
<span className="mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-[#fff0f6]">
<CalendarClock size={20} color="#F83758" />
</span>
<div>
<MyText className="block text-lg font-bold text-gray-900">Slot #{slot.id}</MyText>
<MyText className="block text-xs text-gray-500">ID: {slot.id}</MyText>
</div>
</div>
<div className="flex flex-row items-center">
<button
onClick={(e) => { e.stopPropagation(), navigate({ to: '/dashboard/slots/$id/edit', params: { id: String(slot.id) } }) }}
className="mr-2 rounded-full bg-[#fff0f6] px-3 py-1"
>
<span className="flex flex-row items-center">
<Pencil size={12} color="#F83758" className="mr-1" />
<MyText className="text-xs font-bold text-brand-500">Edit</MyText>
</span>
</button>
<span className={`rounded-full px-3 py-1 ${statusBg}`}>
<MyText className={`text-xs font-bold ${statusTextColor}`}>{statusText}</MyText>
</span>
{slot.isCapacityFull && (
<span className="ml-2 rounded-full bg-red-500 px-2 py-1">
<MyText className="text-xs font-bold text-white">FULL</MyText>
</span>
)}
<button
onClick={(e) => { e.stopPropagation(), setMenuOpen(true) }}
className="ml-2 p-1"
aria-label="Slot actions"
>
<EllipsisVertical size={20} color="#9CA3AF" />
</button>
</div>
</div>
{/* Replicate Menu Dialog */}
<div onClick={(e) => e.stopPropagation()}>
<BottomDialog open={menuOpen} onClose={() => setMenuOpen(false)}>
<div className="p-4">
<MyText className="mb-4 block text-lg font-bold">Slot #{slot.id} Actions</MyText>
{/* Capacity Toggle */}
<button
onClick={handleCapacityToggle}
disabled={updateSlotCapacity.isPending}
className="w-full border-b border-gray-200 py-4 text-left disabled:opacity-50"
>
<span className="flex flex-row items-center justify-between">
<span className="flex flex-1 flex-row items-center">
{updateSlotCapacity.isPending ? (
<span className="mr-3 h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-red-500" />
) : (
<Package size={20} color={slot.isCapacityFull ? '#EF4444' : '#4B5563'} className="mr-3" />
)}
<span>
<MyText className="block text-base text-gray-800">Mark as Full Capacity</MyText>
<MyText className="mt-0.5 block text-xs text-gray-500">
{slot.isCapacityFull
? 'Slot is hidden from users'
: 'Hidden from users when full'}
</MyText>
</span>
</span>
{updateSlotCapacity.isPending ? (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-red-500" />
) : (
<Checkbox
checked={slot.isCapacityFull}
onPress={handleCapacityToggle}
size={22}
fillColor="#EF4444"
checkColor="#FFFFFF"
/>
)}
</span>
</button>
<button
onClick={() => {
setMenuOpen(false)
navigate({ to: '/dashboard/slots/new', search: { baseslot: String(slot.id) } })
}}
className="w-full border-b border-gray-200 py-4 text-left"
>
<span className="flex flex-row items-center">
<Copy size={20} color="#4B5563" className="mr-3" />
<MyText className="text-base text-gray-800">Replicate Slot</MyText>
</span>
</button>
<button
onClick={() => setMenuOpen(false)}
className="mt-2 w-full py-4 text-left"
>
<span className="flex flex-row items-center">
<X size={20} color="#EF4444" className="mr-3" />
<MyText className="text-base text-red-500">Cancel</MyText>
</span>
</button>
</div>
</BottomDialog>
</div>
{/* Divider */}
<div className="mb-4 h-[1px] bg-gray-100" />
{/* Details Grid */}
<div className="flex flex-row flex-wrap">
{/* Delivery Time */}
<div className="mb-4 w-1/2 pr-2">
<div className="mb-1 flex flex-row items-center">
<Truck size={14} color="#6b7280" className="mr-1" />
<MyText className="text-xs font-semibold uppercase tracking-wider text-gray-500">Delivery</MyText>
</div>
<MyText className="text-sm font-medium text-gray-800">
{dayjs(slot.deliveryTime).format('DD MMM, h:mm A')}
</MyText>
</div>
{/* Freeze Time */}
<div className="mb-4 w-1/2 pl-2">
<div className="mb-1 flex flex-row items-center">
<Snowflake size={14} color="#6b7280" className="mr-1" />
<MyText className="text-xs font-semibold uppercase tracking-wider text-gray-500">Freeze</MyText>
</div>
<MyText className="text-sm font-medium text-gray-800">
{dayjs(slot.freezeTime).format('DD MMM, h:mm A')}
</MyText>
</div>
</div>
{/* Products */}
{slotProducts.length > 0 ? (
<div className="mt-1 rounded-xl bg-gray-50 p-3">
<div className="flex flex-row items-start">
<ShoppingBasket size={16} color="#4b5563" className="mr-2 mt-0.5" />
<div className="flex-1">
<MyText className="mb-0.5 block text-xs text-gray-500">Products</MyText>
<div className="flex flex-row flex-wrap items-center">
<MyText className="text-sm leading-5 text-gray-800">{displayProducts}</MyText>
{slotProducts.length > 2 && (
<button
onClick={(e) => {
e.stopPropagation()
setDialogProducts(slotProducts)
setDialogOpen(true)
}}
>
<MyText className="ml-1 text-sm font-semibold text-brand-500">
+{slotProducts.length - 2} more
</MyText>
</button>
)}
</div>
</div>
</div>
</div>
) : null}
</div>
</div>
)
}
function Slots() {
const location = useLocation()
const navigate = useNavigate()
const { data: slotsData, isLoading, refetch } = trpc.admin.slots.getAll.useQuery()
const slots = slotsData?.slots || []
// Dialog state
const [dialogOpen, setDialogOpen] = useState(false)
const [dialogProducts, setDialogProducts] = useState<any[]>([])
const [refreshing, setRefreshing] = useState(false)
const handleRefresh = async () => {
setRefreshing(true)
await refetch()
setRefreshing(false)
}
if (location.pathname !== '/dashboard/slots') {
return <Outlet />
}
if (isLoading) {
return (
<div className="flex flex-1 flex-col items-center justify-center bg-white">
<MyText>Loading slots...</MyText>
</div>
)
}
return (
<div className="relative flex-1 bg-white">
<MyFlatList
data={slots}
keyExtractor={(item) => item.id.toString()}
renderItem={(item) => (
<SlotItemComponent
item={item}
setDialogProducts={setDialogProducts}
setDialogOpen={setDialogOpen}
refetch={refetch}
/>
)}
className="p-4"
onRefresh={handleRefresh}
refreshing={refreshing}
/>
{/* FAB for Add New Slot */}
<button
onClick={() => navigate({ to: '/dashboard/slots/new', search: { baseslot: '' } })}
data-testid="add-slot-fab"
aria-label="add-slot-fab"
className="fixed flex h-16 w-16 items-center justify-center rounded-[24px] shadow-lg"
style={{ bottom: 32, right: 24, zIndex: 100, background: 'linear-gradient(135deg,#F83758,#E91E63)' }}
>
<Plus size={32} color="white" />
</button>
{/* Products Dialog */}
<BottomDialog open={dialogOpen} onClose={() => setDialogOpen(false)}>
<div className="p-4">
<MyText className="mb-4 block text-lg font-bold">All Products</MyText>
<div className="max-h-80 overflow-y-auto">
{dialogProducts.map((item, index) => (
<div key={index} className="border-b border-gray-200 py-2">
<MyText className="text-base">{item}</MyText>
</div>
))}
</div>
</div>
</BottomDialog>
</div>
)
}

View file

@ -0,0 +1,83 @@
import React from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { AppContainer, p as MyText } from 'web-components'
import StoreForm, { type StoreFormData } from '../components/StoreForm'
import { trpc } from '@/lib/trpc-client'
import { ErrorToast } from '@/services/toaster'
export const Route = createFileRoute('/dashboard/stores/edit')({
validateSearch: (s: Record<string, unknown>) => ({
id: (s.id as string) ?? ''
}),
component: EditStore,
})
function EditStore() {
const navigate = useNavigate()
const { id } = Route.useSearch()
const storeId = parseInt(id as string)
const { data: storeData, isLoading: isLoadingStore, refetch } = trpc.admin.store.getStoreById.useQuery(
{ id: storeId },
{ enabled: !!storeId }
)
const updateStoreMutation = trpc.admin.store.updateStore.useMutation()
const handleSubmit = (values: StoreFormData) => {
updateStoreMutation.mutate({ id: storeId, ...values }, {
onSuccess: (data) => {
refetch()
window.alert(`Success: ${data.message}`)
navigate({ to: '/dashboard/stores' } as any)
},
onError: (error: any) => {
ErrorToast(error.message || 'Failed to update store')
},
})
}
if (isLoadingStore) {
return (
<AppContainer>
<div className="flex flex-1 flex-col items-center justify-center">
<MyText>Loading store...</MyText>
</div>
</AppContainer>
)
}
if (!storeData?.store) {
return (
<AppContainer>
<div className="flex flex-1 flex-col items-center justify-center">
<MyText>Store not found</MyText>
</div>
</AppContainer>
)
}
const initialValues = {
name: storeData.store.name,
description: storeData.store.description || '',
imageUrl: storeData.store.imageUrl || '',
owner: storeData.store.owner.id,
products: [], // Will be set by StoreForm
}
return (
<AppContainer>
<div>
<MyText className="mb-6 block text-2xl font-bold text-gray-800">Edit Store</MyText>
<StoreForm
mode="edit"
initialValues={initialValues}
onSubmit={handleSubmit}
isLoading={updateStoreMutation.isPending}
storeId={storeId}
/>
</div>
</AppContainer>
)
}

View file

@ -0,0 +1,46 @@
import React from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { AppContainer, p as MyText } from 'web-components'
import StoreForm, { type StoreFormData } from '../components/StoreForm'
import { trpc } from '@/lib/trpc-client'
import { ErrorToast } from '@/services/toaster'
export const Route = createFileRoute('/dashboard/stores/new')({
component: AddStore,
})
function AddStore() {
const navigate = useNavigate()
const createStoreMutation = trpc.admin.store.createStore.useMutation()
const { refetch: refetchStores } = trpc.admin.store.getStores.useQuery(undefined, {
enabled: false,
})
const handleSubmit = (values: StoreFormData) => {
createStoreMutation.mutate(values, {
onSuccess: async (data) => {
await refetchStores()
window.alert(`Success: ${data.message}`)
navigate({ to: '/dashboard/stores' }) // Navigate back to stores list
},
onError: (error: any) => {
ErrorToast(error.message || 'Failed to create store')
},
})
}
return (
<AppContainer>
<div>
<MyText className="mb-6 block text-2xl font-bold text-gray-800">Add New Store</MyText>
<StoreForm
mode="create"
initialValues={{ name: '', description: '', owner: 0, products: [] }}
onSubmit={handleSubmit}
isLoading={createStoreMutation.isPending}
/>
</div>
</AppContainer>
)
}

View file

@ -0,0 +1,199 @@
import React, { useState } from 'react'
import { createFileRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router'
import { Store as StoreIcon, Trash2, Plus, ArrowRight, CircleAlert } from 'lucide-react'
import { p as MyText, MyButton } from 'web-components'
import { useMarkDataFetchers } from '@/lib/refresh-context'
import { trpc } from '@/lib/trpc-client'
import { ErrorToast } from '@/services/toaster'
export const Route = createFileRoute('/dashboard/stores')({
component: Stores,
})
interface StoreItemProps {
item: any,
isEditMode: boolean,
onEdit: (storeId: number) => void,
onDelete: (storeId: number) => void
}
const StoreItem: React.FC<StoreItemProps> = ({ item, isEditMode, onEdit, onDelete }) => {
return (
<div className="p-2">
<div className="relative flex-1 overflow-hidden rounded-2xl border border-gray-100 bg-white shadow-sm">
{/* Delete Button - Only visible in edit mode */}
{isEditMode && (
<button
onClick={() => onDelete(item.id)}
className="absolute right-2 top-2 z-10 rounded-full bg-red-500 p-2 shadow-lg"
aria-label="Delete store"
>
<Trash2 size={16} color="white" />
</button>
)}
<div
onClick={isEditMode ? undefined : () => onEdit(item.id)}
role={isEditMode ? undefined : 'button'}
tabIndex={isEditMode ? undefined : 0}
onKeyDown={isEditMode ? undefined : (e) => { if (e.key === 'Enter') onEdit(item.id) }}
className={`flex-1 ${isEditMode ? '' : 'cursor-pointer'}`}
>
{/* Image Section - Portrait oriented */}
<div className="relative h-40 w-full bg-gray-50">
{item.imageUrl ? (
<img
src={item.imageUrl}
className="h-full w-full object-cover"
alt={item.name}
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<StoreIcon size={32} color="#E5E7EB" />
</div>
)}
{/* Status Dot */}
<span className="absolute right-2 top-2 h-3 w-3 rounded-full border-2 border-white bg-emerald-500" />
</div>
{/* Content Section */}
<div className="p-3">
<MyText
className="mb-1 block text-base font-bold leading-5 text-gray-900"
numberOfLines={1}
>
{item.name}
</MyText>
<div className="mb-2 flex flex-row items-center">
<MyText className="text-xs font-medium text-gray-400">ID: #{item.id}</MyText>
<span className="mx-1.5 h-1 w-1 rounded-full bg-gray-300" />
<MyText className="text-xs font-medium text-gray-400">
{item.createdAt ? new Date(item.createdAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : 'N/A'}
</MyText>
</div>
{/* Action Footer */}
<div className="flex flex-row items-center justify-between border-t border-gray-50 pt-2">
<MyText className="text-[10px] font-bold uppercase tracking-wide text-blue-600">View Details</MyText>
<ArrowRight size={12} color="#2563EB" />
</div>
</div>
</div>
</div>
</div>
)
}
function Stores() {
const location = useLocation()
const navigate = useNavigate()
const [isEditMode, setIsEditMode] = useState(false)
const { data: storesData, isLoading, error, refetch } = trpc.admin.store.getStores.useQuery()
const { mutateAsync: deleteStoreAsync } = trpc.admin.store.deleteStore.useMutation()
useMarkDataFetchers(() => {
refetch()
})
const stores = storesData?.stores || []
const handleEdit = (storeId: number) => {
navigate({ to: '/dashboard/stores/edit', search: { id: String(storeId) } })
}
const handleDelete = (storeId: number) => {
if (!window.confirm('Delete Store: Are you sure you want to delete this store? This action cannot be undone. All products will be unassigned from this store.')) return
;(async () => {
try {
await deleteStoreAsync({ storeId })
refetch()
window.alert('Success: Store deleted successfully')
} catch (err: any) {
ErrorToast(err.message || 'Failed to delete store')
}
})()
}
if (location.pathname !== '/dashboard/stores') {
return <Outlet />
}
if (isLoading) {
return (
<div className="flex flex-1 flex-col items-center justify-center bg-gray-50">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600" />
<MyText className="mt-4 font-medium text-gray-500">Loading stores...</MyText>
</div>
)
}
if (error) {
return (
<div className="flex flex-1 flex-col items-center justify-center bg-gray-50 p-6">
<span className="mb-4 rounded-full bg-red-50 p-6">
<CircleAlert size={48} color="#EF4444" />
</span>
<MyText className="mb-2 block text-xl font-bold text-gray-900">Oops!</MyText>
<MyText className="mb-6 block text-center text-gray-600">We couldn&apos;t load the stores. Please try again.</MyText>
<MyButton onClick={() => refetch()} variant="red" className="rounded-xl px-8 py-3">
Retry
</MyButton>
</div>
)
}
return (
<div className="relative flex min-h-full flex-1 flex-col bg-gray-50">
<div className="mb-8 mt-4 px-3">
<div className="mb-2 flex flex-row items-end justify-between">
<div>
<MyText className="block text-3xl font-bold tracking-tight text-gray-800">Stores</MyText>
<MyText className="mt-1 block text-base font-medium text-gray-500">Manage your outlets</MyText>
</div>
<div className="flex flex-row items-center">
<button
onClick={() => setIsEditMode(!isEditMode)}
className="mr-2 rounded-full border border-gray-200 bg-white px-4 py-2 shadow-sm"
>
<MyText className="text-sm font-semibold text-gray-600">
{isEditMode ? 'Done' : 'Edit'}
</MyText>
</button>
<span className="rounded-full border border-gray-200 bg-white px-4 py-1.5 shadow-sm">
<MyText className="text-xs font-semibold text-gray-600">{stores.length} Locations</MyText>
</span>
</div>
</div>
</div>
{stores.length === 0 ? (
<div className="mt-20 flex flex-1 flex-col items-center justify-center">
<StoreIcon size={80} color="#E5E7EB" />
<MyText className="mt-4 block text-lg font-medium text-gray-400">No stores found</MyText>
<MyText className="mt-2 block max-w-[250px] text-center text-sm text-gray-400">Get started by adding your first store location.</MyText>
</div>
) : (
<div className="grid grid-cols-2 p-2 pb-24">
{stores.map((item) => (
<StoreItem key={item.id.toString()} item={item} isEditMode={isEditMode} onEdit={handleEdit} onDelete={handleDelete} />
))}
</div>
)}
{/* Floating Action Button for Adding New Store */}
<div className="fixed bottom-8 right-6 shadow-xl">
<button
onClick={() => navigate({ to: '/dashboard/stores/new' })}
aria-label="Add new store"
className="flex h-16 w-16 items-center justify-center rounded-full"
style={{ background: 'linear-gradient(135deg,#2563EB,#1D4ED8)' }}
>
<Plus size={32} color="white" />
</button>
</div>
</div>
)
}

View file

@ -0,0 +1,190 @@
import React, { useState } from 'react'
import { createFileRoute, Outlet, useNavigate, useLocation, Navigate } from '@tanstack/react-router'
import {
LayoutDashboard,
Package,
DollarSign,
Group,
Images,
CalendarClock,
TriangleAlert,
ShoppingBag,
TicketPercent,
Code2,
Store,
Users,
Megaphone,
LogOut,
Menu,
ArrowLeft,
RefreshCw,
UserRound,
} from 'lucide-react'
import { p as MyText } from 'web-components'
import { useStaffAuth } from '../components/context/staff-auth-context'
import { EventBus, REFRESH_EVENT } from '../lib/event-bus'
export const Route = createFileRoute('/dashboard')({
component: DashboardShell,
})
const SCREEN_TITLES: Record<string, string> = {
products: 'Products',
prices: 'Prices Overview',
'product-groupings': 'Product Groupings',
banners: 'Dashboard Banners',
slots: 'Slots',
complaints: 'Complaints',
orders: 'Manage Orders',
coupons: 'Coupons',
'vendor-snippets': 'Vendor Snippets',
stores: 'Stores',
users: 'User Management',
notifications: 'Send Notifications',
'product-tags': 'Product Tags',
rebalance: 'Rebalance Orders',
'customize-app': 'Customize App',
}
interface SidebarEntry {
label: string
to: string
icon: React.ReactNode
action?: 'logout'
}
const SIDEBAR_ENTRIES: SidebarEntry[] = [
{ label: 'Dashboard', to: '/dashboard', icon: <LayoutDashboard size={22} /> },
{ label: 'Products', to: '/dashboard/products', icon: <Package size={22} /> },
{ label: 'Prices Overview', to: '/dashboard/prices', icon: <DollarSign size={22} /> },
{ label: 'Product Groupings', to: '/dashboard/product-groupings', icon: <Group size={22} /> },
{ label: 'Dashboard Banners', to: '/dashboard/banners', icon: <Images size={22} /> },
{ label: 'Slots', to: '/dashboard/slots', icon: <CalendarClock size={22} /> },
{ label: 'Complaints', to: '/dashboard/complaints', icon: <TriangleAlert size={22} /> },
{ label: 'Manage Orders', to: '/dashboard/orders', icon: <ShoppingBag size={22} /> },
{ label: 'Coupons', to: '/dashboard/coupons', icon: <TicketPercent size={22} /> },
{ label: 'Vendor Snippets', to: '/dashboard/vendor-snippets', icon: <Code2 size={22} /> },
{ label: 'Stores', to: '/dashboard/stores', icon: <Store size={22} /> },
{ label: 'User Management', to: '/dashboard/users', icon: <Users size={22} /> },
{ label: 'Send Notifications', to: '/dashboard/notifications', icon: <Megaphone size={22} /> },
{ label: 'Logout', to: '/login', icon: <LogOut size={22} />, action: 'logout' },
]
// Top-level feature roots show the menu toggle; deeper screens show back.
const FEATURE_ROOTS = new Set([
'/dashboard',
...SIDEBAR_ENTRIES.filter((e) => !e.action).map((e) => e.to),
])
function DashboardShell() {
const { isLoggedIn, isLoading, staff, logout } = useStaffAuth()
const navigate = useNavigate()
const location = useLocation()
const [sidebarOpen, setSidebarOpen] = useState(false)
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-white">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
</div>
)
}
if (!isLoggedIn) {
return <Navigate to="/login" />
}
const pathname = location.pathname
const segment = pathname.split('/')[2] ?? ''
const title = segment ? (SCREEN_TITLES[segment] ?? 'Dashboard') : 'Dashboard'
const canGoBack = !FEATURE_ROOTS.has(pathname)
const goEntry = (entry: SidebarEntry) => {
if (entry.action === 'logout') {
logout()
} else if (entry.to === '/dashboard') {
navigate({ to: '/dashboard' })
} else {
navigate({ to: entry.to } as any)
}
setSidebarOpen(false)
}
const isActive = (to: string) =>
to === '/dashboard' ? pathname === '/dashboard' : pathname.startsWith(to)
const sidebar = (
<div className="flex h-full flex-col overflow-y-auto bg-white">
{staff && (
<div className="border-b border-gray-200 p-4">
<UserRound size={40} color="#3B82F6" />
<div className="mt-2">
<MyText className="text-lg font-semibold text-gray-800">{staff.name}</MyText>
<MyText className="text-sm text-gray-600">Staff Member</MyText>
</div>
</div>
)}
<nav className="flex-1 py-2">
{SIDEBAR_ENTRIES.map((entry) => (
<button
key={entry.label}
onClick={() => goEntry(entry)}
className={`flex w-full items-center gap-4 px-4 py-3 text-left text-[15px] font-medium transition-colors ${
!entry.action && isActive(entry.to)
? 'bg-blue-50 text-blue-600'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<span className={!entry.action && isActive(entry.to) ? 'text-blue-600' : 'text-gray-500'}>
{entry.icon}
</span>
{entry.label}
</button>
))}
</nav>
</div>
)
return (
<div className="min-h-screen bg-white md:grid md:grid-cols-[260px_1fr]">
{/* Desktop sidebar */}
<aside className="hidden border-r border-gray-200 md:block">{sidebar}</aside>
{/* Mobile drawer */}
{sidebarOpen && (
<div className="fixed inset-0 z-50 md:hidden">
<div className="absolute inset-0 bg-black/40" onClick={() => setSidebarOpen(false)} />
<aside className="absolute inset-y-0 left-0 w-[280px] bg-white shadow-xl">{sidebar}</aside>
</div>
)}
<div className="flex min-w-0 flex-col">
{/* Header — same chrome as admin-ui drawer header */}
<header
className="sticky top-0 z-40 flex items-center justify-between px-4 py-3"
style={{ backgroundColor: '#fdfdfd' }}
>
<button
onClick={() => (canGoBack ? window.history.back() : setSidebarOpen(true))}
className="flex h-10 w-10 items-center justify-center rounded-full bg-[#f2f2f2]"
aria-label={canGoBack ? 'Go back' : 'Open menu'}
>
{canGoBack ? <ArrowLeft size={24} color="black" /> : <Menu size={24} color="black" />}
</button>
<MyText className="text-lg font-semibold text-gray-900">{title}</MyText>
<button
onClick={() => EventBus.emit(REFRESH_EVENT)}
className="flex h-10 w-10 items-center justify-center rounded-full bg-[#f2f2f2]"
aria-label="Refresh"
>
<RefreshCw size={24} color="black" />
</button>
</header>
<main className="min-w-0 flex-1">
<Outlet />
</main>
</div>
</div>
)
}

View file

@ -0,0 +1,250 @@
import React, { useCallback } from 'react'
import type { OrderCardActionProps } from '@packages/shared'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { ChevronLeft, ChevronRight, CircleAlert, User as UserIcon, Clock, ShoppingBag } from 'lucide-react'
import { AppContainer, p as MyText, Checkbox } from 'web-components'
import { trpc } from '@/lib/trpc-client'
import { formatDistanceToNow } from 'date-fns'
import dayjs from 'dayjs'
import { UserIncidentsView } from '../components/UserIncidentsView'
export const Route = createFileRoute('/dashboard/users/$id')({
component: UserDetails,
})
interface Order {
id: number
readableId: number
totalAmount: string
createdAt: string
status: string
isFlashDelivery: boolean
itemCount: number
}
interface OrderItemProps extends OrderCardActionProps<Order> {
}
const getStatusColor = (status: string) => {
switch (status) {
case 'delivered':
return 'text-green-600 bg-green-50 border-green-100'
case 'cancelled':
return 'text-red-600 bg-red-50 border-red-100'
default:
return 'text-yellow-600 bg-yellow-50 border-yellow-100'
}
}
const OrderItem: React.FC<OrderItemProps> = ({ order, onPress }) => {
const statusStyle = getStatusColor(order.status)
return (
<div
onClick={onPress}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter') onPress()
}}
className="mb-3 cursor-pointer rounded-xl border border-gray-100 bg-white p-4 text-left shadow-sm transition-colors hover:bg-gray-50"
>
<div className="mb-3 flex-row flex items-center justify-between">
<div className="flex-row flex items-center">
<MyText className="text-lg font-bold text-gray-900">
#{order.readableId}
</MyText>
{order.isFlashDelivery && (
<span className="ml-2 rounded-full border border-amber-200 bg-amber-100 px-2 py-0.5">
<MyText className="text-[10px] font-black uppercase text-amber-700"></MyText>
</span>
)}
</div>
<span className={`rounded-full border px-3 py-1 ${statusStyle}`}>
<MyText className={`text-xs font-bold uppercase tracking-wider ${statusStyle.split(' ')[0]}`}>
{order.status}
</MyText>
</span>
</div>
<div className="flex-row flex items-center justify-between">
<div>
<MyText className="mb-1 text-sm text-gray-500">
{dayjs(order.createdAt).format('MMM DD, YYYY • h:mm A')}
</MyText>
<MyText className="text-xs text-gray-400">
{order.itemCount} {order.itemCount === 1 ? 'item' : 'items'}
</MyText>
</div>
<MyText className="text-xl font-bold text-blue-600">
{order.totalAmount}
</MyText>
</div>
<div className="mt-3 flex-row flex items-center justify-center border-t border-gray-100 pt-3">
<MyText className="text-sm font-medium text-blue-600">
View Order Details
</MyText>
<ChevronRight size={18} color="#3b82f6" />
</div>
</div>
)
}
function UserDetails() {
const navigate = useNavigate()
const { id } = Route.useParams() as { id: string }
const userId = id ? parseInt(id) : 0
const { data, isLoading, error, refetch } = trpc.admin.user.getUserDetails.useQuery(
{ userId },
{ enabled: !!userId }
)
const updateSuspension = trpc.admin.user.updateUserSuspension.useMutation({
onSuccess: () => {
refetch()
},
})
const handleOrderPress = useCallback((orderId: number) => {
navigate({ to: '/dashboard/orders/$id', params: { id: String(orderId) } })
}, [navigate])
const handleSuspensionToggle = useCallback((isSuspended: boolean) => {
updateSuspension.mutate({ userId, isSuspended })
}, [userId, updateSuspension])
if (isLoading) {
return (
<AppContainer>
<div className="flex flex-1 items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
<MyText className="mt-4 text-gray-500">Loading user details...</MyText>
</div>
</AppContainer>
)
}
if (error || !data) {
return (
<AppContainer>
<div className="flex flex-1 items-center justify-center p-8">
<CircleAlert size={64} color="#ef4444" />
<MyText className="mt-4 text-lg font-bold text-gray-900">Error</MyText>
<MyText className="mt-2 text-center text-gray-500">
{error?.message || 'Failed to load user details'}
</MyText>
<button
onClick={() => refetch()}
className="mt-6 rounded-full bg-blue-600 px-6 py-3"
>
<MyText className="font-semibold text-white">Retry</MyText>
</button>
</div>
</AppContainer>
)
}
const { user, orders } = data
const displayName = user.name || 'Unnamed User'
return (
<AppContainer>
<div className="flex-1 bg-gray-50">
<div className="flex-row flex items-center border-b border-gray-200 bg-white px-4 py-4">
<button
onClick={() => window.history.back()}
className="-ml-4 p-2"
aria-label="Go back"
>
<ChevronLeft size={24} color="#374151" />
</button>
<MyText className="ml-2 text-xl font-bold text-gray-900">User Details</MyText>
</div>
<div>
<div className="m-4 rounded-2xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-4 flex-row flex items-center">
<div className="mr-4 flex h-12 w-12 items-center justify-center rounded-full bg-blue-50">
<UserIcon size={24} color="#3B82F6" />
</div>
<div className="flex-1">
<div className="flex-row flex items-center">
<MyText className="mb-0.5 text-lg font-bold text-gray-900">
{user.mobile || 'No Mobile'}
</MyText>
{user.isSuspended && (
<span className="ml-2 rounded-full border border-red-200 bg-red-100 px-2 py-0.5">
<MyText className="text-[10px] font-bold uppercase text-red-700">Suspended</MyText>
</span>
)}
</div>
<MyText className="text-gray-500">
{displayName}
</MyText>
</div>
</div>
<div className="mb-4 rounded-xl bg-gray-50 p-3">
<div className="flex-row flex items-center">
<Clock size={18} color="#6B7280" />
<MyText className="ml-2 text-gray-600">
Registered {formatDistanceToNow(new Date(user.createdAt), { addSuffix: true })}
</MyText>
</div>
</div>
<div className="flex-row flex items-center justify-between border-t border-gray-100 pt-4">
<div className="flex-1">
<MyText className="mb-1 text-base font-bold text-gray-900">
Suspend User
</MyText>
<MyText className="text-sm text-gray-500">
Prevent user from placing orders
</MyText>
</div>
{updateSuspension.isPending ? (
<div className="h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
) : (
<Checkbox
checked={user.isSuspended}
onPress={() => handleSuspensionToggle(!user.isSuspended)}
size={28}
/>
)}
</div>
</div>
<UserIncidentsView userId={userId} orderId={null} />
<div className="px-4 pb-8">
<div className="mb-4 flex-row flex items-center justify-between">
<MyText className="text-lg font-bold text-gray-900">Order History</MyText>
<MyText className="text-sm text-gray-500">
{orders.length} {orders.length === 1 ? 'order' : 'orders'}
</MyText>
</div>
{orders.length === 0 ? (
<div className="items-center rounded-xl border border-gray-100 bg-white p-8">
<ShoppingBag size={48} color="#e5e7eb" />
<MyText className="mt-4 text-center text-gray-500">
No orders yet
</MyText>
</div>
) : (
orders.map((order) => (
<OrderItem
key={order.id}
order={order}
onPress={() => handleOrderPress(order.id)}
/>
))
)}
</div>
</div>
</div>
</AppContainer>
)
}

View file

@ -0,0 +1,265 @@
import React, { useState, useCallback, useMemo, useEffect } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { ShoppingBag, CircleAlert, Users } from 'lucide-react'
import { AppContainer, p as MyText, SearchBar, MyFlatList, MyButton } from 'web-components'
import { trpc } from '@/lib/trpc-client'
import { formatDistanceToNow } from 'date-fns'
export const Route = createFileRoute('/dashboard/users')({
component: UserManagement,
})
interface User {
id: number
name: string | null
mobile: string | null
createdAt: string
totalOrders: number
lastOrderDate: string | null
isSuspended: boolean
}
interface UserItemProps {
user: User
index: number
onClick: () => void
}
const UserItem: React.FC<UserItemProps> = ({ user, index, onClick }) => {
const displayName = user.name || 'Unnamed User'
const hasOrders = user.totalOrders > 0
const lastOrderText = user.lastOrderDate
? formatDistanceToNow(new Date(user.lastOrderDate), { addSuffix: true })
: 'Never'
return (
<div
onClick={onClick}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter') onClick()
}}
className="mb-3 cursor-pointer rounded-xl border border-gray-100 bg-white p-4 text-left shadow-sm transition-colors hover:bg-gray-50"
>
<div className="flex-row flex items-center justify-between">
<div className="mr-3 flex h-8 w-8 items-center justify-center rounded-full bg-gray-100">
<MyText className="text-xs font-bold text-gray-600">{index + 1}</MyText>
</div>
<div className="flex-1">
<div className="mb-0.5 flex-row flex items-center">
<MyText className="text-base font-bold text-gray-900">
{user.mobile || 'No Mobile'}
</MyText>
{user.isSuspended && (
<span className="ml-2 rounded-full border border-red-200 bg-red-100 px-2 py-0.5">
<MyText className="text-[10px] font-bold uppercase text-red-700">Suspended</MyText>
</span>
)}
</div>
<MyText className="mb-1 text-sm text-gray-500">
{displayName}
</MyText>
<MyText className="text-xs text-gray-400">
Registered: {formatDistanceToNow(new Date(user.createdAt), { addSuffix: true })}
</MyText>
</div>
<div className="items-end">
<div className="mb-1 flex-row flex items-center">
<ShoppingBag size={14} color={hasOrders ? '#10B981' : '#9CA3AF'} />
<MyText className={`ml-1 text-sm font-bold ${hasOrders ? 'text-green-600' : 'text-gray-400'}`}>
{user.totalOrders} orders
</MyText>
</div>
<MyText className="text-xs text-gray-400">
Last: {lastOrderText}
</MyText>
</div>
</div>
</div>
)
}
interface ListHeaderProps {
searchTerm: string
onSearchChange: (text: string) => void
userCount: number
refreshing: boolean
onRefresh: () => void
}
const ListHeader: React.FC<ListHeaderProps> = ({ searchTerm, onSearchChange, userCount, refreshing, onRefresh }) => (
<div>
<div className="border-b border-gray-100 bg-white px-4 py-3">
<SearchBar
value={searchTerm}
onChange={onSearchChange}
onSearch={onSearchChange}
placeholder="Search by mobile number..."
/>
</div>
<div className="flex-row flex items-center justify-between bg-gray-50 px-4 py-2">
<MyText className="text-sm text-gray-500">
Showing {userCount} users
</MyText>
<button
onClick={onRefresh}
disabled={refreshing}
className="text-sm font-medium text-blue-600 hover:text-blue-700 disabled:opacity-50"
>
{refreshing ? 'Refreshing...' : 'Refresh'}
</button>
</div>
</div>
)
const ListFooter: React.FC<{ isFetching: boolean, hasNextPage: boolean, onLoadMore: () => void }> = ({ isFetching, hasNextPage, onLoadMore }) => {
if (isFetching) {
return (
<div className="items-center py-4">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
</div>
)
}
if (hasNextPage) {
return (
<div className="flex justify-center py-4">
<MyButton textContent="Load more" onClick={onLoadMore} variant="blue" />
</div>
)
}
return null
}
function UserManagement() {
const navigate = useNavigate()
const [searchTerm, setSearchTerm] = useState('')
const [refreshing, setRefreshing] = useState(false)
const [hasLoadedOnce, setHasLoadedOnce] = useState(false)
const {
data,
isLoading,
isError,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
refetch,
} = trpc.admin.user.getAllUsers.useInfiniteQuery(
{
limit: 30,
search: searchTerm || undefined,
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
)
useEffect(() => {
if (data?.pages?.length) {
setHasLoadedOnce(true)
}
}, [data])
const users = useMemo(() => {
const allUsers = data?.pages.flatMap((page) => page.users) || []
const uniqueUsers = allUsers.filter((user, index, self) =>
index === self.findIndex((u) => u.id === user.id)
)
return uniqueUsers
}, [data])
const handleRefresh = useCallback(async () => {
setRefreshing(true)
await refetch()
setRefreshing(false)
}, [refetch])
const handleLoadMore = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage])
const handleUserPress = useCallback((userId: number) => {
navigate({ to: '/dashboard/users/$id', params: { id: String(userId) } })
}, [navigate])
const renderUserItem = useCallback((item: User, index: number) => {
return <UserItem user={item} index={index} onClick={() => handleUserPress(item.id)} />
}, [handleUserPress])
if (isLoading && !hasLoadedOnce) {
return (
<AppContainer>
<div className="flex flex-1 items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
<MyText className="mt-4 text-gray-500">Loading users...</MyText>
</div>
</AppContainer>
)
}
if (isError) {
return (
<AppContainer>
<div className="flex flex-1 items-center justify-center p-8">
<CircleAlert size={64} color="#ef4444" />
<MyText className="mt-4 text-lg font-bold text-gray-900">Error</MyText>
<MyText className="mt-2 text-center text-gray-500">
{error?.message || 'Failed to load users'}
</MyText>
<button
onClick={() => refetch()}
className="mt-6 rounded-full bg-blue-600 px-6 py-3"
>
<MyText className="font-semibold text-white">Retry</MyText>
</button>
</div>
</AppContainer>
)
}
return (
<div className="flex-1 bg-gray-50">
<MyFlatList
data={users}
renderItem={renderUserItem}
keyExtractor={(item) => item.id.toString()}
ListHeaderComponent={
<ListHeader
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
userCount={users.length}
refreshing={refreshing}
onRefresh={handleRefresh}
/>
}
ListFooterComponent={
<ListFooter isFetching={isFetchingNextPage} hasNextPage={!!hasNextPage} onLoadMore={handleLoadMore} />
}
ListEmptyComponent={
<div className="flex flex-1 items-center justify-center py-12">
<Users size={64} color="#e5e7eb" />
<MyText className="mt-4 text-center text-lg text-gray-500">
No users found
</MyText>
{searchTerm && (
<MyText className="mt-1 text-center text-gray-400">
Try adjusting your search
</MyText>
)}
</div>
}
/>
</div>
)
}

View file

@ -0,0 +1,421 @@
import React, { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { AppContainer, p as MyText, div as MyTouchableOpacity } from 'web-components'
import { useManualRefresh, useMarkDataFetchers } from '@/lib/refresh-context'
import { theme } from '@/lib/theme'
import { trpc, getTrpcClient } from '@/lib/trpc-client'
import { Fingerprint, CalendarCheck, ShoppingBag, Link2, ChevronRight, Plus, Code2 } from 'lucide-react'
import dayjs from 'dayjs'
import VendorSnippetForm from '../components/VendorSnippetForm'
import SnippetOrdersView from '../components/SnippetOrdersView'
import { SnippetMenu } from '../components/SnippetMenu'
import { ProductListDialog } from '../components/ProductListDialog'
import type {
VendorSnippet,
VendorSnippetProduct,
VendorSnippetForm as VendorSnippetFormType,
} from '@/types/vendor-snippets'
export const Route = createFileRoute('/dashboard/vendor-snippets')({
component: VendorSnippets,
})
const SnippetItem = ({
snippet,
onEdit,
onDelete,
onViewOrders,
onViewProducts,
index,
}: {
snippet: VendorSnippet
onEdit: (snippet: VendorSnippet) => void
onDelete: (id: number) => void
onViewOrders: (snippetCode: string) => void
onViewProducts: (products: VendorSnippetProduct[]) => void
index: number
}) => {
const isExpired =
snippet.validTill && dayjs(snippet.validTill).isBefore(dayjs())
const handleCopyLink = async () => {
try {
if (!snippet.accessUrl) {
window.alert('Error: No link available')
return
}
if (typeof navigator.share === 'function') {
await navigator.share({ text: snippet.accessUrl })
} else {
await navigator.clipboard.writeText(snippet.accessUrl)
window.alert('Link copied to clipboard')
}
} catch (error: any) {
if (error?.name === 'AbortError') return
window.alert('Error: Failed to share link')
}
}
return (
<div>
<div className="p-6">
<div className="mb-6 flex-row flex items-start justify-between">
<div className="flex-row flex flex-1 items-center">
<div
className="mr-4 flex h-12 w-12 items-center justify-center rounded-2xl"
style={{ backgroundColor: theme.colors.brand50 }}
>
<Fingerprint
size={24}
color={theme.colors.brand600}
/>
</div>
<div className="flex-1">
<MyText
className="text-[10px] font-black uppercase tracking-widest text-slate-400"
>
Identifier
</MyText>
<MyText
className="text-xl font-black text-slate-900"
numberOfLines={1}
>
{snippet.snippetCode}
</MyText>
</div>
</div>
<div className="flex-row flex items-center">
<div
className="mr-2 flex-row flex items-center rounded-full px-3 py-1.5"
style={{ backgroundColor: isExpired ? '#FFF1F2' : '#F0FDF4' }}
>
<span
className="mr-2 h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: isExpired ? '#E11D48' : '#10B981' }}
/>
<MyText
className="text-[10px] font-black uppercase tracking-tighter"
style={{ color: isExpired ? '#E11D48' : '#10B981' }}
>
{isExpired ? 'Expired' : 'Active'}
</MyText>
</div>
<SnippetMenu
snippet={snippet}
onEdit={onEdit}
onDelete={onDelete}
onViewOrders={onViewOrders}
/>
</div>
</div>
<div
className="mb-6 flex-row flex items-center rounded-3xl border border-slate-100 bg-slate-50 p-4"
>
<div
className="flex h-10 w-10 items-center justify-center rounded-2xl bg-white shadow-sm"
>
<CalendarCheck size={20} color="#64748B" />
</div>
<div className="ml-4 flex-1">
{snippet.isPermanent ? (
<>
<MyText className="text-sm font-extrabold text-slate-900">
Permanent
</MyText>
<MyText className="text-[10px] font-bold uppercase text-slate-500">
Always Active
</MyText>
</>
) : snippet.slot?.deliveryTime ? (
<>
<MyText className="text-sm font-extrabold text-slate-900">
{dayjs(snippet.slot.deliveryTime).format('ddd, MMM DD')}
</MyText>
<MyText className="text-[10px] font-bold uppercase text-slate-500">
Time: {dayjs(snippet.slot.deliveryTime).format('hh:mm A')}
</MyText>
</>
) : (
<>
<MyText className="text-sm font-extrabold text-slate-900">
Schedule Pending
</MyText>
<MyText className="text-[10px] font-bold uppercase text-slate-500">
N/A
</MyText>
</>
)}
</div>
{snippet.validTill && (
<div className="items-end">
<MyText className="text-[9px] font-bold uppercase text-slate-400">
Expires
</MyText>
<MyText
className="text-xs font-black"
style={{ color: isExpired ? '#E11D48' : '#64748B' }}
>
{dayjs(snippet.validTill).format('MMM DD')}
</MyText>
</div>
)}
</div>
<div className="flex-row flex items-center justify-between">
<div className="flex-row flex items-center">
<MyTouchableOpacity
onClick={() => onViewProducts(snippet.products)}
className="mr-4 flex-row items-center"
>
<ShoppingBag size={14} color="#94A3B8" />
<MyText className="ml-1.5 text-xs font-bold text-slate-500">
{snippet.skuIds.length} Items
</MyText>
<ChevronRight size={14} color="#94A3B8" />
</MyTouchableOpacity>
<MyTouchableOpacity
onClick={handleCopyLink}
className="flex-row items-center"
>
<Link2
size={16}
color={theme.colors.brand500}
/>
<MyText
className="ml-1 text-xs font-black"
style={{ color: theme.colors.brand600 }}
>
Share
</MyText>
</MyTouchableOpacity>
</div>
<MyTouchableOpacity
onClick={() => {}}
activeOpacity={0.7}
className="flex-row items-center"
>
<MyText
className="text-xs font-black uppercase tracking-widest text-slate-900"
>
View Slot
</MyText>
<ChevronRight size={16} color="#000" />
</MyTouchableOpacity>
</div>
</div>
</div>
)
}
function VendorSnippets() {
const {
data: snippets,
isLoading,
error,
refetch,
} = trpc.admin.vendorSnippets.getAll.useQuery()
const createSnippet = trpc.admin.vendorSnippets.create.useMutation()
const updateSnippet = trpc.admin.vendorSnippets.update.useMutation()
const deleteSnippet = trpc.admin.vendorSnippets.delete.useMutation()
void createSnippet
void updateSnippet
const [showCreateForm, setShowCreateForm] = useState(false)
const [editingSnippet, setEditingSnippet] =
useState<VendorSnippetFormType | null>(null)
const [showOrdersView, setShowOrdersView] = useState(false)
const [ordersData, setOrdersData] = useState<any>(null)
const [showProductList, setShowProductList] = useState(false)
const [selectedProducts, setSelectedProducts] = useState<VendorSnippetProduct[] | null>(null)
useManualRefresh(() => {
void refetch()
})
useMarkDataFetchers(() => {
refetch()
})
const handleCreate = () => {
setShowCreateForm(true)
setEditingSnippet(null)
}
const handleViewProducts = (products: VendorSnippetProduct[]) => {
setSelectedProducts(products)
setShowProductList(true)
}
const handleEdit = (snippet: VendorSnippet) => {
const formSnippet: VendorSnippetFormType = {
id: snippet.id,
snippetCode: snippet.snippetCode,
slotId: snippet.slotId || 0,
isPermanent: snippet.isPermanent,
skuIds: snippet.skuIds,
validTill: snippet.validTill,
createdAt: snippet.createdAt,
}
setEditingSnippet(formSnippet)
setShowCreateForm(true)
}
const handleDelete = (id: number) => {
deleteSnippet.mutate(
{ id },
{
onSuccess: () => {
refetch()
},
},
)
}
const handleViewOrders = async (snippetCode: string) => {
try {
const trpcClient = getTrpcClient()
const result =
await trpcClient.admin.vendorSnippets.getOrdersBySnippet.query({
snippetCode,
})
if (result.success) {
setOrdersData({
orders: result.data,
snippetCode: snippetCode,
})
setShowOrdersView(true)
} else {
window.alert('Error: Failed to fetch orders')
}
} catch (error: any) {
window.alert(`Error: ${error.message || 'Failed to fetch orders'}`)
}
}
if (isLoading) {
return (
<AppContainer>
<div className="flex flex-1 items-center justify-center">
<MyText className="text-gray-600">Loading vendor snippets...</MyText>
</div>
</AppContainer>
)
}
if (error) {
return (
<AppContainer>
<div className="flex flex-1 items-center justify-center">
<MyText className="text-red-600">Error loading snippets</MyText>
</div>
</AppContainer>
)
}
if (showOrdersView && ordersData) {
return (
<SnippetOrdersView
orders={ordersData.orders}
snippetCode={ordersData.snippetCode}
onClose={() => {
setShowOrdersView(false)
setOrdersData(null)
}}
/>
)
}
if (showCreateForm) {
return (
<VendorSnippetForm
snippet={editingSnippet}
onClose={() => {
setShowCreateForm(false)
setEditingSnippet(null)
}}
onSuccess={() => {
setShowCreateForm(false)
setEditingSnippet(null)
refetch()
}}
/>
)
}
return (
<>
<AppContainer>
<div className="h-full flex-1 bg-white">
<div
className="flex-1 pb-32 pt-2"
>
{snippets && snippets.length === 0 ? (
<div className="flex flex-1 items-center justify-center py-20">
<div
className="mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-slate-50"
>
<Code2 size={48} color="#94A3B8" />
</div>
<MyText
className="text-xl font-black tracking-tight text-slate-900"
>
No Snippets Yet
</MyText>
<MyText
className="mt-2 px-8 text-center font-medium text-slate-500"
>
Start by creating your first vendor identifier using the
button below.
</MyText>
</div>
) : (
snippets?.map((snippet, index) => (
<React.Fragment key={snippet.id}>
<SnippetItem
snippet={snippet}
index={index}
onEdit={handleEdit}
onDelete={handleDelete}
onViewOrders={handleViewOrders}
onViewProducts={handleViewProducts}
/>
{index < snippets.length - 1 && (
<div className="h-px w-full bg-slate-200" />
)}
</React.Fragment>
))
)}
</div>
</div>
</AppContainer>
<MyTouchableOpacity
onClick={handleCreate}
activeOpacity={0.95}
className="fixed bottom-8 right-6 z-50 shadow-2xl"
aria-label="Create vendor snippet"
>
<span
className="flex h-16 w-16 items-center justify-center shadow-lg"
style={{
borderRadius: 24,
background: 'linear-gradient(135deg, #1570EF, #194185)',
}}
>
<Plus size={32} color="white" />
</span>
</MyTouchableOpacity>
<ProductListDialog
open={showProductList}
onClose={() => setShowProductList(false)}
products={selectedProducts || []}
/>
</>
)
}

View file

@ -0,0 +1,26 @@
import { createFileRoute, Navigate } from '@tanstack/react-router'
import { AppContainer } from 'web-components'
import { useStaffAuth } from '../components/context/staff-auth-context'
export const Route = createFileRoute('/')({
component: Index,
})
function Index() {
const { isLoggedIn, isLoading } = useStaffAuth()
if (isLoading) {
return (
<AppContainer>
<div className="flex flex-1 items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
</div>
</AppContainer>
)
}
if (isLoggedIn) {
return <Navigate to="/dashboard" />
}
return <Navigate to="/login" />
}

View file

@ -0,0 +1,86 @@
import React, { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { AppContainer, p as MyText, pInput as MyTextInput, MyButton } from 'web-components'
import { useStaffAuth } from '../components/context/staff-auth-context'
export const Route = createFileRoute('/login')({
component: LoginScreen,
})
function LoginScreen() {
const [name, setName] = useState('')
const [password, setPassword] = useState('')
const [localError, setLocalError] = useState<string | null>(null)
const { login, isLoggingIn, loginError } = useStaffAuth()
const handleLogin = async () => {
if (!name.trim() || !password.trim()) {
// Same guard as admin-ui's Alert.alert("Error", ...)
setLocalError('Please enter both name and password')
return
}
setLocalError(null)
try {
await login(name.trim(), password)
} catch (error) {
// Error is handled in the context
}
}
return (
<AppContainer>
<div className="flex flex-1 flex-col justify-center px-5">
<MyText
className="mb-10 text-center text-2xl font-bold text-[#333]"
>
Admin Login
</MyText>
<MyTextInput
topLabel="Staff Name"
placeholder="Enter your name"
value={name}
onChange={(e) => setName(e.target.value)}
autoCapitalize="none"
autoCorrect="off"
className="mb-5"
data-testid="login-name-input"
aria-label="login-name-input"
/>
<MyTextInput
topLabel="Password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
type="password"
autoCapitalize="none"
autoCorrect="off"
className="mb-8"
data-testid="login-password-input"
aria-label="login-password-input"
onKeyDown={(e) => {
if (e.key === 'Enter') handleLogin()
}}
/>
{(loginError || localError) && (
<MyText className="mb-5 text-center text-sm text-red-600">
{localError ?? loginError}
</MyText>
)}
<MyButton
textContent={isLoggingIn ? 'Logging in...' : 'Login'}
onClick={handleLogin}
disabled={isLoggingIn}
fullWidth
className="mb-5"
data-testid="login-button"
aria-label="login-button"
/>
</div>
</AppContainer>
)
}

View file

@ -0,0 +1,18 @@
import { toast } from 'sonner'
// Same API as admin-ui services/toaster.tsx, backed by sonner for web.
export function InfoToast(message: string) {
toast.info(message, { duration: 10000 })
}
export function ErrorToast(message: string) {
toast.error(message)
}
export function SuccessToast(message: string) {
toast.success(message)
}
export function NotificationToast(title: string, subtitle: string, _data: any) {
toast.info(title, { description: subtitle })
}

View file

@ -0,0 +1,8 @@
import {
createStartHandler,
defaultStreamHandler,
} from '@tanstack/react-start/server'
export default createStartHandler({
handler: defaultStreamHandler,
})

View file

@ -0,0 +1,150 @@
@import "tailwindcss";
@theme {
--color-brand-25: #EFF8FF;
--color-brand-50: #F5FAFF;
--color-brand-100: #D1E9FF;
--color-brand-200: #B2DDFF;
--color-brand-300: #84CAFF;
--color-brand-400: #53B1FD;
--color-brand-500: #2E90FA;
--color-brand-600: #1570EF;
--color-brand-700: #175CD3;
--color-brand-800: #1849A9;
--color-brand-900: #194185;
/* Flash delivery accent — consistent with user-ui */
--color-flash-25: #FFF5F6;
--color-flash-50: #FFE8EA;
--color-flash-100: #FFD1D6;
--color-flash-200: #FFA3AE;
--color-flash-300: #FF7585;
--color-flash-400: #F81260;
--color-flash-500: #E11D48;
--color-flash-600: #C40E50;
--color-flash-700: #9E0A3F;
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-display: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
min-height: 100%;
}
body {
margin: 0;
background-color: #f9fafb;
color: #111827;
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* Safe area padding for mobile devices */
.pb-safe {
padding-bottom: env(safe-area-inset-bottom, 0px);
}
/* Hide scrollbar but allow scrolling */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* Line clamp utilities */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Focus rings — architecture, not decoration */
:focus-visible {
outline: 2px solid var(--color-brand-600);
outline-offset: 2px;
border-radius: 4px;
}
/* App shell full-height flex column for content; sidebar rail at md+.
Sticky topbar/search live inside .shell-main so they can stick against
the viewport while scrolling. */
.shell-grid {
min-height: 100vh;
display: grid;
grid-template-areas: "main";
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
}
@media (min-width: 768px) {
.shell-grid {
grid-template-areas: "sidebar main";
grid-template-columns: 20vw minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
}
}
.shell-sidebar {
grid-area: sidebar;
}
/* Flex column so sticky header elements are bounded by the full column,
not a fixed grid row. */
.shell-main {
grid-area: main;
display: flex;
flex-direction: column;
min-width: 0;
}
.shell-topbar {
flex-shrink: 0;
}
.shell-content {
flex: 1;
min-width: 0;
}
/* Typography scale — display */
.display-1 {
font-size: clamp(2rem, 4vw, 3rem);
line-height: 1.1;
letter-spacing: -0.02em;
font-weight: 800;
}
.display-2 {
font-size: clamp(1.5rem, 2.5vw, 2rem);
line-height: 1.2;
letter-spacing: -0.015em;
font-weight: 800;
}
/* Rule under section titles — flat, editorial */
.section-rule {
border-bottom: 1px solid #e5e7eb;
padding-bottom: 1rem;
}
/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View file

@ -0,0 +1,6 @@
// Common props for draggable reorder rows (customize-app / product-tags order screens)
export interface DragOrderItemProps<T> {
item: T
drag: () => void
isActive: boolean
}

View file

@ -0,0 +1,32 @@
import type { IdName } from '@packages/shared';
import type { AdminVendorSnippetInput } from '@packages/shared'
export type VendorSnippetProduct = IdName;
export interface VendorSnippet {
id: number;
snippetCode: string;
slotId: number | null;
isPermanent: boolean;
skuIds: number[];
products: VendorSnippetProduct[];
validTill: string | null;
createdAt: string;
accessUrl?: string;
slot?: {
id: number;
deliveryTime: string;
freezeTime: string;
isActive: boolean;
deliverySequence?: unknown;
isFlash?: boolean;
} | null;
}
// Vendor-snippet form row — anchored to the shared input (wire validTill);
// form requires slotId and carries the serialized createdAt.
export type VendorSnippetForm = Omit<AdminVendorSnippetInput, 'slotId'> & {
id: number;
slotId: number;
createdAt: string;
}

View file

@ -0,0 +1,33 @@
{
"include": ["**/*.ts", "**/*.tsx"],
"compilerOptions": {
"target": "ES2022",
"jsx": "react-jsx",
"module": "ESNext",
"paths": {
"#/*": ["./src/*"],
"@/*": ["./src/*", "../../apps/backend/*"],
"web-components": ["../../packages/web-components/src"],
"web-components/*": ["../../packages/web-components/src/*"],
"@packages/shared": ["../../packages/shared"],
"@packages/shared/*": ["../../packages/shared/*"],
"@backend/*": ["../../apps/backend/src/*"]
},
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client", "node"],
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
/* Linting */
"skipLibCheck": true,
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
}
}

View file

@ -0,0 +1,26 @@
import { defineConfig } from 'vite'
import { devtools } from '@tanstack/devtools-vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { nitro } from 'nitro/vite'
import { cloudflare } from "@cloudflare/vite-plugin";
const config = defineConfig({
resolve: { tsconfigPaths: true },
server: { port: 4174 },
plugins: [
devtools(),
tailwindcss(),
tanstackStart(),
viteReact(),
cloudflare({
viteEnvironment: {
name: "ssr"
}
}),
],
})
export default config

View file

@ -0,0 +1,12 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "admin-web",
"compatibility_date": "2026-05-10",
"observability": {
"enabled": true
},
"main": "@tanstack/react-start/server-entry",
"compatibility_flags": [
"nodejs_compat"
]
}

View file

@ -1,6 +1,6 @@
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod';
import { generateSignedUrlsFromS3Urls } from '@/src/lib/s3-client'
import { scaffoldAssetUrl } from '@/src/lib/s3-client'
import { queueDataPusher } from '@/src/lib/queue-data-pusher'
import {
getComplaintById,
@ -68,29 +68,25 @@ export const complaintRouter = router({
const complaintsToReturn = hasMore ? complaintsData.slice(0, limit) : complaintsData;
const complaintsWithSignedImages = await Promise.all(
complaintsToReturn.map(async (c: ComplaintWithUser) => {
const signedImages = c.images
? await generateSignedUrlsFromS3Urls(c.images as string[])
: [];
const complaintsWithImageUrls = complaintsToReturn.map((c: ComplaintWithUser) => {
const images = scaffoldAssetUrl((c.images as string[]) || [])
return {
id: c.id,
text: c.complaintBody,
userId: c.userId,
userName: c.userName,
userMobile: c.userMobile,
orderId: c.orderId,
status: c.isResolved ? 'resolved' : 'pending',
createdAt: c.createdAt,
images: signedImages,
response: c.response,
};
})
);
return {
id: c.id,
text: c.complaintBody,
userId: c.userId,
userName: c.userName,
userMobile: c.userMobile,
orderId: c.orderId,
status: c.isResolved ? 'resolved' : 'pending',
createdAt: c.createdAt,
images,
response: c.response,
};
});
return {
complaints: complaintsWithSignedImages,
complaints: complaintsWithImageUrls,
nextCursor: hasMore
? complaintsToReturn[complaintsToReturn.length - 1].id
: undefined,

View file

@ -1,7 +1,7 @@
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod'
import { ApiError } from '@/src/lib/api-error'
import { generateSignedUrlsFromS3Urls, scaffoldAssetUrl, claimUploadUrl, extractKeyFromPresignedUrl, deleteImageUtil } from '@/src/lib/s3-client'
import { scaffoldAssetUrl, claimUploadUrl, extractKeyFromPresignedUrl, deleteImageUtil } from '@/src/lib/s3-client'
import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
import { createAvailabilityCacheFile } from '@/src/lib/cloud_cache'
import {
@ -65,21 +65,17 @@ export const productRouter = router({
});
*/
const productsWithSignedUrls = await Promise.all(
products.map(async (product) => ({
...product,
skus: await Promise.all(
product.skus.map(async (sku) => ({
...sku,
images: await generateSignedUrlsFromS3Urls((sku.images as string[]) || []),
}))
),
}))
)
const productsWithImageUrls = products.map((product) => ({
...product,
skus: product.skus.map((sku) => ({
...sku,
images: scaffoldAssetUrl((sku.images as string[]) || []),
})),
}))
return {
products: productsWithSignedUrls,
count: productsWithSignedUrls.length,
products: productsWithImageUrls,
count: productsWithImageUrls.length,
}
}),
@ -136,18 +132,16 @@ export const productRouter = router({
throw new ApiError('Product not found', 404)
}
const productWithSignedUrls = {
const productWithImageUrls = {
...product,
skus: await Promise.all(
product.skus.map(async (sku) => ({
...sku,
images: await generateSignedUrlsFromS3Urls((sku.images as string[]) || []),
}))
),
skus: product.skus.map((sku) => ({
...sku,
images: scaffoldAssetUrl((sku.images as string[]) || []),
})),
}
return {
product: productWithSignedUrls,
product: productWithImageUrls,
}
}),
@ -258,18 +252,16 @@ export const productRouter = router({
await scheduleStoreInitialization()
const productWithSignedUrls = {
const productWithImageUrls = {
...newProduct,
skus: await Promise.all(
newProduct.skus.map(async (sku) => ({
...sku,
images: await generateSignedUrlsFromS3Urls((sku.images as string[]) || []),
}))
),
skus: newProduct.skus.map((sku) => ({
...sku,
images: scaffoldAssetUrl((sku.images as string[]) || []),
})),
}
return {
product: productWithSignedUrls,
product: productWithImageUrls,
message: 'Product created successfully',
}
}),
@ -356,18 +348,16 @@ export const productRouter = router({
await scheduleStoreInitialization()
const productWithSignedUrls = {
const productWithImageUrls = {
...updatedProduct,
skus: await Promise.all(
updatedProduct.skus.map(async (sku) => ({
...sku,
images: await generateSignedUrlsFromS3Urls((sku.images as string[]) || []),
}))
),
skus: updatedProduct.skus.map((sku) => ({
...sku,
images: scaffoldAssetUrl((sku.images as string[]) || []),
})),
}
return {
product: productWithSignedUrls,
product: productWithImageUrls,
message: 'Product updated successfully',
}
}),
@ -544,17 +534,15 @@ export const productRouter = router({
return { reviews: reviewsWithSignedUrls, hasMore };
*/
const reviewsWithSignedUrls = await Promise.all(
reviews.map(async (review) => ({
...review,
signedImageUrls: await generateSignedUrlsFromS3Urls((review.imageUrls as string[]) || []),
signedAdminImageUrls: await generateSignedUrlsFromS3Urls((review.adminResponseImages as string[]) || []),
}))
)
const reviewsWithImageUrls = reviews.map((review) => ({
...review,
signedImageUrls: scaffoldAssetUrl((review.imageUrls as string[]) || []),
signedAdminImageUrls: scaffoldAssetUrl((review.adminResponseImages as string[]) || []),
}))
const hasMore = offset + limit < totalCount
return { reviews: reviewsWithSignedUrls, hasMore }
return { reviews: reviewsWithImageUrls, hasMore }
}),
respondToReview: protectedProcedure
@ -852,15 +840,13 @@ export const productRouter = router({
.query(async (): Promise<{ tags: Array<{ id: number; tagName: string; tagDescription: string | null; imageUrl: string | null; isDashboardTag: boolean; relatedStores: number[] | null; createdAt: Date }>; message: string }> => {
const tags = await getAllProductTagInfosInDb()
const tagsWithSignedUrls = await Promise.all(
tags.map(async (tag) => ({
...tag,
imageUrl: tag.imageUrl ? scaffoldAssetUrl(tag.imageUrl) : null,
}))
)
const tagsWithImageUrls = tags.map((tag) => ({
...tag,
imageUrl: tag.imageUrl ? scaffoldAssetUrl(tag.imageUrl) : null,
}))
return {
tags: tagsWithSignedUrls,
tags: tagsWithImageUrls,
message: 'Tags retrieved successfully',
}
}),

View file

@ -1441,3 +1441,28 @@ apps/admin-ui/src/components/ProductForm.tsx (onSubmit normalization — single
+ marketPrice: v.marketPrice == null || v.marketPrice === '' ? null : Number(v.marketPrice)
+ flashPrice: v.flashPrice == null || v.flashPrice === '' ? null : Number(v.flashPrice)
(features normalization unchanged). Semantics preserved: cleared price → undefined → yup required() blocks submit; cleared market/flash → null/undefined as the old parseFloat-conditional did. add.tsx pre-check (price == null || price <= 0) and edit.tsx (marketPrice ?? undefined) work unchanged — values are genuinely numeric past this point.
[2026-09-06 00:00:00] Scaffold apps/admin-web as copy of apps/web-ui (src, public, package.json, package-lock.json, vite.config.ts, tsconfig.json, wrangler.jsonc, README.md). Excluded: node_modules, dist, .output, .tanstack, .wrangler.
Planned edits: package.json name web-ui->admin-web + add sonner, @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities, date-fns (web equivalents of react-native-toast-message, react-native-draggable-flatlist, date-fns already used by admin-ui); vite.config.ts port 4174->4175; wrangler.jsonc worker name; root package.json workspaces + !"apps/admin-web" + admin-web scripts (mirror web-ui standalone treatment).
[2026-09-06 00:05:00] admin-web infra batch 1 (new files): src/lib/event-bus.ts (DeviceEventEmitter-compatible bus + REFRESH_EVENT/FORCE_LOGOUT_EVENT consts), src/lib/trpc-client.ts (admin: BASE_API_URL https://devapi.freshyo.in fallback + VITE_API_URL override, Bearer JWT, 401 UNAUTHORIZED -> force-logout emit), src/lib/query-client.ts (singleton, retry 3 — same as admin-ui), src/lib/refresh-context.tsx (RefreshProvider/useRefresh/useManualRefresh/useMarkDataFetchers/useFocusCallback web ports), src/lib/theme.ts (exact theme.colors hex copy from packages/ui), src/lib/navigation-target.ts (useNavigationTarget zustand copy), src/hooks/useJWT.ts, src/hooks/useUploadToObjectStorage.ts (trpc path fix only), src/services/toaster.tsx (sonner-backed same 4-function API), src/types/vendor-snippets.ts + drag-order-item.ts (verbatim copies), src/components/context/staff-auth-context.tsx (expo-router/DeviceEventEmitter -> TanStack/bus). Deleted copied user-app src/routes/*, src/components/*, src/hooks/*, src/lib/* (scaffold placeholders, replaced by admin ports).
[2026-09-06 00:30:00] admin-web dashboard heading fixes (3 edits): (1) dashboard.index.tsx MenuItemComponent text wrapper span flex-1 -> flex min-w-0 flex-1 flex-col (title/subtitle stacked vertically like mobile RN column layout; was rendering inline same-line); (2) dashboard.index.tsx quick-action title add numberOfLines={2} (parity with mobile index.tsx:257); (3) dashboard.products.$id.tsx reply-quote raw <p> -> <MyText numberOfLines={3}> (parity with mobile detail/[id].tsx:480 N=3, same classes).
[2026-09-06 00:32:00] VERIFIED dashboard heading fixes: tsc 0 errors, vite build clean.
[2026-09-06 00:35:00] admin-web dashboard quick-action tiles: button className add flex flex-col (was items-center with no display:flex, so icon left-aligned; mobile tile is column + items-center with icon centered above title). Title already text-center.
[2026-09-06 00:36:00] VERIFIED quick-action tile fix: tsc 0 errors, vite build clean.
[2026-09-06 00:40:00] admin-web dashboard quick-action tiles: replace style width calc(25%-9px) with flex-1. RN ignores calc() so mobile tiles are content-width filling the row; web honored calc giving a 4-per-row grid with an empty 4th slot (~30% gap). flex-1 gives 3 equal tiles filling the row on all viewports, matching the phone look.
[2026-09-06 00:41:00] VERIFIED quick-action full-width fix: tsc 0 errors, vite build clean.
[2026-09-06 00:45:00] admin-web FABs absolute->fixed (viewport-pinned bottom-right irrespective of scroll; offsets/z-index unchanged): dashboard.banners.tsx:424, dashboard.customize-app.popular.tsx:338, dashboard.stores.tsx:187, dashboard.slots.tsx:286, dashboard.slots.detail.tsx:204, dashboard.coupons.tsx:502, dashboard.product-groupings.tsx:190, dashboard.rebalance.tsx (style position), dashboard.orders.sequence.tsx:683 save FAB (kept bottom-LEFT per mobile original delivery-sequences:680, only pinned to viewport).
[2026-09-06 00:47:00] VERIFIED FAB viewport pinning: tsc 0 errors, vite build clean.
[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.

View file

@ -12,6 +12,8 @@
"web-ui": "bun run --filter web-ui",
"web-ui:dev": "bun run web-ui dev",
"web-ui:build": "bun run web-ui build",
"admin-web:dev": "npm run dev --prefix apps/admin-web",
"admin-web:build": "npm run build --prefix apps/admin-web",
"docker:build": "docker buildx build --platform linux/amd64 -t mohdshafiuddin54/meat_farmer .",
"docker:push": "docker push mohdshafiuddin54/meat_farmer",
"docker:deploy": "docker buildx build --platform linux/amd64 -t mohdshafiuddin54/meat_farmer . && docker push mohdshafiuddin54/meat_farmer",
@ -21,6 +23,7 @@
"workspaces": [
"apps/*",
"!apps/web-ui",
"!apps/admin-web",
"packages/*"
],
"devDependencies": {