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" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <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
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// 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 OfflineIndicator from "@/components/OfflineIndicator";
|
||||||
import { useI18n } from "@/i18n/I18nContext";
|
import { useI18n } from "@/i18n/I18nContext";
|
||||||
import { useSession } from "@/context/SessionContext";
|
import { useSession } from "@/context/SessionContext";
|
||||||
|
import TastingHub from "@/components/TastingHub";
|
||||||
import { Sparkles, X, Loader2 } from "lucide-react";
|
import { Sparkles, X, Loader2 } from "lucide-react";
|
||||||
import { BottomNavigation } from '@/components/BottomNavigation';
|
import { BottomNavigation } from '@/components/BottomNavigation';
|
||||||
import ScanAndTasteFlow from '@/components/ScanAndTasteFlow';
|
import ScanAndTasteFlow from '@/components/ScanAndTasteFlow';
|
||||||
import UserStatusBadge from '@/components/UserStatusBadge';
|
import UserStatusBadge from '@/components/UserStatusBadge';
|
||||||
|
import { getActiveSplits } from '@/services/split-actions';
|
||||||
|
import SplitCard from '@/components/SplitCard';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
@@ -28,8 +31,10 @@ export default function Home() {
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { activeSession } = useSession();
|
const { activeSession } = useSession();
|
||||||
const [isFlowOpen, setIsFlowOpen] = useState(false);
|
const [isFlowOpen, setIsFlowOpen] = useState(false);
|
||||||
|
const [isTastingHubOpen, setIsTastingHubOpen] = useState(false);
|
||||||
const [capturedFile, setCapturedFile] = useState<File | null>(null);
|
const [capturedFile, setCapturedFile] = useState<File | null>(null);
|
||||||
const [hasMounted, setHasMounted] = useState(false);
|
const [hasMounted, setHasMounted] = useState(false);
|
||||||
|
const [publicSplits, setPublicSplits] = useState<any[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHasMounted(true);
|
setHasMounted(true);
|
||||||
@@ -74,6 +79,13 @@ export default function Home() {
|
|||||||
|
|
||||||
checkUser();
|
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)
|
// Listen for visibility change (wake up from sleep)
|
||||||
const handleVisibilityChange = () => {
|
const handleVisibilityChange = () => {
|
||||||
if (document.visibilityState === 'visible') {
|
if (document.visibilityState === 'visible') {
|
||||||
@@ -153,19 +165,33 @@ export default function Home() {
|
|||||||
|
|
||||||
setBottles(processedBottles);
|
setBottles(processedBottles);
|
||||||
} catch (err: any) {
|
} 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 ||
|
const isNetworkError = !navigator.onLine ||
|
||||||
err.message?.includes('Failed to fetch') ||
|
err?.name === 'TypeError' ||
|
||||||
err.message?.includes('NetworkError') ||
|
err?.message?.includes('Failed to fetch') ||
|
||||||
err.message?.includes('ERR_INTERNET_DISCONNECTED') ||
|
err?.message?.includes('NetworkError') ||
|
||||||
(err && Object.keys(err).length === 0); // Empty error object from Supabase when offline
|
err?.message?.includes('ERR_INTERNET_DISCONNECTED') ||
|
||||||
|
(err && typeof err === 'object' && !err.message && Object.keys(err).length === 0);
|
||||||
|
|
||||||
if (isNetworkError) {
|
if (isNetworkError) {
|
||||||
console.log('[fetchCollection] Skipping due to offline mode');
|
console.log('[fetchCollection] Skipping due to offline mode or network error');
|
||||||
setFetchError(null);
|
setFetchError(null);
|
||||||
} else {
|
} else {
|
||||||
console.error('Detailed fetch error:', err);
|
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 {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -192,13 +218,33 @@ export default function Home() {
|
|||||||
DRAM<span className="text-orange-600">LOG</span>
|
DRAM<span className="text-orange-600">LOG</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-zinc-500 max-w-sm mx-auto font-bold tracking-wide">
|
<p className="text-zinc-500 max-w-sm mx-auto font-bold tracking-wide">
|
||||||
Modern Minimalist Tasting Tool.
|
{t('home.tagline')}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AuthForm />
|
<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>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -254,10 +300,10 @@ export default function Home() {
|
|||||||
<div className="w-full mt-4" id="collection">
|
<div className="w-full mt-4" id="collection">
|
||||||
<div className="flex items-end justify-between mb-8">
|
<div className="flex items-end justify-between mb-8">
|
||||||
<h2 className="text-3xl font-bold text-zinc-50 uppercase tracking-tight">
|
<h2 className="text-3xl font-bold text-zinc-50 uppercase tracking-tight">
|
||||||
Collection
|
{t('home.collection')}
|
||||||
</h2>
|
</h2>
|
||||||
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest pb-1">
|
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest pb-1">
|
||||||
{bottles.length} Bottles
|
{bottles.length} {t('home.bottleCount')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -285,20 +331,25 @@ export default function Home() {
|
|||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="pb-28 pt-8 text-center">
|
<footer className="pb-28 pt-8 text-center">
|
||||||
<div className="flex justify-center gap-4 text-xs text-zinc-600">
|
<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>
|
<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>
|
<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>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<BottomNavigation
|
<BottomNavigation
|
||||||
onScan={handleImageSelected}
|
|
||||||
onHome={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
onHome={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||||
onShelf={() => document.getElementById('collection')?.scrollIntoView({ behavior: 'smooth' })}
|
onShelf={() => document.getElementById('collection')?.scrollIntoView({ behavior: 'smooth' })}
|
||||||
onSearch={() => document.getElementById('search-filter')?.scrollIntoView({ behavior: 'smooth' })}
|
onTastings={() => setIsTastingHubOpen(true)}
|
||||||
onProfile={() => router.push('/settings')}
|
onProfile={() => router.push('/settings')}
|
||||||
|
onScan={handleImageSelected}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TastingHub
|
||||||
|
isOpen={isTastingHubOpen}
|
||||||
|
onClose={() => setIsTastingHubOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ScanAndTasteFlow
|
<ScanAndTasteFlow
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { getProfile } from '@/services/profile-actions';
|
import { getProfile } from '@/services/profile-actions';
|
||||||
import ProfileForm from '@/components/ProfileForm';
|
import SettingsHub from '@/components/SettingsHub';
|
||||||
import PasswordChangeForm from '@/components/PasswordChangeForm';
|
|
||||||
import { ArrowLeft, Settings, Cookie, Shield } from 'lucide-react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Einstellungen',
|
title: 'Einstellungen | Settings',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
@@ -20,88 +17,17 @@ export default async function SettingsPage() {
|
|||||||
|
|
||||||
const profile = await getProfile();
|
const profile = await getProfile();
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
return <div>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-950">
|
<SettingsHub
|
||||||
{/* Header */}
|
profile={{
|
||||||
<header className="sticky top-0 z-40 bg-zinc-950/80 backdrop-blur-lg border-b border-zinc-800">
|
email: profile.email,
|
||||||
<div className="max-w-2xl mx-auto px-4 py-4 flex items-center gap-4">
|
username: profile.username,
|
||||||
<Link
|
created_at: profile.created_at
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,15 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
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 { getSplitBySlug, requestSlot, SplitDetails, SampleSize, ShippingOption } from '@/services/split-actions';
|
||||||
import SplitProgressBar from '@/components/SplitProgressBar';
|
import SplitProgressBar from '@/components/SplitProgressBar';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
|
|
||||||
export default function SplitPublicPage() {
|
export default function SplitPublicPage() {
|
||||||
const { slug } = useParams();
|
const { slug } = useParams();
|
||||||
|
const { t } = useI18n();
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const [split, setSplit] = useState<SplitDetails | null>(null);
|
const [split, setSplit] = useState<SplitDetails | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@@ -44,7 +46,7 @@ export default function SplitPublicPage() {
|
|||||||
setSelectedAmount(result.data.sampleSizes[0].cl);
|
setSelectedAmount(result.data.sampleSizes[0].cl);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setError(result.error || 'Split nicht gefunden');
|
setError(result.error || t('splits.noSplitsFound'));
|
||||||
}
|
}
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
@@ -69,6 +71,10 @@ export default function SplitPublicPage() {
|
|||||||
|
|
||||||
const handleRequest = async () => {
|
const handleRequest = async () => {
|
||||||
if (!selectedShipping || !selectedAmount) return;
|
if (!selectedShipping || !selectedAmount) return;
|
||||||
|
if (!currentUserId) {
|
||||||
|
window.location.href = `/login?redirect=/splits/${slug}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsRequesting(true);
|
setIsRequesting(true);
|
||||||
setRequestError(null);
|
setRequestError(null);
|
||||||
@@ -86,7 +92,7 @@ export default function SplitPublicPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const userParticipation = split?.participants.find(p => p.userId === currentUserId);
|
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;
|
const isWaitlist = split && selectedAmount && split.remaining < selectedAmount;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -101,8 +107,8 @@ export default function SplitPublicPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col items-center justify-center gap-4 bg-zinc-950 p-6">
|
<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" />
|
<AlertCircle size={48} className="text-red-500" />
|
||||||
<h1 className="text-xl font-bold text-zinc-50">{error || 'Split nicht gefunden'}</h1>
|
<h1 className="text-xl font-bold text-zinc-50">{error || t('splits.noSplitsFound')}</h1>
|
||||||
<Link href="/" className="text-orange-600 font-bold">Zurück zum Start</Link>
|
<Link href="/" className="text-orange-600 font-bold">{t('splits.backToStart')}</Link>
|
||||||
</div>
|
</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]"
|
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} />
|
<ChevronLeft size={16} />
|
||||||
Zurück
|
{t('common.back')}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
@@ -136,7 +142,7 @@ export default function SplitPublicPage() {
|
|||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] font-black uppercase tracking-widest text-orange-500 mb-1">
|
<p className="text-[10px] font-black uppercase tracking-widest text-orange-500 mb-1">
|
||||||
Flaschenteilung
|
{t('splits.falscheTeilung')}
|
||||||
</p>
|
</p>
|
||||||
<h1 className="text-2xl md:text-3xl font-black text-zinc-50">
|
<h1 className="text-2xl md:text-3xl font-black text-zinc-50">
|
||||||
{split.bottle.name}
|
{split.bottle.name}
|
||||||
@@ -154,11 +160,11 @@ export default function SplitPublicPage() {
|
|||||||
)}
|
)}
|
||||||
{split.bottle.age && (
|
{split.bottle.age && (
|
||||||
<span className="px-3 py-1.5 bg-zinc-800 rounded-xl text-xs font-bold text-zinc-300">
|
<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>
|
||||||
)}
|
)}
|
||||||
<span className="px-3 py-1.5 bg-zinc-800 rounded-xl text-xs font-bold text-zinc-300">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -175,23 +181,23 @@ export default function SplitPublicPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Request Form */}
|
{/* Request Form */}
|
||||||
{canRequest && !requestSuccess && (
|
{showRequestForm && !requestSuccess && (
|
||||||
<div className="bg-zinc-900 rounded-3xl p-6 border border-zinc-800 space-y-6">
|
<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">
|
<h2 className="text-sm font-black uppercase tracking-widest text-zinc-400">
|
||||||
Sample bestellen
|
{t('splits.joinTitle')}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Amount Selection */}
|
{/* Amount Selection */}
|
||||||
<div className="space-y-3">
|
<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">
|
<div className="flex flex-wrap gap-2">
|
||||||
{split.sampleSizes.map(size => (
|
{split.sampleSizes.map(size => (
|
||||||
<button
|
<button
|
||||||
key={size.cl}
|
key={size.cl}
|
||||||
onClick={() => setSelectedAmount(size.cl)}
|
onClick={() => setSelectedAmount(size.cl)}
|
||||||
className={`px-4 py-3 rounded-xl border-2 transition-all ${selectedAmount === size.cl
|
className={`px-4 py-3 rounded-xl border-2 transition-all ${selectedAmount === size.cl
|
||||||
? 'border-orange-500 bg-orange-500/10'
|
? 'border-orange-500 bg-orange-500/10'
|
||||||
: 'border-zinc-700 hover:border-zinc-600'
|
: 'border-zinc-700 hover:border-zinc-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-lg font-black text-white">{size.cl}cl</span>
|
<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">
|
<div className="space-y-3">
|
||||||
<label className="text-xs font-bold text-zinc-500 uppercase tracking-widest flex items-center gap-2">
|
<label className="text-xs font-bold text-zinc-500 uppercase tracking-widest flex items-center gap-2">
|
||||||
<Truck size={14} />
|
<Truck size={14} />
|
||||||
Versand
|
{t('splits.shipping')}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{split.shippingOptions.map(option => (
|
{split.shippingOptions.map(option => (
|
||||||
@@ -212,8 +218,8 @@ export default function SplitPublicPage() {
|
|||||||
key={option.name}
|
key={option.name}
|
||||||
onClick={() => setSelectedShipping(option.name)}
|
onClick={() => setSelectedShipping(option.name)}
|
||||||
className={`px-4 py-3 rounded-xl border-2 transition-all text-left ${selectedShipping === 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-orange-500 bg-orange-500/10'
|
||||||
: 'border-zinc-700 hover:border-zinc-600'
|
: 'border-zinc-700 hover:border-zinc-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-sm font-bold text-white">{option.name}</span>
|
<span className="text-sm font-bold text-white">{option.name}</span>
|
||||||
@@ -227,19 +233,19 @@ export default function SplitPublicPage() {
|
|||||||
{selectedAmount && (
|
{selectedAmount && (
|
||||||
<div className="bg-zinc-950 rounded-2xl p-4 space-y-2">
|
<div className="bg-zinc-950 rounded-2xl p-4 space-y-2">
|
||||||
<div className="flex justify-between text-sm">
|
<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>
|
<span className="text-zinc-300">{price.whisky.toFixed(2)}€</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-sm">
|
<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>
|
<span className="text-zinc-300">{price.glass.toFixed(2)}€</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-sm">
|
<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>
|
<span className="text-zinc-300">{price.shipping.toFixed(2)}€</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-zinc-800 pt-2 mt-2 flex justify-between">
|
<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>
|
<span className="font-black text-xl text-orange-500">{price.total.toFixed(2)}€</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -251,37 +257,58 @@ export default function SplitPublicPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
{currentUserId ? (
|
||||||
onClick={handleRequest}
|
<button
|
||||||
disabled={isRequesting || !selectedShipping || !selectedAmount}
|
onClick={handleRequest}
|
||||||
className={`w-full py-4 rounded-2xl font-bold text-white transition-all flex items-center justify-center gap-2 ${isWaitlist
|
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-yellow-600 hover:bg-yellow-700'
|
||||||
: 'bg-orange-600 hover:bg-orange-700'
|
: 'bg-orange-600 hover:bg-orange-700'
|
||||||
} disabled:opacity-50`}
|
} disabled:opacity-50`}
|
||||||
>
|
>
|
||||||
{isRequesting ? (
|
{isRequesting ? (
|
||||||
<Loader2 size={20} className="animate-spin" />
|
<Loader2 size={20} className="animate-spin" />
|
||||||
) : isWaitlist ? (
|
) : isWaitlist ? (
|
||||||
<>
|
<>
|
||||||
<Clock size={20} />
|
<Clock size={20} />
|
||||||
Auf Warteliste setzen
|
{t('splits.waitlist')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Package size={20} />
|
<Package size={20} />
|
||||||
Anfrage senden
|
{t('splits.sendRequest')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{requestSuccess && (
|
{requestSuccess && (
|
||||||
<div className="bg-green-500/10 border border-green-500/30 rounded-3xl p-6 text-center">
|
<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" />
|
<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">
|
<p className="text-zinc-400 text-sm">
|
||||||
Der Host wird deine Anfrage prüfen und sich bei dir melden.
|
{t('splits.requestSentDesc')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -290,17 +317,17 @@ export default function SplitPublicPage() {
|
|||||||
<div className="bg-zinc-900 rounded-3xl p-6 border border-zinc-800">
|
<div className="bg-zinc-900 rounded-3xl p-6 border border-zinc-800">
|
||||||
<div className="flex items-center gap-4">
|
<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)
|
<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'
|
? 'bg-green-500/20 text-green-500'
|
||||||
: userParticipation.status === 'PENDING'
|
: userParticipation.status === 'PENDING'
|
||||||
? 'bg-yellow-500/20 text-yellow-500'
|
? 'bg-yellow-500/20 text-yellow-500'
|
||||||
: 'bg-red-500/20 text-red-500'
|
: 'bg-red-500/20 text-red-500'
|
||||||
}`}>
|
}`}>
|
||||||
{userParticipation.status === 'SHIPPED' ? <Package size={24} /> :
|
{userParticipation.status === 'SHIPPED' ? <Package size={24} /> :
|
||||||
userParticipation.status === 'PENDING' ? <Clock size={24} /> :
|
userParticipation.status === 'PENDING' ? <Clock size={24} /> :
|
||||||
<CheckCircle2 size={24} />}
|
<CheckCircle2 size={24} />}
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<p className="text-xs text-zinc-500">
|
||||||
{userParticipation.amountCl}cl · {userParticipation.totalCost.toFixed(2)}€ ·
|
{userParticipation.amountCl}cl · {userParticipation.totalCost.toFixed(2)}€ ·
|
||||||
Status: {userParticipation.status}
|
Status: {userParticipation.status}
|
||||||
@@ -310,19 +337,6 @@ export default function SplitPublicPage() {
|
|||||||
</div>
|
</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 && (
|
{currentUserId === split.hostId && (
|
||||||
<div className="bg-zinc-900 rounded-3xl p-6 border border-zinc-800">
|
<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>
|
<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"
|
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} />
|
<Share2 size={18} />
|
||||||
Link teilen
|
{t('splits.shareLink')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
|
|||||||
const [price, setPrice] = React.useState<string>('');
|
const [price, setPrice] = React.useState<string>('');
|
||||||
const [status, setStatus] = React.useState<string>('sealed');
|
const [status, setStatus] = React.useState<string>('sealed');
|
||||||
const [isUpdating, setIsUpdating] = React.useState(false);
|
const [isUpdating, setIsUpdating] = React.useState(false);
|
||||||
|
const [isEditMode, setIsEditMode] = React.useState(false);
|
||||||
const [isFormVisible, setIsFormVisible] = React.useState(false);
|
const [isFormVisible, setIsFormVisible] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -122,164 +123,154 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content Container */}
|
{/* 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 */}
|
{/* Title Section - HIG Large Title Pattern */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-1 text-center md:text-left">
|
||||||
{isOffline && (
|
{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">
|
<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" />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
<h2 className="text-sm font-black text-orange-600 uppercase tracking-[0.2em]">
|
<h2 className="text-xs md:text-sm font-black text-orange-500 uppercase tracking-[0.25em] drop-shadow-sm">
|
||||||
{bottle.distillery}
|
{bottle.distillery || 'Unknown Distillery'}
|
||||||
</h2>
|
</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}
|
{bottle.name}
|
||||||
</h1>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* 4. Inventory Section (Cohesive Container) */}
|
{/* Primary Bottle Profile Card */}
|
||||||
<section className="bg-zinc-800/30 backdrop-blur-xl border border-white/5 rounded-[40px] p-8 space-y-8">
|
<section className="bg-zinc-900/40 backdrop-blur-2xl border border-white/5 rounded-[32px] overflow-hidden shadow-2xl">
|
||||||
<div className="flex items-center justify-between mb-4">
|
{/* Integrated Header/Tabs */}
|
||||||
<h3 className="text-xs font-black uppercase tracking-[0.2em] text-zinc-500">Collection Stats</h3>
|
<div className="flex border-b border-white/5">
|
||||||
<Package size={18} className="text-zinc-700" />
|
<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>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<AnimatePresence mode="wait">
|
||||||
{/* Segmented Control for Status */}
|
{!isEditMode ? (
|
||||||
<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 && (
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, height: 0 }}
|
key="overview"
|
||||||
animate={{ opacity: 1, height: 'auto' }}
|
initial={{ opacity: 0, x: -20 }}
|
||||||
exit={{ opacity: 0, height: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
transition={{ duration: 0.3, ease: 'circOut' }}
|
exit={{ opacity: 0, x: 20 }}
|
||||||
className="overflow-hidden"
|
className="p-6 md:p-8 space-y-8"
|
||||||
>
|
>
|
||||||
<div className="pt-4 px-2">
|
{/* Fact Grid - Integrated Metadata & Stats */}
|
||||||
<EditBottleForm
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
bottle={bottle as any}
|
<FactCard label="Category" value={bottle.category || 'Whisky'} icon={<Wine size={14} />} />
|
||||||
onComplete={() => setIsFormVisible(false)}
|
<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>
|
</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>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</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>
|
</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">
|
<section className="space-y-8">
|
||||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-4">
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -335,3 +326,25 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
|
|||||||
</div>
|
</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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/bottles/${bottle.id}${sessionId ? `?session_id=${sessionId}` : ''}`}
|
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 */}
|
{/* Image Layer - Clean Split Top */}
|
||||||
<div className="aspect-[4/3] overflow-hidden">
|
<div className="aspect-[4/3] overflow-hidden shrink-0">
|
||||||
<img
|
<img
|
||||||
src={getStorageUrl(bottle.image_url)}
|
src={getStorageUrl(bottle.image_url)}
|
||||||
alt={bottle.name}
|
alt={bottle.name}
|
||||||
@@ -48,37 +48,39 @@ function BottleCard({ bottle, sessionId }: BottleCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info Layer - Clean Split Bottom */}
|
{/* 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">
|
<div className="space-y-1">
|
||||||
<p className="text-[10px] font-black text-orange-600 uppercase tracking-[0.2em] leading-none mb-1">
|
<p className="text-[10px] font-black text-orange-600 uppercase tracking-[0.2em] leading-none mb-1">
|
||||||
{bottle.distillery}
|
{bottle.distillery}
|
||||||
</p>
|
</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')}
|
{bottle.name || t('grid.unknownBottle')}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="space-y-4 pt-2">
|
||||||
<span className="px-2 py-0.5 bg-zinc-800 text-zinc-400 text-[9px] font-bold uppercase tracking-widest rounded-md">
|
<div className="flex flex-wrap gap-2">
|
||||||
{shortenCategory(bottle.category)}
|
<span className="px-2 py-1 bg-zinc-800 text-zinc-400 text-[9px] font-bold uppercase tracking-widest rounded-md">
|
||||||
</span>
|
{shortenCategory(bottle.category)}
|
||||||
<span className="px-2 py-0.5 bg-zinc-800 text-zinc-400 text-[9px] font-bold uppercase tracking-widest rounded-md">
|
</span>
|
||||||
{bottle.abv}% VOL
|
<span className="px-2 py-1 bg-zinc-800 text-zinc-400 text-[9px] font-bold uppercase tracking-widest rounded-md">
|
||||||
</span>
|
{bottle.abv}% VOL
|
||||||
</div>
|
</span>
|
||||||
|
|
||||||
{/* 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>
|
</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">
|
<div className="flex items-center gap-1 text-[10px] font-medium text-zinc-500">
|
||||||
<Clock size={12} className="text-zinc-500" />
|
<Calendar size={12} className="text-zinc-500" />
|
||||||
{new Date(bottle.last_tasted).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')}
|
{new Date(bottle.created_at).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
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 { usePathname } from 'next/navigation';
|
||||||
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
|
|
||||||
interface BottomNavigationProps {
|
interface BottomNavigationProps {
|
||||||
onHome?: () => void;
|
onHome?: () => void;
|
||||||
onShelf?: () => void;
|
onShelf?: () => void;
|
||||||
onSearch?: () => void;
|
onSearch?: () => void;
|
||||||
|
onTastings?: () => void;
|
||||||
onProfile?: () => void;
|
onProfile?: () => void;
|
||||||
onScan: (file: File) => void;
|
onScan: (file: File) => void;
|
||||||
}
|
}
|
||||||
@@ -17,20 +20,25 @@ interface NavButtonProps {
|
|||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
|
active?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NavButton = ({ onClick, icon, label, ariaLabel }: NavButtonProps) => (
|
const NavButton = ({ onClick, icon, label, ariaLabel, active }: NavButtonProps) => (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
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}
|
aria-label={ariaLabel}
|
||||||
>
|
>
|
||||||
{icon}
|
<div className={`transition-transform duration-300 ${active ? 'scale-110' : ''}`}>
|
||||||
<span className="text-[10px] font-bold tracking-tight">{label}</span>
|
{icon}
|
||||||
|
</div>
|
||||||
|
<span className={`text-[9px] font-black tracking-tight uppercase ${active ? 'opacity-100' : 'opacity-60'}`}>{label}</span>
|
||||||
</button>
|
</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 fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const handleScanClick = () => {
|
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 (
|
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 */}
|
{/* Hidden Input for Scanning */}
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
@@ -55,47 +68,56 @@ export const BottomNavigation = ({ onHome, onShelf, onSearch, onProfile, onScan
|
|||||||
className="hidden"
|
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 */}
|
{/* Left Items */}
|
||||||
<NavButton
|
<div className="flex-1 flex justify-around">
|
||||||
onClick={onHome}
|
<NavButton
|
||||||
icon={<Home size={20} strokeWidth={2.5} />}
|
onClick={onHome}
|
||||||
label="Start"
|
icon={<Home size={18} strokeWidth={2.5} />}
|
||||||
ariaLabel="Home"
|
label={t('nav.home')}
|
||||||
/>
|
active={isHome}
|
||||||
|
ariaLabel={t('nav.home')}
|
||||||
|
/>
|
||||||
|
|
||||||
<NavButton
|
<NavButton
|
||||||
onClick={onShelf}
|
onClick={onShelf}
|
||||||
icon={<Grid size={20} strokeWidth={2.5} />}
|
icon={<Library size={18} strokeWidth={2.5} />}
|
||||||
label="Sammlung"
|
label={t('nav.shelf')}
|
||||||
ariaLabel="Sammlung"
|
active={isShelf}
|
||||||
/>
|
ariaLabel={t('nav.shelf')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* PRIMARY ACTION - Scan Button */}
|
{/* Center FAB */}
|
||||||
<button
|
<div className="px-2">
|
||||||
onClick={handleScanClick}
|
<button
|
||||||
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"
|
onClick={handleScanClick}
|
||||||
aria-label="Flasche scannen"
|
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')}
|
||||||
<Scan size={24} strokeWidth={2.5} />
|
>
|
||||||
<span className="text-[8px] font-bold tracking-wide mt-0.5">SCAN</span>
|
<div className="absolute inset-0 bg-white/20 rounded-[30px] opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
</button>
|
<Camera size={28} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Right Items */}
|
{/* Right Items */}
|
||||||
<NavButton
|
<div className="flex-1 flex justify-around">
|
||||||
onClick={onSearch}
|
<NavButton
|
||||||
icon={<Search size={20} strokeWidth={2.5} />}
|
onClick={onTastings}
|
||||||
label="Filter"
|
icon={<GlassWater size={18} strokeWidth={2.5} />}
|
||||||
ariaLabel="Filter"
|
label={t('nav.activity')}
|
||||||
/>
|
ariaLabel={t('nav.activity')}
|
||||||
|
/>
|
||||||
|
|
||||||
<NavButton
|
<NavButton
|
||||||
onClick={onProfile}
|
onClick={onProfile}
|
||||||
icon={<User size={20} strokeWidth={2.5} />}
|
icon={<UserRound size={18} strokeWidth={2.5} />}
|
||||||
label="Profil"
|
label={t('nav.profile')}
|
||||||
ariaLabel="Profil"
|
active={isProfile}
|
||||||
/>
|
ariaLabel={t('nav.profile')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -103,161 +103,163 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-in fade-in slide-in-from-top-4 duration-300">
|
<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-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||||
{/* Full Width Inputs */}
|
{/* Full Width Inputs */}
|
||||||
<div className="space-y-1.5 md:col-span-2">
|
<div className="space-y-2 md:col-span-2">
|
||||||
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1">{t('bottle.nameLabel')}</label>
|
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.nameLabel')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
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>
|
||||||
|
|
||||||
<div className="space-y-1.5 md:col-span-2">
|
<div className="space-y-2 md:col-span-2">
|
||||||
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1">{t('bottle.distilleryLabel')}</label>
|
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.distilleryLabel')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.distillery}
|
value={formData.distillery}
|
||||||
onChange={(e) => setFormData({ ...formData, distillery: e.target.value })}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Compact Row: Category */}
|
{/* Compact Row: Category */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1">{t('bottle.categoryLabel')}</label>
|
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.categoryLabel')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.category}
|
value={formData.category}
|
||||||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Row A: ABV + Age */}
|
{/* Row A: ABV + Age */}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1">{t('bottle.abvLabel')}</label>
|
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.abvLabel')}</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
value={formData.abv}
|
value={formData.abv}
|
||||||
onChange={(e) => setFormData({ ...formData, abv: parseFloat(e.target.value) })}
|
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>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1">{t('bottle.ageLabel')}</label>
|
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.ageLabel')}</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={formData.age}
|
value={formData.age}
|
||||||
onChange={(e) => setFormData({ ...formData, age: parseInt(e.target.value) })}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row B: Distilled + Bottled */}
|
{/* Row B: Distilled + Bottled */}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1">{t('bottle.distilledLabel')}</label>
|
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.distilledLabel')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
placeholder="YYYY"
|
placeholder="YYYY"
|
||||||
value={formData.distilled_at}
|
value={formData.distilled_at}
|
||||||
onChange={(e) => setFormData({ ...formData, distilled_at: e.target.value })}
|
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>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1">{t('bottle.bottledLabel')}</label>
|
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.bottledLabel')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
placeholder="YYYY"
|
placeholder="YYYY"
|
||||||
value={formData.bottled_at}
|
value={formData.bottled_at}
|
||||||
onChange={(e) => setFormData({ ...formData, bottled_at: e.target.value })}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Price and WB ID Row */}
|
{/* Price and WB ID Row */}
|
||||||
<div className="space-y-1.5">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:col-span-2">
|
||||||
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1">{t('bottle.priceLabel')} (€)</label>
|
<div className="space-y-2">
|
||||||
<input
|
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.priceLabel')} (€)</label>
|
||||||
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">
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="number"
|
||||||
inputMode="numeric"
|
inputMode="decimal"
|
||||||
value={formData.whiskybase_id}
|
step="0.01"
|
||||||
onChange={(e) => setFormData({ ...formData, whiskybase_id: e.target.value })}
|
placeholder="0.00"
|
||||||
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"
|
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>
|
||||||
<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>
|
<div className="space-y-2">
|
||||||
<p className="text-[11px] font-bold text-zinc-200 mb-2 truncate">{discoveryResult.title}</p>
|
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 flex justify-between items-center tracking-widest">
|
||||||
<div className="flex gap-2">
|
<span>Whiskybase ID</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={applyDiscovery}
|
onClick={handleDiscover}
|
||||||
className="flex-1 py-2 bg-orange-600 text-white text-[10px] font-black uppercase rounded-xl"
|
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"
|
||||||
ID übernehmen
|
>
|
||||||
</button>
|
{isSearching ? <Loader2 size={12} className="animate-spin" /> : <Search size={12} />}
|
||||||
<a
|
{t('bottle.autoSearch')}
|
||||||
href={discoveryResult.url}
|
</button>
|
||||||
target="_blank"
|
</label>
|
||||||
rel="noopener noreferrer"
|
<div className="relative">
|
||||||
className="px-3 py-2 bg-zinc-800 text-zinc-400 rounded-xl flex items-center justify-center border border-zinc-700"
|
<input
|
||||||
>
|
type="text"
|
||||||
<ExternalLink size={12} />
|
inputMode="numeric"
|
||||||
</a>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Batch Info */}
|
{/* Batch Info */}
|
||||||
<div className="space-y-1.5 md:col-span-2">
|
<div className="space-y-2 md:col-span-2">
|
||||||
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1">{t('bottle.batchLabel')}</label>
|
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.batchLabel')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="z.B. Batch 12 oder L-Code"
|
placeholder="e.g. Batch 12 or L-Code"
|
||||||
value={formData.batch_info}
|
value={formData.batch_info}
|
||||||
onChange={(e) => setFormData({ ...formData, batch_info: e.target.value })}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Scan, GlassWater, Users, Settings, ArrowRight, X, Sparkles } from 'lucide-react';
|
import { Scan, GlassWater, Users, Settings, ArrowRight, X, Sparkles } from 'lucide-react';
|
||||||
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
|
|
||||||
const ONBOARDING_KEY = 'dramlog_onboarding_complete';
|
const ONBOARDING_KEY = 'dramlog_onboarding_complete';
|
||||||
|
|
||||||
@@ -14,40 +15,42 @@ interface OnboardingStep {
|
|||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STEPS: OnboardingStep[] = [
|
const getSteps = (t: (path: string) => string): OnboardingStep[] => [
|
||||||
{
|
{
|
||||||
id: 'welcome',
|
id: 'welcome',
|
||||||
icon: <Sparkles size={32} className="text-orange-500" />,
|
icon: <Sparkles size={32} className="text-orange-500" />,
|
||||||
title: 'Willkommen bei DramLog!',
|
title: t('tutorial.steps.welcome.title'),
|
||||||
description: 'Dein persönliches Whisky-Tagebuch. Scanne, bewerte und entdecke neue Drams.',
|
description: t('tutorial.steps.welcome.desc'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'scan',
|
id: 'scan',
|
||||||
icon: <Scan size={32} className="text-orange-500" />,
|
icon: <Scan size={32} className="text-orange-500" />,
|
||||||
title: 'Scanne deine Flaschen',
|
title: t('tutorial.steps.scan.title'),
|
||||||
description: 'Fotografiere das Etikett einer Flasche – die KI erkennt automatisch alle Details.',
|
description: t('tutorial.steps.scan.desc'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'taste',
|
id: 'taste',
|
||||||
icon: <GlassWater size={32} className="text-orange-500" />,
|
icon: <GlassWater size={32} className="text-orange-500" />,
|
||||||
title: 'Bewerte deine Drams',
|
title: t('tutorial.steps.taste.title'),
|
||||||
description: 'Füge Tasting-Notizen hinzu und behalte den Überblick über deine Lieblings-Whiskys.',
|
description: t('tutorial.steps.taste.desc'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'session',
|
id: 'activity',
|
||||||
icon: <Users size={32} className="text-orange-500" />,
|
icon: <Users size={32} className="text-orange-500" />,
|
||||||
title: 'Tasting-Sessions',
|
title: t('tutorial.steps.activity.title'),
|
||||||
description: 'Organisiere Verkostungen mit Freunden und vergleicht eure Bewertungen.',
|
description: t('tutorial.steps.activity.desc'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ready',
|
id: 'ready',
|
||||||
icon: <Settings size={32} className="text-orange-500" />,
|
icon: <Settings size={32} className="text-orange-500" />,
|
||||||
title: 'Bereit zum Start!',
|
title: t('tutorial.steps.ready.title'),
|
||||||
description: 'Scanne jetzt deine erste Flasche mit dem orangefarbenen Button unten.',
|
description: t('tutorial.steps.ready.desc'),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function OnboardingTutorial() {
|
export default function OnboardingTutorial() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const STEPS = getSteps(t);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -148,14 +151,14 @@ export default function OnboardingTutorial() {
|
|||||||
onClick={handleSkip}
|
onClick={handleSkip}
|
||||||
className="flex-1 py-3 px-4 text-sm font-bold text-zinc-500 hover:text-white transition-colors"
|
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>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleNext}
|
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"
|
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} />
|
<ArrowRight size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import { motion } from 'framer-motion';
|
|||||||
import { Lock, Eye, EyeOff, Loader2, CheckCircle, AlertCircle } from 'lucide-react';
|
import { Lock, Eye, EyeOff, Loader2, CheckCircle, AlertCircle } from 'lucide-react';
|
||||||
import { changePassword } from '@/services/profile-actions';
|
import { changePassword } from '@/services/profile-actions';
|
||||||
|
|
||||||
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
|
|
||||||
export default function PasswordChangeForm() {
|
export default function PasswordChangeForm() {
|
||||||
|
const { t } = useI18n();
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
@@ -20,13 +23,13 @@ export default function PasswordChangeForm() {
|
|||||||
|
|
||||||
if (newPassword !== confirmPassword) {
|
if (newPassword !== confirmPassword) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setError('Passwörter stimmen nicht überein');
|
setError(t('settings.password.mismatch'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPassword.length < 6) {
|
if (newPassword.length < 6) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setError('Passwort muss mindestens 6 Zeichen lang sein');
|
setError(t('settings.password.tooShort'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +46,7 @@ export default function PasswordChangeForm() {
|
|||||||
setTimeout(() => setStatus('idle'), 3000);
|
setTimeout(() => setStatus('idle'), 3000);
|
||||||
} else {
|
} else {
|
||||||
setStatus('error');
|
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">
|
<h2 className="text-lg font-bold text-white mb-6 flex items-center gap-2">
|
||||||
<Lock size={20} className="text-orange-500" />
|
<Lock size={20} className="text-orange-500" />
|
||||||
Passwort ändern
|
{t('settings.password.title')}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* New Password */}
|
{/* New Password */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-zinc-400 mb-2">
|
<label className="block text-sm font-medium text-zinc-400 mb-2">
|
||||||
Neues Passwort
|
{t('settings.password.newPassword')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
@@ -88,7 +91,7 @@ export default function PasswordChangeForm() {
|
|||||||
{/* Confirm Password */}
|
{/* Confirm Password */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-zinc-400 mb-2">
|
<label className="block text-sm font-medium text-zinc-400 mb-2">
|
||||||
Passwort bestätigen
|
{t('settings.password.confirmPassword')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
@@ -107,12 +110,12 @@ export default function PasswordChangeForm() {
|
|||||||
{newPassword === confirmPassword ? (
|
{newPassword === confirmPassword ? (
|
||||||
<>
|
<>
|
||||||
<CheckCircle size={12} />
|
<CheckCircle size={12} />
|
||||||
Passwörter stimmen überein
|
{t('settings.password.match')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<AlertCircle size={12} />
|
<AlertCircle size={12} />
|
||||||
Passwörter stimmen nicht überein
|
{t('settings.password.mismatch')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -122,7 +125,7 @@ export default function PasswordChangeForm() {
|
|||||||
{status === 'success' && (
|
{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">
|
<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} />
|
<CheckCircle size={16} />
|
||||||
Passwort erfolgreich geändert!
|
{t('settings.password.success')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{status === 'error' && (
|
{status === 'error' && (
|
||||||
@@ -141,12 +144,12 @@ export default function PasswordChangeForm() {
|
|||||||
{isPending ? (
|
{isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 size={18} className="animate-spin" />
|
<Loader2 size={18} className="animate-spin" />
|
||||||
Ändern...
|
{t('common.loading')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Lock size={18} />
|
<Lock size={18} />
|
||||||
Passwort ändern
|
{t('settings.password.change')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { motion } from 'framer-motion';
|
|||||||
import { User, Mail, Save, Loader2, CheckCircle, AlertCircle } from 'lucide-react';
|
import { User, Mail, Save, Loader2, CheckCircle, AlertCircle } from 'lucide-react';
|
||||||
import { updateProfile } from '@/services/profile-actions';
|
import { updateProfile } from '@/services/profile-actions';
|
||||||
|
|
||||||
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
|
|
||||||
interface ProfileFormProps {
|
interface ProfileFormProps {
|
||||||
initialData: {
|
initialData: {
|
||||||
email?: string;
|
email?: string;
|
||||||
@@ -13,6 +15,7 @@ interface ProfileFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ProfileForm({ initialData }: ProfileFormProps) {
|
export default function ProfileForm({ initialData }: ProfileFormProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [username, setUsername] = useState(initialData.username || '');
|
const [username, setUsername] = useState(initialData.username || '');
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||||
@@ -33,7 +36,7 @@ export default function ProfileForm({ initialData }: ProfileFormProps) {
|
|||||||
setTimeout(() => setStatus('idle'), 3000);
|
setTimeout(() => setStatus('idle'), 3000);
|
||||||
} else {
|
} else {
|
||||||
setStatus('error');
|
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">
|
<h2 className="text-lg font-bold text-white mb-6 flex items-center gap-2">
|
||||||
<User size={20} className="text-orange-500" />
|
<User size={20} className="text-orange-500" />
|
||||||
Profil
|
{t('nav.profile')}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -63,19 +66,18 @@ export default function ProfileForm({ initialData }: ProfileFormProps) {
|
|||||||
disabled
|
disabled
|
||||||
className="w-full px-4 py-3 bg-zinc-800/50 border border-zinc-700 rounded-xl text-zinc-500 cursor-not-allowed"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Username */}
|
{/* Username */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-zinc-400 mb-2">
|
<label className="block text-sm font-medium text-zinc-400 mb-2">
|
||||||
Benutzername
|
{t('bottle.nameLabel')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
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"
|
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>
|
</div>
|
||||||
@@ -85,7 +87,7 @@ export default function ProfileForm({ initialData }: ProfileFormProps) {
|
|||||||
{status === 'success' && (
|
{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">
|
<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} />
|
<CheckCircle size={16} />
|
||||||
Profil gespeichert!
|
{t('common.success')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{status === 'error' && (
|
{status === 'error' && (
|
||||||
@@ -104,12 +106,12 @@ export default function ProfileForm({ initialData }: ProfileFormProps) {
|
|||||||
{isPending ? (
|
{isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 size={18} className="animate-spin" />
|
<Loader2 size={18} className="animate-spin" />
|
||||||
Speichern...
|
{t('common.loading')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Save size={18} />
|
<Save size={18} />
|
||||||
Speichern
|
{t('common.save')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</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 [lastDramInSession, setLastDramInSession] = useState<{ name: string; isSmoky: boolean; timestamp: number } | null>(null);
|
||||||
const [showPaletteWarning, setShowPaletteWarning] = useState(false);
|
const [showPaletteWarning, setShowPaletteWarning] = useState(false);
|
||||||
|
|
||||||
|
const [bottleOwnerId, setBottleOwnerId] = useState<string | null>(null);
|
||||||
|
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Section collapse states
|
// Section collapse states
|
||||||
const [isNoseExpanded, setIsNoseExpanded] = useState(false);
|
const [isNoseExpanded, setIsNoseExpanded] = useState(false);
|
||||||
const [isPalateExpanded, setIsPalateExpanded] = useState(false);
|
const [isPalateExpanded, setIsPalateExpanded] = useState(false);
|
||||||
@@ -52,14 +55,22 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
|||||||
|
|
||||||
const effectiveSessionId = sessionId || activeSession?.id;
|
const effectiveSessionId = sessionId || activeSession?.id;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const getAuth = async () => {
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (user) setCurrentUserId(user.id);
|
||||||
|
};
|
||||||
|
getAuth();
|
||||||
|
}, [supabase]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
if (!bottleId) return;
|
if (!bottleId) return;
|
||||||
|
|
||||||
// Fetch Bottle Suggestions
|
// Fetch Bottle Suggestions and Owner
|
||||||
const { data: bottleData } = await supabase
|
const { data: bottleData } = await supabase
|
||||||
.from('bottles')
|
.from('bottles')
|
||||||
.select('suggested_tags, suggested_custom_tags')
|
.select('suggested_tags, suggested_custom_tags, user_id')
|
||||||
.eq('id', bottleId)
|
.eq('id', bottleId)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
@@ -69,6 +80,9 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
|||||||
if (bottleData?.suggested_custom_tags) {
|
if (bottleData?.suggested_custom_tags) {
|
||||||
setSuggestedCustomTags(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 Session ID, fetch session participants and pre-select them, and fetch last dram
|
||||||
if (effectiveSessionId) {
|
if (effectiveSessionId) {
|
||||||
@@ -209,8 +223,22 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isSharedBottle = bottleOwnerId && currentUserId && bottleOwnerId !== currentUserId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<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 && (
|
{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="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">
|
<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';
|
import { TranslationKeys } from './types';
|
||||||
|
|
||||||
export const de: TranslationKeys = {
|
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: {
|
common: {
|
||||||
save: 'Speichern',
|
save: 'Speichern',
|
||||||
cancel: 'Abbrechen',
|
cancel: 'Abbrechen',
|
||||||
@@ -37,9 +60,14 @@ export const de: TranslationKeys = {
|
|||||||
},
|
},
|
||||||
searchPlaceholder: 'Flaschen oder Noten suchen...',
|
searchPlaceholder: 'Flaschen oder Noten suchen...',
|
||||||
noBottles: 'Keine Flaschen gefunden. Zeit für einen Einkauf! 🥃',
|
noBottles: 'Keine Flaschen gefunden. Zeit für einen Einkauf! 🥃',
|
||||||
collection: 'Deine Sammlung',
|
collection: 'Kollektion',
|
||||||
reTry: 'Erneut versuchen',
|
reTry: 'Nochmal versuchen',
|
||||||
all: 'Alle',
|
all: 'Alle',
|
||||||
|
tagline: 'Modernes Minimalistisches Tasting-Tool.',
|
||||||
|
bottleCount: 'Flaschen',
|
||||||
|
imprint: 'Impressum',
|
||||||
|
privacy: 'Datenschutz',
|
||||||
|
settings: 'Einstellungen',
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
searchPlaceholder: 'Suchen nach Name oder Distille...',
|
searchPlaceholder: 'Suchen nach Name oder Distille...',
|
||||||
@@ -165,6 +193,84 @@ export const de: TranslationKeys = {
|
|||||||
noSessions: 'Noch keine Sessions vorhanden.',
|
noSessions: 'Noch keine Sessions vorhanden.',
|
||||||
expiryWarning: 'Diese Session läuft bald ab.',
|
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: {
|
aroma: {
|
||||||
'Apfel': 'Apfel',
|
'Apfel': 'Apfel',
|
||||||
'Grüner Apfel': 'Grüner 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';
|
import { TranslationKeys } from './types';
|
||||||
|
|
||||||
export const en: TranslationKeys = {
|
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: {
|
common: {
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
@@ -37,9 +60,14 @@ export const en: TranslationKeys = {
|
|||||||
},
|
},
|
||||||
searchPlaceholder: 'Search bottles or notes...',
|
searchPlaceholder: 'Search bottles or notes...',
|
||||||
noBottles: 'No bottles found. Time to go shopping! 🥃',
|
noBottles: 'No bottles found. Time to go shopping! 🥃',
|
||||||
collection: 'Your Collection',
|
collection: 'Collection',
|
||||||
reTry: 'Retry',
|
reTry: 'Try Again',
|
||||||
all: 'All',
|
all: 'All',
|
||||||
|
tagline: 'Modern Minimalist Tasting Tool.',
|
||||||
|
bottleCount: 'Bottles',
|
||||||
|
imprint: 'Imprint',
|
||||||
|
privacy: 'Privacy',
|
||||||
|
settings: 'Settings',
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
searchPlaceholder: 'Search by name or distillery...',
|
searchPlaceholder: 'Search by name or distillery...',
|
||||||
@@ -165,6 +193,84 @@ export const en: TranslationKeys = {
|
|||||||
noSessions: 'No sessions yet.',
|
noSessions: 'No sessions yet.',
|
||||||
expiryWarning: 'This session will expire soon.',
|
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: {
|
aroma: {
|
||||||
'Apfel': 'Apple',
|
'Apfel': 'Apple',
|
||||||
'Grüner Apfel': 'Green Apple',
|
'Grüner Apfel': 'Green Apple',
|
||||||
|
|||||||
@@ -1,4 +1,27 @@
|
|||||||
export type TranslationKeys = {
|
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: {
|
common: {
|
||||||
save: string;
|
save: string;
|
||||||
cancel: string;
|
cancel: string;
|
||||||
@@ -38,6 +61,11 @@ export type TranslationKeys = {
|
|||||||
collection: string;
|
collection: string;
|
||||||
reTry: string;
|
reTry: string;
|
||||||
all: string;
|
all: string;
|
||||||
|
tagline: string;
|
||||||
|
bottleCount: string;
|
||||||
|
imprint: string;
|
||||||
|
privacy: string;
|
||||||
|
settings: string;
|
||||||
};
|
};
|
||||||
grid: {
|
grid: {
|
||||||
searchPlaceholder: string;
|
searchPlaceholder: string;
|
||||||
@@ -163,5 +191,68 @@ export type TranslationKeys = {
|
|||||||
noSessions: string;
|
noSessions: string;
|
||||||
expiryWarning: 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>;
|
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')
|
.from('tastings')
|
||||||
.insert({
|
.insert({
|
||||||
bottle_id: data.bottle_id,
|
bottle_id: data.bottle_id,
|
||||||
@@ -39,7 +39,29 @@ export async function saveTasting(rawData: TastingNoteData) {
|
|||||||
.select()
|
.select()
|
||||||
.single();
|
.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
|
// Add buddy tags if any
|
||||||
if (data.buddy_ids && data.buddy_ids.length > 0) {
|
if (data.buddy_ids && data.buddy_ids.length > 0) {
|
||||||
@@ -78,11 +100,11 @@ export async function saveTasting(rawData: TastingNoteData) {
|
|||||||
revalidatePath(`/bottles/${data.bottle_id}`);
|
revalidatePath(`/bottles/${data.bottle_id}`);
|
||||||
|
|
||||||
return { success: true, data: tasting };
|
return { success: true, data: tasting };
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error('Save Tasting Error:', error);
|
console.error('Save Tasting Error:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
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 remaining = available - taken - reserved;
|
||||||
const bottle = split.bottles as any;
|
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
|
// Convert sample sizes from DB format
|
||||||
const sampleSizes = ((split.sample_sizes as any[]) || []).map(s => ({
|
const sampleSizes = ((split.sample_sizes as any[]) || []).map(s => ({
|
||||||
cl: s.cl,
|
cl: s.cl,
|
||||||
@@ -228,8 +233,8 @@ export async function getSplitBySlug(slug: string): Promise<{
|
|||||||
createdAt: split.created_at,
|
createdAt: split.created_at,
|
||||||
bottle: {
|
bottle: {
|
||||||
id: bottle.id,
|
id: bottle.id,
|
||||||
name: bottle.name,
|
name: bottle.name || 'Unbekannte Flasche',
|
||||||
distillery: bottle.distillery,
|
distillery: bottle.distillery || 'Unbekannte Destillerie',
|
||||||
imageUrl: bottle.image_url,
|
imageUrl: bottle.image_url,
|
||||||
abv: bottle.abv,
|
abv: bottle.abv,
|
||||||
age: bottle.age,
|
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<{
|
export async function generateForumExport(splitId: string): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
text?: string;
|
text?: string;
|
||||||
@@ -603,3 +684,51 @@ export async function closeSplit(splitId: string): Promise<{
|
|||||||
return { success: false, error: 'Unerwarteter Fehler' };
|
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