Compare commits

...

3 Commits

Author SHA1 Message Date
45a5e4a36f onboarding with delivery slot 2026-10-06 17:37:44 +05:30
db5dbd8b31 nutrition for platform 2026-09-30 13:43:17 +05:30
dd718d4e81 health score: move the non-food early return past the hook
`if (!isEdible(category)) return null` sat on the line above `useQuery`, which
made the hook conditional. React counts hooks per component instance, so one
drawer reused for two products -- a soap and then a biscuit, which is ordinary
browsing in the global catalogue -- went 0 hooks then 1 and threw "rendered more
hooks than during the previous render".

That does not degrade the panel, it unmounts the tree: the health score then
disappears for EVERY product until the page is reloaded, which reads exactly
like the feature having been switched off.

The guard now sits after the hook, and `enabled` carries the intent the early
return was protecting -- a non-food product still asks the service nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 12:23:50 +05:30
5 changed files with 373 additions and 5 deletions

101
src/api/deliverySlots.ts Normal file
View File

@@ -0,0 +1,101 @@
/**
* When a branch delivers.
*
* A branch offers at most three windows a day — morning, afternoon, evening —
* and a shopper picks one at checkout. The window is a PREFERENCE, not a
* promise: every order is accepted and no window ever fills up.
*
* ── A branch with no windows is not broken ──────────────────────────────────
*
* Every tenant trading today has none, and all of them keep taking orders.
* An empty list means "order without a window", never "this shop is closed".
* Nothing here may treat it as an error state.
*
* ── The console edits; it does not decide what is open ──────────────────────
*
* Whether a window is still open today is a clock comparison, and the server
* owns it — see deliverySlotService.go. This module reads what a branch has
* configured and writes it back. The filtered, dated list a shopper sees is an
* app concern and is not fetched here.
*/
import { api, WEB } from './client';
/** The three, in the order a shopper reads them. */
export const SLOT_KEYS = ['morning', 'afternoon', 'evening'] as const;
export type SlotKey = (typeof SLOT_KEYS)[number];
export interface DeliverySlot {
deliveryslotid?: number;
tenantid?: number;
locationid?: number;
slotkey: SlotKey;
/** What the shopper reads. Blank falls back to the capitalised key. */
name: string;
/** "HH:MM", 24-hour, in the shop's own local time. */
starttime: string;
/**
* Also the cut-off. A window takes orders right up to the moment it ends —
* there is deliberately no separate cut-off to configure.
*/
endtime: string;
status: 'active' | 'inactive';
}
/**
* What a branch starts with when nobody has set anything.
*
* Seeded rather than blank so onboarding asks a shopkeeper to CONFIRM rather
* than to invent: three empty time fields is a form most people abandon, and a
* tenant that abandons it has a shop that cannot offer windows at all.
*/
export const DEFAULT_SLOTS: DeliverySlot[] = [
{ slotkey: 'morning', name: 'Morning', starttime: '08:00', endtime: '10:00', status: 'active' },
{ slotkey: 'afternoon', name: 'Afternoon', starttime: '12:00', endtime: '15:00', status: 'active' },
{ slotkey: 'evening', name: 'Evening', starttime: '17:00', endtime: '20:00', status: 'active' },
];
export const deliverySlotsApi = {
/** Everything this branch has configured, active or not. */
list: (tenantid: number, locationid: number) =>
api.get<{ details: DeliverySlot[] }>(`${WEB}/deliveryslots`, { tenantid, locationid }),
/**
* All three together, never one at a time.
*
* They are edited as a set on one screen, and sending them together is what
* lets the server reject the whole edit when one row is wrong instead of
* applying half of it — a shop with two new windows and one old one, and
* nothing on screen saying which took, is worse than a shop with none.
*/
save: (tenantid: number, locationid: number, slots: DeliverySlot[]) =>
api.put<unknown>(`${WEB}/deliveryslots`, { tenantid, locationid, slots }),
};
/**
* Is this set fit to send?
*
* Mirrors the server's rules so the shopkeeper hears about a mistake while
* their hands are still on it, rather than as a 409 after pressing save. The
* server re-checks all of it — this is courtesy, not security.
*/
export function slotProblems(slots: DeliverySlot[]): string[] {
const problems: string[] = [];
for (const slot of slots) {
const label = slot.name.trim() || slot.slotkey;
if (!/^\d{2}:\d{2}$/.test(slot.starttime) || !/^\d{2}:\d{2}$/.test(slot.endtime)) {
problems.push(`${label} needs a start and end time.`);
continue;
}
// An end at or before its start never passes the server's "still running"
// test, so the window would simply never appear to a shopper, with nothing
// saying why.
if (slot.endtime <= slot.starttime) {
problems.push(`${label} ends at or before it starts (${slot.starttime}–${slot.endtime}).`);
}
}
return problems;
}

View File

@@ -26,7 +26,29 @@
* correct; the data has to catch up. * correct; the data has to catch up.
*/ */
const NUTRITION_BASE = 'https://mcp.nearle.ai.in/api'; /*
* Through this console's OWN origin, not the service's.
*
* `/ingest` is proxied to mcp.nearle.ai.in by nginx in production and by vite in
* development, so this is same-origin either way and CORS does not enter into
* it.
*
* It used to be the absolute `https://mcp.nearle.ai.in/api`, and that works only
* where the service's allowlist names the calling origin. Measured 30 Sep 2026:
* a request carrying `Origin: https://app.nearledaily.com` comes back with
* `Access-Control-Allow-Origin` and one carrying
* `Origin: https://platform.nearledaily.com` comes back without it. So the
* browser dropped every response here, `forProduct` returned null, and the panel
* showed "No health score available for this product yet" for every product —
* the same product that renders fine in the merchant console.
*
* `vite.config.ts` called this exactly: "it works on every developer machine and
* fails the moment it is deployed, which is the worst order to find out."
*
* The proxy also attaches the ingest API key, which this endpoint does not need
* and does not mind.
*/
const NUTRITION_BASE = '/ingest/api';
/** How confident the service is that it matched the right source record. */ /** How confident the service is that it matched the right source record. */
export const LOW_CONFIDENCE = 0.7; export const LOW_CONFIDENCE = 0.7;

View File

@@ -1,6 +1,8 @@
import { useState, type FormEvent } from 'react'; import { useState, type FormEvent } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { DEFAULT_SLOTS, deliverySlotsApi, slotProblems, type DeliverySlot } from '@/api/deliverySlots';
import { DeliverySlotsEditor } from '@/features/store-admin/DeliverySlotsEditor';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card'; import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
@@ -61,6 +63,16 @@ const EMPTY: FormState = {
export function OnboardTenantPage() { export function OnboardTenantPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
/*
The primary branch's three delivery windows.
Onboarding creates a tenant AND its first branch, so this is the first and
most natural moment to ask when that branch delivers. Seeded with defaults
so the answer is a confirmation rather than an invention.
*/
const [slots, setSlots] = useState<DeliverySlot[]>(DEFAULT_SLOTS);
const [slotWarning, setSlotWarning] = useState<string | null>(null);
const [form, setForm] = useState<FormState>(EMPTY); const [form, setForm] = useState<FormState>(EMPTY);
const categories = useAppCategories(); const categories = useAppCategories();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -71,8 +83,29 @@ export function OnboardTenantPage() {
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (body: CreateTenantRequest) => tenantsApi.createTenant(body), mutationFn: (body: CreateTenantRequest) => tenantsApi.createTenant(body),
onSuccess: async () => { onSuccess: async (result) => {
await queryClient.invalidateQueries({ queryKey: queryKeys.tenants.all }); await queryClient.invalidateQueries({ queryKey: queryKeys.tenants.all });
/*
The windows, against the PRIMARY BRANCH this call just created.
Not fatal if it fails. The tenant and its branch exist and can trade,
and a branch with no windows simply takes orders without one — the state
every shop on the platform is in today. Warning beats rolling back a
successful onboarding, and the merchant can set these from their own
store profile.
*/
const tenantId = Number(result?.tenant?.tenantid ?? 0);
const locationId = Number(result?.tenant?.locationid ?? 0);
if (tenantId > 0 && locationId > 0 && slotProblems(slots).length === 0) {
try {
await deliverySlotsApi.save(tenantId, locationId, slots);
} catch (cause) {
setSlotWarning(
`The store was created, but its delivery windows were not saved (${errorMessage(cause)}). They can be set from the store profile.`,
);
}
}
}, },
onError: (cause) => setError(errorMessage(cause)), onError: (cause) => setError(errorMessage(cause)),
}); });
@@ -408,6 +441,43 @@ export function OnboardTenantPage() {
</HStack> </HStack>
) : null} ) : null}
{/* When this store delivers.
Asked at onboarding because this flow creates the tenant's first
branch, and the windows belong to a branch. A store onboarded
without them trades exactly as every store does today — customers
order without choosing a time — so this is an opportunity, not a
requirement. */}
<Card padding={0}>
<VStack gap={2} padding={3}>
<Text type="large" weight="semibold">
Delivery windows
</Text>
<DeliverySlotsEditor
slots={slots}
onChange={setSlots}
isDisabled={mutation.isPending}
intro="Confirm with the store when they deliver. Customers pick one of these when ordering, and a window takes orders right up until it ends. The store can change them later themselves."
/>
</VStack>
</Card>
{slotWarning ? (
<HStack
align="center"
gap={1}
padding={1.5}
style={{
background: 'var(--color-warn-muted, #fdf6e3)',
borderRadius: 8,
color: 'var(--color-warn, #8a6d1f)',
fontSize: 12.5,
}}
>
{slotWarning}
</HStack>
) : null}
{/* Sticky Bottom Actions */} {/* Sticky Bottom Actions */}
<div <div
style={{ style={{

View File

@@ -0,0 +1,161 @@
import { Switch } from '@astryxdesign/core/Switch';
import { DEFAULT_SLOTS, slotProblems, type DeliverySlot, type SlotKey } from '@/api/deliverySlots';
import { Note } from './drawerKit';
/**
* The three delivery windows a branch offers, as an editable block.
*
* Used in two places that look the same to a shopkeeper and are quite different
* underneath: onboarding, where the windows are part of a form that creates a
* branch, and the shop profile, where they are saved on their own. This
* component owns neither the saving nor the fetching — it is handed a value and
* reports changes, so both callers can decide what "save" means for them.
*
* ── Three fixed rows, not a list ────────────────────────────────────────────
*
* There is no add or remove. A shop has a morning, an afternoon and an evening,
* and the app has exactly three places to show them. Building this as a generic
* schedule editor would offer a shopkeeper a fourth window that nothing can
* render, and the cost of that discovery lands on a customer who picked it.
*
* ── Why each row has an on/off ──────────────────────────────────────────────
*
* A shop that does not do evenings needs a way to say so that is not deleting
* the row. Orders already placed against a window still have to resolve to
* something with a name, so a window is switched off and kept, never removed.
*/
export function DeliverySlotsEditor({
slots,
onChange,
isDisabled,
/** Shown above the rows. Onboarding and the profile screen say different things. */
intro,
}: {
slots: DeliverySlot[];
onChange: (slots: DeliverySlot[]) => void;
isDisabled?: boolean;
intro?: string;
}) {
const problems = slotProblems(slots);
function update(key: SlotKey, patch: Partial<DeliverySlot>) {
onChange(slots.map((slot) => (slot.slotkey === key ? { ...slot, ...patch } : slot)));
}
// Ordered by the constant, not by whatever order the server returned, so the
// rows do not reshuffle between a fresh branch and a saved one.
const ordered = DEFAULT_SLOTS.map(
(fallback) => slots.find((slot) => slot.slotkey === fallback.slotkey) ?? fallback,
);
return (
<div>
<Note>
{intro ??
'Customers choose one of these when they order. A window takes orders right up until it ends — after that it disappears for the day and only the later windows are offered.'}
</Note>
<div style={{ display: 'grid', gap: 2, marginTop: 12 }}>
{ordered.map((slot) => (
<SlotRow
key={slot.slotkey}
slot={slot}
isDisabled={Boolean(isDisabled)}
onChange={(patch) => update(slot.slotkey, patch)}
/>
))}
</div>
{/* Mirrors the server's own checks so a mistake is caught while the
shopkeeper's hands are still on it, rather than as a 409 after save. */}
{problems.length > 0 ? (
<ul
style={{
margin: '10px 0 0',
paddingLeft: 18,
fontSize: 11.5,
color: 'var(--color-error, #d64545)',
}}
>
{problems.map((problem) => (
<li key={problem}>{problem}</li>
))}
</ul>
) : null}
</div>
);
}
function SlotRow({
slot,
onChange,
isDisabled,
}: {
slot: DeliverySlot;
onChange: (patch: Partial<DeliverySlot>) => void;
isDisabled: boolean;
}) {
const isOff = slot.status !== 'active';
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '11px 0',
borderTop: '1px solid var(--color-line, #e0e4ea)',
// A window that is off stays legible but stops competing for attention.
opacity: isOff ? 0.55 : 1,
}}
>
<input
value={slot.name}
onChange={(event) => onChange({ name: event.target.value })}
placeholder={slot.slotkey}
disabled={isDisabled}
aria-label={`Name for the ${slot.slotkey} window`}
style={{ ...inputStyle, flex: 1, minWidth: 0 }}
/>
<input
type="time"
value={slot.starttime}
onChange={(event) => onChange({ starttime: event.target.value })}
disabled={isDisabled || isOff}
aria-label={`Start of the ${slot.slotkey} window`}
style={{ ...inputStyle, width: 104 }}
/>
<span style={{ color: 'var(--color-ink-4)', fontSize: 12 }}>to</span>
<input
type="time"
value={slot.endtime}
onChange={(event) => onChange({ endtime: event.target.value })}
disabled={isDisabled || isOff}
aria-label={`End of the ${slot.slotkey} window — also when it stops taking orders`}
style={{ ...inputStyle, width: 104 }}
/>
{/* Label hidden: the row's name field says which window this is, and
repeating "Offer the morning window" three times down a block of three
is the same sentence three times. */}
<Switch
label={`Offer the ${slot.slotkey} window`}
isLabelHidden
value={!isOff}
onChange={(on: boolean) => onChange({ status: on ? 'active' : 'inactive' })}
isDisabled={isDisabled}
size="sm"
/>
</div>
);
}
const inputStyle: React.CSSProperties = {
padding: '7px 9px',
fontSize: 13,
border: '1px solid var(--color-line, #d7dce5)',
borderRadius: 7,
background: 'var(--color-surface, #fff)',
color: 'var(--color-ink-1)',
};

View File

@@ -56,14 +56,25 @@ export function HealthScorePanel({
populated for both, so nothing in the response separates them. Our own populated for both, so nothing in the response separates them. Our own
category does, so the decision is made here until they publish theirs. category does, so the decision is made here until they publish theirs.
Before the network call, deliberately: there is nothing to ask about. Still no network call for one — see `enabled` below — but the EARLY RETURN
that used to sit here has moved past the hook.
It read `if (!isEdible(category)) return null` on the line above `useQuery`,
which made the hook conditional. React counts hooks per component instance, so
one drawer reused for two products — a soap and then a biscuit, which is
ordinary browsing in the global catalogue — went 0 hooks then 1, and React
threw "rendered more hooks than during the previous render". That does not
degrade the panel, it unmounts the tree: the health score then disappears for
EVERY product until the page is reloaded, which reads exactly like the feature
being switched off.
*/ */
if (!isEdible(category)) return null; const isFood = isEdible(category);
const query = useQuery({ const query = useQuery({
queryKey: ['nutrition', brand, imageId], queryKey: ['nutrition', brand, imageId],
queryFn: () => nutritionApi.forProduct(brand, imageId), queryFn: () => nutritionApi.forProduct(brand, imageId),
enabled: Boolean(brand && imageId), // Non-food asks nothing, which is what the early return was protecting.
enabled: Boolean(brand && imageId && isFood),
// Nutrition for a packaged product does not change during a trading day. // Nutrition for a packaged product does not change during a trading day.
staleTime: 60 * 60_000, staleTime: 60 * 60_000,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
@@ -72,6 +83,9 @@ export function HealthScorePanel({
retry: false, retry: false,
}); });
// Past the hook, so the count is the same on every render.
if (!isFood) return null;
/* /*
* No catalogue link, so there is nothing to look up. * No catalogue link, so there is nothing to look up.
* *