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:
parent
64ec515393
commit
9ba3adda64
6 changed files with 719 additions and 155 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 5–200 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 3–255 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'}
|
||||
|
|
|
|||
|
|
@ -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 5–200 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>
|
||||
|
|
|
|||
|
|
@ -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 3–255 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 1–1440 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'}
|
||||
|
|
|
|||
|
|
@ -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 2–100 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 (2–100 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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue