diff --git a/performance_indexing.sql b/performance_indexing.sql new file mode 100644 index 0000000..01c52a7 --- /dev/null +++ b/performance_indexing.sql @@ -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; diff --git a/rls_buddy_access.sql b/rls_buddy_access.sql new file mode 100644 index 0000000..ed9678b --- /dev/null +++ b/rls_buddy_access.sql @@ -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 +-- ============================================ diff --git a/rls_policy_performance_fixes.sql b/rls_policy_performance_fixes.sql new file mode 100644 index 0000000..cd52c67 --- /dev/null +++ b/rls_policy_performance_fixes.sql @@ -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; diff --git a/security_search_path_fix.sql b/security_search_path_fix.sql new file mode 100644 index 0000000..87dddd1 --- /dev/null +++ b/security_search_path_fix.sql @@ -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 $$; diff --git a/src/components/BottleDetails.tsx b/src/components/BottleDetails.tsx index 86e1c40..8672cd2 100644 --- a/src/components/BottleDetails.tsx +++ b/src/components/BottleDetails.tsx @@ -82,186 +82,202 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet if (!bottle) return null; // Should not happen due to check above return ( -
+
{/* Back Button */} - - - Zurück zur Sammlung - +
+ + + Zurück + +
{isOffline && (
-

Offline-Modus: Daten aus dem Cache

+

Offline-Modus

)} - {/* Hero Section */} -
-
+ {/* Header & Hero Section */} +
+ {/* 1. Header (Title at top) */} +
+

+ {bottle.name} +

+

+ {bottle.distillery} +

+
+ + {/* 2. Image (Below title) */} +
+
{bottle.name}
+ {/* 3. Metadata Consolidation (Info Row) */} +
+
+ + {bottle.category || 'Whisky'} +
+
+ + {bottle.abv}% +
+ {bottle.age && ( +
+ + {bottle.age}J. +
+ )} + {bottle.distilled_at && ( +
+ + Dist. {bottle.distilled_at} +
+ )} + {bottle.bottled_at && ( +
+ + Bott. {bottle.bottled_at} +
+ )} + {bottle.batch_info && ( +
+ + {bottle.batch_info} +
+ )} + {bottle.whiskybase_id && ( + + + WB {bottle.whiskybase_id} + + )} +
+
+ + {/* 4. Inventory Section (Cohesive Container) */} +
+
+

My Bottle

+ +
+
- -
-
-
-
- Kategorie -
-
{bottle.category || '-'}
-
-
-
-
- Alkoholgehalt -
-
{bottle.abv}% Vol.
-
-
-
- Alter -
-
{bottle.age ? `${bottle.age} J.` : '-'}
-
- - {bottle.distilled_at && ( -
-
- Destilliert -
-
{bottle.distilled_at}
-
- )} - - {bottle.bottled_at && ( -
-
- Abgefüllt -
-
{bottle.bottled_at}
-
- )} - - {/* Quick Collection Card */} -
-
-

- Sammlungs-Status -

- {isUpdating && } -
- -
- {/* Price */} -
- -
- 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" - /> -
-
-
- - {/* Status */} -
- - -
+
+
+ +
+ setPrice(e.target.value)} + onBlur={() => handleQuickUpdate(price)} + placeholder="0.00" + className="w-full bg-zinc-950 border border-zinc-800 rounded-2xl pl-4 pr-8 py-3 text-sm font-bold text-zinc-100 focus:outline-none focus:border-orange-600" + /> +
- {bottle.batch_info && ( -
-
- Batch / Code -
-
{bottle.batch_info}
-
- )} - -
-
- Letzter Dram -
-
+
+ +
+ {tastings && tastings.length > 0 - ? new Date(tastings[0].created_at).toLocaleDateString('de-DE') - : 'Noch nie'} + ? new Date(tastings[0].created_at).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US', { day: '2-digit', month: '2-digit' }) + : '-'}
- -
- {isOffline ? ( -
- - Bearbeiten & Löschen nur online möglich -
- ) : ( - <> - - - - Split starten - - - - )} -
+ {/* 5. Editing Form (Accordion) */} +
+ + + + {isFormVisible && ( + +
+ setIsFormVisible(false)} + /> +
+
+ )} +
+ + {!isOffline && ( +
+ + + Split starten + +
+ +
+
+ )} +
+
{/* Tasting Notes Section */} diff --git a/src/components/EditBottleForm.tsx b/src/components/EditBottleForm.tsx index 8390707..6793ff4 100644 --- a/src/components/EditBottleForm.tsx +++ b/src/components/EditBottleForm.tsx @@ -102,188 +102,189 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro } }; - if (!isEditing) { - return ( -
- - {bottle.purchase_price && ( -
- - {t('bottle.priceLabel')}: {parseFloat(bottle.purchase_price.toString()).toLocaleString(locale === 'de' ? 'de-DE' : 'en-US', { style: 'currency', currency: 'EUR' })} -
- )} -
- ); - } - return ( -
-
-

- {t('bottle.editTitle')} -

- -
- +
-
- + {/* Full Width Inputs */} +
+ 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-4 py-3 bg-zinc-950 border border-zinc-800 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200 text-sm font-bold" />
-
- + +
+ 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-4 py-3 bg-zinc-950 border border-zinc-800 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200 text-sm font-bold" />
-
- + + {/* Compact Row: Category */} +
+ 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-4 py-3 bg-zinc-950 border border-zinc-800 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200 text-sm font-bold" />
-
-
- + + {/* Row A: ABV + Age */} +
+
+ 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-4 py-3 bg-zinc-950 border border-zinc-800 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200 text-sm font-bold" />
-
- +
+ 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-4 py-3 bg-zinc-950 border border-zinc-800 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200 text-sm font-bold" />
-
-