lmao
This commit is contained in:
parent
03445188ea
commit
524580eb25
102 changed files with 36625 additions and 1295 deletions
|
|
@ -1,5 +1,7 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { CloseIcon } from './icons/CloseIcon';
|
||||
import { GoogleIcon } from './icons/GoogleIcon';
|
||||
import { AppleIcon } from './icons/AppleIcon';
|
||||
|
||||
type View = 'login' | 'register' | 'forgot';
|
||||
|
||||
|
|
@ -7,6 +9,7 @@ export default function AuthModal({
|
|||
onClose,
|
||||
onLogin,
|
||||
onRegister,
|
||||
onOAuthLogin,
|
||||
onForgotPassword,
|
||||
loading,
|
||||
error,
|
||||
|
|
@ -16,6 +19,7 @@ export default function AuthModal({
|
|||
onClose: () => void;
|
||||
onLogin: (email: string, password: string) => Promise<void>;
|
||||
onRegister: (email: string, password: string) => Promise<void>;
|
||||
onOAuthLogin: (provider: string) => Promise<void>;
|
||||
onForgotPassword: (email: string) => Promise<void>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
|
@ -57,6 +61,18 @@ export default function AuthModal({
|
|||
[view, email, password, onLogin, onRegister, onForgotPassword, onClose]
|
||||
);
|
||||
|
||||
const handleOAuth = useCallback(
|
||||
async (provider: string) => {
|
||||
try {
|
||||
await onOAuthLogin(provider);
|
||||
onClose();
|
||||
} catch {
|
||||
// Error is handled by the hook
|
||||
}
|
||||
},
|
||||
[onOAuthLogin, onClose]
|
||||
);
|
||||
|
||||
const title =
|
||||
view === 'login' ? 'Log in' : view === 'register' ? 'Create account' : 'Reset password';
|
||||
|
||||
|
|
@ -104,82 +120,117 @@ export default function AuthModal({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-4">
|
||||
{/* OAuth buttons (hidden in forgot view) */}
|
||||
{view !== 'forgot' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
|
||||
placeholder={view === 'register' ? 'Min 8 characters' : 'Your password'}
|
||||
/>
|
||||
{view === 'login' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => switchView('forgot')}
|
||||
className="mt-1 text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||
onClick={() => handleOAuth('google')}
|
||||
disabled={loading}
|
||||
className="w-full flex items-center justify-center gap-2 py-2 px-4 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-warm-100 text-sm font-medium hover:bg-warm-50 dark:hover:bg-warm-700 disabled:opacity-50 disabled:cursor-wait"
|
||||
>
|
||||
Forgot password?
|
||||
<GoogleIcon className="w-4 h-4" />
|
||||
Continue with Google
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOAuth('apple')}
|
||||
disabled={loading}
|
||||
className="w-full flex items-center justify-center gap-2 py-2 px-4 rounded bg-navy-950 dark:bg-white text-white dark:text-navy-950 text-sm font-medium hover:bg-navy-900 dark:hover:bg-warm-100 disabled:opacity-50 disabled:cursor-wait"
|
||||
>
|
||||
<AppleIcon className="w-4 h-4" />
|
||||
Continue with Apple
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 h-px bg-warm-200 dark:bg-warm-700" />
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500">or</span>
|
||||
<div className="flex-1 h-px bg-warm-200 dark:bg-warm-700" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Email form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'forgot' && resetSent && (
|
||||
<p className="text-sm text-teal-700 dark:text-teal-400">
|
||||
Check your email for a reset link.
|
||||
</p>
|
||||
)}
|
||||
{view !== 'forgot' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
|
||||
placeholder={view === 'register' ? 'Min 8 characters' : 'Your password'}
|
||||
/>
|
||||
{view === 'login' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => switchView('forgot')}
|
||||
className="mt-1 text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-300">{error}</p>}
|
||||
{view === 'forgot' && resetSent && (
|
||||
<p className="text-sm text-teal-700 dark:text-teal-400">
|
||||
Check your email for a reset link.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!(view === 'forgot' && resetSent) && (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2 rounded bg-teal-600 text-white text-sm font-medium hover:bg-teal-700 dark:hover:bg-teal-600 disabled:opacity-50 disabled:cursor-wait transition-colors"
|
||||
>
|
||||
{loading
|
||||
? 'Please wait...'
|
||||
: view === 'login'
|
||||
? 'Log in'
|
||||
: view === 'register'
|
||||
? 'Create account'
|
||||
: 'Send reset link'}
|
||||
</button>
|
||||
)}
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-300">{error}</p>}
|
||||
|
||||
{view === 'forgot' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => switchView('login')}
|
||||
className="w-full text-center text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||
>
|
||||
Back to login
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
{!(view === 'forgot' && resetSent) && (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2 rounded bg-teal-600 text-white text-sm font-medium hover:bg-teal-700 dark:hover:bg-teal-600 disabled:opacity-50 disabled:cursor-wait transition-colors"
|
||||
>
|
||||
{loading
|
||||
? 'Please wait...'
|
||||
: view === 'login'
|
||||
? 'Log in'
|
||||
: view === 'register'
|
||||
? 'Create account'
|
||||
: 'Send reset link'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{view === 'forgot' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => switchView('login')}
|
||||
className="w-full text-center text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||
>
|
||||
Back to login
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { SpinnerIcon } from './icons/SpinnerIcon';
|
|||
import UserMenu from './UserMenu';
|
||||
import MobileMenu from './MobileMenu';
|
||||
|
||||
export type Page = 'home' | 'dashboard' | 'saved-searches' | 'learn' | 'pricing' | 'account';
|
||||
export type Page = 'home' | 'dashboard' | 'saved-searches' | 'learn' | 'pricing' | 'account' | 'invite' | 'support';
|
||||
|
||||
export default function Header({
|
||||
activePage,
|
||||
|
|
@ -139,6 +139,9 @@ export default function Header({
|
|||
<button className={tabClass('pricing')} onClick={() => onPageChange('pricing')}>
|
||||
Pricing
|
||||
</button>
|
||||
<button className={tabClass('support')} onClick={() => onPageChange('support')}>
|
||||
Support
|
||||
</button>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
92
frontend/src/components/ui/LicenseSuccessModal.tsx
Normal file
92
frontend/src/components/ui/LicenseSuccessModal.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
interface LicenseSuccessModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProps) {
|
||||
// Generate confetti particles once
|
||||
const particles = useMemo(
|
||||
() =>
|
||||
Array.from({ length: 40 }, (_, i) => ({
|
||||
id: i,
|
||||
left: Math.random() * 100,
|
||||
delay: Math.random() * 2,
|
||||
duration: 2 + Math.random() * 2,
|
||||
color: ['#10b981', '#06b6d4', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'][
|
||||
Math.floor(Math.random() * 6)
|
||||
],
|
||||
size: 6 + Math.random() * 6,
|
||||
})),
|
||||
[]
|
||||
);
|
||||
|
||||
// Auto-dismiss after 8 seconds
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(onClose, 8000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
|
||||
{/* Confetti */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
{particles.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="absolute animate-confetti"
|
||||
style={{
|
||||
left: `${p.left}%`,
|
||||
top: '-10px',
|
||||
width: `${p.size}px`,
|
||||
height: `${p.size}px`,
|
||||
backgroundColor: p.color,
|
||||
borderRadius: Math.random() > 0.5 ? '50%' : '2px',
|
||||
animationDelay: `${p.delay}s`,
|
||||
animationDuration: `${p.duration}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="relative z-10 w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 text-center overflow-hidden">
|
||||
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8">
|
||||
<div className="text-5xl mb-3">🎉</div>
|
||||
<h2 className="text-2xl font-bold text-white">Welcome aboard!</h2>
|
||||
<p className="text-warm-300 text-sm mt-2">
|
||||
Your lifetime license is now active.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-6 py-6">
|
||||
<p className="text-warm-600 dark:text-warm-300 text-sm mb-6">
|
||||
You now have full access to every feature across all of England. Happy exploring!
|
||||
</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full px-6 py-3 bg-teal-600 text-white rounded-lg font-semibold hover:bg-teal-700 transition-colors text-lg"
|
||||
>
|
||||
Start exploring
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CSS animation for confetti */}
|
||||
<style>{`
|
||||
@keyframes confetti-fall {
|
||||
0% {
|
||||
transform: translateY(0) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(100vh) rotate(720deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.animate-confetti {
|
||||
animation: confetti-fall linear forwards;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -83,6 +83,7 @@ export default function MobileMenu({
|
|||
{user && mobileNavItem('saved-searches', 'Saved')}
|
||||
{mobileNavItem('learn', 'Learn')}
|
||||
{mobileNavItem('pricing', 'Pricing')}
|
||||
{mobileNavItem('support', 'Support')}
|
||||
{user && mobileNavItem('account', 'Account')}
|
||||
|
||||
{/* Dashboard actions */}
|
||||
|
|
|
|||
133
frontend/src/components/ui/UpgradeModal.tsx
Normal file
133
frontend/src/components/ui/UpgradeModal.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { CloseIcon } from './icons/CloseIcon';
|
||||
import { SpinnerIcon } from './icons/SpinnerIcon';
|
||||
import { apiUrl } from '../../lib/api';
|
||||
|
||||
interface UpgradeModalProps {
|
||||
isLoggedIn: boolean;
|
||||
onLoginClick: () => void;
|
||||
onRegisterClick: () => void;
|
||||
onStartCheckout: () => Promise<void>;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export default function UpgradeModal({
|
||||
isLoggedIn,
|
||||
onLoginClick,
|
||||
onRegisterClick,
|
||||
onStartCheckout,
|
||||
onDismiss,
|
||||
}: UpgradeModalProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pricePence, setPricePence] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(apiUrl('pricing'))
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
if (data) setPricePence(data.current_price_pence);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const priceLabel =
|
||||
pricePence === null
|
||||
? '...'
|
||||
: pricePence === 0
|
||||
? 'Free'
|
||||
: `\u00A3${pricePence / 100}`;
|
||||
const isFree = pricePence === 0;
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onStartCheckout();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Checkout failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="relative w-full max-w-md mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 overflow-hidden">
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="absolute top-3 right-3 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||
>
|
||||
<CloseIcon className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8 text-center">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Unlock the full map</h2>
|
||||
<p className="text-warm-300 text-sm">
|
||||
Free users can explore inner London. Upgrade for lifetime access to all of England.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-6 py-6">
|
||||
<div className="flex items-baseline justify-center gap-1 mb-4">
|
||||
<span className="text-4xl font-extrabold text-navy-950 dark:text-warm-100">
|
||||
{priceLabel}
|
||||
</span>
|
||||
{!isFree && (
|
||||
<span className="text-warm-500 dark:text-warm-400 text-lg">/once</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-center text-sm text-warm-500 dark:text-warm-400 mb-6">
|
||||
{isFree
|
||||
? 'Free for early adopters. No credit card required.'
|
||||
: 'One-time payment. Lifetime access. 30-day money-back guarantee.'}
|
||||
</p>
|
||||
|
||||
{isLoggedIn ? (
|
||||
<button
|
||||
onClick={handleUpgrade}
|
||||
disabled={loading}
|
||||
className="w-full px-6 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading && <SpinnerIcon className="w-5 h-5 animate-spin" />}
|
||||
{loading
|
||||
? 'Redirecting...'
|
||||
: isFree
|
||||
? 'Claim free license'
|
||||
: `Upgrade for ${priceLabel}`}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={onRegisterClick}
|
||||
className="w-full px-6 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
|
||||
>
|
||||
Register & Upgrade
|
||||
</button>
|
||||
<button
|
||||
onClick={onLoginClick}
|
||||
className="w-full px-4 py-2 text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||
>
|
||||
Already have an account? Log in
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="mt-3 text-center text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="w-full mt-4 text-center text-sm text-warm-400 dark:text-warm-500 hover:text-warm-600 dark:hover:text-warm-400"
|
||||
>
|
||||
Or zoom back into London
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
frontend/src/components/ui/VerificationBanner.tsx
Normal file
53
frontend/src/components/ui/VerificationBanner.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { SpinnerIcon } from './icons/SpinnerIcon';
|
||||
|
||||
export default function VerificationBanner({
|
||||
email,
|
||||
onRequestVerification,
|
||||
onDismiss,
|
||||
}: {
|
||||
email: string;
|
||||
onRequestVerification: (email: string) => Promise<void>;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
const handleResend = useCallback(async () => {
|
||||
setSending(true);
|
||||
try {
|
||||
await onRequestVerification(email);
|
||||
setSent(true);
|
||||
setTimeout(() => setSent(false), 3000);
|
||||
} catch {
|
||||
// Error handled by hook
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [email, onRequestVerification]);
|
||||
|
||||
return (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border-b border-amber-200 dark:border-amber-800 px-4 py-2.5 flex items-center justify-between gap-3">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
Please verify your email address. Check your inbox.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={handleResend}
|
||||
disabled={sending || sent}
|
||||
className="text-sm font-medium text-amber-700 dark:text-amber-300 hover:text-amber-900 dark:hover:text-amber-100 disabled:opacity-50 flex items-center gap-1"
|
||||
>
|
||||
{sending && <SpinnerIcon className="w-3.5 h-3.5 animate-spin" />}
|
||||
{sent ? 'Sent!' : 'Resend'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="text-amber-400 dark:text-amber-600 hover:text-amber-600 dark:hover:text-amber-400 text-lg leading-none"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
frontend/src/components/ui/icons/AppleIcon.tsx
Normal file
11
frontend/src/components/ui/icons/AppleIcon.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AppleIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
26
frontend/src/components/ui/icons/GoogleIcon.tsx
Normal file
26
frontend/src/components/ui/icons/GoogleIcon.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function GoogleIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
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"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
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.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue