Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions app/components/Contact.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"use client";
import PropTypes from "prop-types";
import Link from "next/link";
import Image from "next/image";
import EditButtons from "./EditButtons";

export function ErrorState({ title, message }) {
return (
<div className="container">
<h1>{title}</h1>
<p>{message}</p>
<Link href="/contacts" className="block">
Back to Contacts
</Link>
</div>
);
}

ErrorState.propTypes = {
title: PropTypes.string.isRequired,
message: PropTypes.string.isRequired,
};

function Contact({ contact, onDelete }) {
return (
<li className="contact-item">
<div className="contact-profile">
<Image
src={contact.image_url}
alt={`${contact.name}'s profile picture`}
width={40}
height={40}
className="profile-image"
unoptimized
onError={(e) => {
e.target.src = "https://cdn-icons-png.flaticon.com/512/8847/8847419.png";
}}
/>
</div>
<div className="contact-name">
<Link href={`/contacts/${contact.id}`} className="name-link">
{contact.name}
</Link>
</div>
<div className="contact-email" title={contact.email}>
{contact.email}
</div>
<div className="contact-phone">
{contact.phone_number || "—"}
</div>
<div className="contact-actions">
<EditButtons contact={contact} onDelete={onDelete} />
</div>
</li>
);
}

Contact.propTypes = {
contact: PropTypes.shape({
id: PropTypes.number,
name: PropTypes.string,
email: PropTypes.string,
phone_number: PropTypes.string,
image_url: PropTypes.string,
}),
onDelete: PropTypes.func,
};

Contact.defaultProps = {
contact: {},
onDelete: () => {},
};

export default Contact;
151 changes: 151 additions & 0 deletions app/components/ContactForm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"use client";
import PropTypes from "prop-types";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ContactAPI } from "../data/contactAPI";

function ContactForm({
contact = { id: 0, name: "", email: "", phone_number: "", image_url: "" },
}) {
const router = useRouter();
const [formData, setFormData] = useState({
name: contact.name || "",
email: contact.email || "",
phone_number: contact.phone_number || "",
image_url: contact.image_url || ""
});
const [errors, setErrors] = useState({});

const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
// Clear error when user starts typing
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: "" }));
}
};

const validateForm = () => {
const newErrors = {};
if (!formData.name.trim()) newErrors.name = "Name is required";
if (!formData.email.trim()) newErrors.email = "Email is required";
if (!formData.phone_number.trim()) newErrors.phone_number = "Phone number is required";
return newErrors;
};

const handleSubmit = (e) => {
e.preventDefault();
const validationErrors = validateForm();
setErrors(validationErrors);

if (Object.keys(validationErrors).length === 0) {
try {
if (contact.id) {
ContactAPI.editContact({
id: contact.id,
...formData
});
} else {
ContactAPI.addContact({
id: Math.round(Math.random() * 100000000),
...formData
});
}
router.push("/contacts");
} catch (error) {
console.error('Failed to save contact:', error);
setErrors({ submit: error.message });
}
}
};

return (
<form onSubmit={handleSubmit}>
<div className="mb-3">
<label htmlFor="name">Name</label>
<input
type="text"
id="name"
name="name"
className="form-control"
value={formData.name}
onChange={handleChange}
placeholder="Enter name"
/>
{errors.name && <div className="text-danger">{errors.name}</div>}
</div>

<div className="mb-3">
<label htmlFor="email">Email</label>
<input
type="email"
id="email"
name="email"
className="form-control"
value={formData.email}
onChange={handleChange}
placeholder="Enter email"
/>
{errors.email && <div className="text-danger">{errors.email}</div>}
</div>

<div className="mb-3">
<label htmlFor="phone_number">Phone Number</label>
<input
type="tel"
id="phone_number"
name="phone_number"
className="form-control"
value={formData.phone_number}
onChange={handleChange}
placeholder="Enter phone number"
/>
{errors.phone_number && <div className="text-danger">{errors.phone_number}</div>}
</div>

<div className="mb-3">
<label htmlFor="image_url">Profile Image URL</label>
<input
type="url"
id="image_url"
name="image_url"
className="form-control"
value={formData.image_url}
onChange={handleChange}
placeholder="Enter image URL (optional)"
/>
</div>

{errors.submit && (
<div className="alert alert-danger">
{errors.submit}
</div>
)}

<div className="d-flex gap-2">
<button type="submit" className="btn btn-primary">
{contact.id ? "Update Contact" : "Add Contact"}
</button>
<Link href="/contacts" className="btn btn-secondary">
Cancel
</Link>
</div>
</form>
);
}

ContactForm.propTypes = {
contact: PropTypes.shape({
id: PropTypes.number,
name: PropTypes.string,
email: PropTypes.string,
phone_number: PropTypes.string,
image_url: PropTypes.string,
}),
};

export default ContactForm;
83 changes: 83 additions & 0 deletions app/components/EditButtons.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"use client";
import PropTypes from "prop-types";
import { useState } from "react";
import { RiEditLine, RiDeleteBinLine, RiCloseLine } from "react-icons/ri";
import { useRouter } from "next/navigation";

function EditButtons({ contact, onDelete }) {
const [showConfirmation, setShowConfirmation] = useState(false);
const [deleteInProgress, setDeleteInProgress] = useState(false);
const router = useRouter();

const handleEdit = () => {
if (typeof contact.id === 'number') {
router.push(`/contacts/${contact.id}/edit`);
} else {
console.error('Invalid contact ID:', contact.id);
}
};

const handleDelete = async () => {
try {
setDeleteInProgress(true);
await onDelete(contact);
setShowConfirmation(false);
router.refresh();
} catch (error) {
console.error('Failed to delete contact:', error);
} finally {
setDeleteInProgress(false);
}
};

return (
<div className="edit-buttons-container">
<button className="secondary-button" onClick={handleEdit}>
<RiEditLine size="1.3em" style={{ marginRight: '4px' }} />
Edit
</button>

{!showConfirmation ? (
<button
className="delete-button"
onClick={() => setShowConfirmation(true)}
disabled={deleteInProgress}
>
<RiDeleteBinLine size="1.3em" style={{ marginRight: '4px' }} />
{deleteInProgress ? 'Deleting...' : 'Delete'}
</button>
) : (
<div className="delete-confirmation">
<span className="confirmation-text">
Delete <strong>{contact.name}</strong>?
</span>
<button
className="confirm-button"
onClick={handleDelete}
disabled={deleteInProgress}
>
{deleteInProgress ? 'Deleting...' : 'Confirm'}
</button>
<button
className="cancel-button"
onClick={() => setShowConfirmation(false)}
disabled={deleteInProgress}
aria-label="Cancel delete"
>
<RiCloseLine size="1.5em" color="#dc2626" />
</button>
</div>
)}
</div>
);
}

EditButtons.propTypes = {
contact: PropTypes.shape({
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
}).isRequired,
onDelete: PropTypes.func.isRequired,
};

export default EditButtons;
47 changes: 47 additions & 0 deletions app/components/Search.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"use client";
import { useState } from "react";
import { RiSearchLine } from "react-icons/ri";
import PropTypes from "prop-types";

function Search({ onSearchTermChange, initialValue = "" }) {
const [term, setTerm] = useState(initialValue);

const handleClear = () => {
setTerm("");
onSearchTermChange("");
};

return (
<div className="input-group">
<span className="input-group-text">
<RiSearchLine size="1.2em" />
</span>
<input
type="text"
value={term}
className="form-control"
placeholder="Search contacts..."
onChange={(e) => {
setTerm(e.target.value);
onSearchTermChange(e.target.value);
}}
/>
{term && (
<button
className="btn btn-outline-secondary"
onClick={handleClear}
aria-label="Clear search"
>
×
</button>
)}
</div>
);
}

Search.propTypes = {
onSearchTermChange: PropTypes.func.isRequired,
initialValue: PropTypes.string,
};

export default Search;
39 changes: 39 additions & 0 deletions app/contacts/[id]/edit/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"use client";
import ContactForm from "@/app/components/ContactForm";
import Link from "next/link";
import { ContactAPI } from "@/app/data/contactAPI";
import { ErrorState } from "@/app/components/Contact";

function EditContact({ params }) {
const id = Number(params.id);

// Handle invalid ID
if (isNaN(id)) {
return <ErrorState
title="Invalid Contact ID"
message="The contact ID provided is not valid."
/>;
}

const contact = ContactAPI.get(id);

// Handle contact not found
if (!contact) {
return <ErrorState
title="Contact Not Found"
message={`Sorry, we could not find a contact with that ID: ${id}`}
/>;
}

return (
<div className="container">
<h1>Edit Contact</h1>
<ContactForm contact={contact} />
<Link href="/contacts" className="block">
Back to Contacts
</Link>
</div>
);
}

export default EditContact;
Loading