diff --git a/next.config.mjs b/next.config.mjs index 8c26852..a00ea0a 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -8,6 +8,8 @@ const nextConfig = { // React Compiler for automatic memoization (React 19+) reactCompiler: true, experimental: { + // Note: cacheComponents (PPR) disabled - requires Suspense boundaries for all auth contexts + // Can be enabled later after refactoring to RSC-first architecture serverActions: { bodySizeLimit: '10mb', }, diff --git a/src/app/admin/banners/page.tsx b/src/app/admin/banners/page.tsx index e1176e8..f44c7f1 100644 --- a/src/app/admin/banners/page.tsx +++ b/src/app/admin/banners/page.tsx @@ -1,4 +1,3 @@ -export const dynamic = 'force-dynamic'; import { createClient } from '@/lib/supabase/server'; import { redirect } from 'next/navigation'; diff --git a/src/app/admin/bottles/page.tsx b/src/app/admin/bottles/page.tsx index 9409763..58b1561 100644 --- a/src/app/admin/bottles/page.tsx +++ b/src/app/admin/bottles/page.tsx @@ -1,4 +1,3 @@ -export const dynamic = 'force-dynamic'; import { createClient } from '@/lib/supabase/server'; import { redirect } from 'next/navigation'; diff --git a/src/app/admin/ocr-logs/page.tsx b/src/app/admin/ocr-logs/page.tsx index 93b7ce9..62bddea 100644 --- a/src/app/admin/ocr-logs/page.tsx +++ b/src/app/admin/ocr-logs/page.tsx @@ -1,4 +1,3 @@ -export const dynamic = 'force-dynamic'; import { createClient } from '@/lib/supabase/server'; import { redirect } from 'next/navigation'; import { checkIsAdmin } from '@/services/track-api-usage'; diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index d718d75..6acef87 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,4 +1,3 @@ -export const dynamic = 'force-dynamic'; import { createClient } from '@/lib/supabase/server'; import { redirect } from 'next/navigation'; import { checkIsAdmin, getGlobalApiStats } from '@/services/track-api-usage'; diff --git a/src/app/admin/plans/page.tsx b/src/app/admin/plans/page.tsx index a083905..ef1c82d 100644 --- a/src/app/admin/plans/page.tsx +++ b/src/app/admin/plans/page.tsx @@ -1,4 +1,3 @@ -export const dynamic = 'force-dynamic'; import { createClient } from '@/lib/supabase/server'; import { redirect } from 'next/navigation'; import { checkIsAdmin } from '@/services/track-api-usage'; diff --git a/src/app/admin/sessions/page.tsx b/src/app/admin/sessions/page.tsx index 53f7d74..a040beb 100644 --- a/src/app/admin/sessions/page.tsx +++ b/src/app/admin/sessions/page.tsx @@ -1,4 +1,3 @@ -export const dynamic = 'force-dynamic'; import { createClient } from '@/lib/supabase/server'; import { redirect } from 'next/navigation'; diff --git a/src/app/admin/splits/page.tsx b/src/app/admin/splits/page.tsx index 620292c..088f3f4 100644 --- a/src/app/admin/splits/page.tsx +++ b/src/app/admin/splits/page.tsx @@ -1,4 +1,3 @@ -export const dynamic = 'force-dynamic'; import { createClient } from '@/lib/supabase/server'; import { redirect } from 'next/navigation'; diff --git a/src/app/admin/tastings/page.tsx b/src/app/admin/tastings/page.tsx index 00b4f87..72f2b1d 100644 --- a/src/app/admin/tastings/page.tsx +++ b/src/app/admin/tastings/page.tsx @@ -1,4 +1,3 @@ -export const dynamic = 'force-dynamic'; import { createClient } from '@/lib/supabase/server'; import { redirect } from 'next/navigation'; diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index 0def791..ac80bca 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -1,4 +1,3 @@ -export const dynamic = 'force-dynamic'; import { createClient } from '@/lib/supabase/server'; import { redirect } from 'next/navigation'; import { checkIsAdmin } from '@/services/track-api-usage'; diff --git a/src/app/api/debug-admin/route.ts b/src/app/api/debug-admin/route.ts index b8c628e..510db85 100644 --- a/src/app/api/debug-admin/route.ts +++ b/src/app/api/debug-admin/route.ts @@ -1,4 +1,3 @@ -export const dynamic = 'force-dynamic'; import { createClient } from '@/lib/supabase/server'; import { NextResponse } from 'next/server'; diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 361e95d..d15e466 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -1,4 +1,3 @@ -export const dynamic = 'force-dynamic'; import sharp from 'sharp'; import { createClient } from '@/lib/supabase/server'; import { NextResponse } from 'next/server'; diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts index cd96cbe..82f3b09 100644 --- a/src/app/auth/callback/route.ts +++ b/src/app/auth/callback/route.ts @@ -1,4 +1,3 @@ -export const dynamic = 'force-dynamic'; import { createClient } from '@/lib/supabase/server'; import { NextResponse } from 'next/server'; diff --git a/src/app/manifest.ts b/src/app/manifest.ts index 7e5c53a..6d2bbbc 100644 --- a/src/app/manifest.ts +++ b/src/app/manifest.ts @@ -2,8 +2,8 @@ import { MetadataRoute } from 'next' export default function manifest(): MetadataRoute.Manifest { return { - name: 'Whisky Vault', - short_name: 'WhiskyVault', + name: 'Dramlog.eu', + short_name: 'Dramlog', description: 'Dein persönlicher Whisky-Begleiter zum Scannen und Verkosten.', start_url: '/', display: 'standalone', diff --git a/src/app/stats/page.tsx b/src/app/stats/page.tsx index 44807ce..bc5daa8 100644 --- a/src/app/stats/page.tsx +++ b/src/app/stats/page.tsx @@ -6,6 +6,7 @@ import AnalyticsDashboard from '@/components/AnalyticsDashboard'; import { useI18n } from '@/i18n/I18nContext'; import { useEffect, useState } from 'react'; import { createClient } from '@/lib/supabase/client'; +import { ChartSkeleton, StatsCardSkeleton } from '@/components/Skeletons'; export default function StatsPage() { const router = useRouter(); @@ -59,8 +60,20 @@ export default function StatsPage() { {/* Dashboard */} {isLoading ? ( -
-
+
+ {/* KPI Cards Skeleton */} +
+ + + + +
+ {/* Charts Skeleton */} +
+ + +
+
) : ( diff --git a/src/components/Skeletons.tsx b/src/components/Skeletons.tsx new file mode 100644 index 0000000..eb02ca4 --- /dev/null +++ b/src/components/Skeletons.tsx @@ -0,0 +1,102 @@ +'use client'; + +import React from 'react'; + +// Generic Skeleton components for Suspense fallbacks + +export function TastingListSkeleton({ count = 3 }: { count?: number }) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ); +} + +export function BottleDetailsSkeleton() { + return ( +
+ {/* Image skeleton */} +
+ + {/* Title skeleton */} +
+
+
+
+ + {/* Badges skeleton */} +
+
+
+
+
+ + {/* Stats skeleton */} +
+
+
+
+
+ ); +} + +export function ChartSkeleton({ height = 200 }: { height?: number }) { + return ( +
+
Loading chart...
+
+ ); +} + +export function StatsCardSkeleton() { + return ( +
+
+
+
+ ); +} + +export function SessionTimelineSkeleton() { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+
+ ))} +
+ ); +} + +export function GridSkeleton({ count = 6 }: { count?: number }) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( +
+ ))} +
+ ); +} diff --git a/src/hooks/useOptimistic.ts b/src/hooks/useOptimistic.ts new file mode 100644 index 0000000..0694e6f --- /dev/null +++ b/src/hooks/useOptimistic.ts @@ -0,0 +1,70 @@ +'use client'; + +import { useOptimistic, useTransition } from 'react'; + +/** + * Hook for optimistic updates with automatic rollback on error. + * Uses React 19's useOptimistic + startTransition pattern. + * + * @example + * const { optimisticData, isPending, mutate } = useOptimisticMutation( + * tastings, + * async (newTasting) => await saveTasting(newTasting) + * ); + */ +export function useOptimisticMutation( + initialData: T[], + mutationFn: (input: TInput) => Promise<{ success: boolean; error?: string }> +) { + const [isPending, startTransition] = useTransition(); + + const [optimisticData, addOptimistic] = useOptimistic( + initialData, + (currentData, newItem) => [...currentData, newItem as unknown as T] + ); + + const mutate = async (input: TInput, optimisticValue: T) => { + startTransition(async () => { + // Immediately show optimistic update + addOptimistic(input); + + // Perform actual mutation + const result = await mutationFn(input); + + if (!result.success) { + console.error('[OptimisticMutation] Failed:', result.error); + // Note: React will automatically rollback on error + } + }); + }; + + return { + optimisticData, + isPending, + mutate, + }; +} + +/** + * Simple optimistic state for single values (like ratings). + */ +export function useOptimisticValue( + serverValue: T, + updateFn: (value: T) => Promise<{ success: boolean }> +) { + const [isPending, startTransition] = useTransition(); + const [optimisticValue, setOptimistic] = useOptimistic(serverValue); + + const setValue = (value: T) => { + startTransition(async () => { + setOptimistic(value); + await updateFn(value); + }); + }; + + return { + value: optimisticValue, + isPending, + setValue, + }; +}