Update deps

This commit is contained in:
HugeFrog24
2026-03-26 03:35:53 +01:00
parent 1edd996336
commit c746b2c17f
68 changed files with 8504 additions and 6901 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.cancel.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.success.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>
);
}
+236
View File
@@ -0,0 +1,236 @@
'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';
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 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) => {
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];
});
}
}
}, []);
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 }
);
}
}
+16 -6
View File
@@ -18,23 +18,33 @@ export async function GET(request: Request) {
alignItems: 'center',
justifyContent: 'center',
backgroundColor: appConfig.assets.ogImage.bgColor,
fontSize: 60,
fontSize: 72,
fontWeight: 600,
}}
>
<img
src={`${baseUrl}${appConfig.assets.favicon}`}
alt={appConfig.name}
width={200}
height={200}
width={300}
height={300}
style={{ margin: '0 0 40px' }}
/>
<div style={{ marginBottom: 20 }}>{appConfig.name}</div>
<div
style={{
marginBottom: 30,
color: appConfig.assets.ogImage.textColor,
}}
>
{appConfig.name}
</div>
<div
style={{
fontSize: 30,
fontSize: 36,
fontWeight: 400,
color: appConfig.assets.ogImage.textColor
color: appConfig.assets.ogImage.textColor,
textAlign: 'center',
maxWidth: '80%',
lineHeight: 1.4,
}}
>
{appConfig.description}
+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
}
+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>
);
}
+52 -28
View File
@@ -1,9 +1,9 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { frogMessages } from '../config/messages';
import { useMessages } from 'next-intl';
import { getRandomEmoji } from '../config/emojis';
// Increase visibility duration for speech bubbles
const VISIBILITY_MS = 3000; // 3 seconds for message visibility
const COOLDOWN_MS = 2000; // 2 seconds between new messages
const VISIBILITY_MS = 3000;
const COOLDOWN_MS = 2000;
interface SpeechBubbleProps {
isShaken: boolean;
@@ -14,48 +14,76 @@ 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 randomIndex = Math.floor(Math.random() * frogMessages.length);
return frogMessages[randomIndex];
const currentMessages = messagesRef.current;
if (currentMessages.length === 0) return '';
const randomIndex = Math.floor(Math.random() * currentMessages.length);
const messageValue = currentMessages[randomIndex];
return `${messageValue} ${getRandomEmoji()}`;
}, []);
// Handle new trigger events
useEffect(() => {
if (triggerCount === 0) return;
if (triggerCount === 0 || messagesRef.current.length === 0) return;
const now = Date.now();
const timeSinceLastFade = now - lastFadeTime.current;
// If we're in cooldown, or a message is visible, queue the new message
if (timeSinceLastFade < COOLDOWN_MS || isVisible) {
const newMessage = getRandomMessage();
setMessageQueue(prev => [...prev, newMessage]);
if (newMessage) {
setMessageQueue(prev => [...prev, newMessage]);
}
return;
}
// Otherwise, show the message immediately
lastTriggerTime.current = now;
showTimeRef.current = now;
const newMessage = getRandomMessage();
setMessage(newMessage);
setIsVisible(true);
}, [triggerCount, getRandomMessage, isVisible]);
if (newMessage) {
setMessage(newMessage);
setIsVisible(true);
}
}, [triggerCount, isVisible, getRandomMessage]);
// Handle message queue
useEffect(() => {
if (messageQueue.length === 0 || isVisible) return;
const now = Date.now();
const timeSinceLastFade = now - lastFadeTime.current;
// Only show next message if cooldown has expired
if (timeSinceLastFade >= COOLDOWN_MS) {
const nextMessage = messageQueue[0];
setMessageQueue(prev => prev.slice(1)); // Remove the message from queue
setMessageQueue(prev => prev.slice(1));
lastTriggerTime.current = now;
showTimeRef.current = now;
setMessage(nextMessage);
@@ -63,7 +91,6 @@ export function SpeechBubble({ triggerCount }: SpeechBubbleProps) {
}
}, [messageQueue, isVisible]);
// Handle visibility duration
useEffect(() => {
if (!isVisible) return;
@@ -76,19 +103,16 @@ export function SpeechBubble({ triggerCount }: SpeechBubbleProps) {
}, [isVisible]);
return (
<div
className={`absolute -top-24 left-1/2 -translate-x-1/2 bg-white dark:bg-slate-800
<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%)'
}}
>
<div className="relative">
{message}
<div className="absolute -bottom-6 left-1/2 -translate-x-1/2 w-0 h-0
border-l-[8px] border-l-transparent
border-r-[8px] border-r-transparent
border-t-[8px] border-t-white
dark:border-t-slate-800" />
</div>
{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 z-50"
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>
);
}
+32 -5
View File
@@ -1,14 +1,41 @@
export const appConfig = {
name: 'Shake the Frog',
description: 'A fun interactive frog that reacts to shaking!',
url: 'https://shakethefrog.vercel.app',
url: 'https://shakethefrog.com',
assets: {
favicon: '/images/frog.svg',
ogImage: {
width: 1200,
height: 630,
bgColor: '#f0fdf4',
textColor: '#374151'
bgColor: '#c9ffda',
textColor: '#000000'
}
}
} as const
},
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;
}
-135
View File
@@ -1,135 +0,0 @@
export const frogMessages = [
"Again! Again! ",
"Almost got me! ",
"Can't catch your breath? ",
"Catch me if you can! ",
"Chase me! ",
"Claim me! ",
"Come closer! ",
"Do it again! ",
"Don't stop now! ",
"Faster! Faster! ",
"Give me all you've got! ",
"Higher! Higher! ",
"I can dance all day! ",
"I can't get enough! ",
"I can't resist you! ",
"I crave your touch! ",
"I feel dizzy! ",
"I like your style! ",
"I love this game! ",
"I need you! ",
"I surrender to you! ",
"I want more! ",
"I yearn for your touch! ",
"I'm a furnace for you! ",
"I'm a raging inferno! ",
"I'm addicted to you! ",
"I'm all yours! ",
"I'm burning up! ",
"I'm completely yours! ",
"I'm consumed by you! ",
"I'm floating on air! ",
"I'm getting dizzy! ",
"I'm getting excited! ",
"I'm getting hot! ",
"I'm having a blast! ",
"I'm having a blast! ",
"I'm hooked on you! ",
"I'm in a tizzy! ",
"I'm in heaven! ",
"I'm in paradise! ",
"I'm lost in you! ",
"I'm melting! ",
"I'm on fire! ",
"I'm on the edge! ",
"I'm overflowing! ",
"I'm quivering with desire! ",
"I'm seeing stars! ",
"I'm shaking with anticipation! ",
"I'm so happy! ",
"I'm trembling! ",
"I'm under your spell! ",
"I'm yours for the taking! ",
"I'm yours forever! ",
"I'm yours to command! ",
"I'm yours! ",
"I'm yours, body and soul! ",
"I'm yours, now and forever! ",
"Is that all you've got? ",
"Keep shaking! ",
"Keep the rhythm going! ",
"Let's party! ",
"Let's play more! ",
"Like a record baby! ",
"Make me yours! ",
"Make me yours, completely! ",
"Missed me! ",
"More, more, more! ",
"My heart's racing! ",
"Neither can I! ",
"One more time! ",
"Playing hard to get? ",
"Round and round we go! ",
"Shake me harder! ",
"Show me what you've got! ",
"Show me your moves! ",
"So close! ",
"Spin me right round! ",
"Stop tickling! ",
"Take me to the edge! ",
"Take me! ",
"Take me, I'm yours! ",
"That tickles! ",
"That was fun! ",
"Too slow! ",
"Unleash me! ",
"Wait till I catch you! ",
"What a rush! ",
"Wheeee! ",
"Wheeeeeee! ",
"You drive me wild! ",
"You found me! ",
"You got me! ",
"You know how to party! ",
"You know what I like! ",
"You make me feel alive! ",
"You're absolute perfection! ",
"You're amazing! ",
"You're beyond incredible! ",
"You're driving me insane! ",
"You're driving me wild! ",
"You're fun! ",
"You're getting better! ",
"You're good at this! ",
"You're incredible! ",
"You're irresistible! ",
"You're making me blush! ",
"You're making me bounce! ",
"You're making me bounce! ",
"You're making me crazy! ",
"You're making me giddy! ",
"You're making me spin! ",
"You're making me swoon! ",
"You're making me twirl! ",
"You're my addiction! ",
"You're my desire! ",
"You're my dream! ",
"You're my everything and more! ",
"You're my everything! ",
"You're my fantasy! ",
"You're my heart's desire! ",
"You're my masterpiece! ",
"You're my obsession! ",
"You're my obsession! ",
"You're my temptation! ",
"You're my ultimate fantasy! ",
"You're my weakness! ",
"You're perfect! ",
"You're so good! ",
"You're so playful! ",
"You're such a tease! ",
"You're unstoppable! ",
"You've got the magic touch! ",
"Your touch is electric! "
];
+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;
}
+8 -7
View File
@@ -1,6 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";
/* 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;
@@ -59,19 +60,19 @@ body {
@keyframes float {
0% {
opacity: 0;
transform: translate(-50%, 10px);
transform: translateX(-50%) translateY(10px);
}
20% {
opacity: 1;
transform: translate(-50%, 0);
transform: translateX(-50%) translateY(0);
}
80% {
opacity: 1;
transform: translate(-50%, 0);
transform: translateX(-50%) translateY(0);
}
100% {
opacity: 0;
transform: translate(-50%, -10px);
transform: translateX(-50%) translateY(-10px);
}
}
-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 };
}
+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
};
}
+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;
}
+18 -4
View File
@@ -1,12 +1,16 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import { ThemeProvider } from './providers/ThemeProvider'
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 = {
metadataBase: new URL(appConfig.url),
title: appConfig.name,
description: appConfig.description,
icons: {
@@ -39,12 +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>
{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>
)
-170
View File
@@ -1,170 +0,0 @@
'use client';
import { useState, useEffect, useCallback } 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 { shakeConfig } from './config/shake';
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();
// 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 = useCallback((intensity: number) => {
// Increment shake counter to trigger new message
setShakeCount(count => count + 1);
// Start shake animation
setIsShaken(true);
// Reset shake after configured duration
setTimeout(() => {
setIsShaken(false);
}, shakeConfig.animations.shakeReset);
// Trigger hearts with configured duration
setShakeIntensity(intensity);
setTimeout(() => setShakeIntensity(0), shakeConfig.animations.heartsReset);
}, []);
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);
}
}
};
// 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(shakeConfig.defaultTriggerIntensity);
};
return (
<main className="flex h-[100dvh] flex-col items-center justify-between p-4 bg-green-50 dark:bg-slate-900 relative">
<ThemeToggle />
<div className="flex-1 flex flex-col items-center justify-center w-full relative">
<div className="fixed inset-0 overflow-hidden pointer-events-none">
<FloatingHearts intensity={shakeIntensity} />
</div>
<div
className="relative z-10"
onClick={handleClick}
>
<FloatingHearts intensity={shakeIntensity} />
<div className="relative">
<SpeechBubble isShaken={isShaken} triggerCount={shakeCount} />
<Image
src={isShaken ? '/images/frog-shaken.svg' : '/images/frog.svg'}
alt="Frog"
width={200}
height={200}
priority
className={isShaken ? 'animate-shake' : ''}
/>
</div>
</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${!isMobile ? ', press spacebar,' : ''} or click/tap frog!`
) : (
`${!isMobile ? 'Press spacebar or ' : ''}Click/tap frog!`
)}
</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"
>
shakethefrog
</a>
</footer>
</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;