From b756b73b0e10fe40f9eba4fcbee813ca80767b31 Mon Sep 17 00:00:00 2001 From: henrys86 Date: Fri, 19 Sep 2025 23:05:43 +0700 Subject: [PATCH] updated_login_page --- .vite/deps/_metadata.json | 8 + .vite/deps/package.json | 3 + index.html | 29 +- package-lock.json | 127 ++++++ package.json | 1 + src/App.jsx | 4 +- src/components/GoogleLoginButton.jsx | 180 ++++++++ src/components/ProtectedRoute.jsx | 26 ++ src/components/ui/alert.tsx | 66 +++ src/components/ui/toast.tsx | 129 ++++++ src/components/ui/toaster.tsx | 35 ++ src/contexts/AuthContext.jsx | 20 +- src/hooks/use-toast.js | 34 ++ src/hooks/use-toast.ts | 193 +++++++++ src/lib/notifications.js | 19 + src/pages/LandingPage.jsx | 20 +- src/pages/LoginPageVisual.jsx | 622 +++++++-------------------- src/pages/LoginPageVisualClean.jsx | 231 ++++++++++ src/pages/LogoutPage.jsx | 34 +- src/pages/ScheduleCalenderPage.jsx | 231 ++++++---- 20 files changed, 1399 insertions(+), 613 deletions(-) create mode 100644 .vite/deps/_metadata.json create mode 100644 .vite/deps/package.json create mode 100644 src/components/GoogleLoginButton.jsx create mode 100644 src/components/ProtectedRoute.jsx create mode 100644 src/components/ui/alert.tsx create mode 100644 src/components/ui/toast.tsx create mode 100644 src/components/ui/toaster.tsx create mode 100644 src/hooks/use-toast.js create mode 100644 src/hooks/use-toast.ts create mode 100644 src/lib/notifications.js create mode 100644 src/pages/LoginPageVisualClean.jsx diff --git a/.vite/deps/_metadata.json b/.vite/deps/_metadata.json new file mode 100644 index 0000000..d6390f7 --- /dev/null +++ b/.vite/deps/_metadata.json @@ -0,0 +1,8 @@ +{ + "hash": "8cbd076c", + "configHash": "196ac329", + "lockfileHash": "28d96fc3", + "browserHash": "7b56bdd1", + "optimized": {}, + "chunks": {} +} \ No newline at end of file diff --git a/.vite/deps/package.json b/.vite/deps/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/.vite/deps/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/index.html b/index.html index 0c589ec..80beeb0 100644 --- a/index.html +++ b/index.html @@ -1,13 +1,20 @@ - - - - - Vite + React - - -
- - - + + + + + + Vite + React + + + + + +
+ + + + + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index e2cab0c..f3fc3c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toast": "^1.2.15", "@tabler/icons-react": "^3.34.1", "@tailwindcss/postcss": "^4.1.13", "@tailwindcss/vite": "^4.1.13", @@ -1096,6 +1097,33 @@ } } }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-id": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", @@ -1137,6 +1165,30 @@ } } }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-presence": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", @@ -1310,6 +1362,40 @@ } } }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", + "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -1362,6 +1448,24 @@ } } }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-layout-effect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", @@ -1377,6 +1481,29 @@ } } }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.34", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.34.tgz", diff --git a/package.json b/package.json index 0aaa4c8..5e85d83 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toast": "^1.2.15", "@tabler/icons-react": "^3.34.1", "@tailwindcss/postcss": "^4.1.13", "@tailwindcss/vite": "^4.1.13", diff --git a/src/App.jsx b/src/App.jsx index 9112bbc..c9caded 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -2,6 +2,7 @@ import { BrowserRouter, Navigate, Route, Routes } from "react-router"; import { AuthProvider } from "./contexts/AuthContext"; import AccountSettingsPage from "./pages/AccountSettingPage"; import LoginPageVisual from "./pages/LoginPageVisual"; +import LoginPageVisualClean from "./pages/LoginPageVisualClean"; import LogoutPage from "./pages/LogoutPage"; import ScheduleCalendarPage from "./pages/ScheduleCalenderPage"; import ScheduleDetailPage from "./pages/ScheduleDetailPage"; @@ -18,7 +19,8 @@ function App() { - } /> + } /> + } /> } /> } /> }> diff --git a/src/components/GoogleLoginButton.jsx b/src/components/GoogleLoginButton.jsx new file mode 100644 index 0000000..c421258 --- /dev/null +++ b/src/components/GoogleLoginButton.jsx @@ -0,0 +1,180 @@ +import React, { useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { IconBrandGoogle } from '@tabler/icons-react'; + +const GoogleLoginButton = ({ onSuccess, onError, disabled = false }) => { + const [isGoogleLoaded, setIsGoogleLoaded] = useState(false); + + useEffect(() => { + // Load Google OAuth script + const loadGoogleScript = () => { + if (window.google) { + setIsGoogleLoaded(true); + return; + } + + const script = document.createElement('script'); + script.src = 'https://accounts.google.com/gsi/client'; + script.async = true; + script.defer = true; + script.onload = initializeGoogleSignIn; + script.onerror = () => { + console.error('Failed to load Google OAuth script'); + setIsGoogleLoaded(false); + }; + document.head.appendChild(script); + }; + + const initializeGoogleSignIn = () => { + if (window.google) { + try { + window.google.accounts.id.initialize({ + client_id: import.meta.env.VITE_GOOGLE_CLIENT_ID, + callback: handleCredentialResponse, + auto_select: false, + cancel_on_tap_outside: true, + ux_mode: 'popup', // Use popup mode + context: 'signin', + }); + setIsGoogleLoaded(true); + } catch (error) { + console.error('Failed to initialize Google Sign-In:', error); + setIsGoogleLoaded(false); + } + } + }; + + const handleCredentialResponse = (response) => { + if (response.credential) { + onSuccess(response.credential); + } else { + onError('Google authentication failed'); + } + }; + + loadGoogleScript(); + }, [onSuccess, onError]); + + const handleGoogleLogin = () => { + if (!import.meta.env.VITE_GOOGLE_CLIENT_ID) { + onError('Google Client ID not configured'); + return; + } + + if (window.google && isGoogleLoaded) { + try { + // Use the more reliable renderButton approach with popup + const buttonContainer = document.createElement('div'); + buttonContainer.style.display = 'none'; + document.body.appendChild(buttonContainer); + + window.google.accounts.id.renderButton(buttonContainer, { + theme: 'outline', + size: 'large', + width: 400, + click_listener: () => { + // This will trigger the popup + window.google.accounts.id.prompt((notification) => { + if (notification.isNotDisplayed() || notification.isSkippedMoment()) { + // Fallback to traditional OAuth flow + initiateOAuthFlow(); + } + }); + } + }); + + // Programmatically click the hidden button + const googleButton = buttonContainer.querySelector('div[role="button"]'); + if (googleButton) { + googleButton.click(); + } else { + // Fallback if button rendering fails + window.google.accounts.id.prompt((notification) => { + if (notification.isNotDisplayed() || notification.isSkippedMoment()) { + initiateOAuthFlow(); + } + }); + } + + // Clean up + setTimeout(() => { + if (buttonContainer && buttonContainer.parentNode) { + buttonContainer.parentNode.removeChild(buttonContainer); + } + }, 1000); + + } catch (error) { + console.error('Google Sign-In error:', error); + initiateOAuthFlow(); // Fallback to OAuth flow + } + } else { + onError('Google authentication not loaded'); + } + }; + + // Fallback OAuth flow + const initiateOAuthFlow = () => { + const clientId = import.meta.env.VITE_GOOGLE_CLIENT_ID; + const redirectUri = window.location.origin; + const scope = 'email profile'; + + const authUrl = `https://accounts.google.com/oauth/authorize?` + + `client_id=${clientId}&` + + `redirect_uri=${redirectUri}&` + + `scope=${scope}&` + + `response_type=code&` + + `access_type=offline&` + + `prompt=select_account`; + + // Open popup window + const popup = window.open( + authUrl, + 'google-auth', + 'width=500,height=600,scrollbars=yes,resizable=yes' + ); + + // Listen for popup to close + const checkClosed = setInterval(() => { + if (popup && popup.closed) { + clearInterval(checkClosed); + onError('Google authentication was cancelled'); + } + }, 1000); + }; + + // Test mode button for development + const handleTestGoogleLogin = () => { + // Use the test mode that's configured in your backend + onSuccess('test_credential'); + }; + + return ( +
+ + + {/* Test mode button for development */} + {import.meta.env.DEV && ( + + )} +
+ ); +}; + +export default GoogleLoginButton; \ No newline at end of file diff --git a/src/components/ProtectedRoute.jsx b/src/components/ProtectedRoute.jsx new file mode 100644 index 0000000..fa913c3 --- /dev/null +++ b/src/components/ProtectedRoute.jsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { Navigate } from 'react-router-dom'; +import { useAuth } from '../hooks/useAuth'; + +const ProtectedRoute = ({ children }) => { + const { isAuthenticated, loading } = useAuth(); + + if (loading) { + return ( +
+
+
+

Loading...

+
+
+ ); + } + + if (!isAuthenticated) { + return ; + } + + return children; +}; + +export default ProtectedRoute; \ No newline at end of file diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 0000000..1421354 --- /dev/null +++ b/src/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx new file mode 100644 index 0000000..6d2e12f --- /dev/null +++ b/src/components/ui/toast.tsx @@ -0,0 +1,129 @@ +"use client" + +import * as React from "react" +import * as ToastPrimitives from "@radix-ui/react-toast" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const ToastProvider = ToastPrimitives.Provider + +const ToastViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive border-destructive bg-destructive text-destructive-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Toast = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +type ToastProps = React.ComponentPropsWithoutRef + +type ToastActionElement = React.ReactElement + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} \ No newline at end of file diff --git a/src/components/ui/toaster.tsx b/src/components/ui/toaster.tsx new file mode 100644 index 0000000..b5b97f6 --- /dev/null +++ b/src/components/ui/toaster.tsx @@ -0,0 +1,35 @@ +"use client" + +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from "@/components/ui/toast" +import { useToast } from "@/hooks/use-toast" + +export function Toaster() { + const { toasts } = useToast() + + return ( + + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + +
+ {title && {title}} + {description && ( + {description} + )} +
+ {action} + +
+ ) + })} + +
+ ) +} \ No newline at end of file diff --git a/src/contexts/AuthContext.jsx b/src/contexts/AuthContext.jsx index 03d3338..602e7d3 100644 --- a/src/contexts/AuthContext.jsx +++ b/src/contexts/AuthContext.jsx @@ -92,20 +92,24 @@ export const AuthProvider = ({ children }) => { // Google login function - matches backend expectation const googleLogin = useCallback(async (credential) => { try { - const response = await axios.post('/google-login', {}, { - headers: { - 'id_token': credential - } - }); + // Check if this is test mode + const isTestMode = credential === 'test_credential'; + + const requestData = isTestMode + ? { testMode: true } + : { credential }; + + const response = await axios.post('/google-login', requestData); if (response.data.access_token) { localStorage.setItem('authToken', response.data.access_token); - // For now, we'll create a basic user object since backend doesn't return user data - setUser({ + // Use actual user data from backend response + const userData = response.data.user || { id: 'google-user', name: 'Google User', email: 'google@example.com' - }); + }; + setUser(userData); setIsAuthenticated(true); return { success: true }; } else { diff --git a/src/hooks/use-toast.js b/src/hooks/use-toast.js new file mode 100644 index 0000000..c20e2a1 --- /dev/null +++ b/src/hooks/use-toast.js @@ -0,0 +1,34 @@ +import { useState, useCallback } from 'react'; + +// Simple toast hook implementation +export const useToast = () => { + const [toasts, setToasts] = useState([]); + + const toast = useCallback(({ title, description, variant = 'default' }) => { + const id = Date.now().toString(); + const newToast = { + id, + title, + description, + variant, + timestamp: Date.now() + }; + + setToasts(prev => [...prev, newToast]); + + // Auto remove after 5 seconds + setTimeout(() => { + setToasts(prev => prev.filter(t => t.id !== id)); + }, 5000); + + // Return toast for manual dismissal if needed + return { + id, + dismiss: () => setToasts(prev => prev.filter(t => t.id !== id)) + }; + }, []); + + return { toast, toasts }; +}; + +export default useToast; \ No newline at end of file diff --git a/src/hooks/use-toast.ts b/src/hooks/use-toast.ts new file mode 100644 index 0000000..87e7062 --- /dev/null +++ b/src/hooks/use-toast.ts @@ -0,0 +1,193 @@ +"use client" + +import * as React from "react" + +import type { + ToastActionElement, + ToastProps, +} from "@/components/ui/toast" + +const TOAST_LIMIT = 1 +const TOAST_REMOVE_DELAY = 1000000 + +type ToasterToast = ToastProps & { + id: string + title?: React.ReactNode + description?: React.ReactNode + action?: ToastActionElement +} + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const + +let count = 0 + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER + return count.toString() +} + +type ActionType = typeof actionTypes + +type Action = + | { + type: ActionType["ADD_TOAST"] + toast: ToasterToast + } + | { + type: ActionType["UPDATE_TOAST"] + toast: Partial + } + | { + type: ActionType["DISMISS_TOAST"] + toastId?: ToasterToast["id"] + } + | { + type: ActionType["REMOVE_TOAST"] + toastId?: ToasterToast["id"] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map>() + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + } + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + } + + case "DISMISS_TOAST": { + const { toastId } = action + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id) + }) + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + } + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + } + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + } + } +} + +const listeners: Array<(state: State) => void> = [] + +let memoryState: State = { toasts: [] } + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +type Toast = Omit + +function toast({ ...props }: Toast) { + const id = genId() + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }) + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss() + }, + }, + }) + + return { + id: id, + dismiss, + update, + } +} + +function useToast() { + const [state, setState] = React.useState(memoryState) + + React.useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index > -1) { + listeners.splice(index, 1) + } + } + }, [state]) + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + } +} + +export { useToast, toast } \ No newline at end of file diff --git a/src/lib/notifications.js b/src/lib/notifications.js new file mode 100644 index 0000000..6fe891c --- /dev/null +++ b/src/lib/notifications.js @@ -0,0 +1,19 @@ +// Simple toast wrapper utilities for success and error messages +// Can be expanded later or integrated with a central notification system. +import { useToast } from '@/hooks/use-toast'; + +export const useNotifications = () => { + const { toast } = useToast(); + + const showSuccess = (title, description) => { + toast({ title: title || 'Success', description }); + }; + + const showError = (title, description) => { + toast({ title: title || 'Error', description, variant: 'destructive' }); + }; + + return { showSuccess, showError }; +}; + +export default useNotifications; \ No newline at end of file diff --git a/src/pages/LandingPage.jsx b/src/pages/LandingPage.jsx index f53bad7..dd644b0 100644 --- a/src/pages/LandingPage.jsx +++ b/src/pages/LandingPage.jsx @@ -183,16 +183,16 @@ export function FooterSection() { {/* Social */} @@ -204,10 +204,10 @@ export function FooterSection() {

ยฉ {new Date().getFullYear()} Planoria, All rights reserved

@@ -226,7 +226,7 @@ function GlowButton({ children }) { /> @@ -255,7 +255,7 @@ export default function HeroSection() { className="h-15 w-auto" />
- +
); @@ -286,8 +286,8 @@ export default function HeroSection() {
Get Started diff --git a/src/pages/LoginPageVisual.jsx b/src/pages/LoginPageVisual.jsx index f197d63..50b1ad9 100644 --- a/src/pages/LoginPageVisual.jsx +++ b/src/pages/LoginPageVisual.jsx @@ -1,97 +1,18 @@ -"use client"; - -import React, { useState, useEffect, useCallback, useMemo, memo } from "react"; -import { Link, useNavigate } from "react-router"; -import { - Card, - CardHeader, - CardTitle, - CardContent, - CardDescription, -} from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Separator } from "@/components/ui/separator"; -import { - Chrome, - ArrowLeft, - Eye, - EyeOff, - AlertCircle, - CheckCircle2, -} from "lucide-react"; +import React, { useState, useEffect } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "@/components/ui/card"; +import { IconArrowLeft, IconAlertCircle } from "@tabler/icons-react"; import { useAuth } from "@/hooks/useAuth.jsx"; -import toast, { Toaster } from "react-hot-toast"; - -// Environment variables for production -const GOOGLE_CLIENT_ID = - import.meta.env.VITE_GOOGLE_CLIENT_ID || - "411452003367-vi21idqns4uhui7esotdheodqeqhpk6q.apps.googleusercontent.com"; - -// Production-ready validation -const validateEmail = (email) => { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - return emailRegex.test(email); -}; - -const validatePassword = (password) => { - return password.length >= 8; -}; - -const validateUsername = (username) => { - return username.length >= 3 && username.length <= 20; -}; - -// Loading Component -const LoadingSpinner = memo(() => ( -
-
-
-)); - -// Success Component -const SuccessMessage = memo(({ message }) => ( -
- - {message} -
-)); +import GoogleLoginButton from "@/components/GoogleLoginButton"; +import toast, { Toaster } from 'react-hot-toast'; -const LoginPage = memo(() => { - const [isLogin, setIsLogin] = useState(true); - const [showPassword, setShowPassword] = useState(false); - const [formData, setFormData] = useState({ - username: "", - email: "", - password: "", - }); - const [errors, setErrors] = useState({}); - const [loading, setLoading] = useState(false); - const [googleLoading, setGoogleLoading] = useState(false); - const [successMessage, setSuccessMessage] = useState(""); - - const { login, register, googleLogin, isAuthenticated } = useAuth(); +export default function LoginPageVisual() { + const { googleLogin, isAuthenticated } = useAuth(); const navigate = useNavigate(); - // Memoized validation - const validationErrors = useMemo(() => { - const newErrors = {}; - - if (!isLogin && formData.username && !validateUsername(formData.username)) { - newErrors.username = "Username must be 3-20 characters long"; - } - - if (formData.email && !validateEmail(formData.email)) { - newErrors.email = "Please enter a valid email address"; - } - - if (formData.password && !validatePassword(formData.password)) { - newErrors.password = "Password must be at least 8 characters long"; - } - - return newErrors; - }, [formData, isLogin]); + // Local UI state + const [googleLoading, setGoogleLoading] = useState(false); + const [error, setError] = useState(""); // Redirect if already authenticated useEffect(() => { @@ -100,260 +21,123 @@ const LoginPage = memo(() => { } }, [isAuthenticated, navigate]); - // Initialize Google OAuth with error handling - useEffect(() => { - let isMounted = true; - - const initializeGoogle = () => { - if (!isMounted) return; - - try { - if (window.google && window.google.accounts) { - window.google.accounts.id.initialize({ - client_id: GOOGLE_CLIENT_ID, - callback: handleGoogleSuccess, - auto_select: false, - cancel_on_tap_outside: true, - context: "signin", - ux_mode: "popup", - error_callback: handleGoogleError, - }); - } - } catch (error) { - console.error("Google OAuth initialization failed:", error); - if (isMounted) { - toast.error("Google authentication setup failed", { icon: "๐Ÿ”’" }); - } - } - }; - - // Load Google script with timeout - const loadGoogleScript = () => { - if (window.google) { - initializeGoogle(); - return; - } - - const script = document.createElement("script"); - script.src = "https://accounts.google.com/gsi/client"; - script.async = true; - script.defer = true; - - const timeout = setTimeout(() => { - if (isMounted) { - toast.error("Google authentication failed to load", { icon: "โฐ" }); - } - }, 10000); - - script.onload = () => { - clearTimeout(timeout); - initializeGoogle(); - }; - - script.onerror = () => { - clearTimeout(timeout); - if (isMounted) { - toast.error("Failed to load Google authentication", { icon: "โŒ" }); - } - }; - - document.head.appendChild(script); - }; - - loadGoogleScript(); - - return () => { - isMounted = false; - }; - }, []); - - // Form submission with enhanced error handling - const handleSubmit = useCallback( - async (e) => { - e.preventDefault(); - - // Client-side validation - if (Object.keys(validationErrors).length > 0) { - setErrors(validationErrors); - return; - } - - setErrors({}); - setLoading(true); - setSuccessMessage(""); - - try { - let result; - if (isLogin) { - result = await login(formData.email, formData.password); - } else { - result = await register( - formData.username, - formData.email, - formData.password - ); - } - - if (result.success) { - if (!isLogin) { - setSuccessMessage("Account created successfully!"); - toast.success("Account created successfully! โœจ", { - icon: "๐ŸŽ‰", - style: { - background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", - }, - }); - setIsLogin(true); - setFormData({ username: "", email: "", password: "" }); - } else { - toast.success("Welcome back! ๐Ÿš€", { - icon: "๐Ÿ‘‹", - style: { - background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", - }, - }); - navigate("/dashboard", { replace: true }); - } - } else { - const errorMsg = result.error || "Authentication failed"; - setErrors({ general: errorMsg }); - toast.error(errorMsg, { icon: "โš ๏ธ" }); - } - } catch (error) { - console.error("Form submission error:", error); - const errorMsg = - error.message || "Authentication failed. Please try again."; - setErrors({ general: errorMsg }); - toast.error(errorMsg, { icon: "โš ๏ธ" }); - } finally { - setLoading(false); - } - }, - [formData, isLogin, login, register, navigate, validationErrors] - ); - - // Google OAuth success handler - const handleGoogleSuccess = useCallback( - async (response) => { - if (!response || !response.credential) { - handleGoogleError(new Error("Invalid Google response")); - return; - } - - setErrors({}); - setGoogleLoading(true); - - const loadingToast = toast.loading("Authenticating with Google... โœจ", { - icon: "๐Ÿ”", - style: { - background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", - color: "#ffffff", - }, - }); - - try { - const result = await googleLogin(response.credential); - toast.dismiss(loadingToast); - - if (result.success) { - toast.success("Google authentication successful! ๐ŸŽ‰", { - icon: "โœจ", - style: { - background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", - }, - }); - navigate("/dashboard", { replace: true }); - } else { - const errorMsg = result.error || "Google authentication failed"; - setErrors({ general: errorMsg }); - toast.error(errorMsg, { icon: "โŒ" }); - } - } catch (error) { - toast.dismiss(loadingToast); - console.error("Google login error:", error); - const errorMsg = "Google authentication failed. Please try again."; - setErrors({ general: errorMsg }); - toast.error(errorMsg, { icon: "๐Ÿšซ" }); - } finally { - setGoogleLoading(false); + const handleGoogleSuccess = async (credential) => { + setError(""); + setGoogleLoading(true); + + const loadingToast = toast.loading('Authenticating with Google... โœจ', { + icon: '๐Ÿ”', + style: { + background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', + color: '#ffffff', + }, + }); + + try { + const result = await googleLogin(credential); + toast.dismiss(loadingToast); + + if (result.success) { + toast.success('Google authentication successful! ๐ŸŽ‰', { + icon: 'โœจ', + style: { + background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', + }, + }); + navigate("/dashboard", { replace: true }); + } else { + const errorMsg = result.error || "Google authentication failed"; + setError(errorMsg); + toast.error(errorMsg, { icon: "โŒ" }); } - }, - [googleLogin, navigate] - ); + } catch (error) { + toast.dismiss(loadingToast); + console.error("Google login error:", error); + const errorMsg = "Google authentication failed. Please try again."; + setError(errorMsg); + toast.error(errorMsg, { icon: "๐Ÿšซ" }); + } finally { + setGoogleLoading(false); + } + }; - // Google OAuth error handler - const handleGoogleError = useCallback((error) => { + const handleGoogleError = (error) => { console.error("Google OAuth error:", error); const errorMsg = "Google authentication unavailable. Please try again."; - setErrors({ general: errorMsg }); + setError(errorMsg); toast.error(errorMsg, { icon: "๐Ÿ”’" }); setGoogleLoading(false); - }, []); - - // Input change handler with validation - const handleInputChange = useCallback( - (e) => { - const { name, value } = e.target; - setFormData((prev) => ({ - ...prev, - [name]: value, - })); - - // Clear field-specific errors on change - if (errors[name]) { - setErrors((prev) => ({ - ...prev, - [name]: undefined, - })); - } - - // Clear general error - if (errors.general) { - setErrors((prev) => ({ - ...prev, - general: undefined, - })); - } - }, - [errors] - ); - - // Google sign-in handler - const handleGoogleSignIn = useCallback(() => { - if (window.google && window.google.accounts) { - try { - window.google.accounts.id.prompt(); - } catch (error) { - handleGoogleError(error); - } - } else { - handleGoogleError(new Error("Google OAuth not initialized")); - } - }, [handleGoogleError]); - - // Toggle between login/register - const toggleMode = useCallback(() => { - setIsLogin((prev) => !prev); - setErrors({}); - setSuccessMessage(""); - setFormData({ username: "", email: "", password: "" }); - }, []); - - // Password visibility toggle - const togglePasswordVisibility = useCallback(() => { - setShowPassword((prev) => !prev); - }, []); + }; return (
+ + {/* Top bar (Back / Sign up) */}
- + Back to home
@@ -361,15 +145,24 @@ const LoginPage = memo(() => { {/* Main content */}
- {/* LEFT: Hero image (hide on small screens) */} -
-
- Planoria preview + {/* LEFT: Hero content (hide on small screens) */} +
+
+
+
+

+ Create Impactful Stories with Planoria Today + ^ +

+
@@ -378,149 +171,48 @@ const LoginPage = memo(() => { - {isLogin ? "Welcome Back" : "Create Account"} + Welcome - Sign in with Google to access your account. Email/password - login coming soon! + Sign in with Google to access your account. - {/* Success Message */} - {successMessage && } - {/* Error Display */} - {errors.general && ( -
-
- -

{errors.general}

-
+ {error && ( +
+ + {error}
)} - {/* OAuth */} + {/* Google Login Button */}
- -
- -
- - or - +
- {/* Email / Password Form - Disabled since backend doesn't support it */} -
- {!isLogin && ( -
- - -
- )} - -
- - -
- -
- -
- - -
+ {googleLoading && ( +
+ Processing Google login...
- - -
- - {/* Coming Soon Notice */} -
-

- ๐Ÿ”ง Email/password authentication is coming soon! For now, - please use Google sign-in above. -

-
- - {/* Toggle between login/register - Disabled since backend doesn't support registration */} -
- - Registration coming soon! Use Google sign-in for now. - -
+ )} {/* Meta */}
Forgot password? Create account @@ -534,20 +226,6 @@ const LoginPage = memo(() => {
-
); -}); - -LoginPage.displayName = "LoginPage"; - -export default LoginPage; +} diff --git a/src/pages/LoginPageVisualClean.jsx b/src/pages/LoginPageVisualClean.jsx new file mode 100644 index 0000000..a5aab9d --- /dev/null +++ b/src/pages/LoginPageVisualClean.jsx @@ -0,0 +1,231 @@ +import React, { useState, useEffect } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "@/components/ui/card"; +import { IconArrowLeft, IconAlertCircle } from "@tabler/icons-react"; +import { useAuth } from "@/hooks/useAuth.jsx"; +import GoogleLoginButton from "@/components/GoogleLoginButton"; +import toast, { Toaster } from 'react-hot-toast'; + +export default function LoginPageVisual() { + const { googleLogin, isAuthenticated } = useAuth(); + const navigate = useNavigate(); + + // Local UI state + const [googleLoading, setGoogleLoading] = useState(false); + const [error, setError] = useState(""); + + // Redirect if already authenticated + useEffect(() => { + if (isAuthenticated) { + navigate("/dashboard", { replace: true }); + } + }, [isAuthenticated, navigate]); + + const handleGoogleSuccess = async (credential) => { + setError(""); + setGoogleLoading(true); + + const loadingToast = toast.loading('Authenticating with Google... โœจ', { + icon: '๐Ÿ”', + style: { + background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', + color: '#ffffff', + }, + }); + + try { + const result = await googleLogin(credential); + toast.dismiss(loadingToast); + + if (result.success) { + toast.success('Google authentication successful! ๐ŸŽ‰', { + icon: 'โœจ', + style: { + background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', + }, + }); + navigate("/dashboard", { replace: true }); + } else { + const errorMsg = result.error || "Google authentication failed"; + setError(errorMsg); + toast.error(errorMsg, { icon: "โŒ" }); + } + } catch (error) { + toast.dismiss(loadingToast); + console.error("Google login error:", error); + const errorMsg = "Google authentication failed. Please try again."; + setError(errorMsg); + toast.error(errorMsg, { icon: "๐Ÿšซ" }); + } finally { + setGoogleLoading(false); + } + }; + + const handleGoogleError = (error) => { + console.error("Google OAuth error:", error); + const errorMsg = "Google authentication unavailable. Please try again."; + setError(errorMsg); + toast.error(errorMsg, { icon: "๐Ÿ”’" }); + setGoogleLoading(false); + }; + + return ( +
+ + + {/* Top bar (Back / Sign up) */} +
+ + + Back to home + +
+ + {/* Main content */} +
+
+ {/* LEFT: Hero content (hide on small screens) */} +
+
+
+
+

+ Create Impactful Stories with Planoria Today + ^ +

+
+
+
+ + {/* RIGHT: Form */} +
+ + + + Welcome + + + Sign in with Google to access your account. + + + + + {/* Error Display */} + {error && ( +
+ + {error} +
+ )} + + {/* Google Login Button */} +
+ +
+ + {googleLoading && ( +
+ Processing Google login... +
+ )} + + {/* Meta */} +
+ + Forgot password? + + + Create account + +
+
+
+ +
+ ยฉ 2025 ALL RIGHTS RESERVED +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/pages/LogoutPage.jsx b/src/pages/LogoutPage.jsx index af1248a..fc34fc4 100644 --- a/src/pages/LogoutPage.jsx +++ b/src/pages/LogoutPage.jsx @@ -1,7 +1,7 @@ -import React, { useEffect } from 'react'; -import { useNavigate } from 'react-router'; -import { useAuth } from '@/hooks/useAuth'; -import toast from 'react-hot-toast'; +import React, { useEffect } from "react"; +import { useNavigate } from "react-router"; +import { useAuth } from "@/hooks/useAuth"; +import toast from "react-hot-toast"; const LogoutPage = () => { const { logout, isAuthenticated } = useAuth(); @@ -11,11 +11,10 @@ const LogoutPage = () => { const handleLogout = async () => { try { // Show loading toast - const loadingToast = toast.loading('Logging out...', { - icon: '๐Ÿ”', + const loadingToast = toast.loading("Logging out...", { style: { - background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', - color: '#ffffff', + background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", + color: "#ffffff", }, }); @@ -26,22 +25,19 @@ const LogoutPage = () => { toast.dismiss(loadingToast); // Show success message - toast.success('Logged out successfully! ๐Ÿ‘‹', { - icon: '๐Ÿ‘‹', + toast.success("Logged out successfully!", { style: { - background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', + background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", }, }); // Redirect to login page - navigate('/login', { replace: true }); + navigate("/login", { replace: true }); } catch (error) { - console.error('Logout error:', error); - toast.error('Logout failed. Please try again.', { - icon: 'โŒ', - }); + console.error("Logout error:", error); + toast.error("Logout failed. Please try again.", {}); // Still redirect to login even if logout fails - navigate('/login', { replace: true }); + navigate("/login", { replace: true }); } }; @@ -50,7 +46,7 @@ const LogoutPage = () => { handleLogout(); } else { // If not authenticated, just redirect to login - navigate('/login', { replace: true }); + navigate("/login", { replace: true }); } }, [logout, navigate, isAuthenticated]); @@ -69,4 +65,4 @@ const LogoutPage = () => { ); }; -export default LogoutPage; \ No newline at end of file +export default LogoutPage; diff --git a/src/pages/ScheduleCalenderPage.jsx b/src/pages/ScheduleCalenderPage.jsx index ac090a7..e4b7211 100644 --- a/src/pages/ScheduleCalenderPage.jsx +++ b/src/pages/ScheduleCalenderPage.jsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect, useMemo, useState, useCallback } from "react"; import { CalendarDays, ChevronLeft, @@ -9,63 +9,57 @@ import { import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; +import axios from "axios"; +import { useAuth } from "@/hooks/useAuth"; export default function ScheduleCalendarPage({ onOpenEvent, onCreateSchedule, }) { // views: month | week | day - const [view, setView] = React.useState("month"); - const [currentDate, setCurrentDate] = React.useState(new Date()); - const [selectedDate, setSelectedDate] = React.useState(new Date()); + const [view, setView] = useState("month"); + const [currentDate, setCurrentDate] = useState(new Date()); + const [selectedDate, setSelectedDate] = useState(new Date()); + const [schedules, setSchedules] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const { user } = useAuth(); - const events = React.useMemo( - () => [ - { - id: "s-101", - title: "IG Reels: Editing cepat", - platform: "Instagram", - start: "2025-09-13T10:00:00+07:00", - end: "2025-09-13T10:30:00+07:00", - }, - { - id: "s-102", - title: "TikTok: Hook 3 detik", - platform: "TikTok", - start: "2025-09-14T19:00:00+07:00", - end: "2025-09-14T19:15:00+07:00", - }, - { - id: "s-103", - title: "YouTube Shorts: Lighting", - platform: "YouTube", - start: "2025-09-16T09:30:00+07:00", - end: "2025-09-16T10:00:00+07:00", - }, - { - id: "s-104", - title: "IG Carousel: Hashtag 2025", - platform: "Instagram", - start: "2025-09-16T14:00:00+07:00", - end: "2025-09-16T14:20:00+07:00", - }, - { - id: "s-105", - title: "TikTok: Transisi keren", - platform: "TikTok", - start: "2025-09-18T20:30:00+07:00", - end: "2025-09-18T20:45:00+07:00", - }, - { - id: "s-106", - title: "YT: Upload workflow", - platform: "YouTube", - start: "2025-09-20T08:00:00+07:00", - end: "2025-09-20T08:30:00+07:00", - }, - ], - [] - ); + // Fetch schedules based on current view and date + useEffect(() => { + const fetchSchedules = async () => { + if (!user) return; + setLoading(true); + setError(null); + try { + const response = await axios.get("/schedules"); + setSchedules(response.data.schedules || []); + } catch (err) { + setError(err.message || "Failed to fetch schedules"); + console.error("Error fetching schedules:", err); + } finally { + setLoading(false); + } + }; + fetchSchedules(); + }, [user]); // Fetch once on mount, or when user changes + + const events = useMemo(() => { + return schedules + .filter((s) => s.status !== "failed") // Exclude failed schedules + .map((s) => { + const start = new Date(s.scheduled_at); + const end = new Date(start.getTime() + (s.cover_time || 900) * 1000); // Default 15 min if no cover_time + return { + id: s._id, + title: s.caption || `Post to ${s.platform}`, + platform: s.platform, + start: start.toISOString(), + end: end.toISOString(), + status: s.status, + }; + }); + }, [schedules]); // --- Helpers tanggal --- const startOfDay = (d) => @@ -78,13 +72,13 @@ export default function ScheduleCalendarPage({ a.getDate() === b.getDate(); // const sameMonth = (a, b) => // a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth(); - const startOfWeekMonday = React.useCallback((d) => { + const startOfWeekMonday = useCallback((d) => { const day = (d.getDay() + 6) % 7; // 0=Mon ... 6=Sun const start = addDays(startOfDay(d), -day); return start; }, []); - const monthMatrix = React.useMemo(() => { + const monthMatrix = useMemo(() => { const year = currentDate.getFullYear(); const month = currentDate.getMonth(); @@ -112,7 +106,7 @@ export default function ScheduleCalendarPage({ } // Add days of the month - days.forEach(day => { + days.forEach((day) => { if (currentWeek.length === 7) { weeks.push(currentWeek); currentWeek = []; @@ -129,7 +123,7 @@ export default function ScheduleCalendarPage({ return weeks; }, [currentDate]); - const weekRange = React.useMemo(() => { + const weekRange = useMemo(() => { const start = startOfWeekMonday(currentDate); return Array.from({ length: 7 }, (_, i) => addDays(start, i)); }, [currentDate, startOfWeekMonday]); @@ -204,7 +198,10 @@ export default function ScheduleCalendarPage({

-
@@ -213,15 +210,31 @@ export default function ScheduleCalendarPage({ {/* Toolbar */} + {loading && ( +
Loading schedules...
+ )} + {error && ( +
{error}
+ )}
-
{formatDateHead(currentDate)}
-
-
- {dayEvents.slice(0, 3).map((ev) => ( -
openEvent(ev.id)} - className="cursor-pointer truncate rounded-md bg-black px-2 py-1.5 sm:py-1 text-xs sm:text-[11px] text-white hover:opacity-90 touch-manipulation" - title={`${ev.title} โ€ข ${formatTime( - ev.start - )}-${formatTime(ev.end)}`} - > - {ev.title} - - {formatTime(ev.start)} - -
- ))} - {dayEvents.length > 3 && ( - - )} +
+ {dayEvents.slice(0, 3).map((ev) => ( +
openEvent(ev.id)} + className="cursor-pointer truncate rounded-md bg-black px-2 py-1.5 sm:py-1 text-xs sm:text-[11px] text-white hover:opacity-90 touch-manipulation" + title={`${ev.title} โ€ข ${formatTime( + ev.start + )}-${formatTime(ev.end)}`} + > + {ev.title} + + {formatTime(ev.start)} + +
+ ))} + {dayEvents.length > 3 && ( + + )} +
-
- ); + ); }) )}
@@ -353,12 +366,18 @@ export default function ScheduleCalendarPage({ const list = eventsOnDate(d); const isToday = sameDay(d, new Date()); return ( -
+
{formatWeekdayShort(d)}
- + {formatDayNum(d)}
@@ -382,7 +401,21 @@ export default function ScheduleCalendarPage({ {formatTime(ev.start)}โ€“{formatTime(ev.end)} - {ev.platform} + + {ev.platform} + + + {ev.status} +
))} @@ -463,6 +496,17 @@ export default function ScheduleCalendarPage({ {formatTime(ev.start)}โ€“{formatTime(ev.end)} {ev.platform} + + {ev.status} +
@@ -482,7 +526,10 @@ export default function ScheduleCalendarPage({
Quick Actions
-