Flesh out payment stuff

This commit is contained in:
Joey Yakimowich-Payne 2026-01-22 12:21:12 -07:00
commit acfed861ab
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
27 changed files with 938 additions and 173 deletions

View file

@ -1,9 +1,18 @@
import React, { useState, useEffect, useRef } from 'react';
import { motion } from 'framer-motion';
import { X, Key, Eye, EyeOff, Loader2, ChevronDown, Search } from 'lucide-react';
import { X, Key, Eye, EyeOff, Loader2, ChevronDown, Search, CreditCard, CheckCircle } from 'lucide-react';
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
import { useAuthenticatedFetch } from '../hooks/useAuthenticatedFetch';
import type { AIProvider, UserPreferences } from '../types';
interface SubscriptionInfo {
hasAccess: boolean;
accessType: 'group' | 'subscription' | 'none';
generationCount: number | null;
generationLimit: number | null;
generationsRemaining: number | null;
}
interface OpenAIModel {
id: string;
owned_by: string;
@ -16,6 +25,8 @@ interface ApiKeyModalProps {
onSave: (prefs: Partial<UserPreferences>) => Promise<void>;
saving: boolean;
hasAIAccess: boolean;
subscription?: SubscriptionInfo | null;
hasEarlyAccess?: boolean;
}
export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
@ -25,8 +36,11 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
onSave,
saving,
hasAIAccess,
subscription,
hasEarlyAccess,
}) => {
useBodyScrollLock(isOpen);
const { authFetch } = useAuthenticatedFetch();
const [localProvider, setLocalProvider] = useState<AIProvider>(preferences.aiProvider || 'gemini');
const [localGeminiKey, setLocalGeminiKey] = useState(preferences.geminiApiKey || '');
const [localGeminiModel, setLocalGeminiModel] = useState(preferences.geminiModel || '');
@ -44,6 +58,10 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
const modelDropdownRef = useRef<HTMLDivElement>(null);
const [portalLoading, setPortalLoading] = useState(false);
const [portalError, setPortalError] = useState<string | null>(null);
const [showPortalBanner, setShowPortalBanner] = useState(false);
useEffect(() => {
if (isOpen) {
setLocalProvider(preferences.aiProvider || 'gemini');
@ -55,9 +73,21 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
setLocalOpenAIModel(preferences.openAIModel || '');
setModelSearchQuery('');
setIsModelDropdownOpen(false);
const params = new URLSearchParams(window.location.search);
if (params.get('portal') === 'return') {
setShowPortalBanner(true);
}
}
}, [isOpen, preferences]);
const dismissPortalBanner = () => {
setShowPortalBanner(false);
const url = new URL(window.location.href);
url.searchParams.delete('portal');
window.history.replaceState({}, '', url.toString());
};
useEffect(() => {
const fetchOpenAIModels = async () => {
if (!localOpenAIKey || localOpenAIKey.length < 10) {
@ -123,6 +153,37 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
onClose();
};
const handleManageSubscription = async () => {
setPortalLoading(true);
setPortalError(null);
try {
const response = await authFetch('/api/payments/portal', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
returnUrl: `${window.location.origin}?portal=return`,
}),
});
if (!response.ok) {
throw new Error('Failed to create customer portal session');
}
const data = await response.json();
if (data.url) {
window.location.href = data.url;
} else {
throw new Error('No portal URL returned');
}
} catch (err) {
console.error('Portal error:', err);
setPortalError('Failed to open subscription management. Please try again.');
setPortalLoading(false);
}
};
return (
<motion.div
initial={{ opacity: 0 }}
@ -157,6 +218,64 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
</div>
<div className="p-6 bg-gray-50 space-y-4">
{showPortalBanner && (
<div className="bg-green-50 p-4 rounded-xl border border-green-200 shadow-sm flex items-start gap-3">
<div className="p-1 bg-green-100 rounded-lg text-green-600">
<CheckCircle size={20} />
</div>
<div className="flex-1">
<h3 className="font-bold text-green-900">Subscription Updated</h3>
<p className="text-sm text-green-700 mt-1">
Your subscription settings have been updated successfully.
</p>
</div>
<button
onClick={dismissPortalBanner}
className="text-green-500 hover:text-green-700 transition"
>
<X size={20} />
</button>
</div>
)}
{subscription?.accessType === 'subscription' && (
<div className="mb-6 bg-white p-4 rounded-xl border border-gray-200 shadow-sm">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-theme-primary font-bold">
<CreditCard size={20} />
<span>Subscription Active</span>
</div>
{subscription.generationsRemaining !== null && (
<span className="text-sm font-bold bg-theme-primary/10 text-theme-primary px-2 py-1 rounded-lg">
{subscription.generationsRemaining} generations left
</span>
)}
</div>
<p className="text-sm text-gray-500 mb-3">
Manage your billing, payment methods, and invoices.
</p>
{portalError && (
<p className="text-sm text-red-500 mb-3 bg-red-50 p-2 rounded-lg border border-red-100">
{portalError}
</p>
)}
<button
onClick={handleManageSubscription}
disabled={portalLoading}
className="w-full py-2 bg-gray-100 hover:bg-gray-200 text-gray-800 rounded-lg font-bold text-sm transition-colors flex items-center justify-center gap-2 disabled:opacity-50"
>
{portalLoading ? (
<>
<Loader2 size={16} className="animate-spin" />
Redirecting...
</>
) : (
'Manage Subscription'
)}
</button>
</div>
)}
<div>
<label className="block font-bold text-gray-800 mb-2">AI Provider</label>
<div className="flex bg-gray-200 p-1 rounded-xl">
@ -194,6 +313,9 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
OpenRouter
</button>
</div>
<p className="text-xs text-gray-500 mt-2 ml-1">
Choose the provider you want to use for quiz generation.
</p>
</div>
{localProvider === 'gemini' && (

View file

@ -12,6 +12,7 @@ interface DefaultConfigModalProps {
onChange: (config: GameConfig) => void;
onSave: () => void;
saving: boolean;
maxPlayersLimit?: number;
}
export const DefaultConfigModal: React.FC<DefaultConfigModalProps> = ({
@ -21,6 +22,7 @@ export const DefaultConfigModal: React.FC<DefaultConfigModalProps> = ({
onChange,
onSave,
saving,
maxPlayersLimit = 150,
}) => {
useBodyScrollLock(isOpen);
@ -41,7 +43,7 @@ export const DefaultConfigModal: React.FC<DefaultConfigModalProps> = ({
className="bg-white rounded-2xl max-w-lg w-full shadow-xl max-h-[90vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="p-6 border-b border-gray-100 flex items-center justify-between bg-gradient-to-r from-theme-primary to-purple-600">
<div className="p-6 border-b border-gray-100 flex items-center justify-between bg-theme-primary">
<div className="flex items-center gap-3 text-white">
<div className="p-2 bg-white/20 rounded-xl">
<Settings size={24} />
@ -64,6 +66,7 @@ export const DefaultConfigModal: React.FC<DefaultConfigModalProps> = ({
config={config}
onChange={onChange}
questionCount={10}
maxPlayersLimit={maxPlayersLimit}
/>
</div>

View file

@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Shuffle, Eye, Flame, TrendingUp, MinusCircle, Award, Info, ChevronDown, ChevronUp, Dices } from 'lucide-react';
import { Shuffle, Eye, Flame, TrendingUp, MinusCircle, Award, Info, ChevronDown, ChevronUp, Dices, Users } from 'lucide-react';
import type { GameConfig } from '../types';
interface GameConfigPanelProps {
@ -7,6 +7,7 @@ interface GameConfigPanelProps {
onChange: (config: GameConfig) => void;
questionCount: number;
compact?: boolean;
maxPlayersLimit?: number;
}
interface TooltipProps {
@ -128,6 +129,42 @@ const ToggleRow: React.FC<ToggleRowProps> = ({
);
};
interface ValueRowProps {
icon: React.ReactNode;
label: string;
description: string;
tooltip?: string;
children: React.ReactNode;
}
const ValueRow: React.FC<ValueRowProps> = ({
icon,
label: labelText,
description,
tooltip,
children,
}) => {
return (
<div className="bg-white rounded-xl border-2 border-gray-200 overflow-hidden">
<div className="flex items-center justify-between p-4 hover:bg-gray-50 transition group">
<div className="flex items-center gap-3 flex-1">
<div className="p-2 rounded-lg bg-theme-primary text-white transition">
{icon}
</div>
<div>
<p className="font-bold text-gray-900">{labelText}</p>
<p className="text-sm text-gray-500">{description}</p>
</div>
</div>
<div className="flex items-center gap-2">
{tooltip && <Tooltip content={tooltip} />}
{children}
</div>
</div>
</div>
);
};
interface NumberInputProps {
label: string;
value: number;
@ -161,9 +198,17 @@ export const GameConfigPanel: React.FC<GameConfigPanelProps> = ({
onChange,
questionCount,
compact = false,
maxPlayersLimit = 150,
}) => {
const [expanded, setExpanded] = useState(!compact);
// Clamp maxPlayers if it exceeds the limit
React.useEffect(() => {
if (config.maxPlayers && config.maxPlayers > maxPlayersLimit) {
onChange({ ...config, maxPlayers: maxPlayersLimit });
}
}, [maxPlayersLimit, config.maxPlayers, onChange]);
const update = (partial: Partial<GameConfig>) => {
onChange({ ...config, ...partial });
};
@ -195,6 +240,29 @@ export const GameConfigPanel: React.FC<GameConfigPanelProps> = ({
</button>
)}
<ValueRow
icon={<Users size={20} />}
label="Max Players"
description="Limit the number of players who can join"
tooltip="The maximum number of players allowed in the game (2-150). Subscription required for >10 players."
>
<div className="flex flex-col items-end">
<NumberInput
label=""
value={config.maxPlayers || 10}
onChange={(v) => update({ maxPlayers: Math.min(v, maxPlayersLimit) })}
min={2}
max={maxPlayersLimit}
suffix="players"
/>
{maxPlayersLimit < 150 && (
<span className="text-xs text-amber-600 font-bold">
Free plan max: {maxPlayersLimit} players
</span>
)}
</div>
</ValueRow>
<ToggleRow
icon={<Shuffle size={20} />}
iconActive={config.shuffleQuestions}

View file

@ -133,13 +133,19 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
const showOcrOption = hasImageFile || hasDocumentFile;
const { defaultConfig, saving: savingConfig, saveDefaultConfig } = useUserConfig();
const { preferences, hasAIAccess, subscription, saving: savingPrefs, savePreferences, applyColorScheme } = useUserPreferences();
const { preferences, hasAIAccess, hasEarlyAccess, subscription, saving: savingPrefs, savePreferences, applyColorScheme } = useUserPreferences();
const maxPlayersLimit = (!subscription || subscription.accessType === 'none') ? 10 : 150;
const hasValidApiKey = (() => {
if (preferences.aiProvider === 'openrouter') return !!preferences.openRouterApiKey;
if (preferences.aiProvider === 'openai') return !!preferences.openAIApiKey;
return hasAIAccess || !!preferences.geminiApiKey;
return !!preferences.geminiApiKey;
})();
const canUseAI = auth.isAuthenticated && hasValidApiKey;
const canUseSystemGeneration = auth.isAuthenticated && (
hasAIAccess ||
subscription?.accessType === 'subscription' ||
(subscription?.accessType === 'none' && subscription.generationsRemaining !== null && subscription.generationsRemaining > 0)
);
const canUseAI = auth.isAuthenticated && (hasValidApiKey || canUseSystemGeneration);
const {
quizzes,
@ -249,15 +255,21 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
const handleHostSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (canGenerate && !isLoading) {
const aiProvider = preferences.aiProvider || 'gemini';
const selectedProvider = preferences.aiProvider || 'gemini';
let aiProvider = selectedProvider;
let apiKey: string | undefined;
if (aiProvider === 'openrouter') {
if (selectedProvider === 'openrouter') {
apiKey = preferences.openRouterApiKey;
} else if (aiProvider === 'openai') {
} else if (selectedProvider === 'openai') {
apiKey = preferences.openAIApiKey;
} else {
apiKey = preferences.geminiApiKey;
}
if (!apiKey && selectedProvider === 'gemini' && canUseSystemGeneration) {
aiProvider = 'system';
}
onGenerate({
topic: generateMode === 'topic' ? topic.trim() : undefined,
@ -327,6 +339,14 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
<span className="text-gray-400">left</span>
</div>
)}
{auth.isAuthenticated && subscription && subscription.accessType === 'none' && subscription.generationsRemaining !== null && (
<div className="flex items-center gap-2 bg-white/90 px-3 py-2 rounded-xl shadow-md text-sm font-bold">
<span className="text-gray-500">Free tier:</span>
<span className="text-gray-700">{subscription.generationsRemaining}</span>
<span className="text-gray-400">left</span>
</div>
)}
{auth.isAuthenticated && !hasAIAccess && (!subscription || subscription.accessType === 'none') && (
<a
@ -554,6 +574,14 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
/>
</div>
{auth.isAuthenticated && subscription && subscription.accessType === 'none' && subscription.generationsRemaining !== null && (
<div className="flex justify-center items-center gap-1.5 text-xs font-bold pt-2">
<span className="text-gray-500">Free tier:</span>
<span className="text-gray-700">{subscription.generationsRemaining}</span>
<span className="text-gray-400">generations left</span>
</div>
)}
<button
type="submit"
disabled={isLoading || !canGenerate}
@ -720,6 +748,7 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
}
}}
saving={savingConfig}
maxPlayersLimit={maxPlayersLimit}
/>
<PreferencesModal
@ -740,6 +769,8 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
}}
saving={savingPrefs}
hasAIAccess={hasAIAccess}
subscription={subscription}
hasEarlyAccess={hasEarlyAccess}
/>
</div>
);

View file

@ -63,19 +63,19 @@ export const PreferencesModal: React.FC<PreferencesModalProps> = ({
className="bg-white rounded-2xl max-w-lg w-full shadow-xl max-h-[90vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="p-6 border-b border-gray-100 flex items-center justify-between bg-white">
<div className="flex items-center gap-3 text-gray-900">
<div className="p-2 bg-gray-100 rounded-xl">
<div className="p-6 border-b border-gray-100 flex items-center justify-between bg-theme-primary">
<div className="flex items-center gap-3 text-white">
<div className="p-2 bg-white/20 rounded-xl">
<Palette size={24} />
</div>
<div>
<h2 className="text-xl font-black">Color Scheme</h2>
<p className="text-sm text-gray-500">Customize your theme</p>
<p className="text-sm opacity-80">Customize your theme</p>
</div>
</div>
<button
onClick={handleClose}
className="p-2 hover:bg-gray-100 rounded-xl transition text-gray-400 hover:text-gray-600"
className="p-2 hover:bg-white/20 rounded-xl transition text-white"
>
<X size={24} />
</button>

View file

@ -19,6 +19,7 @@ interface QuizEditorProps {
showSaveButton?: boolean;
isSaving?: boolean;
defaultConfig?: GameConfig;
maxPlayersLimit?: number;
}
export const QuizEditor: React.FC<QuizEditorProps> = ({
@ -30,6 +31,7 @@ export const QuizEditor: React.FC<QuizEditorProps> = ({
showSaveButton = true,
isSaving,
defaultConfig,
maxPlayersLimit = 150,
}) => {
const [quiz, setQuiz] = useState<Quiz>(initialQuiz);
const [expandedId, setExpandedId] = useState<string | null>(null);
@ -300,6 +302,7 @@ export const QuizEditor: React.FC<QuizEditorProps> = ({
onChange={handleConfigChange}
questionCount={quiz.questions.length}
compact={false}
maxPlayersLimit={maxPlayersLimit}
/>
</div>
)}