494 lines
26 KiB
TypeScript
494 lines
26 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import axios from 'axios';
|
|
import { useAuth } from '../AuthContext';
|
|
import { Save, Key, Shield, User, ArrowLeft, CreditCard, Upload, Image as ImageIcon } from 'lucide-react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { Layout } from '../components/Layout';
|
|
import { ProductType } from '../types';
|
|
import { Tag } from 'lucide-react';
|
|
|
|
const SettingsPage: React.FC = () => {
|
|
const { user, refreshUser } = useAuth();
|
|
const navigate = useNavigate();
|
|
|
|
const [apiKey, setApiKey] = useState('');
|
|
const [savedKeyMasked, setSavedKeyMasked] = useState<string | null>(null);
|
|
const [etsyShopName, setEtsyShopName] = useState('');
|
|
const [etsyShopLink, setEtsyShopLink] = useState('');
|
|
const [etsyShopLogo, setEtsyShopLogo] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [brandingLoading, setBrandingLoading] = useState(false);
|
|
const [logoUploading, setLogoUploading] = useState(false);
|
|
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
|
|
|
|
// SKU State
|
|
const [skuConfig, setSkuConfig] = useState<Record<string, { prefix: string, next: number }>>({});
|
|
const [skuLoading, setSkuLoading] = useState(false);
|
|
|
|
const PRODUCT_TYPES: ProductType[] = ["Wall Art", "Bookmark", "Sticker", "Planner", "Phone Wallpaper", "Social Media Kit", "Label"];
|
|
|
|
useEffect(() => {
|
|
fetchApiKey();
|
|
fetchBranding();
|
|
// Check local storage for migration
|
|
const localKey = localStorage.getItem('gemini_api_key');
|
|
if (localKey && !apiKey && !savedKeyMasked) {
|
|
setApiKey(localKey);
|
|
}
|
|
}, []);
|
|
|
|
const fetchApiKey = async () => {
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
const res = await axios.get('/api/user/apikey', {
|
|
headers: { Authorization: `Bearer ${token}` }
|
|
});
|
|
if (res.data.apiKey) {
|
|
setSavedKeyMasked(res.data.apiKey);
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to fetch key", err);
|
|
}
|
|
};
|
|
|
|
const fetchBranding = async () => {
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
const res = await axios.get('/api/user/branding', {
|
|
headers: { Authorization: `Bearer ${token}` }
|
|
});
|
|
setEtsyShopName(res.data.etsyShopName || '');
|
|
setEtsyShopLink(res.data.etsyShopLink || '');
|
|
setEtsyShopLogo(res.data.etsyShopLogo || null);
|
|
} catch (err) {
|
|
console.error("Failed to fetch branding", err);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (user) {
|
|
setEtsyShopName(user.etsyShopName || '');
|
|
setEtsyShopLink(user.etsyShopLink || '');
|
|
setEtsyShopLogo(user.etsyShopLogo || null);
|
|
if (user.apiKey) setSavedKeyMasked(user.apiKey.replace(/.(?=.{4})/g, '*'));
|
|
|
|
try {
|
|
if (user.skuSettings) {
|
|
setSkuConfig(JSON.parse(user.skuSettings));
|
|
}
|
|
} catch (e) { }
|
|
}
|
|
}, [user]);
|
|
|
|
const handleSaveSku = async () => {
|
|
setSkuLoading(true);
|
|
setMessage(null);
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
await axios.post('/api/user/sku', { skuSettings: JSON.stringify(skuConfig) }, {
|
|
headers: { Authorization: `Bearer ${token}` }
|
|
});
|
|
setMessage({ type: 'success', text: 'SKU Settings saved successfully.' });
|
|
await refreshUser();
|
|
} catch (error: any) {
|
|
setMessage({ type: 'error', text: error.response?.data?.error || 'Failed to save SKU settings.' });
|
|
} finally {
|
|
setSkuLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleSaveKey = async () => {
|
|
setLoading(true);
|
|
setMessage(null);
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
await axios.post('/api/user/apikey', { apiKey }, {
|
|
headers: { Authorization: `Bearer ${token}` }
|
|
});
|
|
|
|
setMessage({ type: 'success', text: 'API Key saved securely to your profile.' });
|
|
setSavedKeyMasked(`${apiKey.substring(0, 4)}...${apiKey.substring(apiKey.length - 4)}`);
|
|
setApiKey(''); // Clear input for security
|
|
|
|
// Also update local storage for redundancy if needed, or clear it to enforce server side?
|
|
// Let's keep it in sync for now as a fallback if we revert logic.
|
|
localStorage.setItem('gemini_api_key', apiKey);
|
|
|
|
} catch (err: any) {
|
|
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to save key' });
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleSaveBranding = async () => {
|
|
setBrandingLoading(true);
|
|
setMessage(null);
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
await axios.post('/api/user/branding', { etsyShopName, etsyShopLink }, {
|
|
headers: { Authorization: `Bearer ${token}` }
|
|
});
|
|
setMessage({ type: 'success', text: 'Store branding updated successfully.' });
|
|
await refreshUser(); // Update global context field
|
|
} catch (err: any) {
|
|
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to update branding' });
|
|
} finally {
|
|
setBrandingLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleGodMode = async () => {
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
const res = await axios.post('http://localhost:3001/api/admin/grant-me-god-mode', {}, {
|
|
headers: { Authorization: `Bearer ${token}` }
|
|
});
|
|
|
|
if (res.data.token) {
|
|
localStorage.setItem('token', res.data.token);
|
|
// Force update context if possible, or just reload
|
|
}
|
|
|
|
alert(res.data.message);
|
|
window.location.reload();
|
|
} catch (err: any) {
|
|
console.error(err);
|
|
alert(err.response?.data?.error || "Failed to grant God Mode");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Layout>
|
|
<div className="max-w-3xl mx-auto p-6 pt-12 space-y-8">
|
|
<div className="flex items-center gap-4 mb-4">
|
|
<button
|
|
onClick={() => navigate('/')}
|
|
className="p-3 bg-white hover:bg-stone-100 border border-stone-200 rounded-2xl transition-all shadow-sm group"
|
|
>
|
|
<ArrowLeft className="w-5 h-5 text-stone-600 group-hover:-translate-x-1 transition-transform" />
|
|
</button>
|
|
<div>
|
|
<h1 className="text-3xl font-black tracking-tighter text-stone-900">Account Settings</h1>
|
|
<p className="text-stone-500 font-medium text-sm">Manage your profile and API security.</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Profile Card */}
|
|
<section className="bg-white rounded-xl shadow-sm border border-stone-200 p-6">
|
|
<div className="flex items-center gap-4 mb-6">
|
|
<div className="w-12 h-12 bg-purple-100 rounded-full flex items-center justify-center text-purple-600">
|
|
<User className="w-6 h-6" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-lg font-semibold">{user?.email}</h2>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
<span className="px-2 py-0.5 bg-stone-100 border border-stone-200 rounded text-xs text-stone-600 font-medium">
|
|
{user?.role}
|
|
</span>
|
|
<span className="px-2 py-0.5 bg-indigo-50 border border-indigo-100 rounded text-xs text-indigo-600 font-medium">
|
|
{user?.plan} Plan
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-4 bg-stone-50 rounded-lg border border-stone-100 flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-yellow-100 rounded-lg text-yellow-700">
|
|
<CreditCard className="w-5 h-5" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-stone-500 font-medium">Available Credits</p>
|
|
<p className="text-xl font-bold text-stone-800">{user?.credits}</p>
|
|
</div>
|
|
</div>
|
|
<button className="text-sm font-medium text-purple-600 hover:underline">
|
|
Top Up (Soon)
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
{/* API Key Management */}
|
|
<section className="bg-white rounded-xl shadow-sm border border-stone-200 p-6">
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<Key className="w-5 h-5 text-purple-600" />
|
|
<h3 className="text-lg font-semibold">Gemini API Key</h3>
|
|
</div>
|
|
|
|
<div className="mb-6 p-4 bg-blue-50 text-blue-800 rounded-lg text-sm border border-blue-100 flex gap-3">
|
|
<Shield className="w-5 h-5 flex-shrink-0" />
|
|
<p>
|
|
<strong>Beta Mode Requirement:</strong> Your personal request key is now stored securely in your profile.
|
|
This allows us to process your requests on our servers without you needing to re-enter it on every device.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-stone-700 mb-1">
|
|
{savedKeyMasked ? 'Update API Key' : 'Add API Key'}
|
|
</label>
|
|
<div className="relative">
|
|
<input
|
|
type="text"
|
|
value={apiKey}
|
|
onChange={(e) => setApiKey(e.target.value)}
|
|
placeholder={savedKeyMasked ? `Current: ${savedKeyMasked}` : "Paste your Google Gemini API Key here"}
|
|
className="w-full px-4 py-2 border border-stone-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:outline-none"
|
|
/>
|
|
{savedKeyMasked && !apiKey && (
|
|
<span className="absolute right-3 top-2.5 text-xs text-green-600 font-medium flex items-center gap-1">
|
|
<Shield className="w-3 h-3" /> Securely Saved
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="mt-2 text-xs text-stone-500">
|
|
Get your key from <a href="https://aistudio.google.com/app/apikey" target="_blank" rel="noreferrer" className="text-purple-600 hover:underline">Google AI Studio</a>.
|
|
</p>
|
|
</div>
|
|
|
|
{message && (
|
|
<div className={`text-sm p-3 rounded-lg ${message.type === 'success' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'}`}>
|
|
{message.text}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-end">
|
|
<button
|
|
onClick={handleSaveKey}
|
|
disabled={loading || apiKey.length < 10}
|
|
className={`flex items-center gap-2 px-6 py-2 rounded-lg font-medium text-white transition-all ${loading || apiKey.length < 10
|
|
? 'bg-stone-300 cursor-not-allowed'
|
|
: 'bg-stone-900 hover:bg-stone-800'
|
|
}`}
|
|
>
|
|
<Save className="w-4 h-4" />
|
|
{loading ? 'Saving...' : 'Save Key'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Store Branding */}
|
|
<section className="bg-white rounded-xl shadow-sm border border-stone-200 p-6">
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<Save className="w-5 h-5 text-indigo-600" />
|
|
<h3 className="text-lg font-semibold">Store Branding</h3>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-stone-700 mb-1">
|
|
Etsy Shop Name
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={etsyShopName}
|
|
onChange={(e) => setEtsyShopName(e.target.value)}
|
|
placeholder="e.g. MyAmazingStudio"
|
|
className="w-full px-4 py-2 border border-stone-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-stone-700 mb-1">
|
|
Etsy Shop Link
|
|
</label>
|
|
<input
|
|
type="url"
|
|
value={etsyShopLink}
|
|
onChange={(e) => setEtsyShopLink(e.target.value)}
|
|
placeholder="https://www.etsy.com/shop/..."
|
|
className="w-full px-4 py-2 border border-stone-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Logo Upload Section */}
|
|
<div className="mt-4 border-t border-stone-100 pt-4">
|
|
<label className="block text-sm font-medium text-stone-700 mb-3">
|
|
Shop Logo
|
|
</label>
|
|
<div className="flex items-center gap-6">
|
|
<div className="relative w-24 h-24 bg-stone-100 rounded-full border border-stone-200 flex items-center justify-center overflow-hidden shrink-0">
|
|
{etsyShopLogo ? (
|
|
<img
|
|
src={`/api/storage/${etsyShopLogo}`}
|
|
alt="Shop Logo"
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
) : (
|
|
<ImageIcon className="w-8 h-8 text-stone-300" />
|
|
)}
|
|
{logoUploading && (
|
|
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
|
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-3">
|
|
<label className="cursor-pointer inline-flex items-center gap-2 px-4 py-2 bg-white border border-stone-300 rounded-lg text-sm font-medium text-stone-700 hover:bg-stone-50 transition-colors shadow-sm">
|
|
<Upload className="w-4 h-4" />
|
|
{etsyShopLogo ? 'Change Logo' : 'Upload Logo'}
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={async (e) => {
|
|
if (!e.target.files || !e.target.files[0]) return;
|
|
const file = e.target.files[0];
|
|
setLogoUploading(true);
|
|
setMessage(null);
|
|
|
|
const formData = new FormData();
|
|
formData.append('logo', file);
|
|
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
const res = await axios.post('/api/user/logo', formData, {
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
'Content-Type': 'multipart/form-data'
|
|
}
|
|
});
|
|
setEtsyShopLogo(res.data.logoPath);
|
|
setMessage({ type: 'success', text: 'Logo uploaded successfully.' });
|
|
await refreshUser(); // Update global context field
|
|
} catch (err: any) {
|
|
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to upload logo' });
|
|
} finally {
|
|
setLogoUploading(false);
|
|
}
|
|
}}
|
|
/>
|
|
</label>
|
|
<p className="text-xs text-stone-500">
|
|
Recommended: 500x500px, PNG or JPG.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end">
|
|
<button
|
|
onClick={handleSaveBranding}
|
|
disabled={brandingLoading}
|
|
className={`flex items-center gap-2 px-6 py-2 rounded-lg font-medium text-white transition-all ${brandingLoading
|
|
? 'bg-stone-300 cursor-not-allowed'
|
|
: 'bg-stone-900 hover:bg-stone-800'
|
|
}`}
|
|
>
|
|
<Save className="w-4 h-4" />
|
|
{brandingLoading ? 'Updating...' : 'Save Branding'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* SKU MANAGEMENT */}
|
|
<section className="bg-white rounded-xl shadow-sm border border-stone-200 p-6">
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<Tag className="w-5 h-5 text-emerald-600" />
|
|
<h3 className="text-lg font-semibold">SKU Configuration</h3>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-stone-500">
|
|
Set up prefixes and starting numbers for automatic SKU generation (e.g., WLR105).
|
|
</p>
|
|
|
|
<div className="bg-stone-50 rounded-xl overflow-hidden border border-stone-200">
|
|
<table className="w-full text-sm text-left">
|
|
<thead className="bg-stone-100 text-stone-600 font-bold uppercase text-xs">
|
|
<tr>
|
|
<th className="px-4 py-3">Product Type</th>
|
|
<th className="px-4 py-3">Prefix (3-4 Chars)</th>
|
|
<th className="px-4 py-3">Next Number</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-stone-200">
|
|
{PRODUCT_TYPES.map((type) => {
|
|
const config = skuConfig[type] || { prefix: '', next: 1 };
|
|
return (
|
|
<tr key={type} className="hover:bg-white transition-colors">
|
|
<td className="px-4 py-3 font-medium text-stone-700">{type}</td>
|
|
<td className="px-4 py-3">
|
|
<input
|
|
type="text"
|
|
maxLength={4}
|
|
placeholder="e.g. WLR"
|
|
value={config.prefix}
|
|
onChange={(e) => {
|
|
const val = e.target.value.toUpperCase().replace(/[^A-Z]/g, '');
|
|
setSkuConfig(prev => ({
|
|
...prev,
|
|
[type]: { ...prev[type], prefix: val, next: prev[type]?.next || 1 }
|
|
}));
|
|
}}
|
|
className="w-24 px-2 py-1 border border-stone-300 rounded font-mono uppercase focus:ring-2 focus:ring-emerald-500 focus:outline-none"
|
|
/>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
value={config.next}
|
|
onChange={(e) => {
|
|
const val = parseInt(e.target.value) || 1;
|
|
setSkuConfig(prev => ({
|
|
...prev,
|
|
[type]: { ...prev[type], prefix: prev[type]?.prefix || '', next: val }
|
|
}));
|
|
}}
|
|
className="w-24 px-2 py-1 border border-stone-300 rounded font-mono focus:ring-2 focus:ring-emerald-500 focus:outline-none"
|
|
/>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div className="flex justify-end">
|
|
<button
|
|
onClick={handleSaveSku}
|
|
disabled={skuLoading}
|
|
className={`flex items-center gap-2 px-6 py-2 rounded-lg font-medium text-white transition-all ${skuLoading
|
|
? 'bg-stone-300 cursor-not-allowed'
|
|
: 'bg-stone-900 hover:bg-stone-800'
|
|
}`}
|
|
>
|
|
<Save className="w-4 h-4" />
|
|
{skuLoading ? 'Saving...' : 'Save Configuration'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="bg-red-50 rounded-xl shadow-sm border border-red-100 p-6">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<Shield className="w-5 h-5 text-red-600" />
|
|
<h3 className="text-lg font-bold text-red-900">Emergency Admin Access</h3>
|
|
</div>
|
|
<p className="text-sm text-red-700 mb-4">
|
|
If you are unable to access admin features or are stuck with credit limits, use this button to force-upgrade your account to ADMIN status.
|
|
</p>
|
|
<button
|
|
onClick={handleGodMode}
|
|
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white font-bold rounded-lg transition-colors shadow-sm"
|
|
>
|
|
⚡ Grant Me Admin God Mode
|
|
</button>
|
|
</section>
|
|
</div>
|
|
</Layout >
|
|
);
|
|
};
|
|
|
|
export default SettingsPage;
|