This commit is contained in:
shafi54 2026-09-12 09:06:33 +05:30
parent cc17653402
commit f8e7eec754
31 changed files with 944 additions and 76 deletions

View file

@ -50,7 +50,36 @@
"Shell(sleep:*)",
"Shell(for p in /me /me/orders /me/addresses /me/coupons /me/complaints /me/edit-profile /me/terms; do code=$(curl -s -o /dev/null -w \"%{http_code}\" \"http://localhost:4175$p\"); echo \"$p -> $code\"; done)",
"Shell(curl -s http://localhost:4175/me)",
"Shell(pkill -f \"vite dev\" 2>/dev/null; pkill -f \"web-ui\" 2>/dev/null; echo \"stopped\")"
"Shell(pkill -f \"vite dev\" 2>/dev/null; pkill -f \"web-ui\" 2>/dev/null; echo \"stopped\")",
"Shell(npx playwright install chromium 2 >& 1)",
"Shell(npx playwright --version)",
"Shell(npx playwright test --list 2 >& 1)",
"Shell(curl -s http://localhost:4175/login)",
"Shell(curl -s -o /dev/null -w %{http_code}\\n http://localhost:4175/login)",
"Shell(grep -aiE \"error|fail\" /tmp/adminweb_dev.log | head -10 || echo \"no errors\"; pkill -f \"vite dev\" 2>/dev/null; pkill -f \"admin-web\" 2>/dev/null; echo \"stopped\")",
"Shell(git:*)",
"Shell(bun:*)",
"Shell(bunx playwright test --list -c ../playwright.config.ts 2 >& 1)",
"Shell([:*)",
"Shell(sed:*)",
"Shell(curl -s -o /dev/null -w %{http_code}\\n --max-time 4 http://localhost:4175/login 2 > /dev/null)",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && bun run test:e2e > /tmp/e2e_run.log 2>&1; echo \"exit=$?\")",
"Shell(ps:*)",
"Shell(npx tsc --noEmit -p tests 2 >& 1)",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && bun run test:e2e > /tmp/e2e_run2.log 2>&1; echo \"exit=$?\" >> /tmp/e2e_run2.log)",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && sed -n '/```yaml/,/```/p' \"test-results/auth.setup.ts-authenticate-staff-user-setup/error-context.md\")",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && bun run test:e2e > /tmp/e2e_run3.log 2>&1; echo \"exit=$?\" >> /tmp/e2e_run3.log)",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && npx tsc --noEmit -p tests 2>&1 | tail -5; echo \"tsc_exit=$?\"; bun run test:e2e > /tmp/e2e_run4.log 2>&1; echo \"exit=$?\" >> /tmp/e2e_run4.log)",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && sed -n '/```yaml/,/```/p' \"test-results/specs-product-lifecycle-pr-e8d1e-e-→-update-→-slot-→-suspend-chromium/error-context.md\" | head -60)",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && npx tsc --noEmit -p tests 2>&1 | tail -5; echo \"tsc_exit=$?\"; bun run test:e2e > /tmp/e2e_run5.log 2>&1; echo \"exit=$?\" >> /tmp/e2e_run5.log)",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && sed -n '/```yaml/,/```/p' \"test-results/specs-product-lifecycle-pr-e8d1e-e-→-update-→-slot-→-suspend-chromium/error-context.md\" | head -50)",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && bun run test:e2e > /tmp/e2e_run6.log 2>&1; echo \"exit=$?\" >> /tmp/e2e_run6.log)",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && npx tsc --noEmit -p tests 2>&1 | tail -5; echo \"tsc_exit=$?\"; bun run test:e2e > /tmp/e2e_run7.log 2>&1; echo \"exit=$?\" >> /tmp/e2e_run7.log)",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && sed -n '/```yaml/,/```/p' \"test-results/specs-product-lifecycle-pr-e8d1e-e-→-update-→-slot-→-suspend-chromium/error-context.md\" | head -45)",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && grep -c \"add-slot-fab\" \"test-results/specs-product-lifecycle-pr-e8d1e-e-→-update-→-slot-→-suspend-chromium/error-context.md\"; grep -ao \"add-slot-fab\" \"test-results/specs-product-lifecycle-pr-e8d1e-e-→-update-→-slot-→-suspend-chromium/error-context.md\" | head; echo \"--- tail of snapshot:\"; sed -n '/```yaml/,/```/p' \"test-results/specs-product-lifecycle-pr-e8d1e-e-→-update-→-slot-→-suspend-chromium/error-context.md\" | tail -25)",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && npx tsc --noEmit -p tests 2>&1 | tail -5; echo \"tsc_exit=$?\"; bun run test:e2e > /tmp/e2e_run8.log 2>&1; echo \"exit=$?\" >> /tmp/e2e_run8.log)",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && npx tsc --noEmit -p tests 2>&1 | tail -5; echo \"tsc_exit=$?\"; bun run test:e2e > /tmp/e2e_run9.log 2>&1; echo \"exit=$?\" >> /tmp/e2e_run9.log)",
"Shell(DEBUG=pw:browser npx playwright test tests/specs/smoke.spec.ts 2 >& 1)"
],
"deny": [],
"defaultMode": "default"

View file

@ -106,6 +106,14 @@ er-ui/app/(drawer)/(tabs)/home/index.tsx, I want similar here") and expects the
- When organizing a shared type package, prefers to keep similar types together — all types that have identical properties should stay grouped/collocated so exact duplicates live side by side (e.g., clustering same-shaped types together in the shared files rather than scattering them). Confidence: 0.75
- Scrollable content (e.g., a product grid) must never be buried beneath a persistent floating UI element like the floating cart bar: pages that scroll above such a bar need sufficient bottom padding on the content container so the last row of items can scroll fully clear of the bar ("There should be enough padding in bottom. I see the last row of products buried beneath the floating cart bar. They should be scrollable"). Errs toward GENEROUS bottom spacing and iterates for more if it still feels tight — after the initial fix (matching siblings' mobile `pb-24`) he asked to bump it again ("it's improved but add some more padding"), landing at mobile `pb-32` / desktop `md:pb-16`. Expects the fix to follow the sibling pages' bottom-padding pattern but lean roomier rather than matching the minimum. Confidence: 0.85
- Prefers an incremental "load more"/"Show More" reveal model over rendering the entire product list at once on listing/dashboard pages: show a fixed batch at a time (the user-ui dashboard's All Products list shows 21 items and adds another 21 per click, hiding the button once all are visible) and expects the same model replicated in the sibling web-ui app ("we aren't showing all the items at once. We have a load more model. Implement the same on the @apps/web-ui too"), matching the reference's batch size and button behavior. Confidence: 0.8
- For browser/E2E test automation, prefers Playwright and wants the suite placed in a `tests/` folder at the project ROOT (not inside the app it targets) — "At the project root add a folder tests. I want playwright based tests". Confidence: 0.85
- Wants E2E automation organized around long, multi-step, real user workflows executed in a SINGLE browser instance/session (e.g., add a product → update it → create a slot for it → suspend it), rather than split into isolated per-step tests — "I want to automate long tasks in a single instance". Confidence: 0.85
- For the E2E harness itself: tests should drive whatever UI URL is targeted (no backend management from the harness), pull staff credentials from environment variables (not committed inline), and NOT auto-start the dev server — the app is assumed to be running. Confidence: 0.6
- Wants automation tests (Playwright) to run with a VISIBLE browser, not headless — "I want to see the browser, I don't want it in headless mode" — so local runs should default to headed (CI may stay headless), with a convenience script/flag (e.g. `test:e2e:headed`, `HEADED=0/1`) to switch. Confidence: 0.85
- Prefers names/labels displayed in FULL rather than truncated — e.g., product names in the cart drawer should wrap and show completely (text wrap / `break-words` instead of `truncate`) — "show the name full, if needed do text wrap". Confidence: 0.55
- Wants the floating cart bar HIDDEN on routes where it is redundant — the cart page (including the flash cart alias) and the entire "me" section — and expects the condition to cover ALL child routes via a path-prefix check (`pathname.startsWith('/me')`) rather than matching only the parent route. Confidence: 0.6
- Uses Bun as his package manager / package runner ("I use bun"), so whenever giving or documenting commands he expects `bun run <script>` / `bunx <tool>` rather than `npm run` / `npx` (including inside subfolders, e.g. `bun run --cwd .. <script>`). Confidence: 0.9
- Expects generated test automation to be actually EXECUTED and passing, not just written/typechecking — he asks the agent to run the suite and report how many tests succeed ("try to run the script and see how many tests succeed"), treating a suite that was never run against a live app as unverified. Confidence: 0.65
e changes. Confidence: 0.95
er-ui/app/(drawer)/(tabs)/home/index.tsx, I want similar here") and expects the agent to mirror that pattern exactly, including details like item counts and container styling. Confidence: 0.9
als) rather than introducing new brand palettes; approval of a redesign's layout/composition does not imply approval of color or theme changes. Confidence: 0.95

10
.gitignore vendored
View file

@ -132,4 +132,12 @@ dist
.pnp.*
type-clusters/*
type-clusters/*
# ---> Playwright (root E2E suite)
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
tests/.auth/
tests/.env.test

View file

@ -5,6 +5,7 @@ interface DateTimeInputProps {
setValue: (d: Date | null) => void
showLabels?: boolean
timeOnly?: boolean
testID?: string
}
function toDateStr(d: Date | null): string {
@ -20,7 +21,7 @@ function toTimeStr(d: Date | null): string {
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
export default function DateTimeInput({ value, setValue, showLabels = true, timeOnly = false }: DateTimeInputProps) {
export default function DateTimeInput({ value, setValue, showLabels = true, timeOnly = false, testID }: DateTimeInputProps) {
const handleDateChange = (dateStr: string) => {
if (!dateStr) {
setValue(null)
@ -42,7 +43,7 @@ export default function DateTimeInput({ value, setValue, showLabels = true, time
if (timeOnly) {
return (
<div className="w-full mb-4">
<div className="w-full mb-4" data-testid={testID}>
{showLabels ? <P className="text-xs mb-1">Select Time</P> : null}
<input
type="time"
@ -55,7 +56,7 @@ export default function DateTimeInput({ value, setValue, showLabels = true, time
}
return (
<div className="w-full flex flex-row items-stretch mb-4">
<div className="w-full flex flex-row items-stretch mb-4" data-testid={testID}>
<div className="w-1/2">
{showLabels ? <P className="text-xs mb-1">Select Date</P> : null}
<input

View file

@ -104,6 +104,7 @@ export function MultiSelect({
return (
<div
key={String(option.value)}
data-testid="multiselect-option"
onClick={() => {
if (!option.disabled) toggle(option.value)
}}

View file

@ -297,7 +297,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
onChange={handleChange('longDescription')}
style={{ marginBottom: 16 }}
/>
<div className="mb-4">
<div className="mb-4" data-testid="product-store-select">
<P className="mb-1 text-sm text-gray-500 font-medium">Store</P>
<Dropdown
label="Select store"
@ -308,7 +308,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
/>
</div>
<div className="mb-4">
<div className="mb-4" data-testid="product-type-select">
<P className="mb-1 text-sm text-gray-500 font-medium">Product Type</P>
<Dropdown
label="Select product type"
@ -507,7 +507,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
)}
{mode === 'edit' && (
<div className="flex flex-row items-center mb-3">
<div className="flex flex-row items-center mb-3" data-testid={`variant-suspend-row-${vIndex}`}>
<Checkbox
checked={variant.isSuspended}
onPress={() => setFieldValue(`variants.${vIndex}.isSuspended`, !variant.isSuspended)}
@ -626,6 +626,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
<button
type="button"
data-testid="product-submit-button"
onClick={async () => {
const validationErrors = await validateForm()
if (Object.keys(validationErrors).length > 0) {

View file

@ -27,6 +27,7 @@ interface ProductsSelectorProps {
selectedGroupIds?: number[]
onGroupChange?: (groupIds: number[]) => void
showGroups?: boolean
testID?: string
}
export default function ProductsSelector({
@ -43,6 +44,7 @@ export default function ProductsSelector({
showGroups = true,
selectedGroupIds = [],
onGroupChange,
testID,
}: ProductsSelectorProps) {
const { data: skusData } = trpc.common.product.getAllSkusSummary.useQuery()
const allSkus: SkuSummary[] = skusData?.skus || []
@ -147,6 +149,7 @@ export default function ProductsSelector({
{multiple ? (
<MultiSelect
label={label}
testID={testID}
options={productOptions}
value={Array.isArray(value) ? value.map(id => id.toString()) : []}
onValueChange={(selectedValue) => {
@ -161,6 +164,7 @@ export default function ProductsSelector({
) : (
<SearchableSelect
label={label}
testID={testID}
options={productOptions}
value={value ? value.toString() : ''}
onValueChange={(selectedValue) => {

View file

@ -134,6 +134,7 @@ export default function SlotForm({
<div className="mb-4">
<P className="text-lg font-semibold mb-2 block">Delivery Date & Time</P>
<DateTimeInput
testID="slot-delivery-datetime"
value={values.deliveryTime}
setValue={(value) => setFieldValue('deliveryTime', value)}
/>
@ -142,6 +143,7 @@ export default function SlotForm({
<div className="mb-4">
<P className="text-lg font-semibold mb-2 block">Freeze Date & Time</P>
<DateTimeInput
testID="slot-freeze-datetime"
value={values.freezeTime}
setValue={(value) => setFieldValue('freezeTime', value)}
/>
@ -149,6 +151,7 @@ export default function SlotForm({
<div className="mb-4">
<ProductsSelector
testID="slot-products-selector"
value={values.selectedSkuIds}
onChange={(newSkuIds) => setFieldValue('selectedSkuIds', newSkuIds)}
groups={mappedGroups}

View file

@ -6,7 +6,8 @@ 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://worker.freshyo.in'
// (import.meta.env?.VITE_API_URL as string | undefined) || 'https://worker.freshyo.in'
(import.meta.env?.VITE_API_URL as string | undefined) || 'https://devapi.freshyo.in'
export const trpc = createTRPCReact<AppRouter>()

View file

@ -1,5 +1,5 @@
import React, { useState, useMemo } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { createFileRoute, Outlet, useLocation, 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'
@ -33,6 +33,7 @@ function FilterButton({ filter, label, count, activeFilter, onSelect }: { filter
function Products() {
const navigate = useNavigate()
const location = useLocation()
const [searchTerm, setSearchTerm] = useState('')
const [activeFilter, setActiveFilter] = useState<FilterType>('all')
@ -69,6 +70,12 @@ function Products() {
navigate({ to: '/dashboard/products/$id', params: { id: String(productId) } })
}
// Render child routes (new / edit / detail) when not on the list route.
// Placed after all hooks so the hook order stays stable across renders.
if (location.pathname !== '/dashboard/products') {
return <Outlet />
}
if (isLoading) {
return (
<AppContainer>

View file

@ -0,0 +1,48 @@
toggleOutOfStock: protectedProcedure
.input(z.object({
id: z.number(),
}))
.mutation(async ({ input }): Promise<AdminToggleOutOfStockResult> => {
const { id } = input;
const updatedProduct = await toggleProductOutOfStockInDb(id)
if (!updatedProduct) {
throw new ApiError('Product not found', 404)
}
await scheduleStoreInitialization()
return {
product: updatedProduct,
message: `Product marked as ${updatedProduct.isOutOfStock ? 'out of stock' : 'in stock'}`,
}
}),
export async function toggleProductOutOfStock(id: number): Promise<AdminProduct | null> {
const product = await db.query.productInfo.findFirst({
where: eq(productInfo.id, id),
})
if (!product) {
return null
}
const [updatedProduct] = await db
.update(productInfo)
.set({
isOutOfStock: !product.isOutOfStock,
})
.where(eq(productInfo.id, id))
.returning()
if (!updatedProduct) {
return null
}
return mapProduct(updatedProduct)
}

View file

@ -130,7 +130,7 @@ export default function AddToCartDialog() {
</p>
{product?.name && (
<p className="text-sm text-gray-500">
{product.name} ({product.productQuantity}{product.unitNotation ? ` ${product.unitNotation}` : ''})
{product.name}{product.productType !== 'combo' && product.unitNotation ? ` (${product.unitNotation})` : ''}
</p>
)}
</div>

View file

@ -153,7 +153,6 @@ export function FloatingCartBar({ isFlashDelivery = false }: FlashDeliveryProps)
{productsById[item.skuId]?.name || ''}
</p>
<p className="text-xs font-medium text-gray-500">
{productsById[item.skuId]?.productQuantity || 0}
{productsById[item.skuId]?.unitNotation || ''}
</p>
{item.slotId && (

View file

@ -15,13 +15,6 @@ interface ProductCardProps {
useAddToCartDialog?: boolean
}
const formatQuantity = (quantity: number, unit: string): { value: string; display: string } => {
if (unit?.toLowerCase() === 'kg' && quantity < 1) {
return { value: `${Math.round(quantity * 1000)} g`, display: `${Math.round(quantity * 1000)}g` }
}
return { value: `${quantity} ${unit}(s)`, display: `${quantity}${unit}` }
}
export function ProductCard({
item,
onPress,
@ -185,7 +178,7 @@ export function ProductCard({
</div>
<p className="mb-1 text-[11px] font-medium text-gray-500 sm:text-xs">
Qty: <span className="font-semibold text-brand-700/70">{formatQuantity(item.productQuantity || 1, item.unitNotation).display}</span>
Qty: <span className="font-semibold text-brand-700/70">{item.unitNotation}</span>
</p>
{/* Delivery date hidden for now

View file

@ -72,7 +72,7 @@ function CartPage() {
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-gray-900">{product.name}</p>
<p className="mt-0.5 text-xs text-gray-500">
{product.productQuantity || 1}{product.unitNotation || ''} per unit
{product.unitNotation || ''} per unit
</p>
<div className="mt-2 flex items-center gap-3">
<Quantifier

View file

@ -191,19 +191,6 @@ function FlashDeliveryPage() {
)
}
const formatQuantity = (
quantity: number,
unit: string,
): { value: string; display: string } => {
if (unit?.toLowerCase() === 'kg' && quantity < 1) {
return {
value: `${Math.round(quantity * 1000)} g`,
display: `${Math.round(quantity * 1000)}g`,
};
}
return { value: `${quantity} ${unit}(s)`, display: `${quantity}${unit}` };
};
function CompactProductCard({
item,
handleAddToCart,
@ -282,7 +269,7 @@ function CompactProductCard({
</div>
<p className="mt-0.5 text-[11px] text-gray-500">
<span className="font-semibold text-flash-500">
{formatQuantity(item.productQuantity || 1, item.unit || item.unitNotation).display}
{item.unitNotation || item.unit}
</span>
</p>
</div>

View file

@ -72,7 +72,7 @@ function CartPage() {
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-gray-900">{product.name}</p>
<p className="mt-0.5 text-xs text-gray-500">
{product.productQuantity || 1}{product.unitNotation || ''} per unit
{product.unitNotation || ''} per unit
</p>
<div className="mt-2 flex items-center gap-3">
<Quantifier

View file

@ -19,13 +19,6 @@ export const Route = createFileRoute('/home/product/$id')({
component: ProductDetailPage,
})
const formatQuantity = (quantity: number, unit: string): string => {
if (unit?.toLowerCase() === 'kg' && quantity < 1) {
return `${Math.round(quantity * 1000)}g`
}
return `${quantity}${unit}`
}
function ProductDetailPage() {
const { id } = useParams({ from: '/home/product/$id' })
const productId = Number(id)
@ -238,7 +231,7 @@ function ProductDetailPage() {
<div className="flex items-end">
<span className="text-3xl font-bold text-gray-900">{productDetail.price}</span>
<span className="mb-1 ml-1 text-lg text-gray-500">
/ {formatQuantity(productDetail.productQuantity || 1, productDetail.unitNotation)}
/ {productDetail.unitNotation}
</span>
{productDetail.marketPrice && (
@ -255,7 +248,7 @@ function ProductDetailPage() {
{productAvailability?.isFlashAvailable && productDetail.flashPrice && productDetail.flashPrice !== productDetail.price && (
<div className="mt-1">
<span className="text-lg font-bold text-pink-600">
1 Hr Delivery: {productDetail.flashPrice} / {formatQuantity(productDetail.productQuantity || 1, productDetail.unitNotation)}
1 Hr Delivery: {productDetail.flashPrice} / {productDetail.unitNotation}
</span>
</div>
)}
@ -389,7 +382,7 @@ function ProductDetailPage() {
className="mb-2 flex items-center justify-between rounded-xl border border-amber-100 bg-amber-50 p-3"
>
<span className="font-medium text-amber-900">
Buy {deal.quantity} {formatQuantity(parseFloat(deal.quantity), productDetail.unitNotation)}
Buy {deal.quantity} {productDetail.unitNotation}
</span>
<span className="text-lg font-bold text-amber-900">{deal.price}</span>
</div>

View file

@ -385,19 +385,6 @@ function StoreSidebar({
);
}
const formatQuantity = (
quantity: number,
unit: string,
): { value: string; display: string } => {
if (unit?.toLowerCase() === "kg" && quantity < 1) {
return {
value: `${Math.round(quantity * 1000)} g`,
display: `${Math.round(quantity * 1000)}g`,
};
}
return { value: `${quantity} ${unit}(s)`, display: `${quantity}${unit}` };
};
function CompactProductCard({
item,
handleAddToCart,
@ -484,12 +471,7 @@ function CompactProductCard({
<p className="ml-1 text-xs text-gray-600">
Qty:{" "}
<span className="font-semibold text-brand-500">
{
formatQuantity(
item.productQuantity || 1,
item.unit || item.unitNotation,
).display
}
{item.unitNotation || item.unit}
</span>
</p>
</div>

View file

@ -28,7 +28,9 @@
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@playwright/test": "^1.63.0",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^22.10.2",
"@types/react": "~19.0.10",
"@types/react-native-razorpay": "^2.2.6",
"eslint": "^9.25.0",
@ -1028,6 +1030,8 @@
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@playwright/test": ["@playwright/test@1.63.0", "", { "dependencies": { "playwright": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ=="],
"@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="],
"@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="],
@ -1390,7 +1394,7 @@
"@types/mapbox__vector-tile": ["@types/mapbox__vector-tile@1.3.4", "", { "dependencies": { "@types/geojson": "*", "@types/mapbox__point-geometry": "*", "@types/pbf": "*" } }, "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg=="],
"@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="],
"@types/node": ["@types/node@22.19.18", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ=="],
"@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="],
@ -2732,6 +2736,10 @@
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
"playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="],
"playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="],
"plimit-lit": ["plimit-lit@1.6.1", "", { "dependencies": { "queue-lit": "^1.5.1" } }, "sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA=="],
"plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="],
@ -3288,7 +3296,7 @@
"undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
@ -3538,16 +3546,28 @@
"@istanbuljs/load-nyc-config/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
"@jest/environment/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="],
"@jest/fake-timers/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="],
"@jest/types/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="],
"@maplibre/maplibre-gl-style-spec/quickselect": ["quickselect@2.0.0", "", {}, "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw=="],
"@packages/db_helper_postgres/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="],
"@packages/db_helper_postgres/drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="],
"@packages/db_helper_postgres/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"@packages/db_helper_sqlite/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="],
"@packages/db_helper_sqlite/drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="],
"@packages/db_helper_sqlite/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"@packages/migrator/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="],
"@packages/migrator/drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="],
"@packages/migrator/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
@ -3600,6 +3620,12 @@
"@tanstack/start-plugin-core/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@types/better-sqlite3/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="],
"@types/graceful-fs/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="],
"@types/pg/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
@ -3616,8 +3642,6 @@
"@vitejs/plugin-react-swc/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
"admin-web/@types/node": ["@types/node@22.19.18", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ=="],
"admin-web/@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"admin-web/fuse.js": ["fuse.js@7.3.0", "", {}, "sha512-plz8RVjfcDedTGfVngWH1jmJvBvAwi1v2jecfDerbEnMcmOYUEEwKFTHbNoCiYyzaK2Ws8lABkTCcRSqCY1q4w=="],
@ -3636,6 +3660,8 @@
"backend/@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260401.1", "", {}, "sha512-tKBeV/ySfJjbO0qMKkFrstHDdWzZHAcW4vCpO5QaqjB/667y9lhZt9gZyTKeJ0gluIBwpeQ/efBjqRLqpkgw9g=="],
"backend/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="],
"backend/expo-server-sdk": ["expo-server-sdk@4.0.0", "", { "dependencies": { "node-fetch": "^2.6.0", "promise-limit": "^2.7.0", "promise-retry": "^2.0.1" } }, "sha512-zi83XtG2pqyP3gyn1JIRYkydo2i6HU3CYaWo/VvhZG/F29U+QIDv6LBEUsWf4ddZlVE7c9WN1N8Be49rHgO8OQ=="],
"backend/fuse.js": ["fuse.js@7.3.0", "", {}, "sha512-plz8RVjfcDedTGfVngWH1jmJvBvAwi1v2jecfDerbEnMcmOYUEEwKFTHbNoCiYyzaK2Ws8lABkTCcRSqCY1q4w=="],
@ -3658,6 +3684,10 @@
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"chrome-launcher/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="],
"chromium-edge-launcher/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="],
"chromium-edge-launcher/rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="],
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@ -3766,8 +3796,18 @@
"is-expression/acorn": ["acorn@7.4.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A=="],
"jest-environment-node/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="],
"jest-haste-map/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="],
"jest-mock/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="],
"jest-util/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="],
"jest-util/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"jest-worker/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="],
"jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"jsdom/webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
@ -3922,8 +3962,6 @@
"web-components/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
"web-ui/@types/node": ["@types/node@22.19.18", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ=="],
"web-ui/@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"web-ui/fuse.js": ["fuse.js@7.3.0", "", {}, "sha512-plz8RVjfcDedTGfVngWH1jmJvBvAwi1v2jecfDerbEnMcmOYUEEwKFTHbNoCiYyzaK2Ws8lABkTCcRSqCY1q4w=="],
@ -4050,12 +4088,30 @@
"@istanbuljs/load-nyc-config/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"@jest/environment/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"@jest/fake-timers/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"@jest/types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"@packages/db_helper_postgres/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"@packages/db_helper_sqlite/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"@packages/migrator/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"@react-native/community-cli-plugin/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"@react-native/dev-middleware/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"@tanstack/router-devtools/@tanstack/react-router-devtools/@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.166.9", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "@tanstack/router-core": "^1.167.2", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-PNlA7GmOUX9wY7LUG709Pk3Lg33dfHBztQwzjzrOiOsuf4ggp2R6bwarF8nYGNjG79z/MaB5PN+5yvkCVk8jGw=="],
"@types/better-sqlite3/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"@types/graceful-fs/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"@types/pg/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
"@typescript-eslint/typescript-estree/tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
@ -4070,7 +4126,7 @@
"@vitejs/plugin-react-swc/vite/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"admin-web/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"backend/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"backend/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.3.4", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q=="],
@ -4088,6 +4144,10 @@
"cheerio/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"chrome-launcher/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"chromium-edge-launcher/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"connect/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
@ -4114,8 +4174,6 @@
"express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"fallback-ui/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"fallback-ui/tailwindcss/arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"fallback-ui/tailwindcss/postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
@ -4130,6 +4188,16 @@
"global-prefix/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
"jest-environment-node/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"jest-haste-map/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"jest-mock/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"jest-util/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"jest-worker/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"lighthouse-logger/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"log-symbols/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
@ -4296,8 +4364,6 @@
"web-components/tailwindcss/arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"web-ui/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"web-ui/nitro/crossws": ["crossws@0.4.6", "", { "peerDependencies": { "srvx": ">=0.11.5" }, "optionalPeers": ["srvx"] }, "sha512-/Wxe9Z007EbJ496j88nToZEvyPZ8PY/wjZJ18Agh/GCA9cYHyLbxtrpdFlFzAw3TV20F0SUYGl0g6PzChbwUrg=="],
"web-ui/nitro/db0": ["db0@0.3.4", "", { "peerDependencies": { "@electric-sql/pglite": "*", "@libsql/client": "*", "better-sqlite3": "*", "drizzle-orm": "*", "mysql2": "*", "sqlite3": "*" }, "optionalPeers": ["@electric-sql/pglite", "@libsql/client", "better-sqlite3", "drizzle-orm", "mysql2", "sqlite3"] }, "sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw=="],

View file

@ -1487,3 +1487,92 @@ NOTES: no backend/migration changes; colors/tokens reused from web-ui theme.
[2026-09-06 09:40:00] web-ui flash page bottom padding fix: apps/web-ui/src/routes/flash.tsx content wrapper gained `pb-24 md:pb-12` (was `py-6 md:px-8` only) so the last product row clears the floating 1-Hr cart bar on mobile and is scrollable — matches sibling product-grid pages (offers.tsx, stores.tsx, home.index.tsx).
[2026-09-06 09:45:00] Increased flash page bottom padding further per user: `pb-24 md:pb-12` → `pb-32 md:pb-16` in apps/web-ui/src/routes/flash.tsx.
[2026-09-06 10:00:00] web-ui home "All Available Products" load-more model (parity with user-ui dashboard): apps/web-ui/src/routes/home.index.tsx now keeps visibleCount (starts 21, +21 per click) + hasMore state; effect resets to 21 on productsData/productSlotsMap change (deps = sortedProducts recompute); grid renders sortedProducts.slice(0, visibleCount); centered rounded-full brand "Show More" button shown while hasMore. Explore-tag section already had its own 6→all Show More; untouched.
[2026-09-06 11:25:00] ADD root Playwright E2E suite driving apps/admin-web (long single-instance workflows).
NEW FILES (root):
- playwright.config.ts — testDir ./tests, fullyParallel false, workers 1, timeout 120s, expect 15s, baseURL = TEST_BASE_URL || http://localhost:4175, trace/screenshot/video on failure; projects: setup (auth.setup.ts) + chromium (deps setup, storageState tests/.auth/staff.json). NO webServer block (dev server assumed running, per user).
- tests/README.md — setup/run docs + warning that tests create real records.
- tests/.env.test.example — TEST_BASE_URL / TEST_STAFF_NAME / TEST_STAFF_PASSWORD template (real .env.test is gitignored).
- tests/tsconfig.json — strict, noEmit, types node; includes tests + config.
- tests/helpers/env.ts — dependency-free .env.test loader (no override of real env).
- tests/helpers/admin-app.ts — page object: trackDialogs/expectDialog, login, product add/fill/submit/open-edit, setProductName/Price, toggleSuspend/isSuspendChecked, slot open/fill/select-product/submit, futureSlotTimes.
- tests/auth.setup.ts — logs in via data-testid fields, saves storageState; skips with hint when creds unset.
- tests/specs/smoke.spec.ts — dashboard shell + products list render (skips without creds).
- tests/specs/product-lifecycle.spec.ts — ONE long test (single page/context) with steps: add product → verify in list → update name+price → create slot for it → verify slot card → suspend SKU → reload-check suspension persists.
EDITED (apps/admin-web — additive data-testids only, admin-web-local components; no behavior change):
- components/ProductForm.tsx: data-testid on Store wrapper (product-store-select), Product Type wrapper (product-type-select), suspend row (variant-suspend-row-0), submit button (product-submit-button).
- components/DateTimeInput.tsx: new optional testID prop → applied to wrapper (both branches).
- components/ProductsSelector.tsx: new optional testID prop → forwarded to inner MultiSelect/SearchableSelect.
- components/MultiSelect.tsx: option rows get data-testid="multiselect-option".
- components/SlotForm.tsx: passes testID slot-delivery-datetime / slot-freeze-datetime / slot-products-selector.
EDITED (root):
- package.json: +devDependency @playwright/test ^1.63.0 (via `bun add`, bun.lock updated); +scripts test:e2e / test:e2e:ui.
- .gitignore: +/test-results/, /playwright-report/, /blob-report/, /playwright/.cache/, tests/.auth/, tests/.env.test.
VERIFICATION: `npx playwright test --list` discovers 4 tests / 3 files; `npx tsc --noEmit -p tests` = 0; admin-web `tsc --noEmit` = 0; `npx playwright test` with no creds = 4 skipped (clean skip path); admin-web dev booted on :4175 with /login returning 200 rendering login-name-input/login-password-input/login-button. Full E2E run requires a live admin-web + tests/.env.test credentials (not available here).
NOTE: tests create real products/slots on whatever backend the running admin-web targets.
[2026-09-06 11:40:00] Playwright: run headed (visible browser) by default.
- playwright.config.ts: `use.headless` now env-driven — HEADED=1 → headed, HEADED=0 → headless, unset → headed locally / headless when CI is set. Locally the browser window is now visible.
- package.json: +script "test:e2e:headed": "playwright test --headed".
- tests/README.md: Running section updated (browser visible by default; HEADED=0/1, --headed, --slow-mo options documented).
VERIFICATION: `npx playwright test --list` still discovers 4 tests; `npx tsc --noEmit -p tests` = 0.
[2026-09-06 12:05:00] FIX apps/admin-web: products child routes never rendered (missing Outlet) + make E2E suite pass.
=== apps/admin-web/src/routes/dashboard.products.tsx ===
- import: `createFileRoute, Outlet, useLocation, useNavigate` (added Outlet + useLocation)
- inside Products(): + const location = useLocation()
- + after all hooks (so hook order stays stable), before the isLoading return:
// Render child routes (new / edit / detail) when not on the list route.
if (location.pathname !== '/dashboard/products') {
return <Outlet />
}
BUG: dashboard.products.new.tsx / .edit.tsx / .$id.tsx are children of the
/dashboard/products route, but the parent rendered only the list and no
<Outlet/>, so navigating to /dashboard/products/new (or /edit, or /:id)
changed the URL while still showing the list — product add/edit/detail were
unreachable. Fixed with the same guard its siblings use (stores, orders,
banners, coupons, slots).
=== tests/helpers/admin-app.ts (hydration + actionability hardening) ===
- waitForHydration(page, selector) / waitForAppHydration(page): wait for React's
`__reactFiber$`/`__reactProps$` DOM markers before interacting. Filling an SSR
input pre-hydration passes a value assertion but React (not yet attached)
never records it, then hydration resets the controlled input to ''.
- fillStable / selectStable: retry fill+assert (expect.toPass).
- clickUntilUrl / dispatchClickUntilUrl: retry a navigational click until the URL
matches (covers clicks swallowed pre-hydration, and fixed controls under the
always-mounted TanStack devtools overlay).
- login(): waitForHydration on the submit button, fillStable on both fields.
- openAddProduct/openProductEdit/openAddSlot: hydrate, then clickUntilUrl (still
used for the slots FAB from the devtools overlay).
- fillNewProduct: poll until the async store <select> has real options before
selecting a store.
=== tests/specs/product-lifecycle.spec.ts ===
- assert exact:true for the sidebar "Products" locator (also matched dashboard tiles).
- suspend step: assert unchecked → toggle → expect.poll(checked) BEFORE saving →
save. Without letting the toggle re-render, Formik submitted stale values
(isSuspended=false) — a real race, so the poll is the fix.
VERIFICATION — `bun run test:e2e` against a live admin-web (localhost:4175) with
tests/.env.test creds: 4 passed, 0 failed (44.2s) — setup (login), product-lifecycle
(create → verify in list → update name+price → create slot for it → verify slot card
→ suspend → reload-check suspension persists), smoke dashboard + products.
`npx tsc --noEmit -p tests` = 0; apps/admin-web tsc = 0.
NOTE: the run created a real product ("E2E Product <ts> Updated") and a real slot on
the backend the UI points at.
OTHER MODULES LIKELY AFFECTED (same missing-Outlet defect, not fixed — out of scope
for the product lifecycle flow): dashboard.product-groupings.tsx, dashboard.product-tags.tsx
and dashboard.customize-app.tsx have child routes (new/edit/order/popular/ordering) but
no <Outlet/> guard. Their sub-routes would also fail to render; flagged for a follow-up.
[2026-09-06 04:00:00] BUG web-ui quantity labels: "1Kg" rendered as "11Kg", "0.5Kg" -> "10.5Kg".
ROOT CAUSE: user product `unitNotation` already contains the amount+unit (e.g. "0.5Kg","1Kg"), but web-ui prepends `productQuantity` (always 1) via a ported `formatQuantity(quantity, unit)` that assumed a bare unit. user-ui renders `unitNotation` directly.
FIX (match user-ui): drop `formatQuantity` helpers + `productQuantity` prefixes; render `unitNotation` (with productType !== 'combo' guard where user-ui has one). Files: components/ProductCard.tsx, components/AddToCartDialog.tsx, components/FloatingCartBar.tsx, routes/home.product.$id.tsx, routes/flash.tsx, routes/slot-view.tsx, routes/cart.tsx, routes/home.cart.tsx.
[2026-09-06 04:05:00] VERIFIED web-ui quantity-label fix: grep shows zero formatQuantity/productQuantity render usages left; tsc 0 errors; vite build clean. Label now renders `unitNotation` directly (e.g. Qty: 0.5Kg, 1Kg) matching user-ui.

View file

@ -9,6 +9,9 @@
"lint": "turbo run lint",
"typecheck": "bash typecheck",
"test": "turbo run test",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:ui": "playwright test --ui",
"web-ui": "bun run --filter web-ui",
"web-ui:dev": "bun run web-ui dev",
"web-ui:build": "bun run web-ui build",
@ -28,7 +31,9 @@
],
"devDependencies": {
"@babel/core": "^7.25.2",
"@playwright/test": "^1.63.0",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^22.10.2",
"@types/react": "~19.0.10",
"@types/react-native-razorpay": "^2.2.6",
"eslint": "^9.25.0",

44
playwright.config.ts Normal file
View file

@ -0,0 +1,44 @@
import { defineConfig, devices } from '@playwright/test'
import { loadTestEnv } from './tests/helpers/env'
loadTestEnv()
const baseURL = process.env.TEST_BASE_URL || 'http://localhost:4175'
const authStatePath = 'tests/.auth/staff.json'
export default defineConfig({
testDir: './tests',
// Long, dependent workflows run sequentially in a single worker.
fullyParallel: false,
workers: 1,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
// Generous budget for multi-step admin workflows.
timeout: 120_000,
expect: { timeout: 15_000 },
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL,
// Show the browser by default so runs are watchable; CI stays headless.
// Override with `HEADED=0` to force headless, or `HEADED=1` to force headed.
headless: process.env.HEADED === '1' ? false : process.env.HEADED === '0' ? true : !!process.env.CI,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
actionTimeout: 20_000,
navigationTimeout: 45_000,
},
projects: [
{
name: 'setup',
testMatch: /auth\.setup\.ts/,
use: { ...devices['Desktop Chrome'] },
},
{
name: 'chromium',
testMatch: /.*\.spec\.ts/,
dependencies: ['setup'],
use: { ...devices['Desktop Chrome'], storageState: authStatePath },
},
],
})

11
tests/.env.test.example Normal file
View file

@ -0,0 +1,11 @@
# Copy this file to `tests/.env.test` and fill in the values.
# `tests/.env.test` is gitignored.
# Base URL of the running admin-web instance.
# Defaults to http://localhost:4175 (admin-web's Vite port) when unset.
TEST_BASE_URL=http://localhost:4175
# Staff credentials used to log into the admin panel.
# The suite skips (with a clear message) when either is empty.
TEST_STAFF_NAME=
TEST_STAFF_PASSWORD=

94
tests/README.md Normal file
View file

@ -0,0 +1,94 @@
# admin-web E2E tests (Playwright)
End-to-end tests that drive the admin panel (`apps/admin-web`) through real
browser workflows. Long, multi-step tasks run sequentially in a **single
browser instance** (one worker, one page), mirroring how an admin works.
## Setup
1. Install Playwright + the browser (one time, from the repo root):
```bash
npm i -D @playwright/test
npx playwright install chromium
```
2. Start the admin panel and note its URL (defaults to `http://localhost:4175`):
```bash
npm run admin-web:dev
```
3. Configure credentials — copy the template and fill it in:
```bash
cp tests/.env.test.example tests/.env.test
# then set TEST_STAFF_NAME / TEST_STAFF_PASSWORD (and TEST_BASE_URL if needed)
```
`tests/.env.test` is gitignored. When credentials are missing the whole
suite **skips** with a clear message instead of failing.
## Running
Run from the **repo root** (recommended — picks up `playwright.config.ts`):
```bash
bun run test:e2e # all specs (browser visible locally)
bun run test:e2e:headed # explicit headed run
bun run test:e2e:ui # Playwright UI mode (debug/inspect, time-travel)
bun run test:e2e tests/specs/product-lifecycle.spec.ts # one workflow
```
Run from inside `tests/` — you must point at the root config, otherwise
Playwright finds no config and skips the login setup:
```bash
cd tests
bun run --cwd .. test:e2e # easiest
bunx playwright test -c ../playwright.config.ts
bunx playwright test -c ../playwright.config.ts --headed
bunx playwright show-report ../playwright-report
```
> ⚠️ Don't run a bare `bunx playwright test` from `tests/` — it runs without the
> config (no auth setup, no `baseURL`). Always pass `-c ../playwright.config.ts`.
The browser is **visible by default** locally (headed). It only runs headless in
CI, or when you opt in explicitly:
```bash
HEADED=0 bun run test:e2e # force headless locally
HEADED=1 bun run test:e2e # force headed
bunx playwright test -c ../playwright.config.ts --headed --slow-mo=500 # watch slowly
```
Point the tests at a different UI URL (e.g. a hosted environment):
```bash
TEST_BASE_URL=https://admin.example.com bun run test:e2e
```
## How it works
- `auth.setup.ts` logs in once (`data-testid` login fields) and saves the
session to `tests/.auth/staff.json`. The `chromium` project reuses that
`storageState`, so specs start already authenticated.
- `tests/helpers/admin-app.ts` is a small page object with the reusable
actions (add/update product, create slot, suspend SKU, dialog handling).
- Specs live in `tests/specs/`. `product-lifecycle.spec.ts` is the long
single-instance workflow: **create product → update it → create a slot for it
→ suspend it**, plus a persistence re-check.
## ⚠️ Warning
These tests **create real catalog records** (products, slots) on whatever
backend the running admin-web instance points at. Use a non-production backend
when running them.
## Adding more workflows
Add a sibling spec under `tests/specs/` (matching `*.spec.ts`) and reuse the
helpers in `tests/helpers/admin-app.ts`. Prefer `data-testid` selectors for new
UI; the admin-web components expose test ids where placeholder/role locators
would be brittle.

20
tests/auth.setup.ts Normal file
View file

@ -0,0 +1,20 @@
import { test as setup, expect } from '@playwright/test'
import { CREDENTIALS_HINT, STAFF_NAME, STAFF_PASSWORD, hasStaffCredentials, login } from './helpers/admin-app'
const AUTH_STATE_PATH = 'tests/.auth/staff.json'
/**
* Logs into the admin panel once and persists the session (JWT lives in
* localStorage) so every spec runs already authenticated.
*/
setup('authenticate staff user', async ({ page }) => {
if (!hasStaffCredentials()) {
setup.skip(true, CREDENTIALS_HINT)
}
await login(page, STAFF_NAME, STAFF_PASSWORD)
// Sidebar entry (exact) — the dashboard body also has "Products …" tiles.
await expect(page.getByRole('button', { name: 'Products', exact: true })).toBeVisible()
await page.context().storageState({ path: AUTH_STATE_PATH })
})

295
tests/helpers/admin-app.ts Normal file
View file

@ -0,0 +1,295 @@
import { expect, type Locator, type Page } from '@playwright/test'
import { loadTestEnv } from './env'
// Populate process.env from tests/.env.test as soon as this module loads, so
// specs can branch on credentials at module scope.
loadTestEnv()
export const STAFF_NAME = process.env.TEST_STAFF_NAME ?? ''
export const STAFF_PASSWORD = process.env.TEST_STAFF_PASSWORD ?? ''
export const hasStaffCredentials = (): boolean =>
Boolean(process.env.TEST_STAFF_NAME && process.env.TEST_STAFF_PASSWORD)
export const CREDENTIALS_HINT =
'Set TEST_STAFF_NAME and TEST_STAFF_PASSWORD in tests/.env.test (see tests/.env.test.example)'
/** Collected `window.alert`/`confirm` messages, auto-accepted as they appear. */
export type DialogLog = string[]
/**
* Auto-accept every dialog (the admin forms confirm success via window.alert)
* and record the messages so tests can assert on them.
*/
export function trackDialogs(page: Page): DialogLog {
const messages: DialogLog = []
page.on('dialog', async (dialog) => {
messages.push(dialog.message())
await dialog.accept()
})
return messages
}
/** Wait until a recorded dialog message contains `substring`. */
export async function expectDialog(messages: DialogLog, substring: string): Promise<void> {
await expect
.poll(() => messages.some((message) => message.includes(substring)), {
message: `Expected a dialog containing "${substring}". Recorded: ${JSON.stringify(messages)}`,
})
.toBe(true)
}
// ---------------------------------------------------------------------------
// Hydration-safe interactions
// ---------------------------------------------------------------------------
//
// These pages are SSR'd and React-controlled. Interacting before hydration
// completes silently fails: a `fill()` sets the DOM value and its assertion
// passes (the DOM really does hold the value), but React — not yet attached —
// never records it, and hydration then resets the controlled input to its
// empty state. So we must WAIT FOR HYDRATION, not just for the value.
//
// React attaches `__reactFiber$…` / `__reactProps$…` markers to elements when it
// adopts them during hydration — the reliable, framework-level signal.
export async function waitForHydration(page: Page, selector: string): Promise<void> {
await page.waitForFunction(
(sel) => {
const el = document.querySelector(sel)
if (!el) return false
return Object.keys(el).some(
(key) => key.startsWith('__reactFiber$') || key.startsWith('__reactProps$'),
)
},
selector,
{ timeout: 20_000 },
)
}
/** Wait for the app container to be hydrated (for pages with no stable test id yet). */
export async function waitForAppHydration(page: Page): Promise<void> {
await page.waitForFunction(
() => {
const el = document.querySelector('#app') || document.body
return Object.keys(el).some(
(key) => key.startsWith('__reactContainer$') || key.startsWith('__reactFiber$'),
)
},
undefined,
{ timeout: 20_000 },
)
}
export async function fillStable(locator: Locator, value: string): Promise<void> {
await expect(async () => {
await locator.fill(value)
await expect(locator).toHaveValue(value)
}).toPass({ timeout: 15_000 })
}
export async function selectStable(locator: Locator, value: string): Promise<void> {
await expect(async () => {
await locator.selectOption(value)
await expect(locator).toHaveValue(value)
}).toPass({ timeout: 15_000 })
}
/**
* Click a control that should navigate, retrying until the URL matches.
* Guards against clicks that land before the element's handler is hydrated
* (which are silently swallowed rather than erroring).
*/
export async function clickUntilUrl(page: Page, locator: Locator, url: RegExp): Promise<void> {
await expect(async () => {
await locator.click()
await page.waitForURL(url, { timeout: 3_000 })
}).toPass({ timeout: 20_000 })
}
/**
* Like {@link clickUntilUrl} but dispatches the click straight at the element.
* Needed for fixed-position controls the TanStack devtools overlay sits on top
* of (e.g. the bottom-right slots FAB) a normal click would be intercepted.
*/
export async function dispatchClickUntilUrl(page: Page, locator: Locator, url: RegExp): Promise<void> {
await expect(async () => {
await locator.dispatchEvent('click')
await page.waitForURL(url, { timeout: 3_000 })
}).toPass({ timeout: 20_000 })
}
// ---------------------------------------------------------------------------
// Auth
// ---------------------------------------------------------------------------
export async function login(page: Page, name: string, password: string): Promise<void> {
await page.goto('/login')
await waitForHydration(page, '[data-testid="login-button"]')
await fillStable(page.getByTestId('login-name-input'), name)
await fillStable(page.getByTestId('login-password-input'), password)
await page.getByTestId('login-button').click()
await expect(page).toHaveURL(/\/dashboard/)
}
// ---------------------------------------------------------------------------
// Products
// ---------------------------------------------------------------------------
export interface NewProductInput {
name: string
price: number
marketPrice?: number
quantity: string
}
export async function openAddProduct(page: Page): Promise<void> {
await page.goto('/dashboard/products')
await waitForAppHydration(page)
await clickUntilUrl(
page,
page.getByRole('button', { name: 'Add Product' }),
/\/dashboard\/products\/new/,
)
await waitForHydration(page, '[data-testid="product-submit-button"]')
await expect(page.getByPlaceholder('Enter product name')).toBeVisible()
}
export async function fillNewProduct(
page: Page,
{ name, price, marketPrice, quantity }: NewProductInput,
): Promise<void> {
await fillStable(page.getByPlaceholder('Enter product name'), name)
// Store is a native <select> populated from an async query; option 0 is the
// disabled placeholder. Wait for the real options before choosing.
const storeSelect = page.getByTestId('product-store-select').locator('select')
await expect
.poll(async () => storeSelect.locator('option').count(), { timeout: 15_000 })
.toBeGreaterThan(1)
const firstStoreValue = await storeSelect.locator('option').nth(1).getAttribute('value')
await selectStable(storeSelect, firstStoreValue ?? '1')
// The mandatory `quantity` feature is auto-added as the first variant's first attribute.
await fillStable(page.getByPlaceholder('e.g. 0.5 kg').first(), quantity)
await fillStable(page.getByPlaceholder('Selling price').first(), String(price))
if (marketPrice != null) {
await fillStable(page.getByPlaceholder('MRP').first(), String(marketPrice))
}
}
export async function submitProductForm(page: Page): Promise<void> {
// The submit runs validation then calls the mutation; retry until the page
// actually leaves the form (mutation succeeded) — guards hydration/timing.
await page.getByTestId('product-submit-button').click()
}
export async function gotoProductsAndSearch(page: Page, name: string): Promise<void> {
await page.goto('/dashboard/products')
await waitForAppHydration(page)
await expect(page.getByRole('button', { name: 'Add Product' })).toBeVisible()
// Controlled SearchBar — filtering happens immediately on input.
await fillStable(page.getByPlaceholder('Search products...'), name)
}
export async function openProductEdit(page: Page, name: string): Promise<void> {
await gotoProductsAndSearch(page, name)
await clickUntilUrl(
page,
page.getByRole('button', { name: 'Edit' }).first(),
/\/dashboard\/products\/edit/,
)
await waitForHydration(page, '[data-testid="product-submit-button"]')
await expect(page.getByPlaceholder('Enter product name')).toBeVisible()
}
export async function setProductName(page: Page, name: string): Promise<void> {
await fillStable(page.getByPlaceholder('Enter product name'), name)
}
export async function setProductPrice(page: Page, price: number): Promise<void> {
await fillStable(page.getByPlaceholder('Selling price').first(), String(price))
}
export async function toggleSuspend(page: Page): Promise<void> {
await page.getByTestId('variant-suspend-row-0').locator('button').first().click()
}
export async function isSuspendChecked(page: Page): Promise<boolean> {
return page.getByTestId('variant-suspend-row-0').locator('svg').isVisible()
}
// ---------------------------------------------------------------------------
// Slots
// ---------------------------------------------------------------------------
export interface SlotDateTime {
date: string // YYYY-MM-DD
time: string // HH:MM
}
/** Build future delivery/freeze datetimes (freeze before delivery, as the form requires). */
export function futureSlotTimes(): { delivery: SlotDateTime; freeze: SlotDateTime } {
const formatDate = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
const formatTime = (d: Date) =>
`${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
const now = Date.now()
const delivery = new Date(now + 2 * 24 * 60 * 60 * 1000)
delivery.setHours(18, 0, 0, 0)
const freeze = new Date(now + 1 * 24 * 60 * 60 * 1000)
freeze.setHours(12, 0, 0, 0)
return {
delivery: { date: formatDate(delivery), time: formatTime(delivery) },
freeze: { date: formatDate(freeze), time: formatTime(freeze) },
}
}
export async function openAddSlot(page: Page): Promise<void> {
await page.goto('/dashboard/slots')
await waitForAppHydration(page)
// The FAB is bottom-right, under the always-mounted devtools overlay.
await dispatchClickUntilUrl(
page,
page.getByTestId('add-slot-fab'),
/\/dashboard\/slots\/new/,
)
await waitForHydration(page, '[data-testid="create-slot-button"]')
await expect(page.getByTestId('slot-delivery-datetime')).toBeVisible()
}
export async function fillSlotDatetimes(
page: Page,
delivery: SlotDateTime,
freeze: SlotDateTime,
): Promise<void> {
const deliveryBox = page.getByTestId('slot-delivery-datetime')
await fillStable(deliveryBox.locator('input[type="date"]'), delivery.date)
await fillStable(deliveryBox.locator('input[type="time"]'), delivery.time)
const freezeBox = page.getByTestId('slot-freeze-datetime')
await fillStable(freezeBox.locator('input[type="date"]'), freeze.date)
await fillStable(freezeBox.locator('input[type="time"]'), freeze.time)
}
export async function addProductToSlot(page: Page, productName: string): Promise<void> {
await page.getByTestId('slot-products-selector').locator('button').first().click()
const dialogSearch = page.getByPlaceholder('Search...')
await expect(dialogSearch).toBeVisible()
await fillStable(dialogSearch, productName)
await page
.getByTestId('multiselect-option')
.filter({ hasText: productName })
.first()
.click()
await page.getByRole('button', { name: 'Done' }).click()
}
export async function submitSlotForm(page: Page): Promise<void> {
await page.getByTestId('create-slot-button').click()
}

43
tests/helpers/env.ts Normal file
View file

@ -0,0 +1,43 @@
import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
/**
* Minimal dependency-free `.env` loader for the E2E suite.
*
* Reads `tests/.env.test` (or `./.env.test`) if present and populates
* `process.env` WITHOUT overriding values already provided by the real
* environment so `TEST_BASE_URL=... npx playwright test` still wins.
*/
export function loadTestEnv(): void {
const candidates = [
resolve(process.cwd(), 'tests/.env.test'),
resolve(process.cwd(), '.env.test'),
]
const file = candidates.find((candidate) => existsSync(candidate))
if (!file) return
const content = readFileSync(file, 'utf8')
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || line.startsWith('#')) continue
const separatorIndex = line.indexOf('=')
if (separatorIndex === -1) continue
const key = line.slice(0, separatorIndex).trim()
if (!key) continue
let value = line.slice(separatorIndex + 1).trim()
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
if (process.env[key] === undefined) {
process.env[key] = value
}
}
}

View file

@ -0,0 +1,98 @@
import { test, expect } from '@playwright/test'
import {
CREDENTIALS_HINT,
hasStaffCredentials,
trackDialogs,
expectDialog,
openAddProduct,
fillNewProduct,
submitProductForm,
gotoProductsAndSearch,
openProductEdit,
setProductName,
setProductPrice,
toggleSuspend,
isSuspendChecked,
openAddSlot,
fillSlotDatetimes,
addProductToSlot,
submitSlotForm,
futureSlotTimes,
waitForAppHydration,
} from '../helpers/admin-app'
test.skip(!hasStaffCredentials(), CREDENTIALS_HINT)
/**
* Long, single-instance workflow: add a product, update it, create a delivery
* slot for it, then suspend it all in one browser page/context, mirroring how
* an admin actually works through the panel.
*
* Runs as one test with named steps so the whole chain shares page state.
*/
test('product lifecycle: create → update → slot → suspend', async ({ page }) => {
const productName = `E2E Product ${Date.now()}`
const updatedName = `${productName} Updated`
const dialogs = trackDialogs(page)
await test.step('dashboard is available (authenticated session)', async () => {
await page.goto('/dashboard')
await waitForAppHydration(page)
await expect(page.getByRole('button', { name: 'Products', exact: true })).toBeVisible()
})
await test.step('add a new product', async () => {
await openAddProduct(page)
await fillNewProduct(page, {
name: productName,
price: 250,
marketPrice: 300,
quantity: '1 kg',
})
await submitProductForm(page)
await expectDialog(dialogs, 'Product created successfully!')
})
await test.step('the product appears in the products list', async () => {
await gotoProductsAndSearch(page, productName)
await expect(page.getByText(productName).first()).toBeVisible()
})
await test.step('update the product name and price', async () => {
await openProductEdit(page, productName)
await setProductName(page, updatedName)
await setProductPrice(page, 275)
await submitProductForm(page)
await expectDialog(dialogs, 'Product updated successfully!')
})
await test.step('create a delivery slot for the product', async () => {
const { delivery, freeze } = futureSlotTimes()
await openAddSlot(page)
await fillSlotDatetimes(page, delivery, freeze)
await addProductToSlot(page, updatedName)
await submitSlotForm(page)
await expectDialog(dialogs, 'Slot created successfully!')
})
await test.step('the slot card lists the product', async () => {
// submitSlotForm navigates back to /dashboard/slots
await expect(page).toHaveURL(/\/dashboard\/slots/)
await expect(page.getByText(updatedName).first()).toBeVisible()
})
await test.step('suspend the product', async () => {
await openProductEdit(page, updatedName)
expect(await isSuspendChecked(page)).toBe(false)
await toggleSuspend(page)
// The toggle must take effect in the form before we save.
await expect.poll(() => isSuspendChecked(page), { timeout: 5_000 }).toBe(true)
await submitProductForm(page)
await expectDialog(dialogs, 'Product updated successfully!')
})
await test.step('suspension persists across reloads', async () => {
await openProductEdit(page, updatedName)
expect(await isSuspendChecked(page)).toBe(true)
})
})

23
tests/specs/smoke.spec.ts Normal file
View file

@ -0,0 +1,23 @@
import { test, expect } from '@playwright/test'
import { CREDENTIALS_HINT, hasStaffCredentials, waitForAppHydration } from '../helpers/admin-app'
// The whole suite requires a staff session; skip cleanly when creds are absent.
test.skip(!hasStaffCredentials(), CREDENTIALS_HINT)
test.describe('admin-web smoke', () => {
test('dashboard shell renders with sidebar navigation', async ({ page }) => {
await page.goto('/dashboard')
await waitForAppHydration(page)
await expect(page.getByRole('button', { name: 'Dashboard', exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: 'Products', exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: 'Slots', exact: true })).toBeVisible()
})
test('products list renders with the add-product action', async ({ page }) => {
await page.goto('/dashboard/products')
await waitForAppHydration(page)
await expect(page.getByRole('button', { name: 'Add Product' })).toBeVisible()
})
})

15
tests/tsconfig.json Normal file
View file

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"types": ["node"]
},
"include": ["./**/*.ts", "../playwright.config.ts"]
}