From c2ade93c3f663cd9c725d790485015eb14db5e3d Mon Sep 17 00:00:00 2001 From: phoenix Date: Tue, 30 Dec 2025 00:47:08 +0000 Subject: [PATCH] tsk-8: View contacts (#17) Closes #8 Reviewed-on: https://git.kundeng.us/phoenix/textsender/pulls/17 Co-authored-by: phoenix Co-committed-by: phoenix --- src/components/AddContact.jsx | 1 + src/components/Dashboard.jsx | 24 +- src/components/Login.jsx | 2 - src/components/ViewContact.css | 358 ++++++++++++++++++++++++++ src/components/ViewContact.jsx | 442 +++++++++++++++++++++++++++++++++ 5 files changed, 821 insertions(+), 6 deletions(-) create mode 100644 src/components/ViewContact.css create mode 100644 src/components/ViewContact.jsx diff --git a/src/components/AddContact.jsx b/src/components/AddContact.jsx index ce86a82..6408777 100644 --- a/src/components/AddContact.jsx +++ b/src/components/AddContact.jsx @@ -6,6 +6,7 @@ import { DATA_KEY_ADDED_CONTACT_ID, DATA_KEY_USER_ID, } from '../constants/app'; + import './AddContact.css'; const AddContact = ({ isOpen, onClose, onSuccess }) => { diff --git a/src/components/Dashboard.jsx b/src/components/Dashboard.jsx index 82414b0..4d06665 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 './Dashboard.css'; const Dashboard = () => { const [activeView, setActiveView] = useState('dashboard'); const [showContactForm, setShowContactForm] = useState(false); + const [isPopupOpen, setIsPopupOpen] = useState(false); const handleAddContactClick = () => { setShowContactForm(true); }; const handleContactSuccess = (contactData) => { - // Optional: Handle successful creation console.log('Contact created successfully:', contactData); // You could: @@ -92,11 +94,25 @@ const Dashboard = () => { + + setIsPopupOpen(false)} + title="Item List" + /> {/* Content based on active view */} diff --git a/src/components/Login.jsx b/src/components/Login.jsx index 35d6916..5daf8e2 100644 --- a/src/components/Login.jsx +++ b/src/components/Login.jsx @@ -76,10 +76,8 @@ const Login = () => { if (reponse.ok) { const response = await reponse.json(); - console.log(`Result: ${response.message}`); const loginResult = response.data[0]; const token = loginResult.access_token; - console.log(token); localStorage.setItem(DATA_KEY_ACCESS_TOKEN, token); localStorage.setItem(DATA_KEY_USER_ID, loginResult.user_id); alert(`Login successful! Welcome, ${formData.username}`); diff --git a/src/components/ViewContact.css b/src/components/ViewContact.css new file mode 100644 index 0000000..15ec30e --- /dev/null +++ b/src/components/ViewContact.css @@ -0,0 +1,358 @@ +/* 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 new file mode 100644 index 0000000..b603dc2 --- /dev/null +++ b/src/components/ViewContact.jsx @@ -0,0 +1,442 @@ +import { useState, useEffect, useCallback } from 'react'; + +import { API_BASE_URL } from '../constants/api'; +import { DATA_KEY_ACCESS_TOKEN, DATA_KEY_USER_ID } from '../constants/app'; + +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 = localStorage.getItem(DATA_KEY_USER_ID); + + if (!accessToken) { + throw new Error('Authentication required. Please log in again.'); + } + + console.log('Fetching contacts'); + const response = await fetch( + `${API_BASE_URL}/api/v1/contact?user_id=${userId}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + } + ); + + if (!response.ok) { + if (response.status === 401) { + throw new Error('Session expired. Please log in again.'); + } else if (response.status === 404) { + console.log('No contacts associated with user'); + setLoading(false); + return; + } else { + throw new Error(`Failed to fetch contacts: ${response.status}`); + } + } + + const result = await response.json(); + + if (result.data) { + setContacts(result.data); + setLastUpdated(new Date()); + } else { + 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;