Compare commits

...

10 Commits

Author SHA1 Message Date
9d6a8b358f 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
2025-12-28 22:02:46 +01:00
332bfdaf02 style: redesign app following HIG with larger hero images and refined typography 2025-12-28 20:38:10 +01:00
c51cd23d5e feat: enhanced AI usage logging (model, provider, response) and fixed build blockers 2025-12-27 00:10:55 +01:00
20659567fd feat: mobile-first refactor for BottleDetails & RLS security/performance optimizations 2025-12-26 23:58:35 +01:00
20f7436e66 fix: Dynamic credit initialization from subscription plan
- Removed hardcoded 100 credits from lazy initialization
- Now looks up user's subscription plan and uses monthly_credits
- Set total_purchased to 0 (previously incorrectly set to 100)
- Fallback to 10 credits if no plan found (starter default)
2025-12-26 23:16:13 +01:00
a3915bd610 feat: Dynamic credit initialization based on subscription plan
New trigger creates:
- Profile (username from metadata)
- Subscription (starter plan by default)
- Credits (from plan's monthly_credits, not hardcoded)

Includes RLS policies for self-insert fallback
2025-12-26 23:11:58 +01:00
37634c26c8 fix: Show plan monthly_credits when user has no credits entry yet
UserStatusBadge now falls back to plan's monthly_credits (e.g., 10 for Starter)
instead of showing 0 when user_credits entry doesn't exist
2025-12-26 23:10:02 +01:00
9e2abb0aa3 fix: Update handle_new_user trigger for reliable registration
- Simplified trigger to only create profile
- Added exception handling to prevent user creation failures
- Subscription created client-side after successful signup
2025-12-26 23:05:30 +01:00
30a716f3e2 feat: Add UserStatusBadge showing subscription level and credits
New component in header shows:
- Subscription plan badge (Starter/Bronze/Silver/Gold with icons)
- AI credits balance with sparkle icon

Also includes SQL migration for user_subscriptions RLS fix
2025-12-26 23:02:20 +01:00
02bd025bce feat: Enhanced registration with username, name, and auto-subscription
Registration form now includes:
- Username field (required, unique, validated)
- Full name field (optional)
- Auto-validates username format and availability
- Auto-creates 'starter' subscription on signup

Login form unchanged (email + password only)
2025-12-26 22:52:47 +01:00
46 changed files with 3052 additions and 681 deletions

View 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
View 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.

28
hig.md Normal file
View File

@@ -0,0 +1,28 @@
# Apple Human Interface Guidelines (HIG) - Core Principles for iOS
@Context: Mobile Whisky Tasting App (Dark Mode)
## 1. Layout & Structure
- **Safe Areas:** Always respect the top (Dynamic Island/Notch) and bottom (Home Indicator) safe areas. Never place interactive elements (like the "Save Tasting" sticky button) directly on the bottom edge; add bottom padding.
- **Modality (Sheets):** For the "Session Context" (Step C in our flow), use native-style Sheets. Supports "detents" (medium/large). Sheets should be dismissible by dragging down.
- **Navigation:** Use a Navigation Bar for hierarchy. The title (e.g., "Tasting Editor") should be large (Large Title) on top of the scroll view and collapse to a small title on scroll.
## 2. Touch & Interaction
- **Hit Targets:** Minimum tappable area is **44x44 pt**. Ensure the "Smart Tags" in the form are large enough.
- **Feedback:** Use Haptics (Haptic Feedback) for significant actions (e.g., `success` haptic when "Save Tasting" is clicked, `selection` haptic when moving sliders).
- **Gestures:** Support "Swipe Back" to navigate to the previous screen. Do not block this gesture with custom UI.
## 3. Visual Design (Dark Mode)
- **Colors:** - Never use pure black (`#000000`) for backgrounds. Use semantic system colors or generic dark grays (e.g., `systemBackground` / `#1C1C1E`).
- Use `systemGray` to `systemGray6` for elevation levels (cards on top of background).
- **Typography:**
- Use San Francisco (SF Pro) or the defined app fonts (Inter/Playfair).
- Respect Dynamic Type sizes so users can scale text.
- **Icons:** Use SF Symbols (or Lucide variants closely matching SF Symbols) with consistent stroke weights (usually "Medium" or "Semibold" for active states).
## 4. Specific Component Rules
- **Buttons:**
- "Primary" buttons (Save) should use high-contrast background colors.
- "Secondary" buttons (Cancel/Back) should be plain text or tinted glyphs.
- Avoid using multiple primary buttons on one screen.
- **Inputs:** - Text fields must clearly indicate focus.
- Keyboard: Use the correct keyboard type (e.g., `decimalPad` for ABV input).

17
logging_enhancements.sql Normal file
View File

@@ -0,0 +1,17 @@
-- AI Logging Enhancements
-- Add model, provider and response_text to api_usage table
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'api_usage' AND COLUMN_NAME = 'model') THEN
ALTER TABLE api_usage ADD COLUMN model TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'api_usage' AND COLUMN_NAME = 'provider') THEN
ALTER TABLE api_usage ADD COLUMN provider TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'api_usage' AND COLUMN_NAME = 'response_text') THEN
ALTER TABLE api_usage ADD COLUMN response_text TEXT;
END IF;
END $$;

62
performance_indexing.sql Normal file
View File

@@ -0,0 +1,62 @@
-- ============================================
-- Database Performance Optimization: Indexing
-- ============================================
-- Addresses "unindexed_foreign_keys" and "unused_index" linter warnings
-- ============================================
-- ============================================
-- 1. Missing Indexes for Foreign Keys
-- ============================================
-- bottles
CREATE INDEX IF NOT EXISTS idx_bottles_user_id ON public.bottles(user_id);
-- buddies
CREATE INDEX IF NOT EXISTS idx_buddies_user_id ON public.buddies(user_id);
CREATE INDEX IF NOT EXISTS idx_buddies_buddy_profile_id ON public.buddies(buddy_profile_id);
-- credit_transactions
CREATE INDEX IF NOT EXISTS idx_credit_transactions_admin_id ON public.credit_transactions(admin_id);
-- session_participants
CREATE INDEX IF NOT EXISTS idx_session_participants_session_id ON public.session_participants(session_id);
CREATE INDEX IF NOT EXISTS idx_session_participants_buddy_id ON public.session_participants(buddy_id);
CREATE INDEX IF NOT EXISTS idx_session_participants_user_id ON public.session_participants(user_id);
-- tags
CREATE INDEX IF NOT EXISTS idx_tags_created_by ON public.tags(created_by);
-- tasting_buddies
CREATE INDEX IF NOT EXISTS idx_tasting_buddies_tasting_id ON public.tasting_buddies(tasting_id);
CREATE INDEX IF NOT EXISTS idx_tasting_buddies_buddy_id ON public.tasting_buddies(buddy_id);
CREATE INDEX IF NOT EXISTS idx_tasting_buddies_user_id ON public.tasting_buddies(user_id);
-- tasting_sessions
CREATE INDEX IF NOT EXISTS idx_tasting_sessions_user_id ON public.tasting_sessions(user_id);
-- tasting_tags (aroma tags)
CREATE INDEX IF NOT EXISTS idx_tasting_tags_tasting_id ON public.tasting_tags(tasting_id);
CREATE INDEX IF NOT EXISTS idx_tasting_tags_tag_id ON public.tasting_tags(tag_id);
CREATE INDEX IF NOT EXISTS idx_tasting_tags_user_id ON public.tasting_tags(user_id);
-- tastings
CREATE INDEX IF NOT EXISTS idx_tastings_bottle_id ON public.tastings(bottle_id);
CREATE INDEX IF NOT EXISTS idx_tastings_user_id ON public.tastings(user_id);
-- ============================================
-- 2. Cleanup of Unused Indexes
-- ============================================
-- These were flagged by the linter as "never used".
-- Use with caution, but removal helps with insert/update performance.
-- DROP INDEX IF EXISTS idx_global_products_search_vector;
-- DROP INDEX IF EXISTS idx_buddy_invites_code;
-- DROP INDEX IF EXISTS idx_buddy_invites_expires_at;
-- DROP INDEX IF EXISTS idx_split_participants_status;
-- DROP INDEX IF EXISTS idx_tastings_tasted_at;
-- DROP INDEX IF EXISTS idx_credit_transactions_created_at;
-- DROP INDEX IF EXISTS idx_credit_transactions_type;
-- DROP INDEX IF EXISTS idx_subscription_plans_active;
-- DROP INDEX IF EXISTS idx_subscription_plans_sort_order;
-- DROP INDEX IF EXISTS idx_user_subscriptions_plan_id;

75
rls_buddy_access.sql Normal file
View File

@@ -0,0 +1,75 @@
-- ============================================
-- Buddy Access Logic Migration
-- Run AFTER rls_policy_performance_fixes.sql
-- ============================================
-- Adds read-only access for buddies to see sessions/tastings they participate in
-- ============================================
-- ============================================
-- Fix: tasting_sessions - Add consolidated buddy read access
-- ============================================
-- Drop all previous policies for this table
DROP POLICY IF EXISTS "tasting_sessions_policy" ON tasting_sessions;
DROP POLICY IF EXISTS "tasting_sessions_owner_policy" ON tasting_sessions;
DROP POLICY IF EXISTS "tasting_sessions_buddy_select_policy" ON tasting_sessions;
-- Consolidated SELECT: owner OR participant
CREATE POLICY "tasting_sessions_select_policy" ON tasting_sessions
FOR SELECT 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())
)
);
-- Owner-only for other actions
CREATE POLICY "tasting_sessions_insert_policy" ON tasting_sessions
FOR INSERT WITH CHECK ((SELECT auth.uid()) = user_id);
CREATE POLICY "tasting_sessions_update_policy" ON tasting_sessions
FOR UPDATE USING ((SELECT auth.uid()) = user_id);
CREATE POLICY "tasting_sessions_delete_policy" ON tasting_sessions
FOR DELETE USING ((SELECT auth.uid()) = user_id);
-- ============================================
-- Fix: tastings - Add consolidated buddy read access
-- ============================================
-- Drop all previous policies for this table
DROP POLICY IF EXISTS "tastings_policy" ON tastings;
DROP POLICY IF EXISTS "tastings_owner_policy" ON tastings;
DROP POLICY IF EXISTS "tastings_buddy_select_policy" ON tastings;
-- Consolidated SELECT: owner OR tagged buddy
CREATE POLICY "tastings_select_policy" ON tastings
FOR SELECT USING (
(SELECT auth.uid()) = user_id OR
id IN (
SELECT tb.tasting_id
FROM tasting_buddies tb
JOIN buddies b ON b.id = tb.buddy_id
WHERE b.buddy_profile_id = (SELECT auth.uid())
)
);
-- Owner-only for other actions
CREATE POLICY "tastings_insert_policy" ON tastings
FOR INSERT WITH CHECK ((SELECT auth.uid()) = user_id);
CREATE POLICY "tastings_update_policy" ON tastings
FOR UPDATE USING ((SELECT auth.uid()) = user_id);
CREATE POLICY "tastings_delete_policy" ON tastings
FOR DELETE USING ((SELECT auth.uid()) = user_id);
-- ============================================
-- Note: bottles stays owner-only for now
-- The original logic was complex and could cause RLS recursion
-- If you need buddies to see bottles, we can add it separately
-- ============================================

View File

@@ -0,0 +1,248 @@
-- ============================================
-- RLS Policy Performance Fixes Migration
-- Run this in Supabase SQL Editor
-- ============================================
-- Fixes two types of issues:
-- 1. auth_rls_initplan: Ensures auth.uid() is wrapped in (SELECT ...)
-- 2. multiple_permissive_policies: Consolidates overlapping policies
-- ============================================
-- ============================================
-- Fix 1: user_subscriptions
-- Issues: Multiple permissive policies for INSERT and SELECT
-- ============================================
-- Drop existing policies
DROP POLICY IF EXISTS "user_subscriptions_select_policy" ON user_subscriptions;
DROP POLICY IF EXISTS "user_subscriptions_admin_policy" ON user_subscriptions;
DROP POLICY IF EXISTS "user_subscriptions_insert_self" ON user_subscriptions;
-- Consolidated SELECT policy: user can see own OR admin can see all
CREATE POLICY "user_subscriptions_select_policy" ON user_subscriptions
FOR SELECT USING (
(SELECT auth.uid()) = user_id OR
EXISTS (SELECT 1 FROM admin_users WHERE admin_users.user_id = (SELECT auth.uid()))
);
-- Consolidated INSERT policy: user can insert own OR admin can insert any
CREATE POLICY "user_subscriptions_insert_policy" ON user_subscriptions
FOR INSERT WITH CHECK (
(SELECT auth.uid()) = user_id OR
EXISTS (SELECT 1 FROM admin_users WHERE admin_users.user_id = (SELECT auth.uid()))
);
-- Admin UPDATE/DELETE policy
CREATE POLICY "user_subscriptions_admin_modify_policy" ON user_subscriptions
FOR UPDATE USING (
EXISTS (SELECT 1 FROM admin_users WHERE admin_users.user_id = (SELECT auth.uid()))
);
CREATE POLICY "user_subscriptions_admin_delete_policy" ON user_subscriptions
FOR DELETE USING (
EXISTS (SELECT 1 FROM admin_users WHERE admin_users.user_id = (SELECT auth.uid()))
);
-- ============================================
-- Fix 2: bottle_splits
-- Issues: bottle_splits_host_policy (ALL) and bottle_splits_public_view (SELECT) overlap
-- ============================================
DROP POLICY IF EXISTS "bottle_splits_host_policy" ON bottle_splits;
DROP POLICY IF EXISTS "bottle_splits_public_view" ON bottle_splits;
-- Consolidated SELECT: host can see all own, everyone can see active
CREATE POLICY "bottle_splits_select_policy" ON bottle_splits
FOR SELECT USING (
(SELECT auth.uid()) = host_id OR is_active = true
);
-- Host-only for INSERT/UPDATE/DELETE
CREATE POLICY "bottle_splits_host_insert_policy" ON bottle_splits
FOR INSERT WITH CHECK ((SELECT auth.uid()) = host_id);
CREATE POLICY "bottle_splits_host_update_policy" ON bottle_splits
FOR UPDATE USING ((SELECT auth.uid()) = host_id);
CREATE POLICY "bottle_splits_host_delete_policy" ON bottle_splits
FOR DELETE USING ((SELECT auth.uid()) = host_id);
-- ============================================
-- Fix 3: bottles
-- Issues: bottles_owner_policy (ALL) and bottles_session_select_policy (SELECT) overlap
-- Strategy: Just keep owner policy because the session_select_policy doesn't exist in schema
-- ============================================
DROP POLICY IF EXISTS "bottles_owner_policy" ON bottles;
DROP POLICY IF EXISTS "bottles_session_select_policy" ON bottles;
-- Owner-only policy for all operations (simple and performant)
CREATE POLICY "bottles_policy" ON bottles
FOR ALL USING ((SELECT auth.uid()) = user_id);
-- ============================================
-- Fix 4: buddy_invites
-- Issues: buddy_invites_creator_policy (ALL) and buddy_invites_redeem_policy (SELECT) overlap
-- ============================================
DROP POLICY IF EXISTS "buddy_invites_creator_policy" ON buddy_invites;
DROP POLICY IF EXISTS "buddy_invites_redeem_policy" ON buddy_invites;
-- Consolidated SELECT: creator can see own, anyone can see non-expired (for redemption)
CREATE POLICY "buddy_invites_select_policy" ON buddy_invites
FOR SELECT USING (
(SELECT auth.uid()) = creator_id OR expires_at > now()
);
-- Creator-only for INSERT/UPDATE/DELETE
CREATE POLICY "buddy_invites_creator_insert_policy" ON buddy_invites
FOR INSERT WITH CHECK ((SELECT auth.uid()) = creator_id);
CREATE POLICY "buddy_invites_creator_update_policy" ON buddy_invites
FOR UPDATE USING ((SELECT auth.uid()) = creator_id);
CREATE POLICY "buddy_invites_creator_delete_policy" ON buddy_invites
FOR DELETE USING ((SELECT auth.uid()) = creator_id);
-- ============================================
-- Fix 5: global_products
-- Issues: "Enable Admin Insert/Update" (ALL) and "Enable Read Access for all users" (SELECT) overlap
-- ============================================
DROP POLICY IF EXISTS "Enable Admin Insert/Update" ON global_products;
DROP POLICY IF EXISTS "Enable Read Access for all users" ON global_products;
-- Everyone can SELECT
CREATE POLICY "global_products_select_policy" ON global_products
FOR SELECT USING (true);
-- Admin-only for INSERT/UPDATE/DELETE
CREATE POLICY "global_products_admin_insert_policy" ON global_products
FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM admin_users WHERE admin_users.user_id = (SELECT auth.uid()))
);
CREATE POLICY "global_products_admin_update_policy" ON global_products
FOR UPDATE USING (
EXISTS (SELECT 1 FROM admin_users WHERE admin_users.user_id = (SELECT auth.uid()))
);
CREATE POLICY "global_products_admin_delete_policy" ON global_products
FOR DELETE USING (
EXISTS (SELECT 1 FROM admin_users WHERE admin_users.user_id = (SELECT auth.uid()))
);
-- ============================================
-- Fix 6: session_participants
-- Issues: session_participants_manage_policy and session_participants_read_policy overlap
-- ============================================
DROP POLICY IF EXISTS "session_participants_owner_policy" ON session_participants;
DROP POLICY IF EXISTS "session_participants_manage_policy" ON session_participants;
DROP POLICY IF EXISTS "session_participants_read_policy" ON session_participants;
-- Single unified policy: owner can do all
CREATE POLICY "session_participants_policy" ON session_participants
FOR ALL USING ((SELECT auth.uid()) = user_id);
-- ============================================
-- Fix 7: split_participants
-- Issues: Multiple overlapping policies for different actions
-- ============================================
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_view" ON split_participants;
-- Consolidated SELECT: own participation OR host of split OR public active split
CREATE POLICY "split_participants_select_policy" ON split_participants
FOR SELECT USING (
(SELECT auth.uid()) = user_id OR
split_id IN (SELECT id FROM bottle_splits WHERE host_id = (SELECT auth.uid())) OR
split_id IN (SELECT id FROM bottle_splits WHERE is_active = true)
);
-- INSERT: own participation OR host of split
CREATE POLICY "split_participants_insert_policy" ON split_participants
FOR INSERT WITH CHECK (
(SELECT auth.uid()) = user_id OR
split_id IN (SELECT id FROM bottle_splits WHERE host_id = (SELECT auth.uid()))
);
-- UPDATE: own participation OR host of split
CREATE POLICY "split_participants_update_policy" ON split_participants
FOR UPDATE USING (
(SELECT auth.uid()) = user_id OR
split_id IN (SELECT id FROM bottle_splits WHERE host_id = (SELECT auth.uid()))
);
-- DELETE: own participation OR host of split
CREATE POLICY "split_participants_delete_policy" ON split_participants
FOR DELETE USING (
(SELECT auth.uid()) = user_id OR
split_id IN (SELECT id FROM bottle_splits WHERE host_id = (SELECT auth.uid()))
);
-- ============================================
-- Fix 8: subscription_plans
-- Issues: subscription_plans_admin_policy (ALL) and subscription_plans_select_policy (SELECT) overlap
-- ============================================
DROP POLICY IF EXISTS "subscription_plans_select_policy" ON subscription_plans;
DROP POLICY IF EXISTS "subscription_plans_admin_policy" ON subscription_plans;
-- Consolidated SELECT: active plans OR admin can see all
CREATE POLICY "subscription_plans_select_policy" ON subscription_plans
FOR SELECT USING (
is_active = true OR
EXISTS (SELECT 1 FROM admin_users WHERE admin_users.user_id = (SELECT auth.uid()))
);
-- Admin-only for INSERT/UPDATE/DELETE
CREATE POLICY "subscription_plans_admin_insert_policy" ON subscription_plans
FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM admin_users WHERE admin_users.user_id = (SELECT auth.uid()))
);
CREATE POLICY "subscription_plans_admin_update_policy" ON subscription_plans
FOR UPDATE USING (
EXISTS (SELECT 1 FROM admin_users WHERE admin_users.user_id = (SELECT auth.uid()))
);
CREATE POLICY "subscription_plans_admin_delete_policy" ON subscription_plans
FOR DELETE USING (
EXISTS (SELECT 1 FROM admin_users WHERE admin_users.user_id = (SELECT auth.uid()))
);
-- ============================================
-- Fix 9: tasting_sessions
-- Issues: sessions_access_policy (ALL) and sessions_modification_policy overlap
-- Strategy: Keep it simple - owner-only for modifications, no complex buddy joins
-- ============================================
DROP POLICY IF EXISTS "sessions_access_policy" ON tasting_sessions;
DROP POLICY IF EXISTS "sessions_modification_policy" ON tasting_sessions;
-- Owner-only policy for all operations
CREATE POLICY "tasting_sessions_policy" ON tasting_sessions
FOR ALL USING ((SELECT auth.uid()) = user_id);
-- ============================================
-- Fix 10: tastings
-- Issues: tastings_modify_policy (ALL) and tastings_select_policy (SELECT) overlap
-- Strategy: Keep it simple - owner-only for all operations
-- ============================================
DROP POLICY IF EXISTS "tastings_select_policy" ON tastings;
DROP POLICY IF EXISTS "tastings_modify_policy" ON tastings;
-- Owner-only policy for all operations
CREATE POLICY "tastings_policy" ON tastings
FOR ALL USING ((SELECT auth.uid()) = user_id);
-- ============================================
-- Verification Query (run after migration)
-- ============================================
-- SELECT tablename, policyname, roles, cmd
-- FROM pg_policies
-- WHERE schemaname = 'public'
-- ORDER BY tablename, cmd, roles;

View 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';

View 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())
)
);

View 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
)
);

View File

@@ -0,0 +1,31 @@
-- ============================================
-- Database Security Optimization: Search Path
-- ============================================
-- Addresses "function_search_path_mutable" security warnings
-- ============================================
-- Fix for handle_new_user
ALTER FUNCTION public.handle_new_user() SET search_path = '';
-- Fix for generate_buddy_code
-- If this function exists, update its search_path
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'generate_buddy_code') THEN
ALTER FUNCTION public.generate_buddy_code() SET search_path = '';
END IF;
END $$;
-- Fix for check_session_access
-- If this function exists, update its search_path
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'check_session_access') THEN
BEGIN
ALTER FUNCTION public.check_session_access(uuid) SET search_path = '';
-- Note: If it has different arguments, you might need to adjust the signature above
EXCEPTION WHEN OTHERS THEN
RAISE NOTICE 'Could not set search_path for check_session_access: %', SQLERRM;
END;
END IF;
END $$;

View File

@@ -46,7 +46,7 @@ function sleep(ms: number): Promise<void> {
/** /**
* Enrich with OpenRouter * Enrich with OpenRouter
*/ */
async function enrichWithOpenRouter(instruction: string): Promise<{ data: any; apiTime: number }> { async function enrichWithOpenRouter(instruction: string): Promise<{ data: any; apiTime: number; responseText: string }> {
const client = getOpenRouterClient(); const client = getOpenRouterClient();
const startApi = performance.now(); const startApi = performance.now();
const maxRetries = 3; const maxRetries = 3;
@@ -86,6 +86,7 @@ async function enrichWithOpenRouter(instruction: string): Promise<{ data: any; a
return { return {
data: JSON.parse(jsonStr), data: JSON.parse(jsonStr),
apiTime: endApi - startApi, apiTime: endApi - startApi,
responseText: content
}; };
} catch (error: any) { } catch (error: any) {
@@ -109,7 +110,7 @@ async function enrichWithOpenRouter(instruction: string): Promise<{ data: any; a
/** /**
* Enrich with Gemini * Enrich with Gemini
*/ */
async function enrichWithGemini(instruction: string): Promise<{ data: any; apiTime: number }> { async function enrichWithGemini(instruction: string): Promise<{ data: any; apiTime: number; responseText: string }> {
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!); const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);
const model = genAI.getGenerativeModel({ const model = genAI.getGenerativeModel({
model: 'gemini-2.5-flash', model: 'gemini-2.5-flash',
@@ -130,9 +131,11 @@ async function enrichWithGemini(instruction: string): Promise<{ data: any; apiTi
const result = await model.generateContent(instruction); const result = await model.generateContent(instruction);
const endApi = performance.now(); const endApi = performance.now();
const responseText = result.response.text();
return { return {
data: JSON.parse(result.response.text()), data: JSON.parse(responseText),
apiTime: endApi - startApi, apiTime: endApi - startApi,
responseText: responseText
}; };
} }
@@ -200,7 +203,7 @@ Instructions:
3. Provide a clean "search_string" for Whiskybase (e.g., "Distillery Name Age").`; 3. Provide a clean "search_string" for Whiskybase (e.g., "Distillery Name Age").`;
console.log(`[EnrichData] Using provider: ${provider}`); console.log(`[EnrichData] Using provider: ${provider}`);
let result: { data: any; apiTime: number }; let result: { data: any; apiTime: number; responseText: string };
if (provider === 'openrouter') { if (provider === 'openrouter') {
result = await enrichWithOpenRouter(instruction); result = await enrichWithOpenRouter(instruction);
@@ -224,7 +227,10 @@ Instructions:
userId: userId, userId: userId,
apiType: 'gemini_ai', apiType: 'gemini_ai',
endpoint: `enrichData_${provider}`, endpoint: `enrichData_${provider}`,
success: true success: true,
provider: provider,
model: provider === 'openrouter' ? ENRICHMENT_MODEL : 'gemini-2.5-flash',
responseText: result.responseText
}); });
await deductCredits(userId, 'gemini_ai', `Data enrichment (${provider})`); await deductCredits(userId, 'gemini_ai', `Data enrichment (${provider})`);

View File

@@ -83,7 +83,7 @@ function sleep(ms: number): Promise<void> {
* Analyze whisky label with OpenRouter (Gemma 3 27B) * Analyze whisky label with OpenRouter (Gemma 3 27B)
* Includes retry logic for 429 rate limit errors * Includes retry logic for 429 rate limit errors
*/ */
async function analyzeWithOpenRouter(base64Data: string, mimeType: string): Promise<{ data: any; apiTime: number }> { async function analyzeWithOpenRouter(base64Data: string, mimeType: string): Promise<{ data: any; apiTime: number; responseText: string }> {
const client = getOpenRouterClient(); const client = getOpenRouterClient();
const startApi = performance.now(); const startApi = performance.now();
const maxRetries = 3; const maxRetries = 3;
@@ -129,6 +129,7 @@ async function analyzeWithOpenRouter(base64Data: string, mimeType: string): Prom
return { return {
data: JSON.parse(jsonStr), data: JSON.parse(jsonStr),
apiTime: endApi - startApi, apiTime: endApi - startApi,
responseText: content
}; };
} catch (error: any) { } catch (error: any) {
@@ -155,7 +156,7 @@ async function analyzeWithOpenRouter(base64Data: string, mimeType: string): Prom
/** /**
* Analyze whisky label with Gemini * Analyze whisky label with Gemini
*/ */
async function analyzeWithGemini(base64Data: string, mimeType: string): Promise<{ data: any; apiTime: number }> { async function analyzeWithGemini(base64Data: string, mimeType: string): Promise<{ data: any; apiTime: number; responseText: string }> {
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!); const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);
const model = genAI.getGenerativeModel({ const model = genAI.getGenerativeModel({
model: 'gemini-2.5-flash', model: 'gemini-2.5-flash',
@@ -179,9 +180,11 @@ async function analyzeWithGemini(base64Data: string, mimeType: string): Promise<
]); ]);
const endApi = performance.now(); const endApi = performance.now();
const responseText = result.response.text();
return { return {
data: JSON.parse(result.response.text()), data: JSON.parse(responseText),
apiTime: endApi - startApi, apiTime: endApi - startApi,
responseText: responseText
}; };
} }
@@ -243,7 +246,7 @@ export async function analyzeLabelWithGemini(imageBase64: string): Promise<Gemin
// Call appropriate provider // Call appropriate provider
console.log(`[Vision] Using provider: ${provider}`); console.log(`[Vision] Using provider: ${provider}`);
let result: { data: any; apiTime: number }; let result: { data: any; apiTime: number; responseText: string };
if (provider === 'openrouter') { if (provider === 'openrouter') {
result = await analyzeWithOpenRouter(base64Data, mimeType); result = await analyzeWithOpenRouter(base64Data, mimeType);
@@ -278,7 +281,10 @@ export async function analyzeLabelWithGemini(imageBase64: string): Promise<Gemin
userId: user.id, userId: user.id,
apiType: 'gemini_ai', // Keep same type for tracking apiType: 'gemini_ai', // Keep same type for tracking
endpoint: `analyzeLabelWith${provider === 'openrouter' ? 'OpenRouter' : 'Gemini'}`, endpoint: `analyzeLabelWith${provider === 'openrouter' ? 'OpenRouter' : 'Gemini'}`,
success: true success: true,
provider: provider,
model: provider === 'openrouter' ? OPENROUTER_VISION_MODEL : 'gemini-2.5-flash',
responseText: result.responseText
}); });
await deductCredits(user.id, 'gemini_ai', `Vision label analysis (${provider})`); await deductCredits(user.id, 'gemini_ai', `Vision label analysis (${provider})`);

View File

@@ -186,7 +186,10 @@ export async function scanLabel(input: any): Promise<AnalysisResponse> {
userId: userId, userId: userId,
apiType: 'gemini_ai', apiType: 'gemini_ai',
endpoint: 'scanLabel_openrouter', endpoint: 'scanLabel_openrouter',
success: true success: true,
provider: 'openrouter',
model: 'google/gemma-3-27b-it',
responseText: content
}); });
await deductCredits(userId, 'gemini_ai', 'Bottle OCR scan (OpenRouter)'); await deductCredits(userId, 'gemini_ai', 'Bottle OCR scan (OpenRouter)');
@@ -248,7 +251,10 @@ export async function scanLabel(input: any): Promise<AnalysisResponse> {
userId: userId, userId: userId,
apiType: 'gemini_ai', apiType: 'gemini_ai',
endpoint: 'scanLabel_gemini', endpoint: 'scanLabel_gemini',
success: true success: true,
provider: 'google',
model: 'gemini-2.5-flash',
responseText: result.response.text()
}); });
await deductCredits(userId, 'gemini_ai', 'Bottle OCR scan (Gemini)'); await deductCredits(userId, 'gemini_ai', 'Bottle OCR scan (Gemini)');
@@ -279,7 +285,9 @@ export async function scanLabel(input: any): Promise<AnalysisResponse> {
apiType: 'gemini_ai', apiType: 'gemini_ai',
endpoint: `scanLabel_${provider}`, endpoint: `scanLabel_${provider}`,
success: false, success: false,
errorMessage: aiError.message errorMessage: aiError.message,
provider: provider,
model: provider === 'openrouter' ? 'google/gemma-3-27b-it' : 'gemini-2.5-flash'
}); });
return { return {

View File

@@ -219,36 +219,66 @@ export default async function AdminPage() {
<tr className="border-b border-zinc-200 dark:border-zinc-800"> <tr className="border-b border-zinc-200 dark:border-zinc-800">
<th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">Time</th> <th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">Time</th>
<th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">User</th> <th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">User</th>
<th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">API Type</th> <th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">API/Provider</th>
<th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">Model</th>
<th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">Endpoint</th> <th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">Endpoint</th>
<th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">Status</th> <th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">Status</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{recentUsage.map((call: any) => ( {recentUsage.map((call: any) => (
<tr key={call.id} className="border-b border-zinc-100 dark:border-zinc-800/50"> <tr key={call.id} className="border-b border-zinc-100 dark:border-zinc-800/50 hover:bg-zinc-50 dark:hover:bg-zinc-900/50 transition-colors">
<td className="py-3 px-4 text-sm text-zinc-600 dark:text-zinc-400"> <td className="py-3 px-4 text-[10px] text-zinc-500 font-mono">
{new Date(call.created_at).toLocaleString('de-DE')} {new Date(call.created_at).toLocaleString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit', day: '2-digit', month: '2-digit' })}
</td> </td>
<td className="py-3 px-4 text-sm text-zinc-900 dark:text-white"> <td className="py-3 px-4 text-sm font-bold text-zinc-900 dark:text-white">
{call.profiles?.username || 'Unknown'} {call.profiles?.username || 'Unknown'}
</td> </td>
<td className="py-3 px-4"> <td className="py-3 px-4">
<span className={`px-2 py-1 rounded-full text-xs font-bold ${call.api_type === 'google_search' <div className="flex flex-col gap-1">
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400' <span className={`px-2 py-0.5 rounded-full text-[10px] font-black uppercase w-fit ${call.api_type === 'google_search'
: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400' ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400'
}`}> : 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400'
{call.api_type === 'google_search' ? 'Google Search' : 'Gemini AI'} }`}>
{call.api_type === 'google_search' ? 'Google' : 'AI'}
</span>
{call.provider && (
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-tighter">
via {call.provider}
</span>
)}
</div>
</td>
<td className="py-3 px-4">
<span className="text-[10px] font-mono text-zinc-600 dark:text-zinc-400 block max-w-[120px] truncate" title={call.model}>
{call.model || '-'}
</span> </span>
</td> </td>
<td className="py-3 px-4 text-sm text-zinc-600 dark:text-zinc-400"> <td className="py-3 px-4">
{call.endpoint} <div className="space-y-1">
<div className="text-[10px] font-bold text-zinc-500 uppercase">{call.endpoint}</div>
{call.response_text && (
<details className="text-[10px]">
<summary className="cursor-pointer text-orange-600 hover:text-orange-700 font-bold uppercase transition-colors">Response</summary>
<pre className="mt-2 p-2 bg-zinc-100 dark:bg-zinc-950 rounded border border-zinc-200 dark:border-zinc-800 overflow-x-auto max-w-sm whitespace-pre-wrap font-mono text-[9px] text-zinc-400">
{call.response_text}
</pre>
</details>
)}
</div>
</td> </td>
<td className="py-3 px-4"> <td className="py-3 px-4">
{call.success ? ( {call.success ? (
<span className="text-green-600 dark:text-green-400 font-bold"></span> <span className="text-green-600 dark:text-green-400 font-black text-xs">OK</span>
) : ( ) : (
<span className="text-red-600 dark:text-red-400 font-bold"></span> <div className="group relative">
<span className="text-red-600 dark:text-red-400 font-black text-xs cursor-help">ERR</span>
{call.error_message && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 w-48 p-2 bg-red-600 text-white text-[9px] rounded shadow-xl opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-50">
{call.error_message}
</div>
)}
</div>
)} )}
</td> </td>
</tr> </tr>

View File

@@ -4,24 +4,24 @@
@layer base { @layer base {
:root { :root {
--background: #09090b; --background: #1c1c1e;
/* zinc-950 */ /* systemBackground */
--surface: #18181b; --surface: #2c2c2e;
/* zinc-900 */ /* secondarySystemBackground */
--primary: #ea580c; --primary: #ea580c;
/* orange-600 */ /* orange-600 */
--secondary: #f97316; --secondary: #f97316;
/* orange-500 */ /* orange-500 */
--text-primary: #fafafa; --text-primary: #fafafa;
--text-secondary: #a1a1aa; --text-secondary: #a1a1aa;
--border: #27272a; --border: #38383a;
/* zinc-800 */ /* separator */
--ring: #f97316; --ring: #f97316;
} }
} }
body { body {
@apply bg-[#09090b] text-[#fafafa] antialiased; @apply bg-[#1c1c1e] text-[#fafafa] antialiased selection:bg-orange-500/30;
font-feature-settings: "cv02", "cv03", "cv04", "cv11"; font-feature-settings: "cv02", "cv03", "cv04", "cv11";
} }

View File

@@ -13,9 +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 { getActiveSplits } from '@/services/split-actions';
import SplitCard from '@/components/SplitCard';
export default function Home() { export default function Home() {
const supabase = createClient(); const supabase = createClient();
@@ -27,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);
@@ -73,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') {
@@ -152,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);
@@ -191,19 +218,39 @@ 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>
); );
} }
return ( return (
<main className="flex min-h-screen flex-col items-center gap-6 md:gap-12 p-4 md:p-24 bg-zinc-950 pb-32"> <main className="flex min-h-screen flex-col items-center gap-6 md:gap-12 p-4 md:p-24 bg-[var(--background)] pb-32">
<div className="z-10 max-w-5xl w-full flex flex-col items-center gap-12"> <div className="z-10 max-w-5xl w-full flex flex-col items-center gap-12">
<header className="w-full flex flex-col sm:flex-row justify-between items-center gap-4 sm:gap-0"> <header className="w-full flex flex-col sm:flex-row justify-between items-center gap-4 sm:gap-0">
<div className="flex flex-col items-center sm:items-start group"> <div className="flex flex-col items-center sm:items-start group">
@@ -224,6 +271,7 @@ export default function Home() {
)} )}
</div> </div>
<div className="flex flex-wrap items-center justify-center sm:justify-end gap-3 md:gap-4"> <div className="flex flex-wrap items-center justify-center sm:justify-end gap-3 md:gap-4">
<UserStatusBadge />
<OfflineIndicator /> <OfflineIndicator />
<LanguageSwitcher /> <LanguageSwitcher />
<DramOfTheDay bottles={bottles} /> <DramOfTheDay bottles={bottles} />
@@ -252,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>
@@ -283,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

View File

@@ -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>
); );
} }

View File

@@ -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>

View File

@@ -2,13 +2,15 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
import { LogIn, UserPlus, Mail, Lock, Loader2, AlertCircle } from 'lucide-react'; import { LogIn, UserPlus, Mail, Lock, Loader2, AlertCircle, User, AtSign } from 'lucide-react';
export default function AuthForm() { export default function AuthForm() {
const supabase = createClient(); const supabase = createClient();
const [isLogin, setIsLogin] = useState(true); const [isLogin, setIsLogin] = useState(true);
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [username, setUsername] = useState('');
const [fullName, setFullName] = useState('');
const [rememberMe, setRememberMe] = useState(true); const [rememberMe, setRememberMe] = useState(true);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -30,21 +32,67 @@ export default function AuthForm() {
if (error) throw error; if (error) throw error;
// If remember-me is checked, session will persist (default Supabase behavior) // If remember-me is checked, session will persist (default Supabase behavior)
// If not checked, clear session on browser close via localStorage flag
if (!rememberMe) { if (!rememberMe) {
sessionStorage.setItem('dramlog_session_only', 'true'); sessionStorage.setItem('dramlog_session_only', 'true');
} else { } else {
sessionStorage.removeItem('dramlog_session_only'); sessionStorage.removeItem('dramlog_session_only');
} }
} else { } else {
const { error } = await supabase.auth.signUp({ // Validate username
if (!username.trim()) {
throw new Error('Bitte gib einen Benutzernamen ein.');
}
if (username.length < 3) {
throw new Error('Benutzername muss mindestens 3 Zeichen haben.');
}
if (!/^[a-zA-Z0-9_]+$/.test(username)) {
throw new Error('Benutzername darf nur Buchstaben, Zahlen und Unterstriche enthalten.');
}
// Check if username is already taken
const { data: existingUser } = await supabase
.from('profiles')
.select('id')
.eq('username', username.toLowerCase())
.single();
if (existingUser) {
throw new Error('Dieser Benutzername ist bereits vergeben.');
}
// Create user with metadata
const { data: signUpData, error } = await supabase.auth.signUp({
email, email,
password, password,
options: { options: {
emailRedirectTo: `${window.location.origin}/auth/callback`, emailRedirectTo: `${window.location.origin}/auth/callback`,
data: {
username: username.toLowerCase(),
full_name: fullName.trim() || undefined,
}
} }
}); });
if (error) throw error; if (error) throw error;
// After successful signup, create subscription with starter plan
if (signUpData.user) {
// Get starter plan ID
const { data: starterPlan } = await supabase
.from('subscription_plans')
.select('id')
.eq('name', 'starter')
.single();
if (starterPlan) {
await supabase
.from('user_subscriptions')
.upsert({
user_id: signUpData.user.id,
plan_id: starterPlan.id,
}, { onConflict: 'user_id' });
}
}
setMessage('Checke deine E-Mails, um dein Konto zu bestätigen!'); setMessage('Checke deine E-Mails, um dein Konto zu bestätigen!');
} }
} catch (err) { } catch (err) {
@@ -71,6 +119,43 @@ export default function AuthForm() {
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{/* Registration-only fields */}
{!isLogin && (
<>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-zinc-400 ml-1">Benutzername</label>
<div className="relative">
<AtSign className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={18} />
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))}
placeholder="dein_username"
required
maxLength={20}
className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl focus:ring-1 focus:ring-orange-600 focus:border-transparent outline-none transition-all text-white placeholder:text-zinc-600"
/>
</div>
<p className="text-[10px] text-zinc-600 ml-1">Nur Kleinbuchstaben, Zahlen und _</p>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-zinc-400 ml-1">Name <span className="text-zinc-600">(optional)</span></label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={18} />
<input
type="text"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
placeholder="Max Mustermann"
maxLength={50}
className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl focus:ring-1 focus:ring-orange-600 focus:border-transparent outline-none transition-all text-white placeholder:text-zinc-600"
/>
</div>
</div>
</>
)}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-zinc-400 ml-1">E-Mail</label> <label className="text-[10px] font-black uppercase tracking-widest text-zinc-400 ml-1">E-Mail</label>
<div className="relative"> <div className="relative">

View File

@@ -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(() => {
@@ -39,11 +40,22 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
const handleQuickUpdate = async (newPrice?: string, newStatus?: string) => { const handleQuickUpdate = async (newPrice?: string, newStatus?: string) => {
if (isOffline) return; if (isOffline) return;
setIsUpdating(true); setIsUpdating(true);
// Haptic feedback for interaction
if (window.navigator.vibrate) {
window.navigator.vibrate(10);
}
try { try {
await updateBottle(bottleId, { await updateBottle(bottleId, {
purchase_price: newPrice !== undefined ? (newPrice ? parseFloat(newPrice) : null) : (price ? parseFloat(price) : null), purchase_price: newPrice !== undefined ? (newPrice ? parseFloat(newPrice) : null) : (price ? parseFloat(price) : null),
status: newStatus !== undefined ? newStatus : status status: newStatus !== undefined ? newStatus : status
} as any); } as any);
// Success haptic
if (window.navigator.vibrate) {
window.navigator.vibrate([10, 50, 10]);
}
} catch (err) { } catch (err) {
console.error('Quick update failed:', err); console.error('Quick update failed:', err);
} finally { } finally {
@@ -82,240 +94,257 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
if (!bottle) return null; // Should not happen due to check above if (!bottle) return null; // Should not happen due to check above
return ( return (
<div className="max-w-4xl mx-auto space-y-6 md:space-y-12"> <div className="max-w-4xl mx-auto pb-24">
{/* Back Button */} {/* Header / Hero Section */}
<Link <div className="relative w-full overflow-hidden bg-[var(--surface)] shadow-2xl">
href={`/${sessionId ? `?session_id=${sessionId}` : ''}`} {/* Back Button Overlay */}
className="inline-flex items-center gap-2 text-zinc-500 hover:text-orange-600 transition-colors font-bold mb-4" <div className="absolute top-6 left-6 z-20">
> <Link
<ChevronLeft size={20} /> href={`/${sessionId ? `?session_id=${sessionId}` : ''}`}
Zurück zur Sammlung className="flex items-center justify-center w-10 h-10 rounded-full bg-black/40 backdrop-blur-md text-white border border-white/10 active:scale-95 transition-all"
</Link> >
<ChevronLeft size={24} />
{isOffline && ( </Link>
<div className="bg-orange-600/10 border border-orange-600/20 p-3 rounded-2xl flex items-center gap-3 animate-in fade-in slide-in-from-top-2">
<WifiOff size={16} className="text-orange-600" />
<p className="text-[10px] font-bold uppercase tracking-widest text-orange-500">Offline-Modus: Daten aus dem Cache</p>
</div> </div>
)}
{/* Hero Section */} {/* Hero Image - Slightly More Compact Aspect for better title flow */}
<section className="grid grid-cols-1 md:grid-cols-2 gap-8 items-start"> <div className="relative aspect-[4/3] md:aspect-[16/8] w-full flex items-center justify-center p-6 md:p-10 overflow-hidden">
<div className="aspect-[4/5] rounded-3xl overflow-hidden shadow-2xl border border-zinc-800 bg-zinc-900"> {/* Background Glow */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-orange-600/10 via-transparent to-transparent opacity-30" />
<img <img
src={getStorageUrl(bottle.image_url)} src={getStorageUrl(bottle.image_url)}
alt={bottle.name} alt={bottle.name}
className="w-full h-full object-cover" className="max-h-full max-w-full object-contain drop-shadow-[0_20px_60px_rgba(0,0,0,0.6)] z-10 transition-transform duration-700 hover:scale-105"
/> />
</div> </div>
<div className="space-y-6"> {/* Info Overlay - Mobile Gradient */}
<div> <div className="absolute inset-x-0 bottom-0 h-48 bg-gradient-to-t from-[var(--background)] to-transparent pointer-events-none" />
<h1 className="text-2xl md:text-4xl font-bold text-zinc-50 tracking-tighter leading-tight uppercase"> </div>
{bottle.name}
</h1>
<p className="text-sm md:text-xl text-orange-600 font-bold mt-1 uppercase tracking-widest">{bottle.distillery}</p>
{bottle.whiskybase_id && ( {/* Content Container */}
<div className="mt-4"> <div className="px-4 md:px-12 -mt-12 relative z-10 space-y-8">
<a {/* Title Section - HIG Large Title Pattern */}
href={`https://www.whiskybase.com/whiskies/whisky/${bottle.whiskybase_id}`} <div className="space-y-1 text-center md:text-left">
target="_blank" {isOffline && (
rel="noopener noreferrer" <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">
className="inline-flex items-center gap-2 px-4 py-2 bg-zinc-900 text-zinc-400 border border-zinc-800 rounded-xl text-xs font-bold hover:text-orange-600 transition-colors" <WifiOff size={12} className="text-orange-600" />
> <p className="text-[9px] font-black uppercase tracking-widest text-orange-500">Offline Mode</p>
<ExternalLink size={14} />
Whiskybase ID: {bottle.whiskybase_id}
</a>
</div>
)}
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<div className="p-4 bg-zinc-900 rounded-2xl border border-zinc-800 shadow-sm flex flex-col justify-between">
<div>
<div className="flex items-center gap-2 text-zinc-500 text-[10px] font-bold uppercase mb-1">
<Tag size={12} /> Kategorie
</div>
<div className="font-bold text-sm text-zinc-200">{bottle.category || '-'}</div>
</div>
</div> </div>
<div className="p-4 bg-zinc-900 rounded-2xl border border-zinc-800 shadow-sm"> )}
<div className="flex items-center gap-2 text-zinc-500 text-[10px] font-bold uppercase mb-1"> <h2 className="text-xs md:text-sm font-black text-orange-500 uppercase tracking-[0.25em] drop-shadow-sm">
<Droplets size={12} /> Alkoholgehalt {bottle.distillery || 'Unknown Distillery'}
</div> </h2>
<div className="font-bold text-sm text-zinc-200">{bottle.abv}% Vol.</div> <h1 className="text-4xl md:text-6xl font-extrabold text-white tracking-tight leading-[1.05] drop-shadow-md">
</div> {bottle.name}
<div className="p-4 bg-zinc-900 rounded-2xl border border-zinc-800 shadow-sm"> </h1>
<div className="flex items-center gap-2 text-zinc-500 text-[10px] font-bold uppercase mb-1">
<Award size={12} /> Alter
</div>
<div className="font-bold text-sm text-zinc-200">{bottle.age ? `${bottle.age} J.` : '-'}</div>
</div>
{bottle.distilled_at && (
<div className="p-4 bg-zinc-900 rounded-2xl border border-zinc-800 shadow-sm">
<div className="flex items-center gap-2 text-zinc-500 text-[10px] font-bold uppercase mb-1">
<Calendar size={12} /> Destilliert
</div>
<div className="font-bold text-sm text-zinc-200">{bottle.distilled_at}</div>
</div>
)}
{bottle.bottled_at && (
<div className="p-4 bg-zinc-900 rounded-2xl border border-zinc-800 shadow-sm">
<div className="flex items-center gap-2 text-zinc-500 text-[10px] font-bold uppercase mb-1">
<Package size={12} /> Abgefüllt
</div>
<div className="font-bold text-sm text-zinc-200">{bottle.bottled_at}</div>
</div>
)}
{/* Quick Collection Card */}
<div className="md:col-span-2 p-5 bg-orange-600/5 rounded-3xl border border-orange-600/20 shadow-xl space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-orange-600 flex items-center gap-2">
<Package size={14} /> Sammlungs-Status
</h3>
{isUpdating && <Loader2 size={12} className="animate-spin text-orange-600" />}
</div>
<div className="grid grid-cols-2 gap-4">
{/* Price */}
<div className="space-y-1.5">
<label className="text-[9px] font-bold uppercase text-zinc-500 ml-1">Einkaufspreis</label>
<div className="relative">
<input
type="number"
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-xl pl-3 pr-8 py-2 text-sm font-bold text-orange-500 focus:outline-none focus:border-orange-600 transition-all"
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-[10px] font-bold text-zinc-700"></div>
</div>
</div>
{/* Status */}
<div className="space-y-1.5">
<label className="text-[9px] font-bold uppercase text-zinc-500 ml-1">Flaschenstatus</label>
<select
value={status}
onChange={(e) => {
setStatus(e.target.value);
handleQuickUpdate(undefined, e.target.value);
}}
className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-3 py-2 text-sm font-bold text-zinc-200 focus:outline-none focus:border-orange-600 appearance-none transition-all"
>
<option value="sealed">Versiegelt</option>
<option value="open">Offen</option>
<option value="sampled">Sample</option>
<option value="empty">Leer</option>
</select>
</div>
</div>
</div>
{bottle.batch_info && (
<div className="p-4 bg-zinc-800/30 rounded-2xl border border-dashed border-zinc-700/50 md:col-span-1">
<div className="flex items-center gap-2 text-zinc-500 text-[10px] font-bold uppercase mb-1">
<Info size={12} /> Batch / Code
</div>
<div className="font-mono text-xs text-zinc-300 truncate" title={bottle.batch_info}>{bottle.batch_info}</div>
</div>
)}
<div className="p-4 bg-zinc-900 rounded-2xl border border-zinc-800 shadow-sm">
<div className="flex items-center gap-2 text-zinc-500 text-[10px] font-bold uppercase mb-1">
<Calendar size={12} /> Letzter Dram
</div>
<div className="font-bold text-sm text-zinc-200">
{tastings && tastings.length > 0
? new Date(tastings[0].created_at).toLocaleDateString('de-DE')
: 'Noch nie'}
</div>
</div>
</div>
<div className="pt-2 flex flex-wrap gap-4">
{isOffline ? (
<div className="w-full p-4 bg-zinc-900 border border-dashed border-zinc-800 rounded-2xl flex items-center justify-center gap-2">
<Info size={14} className="text-zinc-500" />
<span className="text-[10px] font-bold uppercase text-zinc-500 tracking-widest">Bearbeiten & Löschen nur online möglich</span>
</div>
) : (
<>
<EditBottleForm bottle={bottle as any} />
<Link
href={`/splits/create?bottle=${bottle.id}`}
className="px-5 py-3 bg-zinc-800 hover:bg-zinc-700 text-white rounded-2xl text-xs font-black uppercase tracking-widest flex items-center gap-2 transition-all border border-zinc-700 hover:border-orange-600/50"
>
<Share2 size={16} className="text-orange-500" />
Split starten
</Link>
<DeleteBottleButton bottleId={bottle.id} />
</>
)}
</div>
</div>
</section>
<hr className="border-zinc-800" />
{/* Tasting Notes Section */}
<section className="space-y-8">
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-4">
<div>
<h2 className="text-3xl font-bold text-zinc-50 tracking-tight uppercase">Tasting Notes</h2>
<p className="text-zinc-500 mt-1">Hier findest du deine bisherigen Eindrücke.</p>
</div>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 md:gap-8 items-start"> {/* Primary Bottle Profile Card */}
{/* Form */} <section className="bg-zinc-900/40 backdrop-blur-2xl border border-white/5 rounded-[32px] overflow-hidden shadow-2xl">
<div className="lg:col-span-1 space-y-4 md:sticky md:top-24"> {/* Integrated Header/Tabs */}
<div className="flex border-b border-white/5">
<button <button
onClick={() => setIsFormVisible(!isFormVisible)} onClick={() => setIsEditMode(false)}
className={`w-full p-6 rounded-3xl border flex items-center justify-between transition-all group ${isFormVisible ? 'bg-orange-600 border-orange-600 text-white shadow-xl shadow-orange-950/40' : 'bg-zinc-900/50 border-zinc-800 text-zinc-400 hover:border-orange-500/30'}`} 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'}`}
> >
<div className="flex items-center gap-3"> Overview
{isFormVisible ? <Plus size={20} className="rotate-45 transition-transform" /> : <Plus size={20} className="text-orange-600 transition-transform" />}
<span className={`text-sm font-black uppercase tracking-widest ${isFormVisible ? 'text-white' : 'text-zinc-100'}`}>Neue Tasting Note</span>
</div>
<ChevronDown size={20} className={`transition-transform duration-300 ${isFormVisible ? 'rotate-180' : 'opacity-0'}`} />
</button> </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>
<AnimatePresence> <AnimatePresence mode="wait">
{isFormVisible && ( {!isEditMode ? (
<motion.div <motion.div
initial={{ opacity: 0, y: -20, height: 0 }} key="overview"
animate={{ opacity: 1, y: 0, height: 'auto' }} initial={{ opacity: 0, x: -20 }}
exit={{ opacity: 0, y: -20, height: 0 }} animate={{ opacity: 1, x: 0 }}
className="overflow-hidden" exit={{ opacity: 0, x: 20 }}
> className="p-6 md:p-8 space-y-8"
<div className="border border-zinc-800 rounded-3xl p-6 bg-zinc-900/50"> >
<h3 className="text-lg font-bold mb-6 flex items-center gap-2 text-orange-600 uppercase tracking-widest"> {/* Fact Grid - Integrated Metadata & Stats */}
<Droplets size={20} /> Dram bewerten <div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
</h3> <FactCard label="Category" value={bottle.category || 'Whisky'} icon={<Wine size={14} />} />
<TastingNoteForm <FactCard label="ABV" value={bottle.abv ? `${bottle.abv}%` : '%'} icon={<Droplets size={14} />} highlight={!bottle.abv} />
bottleId={bottle.id} <FactCard label="Age" value={bottle.age ? `${bottle.age}Y` : '-'} icon={<Award size={14} />} />
sessionId={sessionId} <FactCard label="Price" value={bottle.purchase_price ? `${bottle.purchase_price}` : '-'} icon={<CircleDollarSign size={14} />} />
onSuccess={() => setIsFormVisible(false)} </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> </div>
</motion.div>
)} {/* Last Dram Info */}
</AnimatePresence> <div className="space-y-3">
<div className="flex items-center gap-2 px-1">
<Calendar size={14} className="text-orange-500" />
<span className="text-[10px] font-black uppercase tracking-widest text-zinc-500">Last Enjoyed</span>
</div>
<div className="bg-black/40 rounded-2xl px-6 py-3.5 border border-white/5 flex items-center justify-between">
<span className="text-sm font-black text-zinc-200 uppercase">
{tastings?.length ? new Date(tastings[0].created_at).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US', { day: '2-digit', month: '2-digit', year: 'numeric' }) : 'No dram yet'}
</span>
{tastings?.length > 0 && <CheckCircle2 size={16} className="text-orange-600" />}
</div>
</div>
</div>
{/* Whiskybase Link - Premium Style */}
{bottle.whiskybase_id && (
<div className="pt-2">
<a
href={`https://www.whiskybase.com/whiskies/whisky/${bottle.whiskybase_id}`}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center justify-between p-4 bg-orange-600/10 border border-orange-600/20 rounded-2xl hover:bg-orange-600/20 transition-all"
>
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-orange-600 flex items-center justify-center text-white shadow-lg shadow-orange-950/40">
<ExternalLink size={20} />
</div>
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-orange-400">View on Whiskybase</p>
<p className="text-sm font-black text-zinc-100">#{bottle.whiskybase_id}</p>
</div>
</div>
<ChevronLeft size={20} className="rotate-180 text-orange-500 group-hover:translate-x-1 transition-transform" />
</a>
</div>
)}
</motion.div>
) : (
<motion.div
key="edit"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="p-6 md:p-8"
>
<EditBottleForm
bottle={bottle as any}
onComplete={() => setIsEditMode(false)}
/>
</motion.div>
)}
</AnimatePresence>
</section>
{/* 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" />
<section className="space-y-8">
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-4">
<div>
<h2 className="text-3xl font-bold text-zinc-50 tracking-tight uppercase">Tasting Notes</h2>
<p className="text-zinc-500 mt-1">Hier findest du deine bisherigen Eindrücke.</p>
</div>
</div> </div>
{/* List */} <div className="grid grid-cols-1 lg:grid-cols-3 gap-6 md:gap-8 items-start">
<div className="lg:col-span-2"> {/* Form */}
<TastingList initialTastings={tastings as any || []} currentUserId={userId} /> <div className="lg:col-span-1 space-y-4 md:sticky md:top-24">
<button
onClick={() => setIsFormVisible(!isFormVisible)}
className={`w-full p-6 rounded-3xl border flex items-center justify-between transition-all group ${isFormVisible ? 'bg-orange-600 border-orange-600 text-white shadow-xl shadow-orange-950/40' : 'bg-zinc-900/50 border-zinc-800 text-zinc-400 hover:border-orange-500/30'}`}
>
<div className="flex items-center gap-3">
{isFormVisible ? <Plus size={20} className="rotate-45 transition-transform" /> : <Plus size={20} className="text-orange-600 transition-transform" />}
<span className={`text-sm font-black uppercase tracking-widest ${isFormVisible ? 'text-white' : 'text-zinc-100'}`}>Neue Tasting Note</span>
</div>
<ChevronDown size={20} className={`transition-transform duration-300 ${isFormVisible ? 'rotate-180' : 'opacity-0'}`} />
</button>
<AnimatePresence>
{isFormVisible && (
<motion.div
initial={{ opacity: 0, y: -20, height: 0 }}
animate={{ opacity: 1, y: 0, height: 'auto' }}
exit={{ opacity: 0, y: -20, height: 0 }}
className="overflow-hidden"
>
<div className="border border-zinc-800 rounded-3xl p-6 bg-zinc-900/50">
<h3 className="text-lg font-bold mb-6 flex items-center gap-2 text-orange-600 uppercase tracking-widest">
<Droplets size={20} /> Dram bewerten
</h3>
<TastingNoteForm
bottleId={bottle.id}
sessionId={sessionId}
onSuccess={() => setIsFormVisible(false)}
/>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* List */}
<div className="lg:col-span-2">
<TastingList initialTastings={tastings as any || []} currentUserId={userId} />
</div>
</div> </div>
</div> </section>
</section> </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> </div>
); );
} }

View File

@@ -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-xl bg-zinc-900 border border-zinc-800 transition-all duration-300 hover:border-zinc-700 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-bold text-orange-500 uppercase tracking-widest leading-none"> <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-lg text-zinc-50 leading-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>

View File

@@ -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 gap-0.5 px-3 py-1.5 text-zinc-400 hover:text-white transition-colors active:scale-95" 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-[9px] font-medium tracking-wide">{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-1 py-1 bg-zinc-900/95 backdrop-blur-lg border border-zinc-800 rounded-full shadow-2xl pointer-events-auto"> <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>
); );
}; }

View File

@@ -102,188 +102,191 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
} }
}; };
if (!isEditing) {
return (
<div className="flex flex-col gap-2">
<button
onClick={() => setIsEditing(true)}
className="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded-xl text-sm font-bold transition-all w-fit border border-zinc-700"
>
<Edit2 size={16} />
{t('bottle.editDetails')}
</button>
{bottle.purchase_price && (
<div className="flex items-center gap-2 px-4 py-2 bg-green-900/10 text-green-400 rounded-xl text-sm font-bold border border-green-900/30 w-fit">
<CircleDollarSign size={16} />
{t('bottle.priceLabel')}: {parseFloat(bottle.purchase_price.toString()).toLocaleString(locale === 'de' ? 'de-DE' : 'en-US', { style: 'currency', currency: 'EUR' })}
</div>
)}
</div>
);
}
return ( return (
<div className="p-6 bg-zinc-900 border border-orange-500/20 rounded-3xl shadow-2xl space-y-4 animate-in zoom-in-95 duration-200"> <div className="space-y-6 animate-in fade-in slide-in-from-right-4 duration-500">
<div className="flex justify-between items-center mb-2"> <div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<h3 className="text-lg font-black text-orange-600 uppercase tracking-widest flex items-center gap-2"> {/* Full Width Inputs */}
<Info size={18} /> {t('bottle.editTitle')} <div className="space-y-2 md:col-span-2">
</h3> <label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.nameLabel')}</label>
<button
onClick={() => setIsEditing(false)}
className="text-zinc-400 hover:text-zinc-600 p-1"
>
<X size={20} />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1">{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-2 bg-zinc-800 border border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200" 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">
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1">{t('bottle.distilleryLabel')}</label> <div className="space-y-2 md:col-span-2">
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.distilleryLabel')}</label>
<input <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-2 bg-zinc-800 border border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200" 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">
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1">{t('bottle.categoryLabel')}</label> {/* Compact Row: Category */}
<div className="space-y-2">
<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-2 bg-zinc-800 border border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200" 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="grid grid-cols-2 gap-2">
<div className="space-y-1"> {/* Row A: ABV + Age */}
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1">{t('bottle.abvLabel')}</label> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.abvLabel')}</label>
<input <input
type="number" type="number"
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-2 bg-zinc-800 border border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200" 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"> <div className="space-y-2">
<label className="text-[10px] font-black uppercase text-zinc-400 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"
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-2 bg-zinc-800 border border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200" 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>
<div className="space-y-1">
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1 flex justify-between items-center"> {/* Row B: Distilled + Bottled */}
<span>Whiskybase ID</span> <div className="grid grid-cols-2 gap-4">
<button <div className="space-y-2">
type="button" <label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.distilledLabel')}</label>
onClick={handleDiscover} <input
disabled={isSearching} type="text"
className="text-orange-600 hover:text-orange-700 flex items-center gap-1 normal-case font-bold" inputMode="numeric"
> placeholder="YYYY"
{isSearching ? <Loader2 size={10} className="animate-spin" /> : <Search size={10} />} value={formData.distilled_at}
{t('bottle.autoSearch')} onChange={(e) => setFormData({ ...formData, distilled_at: e.target.value })}
</button> 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"
</label> />
<input </div>
type="text" <div className="space-y-2">
value={formData.whiskybase_id} <label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.bottledLabel')}</label>
onChange={(e) => setFormData({ ...formData, whiskybase_id: e.target.value })} <input
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200" type="text"
/> inputMode="numeric"
{discoveryResult && ( placeholder="YYYY"
<div className="mt-2 p-3 bg-zinc-950 border border-orange-500/20 rounded-xl animate-in fade-in slide-in-from-top-2"> value={formData.bottled_at}
<p className="text-[10px] text-zinc-500 mb-2">Treffer gefunden:</p> onChange={(e) => setFormData({ ...formData, bottled_at: e.target.value })}
<p className="text-[11px] font-bold text-zinc-200 mb-2 truncate">{discoveryResult.title}</p> 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 className="flex gap-2"> />
<button </div>
type="button" </div>
onClick={applyDiscovery}
className="px-3 py-1.5 bg-orange-600 text-white text-[10px] font-black uppercase rounded-lg hover:bg-orange-700 transition-colors" {/* Price and WB ID Row */}
> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:col-span-2">
{t('bottle.applyId')} <div className="space-y-2">
</button> <label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.priceLabel')} ()</label>
<a <input
href={discoveryResult.url} type="number"
target="_blank" inputMode="decimal"
rel="noopener noreferrer" step="0.01"
className="px-3 py-1.5 bg-zinc-800 text-zinc-400 text-[10px] font-black uppercase rounded-lg hover:bg-zinc-700 transition-colors flex items-center gap-1 border border-zinc-700" placeholder="0.00"
> value={formData.purchase_price}
<ExternalLink size={10} /> {t('common.check')} onChange={(e) => setFormData({ ...formData, purchase_price: e.target.value })}
</a> 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"
</div> />
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 flex justify-between items-center tracking-widest">
<span>Whiskybase ID</span>
<button
type="button"
onClick={handleDiscover}
disabled={isSearching}
className="text-orange-600 hover:text-orange-500 flex items-center gap-1.5 normal-case font-black text-[9px] tracking-widest transition-colors"
>
{isSearching ? <Loader2 size={12} className="animate-spin" /> : <Search size={12} />}
{t('bottle.autoSearch')}
</button>
</label>
<div className="relative">
<input
type="text"
inputMode="numeric"
value={formData.whiskybase_id}
onChange={(e) => setFormData({ ...formData, whiskybase_id: e.target.value })}
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-300 text-sm font-mono transition-all"
/>
{discoveryResult && (
<div className="absolute top-full left-0 right-0 z-50 mt-3 p-4 bg-zinc-900 border border-orange-500/30 rounded-2xl shadow-2xl animate-in zoom-in-95 duration-300">
<p className="text-[9px] font-black uppercase tracking-widest text-zinc-500 mb-2">Recommendation:</p>
<p className="text-[12px] font-black text-zinc-100 mb-4 line-clamp-2 leading-tight">{discoveryResult.title}</p>
<div className="flex gap-2">
<button
type="button"
onClick={applyDiscovery}
className="flex-1 py-3 bg-orange-600 hover:bg-orange-500 text-white text-[10px] font-black uppercase tracking-widest rounded-xl transition-colors shadow-lg shadow-orange-950/20"
>
Accept ID
</button>
<a
href={discoveryResult.url}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-3 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded-xl flex items-center justify-center border border-white/5 transition-colors"
>
<ExternalLink size={14} />
</a>
</div>
</div>
)}
</div> </div>
)} </div>
</div>
<div className="space-y-1">
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1">{t('bottle.priceLabel')} ()</label>
<input
type="number"
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-2 bg-zinc-950 border border-zinc-800 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50 font-bold text-zinc-100"
/>
</div> </div>
<div className="space-y-1"> {/* Batch Info */}
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1">{t('bottle.distilledLabel')}</label> <div className="space-y-2 md:col-span-2">
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.batchLabel')}</label>
<input <input
type="text" type="text"
placeholder="z.B. 2010" placeholder="e.g. Batch 12 or L-Code"
value={formData.distilled_at}
onChange={(e) => setFormData({ ...formData, distilled_at: e.target.value })}
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1">{t('bottle.bottledLabel')}</label>
<input
type="text"
placeholder="z.B. 2022"
value={formData.bottled_at}
onChange={(e) => setFormData({ ...formData, bottled_at: e.target.value })}
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200"
/>
</div>
<div className="space-y-1 md:col-span-2">
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1">{t('bottle.batchLabel')}</label>
<input
type="text"
placeholder="z.B. Batch 12 oder 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-2 bg-zinc-800 border border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200" 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>
{error && <p className="text-red-500 text-xs italic">{error}</p>} {error && (
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center gap-2 text-red-500 text-[10px] font-bold">
<Info size={14} />
{error}
</div>
)}
<button <div className="flex gap-3 pt-2">
onClick={handleSave} <button
disabled={isSaving} onClick={() => onComplete?.()}
className="w-full py-4 bg-orange-600 hover:bg-orange-700 text-white rounded-2xl font-black uppercase tracking-widest transition-all flex items-center justify-center gap-2 shadow-xl shadow-orange-950/20 disabled:opacity-50" className="flex-1 py-4 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded-2xl font-black uppercase tracking-widest text-xs transition-all"
> >
{isSaving ? <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div> : <Save size={20} />} {t('common.cancel')}
{t('bottle.saveChanges')} </button>
</button> <button
onClick={handleSave}
disabled={isSaving}
className="flex-[2] py-4 bg-orange-600 hover:bg-orange-700 text-white rounded-2xl font-black uppercase tracking-widest text-xs transition-all flex items-center justify-center gap-2 shadow-xl shadow-orange-950/20 disabled:opacity-50"
>
{isSaving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
{t('bottle.saveChanges')}
</button>
</div>
</div> </div>
); );
} }

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -85,10 +85,10 @@ export default function SessionBottomSheet({ isOpen, onClose }: SessionBottomShe
animate={{ y: 0 }} animate={{ y: 0 }}
exit={{ y: '100%' }} exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }} transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className="fixed bottom-0 left-0 right-0 bg-zinc-950 border-t border-zinc-800 rounded-t-[32px] z-[90] p-8 pb-12 max-h-[80vh] overflow-y-auto shadow-[0_-10px_40px_rgba(0,0,0,0.5)]" className="fixed bottom-0 left-0 right-0 bg-[var(--background)] border-t border-white/5 rounded-t-[40px] z-[90] p-8 pb-12 max-h-[85vh] overflow-y-auto shadow-[0_-20px_60px_rgba(0,0,0,0.8)] ring-1 ring-white/5"
> >
{/* Drag Handle */} {/* Drag Handle */}
<div className="w-12 h-1.5 bg-zinc-800 rounded-full mx-auto mb-8" /> <div className="w-10 h-1 bg-white/10 rounded-full mx-auto mb-8" />
<h2 className="text-2xl font-bold mb-6 text-zinc-50">Tasting Session</h2> <h2 className="text-2xl font-bold mb-6 text-zinc-50">Tasting Session</h2>
@@ -126,7 +126,7 @@ export default function SessionBottomSheet({ isOpen, onClose }: SessionBottomShe
setActiveSession({ id: s.id, name: s.name }); setActiveSession({ id: s.id, name: s.name });
onClose(); onClose();
}} }}
className={`w-full flex items-center justify-between p-4 rounded-2xl border transition-all ${activeSession?.id === s.id ? 'bg-orange-600/10 border-orange-600 text-orange-500' : 'bg-zinc-900 border-zinc-800 hover:border-zinc-700 text-zinc-50'}`} className={`w-full flex items-center justify-between p-5 rounded-[24px] border transition-all active:scale-[0.98] ${activeSession?.id === s.id ? 'bg-orange-600/10 border-orange-600 text-orange-500' : 'bg-white/5 border-white/5 hover:border-white/10 text-zinc-50'}`}
> >
<span className="font-bold">{s.name}</span> <span className="font-bold">{s.name}</span>
{activeSession?.id === s.id ? <Check size={20} /> : <ChevronRight size={20} className="text-zinc-700" />} {activeSession?.id === s.id ? <Check size={20} /> : <ChevronRight size={20} className="text-zinc-700" />}

View File

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

View File

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

View File

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

View File

@@ -45,6 +45,9 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
const [lastDramInSession, setLastDramInSession] = useState<{ name: string; isSmoky: boolean; timestamp: number } | null>(null); const [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">

View File

@@ -0,0 +1,103 @@
'use client';
import { useState, useEffect } from 'react';
import { createClient } from '@/lib/supabase/client';
import { Sparkles, Zap, Crown, Star, Gift } from 'lucide-react';
interface UserStatus {
credits: number;
planName: string;
planDisplayName: string;
}
const PLAN_ICONS: Record<string, { icon: React.ReactNode; color: string }> = {
starter: { icon: <Gift size={12} />, color: 'text-zinc-400 border-zinc-600' },
bronze: { icon: <Star size={12} />, color: 'text-amber-600 border-amber-600/50' },
silver: { icon: <Zap size={12} />, color: 'text-zinc-300 border-zinc-400' },
gold: { icon: <Crown size={12} />, color: 'text-yellow-500 border-yellow-500/50' },
};
export default function UserStatusBadge() {
const [status, setStatus] = useState<UserStatus | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadStatus();
}, []);
const loadStatus = async () => {
try {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
setLoading(false);
return;
}
// Get credits
const { data: credits } = await supabase
.from('user_credits')
.select('balance')
.eq('user_id', user.id)
.single();
// Get subscription plan with monthly credits
const { data: subscription } = await supabase
.from('user_subscriptions')
.select(`
plan_id,
subscription_plans (
name,
display_name,
monthly_credits
)
`)
.eq('user_id', user.id)
.single();
// Extract plan from joined data
const planData = subscription?.subscription_plans;
const plan = Array.isArray(planData) ? planData[0] : planData;
// If no credits entry yet, show plan's monthly credits as expected amount
const displayCredits = credits?.balance ?? plan?.monthly_credits ?? 10;
setStatus({
credits: displayCredits,
planName: plan?.name ?? 'starter',
planDisplayName: plan?.display_name ?? 'Starter',
});
} catch (error) {
console.error('[UserStatusBadge] Error loading status:', error);
} finally {
setLoading(false);
}
};
if (loading || !status) {
return null;
}
const planConfig = PLAN_ICONS[status.planName] || PLAN_ICONS.starter;
return (
<div className="flex items-center gap-2">
{/* Plan Badge */}
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full border bg-zinc-900/50 ${planConfig.color}`}>
{planConfig.icon}
<span className="text-[10px] font-bold uppercase tracking-wider">
{status.planDisplayName}
</span>
</div>
{/* Credits */}
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full border border-orange-600/30 bg-orange-950/20">
<Sparkles size={12} className="text-orange-500" />
<span className="text-[10px] font-bold text-orange-400">
{status.credits}
</span>
</div>
</div>
);
}

View File

@@ -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',

View File

@@ -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',

View File

@@ -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>;
}; };

View File

@@ -107,10 +107,33 @@ export async function updateUserCredits(
const isAdmin = await checkIsAdmin(user.id); const isAdmin = await checkIsAdmin(user.id);
if (!isAdmin) return { success: false, error: 'Not authorized' }; if (!isAdmin) return { success: false, error: 'Not authorized' };
// Get current credits // Get current credits - if not found, create entry first
const currentCredits = await getUserCredits(validated.userId); let currentCredits = await getUserCredits(validated.userId);
if (!currentCredits) { if (!currentCredits) {
return { success: false, error: 'User credits not found' }; // Create credits entry for user who doesn't have one
console.log(`[updateUserCredits] Creating credits entry for user ${validated.userId}`);
const { data: newCredits, error: insertError } = await supabase
.from('user_credits')
.insert({
user_id: validated.userId,
balance: 0,
total_purchased: 0,
total_used: 0
})
.select()
.single();
if (insertError) {
console.error('Error creating user credits:', insertError);
return { success: false, error: 'Failed to create user credits' };
}
currentCredits = newCredits;
}
if (!currentCredits) {
return { success: false, error: 'Konto konnte nicht erstellt werden' };
} }
const difference = validated.newBalance - currentCredits.balance; const difference = validated.newBalance - currentCredits.balance;

View File

@@ -135,7 +135,10 @@ export async function analyzeBottleMistral(input: any): Promise<AnalysisResponse
userId: userId, userId: userId,
apiType: 'gemini_ai', apiType: 'gemini_ai',
endpoint: 'mistral/mistral-large', endpoint: 'mistral/mistral-large',
success: true success: true,
provider: 'mistral',
model: 'mistral-large-latest',
responseText: rawContent as string
}); });
await deductCredits(userId, 'gemini_ai', 'Mistral AI analysis'); await deductCredits(userId, 'gemini_ai', 'Mistral AI analysis');
@@ -164,7 +167,9 @@ export async function analyzeBottleMistral(input: any): Promise<AnalysisResponse
apiType: 'gemini_ai', apiType: 'gemini_ai',
endpoint: 'mistral/mistral-large', endpoint: 'mistral/mistral-large',
success: false, success: false,
errorMessage: aiError.message errorMessage: aiError.message,
provider: 'mistral',
model: 'mistral-large-latest'
}); });
return { return {

View File

@@ -128,7 +128,10 @@ export async function analyzeBottle(input: any): Promise<AnalysisResponse> {
userId: userId, userId: userId,
apiType: 'gemini_ai', apiType: 'gemini_ai',
endpoint: 'analyzeBottle_openrouter', endpoint: 'analyzeBottle_openrouter',
success: true success: true,
provider: 'openrouter',
model: 'google/gemma-3-27b-it',
responseText: content
}); });
await deductCredits(userId, 'gemini_ai', 'Bottle analysis (OpenRouter)'); await deductCredits(userId, 'gemini_ai', 'Bottle analysis (OpenRouter)');
@@ -196,7 +199,10 @@ export async function analyzeBottle(input: any): Promise<AnalysisResponse> {
userId: userId, userId: userId,
apiType: 'gemini_ai', apiType: 'gemini_ai',
endpoint: 'analyzeBottle_gemini', endpoint: 'analyzeBottle_gemini',
success: true success: true,
provider: 'google',
model: 'gemini-2.5-flash',
responseText: responseText
}); });
await deductCredits(userId, 'gemini_ai', 'Bottle analysis (Gemini)'); await deductCredits(userId, 'gemini_ai', 'Bottle analysis (Gemini)');
@@ -225,7 +231,9 @@ export async function analyzeBottle(input: any): Promise<AnalysisResponse> {
apiType: 'gemini_ai', apiType: 'gemini_ai',
endpoint: `analyzeBottle_${provider}`, endpoint: `analyzeBottle_${provider}`,
success: false, success: false,
errorMessage: aiError.message errorMessage: aiError.message,
provider: provider,
model: provider === 'openrouter' ? 'google/gemma-3-27b-it' : 'gemini-2.5-flash'
}); });
return { return {

View File

@@ -53,14 +53,30 @@ export async function getUserCredits(userId: string): Promise<UserCredits | null
.single(); .single();
if (error) { if (error) {
// If user doesn't have credits yet, create entry with default values // If user doesn't have credits yet, create entry based on their subscription plan
if (error.code === 'PGRST116') { if (error.code === 'PGRST116') {
// Get user's subscription plan credits
const { data: subscription } = await supabase
.from('user_subscriptions')
.select(`
subscription_plans (
monthly_credits
)
`)
.eq('user_id', userId)
.single();
// Extract monthly_credits from plan (default to 10 for starter)
const planData = subscription?.subscription_plans;
const plan = Array.isArray(planData) ? planData[0] : planData;
const startingCredits = plan?.monthly_credits ?? 10;
const { data: newCredits, error: insertError } = await supabase const { data: newCredits, error: insertError } = await supabase
.from('user_credits') .from('user_credits')
.insert({ .insert({
user_id: userId, user_id: userId,
balance: 100, // Starting credits balance: startingCredits,
total_purchased: 100, total_purchased: 0,
total_used: 0 total_used: 0
}) })
.select() .select()
@@ -71,6 +87,7 @@ export async function getUserCredits(userId: string): Promise<UserCredits | null
return null; return null;
} }
console.log(`[Credits] Created credits for user with ${startingCredits} balance from plan`);
return newCredits; return newCredits;
} }

View File

@@ -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.'),
}; };
} }
} }

View File

@@ -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' };
}
}

View File

@@ -8,6 +8,9 @@ interface TrackApiUsageParams {
endpoint?: string; endpoint?: string;
success: boolean; success: boolean;
errorMessage?: string; errorMessage?: string;
model?: string;
provider?: string;
responseText?: string;
} }
interface ApiStats { interface ApiStats {
@@ -49,6 +52,9 @@ export async function trackApiUsage(params: TrackApiUsageParams): Promise<{ succ
endpoint: params.endpoint, endpoint: params.endpoint,
success: params.success, success: params.success,
error_message: params.errorMessage, error_message: params.errorMessage,
model: params.model,
provider: params.provider,
response_text: params.responseText
}); });
if (error) { if (error) {

View File

@@ -21,7 +21,7 @@ BEGIN
ON CONFLICT (id) DO NOTHING; ON CONFLICT (id) DO NOTHING;
RETURN new; RETURN new;
END; END;
$$ LANGUAGE plpgsql SECURITY DEFINER; $$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '';
-- Manual sync for existing users (Run this once) -- Manual sync for existing users (Run this once)
-- INSERT INTO public.profiles (id) -- INSERT INTO public.profiles (id)
@@ -238,6 +238,9 @@ CREATE TABLE IF NOT EXISTS api_usage (
endpoint TEXT, endpoint TEXT,
success BOOLEAN DEFAULT true, success BOOLEAN DEFAULT true,
error_message TEXT, error_message TEXT,
model TEXT,
provider TEXT,
response_text TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()) created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
); );

File diff suppressed because one or more lines are too long

72
user_subscription_fix.sql Normal file
View File

@@ -0,0 +1,72 @@
-- Dynamic Credit Initialization Migration
-- Run this in Supabase SQL Editor
-- Updated trigger: Creates profile, subscription, AND credits dynamically
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger AS $$
DECLARE
starter_plan_id UUID;
plan_credits INTEGER;
BEGIN
-- Create profile
INSERT INTO public.profiles (id, username, avatar_url)
VALUES (
new.id,
COALESCE(new.raw_user_meta_data->>'username', 'user_' || substr(new.id::text, 1, 8)),
new.raw_user_meta_data->>'avatar_url'
)
ON CONFLICT (id) DO NOTHING;
-- Get starter plan ID and credits
SELECT id, monthly_credits INTO starter_plan_id, plan_credits
FROM subscription_plans
WHERE name = 'starter'
LIMIT 1;
-- Create subscription with starter plan
IF starter_plan_id IS NOT NULL THEN
INSERT INTO public.user_subscriptions (user_id, plan_id)
VALUES (new.id, starter_plan_id)
ON CONFLICT (user_id) DO NOTHING;
END IF;
-- Create user_credits with plan's monthly_credits
INSERT INTO public.user_credits (user_id, balance, total_purchased, total_used)
VALUES (new.id, COALESCE(plan_credits, 10), 0, 0)
ON CONFLICT (user_id) DO NOTHING;
RETURN new;
EXCEPTION WHEN OTHERS THEN
-- Log but don't fail user creation
RAISE WARNING 'handle_new_user failed: %', SQLERRM;
RETURN new;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '';
-- RLS Policy for user_subscriptions insert (for client-side fallback)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE schemaname = 'public'
AND tablename = 'user_subscriptions'
AND policyname = 'user_subscriptions_insert_self'
) THEN
CREATE POLICY "user_subscriptions_insert_self" ON user_subscriptions
FOR INSERT WITH CHECK ((SELECT auth.uid()) = user_id);
END IF;
END $$;
-- RLS Policy for user_credits insert (for lazy initialization fallback)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE schemaname = 'public'
AND tablename = 'user_credits'
AND policyname = 'user_credits_insert_self'
) THEN
CREATE POLICY "user_credits_insert_self" ON user_credits
FOR INSERT WITH CHECK ((SELECT auth.uid()) = user_id);
END IF;
END $$;