Initial commit
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
interface BlurOverlayProps {
|
||||
onUnlock: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function BlurOverlay({ onUnlock, loading }: BlurOverlayProps) {
|
||||
return (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center backdrop-blur-md bg-white/60 rounded-lg">
|
||||
<div className="text-center p-8 max-w-md">
|
||||
<div className="text-4xl mb-4">🔒</div>
|
||||
<h3 className="text-xl font-bold text-dark mb-2">Unlock Full Output</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Your complete, professional-quality output is ready. Unlock it to access the full document, improvements, validation, and exports.
|
||||
</p>
|
||||
<button
|
||||
onClick={onUnlock}
|
||||
disabled={loading}
|
||||
className="bg-accent hover:bg-accent/90 text-white px-8 py-3 rounded-lg font-semibold transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Processing...' : 'Unlock Now — Pay Once'}
|
||||
</button>
|
||||
<p className="text-xs text-gray-400 mt-3">Secure payment via LemonSqueezy</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
interface EditableOutputProps {
|
||||
content: string;
|
||||
sessionId: string;
|
||||
tool: string;
|
||||
isPaid: boolean;
|
||||
improvementsUsed: number;
|
||||
maxImprovements: number;
|
||||
onImproved: (newContent: string) => void;
|
||||
}
|
||||
|
||||
export default function EditableOutput({
|
||||
content,
|
||||
sessionId,
|
||||
tool,
|
||||
isPaid,
|
||||
improvementsUsed,
|
||||
maxImprovements,
|
||||
onImproved,
|
||||
}: EditableOutputProps) {
|
||||
const [feedback, setFeedback] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [validation, setValidation] = useState<string | null>(null);
|
||||
|
||||
const handleImprove = async () => {
|
||||
if (!feedback.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/${tool}/improve-section`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_id: sessionId, section: content, feedback }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.improved) {
|
||||
onImproved(data.improved);
|
||||
setFeedback('');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Improve failed:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleValidate = async () => {
|
||||
setValidating(true);
|
||||
try {
|
||||
const res = await fetch(`/api/${tool}/validate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_id: sessionId }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.validation) setValidation(data.validation);
|
||||
} catch (e) {
|
||||
console.error('Validate failed:', e);
|
||||
} finally {
|
||||
setValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remaining = maxImprovements - improvementsUsed;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="prose prose-sm max-w-none bg-gray-50 p-6 rounded-lg border whitespace-pre-wrap font-mono text-sm">
|
||||
{content}
|
||||
</div>
|
||||
|
||||
{isPaid && (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleValidate}
|
||||
disabled={validating}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{validating ? 'Analysing...' : '🔍 Analyse Quality'}
|
||||
</button>
|
||||
<span className="text-xs text-gray-400 self-center">
|
||||
{remaining} improvements remaining
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{validation && (
|
||||
<div className="bg-blue-50 border border-blue-200 p-4 rounded-lg">
|
||||
<h4 className="font-semibold text-blue-800 mb-2">Quality Assessment</h4>
|
||||
<div className="text-sm whitespace-pre-wrap">{validation}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{remaining > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
placeholder="Describe what to improve..."
|
||||
className="flex-1 border rounded-lg px-3 py-2 text-sm"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleImprove()}
|
||||
/>
|
||||
<button
|
||||
onClick={handleImprove}
|
||||
disabled={loading || !feedback.trim()}
|
||||
className="px-4 py-2 bg-accent text-white rounded-lg text-sm hover:bg-accent/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Improving...' : '✨ Improve'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
interface ExportMenuProps {
|
||||
sessionId: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function ExportMenu({ sessionId, disabled }: ExportMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [exporting, setExporting] = useState<string | null>(null);
|
||||
|
||||
const handleExport = async (format: string) => {
|
||||
setExporting(format);
|
||||
try {
|
||||
if (format === 'copy') {
|
||||
const res = await fetch(`/api/export/copy`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_id: sessionId }),
|
||||
});
|
||||
const data = await res.json();
|
||||
await navigator.clipboard.writeText(data.text);
|
||||
alert('Copied to clipboard!');
|
||||
} else {
|
||||
const res = await fetch(`/api/export/${format}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_id: sessionId }),
|
||||
});
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `output.${format === 'excel' ? 'xlsx' : format}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Export failed:', e);
|
||||
} finally {
|
||||
setExporting(null);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formats = [
|
||||
{ key: 'copy', label: '📋 Copy to Clipboard', ext: '' },
|
||||
{ key: 'docx', label: '📄 Word Document', ext: '.docx' },
|
||||
{ key: 'excel', label: '📊 Excel Spreadsheet', ext: '.xlsx' },
|
||||
{ key: 'pdf', label: '📕 PDF Document', ext: '.pdf' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
disabled={disabled}
|
||||
className="px-4 py-2 bg-dark text-white rounded-lg text-sm hover:bg-dark/90 disabled:opacity-50"
|
||||
>
|
||||
📥 Export
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 bg-white border rounded-lg shadow-lg py-1 w-56 z-20">
|
||||
{formats.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
onClick={() => handleExport(f.key)}
|
||||
disabled={exporting !== null}
|
||||
className="w-full text-left px-4 py-2 hover:bg-gray-50 text-sm disabled:opacity-50"
|
||||
>
|
||||
{exporting === f.key ? 'Exporting...' : f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import BlurOverlay from './BlurOverlay';
|
||||
import EditableOutput from './EditableOutput';
|
||||
import ExportMenu from './ExportMenu';
|
||||
|
||||
type State = 'idle' | 'generating' | 'preview' | 'unlocked' | 'done';
|
||||
|
||||
interface OutputPanelProps {
|
||||
tool: string;
|
||||
sessionId: string | null;
|
||||
preview: string | null;
|
||||
fullOutput: string | null;
|
||||
isPaid: boolean;
|
||||
onPaymentRequest: () => void;
|
||||
paymentLoading?: boolean;
|
||||
}
|
||||
|
||||
export default function OutputPanel({
|
||||
tool,
|
||||
sessionId,
|
||||
preview,
|
||||
fullOutput,
|
||||
isPaid,
|
||||
onPaymentRequest,
|
||||
paymentLoading,
|
||||
}: OutputPanelProps) {
|
||||
const [output, setOutput] = useState(fullOutput);
|
||||
const [finalizing, setFinalizing] = useState(false);
|
||||
|
||||
const state: State = !sessionId
|
||||
? 'idle'
|
||||
: !preview
|
||||
? 'generating'
|
||||
: !isPaid
|
||||
? 'preview'
|
||||
: finalizing
|
||||
? 'done'
|
||||
: 'unlocked';
|
||||
|
||||
const displayOutput = output || fullOutput;
|
||||
|
||||
const handleFinalize = async () => {
|
||||
setFinalizing(true);
|
||||
try {
|
||||
const res = await fetch(`/api/${tool}/finalize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_id: sessionId }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.finalized) setOutput(data.finalized);
|
||||
} catch (e) {
|
||||
console.error('Finalize failed:', e);
|
||||
}
|
||||
};
|
||||
|
||||
if (state === 'idle') {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-gray-400">
|
||||
<div className="text-center">
|
||||
<div className="text-5xl mb-4">📝</div>
|
||||
<p>Fill in the form and click Generate to get started</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === 'generating') {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin text-4xl mb-4">⚙️</div>
|
||||
<p className="text-gray-600 font-medium">Generating your output...</p>
|
||||
<p className="text-gray-400 text-sm mt-1">This may take 15-30 seconds</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{state === 'preview' && (
|
||||
<>
|
||||
<div className="prose prose-sm max-w-none bg-gray-50 p-6 rounded-lg border whitespace-pre-wrap font-mono text-sm">
|
||||
{preview}
|
||||
<span className="text-gray-300">{'...'.repeat(20)}</span>
|
||||
</div>
|
||||
<BlurOverlay onUnlock={onPaymentRequest} loading={paymentLoading} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{(state === 'unlocked' || state === 'done') && sessionId && displayOutput && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-dark">Your Output</h3>
|
||||
<div className="flex gap-2">
|
||||
{!finalizing && (
|
||||
<button
|
||||
onClick={handleFinalize}
|
||||
className="px-4 py-2 bg-accent text-white rounded-lg text-sm hover:bg-accent/90"
|
||||
>
|
||||
🎯 Finalize
|
||||
</button>
|
||||
)}
|
||||
<ExportMenu sessionId={sessionId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EditableOutput
|
||||
content={displayOutput}
|
||||
sessionId={sessionId}
|
||||
tool={tool}
|
||||
isPaid={true}
|
||||
improvementsUsed={0}
|
||||
maxImprovements={5}
|
||||
onImproved={setOutput}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { FormField } from '@/lib/types';
|
||||
|
||||
interface ToolFormProps {
|
||||
fields: FormField[];
|
||||
onSubmit: (data: Record<string, string>) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function ToolForm({ fields, onSubmit, loading }: ToolFormProps) {
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit(values);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{fields.map((field) => (
|
||||
<div key={field.name}>
|
||||
<label className="block text-sm font-medium text-dark mb-1">
|
||||
{field.label}
|
||||
{field.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
{field.type === 'textarea' ? (
|
||||
<textarea
|
||||
value={values[field.name] || ''}
|
||||
onChange={(e) => setValues({ ...values, [field.name]: e.target.value })}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
rows={4}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-accent/50 focus:border-accent"
|
||||
/>
|
||||
) : field.type === 'select' ? (
|
||||
<select
|
||||
value={values[field.name] || ''}
|
||||
onChange={(e) => setValues({ ...values, [field.name]: e.target.value })}
|
||||
required={field.required}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-accent/50 focus:border-accent"
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
{field.options?.map((opt) => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={values[field.name] || ''}
|
||||
onChange={(e) => setValues({ ...values, [field.name]: e.target.value })}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-accent/50 focus:border-accent"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-accent hover:bg-accent/90 text-white py-3 rounded-lg font-semibold transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Generating...' : '✨ Generate'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user