Flesh out payment stuff
This commit is contained in:
parent
b0dcdd6438
commit
acfed861ab
27 changed files with 938 additions and 173 deletions
|
|
@ -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' && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue