12 Commits
Author SHA1 Message Date
phoenix 5b979c2890 Adding license (#20)
Reviewed-on: #20
2026-07-14 15:40:32 -04:00
phoenix 5c498310fb tsk-18: Updating name (#19)
Closes #18

Reviewed-on: phoenix/textsender#19
2026-07-13 18:17:09 -04:00
phoenix 291cd3c2b1 tsk-10: View scheduled messages (#17)
Closes #10

Reviewed-on: phoenix/textsender#17
2026-07-04 19:39:12 -04:00
phoenix 07f9db2298 tsk-2: Blank screen bug fix (#16)
Closes #2

Reviewed-on: phoenix/textsender#16
2026-07-04 16:56:35 -04:00
phoenix 7a357f6108 tsk-11: Make URLs more dynamic (#15)
Closes #11

Reviewed-on: phoenix/textsender#15
2026-07-04 16:25:45 -04:00
phoenix 53b80e3d2c tsk-4: Fix contacts not being saved (#14)
Closes #4

Reviewed-on: phoenix/textsender#14
2026-07-04 10:53:33 -04:00
phoenix cde21b09c8 tsk-6: Add feature to update password in profile (#13)
Closes #6

Reviewed-on: phoenix/textsender#13
2026-07-02 23:24:31 -04:00
phoenix be1238e24d tsk-5: Add profile page (#12)
Closes #5

Reviewed-on: phoenix/textsender#12
2026-07-01 23:56:22 -04:00
phoenix f2bdb60d55 tsk-7: Schedule message (#8)
Closes #7

Reviewed-on: phoenix/textsender#8
2026-06-30 12:13:52 -04:00
phoenix 833a044ff6 Updating packages (#1)
Reviewed-on: phoenix/textsender#1
2026-06-28 21:52:54 -04:00
phoenixandphoenix 8a31918958 Update versions (#24)
Reviewed-on: phoenix/textsender#24
Co-authored-by: phoenix <mail@kundeng.us>
Co-committed-by: phoenix <mail@kundeng.us>
2026-04-12 18:54:20 -04:00
phoenixandphoenix 0202425a09 tsk-10: Edit Contact (#23)
Closes #10

Reviewed-on: phoenix/textsender#23
Co-authored-by: phoenix <kundeng00@pm.me>
Co-committed-by: phoenix <kundeng00@pm.me>
2026-01-29 22:20:28 +00:00
42 changed files with 3934 additions and 2103 deletions
+2
View File
@@ -0,0 +1,2 @@
VITE_AUTH_URL="http://localhost:9080"
VITE_API_URL="http://localhost:9081"
+2
View File
@@ -0,0 +1,2 @@
VITE_AUTH_URL="http://localhost:9080"
VITE_API_URL="http://localhost:9081"
+4
View File
@@ -22,3 +22,7 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
# Environment
.env.production
.env.development
+22
View File
@@ -0,0 +1,22 @@
Copyright (c) 2026 Kun Deng.
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
+2 -11
View File
@@ -1,15 +1,6 @@
# React + Vite # schedtxt
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. Frontend web app used to send messages
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
# Getting started # Getting started
+1 -2
View File
@@ -2,9 +2,8 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React</title> <title>schedtxt</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+823 -1005
View File
File diff suppressed because it is too large Load Diff
+15 -14
View File
@@ -1,7 +1,7 @@
{ {
"name": "textsender", "name": "schedtxt",
"private": true, "private": true,
"version": "0.0.3", "version": "0.0.11",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -12,19 +12,20 @@
"format:check": "npx prettier --check ." "format:check": "npx prettier --check ."
}, },
"dependencies": { "dependencies": {
"react": "^19.2.3", "dotenv": "^17.4.2",
"react-dom": "^19.2.3", "react": "^19.2.7",
"react-router-dom": "^7.10.1" "react-dom": "^19.2.7",
"react-router-dom": "^7.18.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.30.1", "@eslint/js": "^9.39.4",
"@types/react": "^19.1.8", "@types/react": "^19.2.17",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.6.0", "@vitejs/plugin-react": "^6.0.3",
"eslint": "^9.30.1", "eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^16.3.0", "globals": "^17.5.0",
"vite": "^7.0.4" "vite": "^8.1.0"
} }
} }
View File
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

+2
View File
@@ -8,6 +8,7 @@ import {
import Dashboard from './components/Dashboard'; import Dashboard from './components/Dashboard';
import Login from './components/Login'; import Login from './components/Login';
import Register from './components/Register'; import Register from './components/Register';
import ProfileForm from './components/ProfileForm';
import './App.css'; import './App.css';
@@ -19,6 +20,7 @@ function App() {
<Route path="/dashboard" element={<Dashboard />} /> <Route path="/dashboard" element={<Dashboard />} />
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} /> <Route path="/register" element={<Register />} />
<Route path="/user/profile" element={<ProfileForm />} />
</Routes> </Routes>
</Router> </Router>
); );
+29 -2
View File
@@ -1,12 +1,21 @@
import { API_BASE_URL } from '../constants/api'; import { API_BASE_URL } from '../constants/api';
export class CreateContactRequest { export class CreateContactRequest {
first_name = ''; firstname = '';
last_name = ''; lastname = '';
nickname = '';
phone_number = ''; phone_number = '';
user_id = ''; user_id = '';
} }
export class UpdateContactNamesRequest {
firstname = '';
lastname = '';
nickname = '';
id = '';
user_id = '';
}
export const contactApi = { export const contactApi = {
getContactsWithUserId: async (userId, authBearerToken) => { getContactsWithUserId: async (userId, authBearerToken) => {
console.log('Fetching contacts'); console.log('Fetching contacts');
@@ -55,4 +64,22 @@ export const contactApi = {
return response.json(); 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();
}
},
}; };
+47
View File
@@ -20,3 +20,50 @@ export const loginApi = {
} }
}, },
}; };
export const userApi = {
get_user_profile: async (userId) => {
const reponse = await fetch(
`${AUTH_API_BASE_URL}/api/v1/user/profile/${userId}`,
{
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}
);
if (reponse.ok) {
return reponse.json();
} else {
throw new Error('Error logging in');
}
},
update_password: async (
userId,
currentPassword,
updatedPassword,
confirmedPassword
) => {
const request = {
user_id: userId,
current_password: currentPassword,
updated_password: updatedPassword,
confirmed_password: confirmedPassword,
};
const reponse = await fetch(
`${AUTH_API_BASE_URL}/api/v1/user/password/update`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
}
);
if (reponse.ok) {
return reponse.json();
} else {
throw new Error('Error logging in');
}
},
};
+63
View File
@@ -0,0 +1,63 @@
import { API_BASE_URL } from '../constants/api';
export const scheduleApi = {
scheduleMessage: async (reqBody, authBearerToken) => {
console.log('Scheduling message');
const response = await fetch(`${API_BASE_URL}/api/v1/schedule/message`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: authBearerToken,
},
body: JSON.stringify(reqBody),
});
if (!response.ok) {
throw new Error('Error scheduling message');
} else {
return response.json();
}
},
getScheduledMessage: async (userId, authBearerToken) => {
const response = await fetch(
`${API_BASE_URL}/api/v1/schedule/message?user_id=${userId}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: authBearerToken,
},
}
);
if (!response.ok) {
throw new Error('Error getting scheduled message');
} else {
return response.json();
}
},
prepareMessage: async (reqBody, authBearerToken) => {
console.log('Preparing Scheduled message');
const response = await fetch(
`${API_BASE_URL}/api/v1/schedule/message/status/update`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: authBearerToken,
},
body: JSON.stringify(reqBody),
}
);
if (!response.ok) {
throw new Error('Error updating status of scheduled message');
} else {
return response.json();
}
},
};
+25
View File
@@ -0,0 +1,25 @@
import { API_BASE_URL } from '../constants/api';
export const scheduleEventApi = {
scheduleMessageEvent: async (reqBody, authBearerToken) => {
console.log('Scheduling message Event');
const response = await fetch(
`${API_BASE_URL}/api/v1/schedule/message/event`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: authBearerToken,
},
body: JSON.stringify(reqBody),
}
);
if (!response.ok) {
throw new Error('Error scheduling message event');
} else {
return response.json();
}
},
};
+21 -2
View File
@@ -11,6 +11,7 @@ const AddContact = ({ isOpen, onClose, onSuccess }) => {
phoneNumber: '', phoneNumber: '',
firstName: '', firstName: '',
lastName: '', lastName: '',
nickName: '',
}); });
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@@ -66,8 +67,9 @@ const AddContact = ({ isOpen, onClose, onSuccess }) => {
const userId = tokenService.getUserId(); const userId = tokenService.getUserId();
let contact = new CreateContactRequest(); let contact = new CreateContactRequest();
contact.phone_number = formData.phoneNumber; contact.phone_number = formData.phoneNumber;
contact.first_name = formData.firstName; contact.firstname = formData.firstName;
contact.last_name = formData.lastName; contact.lastname = formData.lastName;
contact.nickname = formData.nickName;
contact.user_id = userId; contact.user_id = userId;
const result = await contactApi.createContact( const result = await contactApi.createContact(
contact, contact,
@@ -178,6 +180,23 @@ const AddContact = ({ isOpen, onClose, onSuccess }) => {
<span className="form-hint">Optional field</span> <span className="form-hint">Optional field</span>
</div> </div>
<div className="form-group">
<label htmlFor="nickName" className="form-label">
Nick Name
</label>
<input
id="nickName"
name="nickName"
type="text"
className="form-input"
value={formData.nickName}
onChange={handleChange}
placeholder="Enter nick name (optional)"
disabled={isSubmitting}
/>
<span className="form-hint">Optional field</span>
</div>
<div className="form-actions"> <div className="form-actions">
<button <button
type="button" type="button"
+25 -25
View File
@@ -1,18 +1,22 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import AddContact from './AddContact'; import AddContact from './AddContact';
import ViewContact from './ViewContact';
import ViewSentMessages from './ViewSentMessages'; import ViewSentMessages from './ViewSentMessages';
import './Dashboard.css'; import './Dashboard.css';
import SendMessageWizardModal from './SendMessageWizard/SendMessageWizardModal'; import SendMessageWizardModal from './SendMessageWizard/SendMessageWizardModal';
import ViewContactWizardModal from './ViewContactWizard/ViewContactWizardModal';
import { APP_NAME } from '../constants/app';
const Dashboard = () => { const Dashboard = () => {
const navigate = useNavigate();
const [activeView, setActiveView] = useState('dashboard'); const [activeView, setActiveView] = useState('dashboard');
const [isWizardOpen, setIsWizardOpen] = useState(false); const [isWizardOpen, setIsWizardOpen] = useState(false);
const [isViewContactWizardOpen, setIsViewContactWizardOpen] = useState(false);
const [isViewSentMessagesOpen, setViewSentMessagesOpen] = useState(false); const [isViewSentMessagesOpen, setViewSentMessagesOpen] = useState(false);
const [showContactForm, setShowContactForm] = useState(false); const [showContactForm, setShowContactForm] = useState(false);
const [isPopupOpen, setIsPopupOpen] = useState(false);
const handleAddContactClick = () => { const handleAddContactClick = () => {
setShowContactForm(true); setShowContactForm(true);
@@ -20,15 +24,9 @@ const Dashboard = () => {
const handleContactSuccess = (contactData) => { const handleContactSuccess = (contactData) => {
console.log('Contact created successfully:', contactData); console.log('Contact created successfully:', contactData);
// You could:
// 1. Refresh the contacts list if you have one
// 2. Show a success notification
// 3. Update local state with the new contact
}; };
const handleWizardComplete = () => { const handleWizardComplete = () => {
// Add your logic here for each button
setIsWizardOpen(false); setIsWizardOpen(false);
}; };
@@ -36,8 +34,20 @@ const Dashboard = () => {
setIsWizardOpen(false); setIsWizardOpen(false);
}; };
const handleViewWizardComplete = () => {
setIsViewContactWizardOpen(false);
};
const handleViewWizardClose = () => {
setIsViewContactWizardOpen(false);
};
const handleNavClick = (view) => { const handleNavClick = (view) => {
setActiveView(view); if (view === 'profile') {
navigate('/user/profile');
} else {
setActiveView(view);
}
}; };
return ( return (
@@ -45,7 +55,7 @@ const Dashboard = () => {
{/* Navbar */} {/* Navbar */}
<nav className="dashboard-nav"> <nav className="dashboard-nav">
<div className="nav-logo"> <div className="nav-logo">
<span className="logo-text">Dashboard</span> <span className="logo-text">{APP_NAME}</span>
</div> </div>
<div className="nav-links"> <div className="nav-links">
<button <button
@@ -54,12 +64,6 @@ const Dashboard = () => {
> >
Dashboard Dashboard
</button> </button>
<button
className={`nav-link ${activeView === 'settings' ? 'active' : ''}`}
onClick={() => handleNavClick('settings')}
>
Settings
</button>
<button <button
className={`nav-link ${activeView === 'profile' ? 'active' : ''}`} className={`nav-link ${activeView === 'profile' ? 'active' : ''}`}
onClick={() => handleNavClick('profile')} onClick={() => handleNavClick('profile')}
@@ -121,7 +125,7 @@ const Dashboard = () => {
<button <button
className="dashboard-btn tertiary" className="dashboard-btn tertiary"
onClick={() => setIsPopupOpen(true)} onClick={() => setIsViewContactWizardOpen(true)}
style={{ style={{
padding: '12px 24px', padding: '12px 24px',
background: '#667eea', background: '#667eea',
@@ -136,9 +140,10 @@ const Dashboard = () => {
<span className="btn-hint">View created contacts</span> <span className="btn-hint">View created contacts</span>
</button> </button>
<ViewContact <ViewContactWizardModal
isOpen={isPopupOpen} isOpen={isViewContactWizardOpen}
onClose={() => setIsPopupOpen(false)} onClose={handleViewWizardClose}
onComplete={handleViewWizardComplete}
title="Item List" title="Item List"
/> />
</div> </div>
@@ -150,11 +155,6 @@ const Dashboard = () => {
Dashboard view is active. Select one of the buttons above. Dashboard view is active. Select one of the buttons above.
</p> </p>
)} )}
{activeView === 'settings' && (
<p className="view-text">
Settings view - Configuration options will go here.
</p>
)}
{activeView === 'profile' && ( {activeView === 'profile' && (
<p className="view-text"> <p className="view-text">
Profile view - User information will go here. Profile view - User information will go here.
+95
View File
@@ -0,0 +1,95 @@
.profile-form {
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
padding: 40px;
width: 100%;
max-width: 420px;
margin: 0 auto;
}
.profile-form h2 {
color: #333;
font-size: 28px;
font-weight: 600;
text-align: center;
margin-bottom: 30px;
}
.profile-form form {
margin-bottom: 25px;
}
.profile-form div {
margin-bottom: 20px;
}
.profile-form label {
display: block;
color: #555;
font-size: 14px;
font-weight: 500;
margin-bottom: 6px;
}
.profile-form input[type='text'],
.profile-form input[type='tel'],
.profile-form input[type='password'] {
width: 100%;
padding: 12px 16px;
border: 2px solid #e1e5e9;
border-radius: 8px;
font-size: 16px;
transition: all 0.3s ease;
box-sizing: border-box;
}
.profile-form input[type='text']:focus,
.profile-form input[type='tel']:focus,
.profile-form input[type='password']:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.profile-form input[readonly] {
background-color: #f9fafb;
cursor: not-allowed;
}
.profile-form button[type='submit'] {
width: 100%;
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;
margin-top: 10px;
}
.profile-form button[type='submit']:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 10px 25px rgba(102, 126, 234, 0.4);
}
.profile-form button[type='button'] {
width: 100%;
background: #f1f5f9;
color: #334155;
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 14px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 10px;
}
.profile-form button[type='button']:hover {
background: #e2e8f0;
}
+252
View File
@@ -0,0 +1,252 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { tokenService } from '../services/TokenService';
import { userApi } from '../api/loginApi';
import { APP_NAME } from '../constants/app';
import './ProfileForm.css';
const ProfileForm = () => {
const navigate = useNavigate();
const [activeView, setActiveView] = useState('profile');
const [subView, setSubView] = useState('details');
const [formData, setFormData] = useState({
firstname: '',
lastname: '',
phone: '',
username: '',
createdDate: new Date().toISOString().split('T')[0],
lastLoginDate: new Date().toISOString().split('T')[0],
});
const [passwordData, setPasswordData] = useState({
currentPassword: '',
newPassword: '',
confirmNewPassword: '',
});
useEffect(() => {
const fetchData = async () => {
const userId = tokenService.getUserId();
const response = await userApi.get_user_profile(userId);
if (response) {
const userProfile = response.data[0];
setFormData((prev) => ({
...prev,
firstname: userProfile.firstname,
lastname: userProfile.lastname,
phone: userProfile.phone_number,
username: userProfile.username,
createdDate: userProfile.created,
lastLoginDate: userProfile.last_login,
}));
} else {
console.error('Error getting user profile');
}
};
fetchData();
}, []);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData({ ...formData, [name]: value });
};
const handlePasswordChange = (e) => {
const { name, value } = e.target;
setPasswordData((prev) => ({
...prev,
[name]: value,
}));
};
const handleNavClick = (view) => {
if (view === 'profile') {
navigate('/user/profile');
} else {
navigate(`/${view}`);
}
};
const handlePasswordSubmit = async (e) => {
e.preventDefault();
try {
const userId = tokenService.getUserId();
const currentPassword = passwordData.currentPassword;
const newPassword = passwordData.newPassword;
const confirmNewPassword = passwordData.confirmNewPassword;
const response = await userApi.update_password(
userId,
currentPassword,
newPassword,
confirmNewPassword
);
if (response) {
console.log('Successfully updated password');
} else {
console.error('Error updating password');
}
} catch (error) {
console.error('Login error:', error);
} finally {
}
};
const renderPasswordForm = () => (
<div className="profile-form">
<h2>Change Password</h2>
<form onSubmit={handlePasswordSubmit}>
<div>
<label htmlFor="currentPassword">Current Password</label>
<input
type="password"
id="currentPassword"
name="currentPassword"
value={passwordData.currentPassword}
onChange={handlePasswordChange}
required
/>
</div>
<div>
<label htmlFor="newPassword">New Password</label>
<input
type="password"
id="newPassword"
name="newPassword"
value={passwordData.newPassword}
onChange={handlePasswordChange}
required
/>
</div>
<div>
<label htmlFor="confirmNewPassword">Confirm New Password</label>
<input
type="password"
id="confirmNewPassword"
name="confirmNewPassword"
value={passwordData.confirmNewPassword}
onChange={handlePasswordChange}
required
/>
</div>
<button type="submit">Update Password</button>
<button type="button" onClick={() => setSubView('details')}>
Back to Profile
</button>
</form>
</div>
);
const renderProfileForm = () => (
<div className="profile-form">
<h2>User Profile</h2>
<form>
<div>
<label htmlFor="firstname">First Name</label>
<input
type="text"
id="firstname"
name="firstname"
value={formData.firstname}
onChange={handleChange}
readOnly
/>
</div>
<div>
<label htmlFor="lastname">Last Name</label>
<input
type="text"
id="lastname"
name="lastname"
value={formData.lastname}
onChange={handleChange}
readOnly
/>
</div>
<div>
<label htmlFor="phone">Phone</label>
<input
type="tel"
id="phone"
name="phone"
value={formData.phone}
onChange={handleChange}
readOnly
/>
</div>
<div>
<label htmlFor="username">Username</label>
<input
type="text"
id="username"
name="username"
value={formData.username}
onChange={handleChange}
readOnly
/>
</div>
<div>
<label htmlFor="createdDate">Created Date</label>
<input
type="text"
id="createdDate"
name="createdDate"
value={formData.createdDate}
readOnly
/>
</div>
<div>
<label htmlFor="lastLoginDate">Last Login Date</label>
<input
type="text"
id="lastLoginDate"
name="lastLoginDate"
value={formData.lastLoginDate}
readOnly
/>
</div>
</form>
<div>
<button type="button" onClick={() => setSubView('password')}>
Change Password
</button>
</div>
</div>
);
return (
<div className="dashboard-container">
{/* Navbar */}
<nav className="dashboard-nav">
<div className="nav-logo">
<span className="logo-text">{APP_NAME}</span>
</div>
<div className="nav-links">
<button
className={`nav-link ${activeView === 'dashboard' ? 'active' : ''}`}
onClick={() => handleNavClick('dashboard')}
>
Dashboard
</button>
<button
className={`nav-link ${activeView === 'profile' ? 'active' : ''}`}
onClick={() => handleNavClick('profile')}
>
Profile
</button>
</div>
</nav>
{/* Main Content */}
{subView === 'details' ? renderProfileForm() : renderPasswordForm()}
</div>
);
};
export default ProfileForm;
@@ -5,6 +5,7 @@ function ConfirmationStep({
selectedContacts, selectedContacts,
createdMessage, createdMessage,
message, message,
scheduledTime,
isSubmitting, isSubmitting,
}) { }) {
// In a real app, you might fetch full contact details here // In a real app, you might fetch full contact details here
@@ -88,6 +89,12 @@ function ConfirmationStep({
</div> </div>
</div> </div>
{/* Scheduled time Section */}
<div>
Scheduled time:{' '}
{scheduledTime ? scheduledTime.toISOString() : 'No schedule date'}
</div>
{/* Summary Section */} {/* Summary Section */}
<div className={styles.confirmationSection}> <div className={styles.confirmationSection}>
<div className={styles.sectionHeader}> <div className={styles.sectionHeader}>
@@ -0,0 +1,89 @@
import React, { useState } from 'react';
function FutureTimeForm({ scheduledTime, setScheduledTime }) {
const [selectedTime, setSelectedTime] = useState(new Date());
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const handleTimeChange = (e) => {
setSelectedTime(e.target.value);
setScheduledTime(new Date(e.target.value));
setError('');
setSuccess('');
};
const validateAndSubmit = (e) => {
e.preventDefault();
if (!selectedTime) {
setError('Please select a date and time.');
return;
}
const now = new Date();
const inputDate = new Date(selectedTime);
// Calculate the minimum allowed time (Now + 10 minutes)
const minAllowedTime = new Date(now.getTime() + 10 * 60000);
if (inputDate < minAllowedTime) {
setError('The time must be at least 10 minutes in the future.');
setSuccess('');
setScheduledTime(null);
} else {
setError('');
setSuccess('Time validated successfully!');
setScheduledTime(inputDate);
}
};
return (
<div
style={{ maxWidth: '400px', margin: '2rem auto', fontFamily: 'Arial' }}
>
<h2>Schedule Event</h2>
<form onSubmit={validateAndSubmit}>
<div style={{ marginBottom: '1rem' }}>
<label
htmlFor="future-time"
style={{ display: 'block', marginBottom: '0.5rem' }}
>
Select Date and Time:
</label>
<input
type="datetime-local"
id="future-time"
value={selectedTime}
onChange={handleTimeChange}
style={{
padding: '8px',
width: '100%',
borderColor: error ? 'red' : '#ccc',
}}
/>
</div>
{error && <p style={{ color: 'red', fontSize: '0.9rem' }}>{error}</p>}
{success && (
<p style={{ color: 'green', fontSize: '0.9rem' }}>{success}</p>
)}
<button
type="submit"
style={{
padding: '10px 20px',
backgroundColor: '#007BFF',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Submit
</button>
</form>
</div>
);
}
export default FutureTimeForm;
@@ -3,9 +3,15 @@ import styles from './SendMessageWizard.module.css';
import { tokenService } from '../../services/TokenService'; import { tokenService } from '../../services/TokenService';
import { messageApi, CreateMessageRequest } from '../../api/messageApi'; import { messageApi, CreateMessageRequest } from '../../api/messageApi';
function MessageForm({ message, onMessageChange, onMessageCreated }) { function MessageForm({
message,
onMessageChange,
onMessageCreated,
onToggleScheduled,
}) {
const [characterCount, setCharacterCount] = useState(message.length); const [characterCount, setCharacterCount] = useState(message.length);
const [isCreatingMessage, setIsCreatingMessage] = useState(false); const [isCreatingMessage, setIsCreatingMessage] = useState(false);
const [isScheduledChecked, setIsScheduledChecked] = useState(false);
const [createdMessage, setCreatedMessage] = useState(null); const [createdMessage, setCreatedMessage] = useState(null);
const handleMessageChange = (e) => { const handleMessageChange = (e) => {
@@ -14,6 +20,11 @@ function MessageForm({ message, onMessageChange, onMessageCreated }) {
setCharacterCount(newMessage.length); setCharacterCount(newMessage.length);
}; };
const handleIsScheduledCheckbox = (event) => {
setIsScheduledChecked(event.target.checked);
onToggleScheduled(event.target.checked);
};
const createMessage = async () => { const createMessage = async () => {
if (!message.trim()) { if (!message.trim()) {
return; return;
@@ -38,10 +49,8 @@ function MessageForm({ message, onMessageChange, onMessageCreated }) {
const createdMessage = response.data[0]; const createdMessage = response.data[0];
setCreatedMessage(createdMessage); setCreatedMessage(createdMessage);
if (onMessageCreated) { if (onMessageCreated) {
console.log('Setting createdMessage');
onMessageCreated(createdMessage); onMessageCreated(createdMessage);
} }
console.log('Created message: ', createdMessage);
} }
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -97,6 +106,17 @@ function MessageForm({ message, onMessageChange, onMessageCreated }) {
<li>Check spelling and grammar</li> <li>Check spelling and grammar</li>
</ul> </ul>
</div> </div>
<div>
<label>
<input
type="checkbox"
checked={isScheduledChecked}
onChange={handleIsScheduledCheckbox}
readOnly
/>
Schedule Message
</label>
</div>
</div> </div>
); );
} }
@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import ContactSelection from './ContactSelection'; import ContactSelection from './ContactSelection';
import FutureTimeForm from './FutureTimeForm';
import MessageForm from './MessageForm'; import MessageForm from './MessageForm';
import ConfirmationStep from './ConfirmationStep'; import ConfirmationStep from './ConfirmationStep';
import StepNavigation from './StepNavigation'; import StepNavigation from './StepNavigation';
@@ -26,6 +27,14 @@ function SendMessageWizardModal({ isOpen, onClose, onComplete }) {
message={wizard.message} message={wizard.message}
onMessageChange={wizard.setMessage} onMessageChange={wizard.setMessage}
onMessageCreated={wizard.setCreatedMessage} onMessageCreated={wizard.setCreatedMessage}
onToggleScheduled={wizard.toggleScheduled}
/>
);
case 'schedule':
return (
<FutureTimeForm
scheduledTime={wizard.scheduledTime}
setScheduledTime={wizard.setScheduledTime}
/> />
); );
case 'confirm': case 'confirm':
@@ -34,6 +43,7 @@ function SendMessageWizardModal({ isOpen, onClose, onComplete }) {
selectedContacts={wizard.selectedContacts} selectedContacts={wizard.selectedContacts}
createdMessage={wizard.createdMessage} createdMessage={wizard.createdMessage}
message={wizard.message} message={wizard.message}
scheduledTime={wizard.scheduledTime}
isSubmitting={wizard.isSubmitting} isSubmitting={wizard.isSubmitting}
/> />
); );
@@ -74,6 +84,7 @@ function SendMessageWizardModal({ isOpen, onClose, onComplete }) {
const stepTitles = { const stepTitles = {
select: 'Select Contacts', select: 'Select Contacts',
message: 'Compose Message', message: 'Compose Message',
schedule: 'Schedule message',
confirm: 'Review & Send', confirm: 'Review & Send',
result: 'Result', result: 'Result',
}; };
@@ -34,6 +34,25 @@ function StepNavigation({
</button> </button>
</> </>
); );
case 'schedule':
return (
<>
<button
onClick={onBack}
disabled={!canGoBack}
className={`${styles.navButton} ${styles.backButton}`}
>
Back
</button>
<button
onClick={onNext}
disabled={!canProceed}
className={`${styles.navButton} ${styles.nextButton}`}
>
{currentStep === 'schedule' ? 'Next' : 'Review'}
</button>
</>
);
case 'confirm': case 'confirm':
return ( return (
<> <>
@@ -5,14 +5,18 @@ import {
sendMessageApi, sendMessageApi,
SendInstantMessageRequest, SendInstantMessageRequest,
} from '../../api/sendMessageApi'; } from '../../api/sendMessageApi';
import { scheduleApi } from '../../api/scheduleApi.js';
import { scheduleEventApi } from '../../api/scheduleEventApi.js';
const useSendMessageWizard = () => { const useSendMessageWizard = () => {
const [state, setState] = useState({ const [state, setState] = useState({
step: 'select', step: 'select',
selectedContacts: [], selectedContacts: [],
scheduledTime: null,
message: '', message: '',
createdMessage: null, createdMessage: null,
isSubmitting: false, isSubmitting: false,
isScheduled: false,
submissionError: null, submissionError: null,
}); });
@@ -24,13 +28,38 @@ const useSendMessageWizard = () => {
case 'select': case 'select':
return state.selectedContacts.length > 0; return state.selectedContacts.length > 0;
case 'message': case 'message':
return state.message.trim().length > 0; if (state.message.trim().length > 0) {
if (state.createdMessage != null) {
return true;
} else {
return false;
}
} else {
return false;
}
case 'schedule':
console.log('Checking to see if schedule can go to the next step');
if (state.scheduledTime == null) {
return false;
}
console.log('Scheduled time is not null');
const result =
state.scheduledTime instanceof Date &&
isNaN(state.scheduledTime.getTime());
return !result;
case 'confirm': case 'confirm':
return true; return true;
default: default:
return false; return false;
} }
}, [state.step, state.selectedContacts.length, state.message]); }, [
state.step,
state.selectedContacts.length,
state.message,
state.createdMessage,
state.scheduledTime,
]);
const canGoBack = state.step !== 'select'; const canGoBack = state.step !== 'select';
@@ -44,6 +73,14 @@ const useSendMessageWizard = () => {
})); }));
}; };
const toggleScheduled = (scheduled) => {
console.log('Yup');
setState((prev) => ({
...prev,
isScheduled: scheduled,
}));
};
const setMessage = (message) => { const setMessage = (message) => {
setState((prev) => ({ ...prev, message })); setState((prev) => ({ ...prev, message }));
}; };
@@ -53,29 +90,67 @@ const useSendMessageWizard = () => {
setState((prev) => ({ ...prev, createdMessage: message })); setState((prev) => ({ ...prev, createdMessage: message }));
}; };
const setScheduledTime = (selectedTime) => {
console.log('Setting scheduled time');
setState((prev) => ({ ...prev, scheduledTime: selectedTime }));
};
const nextStep = () => { const nextStep = () => {
if (!canProceedToNextStep()) return; if (!canProceedToNextStep()) return;
const stepOrder = ['select', 'message', 'confirm', 'result']; const stepOrder = ['select', 'message', 'schedule', 'confirm', 'result'];
const currentIndex = stepOrder.indexOf(state.step); const currentIndex = stepOrder.indexOf(state.step);
if (currentIndex < stepOrder.length - 1) { if (currentIndex < stepOrder.length - 1) {
setState((prev) => ({ if (state.step === 'message') {
...prev, if (state.isScheduled) {
step: stepOrder[currentIndex + 1], setState((prev) => ({
submissionError: null, ...prev,
})); step: stepOrder[currentIndex + 1],
submissionError: null,
}));
} else {
setState((prev) => ({
...prev,
step: stepOrder[currentIndex + 2],
submissionError: null,
}));
}
} else {
setState((prev) => ({
...prev,
step: stepOrder[currentIndex + 1],
submissionError: null,
}));
}
} }
}; };
const prevStep = () => { const prevStep = () => {
const stepOrder = ['select', 'message', 'confirm', 'result']; const stepOrder = ['select', 'message', 'schedule', 'confirm', 'result'];
const currentIndex = stepOrder.indexOf(state.step); const currentIndex = stepOrder.indexOf(state.step);
if (currentIndex > 0) { if (currentIndex > 0) {
setState((prev) => ({ if (state.step === 'confirm') {
...prev, if (state.isScheduled) {
step: stepOrder[currentIndex - 1], setState((prev) => ({
submissionError: null, ...prev,
})); step: stepOrder[currentIndex - 1],
submissionError: null,
}));
} else {
setState((prev) => ({
...prev,
step: stepOrder[currentIndex - 2],
submissionError: null,
}));
}
} else {
setState((prev) => ({
...prev,
step: stepOrder[currentIndex - 1],
submissionError: null,
}));
}
} }
}; };
@@ -91,25 +166,77 @@ const useSendMessageWizard = () => {
})); }));
try { try {
console.log('Sending message'); if (state.scheduledTime != null) {
let reqBod = new SendInstantMessageRequest(); console.log('Going to schedule message');
reqBod.user_id = tokenService.getUserId();
reqBod.message_id = state.createdMessage.id;
state.selectedContacts.forEach((selectedContact) => {
reqBod.contact_ids.push(selectedContact);
});
const response = await sendMessageApi.sendInstantMessage(
reqBod,
tokenService.bearerToken()
);
if (response) { const schMsgReq = {
// Success - move to result step scheduled: state.scheduledTime.toISOString(),
setState((prev) => ({ status: 'PENDING',
...prev, user_id: tokenService.getUserId(),
step: 'result', };
isSubmitting: false,
})); const token = tokenService.bearerToken();
const schMsgResponse = await scheduleApi.scheduleMessage(
schMsgReq,
token
);
if (schMsgResponse) {
console.log('Message scheduled');
const scheduledMessageId = schMsgResponse.data[0].id;
for (let contact of state.selectedContacts) {
let schMsgEvtReq = {
contact_id: contact,
message_id: state.createdMessage.id,
scheduled_message_id: scheduledMessageId,
};
const schMsgEvtResponse =
await scheduleEventApi.scheduleMessageEvent(schMsgEvtReq, token);
}
console.log('Updating message status');
const req = {
scheduled_message_id: scheduledMessageId,
status: 'READY',
};
console.log(req);
const response = await scheduleApi.prepareMessage(req, token);
if (response) {
console.log('Message scheduled');
setState((prev) => ({
...prev,
step: 'result',
isSubmitting: false,
isScheduled: false,
scheduledTime: null,
}));
}
}
} else {
console.log('Sending message');
let reqBod = new SendInstantMessageRequest();
reqBod.user_id = tokenService.getUserId();
reqBod.message_id = state.createdMessage.id;
state.selectedContacts.forEach((selectedContact) => {
reqBod.contact_ids.push(selectedContact);
});
const response = await sendMessageApi.sendInstantMessage(
reqBod,
tokenService.bearerToken()
);
if (response) {
// Success - move to result step
setState((prev) => ({
...prev,
step: 'result',
isSubmitting: false,
isScheduled: false,
scheduledTime: null,
}));
}
} }
} catch (error) { } catch (error) {
if (error.name === 'AbortError') { if (error.name === 'AbortError') {
@@ -121,6 +248,8 @@ const useSendMessageWizard = () => {
...prev, ...prev,
submissionError: error.message || 'Failed to send messages', submissionError: error.message || 'Failed to send messages',
isSubmitting: false, isSubmitting: false,
isScheduled: false,
scheduledTime: null,
})); }));
} }
}; };
@@ -130,9 +259,11 @@ const useSendMessageWizard = () => {
setState({ setState({
step: 'select', step: 'select',
selectedContacts: [], selectedContacts: [],
scheduledTime: null,
message: '', message: '',
createdMessage: null, createdMessage: null,
isSubmitting: false, isSubmitting: false,
isScheduled: false,
submissionError: null, submissionError: null,
}); });
}; };
@@ -146,17 +277,21 @@ const useSendMessageWizard = () => {
// Getters // Getters
currentStep: state.step, currentStep: state.step,
selectedContacts: state.selectedContacts, selectedContacts: state.selectedContacts,
scheduledTime: state.scheduledTime,
message: state.message, message: state.message,
createdMessage: state.createdMessage, createdMessage: state.createdMessage,
isSubmitting: state.isSubmitting, isSubmitting: state.isSubmitting,
isScheduled: state.isScheduled,
submissionError: state.submissionError, submissionError: state.submissionError,
canProceedToNextStep: canProceedToNextStep(), canProceedToNextStep: canProceedToNextStep(),
canGoBack, canGoBack,
// Actions // Actions
toggleContact, toggleContact,
toggleScheduled,
setMessage, setMessage,
setCreatedMessage, setCreatedMessage,
setScheduledTime,
nextStep, nextStep,
prevStep, prevStep,
submit, submit,
-358
View File
@@ -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;
}
-423
View File
@@ -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 (
<div className="contact-modal-overlay" onClick={onClose}>
<div className="contact-modal" onClick={(e) => e.stopPropagation()}>
<div className="contact-card">
<div className="contact-header">
<h2 className="contact-title">{title}</h2>
<button className="close-btn" onClick={onClose} aria-label="Close">
×
</button>
</div>
<div className="contact-form">
{error && (
<div className="submit-error">
<div style={{ marginBottom: '8px' }}>{error}</div>
<button
onClick={handleRefresh}
style={{
background: '#ef4444',
color: 'white',
border: 'none',
borderRadius: '4px',
padding: '6px 12px',
cursor: 'pointer',
fontSize: '12px',
}}
>
Retry Now
</button>
</div>
)}
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<div className="loading-spinner" />
<p style={{ marginTop: '20px', color: '#4b5563' }}>
Loading contacts...
</p>
</div>
) : (
<>
{/* Controls */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px',
flexWrap: 'wrap',
gap: '10px',
}}
>
<button
onClick={handleRefresh}
style={{
background: '#f3f4f6',
color: '#4b5563',
border: '1px solid #e5e7eb',
borderRadius: '6px',
padding: '8px 16px',
cursor: 'pointer',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<span></span> Refresh
</button>
{/* Search Input */}
<input
type="text"
placeholder="Search contacts..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{
flex: 1,
minWidth: '200px',
maxWidth: '300px',
padding: '8px 12px',
border: '1px solid #e5e7eb',
borderRadius: '6px',
fontSize: '14px',
}}
/>
<div
style={{
fontSize: '12px',
color: '#6b7280',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<span>Updated: {formatLastUpdated()}</span>
<span
style={{
display: 'inline-block',
width: '8px',
height: '8px',
borderRadius: '50%',
background: lastUpdated ? '#10b981' : '#9ca3af',
}}
/>
</div>
</div>
{/* Contacts List */}
{filteredContacts.length === 0 ? (
<div
style={{
textAlign: 'center',
padding: '40px',
color: '#4b5563',
}}
>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>
📭
</div>
<p
style={{
fontSize: '18px',
fontWeight: '500',
marginBottom: '8px',
}}
>
{searchTerm
? 'No matching contacts found'
: 'No contacts available'}
</p>
<p style={{ marginBottom: '20px' }}>
{searchTerm
? 'Try a different search term'
: 'Add your first contact to get started'}
</p>
{searchTerm && (
<button
onClick={() => setSearchTerm('')}
style={{
background: '#f3f4f6',
color: '#4b5563',
border: '1px solid #e5e7eb',
borderRadius: '6px',
padding: '8px 16px',
cursor: 'pointer',
fontSize: '14px',
marginBottom: '10px',
}}
>
Clear Search
</button>
)}
</div>
) : (
<>
<div
style={{
maxHeight: '400px',
overflowY: 'auto',
border: '1px solid #e5e7eb',
borderRadius: '8px',
background: '#fafafa',
}}
>
<div style={{ padding: '4px' }}>
{filteredContacts.map((contact, index) => (
<div
key={contact.id}
style={{
padding: '16px',
borderBottom:
index < filteredContacts.length - 1
? '1px solid #e5e7eb'
: 'none',
background: 'white',
transition: 'all 0.2s',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: '8px',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
<span
style={{
background: '#667eea',
color: 'white',
width: '28px',
height: '28px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
fontWeight: '600',
}}
>
{index + 1}
</span>
<div>
<h4
style={{
margin: 0,
color: '#111827',
fontSize: '16px',
fontWeight: '600',
}}
>
{contact.first_name || contact.last_name
? `${contact.first_name || ''} ${contact.last_name || ''}`.trim()
: 'Unnamed Contact'}
</h4>
<p
style={{
margin: '4px 0 0 0',
color: '#6b7280',
fontSize: '14px',
}}
>
📱 {formatPhoneNumber(contact.phone_number)}
</p>
</div>
</div>
<span
style={{
fontSize: '12px',
color: '#9ca3af',
background: '#f3f4f6',
padding: '2px 8px',
borderRadius: '4px',
}}
>
ID: {contact.id}
</span>
</div>
{/* Additional info */}
<div
style={{
marginLeft: '40px',
fontSize: '13px',
color: '#6b7280',
}}
>
{contact.created_at && (
<div style={{ marginTop: '4px' }}>
Created:{' '}
{new Date(
contact.created_at
).toLocaleDateString()}
</div>
)}
</div>
</div>
))}
</div>
</div>
{/* Summary */}
<div
style={{
marginTop: '20px',
padding: '12px 16px',
background: '#f0f9ff',
borderRadius: '6px',
border: '1px solid #bae6fd',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: '14px',
}}
>
<div style={{ color: '#0369a1' }}>
<strong>{filteredContacts.length}</strong> contact
{filteredContacts.length !== 1 ? 's' : ''}
{searchTerm && ` (filtered from ${contacts.length})`}
</div>
<div style={{ color: '#0284c7', fontSize: '12px' }}>
{searchTerm
? 'Showing search results'
: 'All contacts loaded'}
</div>
</div>
</>
)}
</>
)}
<div className="form-actions">
<button
type="button"
className="cancel-btn"
onClick={onClose}
disabled={loading}
>
Close
</button>
<button
type="button"
className="submit-btn"
onClick={handleRefresh}
disabled={loading}
>
{loading ? 'Refreshing...' : 'Refresh List'}
</button>
</div>
</div>
</div>
</div>
</div>
);
};
export default ViewContact;
@@ -0,0 +1,82 @@
import React from 'react';
import styles from './ConfirmationStep.module.css';
function ConfirmationStep({ selectedContact, contactDetails, isSubmitting }) {
if (!selectedContact || !contactDetails) {
return (
<div className={styles.confirmationStep}>
<div className={styles.noDataMessage}>
<p>No contact selected or details available.</p>
<p className={styles.hint}>
Please go back and select a contact to modify.
</p>
</div>
</div>
);
}
return (
<div className={styles.confirmationStep}>
<h3 className={styles.sectionTitle}>Review Your Changes</h3>
<div className={styles.reviewSection}>
<div className={styles.reviewItem}>
<span className={styles.reviewLabel}>Contact Phone</span>
<span className={styles.reviewValue}>
{selectedContact.phone_number}
</span>
</div>
<div className={styles.reviewDivider} />
<div className={styles.reviewItem}>
<span className={styles.reviewLabel}>First Name</span>
<div className={styles.reviewComparison}>
<span className={styles.oldValue}>
{selectedContact.firstname || 'Not set'}
</span>
<span className={styles.arrow}></span>
<span className={styles.newValue}>
{contactDetails.firstname || 'Not set'}
</span>
</div>
</div>
<div className={styles.reviewItem}>
<span className={styles.reviewLabel}>Last Name</span>
<div className={styles.reviewComparison}>
<span className={styles.oldValue}>
{selectedContact.lastname || 'Not set'}
</span>
<span className={styles.arrow}></span>
<span className={styles.newValue}>
{contactDetails.lastname || 'Not set'}
</span>
</div>
</div>
<div className={styles.reviewItem}>
<span className={styles.reviewLabel}>Nickname</span>
<div className={styles.reviewComparison}>
<span className={styles.oldValue}>
{selectedContact.nickname || 'Not set'}
</span>
<span className={styles.arrow}></span>
<span className={styles.newValue}>
{contactDetails.nickname || 'Not set'}
</span>
</div>
</div>
</div>
{isSubmitting && (
<div className={styles.submittingMessage}>
<div className={styles.spinner}></div>
<p>Updating contact information...</p>
</div>
)}
</div>
);
}
export default ConfirmationStep;
@@ -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;
}
@@ -0,0 +1,119 @@
import React, { useState, useEffect } from 'react';
import styles from './ModifyForm.module.css';
function ModifyForm({ contactDetails, setContactDetails }) {
const [formData, setFormData] = useState({
firstname: '',
lastname: '',
nickname: '',
});
// Initialize form with contactDetails when component mounts or contactDetails changes
useEffect(() => {
if (contactDetails) {
setFormData({
firstname: contactDetails.firstname || '',
lastname: contactDetails.lastname || '',
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 (
<div className={styles.modifyForm}>
<h3 className={styles.sectionTitle}>Edit Contact Information</h3>
<div className={styles.formGroup}>
<label htmlFor="firstname" className={styles.formLabel}>
First Name
<span className={styles.requiredIndicator}>*</span>
</label>
<div className={styles.inputContainer}>
<input
type="text"
id="firstname"
name="firstname"
value={formData.firstname}
onChange={handleChange}
className={styles.formInput}
placeholder="Enter contact's first name"
required
minLength="2"
/>
<span className={styles.inputIcon}>👤</span>
</div>
<div className={styles.validationHint}>
Required Minimum 2 characters
</div>
</div>
<div className={styles.formGroup}>
<label htmlFor="lastname" className={styles.formLabel}>
Last Name
<span className={styles.requiredIndicator}>*</span>
</label>
<div className={styles.inputContainer}>
<input
type="text"
id="lastname"
name="lastname"
value={formData.lastname}
onChange={handleChange}
className={styles.formInput}
placeholder="Enter contact's last name"
required
minLength="2"
/>
<span className={styles.inputIcon}>📇</span>
</div>
<div className={styles.validationHint}>
Required Minimum 2 characters
</div>
</div>
<div className={styles.formGroup}>
<label htmlFor="nickname" className={styles.formLabel}>
Nickname
<span className={styles.optionalIndicator}>(Optional)</span>
</label>
<div className={styles.inputContainer}>
<input
type="text"
id="nickname"
name="nickname"
value={formData.nickname}
onChange={handleChange}
className={styles.formInput}
placeholder="Enter a friendly nickname (optional)"
/>
<span className={styles.inputIcon}>🌟</span>
</div>
<div className={styles.validationHint}>
Optional Leave empty if not needed
</div>
</div>
<div className={styles.formInfo}>
<p>
Fields marked with <span className={styles.requiredIndicator}>*</span>{' '}
are required. Nickname is optional and can be left blank.
</p>
</div>
</div>
);
}
export default ModifyForm;
@@ -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;
}
@@ -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 (
<div className={styles.buttonGroup}>
<button
onClick={onBack}
disabled={!canGoBack}
className={`${styles.navButton} ${styles.backButton}`}
>
<span className={styles.buttonIcon}></span>
Back
</button>
<button
onClick={onNext}
disabled={!canProceed}
className={`${styles.navButton} ${styles.nextButton}`}
>
{currentStep === 'select' ? 'Next' : 'Review'}
<span className={styles.buttonIcon}></span>
</button>
</div>
);
case 'confirm':
return (
<div className={styles.buttonGroup}>
<button
onClick={onBack}
className={`${styles.navButton} ${styles.backButton}`}
>
<span className={styles.buttonIcon}></span>
Back
</button>
<button
onClick={onSubmit}
disabled={isSubmitting}
className={`${styles.navButton} ${styles.submitButton}`}
>
{isSubmitting ? (
<>
<span className={styles.buttonIcon}></span>
Updating...
</>
) : (
<>
<span className={styles.buttonIcon}></span>
Update Contact
</>
)}
</button>
</div>
);
case 'result':
return (
<button
onClick={onDone}
className={`${styles.navButton} ${styles.doneButton}`}
>
<span className={styles.buttonIcon}>🏁</span>
Done
</button>
);
default:
return null;
}
};
return (
<div className={styles.navigation}>
{renderButtons()}
{submissionError && (
<span className={styles.error}>{submissionError}</span>
)}
</div>
);
}
export default StepNavigation;
@@ -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;
}
}
@@ -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.firstname?.toLowerCase().includes(searchTerm.toLowerCase()) ||
contact.lastname?.toLowerCase().includes(searchTerm.toLowerCase()) ||
contact.nickname?.toLowerCase().includes(searchTerm.toLowerCase())
);
const formatContactDisplay = (contact) => {
const phone = contact.phone_number || 'No phone';
const name =
[contact.firstname, contact.lastname].filter(Boolean).join(' ') ||
'Unnamed';
const nickname = contact.nickname ? `"${contact.nickname}"` : '';
return `${phone} - ${name} ${nickname}`.trim();
};
if (isLoading) {
return (
<div className={styles.loading}>
<div className={styles.spinner}></div>
<p>Loading contacts...</p>
</div>
);
}
if (error) {
return (
<div className={styles.errorState}>
<p>Error loading contacts: {error}</p>
<button onClick={() => window.location.reload()}>Retry</button>
</div>
);
}
return (
<div className={styles.contactSelection}>
<div className={styles.selectionHeader}>
<div className={styles.searchBox}>
<input
type="text"
placeholder="Search by phone, name, or nickname..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className={styles.searchInput}
/>
</div>
</div>
<div className={styles.contactsList}>
{filteredContacts.length === 0 ? (
<div className={styles.emptyState}>
<div className={styles.emptyStateIcon}>👤</div>
<p className={styles.emptyStateTitle}>No contacts found</p>
<p className={styles.emptyStateDescription}>
{searchTerm
? 'Try a different search term'
: 'No contacts available'}
</p>
</div>
) : (
filteredContacts.map((contact) => (
<div
key={contact.id}
className={`${styles.contactItem} ${
selectedContact?.id === contact.id ? styles.selected : ''
}`}
onClick={() => onSelectedContact(contact)}
>
<div className={styles.contactAvatar}>
{contact.firstname?.[0]?.toUpperCase() || '?'}
</div>
<div className={styles.contactInfo}>
<div className={styles.contactMainInfo}>
<span className={styles.contactPhone}>
{contact.phone_number}
</span>
{contact.nickname && (
<span className={styles.contactNickname}>
"{contact.nickname}"
</span>
)}
</div>
<div className={styles.contactName}>
{[contact.firstname, contact.lastname]
.filter(Boolean)
.join(' ') || 'Unnamed Contact'}
</div>
{selectedContact?.id === contact.id && (
<div className={styles.selectedIndicator}> Selected</div>
)}
</div>
</div>
))
)}
</div>
</div>
);
}
export default ViewContact;
@@ -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;
}
}
@@ -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;
}
@@ -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 (
<ViewContact
selectedContact={wizard.selectedContact}
onSelectedContact={wizard.onSelectedContact}
/>
);
case 'modify':
return (
<ModifyForm
contactDetails={wizard.contactDetails}
setContactDetails={wizard.setContactDetails}
/>
);
case 'confirm':
return (
<ConfirmationStep
selectedContact={wizard.selectedContact}
contactDetails={wizard.contactDetails}
isSubmitting={wizard.isSubmitting}
/>
);
case 'result':
return (
<div className={styles.resultStep}>
<div className={styles.successIcon}></div>
<h3 className={styles.resultTitle}>
Contact Updated Successfully!
</h3>
<p className={styles.resultMessage}>
The contact details have been updated and saved to your address
book. You can now close this window.
</p>
</div>
);
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 (
<div className={styles.modalOverlay} onClick={handleClose}>
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>
{stepTitles[wizard.currentStep]}
</h2>
<button
onClick={handleClose}
className={styles.closeButton}
aria-label="Close"
>
×
</button>
</div>
<div className={styles.stepIndicator}>
{steps.map((step, index) => (
<div
key={step}
className={`${styles.stepDot} ${
index < currentStepIndex ? styles.completed : ''
} ${index === currentStepIndex ? styles.active : ''}`}
/>
))}
</div>
<div className={styles.modalBody}>{renderStep()}</div>
<div className={styles.modalFooter}>
<StepNavigation
currentStep={wizard.currentStep}
onNext={wizard.nextStep}
onBack={wizard.prevStep}
onSubmit={wizard.submit}
onDone={handleComplete}
canProceed={wizard.canProceedToNextStep}
canGoBack={wizard.canGoBack}
isSubmitting={wizard.isSubmitting}
submissionError={wizard.submissionError}
/>
</div>
</div>
</div>
);
}
export default ViewContactWizardModal;
@@ -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.firstname ||
state.contactDetails.firstname.trim() === ''
) {
return false;
} else if (
!state.contactDetails.lastname ||
state.contactDetails.lastname.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,
firstname: contact.firstname,
lastname: contact.lastname,
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: {
firstname: prev.selectedContact.firstname || '',
lastname: prev.selectedContact.lastname || '',
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.firstname = state.contactDetails.firstname;
reqBody.lastname = state.contactDetails.lastname;
reqBody.nickname = state.contactDetails.nickname || ''; // Send empty string if no nickname
reqBody.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;
+99
View File
@@ -355,3 +355,102 @@
.view-sent-message-list-container::-webkit-scrollbar-thumb:hover { .view-sent-message-list-container::-webkit-scrollbar-thumb:hover {
background: #a1a1a1; background: #a1a1a1;
} }
.view-sent-message-tabs {
display: flex;
margin-bottom: 20px;
border-bottom: 1px solid #e5e7eb;
}
.tab-button {
padding: 10px 20px;
background: none;
border: none;
border-bottom: 3px solid transparent;
cursor: pointer;
font-weight: 500;
color: #6b7280;
transition: all 0.3s ease;
}
.tab-button.active {
color: #3b82f6;
border-bottom-color: #3b82f6;
}
.tab-button:hover {
color: #374151;
background-color: #f9fafb;
}
.search-container {
margin-bottom: 20px;
}
.search-input {
width: 100%;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 14px;
}
.search-input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.section-header h3 {
margin: 0;
color: #111827;
font-size: 18px;
font-weight: 600;
}
.last-updated {
font-size: 12px;
color: #6b7280;
}
.no-messages {
text-align: center;
padding: 40px;
color: #6b7280;
font-style: italic;
}
.status-badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
}
.status-processing {
background-color: #fef3c7;
color: #92400e;
}
.status-sent {
background-color: #dcfce7;
color: #166534;
}
.status-failed {
background-color: #fee2e2;
color: #991b1b;
}
.status-default {
background-color: #f3f4f6;
color: #374151;
}
+177 -223
View File
@@ -1,20 +1,21 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { tokenService } from '../services/TokenService'; import { tokenService } from '../services/TokenService';
import { messageEventResponseApi } from '../api/messageEventResponseApi'; import { messageEventResponseApi } from '../api/messageEventResponseApi';
import { scheduleApi } from '../api/scheduleApi';
import './ViewSentMessages.css'; import './ViewSentMessages.css';
const ViewSentMessages = ({ const ViewSentMessages = ({
isOpen, isOpen,
onClose, onClose,
title = 'View Sent Messages ', title = 'View Sent Messages',
}) => { }) => {
const [sentMessages, setSentMessages] = useState([]); const [sentMessages, setSentMessages] = useState([]);
const [scheduledMessages, setScheduledMessages] = useState([]);
const [lastUpdated, setLastUpdated] = useState(null); const [lastUpdated, setLastUpdated] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [activeTab, setActiveTab] = useState('sent');
// Fetch the sent messages or Message Event Responses // Fetch the sent messages or Message Event Responses
const fetchSentMessages = useCallback(async () => { const fetchSentMessages = useCallback(async () => {
@@ -33,7 +34,6 @@ const ViewSentMessages = ({
setLastUpdated(new Date()); setLastUpdated(new Date());
setSentMessages(response.data); setSentMessages(response.data);
} else { } else {
setLoading(false);
throw new Error(response.message || 'Failed to load sent messages'); throw new Error(response.message || 'Failed to load sent messages');
} }
} catch (err) { } catch (err) {
@@ -44,8 +44,32 @@ const ViewSentMessages = ({
} }
}, []); }, []);
const fetchScheduledMessages = useCallback(async () => {
try {
const userId = tokenService.getUserId();
const response = await scheduleApi.getScheduledMessage(
userId,
tokenService.bearerToken()
);
if (response) {
const scheduledMessages = response.data;
setScheduledMessages(scheduledMessages);
} else {
throw new Error(response.message || 'Failed to get scheduled messages');
}
} catch (err) {
console.error('Failed to load scheduled messages:', err);
setScheduledMessages([]);
}
}, []);
const handleRefresh = () => { const handleRefresh = () => {
fetchSentMessages(); if (activeTab === 'sent') {
fetchSentMessages();
} else {
fetchScheduledMessages();
}
}; };
useEffect(() => { useEffect(() => {
@@ -55,6 +79,13 @@ const ViewSentMessages = ({
} }
}, [isOpen, fetchSentMessages]); }, [isOpen, fetchSentMessages]);
// Fetch scheduled messages when tab is switched
useEffect(() => {
if (activeTab === 'scheduled' && isOpen) {
fetchScheduledMessages();
}
}, [activeTab, isOpen, fetchScheduledMessages]);
const filteredSentMessages = sentMessages.filter((sentMessage) => { const filteredSentMessages = sentMessages.filter((sentMessage) => {
const searchLower = searchTerm.toLowerCase(); const searchLower = searchTerm.toLowerCase();
return ( return (
@@ -66,6 +97,15 @@ const ViewSentMessages = ({
); );
}); });
// Filter scheduled messages
const filteredScheduledMessages = scheduledMessages.filter((message) => {
const searchLower = searchTerm.toLowerCase();
return (
(message.status && message.status.toLowerCase().includes(searchLower)) ||
(message.id && message.id.toString().includes(searchTerm))
);
});
const formatLastUpdated = () => { const formatLastUpdated = () => {
if (!lastUpdated) return 'Never'; if (!lastUpdated) return 'Never';
return lastUpdated.toLocaleTimeString([], { return lastUpdated.toLocaleTimeString([], {
@@ -74,6 +114,25 @@ const ViewSentMessages = ({
}); });
}; };
// Format date for scheduled messages
const formatScheduledDate = (dateString) => {
return new Date(dateString).toLocaleString();
};
// Get status badge styling
const getStatusBadgeClass = (status) => {
switch (status) {
case 'PROCESSING':
return 'status-processing';
case 'SENT':
return 'status-sent';
case 'FAILED':
return 'status-failed';
default:
return 'status-default';
}
};
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
@@ -90,13 +149,40 @@ const ViewSentMessages = ({
</button> </button>
</div> </div>
{/* Add tabs for navigation */}
<div className="view-sent-message-tabs">
<button
className={`tab-button ${activeTab === 'sent' ? 'active' : ''}`}
onClick={() => setActiveTab('sent')}
>
Sent Messages
</button>
<button
className={`tab-button ${activeTab === 'scheduled' ? 'active' : ''}`}
onClick={() => setActiveTab('scheduled')}
>
Scheduled Messages
</button>
</div>
{/* Search input */}
<div className="search-container">
<input
type="text"
placeholder="Search messages..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="search-input"
/>
</div>
<div className="view-sent-message-form"> <div className="view-sent-message-form">
{error && ( {error && (
<div className="submit-error"> <div className="submit-error">
<div style={{ marginBottom: '8px' }}>{error}</div> <div style={{ marginBottom: '8px' }}>{error}</div>
<button <button
onClick={handleRefresh} onClick={handleRefresh}
onStype={{ style={{
background: '#ef4444', background: '#ef4444',
color: 'white', color: 'white',
border: 'none', border: 'none',
@@ -113,233 +199,101 @@ const ViewSentMessages = ({
{loading ? ( {loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}> <div style={{ textAlign: 'center', padding: '40px' }}>
<div className="loafing-spinner" /> <div className="loading-spinner" />
<p style={{ marginTop: '20px', color: '#4b5563' }}> <p>Loading messages...</p>
Loading sent messages
</p>
</div> </div>
) : ( ) : (
<> <>
<div {/* Sent Messages Tab Content */}
style={{ {activeTab === 'sent' && (
display: 'flex', <div className="messages-section">
justifyContent: 'space-between', <div className="section-header">
alignItems: 'center', <h3>Sent Messages</h3>
marginBottom: '20px', <span className="last-updated">
flexWrap: 'wrap', Last updated: {formatLastUpdated()}
gap: '10px', </span>
}}
>
<button
onClick={handleRefresh}
style={{
background: '#f3f4f6',
color: '#4b5563',
border: '1px solid #e5e7eb',
borderRadius: '6px',
padding: '8px 16px',
cursor: 'pointer',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<span></span> Refresh
</button>
<input
type="text"
placeholder="Search sent messages..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{
flex: 1,
minWidth: '200px',
maxWidth: '300px',
padding: '8px 12px',
border: '1px solid #e5e7eb',
borderRadius: '6px',
fontSize: '14px',
}}
/>
<div
style={{
fontSize: '12px',
color: '#6b7280',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<span>Updated: {formatLastUpdated()}</span>
<span
style={{
display: 'inline-block',
width: '8px',
height: '8px',
borderRadius: '50%',
background: lastUpdated ? '#10b981' : '#9ca3af',
}}
/>
</div>
</div>
{/* Sent Messages List */}
{filteredSentMessages.length === 0 ? (
<div
style={{
textAlign: 'center',
padding: '40px',
color: '#4b5563',
}}
>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>
X
</div> </div>
<p
style={{ {filteredSentMessages.length > 0 ? (
fontSize: '18px', <div className="view-sent-message-list-container">
fontWeight: '500', <table className="view-sent-message-table">
marginBottom: '8px', <thead>
}} <tr>
> <th>ID</th>
{searchTerm <th>Status</th>
? 'No matching sent messages found' <th>Sent Time</th>
: 'No sent messages available'} </tr>
</p> </thead>
<p style={{ marginBottom: '20px' }}> <tbody>
{searchTerm {filteredSentMessages.map((message) => (
? 'Try a different search term' <tr key={message.id}>
: 'Create a contact and send a message to get started'} <td>{message.id.substring(0, 8)}...</td>
</p> <td>
{searchTerm && ( <span
<button className={`status-badge ${getStatusBadgeClass(message.status)}`}
onClick={() => setSearchTerm('')} >
style={{ {message.status}
background: '#f3f4f6', </span>
color: '#4b5563', </td>
border: '1px solid #e5e7eb', <td>{message.sent || 'N/A'}</td>
borderRadius: '6px', </tr>
padding: '8px 16px', ))}
cursor: 'pointer', </tbody>
fontSize: '14px', </table>
marginBottom: '10px', </div>
}} ) : (
> <div className="no-messages">No sent messages found.</div>
Clear Search
</button>
)} )}
</div> </div>
) : ( )}
<>
<div {/* Scheduled Messages Tab Content */}
style={{ {activeTab === 'scheduled' && (
maxHeight: '400px', <div className="messages-section">
overflowY: 'auto', <div className="section-header">
border: '1px solid #e5e7eb', <h3>Scheduled Messages</h3>
borderRadius: '8px',
background: '#fafafa',
}}
>
<div style={{ padding: '4px' }}>
{filteredSentMessages.map((sentMessage, index) => (
<div
key={sentMessage.id}
style={{
padding: '16px',
borderBottom:
index < filteredSentMessages.length - 1
? '1px solid #e5e7eb'
: 'none',
background: 'white',
transition: 'all 0.2s',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: '8px',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
<span
style={{
background: '#667eea',
color: 'white',
width: '28px',
height: '28px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
fontWeight: '600',
}}
>
{index + 1}
</span>
<div>
<h4
style={{
margin: 0,
color: '#111827',
fontSize: '16px',
fontWeight: '600',
}}
>
{sentMessage.status}
</h4>
<h5>Sent: {sentMessage.sent}</h5>
</div>
</div>
<span
style={{
fontSize: '12px',
color: '#9ca3af',
background: '#f3f4f6',
padding: '2px 8px',
borderRadius: '4px',
}}
>
Id: {sentMessage.id}
</span>
</div>
</div>
))}
</div>
</div> </div>
</>
{filteredScheduledMessages.length > 0 ? (
<div className="view-sent-message-list-container">
<table className="view-sent-message-table">
<thead>
<tr>
<th>ID</th>
<th>Scheduled Time</th>
<th>Created Time</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{filteredScheduledMessages.map((message) => (
<tr key={message.id}>
<td>{message.id.substring(0, 8)}...</td>
<td>
{formatScheduledDate(message.scheduled)}
</td>
<td>{formatScheduledDate(message.created)}</td>
<td>
<span
className={`status-badge ${getStatusBadgeClass(message.status)}`}
>
{message.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="no-messages">
No scheduled messages found.
</div>
)}
</div>
)} )}
</> </>
)} )}
<div className="form-actions">
<button
type="button"
className="cancel-btn"
onClick={onClose}
disabled={loading}
>
Close
</button>
<button
type="button"
className="submit-btn"
onClick={handleRefresh}
disabled={loading}
>
{loading ? 'Refreshing...' : 'Refresh List'}
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
+2 -2
View File
@@ -1,2 +1,2 @@
export const AUTH_API_BASE_URL = 'http://localhost:9080'; export const AUTH_API_BASE_URL = import.meta.env.VITE_AUTH_URL;
export const API_BASE_URL = 'http://localhost:8080'; export const API_BASE_URL = import.meta.env.VITE_API_URL;
+1
View File
@@ -2,3 +2,4 @@ export const DATA_KEY_ACCESS_TOKEN = 'access_token';
export const DATA_KEY_REGISTERED_USER_ID = 'registered_user_id'; export const DATA_KEY_REGISTERED_USER_ID = 'registered_user_id';
export const DATA_KEY_USER_ID = 'user_id'; export const DATA_KEY_USER_ID = 'user_id';
export const DATA_KEY_ADDED_CONTACT_ID = 'added_contact_id'; export const DATA_KEY_ADDED_CONTACT_ID = 'added_contact_id';
export const APP_NAME = 'schedtxt';