login changes

This commit is contained in:
2026-09-02 11:46:41 +05:30
parent 7df8a49e5f
commit 52be0e30ac
7 changed files with 263 additions and 49 deletions

View File

@@ -2,8 +2,9 @@ import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '@/auth/AuthContext';
import { useBranchScope } from '@/features/store-admin/BranchScope';
import { useOwnTenant } from '@/queries/hooks';
import { readOnboarding } from './onboardingState';
import { useLocationProducts } from '@/queries/hooks';
import { readOnboarding, writeOnboarding } from './onboardingState';
import { shouldOfferSetup } from './shouldOfferSetup';
/**
* Sends a first-time merchant to setup instead of the dashboard.
@@ -11,41 +12,63 @@ import { readOnboarding } from './onboardingState';
* Renders nothing — it is a decision, not a screen, and it makes that decision
* once. Three rules keep it from becoming a trap:
*
* - **Only once.** After the first redirect, `started` is set and nobody is
* ever sent again. Somebody who leaves setup has left it.
* - **Only once.** The redirect records `started`, so nobody is ever sent
* twice. Somebody who leaves setup has left it.
* - **Only from the landing page.** A merchant who deep-links to Sales, or is
* already reading Inventory, is not hauled away from what they opened.
* - **Only when there is something to do.** A shop whose profile is already
* filled in is not a first-time user, however new the account.
* - **Only when there is something to do.** A shop already trading is not a
* first-time user, however new the account.
*
* The redirect waits for the shop record. Judging "incomplete" while the query
* is still in flight would redirect every merchant on every first paint.
* ── Why "something to do" is measured in products ───────────────────────────
*
* This used to ask whether the shop had a name, an address and a phone number,
* and so it never fired once — measured 2026-09-02 on tenants 1150, 1148 and
* 1146, every one of them newly created and every one of them already carrying
* all three. It could not have been otherwise: `createtenantuser` COLLECTS
* exactly those fields, so Nearle Admin fills them in at the moment the tenant
* is created, before the merchant has ever signed in. The test was reading the
* onboarding form's own output and concluding the merchant had been onboarded.
*
* Products are the honest question. A shop with none cannot sell anything, so
* it is a shop that still needs setup no matter how complete its profile looks;
* a shop with stock on the shelf is working and must not be interrupted.
*
* The product list is awaited before deciding. Judging "incomplete" while it is
* in flight would redirect every merchant on their first paint — including the
* established ones this is written to leave alone.
*/
export function OnboardingGate() {
const navigate = useNavigate();
const { pathname } = useLocation();
const { user } = useAuth();
const { tenantid } = useBranchScope();
const shop = useOwnTenant(tenantid || undefined);
// Tenant-wide: a merchant with stock in any branch is trading. `allBranches`
// because no single outlet is selected this early.
const products = useLocationProducts(tenantid || undefined, undefined, 0, { allBranches: true });
useEffect(() => {
if (!user?.userid || !tenantid || shop.isLoading || !shop.data) return;
if (pathname !== '/admin/console') return;
if (!user?.userid || !tenantid) return;
const state = readOnboarding(user.userid, tenantid);
if (state.started || state.finished) return;
// "Set up" means the shop can be found and described. Products and stock
// come later and are not a reason to interrupt somebody.
const record = shop.data as unknown as Record<string, unknown>;
const hasBasics =
String(record['tenantname'] ?? '').trim() !== '' &&
String(record['address'] ?? '').trim() !== '' &&
String(record['primarycontact'] ?? '').trim() !== '';
if (hasBasics) return;
const isReady = !products.isLoading && !!products.data;
if (!shouldOfferSetup({ pathname, isReady, productCount: products.data?.length ?? 0, state })) {
return;
}
// Recorded BEFORE navigating, so the decision cannot be taken twice. Without
// this the merchant is trapped: the welcome screen offers no way out, so
// clicking Console to leave setup lands back on `/admin/console` and is
// redirected here again, forever.
writeOnboarding(user.userid, tenantid, { ...state, started: true });
navigate('/admin/onboarding', { replace: true });
}, [user?.userid, tenantid, shop.isLoading, shop.data, pathname, navigate]);
}, [
user?.userid,
tenantid,
products.isLoading,
products.data,
pathname,
navigate,
]);
return null;
}

View File

@@ -0,0 +1,65 @@
import { strict as assert } from 'node:assert';
import { test } from 'node:test';
import { EMPTY_DELIVERY, EMPTY_STORE, type OnboardingState } from './onboardingState';
import { shouldOfferSetup } from './shouldOfferSetup';
const FRESH: OnboardingState = {
step: 'welcome',
completed: [],
skipped: [],
finished: false,
started: false,
store: EMPTY_STORE,
delivery: EMPTY_DELIVERY,
};
const at = (over: Partial<Parameters<typeof shouldOfferSetup>[0]> = {}) =>
shouldOfferSetup({
pathname: '/admin/console',
isReady: true,
productCount: 0,
state: FRESH,
...over,
});
test('a brand-new store admin is sent to setup on first login', () => {
// The reported bug, and it survived a day of testing because the old rule
// asked whether the shop had a name, address and phone — which
// `createtenantuser` fills in at creation, so it was true of every new
// tenant and the redirect never fired once.
assert.equal(at(), true);
});
test('a shop whose profile Nearle Admin already filled in is STILL offered setup', () => {
// Tenants 1150, 1148 and 1146 were all newly created and all carried a name,
// an address and a phone number. A complete profile is evidence about who
// created the tenant, not about whether the merchant has been onboarded.
assert.equal(at({ productCount: 0 }), true);
});
test('a trading shop is never interrupted', () => {
// R mart, 24 products. Stock on the shelf means somebody is working.
assert.equal(at({ productCount: 24 }), false);
});
test('the offer is made once, so declining it is possible', () => {
// Without this the merchant is trapped: the welcome screen offers no way out,
// so clicking Console to leave lands on `/admin/console` and bounces back.
assert.equal(at({ state: { ...FRESH, started: true } }), false);
});
test('somebody who finished setup is not sent through it again', () => {
assert.equal(at({ state: { ...FRESH, finished: true } }), false);
});
test('nobody is hauled away from the page they opened', () => {
for (const pathname of ['/admin/sales', '/admin/inventory', '/admin/reports']) {
assert.equal(at({ pathname }), false, pathname);
}
});
test('no decision is taken while the shop and its products are still loading', () => {
// An empty product list mid-flight looks exactly like a shop with no stock,
// which would redirect every established merchant on their first paint.
assert.equal(at({ isReady: false }), false);
});

View File

@@ -0,0 +1,41 @@
import type { OnboardingState } from './onboardingState';
/**
* Whether to send this merchant to setup rather than the dashboard.
*
* Pulled out of `OnboardingGate` so it can be tested without a router, a query
* client or a DOM — the previous version's bug was a single wrong boolean, and
* a wrong boolean inside a `useEffect` is invisible to every test in this repo.
*/
export interface SetupDecision {
/** Where the merchant is. The offer is only made from the landing page. */
pathname: string;
/** False while either query is still in flight — decide on facts, not blanks. */
isReady: boolean;
/** How many products the tenant has, across every branch. */
productCount: number;
state: OnboardingState;
}
export function shouldOfferSetup({
pathname,
isReady,
productCount,
state,
}: SetupDecision): boolean {
// A merchant who opened Sales or Inventory directly is working. Only somebody
// who arrived at the dashboard is between things.
if (pathname !== '/admin/console') return false;
// Redirecting on incomplete data would catch established shops on first paint.
if (!isReady) return false;
// Offered once. `started` is recorded by the redirect itself, not by the
// merchant accepting it, so declining is possible at all.
if (state.started || state.finished) return false;
// The honest test of a first-time user. NOT the shop's name, address and
// phone: `createtenantuser` collects those, so Nearle Admin fills them in
// before the merchant ever signs in and they are true of every new tenant.
return productCount === 0;
}