Switch all interactive UI elements (buttons, focus rings, active tabs, FAB, links, spinners) from blue to gray to match icon colors for a more cohesive look. Mute card badge text colors to pastels (blue-400, green-500, purple-500) to keep the focus on card content. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
282 lines
7.5 KiB
TypeScript
282 lines
7.5 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useCreateCandidate, useUpdateCandidate } from "../hooks/useCandidates";
|
|
import { useThread } from "../hooks/useThreads";
|
|
import { useUIStore } from "../stores/uiStore";
|
|
import { CategoryPicker } from "./CategoryPicker";
|
|
import { ImageUpload } from "./ImageUpload";
|
|
|
|
interface CandidateFormProps {
|
|
mode: "add" | "edit";
|
|
threadId: number;
|
|
candidateId?: number | null;
|
|
}
|
|
|
|
interface FormData {
|
|
name: string;
|
|
weightGrams: string;
|
|
priceDollars: string;
|
|
categoryId: number;
|
|
notes: string;
|
|
productUrl: string;
|
|
imageFilename: string | null;
|
|
}
|
|
|
|
const INITIAL_FORM: FormData = {
|
|
name: "",
|
|
weightGrams: "",
|
|
priceDollars: "",
|
|
categoryId: 1,
|
|
notes: "",
|
|
productUrl: "",
|
|
imageFilename: null,
|
|
};
|
|
|
|
export function CandidateForm({
|
|
mode,
|
|
threadId,
|
|
candidateId,
|
|
}: CandidateFormProps) {
|
|
const { data: thread } = useThread(threadId);
|
|
const createCandidate = useCreateCandidate(threadId);
|
|
const updateCandidate = useUpdateCandidate(threadId);
|
|
const closeCandidatePanel = useUIStore((s) => s.closeCandidatePanel);
|
|
|
|
const [form, setForm] = useState<FormData>(INITIAL_FORM);
|
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
|
|
// Pre-fill form when editing
|
|
useEffect(() => {
|
|
if (mode === "edit" && candidateId != null && thread?.candidates) {
|
|
const candidate = thread.candidates.find((c) => c.id === candidateId);
|
|
if (candidate) {
|
|
setForm({
|
|
name: candidate.name,
|
|
weightGrams:
|
|
candidate.weightGrams != null ? String(candidate.weightGrams) : "",
|
|
priceDollars:
|
|
candidate.priceCents != null
|
|
? (candidate.priceCents / 100).toFixed(2)
|
|
: "",
|
|
categoryId: candidate.categoryId,
|
|
notes: candidate.notes ?? "",
|
|
productUrl: candidate.productUrl ?? "",
|
|
imageFilename: candidate.imageFilename,
|
|
});
|
|
}
|
|
} else if (mode === "add") {
|
|
setForm(INITIAL_FORM);
|
|
}
|
|
}, [mode, candidateId, thread?.candidates]);
|
|
|
|
function validate(): boolean {
|
|
const newErrors: Record<string, string> = {};
|
|
if (!form.name.trim()) {
|
|
newErrors.name = "Name is required";
|
|
}
|
|
if (
|
|
form.weightGrams &&
|
|
(Number.isNaN(Number(form.weightGrams)) || Number(form.weightGrams) < 0)
|
|
) {
|
|
newErrors.weightGrams = "Must be a positive number";
|
|
}
|
|
if (
|
|
form.priceDollars &&
|
|
(Number.isNaN(Number(form.priceDollars)) || Number(form.priceDollars) < 0)
|
|
) {
|
|
newErrors.priceDollars = "Must be a positive number";
|
|
}
|
|
if (
|
|
form.productUrl &&
|
|
form.productUrl.trim() !== "" &&
|
|
!form.productUrl.match(/^https?:\/\//)
|
|
) {
|
|
newErrors.productUrl = "Must be a valid URL (https://...)";
|
|
}
|
|
setErrors(newErrors);
|
|
return Object.keys(newErrors).length === 0;
|
|
}
|
|
|
|
function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (!validate()) return;
|
|
|
|
const payload = {
|
|
name: form.name.trim(),
|
|
weightGrams: form.weightGrams ? Number(form.weightGrams) : undefined,
|
|
priceCents: form.priceDollars
|
|
? Math.round(Number(form.priceDollars) * 100)
|
|
: undefined,
|
|
categoryId: form.categoryId,
|
|
notes: form.notes.trim() || undefined,
|
|
productUrl: form.productUrl.trim() || undefined,
|
|
imageFilename: form.imageFilename ?? undefined,
|
|
};
|
|
|
|
if (mode === "add") {
|
|
createCandidate.mutate(payload, {
|
|
onSuccess: () => {
|
|
setForm(INITIAL_FORM);
|
|
closeCandidatePanel();
|
|
},
|
|
});
|
|
} else if (candidateId != null) {
|
|
updateCandidate.mutate(
|
|
{ candidateId, ...payload },
|
|
{ onSuccess: () => closeCandidatePanel() },
|
|
);
|
|
}
|
|
}
|
|
|
|
const isPending = createCandidate.isPending || updateCandidate.isPending;
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className="space-y-5">
|
|
{/* Image */}
|
|
<ImageUpload
|
|
value={form.imageFilename}
|
|
onChange={(filename) =>
|
|
setForm((f) => ({ ...f, imageFilename: filename }))
|
|
}
|
|
/>
|
|
|
|
{/* Name */}
|
|
<div>
|
|
<label
|
|
htmlFor="candidate-name"
|
|
className="block text-sm font-medium text-gray-700 mb-1"
|
|
>
|
|
Name *
|
|
</label>
|
|
<input
|
|
id="candidate-name"
|
|
type="text"
|
|
value={form.name}
|
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
|
placeholder="e.g. Osprey Talon 22"
|
|
/>
|
|
{errors.name && (
|
|
<p className="mt-1 text-xs text-red-500">{errors.name}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Weight */}
|
|
<div>
|
|
<label
|
|
htmlFor="candidate-weight"
|
|
className="block text-sm font-medium text-gray-700 mb-1"
|
|
>
|
|
Weight (g)
|
|
</label>
|
|
<input
|
|
id="candidate-weight"
|
|
type="number"
|
|
min="0"
|
|
step="any"
|
|
value={form.weightGrams}
|
|
onChange={(e) =>
|
|
setForm((f) => ({ ...f, weightGrams: e.target.value }))
|
|
}
|
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
|
placeholder="e.g. 680"
|
|
/>
|
|
{errors.weightGrams && (
|
|
<p className="mt-1 text-xs text-red-500">{errors.weightGrams}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Price */}
|
|
<div>
|
|
<label
|
|
htmlFor="candidate-price"
|
|
className="block text-sm font-medium text-gray-700 mb-1"
|
|
>
|
|
Price ($)
|
|
</label>
|
|
<input
|
|
id="candidate-price"
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
value={form.priceDollars}
|
|
onChange={(e) =>
|
|
setForm((f) => ({ ...f, priceDollars: e.target.value }))
|
|
}
|
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
|
placeholder="e.g. 129.99"
|
|
/>
|
|
{errors.priceDollars && (
|
|
<p className="mt-1 text-xs text-red-500">{errors.priceDollars}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Category */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Category
|
|
</label>
|
|
<CategoryPicker
|
|
value={form.categoryId}
|
|
onChange={(id) => setForm((f) => ({ ...f, categoryId: id }))}
|
|
/>
|
|
</div>
|
|
|
|
{/* Notes */}
|
|
<div>
|
|
<label
|
|
htmlFor="candidate-notes"
|
|
className="block text-sm font-medium text-gray-700 mb-1"
|
|
>
|
|
Notes
|
|
</label>
|
|
<textarea
|
|
id="candidate-notes"
|
|
value={form.notes}
|
|
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
|
rows={3}
|
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent resize-none"
|
|
placeholder="Any additional notes..."
|
|
/>
|
|
</div>
|
|
|
|
{/* Product Link */}
|
|
<div>
|
|
<label
|
|
htmlFor="candidate-url"
|
|
className="block text-sm font-medium text-gray-700 mb-1"
|
|
>
|
|
Product Link
|
|
</label>
|
|
<input
|
|
id="candidate-url"
|
|
type="url"
|
|
value={form.productUrl}
|
|
onChange={(e) =>
|
|
setForm((f) => ({ ...f, productUrl: e.target.value }))
|
|
}
|
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
|
placeholder="https://..."
|
|
/>
|
|
{errors.productUrl && (
|
|
<p className="mt-1 text-xs text-red-500">{errors.productUrl}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex gap-3 pt-2">
|
|
<button
|
|
type="submit"
|
|
disabled={isPending}
|
|
className="flex-1 py-2.5 px-4 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
|
>
|
|
{isPending
|
|
? "Saving..."
|
|
: mode === "add"
|
|
? "Add Candidate"
|
|
: "Save Changes"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|