enh
This commit is contained in:
parent
cc17653402
commit
f8e7eec754
31 changed files with 944 additions and 76 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
10
.gitignore
vendored
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ export function MultiSelect({
|
|||
return (
|
||||
<div
|
||||
key={String(option.value)}
|
||||
data-testid="multiselect-option"
|
||||
onClick={() => {
|
||||
if (!option.disabled) toggle(option.value)
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
88
bun.lock
88
bun.lock
|
|
@ -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=="],
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
44
playwright.config.ts
Normal 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
11
tests/.env.test.example
Normal 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
94
tests/README.md
Normal 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
20
tests/auth.setup.ts
Normal 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
295
tests/helpers/admin-app.ts
Normal 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
43
tests/helpers/env.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
98
tests/specs/product-lifecycle.spec.ts
Normal file
98
tests/specs/product-lifecycle.spec.ts
Normal 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
23
tests/specs/smoke.spec.ts
Normal 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
15
tests/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue