From 0202425a098c2f719f944075e2d973d4a80ba346 Mon Sep 17 00:00:00 2001 From: phoenix Date: Thu, 29 Jan 2026 22:20:28 +0000 Subject: [PATCH] tsk-10: Edit Contact (#23) Closes #10 Reviewed-on: https://git.kundeng.us/phoenix/textsender/pulls/23 Co-authored-by: phoenix Co-committed-by: phoenix --- src/api/contactApi.js | 26 ++ src/components/Dashboard.jsx | 23 +- src/components/ViewContact.css | 358 --------------- src/components/ViewContact.jsx | 423 ------------------ .../ViewContactWizard/ConfirmationStep.jsx | 82 ++++ .../ConfirmationStep.module.css | 214 +++++++++ .../ViewContactWizard/ModifyForm.jsx | 119 +++++ .../ViewContactWizard/ModifyForm.module.css | 156 +++++++ .../ViewContactWizard/StepNavigation.jsx | 93 ++++ .../StepNavigation.module.css | 204 +++++++++ .../ViewContactWizard/ViewContact.jsx | 150 +++++++ .../ViewContactWizard/ViewContact.module.css | 290 ++++++++++++ .../ViewContactWizard.module.css | 264 +++++++++++ .../ViewContactWizardModal.jsx | 125 ++++++ .../ViewContactWizard/useViewContactWizard.js | 212 +++++++++ 15 files changed, 1952 insertions(+), 787 deletions(-) delete mode 100644 src/components/ViewContact.css delete mode 100644 src/components/ViewContact.jsx create mode 100644 src/components/ViewContactWizard/ConfirmationStep.jsx create mode 100644 src/components/ViewContactWizard/ConfirmationStep.module.css create mode 100644 src/components/ViewContactWizard/ModifyForm.jsx create mode 100644 src/components/ViewContactWizard/ModifyForm.module.css create mode 100644 src/components/ViewContactWizard/StepNavigation.jsx create mode 100644 src/components/ViewContactWizard/StepNavigation.module.css create mode 100644 src/components/ViewContactWizard/ViewContact.jsx create mode 100644 src/components/ViewContactWizard/ViewContact.module.css create mode 100644 src/components/ViewContactWizard/ViewContactWizard.module.css create mode 100644 src/components/ViewContactWizard/ViewContactWizardModal.jsx create mode 100644 src/components/ViewContactWizard/useViewContactWizard.js diff --git a/src/api/contactApi.js b/src/api/contactApi.js index 6fa2ffe..f587416 100644 --- a/src/api/contactApi.js +++ b/src/api/contactApi.js @@ -7,6 +7,14 @@ export class CreateContactRequest { user_id = ''; } +export class UpdateContactNamesRequest { + first_name = ''; + last_name = ''; + nickname = ''; + contact_id = ''; + user_id = ''; +} + export const contactApi = { getContactsWithUserId: async (userId, authBearerToken) => { console.log('Fetching contacts'); @@ -55,4 +63,22 @@ export const contactApi = { return response.json(); } }, + + updateContactNames: async (reqBody, authBearerToken) => { + const response = await fetch(`${API_BASE_URL}/api/v1/contact/update`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: authBearerToken, + }, + body: JSON.stringify(reqBody), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || `Failed to update contact names.`); + } else { + return response.json(); + } + }, }; diff --git a/src/components/Dashboard.jsx b/src/components/Dashboard.jsx index cb22e4a..cf5148a 100644 --- a/src/components/Dashboard.jsx +++ b/src/components/Dashboard.jsx @@ -1,18 +1,20 @@ import { useState } from 'react'; import AddContact from './AddContact'; -import ViewContact from './ViewContact'; +// import ViewContact from './ViewContact'; import ViewSentMessages from './ViewSentMessages'; import './Dashboard.css'; import SendMessageWizardModal from './SendMessageWizard/SendMessageWizardModal'; +import ViewContactWizardModal from './ViewContactWizard/ViewContactWizardModal'; const Dashboard = () => { const [activeView, setActiveView] = useState('dashboard'); const [isWizardOpen, setIsWizardOpen] = useState(false); + const [isViewContactWizardOpen, setIsViewContactWizardOpen] = useState(false); const [isViewSentMessagesOpen, setViewSentMessagesOpen] = useState(false); const [showContactForm, setShowContactForm] = useState(false); - const [isPopupOpen, setIsPopupOpen] = useState(false); + // const [isPopupOpen, setIsPopupOpen] = useState(false); const handleAddContactClick = () => { setShowContactForm(true); @@ -36,6 +38,14 @@ const Dashboard = () => { setIsWizardOpen(false); }; + const handleViewWizardComplete = () => { + setIsViewContactWizardOpen(false); + }; + + const handleViewWizardClose = () => { + setIsViewContactWizardOpen(false); + }; + const handleNavClick = (view) => { setActiveView(view); }; @@ -121,7 +131,7 @@ const Dashboard = () => { - setIsPopupOpen(false)} + diff --git a/src/components/ViewContact.css b/src/components/ViewContact.css deleted file mode 100644 index 15ec30e..0000000 --- a/src/components/ViewContact.css +++ /dev/null @@ -1,358 +0,0 @@ -/* ViewContact.css */ -/* Contact Modal Styles */ -.contact-modal-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(0, 0, 0, 0.5); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; - padding: 20px; -} - -.contact-modal { - background: transparent; - width: 100%; - max-width: 500px; - max-height: 90vh; - overflow-y: auto; -} - -.contact-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 20px; -} - -.contact-title { - color: white; - font-size: 24px; - font-weight: 600; - margin: 0; -} - -.close-btn { - background: none; - border: none; - color: white; - font-size: 32px; - cursor: pointer; - line-height: 1; - padding: 0; - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - transition: background-color 0.3s ease; -} - -.close-btn:hover { - background-color: rgba(255, 255, 255, 0.1); -} - -.contact-card { - background: white; - border-radius: 12px; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); - padding: 40px; - width: 100%; -} - -.contact-form { - margin-bottom: 0; -} - -.required { - color: #ef4444; -} - -.form-actions { - display: flex; - gap: 12px; - margin-top: 30px; -} - -.cancel-btn { - flex: 1; - background: #f3f4f6; - color: #4b5563; - border: 2px solid #e5e7eb; - border-radius: 8px; - padding: 14px; - font-size: 16px; - font-weight: 600; - cursor: pointer; - transition: all 0.3s ease; -} - -.cancel-btn:hover:not(:disabled) { - background: #e5e7eb; - transform: translateY(-1px); -} - -.cancel-btn:active:not(:disabled) { - transform: translateY(0); -} - -.cancel-btn:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -.submit-btn { - flex: 1; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - border: none; - border-radius: 8px; - padding: 14px; - font-size: 16px; - font-weight: 600; - cursor: pointer; - transition: all 0.3s ease; -} - -.submit-btn:hover:not(:disabled) { - transform: translateY(-1px); - box-shadow: 0 10px 25px rgba(102, 126, 234, 0.4); -} - -.submit-btn:active:not(:disabled) { - transform: translateY(0); -} - -.submit-btn:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -.submit-error { - background-color: #fee; - border: 1px solid #fcc; - border-radius: 6px; - padding: 12px; - margin-bottom: 20px; - text-align: center; - color: #ef4444; - font-size: 14px; -} - -/* Responsive adjustments */ -@media (max-width: 480px) { - .contact-card { - padding: 30px 20px; - } - - .contact-title { - font-size: 20px; - } - - .form-actions { - flex-direction: column; - } -} - -/* Loading animation */ -@keyframes spin { - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(360deg); - } -} - -/* Scrollbar styling */ -.contact-modal::-webkit-scrollbar { - width: 8px; -} - -.contact-modal::-webkit-scrollbar-track { - background: transparent; -} - -.contact-modal::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.3); - border-radius: 4px; -} - -.contact-modal::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.5); -} - -/* ViewContact.css */ -/* Add to existing ViewContact.css */ - -/* Loading spinner */ -.loading-spinner { - border: 4px solid #f3f4f6; - border-top: 4px solid #667eea; - border-radius: 50%; - width: 40px; - height: 40px; - animation: spin 1s linear infinite; - margin: 0 auto; -} - -@keyframes spin { - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(360deg); - } -} - -/* Table styles */ -.contact-table { - width: 100%; - border-collapse: collapse; -} - -.contact-table th { - background: #f9fafb; - padding: 12px 16px; - text-align: left; - border-bottom: 2px solid #e5e7eb; - color: #374151; - font-weight: 600; - font-size: 14px; - position: sticky; - top: 0; - z-index: 1; -} - -.contact-table td { - padding: 12px 16px; - border-bottom: 1px solid #e5e7eb; - font-size: 14px; -} - -.contact-table tr:last-child td { - border-bottom: none; -} - -.contact-table tr:hover { - background-color: #f9fafb; -} - -/* Contact item styles */ -.contact-item { - display: flex; - align-items: flex-start; - gap: 12px; - padding: 16px; - border-bottom: 1px solid #e5e7eb; -} - -.contact-item:last-child { - border-bottom: none; -} - -.contact-avatar { - width: 40px; - height: 40px; - border-radius: 50%; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - display: flex; - align-items: center; - justify-content: center; - font-weight: 600; - font-size: 16px; -} - -.contact-info { - flex: 1; -} - -.contact-name { - font-weight: 600; - color: #111827; - margin-bottom: 4px; -} - -.contact-phone { - color: #6b7280; - font-size: 14px; -} - -.contact-id { - font-size: 12px; - color: #9ca3af; - background: #f3f4f6; - padding: 2px 8px; - border-radius: 4px; -} - -/* Search input */ -.search-input { - width: 100%; - padding: 10px 12px; - border: 1px solid #e5e7eb; - border-radius: 6px; - font-size: 14px; - transition: border-color 0.3s ease; -} - -.search-input:focus { - outline: none; - border-color: #667eea; - box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); -} - -/* Empty state */ -.empty-state { - text-align: center; - padding: 60px 20px; -} - -.empty-state-icon { - font-size: 48px; - margin-bottom: 16px; - opacity: 0.5; -} - -.empty-state-title { - font-size: 18px; - font-weight: 500; - color: #374151; - margin-bottom: 8px; -} - -.empty-state-description { - color: #6b7280; - font-size: 14px; - margin-bottom: 20px; -} - -/* Scrollbar for contact list */ -.contact-list-container { - max-height: 400px; - overflow-y: auto; -} - -.contact-list-container::-webkit-scrollbar { - width: 6px; -} - -.contact-list-container::-webkit-scrollbar-track { - background: #f1f1f1; - border-radius: 3px; -} - -.contact-list-container::-webkit-scrollbar-thumb { - background: #c1c1c1; - border-radius: 3px; -} - -.contact-list-container::-webkit-scrollbar-thumb:hover { - background: #a1a1a1; -} diff --git a/src/components/ViewContact.jsx b/src/components/ViewContact.jsx deleted file mode 100644 index de17c8b..0000000 --- a/src/components/ViewContact.jsx +++ /dev/null @@ -1,423 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; - -import { DATA_KEY_ACCESS_TOKEN } from '../constants/app'; -import { tokenService } from '../services/TokenService'; -import { contactApi } from '../api/contactApi'; - -import './ViewContact.css'; - -const ViewContact = ({ isOpen, onClose, title = 'View Contacts' }) => { - const [contacts, setContacts] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [lastUpdated, setLastUpdated] = useState(null); - const [searchTerm, setSearchTerm] = useState(''); - - // Fetch contacts when the popup opens - const fetchContacts = useCallback(async () => { - try { - setLoading(true); - setError(null); - - const accessToken = localStorage.getItem(DATA_KEY_ACCESS_TOKEN); - const userId = tokenService.getUserId(); - - if (!accessToken) { - throw new Error('Authentication required. Please log in again.'); - } - - const result = await contactApi.getContactsWithUserId( - userId, - tokenService.bearerToken() - ); - - if (result.data) { - setContacts(result.data); - setLastUpdated(new Date()); - } else { - setLoading(false); - throw new Error(result.message || 'Failed to load contacts'); - } - } catch (err) { - console.error('Error fetching contacts:', err); - setError(err.message || 'Failed to load contacts. Please try again.'); - setContacts([]); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - if (isOpen) { - console.log('This is when I would fetch contacts'); - fetchContacts(); - } - }, [isOpen, fetchContacts]); - - // Handle refresh button click - const handleRefresh = () => { - fetchContacts(); - }; - - // Filter contacts based on search term - const filteredContacts = contacts.filter((contact) => { - const searchLower = searchTerm.toLowerCase(); - return ( - (contact.phone_number && - contact.phone_number.toLowerCase().includes(searchLower)) || - (contact.first_name && - contact.first_name.toLowerCase().includes(searchLower)) || - (contact.last_name && - contact.last_name.toLowerCase().includes(searchLower)) || - (contact.id && contact.id.toString().includes(searchTerm)) - ); - }); - - // Format phone number for display - const formatPhoneNumber = (phone) => { - if (!phone) return 'N/A'; - // Simple formatting - you can customize this - const cleaned = phone.replace(/\D/g, ''); - if (cleaned.length === 10) { - return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}`; - } - return phone; - }; - - // Format timestamp - const formatLastUpdated = () => { - if (!lastUpdated) return 'Never'; - return lastUpdated.toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - }); - }; - - if (!isOpen) return null; - - return ( -
-
e.stopPropagation()}> -
-
-

{title}

- -
- -
- {error && ( -
-
{error}
- -
- )} - - {loading ? ( -
-
-

- Loading contacts... -

-
- ) : ( - <> - {/* Controls */} -
- - - {/* Search Input */} - setSearchTerm(e.target.value)} - style={{ - flex: 1, - minWidth: '200px', - maxWidth: '300px', - padding: '8px 12px', - border: '1px solid #e5e7eb', - borderRadius: '6px', - fontSize: '14px', - }} - /> - -
- Updated: {formatLastUpdated()} - -
-
- - {/* Contacts List */} - {filteredContacts.length === 0 ? ( -
-
- 📭 -
-

- {searchTerm - ? 'No matching contacts found' - : 'No contacts available'} -

-

- {searchTerm - ? 'Try a different search term' - : 'Add your first contact to get started'} -

- {searchTerm && ( - - )} -
- ) : ( - <> -
-
- {filteredContacts.map((contact, index) => ( -
-
-
- - {index + 1} - -
-

- {contact.first_name || contact.last_name - ? `${contact.first_name || ''} ${contact.last_name || ''}`.trim() - : 'Unnamed Contact'} -

-

- 📱 {formatPhoneNumber(contact.phone_number)} -

-
-
- - ID: {contact.id} - -
- - {/* Additional info */} -
- {contact.created_at && ( -
- Created:{' '} - {new Date( - contact.created_at - ).toLocaleDateString()} -
- )} -
-
- ))} -
-
- - {/* Summary */} -
-
- {filteredContacts.length} contact - {filteredContacts.length !== 1 ? 's' : ''} - {searchTerm && ` (filtered from ${contacts.length})`} -
-
- {searchTerm - ? 'Showing search results' - : 'All contacts loaded'} -
-
- - )} - - )} - -
- - -
-
-
-
-
- ); -}; - -export default ViewContact; diff --git a/src/components/ViewContactWizard/ConfirmationStep.jsx b/src/components/ViewContactWizard/ConfirmationStep.jsx new file mode 100644 index 0000000..6dde71d --- /dev/null +++ b/src/components/ViewContactWizard/ConfirmationStep.jsx @@ -0,0 +1,82 @@ +import React from 'react'; +import styles from './ConfirmationStep.module.css'; + +function ConfirmationStep({ selectedContact, contactDetails, isSubmitting }) { + if (!selectedContact || !contactDetails) { + return ( +
+
+

No contact selected or details available.

+

+ Please go back and select a contact to modify. +

+
+
+ ); + } + + return ( +
+

Review Your Changes

+ +
+
+ Contact Phone + + {selectedContact.phone_number} + +
+ +
+ +
+ First Name +
+ + {selectedContact.first_name || 'Not set'} + + + + {contactDetails.first_name || 'Not set'} + +
+
+ +
+ Last Name +
+ + {selectedContact.last_name || 'Not set'} + + + + {contactDetails.last_name || 'Not set'} + +
+
+ +
+ Nickname +
+ + {selectedContact.nickname || 'Not set'} + + + + {contactDetails.nickname || 'Not set'} + +
+
+
+ + {isSubmitting && ( +
+
+

Updating contact information...

+
+ )} +
+ ); +} + +export default ConfirmationStep; diff --git a/src/components/ViewContactWizard/ConfirmationStep.module.css b/src/components/ViewContactWizard/ConfirmationStep.module.css new file mode 100644 index 0000000..311edb7 --- /dev/null +++ b/src/components/ViewContactWizard/ConfirmationStep.module.css @@ -0,0 +1,214 @@ +/* ConfirmationStep.module.css */ +.confirmationStep { + padding: 32px; + animation: fadeIn 0.3s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.sectionTitle { + font-size: 20px; + font-weight: 600; + color: #1f2937; + margin-bottom: 28px; + display: flex; + align-items: center; + gap: 10px; +} + +.sectionTitle::before { + content: '✓'; + background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); + color: white; + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; +} + +.reviewSection { + background: linear-gradient(145deg, #f8fafc 0%, #ffffff 100%); + border-radius: 16px; + padding: 32px; + border: 1px solid #e2e8f0; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05); + backdrop-filter: blur(10px); +} + +.reviewItem { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 20px; + border-bottom: 1px solid #f1f5f9; + transition: all 0.2s ease; +} + +.reviewItem:hover { + transform: translateX(5px); +} + +.reviewItem:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; +} + +.reviewLabel { + font-weight: 600; + color: #475569; + flex: 1; + font-size: 15px; + display: flex; + align-items: center; + gap: 8px; +} + +.reviewLabel::before { + content: '•'; + color: #8b5cf6; + font-size: 20px; +} + +.reviewValue { + flex: 2; + text-align: right; + color: #0f172a; + font-weight: 500; + font-size: 15px; + background: #f1f5f9; + padding: 8px 16px; + border-radius: 8px; +} + +.reviewComparison { + flex: 2; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 16px; +} + +.oldValue { + color: #94a3b8; + text-decoration: line-through; + font-size: 14px; + background: #f8fafc; + padding: 6px 12px; + border-radius: 6px; + min-width: 100px; + text-align: center; +} + +.arrow { + color: #8b5cf6; + font-weight: bold; + font-size: 20px; + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.newValue { + color: #059669; + font-weight: 600; + font-size: 15px; + background: #d1fae5; + padding: 8px 16px; + border-radius: 8px; + min-width: 100px; + text-align: center; + box-shadow: 0 2px 4px rgba(5, 150, 105, 0.1); +} + +.reviewDivider { + height: 2px; + background: linear-gradient(90deg, transparent, #8b5cf6, transparent); + margin: 24px 0; + opacity: 0.3; +} + +.submittingMessage { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + margin-top: 32px; + padding: 20px; + background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%); + border-radius: 12px; + border: 1px solid #bae6fd; + animation: pulseBg 1.5s infinite; +} + +@keyframes pulseBg { + 0%, + 100% { + background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%); + } + 50% { + background: linear-gradient(135deg, #e0f2fe 0%, #bae6fd 100%); + } +} + +.spinner { + border: 3px solid rgba(139, 92, 246, 0.1); + border-top: 3px solid #8b5cf6; + border-radius: 50%; + width: 24px; + height: 24px; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.submittingMessage p { + color: #0369a1; + font-weight: 500; + font-size: 15px; +} + +.noDataMessage { + text-align: center; + padding: 60px 20px; + color: #64748b; + font-size: 16px; + background: #f8fafc; + border-radius: 12px; + border: 1px dashed #cbd5e1; +} + +.noDataMessage::before { + content: '⚠️'; + font-size: 32px; + display: block; + margin-bottom: 16px; +} diff --git a/src/components/ViewContactWizard/ModifyForm.jsx b/src/components/ViewContactWizard/ModifyForm.jsx new file mode 100644 index 0000000..81b7dbd --- /dev/null +++ b/src/components/ViewContactWizard/ModifyForm.jsx @@ -0,0 +1,119 @@ +import React, { useState, useEffect } from 'react'; +import styles from './ModifyForm.module.css'; + +function ModifyForm({ contactDetails, setContactDetails }) { + const [formData, setFormData] = useState({ + first_name: '', + last_name: '', + nickname: '', + }); + + // Initialize form with contactDetails when component mounts or contactDetails changes + useEffect(() => { + if (contactDetails) { + setFormData({ + first_name: contactDetails.first_name || '', + last_name: contactDetails.last_name || '', + nickname: contactDetails.nickname || '', + }); + } + }, [contactDetails]); + + const handleChange = (e) => { + const { name, value } = e.target; + const updatedFormData = { + ...formData, + [name]: value, + }; + + setFormData(updatedFormData); + + // Update parent state with the updated form data + setContactDetails(updatedFormData); + }; + + return ( +
+

Edit Contact Information

+ +
+ +
+ + 👤 +
+
+ Required • Minimum 2 characters +
+
+ +
+ +
+ + 📇 +
+
+ Required • Minimum 2 characters +
+
+ +
+ +
+ + 🌟 +
+
+ Optional • Leave empty if not needed +
+
+ +
+

+ Fields marked with *{' '} + are required. Nickname is optional and can be left blank. +

+
+
+ ); +} + +export default ModifyForm; diff --git a/src/components/ViewContactWizard/ModifyForm.module.css b/src/components/ViewContactWizard/ModifyForm.module.css new file mode 100644 index 0000000..5d20a2c --- /dev/null +++ b/src/components/ViewContactWizard/ModifyForm.module.css @@ -0,0 +1,156 @@ +/* ModifyForm.module.css */ +.modifyForm { + padding: 32px; + animation: slideIn 0.3s ease-out; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(-20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.sectionTitle { + font-size: 20px; + font-weight: 600; + color: #1f2937; + margin-bottom: 32px; + padding-bottom: 16px; + border-bottom: 2px solid #e2e8f0; + display: flex; + align-items: center; + gap: 12px; +} + +.sectionTitle::before { + content: '✏️'; + font-size: 24px; +} + +.formGroup { + margin-bottom: 24px; +} + +.formLabel { + display: block; + margin-bottom: 10px; + font-weight: 500; + color: #475569; + font-size: 15px; + display: flex; + align-items: center; + gap: 6px; +} + +.requiredIndicator { + color: #ef4444; + font-size: 18px; + margin-left: 2px; +} + +.optionalIndicator { + color: #64748b; + font-size: 12px; + margin-left: 6px; + font-weight: normal; + background: #f1f5f9; + padding: 2px 6px; + border-radius: 4px; +} + +.formInput { + width: 100%; + padding: 14px 18px; + border: 2px solid #e2e8f0; + border-radius: 10px; + font-size: 15px; + transition: all 0.3s ease; + background: #f8fafc; + color: #1e293b; + font-family: inherit; +} + +.formInput:focus { + outline: none; + border-color: #8b5cf6; + background: white; + box-shadow: 0 0 0 4px rgba(139, 92, 246, 0.1); + transform: translateY(-1px); +} + +.formInput::placeholder { + color: #94a3b8; + font-size: 14px; +} + +.formInput:valid:required { + border-color: #10b981; +} + +.formInput:invalid:required:not(:placeholder-shown) { + border-color: #ef4444; +} + +.inputContainer { + position: relative; +} + +.inputIcon { + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); + color: #94a3b8; + font-size: 18px; +} + +.formInfo { + margin-top: 32px; + padding: 16px; + background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%); + border-radius: 10px; + font-size: 14px; + color: #0369a1; + border-left: 4px solid #3b82f6; + display: flex; + align-items: center; + gap: 10px; +} + +.formInfo::before { + content: '💡'; + font-size: 18px; +} + +.formInfo p { + margin: 0; + line-height: 1.5; +} + +.validationHint { + font-size: 13px; + color: #64748b; + margin-top: 6px; + display: flex; + align-items: center; + gap: 6px; +} + +.validationHint::before { + content: 'ⓘ'; + font-size: 12px; +} + +/* Optional field styling */ +.formGroup:last-child .formInput { + border-color: #cbd5e1; +} + +.formGroup:last-child .formInput:focus { + border-color: #8b5cf6; +} diff --git a/src/components/ViewContactWizard/StepNavigation.jsx b/src/components/ViewContactWizard/StepNavigation.jsx new file mode 100644 index 0000000..8275a8f --- /dev/null +++ b/src/components/ViewContactWizard/StepNavigation.jsx @@ -0,0 +1,93 @@ +import React from 'react'; +import styles from './StepNavigation.module.css'; + +function StepNavigation({ + currentStep, + onNext, + onBack, + onSubmit, + onDone, + canProceed, + canGoBack, + isSubmitting, + submissionError, +}) { + const renderButtons = () => { + switch (currentStep) { + case 'select': + case 'modify': + return ( +
+ + +
+ ); + case 'confirm': + return ( +
+ + +
+ ); + case 'result': + return ( + + ); + default: + return null; + } + }; + + return ( +
+ {renderButtons()} + {submissionError && ( + {submissionError} + )} +
+ ); +} + +export default StepNavigation; diff --git a/src/components/ViewContactWizard/StepNavigation.module.css b/src/components/ViewContactWizard/StepNavigation.module.css new file mode 100644 index 0000000..a018fbe --- /dev/null +++ b/src/components/ViewContactWizard/StepNavigation.module.css @@ -0,0 +1,204 @@ +/* StepNavigation.module.css */ +.navigation { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + padding: 24px; + background: linear-gradient(145deg, #ffffff 0%, #f8fafc 100%); + border-top: 1px solid #e2e8f0; + border-radius: 0 0 16px 16px; +} + +.buttonGroup { + display: flex; + gap: 12px; +} + +.navButton { + padding: 14px 28px; + border-radius: 12px; + font-weight: 600; + font-size: 15px; + cursor: pointer; + border: none; + transition: all 0.3s ease; + min-width: 120px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + position: relative; + overflow: hidden; +} + +.navButton::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 5px; + height: 5px; + background: rgba(255, 255, 255, 0.5); + opacity: 0; + border-radius: 100%; + transform: scale(1, 1) translate(-50%); + transform-origin: 50% 50%; +} + +.navButton:focus:not(:active)::after { + animation: ripple 1s ease-out; +} + +@keyframes ripple { + 0% { + transform: scale(0, 0); + opacity: 0.5; + } + 100% { + transform: scale(40, 40); + opacity: 0; + } +} + +.backButton { + background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); + color: #475569; + border: 2px solid #cbd5e1; +} + +.backButton:hover:not(:disabled) { + background: linear-gradient(135deg, #e2e8f0 0%, #cbd5e1 100%); + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(100, 116, 139, 0.2); +} + +.backButton:active:not(:disabled) { + transform: translateY(0); +} + +.backButton:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none !important; +} + +.nextButton { + background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); + color: white; + box-shadow: 0 4px 15px rgba(139, 92, 246, 0.3); +} + +.nextButton:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 8px 25px rgba(139, 92, 246, 0.4); + background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%); +} + +.nextButton:active:not(:disabled) { + transform: translateY(0); +} + +.nextButton:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none !important; + box-shadow: none; +} + +.submitButton { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + color: white; + box-shadow: 0 4px 15px rgba(16, 185, 129, 0.3); +} + +.submitButton:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 8px 25px rgba(16, 185, 129, 0.4); + background: linear-gradient(135deg, #059669 0%, #047857 100%); +} + +.submitButton:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none !important; + box-shadow: none; +} + +.doneButton { + background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); + color: white; + box-shadow: 0 4px 15px rgba(59, 130, 246, 0.3); + margin-left: auto; +} + +.doneButton:hover { + transform: translateY(-2px); + box-shadow: 0 8px 25px rgba(59, 130, 246, 0.4); + background: linear-gradient(135deg, #1d4ed8 0%, #1e40af 100%); +} + +.error { + color: #dc2626; + font-size: 14px; + font-weight: 500; + background: #fef2f2; + padding: 10px 16px; + border-radius: 8px; + border-left: 4px solid #dc2626; + display: flex; + align-items: center; + gap: 8px; + animation: shake 0.5s ease-in-out; +} + +@keyframes shake { + 0%, + 100% { + transform: translateX(0); + } + 10%, + 30%, + 50%, + 70%, + 90% { + transform: translateX(-5px); + } + 20%, + 40%, + 60%, + 80% { + transform: translateX(5px); + } +} + +.error::before { + content: '⚠️'; + font-size: 16px; +} + +.buttonIcon { + font-size: 16px; +} + +/* Responsive adjustments */ +@media (max-width: 640px) { + .navigation { + flex-direction: column; + gap: 12px; + } + + .buttonGroup { + width: 100%; + flex-direction: column; + } + + .navButton { + width: 100%; + } + + .error { + width: 100%; + text-align: center; + } +} diff --git a/src/components/ViewContactWizard/ViewContact.jsx b/src/components/ViewContactWizard/ViewContact.jsx new file mode 100644 index 0000000..dc47bb3 --- /dev/null +++ b/src/components/ViewContactWizard/ViewContact.jsx @@ -0,0 +1,150 @@ +import { React, useState, useEffect } from 'react'; + +import { tokenService } from '../../services/TokenService'; +import { contactApi } from '../../api/contactApi'; +import styles from './ViewContact.module.css'; + +function ViewContact({ selectedContact, onSelectedContact }) { + const [contacts, setContacts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + + useEffect(() => { + try { + console.log('Going'); + fetchContacts(); + } catch (err) { + setError(err.message); + } finally { + setIsLoading(false); + } + }, []); + + const fetchContacts = async () => { + try { + setIsLoading(true); + console.log('Loading contacts'); + + const userId = tokenService.getUserId(); + const response = await contactApi.getContactsWithUserId( + userId, + tokenService.bearerToken() + ); + if (!response) { + throw new Error('Failed to fetch contacts'); + } else { + const data = response.data; + setContacts(data); + setError(null); + } + } catch (err) { + setError(err.message); + console.error('Error fetching contacts:', err); + } finally { + setIsLoading(false); + } + }; + + const filteredContacts = contacts.filter( + (contact) => + contact.phone_number.toLowerCase().includes(searchTerm.toLowerCase()) || + contact.first_name?.toLowerCase().includes(searchTerm.toLowerCase()) || + contact.last_name?.toLowerCase().includes(searchTerm.toLowerCase()) || + contact.nickname?.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + const formatContactDisplay = (contact) => { + const phone = contact.phone_number || 'No phone'; + const name = + [contact.first_name, contact.last_name].filter(Boolean).join(' ') || + 'Unnamed'; + const nickname = contact.nickname ? `"${contact.nickname}"` : ''; + + return `${phone} - ${name} ${nickname}`.trim(); + }; + + if (isLoading) { + return ( +
+
+

Loading contacts...

+
+ ); + } + + if (error) { + return ( +
+

Error loading contacts: {error}

+ +
+ ); + } + + return ( +
+
+
+ setSearchTerm(e.target.value)} + className={styles.searchInput} + /> +
+
+ +
+ {filteredContacts.length === 0 ? ( +
+
👤
+

No contacts found

+

+ {searchTerm + ? 'Try a different search term' + : 'No contacts available'} +

+
+ ) : ( + filteredContacts.map((contact) => ( +
onSelectedContact(contact)} + > +
+ {contact.first_name?.[0]?.toUpperCase() || '?'} +
+
+
+ + {contact.phone_number} + + {contact.nickname && ( + + "{contact.nickname}" + + )} +
+
+ {[contact.first_name, contact.last_name] + .filter(Boolean) + .join(' ') || 'Unnamed Contact'} +
+ {selectedContact?.id === contact.id && ( +
✓ Selected
+ )} +
+
+ )) + )} +
+
+ ); +} + +export default ViewContact; diff --git a/src/components/ViewContactWizard/ViewContact.module.css b/src/components/ViewContactWizard/ViewContact.module.css new file mode 100644 index 0000000..8280016 --- /dev/null +++ b/src/components/ViewContactWizard/ViewContact.module.css @@ -0,0 +1,290 @@ +/* ViewContact.module.css */ +.contactSelection { + padding: 24px; + height: 100%; + display: flex; + flex-direction: column; + animation: fadeIn 0.3s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.selectionHeader { + margin-bottom: 24px; +} + +.searchBox { + position: relative; +} + +.searchInput { + width: 100%; + padding: 14px 48px 14px 16px; + border: 2px solid #e2e8f0; + border-radius: 12px; + font-size: 15px; + background: #f8fafc; + color: #1e293b; + transition: all 0.3s ease; +} + +.searchInput:focus { + outline: none; + border-color: #8b5cf6; + background: white; + box-shadow: 0 0 0 4px rgba(139, 92, 246, 0.1); +} + +.searchInput::placeholder { + color: #94a3b8; +} + +.searchBox::after { + content: '🔍'; + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); + font-size: 18px; + color: #94a3b8; +} + +.contactsList { + flex: 1; + overflow-y: auto; + border-radius: 12px; + background: white; + border: 1px solid #e2e8f0; +} + +.contactsList::-webkit-scrollbar { + width: 6px; +} + +.contactsList::-webkit-scrollbar-track { + background: #f1f5f9; + border-radius: 3px; +} + +.contactsList::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 3px; +} + +.contactsList::-webkit-scrollbar-thumb:hover { + background: #94a3b8; +} + +.contactItem { + display: flex; + align-items: center; + gap: 16px; + padding: 16px 20px; + border-bottom: 1px solid #f1f5f9; + cursor: pointer; + transition: all 0.2s ease; + background: white; +} + +.contactItem:hover { + background: #f8fafc; + transform: translateX(4px); +} + +.contactItem.selected { + background: linear-gradient(90deg, #f0f9ff 0%, #e0f2fe 100%); + border-left: 4px solid #3b82f6; +} + +.contactItem:last-child { + border-bottom: none; +} + +.contactAvatar { + width: 44px; + height: 44px; + border-radius: 50%; + background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 18px; + flex-shrink: 0; + box-shadow: 0 4px 12px rgba(139, 92, 246, 0.2); +} + +.contactInfo { + flex: 1; + min-width: 0; /* Prevents overflow */ +} + +.contactMainInfo { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 6px; + flex-wrap: wrap; +} + +.contactPhone { + font-weight: 600; + color: #1e293b; + font-size: 15px; + background: #f1f5f9; + padding: 4px 10px; + border-radius: 6px; + display: inline-block; +} + +.contactNickname { + color: #7c3aed; + font-size: 14px; + font-style: italic; + background: #f3f0ff; + padding: 4px 10px; + border-radius: 6px; + display: inline-block; +} + +.contactName { + color: #475569; + font-size: 14px; + margin-bottom: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.selectedIndicator { + color: #3b82f6; + font-size: 12px; + font-weight: 500; + background: rgba(59, 130, 246, 0.1); + padding: 2px 8px; + border-radius: 4px; + display: inline-block; + margin-top: 4px; + animation: fadeIn 0.3s ease; +} + +/* Loading state */ +.loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + text-align: center; +} + +.spinner { + border: 4px solid #f1f5f9; + border-top: 4px solid #8b5cf6; + border-radius: 50%; + width: 48px; + height: 48px; + animation: spin 1s linear infinite; + margin-bottom: 16px; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.loading p { + color: #64748b; + font-size: 16px; +} + +/* Error state */ +.errorState { + text-align: center; + padding: 40px 20px; + background: #fef2f2; + border-radius: 12px; + border: 1px solid #fecaca; +} + +.errorState p { + color: #dc2626; + margin-bottom: 20px; + font-size: 15px; +} + +.errorState button { + background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%); + color: white; + border: none; + padding: 10px 24px; + border-radius: 8px; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; +} + +.errorState button:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(220, 38, 38, 0.3); +} + +/* Empty state */ +.emptyState { + text-align: center; + padding: 60px 20px; + color: #64748b; +} + +.emptyStateIcon { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.5; +} + +.emptyStateTitle { + font-size: 18px; + font-weight: 500; + color: #475569; + margin-bottom: 8px; +} + +.emptyStateDescription { + font-size: 14px; + color: #94a3b8; +} + +/* Responsive adjustments */ +@media (max-width: 480px) { + .contactSelection { + padding: 16px; + } + + .contactItem { + padding: 14px 16px; + } + + .contactMainInfo { + flex-direction: column; + align-items: flex-start; + gap: 6px; + } + + .contactAvatar { + width: 40px; + height: 40px; + font-size: 16px; + } +} diff --git a/src/components/ViewContactWizard/ViewContactWizard.module.css b/src/components/ViewContactWizard/ViewContactWizard.module.css new file mode 100644 index 0000000..b116148 --- /dev/null +++ b/src/components/ViewContactWizard/ViewContactWizard.module.css @@ -0,0 +1,264 @@ +/* ViewContactWizard.module.css - Updated for aesthetics */ +.modalOverlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + 135deg, + rgba(15, 23, 42, 0.9) 0%, + rgba(30, 41, 59, 0.95) 100% + ); + backdrop-filter: blur(10px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 20px; + animation: fadeInOverlay 0.3s ease-out; +} + +@keyframes fadeInOverlay { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.modal { + background: linear-gradient(145deg, #ffffff 0%, #f8fafc 100%); + border-radius: 24px; + box-shadow: + 0 25px 50px -12px rgba(0, 0, 0, 0.25), + 0 10px 40px -10px rgba(139, 92, 246, 0.2); + width: 100%; + max-width: 520px; + max-height: 85vh; + display: flex; + flex-direction: column; + animation: slideUp 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); + border: 1px solid rgba(255, 255, 255, 0.2); + overflow: hidden; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(40px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.modalHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding: 28px 32px; + background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); + color: white; + position: relative; + overflow: hidden; +} + +.modalHeader::before { + content: ''; + position: absolute; + top: -50%; + right: -50%; + width: 200px; + height: 200px; + background: rgba(255, 255, 255, 0.1); + border-radius: 50%; +} + +.modalHeader::after { + content: ''; + position: absolute; + bottom: -30%; + left: -10%; + width: 150px; + height: 150px; + background: rgba(255, 255, 255, 0.05); + border-radius: 50%; +} + +.modalTitle { + font-size: 22px; + font-weight: 700; + margin: 0; + position: relative; + z-index: 1; + letter-spacing: -0.5px; + display: flex; + align-items: center; + gap: 12px; +} + +.modalTitle::before { + content: '📱'; + font-size: 24px; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2)); +} + +.closeButton { + background: rgba(255, 255, 255, 0.2); + border: none; + font-size: 28px; + color: white; + cursor: pointer; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: all 0.3s ease; + position: relative; + z-index: 1; + backdrop-filter: blur(5px); +} + +.closeButton:hover { + background: rgba(255, 255, 255, 0.3); + transform: rotate(90deg); +} + +.closeButton:active { + transform: rotate(90deg) scale(0.95); +} + +.modalBody { + flex: 1; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: #cbd5e1 transparent; +} + +.modalBody::-webkit-scrollbar { + width: 6px; +} + +.modalBody::-webkit-scrollbar-track { + background: transparent; +} + +.modalBody::-webkit-scrollbar-thumb { + background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); + border-radius: 10px; +} + +.modalFooter { + background: linear-gradient(145deg, #ffffff 0%, #f8fafc 100%); + border-top: 1px solid #e2e8f0; + position: relative; +} + +/* Step Indicator */ +.stepIndicator { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + padding: 20px 32px 0; +} + +.stepDot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #cbd5e1; + transition: all 0.3s ease; +} + +.stepDot.active { + width: 24px; + background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); + border-radius: 12px; +} + +.stepDot.completed { + background: #10b981; +} + +/* Responsive adjustments */ +@media (max-width: 480px) { + .modal { + max-height: 90vh; + border-radius: 20px; + } + + .modalHeader { + padding: 24px; + } + + .modalTitle { + font-size: 18px; + } + + .closeButton { + width: 32px; + height: 32px; + font-size: 24px; + } +} + +/* Result Step specific styles */ +.resultStep { + padding: 48px 32px; + text-align: center; + animation: fadeIn 0.6s ease-out; +} + +.successIcon { + width: 80px; + height: 80px; + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 40px; + margin: 0 auto 28px; + box-shadow: 0 10px 30px rgba(16, 185, 129, 0.3); + animation: bounceIn 0.6s cubic-bezier(0.68, -0.55, 0.265, 1.55); +} + +@keyframes bounceIn { + 0% { + opacity: 0; + transform: scale(0.3); + } + 50% { + opacity: 1; + transform: scale(1.05); + } + 100% { + transform: scale(1); + } +} + +.resultTitle { + font-size: 22px; + font-weight: 700; + color: #1f2937; + margin-bottom: 16px; +} + +.resultMessage { + color: #64748b; + font-size: 16px; + line-height: 1.6; + max-width: 320px; + margin: 0 auto; + background: #f8fafc; + padding: 20px; + border-radius: 12px; + border: 1px solid #e2e8f0; +} diff --git a/src/components/ViewContactWizard/ViewContactWizardModal.jsx b/src/components/ViewContactWizard/ViewContactWizardModal.jsx new file mode 100644 index 0000000..e79d37b --- /dev/null +++ b/src/components/ViewContactWizard/ViewContactWizardModal.jsx @@ -0,0 +1,125 @@ +import React from 'react'; + +import ViewContact from './ViewContact'; +import ModifyForm from './ModifyForm'; +import ConfirmationStep from './ConfirmationStep'; +import StepNavigation from './StepNavigation'; +import useViewContactWizard from './useViewContactWizard'; + +import styles from './ViewContactWizard.module.css'; + +function ViewContactWizardModal({ isOpen, onClose, onComplete }) { + const wizard = useViewContactWizard(); + + if (!isOpen) return null; + + const renderStep = () => { + switch (wizard.currentStep) { + case 'select': + return ( + + ); + case 'modify': + return ( + + ); + case 'confirm': + return ( + + ); + case 'result': + return ( +
+
+

+ Contact Updated Successfully! +

+

+ The contact details have been updated and saved to your address + book. You can now close this window. +

+
+ ); + default: + return null; + } + }; + + const handleClose = () => { + wizard.reset(); + onClose(); + }; + + const handleComplete = () => { + wizard.reset(); + onComplete(); + }; + + const stepTitles = { + select: 'Select Contact', + modify: 'Edit Contact Details', + confirm: 'Review Changes', + result: 'Update Complete', + }; + + const steps = ['select', 'modify', 'confirm', 'result']; + const currentStepIndex = steps.indexOf(wizard.currentStep); + + return ( +
+
e.stopPropagation()}> +
+

+ {stepTitles[wizard.currentStep]} +

+ +
+ +
+ {steps.map((step, index) => ( +
+ ))} +
+ +
{renderStep()}
+ +
+ +
+
+
+ ); +} + +export default ViewContactWizardModal; diff --git a/src/components/ViewContactWizard/useViewContactWizard.js b/src/components/ViewContactWizard/useViewContactWizard.js new file mode 100644 index 0000000..f9ed86a --- /dev/null +++ b/src/components/ViewContactWizard/useViewContactWizard.js @@ -0,0 +1,212 @@ +import { useState, useCallback, useRef } from 'react'; + +import { contactApi, UpdateContactNamesRequest } from '../../api/contactApi'; +import { tokenService } from '../../services/TokenService'; + +const useViewContactWizard = () => { + const [state, setState] = useState({ + step: 'select', + selectedContact: null, + contactDetails: null, + isSubmitting: false, + submissionError: null, + }); + + const abortControllerRef = useRef(null); + + const canProceedToNextStep = useCallback(() => { + switch (state.step) { + case 'select': + return state.selectedContact != null; + case 'modify': + if (!state.contactDetails) return false; + if ( + !state.contactDetails.first_name || + state.contactDetails.first_name.trim() === '' + ) { + return false; + } else if ( + !state.contactDetails.last_name || + state.contactDetails.last_name.trim() === '' + ) { + return false; + } else { + return true; + } + // Nickname is optional, so we don't validate it + case 'confirm': + return true; + default: + return false; + } + }, [state.step, state.selectedContact, state.contactDetails]); + + const canGoBack = state.step !== 'select'; + + const setContactDetails = (newDetails) => { + console.log('Setting contact details:', newDetails); + setState((prev) => ({ + ...prev, + contactDetails: { ...prev.contactDetails, ...newDetails }, + })); + }; + + const onSelectedContact = (contact) => { + const contactId = contact.id; + + setState((prev) => ({ + ...prev, + selectedContact: + prev.selectedContact?.id === contactId + ? null + : { + id: contactId, + first_name: contact.first_name, + last_name: contact.last_name, + phone_number: contact.phone_number, + user_id: contact.user_id, + nickname: contact.nickname || '', + }, + })); + }; + + const nextStep = () => { + if (!canProceedToNextStep()) return; + + const stepOrder = ['select', 'modify', 'confirm', 'result']; + const currentIndex = stepOrder.indexOf(state.step); + + if (currentIndex < stepOrder.length - 1) { + const nextStepValue = stepOrder[currentIndex + 1]; + + // When moving from select to modify, initialize contactDetails with selected contact + if ( + state.step === 'select' && + nextStepValue === 'modify' && + state.selectedContact + ) { + setState((prev) => ({ + ...prev, + step: nextStepValue, + submissionError: null, + contactDetails: { + first_name: prev.selectedContact.first_name || '', + last_name: prev.selectedContact.last_name || '', + nickname: prev.selectedContact.nickname || '', + phone_number: prev.selectedContact.phone_number || '', + id: prev.selectedContact.id, + user_id: prev.selectedContact.user_id, + }, + })); + return; + } + + setState((prev) => ({ + ...prev, + step: nextStepValue, + submissionError: null, + })); + } + }; + + const prevStep = () => { + const stepOrder = ['select', 'modify', 'confirm', 'result']; + const currentIndex = stepOrder.indexOf(state.step); + if (currentIndex > 0) { + setState((prev) => ({ + ...prev, + step: stepOrder[currentIndex - 1], + submissionError: null, + })); + } + }; + + const submit = async () => { + if (state.isSubmitting) return; + + abortControllerRef.current = new AbortController(); + + setState((prev) => ({ + ...prev, + isSubmitting: true, + submissionError: null, + })); + + try { + console.log('Updating contact'); + + const userId = tokenService.getUserId(); + console.log('Contact details to update: ', state.contactDetails); + + let reqBody = new UpdateContactNamesRequest(); + reqBody.first_name = state.contactDetails.first_name; + reqBody.last_name = state.contactDetails.last_name; + reqBody.nickname = state.contactDetails.nickname || ''; // Send empty string if no nickname + reqBody.contact_id = state.selectedContact.id; + reqBody.user_id = userId; + + const response = await contactApi.updateContactNames( + reqBody, + tokenService.bearerToken() + ); + + if (response) { + setState((prev) => ({ + ...prev, + step: 'result', + isSubmitting: false, + })); + } else { + throw new Error('Failed to update contact'); + } + } catch (error) { + if (error.name === 'AbortError') { + console.log('Submission was aborted'); + return; + } + + setState((prev) => ({ + ...prev, + submissionError: error.message || 'Failed to update contact', + isSubmitting: false, + })); + } + }; + + const reset = () => { + abortControllerRef.current?.abort(); + setState({ + step: 'select', + selectedContact: null, + contactDetails: null, + isSubmitting: false, + submissionError: null, + }); + }; + + const cleanup = () => { + abortControllerRef.current?.abort(); + }; + + return { + // Getters + currentStep: state.step, + selectedContact: state.selectedContact, + contactDetails: state.contactDetails, + isSubmitting: state.isSubmitting, + submissionError: state.submissionError, + canProceedToNextStep: canProceedToNextStep(), + canGoBack, + + // Actions + setContactDetails, + onSelectedContact, + nextStep, + prevStep, + submit, + reset, + cleanup, + }; +}; + +export default useViewContactWizard;