diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e78ee5f --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +.next/ +out/ +dist/ +.env* +!.env.example +npm-debug.log* +yarn-debug.log* +yarn-error.log* +*.tsbuildinfo diff --git a/README.md b/README.md index b2bc9ba..cc55047 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,27 @@ # dead-simple-crm A minimal, opinionated CRM that does contacts, companies, deals, and notes — nothing more. Clean UI, fast search, CSV import/export. Built for people who want a CRM without the bloat of Salesforce or HubSpot. Every feature is a discrete bounty: filters, email integration, pipeline views, reporting. + +## Development + +Install dependencies and run the app: + +```bash +npm install +npm run dev +``` + +Validate before opening a pull request: + +```bash +npm run typecheck +npm run build +``` + +## Contact Form + +The app includes: + +- `/contacts/new` for adding a contact +- `/contacts/[id]/edit` for editing an existing contact +- fields for name, email, phone, company, tags, and notes +- required-field validation before saving to a local CRM preview diff --git a/app/contacts/[id]/edit/page.tsx b/app/contacts/[id]/edit/page.tsx new file mode 100644 index 0000000..f5aa6c8 --- /dev/null +++ b/app/contacts/[id]/edit/page.tsx @@ -0,0 +1,24 @@ +import { ContactForm } from "../../contact-form" + +export default async function EditContactPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + + return ( + <> +
+
+

Edit contact

+

+ Update core contact fields while preserving the same validation used + for new contacts. +

+
+
+ + + ) +} diff --git a/app/contacts/contact-form.tsx b/app/contacts/contact-form.tsx new file mode 100644 index 0000000..232f6a8 --- /dev/null +++ b/app/contacts/contact-form.tsx @@ -0,0 +1,346 @@ +"use client" + +import { FormEvent, useEffect, useMemo, useState } from "react" + +export type ContactFormMode = "create" | "edit" + +type Contact = { + id: string + name: string + email: string + phone: string + company: string + tags: string + notes: string +} + +type ContactErrors = Partial> + +const companies = [ + "Acme Robotics", + "Bluebird Labs", + "Northwind Systems", + "Pioneer Health", + "Unassigned", +] + +const blankContact: Contact = { + id: "", + name: "", + email: "", + phone: "", + company: "", + tags: "", + notes: "", +} + +const sampleContact: Contact = { + id: "sample-1", + name: "Mina Park", + email: "mina.park@example.com", + phone: "+82 10-1234-5678", + company: "Bluebird Labs", + tags: "renewal, q3", + notes: "Prefers concise email follow-ups after product calls.", +} + +function validateEmail(email: string) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) +} + +function validate(contact: Contact): ContactErrors { + const errors: ContactErrors = {} + + if (contact.name.trim().length < 2) { + errors.name = "Name is required." + } + + if (!validateEmail(contact.email.trim())) { + errors.email = "Enter a valid email address." + } + + if (!contact.company) { + errors.company = "Choose a company or Unassigned." + } + + return errors +} + +function readContacts(): Contact[] { + try { + const stored = window.localStorage.getItem("dead-simple-crm-contacts") + const contacts = stored ? JSON.parse(stored) : [] + return Array.isArray(contacts) ? contacts : [] + } catch { + return [] + } +} + +function upsertContact(contact: Contact) { + const contacts = readContacts() + const nextContacts = [ + contact, + ...contacts.filter((current) => current.id !== contact.id), + ].slice(0, 25) + window.localStorage.setItem( + "dead-simple-crm-contacts", + JSON.stringify(nextContacts), + ) +} + +export function ContactForm({ + mode, + contactId, +}: { + mode: ContactFormMode + contactId?: string +}) { + const [contact, setContact] = useState(blankContact) + const [touched, setTouched] = useState>>({}) + const [savedContact, setSavedContact] = useState(null) + const errors = useMemo(() => validate(contact), [contact]) + + useEffect(() => { + if (mode === "create") { + setContact(blankContact) + return + } + + const storedContact = readContacts().find((item) => item.id === contactId) + setContact(storedContact ?? sampleContact) + }, [contactId, mode]) + + function updateField(key: K, value: Contact[K]) { + setContact((current) => ({ ...current, [key]: value })) + } + + function visibleError(key: keyof Contact) { + return touched[key] ? errors[key] : undefined + } + + function handleSubmit(event: FormEvent) { + event.preventDefault() + setTouched({ + name: true, + email: true, + phone: true, + company: true, + tags: true, + notes: true, + }) + + const nextErrors = validate(contact) + if (Object.keys(nextErrors).length > 0) { + return + } + + const cleanContact: Contact = { + id: + mode === "edit" && contact.id + ? contact.id + : `contact-${Date.now().toString(36)}`, + name: contact.name.trim(), + email: contact.email.trim(), + phone: contact.phone.trim(), + company: contact.company, + tags: contact.tags.trim(), + notes: contact.notes.trim(), + } + + upsertContact(cleanContact) + setContact(cleanContact) + setSavedContact(cleanContact) + } + + return ( +
+
+
+
+
+ + Required +
+ setTouched((current) => ({ ...current, name: true }))} + onChange={(event) => updateField("name", event.target.value)} + aria-invalid={Boolean(visibleError("name"))} + aria-describedby={visibleError("name") ? "name-error" : undefined} + placeholder="Mina Park" + /> + {visibleError("name") ? ( + + {visibleError("name")} + + ) : null} +
+ +
+
+ + Required +
+ setTouched((current) => ({ ...current, email: true }))} + onChange={(event) => updateField("email", event.target.value)} + aria-invalid={Boolean(visibleError("email"))} + aria-describedby={visibleError("email") ? "email-error" : undefined} + placeholder="mina.park@example.com" + inputMode="email" + /> + {visibleError("email") ? ( + + {visibleError("email")} + + ) : null} +
+
+ +
+
+
+ + Optional +
+ updateField("phone", event.target.value)} + placeholder="+1 555 0100" + inputMode="tel" + /> +
+ +
+
+ + Required +
+ + {visibleError("company") ? ( + + {visibleError("company")} + + ) : null} +
+
+ +
+
+ + Comma separated +
+ updateField("tags", event.target.value)} + placeholder="renewal, enterprise, q3" + /> +
+ +
+
+ + {contact.notes.length}/600 +
+