feat: Integrate Stripe payment processing and enhance admin settings management
CI/CD / test-and-build (push) Waiting to run
CI/CD / deploy (push) Blocked by required conditions

- 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:
Najjar\NajjarV02
2026-04-14 10:14:18 +04:00
parent dc42aeb72a
commit 51671b85b8
13 changed files with 598 additions and 156 deletions
+337 -20
View File
@@ -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,
+24 -17
View File
@@ -1,32 +1,39 @@
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2026-03-25.dahlia' as Parameters<typeof Stripe>[1]['apiVersion'],
});
import { prisma } from '@/lib/prisma';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const limit = Math.min(parseInt(searchParams.get('limit') ?? '20', 10), 100);
const limit = Math.min(parseInt(searchParams.get('limit') ?? '50', 10), 200);
try {
const paymentIntents = await stripe.paymentIntents.list({
limit,
expand: ['data.latest_charge'],
const dbOrders = await prisma.order.findMany({
orderBy: { createdAt: 'desc' },
take: limit,
});
const orders = paymentIntents.data.map((pi) => ({
id: pi.id,
amount: pi.amount,
currency: pi.currency,
status: pi.status,
created: pi.created,
metadata: pi.metadata,
const orders = dbOrders.map((o) => ({
id: o.paymentIntentId,
amount: o.amount,
currency: o.currency,
status: o.status,
created: Math.floor(new Date(o.createdAt).getTime() / 1000),
metadata: {
customerName: o.customerName ?? '',
customerEmail: o.customerEmail ?? '',
customerPhone: o.customerPhone ?? '',
customerAddress: o.customerAddress ?? '',
customerCity: o.customerCity ?? '',
customerCountry: o.customerCountry ?? '',
customerPostalCode: o.customerPostalCode ?? '',
persona: o.persona ?? '',
color: o.color ?? '',
},
}));
return NextResponse.json({ orders, hasMore: paymentIntents.has_more });
return NextResponse.json({ orders, hasMore: dbOrders.length === limit });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message }, { status: 500 });
}
}
+69
View File
@@ -0,0 +1,69 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { jwtVerify } from 'jose';
import { prisma } from '@/lib/prisma';
async function verifyAdmin() {
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
if (!token) return false;
const jwtSecret = process.env.ADMIN_JWT_SECRET;
if (!jwtSecret) return false;
try {
await jwtVerify(token, new TextEncoder().encode(jwtSecret));
return true;
} catch {
return false;
}
}
// GET /api/admin/settings/ → all key-value pairs
export async function GET() {
if (!(await verifyAdmin())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const settings = await prisma.appSettings.findMany({ orderBy: { key: 'asc' } });
return NextResponse.json({ settings });
}
// PUT /api/admin/settings/ → upsert a key
export async function PUT(request: Request) {
if (!(await verifyAdmin())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { key, value } = await request.json();
if (!key || typeof key !== 'string' || key.trim() === '') {
return NextResponse.json({ error: 'Invalid key' }, { status: 400 });
}
if (typeof value !== 'string') {
return NextResponse.json({ error: 'Invalid value' }, { status: 400 });
}
// Prevent overwriting internal keys with sensitive data
const protectedKeys = ['jwt_secret'];
if (protectedKeys.includes(key.toLowerCase())) {
return NextResponse.json({ error: 'This key is protected and cannot be edited here' }, { status: 403 });
}
const setting = await prisma.appSettings.upsert({
where: { key: key.trim() },
create: { key: key.trim(), value },
update: { value },
});
return NextResponse.json({ setting });
}
// DELETE /api/admin/settings/?key=xxx
export async function DELETE(request: Request) {
if (!(await verifyAdmin())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { searchParams } = new URL(request.url);
const key = searchParams.get('key');
if (!key) return NextResponse.json({ error: 'Missing key' }, { status: 400 });
const protectedKeys = ['jwt_secret'];
if (protectedKeys.includes(key.toLowerCase())) {
return NextResponse.json({ error: 'This key is protected' }, { status: 403 });
}
await prisma.appSettings.delete({ where: { key } }).catch(() => {});
return NextResponse.json({ success: true });
}
+55
View File
@@ -0,0 +1,55 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { jwtVerify } from 'jose';
import { writeFile, mkdir } from 'fs/promises';
import path from 'path';
export async function POST(request: Request) {
// Verify admin JWT
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const jwtSecret = process.env.ADMIN_JWT_SECRET;
if (!jwtSecret) return NextResponse.json({ error: 'Server error' }, { status: 500 });
try {
await jwtVerify(token, new TextEncoder().encode(jwtSecret));
} catch {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
let formData: FormData;
try {
formData = await request.formData();
} catch {
return NextResponse.json({ error: 'Invalid form data' }, { status: 400 });
}
const file = formData.get('file') as File | null;
const itemId = formData.get('itemId') as string | null;
if (!file || !itemId) {
return NextResponse.json({ error: 'Missing file or itemId' }, { status: 400 });
}
// Only accept .glb files
if (!file.name.toLowerCase().endsWith('.glb')) {
return NextResponse.json({ error: 'Only .glb files are allowed' }, { status: 400 });
}
// Sanitize the item ID — convert underscores to hyphens, keep lowercase letters, digits, hyphens
const safeId = itemId.toLowerCase().replace(/_/g, '-').replace(/[^a-z0-9-]/g, '');
if (!safeId) {
return NextResponse.json({ error: 'Invalid itemId' }, { status: 400 });
}
const modelsDir = path.resolve(process.cwd(), 'public', 'models');
await mkdir(modelsDir, { recursive: true });
const destPath = path.join(modelsDir, `${safeId}.glb`);
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(destPath, buffer);
return NextResponse.json({ modelPath: `/models/${safeId}.glb` });
}
+58
View File
@@ -0,0 +1,58 @@
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
import { prisma } from '@/lib/prisma';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
apiVersion: '2026-03-25.dahlia' as any,
});
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get('stripe-signature');
if (!signature) {
return NextResponse.json({ error: 'Missing stripe-signature header' }, { status: 400 });
}
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!webhookSecret) {
return NextResponse.json({ error: 'Webhook secret not configured' }, { status: 500 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Invalid signature';
return NextResponse.json({ error: `Webhook signature verification failed: ${msg}` }, { status: 400 });
}
if (event.type === 'payment_intent.succeeded' || event.type === 'payment_intent.payment_failed') {
const pi = event.data.object as Stripe.PaymentIntent;
const m = pi.metadata ?? {};
const data = {
amount: pi.amount,
currency: pi.currency,
status: pi.status,
customerName: m.customerName ?? null,
customerEmail: m.customerEmail ?? null,
customerPhone: m.customerPhone ?? null,
customerAddress: m.customerAddress ?? null,
customerCity: m.customerCity ?? null,
customerCountry: m.customerCountry ?? null,
customerPostalCode: m.customerPostalCode ?? null,
persona: m.persona ?? null,
color: m.color ?? null,
};
await prisma.order.upsert({
where: { paymentIntentId: pi.id },
create: { paymentIntentId: pi.id, ...data },
update: data,
});
}
return NextResponse.json({ received: true });
}