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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
115
src/components/SettingsHub.tsx
Normal file
115
src/components/SettingsHub.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
src/components/SplitCard.tsx
Normal file
90
src/components/SplitCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
471
src/components/TastingHub.tsx
Normal file
471
src/components/TastingHub.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user