login changes
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
65
src/features/onboarding/shouldOfferSetup.test.ts
Normal file
65
src/features/onboarding/shouldOfferSetup.test.ts
Normal 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);
|
||||
});
|
||||
41
src/features/onboarding/shouldOfferSetup.ts
Normal file
41
src/features/onboarding/shouldOfferSetup.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user