diff --git a/backend/src/main/java/com/barbershop/api/config/CustomOAuth2SuccessHandler.java b/backend/src/main/java/com/barbershop/api/config/CustomOAuth2SuccessHandler.java index e20d4b3..b39727f 100644 --- a/backend/src/main/java/com/barbershop/api/config/CustomOAuth2SuccessHandler.java +++ b/backend/src/main/java/com/barbershop/api/config/CustomOAuth2SuccessHandler.java @@ -7,9 +7,7 @@ import com.barbershop.api.security.UserPrincipal; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; @@ -20,9 +18,6 @@ @Component public class CustomOAuth2SuccessHandler implements AuthenticationSuccessHandler { - @Value("${spring.security.oauth2.client.registration.google.redirect-uri}") - private String googleRedirectUri; - @Autowired private UserRepository userRepository; @@ -62,7 +57,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, UserPrincipal userPrincipal = UserPrincipal.create(user); String token = jwtService.generateToken(userPrincipal); - response.sendRedirect(googleRedirectUri + "?token=" + token); + response.sendRedirect("http://localhost:5173/oauth2/success?token=" + token); } } diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml index 0229bbe..4b86388 100644 --- a/backend/src/main/resources/application-prod.yml +++ b/backend/src/main/resources/application-prod.yml @@ -37,7 +37,6 @@ spring: google: client-id: ${GOOGLE_AUTH_CLIENT_ID} client-secret: ${GOOGLE_AUTH_CLIENT_SECRET} - redirect-uri: ${GOOGLE_REDIRECT_URI} scope: - email - profile @@ -55,10 +54,8 @@ jwt: expiration: ${JWT_EXPIRATION} cors: - allowed-origins: - - "" - allowed-methods: - - "GET" + allowed-origins: http://localhost:5173,http://127.0.0.1:5173 + allowed-methods: GET,POST,PUT,DELETE,OPTIONS spring-doc: api-docs: diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index b072edc..4462744 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -3,12 +3,12 @@ server: spring: config: - import: optional:file:.env[.properties] + import: optional:file:./.env[.properties] datasource: - url: jdbc:postgresql://localhost:3000/barber_token_db - username: postgres - password: admin + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} jpa: hibernate: @@ -21,8 +21,8 @@ spring: mail: host: smtp.gmail.com port: 587 - username: dev.madhurendra@gmail.com - password: idlg bcep xlty xxbi + username: ${EMAIL_USERNAME} + password: ${EMAIL_APP_PASSWORD} properties: mail: smtp: @@ -35,9 +35,8 @@ spring: client: registration: google: - client-id: 70964715954-comus2knhoh30fponnlu9lfh27on6ama.apps.googleusercontent.com - client-secret: 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 - redirect-uri: http://localhost:5173/oauth2/callback + client-id: ${GOOGLE_AUTH_CLIENT_ID} + client-secret: ${GOOGLE_AUTH_CLIENT_SECRET} scope: - email - profile @@ -49,9 +48,10 @@ spring: user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo user-name-attribute: sub + jwt: - secret: 3xAmPl3SeCuR3KeYth@t_iS_AtLeAst32Ch@rs - expiration: 3600000 + secret: ${JWT_SECRET} + expiration: ${JWT_EXPIRATION} cors: allowed-origins: http://localhost:5173,http://127.0.0.1:5173 @@ -64,3 +64,4 @@ spring-doc: swagger-ui: enabled: true path: /swagger-ui.html + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3a8052b..c51163d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,12 +4,12 @@ import "./App.css"; import AuthContainer from "./components/organisms/AuthContainer"; import AuthTemplate from "./templates/AuthTemplate"; import { theme } from "./styles/theme"; -import CustomerDashboard from "./pages/Dashboard/Customer"; -import BarberDashboard from "./pages/Dashboard/Barber"; import ProtectedRoute from "./routes/protectedRoutes"; import NotFoundPage from "./pages/NotFound"; import BarberProfileForm from "./components/molecules/BarberProfileForm"; import OAuthCallback from "./pages/OAuthCallback"; +import { CustomerDashboard } from "./pages/Dashboard/Customer"; +import { BarberDashboard } from "./pages/Dashboard/Barber"; const router = createBrowserRouter([ { @@ -55,7 +55,7 @@ const router = createBrowserRouter([ ), }, { - path: "/oauth2/callback/*", + path: "/oauth2/success/*", element: }, { diff --git a/frontend/src/components/atoms/ConnectionIndicator/index.tsx b/frontend/src/components/atoms/ConnectionIndicator/index.tsx new file mode 100644 index 0000000..fd3e59d --- /dev/null +++ b/frontend/src/components/atoms/ConnectionIndicator/index.tsx @@ -0,0 +1,80 @@ +import type React from "react" +import { theme } from "../../../styles/theme" +import Typography from "../Typography" + +interface ConnectionIndicatorProps { + status: "connected" | "connecting" | "disconnected" + lastUpdated: Date + onReconnect?: () => void +} + +const ConnectionIndicator: React.FC = ({ status, lastUpdated, onReconnect }) => { + const getStatusColor = () => { + switch (status) { + case "connected": + return theme.colors.success + case "connecting": + return theme.colors.highlight + case "disconnected": + return theme.colors.error + default: + return theme.colors.textSecondary + } + } + + const getStatusText = () => { + switch (status) { + case "connected": + return "Connected" + case "connecting": + return "Connecting..." + case "disconnected": + return "Disconnected" + default: + return "Unknown" + } + } + + return ( + + + + + {status === "disconnected" && onReconnect && ( + + Retry + + )} + + ) +} + +export default ConnectionIndicator diff --git a/frontend/src/components/atoms/Notification/index.tsx b/frontend/src/components/atoms/Notification/index.tsx new file mode 100644 index 0000000..62506e8 --- /dev/null +++ b/frontend/src/components/atoms/Notification/index.tsx @@ -0,0 +1,68 @@ +import type React from "react" +import { useEffect, useState } from "react" +import { theme } from "../../../styles/theme" +import Typography from "../Typography" + +interface NotificationProps { + message: string + onClose: () => void + duration?: number +} + +const Notification: React.FC = ({ message, onClose, duration = 5000 }) => { + const [isVisible, setIsVisible] = useState(true) + + useEffect(() => { + const timer = setTimeout(() => { + setIsVisible(false) + setTimeout(onClose, 300) + }, duration) + + return () => clearTimeout(timer) + }, [duration, onClose]) + + if (!isVisible) return null + + return ( + + + { + setIsVisible(false) + setTimeout(onClose, 300) + }} + style={{ + background: "none", + border: "none", + color: "white", + cursor: "pointer", + fontSize: "1.2rem", + padding: "0", + lineHeight: 1, + }} + > + Γ + + + ) +} + +export default Notification diff --git a/frontend/src/components/molecules/LoginForm/index.tsx b/frontend/src/components/molecules/LoginForm/index.tsx index e9f4fcd..8a4eb06 100644 --- a/frontend/src/components/molecules/LoginForm/index.tsx +++ b/frontend/src/components/molecules/LoginForm/index.tsx @@ -24,15 +24,14 @@ const LoginForm: React.FC = ({ password, isLoading, error, - role, onChange, onSubmit, onToggleMode, onForgotPassword, }) => { const inputFields = LOGIN_FORM_INPUT_FIELDS(email, password); - const handleGoogleLogin = (role: string) => { - window.location.href = `http://localhost:8080/api/v1/oauth/google/init?role=${role}`; + const handleGoogleLogin = () => { + window.location.href = "http://localhost:8080/oauth2/authorization/google"; }; return ( @@ -79,7 +78,7 @@ const LoginForm: React.FC = ({ /> handleGoogleLogin(role)} + onGoogleClick={() => handleGoogleLogin()} onGithubClick={() => console.log("GitHub Login")} onTwitterClick={() => console.log("Twitter Login")} /> diff --git a/frontend/src/hooks/use-realtime-queue-simple.ts b/frontend/src/hooks/use-realtime-queue-simple.ts new file mode 100644 index 0000000..72af245 --- /dev/null +++ b/frontend/src/hooks/use-realtime-queue-simple.ts @@ -0,0 +1,121 @@ +import { useState, useEffect } from "react" + +interface QueueData { + tokenNumber: string + position: number + estimatedWait: number + barberName: string + service: string + totalInQueue: number + averageWait: number + availableBarbers: number + connectionStatus: "connected" | "connecting" | "disconnected" + lastUpdated: Date +} + +interface Barber { + id: string + name: string + status: "available" | "busy" | "break" + queueLength: number + estimatedFinishTime?: string +} + +export const useRealtimeQueueSimple = (userId: string) => { + const [queueData, setQueueData] = useState({ + tokenNumber: "T-042", + position: 3, + estimatedWait: 25, + barberName: "Mike Johnson", + service: "Full Service", + totalInQueue: 12, + averageWait: 18, + availableBarbers: 3, + connectionStatus: "connected", + lastUpdated: new Date(), + }) + + const [barbers, setBarbers] = useState([ + { id: "1", name: "Mike Johnson", status: "busy", queueLength: 4, estimatedFinishTime: "15 min" }, + { id: "2", name: "Sarah Wilson", status: "available", queueLength: 2 }, + { id: "3", name: "Alex Chen", status: "busy", queueLength: 6, estimatedFinishTime: "8 min" }, + ]) + + const [isConnected, setIsConnected] = useState(true) + const [notifications, setNotifications] = useState([]) + + // Simulate real-time updates + useEffect(() => { + const interval = setInterval(() => { + setQueueData((prev) => { + const newPosition = Math.max(1, prev.position + (Math.random() > 0.7 ? -1 : 0)) + const positionChanged = newPosition !== prev.position + + if (positionChanged && newPosition < prev.position) { + setNotifications((prevNotifications) => [ + ...prevNotifications, + `You moved up! Now ${newPosition} ${newPosition === 1 ? "person" : "people"} ahead of you.`, + ]) + } + + return { + ...prev, + position: newPosition, + estimatedWait: Math.max(5, newPosition * 8 + Math.floor(Math.random() * 10) - 5), + totalInQueue: Math.max(8, prev.totalInQueue + Math.floor(Math.random() * 3) - 1), + averageWait: Math.max(10, prev.averageWait + Math.floor(Math.random() * 6) - 3), + lastUpdated: new Date(), + } + }) + + // Simulate barber status changes + setBarbers((prev) => + prev.map((barber) => ({ + ...barber, + queueLength: Math.max(0, barber.queueLength + Math.floor(Math.random() * 3) - 1), + status: Math.random() > 0.9 ? (barber.status === "available" ? "busy" : "available") : barber.status, + })), + ) + }, 3000) + + return () => clearInterval(interval) + }, []) + + // Simulate connection status + useEffect(() => { + const connectionInterval = setInterval(() => { + if (Math.random() > 0.95) { + setIsConnected(false) + setQueueData((prev) => ({ ...prev, connectionStatus: "disconnected" })) + + setTimeout(() => { + setIsConnected(true) + setQueueData((prev) => ({ ...prev, connectionStatus: "connected" })) + }, 2000) + } + }, 10000) + + return () => clearInterval(connectionInterval) + }, []) + + const reconnect = () => { + setQueueData((prev) => ({ ...prev, connectionStatus: "connecting" })) + setTimeout(() => { + setIsConnected(true) + setQueueData((prev) => ({ ...prev, connectionStatus: "connected" })) + }, 1000) + } + + const clearNotifications = () => { + setNotifications([]) + } + + return { + queueData, + barbers, + isConnected, + notifications, + reconnect, + clearNotifications, + } +} diff --git a/frontend/src/pages/Dashboard/Barber/index.tsx b/frontend/src/pages/Dashboard/Barber/index.tsx index d55a19b..9e6ed37 100644 --- a/frontend/src/pages/Dashboard/Barber/index.tsx +++ b/frontend/src/pages/Dashboard/Barber/index.tsx @@ -1,124 +1,642 @@ -import React, { useEffect, useState } from "react"; -import Typography from "../../../components/atoms/Typography"; -import Button from "../../../components/atoms/Button"; -import Card from "../../../components/atoms/Card"; -import { theme } from "../../../styles/theme"; -import { useNavigate } from "react-router-dom"; -import { getCurrentUser } from "../../../api/auth"; // assumes API file exists -import OAuthLoader from "../../../components/atoms/Loader"; // Optional - -const BarberDashboard: React.FC = () => { - const navigate = useNavigate(); - const [loading, setLoading] = useState(true); - const [user, setUser] = useState(null); - const [customers, setCustomers] = useState([]); // Appointments/customers - - const handleLogout = () => { - localStorage.removeItem("token"); - localStorage.removeItem("role"); - navigate("/", { replace: true }); - }; - useEffect(() => { - const fetchUserData = async () => { - try { - const token = localStorage.getItem("token"); - if (!token) { - navigate("/", { replace: true }); - return; - } - - const res = await getCurrentUser(); - const currentUser = res.data; - setUser(currentUser); - - if (currentUser.role === "BARBER") { - setCustomers([ - { - name: "Rahul Sharma", - email: "rahul@example.com", - phone: "9876543210", - }, - { - name: "Priya Verma", - email: "priya@example.com", - phone: "9123456789", - }, - ]); - } - } catch (err) { - console.error(err); - handleLogout(); - } finally { - setLoading(false); - } - }; - - fetchUserData(); - }, []); - - useEffect(() => { - console.log("User", user); - }, [user]); - - if (loading) return ; - - return ( - - - - - - - - {customers.length > 0 ? ( - customers.map((customer, index) => ( - - - - - - )) - ) : ( - - )} - - - - - - - - ); -}; - -export default BarberDashboard; +import type React from "react" +import { useEffect, useState } from "react" +import styled, { keyframes } from "styled-components" +import { getCurrentUser } from "../../../api/auth" +import OAuthLoader from "../../../components/atoms/Loader" +import { theme } from "../../../styles/theme" +import ConnectionIndicator from "../../../components/atoms/ConnectionIndicator" +import Typography from "../../../components/atoms/Typography" +import Button from "../../../components/atoms/Button" +import Card from "../../../components/atoms/Card" +import { useRealtimeQueueSimple } from "../../../hooks/use-realtime-queue-simple" +import Notification from "../../../components/atoms/Notification" + +const pulse = keyframes` + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +` + +const fadeIn = keyframes` + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +` + +const DashboardContainer = styled.div` + width: 80%; + max-width: 1400px; + margin: 0 auto; + padding: 2rem; + background: linear-gradient(135deg, ${theme.colors.background} 0%, ${theme.colors.background}f5 100%); + min-height: 100vh; + animation: ${fadeIn} 0.6s ease-out; + + @media (max-width: 768px) { + width: 95%; + padding: 1rem; + } +` + +const Header = styled.div` + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 2.5rem; + padding-bottom: 1.5rem; + border-bottom: 2px solid ${theme.colors.primary}15; + + @media (max-width: 768px) { + flex-direction: column; + gap: 1rem; + align-items: stretch; + } +` + +const HeaderContent = styled.div` + flex: 1; +` + +const HeaderActions = styled.div` + display: flex; + align-items: center; + gap: 1rem; + + @media (max-width: 768px) { + justify-content: space-between; + } +` + +const MainGrid = styled.div` + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2.5rem; + + @media (max-width: 1024px) { + grid-template-columns: 1fr; + gap: 2rem; + } +` + +const LeftColumn = styled.div` + display: flex; + flex-direction: column; + gap: 2rem; +` + +const RightColumn = styled.div` + display: flex; + flex-direction: column; + gap: 2rem; +` + +const StatusCard = styled(Card)` + position: relative; + background: linear-gradient(135deg, ${theme.colors.primary}08 0%, ${theme.colors.primary}15 100%); + border: 2px solid ${theme.colors.primary}25; + border-left: 6px solid ${theme.colors.primary}; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + top: 0; + right: 0; + width: 100px; + height: 100px; + background: linear-gradient(45deg, ${theme.colors.primary}10, transparent); + border-radius: 50%; + transform: translate(30px, -30px); + } +` + +const StatusBadge = styled.div<{ status: string }>` + background: ${props => + props.status === "available" ? `linear-gradient(135deg, ${theme.colors.success} 0%, ${theme.colors.success}dd 100%)` : + props.status === "busy" ? `linear-gradient(135deg, ${theme.colors.secondary} 0%, ${theme.colors.secondary}dd 100%)` : + `linear-gradient(135deg, ${theme.colors.textSecondary} 0%, ${theme.colors.textSecondary}dd 100%)` + }; + color: white; + padding: 0.5rem 1rem; + border-radius: ${theme.borderRadius.md}; + font-size: 0.875rem; + font-weight: 600; + box-shadow: 0 4px 12px ${props => + props.status === "available" ? `${theme.colors.success}40` : + props.status === "busy" ? `${theme.colors.secondary}40` : + `${theme.colors.textSecondary}40` + }; + position: relative; + z-index: 1; + text-transform: capitalize; +` + +const StatsGrid = styled.div` + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.5rem; + margin: 1.5rem 0; + + @media (max-width: 768px) { + grid-template-columns: 1fr; + gap: 1rem; + } +` + +const StatItem = styled.div` + text-align: center; + padding: 1rem; + border-radius: ${theme.borderRadius.lg}; + background: ${theme.colors.background}; + border: 1px solid ${theme.colors.primary}15; + transition: all 0.3s ease; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 8px 25px ${theme.colors.primary}15; + border-color: ${theme.colors.primary}30; + } +` + +const LiveIndicator = styled.div` + width: 8px; + height: 8px; + background-color: ${theme.colors.success}; + border-radius: 50%; + animation: ${pulse} 2s infinite; + box-shadow: 0 0 10px ${theme.colors.success}60; +` + +const QueueItem = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.25rem; + background: ${theme.colors.background}; + border-radius: ${theme.borderRadius.lg}; + border: 1px solid ${theme.colors.primary}10; + transition: all 0.3s ease; + margin-bottom: 1rem; + + &:hover { + transform: translateX(4px); + box-shadow: 0 6px 20px ${theme.colors.primary}15; + border-color: ${theme.colors.primary}25; + } + + @media (max-width: 768px) { + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + } +` + +const CustomerInfo = styled.div` + flex: 1; +` + +const QueueActions = styled.div` + display: flex; + gap: 0.75rem; + + @media (max-width: 768px) { + width: 100%; + justify-content: space-between; + } +` + +const ActionButton = styled(Button)` + padding: 0.5rem 1rem; + font-size: 0.875rem; + min-width: auto; +` + +const CompactCard = styled(Card)` + background: linear-gradient(135deg, ${theme.colors.background} 0%, ${theme.colors.primary}05 100%); + border: 1px solid ${theme.colors.primary}15; + transition: all 0.3s ease; + + &:hover { + border-color: ${theme.colors.primary}30; + box-shadow: 0 8px 25px ${theme.colors.primary}10; + } +` + +const QuickActions = styled.div` + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; + + @media (max-width: 768px) { + grid-template-columns: 1fr; + } +` + +const QuickActionCard = styled(Card)` + text-align: center; + border: 2px solid ${theme.colors.primary}20; + transition: all 0.3s ease; + cursor: pointer; + position: relative; + overflow: hidden; + + &:hover { + border-color: ${theme.colors.primary}50; + transform: translateY(-2px); + box-shadow: 0 8px 20px ${theme.colors.primary}20; + } +` + +const EarningsCard = styled(CompactCard)` + background: linear-gradient(135deg, ${theme.colors.success}08 0%, ${theme.colors.success}15 100%); + border-color: ${theme.colors.success}25; +` + +const AppointmentItem = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + background: ${theme.colors.background}; + border-radius: ${theme.borderRadius.lg}; + border: 1px solid ${theme.colors.primary}10; + transition: all 0.3s ease; + margin-bottom: 0.75rem; + + &:hover { + transform: translateX(2px); + box-shadow: 0 4px 15px ${theme.colors.primary}10; + border-color: ${theme.colors.primary}20; + } + + @media (max-width: 768px) { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } +` + +const TimeSlotBadge = styled.div` + background: ${theme.colors.primary}15; + color: ${theme.colors.primary}; + padding: 0.25rem 0.75rem; + border-radius: ${theme.borderRadius.md}; + font-size: 0.75rem; + font-weight: 600; +` + +export const BarberDashboard: React.FC = () => { + const [loading, setLoading] = useState(true) + const [user, setUser] = useState(null) + const [barberStatus, setBarberStatus] = useState("available") + const [currentCustomer, setCurrentCustomer] = useState(null) + const [todayAppointments, setTodayAppointments] = useState([]) + const [earnings, setEarnings] = useState({ today: 145, week: 720, month: 2850 }) + + const { queueData, barbers, isConnected, notifications, reconnect, clearNotifications } = + useRealtimeQueueSimple("barber-456") + + const handleLogout = () => { + localStorage.removeItem("token") + localStorage.removeItem("role") + window.location.href = "/" + } + + const handleStatusChange = (status: string) => { + setBarberStatus(status) + // API call to update status would go here + } + + const handleNextCustomer = () => { + // Logic to call next customer + console.log("Calling next customer") + } + + const handleCompleteService = () => { + // Logic to complete current service + console.log("Completing service") + setCurrentCustomer(null) + } + + useEffect(() => { + const fetchBarberData = async () => { + try { + const token = localStorage.getItem("token") + if (!token) { + console.log("No token found") + return + } + + const response = await getCurrentUser() + setUser(response.data) + + if (response.data.role === "BARBER") { + // Mock current customer + setCurrentCustomer({ + name: "John Smith", + service: "md Service", + tokenNumber: "A-042", + startTime: "2:15 PM", + estimatedDuration: "35 min" + }) + + // Mock today's appointments + setTodayAppointments([ + { + time: "9:00 AM", + customer: "Alice Johnson", + service: "Quick Cut", + status: "completed", + duration: "20 min" + }, + { + time: "10:30 AM", + customer: "Bob Wilson", + service: "md Service", + status: "completed", + duration: "45 min" + }, + { + time: "3:00 PM", + customer: "Carol Davis", + service: "Quick Cut", + status: "upcoming", + duration: "15 min" + }, + { + time: "4:15 PM", + customer: "David Brown", + service: "md Service", + status: "upcoming", + duration: "40 min" + } + ]) + } + } catch (err) { + console.error(err) + handleLogout() + } finally { + setLoading(false) + } + } + + fetchBarberData() + }, []) + + if (loading) return + + return ( + + {notifications.map((notification, index) => ( + clearNotifications()} /> + ))} + + + + + + + + + + + + + + + {/* Barber Status & Current Customer */} + + + + + {barberStatus} + + + + {currentCustomer ? ( + <> + + + + + + + + + + + + + + + + + + + + > + ) : ( + <> + + + > + )} + + + handleStatusChange("available")} + /> + handleStatusChange("break")} + /> + handleStatusChange("busy")} + /> + + + + {/* Queue Management */} + + + + {isConnected && } + + + + + {Array.from({ length: Math.min(queueData.totalInQueue, 5) }, (_, index) => ( + + + + + + + + + + + + ))} + + {queueData.totalInQueue === 0 && ( + + )} + + + + {/* Quick Actions */} + + + + + + + + + + + + + + + + + + + + + {/* Earnings */} + + + + + + + + + + + + + + + + + + + {/* Today's Appointments */} + + + + {todayAppointments.map((appointment, index) => ( + + + + {appointment.time} + + + + + + + ))} + + + + {/* Performance Stats */} + + + + + + + + + + + + + + + + + + + + + + + {/* Shop Stats */} + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/frontend/src/pages/Dashboard/Customer/index.tsx b/frontend/src/pages/Dashboard/Customer/index.tsx index 6a50f0d..c47e341 100644 --- a/frontend/src/pages/Dashboard/Customer/index.tsx +++ b/frontend/src/pages/Dashboard/Customer/index.tsx @@ -1,123 +1,611 @@ -import React, { useEffect, useState } from "react"; -import Typography from "../../../components/atoms/Typography"; -import Button from "../../../components/atoms/Button"; -import Card from "../../../components/atoms/Card"; -import { theme } from "../../../styles/theme"; -import { useNavigate } from "react-router-dom"; -import { getCurrentUser } from "../../../api/auth"; -import OAuthLoader from "../../../components/atoms/Loader"; - -const CustomerDashboard: React.FC = () => { - const navigate = useNavigate(); - const [loading, setLoading] = useState(true); - const [user, setUser] = useState(null); - const [appointments, setAppointments] = useState([]); - - const handleLogout = () => { - localStorage.removeItem("token"); - localStorage.removeItem("role"); - navigate("/", { replace: true }); - }; - - useEffect(() => { - const fetchUserData = async () => { - try { - const token = localStorage.getItem("token"); - if (!token) { - navigate("/", { replace: true }); - return; - } - - const response = await getCurrentUser(); - setUser(response.data); - - if (response.data.role === "CUSTOMER") { - // Replace this with a real API call for customer's appointments - setAppointments([ - { - barberName: "Amit Barber", - date: "2025-07-30", - time: "11:00 AM", - service: "Haircut", - }, - { - barberName: "Karan Studio", - date: "2025-08-02", - time: "3:30 PM", - service: "Shave + Massage", - }, - ]); - } else { - navigate("/customer/dashboard", { replace: true }); - } - } catch (err) { - console.error(err); - handleLogout(); - } finally { - setLoading(false); - } - }; - - fetchUserData(); - }, []); - - useEffect(() => { - console.log(user) - }, [user]) - - if (loading) return ; - - return ( - - - - - - - - {appointments.length > 0 ? ( - appointments.map((appt, index) => ( - - - - - - - )) - ) : ( - - )} - - - - - - - - ); -}; - -export default CustomerDashboard; +import type React from "react" +import { useEffect, useState } from "react" +import styled, { keyframes } from "styled-components" +import { getCurrentUser } from "../../../api/auth" +import OAuthLoader from "../../../components/atoms/Loader" +import { theme } from "../../../styles/theme" +import ConnectionIndicator from "../../../components/atoms/ConnectionIndicator" +import Typography from "../../../components/atoms/Typography" +import Button from "../../../components/atoms/Button" +import Card from "../../../components/atoms/Card" +import { useRealtimeQueueSimple } from "../../../hooks/use-realtime-queue-simple" +import Notification from "../../../components/atoms/Notification" + +// Styled Components +const pulse = keyframes` + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +` + +const fadeIn = keyframes` + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +` + +const DashboardContainer = styled.div` + width: 80%; + max-width: 1400px; + margin: 0 auto; + padding: 2rem; + background: linear-gradient(135deg, ${theme.colors.background} 0%, ${theme.colors.background}f5 100%); + min-height: 100vh; + animation: ${fadeIn} 0.6s ease-out; + + @media (max-width: 768px) { + width: 95%; + padding: 1rem; + } +` + +const Header = styled.div` + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 2.5rem; + padding-bottom: 1.5rem; + border-bottom: 2px solid ${theme.colors.primary}15; + + @media (max-width: 768px) { + flex-direction: column; + gap: 1rem; + align-items: stretch; + } +` + +const HeaderContent = styled.div` + flex: 1; +` + +const HeaderActions = styled.div` + display: flex; + align-items: center; + gap: 1rem; + + @media (max-width: 768px) { + justify-content: space-between; + } +` + +const MainGrid = styled.div` + display: grid; + grid-template-columns: 2fr 1fr; + gap: 2.5rem; + + @media (max-width: 1024px) { + grid-template-columns: 1fr; + gap: 2rem; + } +` + +const MainContent = styled.div` + display: flex; + flex-direction: column; + gap: 2rem; +` + +const Sidebar = styled.div` + display: flex; + flex-direction: column; + gap: 1.5rem; +` + +const ActiveTokenCard = styled(Card)` + position: relative; + background: linear-gradient(135deg, ${theme.colors.primary}08 0%, ${theme.colors.primary}15 100%); + border: 2px solid ${theme.colors.primary}25; + border-left: 6px solid ${theme.colors.primary}; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + top: 0; + right: 0; + width: 100px; + height: 100px; + background: linear-gradient(45deg, ${theme.colors.primary}10, transparent); + border-radius: 50%; + transform: translate(30px, -30px); + } +` + +const TokenBadge = styled.div` + background: linear-gradient(135deg, ${theme.colors.primary} 0%, ${theme.colors.primary}dd 100%); + color: white; + padding: 0.5rem 1rem; + border-radius: ${theme.borderRadius.md}; + font-size: 0.875rem; + font-weight: 600; + box-shadow: 0 4px 12px ${theme.colors.primary}40; + position: relative; + z-index: 1; +` + +const StatsGrid = styled.div` + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.5rem; + margin: 1.5rem 0; + + @media (max-width: 768px) { + grid-template-columns: 1fr; + gap: 1rem; + } +` + +const StatItem = styled.div` + text-align: center; + padding: 1rem; + border-radius: ${theme.borderRadius.lg}; + background: ${theme.colors.background}; + border: 1px solid ${theme.colors.primary}15; + transition: all 0.3s ease; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 8px 25px ${theme.colors.primary}15; + border-color: ${theme.colors.primary}30; + } +` + +const LiveIndicator = styled.div` + width: 8px; + height: 8px; + background-color: ${theme.colors.success}; + border-radius: 50%; + animation: ${pulse} 2s infinite; + box-shadow: 0 0 10px ${theme.colors.success}60; +` + +const ServiceGrid = styled.div` + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1.5rem; + + @media (max-width: 768px) { + grid-template-columns: 1fr; + } +` + +const ServiceCard = styled(Card)` + text-align: center; + border: 2px solid ${theme.colors.primary}20; + transition: all 0.3s ease; + cursor: pointer; + position: relative; + overflow: hidden; + + &:hover { + border-color: ${theme.colors.primary}50; + transform: translateY(-4px); + box-shadow: 0 12px 30px ${theme.colors.primary}20; + } + + &::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, ${theme.colors.primary}10, transparent); + transition: left 0.5s ease; + } + + &:hover::before { + left: 100%; + } +` + +const ServiceIcon = styled.div` + width: 56px; + height: 56px; + background: linear-gradient(135deg, ${theme.colors.primary}20, ${theme.colors.primary}30); + border-radius: 50%; + margin: 0 auto 1rem; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + transition: all 0.3s ease; + position: relative; + z-index: 1; + + ${ServiceCard}:hover & { + transform: scale(1.1); + background: linear-gradient(135deg, ${theme.colors.primary}40, ${theme.colors.primary}60); + } +` + +const AppointmentItem = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.25rem; + background: ${theme.colors.background}; + border-radius: ${theme.borderRadius.lg}; + border: 1px solid ${theme.colors.primary}10; + transition: all 0.3s ease; + + &:hover { + transform: translateX(4px); + box-shadow: 0 6px 20px ${theme.colors.primary}15; + border-color: ${theme.colors.primary}25; + } + + @media (max-width: 768px) { + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + } +` + +const StatusBadge = styled.div<{ status: string }>` + background: ${props => + props.status === "Completed" ? theme.colors.success : + props.status === "Upcoming" ? theme.colors.primary : + theme.colors.secondary + }; + color: white; + padding: 0.375rem 0.875rem; + border-radius: ${theme.borderRadius.md}; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +` + +const SidebarCard = styled(Card)` + background: linear-gradient(135deg, ${theme.colors.background} 0%, ${theme.colors.primary}05 100%); + border: 1px solid ${theme.colors.primary}15; + transition: all 0.3s ease; + + &:hover { + border-color: ${theme.colors.primary}30; + box-shadow: 0 8px 25px ${theme.colors.primary}10; + } +` + +const BarberItem = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 0; + border-bottom: 1px solid ${theme.colors.primary}10; + transition: all 0.3s ease; + + &:last-child { + border-bottom: none; + } + + &:hover { + background: ${theme.colors.primary}08; + margin: 0 -1rem; + padding-left: 1rem; + padding-right: 1rem; + border-radius: ${theme.borderRadius.md}; + } +` + +const BarberStatus = styled.div<{ status: string }>` + background: ${props => + props.status === "available" ? theme.colors.success : + props.status === "busy" ? theme.colors.secondary : + theme.colors.textSecondary + }; + color: white; + padding: 0.25rem 0.75rem; + border-radius: ${theme.borderRadius.md}; + font-size: 0.75rem; + font-weight: 500; + text-transform: capitalize; +` + +const ActionButtons = styled.div` + display: flex; + gap: 1rem; + margin-top: 1rem; + + @media (max-width: 768px) { + flex-direction: column; + } +` + +const CancelButton = styled(Button)` + background: linear-gradient(135deg, ${theme.colors.textSecondary} 0%, ${theme.colors.textSecondary}dd 100%); + color: white; + border: none; + + &:hover { + background: linear-gradient(135deg, ${theme.colors.textSecondary}dd 0%, ${theme.colors.textSecondary}bb 100%); + transform: translateY(-1px); + } +` + +export const CustomerDashboard: React.FC = () => { + const [loading, setLoading] = useState(true) + const [user, setUser] = useState(null) + const [appointments, setAppointments] = useState([]) + + const { queueData, barbers, isConnected, notifications, reconnect, clearNotifications } = + useRealtimeQueueSimple("user-123") + + const handleLogout = () => { + localStorage.removeItem("token") + localStorage.removeItem("role") + window.location.href = "/" + } + + useEffect(() => { + const fetchUserData = async () => { + try { + const token = localStorage.getItem("token") + if (!token) { + console.log("No token found") + return + } + + const response = await getCurrentUser() + setUser(response.data) + + if (response.data.role === "CUSTOMER") { + setAppointments([ + { + barberName: "Mike Johnson", + date: "2025-01-20", + time: "11:00 AM", + service: "Full Service", + status: "Upcoming", + }, + { + barberName: "Sarah Wilson", + date: "2025-01-15", + time: "3:30 PM", + service: "Quick Cut", + status: "Completed", + }, + ]) + } + } catch (err) { + console.error(err) + handleLogout() + } finally { + setLoading(false) + } + } + + fetchUserData() + }, []) + + if (loading) return + + return ( + + {notifications.map((notification, index) => ( + clearNotifications()} /> + ))} + + + + + + + + + + + + + + + {/* Active Token */} + + + + + Token #{queueData.tokenNumber} + + + + + + + + {isConnected && } + + + + + + + + + + + + + + + + + + {/* Book New Token */} + + + + + + + βοΈ + + + + + + + π€ + + + + + + + + {/* Appointments History */} + + + + {appointments.length > 0 ? ( + appointments.map((appt, index) => ( + + + + + + + {appt.status} + + + )) + ) : ( + + )} + + + + + + {/* Live Queue Status */} + + + + {isConnected && } + + + + + + + + + + + + + + + + + + {/* Barber Availability */} + + + + {barbers.map((barber) => ( + + + + + + + {barber.status} + + + ))} + + + + {/* Your Stats */} + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/frontend/src/pages/OAuthCallback/index.tsx b/frontend/src/pages/OAuthCallback/index.tsx index e0e1edf..75ae7eb 100644 --- a/frontend/src/pages/OAuthCallback/index.tsx +++ b/frontend/src/pages/OAuthCallback/index.tsx @@ -20,11 +20,12 @@ const OAuthCallback: React.FC = () => { localStorage.setItem("token", token); - const handleRedirect = (role: string, isBarberProfileUpdated: boolean = false) => { + const handleRedirect = (role: string, isUpdated: boolean) => { + if (role === "customer") { navigate("/customer/dashboard"); } else if (role === "barber") { - if (isBarberProfileUpdated) { + if (isUpdated) { navigate("/barber/dashboard"); } else { navigate("/barber/setup-profile"); @@ -35,21 +36,26 @@ const OAuthCallback: React.FC = () => { }; getCurrentUser() - .then((res) => { - const backendRole = res.data.data.role?.toLowerCase(); - - if (backendRole === "not_defined" && preSelectedRole) { - updateRole(preSelectedRole) - .then(() => getCurrentUser()) - .then((res2) => { - handleRedirect(preSelectedRole, res2.data.isBarberProfileUpdated); - }); - } else { - handleRedirect(backendRole, res.data.isBarberProfileUpdated); + .then(async (res) => { + + let role = res.data.data.role?.toLowerCase(); + let isUpdated = res.data.data.barberProfileUpdated; + + + if (role === "not_defined" && preSelectedRole) { + await updateRole(preSelectedRole); + + const res2 = await getCurrentUser(); + + role = res2.data.data.role?.toLowerCase(); + isUpdated = res2.data.data.barberProfileUpdated; + } + + handleRedirect(role, isUpdated); }) .catch((err) => { - console.error("OAuth error:", err); + console.error("[OAuthCallback] OAuth error:", err); navigate("/"); }) .finally(() => setLoading(false));