Add form validation to all dashboard forms

- form-validation.ts: new validators (isValidPhone, isValidTitle, isValidDescription,
  isValidLocation, isValidPrice, isValidBudget, isValidSalaryRange, isValidDuration,
  isValidTags, isValidFullName)
- portfolio/index.tsx: title (3–255), description (optional 10+), tags (≤40 chars, max 20)
- settings.tsx: full_name, phone (Indian 10-digit), location, bio (≤500), password strength meter
- jobs/create.tsx: title (5–200), description (20+), location, salary range, experience, skills
- requirements/index.tsx: title, description, location, budget (optional), preferred date
- services/index.tsx: name (3–255), price (positive), duration (optional 1–1440), description (≤1000)
- All forms: VNote inline component (✓ orange / • gray), submitted signal prevents premature errors,
  red borders on invalid fields post-submit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ashwin Kumar 2026-04-02 22:55:56 +02:00
parent 64ec515393
commit 9ba3adda64
6 changed files with 719 additions and 155 deletions

View file

@ -87,6 +87,95 @@ export function isValidCaptcha(input: string, expected: string): boolean {
return input.trim().toUpperCase() === expected.toUpperCase();
}
/**
* Validate Indian mobile phone number (10 digits, starts with 6-9)
* Allows optional +91 or 0 prefix
*/
export function isValidPhone(phone: string): boolean {
const cleaned = phone.trim().replace(/[\s\-()]/g, '');
if (!cleaned) return false;
// Strip +91 or 91 or 0 prefix
const normalized = cleaned.replace(/^(\+91|91|0)/, '');
return /^[6-9]\d{9}$/.test(normalized);
}
/**
* Validate a title or short text field (letters, numbers, common punctuation)
*/
export function isValidTitle(text: string, minLen = 3, maxLen = 200): boolean {
const trimmed = text.trim();
return trimmed.length >= minLen && trimmed.length <= maxLen;
}
/**
* Validate a description / long text field
*/
export function isValidDescription(text: string, minLen = 20, maxLen = 5000): boolean {
const trimmed = text.trim();
return trimmed.length >= minLen && trimmed.length <= maxLen;
}
/**
* Validate a location string (at least 2 chars)
*/
export function isValidLocation(loc: string, minLen = 2, maxLen = 255): boolean {
const trimmed = loc.trim();
return trimmed.length >= minLen && trimmed.length <= maxLen;
}
/**
* Validate a price (positive number, up to reasonable max)
*/
export function isValidPrice(value: string | number): boolean {
const n = typeof value === 'string' ? parseFloat(value) : value;
return !isNaN(n) && n > 0 && n <= 10_000_000;
}
/**
* Validate a budget string (optional empty is valid; if filled must be positive integer)
*/
export function isValidBudget(budget: string): boolean {
if (!budget.trim()) return true; // optional
const n = parseInt(budget, 10);
return !isNaN(n) && n > 0 && n <= 100_000_000;
}
/**
* Validate salary range both optional, but if both filled min must be < max
*/
export function isValidSalaryRange(min: string, max: string): boolean {
if (!min && !max) return true;
if (min && !max) return parseFloat(min) > 0;
if (!min && max) return parseFloat(max) > 0;
return parseFloat(min) > 0 && parseFloat(max) > 0 && parseFloat(min) <= parseFloat(max);
}
/**
* Validate duration in minutes (optional empty is valid; if filled must be positive integer)
*/
export function isValidDuration(minutes: string): boolean {
if (!minutes.trim()) return true; // optional
const n = parseInt(minutes, 10);
return !isNaN(n) && n > 0 && n <= 1440; // max 24h
}
/**
* Validate comma-separated tags (each tag 1-40 chars, max 20 tags)
*/
export function isValidTags(tags: string): boolean {
if (!tags.trim()) return true; // optional
const parts = tags.split(',').map((t) => t.trim()).filter(Boolean);
if (parts.length > 20) return false;
return parts.every((t) => t.length >= 1 && t.length <= 40);
}
/**
* Validate full name (letters, spaces, hyphens, apostrophes; 2-100 chars)
*/
export function isValidFullName(name: string): boolean {
return isValidName(name, 2, 100);
}
/**
* Get user-friendly error message for validation failure
* @param fieldName - Name of the field

View file

@ -1,20 +1,37 @@
import { createSignal, Show, createResource } from 'solid-js';
import { createSignal, createMemo, Show, createResource } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import { getAuthHeader, authState } from '~/lib/auth';
import {
isValidTitle,
isValidDescription,
isValidLocation,
isValidSalaryRange,
isValidTags,
} from '~/lib/form-validation';
const API = import.meta.env.VITE_API_URL ?? 'http://localhost:8000';
const JOB_TYPES = ['FULL_TIME', 'PART_TIME', 'CONTRACT', 'INTERNSHIP'];
function VNote(props: { show: boolean; ok: boolean; okMsg: string; errMsg: string }) {
return (
<Show when={props.show}>
<p class="validation-note" style={{ color: props.ok ? '#fd6116' : '#6e7591', 'font-size': '12px', margin: '3px 0 0' }}>
{props.ok ? `${props.okMsg}` : `${props.errMsg}`}
</p>
</Show>
);
}
export default function CreateJob() {
const navigate = useNavigate();
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal('');
const [submitted, setSubmitted] = createSignal(false);
const [usage] = createResource(async () => {
const auth = getAuthHeader();
if (!auth.Authorization) return { count: 0 };
// Fetch recent jobs to count monthly usage
const res = await fetch(`${API}/api/companies/jobs?limit=100`, { headers: auth });
if (!res.ok) return { count: 0 };
const data = await res.json();
@ -27,6 +44,7 @@ export default function CreateJob() {
});
const isFree = () => (usage()?.count ?? 0) < 1;
const [form, setForm] = createSignal({
title: '',
category: '',
@ -43,28 +61,44 @@ export default function CreateJob() {
return (e: any) => setForm(f => ({ ...f, [key]: e.target.value }));
}
// Real-time validation
const titleOk = createMemo(() => isValidTitle(form().title, 5, 200));
const descOk = createMemo(() => isValidDescription(form().description, 20, 5000));
const locationOk = createMemo(() => isValidLocation(form().location, 2, 255));
const salaryOk = createMemo(() => isValidSalaryRange(form().salary_min, form().salary_max));
const expOk = createMemo(() => {
if (!form().experience_years) return true;
const n = parseInt(form().experience_years, 10);
return !isNaN(n) && n >= 0 && n <= 50;
});
const skillsOk = createMemo(() => isValidTags(form().skills));
const canSubmit = createMemo(() => titleOk() && descOk() && locationOk() && salaryOk() && expOk() && skillsOk());
async function handleSubmit(e: Event) {
e.preventDefault();
setSubmitted(true);
setError('');
const f = form();
if (!f.title || !f.description || !f.location) {
setError('Title, description, and location are required.');
return;
}
if (!titleOk()) { setError('Job title must be 5200 characters.'); return; }
if (!descOk()) { setError('Description must be at least 20 characters.'); return; }
if (!locationOk()) { setError('Location must be at least 2 characters.'); return; }
if (!salaryOk()) { setError('Salary range is invalid — min must be less than max and both must be positive.'); return; }
if (!expOk()) { setError('Experience years must be between 0 and 50.'); return; }
if (!skillsOk()) { setError('Each skill must be under 40 characters, max 20 skills.'); return; }
const f = form();
const body: Record<string, any> = {
title: f.title,
category: f.category || null,
description: f.description,
location: f.location,
title: f.title.trim(),
category: f.category.trim() || null,
description: f.description.trim(),
location: f.location.trim(),
job_type: f.job_type,
};
if (f.salary_min) body.salary_min = parseInt(f.salary_min) * 100; // convert ₹ to paise
if (f.salary_max) body.salary_max = parseInt(f.salary_max) * 100;
if (f.salary_min) body.salary_min = parseInt(f.salary_min) * 100; // ₹ → paise
if (f.salary_max) body.salary_max = parseInt(f.salary_max) * 100;
if (f.experience_years) body.experience_years = parseInt(f.experience_years);
if (f.skills) body.skills = f.skills.split(',').map(s => s.trim()).filter(Boolean);
if (f.skills) body.skills = f.skills.split(',').map(s => s.trim()).filter(Boolean);
setLoading(true);
try {
@ -95,8 +129,8 @@ export default function CreateJob() {
{isFree() ? '✨ Monthly Free Job Posting' : '🪙 Paid Job Posting'}
</h3>
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: isFree() ? '#15803d' : '#c2410c' }}>
{isFree()
? "This is your 1st job this month—it's completely free!"
{isFree()
? "This is your 1st job this month — it's completely free!"
: "Your monthly free quota is exhausted. Posting this job will cost 100 Tracecoins."}
</p>
</div>
@ -107,39 +141,72 @@ export default function CreateJob() {
</div>
<Show when={error()}>
<div class="error-banner">{error()}</div>
<div class="error-banner" style={{ 'margin-bottom': '16px' }}>{error()}</div>
</Show>
<form onSubmit={handleSubmit} style={{ display: 'flex', 'flex-direction': 'column', gap: '16px' }}>
<div class="form-card">
<div class="field">
<label class="label">Job Title *</label>
<input class="input" type="text" placeholder="e.g. Frontend Developer" maxLength={200}
value={form().title} onInput={field('title')} />
<label class="label">Job Title <span style={{ color: '#e11d48' }}>*</span></label>
<input
class="input"
type="text"
placeholder="e.g. Frontend Developer"
maxLength={200}
value={form().title}
onInput={field('title')}
style={{ 'border-color': submitted() && !titleOk() ? '#e11d48' : '' }}
/>
<VNote show={!!form().title.trim() || submitted()} ok={titleOk()}
okMsg="Title looks good"
errMsg="Job title must be at least 5 characters" />
</div>
<div class="field">
<label class="label">Category</label>
<input class="input" type="text" placeholder="e.g. Technology, Marketing"
value={form().category} onInput={field('category')} />
<input
class="input"
type="text"
placeholder="e.g. Technology, Marketing"
value={form().category}
onInput={field('category')}
/>
</div>
<div class="field">
<label class="label">Description *</label>
<textarea class="textarea" placeholder="Describe the role, responsibilities, and requirements..."
style={{ 'min-height': '160px' }}
value={form().description} onInput={field('description')} maxLength={5000}
<label class="label">Description <span style={{ color: '#e11d48' }}>*</span></label>
<textarea
class="textarea"
placeholder="Describe the role, responsibilities, and requirements… (min 20 characters)"
style={{ 'min-height': '160px', 'border-color': submitted() && !descOk() ? '#e11d48' : '' }}
value={form().description}
onInput={field('description')}
maxLength={5000}
/>
<span style={{ 'font-size': '11px', color: '#94a3b8' }}>
{form().description.length}/5000
</span>
<div style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'center' }}>
<VNote show={!!form().description.trim() || submitted()} ok={descOk()}
okMsg="Description looks good"
errMsg="At least 20 characters required" />
<span style={{ 'font-size': '11px', color: '#94a3b8', 'margin-left': 'auto' }}>
{form().description.length}/5000
</span>
</div>
</div>
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '14px' }}>
<div class="field">
<label class="label">Location *</label>
<input class="input" type="text" placeholder="e.g. Mumbai, Remote"
value={form().location} onInput={field('location')} />
<label class="label">Location <span style={{ color: '#e11d48' }}>*</span></label>
<input
class="input"
type="text"
placeholder="e.g. Mumbai, Remote"
value={form().location}
onInput={field('location')}
style={{ 'border-color': submitted() && !locationOk() ? '#e11d48' : '' }}
/>
<VNote show={!!form().location.trim() || submitted()} ok={locationOk()}
okMsg="Location looks good"
errMsg="Location must be at least 2 characters" />
</div>
<div class="field">
<label class="label">Job Type</label>
@ -152,25 +219,59 @@ export default function CreateJob() {
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr 1fr', gap: '14px' }}>
<div class="field">
<label class="label">Min Salary (/yr)</label>
<input class="input" type="number" placeholder="e.g. 500000"
value={form().salary_min} onInput={field('salary_min')} min="0" />
<input
class="input"
type="number"
placeholder="e.g. 500000"
value={form().salary_min}
onInput={field('salary_min')}
min="0"
style={{ 'border-color': submitted() && !salaryOk() ? '#e11d48' : '' }}
/>
</div>
<div class="field">
<label class="label">Max Salary (/yr)</label>
<input class="input" type="number" placeholder="e.g. 1200000"
value={form().salary_max} onInput={field('salary_max')} min="0" />
<input
class="input"
type="number"
placeholder="e.g. 1200000"
value={form().salary_max}
onInput={field('salary_max')}
min="0"
style={{ 'border-color': submitted() && !salaryOk() ? '#e11d48' : '' }}
/>
</div>
<div class="field">
<label class="label">Experience (years)</label>
<input class="input" type="number" placeholder="e.g. 2"
value={form().experience_years} onInput={field('experience_years')} min="0" />
<input
class="input"
type="number"
placeholder="e.g. 2"
value={form().experience_years}
onInput={field('experience_years')}
min="0"
max="50"
style={{ 'border-color': submitted() && !expOk() ? '#e11d48' : '' }}
/>
</div>
</div>
<VNote show={(!!form().salary_min || !!form().salary_max) || (submitted() && !salaryOk())} ok={salaryOk()}
okMsg="Salary range looks good"
errMsg="Min salary must be less than or equal to max, both must be positive" />
<div class="field">
<label class="label">Skills (comma-separated)</label>
<input class="input" type="text" placeholder="e.g. React, TypeScript, Node.js"
value={form().skills} onInput={field('skills')} />
<div class="field" style={{ 'margin-top': '4px' }}>
<label class="label">Skills <span style={{ color: '#94a3b8', 'font-size': '12px', 'font-weight': '400' }}>(comma-separated)</span></label>
<input
class="input"
type="text"
placeholder="e.g. React, TypeScript, Node.js"
value={form().skills}
onInput={field('skills')}
style={{ 'border-color': submitted() && !skillsOk() ? '#e11d48' : '' }}
/>
<VNote show={!!form().skills.trim() || submitted()} ok={skillsOk()}
okMsg="Skills look good"
errMsg="Each skill max 40 characters, max 20 skills" />
</div>
</div>

View file

@ -1,18 +1,35 @@
import { createResource, createSignal, Show, For } from 'solid-js';
import { createResource, createSignal, createMemo, Show, For } from 'solid-js';
import { getAuthHeader, authState, getRoleApiPath } from '~/lib/auth';
import { isValidTitle, isValidDescription, isValidTags } from '~/lib/form-validation';
const API = import.meta.env.VITE_API_URL ?? 'http://localhost:8000';
// Validation note helper — matches register page style
function VNote(props: { ok: boolean; okMsg: string; errMsg: string }) {
return (
<p class="validation-note" style={{ color: props.ok ? '#fd6116' : '#6e7591', 'font-size': '12px', margin: '3px 0 0' }}>
{props.ok ? `${props.okMsg}` : `${props.errMsg}`}
</p>
);
}
export default function Portfolio() {
const [showForm, setShowForm] = createSignal(false);
const [saving, setSaving] = createSignal(false);
const [editId, setEditId] = createSignal<string | null>(null);
const [error, setError] = createSignal('');
const [submitted, setSubmitted] = createSignal(false);
const [form, setForm] = createSignal({ title: '', description: '', tags: '' });
const rc = () => authState().runtime_config;
const rolePrefix = () => getRoleApiPath(rc()?.role);
function f(k: string) { return (e: any) => setForm(p => ({ ...p, [k]: e.target.value })); }
// Real-time validation
const titleOk = createMemo(() => isValidTitle(form().title, 3, 255));
const descOk = createMemo(() => !form().description.trim() || isValidDescription(form().description, 10, 2000));
const tagsOk = createMemo(() => isValidTags(form().tags));
const canSubmit = createMemo(() => titleOk() && descOk() && tagsOk());
const [items, { refetch }] = createResource(async () => {
const auth = getAuthHeader();
if (!auth.Authorization || !rc()?.role) return { data: [] };
@ -23,13 +40,18 @@ export default function Portfolio() {
async function saveItem(e: Event) {
e.preventDefault();
setSubmitted(true);
setError('');
const data = form();
if (!data.title) { setError('Title is required.'); return; }
if (!titleOk()) { setError('Title must be 3255 characters.'); return; }
if (!descOk()) { setError('Description must be at least 10 characters if provided.'); return; }
if (!tagsOk()) { setError('Each tag must be under 40 characters, max 20 tags.'); return; }
setSaving(true);
const data = form();
const body = {
title: data.title,
description: data.description || null,
title: data.title.trim(),
description: data.description.trim() || null,
tags: data.tags ? data.tags.split(',').map(s => s.trim()).filter(Boolean) : [],
};
const isEdit = !!editId();
@ -45,6 +67,7 @@ export default function Portfolio() {
if (res.ok) {
setShowForm(false);
setEditId(null);
setSubmitted(false);
setForm({ title: '', description: '', tags: '' });
refetch();
} else {
@ -64,15 +87,24 @@ export default function Portfolio() {
function startEdit(item: any) {
setEditId(item.id);
setSubmitted(false);
setForm({ title: item.title, description: item.description ?? '', tags: (item.tags ?? []).join(', ') });
setShowForm(true);
}
function openCreate() {
setEditId(null);
setSubmitted(false);
setForm({ title: '', description: '', tags: '' });
setError('');
setShowForm(s => !s);
}
return (
<div>
<div class="page-actions">
<h1 style={{ margin: 0, 'font-size': '22px', 'font-weight': '800' }}>Portfolio</h1>
<button class="btn btn-primary" onClick={() => { setShowForm(s => !s); setEditId(null); setForm({ title: '', description: '', tags: '' }); }}>
<button class="btn btn-primary" onClick={openCreate}>
{showForm() ? '✕ Cancel' : '+ Add Item'}
</button>
</div>
@ -86,22 +118,58 @@ export default function Portfolio() {
<Show when={error()}>
<div class="error-banner" style={{ 'margin-bottom': '12px' }}>{error()}</div>
</Show>
<form onSubmit={saveItem} style={{ display: 'flex', 'flex-direction': 'column', gap: '12px' }}>
<form onSubmit={saveItem} style={{ display: 'flex', 'flex-direction': 'column', gap: '14px' }}>
<div class="field">
<label class="label">Title *</label>
<input class="input" type="text" placeholder="e.g. Wedding at Taj Hotel"
value={form().title} onInput={f('title')} maxLength={255} />
<label class="label">Title <span style={{ color: '#e11d48' }}>*</span></label>
<input
class="input"
type="text"
placeholder="e.g. Wedding at Taj Hotel"
value={form().title}
onInput={f('title')}
maxLength={255}
style={{ 'border-color': submitted() && !titleOk() ? '#e11d48' : '' }}
/>
<Show when={form().title.trim() || submitted()}>
<VNote ok={titleOk()} okMsg="Title looks good" errMsg="Title must be at least 3 characters" />
</Show>
</div>
<div class="field">
<label class="label">Description</label>
<textarea class="textarea" placeholder="Describe the project..."
style={{ 'min-height': '80px' }} value={form().description} onInput={f('description')} />
<textarea
class="textarea"
placeholder="Describe the project… (min 10 characters if filled)"
style={{ 'min-height': '80px', 'border-color': submitted() && !descOk() ? '#e11d48' : '' }}
value={form().description}
onInput={f('description')}
maxLength={2000}
/>
<div style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'center' }}>
<Show when={form().description.trim() || submitted()}>
<VNote ok={descOk()} okMsg="Description looks good" errMsg="Minimum 10 characters if provided" />
</Show>
<span style={{ 'font-size': '11px', color: '#94a3b8', 'margin-left': 'auto' }}>
{form().description.length}/2000
</span>
</div>
</div>
<div class="field">
<label class="label">Tags (comma-separated)</label>
<input class="input" type="text" placeholder="e.g. Wedding, Outdoor, Mumbai"
value={form().tags} onInput={f('tags')} />
<label class="label">Tags <span style={{ color: '#94a3b8', 'font-size': '12px', 'font-weight': '400' }}>(comma-separated, optional)</span></label>
<input
class="input"
type="text"
placeholder="e.g. Wedding, Outdoor, Mumbai"
value={form().tags}
onInput={f('tags')}
style={{ 'border-color': submitted() && !tagsOk() ? '#e11d48' : '' }}
/>
<Show when={form().tags.trim() || submitted()}>
<VNote ok={tagsOk()} okMsg="Tags look good" errMsg="Each tag max 40 characters, max 20 tags" />
</Show>
</div>
<div style={{ display: 'flex', gap: '10px' }}>
<button type="submit" class="btn btn-primary" disabled={saving()}>
{saving() ? 'Saving...' : '💾 Save'}

View file

@ -1,23 +1,40 @@
import { createResource, createSignal, Show, For } from 'solid-js';
import { createResource, createSignal, createMemo, Show, For } from 'solid-js';
import { A } from '@solidjs/router';
import { getAuthHeader, authState, getRoleApiPath } from '~/lib/auth';
import {
isValidTitle,
isValidDescription,
isValidLocation,
isValidBudget,
} from '~/lib/form-validation';
const API = import.meta.env.VITE_API_URL ?? 'http://localhost:8000';
function VNote(props: { show: boolean; ok: boolean; okMsg: string; errMsg: string }) {
return (
<Show when={props.show}>
<p class="validation-note" style={{ color: props.ok ? '#fd6116' : '#6e7591', 'font-size': '12px', margin: '3px 0 0' }}>
{props.ok ? `${props.okMsg}` : `${props.errMsg}`}
</p>
</Show>
);
}
export default function Requirements() {
const [page, setPage] = createSignal(1);
const [showCreate, setShowCreate] = createSignal(false);
const [creating, setCreating] = createSignal(false);
const [createError, setCreateError] = createSignal('');
const [activeCount, setActiveCount] = createSignal(0);
const [submitted, setSubmitted] = createSignal(false);
const rc = () => authState().runtime_config;
const rolePrefix = () => getRoleApiPath(rc()?.role);
const PROFESSIONS = () => rc()?.role_config?.professions ?? [
'PHOTOGRAPHER','MAKEUP_ARTIST','TUTOR','DEVELOPER',
'VIDEO_EDITOR','GRAPHIC_DESIGNER','SOCIAL_MEDIA_MANAGER',
'FITNESS_TRAINER','CATERING_SERVICES'
'PHOTOGRAPHER', 'MAKEUP_ARTIST', 'TUTOR', 'DEVELOPER',
'VIDEO_EDITOR', 'GRAPHIC_DESIGNER', 'SOCIAL_MEDIA_MANAGER',
'FITNESS_TRAINER', 'CATERING_SERVICES',
];
const [form, setForm] = createSignal({
@ -29,6 +46,17 @@ export default function Requirements() {
preferred_date: '',
});
// Real-time validation
const titleOk = createMemo(() => isValidTitle(form().title, 5, 200));
const descOk = createMemo(() => isValidDescription(form().description, 20, 2000));
const locOk = createMemo(() => isValidLocation(form().location, 2, 255));
const budgetOk = createMemo(() => isValidBudget(form().budget));
const dateOk = createMemo(() => {
if (!form().preferred_date) return true;
return new Date(form().preferred_date) >= new Date(new Date().toDateString());
});
const canSubmit = createMemo(() => titleOk() && descOk() && locOk() && budgetOk() && dateOk());
const [requirements, { refetch }] = createResource(
() => page(),
async (p: number) => {
@ -37,40 +65,39 @@ export default function Requirements() {
const res = await fetch(`${API}${rolePrefix()}/requirements?page=${p}&limit=20`, { headers: auth });
if (!res.ok) return { data: [] };
const data = await res.json();
// Calculate active count for limit enforcement
const count = data.data?.filter((r: any) => r.status === 'OPEN').length || 0;
setActiveCount(count);
return data;
}
);
async function createRequirement(e: Event) {
e.preventDefault();
setSubmitted(true);
setCreateError('');
const f = form();
if (!f.title || !f.description || !f.location) {
setCreateError('Title, description and location are required.');
return;
}
if (!titleOk()) { setCreateError('Title must be 5200 characters.'); return; }
if (!descOk()) { setCreateError('Description must be at least 20 characters.'); return; }
if (!locOk()) { setCreateError('Location must be at least 2 characters.'); return; }
if (!budgetOk()) { setCreateError('Budget must be a positive number.'); return; }
if (!dateOk()) { setCreateError('Preferred date cannot be in the past.'); return; }
setCreating(true);
const f = form();
const body: Record<string, any> = {
profession_key: f.profession_key,
title: f.title,
description: f.description,
location: f.location,
title: f.title.trim(),
description: f.description.trim(),
location: f.location.trim(),
};
if (f.budget) body.budget = parseInt(f.budget) * 100; // paise
if (f.preferred_date) body.preferred_date = f.preferred_date;
// Support dynamic custom fields from role_config if needed
if (rc()?.role_config?.fields) {
rc()?.role_config.fields.forEach((field: any) => {
if (f[field.name as keyof typeof f]) {
body[field.name] = f[field.name as keyof typeof f];
}
});
rc()?.role_config.fields.forEach((fld: any) => {
const val = f[fld.name as keyof typeof f];
if (val) body[fld.name] = val;
});
}
const res = await fetch(`${API}${rolePrefix()}/requirements`, {
@ -81,6 +108,7 @@ export default function Requirements() {
setCreating(false);
if (res.ok) {
setShowCreate(false);
setSubmitted(false);
setForm({ profession_key: 'PHOTOGRAPHER', title: '', description: '', location: '', budget: '', preferred_date: '' });
refetch();
} else {
@ -102,11 +130,11 @@ export default function Requirements() {
You can have up to 2 active requirements.
</p>
</div>
<button
class="btn btn-primary"
disabled={!showCreate() && activeCount() >= 2}
onClick={() => setShowCreate(s => !s)}
title={activeCount() >= 2 ? "You can only have 2 active requirements at a time." : ""}
<button
class="btn btn-primary"
disabled={!showCreate() && activeCount() >= 2}
onClick={() => { setShowCreate(s => !s); setSubmitted(false); }}
title={activeCount() >= 2 ? 'You can only have 2 active requirements at a time.' : ''}
>
{showCreate() ? '✕ Cancel' : activeCount() >= 2 ? 'Limit Reached (2)' : '+ Post Requirement'}
</button>
@ -128,43 +156,91 @@ export default function Requirements() {
<form onSubmit={createRequirement}>
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '14px' }}>
<div class="field">
<label class="label">Profession Type *</label>
<label class="label">Profession Type <span style={{ color: '#e11d48' }}>*</span></label>
<select class="select" value={form().profession_key}
onChange={(e) => setForm(f => ({ ...f, profession_key: e.currentTarget.value }))}>
{PROFESSIONS().map((p: string) => <option value={p}>{p.replace(/_/g, ' ')}</option>)}
</select>
</div>
<div class="field">
<label class="label">Location *</label>
<input class="input" type="text" placeholder="e.g. Mumbai"
value={form().location} onInput={(e) => setForm(f => ({ ...f, location: e.currentTarget.value }))} />
<label class="label">Location <span style={{ color: '#e11d48' }}>*</span></label>
<input
class="input"
type="text"
placeholder="e.g. Mumbai"
value={form().location}
onInput={(e) => setForm(f => ({ ...f, location: e.currentTarget.value }))}
style={{ 'border-color': submitted() && !locOk() ? '#e11d48' : '' }}
/>
<VNote show={!!form().location.trim() || submitted()} ok={locOk()}
okMsg="Location looks good" errMsg="At least 2 characters" />
</div>
</div>
<div class="field">
<label class="label">Title *</label>
<input class="input" type="text" placeholder="e.g. Photographer needed for wedding"
value={form().title} onInput={(e) => setForm(f => ({ ...f, title: e.currentTarget.value }))} maxLength={200} />
</div>
<div class="field">
<label class="label">Description *</label>
<textarea class="textarea" placeholder="Describe what you need in detail..."
style={{ 'min-height': '120px' }}
value={form().description} onInput={(e) => setForm(f => ({ ...f, description: e.currentTarget.value }))}
<label class="label">Title <span style={{ color: '#e11d48' }}>*</span></label>
<input
class="input"
type="text"
placeholder="e.g. Photographer needed for wedding"
value={form().title}
onInput={(e) => setForm(f => ({ ...f, title: e.currentTarget.value }))}
maxLength={200}
style={{ 'border-color': submitted() && !titleOk() ? '#e11d48' : '' }}
/>
<VNote show={!!form().title.trim() || submitted()} ok={titleOk()}
okMsg="Title looks good" errMsg="Title must be at least 5 characters" />
</div>
<div class="field">
<label class="label">Description <span style={{ color: '#e11d48' }}>*</span></label>
<textarea
class="textarea"
placeholder="Describe what you need in detail… (min 20 characters)"
style={{ 'min-height': '120px', 'border-color': submitted() && !descOk() ? '#e11d48' : '' }}
value={form().description}
onInput={(e) => setForm(f => ({ ...f, description: e.currentTarget.value }))}
maxLength={2000}
/>
<div style={{ display: 'flex', 'justify-content': 'space-between' }}>
<VNote show={!!form().description.trim() || submitted()} ok={descOk()}
okMsg="Description looks good" errMsg="At least 20 characters required" />
<span style={{ 'font-size': '11px', color: '#94a3b8', 'margin-left': 'auto' }}>
{form().description.length}/2000
</span>
</div>
</div>
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '14px' }}>
<div class="field">
<label class="label">Budget ()</label>
<input class="input" type="number" placeholder="Optional"
value={form().budget} onInput={(e) => setForm(f => ({ ...f, budget: e.currentTarget.value }))} />
<label class="label">Budget () <span style={{ color: '#94a3b8', 'font-size': '12px', 'font-weight': '400' }}>(optional)</span></label>
<input
class="input"
type="number"
placeholder="e.g. 25000"
value={form().budget}
onInput={(e) => setForm(f => ({ ...f, budget: e.currentTarget.value }))}
min="1"
style={{ 'border-color': submitted() && !budgetOk() ? '#e11d48' : '' }}
/>
<VNote show={!!form().budget.trim()} ok={budgetOk()}
okMsg="Budget looks good" errMsg="Must be a positive number" />
</div>
<div class="field">
<label class="label">Preferred Date</label>
<input class="input" type="date"
value={form().preferred_date} onInput={(e) => setForm(f => ({ ...f, preferred_date: e.currentTarget.value }))} />
<label class="label">Preferred Date <span style={{ color: '#94a3b8', 'font-size': '12px', 'font-weight': '400' }}>(optional)</span></label>
<input
class="input"
type="date"
value={form().preferred_date}
onInput={(e) => setForm(f => ({ ...f, preferred_date: e.currentTarget.value }))}
style={{ 'border-color': submitted() && !dateOk() ? '#e11d48' : '' }}
/>
<VNote show={!!form().preferred_date && !dateOk()} ok={false}
okMsg="" errMsg="Date cannot be in the past" />
</div>
</div>
<div style={{ display: 'flex', gap: '10px', 'margin-top': '4px' }}>
<div style={{ display: 'flex', gap: '10px', 'margin-top': '8px' }}>
<button type="submit" class="btn btn-primary" disabled={creating()}>
{creating() ? 'Posting...' : '📋 Post Requirement'}
</button>

View file

@ -1,18 +1,41 @@
import { createResource, createSignal, Show, For } from 'solid-js';
import { createResource, createSignal, createMemo, Show, For } from 'solid-js';
import { getAuthHeader } from '~/lib/auth';
import {
isValidTitle,
isValidPrice,
isValidDuration,
} from '~/lib/form-validation';
const API = import.meta.env.VITE_API_URL ?? 'http://localhost:8000';
function VNote(props: { show: boolean; ok: boolean; okMsg: string; errMsg: string }) {
return (
<Show when={props.show}>
<p class="validation-note" style={{ color: props.ok ? '#fd6116' : '#6e7591', 'font-size': '12px', margin: '3px 0 0' }}>
{props.ok ? `${props.okMsg}` : `${props.errMsg}`}
</p>
</Show>
);
}
export default function Services() {
const [showForm, setShowForm] = createSignal(false);
const [saving, setSaving] = createSignal(false);
const [editId, setEditId] = createSignal<string | null>(null);
const [error, setError] = createSignal('');
const [submitted, setSubmitted] = createSignal(false);
const [form, setForm] = createSignal({
name: '', description: '', price: '', duration_minutes: '',
});
function f(k: string) { return (e: any) => setForm(p => ({ ...p, [k]: e.target.value })); }
// Real-time validation
const nameOk = createMemo(() => isValidTitle(form().name, 3, 255));
const priceOk = createMemo(() => isValidPrice(form().price));
const durationOk = createMemo(() => isValidDuration(form().duration_minutes));
const descOk = createMemo(() => !form().description.trim() || form().description.trim().length <= 1000);
const canSubmit = createMemo(() => nameOk() && priceOk() && durationOk() && descOk());
const [services, { refetch }] = createResource(async () => {
const auth = getAuthHeader();
if (!auth.Authorization) return { data: [] };
@ -23,13 +46,19 @@ export default function Services() {
async function saveService(e: Event) {
e.preventDefault();
setSubmitted(true);
setError('');
const data = form();
if (!data.name || !data.price) { setError('Service name and price are required.'); return; }
if (!nameOk()) { setError('Service name must be 3255 characters.'); return; }
if (!priceOk()) { setError('Price must be a positive number.'); return; }
if (!durationOk()) { setError('Duration must be between 1 and 1440 minutes if provided.'); return; }
if (!descOk()) { setError('Description must be 1000 characters or less.'); return; }
setSaving(true);
const data = form();
const body: Record<string, any> = {
name: data.name,
description: data.description || null,
name: data.name.trim(),
description: data.description.trim() || null,
price: Math.round(parseFloat(data.price) * 100), // paise
};
if (data.duration_minutes) body.duration_minutes = parseInt(data.duration_minutes);
@ -47,6 +76,7 @@ export default function Services() {
if (res.ok) {
setShowForm(false);
setEditId(null);
setSubmitted(false);
setForm({ name: '', description: '', price: '', duration_minutes: '' });
refetch();
} else {
@ -66,6 +96,7 @@ export default function Services() {
function startEdit(svc: any) {
setEditId(svc.id);
setSubmitted(false);
setForm({
name: svc.name,
description: svc.description ?? '',
@ -75,12 +106,19 @@ export default function Services() {
setShowForm(true);
}
function openCreate() {
setEditId(null);
setSubmitted(false);
setForm({ name: '', description: '', price: '', duration_minutes: '' });
setError('');
setShowForm(s => !s);
}
return (
<div>
<div class="page-actions">
<h1 style={{ margin: 0, 'font-size': '22px', 'font-weight': '800' }}>My Services</h1>
<button class="btn btn-primary"
onClick={() => { setShowForm(s => !s); setEditId(null); setForm({ name: '', description: '', price: '', duration_minutes: '' }); }}>
<button class="btn btn-primary" onClick={openCreate}>
{showForm() ? '✕ Cancel' : '+ Add Service'}
</button>
</div>
@ -93,29 +131,73 @@ export default function Services() {
<Show when={error()}>
<div class="error-banner" style={{ 'margin-bottom': '12px' }}>{error()}</div>
</Show>
<form onSubmit={saveService} style={{ display: 'flex', 'flex-direction': 'column', gap: '12px' }}>
<form onSubmit={saveService} style={{ display: 'flex', 'flex-direction': 'column', gap: '14px' }}>
<div class="field">
<label class="label">Service Name *</label>
<input class="input" type="text" placeholder="e.g. Full Day Wedding Photography"
value={form().name} onInput={f('name')} maxLength={255} />
<label class="label">Service Name <span style={{ color: '#e11d48' }}>*</span></label>
<input
class="input"
type="text"
placeholder="e.g. Full Day Wedding Photography"
value={form().name}
onInput={f('name')}
maxLength={255}
style={{ 'border-color': submitted() && !nameOk() ? '#e11d48' : '' }}
/>
<VNote show={!!form().name.trim() || submitted()} ok={nameOk()}
okMsg="Name looks good" errMsg="Service name must be at least 3 characters" />
</div>
<div class="field">
<label class="label">Description</label>
<textarea class="textarea" placeholder="What does this service include?"
style={{ 'min-height': '80px' }} value={form().description} onInput={f('description')} />
</div>
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '12px' }}>
<div class="field">
<label class="label">Price () *</label>
<input class="input" type="number" placeholder="e.g. 25000" step="0.01"
value={form().price} onInput={f('price')} min="0" />
</div>
<div class="field">
<label class="label">Duration (minutes)</label>
<input class="input" type="number" placeholder="e.g. 480"
value={form().duration_minutes} onInput={f('duration_minutes')} min="0" />
<label class="label">Description <span style={{ color: '#94a3b8', 'font-size': '12px', 'font-weight': '400' }}>(optional)</span></label>
<textarea
class="textarea"
placeholder="What does this service include?"
style={{ 'min-height': '80px', 'border-color': !descOk() ? '#e11d48' : '' }}
value={form().description}
onInput={f('description')}
maxLength={1000}
/>
<div style={{ display: 'flex', 'justify-content': 'space-between' }}>
<VNote show={!descOk()} ok={false} okMsg="" errMsg="Description must be 1000 characters or less" />
<span style={{ 'font-size': '11px', color: form().description.length > 950 ? '#e11d48' : '#94a3b8', 'margin-left': 'auto' }}>
{form().description.length}/1000
</span>
</div>
</div>
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '14px' }}>
<div class="field">
<label class="label">Price () <span style={{ color: '#e11d48' }}>*</span></label>
<input
class="input"
type="number"
placeholder="e.g. 25000"
step="0.01"
value={form().price}
onInput={f('price')}
min="1"
style={{ 'border-color': submitted() && !priceOk() ? '#e11d48' : '' }}
/>
<VNote show={!!form().price || submitted()} ok={priceOk()}
okMsg="Price looks good" errMsg="Price must be a positive number" />
</div>
<div class="field">
<label class="label">Duration (minutes) <span style={{ color: '#94a3b8', 'font-size': '12px', 'font-weight': '400' }}>(optional)</span></label>
<input
class="input"
type="number"
placeholder="e.g. 480"
value={form().duration_minutes}
onInput={f('duration_minutes')}
min="1"
max="1440"
style={{ 'border-color': submitted() && !durationOk() ? '#e11d48' : '' }}
/>
<VNote show={!!form().duration_minutes} ok={durationOk()}
okMsg="Duration looks good" errMsg="Must be 11440 minutes (max 24 hrs)" />
</div>
</div>
<div style={{ display: 'flex', gap: '10px' }}>
<button type="submit" class="btn btn-primary" disabled={saving()}>
{saving() ? 'Saving...' : '💾 Save Service'}

View file

@ -1,17 +1,43 @@
import { createResource, createSignal, Show } from 'solid-js';
import { createResource, createSignal, createMemo, Show } from 'solid-js';
import { getAuthHeader } from '~/lib/auth';
import {
isValidFullName,
isValidPhone,
isValidLocation,
checkPasswordStrength,
isPasswordStrong,
} from '~/lib/form-validation';
const API = import.meta.env.VITE_API_URL ?? 'http://localhost:8000';
function VNote(props: { show: boolean; ok: boolean; okMsg: string; errMsg: string }) {
return (
<Show when={props.show}>
<p class="validation-note" style={{ color: props.ok ? '#fd6116' : '#6e7591', 'font-size': '12px', margin: '3px 0 0' }}>
{props.ok ? `${props.okMsg}` : `${props.errMsg}`}
</p>
</Show>
);
}
/**
* Universal profile/settings page role-aware.
* Always edits /api/me (common fields: full_name, phone, location, bio).
* Role-specific profile link shown below.
*/
export default function ProfileSettings() {
const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal('');
const [success, setSuccess] = createSignal(false);
const [profileSubmitted, setProfileSubmitted] = createSignal(false);
const [pwSubmitted, setPwSubmitted] = createSignal(false);
// Change-password fields
const [currentPw, setCurrentPw] = createSignal('');
const [newPw, setNewPw] = createSignal('');
const [confirmPw, setConfirmPw] = createSignal('');
const [pwError, setPwError] = createSignal('');
const [pwSuccess, setPwSuccess] = createSignal(false);
const [pwSaving, setPwSaving] = createSignal(false);
const [me, { refetch }] = createResource(async () => {
const auth = getAuthHeader();
@ -43,11 +69,30 @@ export default function ProfileSettings() {
function f(k: string) { return (e: any) => setForm(p => ({ ...p, [k]: e.target.value })); }
// Profile validation
const nameOk = createMemo(() => isValidFullName(form().full_name));
const phoneOk = createMemo(() => !form().phone.trim() || isValidPhone(form().phone));
const locationOk = createMemo(() => !form().location.trim() || isValidLocation(form().location));
const bioOk = createMemo(() => form().bio.trim().length <= 500);
const profileOk = createMemo(() => nameOk() && phoneOk() && locationOk() && bioOk());
// Password validation
const pwChecks = createMemo(() => checkPasswordStrength(newPw(), confirmPw()));
const newPwStrong = createMemo(() => isPasswordStrong(pwChecks()));
const pwOk = createMemo(() => !!currentPw().trim() && newPwStrong() && pwChecks().match);
async function handleSubmit(e: Event) {
e.preventDefault();
setProfileSubmitted(true);
setSaving(true);
setError('');
setSuccess(false);
if (!nameOk()) { setError('Full name must be 2100 characters, letters only.'); setSaving(false); return; }
if (!phoneOk()) { setError('Please enter a valid 10-digit Indian mobile number.'); setSaving(false); return; }
if (!locationOk()) { setError('Location must be at least 2 characters.'); setSaving(false); return; }
if (!bioOk()) { setError('Bio must be 500 characters or less.'); setSaving(false); return; }
const res = await fetch(`${API}/api/me`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...getAuthHeader() },
@ -65,18 +110,31 @@ export default function ProfileSettings() {
async function changePassword(e: Event) {
e.preventDefault();
const f = e.target as HTMLFormElement;
const current = (f.elements.namedItem('current') as HTMLInputElement).value;
const next = (f.elements.namedItem('next') as HTMLInputElement).value;
const confirm_ = (f.elements.namedItem('confirm') as HTMLInputElement).value;
if (next !== confirm_) { alert('New passwords do not match'); return; }
setPwSubmitted(true);
setPwError('');
setPwSuccess(false);
if (!currentPw().trim()) { setPwError('Current password is required.'); return; }
if (!newPwStrong()) { setPwError('New password must meet all strength requirements.'); return; }
if (!pwChecks().match) { setPwError('New passwords do not match.'); return; }
setPwSaving(true);
const res = await fetch(`${API}/api/auth/change-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeader() },
body: JSON.stringify({ current_password: current, new_password: next }),
body: JSON.stringify({ current_password: currentPw(), new_password: newPw() }),
});
if (res.ok) { alert('Password changed successfully'); f.reset(); }
else { const d = await res.json(); alert(d.error ?? 'Failed'); }
setPwSaving(false);
if (res.ok) {
setPwSuccess(true);
setCurrentPw('');
setNewPw('');
setConfirmPw('');
setPwSubmitted(false);
} else {
const d = await res.json();
setPwError(d.error ?? 'Failed to change password');
}
}
return (
@ -91,7 +149,7 @@ export default function ProfileSettings() {
<Show when={!me.loading}>
{/* Personal Details */}
<div class="form-card" style={{ 'margin-bottom': '20px' }}>
<h3 style={{ margin: '0 0 4px', 'font-size': '16px', 'font-weight': '700' }}>Personal Details</h3>
<h3 style={{ margin: '0 0 16px', 'font-size': '16px', 'font-weight': '700' }}>Personal Details</h3>
<Show when={success()}>
<div class="status-banner status-banner--success"> Saved successfully</div>
@ -102,24 +160,67 @@ export default function ProfileSettings() {
<form onSubmit={handleSubmit} style={{ display: 'flex', 'flex-direction': 'column', gap: '14px' }}>
<div class="field">
<label class="label">Full Name</label>
<input class="input" type="text" value={form().full_name} onInput={f('full_name')} maxLength={255} />
<label class="label">Full Name <span style={{ color: '#e11d48' }}>*</span></label>
<input
class="input"
type="text"
value={form().full_name}
onInput={f('full_name')}
maxLength={100}
style={{ 'border-color': profileSubmitted() && !nameOk() ? '#e11d48' : '' }}
/>
<VNote show={!!form().full_name.trim() || profileSubmitted()} ok={nameOk()}
okMsg="Name looks good"
errMsg="Full name required (2100 letters, spaces or hyphens)" />
</div>
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '12px' }}>
<div class="field">
<label class="label">Phone</label>
<input class="input" type="tel" value={form().phone} onInput={f('phone')} />
<input
class="input"
type="tel"
value={form().phone}
onInput={f('phone')}
placeholder="10-digit mobile number"
style={{ 'border-color': profileSubmitted() && !phoneOk() ? '#e11d48' : '' }}
/>
<VNote show={!!form().phone.trim() || profileSubmitted()} ok={phoneOk()}
okMsg="Valid mobile number"
errMsg="Enter a valid 10-digit mobile number" />
</div>
<div class="field">
<label class="label">Location</label>
<input class="input" type="text" placeholder="City, State" value={form().location} onInput={f('location')} />
<input
class="input"
type="text"
placeholder="City, State"
value={form().location}
onInput={f('location')}
style={{ 'border-color': profileSubmitted() && !locationOk() ? '#e11d48' : '' }}
/>
<VNote show={!!form().location.trim() || profileSubmitted()} ok={locationOk()}
okMsg="Location looks good"
errMsg="At least 2 characters" />
</div>
</div>
<div class="field">
<label class="label">Bio</label>
<textarea class="textarea" placeholder="Brief bio..." style={{ 'min-height': '80px' }}
value={form().bio} onInput={f('bio')} maxLength={500} />
<span style={{ 'font-size': '11px', color: '#94a3b8' }}>{form().bio.length}/500</span>
<textarea
class="textarea"
placeholder="Brief bio..."
style={{ 'min-height': '80px', 'border-color': !bioOk() ? '#e11d48' : '' }}
value={form().bio}
onInput={f('bio')}
maxLength={500}
/>
<div style={{ display: 'flex', 'justify-content': 'space-between' }}>
<VNote show={!bioOk()} ok={false} okMsg="" errMsg="Bio must be 500 characters or less" />
<span style={{ 'font-size': '11px', color: form().bio.length > 490 ? '#e11d48' : '#94a3b8', 'margin-left': 'auto' }}>
{form().bio.length}/500
</span>
</div>
</div>
{/* Email — read-only */}
@ -148,21 +249,68 @@ export default function ProfileSettings() {
{/* Change Password */}
<div class="form-card">
<h3 style={{ margin: '0 0 16px', 'font-size': '16px', 'font-weight': '700' }}>Change Password</h3>
<Show when={pwSuccess()}>
<div class="status-banner status-banner--success" style={{ 'margin-bottom': '12px' }}>
Password changed successfully
</div>
</Show>
<Show when={pwError()}>
<div class="error-banner" style={{ 'margin-bottom': '12px' }}>{pwError()}</div>
</Show>
<form onSubmit={changePassword} style={{ display: 'flex', 'flex-direction': 'column', gap: '14px' }}>
<div class="field">
<label class="label">Current Password</label>
<input class="input" type="password" name="current" required />
<label class="label">Current Password <span style={{ color: '#e11d48' }}>*</span></label>
<input
class="input"
type="password"
value={currentPw()}
onInput={(e) => setCurrentPw(e.currentTarget.value)}
style={{ 'border-color': pwSubmitted() && !currentPw().trim() ? '#e11d48' : '' }}
/>
<VNote show={pwSubmitted() && !currentPw().trim()} ok={false}
okMsg="" errMsg="Current password is required" />
</div>
<div class="field">
<label class="label">New Password</label>
<input class="input" type="password" name="next" required minLength={8} />
<label class="label">New Password <span style={{ color: '#e11d48' }}>*</span></label>
<input
class="input"
type="password"
value={newPw()}
onInput={(e) => setNewPw(e.currentTarget.value)}
style={{ 'border-color': pwSubmitted() && !newPwStrong() ? '#e11d48' : '' }}
/>
<Show when={newPw()}>
<div class="password-strength-grid" style={{ 'margin-top': '6px' }}>
<p style={{ color: pwChecks().minLength ? '#fd6116' : '#6e7591', 'font-size': '12px' }}>{pwChecks().minLength ? '✓' : '•'} 8+ chars</p>
<p style={{ color: pwChecks().uppercase ? '#fd6116' : '#6e7591', 'font-size': '12px' }}>{pwChecks().uppercase ? '✓' : '•'} Uppercase</p>
<p style={{ color: pwChecks().special ? '#fd6116' : '#6e7591', 'font-size': '12px' }}>{pwChecks().special ? '✓' : '•'} Special</p>
<p style={{ color: pwChecks().lowercase ? '#fd6116' : '#6e7591', 'font-size': '12px' }}>{pwChecks().lowercase ? '✓' : '•'} Lowercase</p>
<p style={{ color: pwChecks().number ? '#fd6116' : '#6e7591', 'font-size': '12px' }}>{pwChecks().number ? '✓' : '•'} Number</p>
</div>
</Show>
</div>
<div class="field">
<label class="label">Confirm New Password</label>
<input class="input" type="password" name="confirm" required minLength={8} />
<label class="label">Confirm New Password <span style={{ color: '#e11d48' }}>*</span></label>
<input
class="input"
type="password"
value={confirmPw()}
onInput={(e) => setConfirmPw(e.currentTarget.value)}
style={{ 'border-color': pwSubmitted() && !pwChecks().match ? '#e11d48' : '' }}
/>
<VNote show={!!confirmPw()} ok={pwChecks().match}
okMsg="Passwords match"
errMsg="Passwords do not match" />
</div>
<div>
<button type="submit" class="btn">🔒 Update Password</button>
<button type="submit" class="btn" disabled={pwSaving()}>
{pwSaving() ? 'Updating...' : '🔒 Update Password'}
</button>
</div>
</form>
</div>