- Migrate from tailwindcss v3.3 to v4.1.18 - Replace @tailwind directives with @import 'tailwindcss' - Move custom colors to @theme block in globals.css - Convert custom utilities to @utility syntax - Update PostCSS config to use @tailwindcss/postcss - Remove autoprefixer (now built-in)
297 lines
14 KiB
TypeScript
297 lines
14 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { X, QrCode, Keyboard, Loader2, CheckCircle2, Copy, RefreshCw, Link2 } from 'lucide-react';
|
|
import QRCode from 'react-qr-code';
|
|
import { generateBuddyCode, redeemBuddyCode, revokeBuddyCode } from '@/services/buddy-link';
|
|
import { useI18n } from '@/i18n/I18nContext';
|
|
|
|
interface BuddyHandshakeProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onSuccess?: () => void;
|
|
}
|
|
|
|
type Tab = 'show' | 'enter';
|
|
|
|
export default function BuddyHandshake({ isOpen, onClose, onSuccess }: BuddyHandshakeProps) {
|
|
const { t } = useI18n();
|
|
const [activeTab, setActiveTab] = useState<Tab>('show');
|
|
|
|
// Show Code Tab State
|
|
const [code, setCode] = useState<string | null>(null);
|
|
const [isGenerating, setIsGenerating] = useState(false);
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
// Enter Code Tab State
|
|
const [inputCode, setInputCode] = useState('');
|
|
const [isRedeeming, setIsRedeeming] = useState(false);
|
|
const [redeemError, setRedeemError] = useState<string | null>(null);
|
|
const [redeemSuccess, setRedeemSuccess] = useState<string | null>(null);
|
|
|
|
// Generate code when showing "Show Code" tab
|
|
useEffect(() => {
|
|
if (isOpen && activeTab === 'show' && !code) {
|
|
handleGenerateCode();
|
|
}
|
|
}, [isOpen, activeTab]);
|
|
|
|
// Reset state when closing
|
|
useEffect(() => {
|
|
if (!isOpen) {
|
|
setCode(null);
|
|
setInputCode('');
|
|
setRedeemError(null);
|
|
setRedeemSuccess(null);
|
|
setCopied(false);
|
|
}
|
|
}, [isOpen]);
|
|
|
|
const handleGenerateCode = async () => {
|
|
setIsGenerating(true);
|
|
const result = await generateBuddyCode();
|
|
if (result.success && result.code) {
|
|
setCode(result.code);
|
|
}
|
|
setIsGenerating(false);
|
|
};
|
|
|
|
const handleRefreshCode = async () => {
|
|
await revokeBuddyCode();
|
|
setCode(null);
|
|
handleGenerateCode();
|
|
};
|
|
|
|
const handleCopyCode = () => {
|
|
if (code) {
|
|
navigator.clipboard.writeText(code);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
}
|
|
};
|
|
|
|
const handleRedeemCode = async () => {
|
|
if (!inputCode.trim()) return;
|
|
|
|
setIsRedeeming(true);
|
|
setRedeemError(null);
|
|
setRedeemSuccess(null);
|
|
|
|
const result = await redeemBuddyCode(inputCode);
|
|
|
|
if (result.success) {
|
|
setRedeemSuccess(`Verbunden mit ${result.buddyName}!`);
|
|
setTimeout(() => {
|
|
onSuccess?.();
|
|
onClose();
|
|
}, 1500);
|
|
} else {
|
|
setRedeemError(result.error || 'Unbekannter Fehler');
|
|
}
|
|
|
|
setIsRedeeming(false);
|
|
};
|
|
|
|
const formatCodeForDisplay = (code: string) => {
|
|
// Format as XXX-XXX for better readability
|
|
return `${code.slice(0, 3)}-${code.slice(3)}`;
|
|
};
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 bg-black/80 backdrop-blur-xs z-50 flex items-center justify-center p-4"
|
|
onClick={onClose}
|
|
>
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
className="w-full max-w-md bg-zinc-900 rounded-3xl border border-zinc-800 shadow-2xl overflow-hidden"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-5 border-b border-zinc-800">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-xl bg-orange-600/20 flex items-center justify-center">
|
|
<Link2 size={20} className="text-orange-500" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-lg font-bold text-white">Account verbinden</h2>
|
|
<p className="text-xs text-zinc-500">Buddy-Handshake</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-2 hover:bg-zinc-800 rounded-xl transition-colors text-zinc-500 hover:text-white"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex border-b border-zinc-800">
|
|
<button
|
|
onClick={() => setActiveTab('show')}
|
|
className={`flex-1 py-3.5 text-xs font-bold uppercase tracking-widest transition-all flex items-center justify-center gap-2 ${activeTab === 'show'
|
|
? 'text-orange-500 border-b-2 border-orange-500 bg-orange-500/5'
|
|
: 'text-zinc-500 hover:text-zinc-300'
|
|
}`}
|
|
>
|
|
<QrCode size={14} />
|
|
Mein Code
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('enter')}
|
|
className={`flex-1 py-3.5 text-xs font-bold uppercase tracking-widest transition-all flex items-center justify-center gap-2 ${activeTab === 'enter'
|
|
? 'text-orange-500 border-b-2 border-orange-500 bg-orange-500/5'
|
|
: 'text-zinc-500 hover:text-zinc-300'
|
|
}`}
|
|
>
|
|
<Keyboard size={14} />
|
|
Code eingeben
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="p-6">
|
|
{activeTab === 'show' ? (
|
|
<div className="flex flex-col items-center gap-6">
|
|
{isGenerating ? (
|
|
<div className="py-12 flex flex-col items-center gap-4">
|
|
<Loader2 size={32} className="animate-spin text-orange-500" />
|
|
<p className="text-sm text-zinc-500">Generiere Code...</p>
|
|
</div>
|
|
) : code ? (
|
|
<>
|
|
{/* QR Code */}
|
|
<div className="bg-white p-4 rounded-2xl">
|
|
<QRCode
|
|
value={`dramlog://buddy/${code}`}
|
|
size={180}
|
|
level="M"
|
|
/>
|
|
</div>
|
|
|
|
{/* Text Code */}
|
|
<div className="flex flex-col items-center gap-2">
|
|
<p className="text-xs text-zinc-500 uppercase tracking-widest">Oder Code teilen:</p>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-3xl font-black tracking-[0.3em] text-white font-mono">
|
|
{formatCodeForDisplay(code)}
|
|
</span>
|
|
<button
|
|
onClick={handleCopyCode}
|
|
className={`p-2 rounded-lg transition-all ${copied
|
|
? 'bg-green-500/20 text-green-500'
|
|
: 'bg-zinc-800 hover:bg-zinc-700 text-zinc-400'
|
|
}`}
|
|
>
|
|
{copied ? <CheckCircle2 size={18} /> : <Copy size={18} />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Timer/Refresh */}
|
|
<div className="flex items-center gap-4 text-xs text-zinc-500">
|
|
<span>Gültig für 15 Minuten</span>
|
|
<button
|
|
onClick={handleRefreshCode}
|
|
className="flex items-center gap-1.5 text-orange-500 hover:text-orange-400"
|
|
>
|
|
<RefreshCw size={12} />
|
|
Neuer Code
|
|
</button>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="py-12 text-center text-zinc-500">
|
|
<p>Fehler beim Generieren</p>
|
|
<button
|
|
onClick={handleGenerateCode}
|
|
className="mt-4 text-orange-500 hover:underline"
|
|
>
|
|
Erneut versuchen
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col gap-6">
|
|
<p className="text-sm text-zinc-400 text-center">
|
|
Gib den 6-stelligen Code deines Buddies ein:
|
|
</p>
|
|
|
|
{/* Code Input */}
|
|
<input
|
|
type="text"
|
|
value={inputCode}
|
|
onChange={(e) => {
|
|
const val = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
|
if (val.length <= 6) {
|
|
setInputCode(val);
|
|
setRedeemError(null);
|
|
}
|
|
}}
|
|
placeholder="XXXXXX"
|
|
className="w-full text-center text-3xl font-black tracking-[0.4em] bg-zinc-950 border-2 border-zinc-800 rounded-2xl py-4 text-white placeholder:text-zinc-700 focus:outline-hidden focus:border-orange-500 transition-colors font-mono"
|
|
maxLength={6}
|
|
autoFocus
|
|
/>
|
|
|
|
{/* Error Message */}
|
|
{redeemError && (
|
|
<motion.p
|
|
initial={{ opacity: 0, y: -10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="text-sm text-red-500 text-center bg-red-500/10 py-2 px-4 rounded-xl"
|
|
>
|
|
{redeemError}
|
|
</motion.p>
|
|
)}
|
|
|
|
{/* Success Message */}
|
|
{redeemSuccess && (
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.9 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
className="flex items-center justify-center gap-2 text-green-500 bg-green-500/10 py-3 px-4 rounded-xl"
|
|
>
|
|
<CheckCircle2 size={20} />
|
|
<span className="font-bold">{redeemSuccess}</span>
|
|
</motion.div>
|
|
)}
|
|
|
|
{/* Submit Button */}
|
|
<button
|
|
onClick={handleRedeemCode}
|
|
disabled={inputCode.length !== 6 || isRedeeming || !!redeemSuccess}
|
|
className="w-full py-4 bg-orange-600 hover:bg-orange-700 disabled:bg-zinc-800 disabled:text-zinc-600 text-white font-bold rounded-2xl transition-all flex items-center justify-center gap-2"
|
|
>
|
|
{isRedeeming ? (
|
|
<>
|
|
<Loader2 size={18} className="animate-spin" />
|
|
Verbinde...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Link2 size={18} />
|
|
Verbinden
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
);
|
|
}
|