feat: Integrate Stripe payment processing and enhance admin settings management
- Removed the previous demo payment system and implemented a real integration with Stripe for handling payments. - Added new API routes for creating payment intents and handling webhooks from Stripe. - Updated the database schema to include an Order model for storing payment details. - Enhanced the admin page to manage pricing items, including the ability to upload 3D models. - Introduced a settings management feature for the admin panel, allowing for dynamic key-value pairs. - Improved the RobotModel component to support dynamic attire based on uploaded models. - Added error handling and validation for file uploads and settings management.
This commit is contained in:
+337
-20
@@ -15,7 +15,7 @@ interface Order {
|
||||
metadata: Record<string, string>;
|
||||
}
|
||||
|
||||
type Tab = 'pricing' | 'personas' | 'orders';
|
||||
type Tab = 'pricing' | 'personas' | 'orders' | 'settings';
|
||||
|
||||
export default function AdminPage() {
|
||||
const router = useRouter();
|
||||
@@ -25,16 +25,25 @@ export default function AdminPage() {
|
||||
const items = usePricingStore((s) => s.items);
|
||||
const isPricingHydrated = usePricingStore((s) => s.isHydrated);
|
||||
const [editedPrices, setEditedPrices] = useState<Record<string, number>>({});
|
||||
const [editedLabels, setEditedLabels] = useState<Record<string, string>>({});
|
||||
const [priceSaved, setPriceSaved] = useState(false);
|
||||
const [newItem, setNewItem] = useState({ id: '', label: '', price: '' });
|
||||
const [addItemGlb, setAddItemGlb] = useState<File | null>(null);
|
||||
const [addItemUploading, setAddItemUploading] = useState(false);
|
||||
const [addItemError, setAddItemError] = useState('');
|
||||
// Per-row GLB upload state
|
||||
const [rowGlbFiles, setRowGlbFiles] = useState<Record<string, File | null>>({});
|
||||
const [rowGlbUploading, setRowGlbUploading] = useState<Record<string, boolean>>({});
|
||||
const [rowGlbError, setRowGlbError] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => { pricingStore.getState().hydrate(); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
const map: Record<string, number> = {};
|
||||
items.forEach((item) => { map[item.id] = item.price; });
|
||||
setEditedPrices(map);
|
||||
const priceMap: Record<string, number> = {};
|
||||
const labelMap: Record<string, string> = {};
|
||||
items.forEach((item) => { priceMap[item.id] = item.price; labelMap[item.id] = item.label; });
|
||||
setEditedPrices(priceMap);
|
||||
setEditedLabels(labelMap);
|
||||
}, [items]);
|
||||
|
||||
const handlePriceChange = (id: string, value: string) => {
|
||||
@@ -43,16 +52,56 @@ export default function AdminPage() {
|
||||
};
|
||||
|
||||
const handleSavePrices = () => {
|
||||
Object.entries(editedPrices).forEach(([id, price]) => {
|
||||
pricingStore.getState().updatePrice(id, price);
|
||||
items.forEach((item) => {
|
||||
pricingStore.getState().updateItem(item.id, {
|
||||
price: editedPrices[item.id] ?? item.price,
|
||||
label: (editedLabels[item.id] ?? item.label).trim() || item.label,
|
||||
});
|
||||
});
|
||||
setPriceSaved(true);
|
||||
setTimeout(() => setPriceSaved(false), 2000);
|
||||
};
|
||||
|
||||
const handleAddItem = () => {
|
||||
const handleRowGlbUpload = async (itemId: string) => {
|
||||
const file = rowGlbFiles[itemId];
|
||||
if (!file) return;
|
||||
setRowGlbUploading((p) => ({ ...p, [itemId]: true }));
|
||||
setRowGlbError((p) => ({ ...p, [itemId]: '' }));
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('itemId', itemId);
|
||||
const res = await fetch('/api/admin/upload-model/', { method: 'POST', body: fd });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? 'Upload failed');
|
||||
const modelPath = data.modelPath as string;
|
||||
// Update pricing item
|
||||
pricingStore.getState().updateItem(itemId, { modelPath });
|
||||
// Update or create persona
|
||||
const existing = personaStore.getState().personas.find((p) => p.id === itemId);
|
||||
if (existing) {
|
||||
personaStore.getState().updatePersona(itemId, { modelPath });
|
||||
} else {
|
||||
const item = pricingStore.getState().items.find((i) => i.id === itemId);
|
||||
personaStore.getState().addPersona({
|
||||
id: itemId,
|
||||
label: item?.label ?? itemId,
|
||||
description: item?.label ?? itemId,
|
||||
colors: { torso: '#3b82f6', legs: '#3b82f6' },
|
||||
modelPath,
|
||||
});
|
||||
}
|
||||
setRowGlbFiles((p) => ({ ...p, [itemId]: null }));
|
||||
} catch (err) {
|
||||
setRowGlbError((p) => ({ ...p, [itemId]: err instanceof Error ? err.message : 'Upload failed' }));
|
||||
} finally {
|
||||
setRowGlbUploading((p) => ({ ...p, [itemId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddItem = async () => {
|
||||
setAddItemError('');
|
||||
const id = newItem.id.trim().toLowerCase().replace(/\s+/g, '-');
|
||||
const id = newItem.id.trim().toLowerCase().replace(/_/g, '-').replace(/\s+/g, '-');
|
||||
const label = newItem.label.trim();
|
||||
const price = parseInt(newItem.price, 10);
|
||||
if (!id || !label || isNaN(price) || price < 0) {
|
||||
@@ -63,8 +112,43 @@ export default function AdminPage() {
|
||||
setAddItemError(`ID "${id}" already exists.`);
|
||||
return;
|
||||
}
|
||||
pricingStore.getState().addItem({ id, label, price });
|
||||
|
||||
let modelPath: string | undefined;
|
||||
|
||||
if (addItemGlb) {
|
||||
setAddItemUploading(true);
|
||||
const fd = new FormData();
|
||||
fd.append('file', addItemGlb);
|
||||
fd.append('itemId', id);
|
||||
try {
|
||||
const res = await fetch('/api/admin/upload-model/', { method: 'POST', body: fd });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? 'Upload failed');
|
||||
modelPath = data.modelPath as string;
|
||||
} catch (err) {
|
||||
setAddItemError(err instanceof Error ? err.message : 'GLB upload failed');
|
||||
setAddItemUploading(false);
|
||||
return;
|
||||
} finally {
|
||||
setAddItemUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
pricingStore.getState().addItem({ id, label, price, modelPath });
|
||||
|
||||
// If a GLB was uploaded, also register as a persona so it shows on the robot
|
||||
if (modelPath) {
|
||||
personaStore.getState().addPersona({
|
||||
id,
|
||||
label,
|
||||
description: label,
|
||||
colors: { torso: '#3b82f6', legs: '#3b82f6' },
|
||||
modelPath,
|
||||
});
|
||||
}
|
||||
|
||||
setNewItem({ id: '', label: '', price: '' });
|
||||
setAddItemGlb(null);
|
||||
};
|
||||
|
||||
// --------------- PERSONAS ---------------
|
||||
@@ -121,15 +205,72 @@ export default function AdminPage() {
|
||||
if (activeTab === 'orders') loadOrders();
|
||||
}, [activeTab, loadOrders]);
|
||||
|
||||
// --------------- AUTH ---------------
|
||||
// --------------- SETTINGS ---------------
|
||||
const [settings, setSettings] = useState<{ key: string; value: string }[]>([]);
|
||||
const [settingsLoading, setSettingsLoading] = useState(false);
|
||||
const [newSetting, setNewSetting] = useState({ key: '', value: '' });
|
||||
const [settingError, setSettingError] = useState('');
|
||||
const [settingSaved, setSettingSaved] = useState('');
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
setSettingsLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/settings/');
|
||||
if (!res.ok) throw new Error('Failed');
|
||||
const data = await res.json();
|
||||
setSettings(data.settings ?? []);
|
||||
} catch { /* ignore */ } finally {
|
||||
setSettingsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'settings') loadSettings();
|
||||
}, [activeTab, loadSettings]);
|
||||
|
||||
const handleSaveSetting = async (key: string, value: string) => {
|
||||
const res = await fetch('/api/admin/settings/', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key, value }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setSettingSaved(key);
|
||||
setTimeout(() => setSettingSaved(''), 1500);
|
||||
loadSettings();
|
||||
} else {
|
||||
const d = await res.json();
|
||||
setSettingError(d.error ?? 'Failed to save');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddSetting = async () => {
|
||||
setSettingError('');
|
||||
const key = newSetting.key.trim();
|
||||
const value = newSetting.value;
|
||||
if (!key) { setSettingError('Key is required.'); return; }
|
||||
await handleSaveSetting(key, value);
|
||||
setNewSetting({ key: '', value: '' });
|
||||
};
|
||||
|
||||
const handleDeleteSetting = async (key: string) => {
|
||||
const res = await fetch(`/api/admin/settings/?key=${encodeURIComponent(key)}`, { method: 'DELETE' });
|
||||
if (res.ok) loadSettings();
|
||||
else {
|
||||
const d = await res.json();
|
||||
setSettingError(d.error ?? 'Failed to delete');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await fetch('/api/admin/logout/', { method: 'POST' });
|
||||
router.push('/admin/login/');
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
// --------------- CHANGE PASSWORD ---------------
|
||||
const [showPwModal, setShowPwModal] = useState(false);
|
||||
|
||||
// --------------- CHANGE PASSWORD ---------------
|
||||
const [pwForm, setPwForm] = useState({ current: '', next: '', confirm: '' });
|
||||
const [pwError, setPwError] = useState('');
|
||||
const [pwSaved, setPwSaved] = useState(false);
|
||||
@@ -190,7 +331,7 @@ export default function AdminPage() {
|
||||
|
||||
{/* TABS */}
|
||||
<div style={{ display: 'flex', gap: '0.25rem', marginBottom: '1.25rem', borderBottom: '1px solid rgba(0,0,0,0.06)', paddingBottom: '0.5rem' }}>
|
||||
{(['pricing', 'personas', 'orders'] as Tab[]).map((t) => (
|
||||
{(['pricing', 'personas', 'orders', 'settings'] as Tab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setActiveTab(t)}
|
||||
@@ -215,13 +356,22 @@ export default function AdminPage() {
|
||||
{activeTab === 'pricing' && (
|
||||
<div>
|
||||
<TableCard>
|
||||
<TableHeader cols="1fr 180px 56px" labels={['Item', 'Price (AED)', '']} />
|
||||
<TableHeader cols="1fr 150px 160px 160px 56px" labels={['Label', 'ID', 'Price (AED)', '3D Model', '']} />
|
||||
{items.map((item, i) => (
|
||||
<div key={item.id} style={{ display: 'grid', gridTemplateColumns: '1fr 180px 56px', padding: '0.875rem 1.25rem', alignItems: 'center', borderBottom: i < items.length - 1 ? '1px solid rgba(0,0,0,0.04)' : 'none' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.85rem', color: '#374151', fontWeight: 500 }}>{item.label}</div>
|
||||
<div style={{ fontSize: '0.7rem', color: '#94a3b8', fontFamily: 'monospace' }}>{item.id}</div>
|
||||
<div key={item.id} style={{ borderBottom: i < items.length - 1 ? '1px solid rgba(0,0,0,0.04)' : 'none' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 150px 160px 160px 56px', padding: '0.625rem 1.25rem', alignItems: 'center' }}>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={editedLabels[item.id] ?? item.label}
|
||||
onChange={(e) => setEditedLabels((prev) => ({ ...prev, [item.id]: e.target.value }))}
|
||||
style={tableInputStyle}
|
||||
onFocus={(e) => (e.currentTarget.style.borderColor = 'rgba(59,130,246,0.5)')}
|
||||
onBlur={(e) => (e.currentTarget.style.borderColor = 'rgba(0,0,0,0.1)')}
|
||||
aria-label={`Label for ${item.label}`}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.7rem', color: '#94a3b8', fontFamily: 'monospace', paddingLeft: '0.25rem' }}>{item.id}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
|
||||
<span style={{ fontSize: '0.7rem', color: '#94a3b8' }}>AED</span>
|
||||
<input
|
||||
@@ -234,12 +384,45 @@ export default function AdminPage() {
|
||||
aria-label={`Price for ${item.label}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 3D Model column */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
|
||||
{item.modelPath ? (
|
||||
<span
|
||||
title={item.modelPath}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.7rem', color: '#16a34a', background: 'rgba(22,163,74,0.08)', border: '1px solid rgba(22,163,74,0.2)', borderRadius: '0.375rem', padding: '0.2rem 0.45rem', whiteSpace: 'nowrap', overflow: 'hidden', maxWidth: '100%' }}
|
||||
>
|
||||
✓ {item.modelPath.split('/').pop()}
|
||||
</span>
|
||||
) : null}
|
||||
<label
|
||||
title="Upload / replace .glb"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.25rem', cursor: 'pointer', fontSize: '0.7rem', color: rowGlbFiles[item.id] ? '#2563eb' : '#94a3b8', background: rowGlbFiles[item.id] ? 'rgba(59,130,246,0.06)' : 'transparent', border: `1px dashed ${rowGlbFiles[item.id] ? 'rgba(59,130,246,0.3)' : 'rgba(0,0,0,0.12)'}`, borderRadius: '0.375rem', padding: '0.2rem 0.45rem', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
<input type="file" accept=".glb" style={{ display: 'none' }} onChange={(e) => setRowGlbFiles((p) => ({ ...p, [item.id]: e.target.files?.[0] ?? null }))} />
|
||||
{rowGlbFiles[item.id] ? '📎 ' + rowGlbFiles[item.id]!.name.slice(0, 12) + '…' : '+ .glb'}
|
||||
</label>
|
||||
{rowGlbFiles[item.id] && (
|
||||
<button
|
||||
onClick={() => handleRowGlbUpload(item.id)}
|
||||
disabled={rowGlbUploading[item.id]}
|
||||
style={{ ...secondaryBtnStyle, fontSize: '0.7rem', padding: '0.2rem 0.45rem' }}
|
||||
>
|
||||
{rowGlbUploading[item.id] ? '…' : 'Save'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
{item.id !== 'base' && (
|
||||
<button onClick={() => pricingStore.getState().removeItem(item.id)} style={deleteBtnStyle} title="Remove item">✕</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{rowGlbError[item.id] && (
|
||||
<div style={{ gridColumn: '1 / -1', padding: '0 1.25rem 0.5rem', fontSize: '0.75rem', color: '#dc2626' }}>{rowGlbError[item.id]}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</TableCard>
|
||||
|
||||
@@ -249,21 +432,59 @@ export default function AdminPage() {
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 120px', gap: '0.5rem', alignItems: 'flex-end' }}>
|
||||
<div>
|
||||
<label style={labelStyle}>ID (slug)</label>
|
||||
<input style={formInputStyle} placeholder="e.g. wifi-module" value={newItem.id} onChange={(e) => setNewItem((p) => ({ ...p, id: e.target.value }))} />
|
||||
<input style={formInputStyle} placeholder="e.g. medical-suit" value={newItem.id} onChange={(e) => setNewItem((p) => ({ ...p, id: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Label</label>
|
||||
<input style={formInputStyle} placeholder="e.g. Wi-Fi Module" value={newItem.label} onChange={(e) => setNewItem((p) => ({ ...p, label: e.target.value }))} />
|
||||
<input style={formInputStyle} placeholder="e.g. Medical Suit" value={newItem.label} onChange={(e) => setNewItem((p) => ({ ...p, label: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Price (AED)</label>
|
||||
<input style={formInputStyle} type="number" min="0" placeholder="5000" value={newItem.price} onChange={(e) => setNewItem((p) => ({ ...p, price: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GLB file picker */}
|
||||
<div style={{ marginTop: '0.625rem' }}>
|
||||
<label style={labelStyle}>3D Model — .glb file (optional)</label>
|
||||
<label style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.625rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
borderRadius: '0.375rem',
|
||||
border: `1px dashed ${addItemGlb ? 'rgba(59,130,246,0.4)' : 'rgba(0,0,0,0.15)'}`,
|
||||
background: addItemGlb ? 'rgba(59,130,246,0.04)' : '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.8rem',
|
||||
color: addItemGlb ? '#2563eb' : '#94a3b8',
|
||||
transition: 'all 0.2s',
|
||||
}}>
|
||||
<input
|
||||
type="file"
|
||||
accept=".glb"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => setAddItemGlb(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
{addItemGlb ? addItemGlb.name : 'Browse .glb file…'}
|
||||
{addItemGlb && (
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); setAddItemGlb(null); }}
|
||||
style={{ marginLeft: 'auto', background: 'none', border: 'none', cursor: 'pointer', color: '#94a3b8', fontSize: '0.75rem', padding: '0 2px' }}
|
||||
>✕</button>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{addItemError && <p style={errorTextStyle}>{addItemError}</p>}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.75rem', justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => pricingStore.getState().resetPrices()} style={dangerBtnStyle}>Reset to Defaults</button>
|
||||
<button onClick={handleAddItem} style={secondaryBtnStyle}>Add Item</button>
|
||||
<button onClick={handleAddItem} disabled={addItemUploading} style={secondaryBtnStyle}>
|
||||
{addItemUploading ? 'Uploading…' : 'Add Item'}
|
||||
</button>
|
||||
<button onClick={handleSavePrices} style={{ ...primaryBtnStyle, background: priceSaved ? 'rgba(34,197,94,0.08)' : 'rgba(59,130,246,0.08)', color: priceSaved ? '#16a34a' : '#2563eb' }}>
|
||||
{priceSaved ? 'Saved!' : 'Save Prices'}
|
||||
</button>
|
||||
@@ -370,6 +591,60 @@ export default function AdminPage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SETTINGS TAB */}
|
||||
{activeTab === 'settings' && (
|
||||
<div>
|
||||
<h2 style={{ fontSize: '1rem', fontWeight: 700, color: '#1a1a2e', margin: '0 0 1rem' }}>App Settings</h2>
|
||||
{settingError && <p style={{ color: '#dc2626', fontSize: '0.8rem', marginBottom: '0.75rem' }}>{settingError}</p>}
|
||||
{settingsLoading ? (
|
||||
<p style={{ color: '#64748b', fontSize: '0.875rem' }}>Loading…</p>
|
||||
) : (
|
||||
<TableCard>
|
||||
<TableHeader cols="1fr 1.5fr 110px 50px" labels={['Key', 'Value', '', '']} />
|
||||
{settings.length === 0 && (
|
||||
<p style={{ padding: '1rem', color: '#94a3b8', fontSize: '0.8rem' }}>No settings yet.</p>
|
||||
)}
|
||||
{settings.map((s, i) => (
|
||||
<SettingRow
|
||||
key={s.key}
|
||||
setting={s}
|
||||
index={i}
|
||||
isSaved={settingSaved === s.key}
|
||||
onSave={handleSaveSetting}
|
||||
onDelete={handleDeleteSetting}
|
||||
/>
|
||||
))}
|
||||
</TableCard>
|
||||
)}
|
||||
|
||||
{/* Add new setting */}
|
||||
<div style={{ marginTop: '1.25rem', padding: '1rem', background: '#f8fafc', borderRadius: '0.75rem', border: '1px solid rgba(0,0,0,0.06)' }}>
|
||||
<h3 style={{ fontSize: '0.875rem', fontWeight: 600, color: '#1a1a2e', margin: '0 0 0.75rem' }}>Add Setting</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1.5fr auto', gap: '0.5rem', alignItems: 'flex-end' }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Key</label>
|
||||
<input
|
||||
value={newSetting.key}
|
||||
onChange={(e) => setNewSetting((p) => ({ ...p, key: e.target.value }))}
|
||||
placeholder="e.g. site_name"
|
||||
style={formInputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Value</label>
|
||||
<input
|
||||
value={newSetting.value}
|
||||
onChange={(e) => setNewSetting((p) => ({ ...p, value: e.target.value }))}
|
||||
placeholder="Value"
|
||||
style={formInputStyle}
|
||||
/>
|
||||
</div>
|
||||
<button onClick={handleAddSetting} style={secondaryBtnStyle}>Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* CHANGE PASSWORD MODAL */}
|
||||
@@ -406,6 +681,48 @@ export default function AdminPage() {
|
||||
|
||||
// ===== SUB-COMPONENTS =====
|
||||
|
||||
function SettingRow({
|
||||
setting,
|
||||
index,
|
||||
isSaved,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: {
|
||||
setting: { key: string; value: string };
|
||||
index: number;
|
||||
isSaved: boolean;
|
||||
onSave: (key: string, value: string) => void;
|
||||
onDelete: (key: string) => void;
|
||||
}) {
|
||||
const [editVal, setEditVal] = useState(setting.value);
|
||||
|
||||
// Sync if parent refreshes the list
|
||||
useEffect(() => { setEditVal(setting.value); }, [setting.value]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1.5fr 110px 50px', gap: '0.75rem', padding: '0.625rem 1rem', alignItems: 'center', background: index % 2 === 0 ? '#fff' : '#f8fafc', borderTop: index > 0 ? '1px solid rgba(0,0,0,0.04)' : 'none' }}>
|
||||
<span style={{ fontSize: '0.8rem', fontFamily: 'monospace', color: '#334155' }}>{setting.key}</span>
|
||||
<input
|
||||
value={editVal}
|
||||
onChange={(e) => setEditVal(e.target.value)}
|
||||
style={{ ...formInputStyle, fontSize: '0.8rem', padding: '0.3rem 0.5rem' }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => onSave(setting.key, editVal)}
|
||||
style={{ ...secondaryBtnStyle, fontSize: '0.75rem', padding: '0.3rem 0.6rem', color: isSaved ? '#16a34a' : undefined }}
|
||||
>
|
||||
{isSaved ? '✓ Saved' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(setting.key)}
|
||||
style={{ ...deleteBtnStyle, fontSize: '0.75rem', padding: '0.3rem 0.5rem' }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OrderRow({
|
||||
order,
|
||||
isLast,
|
||||
|
||||
Reference in New Issue
Block a user