Compare commits
6 Commits
004698b604
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 467bd88f95 | |||
| d75a30f459 | |||
| 06fa208dd8 | |||
| 883f76e488 | |||
| d8a9e9fd0a | |||
| 5c00be59f1 |
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ const nextConfig = {
|
|||||||
// React Compiler for automatic memoization (React 19+)
|
// React Compiler for automatic memoization (React 19+)
|
||||||
reactCompiler: true,
|
reactCompiler: true,
|
||||||
experimental: {
|
experimental: {
|
||||||
|
// Note: cacheComponents (PPR) disabled - requires Suspense boundaries for all auth contexts
|
||||||
|
// Can be enabled later after refactoring to RSC-first architecture
|
||||||
serverActions: {
|
serverActions: {
|
||||||
bodySizeLimit: '10mb',
|
bodySizeLimit: '10mb',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export const dynamic = 'force-dynamic';
|
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { checkIsAdmin } from '@/services/track-api-usage';
|
import { checkIsAdmin } from '@/services/track-api-usage';
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export const dynamic = 'force-dynamic';
|
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { checkIsAdmin, getGlobalApiStats } from '@/services/track-api-usage';
|
import { checkIsAdmin, getGlobalApiStats } from '@/services/track-api-usage';
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export const dynamic = 'force-dynamic';
|
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { checkIsAdmin } from '@/services/track-api-usage';
|
import { checkIsAdmin } from '@/services/track-api-usage';
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export const dynamic = 'force-dynamic';
|
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { checkIsAdmin } from '@/services/track-api-usage';
|
import { checkIsAdmin } from '@/services/track-api-usage';
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export const dynamic = 'force-dynamic';
|
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export const dynamic = 'force-dynamic';
|
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export const dynamic = 'force-dynamic';
|
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { MetadataRoute } from 'next'
|
|||||||
|
|
||||||
export default function manifest(): MetadataRoute.Manifest {
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
return {
|
return {
|
||||||
name: 'Whisky Vault',
|
name: 'Dramlog.eu',
|
||||||
short_name: 'WhiskyVault',
|
short_name: 'Dramlog',
|
||||||
description: 'Dein persönlicher Whisky-Begleiter zum Scannen und Verkosten.',
|
description: 'Dein persönlicher Whisky-Begleiter zum Scannen und Verkosten.',
|
||||||
start_url: '/',
|
start_url: '/',
|
||||||
display: 'standalone',
|
display: 'standalone',
|
||||||
|
|||||||
@@ -5,13 +5,12 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
|||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
import BottleGrid from "@/components/BottleGrid";
|
import BottleGrid from "@/components/BottleGrid";
|
||||||
import AuthForm from "@/components/AuthForm";
|
import AuthForm from "@/components/AuthForm";
|
||||||
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 { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import { useSession } from "@/context/SessionContext";
|
import { useSession } from "@/context/SessionContext";
|
||||||
import TastingHub from "@/components/TastingHub";
|
import TastingHub from "@/components/TastingHub";
|
||||||
import { Sparkles, Loader2, Search, SlidersHorizontal, Settings, CircleUser } from "lucide-react";
|
import { Sparkles, Loader2, Search, SlidersHorizontal } from "lucide-react";
|
||||||
import { BottomNavigation } from '@/components/BottomNavigation';
|
import { BottomNavigation } from '@/components/BottomNavigation';
|
||||||
import ScanAndTasteFlow from '@/components/ScanAndTasteFlow';
|
import ScanAndTasteFlow from '@/components/ScanAndTasteFlow';
|
||||||
import UserStatusBadge from '@/components/UserStatusBadge';
|
import UserStatusBadge from '@/components/UserStatusBadge';
|
||||||
@@ -19,7 +18,6 @@ import { getActiveSplits } from '@/services/split-actions';
|
|||||||
import SplitCard from '@/components/SplitCard';
|
import SplitCard from '@/components/SplitCard';
|
||||||
import HeroBanner from '@/components/HeroBanner';
|
import HeroBanner from '@/components/HeroBanner';
|
||||||
import QuickActionsGrid from '@/components/QuickActionsGrid';
|
import QuickActionsGrid from '@/components/QuickActionsGrid';
|
||||||
import DramOfTheDay from '@/components/DramOfTheDay';
|
|
||||||
import { checkIsAdmin } from '@/services/track-api-usage';
|
import { checkIsAdmin } from '@/services/track-api-usage';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
@@ -152,9 +150,6 @@ export default function Home() {
|
|||||||
<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">
|
||||||
{t('home.tagline')}
|
{t('home.tagline')}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-8">
|
|
||||||
<LanguageSwitcher />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<AuthForm />
|
<AuthForm />
|
||||||
|
|
||||||
@@ -189,36 +184,39 @@ export default function Home() {
|
|||||||
{/* Scrollable Content Area */}
|
{/* Scrollable Content Area */}
|
||||||
<div className="flex-1 overflow-y-auto pb-24">
|
<div className="flex-1 overflow-y-auto pb-24">
|
||||||
{/* 1. Header */}
|
{/* 1. Header */}
|
||||||
<header className="px-4 pt-4 pb-2">
|
<header className="px-4 pt-4 pb-2 space-y-2">
|
||||||
|
{/* Row 1: Logo + Logout */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex flex-col">
|
<h1 className="text-2xl font-bold text-zinc-50 tracking-tighter">
|
||||||
<h1 className="text-2xl font-bold text-zinc-50 tracking-tighter">
|
DRAM<span className="text-orange-600">LOG</span>
|
||||||
DRAM<span className="text-orange-600">LOG</span>
|
</h1>
|
||||||
</h1>
|
<button
|
||||||
{activeSession && (
|
onClick={handleLogout}
|
||||||
<div className="flex items-center gap-2 mt-0.5 animate-in fade-in slide-in-from-left-2 duration-700">
|
className="text-[10px] font-bold uppercase tracking-widest text-zinc-500 hover:text-white transition-colors"
|
||||||
<div className="relative flex h-2 w-2">
|
>
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-orange-600 opacity-75"></span>
|
{t('home.logout')}
|
||||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-orange-600"></span>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[9px] font-bold uppercase tracking-widest text-orange-600 flex items-center gap-1">
|
|
||||||
<Sparkles size={10} className="animate-pulse" />
|
{/* Row 2: Session info + Status */}
|
||||||
Live: {activeSession.name}
|
<div className="flex items-center justify-between">
|
||||||
</span>
|
{activeSession ? (
|
||||||
|
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-left-2 duration-700">
|
||||||
|
<div className="relative flex h-2 w-2">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-orange-600 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-orange-600"></span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<span className="text-[9px] font-bold uppercase tracking-widest text-orange-600 flex items-center gap-1">
|
||||||
</div>
|
<Sparkles size={10} className="animate-pulse" />
|
||||||
|
Live: {activeSession.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<UserStatusBadge />
|
<UserStatusBadge />
|
||||||
<OfflineIndicator />
|
<OfflineIndicator />
|
||||||
<LanguageSwitcher />
|
|
||||||
<DramOfTheDay bottles={bottles} />
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className="text-[9px] font-bold uppercase tracking-widest text-zinc-600 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
{t('home.logout')}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import AnalyticsDashboard from '@/components/AnalyticsDashboard';
|
|||||||
import { useI18n } from '@/i18n/I18nContext';
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import { ChartSkeleton, StatsCardSkeleton } from '@/components/Skeletons';
|
||||||
|
|
||||||
export default function StatsPage() {
|
export default function StatsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -59,8 +60,20 @@ export default function StatsPage() {
|
|||||||
|
|
||||||
{/* Dashboard */}
|
{/* Dashboard */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="space-y-6">
|
||||||
<div className="w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full animate-spin"></div>
|
{/* KPI Cards Skeleton */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<StatsCardSkeleton />
|
||||||
|
<StatsCardSkeleton />
|
||||||
|
<StatsCardSkeleton />
|
||||||
|
<StatsCardSkeleton />
|
||||||
|
</div>
|
||||||
|
{/* Charts Skeleton */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<ChartSkeleton height={300} />
|
||||||
|
<ChartSkeleton height={300} />
|
||||||
|
</div>
|
||||||
|
<ChartSkeleton height={400} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<AnalyticsDashboard bottles={bottles} />
|
<AnalyticsDashboard bottles={bottles} />
|
||||||
|
|||||||
102
src/components/Skeletons.tsx
Normal file
102
src/components/Skeletons.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
// Generic Skeleton components for Suspense fallbacks
|
||||||
|
|
||||||
|
export function TastingListSkeleton({ count = 3 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4 animate-pulse"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-12 h-12 bg-zinc-800 rounded-xl shrink-0" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="h-4 bg-zinc-800 rounded w-3/4" />
|
||||||
|
<div className="h-3 bg-zinc-800 rounded w-1/2" />
|
||||||
|
<div className="h-3 bg-zinc-800 rounded w-1/4" />
|
||||||
|
</div>
|
||||||
|
<div className="w-10 h-10 bg-zinc-800 rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BottleDetailsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-pulse">
|
||||||
|
{/* Image skeleton */}
|
||||||
|
<div className="aspect-square bg-zinc-900 rounded-3xl" />
|
||||||
|
|
||||||
|
{/* Title skeleton */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-8 bg-zinc-800 rounded w-3/4" />
|
||||||
|
<div className="h-5 bg-zinc-800 rounded w-1/2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Badges skeleton */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="h-8 w-20 bg-zinc-800 rounded-xl" />
|
||||||
|
<div className="h-8 w-16 bg-zinc-800 rounded-xl" />
|
||||||
|
<div className="h-8 w-24 bg-zinc-800 rounded-xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats skeleton */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="h-20 bg-zinc-900 rounded-2xl" />
|
||||||
|
<div className="h-20 bg-zinc-900 rounded-2xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChartSkeleton({ height = 200 }: { height?: number }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bg-zinc-900 border border-zinc-800 rounded-2xl animate-pulse flex items-center justify-center"
|
||||||
|
style={{ height }}
|
||||||
|
>
|
||||||
|
<div className="text-zinc-700 text-sm">Loading chart...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatsCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4 animate-pulse">
|
||||||
|
<div className="h-3 bg-zinc-800 rounded w-1/2 mb-2" />
|
||||||
|
<div className="h-8 bg-zinc-800 rounded w-1/3" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionTimelineSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 animate-pulse">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-4">
|
||||||
|
<div className="w-3 h-3 bg-zinc-700 rounded-full" />
|
||||||
|
<div className="flex-1 h-16 bg-zinc-900 border border-zinc-800 rounded-xl" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GridSkeleton({ count = 6 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="aspect-[3/4] bg-zinc-900 border border-zinc-800 rounded-2xl animate-pulse"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
src/hooks/useOptimistic.ts
Normal file
70
src/hooks/useOptimistic.ts
Normal file
@@ -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<T, TInput>(
|
||||||
|
initialData: T[],
|
||||||
|
mutationFn: (input: TInput) => Promise<{ success: boolean; error?: string }>
|
||||||
|
) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const [optimisticData, addOptimistic] = useOptimistic<T[], TInput>(
|
||||||
|
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<T>(
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -18,19 +18,28 @@ const translations: Record<Locale, TranslationKeys> = { de, en };
|
|||||||
const I18nContext = createContext<I18nContextType | undefined>(undefined);
|
const I18nContext = createContext<I18nContextType | undefined>(undefined);
|
||||||
|
|
||||||
export const I18nProvider = ({ children }: { children: ReactNode }) => {
|
export const I18nProvider = ({ children }: { children: ReactNode }) => {
|
||||||
const [locale, setLocaleState] = useState<Locale>('de');
|
const [locale, setLocaleState] = useState<Locale>('en');
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Only run on client side
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
// Check for saved preference first
|
||||||
const savedLocale = localStorage.getItem('locale') as Locale;
|
const savedLocale = localStorage.getItem('locale') as Locale;
|
||||||
if (savedLocale && (savedLocale === 'de' || savedLocale === 'en')) {
|
if (savedLocale && (savedLocale === 'de' || savedLocale === 'en')) {
|
||||||
setLocaleState(savedLocale);
|
setLocaleState(savedLocale);
|
||||||
} else {
|
} else {
|
||||||
// Try to detect browser language
|
// Auto-detect from browser: default to English, switch to German if detected
|
||||||
const browserLang = navigator.language.split('-')[0];
|
const browserLang = navigator.language?.toLowerCase() || 'en';
|
||||||
if (browserLang === 'en') {
|
if (browserLang.startsWith('de')) {
|
||||||
setLocaleState('en');
|
setLocaleState('de');
|
||||||
|
localStorage.setItem('locale', 'de');
|
||||||
|
} else {
|
||||||
|
localStorage.setItem('locale', 'en');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
setIsInitialized(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setLocale = (newLocale: Locale) => {
|
const setLocale = (newLocale: Locale) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user