18 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
phoenixandphoenix 34644d1cd2 tsk-6: Send Message (#22)
Closes #6

Reviewed-on: phoenix/textsender#22
Co-authored-by: phoenix <kundeng00@pm.me>
Co-committed-by: phoenix <kundeng00@pm.me>
2026-01-04 00:21:22 +00:00
phoenixandphoenix a1a8b44da3 tsk-9: View sent messages (#19)
Closes #9

Reviewed-on: phoenix/textsender#19
Co-authored-by: phoenix <kundeng00@pm.me>
Co-committed-by: phoenix <kundeng00@pm.me>
2026-01-02 23:36:26 +00:00
phoenixandphoenix c2ade93c3f tsk-8: View contacts (#17)
Closes #8

Reviewed-on: phoenix/textsender#17
Co-authored-by: phoenix <kundeng00@pm.me>
Co-committed-by: phoenix <kundeng00@pm.me>
2025-12-30 00:47:08 +00:00
phoenixandphoenix 6f952dc8df tsk-5: Add contact (#15)
Closes #5

Reviewed-on: phoenix/textsender#15
Co-authored-by: phoenix <kundeng00@pm.me>
Co-committed-by: phoenix <kundeng00@pm.me>
2025-12-21 00:27:20 +00:00
phoenixandphoenix 6514f290d7 tsk-3: Dashboard (#7)
Closes #3

Reviewed-on: phoenix/textsender#7
Co-authored-by: phoenix <kundeng00@pm.me>
Co-committed-by: phoenix <kundeng00@pm.me>
2025-12-14 21:16:05 +00:00
phoenixandphoenix ef4952b8cb tsk-2: Login (#4)
Closes #2

Reviewed-on: phoenix/textsender#4
Co-authored-by: phoenix <kundeng00@pm.me>
Co-committed-by: phoenix <kundeng00@pm.me>
2025-12-13 22:39:44 +00:00
54 changed files with 7073 additions and 1061 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
*.sln
*.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.
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.
Frontend web app used to send messages
# Getting started
+1 -2
View File
@@ -2,9 +2,8 @@
<html lang="en">
<head>
<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" />
<title>Vite + React</title>
<title>schedtxt</title>
</head>
<body>
<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,
"version": "0.0.0",
"version": "0.0.11",
"type": "module",
"scripts": {
"dev": "vite",
@@ -12,19 +12,20 @@
"format:check": "npx prettier --check ."
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.10.1"
"dotenv": "^17.4.2",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.0"
},
"devDependencies": {
"@eslint/js": "^9.30.1",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"eslint": "^9.30.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"vite": "^7.0.4"
"@eslint/js": "^9.39.4",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"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

+8 -10
View File
@@ -4,7 +4,12 @@ import {
Route,
Navigate,
} from 'react-router-dom';
import Dashboard from './components/Dashboard';
import Login from './components/Login';
import Register from './components/Register';
import ProfileForm from './components/ProfileForm';
import './App.css';
function App() {
@@ -12,17 +17,10 @@ function App() {
<Router>
<Routes>
<Route path="/" element={<Navigate to="/register" replace />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
{/* Add other routes as needed */}
<Route
path="/login"
element={
<div className="login-placeholder">
<h1>Login Page</h1>
<p>This would be your login page</p>
</div>
}
/>
<Route path="/user/profile" element={<ProfileForm />} />
</Routes>
</Router>
);
+85
View File
@@ -0,0 +1,85 @@
import { API_BASE_URL } from '../constants/api';
export class CreateContactRequest {
firstname = '';
lastname = '';
nickname = '';
phone_number = '';
user_id = '';
}
export class UpdateContactNamesRequest {
firstname = '';
lastname = '';
nickname = '';
id = '';
user_id = '';
}
export const contactApi = {
getContactsWithUserId: async (userId, authBearerToken) => {
console.log('Fetching contacts');
const response = await fetch(
`${API_BASE_URL}/api/v1/contact?user_id=${userId}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: authBearerToken,
},
}
);
if (!response.ok) {
if (response.status === 401) {
throw new Error('Session expired. Please log in again.');
} else if (response.status === 404) {
console.log('No contacts associated with user');
throw new Error('No contacts associated with user');
} else {
throw new Error(`Failed to fetch contacts: ${response.status}`);
}
} else {
return response.json();
}
},
createContact: async (contact, authBearerToken) => {
const response = await fetch(`${API_BASE_URL}/api/v1/contact/new`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: authBearerToken,
},
body: JSON.stringify(contact),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message ||
`Failed to create contact. Status: ${response.status}`
);
} else {
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();
}
},
};
+69
View File
@@ -0,0 +1,69 @@
import { AUTH_API_BASE_URL } from '../constants/api';
export class LoginRequest {
username = '';
password = '';
}
export const loginApi = {
login: async (request) => {
const reponse = await fetch(`${AUTH_API_BASE_URL}/api/v1/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
if (reponse.ok) {
return reponse.json();
} else {
throw new Error('Error logging in');
}
},
};
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');
}
},
};
+29
View File
@@ -0,0 +1,29 @@
import { API_BASE_URL } from '../constants/api';
export class CreateMessageRequest {
content = '';
user_id = '';
}
export const messageApi = {
createMessage: async (requestBody, authBearerToken) => {
const response = await fetch(`${API_BASE_URL}/api/v1/message/new`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: authBearerToken,
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
if (response.status === 500) {
throw new Error('Something went wrong with the system');
} else {
throw new Error('Failed to create message');
}
} else {
return response.json();
}
},
};
+30
View File
@@ -0,0 +1,30 @@
import { API_BASE_URL } from '../constants/api';
export const messageEventResponseApi = {
getMessageEventResponsesByUserId: async (userId, authBearerToken) => {
const response = await fetch(
`${API_BASE_URL}/api/v1/schedule/message/event/response?user_id=${userId}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: authBearerToken,
},
}
);
if (!response.ok) {
if (response.status === 401) {
throw new Error('Session expired. Please log in again.');
} else if (response.status === 404) {
console.log('No message event responses associated with user');
// setLoading(false);
return;
} else {
throw new Error(`Failed to fetch sent messages: ${response.status}`);
}
} else {
return response.json();
}
},
};
+24
View File
@@ -0,0 +1,24 @@
import { AUTH_API_BASE_URL } from '../constants/api';
export class RegisterRequest {
phone_number = '';
firstname = '';
password = '';
}
export const registerApi = {
registerUser: async (request) => {
const response = await fetch(`${AUTH_API_BASE_URL}/api/v1/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
if (response.ok) {
return response.json();
} else {
console.error('Error registering');
throw new Error('Registration failed');
}
},
};
+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();
}
},
};
+26
View File
@@ -0,0 +1,26 @@
import { API_BASE_URL } from '../constants/api';
export class SendInstantMessageRequest {
contact_ids = [];
message_id = '';
user_id = '';
}
export const sendMessageApi = {
sendInstantMessage: async (requestBody, authBearerToken) => {
const response = await fetch(`${API_BASE_URL}/api/v1/instant/message`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: authBearerToken,
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
throw new Error('Failed to send instant message');
} else {
return response.json();
}
},
};
+159
View File
@@ -0,0 +1,159 @@
/* 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;
}
/* Responsive adjustments */
@media (max-width: 480px) {
.contact-card {
padding: 30px 20px;
}
.contact-title {
font-size: 20px;
}
.form-actions {
flex-direction: column;
}
}
.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;
}
+224
View File
@@ -0,0 +1,224 @@
import { useState } from 'react';
import { contactApi, CreateContactRequest } from '../api/contactApi';
import { tokenService } from '../services/TokenService';
import { DATA_KEY_ADDED_CONTACT_ID } from '../constants/app';
import './AddContact.css';
const AddContact = ({ isOpen, onClose, onSuccess }) => {
const [formData, setFormData] = useState({
phoneNumber: '',
firstName: '',
lastName: '',
nickName: '',
});
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState('');
if (!isOpen) return null;
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
// Clear error when user starts typing
if (errors[name]) {
setErrors((prev) => ({
...prev,
[name]: '',
}));
}
// Clear submit error when user modifies form
if (submitError) {
setSubmitError('');
}
};
const validateForm = () => {
const newErrors = {};
// Phone number is required
if (!formData.phoneNumber.trim()) {
newErrors.phoneNumber = 'Phone number is required';
} else if (!/^[\d\s\-\+\(\)]{10,}$/.test(formData.phoneNumber)) {
newErrors.phoneNumber = 'Please enter a valid phone number';
}
return newErrors;
};
const handleSubmit = async (e) => {
e.preventDefault();
const validationErrors = validateForm();
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setIsSubmitting(true);
setSubmitError('');
try {
const userId = tokenService.getUserId();
let contact = new CreateContactRequest();
contact.phone_number = formData.phoneNumber;
contact.firstname = formData.firstName;
contact.lastname = formData.lastName;
contact.nickname = formData.nickName;
contact.user_id = userId;
const result = await contactApi.createContact(
contact,
tokenService.bearerToken()
);
if (result.data.length > 0) {
console.log('Contact added');
const contactId = result.data[0].id;
localStorage.setItem(DATA_KEY_ADDED_CONTACT_ID, contactId);
}
// Reset form
setFormData({ phoneNumber: '', firstName: '', lastName: '' });
// Call onSuccess callback if provided
if (onSuccess) {
onSuccess(result);
}
// Close the modal
onClose();
} catch (error) {
console.error('Error submitting form:', error);
setSubmitError(
error.message || 'Failed to create contact. Please try again.'
);
} finally {
setIsSubmitting(false);
}
};
const handleCancel = () => {
setFormData({ phoneNumber: '', firstName: '', lastName: '' });
setErrors({});
setSubmitError('');
onClose();
};
return (
<div className="contact-modal-overlay">
<div className="contact-modal">
<div className="contact-header">
<h2 className="contact-title">Add New Contact</h2>
<button
className="close-btn"
onClick={handleCancel}
aria-label="Close"
>
×
</button>
</div>
<div className="contact-card">
{submitError && <div className="submit-error">{submitError}</div>}
<form className="contact-form" onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="phoneNumber" className="form-label">
Phone Number <span className="required">*</span>
</label>
<input
id="phoneNumber"
name="phoneNumber"
type="tel"
className={`form-input ${errors.phoneNumber ? 'error' : ''}`}
value={formData.phoneNumber}
onChange={handleChange}
placeholder="Enter phone number"
disabled={isSubmitting}
required
/>
{errors.phoneNumber && (
<span className="error-message">{errors.phoneNumber}</span>
)}
</div>
<div className="form-group">
<label htmlFor="firstName" className="form-label">
First Name
</label>
<input
id="firstName"
name="firstName"
type="text"
className="form-input"
value={formData.firstName}
onChange={handleChange}
placeholder="Enter first name (optional)"
disabled={isSubmitting}
/>
<span className="form-hint">Optional field</span>
</div>
<div className="form-group">
<label htmlFor="lastName" className="form-label">
Last Name
</label>
<input
id="lastName"
name="lastName"
type="text"
className="form-input"
value={formData.lastName}
onChange={handleChange}
placeholder="Enter last name (optional)"
disabled={isSubmitting}
/>
<span className="form-hint">Optional field</span>
</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">
<button
type="button"
className="cancel-btn"
onClick={handleCancel}
disabled={isSubmitting}
>
Cancel
</button>
<button
type="submit"
className="submit-btn"
disabled={isSubmitting}
>
{isSubmitting ? 'Creating...' : 'Create Contact'}
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default AddContact;
+238
View File
@@ -0,0 +1,238 @@
.dashboard-container {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
sans-serif;
}
/* Navbar Styles */
.dashboard-nav {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
padding: 16px 40px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
position: sticky;
top: 0;
z-index: 1000;
}
.nav-logo .logo-text {
color: #333;
font-size: 24px;
font-weight: 600;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.nav-links {
display: flex;
gap: 20px;
}
.nav-link {
background: none;
border: none;
color: #555;
font-size: 16px;
font-weight: 500;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
}
.nav-link:hover {
color: #667eea;
background-color: rgba(102, 126, 234, 0.1);
}
.nav-link.active {
color: #667eea;
background-color: rgba(102, 126, 234, 0.15);
font-weight: 600;
}
/* Main Content */
.dashboard-content {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
min-height: calc(100vh - 72px); /* Subtract navbar height */
}
.dashboard-card {
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
padding: 40px;
width: 100%;
max-width: 800px;
box-sizing: border-box;
}
.dashboard-title {
color: #333;
font-size: 32px;
font-weight: 600;
text-align: center;
margin-bottom: 40px;
}
/* Button Grid */
.button-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 30px;
margin-bottom: 40px;
}
.dashboard-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 12px;
padding: 30px 20px;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.2);
}
.dashboard-btn:hover {
transform: translateY(-5px);
box-shadow: 0 15px 40px rgba(102, 126, 234, 0.4);
}
.dashboard-btn:active {
transform: translateY(-2px);
}
.dashboard-btn.secondary {
background: linear-gradient(135deg, #4caf50 0%, #2e7d32 100%);
box-shadow: 0 10px 30px rgba(76, 175, 80, 0.2);
}
.dashboard-btn.secondary:hover {
box-shadow: 0 15px 40px rgba(76, 175, 80, 0.4);
}
.dashboard-btn.tertiary {
background: linear-gradient(135deg, #ff9800 0%, #f57c00 100%);
box-shadow: 0 10px 30px rgba(255, 152, 0, 0.2);
}
.dashboard-btn.tertiary:hover {
box-shadow: 0 15px 40px rgba(255, 152, 0, 0.4);
}
.btn-text {
color: white;
font-size: 20px;
font-weight: 600;
margin-bottom: 8px;
}
.btn-hint {
color: rgba(255, 255, 255, 0.9);
font-size: 14px;
font-weight: 400;
text-align: center;
}
/* View Content */
.view-content {
background: #f8f9fa;
border-radius: 8px;
padding: 30px;
margin-top: 20px;
}
.view-text {
color: #555;
font-size: 16px;
line-height: 1.6;
margin: 0;
text-align: center;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.dashboard-nav {
padding: 16px 20px;
flex-direction: column;
gap: 15px;
}
.nav-links {
width: 100%;
justify-content: center;
}
.dashboard-card {
padding: 30px 20px;
}
.dashboard-title {
font-size: 28px;
}
.button-grid {
grid-template-columns: 1fr;
gap: 20px;
}
}
@media (max-width: 480px) {
.nav-links {
flex-direction: column;
align-items: center;
gap: 10px;
}
.nav-link {
width: 100%;
text-align: center;
}
.dashboard-btn {
padding: 25px 15px;
}
.btn-text {
font-size: 18px;
}
}
/* Add Contact Button Styles */
.add-contact-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
padding: 12px 24px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
margin: 20px;
}
.add-contact-btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(102, 126, 234, 0.4);
}
.add-contact-btn:active {
transform: translateY(0);
}
/* Existing dashboard styles... */
+170
View File
@@ -0,0 +1,170 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import AddContact from './AddContact';
import ViewSentMessages from './ViewSentMessages';
import './Dashboard.css';
import SendMessageWizardModal from './SendMessageWizard/SendMessageWizardModal';
import ViewContactWizardModal from './ViewContactWizard/ViewContactWizardModal';
import { APP_NAME } from '../constants/app';
const Dashboard = () => {
const navigate = useNavigate();
const [activeView, setActiveView] = useState('dashboard');
const [isWizardOpen, setIsWizardOpen] = useState(false);
const [isViewContactWizardOpen, setIsViewContactWizardOpen] = useState(false);
const [isViewSentMessagesOpen, setViewSentMessagesOpen] = useState(false);
const [showContactForm, setShowContactForm] = useState(false);
const handleAddContactClick = () => {
setShowContactForm(true);
};
const handleContactSuccess = (contactData) => {
console.log('Contact created successfully:', contactData);
};
const handleWizardComplete = () => {
setIsWizardOpen(false);
};
const handleWizardClose = () => {
setIsWizardOpen(false);
};
const handleViewWizardComplete = () => {
setIsViewContactWizardOpen(false);
};
const handleViewWizardClose = () => {
setIsViewContactWizardOpen(false);
};
const handleNavClick = (view) => {
if (view === 'profile') {
navigate('/user/profile');
} else {
setActiveView(view);
}
};
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 */}
<div className="dashboard-content">
<div className="dashboard-card">
<h1 className="dashboard-title">Dashboard</h1>
<div className="button-grid">
<button
className="dashboard-btn primary"
onClick={() => setIsWizardOpen(true)}
>
<span className="btn-text">Send Message</span>
<span className="btn-hint">Send a message</span>
</button>
<SendMessageWizardModal
isOpen={isWizardOpen}
onClose={handleWizardClose}
onComplete={handleWizardComplete}
/>
<button
className="dashboard-btn primary"
onClick={() => setViewSentMessagesOpen(true)}
>
<span className="btn-text">Sent messages</span>
<span className="btn-hint">View sent messages</span>
</button>
<ViewSentMessages
isOpen={isViewSentMessagesOpen}
onClose={() => setViewSentMessagesOpen(false)}
/>
<button
className="dashboard-btn secondary"
onClick={() => handleAddContactClick()}
>
<span className="btn-text">Add Contact</span>
<span className="btn-hint">
Create contacts to send text messages
</span>
</button>
{/* Contact Form Modal - Now self-contained */}
<AddContact
isOpen={showContactForm}
onClose={() => setShowContactForm(false)}
onSuccess={handleContactSuccess}
/>
<button
className="dashboard-btn tertiary"
onClick={() => setIsViewContactWizardOpen(true)}
style={{
padding: '12px 24px',
background: '#667eea',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '16px',
}}
>
<span className="btn-text">View Contact</span>
<span className="btn-hint">View created contacts</span>
</button>
<ViewContactWizardModal
isOpen={isViewContactWizardOpen}
onClose={handleViewWizardClose}
onComplete={handleViewWizardComplete}
title="Item List"
/>
</div>
{/* Content based on active view */}
<div className="view-content">
{activeView === 'dashboard' && (
<p className="view-text">
Dashboard view is active. Select one of the buttons above.
</p>
)}
{activeView === 'profile' && (
<p className="view-text">
Profile view - User information will go here.
</p>
)}
</div>
</div>
</div>
</div>
);
};
export default Dashboard;
+150
View File
@@ -0,0 +1,150 @@
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.login-card {
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
padding: 40px;
width: 100%;
max-width: 420px;
}
.login-title {
color: #333;
font-size: 28px;
font-weight: 600;
text-align: center;
margin-bottom: 30px;
}
.login-form {
margin-bottom: 25px;
}
.form-group {
margin-bottom: 20px;
}
.form-label {
display: block;
color: #555;
font-size: 14px;
font-weight: 500;
margin-bottom: 6px;
}
.form-input {
width: 100%;
padding: 12px 16px;
border: 2px solid #e1e5e9;
border-radius: 8px;
font-size: 16px;
transition: all 0.3s ease;
box-sizing: border-box;
}
.form-input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-input.error {
border-color: #ef4444;
}
.form-input:disabled {
background-color: #f9fafb;
cursor: not-allowed;
}
.error-message {
color: #ef4444;
font-size: 13px;
margin-top: 5px;
display: block;
}
.submit-error {
background-color: #fee;
border: 1px solid #fcc;
border-radius: 6px;
padding: 12px;
margin-bottom: 20px;
text-align: center;
}
.form-hint {
display: block;
color: #6b7280;
font-size: 12px;
margin-top: 4px;
}
.submit-btn {
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;
}
.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;
}
.register-link {
text-align: center;
margin: 20px 0;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
}
.register-link p {
color: #6b7280;
margin: 0;
}
.link {
color: #667eea;
text-decoration: none;
font-weight: 500;
}
.link:hover {
text-decoration: underline;
}
/* Responsive adjustments */
@media (max-width: 480px) {
.login-card {
padding: 30px 20px;
}
.login-title {
font-size: 24px;
}
}
+158
View File
@@ -0,0 +1,158 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { tokenService } from '../services/TokenService';
import { loginApi, LoginRequest } from '../api/loginApi';
import './Login.css';
const Login = () => {
const navigate = useNavigate();
const [formData, setFormData] = useState({
username: '',
password: '',
});
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState('');
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
// Clear error for this field when user starts typing
if (errors[name]) {
setErrors((prev) => ({
...prev,
[name]: '',
}));
}
};
const validateForm = () => {
const newErrors = {};
if (!formData.username.trim()) {
newErrors.username = 'Username is required';
}
if (!formData.password) {
newErrors.password = 'Password is required';
} else if (formData.password.length < 6) {
newErrors.password = 'Password must be at least 6 characters';
}
return newErrors;
};
const handleSubmit = async (e) => {
e.preventDefault();
setSubmitError('');
const formErrors = validateForm();
if (Object.keys(formErrors).length > 0) {
setErrors(formErrors);
return;
}
setIsSubmitting(true);
try {
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
let reqBod = new LoginRequest();
reqBod.username = formData.username;
reqBod.password = formData.password;
const response = await loginApi.login(reqBod);
if (response) {
const loginResult = response.data[0];
const token = loginResult.access_token;
tokenService.setToken(token);
tokenService.setUserId(loginResult.user_id);
alert(`Login successful! Welcome, ${formData.username}`);
navigate('/dashboard');
} else {
console.error('Error logging in');
}
// Reset form
setFormData({ username: '', password: '' });
setErrors({});
} catch (error) {
setSubmitError('Login failed. Please try again.');
console.error('Login error:', error);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="login-container">
<div className="login-card">
<h1 className="login-title">Login</h1>
{submitError && <div className="submit-error">{submitError}</div>}
<form className="login-form" onSubmit={handleSubmit} noValidate>
<div className="form-group">
<label htmlFor="username" className="form-label">
Username
</label>
<input
type="text"
id="username"
name="username"
value={formData.username}
onChange={handleChange}
className={`form-input ${errors.username ? 'error' : ''}`}
placeholder="Enter your username"
disabled={isSubmitting}
/>
{errors.username && (
<span className="error-message">{errors.username}</span>
)}
</div>
<div className="form-group">
<label htmlFor="password" className="form-label">
Password
</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleChange}
className={`form-input ${errors.password ? 'error' : ''}`}
placeholder="Enter your password"
disabled={isSubmitting}
/>
{errors.password && (
<span className="error-message">{errors.password}</span>
)}
<span className="form-hint">Must be at least 6 characters</span>
</div>
<button type="submit" className="submit-btn" disabled={isSubmitting}>
{isSubmitting ? 'Logging in...' : 'Login'}
</button>
</form>
<div className="register-link">
<p>
Don't have an account?{' '}
<a href="register" className="link">
Register here
</a>
</p>
</div>
</div>
</div>
);
};
export default Login;
+1
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import './Register.css';
const PhoneVerification = ({ phoneNumber, onVerify, onResend }) => {
+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;
+11 -18
View File
@@ -1,5 +1,9 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { DATA_KEY_REGISTERED_USER_ID } from '../constants/app';
import { registerApi, RegisterRequest } from '../api/registerApi';
import './Register.css';
const Register = () => {
@@ -80,27 +84,16 @@ const Register = () => {
// Prepare data for API call (remove confirmPassword)
const { confirmPassword, ...userData } = formData;
// Simulate API call
console.log('Submitting registration data:', userData);
let reqBod = new RegisterRequest();
reqBod.phone_number = userData.phoneNumber;
reqBod.username = userData.username;
reqBod.password = userData.password;
// Mock API call delay
await new Promise((resolve) => setTimeout(resolve, 1500));
const reqBod = {
phone_number: userData.phoneNumber,
username: userData.username,
password: userData.password,
};
const response = await registerApi.registerUser(reqBod);
const response = await fetch('http://localhost:9080/api/v1/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqBod),
});
if (response.ok) {
if (response) {
alert('Registration successful! Redirecting to login...');
const res = await response.json();
console.log(res.message);
localStorage.setItem(DATA_KEY_REGISTERED_USER_ID, response.data[0].id);
navigate('/login');
} else {
console.error('Error registering');
@@ -0,0 +1,145 @@
import React from 'react';
import styles from './SendMessageWizard.module.css';
function ConfirmationStep({
selectedContacts,
createdMessage,
message,
scheduledTime,
isSubmitting,
}) {
// In a real app, you might fetch full contact details here
// For now, we'll work with just the IDs
const contactsCount = selectedContacts.length;
const messagePreview =
message.length > 100 ? `${message.substring(0, 100)}...` : message;
const getContactListText = () => {
if (contactsCount === 0) return 'No contacts selected';
if (contactsCount === 1) return '1 contact selected';
return `${contactsCount} contacts selected`;
};
return (
<div className={styles.confirmationStep}>
<div className={styles.confirmationHeader}>
<h3>Ready to Send</h3>
<p className={styles.confirmationSubtitle}>
Review your message and recipients before sending
</p>
</div>
<div className={styles.confirmationSections}>
{/* Recipients Section */}
<div className={styles.confirmationSection}>
<div className={styles.sectionHeader}>
<span className={styles.sectionIcon}>👥</span>
<h4>Recipients</h4>
</div>
<div className={styles.sectionContent}>
<div className={styles.recipientsSummary}>
<span className={styles.recipientsCount}>
{getContactListText()}
</span>
{contactsCount > 0 && (
<div className={styles.recipientIds}>
<strong>Contact IDs:</strong>
<div className={styles.idList}>
{selectedContacts.map((id, index) => (
<span key={id} className={styles.idTag}>
{id}
{index < selectedContacts.length - 1 && ', '}
</span>
))}
</div>
</div>
)}
</div>
</div>
</div>
{/* Message Section */}
<div className={styles.confirmationSection}>
<div className={styles.sectionHeader}>
<span className={styles.sectionIcon}>💬</span>
<h4>Message</h4>
</div>
<div className={styles.sectionContent}>
<div className={styles.messagePreviewContainer}>
<div className={styles.messagePreview}>
{message || (
<em className={styles.noMessage}>No message entered</em>
)}
</div>
<div className={styles.messageStats}>
<div className={styles.statItem}>
<span className={styles.statLabel}>Characters:</span>
<span className={styles.statLabel}>
{createdMessage.content.length}
</span>
</div>
<div className={styles.statItem}>
<span className={styles.statValue}>
{message.trim() ? message.trim().split(/\s+/).length : 0}
</span>
</div>
</div>
</div>
</div>
</div>
{/* Scheduled time Section */}
<div>
Scheduled time:{' '}
{scheduledTime ? scheduledTime.toISOString() : 'No schedule date'}
</div>
{/* Summary Section */}
<div className={styles.confirmationSection}>
<div className={styles.sectionHeader}>
<span className={styles.sectionIcon}>📊</span>
<h4>Summary</h4>
</div>
<div className={styles.sectionContent}>
<div className={styles.summaryStats}>
<div className={styles.summaryStat}>
<span className={styles.statNumber}>{contactsCount}</span>
<span className={styles.statLabel}>Recipients</span>
</div>
<div className={styles.summaryStat}>
<span className={styles.statNumber}>
{message.split('.').length - 1}
</span>
<span className={styles.statLabel}>Sentences</span>
</div>
<div className={styles.summaryStat}>
<span className={styles.statNumber}>
{Math.ceil(message.length / 160)}
</span>
<span className={styles.statLabel}>SMS Parts</span>
</div>
</div>
</div>
</div>
{/* Warning/Info Section */}
<div className={styles.confirmationNotice}>
<div className={styles.noticeIcon}></div>
<div className={styles.noticeContent}>
<strong>Important:</strong> Once sent, messages cannot be recalled.
Please double-check your recipients and message content.
{isSubmitting && (
<div className={styles.sendingNotice}>
<div className={styles.sendingSpinner}></div>
<span>Sending messages...</span>
</div>
)}
</div>
</div>
</div>
</div>
);
}
export default ConfirmationStep;
@@ -0,0 +1,203 @@
/* Confirmation Step Styles */
.confirmationStep {
display: flex;
flex-direction: column;
gap: 24px;
}
.confirmationHeader {
text-align: center;
}
.confirmationHeader h3 {
margin: 0 0 8px 0;
color: #111827;
font-size: 24px;
font-weight: 600;
}
.confirmationSubtitle {
margin: 0;
color: #6b7280;
font-size: 14px;
}
.confirmationSections {
display: flex;
flex-direction: column;
gap: 16px;
}
.confirmationSection {
background-color: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 16px;
}
.sectionHeader {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #e5e7eb;
}
.sectionIcon {
font-size: 20px;
}
.sectionHeader h4 {
margin: 0;
color: #374151;
font-size: 16px;
font-weight: 600;
}
.sectionContent {
padding-left: 32px;
}
.recipientsSummary {
display: flex;
flex-direction: column;
gap: 8px;
}
.recipientsCount {
font-weight: 500;
color: #111827;
}
.recipientIds {
display: flex;
flex-direction: column;
gap: 4px;
}
.idList {
display: flex;
flex-wrap: wrap;
gap: 4px;
font-size: 13px;
}
.idTag {
background-color: #e5e7eb;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Monaco', 'Consolas', monospace;
}
.messagePreviewContainer {
display: flex;
flex-direction: column;
gap: 12px;
}
.messagePreview {
background-color: white;
padding: 12px;
border: 1px solid #e5e7eb;
border-radius: 6px;
font-size: 14px;
line-height: 1.5;
white-space: pre-wrap;
min-height: 80px;
}
.noMessage {
color: #9ca3af;
font-style: italic;
}
.messageStats {
display: flex;
gap: 16px;
}
.statItem {
display: flex;
align-items: center;
gap: 6px;
}
.statLabel {
font-size: 13px;
color: #6b7280;
}
.statValue {
font-weight: 600;
color: #111827;
}
.summaryStats {
display: flex;
justify-content: space-around;
gap: 16px;
}
.summaryStat {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.statNumber {
font-size: 24px;
font-weight: 700;
color: #3b82f6;
}
.statLabel {
font-size: 12px;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.confirmationNotice {
display: flex;
gap: 12px;
padding: 16px;
background-color: #fff7ed;
border: 1px solid #fed7aa;
border-radius: 8px;
color: #9a3412;
}
.noticeIcon {
font-size: 20px;
}
.noticeContent {
flex: 1;
font-size: 14px;
line-height: 1.5;
}
.noticeContent strong {
color: #ea580c;
}
.sendingNotice {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
padding: 8px;
background-color: rgba(255, 255, 255, 0.5);
border-radius: 4px;
}
.sendingSpinner {
width: 16px;
height: 16px;
border: 2px solid #f97316;
border-top-color: transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@@ -0,0 +1,141 @@
import React, { useState, useEffect } from 'react';
import styles from './SendMessageWizard.module.css';
import { tokenService } from '../../services/TokenService';
import { contactApi } from '../../api/contactApi';
function ContactSelection({ selectedContacts, onToggleContact }) {
const [contacts, setContacts] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
const fetchContacts = async () => {
try {
setIsLoading(true);
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);
}
};
fetchContacts();
}, []);
const filteredContacts = contacts.filter(
(contact) =>
contact.phone_number.toLowerCase().includes(searchTerm.toLowerCase()) ||
contact.first_name.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleSelectAll = () => {
const allContactIds = contacts.map((contact) => contact.id);
if (selectedContacts.length === contacts.length) {
// Deselect all
allContactIds.forEach((id) => {
if (selectedContacts.includes(id)) onToggleContact(id);
});
} else {
// Select all
allContactIds.forEach((id) => {
if (!selectedContacts.includes(id)) onToggleContact(id);
});
}
};
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 contacts..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className={styles.searchInput}
/>
</div>
<div className={styles.selectionControls}>
<button onClick={handleSelectAll} className={styles.selectAllButton}>
{selectedContacts.length === contacts.length
? 'Deselect All'
: 'Select All'}
</button>
<span className={styles.selectionCount}>
{selectedContacts.length} of {contacts.length} selected
</span>
</div>
</div>
<div className={styles.contactsList}>
{filteredContacts.length === 0 ? (
<p className={styles.noResults}>No contacts found</p>
) : (
filteredContacts.map((contact) => (
<div
key={contact.id}
className={`${styles.contactItem} ${
selectedContacts.includes(contact.id) ? styles.selected : ''
}`}
onClick={() => onToggleContact(contact.id)}
>
<div className={styles.contactCheckbox}>
<input
type="checkbox"
checked={selectedContacts.includes(contact.id)}
onChange={() => {}}
readOnly
/>
</div>
<div className={styles.contactInfo}>
<span className={styles.contactName}>
{contact.phone_number}
</span>
<span className={styles.contactEmail}>
{contact.first_name}
</span>
</div>
</div>
))
)}
</div>
</div>
);
}
export default ContactSelection;
@@ -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;
@@ -0,0 +1,124 @@
import React, { useState } from 'react';
import styles from './SendMessageWizard.module.css';
import { tokenService } from '../../services/TokenService';
import { messageApi, CreateMessageRequest } from '../../api/messageApi';
function MessageForm({
message,
onMessageChange,
onMessageCreated,
onToggleScheduled,
}) {
const [characterCount, setCharacterCount] = useState(message.length);
const [isCreatingMessage, setIsCreatingMessage] = useState(false);
const [isScheduledChecked, setIsScheduledChecked] = useState(false);
const [createdMessage, setCreatedMessage] = useState(null);
const handleMessageChange = (e) => {
const newMessage = e.target.value;
onMessageChange(newMessage);
setCharacterCount(newMessage.length);
};
const handleIsScheduledCheckbox = (event) => {
setIsScheduledChecked(event.target.checked);
onToggleScheduled(event.target.checked);
};
const createMessage = async () => {
if (!message.trim()) {
return;
}
setIsCreatingMessage(true);
setCreatedMessage(null);
try {
const userId = tokenService.getUserId();
let reqBod = new CreateMessageRequest();
reqBod.user_id = userId;
reqBod.content = message;
const response = await messageApi.createMessage(
reqBod,
tokenService.bearerToken()
);
if (!response) {
throw new Error('Failed to create message');
} else {
const createdMessage = response.data[0];
setCreatedMessage(createdMessage);
if (onMessageCreated) {
onMessageCreated(createdMessage);
}
}
} catch (error) {
console.error(error);
} finally {
setIsCreatingMessage(false);
}
};
return (
<div className={styles.messageForm}>
<div className={styles.formGroup}>
<label htmlFor="message" className={styles.formLabel}>
Your Message
</label>
<textarea
id="message"
value={message}
onChange={handleMessageChange}
placeholder="Type your message here..."
className={styles.messageTextarea}
rows={8}
/>
<div className={styles.textareaFooter}>
<span className={styles.characterCount}>
{characterCount} characters
</span>
<button
type="button"
onClick={createMessage}
disabled={isCreatingMessage || !message.trim()}
className={styles.testButton}
>
{isCreatingMessage ? 'Creating message...' : 'Create message'}
</button>
</div>
</div>
{createdMessage && (
<div
className={`${styles.createdMessage} ${
createdMessage.content ? styles.success : styles.error
}`}
>
{createdMessage.content}
</div>
)}
<div className={styles.messageTips}>
<h4>Tips:</h4>
<ul>
<li>Keep messages clear and concise</li>
<li>Use [Name] placeholder for personalization</li>
<li>Check spelling and grammar</li>
</ul>
</div>
<div>
<label>
<input
type="checkbox"
checked={isScheduledChecked}
onChange={handleIsScheduledCheckbox}
readOnly
/>
Schedule Message
</label>
</div>
</div>
);
}
export default MessageForm;
@@ -0,0 +1,453 @@
/* Modal Styles */
.modalOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal {
background-color: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
width: 90%;
max-width: 600px;
max-height: 90vh;
display: flex;
flex-direction: column;
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modalHeader {
padding: 20px 24px;
border-bottom: 1px solid #e5e7eb;
display: flex;
justify-content: space-between;
align-items: center;
}
.modalHeader h2 {
margin: 0;
color: #111827;
font-size: 20px;
font-weight: 600;
}
.closeButton {
background: none;
border: none;
font-size: 28px;
line-height: 1;
color: #6b7280;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
transition: background-color 0.2s;
}
.closeButton:hover {
background-color: #f3f4f6;
color: #374151;
}
.modalBody {
padding: 24px;
flex: 1;
overflow-y: auto;
}
.modalFooter {
padding: 20px 24px;
border-top: 1px solid #e5e7eb;
background-color: #f9fafb;
}
/* Contact Selection Styles */
.contactSelection {
display: flex;
flex-direction: column;
gap: 16px;
}
.selectionHeader {
display: flex;
flex-direction: column;
gap: 12px;
}
.searchBox {
position: relative;
}
.searchInput {
width: 100%;
padding: 10px 16px;
padding-left: 40px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.2s;
}
.searchInput:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.searchBox::before {
content: '🔍';
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #9ca3af;
}
.selectionControls {
display: flex;
justify-content: space-between;
align-items: center;
}
.selectAllButton {
padding: 6px 12px;
background-color: #f3f4f6;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 14px;
color: #374151;
cursor: pointer;
transition: all 0.2s;
}
.selectAllButton:hover {
background-color: #e5e7eb;
}
.selectionCount {
font-size: 14px;
color: #6b7280;
}
.contactsList {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 400px;
overflow-y: auto;
}
.contactItem {
display: flex;
align-items: center;
padding: 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
gap: 12px;
}
.contactItem:hover {
border-color: #3b82f6;
background-color: #f8fafc;
}
.contactItem.selected {
border-color: #3b82f6;
background-color: #eff6ff;
}
.contactCheckbox {
display: flex;
align-items: center;
}
.contactCheckbox input[type='checkbox'] {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: #3b82f6;
}
.contactInfo {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.contactName {
font-weight: 500;
color: #111827;
}
.contactEmail {
font-size: 14px;
color: #6b7280;
}
.noResults {
text-align: center;
padding: 32px;
color: #6b7280;
font-style: italic;
}
/* Loading State */
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
gap: 16px;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.errorState {
text-align: center;
padding: 32px;
color: #dc2626;
}
.errorState button {
margin-top: 12px;
padding: 8px 16px;
background-color: #dc2626;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
}
/* Message Form Styles */
.messageForm {
display: flex;
flex-direction: column;
gap: 20px;
}
.formGroup {
display: flex;
flex-direction: column;
gap: 8px;
}
.formLabel {
font-weight: 500;
color: #374151;
}
.messageTextarea {
width: 100%;
padding: 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
font-family: inherit;
resize: vertical;
transition: border-color 0.2s;
}
.messageTextarea:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.textareaFooter {
display: flex;
justify-content: space-between;
align-items: center;
}
.characterCount {
font-size: 14px;
color: #6b7280;
}
.testButton {
padding: 6px 12px;
background-color: #10b981;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s;
}
.testButton:hover:not(:disabled) {
background-color: #059669;
}
.testButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.testResult {
padding: 12px;
border-radius: 6px;
font-size: 14px;
}
.testResult.success {
background-color: #d1fae5;
color: #065f46;
border: 1px solid #a7f3d0;
}
.testResult.error {
background-color: #fee2e2;
color: #991b1b;
border: 1px solid #fecaca;
}
.messageTips {
background-color: #f8fafc;
padding: 16px;
border-radius: 8px;
border-left: 4px solid #3b82f6;
}
.messageTips h4 {
margin: 0 0 8px 0;
color: #374151;
}
.messageTips ul {
margin: 0;
padding-left: 20px;
color: #6b7280;
font-size: 14px;
}
.messageTips li {
margin-bottom: 4px;
}
/* Step Navigation */
.navigation {
display: flex;
justify-content: space-between;
align-items: center;
}
.navButton {
padding: 10px 20px;
border-radius: 6px;
font-weight: 500;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
border: 1px solid transparent;
}
.navButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.backButton {
background-color: white;
color: #374151;
border-color: #d1d5db;
}
.backButton:hover:not(:disabled) {
background-color: #f9fafb;
}
.nextButton {
background-color: #3b82f6;
color: white;
}
.nextButton:hover:not(:disabled) {
background-color: #2563eb;
}
.submitButton {
background-color: #10b981;
color: white;
}
.submitButton:hover:not(:disabled) {
background-color: #059669;
}
.doneButton {
background-color: #8b5cf6;
color: white;
}
.doneButton:hover:not(:disabled) {
background-color: #7c3aed;
}
/* Result Step */
.resultStep {
text-align: center;
padding: 32px;
}
.success {
color: #065f46;
}
.error {
color: #dc2626;
}
.error h3 {
margin-top: 0;
}
@@ -0,0 +1,126 @@
import React from 'react';
import ContactSelection from './ContactSelection';
import FutureTimeForm from './FutureTimeForm';
import MessageForm from './MessageForm';
import ConfirmationStep from './ConfirmationStep';
import StepNavigation from './StepNavigation';
import useSendMessageWizard from './useSendMessageWizard';
import styles from './SendMessageWizard.module.css';
function SendMessageWizardModal({ isOpen, onClose, onComplete }) {
const wizard = useSendMessageWizard();
if (!isOpen) return null;
const renderStep = () => {
switch (wizard.currentStep) {
case 'select':
return (
<ContactSelection
selectedContacts={wizard.selectedContacts}
onToggleContact={wizard.toggleContact}
/>
);
case 'message':
return (
<MessageForm
message={wizard.message}
onMessageChange={wizard.setMessage}
onMessageCreated={wizard.setCreatedMessage}
onToggleScheduled={wizard.toggleScheduled}
/>
);
case 'schedule':
return (
<FutureTimeForm
scheduledTime={wizard.scheduledTime}
setScheduledTime={wizard.setScheduledTime}
/>
);
case 'confirm':
return (
<ConfirmationStep
selectedContacts={wizard.selectedContacts}
createdMessage={wizard.createdMessage}
message={wizard.message}
scheduledTime={wizard.scheduledTime}
isSubmitting={wizard.isSubmitting}
/>
);
case 'result':
return (
<div className={styles.resultStep}>
{wizard.submissionError ? (
<div className={styles.error}>
<h3>Error Sending Messages</h3>
<p>{wizard.submissionError}</p>
</div>
) : (
<div className={styles.success}>
<h3>Messages Sent Successfully!</h3>
<p>
Your message has been sent to {wizard.selectedContacts.length}{' '}
contacts.
</p>
</div>
)}
</div>
);
default:
return null;
}
};
const handleClose = () => {
wizard.reset();
onClose();
};
const handleComplete = () => {
wizard.reset();
onComplete();
};
const stepTitles = {
select: 'Select Contacts',
message: 'Compose Message',
schedule: 'Schedule message',
confirm: 'Review & Send',
result: 'Result',
};
return (
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2>{stepTitles[wizard.currentStep]}</h2>
<button
onClick={handleClose}
className={styles.closeButton}
aria-label="Close"
>
×
</button>
</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 SendMessageWizardModal;
@@ -0,0 +1,100 @@
import React from 'react';
import styles from './SendMessageWizard.module.css';
function StepNavigation({
currentStep,
onNext,
onBack,
onSubmit,
onDone,
canProceed,
canGoBack,
isSubmitting,
submissionError,
}) {
const renderButtons = () => {
switch (currentStep) {
case 'select':
case 'message':
return (
<>
<button
onClick={onBack}
disabled={!canGoBack}
className={`${styles.navButton} ${styles.backButton}`}
>
Back
</button>
<button
onClick={onNext}
disabled={!canProceed}
className={`${styles.navButton} ${styles.nextButton}`}
>
{currentStep === 'select' ? 'Next' : 'Review'}
</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':
return (
<>
<button
onClick={onBack}
className={`${styles.navButton} ${styles.backButton}`}
>
Back
</button>
<button
onClick={onSubmit}
disabled={isSubmitting}
className={`${styles.navButton} ${styles.submitButton}`}
>
{isSubmitting ? 'Sending...' : 'Send Messages'}
</button>
</>
);
case 'result':
return (
<button
onClick={onDone}
className={`${styles.navButton} ${styles.doneButton}`}
>
Done
</button>
);
default:
return null;
}
};
return (
<div className={styles.navigation}>
{renderButtons()}
{submissionError && (
<span className={styles.error} style={{ color: '#dc2626' }}>
{submissionError}
</span>
)}
</div>
);
}
export default StepNavigation;
@@ -0,0 +1,303 @@
import { useState, useCallback, useRef } from 'react';
import { tokenService } from '../../services/TokenService';
import {
sendMessageApi,
SendInstantMessageRequest,
} from '../../api/sendMessageApi';
import { scheduleApi } from '../../api/scheduleApi.js';
import { scheduleEventApi } from '../../api/scheduleEventApi.js';
const useSendMessageWizard = () => {
const [state, setState] = useState({
step: 'select',
selectedContacts: [],
scheduledTime: null,
message: '',
createdMessage: null,
isSubmitting: false,
isScheduled: false,
submissionError: null,
});
const abortControllerRef = useRef(null);
// Computed values
const canProceedToNextStep = useCallback(() => {
switch (state.step) {
case 'select':
return state.selectedContacts.length > 0;
case 'message':
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':
return true;
default:
return false;
}
}, [
state.step,
state.selectedContacts.length,
state.message,
state.createdMessage,
state.scheduledTime,
]);
const canGoBack = state.step !== 'select';
// Actions
const toggleContact = (contactId) => {
setState((prev) => ({
...prev,
selectedContacts: prev.selectedContacts.includes(contactId)
? prev.selectedContacts.filter((id) => id !== contactId)
: [...prev.selectedContacts, contactId],
}));
};
const toggleScheduled = (scheduled) => {
console.log('Yup');
setState((prev) => ({
...prev,
isScheduled: scheduled,
}));
};
const setMessage = (message) => {
setState((prev) => ({ ...prev, message }));
};
const setCreatedMessage = (message) => {
console.log('Setting created message');
setState((prev) => ({ ...prev, createdMessage: message }));
};
const setScheduledTime = (selectedTime) => {
console.log('Setting scheduled time');
setState((prev) => ({ ...prev, scheduledTime: selectedTime }));
};
const nextStep = () => {
if (!canProceedToNextStep()) return;
const stepOrder = ['select', 'message', 'schedule', 'confirm', 'result'];
const currentIndex = stepOrder.indexOf(state.step);
if (currentIndex < stepOrder.length - 1) {
if (state.step === 'message') {
if (state.isScheduled) {
setState((prev) => ({
...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 stepOrder = ['select', 'message', 'schedule', 'confirm', 'result'];
const currentIndex = stepOrder.indexOf(state.step);
if (currentIndex > 0) {
if (state.step === 'confirm') {
if (state.isScheduled) {
setState((prev) => ({
...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 submit = async () => {
if (state.isSubmitting) return;
abortControllerRef.current = new AbortController();
setState((prev) => ({
...prev,
isSubmitting: true,
submissionError: null,
}));
try {
if (state.scheduledTime != null) {
console.log('Going to schedule message');
const schMsgReq = {
scheduled: state.scheduledTime.toISOString(),
status: 'PENDING',
user_id: tokenService.getUserId(),
};
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) {
if (error.name === 'AbortError') {
console.log('Submission was aborted');
return;
}
setState((prev) => ({
...prev,
submissionError: error.message || 'Failed to send messages',
isSubmitting: false,
isScheduled: false,
scheduledTime: null,
}));
}
};
const reset = () => {
abortControllerRef.current?.abort();
setState({
step: 'select',
selectedContacts: [],
scheduledTime: null,
message: '',
createdMessage: null,
isSubmitting: false,
isScheduled: false,
submissionError: null,
});
};
// Cleanup
const cleanup = () => {
abortControllerRef.current?.abort();
};
return {
// Getters
currentStep: state.step,
selectedContacts: state.selectedContacts,
scheduledTime: state.scheduledTime,
message: state.message,
createdMessage: state.createdMessage,
isSubmitting: state.isSubmitting,
isScheduled: state.isScheduled,
submissionError: state.submissionError,
canProceedToNextStep: canProceedToNextStep(),
canGoBack,
// Actions
toggleContact,
toggleScheduled,
setMessage,
setCreatedMessage,
setScheduledTime,
nextStep,
prevStep,
submit,
reset,
cleanup,
};
};
export default useSendMessageWizard;
@@ -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;
+456
View File
@@ -0,0 +1,456 @@
/* ViewSentMessages.css */
.view-sent-message-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;
}
.view-sent-message-modal {
background: transparent;
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
}
.view-sent-message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.view-sent-message-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);
}
.view-sent-message-card {
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
padding: 40px;
width: 100%;
}
.view-sent-message-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) {
.view-sent-message-card {
padding: 30px 20px;
}
.view-sent-message-title {
font-size: 20px;
}
.form-actions {
flex-direction: column;
}
}
/* Loading animation */
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Scrollbar styling */
.view-sent-message-modal::-webkit-scrollbar {
width: 8px;
}
.view-sent-message-modal::-webkit-scrollbar-track {
background: transparent;
}
.view-sent-message-modal::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
.view-sent-message-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 */
.view-sent-message-table {
width: 100%;
border-collapse: collapse;
}
.view-sent-message-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;
}
.view-sent-message-table td {
padding: 12px 16px;
border-bottom: 1px solid #e5e7eb;
font-size: 14px;
}
.view-sent-message-table tr:last-child td {
border-bottom: none;
}
.view-sent-message-table tr:hover {
background-color: #f9fafb;
}
/* Contact item styles */
.view-sent-message-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
border-bottom: 1px solid #e5e7eb;
}
.view-sent-message-item:last-child {
border-bottom: none;
}
.view-sent-message-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;
}
.view-sent-message-info {
flex: 1;
}
.view-sent-message-name {
font-weight: 600;
color: #111827;
margin-bottom: 4px;
}
.view-sent-message-phone {
color: #6b7280;
font-size: 14px;
}
.view-sent-message-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 view-sent-message list */
.view-sent-message-list-container {
max-height: 400px;
overflow-y: auto;
}
.view-sent-message-list-container::-webkit-scrollbar {
width: 6px;
}
.view-sent-message-list-container::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.view-sent-message-list-container::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.view-sent-message-list-container::-webkit-scrollbar-thumb:hover {
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;
}
+304
View File
@@ -0,0 +1,304 @@
import { useState, useEffect, useCallback } from 'react';
import { tokenService } from '../services/TokenService';
import { messageEventResponseApi } from '../api/messageEventResponseApi';
import { scheduleApi } from '../api/scheduleApi';
import './ViewSentMessages.css';
const ViewSentMessages = ({
isOpen,
onClose,
title = 'View Sent Messages',
}) => {
const [sentMessages, setSentMessages] = useState([]);
const [scheduledMessages, setScheduledMessages] = useState([]);
const [lastUpdated, setLastUpdated] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [searchTerm, setSearchTerm] = useState('');
const [activeTab, setActiveTab] = useState('sent');
// Fetch the sent messages or Message Event Responses
const fetchSentMessages = useCallback(async () => {
try {
setLoading(true);
setError(null);
const userId = tokenService.getUserId();
const response =
await messageEventResponseApi.getMessageEventResponsesByUserId(
userId,
tokenService.bearerToken()
);
if (response) {
setLastUpdated(new Date());
setSentMessages(response.data);
} else {
throw new Error(response.message || 'Failed to load sent messages');
}
} catch (err) {
setError(err.message || 'Failed to load sent messages.');
setSentMessages([]);
} finally {
setLoading(false);
}
}, []);
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 = () => {
if (activeTab === 'sent') {
fetchSentMessages();
} else {
fetchScheduledMessages();
}
};
useEffect(() => {
if (isOpen) {
console.log('Fetching sent messages');
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 searchLower = searchTerm.toLowerCase();
return (
(sentMessage.status &&
sentMessage.status.toLowerCase().includes(searchLower)) ||
(sentMessage.sent &&
sentMessage.sent.toLowerCase().includes(searchLower)) ||
(sentMessage.id && sentMessage.id.toString().includes(searchTerm))
);
});
// 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 = () => {
if (!lastUpdated) return 'Never';
return lastUpdated.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
};
// 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;
return (
<div className="view-sent-message-modal-overlay" onClick={onClose}>
<div
className="view-sent-message-modal"
onClick={(e) => e.stopPropagation()}
>
<div className="view-sent-message-card">
<div className="view-sent-message-header">
<h2 className="view-sent-message-title">{title}</h2>
<button className="close-btn" onClick={onClose} aria-label="Close">
×
</button>
</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">
{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>Loading messages...</p>
</div>
) : (
<>
{/* Sent Messages Tab Content */}
{activeTab === 'sent' && (
<div className="messages-section">
<div className="section-header">
<h3>Sent Messages</h3>
<span className="last-updated">
Last updated: {formatLastUpdated()}
</span>
</div>
{filteredSentMessages.length > 0 ? (
<div className="view-sent-message-list-container">
<table className="view-sent-message-table">
<thead>
<tr>
<th>ID</th>
<th>Status</th>
<th>Sent Time</th>
</tr>
</thead>
<tbody>
{filteredSentMessages.map((message) => (
<tr key={message.id}>
<td>{message.id.substring(0, 8)}...</td>
<td>
<span
className={`status-badge ${getStatusBadgeClass(message.status)}`}
>
{message.status}
</span>
</td>
<td>{message.sent || 'N/A'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="no-messages">No sent messages found.</div>
)}
</div>
)}
{/* Scheduled Messages Tab Content */}
{activeTab === 'scheduled' && (
<div className="messages-section">
<div className="section-header">
<h3>Scheduled Messages</h3>
</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>
</div>
</div>
</div>
);
};
export default ViewSentMessages;
+2
View File
@@ -0,0 +1,2 @@
export const AUTH_API_BASE_URL = import.meta.env.VITE_AUTH_URL;
export const API_BASE_URL = import.meta.env.VITE_API_URL;
+5
View File
@@ -0,0 +1,5 @@
export const DATA_KEY_ACCESS_TOKEN = 'access_token';
export const DATA_KEY_REGISTERED_USER_ID = 'registered_user_id';
export const DATA_KEY_USER_ID = 'user_id';
export const DATA_KEY_ADDED_CONTACT_ID = 'added_contact_id';
export const APP_NAME = 'schedtxt';
+25
View File
@@ -0,0 +1,25 @@
import { DATA_KEY_ACCESS_TOKEN, DATA_KEY_USER_ID } from '../constants/app';
class TokenService {
getToken() {
return localStorage.getItem(DATA_KEY_ACCESS_TOKEN);
}
setToken(token) {
localStorage.setItem(DATA_KEY_ACCESS_TOKEN, token);
}
getUserId() {
return localStorage.getItem(DATA_KEY_USER_ID);
}
setUserId(userId) {
localStorage.setItem(DATA_KEY_USER_ID, userId);
}
bearerToken() {
return `Bearer ${this.getToken()}`;
}
}
export const tokenService = new TokenService();