Better form control and profile page
This commit is contained in:
24
App.tsx
24
App.tsx
@@ -3,13 +3,14 @@ import { Dashboard } from './components/Dashboard';
|
|||||||
import { Inventory } from './components/Inventory';
|
import { Inventory } from './components/Inventory';
|
||||||
import { ProtocolChecker } from './components/ProtocolChecker';
|
import { ProtocolChecker } from './components/ProtocolChecker';
|
||||||
import { Onboarding } from './components/Onboarding';
|
import { Onboarding } from './components/Onboarding';
|
||||||
|
import { ProfileSettings } from './components/ProfileSettings';
|
||||||
import { LoginForm } from './components/auth/LoginForm';
|
import { LoginForm } from './components/auth/LoginForm';
|
||||||
import { SignUpForm } from './components/auth/SignUpForm';
|
import { SignUpForm } from './components/auth/SignUpForm';
|
||||||
import { EmailVerification } from './components/auth/EmailVerification';
|
import { EmailVerification } from './components/auth/EmailVerification';
|
||||||
import { ForgotPassword } from './components/auth/ForgotPassword';
|
import { ForgotPassword } from './components/auth/ForgotPassword';
|
||||||
import { ResetPassword } from './components/auth/ResetPassword';
|
import { ResetPassword } from './components/auth/ResetPassword';
|
||||||
import { useSession, signOut } from './lib/auth-client';
|
import { useSession, signOut } from './lib/auth-client';
|
||||||
import { LayoutDashboard, Package, FileCheck, LogOut } from 'lucide-react';
|
import { LayoutDashboard, Package, FileCheck, LogOut, UserCircle } from 'lucide-react';
|
||||||
|
|
||||||
const logo = '/logo.png';
|
const logo = '/logo.png';
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ type AppView =
|
|||||||
| 'onboarding'
|
| 'onboarding'
|
||||||
| 'app';
|
| 'app';
|
||||||
|
|
||||||
type Tab = 'dashboard' | 'inventory' | 'protocol';
|
type Tab = 'dashboard' | 'inventory' | 'protocol' | 'profile';
|
||||||
|
|
||||||
function isResetPasswordRoute() {
|
function isResetPasswordRoute() {
|
||||||
return (
|
return (
|
||||||
@@ -135,10 +136,20 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="p-4 border-t border-border">
|
<div className="p-4 border-t border-border space-y-1">
|
||||||
<div className="text-xs text-muted-foreground mb-2 truncate px-1">
|
<button
|
||||||
{session.user.email}
|
onClick={() => setActiveTab('profile')}
|
||||||
</div>
|
className={`w-full flex items-center gap-3 px-4 py-2 rounded-lg transition-colors text-sm ${
|
||||||
|
activeTab === 'profile'
|
||||||
|
? 'bg-accent text-primary'
|
||||||
|
: 'text-muted-foreground hover:bg-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<UserCircle className="w-4 h-4 shrink-0" />
|
||||||
|
<span className="truncate text-left">
|
||||||
|
{session.user.name || session.user.email}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => signOut()}
|
onClick={() => signOut()}
|
||||||
className="w-full flex items-center gap-3 px-4 py-2 rounded-lg text-muted-foreground hover:bg-muted transition-colors text-sm"
|
className="w-full flex items-center gap-3 px-4 py-2 rounded-lg text-muted-foreground hover:bg-muted transition-colors text-sm"
|
||||||
@@ -152,6 +163,7 @@ export default function App() {
|
|||||||
{activeTab === 'dashboard' && <Dashboard setActiveTab={setActiveTab} />}
|
{activeTab === 'dashboard' && <Dashboard setActiveTab={setActiveTab} />}
|
||||||
{activeTab === 'inventory' && <Inventory />}
|
{activeTab === 'inventory' && <Inventory />}
|
||||||
{activeTab === 'protocol' && <ProtocolChecker />}
|
{activeTab === 'protocol' && <ProtocolChecker />}
|
||||||
|
{activeTab === 'profile' && <ProfileSettings />}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import ExcelJS from "exceljs";
|
import ExcelJS from "exceljs";
|
||||||
import { chemicalsApi } from "../lib/api";
|
import { chemicalsApi } from "../lib/api";
|
||||||
|
import { validateCAS, validateNumber, validatePhoneOrEmail } from "../lib/validators";
|
||||||
import type { ChemicalInventory } from "../shared/types";
|
import type { ChemicalInventory } from "../shared/types";
|
||||||
import { Card } from "./ui/card";
|
import { Card } from "./ui/card";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
@@ -199,6 +200,30 @@ export function Inventory() {
|
|||||||
setFormError("Please fill in all required fields.");
|
setFormError("Please fill in all required fields.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!validateCAS(String(form.casNumber || ""))) {
|
||||||
|
setFormError("CAS # must be in the format ##-##-# (e.g. 67-56-1).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!validateNumber(form.numberOfContainers, { min: 1, integer: true })) {
|
||||||
|
setFormError("# of containers must be a whole number of 1 or more.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!validateNumber(form.amountPerContainer, { min: 0 })) {
|
||||||
|
setFormError("Amount per container must be a number.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.molecularWeight && !validateNumber(form.molecularWeight, { min: 0 })) {
|
||||||
|
setFormError("Molecular weight must be a number.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.percentageFull != null && !validateNumber(form.percentageFull, { min: 0, max: 100 })) {
|
||||||
|
setFormError("% full must be between 0 and 100.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.contact && !validatePhoneOrEmail(String(form.contact))) {
|
||||||
|
setFormError("Contact must be a valid phone number or email address.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setFormError("");
|
setFormError("");
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -963,7 +988,14 @@ export function Inventory() {
|
|||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">CAS # <span className="text-red-500">*</span></Label>
|
<Label className="text-xs">CAS # <span className="text-red-500">*</span></Label>
|
||||||
<Input value={form.casNumber || ""} onChange={e => setField("casNumber", e.target.value)} placeholder="e.g. 67-56-1" />
|
<Input
|
||||||
|
value={form.casNumber || ""}
|
||||||
|
onChange={e => setField("casNumber", e.target.value)}
|
||||||
|
placeholder="e.g. 67-56-1"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="\d{2,7}-\d{2}-\d"
|
||||||
|
title="CAS format: digits-digits-digit (e.g. 67-56-1)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -1009,7 +1041,14 @@ export function Inventory() {
|
|||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">Amount / Container <span className="text-red-500">*</span></Label>
|
<Label className="text-xs">Amount / Container <span className="text-red-500">*</span></Label>
|
||||||
<Input value={form.amountPerContainer || ""} onChange={e => setField("amountPerContainer", e.target.value)} placeholder="e.g. 500" />
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="any"
|
||||||
|
value={form.amountPerContainer || ""}
|
||||||
|
onChange={e => setField("amountPerContainer", e.target.value)}
|
||||||
|
placeholder="e.g. 500"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1 col-span-2">
|
<div className="space-y-1 col-span-2">
|
||||||
@@ -1045,7 +1084,14 @@ export function Inventory() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">Molecular Weight</Label>
|
<Label className="text-xs">Molecular Weight</Label>
|
||||||
<Input value={form.molecularWeight || ""} onChange={e => setField("molecularWeight", e.target.value)} placeholder="g/mol" />
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="any"
|
||||||
|
value={form.molecularWeight || ""}
|
||||||
|
onChange={e => setField("molecularWeight", e.target.value)}
|
||||||
|
placeholder="g/mol"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">Concentration</Label>
|
<Label className="text-xs">Concentration</Label>
|
||||||
@@ -1077,7 +1123,11 @@ export function Inventory() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">Contact</Label>
|
<Label className="text-xs">Contact</Label>
|
||||||
<Input value={form.contact || ""} onChange={e => setField("contact", e.target.value)} />
|
<Input
|
||||||
|
value={form.contact || ""}
|
||||||
|
onChange={e => setField("contact", e.target.value)}
|
||||||
|
placeholder="Phone (e.g. 555-123-4567) or email"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 col-span-2">
|
<div className="space-y-1 col-span-2">
|
||||||
<Label className="text-xs">Comments</Label>
|
<Label className="text-xs">Comments</Label>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Card, CardContent } from './ui/card';
|
|||||||
import { Input } from './ui/input';
|
import { Input } from './ui/input';
|
||||||
import { Label } from './ui/label';
|
import { Label } from './ui/label';
|
||||||
import { useSession } from '../lib/auth-client';
|
import { useSession } from '../lib/auth-client';
|
||||||
|
import { validatePhoneOrEmail } from '../lib/validators';
|
||||||
|
|
||||||
const logo = '/logo.png';
|
const logo = '/logo.png';
|
||||||
|
|
||||||
@@ -23,16 +24,21 @@ export function Onboarding({ onComplete }: Props) {
|
|||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
|
const trimmedContact = contact.trim();
|
||||||
|
if (trimmedContact && !validatePhoneOrEmail(trimmedContact)) {
|
||||||
|
setError('Contact must be a valid phone number or email address.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await fetch('/api/profile', {
|
const res = await fetch('/api/profile', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
pi_first_name: piFirstName,
|
pi_first_name: piFirstName.trim(),
|
||||||
bldg_code: bldgCode,
|
bldg_code: bldgCode.trim(),
|
||||||
lab,
|
lab: lab.trim(),
|
||||||
contact: contact || undefined,
|
contact: trimmedContact || undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -103,7 +109,7 @@ export function Onboarding({ onComplete }: Props) {
|
|||||||
type="text"
|
type="text"
|
||||||
value={contact}
|
value={contact}
|
||||||
onChange={e => setContact(e.target.value)}
|
onChange={e => setContact(e.target.value)}
|
||||||
placeholder="Phone or email"
|
placeholder="Phone (e.g. 555-123-4567) or email"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
|||||||
176
components/ProfileSettings.tsx
Normal file
176
components/ProfileSettings.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Loader2, Check } from 'lucide-react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { Label } from './ui/label';
|
||||||
|
import { useSession } from '../lib/auth-client';
|
||||||
|
import { validatePhoneOrEmail } from '../lib/validators';
|
||||||
|
|
||||||
|
export function ProfileSettings() {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const [piFirstName, setPiFirstName] = useState('');
|
||||||
|
const [bldgCode, setBldgCode] = useState('');
|
||||||
|
const [lab, setLab] = useState('');
|
||||||
|
const [contact, setContact] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/profile', { credentials: 'include' })
|
||||||
|
.then(r => (r.ok ? r.json() : null))
|
||||||
|
.then(data => {
|
||||||
|
if (data) {
|
||||||
|
setPiFirstName(data.pi_first_name || '');
|
||||||
|
setBldgCode(data.bldg_code || '');
|
||||||
|
setLab(data.lab || '');
|
||||||
|
setContact(data.contact || '');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setSaved(false);
|
||||||
|
|
||||||
|
if (!piFirstName.trim() || !bldgCode.trim() || !lab.trim()) {
|
||||||
|
setError('PI first name, building code, and lab are required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (contact.trim() && !validatePhoneOrEmail(contact.trim())) {
|
||||||
|
setError('Contact must be a valid phone number or email address.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
const res = await fetch('/api/profile', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
pi_first_name: piFirstName.trim(),
|
||||||
|
bldg_code: bldgCode.trim(),
|
||||||
|
lab: lab.trim(),
|
||||||
|
contact: contact.trim() || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setSaving(false);
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setSaved(true);
|
||||||
|
setTimeout(() => setSaved(false), 3000);
|
||||||
|
} else {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
setError(data.error || 'Failed to save profile.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-2xl mx-auto">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-semibold">Profile settings</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Update your account details and lab defaults.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Account</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 pb-6">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">Name</Label>
|
||||||
|
<p className="text-sm">{session?.user.name || '—'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">Email</Label>
|
||||||
|
<p className="text-sm">{session?.user.email || '—'}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Card className="mt-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Lab defaults</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 pb-6">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="pi">
|
||||||
|
PI first name <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="pi"
|
||||||
|
value={piFirstName}
|
||||||
|
onChange={e => setPiFirstName(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="e.g. Smith"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="bldg">
|
||||||
|
Building code <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="bldg"
|
||||||
|
value={bldgCode}
|
||||||
|
onChange={e => setBldgCode(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="e.g. EER"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="lab">
|
||||||
|
Lab <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="lab"
|
||||||
|
value={lab}
|
||||||
|
onChange={e => setLab(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="e.g. 3.822"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="contact">
|
||||||
|
Contact{' '}
|
||||||
|
<span className="text-muted-foreground font-normal">(optional)</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="contact"
|
||||||
|
type="text"
|
||||||
|
value={contact}
|
||||||
|
onChange={e => setContact(e.target.value)}
|
||||||
|
placeholder="Phone (e.g. 555-123-4567) or email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
{saved && (
|
||||||
|
<p className="text-sm text-green-600 flex items-center gap-1">
|
||||||
|
<Check className="w-4 h-4" /> Saved
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="pt-1">
|
||||||
|
<Button type="submit" disabled={saving}>
|
||||||
|
{saving ? 'Saving…' : 'Save changes'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from '../ui/dialog';
|
} from '../ui/dialog';
|
||||||
import { signUp, sendVerificationEmail } from '../../lib/auth-client';
|
import { signUp, sendVerificationEmail } from '../../lib/auth-client';
|
||||||
|
import { validateEmail } from '../../lib/validators';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
const logo = '/logo.png';
|
const logo = '/logo.png';
|
||||||
@@ -48,6 +49,16 @@ export function SignUpForm({ onLogin }: Props) {
|
|||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
const trimmedEmail = email.trim();
|
||||||
|
if (trimmedName.length < 2) {
|
||||||
|
setError('Please enter your full name.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!validateEmail(trimmedEmail)) {
|
||||||
|
setError('Please enter a valid email address.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (password.length < 8) {
|
if (password.length < 8) {
|
||||||
setError('Password must be at least 8 characters');
|
setError('Password must be at least 8 characters');
|
||||||
return;
|
return;
|
||||||
@@ -58,8 +69,8 @@ export function SignUpForm({ onLogin }: Props) {
|
|||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await signUp.email({
|
const res = await signUp.email({
|
||||||
name,
|
name: trimmedName,
|
||||||
email,
|
email: trimmedEmail,
|
||||||
password,
|
password,
|
||||||
callbackURL: window.location.origin,
|
callbackURL: window.location.origin,
|
||||||
});
|
});
|
||||||
|
|||||||
47
lib/validators.ts
Normal file
47
lib/validators.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// Shared input validators for sign-up, profile, and inventory forms.
|
||||||
|
|
||||||
|
export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
// Phone: allows digits, spaces, parens, +, -, . — must contain at least 7 digits.
|
||||||
|
export const PHONE_REGEX = /^\+?[\d\s().\-]{7,20}$/;
|
||||||
|
|
||||||
|
// CAS Registry Number: 2–7 digits, 2 digits, 1 check digit (e.g. 67-56-1).
|
||||||
|
export const CAS_REGEX = /^\d{2,7}-\d{2}-\d$/;
|
||||||
|
|
||||||
|
export function validateEmail(value: string): boolean {
|
||||||
|
return EMAIL_REGEX.test(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePhone(value: string): boolean {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!PHONE_REGEX.test(trimmed)) return false;
|
||||||
|
// Require at least 7 actual digits to avoid strings of only punctuation.
|
||||||
|
return trimmed.replace(/\D/g, '').length >= 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePhoneOrEmail(value: string): boolean {
|
||||||
|
return validateEmail(value) || validatePhone(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateCAS(value: string): boolean {
|
||||||
|
return CAS_REGEX.test(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NumberOpts {
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
integer?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateNumber(
|
||||||
|
value: string | number | undefined | null,
|
||||||
|
opts: NumberOpts = {},
|
||||||
|
): boolean {
|
||||||
|
if (value == null || value === '') return false;
|
||||||
|
const n = typeof value === 'number' ? value : Number(value);
|
||||||
|
if (!Number.isFinite(n)) return false;
|
||||||
|
if (opts.integer && !Number.isInteger(n)) return false;
|
||||||
|
if (opts.min != null && n < opts.min) return false;
|
||||||
|
if (opts.max != null && n > opts.max) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user