Compare commits
10 Commits
73a057b1e3
...
9d6a8b358f
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d6a8b358f | |||
| 332bfdaf02 | |||
| c51cd23d5e | |||
| 20659567fd | |||
| 20f7436e66 | |||
| a3915bd610 | |||
| 37634c26c8 | |||
| 9e2abb0aa3 | |||
| 30a716f3e2 | |||
| 02bd025bce |
33
enable_public_split_access.sql
Normal file
33
enable_public_split_access.sql
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- Enable Public Access for Bottle Splits
|
||||||
|
-- ============================================
|
||||||
|
-- This script enables unauthenticated users (guests) to view:
|
||||||
|
-- 1. Profiles (to see hostnames)
|
||||||
|
-- 2. Bottle Splits (if is_active = true)
|
||||||
|
-- 3. Split Participants (to see progress bar data)
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 1. Profiles: Allow anyone to see usernames and avatars
|
||||||
|
DROP POLICY IF EXISTS "profiles_select_policy" ON profiles;
|
||||||
|
CREATE POLICY "profiles_select_policy" ON profiles
|
||||||
|
FOR SELECT USING (true);
|
||||||
|
|
||||||
|
-- 2. Bottle Splits: Allow guests to see active splits
|
||||||
|
-- This policy allows anyone to read splits that are marked as active.
|
||||||
|
DROP POLICY IF EXISTS "bottle_splits_public_select" ON bottle_splits;
|
||||||
|
CREATE POLICY "bottle_splits_public_select" ON bottle_splits
|
||||||
|
FOR SELECT USING (is_active = true);
|
||||||
|
|
||||||
|
-- 3. Split Participants: Allow guests to see progress of active splits
|
||||||
|
-- This is necessary to calculate the "taken" volume in the progress bar.
|
||||||
|
DROP POLICY IF EXISTS "split_participants_public_select" ON split_participants;
|
||||||
|
CREATE POLICY "split_participants_public_select" ON split_participants
|
||||||
|
FOR SELECT USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.bottle_splits
|
||||||
|
WHERE id = split_participants.split_id AND is_active = true
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Note: Ensure "bottles_select_policy" in rls_public_bottle_access.sql
|
||||||
|
-- is also applied to allow guests to see bottle details for active splits.
|
||||||
54
fix_rls_recursion.sql
Normal file
54
fix_rls_recursion.sql
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- Fix RLS Infinite Recursion
|
||||||
|
-- ============================================
|
||||||
|
-- This script breaks the circular dependency between bottle_splits
|
||||||
|
-- and split_participants RLS policies using SECURITY DEFINER functions.
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 1. Create Security Definer functions to bypass RLS
|
||||||
|
CREATE OR REPLACE FUNCTION public.check_is_split_host(check_split_id UUID, check_user_id UUID)
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN EXISTS (
|
||||||
|
SELECT 1 FROM public.bottle_splits
|
||||||
|
WHERE id = check_split_id AND host_id = check_user_id
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.check_is_split_participant(check_split_id UUID, check_user_id UUID)
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN EXISTS (
|
||||||
|
SELECT 1 FROM public.split_participants
|
||||||
|
WHERE split_id = check_split_id AND user_id = check_user_id
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- 2. Update bottle_splits policies
|
||||||
|
DROP POLICY IF EXISTS "bottle_splits_participant_view" ON bottle_splits;
|
||||||
|
DROP POLICY IF EXISTS "bottle_splits_host_policy" ON bottle_splits;
|
||||||
|
|
||||||
|
CREATE POLICY "bottle_splits_host_policy" ON bottle_splits
|
||||||
|
FOR ALL USING ((SELECT auth.uid()) = host_id);
|
||||||
|
|
||||||
|
CREATE POLICY "bottle_splits_participant_view" ON bottle_splits
|
||||||
|
FOR SELECT USING (
|
||||||
|
check_is_split_participant(id, (SELECT auth.uid()))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 3. Update split_participants policies
|
||||||
|
DROP POLICY IF EXISTS "split_participants_host_policy" ON split_participants;
|
||||||
|
DROP POLICY IF EXISTS "split_participants_own_policy" ON split_participants;
|
||||||
|
|
||||||
|
CREATE POLICY "split_participants_own_policy" ON split_participants
|
||||||
|
FOR ALL USING ((SELECT auth.uid()) = user_id);
|
||||||
|
|
||||||
|
CREATE POLICY "split_participants_host_policy" ON split_participants
|
||||||
|
FOR ALL USING (
|
||||||
|
check_is_split_host(split_id, (SELECT auth.uid()))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Note: bottle_splits_public_view and split_participants_public_view
|
||||||
|
-- don't cause recursion as they don't cross-reference tables in a cycle.
|
||||||
28
hig.md
Normal file
28
hig.md
Normal 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
17
logging_enhancements.sql
Normal 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
62
performance_indexing.sql
Normal 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
75
rls_buddy_access.sql
Normal 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
|
||||||
|
-- ============================================
|
||||||
248
rls_policy_performance_fixes.sql
Normal file
248
rls_policy_performance_fixes.sql
Normal 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;
|
||||||
54
rls_public_bottle_access.sql
Normal file
54
rls_public_bottle_access.sql
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- Restore Public/Buddy Bottle Read Access
|
||||||
|
-- ============================================
|
||||||
|
-- This script fixes the issue where non-owners cannot see bottle data
|
||||||
|
-- for public splits or shared tastings.
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Drop the overly restrictive performance-fix policy
|
||||||
|
DROP POLICY IF EXISTS "bottles_policy" ON bottles;
|
||||||
|
|
||||||
|
-- 1. Unified SELECT policy: owner OR active split viewer OR session participant
|
||||||
|
CREATE POLICY "bottles_select_policy" ON bottles
|
||||||
|
FOR SELECT USING (
|
||||||
|
-- Owner access
|
||||||
|
(SELECT auth.uid()) = user_id OR
|
||||||
|
|
||||||
|
-- Public split access (anyone can see bottle info if the split is active)
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM bottle_splits
|
||||||
|
WHERE bottle_id = bottles.id AND is_active = true
|
||||||
|
) OR
|
||||||
|
|
||||||
|
-- Participant access (user is already part of this split)
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM split_participants sp
|
||||||
|
JOIN bottle_splits bs ON bs.id = sp.split_id
|
||||||
|
WHERE bs.bottle_id = bottles.id AND sp.user_id = (SELECT auth.uid())
|
||||||
|
) OR
|
||||||
|
|
||||||
|
-- Buddy/Session access (user is a buddy in a session involving this bottle)
|
||||||
|
id IN (
|
||||||
|
SELECT t.bottle_id
|
||||||
|
FROM tastings t
|
||||||
|
JOIN tasting_sessions ts ON ts.id = t.session_id
|
||||||
|
JOIN session_participants sp ON sp.session_id = ts.id
|
||||||
|
JOIN buddies b ON b.id = sp.buddy_id
|
||||||
|
WHERE b.buddy_profile_id = (SELECT auth.uid())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2. Owner-only for modifications (No change needed from security perspective)
|
||||||
|
CREATE POLICY "bottles_insert_policy" ON bottles
|
||||||
|
FOR INSERT WITH CHECK ((SELECT auth.uid()) = user_id);
|
||||||
|
|
||||||
|
CREATE POLICY "bottles_update_policy" ON bottles
|
||||||
|
FOR UPDATE USING ((SELECT auth.uid()) = user_id);
|
||||||
|
|
||||||
|
CREATE POLICY "bottles_delete_policy" ON bottles
|
||||||
|
FOR DELETE USING ((SELECT auth.uid()) = user_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Verification query
|
||||||
|
-- ============================================
|
||||||
|
-- SELECT * FROM pg_policies WHERE tablename = 'bottles';
|
||||||
9
rls_split_participant_access.sql
Normal file
9
rls_split_participant_access.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
-- Allow participants to see the split record even if it's not active anymore
|
||||||
|
DROP POLICY IF EXISTS "bottle_splits_participant_view" ON bottle_splits;
|
||||||
|
CREATE POLICY "bottle_splits_participant_view" ON bottle_splits
|
||||||
|
FOR SELECT USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM split_participants
|
||||||
|
WHERE split_id = bottle_splits.id AND user_id = (SELECT auth.uid())
|
||||||
|
)
|
||||||
|
);
|
||||||
173
rls_unified_consolidation.sql
Normal file
173
rls_unified_consolidation.sql
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- UNIFIED RLS CONSOLIDATION & TABLE FIXES
|
||||||
|
-- ============================================
|
||||||
|
-- 1. Fix Table Mismatch (Rename to match services)
|
||||||
|
-- 2. Consolidate RLS for Bottles, Tastings, Sessions
|
||||||
|
-- 3. Resolve Naming Conflicts (tasting_tags vs tasting_buddies)
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 1. FIX TABLE NAMES (Ensuring consistency across project)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
-- If 'tasting_tags' currently contains buddy_id (old schema), rename it
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'tasting_tags' AND column_name = 'buddy_id'
|
||||||
|
) THEN
|
||||||
|
DROP TABLE IF EXISTS tasting_buddies CASCADE;
|
||||||
|
ALTER TABLE tasting_tags RENAME TO tasting_buddies;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Ensure 'tasting_tags' exists for Aroma Tags (Aroma Filter)
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'tasting_tags') THEN
|
||||||
|
CREATE TABLE tasting_tags (
|
||||||
|
tasting_id UUID REFERENCES tastings(id) ON DELETE CASCADE NOT NULL,
|
||||||
|
tag_id UUID REFERENCES tags(id) ON DELETE CASCADE NOT NULL,
|
||||||
|
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL,
|
||||||
|
PRIMARY KEY (tasting_id, tag_id)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Enable RLS on all relevant tables
|
||||||
|
ALTER TABLE bottles ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE tastings ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE tasting_sessions ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE tasting_buddies ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE tasting_tags ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- 2. UNIFIED BOTTLES POLICIES
|
||||||
|
DROP POLICY IF EXISTS "bottles_policy" ON bottles;
|
||||||
|
DROP POLICY IF EXISTS "bottles_select_policy" ON bottles;
|
||||||
|
DROP POLICY IF EXISTS "bottles_insert_policy" ON bottles;
|
||||||
|
DROP POLICY IF EXISTS "bottles_update_policy" ON bottles;
|
||||||
|
DROP POLICY IF EXISTS "bottles_delete_policy" ON bottles;
|
||||||
|
|
||||||
|
-- SELECT: owner OR split participant OR session buddy
|
||||||
|
CREATE POLICY "bottles_select_policy" ON bottles
|
||||||
|
FOR SELECT USING (
|
||||||
|
(SELECT auth.uid()) = user_id OR
|
||||||
|
EXISTS (SELECT 1 FROM bottle_splits WHERE bottle_id = bottles.id AND is_active = true) OR
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM tasting_sessions ts
|
||||||
|
JOIN session_participants sp ON sp.session_id = ts.id
|
||||||
|
JOIN buddies b ON b.id = sp.buddy_id
|
||||||
|
JOIN tastings t ON t.session_id = ts.id
|
||||||
|
WHERE t.bottle_id = bottles.id AND b.buddy_profile_id = (SELECT auth.uid())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "bottles_insert_policy" ON bottles
|
||||||
|
FOR INSERT WITH CHECK ((SELECT auth.uid()) = user_id);
|
||||||
|
|
||||||
|
CREATE POLICY "bottles_update_policy" ON bottles
|
||||||
|
FOR UPDATE USING ((SELECT auth.uid()) = user_id);
|
||||||
|
|
||||||
|
CREATE POLICY "bottles_delete_policy" ON bottles
|
||||||
|
FOR DELETE USING ((SELECT auth.uid()) = user_id);
|
||||||
|
|
||||||
|
-- 3. UNIFIED TASTINGS POLICIES
|
||||||
|
DROP POLICY IF EXISTS "tastings_policy" ON tastings;
|
||||||
|
DROP POLICY IF EXISTS "tastings_select_policy" ON tastings;
|
||||||
|
DROP POLICY IF EXISTS "tastings_insert_policy" ON tastings;
|
||||||
|
DROP POLICY IF EXISTS "tastings_update_policy" ON tastings;
|
||||||
|
DROP POLICY IF EXISTS "tastings_delete_policy" ON tastings;
|
||||||
|
DROP POLICY IF EXISTS "tastings_modify_policy" ON tastings;
|
||||||
|
|
||||||
|
-- SELECT: owner OR buddy in the same tasting/session
|
||||||
|
CREATE POLICY "tastings_select_policy" ON tastings
|
||||||
|
FOR SELECT USING (
|
||||||
|
(SELECT auth.uid()) = user_id OR
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM tasting_buddies tb
|
||||||
|
JOIN buddies b ON b.id = tb.buddy_id
|
||||||
|
WHERE tb.tasting_id = tastings.id AND b.buddy_profile_id = (SELECT auth.uid())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- INSERT: Anyone can insert if they are the user_id (the owner of the note)
|
||||||
|
CREATE POLICY "tastings_insert_policy" ON tastings
|
||||||
|
FOR INSERT WITH CHECK ((SELECT auth.uid()) = user_id);
|
||||||
|
|
||||||
|
-- UPDATE/DELETE: strictly owner
|
||||||
|
CREATE POLICY "tastings_modify_policy" ON tastings
|
||||||
|
FOR ALL USING ((SELECT auth.uid()) = user_id);
|
||||||
|
|
||||||
|
-- 4. UNIFIED JOIN TABLES POLICIES (Buddies & Aroma Tags)
|
||||||
|
DROP POLICY IF EXISTS "tasting_buddies_policy" ON tasting_buddies;
|
||||||
|
DROP POLICY IF EXISTS "tasting_tags_policy" ON tasting_tags;
|
||||||
|
|
||||||
|
CREATE POLICY "tasting_buddies_policy" ON tasting_buddies
|
||||||
|
FOR ALL USING ((SELECT auth.uid()) = user_id);
|
||||||
|
|
||||||
|
CREATE POLICY "tasting_tags_policy" ON tasting_tags
|
||||||
|
FOR ALL USING ((SELECT auth.uid()) = user_id);
|
||||||
|
|
||||||
|
-- 5. UNIFIED SESSIONS POLICIES
|
||||||
|
DROP POLICY IF EXISTS "tasting_sessions_policy" ON tasting_sessions;
|
||||||
|
DROP POLICY IF EXISTS "sessions_access_policy" ON tasting_sessions;
|
||||||
|
|
||||||
|
CREATE POLICY "tasting_sessions_policy" ON tasting_sessions
|
||||||
|
FOR ALL USING (
|
||||||
|
(SELECT auth.uid()) = user_id OR
|
||||||
|
id IN (
|
||||||
|
SELECT sp.session_id
|
||||||
|
FROM session_participants sp
|
||||||
|
JOIN buddies b ON b.id = sp.buddy_id
|
||||||
|
WHERE b.buddy_profile_id = (SELECT auth.uid())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 6. BOTTLE SPLITS & RECURSION FIXES
|
||||||
|
-- Breaks loop between bottle_splits and split_participants
|
||||||
|
CREATE OR REPLACE FUNCTION public.check_is_split_host(check_split_id UUID, check_user_id UUID)
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN EXISTS (
|
||||||
|
SELECT 1 FROM public.bottle_splits
|
||||||
|
WHERE id = check_split_id AND host_id = check_user_id
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.check_is_split_participant(check_split_id UUID, check_user_id UUID)
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN EXISTS (
|
||||||
|
SELECT 1 FROM public.split_participants
|
||||||
|
WHERE split_id = check_split_id AND user_id = check_user_id
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "bottle_splits_host_policy" ON bottle_splits;
|
||||||
|
DROP POLICY IF EXISTS "bottle_splits_participant_view" ON bottle_splits;
|
||||||
|
DROP POLICY IF EXISTS "bottle_splits_public_select" ON bottle_splits;
|
||||||
|
|
||||||
|
CREATE POLICY "bottle_splits_host_policy" ON bottle_splits
|
||||||
|
FOR ALL USING ((SELECT auth.uid()) = host_id);
|
||||||
|
|
||||||
|
CREATE POLICY "bottle_splits_participant_view" ON bottle_splits
|
||||||
|
FOR SELECT USING (check_is_split_participant(id, (SELECT auth.uid())));
|
||||||
|
|
||||||
|
CREATE POLICY "bottle_splits_public_select" ON bottle_splits
|
||||||
|
FOR SELECT USING (is_active = true);
|
||||||
|
|
||||||
|
-- 7. SPLIT PARTICIPANTS
|
||||||
|
DROP POLICY IF EXISTS "split_participants_own_policy" ON split_participants;
|
||||||
|
DROP POLICY IF EXISTS "split_participants_host_policy" ON split_participants;
|
||||||
|
DROP POLICY IF EXISTS "split_participants_public_select" ON split_participants;
|
||||||
|
|
||||||
|
CREATE POLICY "split_participants_own_policy" ON split_participants
|
||||||
|
FOR ALL USING ((SELECT auth.uid()) = user_id);
|
||||||
|
|
||||||
|
CREATE POLICY "split_participants_host_policy" ON split_participants
|
||||||
|
FOR ALL USING (check_is_split_host(split_id, (SELECT auth.uid())));
|
||||||
|
|
||||||
|
CREATE POLICY "split_participants_public_select" ON split_participants
|
||||||
|
FOR SELECT USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.bottle_splits
|
||||||
|
WHERE id = split_participants.split_id AND is_active = true
|
||||||
|
)
|
||||||
|
);
|
||||||
31
security_search_path_fix.sql
Normal file
31
security_search_path_fix.sql
Normal 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 $$;
|
||||||
@@ -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})`);
|
||||||
|
|||||||
@@ -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})`);
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { getProfile } from '@/services/profile-actions';
|
import { getProfile } from '@/services/profile-actions';
|
||||||
import ProfileForm from '@/components/ProfileForm';
|
import SettingsHub from '@/components/SettingsHub';
|
||||||
import PasswordChangeForm from '@/components/PasswordChangeForm';
|
|
||||||
import { ArrowLeft, Settings, Cookie, Shield } from 'lucide-react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Einstellungen',
|
title: 'Einstellungen | Settings',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
@@ -20,88 +17,17 @@ export default async function SettingsPage() {
|
|||||||
|
|
||||||
const profile = await getProfile();
|
const profile = await getProfile();
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
return <div>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-950">
|
<SettingsHub
|
||||||
{/* Header */}
|
profile={{
|
||||||
<header className="sticky top-0 z-40 bg-zinc-950/80 backdrop-blur-lg border-b border-zinc-800">
|
email: profile.email,
|
||||||
<div className="max-w-2xl mx-auto px-4 py-4 flex items-center gap-4">
|
username: profile.username,
|
||||||
<Link
|
created_at: profile.created_at
|
||||||
href="/"
|
}}
|
||||||
className="p-2 -ml-2 text-zinc-400 hover:text-white transition-colors"
|
/>
|
||||||
>
|
|
||||||
<ArrowLeft size={20} />
|
|
||||||
</Link>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Settings size={20} className="text-orange-500" />
|
|
||||||
<h1 className="text-lg font-bold text-white">Einstellungen</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<main className="max-w-2xl mx-auto px-4 py-6 space-y-6">
|
|
||||||
{/* Profile Form */}
|
|
||||||
<ProfileForm
|
|
||||||
initialData={{
|
|
||||||
email: profile?.email,
|
|
||||||
username: profile?.username,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Password Change Form */}
|
|
||||||
<PasswordChangeForm />
|
|
||||||
|
|
||||||
{/* Cookie Settings */}
|
|
||||||
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6">
|
|
||||||
<h2 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
|
|
||||||
<Cookie size={20} className="text-orange-500" />
|
|
||||||
Cookie-Einstellungen
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-zinc-400 mb-4">
|
|
||||||
Diese App verwendet nur technisch notwendige Cookies für die Authentifizierung und funktionale Cookies für UI-Präferenzen.
|
|
||||||
</p>
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
<div className="flex items-center gap-2 text-zinc-300">
|
|
||||||
<Shield size={14} className="text-green-500" />
|
|
||||||
<span><strong>Notwendig:</strong> Supabase Auth Cookies</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-zinc-300">
|
|
||||||
<Shield size={14} className="text-blue-500" />
|
|
||||||
<span><strong>Funktional:</strong> Sprache, UI-Status</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Data & Privacy */}
|
|
||||||
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6">
|
|
||||||
<h2 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
|
|
||||||
<Shield size={20} className="text-orange-500" />
|
|
||||||
Datenschutz
|
|
||||||
</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-sm text-zinc-400">
|
|
||||||
Deine Daten werden sicher auf EU-Servern gespeichert.
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href="/privacy"
|
|
||||||
className="inline-block text-sm text-orange-500 hover:text-orange-400 underline"
|
|
||||||
>
|
|
||||||
Datenschutzerklärung lesen
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Account info */}
|
|
||||||
<div className="bg-zinc-800/50 border border-zinc-700 rounded-2xl p-4 text-center">
|
|
||||||
<p className="text-xs text-zinc-500">
|
|
||||||
Mitglied seit: {new Date(profile?.created_at || '').toLocaleDateString('de-DE', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: 'long',
|
|
||||||
year: 'numeric'
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,15 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ChevronLeft, Share2, User, Package, Truck, Loader2, CheckCircle2, Clock, AlertCircle } from 'lucide-react';
|
import { ChevronLeft, Share2, User, Package, Truck, Loader2, CheckCircle2, Clock, AlertCircle, LogIn } from 'lucide-react';
|
||||||
import { getSplitBySlug, requestSlot, SplitDetails, SampleSize, ShippingOption } from '@/services/split-actions';
|
import { getSplitBySlug, requestSlot, SplitDetails, SampleSize, ShippingOption } from '@/services/split-actions';
|
||||||
import SplitProgressBar from '@/components/SplitProgressBar';
|
import SplitProgressBar from '@/components/SplitProgressBar';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
|
|
||||||
export default function SplitPublicPage() {
|
export default function SplitPublicPage() {
|
||||||
const { slug } = useParams();
|
const { slug } = useParams();
|
||||||
|
const { t } = useI18n();
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const [split, setSplit] = useState<SplitDetails | null>(null);
|
const [split, setSplit] = useState<SplitDetails | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@@ -44,7 +46,7 @@ export default function SplitPublicPage() {
|
|||||||
setSelectedAmount(result.data.sampleSizes[0].cl);
|
setSelectedAmount(result.data.sampleSizes[0].cl);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setError(result.error || 'Split nicht gefunden');
|
setError(result.error || t('splits.noSplitsFound'));
|
||||||
}
|
}
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
@@ -69,6 +71,10 @@ export default function SplitPublicPage() {
|
|||||||
|
|
||||||
const handleRequest = async () => {
|
const handleRequest = async () => {
|
||||||
if (!selectedShipping || !selectedAmount) return;
|
if (!selectedShipping || !selectedAmount) return;
|
||||||
|
if (!currentUserId) {
|
||||||
|
window.location.href = `/login?redirect=/splits/${slug}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsRequesting(true);
|
setIsRequesting(true);
|
||||||
setRequestError(null);
|
setRequestError(null);
|
||||||
@@ -86,7 +92,7 @@ export default function SplitPublicPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const userParticipation = split?.participants.find(p => p.userId === currentUserId);
|
const userParticipation = split?.participants.find(p => p.userId === currentUserId);
|
||||||
const canRequest = !userParticipation && currentUserId && currentUserId !== split?.hostId;
|
const showRequestForm = !userParticipation && currentUserId !== split?.hostId;
|
||||||
const isWaitlist = split && selectedAmount && split.remaining < selectedAmount;
|
const isWaitlist = split && selectedAmount && split.remaining < selectedAmount;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -101,8 +107,8 @@ export default function SplitPublicPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col items-center justify-center gap-4 bg-zinc-950 p-6">
|
<div className="min-h-screen flex flex-col items-center justify-center gap-4 bg-zinc-950 p-6">
|
||||||
<AlertCircle size={48} className="text-red-500" />
|
<AlertCircle size={48} className="text-red-500" />
|
||||||
<h1 className="text-xl font-bold text-zinc-50">{error || 'Split nicht gefunden'}</h1>
|
<h1 className="text-xl font-bold text-zinc-50">{error || t('splits.noSplitsFound')}</h1>
|
||||||
<Link href="/" className="text-orange-600 font-bold">Zurück zum Start</Link>
|
<Link href="/" className="text-orange-600 font-bold">{t('splits.backToStart')}</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -117,7 +123,7 @@ export default function SplitPublicPage() {
|
|||||||
className="inline-flex items-center gap-2 text-zinc-500 hover:text-orange-600 transition-colors font-bold uppercase text-[10px] tracking-[0.2em]"
|
className="inline-flex items-center gap-2 text-zinc-500 hover:text-orange-600 transition-colors font-bold uppercase text-[10px] tracking-[0.2em]"
|
||||||
>
|
>
|
||||||
<ChevronLeft size={16} />
|
<ChevronLeft size={16} />
|
||||||
Zurück
|
{t('common.back')}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
@@ -136,7 +142,7 @@ export default function SplitPublicPage() {
|
|||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] font-black uppercase tracking-widest text-orange-500 mb-1">
|
<p className="text-[10px] font-black uppercase tracking-widest text-orange-500 mb-1">
|
||||||
Flaschenteilung
|
{t('splits.falscheTeilung')}
|
||||||
</p>
|
</p>
|
||||||
<h1 className="text-2xl md:text-3xl font-black text-zinc-50">
|
<h1 className="text-2xl md:text-3xl font-black text-zinc-50">
|
||||||
{split.bottle.name}
|
{split.bottle.name}
|
||||||
@@ -154,11 +160,11 @@ export default function SplitPublicPage() {
|
|||||||
)}
|
)}
|
||||||
{split.bottle.age && (
|
{split.bottle.age && (
|
||||||
<span className="px-3 py-1.5 bg-zinc-800 rounded-xl text-xs font-bold text-zinc-300">
|
<span className="px-3 py-1.5 bg-zinc-800 rounded-xl text-xs font-bold text-zinc-300">
|
||||||
{split.bottle.age} Jahre
|
{split.bottle.age} {t('splits.jahre')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="px-3 py-1.5 bg-zinc-800 rounded-xl text-xs font-bold text-zinc-300">
|
<span className="px-3 py-1.5 bg-zinc-800 rounded-xl text-xs font-bold text-zinc-300">
|
||||||
{split.totalVolume}cl Flasche
|
{split.totalVolume}{t('splits.clFlasche')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -175,23 +181,23 @@ export default function SplitPublicPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Request Form */}
|
{/* Request Form */}
|
||||||
{canRequest && !requestSuccess && (
|
{showRequestForm && !requestSuccess && (
|
||||||
<div className="bg-zinc-900 rounded-3xl p-6 border border-zinc-800 space-y-6">
|
<div className="bg-zinc-900 rounded-3xl p-6 border border-zinc-800 space-y-6">
|
||||||
<h2 className="text-sm font-black uppercase tracking-widest text-zinc-400">
|
<h2 className="text-sm font-black uppercase tracking-widest text-zinc-400">
|
||||||
Sample bestellen
|
{t('splits.joinTitle')}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Amount Selection */}
|
{/* Amount Selection */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="text-xs font-bold text-zinc-500 uppercase tracking-widest">Menge</label>
|
<label className="text-xs font-bold text-zinc-500 uppercase tracking-widest">{t('splits.amount')}</label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{split.sampleSizes.map(size => (
|
{split.sampleSizes.map(size => (
|
||||||
<button
|
<button
|
||||||
key={size.cl}
|
key={size.cl}
|
||||||
onClick={() => setSelectedAmount(size.cl)}
|
onClick={() => setSelectedAmount(size.cl)}
|
||||||
className={`px-4 py-3 rounded-xl border-2 transition-all ${selectedAmount === size.cl
|
className={`px-4 py-3 rounded-xl border-2 transition-all ${selectedAmount === size.cl
|
||||||
? 'border-orange-500 bg-orange-500/10'
|
? 'border-orange-500 bg-orange-500/10'
|
||||||
: 'border-zinc-700 hover:border-zinc-600'
|
: 'border-zinc-700 hover:border-zinc-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-lg font-black text-white">{size.cl}cl</span>
|
<span className="text-lg font-black text-white">{size.cl}cl</span>
|
||||||
@@ -204,7 +210,7 @@ export default function SplitPublicPage() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="text-xs font-bold text-zinc-500 uppercase tracking-widest flex items-center gap-2">
|
<label className="text-xs font-bold text-zinc-500 uppercase tracking-widest flex items-center gap-2">
|
||||||
<Truck size={14} />
|
<Truck size={14} />
|
||||||
Versand
|
{t('splits.shipping')}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{split.shippingOptions.map(option => (
|
{split.shippingOptions.map(option => (
|
||||||
@@ -212,8 +218,8 @@ export default function SplitPublicPage() {
|
|||||||
key={option.name}
|
key={option.name}
|
||||||
onClick={() => setSelectedShipping(option.name)}
|
onClick={() => setSelectedShipping(option.name)}
|
||||||
className={`px-4 py-3 rounded-xl border-2 transition-all text-left ${selectedShipping === option.name
|
className={`px-4 py-3 rounded-xl border-2 transition-all text-left ${selectedShipping === option.name
|
||||||
? 'border-orange-500 bg-orange-500/10'
|
? 'border-orange-500 bg-orange-500/10'
|
||||||
: 'border-zinc-700 hover:border-zinc-600'
|
: 'border-zinc-700 hover:border-zinc-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-sm font-bold text-white">{option.name}</span>
|
<span className="text-sm font-bold text-white">{option.name}</span>
|
||||||
@@ -227,19 +233,19 @@ export default function SplitPublicPage() {
|
|||||||
{selectedAmount && (
|
{selectedAmount && (
|
||||||
<div className="bg-zinc-950 rounded-2xl p-4 space-y-2">
|
<div className="bg-zinc-950 rounded-2xl p-4 space-y-2">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-zinc-500">Whisky ({selectedAmount}cl)</span>
|
<span className="text-zinc-500">{t('splits.whisky')} ({selectedAmount}cl)</span>
|
||||||
<span className="text-zinc-300">{price.whisky.toFixed(2)}€</span>
|
<span className="text-zinc-300">{price.whisky.toFixed(2)}€</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-zinc-500">Sample-Flasche</span>
|
<span className="text-zinc-500">{t('splits.glass')}</span>
|
||||||
<span className="text-zinc-300">{price.glass.toFixed(2)}€</span>
|
<span className="text-zinc-300">{price.glass.toFixed(2)}€</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-zinc-500">Versand ({selectedShipping})</span>
|
<span className="text-zinc-500">{t('splits.shipping')} ({selectedShipping})</span>
|
||||||
<span className="text-zinc-300">{price.shipping.toFixed(2)}€</span>
|
<span className="text-zinc-300">{price.shipping.toFixed(2)}€</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-zinc-800 pt-2 mt-2 flex justify-between">
|
<div className="border-t border-zinc-800 pt-2 mt-2 flex justify-between">
|
||||||
<span className="font-bold text-white">Gesamt</span>
|
<span className="font-bold text-white">{t('splits.total')}</span>
|
||||||
<span className="font-black text-xl text-orange-500">{price.total.toFixed(2)}€</span>
|
<span className="font-black text-xl text-orange-500">{price.total.toFixed(2)}€</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -251,37 +257,58 @@ export default function SplitPublicPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
{currentUserId ? (
|
||||||
onClick={handleRequest}
|
<button
|
||||||
disabled={isRequesting || !selectedShipping || !selectedAmount}
|
onClick={handleRequest}
|
||||||
className={`w-full py-4 rounded-2xl font-bold text-white transition-all flex items-center justify-center gap-2 ${isWaitlist
|
disabled={isRequesting || !selectedShipping || !selectedAmount}
|
||||||
|
className={`w-full py-4 rounded-2xl font-bold text-white transition-all flex items-center justify-center gap-2 ${isWaitlist
|
||||||
? 'bg-yellow-600 hover:bg-yellow-700'
|
? 'bg-yellow-600 hover:bg-yellow-700'
|
||||||
: 'bg-orange-600 hover:bg-orange-700'
|
: 'bg-orange-600 hover:bg-orange-700'
|
||||||
} disabled:opacity-50`}
|
} disabled:opacity-50`}
|
||||||
>
|
>
|
||||||
{isRequesting ? (
|
{isRequesting ? (
|
||||||
<Loader2 size={20} className="animate-spin" />
|
<Loader2 size={20} className="animate-spin" />
|
||||||
) : isWaitlist ? (
|
) : isWaitlist ? (
|
||||||
<>
|
<>
|
||||||
<Clock size={20} />
|
<Clock size={20} />
|
||||||
Auf Warteliste setzen
|
{t('splits.waitlist')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Package size={20} />
|
<Package size={20} />
|
||||||
Anfrage senden
|
{t('splits.sendRequest')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 pt-4 border-t border-zinc-800">
|
||||||
|
<div className="flex items-start gap-4 p-4 bg-orange-600/10 rounded-2xl border border-orange-500/20">
|
||||||
|
<AlertCircle className="text-orange-500 shrink-0 mt-0.5" size={18} />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-white mb-1">{t('splits.loginToParticipate')}</p>
|
||||||
|
<p className="text-xs text-orange-200/60 leading-relaxed">
|
||||||
|
{t('splits.loginToParticipateDesc')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.href = `/login?redirect=/splits/${slug}`}
|
||||||
|
className="w-full py-4 bg-orange-600 hover:bg-orange-500 text-white rounded-2xl font-bold flex items-center justify-center gap-2 shadow-xl shadow-orange-950/20 transition-all active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
<LogIn size={20} />
|
||||||
|
{t('home.logout').replace('Logout', 'Login').replace('Abmelden', 'Anmelden')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{requestSuccess && (
|
{requestSuccess && (
|
||||||
<div className="bg-green-500/10 border border-green-500/30 rounded-3xl p-6 text-center">
|
<div className="bg-green-500/10 border border-green-500/30 rounded-3xl p-6 text-center">
|
||||||
<CheckCircle2 size={48} className="mx-auto text-green-500 mb-4" />
|
<CheckCircle2 size={48} className="mx-auto text-green-500 mb-4" />
|
||||||
<h3 className="text-lg font-bold text-white mb-2">Anfrage gesendet!</h3>
|
<h3 className="text-lg font-bold text-white mb-2">{t('splits.requestSent')}</h3>
|
||||||
<p className="text-zinc-400 text-sm">
|
<p className="text-zinc-400 text-sm">
|
||||||
Der Host wird deine Anfrage prüfen und sich bei dir melden.
|
{t('splits.requestSentDesc')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -290,17 +317,17 @@ export default function SplitPublicPage() {
|
|||||||
<div className="bg-zinc-900 rounded-3xl p-6 border border-zinc-800">
|
<div className="bg-zinc-900 rounded-3xl p-6 border border-zinc-800">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${['APPROVED', 'PAID', 'SHIPPED'].includes(userParticipation.status)
|
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${['APPROVED', 'PAID', 'SHIPPED'].includes(userParticipation.status)
|
||||||
? 'bg-green-500/20 text-green-500'
|
? 'bg-green-500/20 text-green-500'
|
||||||
: userParticipation.status === 'PENDING'
|
: userParticipation.status === 'PENDING'
|
||||||
? 'bg-yellow-500/20 text-yellow-500'
|
? 'bg-yellow-500/20 text-yellow-500'
|
||||||
: 'bg-red-500/20 text-red-500'
|
: 'bg-red-500/20 text-red-500'
|
||||||
}`}>
|
}`}>
|
||||||
{userParticipation.status === 'SHIPPED' ? <Package size={24} /> :
|
{userParticipation.status === 'SHIPPED' ? <Package size={24} /> :
|
||||||
userParticipation.status === 'PENDING' ? <Clock size={24} /> :
|
userParticipation.status === 'PENDING' ? <Clock size={24} /> :
|
||||||
<CheckCircle2 size={24} />}
|
<CheckCircle2 size={24} />}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-bold text-white">Du nimmst teil</p>
|
<p className="text-sm font-bold text-white">{t('splits.youAreParticipating')}</p>
|
||||||
<p className="text-xs text-zinc-500">
|
<p className="text-xs text-zinc-500">
|
||||||
{userParticipation.amountCl}cl · {userParticipation.totalCost.toFixed(2)}€ ·
|
{userParticipation.amountCl}cl · {userParticipation.totalCost.toFixed(2)}€ ·
|
||||||
Status: {userParticipation.status}
|
Status: {userParticipation.status}
|
||||||
@@ -310,19 +337,6 @@ export default function SplitPublicPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!currentUserId && (
|
|
||||||
<div className="bg-zinc-900 rounded-3xl p-6 border border-zinc-800 text-center">
|
|
||||||
<User size={32} className="mx-auto text-zinc-500 mb-3" />
|
|
||||||
<p className="text-zinc-400 mb-4">Melde dich an, um teilzunehmen</p>
|
|
||||||
<Link
|
|
||||||
href="/login"
|
|
||||||
className="inline-block px-6 py-3 bg-orange-600 text-white rounded-xl font-bold"
|
|
||||||
>
|
|
||||||
Anmelden
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentUserId === split.hostId && (
|
{currentUserId === split.hostId && (
|
||||||
<div className="bg-zinc-900 rounded-3xl p-6 border border-zinc-800">
|
<div className="bg-zinc-900 rounded-3xl p-6 border border-zinc-800">
|
||||||
<p className="text-sm text-zinc-500 mb-4">Du bist der Host dieses Splits</p>
|
<p className="text-sm text-zinc-500 mb-4">Du bist der Host dieses Splits</p>
|
||||||
@@ -340,7 +354,7 @@ export default function SplitPublicPage() {
|
|||||||
className="w-full py-4 bg-zinc-900 hover:bg-zinc-800 border border-zinc-800 rounded-2xl text-zinc-400 font-bold flex items-center justify-center gap-2 transition-colors"
|
className="w-full py-4 bg-zinc-900 hover:bg-zinc-800 border border-zinc-800 rounded-2xl text-zinc-400 font-bold flex items-center justify-center gap-2 transition-colors"
|
||||||
>
|
>
|
||||||
<Share2 size={18} />
|
<Share2 size={18} />
|
||||||
Link teilen
|
{t('splits.shareLink')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Scan, GlassWater, Users, Settings, ArrowRight, X, Sparkles } from 'lucide-react';
|
import { Scan, GlassWater, Users, Settings, ArrowRight, X, Sparkles } from 'lucide-react';
|
||||||
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
|
|
||||||
const ONBOARDING_KEY = 'dramlog_onboarding_complete';
|
const ONBOARDING_KEY = 'dramlog_onboarding_complete';
|
||||||
|
|
||||||
@@ -14,40 +15,42 @@ interface OnboardingStep {
|
|||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STEPS: OnboardingStep[] = [
|
const getSteps = (t: (path: string) => string): OnboardingStep[] => [
|
||||||
{
|
{
|
||||||
id: 'welcome',
|
id: 'welcome',
|
||||||
icon: <Sparkles size={32} className="text-orange-500" />,
|
icon: <Sparkles size={32} className="text-orange-500" />,
|
||||||
title: 'Willkommen bei DramLog!',
|
title: t('tutorial.steps.welcome.title'),
|
||||||
description: 'Dein persönliches Whisky-Tagebuch. Scanne, bewerte und entdecke neue Drams.',
|
description: t('tutorial.steps.welcome.desc'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'scan',
|
id: 'scan',
|
||||||
icon: <Scan size={32} className="text-orange-500" />,
|
icon: <Scan size={32} className="text-orange-500" />,
|
||||||
title: 'Scanne deine Flaschen',
|
title: t('tutorial.steps.scan.title'),
|
||||||
description: 'Fotografiere das Etikett einer Flasche – die KI erkennt automatisch alle Details.',
|
description: t('tutorial.steps.scan.desc'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'taste',
|
id: 'taste',
|
||||||
icon: <GlassWater size={32} className="text-orange-500" />,
|
icon: <GlassWater size={32} className="text-orange-500" />,
|
||||||
title: 'Bewerte deine Drams',
|
title: t('tutorial.steps.taste.title'),
|
||||||
description: 'Füge Tasting-Notizen hinzu und behalte den Überblick über deine Lieblings-Whiskys.',
|
description: t('tutorial.steps.taste.desc'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'session',
|
id: 'activity',
|
||||||
icon: <Users size={32} className="text-orange-500" />,
|
icon: <Users size={32} className="text-orange-500" />,
|
||||||
title: 'Tasting-Sessions',
|
title: t('tutorial.steps.activity.title'),
|
||||||
description: 'Organisiere Verkostungen mit Freunden und vergleicht eure Bewertungen.',
|
description: t('tutorial.steps.activity.desc'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ready',
|
id: 'ready',
|
||||||
icon: <Settings size={32} className="text-orange-500" />,
|
icon: <Settings size={32} className="text-orange-500" />,
|
||||||
title: 'Bereit zum Start!',
|
title: t('tutorial.steps.ready.title'),
|
||||||
description: 'Scanne jetzt deine erste Flasche mit dem orangefarbenen Button unten.',
|
description: t('tutorial.steps.ready.desc'),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function OnboardingTutorial() {
|
export default function OnboardingTutorial() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const STEPS = getSteps(t);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -148,14 +151,14 @@ export default function OnboardingTutorial() {
|
|||||||
onClick={handleSkip}
|
onClick={handleSkip}
|
||||||
className="flex-1 py-3 px-4 text-sm font-bold text-zinc-500 hover:text-white transition-colors"
|
className="flex-1 py-3 px-4 text-sm font-bold text-zinc-500 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
Überspringen
|
{t('tutorial.skip')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
className="flex-1 py-3 px-4 bg-orange-600 hover:bg-orange-500 text-white font-bold text-sm rounded-xl flex items-center justify-center gap-2 transition-colors"
|
className="flex-1 py-3 px-4 bg-orange-600 hover:bg-orange-500 text-white font-bold text-sm rounded-xl flex items-center justify-center gap-2 transition-colors"
|
||||||
>
|
>
|
||||||
{isLastStep ? 'Los geht\'s!' : 'Weiter'}
|
{isLastStep ? t('tutorial.finish') : t('tutorial.next')}
|
||||||
<ArrowRight size={16} />
|
<ArrowRight size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import { motion } from 'framer-motion';
|
|||||||
import { Lock, Eye, EyeOff, Loader2, CheckCircle, AlertCircle } from 'lucide-react';
|
import { Lock, Eye, EyeOff, Loader2, CheckCircle, AlertCircle } from 'lucide-react';
|
||||||
import { changePassword } from '@/services/profile-actions';
|
import { changePassword } from '@/services/profile-actions';
|
||||||
|
|
||||||
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
|
|
||||||
export default function PasswordChangeForm() {
|
export default function PasswordChangeForm() {
|
||||||
|
const { t } = useI18n();
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
@@ -20,13 +23,13 @@ export default function PasswordChangeForm() {
|
|||||||
|
|
||||||
if (newPassword !== confirmPassword) {
|
if (newPassword !== confirmPassword) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setError('Passwörter stimmen nicht überein');
|
setError(t('settings.password.mismatch'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPassword.length < 6) {
|
if (newPassword.length < 6) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setError('Passwort muss mindestens 6 Zeichen lang sein');
|
setError(t('settings.password.tooShort'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +46,7 @@ export default function PasswordChangeForm() {
|
|||||||
setTimeout(() => setStatus('idle'), 3000);
|
setTimeout(() => setStatus('idle'), 3000);
|
||||||
} else {
|
} else {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setError(result.error || 'Fehler beim Ändern');
|
setError(result.error || t('common.error'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -58,14 +61,14 @@ export default function PasswordChangeForm() {
|
|||||||
>
|
>
|
||||||
<h2 className="text-lg font-bold text-white mb-6 flex items-center gap-2">
|
<h2 className="text-lg font-bold text-white mb-6 flex items-center gap-2">
|
||||||
<Lock size={20} className="text-orange-500" />
|
<Lock size={20} className="text-orange-500" />
|
||||||
Passwort ändern
|
{t('settings.password.title')}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* New Password */}
|
{/* New Password */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-zinc-400 mb-2">
|
<label className="block text-sm font-medium text-zinc-400 mb-2">
|
||||||
Neues Passwort
|
{t('settings.password.newPassword')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
@@ -88,7 +91,7 @@ export default function PasswordChangeForm() {
|
|||||||
{/* Confirm Password */}
|
{/* Confirm Password */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-zinc-400 mb-2">
|
<label className="block text-sm font-medium text-zinc-400 mb-2">
|
||||||
Passwort bestätigen
|
{t('settings.password.confirmPassword')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
@@ -107,12 +110,12 @@ export default function PasswordChangeForm() {
|
|||||||
{newPassword === confirmPassword ? (
|
{newPassword === confirmPassword ? (
|
||||||
<>
|
<>
|
||||||
<CheckCircle size={12} />
|
<CheckCircle size={12} />
|
||||||
Passwörter stimmen überein
|
{t('settings.password.match')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<AlertCircle size={12} />
|
<AlertCircle size={12} />
|
||||||
Passwörter stimmen nicht überein
|
{t('settings.password.mismatch')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -122,7 +125,7 @@ export default function PasswordChangeForm() {
|
|||||||
{status === 'success' && (
|
{status === 'success' && (
|
||||||
<div className="mt-4 p-3 bg-green-500/10 border border-green-500/20 rounded-xl flex items-center gap-2 text-green-500 text-sm">
|
<div className="mt-4 p-3 bg-green-500/10 border border-green-500/20 rounded-xl flex items-center gap-2 text-green-500 text-sm">
|
||||||
<CheckCircle size={16} />
|
<CheckCircle size={16} />
|
||||||
Passwort erfolgreich geändert!
|
{t('settings.password.success')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{status === 'error' && (
|
{status === 'error' && (
|
||||||
@@ -141,12 +144,12 @@ export default function PasswordChangeForm() {
|
|||||||
{isPending ? (
|
{isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 size={18} className="animate-spin" />
|
<Loader2 size={18} className="animate-spin" />
|
||||||
Ändern...
|
{t('common.loading')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Lock size={18} />
|
<Lock size={18} />
|
||||||
Passwort ändern
|
{t('settings.password.change')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { motion } from 'framer-motion';
|
|||||||
import { User, Mail, Save, Loader2, CheckCircle, AlertCircle } from 'lucide-react';
|
import { User, Mail, Save, Loader2, CheckCircle, AlertCircle } from 'lucide-react';
|
||||||
import { updateProfile } from '@/services/profile-actions';
|
import { updateProfile } from '@/services/profile-actions';
|
||||||
|
|
||||||
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
|
|
||||||
interface ProfileFormProps {
|
interface ProfileFormProps {
|
||||||
initialData: {
|
initialData: {
|
||||||
email?: string;
|
email?: string;
|
||||||
@@ -13,6 +15,7 @@ interface ProfileFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ProfileForm({ initialData }: ProfileFormProps) {
|
export default function ProfileForm({ initialData }: ProfileFormProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [username, setUsername] = useState(initialData.username || '');
|
const [username, setUsername] = useState(initialData.username || '');
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||||
@@ -33,7 +36,7 @@ export default function ProfileForm({ initialData }: ProfileFormProps) {
|
|||||||
setTimeout(() => setStatus('idle'), 3000);
|
setTimeout(() => setStatus('idle'), 3000);
|
||||||
} else {
|
} else {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setError(result.error || 'Fehler beim Speichern');
|
setError(result.error || t('common.error'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -47,7 +50,7 @@ export default function ProfileForm({ initialData }: ProfileFormProps) {
|
|||||||
>
|
>
|
||||||
<h2 className="text-lg font-bold text-white mb-6 flex items-center gap-2">
|
<h2 className="text-lg font-bold text-white mb-6 flex items-center gap-2">
|
||||||
<User size={20} className="text-orange-500" />
|
<User size={20} className="text-orange-500" />
|
||||||
Profil
|
{t('nav.profile')}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -63,19 +66,18 @@ export default function ProfileForm({ initialData }: ProfileFormProps) {
|
|||||||
disabled
|
disabled
|
||||||
className="w-full px-4 py-3 bg-zinc-800/50 border border-zinc-700 rounded-xl text-zinc-500 cursor-not-allowed"
|
className="w-full px-4 py-3 bg-zinc-800/50 border border-zinc-700 rounded-xl text-zinc-500 cursor-not-allowed"
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-zinc-500">E-Mail kann nicht geändert werden</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Username */}
|
{/* Username */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-zinc-400 mb-2">
|
<label className="block text-sm font-medium text-zinc-400 mb-2">
|
||||||
Benutzername
|
{t('bottle.nameLabel')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
placeholder="Dein Benutzername"
|
placeholder={t('bottle.nameLabel')}
|
||||||
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,7 +87,7 @@ export default function ProfileForm({ initialData }: ProfileFormProps) {
|
|||||||
{status === 'success' && (
|
{status === 'success' && (
|
||||||
<div className="mt-4 p-3 bg-green-500/10 border border-green-500/20 rounded-xl flex items-center gap-2 text-green-500 text-sm">
|
<div className="mt-4 p-3 bg-green-500/10 border border-green-500/20 rounded-xl flex items-center gap-2 text-green-500 text-sm">
|
||||||
<CheckCircle size={16} />
|
<CheckCircle size={16} />
|
||||||
Profil gespeichert!
|
{t('common.success')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{status === 'error' && (
|
{status === 'error' && (
|
||||||
@@ -104,12 +106,12 @@ export default function ProfileForm({ initialData }: ProfileFormProps) {
|
|||||||
{isPending ? (
|
{isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 size={18} className="animate-spin" />
|
<Loader2 size={18} className="animate-spin" />
|
||||||
Speichern...
|
{t('common.loading')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Save size={18} />
|
<Save size={18} />
|
||||||
Speichern
|
{t('common.save')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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" />}
|
||||||
|
|||||||
115
src/components/SettingsHub.tsx
Normal file
115
src/components/SettingsHub.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Settings, Cookie, Shield, ArrowLeft } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
|
import LanguageSwitcher from './LanguageSwitcher';
|
||||||
|
import ProfileForm from './ProfileForm';
|
||||||
|
import PasswordChangeForm from './PasswordChangeForm';
|
||||||
|
|
||||||
|
interface SettingsHubProps {
|
||||||
|
profile: {
|
||||||
|
email?: string;
|
||||||
|
username?: string | null;
|
||||||
|
created_at?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsHub({ profile }: SettingsHubProps) {
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-950">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="sticky top-0 z-40 bg-zinc-950/80 backdrop-blur-lg border-b border-zinc-800">
|
||||||
|
<div className="max-w-2xl mx-auto px-4 py-4 flex items-center gap-4">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="p-2 -ml-2 text-zinc-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Settings size={20} className="text-orange-500" />
|
||||||
|
<h1 className="text-lg font-bold text-white">{t('settings.title')}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<main className="max-w-2xl mx-auto px-4 py-6 space-y-6">
|
||||||
|
{/* Language Switcher */}
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6">
|
||||||
|
<h2 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
|
||||||
|
<Settings size={20} className="text-orange-500" />
|
||||||
|
{t('settings.language')}
|
||||||
|
</h2>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Profile Form */}
|
||||||
|
<ProfileForm
|
||||||
|
initialData={{
|
||||||
|
email: profile.email,
|
||||||
|
username: profile.username,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Password Change Form */}
|
||||||
|
<PasswordChangeForm />
|
||||||
|
|
||||||
|
{/* Cookie Settings */}
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6">
|
||||||
|
<h2 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
|
||||||
|
<Cookie size={20} className="text-orange-500" />
|
||||||
|
{t('settings.cookieSettings')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-zinc-400 mb-4">
|
||||||
|
{t('settings.cookieDesc')}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex items-center gap-2 text-zinc-300">
|
||||||
|
<Shield size={14} className="text-green-500" />
|
||||||
|
<span>{t('settings.cookieNecessary')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-zinc-300">
|
||||||
|
<Shield size={14} className="text-blue-500" />
|
||||||
|
<span>{t('settings.cookieFunctional')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Data & Privacy */}
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6">
|
||||||
|
<h2 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
|
||||||
|
<Shield size={20} className="text-orange-500" />
|
||||||
|
{t('settings.privacy')}
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-zinc-400">
|
||||||
|
{t('settings.privacyDesc')}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/privacy"
|
||||||
|
className="inline-block text-sm text-orange-500 hover:text-orange-400 underline"
|
||||||
|
>
|
||||||
|
{t('settings.privacyLink')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Account info */}
|
||||||
|
<div className="bg-zinc-800/50 border border-zinc-700 rounded-2xl p-4 text-center">
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
{t('settings.memberSince')}: {new Date(profile.created_at || '').toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric'
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
src/components/SplitCard.tsx
Normal file
90
src/components/SplitCard.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Package, Users, Info, Terminal, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Split {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
bottleName: string;
|
||||||
|
bottleImage?: string;
|
||||||
|
distillery?: string;
|
||||||
|
totalVolume: number;
|
||||||
|
hostShare: number;
|
||||||
|
participantCount?: number;
|
||||||
|
amountCl?: number; // for participating
|
||||||
|
status?: string; // for participating
|
||||||
|
isActive: boolean;
|
||||||
|
hostName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SplitCardProps {
|
||||||
|
split: Split;
|
||||||
|
isParticipant?: boolean;
|
||||||
|
onSelect?: () => void;
|
||||||
|
showChevron?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SplitCard({ split, isParticipant, onSelect, showChevron = true }: SplitCardProps) {
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
PENDING: 'Waiting',
|
||||||
|
APPROVED: 'Confirmed',
|
||||||
|
PAID: 'Paid',
|
||||||
|
SHIPPED: 'Shipped',
|
||||||
|
REJECTED: 'Rejected',
|
||||||
|
WAITLIST: 'Waitlist'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="p-5 rounded-[28px] border bg-zinc-900/30 border-white/5 hover:border-white/10 hover:bg-zinc-900/50 transition-all flex items-center justify-between group cursor-pointer"
|
||||||
|
onClick={onSelect}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0 pr-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h4 className="text-base font-black uppercase truncate tracking-tight text-zinc-200">
|
||||||
|
{split.bottleName}
|
||||||
|
</h4>
|
||||||
|
{!split.isActive && (
|
||||||
|
<span className="text-[7px] font-black uppercase px-1.5 py-0.5 rounded-full bg-zinc-800 border border-white/5 text-zinc-500">Closed</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-[9px] font-black uppercase tracking-[0.15em] text-zinc-600">
|
||||||
|
{isParticipant ? (
|
||||||
|
<>
|
||||||
|
<span className="flex items-center gap-1 text-orange-500/80">
|
||||||
|
<Package size={10} />
|
||||||
|
{split.amountCl}cl
|
||||||
|
</span>
|
||||||
|
<span className={`px-1.5 py-0.5 rounded bg-white/5 border border-white/5 ${split.status === 'SHIPPED' ? 'text-green-500' : 'text-zinc-400'}`}>
|
||||||
|
{statusLabels[split.status || ''] || split.status}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Users size={10} className="text-zinc-700" />
|
||||||
|
{split.participantCount ?? 0} Confirmed
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Info size={10} className="text-zinc-700" />
|
||||||
|
{split.totalVolume}cl Total
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{split.hostName && (
|
||||||
|
<div className="mt-2 flex items-center gap-1 text-[8px] font-black uppercase text-zinc-700">
|
||||||
|
<Terminal size={8} /> By {split.hostName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showChevron && (
|
||||||
|
<div className="w-10 h-10 rounded-2xl border bg-black/40 border-white/5 text-zinc-700 group-hover:text-zinc-400 group-hover:border-white/10 transition-all flex items-center justify-center">
|
||||||
|
<ChevronRight size={20} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
471
src/components/TastingHub.tsx
Normal file
471
src/components/TastingHub.tsx
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
X, GlassWater, Plus, Users, Calendar,
|
||||||
|
ChevronRight, Loader2, Sparkles, Check,
|
||||||
|
ArrowRight, User, Terminal, Package, Info
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
|
import { useSession } from '@/context/SessionContext';
|
||||||
|
import { getHostSplits, getParticipatingSplits } from '@/services/split-actions';
|
||||||
|
import AvatarStack from './AvatarStack';
|
||||||
|
import SplitCard from './SplitCard';
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
scheduled_at: string;
|
||||||
|
ended_at?: string;
|
||||||
|
host_name?: string;
|
||||||
|
participant_count?: number;
|
||||||
|
whisky_count?: number;
|
||||||
|
participants?: string[];
|
||||||
|
is_host: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Split {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
bottleName: string;
|
||||||
|
bottleImage?: string;
|
||||||
|
totalVolume: number;
|
||||||
|
hostShare: number;
|
||||||
|
participantCount: number;
|
||||||
|
amountCl?: number; // for participating
|
||||||
|
status?: string; // for participating
|
||||||
|
isActive: boolean;
|
||||||
|
hostName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TastingHubProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TastingHub({ isOpen, onClose }: TastingHubProps) {
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
const supabase = createClient();
|
||||||
|
const { activeSession, setActiveSession } = useSession();
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<'tastings' | 'splits'>('tastings');
|
||||||
|
const [mySessions, setMySessions] = useState<Session[]>([]);
|
||||||
|
const [guestSessions, setGuestSessions] = useState<Session[]>([]);
|
||||||
|
|
||||||
|
const [mySplits, setMySplits] = useState<Split[]>([]);
|
||||||
|
const [participatingSplits, setParticipatingSplits] = useState<Split[]>([]);
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
fetchAll();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const fetchAll = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
await Promise.all([fetchSessions(), fetchSplits()]);
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchSessions = async () => {
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
// 1. Fetch My Sessions (Host)
|
||||||
|
const { data: hostData, error: hostError } = await supabase
|
||||||
|
.from('tasting_sessions')
|
||||||
|
.select(`
|
||||||
|
*,
|
||||||
|
session_participants (
|
||||||
|
buddies (name)
|
||||||
|
),
|
||||||
|
tastings (count)
|
||||||
|
`)
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.order('scheduled_at', { ascending: false });
|
||||||
|
|
||||||
|
// 2. Fetch Sessions I'm participating in (Guest)
|
||||||
|
const { data: participantData, error: partError } = await supabase
|
||||||
|
.from('tasting_sessions')
|
||||||
|
.select(`
|
||||||
|
*,
|
||||||
|
profiles (username),
|
||||||
|
tastings (count),
|
||||||
|
session_participants!inner (
|
||||||
|
buddy_id,
|
||||||
|
buddies!inner (
|
||||||
|
buddy_profile_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.eq('session_participants.buddies.buddy_profile_id', user.id)
|
||||||
|
.order('scheduled_at', { ascending: false });
|
||||||
|
|
||||||
|
if (hostData) {
|
||||||
|
setMySessions(hostData.map(s => ({
|
||||||
|
...s,
|
||||||
|
is_host: true,
|
||||||
|
participant_count: (s.session_participants as any[])?.length || 0,
|
||||||
|
participants: (s.session_participants as any[])?.filter(p => p.buddies).map(p => p.buddies.name) || [],
|
||||||
|
whisky_count: s.tastings[0]?.count || 0
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (participantData) {
|
||||||
|
// Filter out host sessions (though RLS might already separate them, better safe)
|
||||||
|
setGuestSessions(participantData
|
||||||
|
.filter(s => s.user_id !== user.id)
|
||||||
|
.map(s => ({
|
||||||
|
...s,
|
||||||
|
is_host: false,
|
||||||
|
host_name: s.profiles?.username || 'Host',
|
||||||
|
participant_count: 0,
|
||||||
|
whisky_count: s.tastings[0]?.count || 0
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchSplits = async () => {
|
||||||
|
const hostRes = await getHostSplits();
|
||||||
|
if (hostRes.success && hostRes.splits) {
|
||||||
|
setMySplits(hostRes.splits as Split[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const partRes = await getParticipatingSplits();
|
||||||
|
if (partRes.success && partRes.splits) {
|
||||||
|
setParticipatingSplits(partRes.splits as Split[]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateSession = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newName.trim()) return;
|
||||||
|
|
||||||
|
setIsCreating(true);
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('tasting_sessions')
|
||||||
|
.insert([{ name: newName.trim(), user_id: user.id }])
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error creating session:', error);
|
||||||
|
} else {
|
||||||
|
fetchSessions();
|
||||||
|
setNewName('');
|
||||||
|
setActiveSession({ id: data.id, name: data.name });
|
||||||
|
}
|
||||||
|
setIsCreating(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-[60]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: '100%' }}
|
||||||
|
animate={{ y: 0 }}
|
||||||
|
exit={{ y: '100%' }}
|
||||||
|
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||||
|
className="fixed bottom-0 left-0 right-0 h-[85vh] bg-[#09090b] border-t border-white/10 rounded-t-[40px] z-[70] flex flex-col shadow-2xl overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-8 pb-4 flex items-center justify-between shrink-0">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<div className="w-10 h-10 rounded-2xl bg-orange-600/10 flex items-center justify-center text-orange-500">
|
||||||
|
<GlassWater size={24} />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-black text-white uppercase tracking-tight">{t('hub.title')}</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-zinc-500 text-xs font-bold uppercase tracking-[0.2em] ml-1">{t('hub.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-12 h-12 rounded-2xl bg-zinc-900 border border-white/5 flex items-center justify-center text-zinc-400 hover:text-white transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="px-8 shrink-0">
|
||||||
|
<div className="bg-zinc-900/50 p-1.5 rounded-2xl flex gap-1 border border-white/5">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('tastings')}
|
||||||
|
className={`flex-1 py-3 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all ${activeTab === 'tastings' ? 'bg-orange-600 text-white shadow-lg' : 'text-zinc-500 hover:text-zinc-300'}`}
|
||||||
|
>
|
||||||
|
{t('hub.tabs.tastings')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('splits')}
|
||||||
|
className={`flex-1 py-3 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all ${activeTab === 'splits' ? 'bg-orange-600 text-white shadow-lg' : 'text-zinc-500 hover:text-zinc-300'}`}
|
||||||
|
>
|
||||||
|
{t('hub.tabs.splits')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrolling Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-8 pb-24 pt-8 space-y-12">
|
||||||
|
{activeTab === 'tastings' ? (
|
||||||
|
<>
|
||||||
|
{/* Create Section */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h3 className="text-[10px] font-black uppercase tracking-[0.3em] text-zinc-500 flex items-center gap-2">
|
||||||
|
<Plus size={14} className="text-orange-600" /> {t('hub.sections.startSession')}
|
||||||
|
</h3>
|
||||||
|
<form onSubmit={handleCreateSession} className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
placeholder={t('hub.placeholders.sessionName')}
|
||||||
|
className="flex-1 bg-black/40 border border-white/5 rounded-2xl px-6 py-4 text-sm font-bold text-white placeholder:text-zinc-700 focus:outline-none focus:border-orange-600 transition-all ring-inset focus:ring-1 focus:ring-orange-600/50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isCreating || !newName.trim()}
|
||||||
|
className="bg-orange-600 hover:bg-orange-500 text-white px-8 rounded-2xl font-black uppercase tracking-widest text-xs transition-all shadow-lg shadow-orange-950/20 disabled:opacity-50 active:scale-95"
|
||||||
|
>
|
||||||
|
{isCreating ? <Loader2 size={20} className="animate-spin" /> : 'Go'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Active Session Highlight */}
|
||||||
|
{activeSession && (
|
||||||
|
<section className="space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||||
|
<h3 className="text-[10px] font-black uppercase tracking-[0.3em] text-orange-500 flex items-center gap-2">
|
||||||
|
<Sparkles size={14} /> {t('hub.sections.activeNow')}
|
||||||
|
</h3>
|
||||||
|
<div className="bg-orange-600 rounded-[32px] p-8 shadow-2xl shadow-orange-950/40 border border-white/10 relative overflow-hidden group">
|
||||||
|
<div className="absolute top-0 right-0 p-6 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||||
|
<GlassWater size={120} />
|
||||||
|
</div>
|
||||||
|
<div className="relative z-10 flex justify-between items-end">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-orange-200 text-xs font-black uppercase tracking-widest">{t('session.activeSession')}</p>
|
||||||
|
<h4 className="text-2xl font-black text-white uppercase leading-none">{activeSession.name}</h4>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onClose();
|
||||||
|
window.location.href = `/sessions/${activeSession.id}`;
|
||||||
|
}}
|
||||||
|
className="px-6 py-3 bg-white text-orange-600 rounded-2xl font-black uppercase tracking-widest text-[10px] shadow-xl hover:scale-105 transition-transform active:scale-95 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{t('grid.close')} <ArrowRight size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* My Sessions List */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h3 className="text-[10px] font-black uppercase tracking-[0.3em] text-zinc-500 flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-2"><User size={14} className="text-orange-600" /> {t('hub.sections.yourSessions')}</span>
|
||||||
|
<span className="text-zinc-700">{mySessions.length}</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<Loader2 size={32} className="animate-spin text-zinc-800" />
|
||||||
|
</div>
|
||||||
|
) : mySessions.length === 0 ? (
|
||||||
|
<div className="bg-zinc-900/30 border border-dashed border-white/5 rounded-[32px] p-12 text-center">
|
||||||
|
<Calendar size={32} className="text-zinc-800 mx-auto mb-3" />
|
||||||
|
<p className="text-zinc-600 font-bold uppercase tracking-widest text-[10px]">{t('hub.placeholders.noSessions')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{mySessions.map((session) => (
|
||||||
|
<SessionCard
|
||||||
|
key={session.id}
|
||||||
|
session={session}
|
||||||
|
isActive={activeSession?.id === session.id}
|
||||||
|
locale={locale}
|
||||||
|
onSelect={() => {
|
||||||
|
setActiveSession({ id: session.id, name: session.name });
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Guest Sessions List */}
|
||||||
|
{guestSessions.length > 0 && (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h3 className="text-[10px] font-black uppercase tracking-[0.3em] text-zinc-500 flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-2"><Users size={14} className="text-orange-600" /> {t('hub.sections.participating')}</span>
|
||||||
|
<span className="text-zinc-700">{guestSessions.length}</span>
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{guestSessions.map((session) => (
|
||||||
|
<SessionCard
|
||||||
|
key={session.id}
|
||||||
|
session={session}
|
||||||
|
isActive={activeSession?.id === session.id}
|
||||||
|
locale={locale}
|
||||||
|
onSelect={() => {
|
||||||
|
setActiveSession({ id: session.id, name: session.name });
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Split Section */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h3 className="text-[10px] font-black uppercase tracking-[0.3em] text-zinc-500 flex items-center gap-2">
|
||||||
|
<Plus size={14} className="text-orange-600" /> {t('hub.sections.startSplit')}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onClose();
|
||||||
|
window.location.href = '/splits/create';
|
||||||
|
}}
|
||||||
|
className="w-full bg-zinc-900 border border-white/5 hover:border-orange-500/30 rounded-2xl px-6 py-4 text-xs font-black uppercase tracking-widest text-zinc-400 hover:text-white transition-all flex items-center justify-center gap-3"
|
||||||
|
>
|
||||||
|
<Package size={18} className="text-orange-600" /> {t('hub.placeholders.openSplitCreator')}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* My Splits */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h3 className="text-[10px] font-black uppercase tracking-[0.3em] text-zinc-500 flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-2"><Package size={14} className="text-orange-600" /> {t('hub.sections.yourSplits')}</span>
|
||||||
|
<span className="text-zinc-700">{mySplits.length}</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<Loader2 size={32} className="animate-spin text-zinc-800" />
|
||||||
|
</div>
|
||||||
|
) : mySplits.length === 0 ? (
|
||||||
|
<div className="bg-zinc-900/30 border border-dashed border-white/5 rounded-[32px] p-12 text-center">
|
||||||
|
<Package size={32} className="text-zinc-800 mx-auto mb-3" />
|
||||||
|
<p className="text-zinc-600 font-bold uppercase tracking-widest text-[10px]">{t('hub.placeholders.noSplits')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{mySplits.map((split) => (
|
||||||
|
<SplitCard
|
||||||
|
key={split.id}
|
||||||
|
split={split}
|
||||||
|
onSelect={() => {
|
||||||
|
onClose();
|
||||||
|
window.location.href = '/splits/manage';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Participating Splits */}
|
||||||
|
{participatingSplits.length > 0 && (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h3 className="text-[10px] font-black uppercase tracking-[0.3em] text-zinc-500 flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-2"><Users size={14} className="text-orange-600" /> {t('hub.sections.participating')}</span>
|
||||||
|
<span className="text-zinc-700">{participatingSplits.length}</span>
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{participatingSplits.map((split) => (
|
||||||
|
<SplitCard
|
||||||
|
key={split.id}
|
||||||
|
split={split}
|
||||||
|
isParticipant
|
||||||
|
onSelect={() => {
|
||||||
|
onClose();
|
||||||
|
window.location.href = `/splits/${split.slug}`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function SessionCard({ session, isActive, locale, onSelect }: { session: Session, isActive: boolean, locale: string, onSelect: () => void }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`p-5 rounded-[28px] border transition-all flex items-center justify-between group cursor-pointer ${isActive ? 'bg-zinc-800/50 border-orange-500/30' : 'bg-zinc-900/30 border-white/5 hover:border-white/10 hover:bg-zinc-900/50'}`}
|
||||||
|
onClick={onSelect}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0 pr-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h4 className={`text-base font-black uppercase truncate tracking-tight transition-colors ${isActive ? 'text-orange-500' : 'text-zinc-200'}`}>
|
||||||
|
{session.name}
|
||||||
|
</h4>
|
||||||
|
{session.ended_at && (
|
||||||
|
<span className="text-[7px] font-black uppercase px-1.5 py-0.5 rounded-full bg-zinc-800 border border-white/5 text-zinc-500">Done</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-[9px] font-black uppercase tracking-[0.15em] text-zinc-600">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar size={10} className="text-zinc-700" />
|
||||||
|
{new Date(session.scheduled_at).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')}
|
||||||
|
</span>
|
||||||
|
{!session.is_host && session.host_name && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Terminal size={10} className="text-zinc-700" />
|
||||||
|
By {session.host_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{session.whisky_count! > 0 && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<GlassWater size={10} className="text-orange-600/50" />
|
||||||
|
{session.whisky_count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{session.participants && session.participants.length > 0 && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<AvatarStack names={session.participants} limit={4} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`w-10 h-10 rounded-2xl border transition-all flex items-center justify-center ${isActive ? 'bg-orange-600 border-orange-600 text-white shadow-lg shadow-orange-950/20' : 'bg-black/40 border-white/5 text-zinc-700 group-hover:text-zinc-400 group-hover:border-white/10'}`}>
|
||||||
|
{isActive ? <Check size={20} /> : <ChevronRight size={20} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,6 +45,9 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
|||||||
const [lastDramInSession, setLastDramInSession] = useState<{ name: string; isSmoky: boolean; timestamp: number } | null>(null);
|
const [lastDramInSession, setLastDramInSession] = useState<{ name: string; isSmoky: boolean; timestamp: number } | null>(null);
|
||||||
const [showPaletteWarning, setShowPaletteWarning] = useState(false);
|
const [showPaletteWarning, setShowPaletteWarning] = useState(false);
|
||||||
|
|
||||||
|
const [bottleOwnerId, setBottleOwnerId] = useState<string | null>(null);
|
||||||
|
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Section collapse states
|
// Section collapse states
|
||||||
const [isNoseExpanded, setIsNoseExpanded] = useState(false);
|
const [isNoseExpanded, setIsNoseExpanded] = useState(false);
|
||||||
const [isPalateExpanded, setIsPalateExpanded] = useState(false);
|
const [isPalateExpanded, setIsPalateExpanded] = useState(false);
|
||||||
@@ -52,14 +55,22 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
|||||||
|
|
||||||
const effectiveSessionId = sessionId || activeSession?.id;
|
const effectiveSessionId = sessionId || activeSession?.id;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const getAuth = async () => {
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (user) setCurrentUserId(user.id);
|
||||||
|
};
|
||||||
|
getAuth();
|
||||||
|
}, [supabase]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
if (!bottleId) return;
|
if (!bottleId) return;
|
||||||
|
|
||||||
// Fetch Bottle Suggestions
|
// Fetch Bottle Suggestions and Owner
|
||||||
const { data: bottleData } = await supabase
|
const { data: bottleData } = await supabase
|
||||||
.from('bottles')
|
.from('bottles')
|
||||||
.select('suggested_tags, suggested_custom_tags')
|
.select('suggested_tags, suggested_custom_tags, user_id')
|
||||||
.eq('id', bottleId)
|
.eq('id', bottleId)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
@@ -69,6 +80,9 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
|||||||
if (bottleData?.suggested_custom_tags) {
|
if (bottleData?.suggested_custom_tags) {
|
||||||
setSuggestedCustomTags(bottleData.suggested_custom_tags);
|
setSuggestedCustomTags(bottleData.suggested_custom_tags);
|
||||||
}
|
}
|
||||||
|
if (bottleData?.user_id) {
|
||||||
|
setBottleOwnerId(bottleData.user_id);
|
||||||
|
}
|
||||||
|
|
||||||
// If Session ID, fetch session participants and pre-select them, and fetch last dram
|
// If Session ID, fetch session participants and pre-select them, and fetch last dram
|
||||||
if (effectiveSessionId) {
|
if (effectiveSessionId) {
|
||||||
@@ -209,8 +223,22 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isSharedBottle = bottleOwnerId && currentUserId && bottleOwnerId !== currentUserId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{isSharedBottle && !activeSession && (
|
||||||
|
<div className="p-4 bg-orange-500/10 border border-orange-500/20 rounded-2xl flex items-start gap-3">
|
||||||
|
<AlertTriangle size={20} className="text-orange-500 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-wider text-orange-600">Shared Bottle Ownership Check</p>
|
||||||
|
<p className="text-xs font-bold text-orange-200">Diese Flasche gehört einem Buddy.</p>
|
||||||
|
<p className="text-[10px] text-orange-400/80 leading-relaxed font-medium mt-1">
|
||||||
|
Hinweis: Falls kein Session-Sharing aktiv ist, schlägt das Speichern fehl. Starte eine Session um gemeinsam zu bewerten!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{activeSession && (
|
{activeSession && (
|
||||||
<div className="p-3 bg-orange-950/20 border border-orange-900/30 rounded-2xl flex items-center gap-3 animate-in fade-in slide-in-from-top-2">
|
<div className="p-3 bg-orange-950/20 border border-orange-900/30 rounded-2xl flex items-center gap-3 animate-in fade-in slide-in-from-top-2">
|
||||||
<div className="bg-orange-600 text-white p-2 rounded-xl">
|
<div className="bg-orange-600 text-white p-2 rounded-xl">
|
||||||
|
|||||||
103
src/components/UserStatusBadge.tsx
Normal file
103
src/components/UserStatusBadge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
src/i18n/de.ts
110
src/i18n/de.ts
@@ -1,6 +1,29 @@
|
|||||||
import { TranslationKeys } from './types';
|
import { TranslationKeys } from './types';
|
||||||
|
|
||||||
export const de: TranslationKeys = {
|
export const de: TranslationKeys = {
|
||||||
|
splits: {
|
||||||
|
joinTitle: 'Sample bestellen',
|
||||||
|
amount: 'Menge',
|
||||||
|
shipping: 'Versand',
|
||||||
|
whisky: 'Whisky',
|
||||||
|
glass: 'Sample-Flasche',
|
||||||
|
total: 'Gesamt',
|
||||||
|
requestSent: 'Anfrage gesendet!',
|
||||||
|
requestSentDesc: 'Der Host wird deine Anfrage prüfen und sich bei dir melden.',
|
||||||
|
loginToParticipate: 'Anmelden zum Teilnehmen',
|
||||||
|
loginToParticipateDesc: 'Um an dieser Flaschenteilung teilzunehmen, musst du angemeldet sein.',
|
||||||
|
publicExplore: 'Aktuelle Flaschenteilungen',
|
||||||
|
waitlist: 'Auf Warteliste setzen',
|
||||||
|
sendRequest: 'Anfrage senden',
|
||||||
|
youAreParticipating: 'Du nimmst teil',
|
||||||
|
byHost: 'Von',
|
||||||
|
shareLink: 'Link teilen',
|
||||||
|
backToStart: 'Zurück zum Start',
|
||||||
|
noSplitsFound: 'Keine aktiven Teilungen gefunden',
|
||||||
|
falscheTeilung: 'Flaschenteilung',
|
||||||
|
clFlasche: 'cl Flasche',
|
||||||
|
jahre: 'Jahre',
|
||||||
|
},
|
||||||
common: {
|
common: {
|
||||||
save: 'Speichern',
|
save: 'Speichern',
|
||||||
cancel: 'Abbrechen',
|
cancel: 'Abbrechen',
|
||||||
@@ -37,9 +60,14 @@ export const de: TranslationKeys = {
|
|||||||
},
|
},
|
||||||
searchPlaceholder: 'Flaschen oder Noten suchen...',
|
searchPlaceholder: 'Flaschen oder Noten suchen...',
|
||||||
noBottles: 'Keine Flaschen gefunden. Zeit für einen Einkauf! 🥃',
|
noBottles: 'Keine Flaschen gefunden. Zeit für einen Einkauf! 🥃',
|
||||||
collection: 'Deine Sammlung',
|
collection: 'Kollektion',
|
||||||
reTry: 'Erneut versuchen',
|
reTry: 'Nochmal versuchen',
|
||||||
all: 'Alle',
|
all: 'Alle',
|
||||||
|
tagline: 'Modernes Minimalistisches Tasting-Tool.',
|
||||||
|
bottleCount: 'Flaschen',
|
||||||
|
imprint: 'Impressum',
|
||||||
|
privacy: 'Datenschutz',
|
||||||
|
settings: 'Einstellungen',
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
searchPlaceholder: 'Suchen nach Name oder Distille...',
|
searchPlaceholder: 'Suchen nach Name oder Distille...',
|
||||||
@@ -165,6 +193,84 @@ export const de: TranslationKeys = {
|
|||||||
noSessions: 'Noch keine Sessions vorhanden.',
|
noSessions: 'Noch keine Sessions vorhanden.',
|
||||||
expiryWarning: 'Diese Session läuft bald ab.',
|
expiryWarning: 'Diese Session läuft bald ab.',
|
||||||
},
|
},
|
||||||
|
nav: {
|
||||||
|
home: 'Home',
|
||||||
|
shelf: 'Sammlung',
|
||||||
|
activity: 'Aktivität',
|
||||||
|
search: 'Suchen',
|
||||||
|
profile: 'Profil',
|
||||||
|
},
|
||||||
|
hub: {
|
||||||
|
title: 'Activity Hub',
|
||||||
|
subtitle: 'Live-Events & Splits',
|
||||||
|
tabs: {
|
||||||
|
tastings: 'Tastings',
|
||||||
|
splits: 'Splits',
|
||||||
|
},
|
||||||
|
sections: {
|
||||||
|
startSession: 'Neue Session starten',
|
||||||
|
startSplit: 'Neuen Split starten',
|
||||||
|
activeNow: 'Gerade aktiv',
|
||||||
|
yourSessions: 'Deine Sessions',
|
||||||
|
yourSplits: 'Deine Splits',
|
||||||
|
participating: 'Teilnahmen',
|
||||||
|
},
|
||||||
|
placeholders: {
|
||||||
|
sessionName: 'Session-Name (z.B. Islay Nacht)',
|
||||||
|
noSessions: 'Noch keine Sessions',
|
||||||
|
noSplits: 'Noch keine Splits erstellt',
|
||||||
|
openSplitCreator: 'Split-Creator öffnen',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tutorial: {
|
||||||
|
skip: 'Überspringen',
|
||||||
|
next: 'Weiter',
|
||||||
|
finish: 'Los geht\'s!',
|
||||||
|
steps: {
|
||||||
|
welcome: {
|
||||||
|
title: 'Willkommen bei DramLog!',
|
||||||
|
desc: 'Dein persönliches Whisky-Tagebuch. Scanne, bewerte und entdecke neue Drams.',
|
||||||
|
},
|
||||||
|
scan: {
|
||||||
|
title: 'Scanne deine Flaschen',
|
||||||
|
desc: 'Fotografiere das Etikett einer Flasche – die KI erkennt automatisch alle Details.',
|
||||||
|
},
|
||||||
|
taste: {
|
||||||
|
title: 'Bewerte deine Drams',
|
||||||
|
desc: 'Füge Tasting-Notizen hinzu und behalte den Überblick über deine Lieblings-Whiskys.',
|
||||||
|
},
|
||||||
|
activity: {
|
||||||
|
title: 'Aktivitätshub',
|
||||||
|
desc: 'Organisiere Tasting-Sessions mit Freunden oder nimm an exklusiven Bottle Splits teil.',
|
||||||
|
},
|
||||||
|
ready: {
|
||||||
|
title: 'Bereit zum Start!',
|
||||||
|
desc: 'Scanne jetzt deine erste Flasche mit dem orangefarbenen Button unten.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
title: 'Einstellungen',
|
||||||
|
language: 'Sprache',
|
||||||
|
cookieSettings: 'Cookie-Einstellungen',
|
||||||
|
cookieDesc: 'Diese App verwendet nur technisch notwendige Cookies für die Authentifizierung und funktionale Cookies für UI-Präferenzen.',
|
||||||
|
cookieNecessary: 'Notwendig: Supabase Auth Cookies',
|
||||||
|
cookieFunctional: 'Funktional: Sprache, UI-Status',
|
||||||
|
privacy: 'Datenschutz',
|
||||||
|
privacyDesc: 'Deine Daten werden sicher auf EU-Servern gespeichert.',
|
||||||
|
privacyLink: 'Datenschutzerklärung lesen',
|
||||||
|
memberSince: 'Mitglied seit',
|
||||||
|
password: {
|
||||||
|
title: 'Passwort ändern',
|
||||||
|
newPassword: 'Neues Passwort',
|
||||||
|
confirmPassword: 'Passwort bestätigen',
|
||||||
|
match: 'Passwörter stimmen überein',
|
||||||
|
mismatch: 'Passwörter stimmen nicht überein',
|
||||||
|
tooShort: 'Passwort muss mindestens 6 Zeichen lang sein',
|
||||||
|
success: 'Passwort erfolgreich geändert!',
|
||||||
|
change: 'Passwort ändern',
|
||||||
|
},
|
||||||
|
},
|
||||||
aroma: {
|
aroma: {
|
||||||
'Apfel': 'Apfel',
|
'Apfel': 'Apfel',
|
||||||
'Grüner Apfel': 'Grüner Apfel',
|
'Grüner Apfel': 'Grüner Apfel',
|
||||||
|
|||||||
110
src/i18n/en.ts
110
src/i18n/en.ts
@@ -1,6 +1,29 @@
|
|||||||
import { TranslationKeys } from './types';
|
import { TranslationKeys } from './types';
|
||||||
|
|
||||||
export const en: TranslationKeys = {
|
export const en: TranslationKeys = {
|
||||||
|
splits: {
|
||||||
|
joinTitle: 'Order Sample',
|
||||||
|
amount: 'Amount',
|
||||||
|
shipping: 'Shipping',
|
||||||
|
whisky: 'Whisky',
|
||||||
|
glass: 'Sample Bottle',
|
||||||
|
total: 'Total',
|
||||||
|
requestSent: 'Request sent!',
|
||||||
|
requestSentDesc: 'The host will review your request and get back to you.',
|
||||||
|
loginToParticipate: 'Login to participate',
|
||||||
|
loginToParticipateDesc: 'You must be logged in to participate in this bottle split.',
|
||||||
|
publicExplore: 'Active Bottle Splits',
|
||||||
|
waitlist: 'Join Waitlist',
|
||||||
|
sendRequest: 'Send Request',
|
||||||
|
youAreParticipating: 'You are participating',
|
||||||
|
byHost: 'By',
|
||||||
|
shareLink: 'Share Link',
|
||||||
|
backToStart: 'Back to start',
|
||||||
|
noSplitsFound: 'No active splits found',
|
||||||
|
falscheTeilung: 'Bottle Split',
|
||||||
|
clFlasche: 'cl Bottle',
|
||||||
|
jahre: 'Years',
|
||||||
|
},
|
||||||
common: {
|
common: {
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
@@ -37,9 +60,14 @@ export const en: TranslationKeys = {
|
|||||||
},
|
},
|
||||||
searchPlaceholder: 'Search bottles or notes...',
|
searchPlaceholder: 'Search bottles or notes...',
|
||||||
noBottles: 'No bottles found. Time to go shopping! 🥃',
|
noBottles: 'No bottles found. Time to go shopping! 🥃',
|
||||||
collection: 'Your Collection',
|
collection: 'Collection',
|
||||||
reTry: 'Retry',
|
reTry: 'Try Again',
|
||||||
all: 'All',
|
all: 'All',
|
||||||
|
tagline: 'Modern Minimalist Tasting Tool.',
|
||||||
|
bottleCount: 'Bottles',
|
||||||
|
imprint: 'Imprint',
|
||||||
|
privacy: 'Privacy',
|
||||||
|
settings: 'Settings',
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
searchPlaceholder: 'Search by name or distillery...',
|
searchPlaceholder: 'Search by name or distillery...',
|
||||||
@@ -165,6 +193,84 @@ export const en: TranslationKeys = {
|
|||||||
noSessions: 'No sessions yet.',
|
noSessions: 'No sessions yet.',
|
||||||
expiryWarning: 'This session will expire soon.',
|
expiryWarning: 'This session will expire soon.',
|
||||||
},
|
},
|
||||||
|
nav: {
|
||||||
|
home: 'Home',
|
||||||
|
shelf: 'Shelf',
|
||||||
|
activity: 'Activity',
|
||||||
|
search: 'Search',
|
||||||
|
profile: 'Profile',
|
||||||
|
},
|
||||||
|
hub: {
|
||||||
|
title: 'Activity Hub',
|
||||||
|
subtitle: 'Live Events & Splits',
|
||||||
|
tabs: {
|
||||||
|
tastings: 'Tastings',
|
||||||
|
splits: 'Splits',
|
||||||
|
},
|
||||||
|
sections: {
|
||||||
|
startSession: 'Start New Session',
|
||||||
|
startSplit: 'Start New Split',
|
||||||
|
activeNow: 'Active Right Now',
|
||||||
|
yourSessions: 'Your Sessions',
|
||||||
|
yourSplits: 'Your Splits',
|
||||||
|
participating: 'Participating',
|
||||||
|
},
|
||||||
|
placeholders: {
|
||||||
|
sessionName: 'Session Name (e.g. Islay Night)',
|
||||||
|
noSessions: 'No sessions yet',
|
||||||
|
noSplits: 'No splits created',
|
||||||
|
openSplitCreator: 'Open Split Creator',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tutorial: {
|
||||||
|
skip: 'Skip',
|
||||||
|
next: 'Next',
|
||||||
|
finish: 'Let\'s go!',
|
||||||
|
steps: {
|
||||||
|
welcome: {
|
||||||
|
title: 'Welcome to DramLog!',
|
||||||
|
desc: 'Your personal whisky diary. Scan, rate and discover new drams.',
|
||||||
|
},
|
||||||
|
scan: {
|
||||||
|
title: 'Scan your bottles',
|
||||||
|
desc: 'Take a photo of a bottle label – AI automatically recognizes all details.',
|
||||||
|
},
|
||||||
|
taste: {
|
||||||
|
title: 'Rate your drams',
|
||||||
|
desc: 'Add tasting notes and keep track of your favorite whiskies.',
|
||||||
|
},
|
||||||
|
activity: {
|
||||||
|
title: 'Activity Hub',
|
||||||
|
desc: 'Organize tasting sessions with friends or join exclusive bottle splits.',
|
||||||
|
},
|
||||||
|
ready: {
|
||||||
|
title: 'Ready to start!',
|
||||||
|
desc: 'Scan your first bottle now using the orange button below.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
title: 'Settings',
|
||||||
|
language: 'Language',
|
||||||
|
cookieSettings: 'Cookie Settings',
|
||||||
|
cookieDesc: 'This app uses only technically necessary cookies for authentication and functional cookies for UI preferences.',
|
||||||
|
cookieNecessary: 'Necessary: Supabase Auth Cookies',
|
||||||
|
cookieFunctional: 'Functional: Language, UI Status',
|
||||||
|
privacy: 'Privacy',
|
||||||
|
privacyDesc: 'Your data is securely stored on EU servers.',
|
||||||
|
privacyLink: 'Read Privacy Policy',
|
||||||
|
memberSince: 'Member since',
|
||||||
|
password: {
|
||||||
|
title: 'Change Password',
|
||||||
|
newPassword: 'New Password',
|
||||||
|
confirmPassword: 'Confirm Password',
|
||||||
|
match: 'Passwords match',
|
||||||
|
mismatch: 'Passwords do not match',
|
||||||
|
tooShort: 'Password must be at least 6 characters long',
|
||||||
|
success: 'Password successfully changed!',
|
||||||
|
change: 'Change Password',
|
||||||
|
},
|
||||||
|
},
|
||||||
aroma: {
|
aroma: {
|
||||||
'Apfel': 'Apple',
|
'Apfel': 'Apple',
|
||||||
'Grüner Apfel': 'Green Apple',
|
'Grüner Apfel': 'Green Apple',
|
||||||
|
|||||||
@@ -1,4 +1,27 @@
|
|||||||
export type TranslationKeys = {
|
export type TranslationKeys = {
|
||||||
|
splits: {
|
||||||
|
joinTitle: string;
|
||||||
|
amount: string;
|
||||||
|
shipping: string;
|
||||||
|
whisky: string;
|
||||||
|
glass: string;
|
||||||
|
total: string;
|
||||||
|
requestSent: string;
|
||||||
|
requestSentDesc: string;
|
||||||
|
loginToParticipate: string;
|
||||||
|
loginToParticipateDesc: string;
|
||||||
|
publicExplore: string;
|
||||||
|
waitlist: string;
|
||||||
|
sendRequest: string;
|
||||||
|
youAreParticipating: string;
|
||||||
|
byHost: string;
|
||||||
|
shareLink: string;
|
||||||
|
backToStart: string;
|
||||||
|
noSplitsFound: string;
|
||||||
|
falscheTeilung: string;
|
||||||
|
clFlasche: string;
|
||||||
|
jahre: string;
|
||||||
|
};
|
||||||
common: {
|
common: {
|
||||||
save: string;
|
save: string;
|
||||||
cancel: string;
|
cancel: string;
|
||||||
@@ -38,6 +61,11 @@ export type TranslationKeys = {
|
|||||||
collection: string;
|
collection: string;
|
||||||
reTry: string;
|
reTry: string;
|
||||||
all: string;
|
all: string;
|
||||||
|
tagline: string;
|
||||||
|
bottleCount: string;
|
||||||
|
imprint: string;
|
||||||
|
privacy: string;
|
||||||
|
settings: string;
|
||||||
};
|
};
|
||||||
grid: {
|
grid: {
|
||||||
searchPlaceholder: string;
|
searchPlaceholder: string;
|
||||||
@@ -163,5 +191,68 @@ export type TranslationKeys = {
|
|||||||
noSessions: string;
|
noSessions: string;
|
||||||
expiryWarning: string;
|
expiryWarning: string;
|
||||||
};
|
};
|
||||||
|
nav: {
|
||||||
|
home: string;
|
||||||
|
shelf: string;
|
||||||
|
activity: string;
|
||||||
|
search: string;
|
||||||
|
profile: string;
|
||||||
|
};
|
||||||
|
hub: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
tabs: {
|
||||||
|
tastings: string;
|
||||||
|
splits: string;
|
||||||
|
};
|
||||||
|
sections: {
|
||||||
|
startSession: string;
|
||||||
|
startSplit: string;
|
||||||
|
activeNow: string;
|
||||||
|
yourSessions: string;
|
||||||
|
yourSplits: string;
|
||||||
|
participating: string;
|
||||||
|
};
|
||||||
|
placeholders: {
|
||||||
|
sessionName: string;
|
||||||
|
noSessions: string;
|
||||||
|
noSplits: string;
|
||||||
|
openSplitCreator: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
tutorial: {
|
||||||
|
skip: string;
|
||||||
|
next: string;
|
||||||
|
finish: string;
|
||||||
|
steps: {
|
||||||
|
welcome: { title: string; desc: string };
|
||||||
|
scan: { title: string; desc: string };
|
||||||
|
taste: { title: string; desc: string };
|
||||||
|
activity: { title: string; desc: string };
|
||||||
|
ready: { title: string; desc: string };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
settings: {
|
||||||
|
title: string;
|
||||||
|
language: string;
|
||||||
|
cookieSettings: string;
|
||||||
|
cookieDesc: string;
|
||||||
|
cookieNecessary: string;
|
||||||
|
cookieFunctional: string;
|
||||||
|
privacy: string;
|
||||||
|
privacyDesc: string;
|
||||||
|
privacyLink: string;
|
||||||
|
memberSince: string;
|
||||||
|
password: {
|
||||||
|
title: string;
|
||||||
|
newPassword: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
match: string;
|
||||||
|
mismatch: string;
|
||||||
|
tooShort: string;
|
||||||
|
success: string;
|
||||||
|
change: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
aroma: Record<string, string>;
|
aroma: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export async function saveTasting(rawData: TastingNoteData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: tasting, error } = await supabase
|
const { data: tasting, error: insertError } = await supabase
|
||||||
.from('tastings')
|
.from('tastings')
|
||||||
.insert({
|
.insert({
|
||||||
bottle_id: data.bottle_id,
|
bottle_id: data.bottle_id,
|
||||||
@@ -39,7 +39,29 @@ export async function saveTasting(rawData: TastingNoteData) {
|
|||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (error) throw error;
|
if (insertError) {
|
||||||
|
console.error('[saveTasting] Insert error:', {
|
||||||
|
code: insertError.code,
|
||||||
|
message: insertError.message,
|
||||||
|
details: insertError.details,
|
||||||
|
hint: insertError.hint,
|
||||||
|
data: {
|
||||||
|
bottle_id: data.bottle_id,
|
||||||
|
user_id: user.id,
|
||||||
|
session_id: data.session_id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for RLS violation (42501)
|
||||||
|
if ((insertError as any).code === '42501') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Keine Berechtigung zum Speichern (RLS). Prüfe ob du Besitzer der Flasche bist oder in einer aktiven Session teilnimmst.',
|
||||||
|
code: 'RLS_VIOLATION'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw insertError;
|
||||||
|
}
|
||||||
|
|
||||||
// Add buddy tags if any
|
// Add buddy tags if any
|
||||||
if (data.buddy_ids && data.buddy_ids.length > 0) {
|
if (data.buddy_ids && data.buddy_ids.length > 0) {
|
||||||
@@ -78,11 +100,11 @@ export async function saveTasting(rawData: TastingNoteData) {
|
|||||||
revalidatePath(`/bottles/${data.bottle_id}`);
|
revalidatePath(`/bottles/${data.bottle_id}`);
|
||||||
|
|
||||||
return { success: true, data: tasting };
|
return { success: true, data: tasting };
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error('Save Tasting Error:', error);
|
console.error('Save Tasting Error:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Speichern der Tasting Note',
|
error: error instanceof Error ? error.message : (error?.message || 'Fehler beim Speichern.'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,6 +206,11 @@ export async function getSplitBySlug(slug: string): Promise<{
|
|||||||
const remaining = available - taken - reserved;
|
const remaining = available - taken - reserved;
|
||||||
const bottle = split.bottles as any;
|
const bottle = split.bottles as any;
|
||||||
|
|
||||||
|
if (!bottle) {
|
||||||
|
console.error(`Split ${slug} has no associated bottle data.`);
|
||||||
|
return { success: false, error: 'Flaschendaten für diesen Split fehlen.' };
|
||||||
|
}
|
||||||
|
|
||||||
// Convert sample sizes from DB format
|
// Convert sample sizes from DB format
|
||||||
const sampleSizes = ((split.sample_sizes as any[]) || []).map(s => ({
|
const sampleSizes = ((split.sample_sizes as any[]) || []).map(s => ({
|
||||||
cl: s.cl,
|
cl: s.cl,
|
||||||
@@ -228,8 +233,8 @@ export async function getSplitBySlug(slug: string): Promise<{
|
|||||||
createdAt: split.created_at,
|
createdAt: split.created_at,
|
||||||
bottle: {
|
bottle: {
|
||||||
id: bottle.id,
|
id: bottle.id,
|
||||||
name: bottle.name,
|
name: bottle.name || 'Unbekannte Flasche',
|
||||||
distillery: bottle.distillery,
|
distillery: bottle.distillery || 'Unbekannte Destillerie',
|
||||||
imageUrl: bottle.image_url,
|
imageUrl: bottle.image_url,
|
||||||
abv: bottle.abv,
|
abv: bottle.abv,
|
||||||
age: bottle.age,
|
age: bottle.age,
|
||||||
@@ -500,8 +505,84 @@ export async function getHostSplits(): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate forum export text
|
* Get all splits the current user is participating in
|
||||||
*/
|
*/
|
||||||
|
export async function getParticipatingSplits(): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
splits?: Array<{
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
bottleName: string;
|
||||||
|
bottleImage?: string;
|
||||||
|
totalVolume: number;
|
||||||
|
hostShare: number;
|
||||||
|
participantCount: number;
|
||||||
|
amountCl: number;
|
||||||
|
status: string;
|
||||||
|
isActive: boolean;
|
||||||
|
hostName?: string;
|
||||||
|
}>;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
const supabase = await createClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, error: 'Nicht autorisiert' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: participations, error } = await supabase
|
||||||
|
.from('split_participants')
|
||||||
|
.select(`
|
||||||
|
amount_cl,
|
||||||
|
status,
|
||||||
|
bottle_splits!inner (
|
||||||
|
id,
|
||||||
|
public_slug,
|
||||||
|
total_volume,
|
||||||
|
host_share,
|
||||||
|
is_active,
|
||||||
|
host_id,
|
||||||
|
bottles (name, image_url),
|
||||||
|
profiles:host_id (username)
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('getParticipatingSplits error:', error);
|
||||||
|
return { success: false, error: 'Fehler beim Laden' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
splits: (participations || []).map(p => {
|
||||||
|
const split = p.bottle_splits as any;
|
||||||
|
const bottle = split.bottles as any;
|
||||||
|
const hostProfile = split.profiles as any;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: split.id,
|
||||||
|
slug: split.public_slug,
|
||||||
|
bottleName: bottle?.name || 'Unbekannt',
|
||||||
|
bottleImage: bottle?.image_url,
|
||||||
|
totalVolume: split.total_volume,
|
||||||
|
hostShare: split.host_share,
|
||||||
|
participantCount: 0, // We could count this but might be overkill for list view
|
||||||
|
amountCl: p.amount_cl,
|
||||||
|
status: p.status,
|
||||||
|
isActive: split.is_active,
|
||||||
|
hostName: hostProfile?.username || 'Host',
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getParticipatingSplits unexpected error:', error);
|
||||||
|
return { success: false, error: 'Unerwarteter Fehler' };
|
||||||
|
}
|
||||||
|
}
|
||||||
export async function generateForumExport(splitId: string): Promise<{
|
export async function generateForumExport(splitId: string): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
text?: string;
|
text?: string;
|
||||||
@@ -603,3 +684,51 @@ export async function closeSplit(splitId: string): Promise<{
|
|||||||
return { success: false, error: 'Unerwarteter Fehler' };
|
return { success: false, error: 'Unerwarteter Fehler' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Get all active splits for public discovery
|
||||||
|
*/
|
||||||
|
export async function getActiveSplits() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data: splits, error } = await supabase
|
||||||
|
.from('bottle_splits')
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
public_slug,
|
||||||
|
total_volume,
|
||||||
|
host_share,
|
||||||
|
is_active,
|
||||||
|
bottles (name, image_url, distillery),
|
||||||
|
profiles:host_id (username)
|
||||||
|
`)
|
||||||
|
.eq('is_active', true)
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('getActiveSplits error:', error);
|
||||||
|
return { success: false, error: 'Fehler beim Laden' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
splits: (splits || []).map(s => {
|
||||||
|
const bottle = s.bottles as any;
|
||||||
|
const hostProfile = s.profiles as any;
|
||||||
|
return {
|
||||||
|
id: s.id,
|
||||||
|
slug: s.public_slug,
|
||||||
|
bottleName: bottle?.name || 'Unbekannt',
|
||||||
|
bottleImage: bottle?.image_url,
|
||||||
|
distillery: bottle?.distillery,
|
||||||
|
totalVolume: s.total_volume,
|
||||||
|
hostShare: s.host_share,
|
||||||
|
hostName: hostProfile?.username || 'Host',
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getActiveSplits unexpected error:', error);
|
||||||
|
return { success: false, error: 'Unerwarteter Fehler' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
72
user_subscription_fix.sql
Normal 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 $$;
|
||||||
Reference in New Issue
Block a user