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(); 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 * Get user-friendly error message for validation failure
* @param fieldName - Name of the field * @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 { useNavigate } from '@solidjs/router';
import { getAuthHeader, authState } from '~/lib/auth'; 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 API = import.meta.env.VITE_API_URL ?? 'http://localhost:8000';
const JOB_TYPES = ['FULL_TIME', 'PART_TIME', 'CONTRACT', 'INTERNSHIP']; 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() { export default function CreateJob() {
const navigate = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = createSignal(false); const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal(''); const [error, setError] = createSignal('');
const [submitted, setSubmitted] = createSignal(false);
const [usage] = createResource(async () => { const [usage] = createResource(async () => {
const auth = getAuthHeader(); const auth = getAuthHeader();
if (!auth.Authorization) return { count: 0 }; 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 }); const res = await fetch(`${API}/api/companies/jobs?limit=100`, { headers: auth });
if (!res.ok) return { count: 0 }; if (!res.ok) return { count: 0 };
const data = await res.json(); const data = await res.json();
@ -27,6 +44,7 @@ export default function CreateJob() {
}); });
const isFree = () => (usage()?.count ?? 0) < 1; const isFree = () => (usage()?.count ?? 0) < 1;
const [form, setForm] = createSignal({ const [form, setForm] = createSignal({
title: '', title: '',
category: '', category: '',
@ -43,28 +61,44 @@ export default function CreateJob() {
return (e: any) => setForm(f => ({ ...f, [key]: e.target.value })); 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) { async function handleSubmit(e: Event) {
e.preventDefault(); e.preventDefault();
setSubmitted(true);
setError(''); setError('');
const f = form(); if (!titleOk()) { setError('Job title must be 5200 characters.'); return; }
if (!f.title || !f.description || !f.location) { if (!descOk()) { setError('Description must be at least 20 characters.'); return; }
setError('Title, description, and location are required.'); if (!locationOk()) { setError('Location must be at least 2 characters.'); return; }
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> = { const body: Record<string, any> = {
title: f.title, title: f.title.trim(),
category: f.category || null, category: f.category.trim() || null,
description: f.description, description: f.description.trim(),
location: f.location, location: f.location.trim(),
job_type: f.job_type, job_type: f.job_type,
}; };
if (f.salary_min) body.salary_min = parseInt(f.salary_min) * 100; // convert ₹ to paise 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.salary_max) body.salary_max = parseInt(f.salary_max) * 100;
if (f.experience_years) body.experience_years = parseInt(f.experience_years); 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); setLoading(true);
try { try {
@ -95,8 +129,8 @@ export default function CreateJob() {
{isFree() ? '✨ Monthly Free Job Posting' : '🪙 Paid Job Posting'} {isFree() ? '✨ Monthly Free Job Posting' : '🪙 Paid Job Posting'}
</h3> </h3>
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: isFree() ? '#15803d' : '#c2410c' }}> <p style={{ margin: '4px 0 0', 'font-size': '13px', color: isFree() ? '#15803d' : '#c2410c' }}>
{isFree() {isFree()
? "This is your 1st job this month—it's completely free!" ? "This is your 1st job this month — it's completely free!"
: "Your monthly free quota is exhausted. Posting this job will cost 100 Tracecoins."} : "Your monthly free quota is exhausted. Posting this job will cost 100 Tracecoins."}
</p> </p>
</div> </div>
@ -107,39 +141,72 @@ export default function CreateJob() {
</div> </div>
<Show when={error()}> <Show when={error()}>
<div class="error-banner">{error()}</div> <div class="error-banner" style={{ 'margin-bottom': '16px' }}>{error()}</div>
</Show> </Show>
<form onSubmit={handleSubmit} style={{ display: 'flex', 'flex-direction': 'column', gap: '16px' }}> <form onSubmit={handleSubmit} style={{ display: 'flex', 'flex-direction': 'column', gap: '16px' }}>
<div class="form-card"> <div class="form-card">
<div class="field"> <div class="field">
<label class="label">Job Title *</label> <label class="label">Job Title <span style={{ color: '#e11d48' }}>*</span></label>
<input class="input" type="text" placeholder="e.g. Frontend Developer" maxLength={200} <input
value={form().title} onInput={field('title')} /> 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>
<div class="field"> <div class="field">
<label class="label">Category</label> <label class="label">Category</label>
<input class="input" type="text" placeholder="e.g. Technology, Marketing" <input
value={form().category} onInput={field('category')} /> class="input"
type="text"
placeholder="e.g. Technology, Marketing"
value={form().category}
onInput={field('category')}
/>
</div> </div>
<div class="field"> <div class="field">
<label class="label">Description *</label> <label class="label">Description <span style={{ color: '#e11d48' }}>*</span></label>
<textarea class="textarea" placeholder="Describe the role, responsibilities, and requirements..." <textarea
style={{ 'min-height': '160px' }} class="textarea"
value={form().description} onInput={field('description')} maxLength={5000} 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' }}> <div style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'center' }}>
{form().description.length}/5000 <VNote show={!!form().description.trim() || submitted()} ok={descOk()}
</span> 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>
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '14px' }}> <div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '14px' }}>
<div class="field"> <div class="field">
<label class="label">Location *</label> <label class="label">Location <span style={{ color: '#e11d48' }}>*</span></label>
<input class="input" type="text" placeholder="e.g. Mumbai, Remote" <input
value={form().location} onInput={field('location')} /> 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>
<div class="field"> <div class="field">
<label class="label">Job Type</label> <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 style={{ display: 'grid', 'grid-template-columns': '1fr 1fr 1fr', gap: '14px' }}>
<div class="field"> <div class="field">
<label class="label">Min Salary (/yr)</label> <label class="label">Min Salary (/yr)</label>
<input class="input" type="number" placeholder="e.g. 500000" <input
value={form().salary_min} onInput={field('salary_min')} min="0" /> 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>
<div class="field"> <div class="field">
<label class="label">Max Salary (/yr)</label> <label class="label">Max Salary (/yr)</label>
<input class="input" type="number" placeholder="e.g. 1200000" <input
value={form().salary_max} onInput={field('salary_max')} min="0" /> 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>
<div class="field"> <div class="field">
<label class="label">Experience (years)</label> <label class="label">Experience (years)</label>
<input class="input" type="number" placeholder="e.g. 2" <input
value={form().experience_years} onInput={field('experience_years')} min="0" /> 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>
</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"> <div class="field" style={{ 'margin-top': '4px' }}>
<label class="label">Skills (comma-separated)</label> <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" <input
value={form().skills} onInput={field('skills')} /> 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>
</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 { 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'; 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() { export default function Portfolio() {
const [showForm, setShowForm] = createSignal(false); const [showForm, setShowForm] = createSignal(false);
const [saving, setSaving] = createSignal(false); const [saving, setSaving] = createSignal(false);
const [editId, setEditId] = createSignal<string | null>(null); const [editId, setEditId] = createSignal<string | null>(null);
const [error, setError] = createSignal(''); const [error, setError] = createSignal('');
const [submitted, setSubmitted] = createSignal(false);
const [form, setForm] = createSignal({ title: '', description: '', tags: '' }); const [form, setForm] = createSignal({ title: '', description: '', tags: '' });
const rc = () => authState().runtime_config; const rc = () => authState().runtime_config;
const rolePrefix = () => getRoleApiPath(rc()?.role); const rolePrefix = () => getRoleApiPath(rc()?.role);
function f(k: string) { return (e: any) => setForm(p => ({ ...p, [k]: e.target.value })); } 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 [items, { refetch }] = createResource(async () => {
const auth = getAuthHeader(); const auth = getAuthHeader();
if (!auth.Authorization || !rc()?.role) return { data: [] }; if (!auth.Authorization || !rc()?.role) return { data: [] };
@ -23,13 +40,18 @@ export default function Portfolio() {
async function saveItem(e: Event) { async function saveItem(e: Event) {
e.preventDefault(); e.preventDefault();
setSubmitted(true);
setError(''); 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); setSaving(true);
const data = form();
const body = { const body = {
title: data.title, title: data.title.trim(),
description: data.description || null, description: data.description.trim() || null,
tags: data.tags ? data.tags.split(',').map(s => s.trim()).filter(Boolean) : [], tags: data.tags ? data.tags.split(',').map(s => s.trim()).filter(Boolean) : [],
}; };
const isEdit = !!editId(); const isEdit = !!editId();
@ -45,6 +67,7 @@ export default function Portfolio() {
if (res.ok) { if (res.ok) {
setShowForm(false); setShowForm(false);
setEditId(null); setEditId(null);
setSubmitted(false);
setForm({ title: '', description: '', tags: '' }); setForm({ title: '', description: '', tags: '' });
refetch(); refetch();
} else { } else {
@ -64,15 +87,24 @@ export default function Portfolio() {
function startEdit(item: any) { function startEdit(item: any) {
setEditId(item.id); setEditId(item.id);
setSubmitted(false);
setForm({ title: item.title, description: item.description ?? '', tags: (item.tags ?? []).join(', ') }); setForm({ title: item.title, description: item.description ?? '', tags: (item.tags ?? []).join(', ') });
setShowForm(true); setShowForm(true);
} }
function openCreate() {
setEditId(null);
setSubmitted(false);
setForm({ title: '', description: '', tags: '' });
setError('');
setShowForm(s => !s);
}
return ( return (
<div> <div>
<div class="page-actions"> <div class="page-actions">
<h1 style={{ margin: 0, 'font-size': '22px', 'font-weight': '800' }}>Portfolio</h1> <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'} {showForm() ? '✕ Cancel' : '+ Add Item'}
</button> </button>
</div> </div>
@ -86,22 +118,58 @@ export default function Portfolio() {
<Show when={error()}> <Show when={error()}>
<div class="error-banner" style={{ 'margin-bottom': '12px' }}>{error()}</div> <div class="error-banner" style={{ 'margin-bottom': '12px' }}>{error()}</div>
</Show> </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"> <div class="field">
<label class="label">Title *</label> <label class="label">Title <span style={{ color: '#e11d48' }}>*</span></label>
<input class="input" type="text" placeholder="e.g. Wedding at Taj Hotel" <input
value={form().title} onInput={f('title')} maxLength={255} /> 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>
<div class="field"> <div class="field">
<label class="label">Description</label> <label class="label">Description</label>
<textarea class="textarea" placeholder="Describe the project..." <textarea
style={{ 'min-height': '80px' }} value={form().description} onInput={f('description')} /> 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>
<div class="field"> <div class="field">
<label class="label">Tags (comma-separated)</label> <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" <input
value={form().tags} onInput={f('tags')} /> 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>
<div style={{ display: 'flex', gap: '10px' }}> <div style={{ display: 'flex', gap: '10px' }}>
<button type="submit" class="btn btn-primary" disabled={saving()}> <button type="submit" class="btn btn-primary" disabled={saving()}>
{saving() ? 'Saving...' : '💾 Save'} {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 { A } from '@solidjs/router';
import { getAuthHeader, authState, getRoleApiPath } from '~/lib/auth'; 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'; 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() { export default function Requirements() {
const [page, setPage] = createSignal(1); const [page, setPage] = createSignal(1);
const [showCreate, setShowCreate] = createSignal(false); const [showCreate, setShowCreate] = createSignal(false);
const [creating, setCreating] = createSignal(false); const [creating, setCreating] = createSignal(false);
const [createError, setCreateError] = createSignal(''); const [createError, setCreateError] = createSignal('');
const [activeCount, setActiveCount] = createSignal(0); const [activeCount, setActiveCount] = createSignal(0);
const [submitted, setSubmitted] = createSignal(false);
const rc = () => authState().runtime_config; const rc = () => authState().runtime_config;
const rolePrefix = () => getRoleApiPath(rc()?.role); const rolePrefix = () => getRoleApiPath(rc()?.role);
const PROFESSIONS = () => rc()?.role_config?.professions ?? [ const PROFESSIONS = () => rc()?.role_config?.professions ?? [
'PHOTOGRAPHER','MAKEUP_ARTIST','TUTOR','DEVELOPER', 'PHOTOGRAPHER', 'MAKEUP_ARTIST', 'TUTOR', 'DEVELOPER',
'VIDEO_EDITOR','GRAPHIC_DESIGNER','SOCIAL_MEDIA_MANAGER', 'VIDEO_EDITOR', 'GRAPHIC_DESIGNER', 'SOCIAL_MEDIA_MANAGER',
'FITNESS_TRAINER','CATERING_SERVICES' 'FITNESS_TRAINER', 'CATERING_SERVICES',
]; ];
const [form, setForm] = createSignal({ const [form, setForm] = createSignal({
@ -29,6 +46,17 @@ export default function Requirements() {
preferred_date: '', 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( const [requirements, { refetch }] = createResource(
() => page(), () => page(),
async (p: number) => { async (p: number) => {
@ -37,40 +65,39 @@ export default function Requirements() {
const res = await fetch(`${API}${rolePrefix()}/requirements?page=${p}&limit=20`, { headers: auth }); const res = await fetch(`${API}${rolePrefix()}/requirements?page=${p}&limit=20`, { headers: auth });
if (!res.ok) return { data: [] }; if (!res.ok) return { data: [] };
const data = await res.json(); const data = await res.json();
// Calculate active count for limit enforcement
const count = data.data?.filter((r: any) => r.status === 'OPEN').length || 0; const count = data.data?.filter((r: any) => r.status === 'OPEN').length || 0;
setActiveCount(count); setActiveCount(count);
return data; return data;
} }
); );
async function createRequirement(e: Event) { async function createRequirement(e: Event) {
e.preventDefault(); e.preventDefault();
setSubmitted(true);
setCreateError(''); setCreateError('');
const f = form();
if (!f.title || !f.description || !f.location) { if (!titleOk()) { setCreateError('Title must be 5200 characters.'); return; }
setCreateError('Title, description and location are required.'); if (!descOk()) { setCreateError('Description must be at least 20 characters.'); return; }
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); setCreating(true);
const f = form();
const body: Record<string, any> = { const body: Record<string, any> = {
profession_key: f.profession_key, profession_key: f.profession_key,
title: f.title, title: f.title.trim(),
description: f.description, description: f.description.trim(),
location: f.location, location: f.location.trim(),
}; };
if (f.budget) body.budget = parseInt(f.budget) * 100; // paise if (f.budget) body.budget = parseInt(f.budget) * 100; // paise
if (f.preferred_date) body.preferred_date = f.preferred_date; if (f.preferred_date) body.preferred_date = f.preferred_date;
// Support dynamic custom fields from role_config if needed
if (rc()?.role_config?.fields) { if (rc()?.role_config?.fields) {
rc()?.role_config.fields.forEach((field: any) => { rc()?.role_config.fields.forEach((fld: any) => {
if (f[field.name as keyof typeof f]) { const val = f[fld.name as keyof typeof f];
body[field.name] = f[field.name as keyof typeof f]; if (val) body[fld.name] = val;
} });
});
} }
const res = await fetch(`${API}${rolePrefix()}/requirements`, { const res = await fetch(`${API}${rolePrefix()}/requirements`, {
@ -81,6 +108,7 @@ export default function Requirements() {
setCreating(false); setCreating(false);
if (res.ok) { if (res.ok) {
setShowCreate(false); setShowCreate(false);
setSubmitted(false);
setForm({ profession_key: 'PHOTOGRAPHER', title: '', description: '', location: '', budget: '', preferred_date: '' }); setForm({ profession_key: 'PHOTOGRAPHER', title: '', description: '', location: '', budget: '', preferred_date: '' });
refetch(); refetch();
} else { } else {
@ -102,11 +130,11 @@ export default function Requirements() {
You can have up to 2 active requirements. You can have up to 2 active requirements.
</p> </p>
</div> </div>
<button <button
class="btn btn-primary" class="btn btn-primary"
disabled={!showCreate() && activeCount() >= 2} disabled={!showCreate() && activeCount() >= 2}
onClick={() => setShowCreate(s => !s)} onClick={() => { setShowCreate(s => !s); setSubmitted(false); }}
title={activeCount() >= 2 ? "You can only have 2 active requirements at a time." : ""} title={activeCount() >= 2 ? 'You can only have 2 active requirements at a time.' : ''}
> >
{showCreate() ? '✕ Cancel' : activeCount() >= 2 ? 'Limit Reached (2)' : '+ Post Requirement'} {showCreate() ? '✕ Cancel' : activeCount() >= 2 ? 'Limit Reached (2)' : '+ Post Requirement'}
</button> </button>
@ -128,43 +156,91 @@ export default function Requirements() {
<form onSubmit={createRequirement}> <form onSubmit={createRequirement}>
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '14px' }}> <div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '14px' }}>
<div class="field"> <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} <select class="select" value={form().profession_key}
onChange={(e) => setForm(f => ({ ...f, profession_key: e.currentTarget.value }))}> onChange={(e) => setForm(f => ({ ...f, profession_key: e.currentTarget.value }))}>
{PROFESSIONS().map((p: string) => <option value={p}>{p.replace(/_/g, ' ')}</option>)} {PROFESSIONS().map((p: string) => <option value={p}>{p.replace(/_/g, ' ')}</option>)}
</select> </select>
</div> </div>
<div class="field"> <div class="field">
<label class="label">Location *</label> <label class="label">Location <span style={{ color: '#e11d48' }}>*</span></label>
<input class="input" type="text" placeholder="e.g. Mumbai" <input
value={form().location} onInput={(e) => setForm(f => ({ ...f, location: e.currentTarget.value }))} /> 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> </div>
<div class="field"> <div class="field">
<label class="label">Title *</label> <label class="label">Title <span style={{ color: '#e11d48' }}>*</span></label>
<input class="input" type="text" placeholder="e.g. Photographer needed for wedding" <input
value={form().title} onInput={(e) => setForm(f => ({ ...f, title: e.currentTarget.value }))} maxLength={200} /> class="input"
</div> type="text"
<div class="field"> placeholder="e.g. Photographer needed for wedding"
<label class="label">Description *</label> value={form().title}
<textarea class="textarea" placeholder="Describe what you need in detail..." onInput={(e) => setForm(f => ({ ...f, title: e.currentTarget.value }))}
style={{ 'min-height': '120px' }} maxLength={200}
value={form().description} onInput={(e) => setForm(f => ({ ...f, description: e.currentTarget.value }))} 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>
<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 style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '14px' }}>
<div class="field"> <div class="field">
<label class="label">Budget ()</label> <label class="label">Budget () <span style={{ color: '#94a3b8', 'font-size': '12px', 'font-weight': '400' }}>(optional)</span></label>
<input class="input" type="number" placeholder="Optional" <input
value={form().budget} onInput={(e) => setForm(f => ({ ...f, budget: e.currentTarget.value }))} /> 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>
<div class="field"> <div class="field">
<label class="label">Preferred Date</label> <label class="label">Preferred Date <span style={{ color: '#94a3b8', 'font-size': '12px', 'font-weight': '400' }}>(optional)</span></label>
<input class="input" type="date" <input
value={form().preferred_date} onInput={(e) => setForm(f => ({ ...f, preferred_date: e.currentTarget.value }))} /> 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> </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()}> <button type="submit" class="btn btn-primary" disabled={creating()}>
{creating() ? 'Posting...' : '📋 Post Requirement'} {creating() ? 'Posting...' : '📋 Post Requirement'}
</button> </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 { getAuthHeader } from '~/lib/auth';
import {
isValidTitle,
isValidPrice,
isValidDuration,
} from '~/lib/form-validation';
const API = import.meta.env.VITE_API_URL ?? 'http://localhost:8000'; 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() { export default function Services() {
const [showForm, setShowForm] = createSignal(false); const [showForm, setShowForm] = createSignal(false);
const [saving, setSaving] = createSignal(false); const [saving, setSaving] = createSignal(false);
const [editId, setEditId] = createSignal<string | null>(null); const [editId, setEditId] = createSignal<string | null>(null);
const [error, setError] = createSignal(''); const [error, setError] = createSignal('');
const [submitted, setSubmitted] = createSignal(false);
const [form, setForm] = createSignal({ const [form, setForm] = createSignal({
name: '', description: '', price: '', duration_minutes: '', name: '', description: '', price: '', duration_minutes: '',
}); });
function f(k: string) { return (e: any) => setForm(p => ({ ...p, [k]: e.target.value })); } 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 [services, { refetch }] = createResource(async () => {
const auth = getAuthHeader(); const auth = getAuthHeader();
if (!auth.Authorization) return { data: [] }; if (!auth.Authorization) return { data: [] };
@ -23,13 +46,19 @@ export default function Services() {
async function saveService(e: Event) { async function saveService(e: Event) {
e.preventDefault(); e.preventDefault();
setSubmitted(true);
setError(''); 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); setSaving(true);
const data = form();
const body: Record<string, any> = { const body: Record<string, any> = {
name: data.name, name: data.name.trim(),
description: data.description || null, description: data.description.trim() || null,
price: Math.round(parseFloat(data.price) * 100), // paise price: Math.round(parseFloat(data.price) * 100), // paise
}; };
if (data.duration_minutes) body.duration_minutes = parseInt(data.duration_minutes); if (data.duration_minutes) body.duration_minutes = parseInt(data.duration_minutes);
@ -47,6 +76,7 @@ export default function Services() {
if (res.ok) { if (res.ok) {
setShowForm(false); setShowForm(false);
setEditId(null); setEditId(null);
setSubmitted(false);
setForm({ name: '', description: '', price: '', duration_minutes: '' }); setForm({ name: '', description: '', price: '', duration_minutes: '' });
refetch(); refetch();
} else { } else {
@ -66,6 +96,7 @@ export default function Services() {
function startEdit(svc: any) { function startEdit(svc: any) {
setEditId(svc.id); setEditId(svc.id);
setSubmitted(false);
setForm({ setForm({
name: svc.name, name: svc.name,
description: svc.description ?? '', description: svc.description ?? '',
@ -75,12 +106,19 @@ export default function Services() {
setShowForm(true); setShowForm(true);
} }
function openCreate() {
setEditId(null);
setSubmitted(false);
setForm({ name: '', description: '', price: '', duration_minutes: '' });
setError('');
setShowForm(s => !s);
}
return ( return (
<div> <div>
<div class="page-actions"> <div class="page-actions">
<h1 style={{ margin: 0, 'font-size': '22px', 'font-weight': '800' }}>My Services</h1> <h1 style={{ margin: 0, 'font-size': '22px', 'font-weight': '800' }}>My Services</h1>
<button class="btn btn-primary" <button class="btn btn-primary" onClick={openCreate}>
onClick={() => { setShowForm(s => !s); setEditId(null); setForm({ name: '', description: '', price: '', duration_minutes: '' }); }}>
{showForm() ? '✕ Cancel' : '+ Add Service'} {showForm() ? '✕ Cancel' : '+ Add Service'}
</button> </button>
</div> </div>
@ -93,29 +131,73 @@ export default function Services() {
<Show when={error()}> <Show when={error()}>
<div class="error-banner" style={{ 'margin-bottom': '12px' }}>{error()}</div> <div class="error-banner" style={{ 'margin-bottom': '12px' }}>{error()}</div>
</Show> </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"> <div class="field">
<label class="label">Service Name *</label> <label class="label">Service Name <span style={{ color: '#e11d48' }}>*</span></label>
<input class="input" type="text" placeholder="e.g. Full Day Wedding Photography" <input
value={form().name} onInput={f('name')} maxLength={255} /> 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>
<div class="field"> <div class="field">
<label class="label">Description</label> <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?" <textarea
style={{ 'min-height': '80px' }} value={form().description} onInput={f('description')} /> class="textarea"
</div> placeholder="What does this service include?"
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '12px' }}> style={{ 'min-height': '80px', 'border-color': !descOk() ? '#e11d48' : '' }}
<div class="field"> value={form().description}
<label class="label">Price () *</label> onInput={f('description')}
<input class="input" type="number" placeholder="e.g. 25000" step="0.01" maxLength={1000}
value={form().price} onInput={f('price')} min="0" /> />
</div> <div style={{ display: 'flex', 'justify-content': 'space-between' }}>
<div class="field"> <VNote show={!descOk()} ok={false} okMsg="" errMsg="Description must be 1000 characters or less" />
<label class="label">Duration (minutes)</label> <span style={{ 'font-size': '11px', color: form().description.length > 950 ? '#e11d48' : '#94a3b8', 'margin-left': 'auto' }}>
<input class="input" type="number" placeholder="e.g. 480" {form().description.length}/1000
value={form().duration_minutes} onInput={f('duration_minutes')} min="0" /> </span>
</div> </div>
</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' }}> <div style={{ display: 'flex', gap: '10px' }}>
<button type="submit" class="btn btn-primary" disabled={saving()}> <button type="submit" class="btn btn-primary" disabled={saving()}>
{saving() ? 'Saving...' : '💾 Save Service'} {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 { 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'; 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. * Universal profile/settings page role-aware.
* Always edits /api/me (common fields: full_name, phone, location, bio). * Always edits /api/me (common fields: full_name, phone, location, bio).
* Role-specific profile link shown below.
*/ */
export default function ProfileSettings() { export default function ProfileSettings() {
const [saving, setSaving] = createSignal(false); const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal(''); const [error, setError] = createSignal('');
const [success, setSuccess] = createSignal(false); 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 [me, { refetch }] = createResource(async () => {
const auth = getAuthHeader(); 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 })); } 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) { async function handleSubmit(e: Event) {
e.preventDefault(); e.preventDefault();
setProfileSubmitted(true);
setSaving(true); setSaving(true);
setError(''); setError('');
setSuccess(false); 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`, { const res = await fetch(`${API}/api/me`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...getAuthHeader() }, headers: { 'Content-Type': 'application/json', ...getAuthHeader() },
@ -65,18 +110,31 @@ export default function ProfileSettings() {
async function changePassword(e: Event) { async function changePassword(e: Event) {
e.preventDefault(); e.preventDefault();
const f = e.target as HTMLFormElement; setPwSubmitted(true);
const current = (f.elements.namedItem('current') as HTMLInputElement).value; setPwError('');
const next = (f.elements.namedItem('next') as HTMLInputElement).value; setPwSuccess(false);
const confirm_ = (f.elements.namedItem('confirm') as HTMLInputElement).value;
if (next !== confirm_) { alert('New passwords do not match'); return; } 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`, { const res = await fetch(`${API}/api/auth/change-password`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeader() }, 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(); } setPwSaving(false);
else { const d = await res.json(); alert(d.error ?? 'Failed'); } if (res.ok) {
setPwSuccess(true);
setCurrentPw('');
setNewPw('');
setConfirmPw('');
setPwSubmitted(false);
} else {
const d = await res.json();
setPwError(d.error ?? 'Failed to change password');
}
} }
return ( return (
@ -91,7 +149,7 @@ export default function ProfileSettings() {
<Show when={!me.loading}> <Show when={!me.loading}>
{/* Personal Details */} {/* Personal Details */}
<div class="form-card" style={{ 'margin-bottom': '20px' }}> <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()}> <Show when={success()}>
<div class="status-banner status-banner--success"> Saved successfully</div> <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' }}> <form onSubmit={handleSubmit} style={{ display: 'flex', 'flex-direction': 'column', gap: '14px' }}>
<div class="field"> <div class="field">
<label class="label">Full Name</label> <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={255} /> <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>
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '12px' }}> <div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '12px' }}>
<div class="field"> <div class="field">
<label class="label">Phone</label> <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>
<div class="field"> <div class="field">
<label class="label">Location</label> <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> </div>
<div class="field"> <div class="field">
<label class="label">Bio</label> <label class="label">Bio</label>
<textarea class="textarea" placeholder="Brief bio..." style={{ 'min-height': '80px' }} <textarea
value={form().bio} onInput={f('bio')} maxLength={500} /> class="textarea"
<span style={{ 'font-size': '11px', color: '#94a3b8' }}>{form().bio.length}/500</span> 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> </div>
{/* Email — read-only */} {/* Email — read-only */}
@ -148,21 +249,68 @@ export default function ProfileSettings() {
{/* Change Password */} {/* Change Password */}
<div class="form-card"> <div class="form-card">
<h3 style={{ margin: '0 0 16px', 'font-size': '16px', 'font-weight': '700' }}>Change Password</h3> <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' }}> <form onSubmit={changePassword} style={{ display: 'flex', 'flex-direction': 'column', gap: '14px' }}>
<div class="field"> <div class="field">
<label class="label">Current Password</label> <label class="label">Current Password <span style={{ color: '#e11d48' }}>*</span></label>
<input class="input" type="password" name="current" required /> <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>
<div class="field"> <div class="field">
<label class="label">New Password</label> <label class="label">New Password <span style={{ color: '#e11d48' }}>*</span></label>
<input class="input" type="password" name="next" required minLength={8} /> <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>
<div class="field"> <div class="field">
<label class="label">Confirm New Password</label> <label class="label">Confirm New Password <span style={{ color: '#e11d48' }}>*</span></label>
<input class="input" type="password" name="confirm" required minLength={8} /> <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>
<div> <div>
<button type="submit" class="btn">🔒 Update Password</button> <button type="submit" class="btn" disabled={pwSaving()}>
{pwSaving() ? 'Updating...' : '🔒 Update Password'}
</button>
</div> </div>
</form> </form>
</div> </div>