feat: public split visibility, RLS recursion fixes, and consolidated tasting permission management

- Added public discovery section for active splits on the landing page
- Refactored split detail page for guest support and login redirects
- Extracted SplitCard component for reuse
- Consolidated RLS policies for bottles and tastings to resolve permission errors
- Added unified SQL consolidation script for RLS and naming fixes
- Enhanced service logging for better database error diagnostics
This commit is contained in:
2025-12-28 22:02:46 +01:00
parent 332bfdaf02
commit 9d6a8b358f
25 changed files with 2014 additions and 495 deletions

View File

@@ -27,6 +27,7 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
const [price, setPrice] = React.useState<string>('');
const [status, setStatus] = React.useState<string>('sealed');
const [isUpdating, setIsUpdating] = React.useState(false);
const [isEditMode, setIsEditMode] = React.useState(false);
const [isFormVisible, setIsFormVisible] = React.useState(false);
React.useEffect(() => {
@@ -122,164 +123,154 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
</div>
{/* Content Container */}
<div className="px-6 md:px-12 -mt-12 relative z-10 space-y-12">
<div className="px-4 md:px-12 -mt-12 relative z-10 space-y-8">
{/* Title Section - HIG Large Title Pattern */}
<div className="space-y-2">
<div className="space-y-1 text-center md:text-left">
{isOffline && (
<div className="inline-flex bg-orange-600/10 border border-orange-600/20 px-3 py-1 rounded-full items-center gap-2 mb-2">
<WifiOff size={12} className="text-orange-600" />
<p className="text-[9px] font-black uppercase tracking-widest text-orange-500">Offline</p>
<p className="text-[9px] font-black uppercase tracking-widest text-orange-500">Offline Mode</p>
</div>
)}
<h2 className="text-sm font-black text-orange-600 uppercase tracking-[0.2em]">
{bottle.distillery}
<h2 className="text-xs md:text-sm font-black text-orange-500 uppercase tracking-[0.25em] drop-shadow-sm">
{bottle.distillery || 'Unknown Distillery'}
</h2>
<h1 className="text-3xl md:text-5xl font-extrabold text-white tracking-tight leading-[1.1]">
<h1 className="text-4xl md:text-6xl font-extrabold text-white tracking-tight leading-[1.05] drop-shadow-md">
{bottle.name}
</h1>
{/* Metadata Items - Text based for better readability */}
<div className="flex flex-wrap items-center gap-3 pt-6">
<div className="px-4 py-2 bg-white/5 border border-white/10 rounded-xl">
<p className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest mb-0.5">Category</p>
<p className="text-sm font-black text-zinc-100 uppercase">{bottle.category || 'Whisky'}</p>
</div>
<div className="px-4 py-2 bg-white/5 border border-white/10 rounded-xl">
<p className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest mb-0.5">ABV</p>
<p className="text-sm font-black text-zinc-100 uppercase">{bottle.abv}%</p>
</div>
{bottle.age && (
<div className="px-4 py-2 bg-white/5 border border-white/10 rounded-xl">
<p className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest mb-0.5">Age</p>
<p className="text-sm font-black text-zinc-100 uppercase">{bottle.age} Years</p>
</div>
)}
{bottle.whiskybase_id && (
<a
href={`https://www.whiskybase.com/whiskies/whisky/${bottle.whiskybase_id}`}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 bg-orange-600 border border-orange-500 rounded-xl hover:bg-orange-500 transition-colors"
>
<p className="text-[10px] font-bold text-orange-200 uppercase tracking-widest mb-0.5">Whiskybase</p>
<p className="text-sm font-black text-white uppercase flex items-center gap-2">
#{bottle.whiskybase_id} <ExternalLink size={14} />
</p>
</a>
)}
</div>
</div>
{/* 4. Inventory Section (Cohesive Container) */}
<section className="bg-zinc-800/30 backdrop-blur-xl border border-white/5 rounded-[40px] p-8 space-y-8">
<div className="flex items-center justify-between mb-4">
<h3 className="text-xs font-black uppercase tracking-[0.2em] text-zinc-500">Collection Stats</h3>
<Package size={18} className="text-zinc-700" />
{/* Primary Bottle Profile Card */}
<section className="bg-zinc-900/40 backdrop-blur-2xl border border-white/5 rounded-[32px] overflow-hidden shadow-2xl">
{/* Integrated Header/Tabs */}
<div className="flex border-b border-white/5">
<button
onClick={() => setIsEditMode(false)}
className={`flex-1 py-4 text-[10px] font-black uppercase tracking-[0.2em] transition-all ${!isEditMode ? 'text-orange-500 bg-white/5' : 'text-zinc-500 hover:text-zinc-300'}`}
>
Overview
</button>
<button
onClick={() => setIsEditMode(true)}
className={`flex-1 py-4 text-[10px] font-black uppercase tracking-[0.2em] transition-all ${isEditMode ? 'text-orange-500 bg-white/5' : 'text-zinc-500 hover:text-zinc-300'}`}
>
Edit Details
</button>
</div>
<div className="space-y-6">
{/* Segmented Control for Status */}
<div className="space-y-2">
<label className="text-[10px] font-bold uppercase text-zinc-600 ml-1">Status</label>
<div className="grid grid-cols-3 bg-zinc-950 p-1 rounded-2xl border border-zinc-800/50">
{['sealed', 'open', 'empty'].map((s) => (
<button
key={s}
disabled={isOffline}
onClick={() => handleQuickUpdate(undefined, s)}
className={`py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all ${status === s
? 'bg-orange-600 text-white shadow-lg'
: 'text-zinc-600 hover:text-zinc-400'
}`}
>
{s === 'sealed' ? 'Sealed' : s === 'open' ? 'Open' : 'Empty'}
</button>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-[10px] font-bold uppercase text-zinc-600 ml-1">Price</label>
<div className="relative">
<input
type="number"
inputMode="decimal"
step="0.01"
value={price}
onChange={(e) => setPrice(e.target.value)}
onBlur={() => handleQuickUpdate(price)}
placeholder="0.00"
className="w-full bg-zinc-950 border border-zinc-800 rounded-2xl pl-4 pr-8 py-3 text-sm font-bold text-zinc-100 focus:outline-none focus:border-orange-600"
/>
<div className="absolute right-4 top-1/2 -translate-y-1/2 text-[10px] font-bold text-zinc-700"></div>
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] font-bold uppercase text-zinc-600 ml-1">Last Dram</label>
<div className="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-4 py-3 text-sm font-bold text-zinc-400 flex items-center gap-2">
<Calendar size={14} className="text-zinc-700" />
{tastings && tastings.length > 0
? new Date(tastings[0].created_at).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US', { day: '2-digit', month: '2-digit' })
: '-'}
</div>
</div>
</div>
</div>
</section>
{/* 5. Editing Form (Accordion) */}
<section>
<button
onClick={() => setIsFormVisible(!isFormVisible)}
className="w-full px-6 py-4 bg-zinc-900/50 border border-zinc-800/80 rounded-2xl flex items-center justify-between text-zinc-400 hover:text-orange-500 transition-all group"
>
<div className="flex items-center gap-3">
<Circle size={14} className={isFormVisible ? 'text-orange-600 fill-orange-600' : 'text-zinc-700'} />
<span className="text-xs font-black uppercase tracking-widest">Details korrigieren</span>
</div>
<ChevronDown size={18} className={`transition-transform duration-300 ${isFormVisible ? 'rotate-180 text-orange-600' : ''}`} />
</button>
<AnimatePresence>
{isFormVisible && (
<AnimatePresence mode="wait">
{!isEditMode ? (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3, ease: 'circOut' }}
className="overflow-hidden"
key="overview"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
className="p-6 md:p-8 space-y-8"
>
<div className="pt-4 px-2">
<EditBottleForm
bottle={bottle as any}
onComplete={() => setIsFormVisible(false)}
/>
{/* Fact Grid - Integrated Metadata & Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<FactCard label="Category" value={bottle.category || 'Whisky'} icon={<Wine size={14} />} />
<FactCard label="ABV" value={bottle.abv ? `${bottle.abv}%` : '%'} icon={<Droplets size={14} />} highlight={!bottle.abv} />
<FactCard label="Age" value={bottle.age ? `${bottle.age}Y` : '-'} icon={<Award size={14} />} />
<FactCard label="Price" value={bottle.purchase_price ? `${bottle.purchase_price}` : '-'} icon={<CircleDollarSign size={14} />} />
</div>
{/* Status & Last Dram Row */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Status Switcher */}
<div className="space-y-3">
<div className="flex items-center gap-2 px-1">
<Package size={14} className="text-orange-500" />
<span className="text-[10px] font-black uppercase tracking-widest text-zinc-500">Bottle Status</span>
</div>
<div className="grid grid-cols-3 bg-black/40 p-1 rounded-2xl border border-white/5">
{['sealed', 'open', 'empty'].map((s) => (
<button
key={s}
disabled={isOffline || isUpdating}
onClick={() => handleQuickUpdate(undefined, s)}
className={`py-3 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all ${status === s
? 'bg-orange-600 text-white shadow-lg'
: 'text-zinc-600 hover:text-zinc-400'
}`}
>
{s}
</button>
))}
</div>
</div>
{/* Last Dram Info */}
<div className="space-y-3">
<div className="flex items-center gap-2 px-1">
<Calendar size={14} className="text-orange-500" />
<span className="text-[10px] font-black uppercase tracking-widest text-zinc-500">Last Enjoyed</span>
</div>
<div className="bg-black/40 rounded-2xl px-6 py-3.5 border border-white/5 flex items-center justify-between">
<span className="text-sm font-black text-zinc-200 uppercase">
{tastings?.length ? new Date(tastings[0].created_at).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US', { day: '2-digit', month: '2-digit', year: 'numeric' }) : 'No dram yet'}
</span>
{tastings?.length > 0 && <CheckCircle2 size={16} className="text-orange-600" />}
</div>
</div>
</div>
{/* Whiskybase Link - Premium Style */}
{bottle.whiskybase_id && (
<div className="pt-2">
<a
href={`https://www.whiskybase.com/whiskies/whisky/${bottle.whiskybase_id}`}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center justify-between p-4 bg-orange-600/10 border border-orange-600/20 rounded-2xl hover:bg-orange-600/20 transition-all"
>
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-orange-600 flex items-center justify-center text-white shadow-lg shadow-orange-950/40">
<ExternalLink size={20} />
</div>
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-orange-400">View on Whiskybase</p>
<p className="text-sm font-black text-zinc-100">#{bottle.whiskybase_id}</p>
</div>
</div>
<ChevronLeft size={20} className="rotate-180 text-orange-500 group-hover:translate-x-1 transition-transform" />
</a>
</div>
)}
</motion.div>
) : (
<motion.div
key="edit"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="p-6 md:p-8"
>
<EditBottleForm
bottle={bottle as any}
onComplete={() => setIsEditMode(false)}
/>
</motion.div>
)}
</AnimatePresence>
{!isOffline && (
<div className="flex gap-2 pt-6">
<Link
href={`/splits/create?bottle=${bottle.id}`}
className="flex-1 py-4 bg-zinc-900 hover:bg-zinc-800 text-white rounded-2xl text-[10px] font-black uppercase tracking-[0.2em] flex items-center justify-center gap-2 border border-zinc-800 transition-all"
>
<Share2 size={14} className="text-orange-500" />
Split starten
</Link>
<div className="flex-none">
<DeleteBottleButton bottleId={bottle.id} />
</div>
</div>
)}
</section>
<hr className="border-zinc-800" />
{/* Secondary Actions */}
{!isOffline && (
<div className="flex flex-col sm:flex-row gap-3 pt-4">
<Link
href={`/splits/create?bottle=${bottle.id}`}
className="flex-1 py-5 bg-zinc-900 border border-white/5 hover:border-orange-500/30 text-white rounded-2xl text-[11px] font-black uppercase tracking-[0.25em] flex items-center justify-center gap-3 transition-all active:scale-95 group shadow-xl"
>
<Share2 size={16} className="text-orange-500 group-hover:scale-110 transition-transform" />
Launch Split
</Link>
<DeleteBottleButton bottleId={bottle.id} />
</div>
)}
<hr className="border-white/5" />
{/* Tasting Notes Section */}
<section className="space-y-8">
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-4">
<div>
@@ -335,3 +326,25 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
</div>
);
}
// Premium Fact Card Sub-component
interface FactCardProps {
label: string;
value: string;
icon: React.ReactNode;
highlight?: boolean;
}
function FactCard({ label, value, icon, highlight }: FactCardProps) {
return (
<div className={`p-4 rounded-2xl border transition-all ${highlight ? 'bg-orange-600/10 border-orange-500/30 animate-pulse' : 'bg-black/20 border-white/5 hover:border-white/10'}`}>
<div className="flex items-center gap-2 mb-1.5">
<div className="text-orange-500">{icon}</div>
<span className="text-[9px] font-black uppercase tracking-widest text-zinc-500">{label}</span>
</div>
<p className={`text-sm font-black uppercase tracking-tight ${highlight ? 'text-orange-400' : 'text-zinc-100'}`}>
{value}
</p>
</div>
);
}

View File

@@ -36,10 +36,10 @@ function BottleCard({ bottle, sessionId }: BottleCardProps) {
return (
<Link
href={`/bottles/${bottle.id}${sessionId ? `?session_id=${sessionId}` : ''}`}
className="block h-fit group relative overflow-hidden rounded-2xl bg-zinc-800/20 backdrop-blur-sm border border-white/[0.05] transition-all duration-500 hover:border-orange-500/30 hover:shadow-2xl hover:shadow-orange-950/20 active:scale-[0.98]"
className="block h-full group relative overflow-hidden rounded-2xl bg-zinc-800/20 backdrop-blur-sm border border-white/[0.05] transition-all duration-500 hover:border-orange-500/30 hover:shadow-2xl hover:shadow-orange-950/20 active:scale-[0.98] flex flex-col"
>
{/* Image Layer - Clean Split Top */}
<div className="aspect-[4/3] overflow-hidden">
<div className="aspect-[4/3] overflow-hidden shrink-0">
<img
src={getStorageUrl(bottle.image_url)}
alt={bottle.name}
@@ -48,37 +48,39 @@ function BottleCard({ bottle, sessionId }: BottleCardProps) {
</div>
{/* Info Layer - Clean Split Bottom */}
<div className="p-4 space-y-4">
<div className="p-4 flex-1 flex flex-col justify-between space-y-4">
<div className="space-y-1">
<p className="text-[10px] font-black text-orange-600 uppercase tracking-[0.2em] leading-none mb-1">
{bottle.distillery}
</p>
<h3 className="font-bold text-xl text-zinc-50 leading-tight tracking-tight">
<h3 className="font-bold text-lg text-zinc-50 leading-tight tracking-tight">
{bottle.name || t('grid.unknownBottle')}
</h3>
</div>
<div className="flex flex-wrap gap-2">
<span className="px-2 py-0.5 bg-zinc-800 text-zinc-400 text-[9px] font-bold uppercase tracking-widest rounded-md">
{shortenCategory(bottle.category)}
</span>
<span className="px-2 py-0.5 bg-zinc-800 text-zinc-400 text-[9px] font-bold uppercase tracking-widest rounded-md">
{bottle.abv}% VOL
</span>
</div>
{/* Metadata items */}
<div className="flex items-center gap-4 pt-3 border-t border-zinc-800/50">
<div className="flex items-center gap-1 text-[10px] font-medium text-zinc-500">
<Calendar size={12} className="text-zinc-500" />
{new Date(bottle.created_at).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')}
<div className="space-y-4 pt-2">
<div className="flex flex-wrap gap-2">
<span className="px-2 py-1 bg-zinc-800 text-zinc-400 text-[9px] font-bold uppercase tracking-widest rounded-md">
{shortenCategory(bottle.category)}
</span>
<span className="px-2 py-1 bg-zinc-800 text-zinc-400 text-[9px] font-bold uppercase tracking-widest rounded-md">
{bottle.abv}% VOL
</span>
</div>
{bottle.last_tasted && (
{/* Metadata items */}
<div className="flex items-center gap-4 pt-3 border-t border-zinc-800/50 mt-auto">
<div className="flex items-center gap-1 text-[10px] font-medium text-zinc-500">
<Clock size={12} className="text-zinc-500" />
{new Date(bottle.last_tasted).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')}
<Calendar size={12} className="text-zinc-500" />
{new Date(bottle.created_at).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')}
</div>
)}
{bottle.last_tasted && (
<div className="flex items-center gap-1 text-[10px] font-medium text-zinc-500">
<Clock size={12} className="text-zinc-500" />
{new Date(bottle.last_tasted).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')}
</div>
)}
</div>
</div>
</div>

View File

@@ -1,13 +1,16 @@
'use client';
import React from 'react';
import { Home, Grid, Scan, User, Search } from 'lucide-react';
import { Home, Library, Camera, UserRound, GlassWater } from 'lucide-react';
import { motion } from 'framer-motion';
import { usePathname } from 'next/navigation';
import { useI18n } from '@/i18n/I18nContext';
interface BottomNavigationProps {
onHome?: () => void;
onShelf?: () => void;
onSearch?: () => void;
onTastings?: () => void;
onProfile?: () => void;
onScan: (file: File) => void;
}
@@ -17,20 +20,25 @@ interface NavButtonProps {
icon: React.ReactNode;
label: string;
ariaLabel: string;
active?: boolean;
}
const NavButton = ({ onClick, icon, label, ariaLabel }: NavButtonProps) => (
const NavButton = ({ onClick, icon, label, ariaLabel, active }: NavButtonProps) => (
<button
onClick={onClick}
className="flex flex-col items-center justify-center gap-1 w-full min-w-[44px] min-h-[44px] text-zinc-400 hover:text-white transition-colors active:scale-90"
className={`flex flex-col items-center justify-center gap-1 w-full min-w-[44px] min-h-[44px] transition-all active:scale-95 ${active ? 'text-orange-500' : 'text-zinc-400 hover:text-zinc-200'}`}
aria-label={ariaLabel}
>
{icon}
<span className="text-[10px] font-bold tracking-tight">{label}</span>
<div className={`transition-transform duration-300 ${active ? 'scale-110' : ''}`}>
{icon}
</div>
<span className={`text-[9px] font-black tracking-tight uppercase ${active ? 'opacity-100' : 'opacity-60'}`}>{label}</span>
</button>
);
export const BottomNavigation = ({ onHome, onShelf, onSearch, onProfile, onScan }: BottomNavigationProps) => {
export function BottomNavigation({ onHome, onShelf, onTastings, onProfile, onScan }: BottomNavigationProps) {
const { t } = useI18n();
const pathname = usePathname();
const fileInputRef = React.useRef<HTMLInputElement>(null);
const handleScanClick = () => {
@@ -44,8 +52,13 @@ export const BottomNavigation = ({ onHome, onShelf, onSearch, onProfile, onScan
}
};
// Determine active tab based on path
const isHome = pathname === '/';
const isShelf = pathname?.includes('/shelf');
const isProfile = pathname?.includes('/settings') || pathname?.includes('/profile');
return (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 w-[95%] max-w-md z-50 pointer-events-none">
<div className="fixed bottom-0 left-0 right-0 p-6 pb-10 z-50 pointer-events-none">
{/* Hidden Input for Scanning */}
<input
type="file"
@@ -55,47 +68,56 @@ export const BottomNavigation = ({ onHome, onShelf, onSearch, onProfile, onScan
className="hidden"
/>
<div className="flex items-center justify-between px-2 py-1 bg-[#1c1c1e]/80 backdrop-blur-2xl border border-white/10 rounded-full shadow-2xl pointer-events-auto ring-1 ring-black/20">
<div className="max-w-md mx-auto bg-[#09090b]/90 backdrop-blur-xl border border-white/10 rounded-[40px] p-2 flex items-center shadow-2xl pointer-events-auto">
{/* Left Items */}
<NavButton
onClick={onHome}
icon={<Home size={20} strokeWidth={2.5} />}
label="Start"
ariaLabel="Home"
/>
<div className="flex-1 flex justify-around">
<NavButton
onClick={onHome}
icon={<Home size={18} strokeWidth={2.5} />}
label={t('nav.home')}
active={isHome}
ariaLabel={t('nav.home')}
/>
<NavButton
onClick={onShelf}
icon={<Grid size={20} strokeWidth={2.5} />}
label="Sammlung"
ariaLabel="Sammlung"
/>
<NavButton
onClick={onShelf}
icon={<Library size={18} strokeWidth={2.5} />}
label={t('nav.shelf')}
active={isShelf}
ariaLabel={t('nav.shelf')}
/>
</div>
{/* PRIMARY ACTION - Scan Button */}
<button
onClick={handleScanClick}
className="flex flex-col items-center justify-center w-16 h-16 -mt-4 rounded-full bg-orange-600 text-white hover:bg-orange-500 active:scale-95 transition-all shadow-lg shadow-orange-950/50 border-4 border-zinc-950"
aria-label="Flasche scannen"
>
<Scan size={24} strokeWidth={2.5} />
<span className="text-[8px] font-bold tracking-wide mt-0.5">SCAN</span>
</button>
{/* Center FAB */}
<div className="px-2">
<button
onClick={handleScanClick}
className="w-16 h-16 bg-orange-600 rounded-[30px] flex items-center justify-center text-white shadow-lg shadow-orange-950/40 border border-white/20 active:scale-90 transition-all hover:bg-orange-500 hover:rotate-2 group relative"
aria-label={t('camera.scanBottle')}
>
<div className="absolute inset-0 bg-white/20 rounded-[30px] opacity-0 group-hover:opacity-100 transition-opacity" />
<Camera size={28} strokeWidth={2.5} />
</button>
</div>
{/* Right Items */}
<NavButton
onClick={onSearch}
icon={<Search size={20} strokeWidth={2.5} />}
label="Filter"
ariaLabel="Filter"
/>
<div className="flex-1 flex justify-around">
<NavButton
onClick={onTastings}
icon={<GlassWater size={18} strokeWidth={2.5} />}
label={t('nav.activity')}
ariaLabel={t('nav.activity')}
/>
<NavButton
onClick={onProfile}
icon={<User size={20} strokeWidth={2.5} />}
label="Profil"
ariaLabel="Profil"
/>
<NavButton
onClick={onProfile}
icon={<UserRound size={18} strokeWidth={2.5} />}
label={t('nav.profile')}
active={isProfile}
ariaLabel={t('nav.profile')}
/>
</div>
</div>
</div>
);
};
}

View File

@@ -103,161 +103,163 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
};
return (
<div className="space-y-6 animate-in fade-in slide-in-from-top-4 duration-300">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-6 animate-in fade-in slide-in-from-right-4 duration-500">
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Full Width Inputs */}
<div className="space-y-1.5 md:col-span-2">
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1">{t('bottle.nameLabel')}</label>
<div className="space-y-2 md:col-span-2">
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.nameLabel')}</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200 text-sm font-bold"
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
/>
</div>
<div className="space-y-1.5 md:col-span-2">
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1">{t('bottle.distilleryLabel')}</label>
<div className="space-y-2 md:col-span-2">
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.distilleryLabel')}</label>
<input
type="text"
value={formData.distillery}
onChange={(e) => setFormData({ ...formData, distillery: e.target.value })}
className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200 text-sm font-bold"
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
/>
</div>
{/* Compact Row: Category */}
<div className="space-y-1.5">
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1">{t('bottle.categoryLabel')}</label>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.categoryLabel')}</label>
<input
type="text"
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200 text-sm font-bold"
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
/>
</div>
{/* Row A: ABV + Age */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1">{t('bottle.abvLabel')}</label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.abvLabel')}</label>
<input
type="number"
inputMode="decimal"
step="0.1"
value={formData.abv}
onChange={(e) => setFormData({ ...formData, abv: parseFloat(e.target.value) })}
className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200 text-sm font-bold"
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-orange-500 text-sm font-bold transition-all"
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1">{t('bottle.ageLabel')}</label>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.ageLabel')}</label>
<input
type="number"
inputMode="numeric"
value={formData.age}
onChange={(e) => setFormData({ ...formData, age: parseInt(e.target.value) })}
className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200 text-sm font-bold"
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all"
/>
</div>
</div>
{/* Row B: Distilled + Bottled */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1">{t('bottle.distilledLabel')}</label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.distilledLabel')}</label>
<input
type="text"
inputMode="numeric"
placeholder="YYYY"
value={formData.distilled_at}
onChange={(e) => setFormData({ ...formData, distilled_at: e.target.value })}
className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200 text-sm font-bold"
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1">{t('bottle.bottledLabel')}</label>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.bottledLabel')}</label>
<input
type="text"
inputMode="numeric"
placeholder="YYYY"
value={formData.bottled_at}
onChange={(e) => setFormData({ ...formData, bottled_at: e.target.value })}
className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200 text-sm font-bold"
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
/>
</div>
</div>
{/* Price and WB ID Row */}
<div className="space-y-1.5">
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1">{t('bottle.priceLabel')} ()</label>
<input
type="number"
inputMode="decimal"
step="0.01"
placeholder="0.00"
value={formData.purchase_price}
onChange={(e) => setFormData({ ...formData, purchase_price: e.target.value })}
className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 font-bold text-orange-500 text-sm"
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 flex justify-between items-center">
<span>Whiskybase ID</span>
<button
type="button"
onClick={handleDiscover}
disabled={isSearching}
className="text-orange-600 hover:text-orange-700 flex items-center gap-1 normal-case font-bold text-[10px]"
>
{isSearching ? <Loader2 size={10} className="animate-spin" /> : <Search size={10} />}
{t('bottle.autoSearch')}
</button>
</label>
<div className="relative">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:col-span-2">
<div className="space-y-2">
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.priceLabel')} ()</label>
<input
type="text"
inputMode="numeric"
value={formData.whiskybase_id}
onChange={(e) => setFormData({ ...formData, whiskybase_id: e.target.value })}
className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200 text-sm font-mono"
type="number"
inputMode="decimal"
step="0.01"
placeholder="0.00"
value={formData.purchase_price}
onChange={(e) => setFormData({ ...formData, purchase_price: e.target.value })}
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 font-bold text-orange-500 text-sm transition-all"
/>
{discoveryResult && (
<div className="absolute top-full left-0 right-0 z-50 mt-2 p-3 bg-zinc-900 border border-orange-500/30 rounded-2xl shadow-2xl animate-in zoom-in-95">
<p className="text-[10px] text-zinc-500 mb-1">Empfehlung:</p>
<p className="text-[11px] font-bold text-zinc-200 mb-2 truncate">{discoveryResult.title}</p>
<div className="flex gap-2">
<button
type="button"
onClick={applyDiscovery}
className="flex-1 py-2 bg-orange-600 text-white text-[10px] font-black uppercase rounded-xl"
>
ID übernehmen
</button>
<a
href={discoveryResult.url}
target="_blank"
rel="noopener noreferrer"
className="px-3 py-2 bg-zinc-800 text-zinc-400 rounded-xl flex items-center justify-center border border-zinc-700"
>
<ExternalLink size={12} />
</a>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 flex justify-between items-center tracking-widest">
<span>Whiskybase ID</span>
<button
type="button"
onClick={handleDiscover}
disabled={isSearching}
className="text-orange-600 hover:text-orange-500 flex items-center gap-1.5 normal-case font-black text-[9px] tracking-widest transition-colors"
>
{isSearching ? <Loader2 size={12} className="animate-spin" /> : <Search size={12} />}
{t('bottle.autoSearch')}
</button>
</label>
<div className="relative">
<input
type="text"
inputMode="numeric"
value={formData.whiskybase_id}
onChange={(e) => setFormData({ ...formData, whiskybase_id: e.target.value })}
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-300 text-sm font-mono transition-all"
/>
{discoveryResult && (
<div className="absolute top-full left-0 right-0 z-50 mt-3 p-4 bg-zinc-900 border border-orange-500/30 rounded-2xl shadow-2xl animate-in zoom-in-95 duration-300">
<p className="text-[9px] font-black uppercase tracking-widest text-zinc-500 mb-2">Recommendation:</p>
<p className="text-[12px] font-black text-zinc-100 mb-4 line-clamp-2 leading-tight">{discoveryResult.title}</p>
<div className="flex gap-2">
<button
type="button"
onClick={applyDiscovery}
className="flex-1 py-3 bg-orange-600 hover:bg-orange-500 text-white text-[10px] font-black uppercase tracking-widest rounded-xl transition-colors shadow-lg shadow-orange-950/20"
>
Accept ID
</button>
<a
href={discoveryResult.url}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-3 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded-xl flex items-center justify-center border border-white/5 transition-colors"
>
<ExternalLink size={14} />
</a>
</div>
</div>
</div>
)}
)}
</div>
</div>
</div>
{/* Batch Info */}
<div className="space-y-1.5 md:col-span-2">
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1">{t('bottle.batchLabel')}</label>
<div className="space-y-2 md:col-span-2">
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.batchLabel')}</label>
<input
type="text"
placeholder="z.B. Batch 12 oder L-Code"
placeholder="e.g. Batch 12 or L-Code"
value={formData.batch_info}
onChange={(e) => setFormData({ ...formData, batch_info: e.target.value })}
className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200 text-sm font-bold"
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
/>
</div>
</div>

View File

@@ -1,9 +1,10 @@
'use client';
import { useState, useEffect } from 'react';
import React, { useState, useEffect } from 'react';
import { usePathname } from 'next/navigation';
import { motion, AnimatePresence } from 'framer-motion';
import { Scan, GlassWater, Users, Settings, ArrowRight, X, Sparkles } from 'lucide-react';
import { useI18n } from '@/i18n/I18nContext';
const ONBOARDING_KEY = 'dramlog_onboarding_complete';
@@ -14,40 +15,42 @@ interface OnboardingStep {
description: string;
}
const STEPS: OnboardingStep[] = [
const getSteps = (t: (path: string) => string): OnboardingStep[] => [
{
id: 'welcome',
icon: <Sparkles size={32} className="text-orange-500" />,
title: 'Willkommen bei DramLog!',
description: 'Dein persönliches Whisky-Tagebuch. Scanne, bewerte und entdecke neue Drams.',
title: t('tutorial.steps.welcome.title'),
description: t('tutorial.steps.welcome.desc'),
},
{
id: 'scan',
icon: <Scan size={32} className="text-orange-500" />,
title: 'Scanne deine Flaschen',
description: 'Fotografiere das Etikett einer Flasche die KI erkennt automatisch alle Details.',
title: t('tutorial.steps.scan.title'),
description: t('tutorial.steps.scan.desc'),
},
{
id: 'taste',
icon: <GlassWater size={32} className="text-orange-500" />,
title: 'Bewerte deine Drams',
description: 'Füge Tasting-Notizen hinzu und behalte den Überblick über deine Lieblings-Whiskys.',
title: t('tutorial.steps.taste.title'),
description: t('tutorial.steps.taste.desc'),
},
{
id: 'session',
id: 'activity',
icon: <Users size={32} className="text-orange-500" />,
title: 'Tasting-Sessions',
description: 'Organisiere Verkostungen mit Freunden und vergleicht eure Bewertungen.',
title: t('tutorial.steps.activity.title'),
description: t('tutorial.steps.activity.desc'),
},
{
id: 'ready',
icon: <Settings size={32} className="text-orange-500" />,
title: 'Bereit zum Start!',
description: 'Scanne jetzt deine erste Flasche mit dem orangefarbenen Button unten.',
title: t('tutorial.steps.ready.title'),
description: t('tutorial.steps.ready.desc'),
},
];
export default function OnboardingTutorial() {
const { t } = useI18n();
const STEPS = getSteps(t);
const [isOpen, setIsOpen] = useState(false);
const [currentStep, setCurrentStep] = useState(0);
const pathname = usePathname();
@@ -148,14 +151,14 @@ export default function OnboardingTutorial() {
onClick={handleSkip}
className="flex-1 py-3 px-4 text-sm font-bold text-zinc-500 hover:text-white transition-colors"
>
Überspringen
{t('tutorial.skip')}
</button>
)}
<button
onClick={handleNext}
className="flex-1 py-3 px-4 bg-orange-600 hover:bg-orange-500 text-white font-bold text-sm rounded-xl flex items-center justify-center gap-2 transition-colors"
>
{isLastStep ? 'Los geht\'s!' : 'Weiter'}
{isLastStep ? t('tutorial.finish') : t('tutorial.next')}
<ArrowRight size={16} />
</button>
</div>

View File

@@ -5,7 +5,10 @@ import { motion } from 'framer-motion';
import { Lock, Eye, EyeOff, Loader2, CheckCircle, AlertCircle } from 'lucide-react';
import { changePassword } from '@/services/profile-actions';
import { useI18n } from '@/i18n/I18nContext';
export default function PasswordChangeForm() {
const { t } = useI18n();
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
@@ -20,13 +23,13 @@ export default function PasswordChangeForm() {
if (newPassword !== confirmPassword) {
setStatus('error');
setError('Passwörter stimmen nicht überein');
setError(t('settings.password.mismatch'));
return;
}
if (newPassword.length < 6) {
setStatus('error');
setError('Passwort muss mindestens 6 Zeichen lang sein');
setError(t('settings.password.tooShort'));
return;
}
@@ -43,7 +46,7 @@ export default function PasswordChangeForm() {
setTimeout(() => setStatus('idle'), 3000);
} else {
setStatus('error');
setError(result.error || 'Fehler beim Ändern');
setError(result.error || t('common.error'));
}
});
};
@@ -58,14 +61,14 @@ export default function PasswordChangeForm() {
>
<h2 className="text-lg font-bold text-white mb-6 flex items-center gap-2">
<Lock size={20} className="text-orange-500" />
Passwort ändern
{t('settings.password.title')}
</h2>
<div className="space-y-4">
{/* New Password */}
<div>
<label className="block text-sm font-medium text-zinc-400 mb-2">
Neues Passwort
{t('settings.password.newPassword')}
</label>
<div className="relative">
<input
@@ -88,7 +91,7 @@ export default function PasswordChangeForm() {
{/* Confirm Password */}
<div>
<label className="block text-sm font-medium text-zinc-400 mb-2">
Passwort bestätigen
{t('settings.password.confirmPassword')}
</label>
<input
type={showPassword ? 'text' : 'password'}
@@ -107,12 +110,12 @@ export default function PasswordChangeForm() {
{newPassword === confirmPassword ? (
<>
<CheckCircle size={12} />
Passwörter stimmen überein
{t('settings.password.match')}
</>
) : (
<>
<AlertCircle size={12} />
Passwörter stimmen nicht überein
{t('settings.password.mismatch')}
</>
)}
</div>
@@ -122,7 +125,7 @@ export default function PasswordChangeForm() {
{status === 'success' && (
<div className="mt-4 p-3 bg-green-500/10 border border-green-500/20 rounded-xl flex items-center gap-2 text-green-500 text-sm">
<CheckCircle size={16} />
Passwort erfolgreich geändert!
{t('settings.password.success')}
</div>
)}
{status === 'error' && (
@@ -141,12 +144,12 @@ export default function PasswordChangeForm() {
{isPending ? (
<>
<Loader2 size={18} className="animate-spin" />
Ändern...
{t('common.loading')}
</>
) : (
<>
<Lock size={18} />
Passwort ändern
{t('settings.password.change')}
</>
)}
</button>

View File

@@ -5,6 +5,8 @@ import { motion } from 'framer-motion';
import { User, Mail, Save, Loader2, CheckCircle, AlertCircle } from 'lucide-react';
import { updateProfile } from '@/services/profile-actions';
import { useI18n } from '@/i18n/I18nContext';
interface ProfileFormProps {
initialData: {
email?: string;
@@ -13,6 +15,7 @@ interface ProfileFormProps {
}
export default function ProfileForm({ initialData }: ProfileFormProps) {
const { t } = useI18n();
const [username, setUsername] = useState(initialData.username || '');
const [isPending, startTransition] = useTransition();
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle');
@@ -33,7 +36,7 @@ export default function ProfileForm({ initialData }: ProfileFormProps) {
setTimeout(() => setStatus('idle'), 3000);
} else {
setStatus('error');
setError(result.error || 'Fehler beim Speichern');
setError(result.error || t('common.error'));
}
});
};
@@ -47,7 +50,7 @@ export default function ProfileForm({ initialData }: ProfileFormProps) {
>
<h2 className="text-lg font-bold text-white mb-6 flex items-center gap-2">
<User size={20} className="text-orange-500" />
Profil
{t('nav.profile')}
</h2>
<div className="space-y-4">
@@ -63,19 +66,18 @@ export default function ProfileForm({ initialData }: ProfileFormProps) {
disabled
className="w-full px-4 py-3 bg-zinc-800/50 border border-zinc-700 rounded-xl text-zinc-500 cursor-not-allowed"
/>
<p className="mt-1 text-xs text-zinc-500">E-Mail kann nicht geändert werden</p>
</div>
{/* Username */}
<div>
<label className="block text-sm font-medium text-zinc-400 mb-2">
Benutzername
{t('bottle.nameLabel')}
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Dein Benutzername"
placeholder={t('bottle.nameLabel')}
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
/>
</div>
@@ -85,7 +87,7 @@ export default function ProfileForm({ initialData }: ProfileFormProps) {
{status === 'success' && (
<div className="mt-4 p-3 bg-green-500/10 border border-green-500/20 rounded-xl flex items-center gap-2 text-green-500 text-sm">
<CheckCircle size={16} />
Profil gespeichert!
{t('common.success')}
</div>
)}
{status === 'error' && (
@@ -104,12 +106,12 @@ export default function ProfileForm({ initialData }: ProfileFormProps) {
{isPending ? (
<>
<Loader2 size={18} className="animate-spin" />
Speichern...
{t('common.loading')}
</>
) : (
<>
<Save size={18} />
Speichern
{t('common.save')}
</>
)}
</button>

View File

@@ -0,0 +1,115 @@
'use client';
import React from 'react';
import { Settings, Cookie, Shield, ArrowLeft } from 'lucide-react';
import Link from 'next/link';
import { useI18n } from '@/i18n/I18nContext';
import LanguageSwitcher from './LanguageSwitcher';
import ProfileForm from './ProfileForm';
import PasswordChangeForm from './PasswordChangeForm';
interface SettingsHubProps {
profile: {
email?: string;
username?: string | null;
created_at?: string;
};
}
export default function SettingsHub({ profile }: SettingsHubProps) {
const { t, locale } = useI18n();
return (
<div className="min-h-screen bg-zinc-950">
{/* Header */}
<header className="sticky top-0 z-40 bg-zinc-950/80 backdrop-blur-lg border-b border-zinc-800">
<div className="max-w-2xl mx-auto px-4 py-4 flex items-center gap-4">
<Link
href="/"
className="p-2 -ml-2 text-zinc-400 hover:text-white transition-colors"
>
<ArrowLeft size={20} />
</Link>
<div className="flex items-center gap-2">
<Settings size={20} className="text-orange-500" />
<h1 className="text-lg font-bold text-white">{t('settings.title')}</h1>
</div>
</div>
</header>
{/* Content */}
<main className="max-w-2xl mx-auto px-4 py-6 space-y-6">
{/* Language Switcher */}
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6">
<h2 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
<Settings size={20} className="text-orange-500" />
{t('settings.language')}
</h2>
<LanguageSwitcher />
</div>
{/* Profile Form */}
<ProfileForm
initialData={{
email: profile.email,
username: profile.username,
}}
/>
{/* Password Change Form */}
<PasswordChangeForm />
{/* Cookie Settings */}
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6">
<h2 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
<Cookie size={20} className="text-orange-500" />
{t('settings.cookieSettings')}
</h2>
<p className="text-sm text-zinc-400 mb-4">
{t('settings.cookieDesc')}
</p>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2 text-zinc-300">
<Shield size={14} className="text-green-500" />
<span>{t('settings.cookieNecessary')}</span>
</div>
<div className="flex items-center gap-2 text-zinc-300">
<Shield size={14} className="text-blue-500" />
<span>{t('settings.cookieFunctional')}</span>
</div>
</div>
</div>
{/* Data & Privacy */}
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6">
<h2 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
<Shield size={20} className="text-orange-500" />
{t('settings.privacy')}
</h2>
<div className="space-y-4">
<p className="text-sm text-zinc-400">
{t('settings.privacyDesc')}
</p>
<Link
href="/privacy"
className="inline-block text-sm text-orange-500 hover:text-orange-400 underline"
>
{t('settings.privacyLink')}
</Link>
</div>
</div>
{/* Account info */}
<div className="bg-zinc-800/50 border border-zinc-700 rounded-2xl p-4 text-center">
<p className="text-xs text-zinc-500">
{t('settings.memberSince')}: {new Date(profile.created_at || '').toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US', {
day: '2-digit',
month: 'long',
year: 'numeric'
})}
</p>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,90 @@
'use client';
import React from 'react';
import { Package, Users, Info, Terminal, ChevronRight } from 'lucide-react';
interface Split {
id: string;
slug: string;
bottleName: string;
bottleImage?: string;
distillery?: string;
totalVolume: number;
hostShare: number;
participantCount?: number;
amountCl?: number; // for participating
status?: string; // for participating
isActive: boolean;
hostName?: string;
}
interface SplitCardProps {
split: Split;
isParticipant?: boolean;
onSelect?: () => void;
showChevron?: boolean;
}
export default function SplitCard({ split, isParticipant, onSelect, showChevron = true }: SplitCardProps) {
const statusLabels: Record<string, string> = {
PENDING: 'Waiting',
APPROVED: 'Confirmed',
PAID: 'Paid',
SHIPPED: 'Shipped',
REJECTED: 'Rejected',
WAITLIST: 'Waitlist'
};
return (
<div
className="p-5 rounded-[28px] border bg-zinc-900/30 border-white/5 hover:border-white/10 hover:bg-zinc-900/50 transition-all flex items-center justify-between group cursor-pointer"
onClick={onSelect}
>
<div className="flex-1 min-w-0 pr-4">
<div className="flex items-center gap-2 mb-1">
<h4 className="text-base font-black uppercase truncate tracking-tight text-zinc-200">
{split.bottleName}
</h4>
{!split.isActive && (
<span className="text-[7px] font-black uppercase px-1.5 py-0.5 rounded-full bg-zinc-800 border border-white/5 text-zinc-500">Closed</span>
)}
</div>
<div className="flex items-center gap-4 text-[9px] font-black uppercase tracking-[0.15em] text-zinc-600">
{isParticipant ? (
<>
<span className="flex items-center gap-1 text-orange-500/80">
<Package size={10} />
{split.amountCl}cl
</span>
<span className={`px-1.5 py-0.5 rounded bg-white/5 border border-white/5 ${split.status === 'SHIPPED' ? 'text-green-500' : 'text-zinc-400'}`}>
{statusLabels[split.status || ''] || split.status}
</span>
</>
) : (
<>
<span className="flex items-center gap-1">
<Users size={10} className="text-zinc-700" />
{split.participantCount ?? 0} Confirmed
</span>
<span className="flex items-center gap-1">
<Info size={10} className="text-zinc-700" />
{split.totalVolume}cl Total
</span>
</>
)}
</div>
{split.hostName && (
<div className="mt-2 flex items-center gap-1 text-[8px] font-black uppercase text-zinc-700">
<Terminal size={8} /> By {split.hostName}
</div>
)}
</div>
{showChevron && (
<div className="w-10 h-10 rounded-2xl border bg-black/40 border-white/5 text-zinc-700 group-hover:text-zinc-400 group-hover:border-white/10 transition-all flex items-center justify-center">
<ChevronRight size={20} />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,471 @@
'use client';
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
X, GlassWater, Plus, Users, Calendar,
ChevronRight, Loader2, Sparkles, Check,
ArrowRight, User, Terminal, Package, Info
} from 'lucide-react';
import { createClient } from '@/lib/supabase/client';
import { useI18n } from '@/i18n/I18nContext';
import { useSession } from '@/context/SessionContext';
import { getHostSplits, getParticipatingSplits } from '@/services/split-actions';
import AvatarStack from './AvatarStack';
import SplitCard from './SplitCard';
interface Session {
id: string;
name: string;
scheduled_at: string;
ended_at?: string;
host_name?: string;
participant_count?: number;
whisky_count?: number;
participants?: string[];
is_host: boolean;
}
interface Split {
id: string;
slug: string;
bottleName: string;
bottleImage?: string;
totalVolume: number;
hostShare: number;
participantCount: number;
amountCl?: number; // for participating
status?: string; // for participating
isActive: boolean;
hostName?: string;
}
interface TastingHubProps {
isOpen: boolean;
onClose: () => void;
}
export default function TastingHub({ isOpen, onClose }: TastingHubProps) {
const { t, locale } = useI18n();
const supabase = createClient();
const { activeSession, setActiveSession } = useSession();
const [activeTab, setActiveTab] = useState<'tastings' | 'splits'>('tastings');
const [mySessions, setMySessions] = useState<Session[]>([]);
const [guestSessions, setGuestSessions] = useState<Session[]>([]);
const [mySplits, setMySplits] = useState<Split[]>([]);
const [participatingSplits, setParticipatingSplits] = useState<Split[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isCreating, setIsCreating] = useState(false);
const [newName, setNewName] = useState('');
useEffect(() => {
if (isOpen) {
fetchAll();
}
}, [isOpen]);
const fetchAll = async () => {
setIsLoading(true);
await Promise.all([fetchSessions(), fetchSplits()]);
setIsLoading(false);
};
const fetchSessions = async () => {
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
// 1. Fetch My Sessions (Host)
const { data: hostData, error: hostError } = await supabase
.from('tasting_sessions')
.select(`
*,
session_participants (
buddies (name)
),
tastings (count)
`)
.eq('user_id', user.id)
.order('scheduled_at', { ascending: false });
// 2. Fetch Sessions I'm participating in (Guest)
const { data: participantData, error: partError } = await supabase
.from('tasting_sessions')
.select(`
*,
profiles (username),
tastings (count),
session_participants!inner (
buddy_id,
buddies!inner (
buddy_profile_id
)
)
`)
.eq('session_participants.buddies.buddy_profile_id', user.id)
.order('scheduled_at', { ascending: false });
if (hostData) {
setMySessions(hostData.map(s => ({
...s,
is_host: true,
participant_count: (s.session_participants as any[])?.length || 0,
participants: (s.session_participants as any[])?.filter(p => p.buddies).map(p => p.buddies.name) || [],
whisky_count: s.tastings[0]?.count || 0
})));
}
if (participantData) {
// Filter out host sessions (though RLS might already separate them, better safe)
setGuestSessions(participantData
.filter(s => s.user_id !== user.id)
.map(s => ({
...s,
is_host: false,
host_name: s.profiles?.username || 'Host',
participant_count: 0,
whisky_count: s.tastings[0]?.count || 0
})));
}
};
const fetchSplits = async () => {
const hostRes = await getHostSplits();
if (hostRes.success && hostRes.splits) {
setMySplits(hostRes.splits as Split[]);
}
const partRes = await getParticipatingSplits();
if (partRes.success && partRes.splits) {
setParticipatingSplits(partRes.splits as Split[]);
}
};
const handleCreateSession = async (e: React.FormEvent) => {
e.preventDefault();
if (!newName.trim()) return;
setIsCreating(true);
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
const { data, error } = await supabase
.from('tasting_sessions')
.insert([{ name: newName.trim(), user_id: user.id }])
.select()
.single();
if (error) {
console.error('Error creating session:', error);
} else {
fetchSessions();
setNewName('');
setActiveSession({ id: data.id, name: data.name });
}
setIsCreating(false);
};
return (
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-[60]"
/>
{/* Content */}
<motion.div
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className="fixed bottom-0 left-0 right-0 h-[85vh] bg-[#09090b] border-t border-white/10 rounded-t-[40px] z-[70] flex flex-col shadow-2xl overflow-hidden"
>
{/* Header */}
<div className="p-8 pb-4 flex items-center justify-between shrink-0">
<div>
<div className="flex items-center gap-3 mb-1">
<div className="w-10 h-10 rounded-2xl bg-orange-600/10 flex items-center justify-center text-orange-500">
<GlassWater size={24} />
</div>
<h2 className="text-3xl font-black text-white uppercase tracking-tight">{t('hub.title')}</h2>
</div>
<p className="text-zinc-500 text-xs font-bold uppercase tracking-[0.2em] ml-1">{t('hub.subtitle')}</p>
</div>
<button
onClick={onClose}
className="w-12 h-12 rounded-2xl bg-zinc-900 border border-white/5 flex items-center justify-center text-zinc-400 hover:text-white transition-all active:scale-95"
>
<X size={24} />
</button>
</div>
{/* Tabs */}
<div className="px-8 shrink-0">
<div className="bg-zinc-900/50 p-1.5 rounded-2xl flex gap-1 border border-white/5">
<button
onClick={() => setActiveTab('tastings')}
className={`flex-1 py-3 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all ${activeTab === 'tastings' ? 'bg-orange-600 text-white shadow-lg' : 'text-zinc-500 hover:text-zinc-300'}`}
>
{t('hub.tabs.tastings')}
</button>
<button
onClick={() => setActiveTab('splits')}
className={`flex-1 py-3 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all ${activeTab === 'splits' ? 'bg-orange-600 text-white shadow-lg' : 'text-zinc-500 hover:text-zinc-300'}`}
>
{t('hub.tabs.splits')}
</button>
</div>
</div>
{/* Scrolling Content */}
<div className="flex-1 overflow-y-auto px-8 pb-24 pt-8 space-y-12">
{activeTab === 'tastings' ? (
<>
{/* Create Section */}
<section className="space-y-4">
<h3 className="text-[10px] font-black uppercase tracking-[0.3em] text-zinc-500 flex items-center gap-2">
<Plus size={14} className="text-orange-600" /> {t('hub.sections.startSession')}
</h3>
<form onSubmit={handleCreateSession} className="flex gap-2">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t('hub.placeholders.sessionName')}
className="flex-1 bg-black/40 border border-white/5 rounded-2xl px-6 py-4 text-sm font-bold text-white placeholder:text-zinc-700 focus:outline-none focus:border-orange-600 transition-all ring-inset focus:ring-1 focus:ring-orange-600/50"
/>
<button
type="submit"
disabled={isCreating || !newName.trim()}
className="bg-orange-600 hover:bg-orange-500 text-white px-8 rounded-2xl font-black uppercase tracking-widest text-xs transition-all shadow-lg shadow-orange-950/20 disabled:opacity-50 active:scale-95"
>
{isCreating ? <Loader2 size={20} className="animate-spin" /> : 'Go'}
</button>
</form>
</section>
{/* Active Session Highlight */}
{activeSession && (
<section className="space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-500">
<h3 className="text-[10px] font-black uppercase tracking-[0.3em] text-orange-500 flex items-center gap-2">
<Sparkles size={14} /> {t('hub.sections.activeNow')}
</h3>
<div className="bg-orange-600 rounded-[32px] p-8 shadow-2xl shadow-orange-950/40 border border-white/10 relative overflow-hidden group">
<div className="absolute top-0 right-0 p-6 opacity-10 group-hover:opacity-20 transition-opacity">
<GlassWater size={120} />
</div>
<div className="relative z-10 flex justify-between items-end">
<div className="space-y-2">
<p className="text-orange-200 text-xs font-black uppercase tracking-widest">{t('session.activeSession')}</p>
<h4 className="text-2xl font-black text-white uppercase leading-none">{activeSession.name}</h4>
</div>
<button
onClick={() => {
onClose();
window.location.href = `/sessions/${activeSession.id}`;
}}
className="px-6 py-3 bg-white text-orange-600 rounded-2xl font-black uppercase tracking-widest text-[10px] shadow-xl hover:scale-105 transition-transform active:scale-95 flex items-center gap-2"
>
{t('grid.close')} <ArrowRight size={14} />
</button>
</div>
</div>
</section>
)}
{/* My Sessions List */}
<section className="space-y-4">
<h3 className="text-[10px] font-black uppercase tracking-[0.3em] text-zinc-500 flex items-center justify-between">
<span className="flex items-center gap-2"><User size={14} className="text-orange-600" /> {t('hub.sections.yourSessions')}</span>
<span className="text-zinc-700">{mySessions.length}</span>
</h3>
{isLoading ? (
<div className="flex justify-center py-12">
<Loader2 size={32} className="animate-spin text-zinc-800" />
</div>
) : mySessions.length === 0 ? (
<div className="bg-zinc-900/30 border border-dashed border-white/5 rounded-[32px] p-12 text-center">
<Calendar size={32} className="text-zinc-800 mx-auto mb-3" />
<p className="text-zinc-600 font-bold uppercase tracking-widest text-[10px]">{t('hub.placeholders.noSessions')}</p>
</div>
) : (
<div className="space-y-3">
{mySessions.map((session) => (
<SessionCard
key={session.id}
session={session}
isActive={activeSession?.id === session.id}
locale={locale}
onSelect={() => {
setActiveSession({ id: session.id, name: session.name });
onClose();
}}
/>
))}
</div>
)}
</section>
{/* Guest Sessions List */}
{guestSessions.length > 0 && (
<section className="space-y-4">
<h3 className="text-[10px] font-black uppercase tracking-[0.3em] text-zinc-500 flex items-center justify-between">
<span className="flex items-center gap-2"><Users size={14} className="text-orange-600" /> {t('hub.sections.participating')}</span>
<span className="text-zinc-700">{guestSessions.length}</span>
</h3>
<div className="space-y-3">
{guestSessions.map((session) => (
<SessionCard
key={session.id}
session={session}
isActive={activeSession?.id === session.id}
locale={locale}
onSelect={() => {
setActiveSession({ id: session.id, name: session.name });
onClose();
}}
/>
))}
</div>
</section>
)}
</>
) : (
<>
{/* Split Section */}
<section className="space-y-4">
<h3 className="text-[10px] font-black uppercase tracking-[0.3em] text-zinc-500 flex items-center gap-2">
<Plus size={14} className="text-orange-600" /> {t('hub.sections.startSplit')}
</h3>
<button
onClick={() => {
onClose();
window.location.href = '/splits/create';
}}
className="w-full bg-zinc-900 border border-white/5 hover:border-orange-500/30 rounded-2xl px-6 py-4 text-xs font-black uppercase tracking-widest text-zinc-400 hover:text-white transition-all flex items-center justify-center gap-3"
>
<Package size={18} className="text-orange-600" /> {t('hub.placeholders.openSplitCreator')}
</button>
</section>
{/* My Splits */}
<section className="space-y-4">
<h3 className="text-[10px] font-black uppercase tracking-[0.3em] text-zinc-500 flex items-center justify-between">
<span className="flex items-center gap-2"><Package size={14} className="text-orange-600" /> {t('hub.sections.yourSplits')}</span>
<span className="text-zinc-700">{mySplits.length}</span>
</h3>
{isLoading ? (
<div className="flex justify-center py-12">
<Loader2 size={32} className="animate-spin text-zinc-800" />
</div>
) : mySplits.length === 0 ? (
<div className="bg-zinc-900/30 border border-dashed border-white/5 rounded-[32px] p-12 text-center">
<Package size={32} className="text-zinc-800 mx-auto mb-3" />
<p className="text-zinc-600 font-bold uppercase tracking-widest text-[10px]">{t('hub.placeholders.noSplits')}</p>
</div>
) : (
<div className="space-y-3">
{mySplits.map((split) => (
<SplitCard
key={split.id}
split={split}
onSelect={() => {
onClose();
window.location.href = '/splits/manage';
}}
/>
))}
</div>
)}
</section>
{/* Participating Splits */}
{participatingSplits.length > 0 && (
<section className="space-y-4">
<h3 className="text-[10px] font-black uppercase tracking-[0.3em] text-zinc-500 flex items-center justify-between">
<span className="flex items-center gap-2"><Users size={14} className="text-orange-600" /> {t('hub.sections.participating')}</span>
<span className="text-zinc-700">{participatingSplits.length}</span>
</h3>
<div className="space-y-3">
{participatingSplits.map((split) => (
<SplitCard
key={split.id}
split={split}
isParticipant
onSelect={() => {
onClose();
window.location.href = `/splits/${split.slug}`;
}}
/>
))}
</div>
</section>
)}
</>
)}
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
}
function SessionCard({ session, isActive, locale, onSelect }: { session: Session, isActive: boolean, locale: string, onSelect: () => void }) {
return (
<div
className={`p-5 rounded-[28px] border transition-all flex items-center justify-between group cursor-pointer ${isActive ? 'bg-zinc-800/50 border-orange-500/30' : 'bg-zinc-900/30 border-white/5 hover:border-white/10 hover:bg-zinc-900/50'}`}
onClick={onSelect}
>
<div className="flex-1 min-w-0 pr-4">
<div className="flex items-center gap-2 mb-1">
<h4 className={`text-base font-black uppercase truncate tracking-tight transition-colors ${isActive ? 'text-orange-500' : 'text-zinc-200'}`}>
{session.name}
</h4>
{session.ended_at && (
<span className="text-[7px] font-black uppercase px-1.5 py-0.5 rounded-full bg-zinc-800 border border-white/5 text-zinc-500">Done</span>
)}
</div>
<div className="flex items-center gap-4 text-[9px] font-black uppercase tracking-[0.15em] text-zinc-600">
<span className="flex items-center gap-1">
<Calendar size={10} className="text-zinc-700" />
{new Date(session.scheduled_at).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')}
</span>
{!session.is_host && session.host_name && (
<span className="flex items-center gap-1">
<Terminal size={10} className="text-zinc-700" />
By {session.host_name}
</span>
)}
{session.whisky_count! > 0 && (
<span className="flex items-center gap-1">
<GlassWater size={10} className="text-orange-600/50" />
{session.whisky_count}
</span>
)}
</div>
{session.participants && session.participants.length > 0 && (
<div className="mt-3">
<AvatarStack names={session.participants} limit={4} />
</div>
)}
</div>
<div className={`w-10 h-10 rounded-2xl border transition-all flex items-center justify-center ${isActive ? 'bg-orange-600 border-orange-600 text-white shadow-lg shadow-orange-950/20' : 'bg-black/40 border-white/5 text-zinc-700 group-hover:text-zinc-400 group-hover:border-white/10'}`}>
{isActive ? <Check size={20} /> : <ChevronRight size={20} />}
</div>
</div>
);
}

View File

@@ -45,6 +45,9 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
const [lastDramInSession, setLastDramInSession] = useState<{ name: string; isSmoky: boolean; timestamp: number } | null>(null);
const [showPaletteWarning, setShowPaletteWarning] = useState(false);
const [bottleOwnerId, setBottleOwnerId] = useState<string | null>(null);
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
// Section collapse states
const [isNoseExpanded, setIsNoseExpanded] = useState(false);
const [isPalateExpanded, setIsPalateExpanded] = useState(false);
@@ -52,14 +55,22 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
const effectiveSessionId = sessionId || activeSession?.id;
useEffect(() => {
const getAuth = async () => {
const { data: { user } } = await supabase.auth.getUser();
if (user) setCurrentUserId(user.id);
};
getAuth();
}, [supabase]);
useEffect(() => {
const fetchData = async () => {
if (!bottleId) return;
// Fetch Bottle Suggestions
// Fetch Bottle Suggestions and Owner
const { data: bottleData } = await supabase
.from('bottles')
.select('suggested_tags, suggested_custom_tags')
.select('suggested_tags, suggested_custom_tags, user_id')
.eq('id', bottleId)
.maybeSingle();
@@ -69,6 +80,9 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
if (bottleData?.suggested_custom_tags) {
setSuggestedCustomTags(bottleData.suggested_custom_tags);
}
if (bottleData?.user_id) {
setBottleOwnerId(bottleData.user_id);
}
// If Session ID, fetch session participants and pre-select them, and fetch last dram
if (effectiveSessionId) {
@@ -209,8 +223,22 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
}
};
const isSharedBottle = bottleOwnerId && currentUserId && bottleOwnerId !== currentUserId;
return (
<form onSubmit={handleSubmit} className="space-y-6">
{isSharedBottle && !activeSession && (
<div className="p-4 bg-orange-500/10 border border-orange-500/20 rounded-2xl flex items-start gap-3">
<AlertTriangle size={20} className="text-orange-500 shrink-0 mt-0.5" />
<div>
<p className="text-[10px] font-black uppercase tracking-wider text-orange-600">Shared Bottle Ownership Check</p>
<p className="text-xs font-bold text-orange-200">Diese Flasche gehört einem Buddy.</p>
<p className="text-[10px] text-orange-400/80 leading-relaxed font-medium mt-1">
Hinweis: Falls kein Session-Sharing aktiv ist, schlägt das Speichern fehl. Starte eine Session um gemeinsam zu bewerten!
</p>
</div>
</div>
)}
{activeSession && (
<div className="p-3 bg-orange-950/20 border border-orange-900/30 rounded-2xl flex items-center gap-3 animate-in fade-in slide-in-from-top-2">
<div className="bg-orange-600 text-white p-2 rounded-xl">