Skip to content

Commit 1c7b031

Browse files
feat(product): add first-run checklist for photo → lead → quote
Guide new dealers through the core MVP loop with a dismissible checklist. Point Render blueprint at main for post-merge deploys. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 9deffaa commit 1c7b031

10 files changed

Lines changed: 334 additions & 10 deletions

File tree

BACKLOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ See [`docs/PRODUCTION_READINESS.md`](docs/PRODUCTION_READINESS.md) for the full
4141
| P2-7 | Further extract canvas engine from `script.js` | Maintainability | Med | Med | Med |
4242
| P2-8 | Deduplicate client `palette.js` ↔ server `heuristic.js` | One ranking model | Low | Med | Low |
4343
| P2-9 | Neon/Fly backup restore drill documented + scheduled | Operator confidence | Med | Low | Low |
44-
| P2-10 | In-app first-run checklist (photo → lead → quote) | Reduce support | Med | Med | Low |
44+
| P2-10 | In-app first-run checklist (photo → lead → quote) | Reduce support | Med | Med | Low | **Done** |
4545
| P2-11 | OpenAPI `/api/docs` | Integrator DX | Med | Med | Low |
4646
| P2-12 | Remove or archive legacy Python `test-scripts/` | Less confusion vs `e2e/` | Low | Low | Low |
4747
| P2-13 | India DLT / MSG91 template compliance for SMS | Legal SMS in production | High | Med | Med |

CHANGELOG.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,20 @@
22

33
All notable changes to PaintCRM are documented here.
44

5-
## Unreleased — 2026-07-16
5+
## [1.2.0] — 2026-07-16
6+
7+
### Product
8+
- First-run checklist: photo → lead → quote (dismissible, local)
9+
- Smart palette picks for guests (heuristic); OpenAI optional when signed in + configured
10+
- Ledger reminder UI labels honest about WhatsApp / SMS / log-only channels
11+
- Forgot-password + reset flows on `/login`
612

713
### Security
814
- Production requires `ALLOWED_ORIGINS`; CORS no longer silently allows `*`
915
- Password reset: hashed single-use tokens, expiry, rate limits, generic responses, session revocation
1016
- Tenant defense-in-depth on lead fetch and quote convert updates
1117
- Reminder cron no longer counts WhatsApp click-to-chat as delivered
1218

13-
### Product
14-
- Smart palette picks work for guests (heuristic); OpenAI optional when signed in + configured
15-
- Ledger reminder UI labels honest about WhatsApp / SMS / log-only channels
16-
- Forgot-password + reset flows on `/login`
17-
1819
### Engineering
1920
- Frontend views: `customers`, `quotes`, `inventory`, `ledger`
2021
- Playwright E2E suite under `e2e/` wired into CI (fails build on regression)
@@ -23,5 +24,5 @@ All notable changes to PaintCRM are documented here.
2324
- Docs: tenancy model, security audit, production readiness
2425

2526
### Ops
26-
- Render blueprint branch policy documented
27+
- Render blueprint tracks `main` after merge
2728
- Env example documents SMTP, password reset, AI, MSG91, cron defaults

paint-preview-app/index.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ <h1>Upload a room. Test colors. Decide fast.</h1>
6868
</div>
6969
</section>
7070

71+
<section id="onboardingChecklist" class="onboarding-wrap hidden" aria-label="Getting started checklist"></section>
72+
7173
<section class="workspace-grid">
7274
<article class="canvas-panel">
7375
<header>

paint-preview-app/script.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { estimatePaint } from "./src/cost.js";
4040
import { generateLeadId, generateEventId } from "./src/ids.js";
4141
import { buildSmartSuggestions, roomMoodSummary } from "./src/palette.js";
4242
import { showTransientToast } from "./src/app/toast.js";
43+
import { createOnboardingChecklist } from "./src/app/onboardingChecklist.js";
4344
import { createLedgerView } from "./src/views/ledger.js";
4445
import { createQuotesView } from "./src/views/quotes.js";
4546
import { createInventoryView } from "./src/views/inventory.js";
@@ -1204,6 +1205,7 @@ function handleImageUpload(file) {
12041205
state.originalImage = img;
12051206
startPilotSession(); // Phase 3: begin a new pilot session
12061207
initializeShadesFromImage();
1208+
onboardingChecklist?.complete("photo");
12071209
updateRestoreDraftUI();
12081210
};
12091211
img.src = reader.result;
@@ -1406,6 +1408,7 @@ function captureLeadFromForm(e) {
14061408
syncLeadToServer(lead); // Phase 4: best-effort server sync
14071409
closeContactModal();
14081410

1411+
onboardingChecklist?.complete("lead");
14091412
showTransientToast(`Lead saved for ${name}. View in Leads inbox.`);
14101413
}
14111414

@@ -2074,6 +2077,14 @@ const quotesView = createQuotesView({
20742077
getCatalog: () => SHADE_CATALOG,
20752078
roomSqM: ROOM_SQ_M,
20762079
coverageSqMPerL: COVERAGE_SQ_M_PER_L,
2080+
onQuoteCreated: () => onboardingChecklist?.complete("quote"),
2081+
});
2082+
2083+
let onboardingChecklist = createOnboardingChecklist({
2084+
rootEl: document.getElementById("onboardingChecklist"),
2085+
getApiToken,
2086+
onOpenQuotes: () => quotesView.openQuotesModal(),
2087+
onOpenContact: () => openContactModal(),
20772088
});
20782089

20792090
const inventoryView = createInventoryView({
@@ -2613,6 +2624,7 @@ customersView.wireListeners();
26132624
quotesView.wireListeners();
26142625
inventoryView.wireListeners();
26152626
ledgerView.wireListeners();
2627+
onboardingChecklist?.render();
26162628

26172629
// Phase 3: leads modal tab buttons
26182630
const leadsTabBtn = document.getElementById("leadsTabBtn");
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// First-run checklist: photo → lead → quote. Pure state so Vitest can cover it
2+
// without DOM. Progress lives in localStorage until the dealer dismisses it.
3+
4+
export const ONBOARDING_STORAGE_KEY = 'paintcrm_onboarding_v1';
5+
6+
export const ONBOARDING_STEPS = [
7+
{
8+
id: 'photo',
9+
label: 'Upload a room photo',
10+
hint: 'Tap Upload Room Photo',
11+
},
12+
{
13+
id: 'lead',
14+
label: 'Capture a lead',
15+
hint: 'Use Contact Dealer after picking a shade',
16+
},
17+
{
18+
id: 'quote',
19+
label: 'Create a quote',
20+
hint: 'Sign in, then open Quotes',
21+
},
22+
];
23+
24+
const DEFAULT_STATE = Object.freeze({
25+
photo: false,
26+
lead: false,
27+
quote: false,
28+
dismissed: false,
29+
});
30+
31+
export function defaultOnboardingState() {
32+
return { ...DEFAULT_STATE };
33+
}
34+
35+
export function loadOnboardingState(storage = globalThis.localStorage) {
36+
try {
37+
const raw = storage?.getItem?.(ONBOARDING_STORAGE_KEY);
38+
if (!raw) return defaultOnboardingState();
39+
const parsed = JSON.parse(raw);
40+
return {
41+
photo: Boolean(parsed.photo),
42+
lead: Boolean(parsed.lead),
43+
quote: Boolean(parsed.quote),
44+
dismissed: Boolean(parsed.dismissed),
45+
};
46+
} catch {
47+
return defaultOnboardingState();
48+
}
49+
}
50+
51+
export function saveOnboardingState(state, storage = globalThis.localStorage) {
52+
try {
53+
storage?.setItem?.(ONBOARDING_STORAGE_KEY, JSON.stringify(state));
54+
return true;
55+
} catch {
56+
return false;
57+
}
58+
}
59+
60+
export function markOnboardingStep(stepId, storage = globalThis.localStorage) {
61+
if (!ONBOARDING_STEPS.some((s) => s.id === stepId)) {
62+
return loadOnboardingState(storage);
63+
}
64+
const next = { ...loadOnboardingState(storage), [stepId]: true };
65+
saveOnboardingState(next, storage);
66+
return next;
67+
}
68+
69+
export function dismissOnboarding(storage = globalThis.localStorage) {
70+
const next = { ...loadOnboardingState(storage), dismissed: true };
71+
saveOnboardingState(next, storage);
72+
return next;
73+
}
74+
75+
export function isOnboardingComplete(state) {
76+
return Boolean(state?.photo && state?.lead && state?.quote);
77+
}
78+
79+
export function shouldShowOnboarding(state = loadOnboardingState()) {
80+
if (state.dismissed) return false;
81+
return !isOnboardingComplete(state);
82+
}
83+
84+
export function onboardingProgress(state) {
85+
const done = ONBOARDING_STEPS.filter((s) => state[s.id]).length;
86+
return { done, total: ONBOARDING_STEPS.length };
87+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import {
2+
ONBOARDING_STEPS,
3+
defaultOnboardingState,
4+
loadOnboardingState,
5+
saveOnboardingState,
6+
markOnboardingStep,
7+
dismissOnboarding,
8+
isOnboardingComplete,
9+
shouldShowOnboarding,
10+
onboardingProgress,
11+
ONBOARDING_STORAGE_KEY,
12+
} from './onboarding.js';
13+
14+
function memoryStorage(seed = {}) {
15+
const map = new Map(Object.entries(seed));
16+
return {
17+
getItem: (k) => (map.has(k) ? map.get(k) : null),
18+
setItem: (k, v) => { map.set(k, String(v)); },
19+
removeItem: (k) => { map.delete(k); },
20+
};
21+
}
22+
23+
describe('onboarding checklist', () => {
24+
it('starts empty and incomplete', () => {
25+
const state = defaultOnboardingState();
26+
expect(state).toEqual({ photo: false, lead: false, quote: false, dismissed: false });
27+
expect(shouldShowOnboarding(state)).toBe(true);
28+
expect(isOnboardingComplete(state)).toBe(false);
29+
});
30+
31+
it('persists step progress', () => {
32+
const storage = memoryStorage();
33+
markOnboardingStep('photo', storage);
34+
markOnboardingStep('lead', storage);
35+
const loaded = loadOnboardingState(storage);
36+
expect(loaded.photo).toBe(true);
37+
expect(loaded.lead).toBe(true);
38+
expect(loaded.quote).toBe(false);
39+
expect(onboardingProgress(loaded)).toEqual({ done: 2, total: 3 });
40+
});
41+
42+
it('hides when dismissed or fully complete', () => {
43+
const storage = memoryStorage();
44+
markOnboardingStep('photo', storage);
45+
markOnboardingStep('lead', storage);
46+
markOnboardingStep('quote', storage);
47+
expect(shouldShowOnboarding(loadOnboardingState(storage))).toBe(false);
48+
49+
const again = memoryStorage();
50+
saveOnboardingState({ photo: true, lead: false, quote: false, dismissed: false }, again);
51+
dismissOnboarding(again);
52+
expect(shouldShowOnboarding(loadOnboardingState(again))).toBe(false);
53+
expect(JSON.parse(again.getItem(ONBOARDING_STORAGE_KEY)).dismissed).toBe(true);
54+
});
55+
56+
it('ignores unknown steps', () => {
57+
const storage = memoryStorage();
58+
const state = markOnboardingStep('nope', storage);
59+
expect(state).toEqual(defaultOnboardingState());
60+
expect(ONBOARDING_STEPS.map((s) => s.id)).toEqual(['photo', 'lead', 'quote']);
61+
});
62+
});
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import {
2+
ONBOARDING_STEPS,
3+
loadOnboardingState,
4+
markOnboardingStep,
5+
dismissOnboarding,
6+
shouldShowOnboarding,
7+
isOnboardingComplete,
8+
onboardingProgress,
9+
} from './onboarding.js';
10+
11+
/**
12+
* Renders the first-run checklist into `rootEl` and returns helpers to refresh
13+
* after product actions (photo / lead / quote).
14+
*/
15+
export function createOnboardingChecklist({
16+
rootEl,
17+
getApiToken,
18+
onOpenQuotes,
19+
onOpenContact,
20+
}) {
21+
function render() {
22+
if (!rootEl) return;
23+
const state = loadOnboardingState();
24+
if (!shouldShowOnboarding(state)) {
25+
rootEl.classList.add('hidden');
26+
rootEl.innerHTML = '';
27+
return;
28+
}
29+
30+
const { done, total } = onboardingProgress(state);
31+
const signedIn = typeof getApiToken === 'function' ? Boolean(getApiToken()) : false;
32+
33+
const stepsHtml = ONBOARDING_STEPS.map((step) => {
34+
const complete = Boolean(state[step.id]);
35+
let hint = step.hint;
36+
if (step.id === 'quote' && !signedIn && !complete) {
37+
hint = 'Sign in via Settings, then open Quotes';
38+
}
39+
return `
40+
<li class="onboarding-step ${complete ? 'is-done' : ''}" data-step="${step.id}">
41+
<span class="onboarding-check" aria-hidden="true">${complete ? '✓' : ''}</span>
42+
<div>
43+
<strong>${step.label}</strong>
44+
<p class="muted tiny">${hint}</p>
45+
</div>
46+
</li>`;
47+
}).join('');
48+
49+
rootEl.classList.remove('hidden');
50+
rootEl.innerHTML = `
51+
<div class="onboarding-card">
52+
<div class="onboarding-head">
53+
<div>
54+
<p class="eyebrow">Getting started</p>
55+
<h2>Three steps to your first win</h2>
56+
<p class="muted tiny">${done} of ${total} complete${isOnboardingComplete(state) ? ' — nice work.' : ''}</p>
57+
</div>
58+
<button type="button" class="button ghost tiny" id="onboardingDismissBtn" title="Hide checklist">Dismiss</button>
59+
</div>
60+
<ol class="onboarding-steps">${stepsHtml}</ol>
61+
<div class="onboarding-actions">
62+
${!state.lead ? '<button type="button" class="button tiny primary" id="onboardingLeadBtn">Contact Dealer</button>' : ''}
63+
${!state.quote ? '<button type="button" class="button tiny ghost" id="onboardingQuoteBtn">Open Quotes</button>' : ''}
64+
</div>
65+
</div>`;
66+
67+
rootEl.querySelector('#onboardingDismissBtn')?.addEventListener('click', () => {
68+
dismissOnboarding();
69+
render();
70+
});
71+
rootEl.querySelector('#onboardingLeadBtn')?.addEventListener('click', () => {
72+
onOpenContact?.();
73+
});
74+
rootEl.querySelector('#onboardingQuoteBtn')?.addEventListener('click', () => {
75+
onOpenQuotes?.();
76+
});
77+
}
78+
79+
function complete(stepId) {
80+
markOnboardingStep(stepId);
81+
render();
82+
}
83+
84+
return { render, complete };
85+
}

paint-preview-app/src/views/quotes.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export function createQuotesView({
1616
getCatalog,
1717
roomSqM,
1818
coverageSqMPerL,
19+
onQuoteCreated,
1920
}) {
2021
let commerceTab = 'quotes';
2122
let editingQuoteId = null;
@@ -286,6 +287,7 @@ export function createQuotesView({
286287
const wasEditing = editingQuoteId;
287288
closeQuoteForm();
288289
showTransientToast(wasEditing ? 'Quote updated.' : 'Quote created.');
290+
if (!wasEditing) onQuoteCreated?.();
289291
commerceTab = 'quotes';
290292
if (els.docStatusFilter) els.docStatusFilter.value = '';
291293
buildStatusFilter();

0 commit comments

Comments
 (0)