This commit is contained in:
HugeFrog24
2026-04-26 17:40:52 +02:00
parent 99cc7218a7
commit 02bb07e780
73 changed files with 8846 additions and 6774 deletions
+93
View File
@@ -0,0 +1,93 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useFeature } from '../../../providers/FeatureProvider';
export default function CheckoutCancelPage() {
const router = useRouter();
const t = useTranslations('ui');
const paymentsEnabled = useFeature('paymentsEnabled');
const [countdown, setCountdown] = useState(5);
// Redirect home immediately if payments are disabled
useEffect(() => {
if (!paymentsEnabled) {
router.replace('/');
}
}, [paymentsEnabled, router]);
useEffect(() => {
if (!paymentsEnabled) return;
// Countdown timer to redirect to home
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(timer);
router.push('/');
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [router, paymentsEnabled]);
if (!paymentsEnabled) {
return null;
}
const handleGoBack = () => {
router.push('/');
};
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-lg shadow-xl p-8 text-center">
{/* Cancel Icon */}
<div className="w-16 h-16 mx-auto mb-6 bg-gray-100 dark:bg-gray-700 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
{/* Cancel Message */}
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
{t('checkout.cancel.title')}
</h1>
<p className="text-gray-600 dark:text-gray-300 mb-6">
{t('checkout.cancel.message')}
</p>
<p className="text-gray-500 dark:text-gray-400 mb-6">
{t('checkout.cancel.tryAgain')}
</p>
{/* Action Buttons */}
<div className="space-y-3">
<button
onClick={handleGoBack}
className="w-full px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors font-medium"
>
{t('checkout.cancel.backToApp')}
</button>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('checkout.redirecting', { countdown })}
</p>
</div>
{/* Help Info */}
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
<p className="text-xs text-gray-500 dark:text-gray-400">
{t('checkout.cancel.needHelp')}
</p>
</div>
</div>
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
'use client';
import { useEffect, useState } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { appConfig } from '../../../config/app';
import { SkinId } from '../../../types';
import { useLocalizedSkinName } from '../../../hooks/useLocalizedSkinName';
import { useFeature } from '../../../providers/FeatureProvider';
export default function CheckoutSuccessPage() {
const searchParams = useSearchParams();
const router = useRouter();
const t = useTranslations('ui');
const getLocalizedSkinName = useLocalizedSkinName();
const paymentsEnabled = useFeature('paymentsEnabled');
const [countdown, setCountdown] = useState(5);
const skinId = searchParams.get('skin') as SkinId;
const skin = skinId ? appConfig.skins[skinId] : null;
const skinName = skinId ? getLocalizedSkinName(skinId) : '';
// Redirect home immediately if payments are disabled
useEffect(() => {
if (!paymentsEnabled) {
router.replace('/');
}
}, [paymentsEnabled, router]);
useEffect(() => {
if (!paymentsEnabled) return;
// Countdown timer to redirect to home
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(timer);
// Redirect to home with the purchased skin
const params = new URLSearchParams();
if (skinId && skinId !== appConfig.defaultSkin) {
params.set('skin', skinId);
}
const newUrl = `/${params.toString() ? '?' + params.toString() : ''}`;
router.push(newUrl);
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [skinId, router, paymentsEnabled]);
if (!paymentsEnabled) {
return null;
}
const handleGoToApp = () => {
const params = new URLSearchParams();
if (skinId && skinId !== appConfig.defaultSkin) {
params.set('skin', skinId);
}
const newUrl = `/${params.toString() ? '?' + params.toString() : ''}`;
router.push(newUrl);
};
return (
<div className="min-h-screen bg-gradient-to-br from-green-50 to-blue-50 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-lg shadow-xl p-8 text-center">
{/* Success Icon */}
<div className="w-16 h-16 mx-auto mb-6 bg-green-100 dark:bg-green-900 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
{/* Success Message */}
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
{t('checkout.success.title')}
</h1>
{skin && (
<div className="mb-6">
<div className="w-20 h-20 mx-auto mb-4 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
<img
src={skin.normal}
alt={skinName}
className="w-16 h-16"
/>
</div>
<p className="text-gray-600 dark:text-gray-300">
{t('checkout.success.unlockedSkin', { skinName })}
</p>
</div>
)}
<p className="text-gray-500 dark:text-gray-400 mb-6">
{t('checkout.success.thankYou')}
</p>
{/* Action Buttons */}
<div className="space-y-3">
<button
onClick={handleGoToApp}
className="w-full px-6 py-3 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors font-medium"
>
{t('checkout.success.goToApp')}
</button>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('checkout.redirecting', { countdown })}
</p>
</div>
{/* Receipt Info */}
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
<p className="text-xs text-gray-500 dark:text-gray-400">
{t('checkout.success.receiptSent')}
</p>
</div>
</div>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { locales } from '../../i18n/request';
export default async function LocaleLayout({
children,
params
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
// Ensure that the incoming `locale` is valid
if (!locales.includes(locale as (typeof locales)[number])) {
notFound();
}
// Providing all messages to the client
// side is the easiest way to get started
const messages = await getMessages();
return (
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
);
}
+239
View File
@@ -0,0 +1,239 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useIsMobile } from '../hooks/useIsMobile';
import Image from 'next/image';
import { FloatingHearts } from '../components/FloatingHearts';
import { ThemeToggle } from '../components/ThemeToggle';
import { SpeechBubble } from '../components/SpeechBubble';
import { SkinSelector } from '../components/SkinSelector';
import { shakeConfig } from '../config/shake';
import { ArrowTopRightOnSquareIcon } from '@heroicons/react/24/outline';
import { appConfig } from '../config/app';
import { useSkin } from '../hooks/useSkin';
import { LanguageToggle } from '../components/LanguageToggle';
import { useTranslations } from 'next-intl';
import { useLocalizedSkinName } from '../hooks/useLocalizedSkinName';
import { useShakeAudio } from '../hooks/useShakeAudio';
export default function Home() {
const [isShaken, setIsShaken] = useState(false);
const [shakeIntensity, setShakeIntensity] = useState(0);
const [lastUpdate, setLastUpdate] = useState(0);
const [shakeCount, setShakeCount] = useState(0);
const [motionPermission, setMotionPermission] = useState<PermissionState>('prompt');
const isMobile = useIsMobile();
const [, setIsAnimating] = useState(false);
const [, setShakeQueue] = useState<number[]>([]);
const isAnimatingRef = useRef<boolean>(false);
const animationTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
const animationStartTimeRef = useRef<number>(0);
const currentSkin = useSkin();
const getLocalizedSkinName = useLocalizedSkinName();
const t = useTranslations('ui');
const bumpAudio = useShakeAudio();
const requestMotionPermission = async () => {
if (typeof window === 'undefined') return;
if (!('DeviceMotionEvent' in window)) {
setMotionPermission('denied');
return;
}
if ('requestPermission' in DeviceMotionEvent) {
try {
// @ts-expect-error - TypeScript doesn't know about requestPermission
const permission = await DeviceMotionEvent.requestPermission();
setMotionPermission(permission);
} catch (err) {
console.error('Error requesting motion permission:', err);
setMotionPermission('denied');
}
} else {
setMotionPermission('granted');
}
};
const triggerShake = useCallback((intensity: number) => {
bumpAudio();
if (!isAnimatingRef.current) {
if (animationTimeoutRef.current) {
clearTimeout(animationTimeoutRef.current);
}
isAnimatingRef.current = true;
animationStartTimeRef.current = Date.now();
setIsAnimating(true);
setIsShaken(true);
setShakeIntensity(intensity);
setShakeCount(count => count + 1);
animationTimeoutRef.current = setTimeout(() => {
setIsShaken(false);
setShakeIntensity(0);
setIsAnimating(false);
isAnimatingRef.current = false;
setShakeQueue(prev => {
if (prev.length > 0) {
const [nextIntensity, ...rest] = prev;
setTimeout(() => {
triggerShake(nextIntensity);
}, 16);
return rest;
}
return prev;
});
}, shakeConfig.animations.shakeReset);
} else {
const timeSinceStart = Date.now() - animationStartTimeRef.current;
if (timeSinceStart > 100) {
setShakeQueue(prev => {
if (prev.length >= 1) return prev;
return [...prev, intensity];
});
}
}
}, [bumpAudio]);
useEffect(() => {
const handleKeyPress = (event: KeyboardEvent) => {
if (event.code === 'Space') {
triggerShake(shakeConfig.defaultTriggerIntensity);
}
};
const handleMotion = (event: DeviceMotionEvent) => {
const acceleration = event.accelerationIncludingGravity;
if (!acceleration) return;
const currentTime = new Date().getTime();
const timeDiff = currentTime - lastUpdate;
if (timeDiff > shakeConfig.debounceTime) {
setLastUpdate(currentTime);
const speed = Math.abs(acceleration.x || 0) +
Math.abs(acceleration.y || 0) +
Math.abs(acceleration.z || 0);
if (speed > shakeConfig.threshold) {
triggerShake(speed);
}
}
};
if (typeof window !== 'undefined') {
if (motionPermission === 'granted' && 'DeviceMotionEvent' in window) {
window.addEventListener('devicemotion', handleMotion);
}
window.addEventListener('keydown', handleKeyPress);
}
return () => {
if (typeof window !== 'undefined') {
if (motionPermission === 'granted') {
window.removeEventListener('devicemotion', handleMotion);
}
window.removeEventListener('keydown', handleKeyPress);
}
};
}, [lastUpdate, motionPermission, triggerShake]);
useEffect(() => {
requestMotionPermission();
}, []);
const handleClick = () => {
if ('vibrate' in navigator) {
navigator.vibrate(50); // Short 50ms vibration
}
triggerShake(shakeConfig.defaultTriggerIntensity);
};
useEffect(() => {
return () => {
if (animationTimeoutRef.current) {
clearTimeout(animationTimeoutRef.current);
}
};
}, []);
return (
<div className="flex h-[100dvh] flex-col items-center justify-between p-4 bg-green-50 dark:bg-slate-900 relative">
<div className="w-full flex justify-between items-center">
<div className="flex items-center gap-2">
<LanguageToggle />
<SkinSelector />
</div>
<ThemeToggle />
</div>
<div className="flex-1 flex flex-col items-center justify-center w-full">
<div className="fixed inset-0 overflow-hidden pointer-events-none">
<FloatingHearts intensity={shakeIntensity} />
</div>
<button
onClick={handleClick}
className="relative z-10"
aria-label={t('shakeCharacter', { item: getLocalizedSkinName(currentSkin) })}
>
<FloatingHearts intensity={shakeIntensity} />
<SpeechBubble
isShaken={isShaken}
triggerCount={shakeCount}
/>
<Image
src={isShaken
? appConfig.skins[currentSkin].shaken
: appConfig.skins[currentSkin].normal
}
alt={getLocalizedSkinName(currentSkin)}
width={200}
height={200}
priority
className={isShaken ? 'animate-shake' : ''}
/>
</button>
<div className="mt-8 flex flex-col items-center gap-2">
<p className="text-gray-600 dark:text-gray-400 text-center max-w-[240px]">
{motionPermission === 'prompt' ? (
<button
onClick={requestMotionPermission}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
{t('enableDeviceShake')}
</button>
) : motionPermission === 'granted' ? (
t(
isMobile ? 'shakeInstructionsMobile' : 'shakeInstructionsDesktop',
{ item: getLocalizedSkinName(currentSkin) }
)
) : (
t(
isMobile ? 'noShakeInstructionsMobile' : 'noShakeInstructionsDesktop',
{ item: getLocalizedSkinName(currentSkin) }
)
)}
</p>
</div>
</div>
<footer className="w-full text-center text-xs text-gray-400 dark:text-gray-600 mt-auto pt-4">
© {new Date().getFullYear()}{' '}
<a
href="https://github.com/HugeFrog24/shakethefrog"
target="_blank"
rel="noopener noreferrer"
className="hover:text-gray-600 dark:hover:text-gray-400 transition-colors inline-flex items-center gap-1"
>
{appConfig.name}
<ArrowTopRightOnSquareIcon className="w-3 h-3" />
</a>
</footer>
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
import { NextRequest, NextResponse } from 'next/server';
import { createCheckout } from '@lemonsqueezy/lemonsqueezy.js';
import { initializeLemonSqueezy, getLemonSqueezyConfig } from '../../config/lemonsqueezy';
import { getFeatureFlags } from '../../config/features';
import { appConfig } from '../../config/app';
export async function POST(request: NextRequest) {
try {
const { paymentsEnabled } = getFeatureFlags();
if (!paymentsEnabled) {
return NextResponse.json(
{ error: 'Payments are currently disabled' },
{ status: 503 }
);
}
// Initialize Lemon Squeezy SDK
initializeLemonSqueezy();
const { skinId, locale } = await request.json();
if (!skinId) {
return NextResponse.json(
{ error: 'Skin ID is required' },
{ status: 400 }
);
}
if (!locale) {
return NextResponse.json(
{ error: 'Locale is required' },
{ status: 400 }
);
}
// Get skin configuration
const skin = appConfig.skins[skinId as keyof typeof appConfig.skins];
if (!skin) {
return NextResponse.json(
{ error: 'Invalid skin ID' },
{ status: 400 }
);
}
if (!skin.isPremium) {
return NextResponse.json(
{ error: 'This skin is not premium' },
{ status: 400 }
);
}
if (!skin.variantId) {
return NextResponse.json(
{ error: 'Variant ID not configured for this skin' },
{ status: 500 }
);
}
// Create checkout session
const config = getLemonSqueezyConfig();
const checkout = await createCheckout(config.storeId, skin.variantId!, {
productOptions: {
name: `Premium ${skin.name} Skin`,
description: `Unlock the premium ${skin.name} skin for Shake the Frog!`,
redirectUrl: `${config.baseUrl}/${locale}/checkout/success?skin=${skinId}`,
receiptButtonText: 'Go to App',
receiptThankYouNote: 'Thank you for your purchase! Your premium skin is now available.',
},
checkoutOptions: {
embed: false,
media: false,
logo: true,
desc: true,
discount: true,
subscriptionPreview: true,
buttonColor: '#16a34a'
},
checkoutData: {
custom: {
skin_id: skinId,
},
},
testMode: process.env.NODE_ENV !== 'production',
});
if (checkout.error) {
console.error('Checkout creation error:', checkout.error);
return NextResponse.json(
{ error: 'Failed to create checkout session' },
{ status: 500 }
);
}
return NextResponse.json({
checkoutUrl: checkout.data?.data.attributes.url,
checkoutId: checkout.data?.data.id,
});
} catch (error) {
console.error('Checkout API error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
+59
View File
@@ -0,0 +1,59 @@
import { ImageResponse } from 'next/og'
import { appConfig } from '../../config/app'
export const runtime = 'edge'
export async function GET(request: Request) {
const url = new URL(request.url)
const baseUrl = `${url.protocol}//${url.host}`
return new ImageResponse(
(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: appConfig.assets.ogImage.bgColor,
fontSize: 72,
fontWeight: 600,
}}
>
<img
src={`${baseUrl}${appConfig.assets.favicon}`}
alt={appConfig.name}
width={300}
height={300}
style={{ margin: '0 0 40px' }}
/>
<div
style={{
marginBottom: 30,
color: appConfig.assets.ogImage.textColor,
}}
>
{appConfig.name}
</div>
<div
style={{
fontSize: 36,
fontWeight: 400,
color: appConfig.assets.ogImage.textColor,
textAlign: 'center',
maxWidth: '80%',
lineHeight: 1.4,
}}
>
{appConfig.description}
</div>
</div>
),
{
width: appConfig.assets.ogImage.width,
height: appConfig.assets.ogImage.height,
},
)
}
+33
View File
@@ -0,0 +1,33 @@
import { NextResponse } from 'next/server';
import { getVariant } from '@lemonsqueezy/lemonsqueezy.js';
import { initializeLemonSqueezy } from '../../config/lemonsqueezy';
import { getFeatureFlags } from '../../config/features';
import { appConfig } from '../../config/app';
export async function GET() {
const { paymentsEnabled } = getFeatureFlags();
if (!paymentsEnabled) {
return NextResponse.json({ prices: {}, enabled: false });
}
// Initialize Lemon Squeezy SDK
initializeLemonSqueezy();
const prices: Record<string, string> = {};
// Fetch prices for all premium skins
for (const [skinId, skin] of Object.entries(appConfig.skins)) {
if (skin.isPremium && skin.variantId) {
const variant = await getVariant(skin.variantId);
if (!variant.data) {
throw new Error(`No variant data found for ${skinId}`);
}
const priceInCents = variant.data.data.attributes.price;
prices[skinId] = `$${(priceInCents / 100).toFixed(2)}`;
}
}
return NextResponse.json({ prices });
}
+148
View File
@@ -0,0 +1,148 @@
import { NextRequest, NextResponse } from 'next/server';
import { createHmac } from 'crypto';
import { getLemonSqueezyConfig } from '../../../config/lemonsqueezy';
// Webhook payload interface using proper typing
interface WebhookPayload {
meta: {
event_name: string;
custom_data?: Record<string, unknown>;
};
data: {
type: string;
id: string;
attributes: Record<string, unknown>;
relationships?: Record<string, unknown>;
};
}
export async function POST(request: NextRequest) {
try {
const body = await request.text();
const signature = request.headers.get('x-signature');
if (!signature) {
return NextResponse.json(
{ error: 'Missing signature' },
{ status: 400 }
);
}
// Verify webhook signature
const config = getLemonSqueezyConfig();
const secret = config.webhookSecret;
const hmac = createHmac('sha256', secret);
hmac.update(body);
const digest = hmac.digest('hex');
if (signature !== digest) {
console.error('Invalid webhook signature');
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 401 }
);
}
// Parse webhook payload
const payload = JSON.parse(body);
const eventName = payload.meta?.event_name;
console.log('Received webhook:', eventName);
// Handle different webhook events
switch (eventName) {
case 'order_created':
await handleOrderCreated(payload);
break;
case 'subscription_created':
await handleSubscriptionCreated(payload);
break;
case 'subscription_updated':
await handleSubscriptionUpdated(payload);
break;
case 'subscription_cancelled':
await handleSubscriptionCancelled(payload);
break;
default:
console.log('Unhandled webhook event:', eventName);
}
return NextResponse.json({ received: true });
} catch (error) {
console.error('Webhook processing error:', error);
return NextResponse.json(
{ error: 'Webhook processing failed' },
{ status: 500 }
);
}
}
async function handleOrderCreated(payload: WebhookPayload) {
const order = payload.data;
const attributes = order.attributes as Record<string, unknown>;
const firstOrderItem = attributes.first_order_item as Record<string, unknown> | undefined;
const customData = firstOrderItem?.product_name;
console.log('Order created:', {
orderId: order.id,
customerEmail: attributes.user_email,
total: attributes.total_formatted,
status: attributes.status,
customData: customData,
});
// Here you could:
// - Send confirmation email
// - Update user permissions in your database
// - Log the purchase for analytics
// - Grant access to premium features
}
async function handleSubscriptionCreated(payload: WebhookPayload) {
const subscription = payload.data;
const attributes = subscription.attributes as Record<string, unknown>;
console.log('Subscription created:', {
subscriptionId: subscription.id,
customerEmail: attributes.user_email,
status: attributes.status,
productName: attributes.product_name,
});
// Handle subscription creation
// - Update user subscription status
// - Send welcome email
// - Grant premium access
}
async function handleSubscriptionUpdated(payload: WebhookPayload) {
const subscription = payload.data;
const attributes = subscription.attributes as Record<string, unknown>;
console.log('Subscription updated:', {
subscriptionId: subscription.id,
status: attributes.status,
endsAt: attributes.ends_at,
});
// Handle subscription updates
// - Update user access based on status
// - Handle plan changes
}
async function handleSubscriptionCancelled(payload: WebhookPayload) {
const subscription = payload.data;
const attributes = subscription.attributes as Record<string, unknown>;
console.log('Subscription cancelled:', {
subscriptionId: subscription.id,
customerEmail: attributes.user_email,
endsAt: attributes.ends_at,
});
// Handle subscription cancellation
// - Schedule access removal for end date
// - Send cancellation confirmation
}
+43 -28
View File
@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react';
import { HeartIcon } from '@heroicons/react/24/solid';
import { shakeConfig } from '../config/shake';
interface Heart {
id: number;
@@ -9,6 +10,7 @@ interface Heart {
speed: number;
startPosition: { x: number; y: number };
scale: number;
createdAt: number;
}
interface FloatingHeartsProps {
@@ -18,56 +20,66 @@ interface FloatingHeartsProps {
export function FloatingHearts({ intensity }: FloatingHeartsProps) {
const [hearts, setHearts] = useState<Heart[]>([]);
// Cleanup interval to remove old hearts
useEffect(() => {
const cleanupInterval = setInterval(() => {
const now = Date.now();
setHearts(prev => prev.filter(heart =>
now - heart.createdAt < shakeConfig.animations.heartFloat
));
}, shakeConfig.hearts.cleanupInterval);
return () => clearInterval(cleanupInterval);
}, []);
useEffect(() => {
if (intensity <= 0) return;
// Number of hearts based on intensity
const numHearts = Math.min(Math.floor(intensity * 2), 50);
const numHearts = Math.min(Math.floor(intensity * 2), shakeConfig.hearts.maxPerShake);
// Create waves of hearts
const waves = 4; // Number of waves
const heartsPerWave = Math.ceil(numHearts / waves);
const waveDelay = 200; // Delay between waves in ms
const heartsPerWave = Math.ceil(numHearts / shakeConfig.hearts.waves);
const timers: NodeJS.Timeout[] = [];
// Generate hearts in waves
for (let wave = 0; wave < waves; wave++) {
for (let wave = 0; wave < shakeConfig.hearts.waves; wave++) {
const timer = setTimeout(() => {
const newHearts = Array.from({ length: heartsPerWave }, (_, i) => {
const totalIndex = wave * heartsPerWave + i;
return {
id: Date.now() + totalIndex,
// Distribute angles evenly within each wave
angle: Math.random() * 360, // Random angle for full radial distribution
speed: 0.8 + Math.random() * 0.4,
startPosition: {
x: Math.random() * 40 - 20,
y: Math.random() * 40 - 20,
},
scale: 0.8 + Math.random() * 0.4,
};
});
const now = Date.now();
const newHearts = Array.from({ length: heartsPerWave }, (_, i) => ({
id: now + i,
angle: Math.random() * 360,
speed: shakeConfig.hearts.minSpeed +
Math.random() * (shakeConfig.hearts.maxSpeed - shakeConfig.hearts.minSpeed),
startPosition: {
x: Math.random() * (shakeConfig.hearts.spreadX * 2) - shakeConfig.hearts.spreadX,
y: Math.random() * (shakeConfig.hearts.spreadY * 2) - shakeConfig.hearts.spreadY,
},
scale: shakeConfig.hearts.minScale +
Math.random() * (shakeConfig.hearts.maxScale - shakeConfig.hearts.minScale),
createdAt: now,
}));
setHearts(prev => [...prev, ...newHearts]);
}, wave * waveDelay);
// Remove this wave's hearts after animation
const cleanupTimer = setTimeout(() => {
setHearts(prev => prev.filter(heart => heart.createdAt !== now));
}, shakeConfig.animations.heartFloat);
timers.push(cleanupTimer);
}, wave * shakeConfig.hearts.waveDelay);
timers.push(timer);
}
// Remove hearts after animation completes
const cleanupTimer = setTimeout(() => {
setHearts(prev => prev.filter(heart => heart.id > Date.now() - 3500));
}, waves * waveDelay + 3500);
timers.push(cleanupTimer);
return () => {
timers.forEach(timer => clearTimeout(timer));
};
}, [intensity]);
return (
<div className="absolute inset-0 pointer-events-none -z-10">
<div className="absolute inset-0 pointer-events-none -z-10 overflow-hidden">
{hearts.map((heart) => {
const style = {
'--angle': `${heart.angle}deg`,
@@ -82,6 +94,9 @@ export function FloatingHearts({ intensity }: FloatingHeartsProps) {
key={heart.id}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 animate-float-heart"
style={style}
onAnimationEnd={() => {
setHearts(prev => prev.filter(h => h.id !== heart.id));
}}
>
<HeartIcon
className="w-16 h-16 text-pink-500 opacity-80 animate-fade-out"
+116
View File
@@ -0,0 +1,116 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Link } from '../../i18n/routing';
import { GlobeAltIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
type Locale = 'en' | 'de' | 'ru' | 'ka' | 'ar';
interface LanguageOption {
code: Locale;
name: string;
}
export function LanguageToggle() {
const locale = useLocale() as Locale;
const t = useTranslations('ui');
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const locales: Locale[] = ['en', 'de', 'ru', 'ka', 'ar'];
const languageOptions: LanguageOption[] = locales.map((code) => ({
code,
name: t(`languages.${code}`)
}));
const currentLanguage = languageOptions.find(lang => lang.code === locale) || languageOptions[0];
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
}
return () => {
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen]);
const toggleDropdown = () => {
setIsOpen(!isOpen);
};
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={toggleDropdown}
className="flex items-center gap-2 p-2 rounded-lg bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 transition-colors"
aria-label={t('languageSelector')}
aria-expanded={isOpen}
aria-haspopup="true"
>
<GlobeAltIcon className="w-4 h-4 text-gray-700 dark:text-gray-300" />
<span className="text-sm text-gray-700 dark:text-gray-300 min-w-[60px] text-left hidden min-[360px]:block">
{currentLanguage.name}
</span>
<ChevronDownIcon
className={`w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform ${
isOpen ? 'rotate-180' : ''
}`}
/>
</button>
{isOpen && (
<div className="absolute left-0 mt-2 w-36 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50">
<div className="py-1">
{languageOptions.map((option) => (
<Link
key={option.code}
href="/"
locale={option.code}
onClick={() => setIsOpen(false)}
className={`w-full flex items-center gap-3 px-3 py-2 text-sm text-left hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${
locale === option.code
? 'bg-gray-100 dark:bg-gray-700 text-blue-600 dark:text-blue-400'
: 'text-gray-700 dark:text-gray-300'
}`}
role="menuitem"
>
<GlobeAltIcon className={`w-4 h-4 ${
locale === option.code ? 'text-blue-600 dark:text-blue-400' : 'text-gray-500 dark:text-gray-400'
}`} />
<span>{option.name}</span>
{locale === option.code && (
<div className="ml-auto w-2 h-2 bg-blue-600 dark:bg-blue-400 rounded-full"></div>
)}
</Link>
))}
</div>
</div>
)}
</div>
);
}
+143
View File
@@ -0,0 +1,143 @@
'use client';
import { useState } from 'react';
import { useParams } from 'next/navigation';
import { appConfig } from '../config/app';
import { SkinId } from '../types';
import { useLocalizedSkinName } from '../hooks/useLocalizedSkinName';
import { usePrices } from '../hooks/usePrices';
import { useFeature } from '../providers/FeatureProvider';
interface PremiumCheckoutProps {
skinId: SkinId;
onClose: () => void;
}
export function PremiumCheckout({ skinId, onClose }: PremiumCheckoutProps) {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const params = useParams();
const getLocalizedSkinName = useLocalizedSkinName();
const paymentsEnabled = useFeature('paymentsEnabled');
const { getPrice, loading: pricesLoading } = usePrices();
const skin = appConfig.skins[skinId];
const skinName = getLocalizedSkinName(skinId);
const price = getPrice(skinId);
const locale = params.locale as string;
// Guard: never render if payments are disabled or skin is not premium
if (!paymentsEnabled || !skin?.isPremium) {
return null;
}
const handlePurchase = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch('/api/checkout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ skinId, locale }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to create checkout');
}
// Redirect to Lemon Squeezy checkout
if (data.checkoutUrl) {
window.location.href = data.checkoutUrl;
} else {
throw new Error('No checkout URL received');
}
} catch (err) {
console.error('Checkout error:', err);
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setIsLoading(false);
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
Premium Skin
</h2>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
disabled={isLoading}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="text-center mb-6">
<div className="w-24 h-24 mx-auto mb-4 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
<img
src={skin.normal}
alt={skinName}
className="w-16 h-16"
/>
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
{skinName}
</h3>
<p className="text-gray-600 dark:text-gray-300 mb-4">
Unlock this premium skin to customize your experience!
</p>
<div className="text-2xl font-bold text-green-600 dark:text-green-400">
{pricesLoading ? '...' : (price ?? '')}
</div>
</div>
{error && (
<div className="mb-4 p-3 bg-red-100 dark:bg-red-900 border border-red-300 dark:border-red-700 rounded-md">
<p className="text-red-700 dark:text-red-300 text-sm">{error}</p>
</div>
)}
<div className="flex gap-3">
<button
onClick={onClose}
className="flex-1 px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-200 dark:bg-gray-700 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
disabled={isLoading}
>
Cancel
</button>
<button
onClick={handlePurchase}
disabled={isLoading}
className="flex-1 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
>
{isLoading ? (
<>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Processing...
</>
) : (
'Purchase'
)}
</button>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 text-center mt-4">
Secure payment powered by Lemon Squeezy
</p>
</div>
</div>
);
}
+197
View File
@@ -0,0 +1,197 @@
'use client';
import { useState, useRef, useEffect, useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Image from 'next/image';
import { appConfig } from '../config/app';
import { SkinId } from '../types';
import { useLocalizedSkinName } from '../hooks/useLocalizedSkinName';
import { usePrices } from '../hooks/usePrices';
import { useFeature } from '../providers/FeatureProvider';
import { ChevronDownIcon, LockClosedIcon } from '@heroicons/react/24/outline';
import { PremiumCheckout } from './PremiumCheckout';
interface SkinOption {
id: SkinId;
name: string;
image: string;
}
export function SkinSelector() {
const router = useRouter();
const searchParams = useSearchParams();
const getLocalizedSkinName = useLocalizedSkinName();
const paymentsEnabled = useFeature('paymentsEnabled');
const { getPrice, loading: pricesLoading } = usePrices();
const [isOpen, setIsOpen] = useState(false);
const [showCheckout, setShowCheckout] = useState<SkinId | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
// When payments are disabled, filter out premium skins entirely
const skinOptions: SkinOption[] = Object.entries(appConfig.skins)
.filter(([, skin]) => paymentsEnabled || !skin.isPremium)
.map(([id, skin]) => ({
id: id as SkinId,
name: getLocalizedSkinName(id),
image: skin.normal
}));
const skinParam = searchParams.get('skin');
// Validate that the skin exists in our config
const isValidSkin = skinParam && Object.keys(appConfig.skins).includes(skinParam);
// Use the skin from URL if valid, otherwise use default skin
const currentSkin = (isValidSkin ? skinParam : appConfig.defaultSkin) as SkinId;
const currentSkinOption = skinOptions.find(skin => skin.id === currentSkin) || skinOptions[0];
const handleSkinChange = useCallback((newSkin: SkinId) => {
const skin = appConfig.skins[newSkin];
// If it's a premium skin, show checkout modal
if (skin.isPremium) {
setShowCheckout(newSkin);
setIsOpen(false);
return;
}
// For free skins, change immediately
const params = new URLSearchParams(searchParams.toString());
if (newSkin === appConfig.defaultSkin) {
params.delete('skin');
} else {
params.set('skin', newSkin);
}
const newUrl = `${window.location.pathname}${params.toString() ? '?' + params.toString() : ''}`;
router.push(newUrl);
setIsOpen(false);
}, [router, searchParams]);
const handleCheckoutClose = useCallback(() => {
setShowCheckout(null);
}, []);
// Handle clicking outside to close dropdown
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
// Handle escape key to close dropdown
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
}
return () => {
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen]);
const toggleDropdown = () => {
setIsOpen(!isOpen);
};
return (
<div className="relative" ref={dropdownRef}>
{/* Main toggle button */}
<button
onClick={toggleDropdown}
className="flex items-center gap-2 p-2 rounded-lg bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 transition-colors"
aria-label="Skin selector"
aria-expanded={isOpen}
aria-haspopup="true"
>
<Image
src={currentSkinOption.image}
alt={currentSkinOption.name}
width={16}
height={16}
className="rounded"
/>
<span className="text-sm text-gray-700 dark:text-gray-300 min-w-[60px] text-left hidden min-[360px]:block">
{currentSkinOption.name}
</span>
<ChevronDownIcon
className={`w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform ${
isOpen ? 'rotate-180' : ''
}`}
/>
</button>
{/* Dropdown menu */}
{isOpen && (
<div className="absolute left-0 mt-2 w-36 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50">
<div className="py-1">
{skinOptions.map((option) => {
const skin = appConfig.skins[option.id];
const isPremium = skin.isPremium;
return (
<button
key={option.id}
onClick={() => handleSkinChange(option.id)}
className={`w-full flex items-center gap-3 px-3 py-2 text-sm text-left hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${
currentSkin === option.id
? 'bg-gray-100 dark:bg-gray-700 text-blue-600 dark:text-blue-400'
: 'text-gray-700 dark:text-gray-300'
}`}
role="menuitem"
>
<div className="relative">
<Image
src={option.image}
alt={option.name}
width={16}
height={16}
className="rounded"
/>
{isPremium && (
<LockClosedIcon className="absolute -top-1 -right-1 w-3 h-3 text-yellow-500" />
)}
</div>
<span className="flex-1">{option.name}</span>
{isPremium && paymentsEnabled && (
<span className="text-xs text-yellow-600 dark:text-yellow-400 font-medium">
{pricesLoading ? '...' : (getPrice(option.id) ?? '')}
</span>
)}
{currentSkin === option.id && (
<div className="w-2 h-2 bg-blue-600 dark:bg-blue-400 rounded-full"></div>
)}
</button>
);
})}
</div>
</div>
)}
{/* Premium Checkout Modal */}
{showCheckout && (
<PremiumCheckout
skinId={showCheckout}
onClose={handleCheckoutClose}
/>
)}
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { useMessages } from 'next-intl';
import { getRandomEmoji } from '../config/emojis';
const VISIBILITY_MS = 3000;
const COOLDOWN_MS = 2000;
interface SpeechBubbleProps {
isShaken: boolean;
triggerCount: number;
}
export function SpeechBubble({ triggerCount }: SpeechBubbleProps) {
const [message, setMessage] = useState('');
const [isVisible, setIsVisible] = useState(false);
const [messageQueue, setMessageQueue] = useState<string[]>([]);
const allMessages = useMessages();
const messagesRef = useRef<string[]>([]);
const lastTriggerTime = useRef(0);
const showTimeRef = useRef<number>(0);
const lastFadeTime = useRef(0);
useEffect(() => {
if (messagesRef.current.length > 0) return;
try {
const characterMessages = allMessages.character;
if (characterMessages && typeof characterMessages === 'object') {
const messageArray = Object.values(characterMessages) as string[];
if (messageArray.length === 0) {
console.error(`No character messages found! Expected messages in 'character' namespace but got none.`);
return;
}
console.log(`Loaded ${messageArray.length} character messages`);
messagesRef.current = messageArray;
} else {
console.error(`Character messages not found or invalid format:`, characterMessages);
}
} catch (error) {
console.error(`Error loading character messages:`, error);
}
}, [allMessages]);
const getRandomMessage = useCallback(() => {
const currentMessages = messagesRef.current;
if (currentMessages.length === 0) return '';
const randomIndex = Math.floor(Math.random() * currentMessages.length);
const messageValue = currentMessages[randomIndex];
return `${messageValue} ${getRandomEmoji()}`;
}, []);
useEffect(() => {
if (triggerCount === 0 || messagesRef.current.length === 0) return;
const now = Date.now();
const timeSinceLastFade = now - lastFadeTime.current;
if (timeSinceLastFade < COOLDOWN_MS || isVisible) {
const newMessage = getRandomMessage();
if (newMessage) {
setMessageQueue(prev => [...prev, newMessage]);
}
return;
}
lastTriggerTime.current = now;
showTimeRef.current = now;
const newMessage = getRandomMessage();
if (newMessage) {
setMessage(newMessage);
setIsVisible(true);
}
}, [triggerCount, isVisible, getRandomMessage]);
useEffect(() => {
if (messageQueue.length === 0 || isVisible) return;
const now = Date.now();
const timeSinceLastFade = now - lastFadeTime.current;
if (timeSinceLastFade >= COOLDOWN_MS) {
const nextMessage = messageQueue[0];
setMessageQueue(prev => prev.slice(1));
lastTriggerTime.current = now;
showTimeRef.current = now;
setMessage(nextMessage);
setIsVisible(true);
}
}, [messageQueue, isVisible]);
useEffect(() => {
if (!isVisible) return;
const hideTimer = setTimeout(() => {
setIsVisible(false);
lastFadeTime.current = Date.now();
}, VISIBILITY_MS);
return () => clearTimeout(hideTimer);
}, [isVisible]);
return (
<div
className={`absolute -top-24 bg-white dark:bg-slate-800
px-4 py-2 rounded-xl shadow-lg z-20 transition-opacity duration-300
${isVisible ? 'opacity-100 animate-float' : 'opacity-0 pointer-events-none'}`}
style={{
left: '50%',
transform: 'translateX(-50%)'
}}
>
{message}
</div>
);
}
+128 -12
View File
@@ -1,22 +1,138 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { useTheme } from '../providers/ThemeProvider';
import { SunIcon, MoonIcon } from '@heroicons/react/24/outline';
import { SunIcon, MoonIcon, ComputerDesktopIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
type ThemeMode = 'light' | 'dark' | 'system';
interface ThemeOption {
mode: ThemeMode;
label: string;
icon: React.ReactNode;
}
export function ThemeToggle() {
const { darkMode, toggleDarkMode } = useTheme();
const { themeMode, setThemeMode } = useTheme();
const t = useTranslations('ui');
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const themeOptions: ThemeOption[] = [
{
mode: 'light',
label: t('themes.light'),
icon: <SunIcon className="w-4 h-4" />
},
{
mode: 'dark',
label: t('themes.dark'),
icon: <MoonIcon className="w-4 h-4" />
},
{
mode: 'system',
label: t('themes.system'),
icon: <ComputerDesktopIcon className="w-4 h-4" />
}
];
// Get current theme option
const currentTheme = themeOptions.find(option => option.mode === themeMode) || themeOptions[2];
// Handle clicking outside to close dropdown
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
// Handle escape key to close dropdown
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
}
return () => {
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen]);
const handleThemeSelect = (mode: ThemeMode) => {
setThemeMode(mode);
setIsOpen(false);
};
const toggleDropdown = () => {
setIsOpen(!isOpen);
};
return (
<button
onClick={toggleDarkMode}
className="fixed top-4 right-4 p-2 rounded-full bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 transition-colors"
aria-label="Toggle dark mode"
>
{darkMode ? (
<SunIcon className="w-6 h-6 text-yellow-500" />
) : (
<MoonIcon className="w-6 h-6 text-gray-900" />
<div className="relative" ref={dropdownRef}>
{/* Main toggle button */}
<button
onClick={toggleDropdown}
className="flex items-center gap-2 p-2 rounded-lg bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 transition-colors z-50"
aria-label={t('themeSelector')}
aria-expanded={isOpen}
aria-haspopup="true"
>
<div className="flex items-center text-gray-700 dark:text-gray-300">
{currentTheme.icon}
</div>
<span className="text-sm text-gray-700 dark:text-gray-300 min-w-[60px] text-left hidden min-[360px]:block">
{currentTheme.label}
</span>
<ChevronDownIcon
className={`w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform ${
isOpen ? 'rotate-180' : ''
}`}
/>
</button>
{/* Dropdown menu */}
{isOpen && (
<div className="absolute right-0 mt-2 w-32 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50">
<div className="py-1">
{themeOptions.map((option) => (
<button
key={option.mode}
onClick={() => handleThemeSelect(option.mode)}
className={`w-full flex items-center gap-3 px-3 py-2 text-sm text-left hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${
themeMode === option.mode
? 'bg-gray-100 dark:bg-gray-700 text-blue-600 dark:text-blue-400'
: 'text-gray-700 dark:text-gray-300'
}`}
role="menuitem"
>
<div className={themeMode === option.mode ? 'text-blue-600 dark:text-blue-400' : ''}>
{option.icon}
</div>
<span>{option.label}</span>
{themeMode === option.mode && (
<div className="ml-auto w-2 h-2 bg-blue-600 dark:bg-blue-400 rounded-full"></div>
)}
</button>
))}
</div>
</div>
)}
</button>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
export const appConfig = {
name: 'Shake the Frog',
description: 'A fun interactive frog that reacts to shaking!',
url: 'https://shakethefrog.com',
assets: {
favicon: '/images/frog.svg',
ogImage: {
width: 1200,
height: 630,
bgColor: '#c9ffda',
textColor: '#000000'
}
},
skins: {
frog: {
id: 'frog',
name: 'Frog',
normal: '/images/frog.svg',
shaken: '/images/frog-shaken.svg',
isPremium: false
},
mandarin: {
id: 'mandarin',
name: 'Mandarin',
normal: '/images/mandarin.svg',
// TODO: Create a proper shaken version of the mandarin skin
shaken: '/images/mandarin.svg', // Using the same image for both states until a shaken version is created
isPremium: false,
variantId: 'your_mandarin_variant_id_here' // Replace with actual variant ID when created
},
beaver: {
id: 'beaver',
name: 'Beaver',
normal: '/images/beaver.svg',
shaken: '/images/beaver-shaken.svg',
isPremium: true,
variantId: '1047017'
}
},
defaultSkin: 'frog'
} as const
+14
View File
@@ -0,0 +1,14 @@
// Define our curated emoji pool
const emojiPool = [
'💫', '💝', '💘', '💖', '💕',
'💓', '💗', '💞', '✨', '🌟',
'🔥', '👼', '⭐', '💎', '💨',
'🎉', '🕸️', '🤗', '💋', '😘',
'🫂', '👫', '💟', '💌', '🥰',
'😍', '🥺', '😢', '😭'
];
// Helper function to get a random emoji
export function getRandomEmoji(): string {
return emojiPool[Math.floor(Math.random() * emojiPool.length)];
}
+20
View File
@@ -0,0 +1,20 @@
/**
* Server-side feature flag definitions.
*
* Flags are read from environment variables. The abstraction is kept thin
* so a runtime provider (Flipt, Unleash, Flags SDK adapter, etc.) can be
* swapped in later without changing any consumer code.
*
* Convention: FEATURE_<NAME>=1 → enabled
* anything else → disabled
*/
export interface FeatureFlags {
paymentsEnabled: boolean;
}
export function getFeatureFlags(): FeatureFlags {
return {
paymentsEnabled: process.env.FEATURE_PAYMENTS === '1',
};
}
+45
View File
@@ -0,0 +1,45 @@
import { lemonSqueezySetup } from '@lemonsqueezy/lemonsqueezy.js';
// Initialize Lemon Squeezy SDK
export function initializeLemonSqueezy() {
const apiKey = process.env.LEMONSQUEEZY_API_KEY;
if (!apiKey) {
throw new Error('LEMONSQUEEZY_API_KEY is required');
}
lemonSqueezySetup({
apiKey,
onError: (error) => {
throw error; // Fail fast instead of just logging
},
});
}
// Lemon Squeezy configuration with lazy validation.
// Config is only resolved on first access so the module can be safely
// imported even when payment env vars are absent (e.g. payments disabled).
let _config: { storeId: string; webhookSecret: string; baseUrl: string } | null = null;
export function getLemonSqueezyConfig() {
if (_config) return _config;
const storeId = process.env.LEMONSQUEEZY_STORE_ID;
const webhookSecret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET;
const baseUrl = process.env.NEXT_PUBLIC_APP_URL;
if (!storeId) {
throw new Error('LEMONSQUEEZY_STORE_ID is required');
}
if (!webhookSecret) {
throw new Error('LEMONSQUEEZY_WEBHOOK_SECRET is required');
}
if (!baseUrl) {
throw new Error('NEXT_PUBLIC_APP_URL is required');
}
_config = { storeId, webhookSecret, baseUrl };
return _config;
}
+32
View File
@@ -0,0 +1,32 @@
export const shakeConfig = {
// Threshold for triggering shake (lower = more sensitive)
threshold: 20, // Increased from 15 to make it less sensitive
// Minimum time between shake detections (in ms)
debounceTime: 100,
// Animation durations (in ms)
animations: {
shakeReset: 600, // Reduced from 10000ms to 600ms (0.6 seconds)
heartsReset: 300, // How long the hearts animation lasts
heartFloat: 2000, // Duration of floating heart animation
heartFadeOut: 2000 // Duration of heart fade out
},
// Hearts configuration
hearts: {
waves: 4, // Number of waves per shake
waveDelay: 200, // Delay between waves in ms
cleanupInterval: 1000, // How often to check for and remove old hearts
minSpeed: 0.8, // Minimum heart float speed
maxSpeed: 1.2, // Maximum heart float speed
minScale: 0.8, // Minimum heart size
maxScale: 1.2, // Maximum heart size
spreadX: 20, // How far hearts can spread horizontally from center
spreadY: 20, // How far hearts can spread vertically from center
maxPerShake: 50 // Maximum number of hearts per shake
},
// Default intensity for manual triggers (click/spacebar)
defaultTriggerIntensity: 25
};
+100
View File
@@ -0,0 +1,100 @@
import { type Locale } from '../../i18n/request';
// Define grammatical cases for languages that need them
type GrammaticalCase = 'nominative' | 'accusative' | 'dative' | 'genitive' | 'instrumental' | 'prepositional';
// Define which languages need grammatical cases
const languagesWithCases: Partial<Record<Locale, boolean>> = {
ru: true,
ka: true
};
// Localized skin names for different languages with grammatical cases
const skinNames: Record<string, Record<Locale, string | Record<GrammaticalCase, string>>> = {
frog: {
en: 'Frog',
de: 'Frosch',
ru: {
nominative: 'Лягушка',
accusative: 'Лягушку',
dative: 'Лягушке',
genitive: 'Лягушки',
instrumental: 'Лягушкой',
prepositional: 'Лягушке'
},
ka: {
nominative: 'ბაყაყი',
accusative: 'ბაყაყს',
dative: 'ბაყაყს',
genitive: 'ბაყაყის',
instrumental: 'ბაყაყით',
prepositional: 'ბაყაყზე'
},
ar: 'ضفدع'
},
mandarin: {
en: 'Mandarin',
de: 'Mandarine',
ru: {
nominative: 'Мандарин',
accusative: 'Мандарин',
dative: 'Мандарину',
genitive: 'Мандарина',
instrumental: 'Мандарином',
prepositional: 'Мандарине'
},
ka: {
nominative: 'მანდარინი',
accusative: 'მანდარინს',
dative: 'მანდარინს',
genitive: 'მანდარინის',
instrumental: 'მანდარინით',
prepositional: 'მანდარინზე'
},
ar: 'ماندرين'
},
beaver: {
en: 'Beaver',
de: 'Biber',
ru: {
nominative: 'Бобр',
accusative: 'Бобра',
dative: 'Бобру',
genitive: 'Бобра',
instrumental: 'Бобром',
prepositional: 'Бобре'
},
ka: {
nominative: 'თახვი',
accusative: 'თახვს',
dative: 'თახვს',
genitive: 'თახვის',
instrumental: 'თახვით',
prepositional: 'თახვზე'
},
ar: 'قندس'
}
};
/**
* Get the localized name for a skin with the appropriate grammatical case
* @param skinId The skin ID
* @param language The language code
* @param grammaticalCase The grammatical case to use (for languages that need it)
* @returns The localized skin name
*/
export function getLocalizedSkinName(
skinId: string,
language: Locale,
grammaticalCase: GrammaticalCase = 'nominative'
): string {
const skinName = skinNames[skinId]?.[language];
// If the language doesn't use cases or we don't have cases for this skin
if (!skinName || typeof skinName === 'string' || !languagesWithCases[language]) {
return typeof skinName === 'string' ? skinName : skinNames[skinId]?.en as string || skinId;
}
// Return the appropriate case, or fallback to nominative if the case doesn't exist
return skinName[grammaticalCase] || skinName.nominative;
}
+35 -6
View File
@@ -1,9 +1,15 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";
body {
/* Override the dark variant to use class-based dark mode instead of media query */
@custom-variant dark (&:where(.dark, .dark *));
html, body {
-webkit-tap-highlight-color: transparent;
overflow: hidden;
min-height: 100vh;
min-height: 100dvh;
margin: 0;
padding: 0;
}
/* Light mode styles */
@@ -32,8 +38,8 @@ body {
@keyframes float-heart {
to {
transform: translate(
calc(var(--start-x) + (50vw * cos(var(--angle)))),
calc(var(--start-y) + (50vh * sin(var(--angle))))
calc(var(--start-x) + (70vw * cos(var(--angle)))),
calc(var(--start-y) + (70vh * sin(var(--angle))))
) scale(var(--scale));
opacity: 0;
}
@@ -50,3 +56,26 @@ body {
.animate-fade-out {
animation: fade-out 2s ease-out forwards;
}
@keyframes float {
0% {
opacity: 0;
transform: translateX(-50%) translateY(10px);
}
20% {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
80% {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
100% {
opacity: 0;
transform: translateX(-50%) translateY(-10px);
}
}
.animate-float {
animation: float 3s ease-out forwards;
}
-34
View File
@@ -1,34 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
export function useDarkMode() {
const [darkMode, setDarkMode] = useState(false);
useEffect(() => {
// Check if user has a dark mode preference in localStorage
const isDark = localStorage.getItem('darkMode') === 'true';
// Check system preference if no localStorage value
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
setDarkMode(isDark ?? systemPrefersDark);
// Add listener for system theme changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
if (localStorage.getItem('darkMode') === null) {
setDarkMode(e.matches);
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
const toggleDarkMode = () => {
setDarkMode(!darkMode);
localStorage.setItem('darkMode', (!darkMode).toString());
};
return { darkMode, toggleDarkMode };
}
+20
View File
@@ -0,0 +1,20 @@
import { useState, useEffect } from 'react';
export function useIsMobile() {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkIfMobile = () => {
const userAgent = window.navigator.userAgent.toLowerCase();
const mobileRegex = /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i;
setIsMobile(mobileRegex.test(userAgent));
};
checkIfMobile();
window.addEventListener('resize', checkIfMobile);
return () => window.removeEventListener('resize', checkIfMobile);
}, []);
return isMobile;
}
+27
View File
@@ -0,0 +1,27 @@
'use client';
import { useLocale } from 'next-intl';
import { getLocalizedSkinName } from '../config/skin-names';
import { type Locale } from '../../i18n/request';
// Define grammatical cases
type GrammaticalCase = 'nominative' | 'accusative' | 'dative' | 'genitive' | 'instrumental' | 'prepositional';
/**
* Hook to get localized skin names
*/
export function useLocalizedSkinName() {
const locale = useLocale();
/**
* Get a localized skin name with the appropriate grammatical case
* @param skinId The skin ID
* @param grammaticalCase The grammatical case to use (for languages that need it)
* @returns The localized skin name
*/
const getLocalizedName = (skinId: string, grammaticalCase: GrammaticalCase = 'nominative'): string => {
return getLocalizedSkinName(skinId, locale as Locale, grammaticalCase);
};
return getLocalizedName;
}
+60
View File
@@ -0,0 +1,60 @@
'use client';
import { useState, useEffect } from 'react';
import { useFeature } from '../providers/FeatureProvider';
interface PricesData {
prices: Record<string, string>;
}
export function usePrices() {
const paymentsEnabled = useFeature('paymentsEnabled');
const [prices, setPrices] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!paymentsEnabled) {
setLoading(false);
return;
}
const fetchPrices = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch('/api/prices');
if (!response.ok) {
throw new Error('Failed to fetch prices');
}
const data: PricesData = await response.json();
setPrices(data.prices);
} catch (err) {
console.error('Error fetching prices:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch prices');
} finally {
setLoading(false);
}
};
fetchPrices();
}, [paymentsEnabled]);
const getPrice = (skinId: string): string | null => {
if (!paymentsEnabled || loading) {
return null;
}
return prices[skinId] ?? null;
};
return {
prices,
loading,
error,
enabled: paymentsEnabled,
getPrice
};
}
+109
View File
@@ -0,0 +1,109 @@
'use client';
import { useRef, useEffect, useCallback } from 'react';
const AUDIO_URL = '/audio/starley_call_on_me.ogg';
const FADE_IN_SEC = 0.1;
const FADE_OUT_SEC = 0.8;
const QUIET_TIMEOUT_MS = 300;
const PLAY_GAIN = 0.7;
// exponentialRampToValueAtTime can't reach 0; use a tiny positive target
const NEAR_ZERO = 0.0001;
export function useShakeAudio() {
const ctxRef = useRef<AudioContext | null>(null);
const bufferRef = useRef<AudioBuffer | null>(null);
const sourceRef = useRef<AudioBufferSourceNode | null>(null);
const gainRef = useRef<GainNode | null>(null);
const quietTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fadeOutAndStop = useCallback(() => {
const ctx = ctxRef.current;
const source = sourceRef.current;
const gain = gainRef.current;
if (!ctx || !source || !gain) return;
const now = ctx.currentTime;
gain.gain.cancelScheduledValues(now);
gain.gain.setValueAtTime(gain.gain.value, now);
gain.gain.linearRampToValueAtTime(NEAR_ZERO, now + FADE_OUT_SEC);
try {
source.stop(now + FADE_OUT_SEC + 0.05);
} catch {
// source may already be stopped
}
sourceRef.current = null;
gainRef.current = null;
}, []);
const bump = useCallback(async () => {
if (typeof window === 'undefined') return;
if (!ctxRef.current) {
try {
ctxRef.current = new AudioContext();
} catch (err) {
console.error('AudioContext creation failed:', err);
return;
}
}
const ctx = ctxRef.current;
if (ctx.state === 'suspended') {
try {
await ctx.resume();
} catch (err) {
console.error('AudioContext resume failed:', err);
return;
}
}
if (!bufferRef.current) {
try {
const res = await fetch(AUDIO_URL);
const arr = await res.arrayBuffer();
bufferRef.current = await ctx.decodeAudioData(arr);
} catch (err) {
console.error('Audio decode failed:', err);
return;
}
}
if (quietTimerRef.current) clearTimeout(quietTimerRef.current);
quietTimerRef.current = setTimeout(fadeOutAndStop, QUIET_TIMEOUT_MS);
if (sourceRef.current) return;
const source = ctx.createBufferSource();
source.buffer = bufferRef.current;
source.loop = true;
const gain = ctx.createGain();
const now = ctx.currentTime;
gain.gain.setValueAtTime(NEAR_ZERO, now);
gain.gain.linearRampToValueAtTime(PLAY_GAIN, now + FADE_IN_SEC);
source.connect(gain).connect(ctx.destination);
source.start(now);
sourceRef.current = source;
gainRef.current = gain;
}, [fadeOutAndStop]);
useEffect(() => {
return () => {
if (quietTimerRef.current) clearTimeout(quietTimerRef.current);
const source = sourceRef.current;
if (source) {
try { source.stop(); } catch { /* already stopped */ }
source.disconnect();
}
const ctx = ctxRef.current;
if (ctx) ctx.close().catch(() => { /* ignore */ });
};
}, []);
return bump;
}
+18
View File
@@ -0,0 +1,18 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { appConfig } from '../config/app';
import { SkinId } from '../types';
export function useSkin() {
const searchParams = useSearchParams();
const skinParam = searchParams.get('skin');
// Validate that the skin exists in our config
const isValidSkin = skinParam && Object.keys(appConfig.skins).includes(skinParam);
// Return the skin from URL if valid, otherwise return default skin
const currentSkin = (isValidSkin ? skinParam : appConfig.defaultSkin) as SkinId;
return currentSkin;
}
+42 -9
View File
@@ -1,16 +1,40 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import { ThemeProvider } from './providers/ThemeProvider'
import { ThemeToggle } from './components/ThemeToggle'
import { FeatureProvider } from './providers/FeatureProvider'
import { getFeatureFlags } from './config/features'
import { appConfig } from './config/app'
import { Suspense } from 'react'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'Shake the Frog',
description: 'A fun interactive frog that reacts to shaking!',
metadataBase: new URL(appConfig.url),
title: appConfig.name,
description: appConfig.description,
icons: {
icon: '/images/frog.svg'
icon: appConfig.assets.favicon
},
openGraph: {
title: appConfig.name,
description: appConfig.description,
url: appConfig.url,
siteName: appConfig.name,
images: [{
url: '/api/og',
width: appConfig.assets.ogImage.width,
height: appConfig.assets.ogImage.height,
alt: `${appConfig.name} preview`
}],
locale: 'en_US',
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: appConfig.name,
description: appConfig.description,
images: ['/api/og']
}
}
@@ -19,13 +43,22 @@ export default function RootLayout({
}: {
children: React.ReactNode
}) {
const features = getFeatureFlags();
return (
<html lang="en" suppressHydrationWarning>
<html suppressHydrationWarning>
<body className={`${inter.className} transition-colors`}>
<ThemeProvider>
<ThemeToggle />
{children}
</ThemeProvider>
<FeatureProvider features={features}>
<ThemeProvider>
<Suspense fallback={
<div className="flex h-[100dvh] items-center justify-center bg-green-50 dark:bg-slate-900">
<p className="text-gray-600 dark:text-gray-400">Loading...</p>
</div>
}>
{children}
</Suspense>
</ThemeProvider>
</FeatureProvider>
</body>
</html>
)
-141
View File
@@ -1,141 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import Image from 'next/image';
import { FloatingHearts } from './components/FloatingHearts';
export default function Home() {
const [isShaken, setIsShaken] = useState(false);
const [shakeIntensity, setShakeIntensity] = useState(0);
const [lastUpdate, setLastUpdate] = useState(0);
const [motionPermission, setMotionPermission] = useState<PermissionState>('prompt');
const shakeThreshold = 15;
// Check if device motion is available and handle permissions
const requestMotionPermission = async () => {
if (typeof window === 'undefined') return;
// Check if device motion is available
if (!('DeviceMotionEvent' in window)) {
setMotionPermission('denied');
return;
}
// Request permission on iOS devices
if ('requestPermission' in DeviceMotionEvent) {
try {
// @ts-expect-error - TypeScript doesn't know about requestPermission
const permission = await DeviceMotionEvent.requestPermission();
setMotionPermission(permission);
} catch (err) {
console.error('Error requesting motion permission:', err);
setMotionPermission('denied');
}
} else {
// Android or desktop - no permission needed
setMotionPermission('granted');
}
};
const triggerShake = (intensity: number) => {
// Start shake animation
setIsShaken(true);
// Always reset shake after 500ms
setTimeout(() => {
setIsShaken(false);
}, 500);
// Trigger hearts with a shorter duration
setShakeIntensity(intensity);
setTimeout(() => setShakeIntensity(0), 300);
};
useEffect(() => {
const handleKeyPress = (event: KeyboardEvent) => {
if (event.code === 'Space') {
triggerShake(25);
}
};
const handleMotion = (event: DeviceMotionEvent) => {
const acceleration = event.accelerationIncludingGravity;
if (!acceleration) return;
const currentTime = new Date().getTime();
const timeDiff = currentTime - lastUpdate;
if (timeDiff > 100) {
setLastUpdate(currentTime);
const speed = Math.abs(acceleration.x || 0) +
Math.abs(acceleration.y || 0) +
Math.abs(acceleration.z || 0);
if (speed > shakeThreshold) {
triggerShake(speed);
}
}
};
// Only add motion listener if permission is granted
if (typeof window !== 'undefined') {
if (motionPermission === 'granted' && 'DeviceMotionEvent' in window) {
window.addEventListener('devicemotion', handleMotion);
}
window.addEventListener('keydown', handleKeyPress);
}
return () => {
if (typeof window !== 'undefined') {
if (motionPermission === 'granted') {
window.removeEventListener('devicemotion', handleMotion);
}
window.removeEventListener('keydown', handleKeyPress);
}
};
}, [lastUpdate, motionPermission, triggerShake]);
// Initial permission check
useEffect(() => {
requestMotionPermission();
}, []);
const handleClick = () => {
triggerShake(25);
};
return (
<main className="flex h-[100dvh] flex-col items-center justify-center p-4 bg-green-50 dark:bg-slate-900">
<div
className={`relative ${isShaken ? 'animate-shake' : ''} z-10`}
onClick={handleClick}
>
<FloatingHearts intensity={shakeIntensity} />
<Image
src={isShaken ? '/images/frog-shaken.svg' : '/images/frog.svg'}
alt="Frog"
width={200}
height={200}
priority
/>
</div>
<div className="mt-8 flex flex-col items-center gap-2">
<p className="text-gray-600 dark:text-gray-400 text-center max-w-[240px]">
{motionPermission === 'prompt' ? (
<button
onClick={requestMotionPermission}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
Enable device shake
</button>
) : motionPermission === 'granted' ? (
"Shake your device, press spacebar, or click/tap frog!"
) : (
"Press spacebar or click/tap frog!"
)}
</p>
</div>
</main>
);
}
+27
View File
@@ -0,0 +1,27 @@
'use client';
import { createContext, useContext } from 'react';
import type { FeatureFlags } from '../config/features';
const FeatureContext = createContext<FeatureFlags | undefined>(undefined);
interface FeatureProviderProps {
features: FeatureFlags;
children: React.ReactNode;
}
export function FeatureProvider({ features, children }: FeatureProviderProps) {
return (
<FeatureContext.Provider value={features}>
{children}
</FeatureContext.Provider>
);
}
export function useFeature<K extends keyof FeatureFlags>(key: K): FeatureFlags[K] {
const context = useContext(FeatureContext);
if (context === undefined) {
throw new Error('useFeature must be used within a FeatureProvider');
}
return context[key];
}
+132 -6
View File
@@ -1,25 +1,151 @@
'use client';
import { createContext, useContext, useEffect } from 'react';
import { useDarkMode } from '../hooks/useDarkMode';
import { createContext, useContext, useEffect, useState } from 'react';
const ThemeContext = createContext({ darkMode: false, toggleDarkMode: () => {} });
// Define theme modes
type ThemeMode = 'light' | 'dark' | 'system';
// Helper function to detect system dark mode preference
const getSystemPreference = (): boolean => {
if (typeof window === 'undefined') return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
};
// Update context type to include the new properties
interface ThemeContextType {
darkMode: boolean;
themeMode: ThemeMode;
setThemeMode: (mode: ThemeMode) => void;
}
const ThemeContext = createContext<ThemeContextType>({
darkMode: false,
themeMode: 'system',
setThemeMode: () => {},
});
export const useTheme = () => useContext(ThemeContext);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const { darkMode, toggleDarkMode } = useDarkMode();
const [darkMode, setDarkMode] = useState(false);
const [themeMode, setThemeModeState] = useState<ThemeMode>('system');
const [mounted, setMounted] = useState(false);
// Initialize theme state from localStorage
useEffect(() => {
if (typeof window !== 'undefined') {
try {
// Get theme mode preference following Tailwind's recommendation
console.log('ThemeProvider init - Reading from localStorage');
const savedTheme = localStorage.getItem('theme');
console.log('ThemeProvider init - localStorage.theme:', savedTheme);
// Determine if we should use system preference
const useSystemPreference = !savedTheme;
console.log('ThemeProvider init - Using system preference:', useSystemPreference);
// Set theme mode state based on localStorage
if (savedTheme === 'light') {
console.log('ThemeProvider init - Setting theme mode to: light');
setThemeModeState('light');
setDarkMode(false);
} else if (savedTheme === 'dark') {
console.log('ThemeProvider init - Setting theme mode to: dark');
setThemeModeState('dark');
setDarkMode(true);
} else {
// Use system preference
console.log('ThemeProvider init - Setting theme mode to: system');
setThemeModeState('system');
const systemPreference = getSystemPreference();
console.log('ThemeProvider init - System preference is dark:', systemPreference);
setDarkMode(systemPreference);
}
// Apply dark mode class to html element directly (Tailwind recommendation)
const shouldUseDarkMode =
savedTheme === 'dark' ||
(!savedTheme && getSystemPreference());
console.log('ThemeProvider init - Should use dark mode:', shouldUseDarkMode);
if (shouldUseDarkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
} catch (error) {
console.error('ThemeProvider init - Error accessing localStorage:', error);
// Fallback to system preference if localStorage access fails
setThemeModeState('system');
setDarkMode(getSystemPreference());
}
}
setMounted(true);
}, []);
// Listen for system preference changes
useEffect(() => {
if (typeof window === 'undefined') return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
if (themeMode === 'system') {
setDarkMode(e.matches);
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [themeMode]);
// Function to set theme mode and update localStorage following Tailwind's recommendation
const setThemeMode = (mode: ThemeMode) => {
console.log('ThemeProvider - Setting theme mode to:', mode);
setThemeModeState(mode);
try {
if (mode === 'light') {
localStorage.setItem('theme', 'light');
console.log('ThemeProvider - Saved "light" to localStorage.theme');
setDarkMode(false);
} else if (mode === 'dark') {
localStorage.setItem('theme', 'dark');
console.log('ThemeProvider - Saved "dark" to localStorage.theme');
setDarkMode(true);
} else if (mode === 'system') {
// For system preference, remove the item from localStorage
localStorage.removeItem('theme');
console.log('ThemeProvider - Removed theme from localStorage for system preference');
const systemPreference = getSystemPreference();
console.log('ThemeProvider - System preference is dark:', systemPreference);
setDarkMode(systemPreference);
}
} catch (error) {
console.error('ThemeProvider - Error saving to localStorage:', error);
}
};
// Update DOM when darkMode changes
useEffect(() => {
if (!mounted) return;
console.log('ThemeProvider - Updating DOM, darkMode:', darkMode);
if (darkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [darkMode]);
}, [darkMode, mounted]);
// Prevent hydration mismatch by not rendering theme-dependent content until mounted
if (!mounted) {
return <>{children}</>;
}
return (
<ThemeContext.Provider value={{ darkMode, toggleDarkMode }}>
<ThemeContext.Provider value={{ darkMode, themeMode, setThemeMode }}>
{children}
</ThemeContext.Provider>
);
+4
View File
@@ -0,0 +1,4 @@
import { appConfig } from '../config/app';
// Define skin types
export type SkinId = keyof typeof appConfig.skins;