437 lines
15 KiB
TypeScript
437 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
LogIn,
|
|
X,
|
|
Lock,
|
|
Link as LinkIcon,
|
|
ArrowLeft,
|
|
Package,
|
|
MapPin,
|
|
ShoppingBag,
|
|
} from 'lucide-react';
|
|
import { GoogleOAuthProvider, useGoogleLogin } from '@react-oauth/google';
|
|
import { useAuthStore } from '@/store/authStore';
|
|
import { setAccessToken, API_BASE_URL } from '@/services/api/client';
|
|
|
|
interface GoogleAuthModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onSuccess?: (customerData: any) => void;
|
|
}
|
|
|
|
function GoogleIcon({ className = 'w-5 h-5' }: { className?: string }) {
|
|
return (
|
|
<svg className={className} viewBox="0 0 24 24" aria-hidden>
|
|
<path
|
|
fill="#4285F4"
|
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
|
/>
|
|
<path
|
|
fill="#34A853"
|
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
|
/>
|
|
<path
|
|
fill="#FBBC05"
|
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.06H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.94l2.85-2.22.81-.63z"
|
|
/>
|
|
<path
|
|
fill="#EA4335"
|
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.06l3.66 2.84c.87-2.6 3.3-4.52 6.16-4.52z"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function BrandMark() {
|
|
return (
|
|
<div className="text-[22px] font-extrabold tracking-tight leading-none select-none">
|
|
<span className="text-primary">iFix</span>
|
|
<span className="text-[#1a1a1a]">Kart</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function GoogleAuthInner({ isOpen, onClose, onSuccess }: GoogleAuthModalProps) {
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [csrfToken, setCsrfToken] = useState('');
|
|
const [mode, setMode] = useState<'login' | 'link'>('login');
|
|
const [linkEmail, setLinkEmail] = useState('');
|
|
const [linkPassword, setLinkPassword] = useState('');
|
|
const [tempAuthCode, setTempAuthCode] = useState('');
|
|
|
|
const setAuth = useAuthStore((state) => state.setAuth);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
const prev = document.body.style.overflow;
|
|
document.body.style.overflow = 'hidden';
|
|
|
|
const array = new Uint32Array(4);
|
|
if (typeof window !== 'undefined' && window.crypto) {
|
|
window.crypto.getRandomValues(array);
|
|
setCsrfToken(Array.from(array, (dec) => dec.toString(16)).join(''));
|
|
} else {
|
|
setCsrfToken(
|
|
Math.random().toString(36).substring(2, 15) +
|
|
Math.random().toString(36).substring(2, 15)
|
|
);
|
|
}
|
|
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') onClose();
|
|
};
|
|
document.addEventListener('keydown', onKey);
|
|
return () => {
|
|
document.body.style.overflow = prev;
|
|
document.removeEventListener('keydown', onKey);
|
|
};
|
|
}, [isOpen, onClose]);
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
setMode('login');
|
|
setError(null);
|
|
setLinkPassword('');
|
|
setLoading(false);
|
|
}
|
|
}, [isOpen]);
|
|
|
|
const handleAuthCodeSubmit = async (code: string) => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const guestSessionId =
|
|
typeof window !== 'undefined'
|
|
? localStorage.getItem('guest_session_id') || undefined
|
|
: undefined;
|
|
|
|
const res = await fetch(`${API_BASE_URL}/api/v1/customer/auth/google`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
credentials: 'include',
|
|
body: JSON.stringify({
|
|
code,
|
|
state: csrfToken,
|
|
guest_session_id: guestSessionId,
|
|
redirect_uri: 'postmessage',
|
|
}),
|
|
});
|
|
|
|
const data = await res.json();
|
|
|
|
if (res.status === 409 && data.detail === 'account_linking_required') {
|
|
setTempAuthCode(code);
|
|
setLinkEmail(data.email || '');
|
|
setMode('link');
|
|
return;
|
|
}
|
|
|
|
if (!res.ok) {
|
|
throw new Error(data.detail || 'Google authentication failed.');
|
|
}
|
|
|
|
setAccessToken(data.access_token);
|
|
setAuth(data.access_token, {
|
|
customer_id: data.customer_id,
|
|
email: data.email,
|
|
first_name: data.first_name,
|
|
});
|
|
|
|
onSuccess?.(data);
|
|
onClose();
|
|
} catch (err: any) {
|
|
setError(err.message || 'Google authentication failed. Please try again.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleLinkAccountSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!linkPassword) {
|
|
setError('Password is required to link account.');
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const guestSessionId =
|
|
typeof window !== 'undefined'
|
|
? localStorage.getItem('guest_session_id') || undefined
|
|
: undefined;
|
|
|
|
const res = await fetch(`${API_BASE_URL}/api/v1/customer/auth/link-google`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
credentials: 'include',
|
|
body: JSON.stringify({
|
|
email: linkEmail,
|
|
password: linkPassword,
|
|
code: tempAuthCode,
|
|
state: csrfToken,
|
|
guest_session_id: guestSessionId,
|
|
redirect_uri: 'postmessage',
|
|
}),
|
|
});
|
|
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
throw new Error(data.detail || 'Verification failed. Check your password.');
|
|
}
|
|
|
|
setAccessToken(data.access_token);
|
|
setAuth(data.access_token, {
|
|
customer_id: data.customer_id,
|
|
email: data.email,
|
|
first_name: data.first_name,
|
|
});
|
|
|
|
onSuccess?.(data);
|
|
onClose();
|
|
} catch (err: any) {
|
|
setError(err.message || 'Account linking failed.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const googleLogin = useGoogleLogin({
|
|
onSuccess: (codeResponse) => {
|
|
if (codeResponse.code) handleAuthCodeSubmit(codeResponse.code);
|
|
},
|
|
onError: () => {
|
|
setError('Google Login popup was closed or authentication failed.');
|
|
},
|
|
flow: 'auth-code',
|
|
state: csrfToken,
|
|
});
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-[210] flex items-center justify-center p-4"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="auth-modal-title"
|
|
>
|
|
<button
|
|
type="button"
|
|
aria-label="Close sign in"
|
|
onClick={onClose}
|
|
className="absolute inset-0 bg-black/50 border-0 cursor-pointer"
|
|
/>
|
|
|
|
<div className="relative w-full max-w-[420px] bg-white rounded-xl shadow-2xl border border-gray-200 overflow-hidden">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-5 py-3.5 border-b border-gray-100 bg-[#F8F9FB]">
|
|
<div className="flex items-center gap-2.5 min-w-0">
|
|
{mode === 'link' ? (
|
|
<span className="w-8 h-8 rounded-lg bg-amber-50 text-amber-600 flex items-center justify-center shrink-0">
|
|
<LinkIcon className="w-4 h-4" />
|
|
</span>
|
|
) : (
|
|
<span className="w-8 h-8 rounded-lg bg-primary-light text-primary flex items-center justify-center shrink-0">
|
|
<LogIn className="w-4 h-4" />
|
|
</span>
|
|
)}
|
|
<h3
|
|
id="auth-modal-title"
|
|
className="text-[15px] font-bold text-[#1a1a1a] truncate"
|
|
>
|
|
{mode === 'link' ? 'Link Google Account' : 'Customer Sign In'}
|
|
</h3>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="p-1.5 rounded-full text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors cursor-pointer"
|
|
aria-label="Close"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="px-6 py-7 space-y-6">
|
|
{error && (
|
|
<div className="p-3 text-[13px] text-red-600 bg-red-50 rounded-lg border border-red-100">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{mode === 'login' ? (
|
|
<>
|
|
<div className="text-center space-y-3">
|
|
<BrandMark />
|
|
<div className="space-y-1.5">
|
|
<h4 className="text-[20px] font-bold text-[#1a1a1a]">
|
|
Welcome to iFixKart
|
|
</h4>
|
|
<p className="text-[13px] text-gray-500 leading-relaxed max-w-[300px] mx-auto">
|
|
Access your order history, track shipments, and sync your cart
|
|
seamlessly.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<ul className="grid grid-cols-3 gap-2 text-center">
|
|
{[
|
|
{ icon: Package, label: 'Orders' },
|
|
{ icon: MapPin, label: 'Addresses' },
|
|
{ icon: ShoppingBag, label: 'Cart sync' },
|
|
].map(({ icon: Icon, label }) => (
|
|
<li
|
|
key={label}
|
|
className="rounded-lg bg-[#F5F6F8] px-2 py-3 flex flex-col items-center gap-1.5"
|
|
>
|
|
<Icon className="w-4 h-4 text-primary" strokeWidth={1.75} />
|
|
<span className="text-[11px] font-semibold text-gray-600">
|
|
{label}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => googleLogin()}
|
|
disabled={loading}
|
|
className="w-full flex items-center justify-center gap-3 px-4 py-3.5 rounded-lg font-semibold text-[14px] text-[#1a1a1a] bg-white border border-gray-200 hover:border-primary/40 hover:bg-[#F8FBFF] shadow-sm transition-colors disabled:opacity-50 cursor-pointer"
|
|
>
|
|
<GoogleIcon />
|
|
{loading ? 'Connecting Google...' : 'Continue with Google'}
|
|
</button>
|
|
</>
|
|
) : (
|
|
<form onSubmit={handleLinkAccountSubmit} className="space-y-4 text-left">
|
|
<div className="space-y-1">
|
|
<h4 className="text-[15px] font-bold text-[#1a1a1a]">
|
|
Existing Account Detected
|
|
</h4>
|
|
<p className="text-[12px] text-gray-500 leading-relaxed">
|
|
An account already exists under{' '}
|
|
<strong className="text-[#1a1a1a]">{linkEmail}</strong>. Enter
|
|
your password to link Google securely.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<label className="block text-[11px] font-bold text-gray-600 uppercase tracking-wide">
|
|
Password
|
|
</label>
|
|
<div className="relative">
|
|
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-400">
|
|
<Lock className="w-4 h-4" />
|
|
</span>
|
|
<input
|
|
type="password"
|
|
value={linkPassword}
|
|
onChange={(e) => setLinkPassword(e.target.value)}
|
|
required
|
|
disabled={loading}
|
|
placeholder="••••••••"
|
|
className="w-full pl-10 pr-4 py-2.5 bg-[#F8F9FB] border border-gray-200 rounded-lg text-sm text-[#1a1a1a] placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-primary/25 focus:border-primary transition"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2.5 pt-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setMode('login');
|
|
setError(null);
|
|
}}
|
|
disabled={loading}
|
|
className="flex-1 flex items-center justify-center gap-1.5 px-4 py-2.5 border border-gray-200 rounded-lg text-[13px] font-bold text-gray-700 hover:bg-gray-50 transition cursor-pointer"
|
|
>
|
|
<ArrowLeft className="w-3.5 h-3.5" />
|
|
Back
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={loading}
|
|
className="flex-1 px-4 py-2.5 bg-primary hover:brightness-95 text-white rounded-lg text-[13px] font-bold transition disabled:opacity-50 cursor-pointer"
|
|
>
|
|
{loading ? 'Verifying...' : 'Link & Sign In'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function GoogleAuthModal(props: GoogleAuthModalProps) {
|
|
const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || '';
|
|
|
|
if (!props.isOpen) return null;
|
|
|
|
if (!googleClientId) {
|
|
return (
|
|
<div className="fixed inset-0 z-[210] flex items-center justify-center p-4">
|
|
<button
|
|
type="button"
|
|
aria-label="Close"
|
|
onClick={props.onClose}
|
|
className="absolute inset-0 bg-black/50 border-0 cursor-pointer"
|
|
/>
|
|
<div className="relative w-full max-w-[420px] bg-white rounded-xl shadow-2xl border border-gray-200 overflow-hidden">
|
|
<div className="flex items-center justify-between px-5 py-3.5 border-b border-gray-100 bg-[#F8F9FB]">
|
|
<div className="flex items-center gap-2.5">
|
|
<span className="w-8 h-8 rounded-lg bg-red-50 text-red-500 flex items-center justify-center">
|
|
<LogIn className="w-4 h-4" />
|
|
</span>
|
|
<h3 className="text-[15px] font-bold text-[#1a1a1a]">
|
|
Customer Sign In
|
|
</h3>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={props.onClose}
|
|
className="p-1.5 rounded-full text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors cursor-pointer"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
<div className="p-6 space-y-4 text-center">
|
|
<div className="p-4 text-sm text-amber-800 bg-amber-50 rounded-lg border border-amber-200 text-left">
|
|
<p className="font-bold mb-1">Google Sign-In Unconfigured</p>
|
|
<p className="text-xs leading-relaxed">
|
|
<code className="text-[11px]">NEXT_PUBLIC_GOOGLE_CLIENT_ID</code> is
|
|
missing. Add it to <code className="text-[11px]">.env.local</code> to
|
|
enable Google Sign-In.
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={props.onClose}
|
|
className="w-full py-2.5 bg-gray-100 hover:bg-gray-200 text-[#1a1a1a] rounded-lg text-[13px] font-bold transition cursor-pointer"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<GoogleOAuthProvider clientId={googleClientId}>
|
|
<GoogleAuthInner {...props} />
|
|
</GoogleOAuthProvider>
|
|
);
|
|
}
|