From 9ba3adda64eb8bb3ee8e9d61106a241e68a2ba28 Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Date: Thu, 2 Apr 2026 22:55:56 +0200 Subject: [PATCH] Add form validation to all dashboard forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/lib/form-validation.ts | 89 +++++++++ src/routes/dashboard/jobs/create.tsx | 187 +++++++++++++----- src/routes/dashboard/portfolio/index.tsx | 98 ++++++++-- src/routes/dashboard/requirements/index.tsx | 170 ++++++++++++----- src/routes/dashboard/services/index.tsx | 132 ++++++++++--- src/routes/dashboard/settings.tsx | 198 +++++++++++++++++--- 6 files changed, 719 insertions(+), 155 deletions(-) diff --git a/src/lib/form-validation.ts b/src/lib/form-validation.ts index 8965ee2..4af22c8 100644 --- a/src/lib/form-validation.ts +++ b/src/lib/form-validation.ts @@ -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 diff --git a/src/routes/dashboard/jobs/create.tsx b/src/routes/dashboard/jobs/create.tsx index c02c3be..93b0d8b 100644 --- a/src/routes/dashboard/jobs/create.tsx +++ b/src/routes/dashboard/jobs/create.tsx @@ -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 ( + +

+ {props.ok ? `✓ ${props.okMsg}` : `• ${props.errMsg}`} +

+
+ ); +} + 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 = { - 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'}

- {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."}

@@ -107,39 +141,72 @@ export default function CreateJob() { -
{error()}
+
{error()}
- - + + +
- +
- -