ifixkart-storefront/components/auth/GoogleAuthModal.tsx

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>
);
}