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:
33
enable_public_split_access.sql
Normal file
33
enable_public_split_access.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
-- ============================================
|
||||
-- Enable Public Access for Bottle Splits
|
||||
-- ============================================
|
||||
-- This script enables unauthenticated users (guests) to view:
|
||||
-- 1. Profiles (to see hostnames)
|
||||
-- 2. Bottle Splits (if is_active = true)
|
||||
-- 3. Split Participants (to see progress bar data)
|
||||
-- ============================================
|
||||
|
||||
-- 1. Profiles: Allow anyone to see usernames and avatars
|
||||
DROP POLICY IF EXISTS "profiles_select_policy" ON profiles;
|
||||
CREATE POLICY "profiles_select_policy" ON profiles
|
||||
FOR SELECT USING (true);
|
||||
|
||||
-- 2. Bottle Splits: Allow guests to see active splits
|
||||
-- This policy allows anyone to read splits that are marked as active.
|
||||
DROP POLICY IF EXISTS "bottle_splits_public_select" ON bottle_splits;
|
||||
CREATE POLICY "bottle_splits_public_select" ON bottle_splits
|
||||
FOR SELECT USING (is_active = true);
|
||||
|
||||
-- 3. Split Participants: Allow guests to see progress of active splits
|
||||
-- This is necessary to calculate the "taken" volume in the progress bar.
|
||||
DROP POLICY IF EXISTS "split_participants_public_select" ON split_participants;
|
||||
CREATE POLICY "split_participants_public_select" ON split_participants
|
||||
FOR SELECT USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.bottle_splits
|
||||
WHERE id = split_participants.split_id AND is_active = true
|
||||
)
|
||||
);
|
||||
|
||||
-- Note: Ensure "bottles_select_policy" in rls_public_bottle_access.sql
|
||||
-- is also applied to allow guests to see bottle details for active splits.
|
||||
54
fix_rls_recursion.sql
Normal file
54
fix_rls_recursion.sql
Normal file
@@ -0,0 +1,54 @@
|
||||
-- ============================================
|
||||
-- Fix RLS Infinite Recursion
|
||||
-- ============================================
|
||||
-- This script breaks the circular dependency between bottle_splits
|
||||
-- and split_participants RLS policies using SECURITY DEFINER functions.
|
||||
-- ============================================
|
||||
|
||||
-- 1. Create Security Definer functions to bypass RLS
|
||||
CREATE OR REPLACE FUNCTION public.check_is_split_host(check_split_id UUID, check_user_id UUID)
|
||||
RETURNS BOOLEAN AS $$
|
||||
BEGIN
|
||||
RETURN EXISTS (
|
||||
SELECT 1 FROM public.bottle_splits
|
||||
WHERE id = check_split_id AND host_id = check_user_id
|
||||
);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.check_is_split_participant(check_split_id UUID, check_user_id UUID)
|
||||
RETURNS BOOLEAN AS $$
|
||||
BEGIN
|
||||
RETURN EXISTS (
|
||||
SELECT 1 FROM public.split_participants
|
||||
WHERE split_id = check_split_id AND user_id = check_user_id
|
||||
);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- 2. Update bottle_splits policies
|
||||
DROP POLICY IF EXISTS "bottle_splits_participant_view" ON bottle_splits;
|
||||
DROP POLICY IF EXISTS "bottle_splits_host_policy" ON bottle_splits;
|
||||
|
||||
CREATE POLICY "bottle_splits_host_policy" ON bottle_splits
|
||||
FOR ALL USING ((SELECT auth.uid()) = host_id);
|
||||
|
||||
CREATE POLICY "bottle_splits_participant_view" ON bottle_splits
|
||||
FOR SELECT USING (
|
||||
check_is_split_participant(id, (SELECT auth.uid()))
|
||||
);
|
||||
|
||||
-- 3. Update split_participants policies
|
||||
DROP POLICY IF EXISTS "split_participants_host_policy" ON split_participants;
|
||||
DROP POLICY IF EXISTS "split_participants_own_policy" ON split_participants;
|
||||
|
||||
CREATE POLICY "split_participants_own_policy" ON split_participants
|
||||
FOR ALL USING ((SELECT auth.uid()) = user_id);
|
||||
|
||||
CREATE POLICY "split_participants_host_policy" ON split_participants
|
||||
FOR ALL USING (
|
||||
check_is_split_host(split_id, (SELECT auth.uid()))
|
||||
);
|
||||
|
||||
-- Note: bottle_splits_public_view and split_participants_public_view
|
||||
-- don't cause recursion as they don't cross-reference tables in a cycle.
|
||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
54
rls_public_bottle_access.sql
Normal file
54
rls_public_bottle_access.sql
Normal file
@@ -0,0 +1,54 @@
|
||||
-- ============================================
|
||||
-- Restore Public/Buddy Bottle Read Access
|
||||
-- ============================================
|
||||
-- This script fixes the issue where non-owners cannot see bottle data
|
||||
-- for public splits or shared tastings.
|
||||
-- ============================================
|
||||
|
||||
-- Drop the overly restrictive performance-fix policy
|
||||
DROP POLICY IF EXISTS "bottles_policy" ON bottles;
|
||||
|
||||
-- 1. Unified SELECT policy: owner OR active split viewer OR session participant
|
||||
CREATE POLICY "bottles_select_policy" ON bottles
|
||||
FOR SELECT USING (
|
||||
-- Owner access
|
||||
(SELECT auth.uid()) = user_id OR
|
||||
|
||||
-- Public split access (anyone can see bottle info if the split is active)
|
||||
EXISTS (
|
||||
SELECT 1 FROM bottle_splits
|
||||
WHERE bottle_id = bottles.id AND is_active = true
|
||||
) OR
|
||||
|
||||
-- Participant access (user is already part of this split)
|
||||
EXISTS (
|
||||
SELECT 1 FROM split_participants sp
|
||||
JOIN bottle_splits bs ON bs.id = sp.split_id
|
||||
WHERE bs.bottle_id = bottles.id AND sp.user_id = (SELECT auth.uid())
|
||||
) OR
|
||||
|
||||
-- Buddy/Session access (user is a buddy in a session involving this bottle)
|
||||
id IN (
|
||||
SELECT t.bottle_id
|
||||
FROM tastings t
|
||||
JOIN tasting_sessions ts ON ts.id = t.session_id
|
||||
JOIN session_participants sp ON sp.session_id = ts.id
|
||||
JOIN buddies b ON b.id = sp.buddy_id
|
||||
WHERE b.buddy_profile_id = (SELECT auth.uid())
|
||||
)
|
||||
);
|
||||
|
||||
-- 2. Owner-only for modifications (No change needed from security perspective)
|
||||
CREATE POLICY "bottles_insert_policy" ON bottles
|
||||
FOR INSERT WITH CHECK ((SELECT auth.uid()) = user_id);
|
||||
|
||||
CREATE POLICY "bottles_update_policy" ON bottles
|
||||
FOR UPDATE USING ((SELECT auth.uid()) = user_id);
|
||||
|
||||
CREATE POLICY "bottles_delete_policy" ON bottles
|
||||
FOR DELETE USING ((SELECT auth.uid()) = user_id);
|
||||
|
||||
-- ============================================
|
||||
-- Verification query
|
||||
-- ============================================
|
||||
-- SELECT * FROM pg_policies WHERE tablename = 'bottles';
|
||||
9
rls_split_participant_access.sql
Normal file
9
rls_split_participant_access.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- Allow participants to see the split record even if it's not active anymore
|
||||
DROP POLICY IF EXISTS "bottle_splits_participant_view" ON bottle_splits;
|
||||
CREATE POLICY "bottle_splits_participant_view" ON bottle_splits
|
||||
FOR SELECT USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM split_participants
|
||||
WHERE split_id = bottle_splits.id AND user_id = (SELECT auth.uid())
|
||||
)
|
||||
);
|
||||
173
rls_unified_consolidation.sql
Normal file
173
rls_unified_consolidation.sql
Normal file
@@ -0,0 +1,173 @@
|
||||
-- ============================================
|
||||
-- UNIFIED RLS CONSOLIDATION & TABLE FIXES
|
||||
-- ============================================
|
||||
-- 1. Fix Table Mismatch (Rename to match services)
|
||||
-- 2. Consolidate RLS for Bottles, Tastings, Sessions
|
||||
-- 3. Resolve Naming Conflicts (tasting_tags vs tasting_buddies)
|
||||
-- ============================================
|
||||
|
||||
-- 1. FIX TABLE NAMES (Ensuring consistency across project)
|
||||
DO $$
|
||||
BEGIN
|
||||
-- If 'tasting_tags' currently contains buddy_id (old schema), rename it
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'tasting_tags' AND column_name = 'buddy_id'
|
||||
) THEN
|
||||
DROP TABLE IF EXISTS tasting_buddies CASCADE;
|
||||
ALTER TABLE tasting_tags RENAME TO tasting_buddies;
|
||||
END IF;
|
||||
|
||||
-- Ensure 'tasting_tags' exists for Aroma Tags (Aroma Filter)
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'tasting_tags') THEN
|
||||
CREATE TABLE tasting_tags (
|
||||
tasting_id UUID REFERENCES tastings(id) ON DELETE CASCADE NOT NULL,
|
||||
tag_id UUID REFERENCES tags(id) ON DELETE CASCADE NOT NULL,
|
||||
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL,
|
||||
PRIMARY KEY (tasting_id, tag_id)
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Enable RLS on all relevant tables
|
||||
ALTER TABLE bottles ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tastings ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tasting_sessions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tasting_buddies ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tasting_tags ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- 2. UNIFIED BOTTLES POLICIES
|
||||
DROP POLICY IF EXISTS "bottles_policy" ON bottles;
|
||||
DROP POLICY IF EXISTS "bottles_select_policy" ON bottles;
|
||||
DROP POLICY IF EXISTS "bottles_insert_policy" ON bottles;
|
||||
DROP POLICY IF EXISTS "bottles_update_policy" ON bottles;
|
||||
DROP POLICY IF EXISTS "bottles_delete_policy" ON bottles;
|
||||
|
||||
-- SELECT: owner OR split participant OR session buddy
|
||||
CREATE POLICY "bottles_select_policy" ON bottles
|
||||
FOR SELECT USING (
|
||||
(SELECT auth.uid()) = user_id OR
|
||||
EXISTS (SELECT 1 FROM bottle_splits WHERE bottle_id = bottles.id AND is_active = true) OR
|
||||
EXISTS (
|
||||
SELECT 1 FROM tasting_sessions ts
|
||||
JOIN session_participants sp ON sp.session_id = ts.id
|
||||
JOIN buddies b ON b.id = sp.buddy_id
|
||||
JOIN tastings t ON t.session_id = ts.id
|
||||
WHERE t.bottle_id = bottles.id AND b.buddy_profile_id = (SELECT auth.uid())
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "bottles_insert_policy" ON bottles
|
||||
FOR INSERT WITH CHECK ((SELECT auth.uid()) = user_id);
|
||||
|
||||
CREATE POLICY "bottles_update_policy" ON bottles
|
||||
FOR UPDATE USING ((SELECT auth.uid()) = user_id);
|
||||
|
||||
CREATE POLICY "bottles_delete_policy" ON bottles
|
||||
FOR DELETE USING ((SELECT auth.uid()) = user_id);
|
||||
|
||||
-- 3. UNIFIED TASTINGS POLICIES
|
||||
DROP POLICY IF EXISTS "tastings_policy" ON tastings;
|
||||
DROP POLICY IF EXISTS "tastings_select_policy" ON tastings;
|
||||
DROP POLICY IF EXISTS "tastings_insert_policy" ON tastings;
|
||||
DROP POLICY IF EXISTS "tastings_update_policy" ON tastings;
|
||||
DROP POLICY IF EXISTS "tastings_delete_policy" ON tastings;
|
||||
DROP POLICY IF EXISTS "tastings_modify_policy" ON tastings;
|
||||
|
||||
-- SELECT: owner OR buddy in the same tasting/session
|
||||
CREATE POLICY "tastings_select_policy" ON tastings
|
||||
FOR SELECT USING (
|
||||
(SELECT auth.uid()) = user_id OR
|
||||
EXISTS (
|
||||
SELECT 1 FROM tasting_buddies tb
|
||||
JOIN buddies b ON b.id = tb.buddy_id
|
||||
WHERE tb.tasting_id = tastings.id AND b.buddy_profile_id = (SELECT auth.uid())
|
||||
)
|
||||
);
|
||||
|
||||
-- INSERT: Anyone can insert if they are the user_id (the owner of the note)
|
||||
CREATE POLICY "tastings_insert_policy" ON tastings
|
||||
FOR INSERT WITH CHECK ((SELECT auth.uid()) = user_id);
|
||||
|
||||
-- UPDATE/DELETE: strictly owner
|
||||
CREATE POLICY "tastings_modify_policy" ON tastings
|
||||
FOR ALL USING ((SELECT auth.uid()) = user_id);
|
||||
|
||||
-- 4. UNIFIED JOIN TABLES POLICIES (Buddies & Aroma Tags)
|
||||
DROP POLICY IF EXISTS "tasting_buddies_policy" ON tasting_buddies;
|
||||
DROP POLICY IF EXISTS "tasting_tags_policy" ON tasting_tags;
|
||||
|
||||
CREATE POLICY "tasting_buddies_policy" ON tasting_buddies
|
||||
FOR ALL USING ((SELECT auth.uid()) = user_id);
|
||||
|
||||
CREATE POLICY "tasting_tags_policy" ON tasting_tags
|
||||
FOR ALL USING ((SELECT auth.uid()) = user_id);
|
||||
|
||||
-- 5. UNIFIED SESSIONS POLICIES
|
||||
DROP POLICY IF EXISTS "tasting_sessions_policy" ON tasting_sessions;
|
||||
DROP POLICY IF EXISTS "sessions_access_policy" ON tasting_sessions;
|
||||
|
||||
CREATE POLICY "tasting_sessions_policy" ON tasting_sessions
|
||||
FOR ALL USING (
|
||||
(SELECT auth.uid()) = user_id OR
|
||||
id IN (
|
||||
SELECT sp.session_id
|
||||
FROM session_participants sp
|
||||
JOIN buddies b ON b.id = sp.buddy_id
|
||||
WHERE b.buddy_profile_id = (SELECT auth.uid())
|
||||
)
|
||||
);
|
||||
|
||||
-- 6. BOTTLE SPLITS & RECURSION FIXES
|
||||
-- Breaks loop between bottle_splits and split_participants
|
||||
CREATE OR REPLACE FUNCTION public.check_is_split_host(check_split_id UUID, check_user_id UUID)
|
||||
RETURNS BOOLEAN AS $$
|
||||
BEGIN
|
||||
RETURN EXISTS (
|
||||
SELECT 1 FROM public.bottle_splits
|
||||
WHERE id = check_split_id AND host_id = check_user_id
|
||||
);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.check_is_split_participant(check_split_id UUID, check_user_id UUID)
|
||||
RETURNS BOOLEAN AS $$
|
||||
BEGIN
|
||||
RETURN EXISTS (
|
||||
SELECT 1 FROM public.split_participants
|
||||
WHERE split_id = check_split_id AND user_id = check_user_id
|
||||
);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
DROP POLICY IF EXISTS "bottle_splits_host_policy" ON bottle_splits;
|
||||
DROP POLICY IF EXISTS "bottle_splits_participant_view" ON bottle_splits;
|
||||
DROP POLICY IF EXISTS "bottle_splits_public_select" ON bottle_splits;
|
||||
|
||||
CREATE POLICY "bottle_splits_host_policy" ON bottle_splits
|
||||
FOR ALL USING ((SELECT auth.uid()) = host_id);
|
||||
|
||||
CREATE POLICY "bottle_splits_participant_view" ON bottle_splits
|
||||
FOR SELECT USING (check_is_split_participant(id, (SELECT auth.uid())));
|
||||
|
||||
CREATE POLICY "bottle_splits_public_select" ON bottle_splits
|
||||
FOR SELECT USING (is_active = true);
|
||||
|
||||
-- 7. SPLIT PARTICIPANTS
|
||||
DROP POLICY IF EXISTS "split_participants_own_policy" ON split_participants;
|
||||
DROP POLICY IF EXISTS "split_participants_host_policy" ON split_participants;
|
||||
DROP POLICY IF EXISTS "split_participants_public_select" ON split_participants;
|
||||
|
||||
CREATE POLICY "split_participants_own_policy" ON split_participants
|
||||
FOR ALL USING ((SELECT auth.uid()) = user_id);
|
||||
|
||||
CREATE POLICY "split_participants_host_policy" ON split_participants
|
||||
FOR ALL USING (check_is_split_host(split_id, (SELECT auth.uid())));
|
||||
|
||||
CREATE POLICY "split_participants_public_select" ON split_participants
|
||||
FOR SELECT USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.bottle_splits
|
||||
WHERE id = split_participants.split_id AND is_active = true
|
||||
)
|
||||
);
|
||||
@@ -13,10 +13,13 @@ import LanguageSwitcher from "@/components/LanguageSwitcher";
|
||||
import OfflineIndicator from "@/components/OfflineIndicator";
|
||||
import { useI18n } from "@/i18n/I18nContext";
|
||||
import { useSession } from "@/context/SessionContext";
|
||||
import TastingHub from "@/components/TastingHub";
|
||||
import { Sparkles, X, Loader2 } from "lucide-react";
|
||||
import { BottomNavigation } from '@/components/BottomNavigation';
|
||||
import ScanAndTasteFlow from '@/components/ScanAndTasteFlow';
|
||||
import UserStatusBadge from '@/components/UserStatusBadge';
|
||||
import { getActiveSplits } from '@/services/split-actions';
|
||||
import SplitCard from '@/components/SplitCard';
|
||||
|
||||
export default function Home() {
|
||||
const supabase = createClient();
|
||||
@@ -28,8 +31,10 @@ export default function Home() {
|
||||
const { t } = useI18n();
|
||||
const { activeSession } = useSession();
|
||||
const [isFlowOpen, setIsFlowOpen] = useState(false);
|
||||
const [isTastingHubOpen, setIsTastingHubOpen] = useState(false);
|
||||
const [capturedFile, setCapturedFile] = useState<File | null>(null);
|
||||
const [hasMounted, setHasMounted] = useState(false);
|
||||
const [publicSplits, setPublicSplits] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setHasMounted(true);
|
||||
@@ -74,6 +79,13 @@ export default function Home() {
|
||||
|
||||
checkUser();
|
||||
|
||||
// Fetch public splits if guest
|
||||
getActiveSplits().then(res => {
|
||||
if (res.success && res.splits) {
|
||||
setPublicSplits(res.splits);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for visibility change (wake up from sleep)
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
@@ -153,19 +165,33 @@ export default function Home() {
|
||||
|
||||
setBottles(processedBottles);
|
||||
} catch (err: any) {
|
||||
// Silently skip if offline
|
||||
// Enhanced logging for empty-looking error objects
|
||||
console.warn('[Home] Fetch collection error caught:', {
|
||||
name: err?.name,
|
||||
message: err?.message,
|
||||
keys: err ? Object.keys(err) : [],
|
||||
allProps: err ? Object.getOwnPropertyNames(err) : [],
|
||||
stack: err?.stack,
|
||||
online: navigator.onLine
|
||||
});
|
||||
|
||||
// Silently skip if offline or common network failure
|
||||
const isNetworkError = !navigator.onLine ||
|
||||
err.message?.includes('Failed to fetch') ||
|
||||
err.message?.includes('NetworkError') ||
|
||||
err.message?.includes('ERR_INTERNET_DISCONNECTED') ||
|
||||
(err && Object.keys(err).length === 0); // Empty error object from Supabase when offline
|
||||
err?.name === 'TypeError' ||
|
||||
err?.message?.includes('Failed to fetch') ||
|
||||
err?.message?.includes('NetworkError') ||
|
||||
err?.message?.includes('ERR_INTERNET_DISCONNECTED') ||
|
||||
(err && typeof err === 'object' && !err.message && Object.keys(err).length === 0);
|
||||
|
||||
if (isNetworkError) {
|
||||
console.log('[fetchCollection] Skipping due to offline mode');
|
||||
console.log('[fetchCollection] Skipping due to offline mode or network error');
|
||||
setFetchError(null);
|
||||
} else {
|
||||
console.error('Detailed fetch error:', err);
|
||||
setFetchError(err.message || JSON.stringify(err));
|
||||
// Safe stringification for Error objects
|
||||
const errorMessage = err?.message ||
|
||||
(err && typeof err === 'object' ? JSON.stringify(err, Object.getOwnPropertyNames(err)) : String(err));
|
||||
setFetchError(errorMessage);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -192,13 +218,33 @@ export default function Home() {
|
||||
DRAM<span className="text-orange-600">LOG</span>
|
||||
</h1>
|
||||
<p className="text-zinc-500 max-w-sm mx-auto font-bold tracking-wide">
|
||||
Modern Minimalist Tasting Tool.
|
||||
{t('home.tagline')}
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
<AuthForm />
|
||||
|
||||
{!user && publicSplits.length > 0 && (
|
||||
<div className="mt-16 w-full max-w-lg space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-1000 delay-300">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<h2 className="text-[10px] font-black uppercase tracking-[0.4em] text-orange-600/60">
|
||||
{t('splits.publicExplore')}
|
||||
</h2>
|
||||
<div className="h-px w-8 bg-orange-600/20" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{publicSplits.map((split) => (
|
||||
<SplitCard
|
||||
key={split.id}
|
||||
split={split}
|
||||
onSelect={() => router.push(`/splits/${split.slug}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -254,10 +300,10 @@ export default function Home() {
|
||||
<div className="w-full mt-4" id="collection">
|
||||
<div className="flex items-end justify-between mb-8">
|
||||
<h2 className="text-3xl font-bold text-zinc-50 uppercase tracking-tight">
|
||||
Collection
|
||||
{t('home.collection')}
|
||||
</h2>
|
||||
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest pb-1">
|
||||
{bottles.length} Bottles
|
||||
{bottles.length} {t('home.bottleCount')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -285,20 +331,25 @@ export default function Home() {
|
||||
{/* Footer */}
|
||||
<footer className="pb-28 pt-8 text-center">
|
||||
<div className="flex justify-center gap-4 text-xs text-zinc-600">
|
||||
<a href="/impressum" className="hover:text-orange-500 transition-colors">Impressum</a>
|
||||
<a href="/impressum" className="hover:text-orange-500 transition-colors">{t('home.imprint')}</a>
|
||||
<span>•</span>
|
||||
<a href="/privacy" className="hover:text-orange-500 transition-colors">Datenschutz</a>
|
||||
<a href="/privacy" className="hover:text-orange-500 transition-colors">{t('home.privacy')}</a>
|
||||
<span>•</span>
|
||||
<a href="/settings" className="hover:text-orange-500 transition-colors">Einstellungen</a>
|
||||
<a href="/settings" className="hover:text-orange-500 transition-colors">{t('home.settings')}</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<BottomNavigation
|
||||
onScan={handleImageSelected}
|
||||
onHome={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||
onShelf={() => document.getElementById('collection')?.scrollIntoView({ behavior: 'smooth' })}
|
||||
onSearch={() => document.getElementById('search-filter')?.scrollIntoView({ behavior: 'smooth' })}
|
||||
onTastings={() => setIsTastingHubOpen(true)}
|
||||
onProfile={() => router.push('/settings')}
|
||||
onScan={handleImageSelected}
|
||||
/>
|
||||
|
||||
<TastingHub
|
||||
isOpen={isTastingHubOpen}
|
||||
onClose={() => setIsTastingHubOpen(false)}
|
||||
/>
|
||||
|
||||
<ScanAndTasteFlow
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { getProfile } from '@/services/profile-actions';
|
||||
import ProfileForm from '@/components/ProfileForm';
|
||||
import PasswordChangeForm from '@/components/PasswordChangeForm';
|
||||
import { ArrowLeft, Settings, Cookie, Shield } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import SettingsHub from '@/components/SettingsHub';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Einstellungen',
|
||||
title: 'Einstellungen | Settings',
|
||||
};
|
||||
|
||||
export default async function SettingsPage() {
|
||||
@@ -20,88 +17,17 @@ export default async function SettingsPage() {
|
||||
|
||||
const profile = await getProfile();
|
||||
|
||||
if (!profile) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
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">Einstellungen</h1>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="max-w-2xl mx-auto px-4 py-6 space-y-6">
|
||||
{/* 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" />
|
||||
Cookie-Einstellungen
|
||||
</h2>
|
||||
<p className="text-sm text-zinc-400 mb-4">
|
||||
Diese App verwendet nur technisch notwendige Cookies für die Authentifizierung und funktionale Cookies für UI-Präferenzen.
|
||||
</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><strong>Notwendig:</strong> Supabase Auth Cookies</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-zinc-300">
|
||||
<Shield size={14} className="text-blue-500" />
|
||||
<span><strong>Funktional:</strong> Sprache, UI-Status</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" />
|
||||
Datenschutz
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-zinc-400">
|
||||
Deine Daten werden sicher auf EU-Servern gespeichert.
|
||||
</p>
|
||||
<Link
|
||||
href="/privacy"
|
||||
className="inline-block text-sm text-orange-500 hover:text-orange-400 underline"
|
||||
>
|
||||
Datenschutzerklärung lesen
|
||||
</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">
|
||||
Mitglied seit: {new Date(profile?.created_at || '').toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<SettingsHub
|
||||
profile={{
|
||||
email: profile.email,
|
||||
username: profile.username,
|
||||
created_at: profile.created_at
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ChevronLeft, Share2, User, Package, Truck, Loader2, CheckCircle2, Clock, AlertCircle } from 'lucide-react';
|
||||
import { ChevronLeft, Share2, User, Package, Truck, Loader2, CheckCircle2, Clock, AlertCircle, LogIn } from 'lucide-react';
|
||||
import { getSplitBySlug, requestSlot, SplitDetails, SampleSize, ShippingOption } from '@/services/split-actions';
|
||||
import SplitProgressBar from '@/components/SplitProgressBar';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { useI18n } from '@/i18n/I18nContext';
|
||||
|
||||
export default function SplitPublicPage() {
|
||||
const { slug } = useParams();
|
||||
const { t } = useI18n();
|
||||
const supabase = createClient();
|
||||
const [split, setSplit] = useState<SplitDetails | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -44,7 +46,7 @@ export default function SplitPublicPage() {
|
||||
setSelectedAmount(result.data.sampleSizes[0].cl);
|
||||
}
|
||||
} else {
|
||||
setError(result.error || 'Split nicht gefunden');
|
||||
setError(result.error || t('splits.noSplitsFound'));
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
@@ -69,6 +71,10 @@ export default function SplitPublicPage() {
|
||||
|
||||
const handleRequest = async () => {
|
||||
if (!selectedShipping || !selectedAmount) return;
|
||||
if (!currentUserId) {
|
||||
window.location.href = `/login?redirect=/splits/${slug}`;
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRequesting(true);
|
||||
setRequestError(null);
|
||||
@@ -86,7 +92,7 @@ export default function SplitPublicPage() {
|
||||
};
|
||||
|
||||
const userParticipation = split?.participants.find(p => p.userId === currentUserId);
|
||||
const canRequest = !userParticipation && currentUserId && currentUserId !== split?.hostId;
|
||||
const showRequestForm = !userParticipation && currentUserId !== split?.hostId;
|
||||
const isWaitlist = split && selectedAmount && split.remaining < selectedAmount;
|
||||
|
||||
if (isLoading) {
|
||||
@@ -101,8 +107,8 @@ export default function SplitPublicPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center gap-4 bg-zinc-950 p-6">
|
||||
<AlertCircle size={48} className="text-red-500" />
|
||||
<h1 className="text-xl font-bold text-zinc-50">{error || 'Split nicht gefunden'}</h1>
|
||||
<Link href="/" className="text-orange-600 font-bold">Zurück zum Start</Link>
|
||||
<h1 className="text-xl font-bold text-zinc-50">{error || t('splits.noSplitsFound')}</h1>
|
||||
<Link href="/" className="text-orange-600 font-bold">{t('splits.backToStart')}</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -117,7 +123,7 @@ export default function SplitPublicPage() {
|
||||
className="inline-flex items-center gap-2 text-zinc-500 hover:text-orange-600 transition-colors font-bold uppercase text-[10px] tracking-[0.2em]"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
Zurück
|
||||
{t('common.back')}
|
||||
</Link>
|
||||
|
||||
{/* Hero */}
|
||||
@@ -136,7 +142,7 @@ export default function SplitPublicPage() {
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-orange-500 mb-1">
|
||||
Flaschenteilung
|
||||
{t('splits.falscheTeilung')}
|
||||
</p>
|
||||
<h1 className="text-2xl md:text-3xl font-black text-zinc-50">
|
||||
{split.bottle.name}
|
||||
@@ -154,11 +160,11 @@ export default function SplitPublicPage() {
|
||||
)}
|
||||
{split.bottle.age && (
|
||||
<span className="px-3 py-1.5 bg-zinc-800 rounded-xl text-xs font-bold text-zinc-300">
|
||||
{split.bottle.age} Jahre
|
||||
{split.bottle.age} {t('splits.jahre')}
|
||||
</span>
|
||||
)}
|
||||
<span className="px-3 py-1.5 bg-zinc-800 rounded-xl text-xs font-bold text-zinc-300">
|
||||
{split.totalVolume}cl Flasche
|
||||
{split.totalVolume}{t('splits.clFlasche')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -175,23 +181,23 @@ export default function SplitPublicPage() {
|
||||
</div>
|
||||
|
||||
{/* Request Form */}
|
||||
{canRequest && !requestSuccess && (
|
||||
{showRequestForm && !requestSuccess && (
|
||||
<div className="bg-zinc-900 rounded-3xl p-6 border border-zinc-800 space-y-6">
|
||||
<h2 className="text-sm font-black uppercase tracking-widest text-zinc-400">
|
||||
Sample bestellen
|
||||
{t('splits.joinTitle')}
|
||||
</h2>
|
||||
|
||||
{/* Amount Selection */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-xs font-bold text-zinc-500 uppercase tracking-widest">Menge</label>
|
||||
<label className="text-xs font-bold text-zinc-500 uppercase tracking-widest">{t('splits.amount')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{split.sampleSizes.map(size => (
|
||||
<button
|
||||
key={size.cl}
|
||||
onClick={() => setSelectedAmount(size.cl)}
|
||||
className={`px-4 py-3 rounded-xl border-2 transition-all ${selectedAmount === size.cl
|
||||
? 'border-orange-500 bg-orange-500/10'
|
||||
: 'border-zinc-700 hover:border-zinc-600'
|
||||
? 'border-orange-500 bg-orange-500/10'
|
||||
: 'border-zinc-700 hover:border-zinc-600'
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg font-black text-white">{size.cl}cl</span>
|
||||
@@ -204,7 +210,7 @@ export default function SplitPublicPage() {
|
||||
<div className="space-y-3">
|
||||
<label className="text-xs font-bold text-zinc-500 uppercase tracking-widest flex items-center gap-2">
|
||||
<Truck size={14} />
|
||||
Versand
|
||||
{t('splits.shipping')}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{split.shippingOptions.map(option => (
|
||||
@@ -212,8 +218,8 @@ export default function SplitPublicPage() {
|
||||
key={option.name}
|
||||
onClick={() => setSelectedShipping(option.name)}
|
||||
className={`px-4 py-3 rounded-xl border-2 transition-all text-left ${selectedShipping === option.name
|
||||
? 'border-orange-500 bg-orange-500/10'
|
||||
: 'border-zinc-700 hover:border-zinc-600'
|
||||
? 'border-orange-500 bg-orange-500/10'
|
||||
: 'border-zinc-700 hover:border-zinc-600'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-bold text-white">{option.name}</span>
|
||||
@@ -227,19 +233,19 @@ export default function SplitPublicPage() {
|
||||
{selectedAmount && (
|
||||
<div className="bg-zinc-950 rounded-2xl p-4 space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-zinc-500">Whisky ({selectedAmount}cl)</span>
|
||||
<span className="text-zinc-500">{t('splits.whisky')} ({selectedAmount}cl)</span>
|
||||
<span className="text-zinc-300">{price.whisky.toFixed(2)}€</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-zinc-500">Sample-Flasche</span>
|
||||
<span className="text-zinc-500">{t('splits.glass')}</span>
|
||||
<span className="text-zinc-300">{price.glass.toFixed(2)}€</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-zinc-500">Versand ({selectedShipping})</span>
|
||||
<span className="text-zinc-500">{t('splits.shipping')} ({selectedShipping})</span>
|
||||
<span className="text-zinc-300">{price.shipping.toFixed(2)}€</span>
|
||||
</div>
|
||||
<div className="border-t border-zinc-800 pt-2 mt-2 flex justify-between">
|
||||
<span className="font-bold text-white">Gesamt</span>
|
||||
<span className="font-bold text-white">{t('splits.total')}</span>
|
||||
<span className="font-black text-xl text-orange-500">{price.total.toFixed(2)}€</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -251,37 +257,58 @@ export default function SplitPublicPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleRequest}
|
||||
disabled={isRequesting || !selectedShipping || !selectedAmount}
|
||||
className={`w-full py-4 rounded-2xl font-bold text-white transition-all flex items-center justify-center gap-2 ${isWaitlist
|
||||
{currentUserId ? (
|
||||
<button
|
||||
onClick={handleRequest}
|
||||
disabled={isRequesting || !selectedShipping || !selectedAmount}
|
||||
className={`w-full py-4 rounded-2xl font-bold text-white transition-all flex items-center justify-center gap-2 ${isWaitlist
|
||||
? 'bg-yellow-600 hover:bg-yellow-700'
|
||||
: 'bg-orange-600 hover:bg-orange-700'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{isRequesting ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : isWaitlist ? (
|
||||
<>
|
||||
<Clock size={20} />
|
||||
Auf Warteliste setzen
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Package size={20} />
|
||||
Anfrage senden
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{isRequesting ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : isWaitlist ? (
|
||||
<>
|
||||
<Clock size={20} />
|
||||
{t('splits.waitlist')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Package size={20} />
|
||||
{t('splits.sendRequest')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-4 pt-4 border-t border-zinc-800">
|
||||
<div className="flex items-start gap-4 p-4 bg-orange-600/10 rounded-2xl border border-orange-500/20">
|
||||
<AlertCircle className="text-orange-500 shrink-0 mt-0.5" size={18} />
|
||||
<div>
|
||||
<p className="text-sm font-bold text-white mb-1">{t('splits.loginToParticipate')}</p>
|
||||
<p className="text-xs text-orange-200/60 leading-relaxed">
|
||||
{t('splits.loginToParticipateDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => window.location.href = `/login?redirect=/splits/${slug}`}
|
||||
className="w-full py-4 bg-orange-600 hover:bg-orange-500 text-white rounded-2xl font-bold flex items-center justify-center gap-2 shadow-xl shadow-orange-950/20 transition-all active:scale-[0.98]"
|
||||
>
|
||||
<LogIn size={20} />
|
||||
{t('home.logout').replace('Logout', 'Login').replace('Abmelden', 'Anmelden')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requestSuccess && (
|
||||
<div className="bg-green-500/10 border border-green-500/30 rounded-3xl p-6 text-center">
|
||||
<CheckCircle2 size={48} className="mx-auto text-green-500 mb-4" />
|
||||
<h3 className="text-lg font-bold text-white mb-2">Anfrage gesendet!</h3>
|
||||
<h3 className="text-lg font-bold text-white mb-2">{t('splits.requestSent')}</h3>
|
||||
<p className="text-zinc-400 text-sm">
|
||||
Der Host wird deine Anfrage prüfen und sich bei dir melden.
|
||||
{t('splits.requestSentDesc')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -290,17 +317,17 @@ export default function SplitPublicPage() {
|
||||
<div className="bg-zinc-900 rounded-3xl p-6 border border-zinc-800">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${['APPROVED', 'PAID', 'SHIPPED'].includes(userParticipation.status)
|
||||
? 'bg-green-500/20 text-green-500'
|
||||
: userParticipation.status === 'PENDING'
|
||||
? 'bg-yellow-500/20 text-yellow-500'
|
||||
: 'bg-red-500/20 text-red-500'
|
||||
? 'bg-green-500/20 text-green-500'
|
||||
: userParticipation.status === 'PENDING'
|
||||
? 'bg-yellow-500/20 text-yellow-500'
|
||||
: 'bg-red-500/20 text-red-500'
|
||||
}`}>
|
||||
{userParticipation.status === 'SHIPPED' ? <Package size={24} /> :
|
||||
userParticipation.status === 'PENDING' ? <Clock size={24} /> :
|
||||
<CheckCircle2 size={24} />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-white">Du nimmst teil</p>
|
||||
<p className="text-sm font-bold text-white">{t('splits.youAreParticipating')}</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{userParticipation.amountCl}cl · {userParticipation.totalCost.toFixed(2)}€ ·
|
||||
Status: {userParticipation.status}
|
||||
@@ -310,19 +337,6 @@ export default function SplitPublicPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!currentUserId && (
|
||||
<div className="bg-zinc-900 rounded-3xl p-6 border border-zinc-800 text-center">
|
||||
<User size={32} className="mx-auto text-zinc-500 mb-3" />
|
||||
<p className="text-zinc-400 mb-4">Melde dich an, um teilzunehmen</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-block px-6 py-3 bg-orange-600 text-white rounded-xl font-bold"
|
||||
>
|
||||
Anmelden
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentUserId === split.hostId && (
|
||||
<div className="bg-zinc-900 rounded-3xl p-6 border border-zinc-800">
|
||||
<p className="text-sm text-zinc-500 mb-4">Du bist der Host dieses Splits</p>
|
||||
@@ -340,7 +354,7 @@ export default function SplitPublicPage() {
|
||||
className="w-full py-4 bg-zinc-900 hover:bg-zinc-800 border border-zinc-800 rounded-2xl text-zinc-400 font-bold flex items-center justify-center gap-2 transition-colors"
|
||||
>
|
||||
<Share2 size={18} />
|
||||
Link teilen
|
||||
{t('splits.shareLink')}
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -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">
|
||||
|
||||
110
src/i18n/de.ts
110
src/i18n/de.ts
@@ -1,6 +1,29 @@
|
||||
import { TranslationKeys } from './types';
|
||||
|
||||
export const de: TranslationKeys = {
|
||||
splits: {
|
||||
joinTitle: 'Sample bestellen',
|
||||
amount: 'Menge',
|
||||
shipping: 'Versand',
|
||||
whisky: 'Whisky',
|
||||
glass: 'Sample-Flasche',
|
||||
total: 'Gesamt',
|
||||
requestSent: 'Anfrage gesendet!',
|
||||
requestSentDesc: 'Der Host wird deine Anfrage prüfen und sich bei dir melden.',
|
||||
loginToParticipate: 'Anmelden zum Teilnehmen',
|
||||
loginToParticipateDesc: 'Um an dieser Flaschenteilung teilzunehmen, musst du angemeldet sein.',
|
||||
publicExplore: 'Aktuelle Flaschenteilungen',
|
||||
waitlist: 'Auf Warteliste setzen',
|
||||
sendRequest: 'Anfrage senden',
|
||||
youAreParticipating: 'Du nimmst teil',
|
||||
byHost: 'Von',
|
||||
shareLink: 'Link teilen',
|
||||
backToStart: 'Zurück zum Start',
|
||||
noSplitsFound: 'Keine aktiven Teilungen gefunden',
|
||||
falscheTeilung: 'Flaschenteilung',
|
||||
clFlasche: 'cl Flasche',
|
||||
jahre: 'Jahre',
|
||||
},
|
||||
common: {
|
||||
save: 'Speichern',
|
||||
cancel: 'Abbrechen',
|
||||
@@ -37,9 +60,14 @@ export const de: TranslationKeys = {
|
||||
},
|
||||
searchPlaceholder: 'Flaschen oder Noten suchen...',
|
||||
noBottles: 'Keine Flaschen gefunden. Zeit für einen Einkauf! 🥃',
|
||||
collection: 'Deine Sammlung',
|
||||
reTry: 'Erneut versuchen',
|
||||
collection: 'Kollektion',
|
||||
reTry: 'Nochmal versuchen',
|
||||
all: 'Alle',
|
||||
tagline: 'Modernes Minimalistisches Tasting-Tool.',
|
||||
bottleCount: 'Flaschen',
|
||||
imprint: 'Impressum',
|
||||
privacy: 'Datenschutz',
|
||||
settings: 'Einstellungen',
|
||||
},
|
||||
grid: {
|
||||
searchPlaceholder: 'Suchen nach Name oder Distille...',
|
||||
@@ -165,6 +193,84 @@ export const de: TranslationKeys = {
|
||||
noSessions: 'Noch keine Sessions vorhanden.',
|
||||
expiryWarning: 'Diese Session läuft bald ab.',
|
||||
},
|
||||
nav: {
|
||||
home: 'Home',
|
||||
shelf: 'Sammlung',
|
||||
activity: 'Aktivität',
|
||||
search: 'Suchen',
|
||||
profile: 'Profil',
|
||||
},
|
||||
hub: {
|
||||
title: 'Activity Hub',
|
||||
subtitle: 'Live-Events & Splits',
|
||||
tabs: {
|
||||
tastings: 'Tastings',
|
||||
splits: 'Splits',
|
||||
},
|
||||
sections: {
|
||||
startSession: 'Neue Session starten',
|
||||
startSplit: 'Neuen Split starten',
|
||||
activeNow: 'Gerade aktiv',
|
||||
yourSessions: 'Deine Sessions',
|
||||
yourSplits: 'Deine Splits',
|
||||
participating: 'Teilnahmen',
|
||||
},
|
||||
placeholders: {
|
||||
sessionName: 'Session-Name (z.B. Islay Nacht)',
|
||||
noSessions: 'Noch keine Sessions',
|
||||
noSplits: 'Noch keine Splits erstellt',
|
||||
openSplitCreator: 'Split-Creator öffnen',
|
||||
},
|
||||
},
|
||||
tutorial: {
|
||||
skip: 'Überspringen',
|
||||
next: 'Weiter',
|
||||
finish: 'Los geht\'s!',
|
||||
steps: {
|
||||
welcome: {
|
||||
title: 'Willkommen bei DramLog!',
|
||||
desc: 'Dein persönliches Whisky-Tagebuch. Scanne, bewerte und entdecke neue Drams.',
|
||||
},
|
||||
scan: {
|
||||
title: 'Scanne deine Flaschen',
|
||||
desc: 'Fotografiere das Etikett einer Flasche – die KI erkennt automatisch alle Details.',
|
||||
},
|
||||
taste: {
|
||||
title: 'Bewerte deine Drams',
|
||||
desc: 'Füge Tasting-Notizen hinzu und behalte den Überblick über deine Lieblings-Whiskys.',
|
||||
},
|
||||
activity: {
|
||||
title: 'Aktivitätshub',
|
||||
desc: 'Organisiere Tasting-Sessions mit Freunden oder nimm an exklusiven Bottle Splits teil.',
|
||||
},
|
||||
ready: {
|
||||
title: 'Bereit zum Start!',
|
||||
desc: 'Scanne jetzt deine erste Flasche mit dem orangefarbenen Button unten.',
|
||||
},
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
title: 'Einstellungen',
|
||||
language: 'Sprache',
|
||||
cookieSettings: 'Cookie-Einstellungen',
|
||||
cookieDesc: 'Diese App verwendet nur technisch notwendige Cookies für die Authentifizierung und funktionale Cookies für UI-Präferenzen.',
|
||||
cookieNecessary: 'Notwendig: Supabase Auth Cookies',
|
||||
cookieFunctional: 'Funktional: Sprache, UI-Status',
|
||||
privacy: 'Datenschutz',
|
||||
privacyDesc: 'Deine Daten werden sicher auf EU-Servern gespeichert.',
|
||||
privacyLink: 'Datenschutzerklärung lesen',
|
||||
memberSince: 'Mitglied seit',
|
||||
password: {
|
||||
title: 'Passwort ändern',
|
||||
newPassword: 'Neues Passwort',
|
||||
confirmPassword: 'Passwort bestätigen',
|
||||
match: 'Passwörter stimmen überein',
|
||||
mismatch: 'Passwörter stimmen nicht überein',
|
||||
tooShort: 'Passwort muss mindestens 6 Zeichen lang sein',
|
||||
success: 'Passwort erfolgreich geändert!',
|
||||
change: 'Passwort ändern',
|
||||
},
|
||||
},
|
||||
aroma: {
|
||||
'Apfel': 'Apfel',
|
||||
'Grüner Apfel': 'Grüner Apfel',
|
||||
|
||||
110
src/i18n/en.ts
110
src/i18n/en.ts
@@ -1,6 +1,29 @@
|
||||
import { TranslationKeys } from './types';
|
||||
|
||||
export const en: TranslationKeys = {
|
||||
splits: {
|
||||
joinTitle: 'Order Sample',
|
||||
amount: 'Amount',
|
||||
shipping: 'Shipping',
|
||||
whisky: 'Whisky',
|
||||
glass: 'Sample Bottle',
|
||||
total: 'Total',
|
||||
requestSent: 'Request sent!',
|
||||
requestSentDesc: 'The host will review your request and get back to you.',
|
||||
loginToParticipate: 'Login to participate',
|
||||
loginToParticipateDesc: 'You must be logged in to participate in this bottle split.',
|
||||
publicExplore: 'Active Bottle Splits',
|
||||
waitlist: 'Join Waitlist',
|
||||
sendRequest: 'Send Request',
|
||||
youAreParticipating: 'You are participating',
|
||||
byHost: 'By',
|
||||
shareLink: 'Share Link',
|
||||
backToStart: 'Back to start',
|
||||
noSplitsFound: 'No active splits found',
|
||||
falscheTeilung: 'Bottle Split',
|
||||
clFlasche: 'cl Bottle',
|
||||
jahre: 'Years',
|
||||
},
|
||||
common: {
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
@@ -37,9 +60,14 @@ export const en: TranslationKeys = {
|
||||
},
|
||||
searchPlaceholder: 'Search bottles or notes...',
|
||||
noBottles: 'No bottles found. Time to go shopping! 🥃',
|
||||
collection: 'Your Collection',
|
||||
reTry: 'Retry',
|
||||
collection: 'Collection',
|
||||
reTry: 'Try Again',
|
||||
all: 'All',
|
||||
tagline: 'Modern Minimalist Tasting Tool.',
|
||||
bottleCount: 'Bottles',
|
||||
imprint: 'Imprint',
|
||||
privacy: 'Privacy',
|
||||
settings: 'Settings',
|
||||
},
|
||||
grid: {
|
||||
searchPlaceholder: 'Search by name or distillery...',
|
||||
@@ -165,6 +193,84 @@ export const en: TranslationKeys = {
|
||||
noSessions: 'No sessions yet.',
|
||||
expiryWarning: 'This session will expire soon.',
|
||||
},
|
||||
nav: {
|
||||
home: 'Home',
|
||||
shelf: 'Shelf',
|
||||
activity: 'Activity',
|
||||
search: 'Search',
|
||||
profile: 'Profile',
|
||||
},
|
||||
hub: {
|
||||
title: 'Activity Hub',
|
||||
subtitle: 'Live Events & Splits',
|
||||
tabs: {
|
||||
tastings: 'Tastings',
|
||||
splits: 'Splits',
|
||||
},
|
||||
sections: {
|
||||
startSession: 'Start New Session',
|
||||
startSplit: 'Start New Split',
|
||||
activeNow: 'Active Right Now',
|
||||
yourSessions: 'Your Sessions',
|
||||
yourSplits: 'Your Splits',
|
||||
participating: 'Participating',
|
||||
},
|
||||
placeholders: {
|
||||
sessionName: 'Session Name (e.g. Islay Night)',
|
||||
noSessions: 'No sessions yet',
|
||||
noSplits: 'No splits created',
|
||||
openSplitCreator: 'Open Split Creator',
|
||||
},
|
||||
},
|
||||
tutorial: {
|
||||
skip: 'Skip',
|
||||
next: 'Next',
|
||||
finish: 'Let\'s go!',
|
||||
steps: {
|
||||
welcome: {
|
||||
title: 'Welcome to DramLog!',
|
||||
desc: 'Your personal whisky diary. Scan, rate and discover new drams.',
|
||||
},
|
||||
scan: {
|
||||
title: 'Scan your bottles',
|
||||
desc: 'Take a photo of a bottle label – AI automatically recognizes all details.',
|
||||
},
|
||||
taste: {
|
||||
title: 'Rate your drams',
|
||||
desc: 'Add tasting notes and keep track of your favorite whiskies.',
|
||||
},
|
||||
activity: {
|
||||
title: 'Activity Hub',
|
||||
desc: 'Organize tasting sessions with friends or join exclusive bottle splits.',
|
||||
},
|
||||
ready: {
|
||||
title: 'Ready to start!',
|
||||
desc: 'Scan your first bottle now using the orange button below.',
|
||||
},
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
language: 'Language',
|
||||
cookieSettings: 'Cookie Settings',
|
||||
cookieDesc: 'This app uses only technically necessary cookies for authentication and functional cookies for UI preferences.',
|
||||
cookieNecessary: 'Necessary: Supabase Auth Cookies',
|
||||
cookieFunctional: 'Functional: Language, UI Status',
|
||||
privacy: 'Privacy',
|
||||
privacyDesc: 'Your data is securely stored on EU servers.',
|
||||
privacyLink: 'Read Privacy Policy',
|
||||
memberSince: 'Member since',
|
||||
password: {
|
||||
title: 'Change Password',
|
||||
newPassword: 'New Password',
|
||||
confirmPassword: 'Confirm Password',
|
||||
match: 'Passwords match',
|
||||
mismatch: 'Passwords do not match',
|
||||
tooShort: 'Password must be at least 6 characters long',
|
||||
success: 'Password successfully changed!',
|
||||
change: 'Change Password',
|
||||
},
|
||||
},
|
||||
aroma: {
|
||||
'Apfel': 'Apple',
|
||||
'Grüner Apfel': 'Green Apple',
|
||||
|
||||
@@ -1,4 +1,27 @@
|
||||
export type TranslationKeys = {
|
||||
splits: {
|
||||
joinTitle: string;
|
||||
amount: string;
|
||||
shipping: string;
|
||||
whisky: string;
|
||||
glass: string;
|
||||
total: string;
|
||||
requestSent: string;
|
||||
requestSentDesc: string;
|
||||
loginToParticipate: string;
|
||||
loginToParticipateDesc: string;
|
||||
publicExplore: string;
|
||||
waitlist: string;
|
||||
sendRequest: string;
|
||||
youAreParticipating: string;
|
||||
byHost: string;
|
||||
shareLink: string;
|
||||
backToStart: string;
|
||||
noSplitsFound: string;
|
||||
falscheTeilung: string;
|
||||
clFlasche: string;
|
||||
jahre: string;
|
||||
};
|
||||
common: {
|
||||
save: string;
|
||||
cancel: string;
|
||||
@@ -38,6 +61,11 @@ export type TranslationKeys = {
|
||||
collection: string;
|
||||
reTry: string;
|
||||
all: string;
|
||||
tagline: string;
|
||||
bottleCount: string;
|
||||
imprint: string;
|
||||
privacy: string;
|
||||
settings: string;
|
||||
};
|
||||
grid: {
|
||||
searchPlaceholder: string;
|
||||
@@ -163,5 +191,68 @@ export type TranslationKeys = {
|
||||
noSessions: string;
|
||||
expiryWarning: string;
|
||||
};
|
||||
nav: {
|
||||
home: string;
|
||||
shelf: string;
|
||||
activity: string;
|
||||
search: string;
|
||||
profile: string;
|
||||
};
|
||||
hub: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
tabs: {
|
||||
tastings: string;
|
||||
splits: string;
|
||||
};
|
||||
sections: {
|
||||
startSession: string;
|
||||
startSplit: string;
|
||||
activeNow: string;
|
||||
yourSessions: string;
|
||||
yourSplits: string;
|
||||
participating: string;
|
||||
};
|
||||
placeholders: {
|
||||
sessionName: string;
|
||||
noSessions: string;
|
||||
noSplits: string;
|
||||
openSplitCreator: string;
|
||||
};
|
||||
};
|
||||
tutorial: {
|
||||
skip: string;
|
||||
next: string;
|
||||
finish: string;
|
||||
steps: {
|
||||
welcome: { title: string; desc: string };
|
||||
scan: { title: string; desc: string };
|
||||
taste: { title: string; desc: string };
|
||||
activity: { title: string; desc: string };
|
||||
ready: { title: string; desc: string };
|
||||
};
|
||||
};
|
||||
settings: {
|
||||
title: string;
|
||||
language: string;
|
||||
cookieSettings: string;
|
||||
cookieDesc: string;
|
||||
cookieNecessary: string;
|
||||
cookieFunctional: string;
|
||||
privacy: string;
|
||||
privacyDesc: string;
|
||||
privacyLink: string;
|
||||
memberSince: string;
|
||||
password: {
|
||||
title: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
match: string;
|
||||
mismatch: string;
|
||||
tooShort: string;
|
||||
success: string;
|
||||
change: string;
|
||||
};
|
||||
};
|
||||
aroma: Record<string, string>;
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function saveTasting(rawData: TastingNoteData) {
|
||||
}
|
||||
}
|
||||
|
||||
const { data: tasting, error } = await supabase
|
||||
const { data: tasting, error: insertError } = await supabase
|
||||
.from('tastings')
|
||||
.insert({
|
||||
bottle_id: data.bottle_id,
|
||||
@@ -39,7 +39,29 @@ export async function saveTasting(rawData: TastingNoteData) {
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
if (insertError) {
|
||||
console.error('[saveTasting] Insert error:', {
|
||||
code: insertError.code,
|
||||
message: insertError.message,
|
||||
details: insertError.details,
|
||||
hint: insertError.hint,
|
||||
data: {
|
||||
bottle_id: data.bottle_id,
|
||||
user_id: user.id,
|
||||
session_id: data.session_id
|
||||
}
|
||||
});
|
||||
|
||||
// Check for RLS violation (42501)
|
||||
if ((insertError as any).code === '42501') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Keine Berechtigung zum Speichern (RLS). Prüfe ob du Besitzer der Flasche bist oder in einer aktiven Session teilnimmst.',
|
||||
code: 'RLS_VIOLATION'
|
||||
};
|
||||
}
|
||||
throw insertError;
|
||||
}
|
||||
|
||||
// Add buddy tags if any
|
||||
if (data.buddy_ids && data.buddy_ids.length > 0) {
|
||||
@@ -78,11 +100,11 @@ export async function saveTasting(rawData: TastingNoteData) {
|
||||
revalidatePath(`/bottles/${data.bottle_id}`);
|
||||
|
||||
return { success: true, data: tasting };
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('Save Tasting Error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Speichern der Tasting Note',
|
||||
error: error instanceof Error ? error.message : (error?.message || 'Fehler beim Speichern.'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,6 +206,11 @@ export async function getSplitBySlug(slug: string): Promise<{
|
||||
const remaining = available - taken - reserved;
|
||||
const bottle = split.bottles as any;
|
||||
|
||||
if (!bottle) {
|
||||
console.error(`Split ${slug} has no associated bottle data.`);
|
||||
return { success: false, error: 'Flaschendaten für diesen Split fehlen.' };
|
||||
}
|
||||
|
||||
// Convert sample sizes from DB format
|
||||
const sampleSizes = ((split.sample_sizes as any[]) || []).map(s => ({
|
||||
cl: s.cl,
|
||||
@@ -228,8 +233,8 @@ export async function getSplitBySlug(slug: string): Promise<{
|
||||
createdAt: split.created_at,
|
||||
bottle: {
|
||||
id: bottle.id,
|
||||
name: bottle.name,
|
||||
distillery: bottle.distillery,
|
||||
name: bottle.name || 'Unbekannte Flasche',
|
||||
distillery: bottle.distillery || 'Unbekannte Destillerie',
|
||||
imageUrl: bottle.image_url,
|
||||
abv: bottle.abv,
|
||||
age: bottle.age,
|
||||
@@ -500,8 +505,84 @@ export async function getHostSplits(): Promise<{
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate forum export text
|
||||
* Get all splits the current user is participating in
|
||||
*/
|
||||
export async function getParticipatingSplits(): Promise<{
|
||||
success: boolean;
|
||||
splits?: Array<{
|
||||
id: string;
|
||||
slug: string;
|
||||
bottleName: string;
|
||||
bottleImage?: string;
|
||||
totalVolume: number;
|
||||
hostShare: number;
|
||||
participantCount: number;
|
||||
amountCl: number;
|
||||
status: string;
|
||||
isActive: boolean;
|
||||
hostName?: string;
|
||||
}>;
|
||||
error?: string;
|
||||
}> {
|
||||
const supabase = await createClient();
|
||||
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return { success: false, error: 'Nicht autorisiert' };
|
||||
}
|
||||
|
||||
const { data: participations, error } = await supabase
|
||||
.from('split_participants')
|
||||
.select(`
|
||||
amount_cl,
|
||||
status,
|
||||
bottle_splits!inner (
|
||||
id,
|
||||
public_slug,
|
||||
total_volume,
|
||||
host_share,
|
||||
is_active,
|
||||
host_id,
|
||||
bottles (name, image_url),
|
||||
profiles:host_id (username)
|
||||
)
|
||||
`)
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('getParticipatingSplits error:', error);
|
||||
return { success: false, error: 'Fehler beim Laden' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
splits: (participations || []).map(p => {
|
||||
const split = p.bottle_splits as any;
|
||||
const bottle = split.bottles as any;
|
||||
const hostProfile = split.profiles as any;
|
||||
|
||||
return {
|
||||
id: split.id,
|
||||
slug: split.public_slug,
|
||||
bottleName: bottle?.name || 'Unbekannt',
|
||||
bottleImage: bottle?.image_url,
|
||||
totalVolume: split.total_volume,
|
||||
hostShare: split.host_share,
|
||||
participantCount: 0, // We could count this but might be overkill for list view
|
||||
amountCl: p.amount_cl,
|
||||
status: p.status,
|
||||
isActive: split.is_active,
|
||||
hostName: hostProfile?.username || 'Host',
|
||||
};
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getParticipatingSplits unexpected error:', error);
|
||||
return { success: false, error: 'Unerwarteter Fehler' };
|
||||
}
|
||||
}
|
||||
export async function generateForumExport(splitId: string): Promise<{
|
||||
success: boolean;
|
||||
text?: string;
|
||||
@@ -603,3 +684,51 @@ export async function closeSplit(splitId: string): Promise<{
|
||||
return { success: false, error: 'Unerwarteter Fehler' };
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Get all active splits for public discovery
|
||||
*/
|
||||
export async function getActiveSplits() {
|
||||
const supabase = await createClient();
|
||||
|
||||
try {
|
||||
const { data: splits, error } = await supabase
|
||||
.from('bottle_splits')
|
||||
.select(`
|
||||
id,
|
||||
public_slug,
|
||||
total_volume,
|
||||
host_share,
|
||||
is_active,
|
||||
bottles (name, image_url, distillery),
|
||||
profiles:host_id (username)
|
||||
`)
|
||||
.eq('is_active', true)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('getActiveSplits error:', error);
|
||||
return { success: false, error: 'Fehler beim Laden' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
splits: (splits || []).map(s => {
|
||||
const bottle = s.bottles as any;
|
||||
const hostProfile = s.profiles as any;
|
||||
return {
|
||||
id: s.id,
|
||||
slug: s.public_slug,
|
||||
bottleName: bottle?.name || 'Unbekannt',
|
||||
bottleImage: bottle?.image_url,
|
||||
distillery: bottle?.distillery,
|
||||
totalVolume: s.total_volume,
|
||||
hostShare: s.host_share,
|
||||
hostName: hostProfile?.username || 'Host',
|
||||
};
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getActiveSplits unexpected error:', error);
|
||||
return { success: false, error: 'Unerwarteter Fehler' };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user