- Implemented the complete first-run experience:
Account creation and guarded onboarding flow. Current-cash starting balance with automatic rollover enabled. Dynamic bill generation, shared/custom categories, recurring targets, and optional same-day onboarding transactions. Optional dormant reserve and additional categories. Mailgun email or authenticator-app 2FA configuration. Responsive dark/light UI with no overflow at 390px. Fixed administrator session-ID creation bug. Core implementation: [OnboardingService.php (line 59)](/Users/tyemeclifford/Documents/GH/budget/app/OnboardingService.php:59), [onboarding.php (line 19)](/Users/tyemeclifford/Documents/GH/budget/views/onboarding.php:19), and [index.php (line 143)](/Users/tyemeclifford/Documents/GH/budget/public/index.php:143). Verification passed: financial self-test, onboarding integration test, full browser walkthrough, mobile layout, routing guards, and zero browser console errors. Live email delivery requires actual Mailgun credentials and was not sent during testing.
This commit is contained in:
+125
-1
@@ -656,13 +656,106 @@ td > small { margin-top: 3px; font-size: .53rem; }
|
||||
.auth-orbit .orbit-two { inset: 78px; border-color: rgba(255, 91, 157, .14); }
|
||||
.auth-orbit .orbit-three { inset: 120px; border-color: rgba(73, 232, 164, .14); }
|
||||
.auth-orbit strong { position: absolute; inset: 0; display: grid; place-items: center; color: var(--green); font-size: 2.6rem; text-shadow: 0 0 28px rgba(73, 232, 164, .35); }
|
||||
.auth-panel { display: grid; place-items: center; padding: 32px; background: #0b0e16; }
|
||||
.auth-panel { position: relative; display: grid; place-items: center; padding: 32px; background: #0b0e16; }
|
||||
.auth-theme-toggle { position: absolute; z-index: 2; top: 20px; right: 20px; }
|
||||
.auth-panel .flash { width: min(420px, 100%); margin: 0 0 13px; }
|
||||
.auth-card { width: min(420px, 100%); padding: 30px; border: 1px solid rgba(255, 255, 255, .08); border-radius: 19px; background: #121621; box-shadow: 0 28px 75px rgba(0, 0, 0, .32); }
|
||||
.auth-card h2 { margin-bottom: 7px; font-size: 1.55rem; }
|
||||
.auth-card > .muted { margin-bottom: 22px; }
|
||||
.auth-card input, .auth-card select { background: #191e2c; }
|
||||
.auth-info { padding: 12px 13px; border: 1px solid rgba(73, 168, 255, .16); border-radius: 10px; background: rgba(73, 168, 255, .055); }
|
||||
.auth-info strong, .auth-info small { display: block; }
|
||||
.auth-info strong { font-size: .66rem; }
|
||||
.auth-info small { margin-top: 3px; font-size: .54rem; line-height: 1.5; }
|
||||
.otp-input { font-size: 1.45rem; letter-spacing: .38em; text-align: center; }
|
||||
[data-theme="light"] .auth-body { color: var(--text); background: #e9edf4; }
|
||||
[data-theme="light"] .auth-panel { background: #f4f6fa; }
|
||||
[data-theme="light"] .auth-card { border-color: var(--border); background: #fff; box-shadow: var(--shadow); }
|
||||
[data-theme="light"] .auth-card input, [data-theme="light"] .auth-card select { background: var(--surface-2); }
|
||||
|
||||
.onboarding-body { min-height: 100vh; background: radial-gradient(circle at 50% -10%, rgba(139, 92, 246, .17), transparent 34rem), var(--bg); }
|
||||
.onboarding-shell { min-height: 100vh; }
|
||||
.onboarding-header {
|
||||
display: flex;
|
||||
min-height: 86px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 16px clamp(20px, 4vw, 58px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--bg) 84%, transparent);
|
||||
backdrop-filter: blur(22px);
|
||||
}
|
||||
.onboarding-brand { padding: 0; }
|
||||
.onboarding-account { display: flex; align-items: center; gap: 6px; }
|
||||
.onboarding-account > span { margin-right: 7px; text-align: right; }
|
||||
.onboarding-account small, .onboarding-account strong { display: block; }
|
||||
.onboarding-account small { font-size: .5rem; }
|
||||
.onboarding-account strong { max-width: 240px; overflow: hidden; font-size: .63rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.onboarding-main { width: min(1120px, calc(100% - 36px)); margin: 0 auto; padding: clamp(42px, 6vw, 76px) 0 55px; }
|
||||
.onboarding-intro { max-width: 800px; margin: 0 auto 30px; text-align: center; }
|
||||
.onboarding-intro h1 { font-size: clamp(2.1rem, 5vw, 4.35rem); line-height: 1; letter-spacing: -.065em; }
|
||||
.onboarding-intro > p:last-child { max-width: 660px; margin: 17px auto 0; color: var(--muted); font-size: .82rem; line-height: 1.7; }
|
||||
.onboarding-progress { position: relative; display: grid; max-width: 760px; margin: 0 auto 27px; grid-template-columns: repeat(4, 1fr); }
|
||||
.onboarding-progress::before { position: absolute; height: 1px; left: 12.5%; right: 12.5%; top: 16px; background: var(--border-strong); content: ""; }
|
||||
.onboarding-progress > div { position: relative; z-index: 1; display: grid; justify-items: center; gap: 6px; color: var(--muted-2); text-align: center; }
|
||||
.onboarding-progress span { display: grid; width: 33px; height: 33px; place-items: center; border: 1px solid var(--border-strong); border-radius: 50%; background: var(--bg); font-size: .6rem; font-weight: 850; }
|
||||
.onboarding-progress strong { font-size: .56rem; }
|
||||
.onboarding-progress > div.active, .onboarding-progress > div.complete { color: var(--text); }
|
||||
.onboarding-progress > div.active span { border-color: var(--purple-light); color: #fff; background: var(--purple); box-shadow: 0 0 22px rgba(139, 92, 246, .32); }
|
||||
.onboarding-progress > div.complete span { border-color: rgba(73, 232, 164, .44); color: var(--green); background: rgba(73, 232, 164, .08); }
|
||||
.onboarding-form { max-width: 980px; min-height: 500px; margin: 0 auto; padding: clamp(24px, 4vw, 46px); overflow: hidden; }
|
||||
.onboarding-step { animation: onboarding-in .24s ease both; }
|
||||
@keyframes onboarding-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||||
.step-copy { display: grid; grid-template-columns: 54px 1fr; gap: 16px; padding-bottom: 25px; border-bottom: 1px solid var(--border); }
|
||||
.step-number { display: grid; width: 49px; height: 49px; place-items: center; border: 1px solid rgba(139, 92, 246, .26); border-radius: 14px; color: var(--purple-light); background: rgba(139, 92, 246, .08); font-size: .68rem; font-weight: 900; }
|
||||
.step-copy h2 { font-size: clamp(1.35rem, 2.5vw, 2rem); }
|
||||
.step-copy p:last-child { max-width: 720px; margin: 8px 0 0; color: var(--muted); font-size: .72rem; }
|
||||
.hero-money-field, .bill-count-field { max-width: 540px; margin: 42px auto 34px; text-align: center; }
|
||||
.hero-money-field > span, .bill-count-field > span { font-size: .65rem; text-transform: uppercase; letter-spacing: .09em; }
|
||||
.hero-money-field .money-input { margin-top: 10px; }
|
||||
.hero-money-field .money-input > span { left: 18px; font-size: 1.1rem; }
|
||||
.hero-money-field input { min-height: 76px; padding: 10px 20px 10px 43px; border-radius: 15px; font-size: 2.15rem; font-weight: 820; letter-spacing: -.04em; text-align: center; }
|
||||
.hero-money-field small, .bill-count-field small { margin-top: 8px; font-size: .58rem; font-weight: 550; }
|
||||
.bill-count-field { max-width: 260px; }
|
||||
.bill-count-field input { min-height: 76px; margin-top: 10px; border-radius: 15px; font-size: 2.15rem; font-weight: 820; text-align: center; }
|
||||
.onboarding-actions { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 32px; padding-top: 23px; border-top: 1px solid var(--border); }
|
||||
.onboarding-actions .button { min-width: 145px; }
|
||||
.onboarding-check-all { margin: 22px 0 15px; }
|
||||
.onboarding-bills { display: grid; gap: 13px; }
|
||||
.onboarding-bill-card { padding: 18px; border: 1px solid var(--border); border-radius: 14px; background: rgba(255, 255, 255, .015); }
|
||||
.bill-card-heading { display: flex; align-items: center; gap: 11px; margin-bottom: 15px; }
|
||||
.bill-index { display: grid; width: 32px; height: 32px; flex: 0 0 32px; place-items: center; border-radius: 9px; color: var(--blue); background: rgba(73, 168, 255, .09); font-size: .62rem; font-weight: 900; }
|
||||
.bill-card-heading h3 { margin: 0; font-size: .78rem; }
|
||||
.bill-card-heading p { margin: 2px 0 0; color: var(--muted); font-size: .53rem; }
|
||||
.bill-fields { display: grid; grid-template-columns: 1.25fr .72fr .85fr .78fr; gap: 11px; }
|
||||
.bill-category-field { grid-column: span 2; }
|
||||
.bill-custom-category { grid-column: span 2; }
|
||||
.bill-paid-row { margin-top: 12px; }
|
||||
.onboarding-preference-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 15px; margin-top: 22px; }
|
||||
.onboarding-option-panel { padding: 19px; border: 1px solid var(--border); border-radius: 14px; background: rgba(255, 255, 255, .016); }
|
||||
.option-heading { display: flex; gap: 12px; margin-bottom: 17px; }
|
||||
.option-heading h3 { margin: 0; font-size: .78rem; }
|
||||
.option-heading p { margin: 4px 0 0; color: var(--muted); font-size: .57rem; line-height: 1.5; }
|
||||
.category-chip-grid { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.category-chip-grid label { position: relative; display: block; cursor: pointer; }
|
||||
.category-chip-grid input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.category-chip-grid span { display: block; padding: 7px 10px; border: 1px solid var(--border); border-radius: 20px; color: var(--muted); background: var(--surface-2); font-size: .58rem; font-weight: 750; }
|
||||
.category-chip-grid input:checked + span { border-color: rgba(73, 168, 255, .4); color: var(--blue); background: rgba(73, 168, 255, .09); }
|
||||
.dead-option > label:last-child { margin-top: 12px; }
|
||||
.onboarding-review { display: grid; margin-top: 18px; grid-template-columns: repeat(4, minmax(0, 1fr)); border: 1px solid var(--border); border-radius: 14px; overflow: hidden; background: var(--surface-2); }
|
||||
.onboarding-review > div { padding: 15px; border-left: 1px solid var(--border); }
|
||||
.onboarding-review > div:first-child { border-left: 0; }
|
||||
.onboarding-review span, .onboarding-review strong { display: block; }
|
||||
.onboarding-review span { color: var(--muted); font-size: .54rem; text-transform: uppercase; }
|
||||
.onboarding-review strong { margin-top: 4px; font-size: .9rem; }
|
||||
.onboarding-review .review-remaining { background: rgba(73, 232, 164, .055); }
|
||||
.onboarding-review .review-remaining strong { color: var(--green); }
|
||||
.onboarding-review .review-remaining.negative { background: rgba(255, 100, 115, .06); }
|
||||
.onboarding-review .review-remaining.negative strong { color: var(--red); }
|
||||
.onboarding-warning { margin: 10px 0 0; color: var(--red); font-size: .62rem; text-align: right; }
|
||||
.onboarding-flash { width: min(980px, calc(100% - 36px)); margin: 18px auto -16px; }
|
||||
.onboarding-footer { display: flex; width: min(1120px, calc(100% - 36px)); justify-content: space-between; gap: 20px; margin: 0 auto; padding: 20px 0 26px; border-top: 1px solid var(--border); color: var(--muted-2); font-size: .56rem; }
|
||||
|
||||
@media (max-width: 1220px) {
|
||||
.metric-grid, .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
@@ -736,6 +829,15 @@ td > small { margin-top: 3px; font-size: .53rem; }
|
||||
.data-table td::before { color: var(--muted); font-size: .55rem; font-weight: 800; text-transform: uppercase; content: attr(data-label); }
|
||||
.data-table td.table-actions { display: flex; position: absolute; top: 10px; right: 0; width: auto; }
|
||||
.data-table td.table-actions::before { display: none; }
|
||||
.onboarding-main { width: min(100% - 28px, 1120px); padding-top: 35px; }
|
||||
.onboarding-intro h1 { font-size: 2.35rem; }
|
||||
.onboarding-form { padding: 23px; }
|
||||
.bill-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.bill-category-field, .bill-custom-category { grid-column: span 1; }
|
||||
.onboarding-preference-grid { grid-template-columns: 1fr; }
|
||||
.onboarding-review { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.onboarding-review > div:nth-child(3) { border-left: 0; border-top: 1px solid var(--border); }
|
||||
.onboarding-review > div:nth-child(4) { border-top: 1px solid var(--border); }
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
@@ -754,4 +856,26 @@ td > small { margin-top: 3px; font-size: .53rem; }
|
||||
.category-admin-list .money-input { grid-column: 2 / -1; }
|
||||
.auth-panel { padding: 17px; }
|
||||
.auth-card { padding: 23px; }
|
||||
.onboarding-header { min-height: 72px; padding: 12px 15px; }
|
||||
.onboarding-header .brand-copy { display: none; }
|
||||
.onboarding-account > span { display: none; }
|
||||
.onboarding-intro { margin-bottom: 23px; }
|
||||
.onboarding-intro h1 { font-size: 2rem; }
|
||||
.onboarding-intro > p:last-child { font-size: .72rem; }
|
||||
.onboarding-progress strong { display: none; }
|
||||
.onboarding-progress::before { left: 12%; right: 12%; }
|
||||
.onboarding-form { min-height: 0; padding: 19px; border-radius: 15px; }
|
||||
.step-copy { grid-template-columns: 1fr; }
|
||||
.step-number { width: 40px; height: 40px; }
|
||||
.hero-money-field { margin: 30px auto 24px; }
|
||||
.hero-money-field input { min-height: 67px; font-size: 1.75rem; }
|
||||
.bill-fields { grid-template-columns: 1fr; }
|
||||
.bill-category-field, .bill-custom-category { grid-column: 1; }
|
||||
.onboarding-actions { align-items: stretch; flex-direction: column-reverse; }
|
||||
.onboarding-actions > span { display: none; }
|
||||
.onboarding-actions .button { width: 100%; }
|
||||
.onboarding-review { grid-template-columns: 1fr; }
|
||||
.onboarding-review > div { border-top: 1px solid var(--border); border-left: 0; }
|
||||
.onboarding-review > div:first-child { border-top: 0; }
|
||||
.onboarding-footer { align-items: center; flex-direction: column; text-align: center; }
|
||||
}
|
||||
|
||||
@@ -95,6 +95,178 @@
|
||||
syncRestoreFields();
|
||||
});
|
||||
|
||||
const onboardingForm = document.querySelector('[data-onboarding-form]');
|
||||
if (onboardingForm) {
|
||||
const steps = [...onboardingForm.querySelectorAll('[data-onboarding-step]')];
|
||||
const progress = [...document.querySelectorAll('[data-onboarding-progress]')];
|
||||
const cashInput = onboardingForm.querySelector('[data-current-cash]');
|
||||
const billCountInput = onboardingForm.querySelector('[data-bill-count]');
|
||||
const billRows = onboardingForm.querySelector('[data-bill-rows]');
|
||||
const billTemplate = document.querySelector('#onboarding-bill-template');
|
||||
const markAllPaid = onboardingForm.querySelector('[data-mark-all-paid]');
|
||||
const enableDead = onboardingForm.querySelector('[data-enable-onboarding-dead]');
|
||||
const deadFields = onboardingForm.querySelector('[data-onboarding-dead-fields]');
|
||||
const deadAmount = onboardingForm.querySelector('[data-dead-amount]');
|
||||
const submitButton = onboardingForm.querySelector('[data-onboarding-submit]');
|
||||
const warning = onboardingForm.querySelector('[data-onboarding-warning]');
|
||||
const symbol = onboardingForm.dataset.symbol || '$';
|
||||
let currentStep = 1;
|
||||
let generatedCount = -1;
|
||||
|
||||
const money = (value) => `${value < 0 ? '-' : ''}${symbol}${Math.abs(value).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`;
|
||||
|
||||
const showStep = (target) => {
|
||||
currentStep = target;
|
||||
steps.forEach((step) => {
|
||||
const active = Number(step.dataset.onboardingStep) === target;
|
||||
step.hidden = !active;
|
||||
step.classList.toggle('active', active);
|
||||
});
|
||||
progress.forEach((item) => {
|
||||
const number = Number(item.dataset.onboardingProgress);
|
||||
item.classList.toggle('active', number === target);
|
||||
item.classList.toggle('complete', number < target);
|
||||
});
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const validateCurrentStep = () => {
|
||||
const step = steps.find((item) => Number(item.dataset.onboardingStep) === currentStep);
|
||||
const fields = [...step.querySelectorAll('input, select, textarea')].filter((field) => !field.disabled);
|
||||
for (const field of fields) {
|
||||
if (!field.checkValidity()) {
|
||||
field.reportValidity();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const paidInputs = () => [...billRows.querySelectorAll('[data-bill-field="paid_now"]')];
|
||||
const syncPaidMaster = () => {
|
||||
const inputs = paidInputs();
|
||||
const checked = inputs.filter((input) => input.checked).length;
|
||||
markAllPaid.checked = inputs.length > 0 && checked === inputs.length;
|
||||
markAllPaid.indeterminate = checked > 0 && checked < inputs.length;
|
||||
};
|
||||
|
||||
const updateReview = () => {
|
||||
const cash = Number(cashInput.value) || 0;
|
||||
let paid = 0;
|
||||
billRows.querySelectorAll('.onboarding-bill-card').forEach((card) => {
|
||||
if (card.querySelector('[data-bill-field="paid_now"]').checked) {
|
||||
paid += Number(card.querySelector('[data-bill-field="amount"]').value) || 0;
|
||||
}
|
||||
});
|
||||
const reserved = enableDead.checked ? Number(deadAmount.value) || 0 : 0;
|
||||
const remaining = cash - paid - reserved;
|
||||
onboardingForm.querySelector('[data-review-cash]').textContent = money(cash);
|
||||
onboardingForm.querySelector('[data-review-paid]').textContent = money(paid);
|
||||
onboardingForm.querySelector('[data-review-dead]').textContent = money(reserved);
|
||||
onboardingForm.querySelector('[data-review-remaining]').textContent = money(remaining);
|
||||
onboardingForm.querySelector('.review-remaining').classList.toggle('negative', remaining < 0);
|
||||
warning.hidden = remaining >= 0;
|
||||
submitButton.disabled = remaining < 0;
|
||||
};
|
||||
|
||||
const generateBillRows = () => {
|
||||
const count = Math.max(0, Math.min(50, Number.parseInt(billCountInput.value, 10) || 0));
|
||||
billCountInput.value = String(count);
|
||||
if (generatedCount === count) return count;
|
||||
generatedCount = count;
|
||||
billRows.replaceChildren();
|
||||
for (let index = 0; index < count; index++) {
|
||||
const fragment = billTemplate.content.cloneNode(true);
|
||||
const card = fragment.querySelector('.onboarding-bill-card');
|
||||
card.querySelector('[data-bill-index]').textContent = String(index + 1).padStart(2, '0');
|
||||
const title = card.querySelector('[data-bill-title]');
|
||||
title.textContent = `Bill ${index + 1}`;
|
||||
card.querySelectorAll('[data-bill-field]').forEach((field) => {
|
||||
const key = field.dataset.billField;
|
||||
field.name = `bills[${index}][${key}]`;
|
||||
});
|
||||
const nameInput = card.querySelector('[data-bill-field="name"]');
|
||||
const amountInput = card.querySelector('[data-bill-field="amount"]');
|
||||
const paidInput = card.querySelector('[data-bill-field="paid_now"]');
|
||||
const categoryInput = card.querySelector('[data-bill-field="category"]');
|
||||
const customWrap = card.querySelector('[data-custom-category]');
|
||||
const customInput = card.querySelector('[data-bill-field="custom_category"]');
|
||||
|
||||
nameInput.addEventListener('input', () => {
|
||||
title.textContent = nameInput.value.trim() || `Bill ${index + 1}`;
|
||||
});
|
||||
amountInput.addEventListener('input', updateReview);
|
||||
paidInput.addEventListener('change', () => {
|
||||
syncPaidMaster();
|
||||
updateReview();
|
||||
});
|
||||
categoryInput.addEventListener('change', () => {
|
||||
const custom = categoryInput.value === '__custom__';
|
||||
customWrap.hidden = !custom;
|
||||
customInput.required = custom;
|
||||
if (!custom) customInput.value = '';
|
||||
});
|
||||
paidInput.checked = markAllPaid.checked;
|
||||
billRows.append(fragment);
|
||||
}
|
||||
syncPaidMaster();
|
||||
updateReview();
|
||||
return count;
|
||||
};
|
||||
|
||||
onboardingForm.querySelectorAll('[data-onboarding-next]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
if (!validateCurrentStep()) return;
|
||||
if (currentStep === 2) {
|
||||
const count = generateBillRows();
|
||||
showStep(count === 0 ? 4 : 3);
|
||||
updateReview();
|
||||
return;
|
||||
}
|
||||
showStep(Math.min(4, currentStep + 1));
|
||||
updateReview();
|
||||
});
|
||||
});
|
||||
|
||||
onboardingForm.querySelectorAll('[data-onboarding-back]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
if (currentStep === 4 && generatedCount === 0) {
|
||||
showStep(2);
|
||||
return;
|
||||
}
|
||||
showStep(Math.max(1, currentStep - 1));
|
||||
});
|
||||
});
|
||||
|
||||
markAllPaid.addEventListener('change', () => {
|
||||
paidInputs().forEach((input) => {
|
||||
input.checked = markAllPaid.checked;
|
||||
});
|
||||
markAllPaid.indeterminate = false;
|
||||
updateReview();
|
||||
});
|
||||
enableDead.addEventListener('change', () => {
|
||||
deadFields.hidden = !enableDead.checked;
|
||||
deadAmount.required = enableDead.checked;
|
||||
if (!enableDead.checked) deadAmount.value = '';
|
||||
updateReview();
|
||||
});
|
||||
cashInput.addEventListener('input', updateReview);
|
||||
deadAmount.addEventListener('input', updateReview);
|
||||
onboardingForm.addEventListener('submit', (event) => {
|
||||
generateBillRows();
|
||||
updateReview();
|
||||
if (submitButton.disabled) {
|
||||
event.preventDefault();
|
||||
warning.hidden = false;
|
||||
}
|
||||
});
|
||||
updateReview();
|
||||
}
|
||||
|
||||
function drawTrendChart(canvas) {
|
||||
let data;
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user