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:
parent
dc42aeb72a
commit
51671b85b8
@ -1,108 +0,0 @@
|
|||||||
# Stripe Payment Integration
|
|
||||||
|
|
||||||
## ملخص التعديلات
|
|
||||||
|
|
||||||
تم استبدال نظام الدفع التجريبي (Demo) بتكامل حقيقي مع **Stripe** لمعالجة المدفوعات.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## الملفات الجديدة
|
|
||||||
|
|
||||||
### 1. `src/app/api/create-payment-intent/route.ts`
|
|
||||||
- API Route على السيرفر لإنشاء Stripe PaymentIntent
|
|
||||||
- يستقبل المبلغ بالدرهم (AED) ويحوله لأصغر وحدة (فلس)
|
|
||||||
- يرسل metadata تشمل الـ persona واللون وإيميل العميل
|
|
||||||
|
|
||||||
### 2. `.env.example`
|
|
||||||
- ملف مرجعي يحتوي على أسماء المتغيرات المطلوبة لمفاتيح Stripe
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## الملفات المعدّلة
|
|
||||||
|
|
||||||
### 3. `src/store/useOrderStore.ts`
|
|
||||||
**قبل:**
|
|
||||||
```ts
|
|
||||||
export interface PaymentInfo {
|
|
||||||
cardNumber: string;
|
|
||||||
expiry: string;
|
|
||||||
cvv: string;
|
|
||||||
nameOnCard: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**بعد:**
|
|
||||||
```ts
|
|
||||||
export interface PaymentInfo {
|
|
||||||
paymentIntentId: string;
|
|
||||||
clientSecret: string;
|
|
||||||
status: 'idle' | 'processing' | 'succeeded' | 'failed';
|
|
||||||
errorMessage: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- تمت إضافة action جديد: `createPaymentIntent()` — يستدعي الـ API Route وينشئ PaymentIntent
|
|
||||||
- تم تعديل `setPayment()` ليقبل `Partial<PaymentInfo>` للتحديث التدريجي
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. `src/components/checkout/PaymentStep.tsx`
|
|
||||||
**قبل:** فورم يدوي يجمع بيانات البطاقة (رقم، تاريخ انتهاء، CVV، اسم) — بدون معالجة حقيقية
|
|
||||||
|
|
||||||
**بعد:** يستخدم `<PaymentElement>` من Stripe Elements — يعرض واجهة دفع آمنة تدعم:
|
|
||||||
- بطاقات الائتمان/الخصم
|
|
||||||
- Apple Pay
|
|
||||||
- Google Pay
|
|
||||||
- طرق دفع أخرى حسب إعدادات حساب Stripe
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. `src/components/checkout/ReviewStep.tsx`
|
|
||||||
**قبل:** زر "Place Order" كان يعمل `setTimeout(1500)` فقط (محاكاة)
|
|
||||||
|
|
||||||
**بعد:** يستدعي `stripe.confirmPayment()` لتأكيد الدفع الحقيقي عبر Stripe، مع عرض رسائل الخطأ إذا فشل الدفع
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6. `src/components/CheckoutOverlay.tsx`
|
|
||||||
- تم تحميل Stripe.js عبر `loadStripe()`
|
|
||||||
- يتم إنشاء PaymentIntent تلقائياً عند الدخول لخطوة الدفع
|
|
||||||
- يتم تغليف خطوتي Payment و Review بـ `<Elements>` provider من Stripe
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 7. `src/store/useOrderStore.test.ts`
|
|
||||||
- تم تحديث الـ tests لتتوافق مع الـ `PaymentInfo` الجديد
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## الحزم المضافة
|
|
||||||
|
|
||||||
```
|
|
||||||
stripe — Stripe SDK (سيرفر)
|
|
||||||
@stripe/stripe-js — Stripe.js loader (كلاينت)
|
|
||||||
@stripe/react-stripe-js — React components (Elements, PaymentElement)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## الإعداد
|
|
||||||
|
|
||||||
1. أنشئ ملف `.env.local` في root المشروع:
|
|
||||||
|
|
||||||
```env
|
|
||||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxx
|
|
||||||
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx
|
|
||||||
```
|
|
||||||
|
|
||||||
2. احصل على المفاتيح من: https://dashboard.stripe.com/apikeys
|
|
||||||
|
|
||||||
3. للتجربة استخدم مفاتيح الـ test (`pk_test_` / `sk_test_`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## تدفق الدفع الجديد
|
|
||||||
|
|
||||||
```
|
|
||||||
Config → Shipping → [Payment Intent Created] → Payment (Stripe Elements) → Review → confirmPayment() → Confirmed
|
|
||||||
```
|
|
||||||
BIN
prisma/lootah.db
BIN
prisma/lootah.db
Binary file not shown.
@ -26,3 +26,22 @@ model Snapshot {
|
|||||||
imageData String
|
imageData String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Order {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
paymentIntentId String @unique
|
||||||
|
amount Int
|
||||||
|
currency String @default("aed")
|
||||||
|
status String
|
||||||
|
customerName String?
|
||||||
|
customerEmail String?
|
||||||
|
customerPhone String?
|
||||||
|
customerAddress String?
|
||||||
|
customerCity String?
|
||||||
|
customerCountry String?
|
||||||
|
customerPostalCode String?
|
||||||
|
persona String?
|
||||||
|
color String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|||||||
BIN
public/models/security-guard.glb
Normal file
BIN
public/models/security-guard.glb
Normal file
Binary file not shown.
BIN
public/models/security-guardglb.glb
Normal file
BIN
public/models/security-guardglb.glb
Normal file
Binary file not shown.
@ -15,7 +15,7 @@ interface Order {
|
|||||||
metadata: Record<string, string>;
|
metadata: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tab = 'pricing' | 'personas' | 'orders';
|
type Tab = 'pricing' | 'personas' | 'orders' | 'settings';
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -25,16 +25,25 @@ export default function AdminPage() {
|
|||||||
const items = usePricingStore((s) => s.items);
|
const items = usePricingStore((s) => s.items);
|
||||||
const isPricingHydrated = usePricingStore((s) => s.isHydrated);
|
const isPricingHydrated = usePricingStore((s) => s.isHydrated);
|
||||||
const [editedPrices, setEditedPrices] = useState<Record<string, number>>({});
|
const [editedPrices, setEditedPrices] = useState<Record<string, number>>({});
|
||||||
|
const [editedLabels, setEditedLabels] = useState<Record<string, string>>({});
|
||||||
const [priceSaved, setPriceSaved] = useState(false);
|
const [priceSaved, setPriceSaved] = useState(false);
|
||||||
const [newItem, setNewItem] = useState({ id: '', label: '', price: '' });
|
const [newItem, setNewItem] = useState({ id: '', label: '', price: '' });
|
||||||
|
const [addItemGlb, setAddItemGlb] = useState<File | null>(null);
|
||||||
|
const [addItemUploading, setAddItemUploading] = useState(false);
|
||||||
const [addItemError, setAddItemError] = useState('');
|
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(() => { pricingStore.getState().hydrate(); }, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map: Record<string, number> = {};
|
const priceMap: Record<string, number> = {};
|
||||||
items.forEach((item) => { map[item.id] = item.price; });
|
const labelMap: Record<string, string> = {};
|
||||||
setEditedPrices(map);
|
items.forEach((item) => { priceMap[item.id] = item.price; labelMap[item.id] = item.label; });
|
||||||
|
setEditedPrices(priceMap);
|
||||||
|
setEditedLabels(labelMap);
|
||||||
}, [items]);
|
}, [items]);
|
||||||
|
|
||||||
const handlePriceChange = (id: string, value: string) => {
|
const handlePriceChange = (id: string, value: string) => {
|
||||||
@ -43,16 +52,56 @@ export default function AdminPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSavePrices = () => {
|
const handleSavePrices = () => {
|
||||||
Object.entries(editedPrices).forEach(([id, price]) => {
|
items.forEach((item) => {
|
||||||
pricingStore.getState().updatePrice(id, price);
|
pricingStore.getState().updateItem(item.id, {
|
||||||
|
price: editedPrices[item.id] ?? item.price,
|
||||||
|
label: (editedLabels[item.id] ?? item.label).trim() || item.label,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
setPriceSaved(true);
|
setPriceSaved(true);
|
||||||
setTimeout(() => setPriceSaved(false), 2000);
|
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('');
|
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 label = newItem.label.trim();
|
||||||
const price = parseInt(newItem.price, 10);
|
const price = parseInt(newItem.price, 10);
|
||||||
if (!id || !label || isNaN(price) || price < 0) {
|
if (!id || !label || isNaN(price) || price < 0) {
|
||||||
@ -63,8 +112,43 @@ export default function AdminPage() {
|
|||||||
setAddItemError(`ID "${id}" already exists.`);
|
setAddItemError(`ID "${id}" already exists.`);
|
||||||
return;
|
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: '' });
|
setNewItem({ id: '', label: '', price: '' });
|
||||||
|
setAddItemGlb(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// --------------- PERSONAS ---------------
|
// --------------- PERSONAS ---------------
|
||||||
@ -121,15 +205,72 @@ export default function AdminPage() {
|
|||||||
if (activeTab === 'orders') loadOrders();
|
if (activeTab === 'orders') loadOrders();
|
||||||
}, [activeTab, 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 () => {
|
const handleLogout = async () => {
|
||||||
await fetch('/api/admin/logout/', { method: 'POST' });
|
await fetch('/api/admin/logout/', { method: 'POST' });
|
||||||
router.push('/admin/login/');
|
router.push('/admin/login/');
|
||||||
router.refresh();
|
router.refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
// --------------- CHANGE PASSWORD ---------------
|
|
||||||
const [showPwModal, setShowPwModal] = useState(false);
|
const [showPwModal, setShowPwModal] = useState(false);
|
||||||
|
|
||||||
|
// --------------- CHANGE PASSWORD ---------------
|
||||||
const [pwForm, setPwForm] = useState({ current: '', next: '', confirm: '' });
|
const [pwForm, setPwForm] = useState({ current: '', next: '', confirm: '' });
|
||||||
const [pwError, setPwError] = useState('');
|
const [pwError, setPwError] = useState('');
|
||||||
const [pwSaved, setPwSaved] = useState(false);
|
const [pwSaved, setPwSaved] = useState(false);
|
||||||
@ -190,7 +331,7 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
{/* TABS */}
|
{/* TABS */}
|
||||||
<div style={{ display: 'flex', gap: '0.25rem', marginBottom: '1.25rem', borderBottom: '1px solid rgba(0,0,0,0.06)', paddingBottom: '0.5rem' }}>
|
<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
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
onClick={() => setActiveTab(t)}
|
onClick={() => setActiveTab(t)}
|
||||||
@ -215,13 +356,22 @@ export default function AdminPage() {
|
|||||||
{activeTab === 'pricing' && (
|
{activeTab === 'pricing' && (
|
||||||
<div>
|
<div>
|
||||||
<TableCard>
|
<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) => (
|
{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 key={item.id} style={{ borderBottom: i < items.length - 1 ? '1px solid rgba(0,0,0,0.04)' : 'none' }}>
|
||||||
<div>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 150px 160px 160px 56px', padding: '0.625rem 1.25rem', alignItems: 'center' }}>
|
||||||
<div style={{ fontSize: '0.85rem', color: '#374151', fontWeight: 500 }}>{item.label}</div>
|
<div>
|
||||||
<div style={{ fontSize: '0.7rem', color: '#94a3b8', fontFamily: 'monospace' }}>{item.id}</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>
|
||||||
|
<div style={{ fontSize: '0.7rem', color: '#94a3b8', fontFamily: 'monospace', paddingLeft: '0.25rem' }}>{item.id}</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
|
||||||
<span style={{ fontSize: '0.7rem', color: '#94a3b8' }}>AED</span>
|
<span style={{ fontSize: '0.7rem', color: '#94a3b8' }}>AED</span>
|
||||||
<input
|
<input
|
||||||
@ -234,12 +384,45 @@ export default function AdminPage() {
|
|||||||
aria-label={`Price for ${item.label}`}
|
aria-label={`Price for ${item.label}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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' }}>
|
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
{item.id !== 'base' && (
|
{item.id !== 'base' && (
|
||||||
<button onClick={() => pricingStore.getState().removeItem(item.id)} style={deleteBtnStyle} title="Remove item">✕</button>
|
<button onClick={() => pricingStore.getState().removeItem(item.id)} style={deleteBtnStyle} title="Remove item">✕</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</TableCard>
|
||||||
|
|
||||||
@ -249,21 +432,59 @@ export default function AdminPage() {
|
|||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 120px', gap: '0.5rem', alignItems: 'flex-end' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 120px', gap: '0.5rem', alignItems: 'flex-end' }}>
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>ID (slug)</label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>Label</label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>Price (AED)</label>
|
<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 }))} />
|
<input style={formInputStyle} type="number" min="0" placeholder="5000" value={newItem.price} onChange={(e) => setNewItem((p) => ({ ...p, price: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
</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>}
|
{addItemError && <p style={errorTextStyle}>{addItemError}</p>}
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.75rem', justifyContent: 'flex-end' }}>
|
<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={() => 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' }}>
|
<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'}
|
{priceSaved ? 'Saved!' : 'Save Prices'}
|
||||||
</button>
|
</button>
|
||||||
@ -370,6 +591,60 @@ export default function AdminPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* CHANGE PASSWORD MODAL */}
|
{/* CHANGE PASSWORD MODAL */}
|
||||||
@ -406,6 +681,48 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
// ===== SUB-COMPONENTS =====
|
// ===== 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({
|
function OrderRow({
|
||||||
order,
|
order,
|
||||||
isLast,
|
isLast,
|
||||||
|
|||||||
@ -1,32 +1,39 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import Stripe from 'stripe';
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
|
||||||
apiVersion: '2026-03-25.dahlia' as Parameters<typeof Stripe>[1]['apiVersion'],
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const { searchParams } = new URL(request.url);
|
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 {
|
try {
|
||||||
const paymentIntents = await stripe.paymentIntents.list({
|
const dbOrders = await prisma.order.findMany({
|
||||||
limit,
|
orderBy: { createdAt: 'desc' },
|
||||||
expand: ['data.latest_charge'],
|
take: limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
const orders = paymentIntents.data.map((pi) => ({
|
const orders = dbOrders.map((o) => ({
|
||||||
id: pi.id,
|
id: o.paymentIntentId,
|
||||||
amount: pi.amount,
|
amount: o.amount,
|
||||||
currency: pi.currency,
|
currency: o.currency,
|
||||||
status: pi.status,
|
status: o.status,
|
||||||
created: pi.created,
|
created: Math.floor(new Date(o.createdAt).getTime() / 1000),
|
||||||
metadata: pi.metadata,
|
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) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
69
src/app/api/admin/settings/route.ts
Normal file
69
src/app/api/admin/settings/route.ts
Normal 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
src/app/api/admin/upload-model/route.ts
Normal file
55
src/app/api/admin/upload-model/route.ts
Normal 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
src/app/api/webhooks/stripe/route.ts
Normal file
58
src/app/api/webhooks/stripe/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
@ -5,15 +5,25 @@ import { useGLTF } from '@react-three/drei';
|
|||||||
import { useFrame } from '@react-three/fiber';
|
import { useFrame } from '@react-three/fiber';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { useConfigStore } from '@/store/useConfigStore';
|
import { useConfigStore } from '@/store/useConfigStore';
|
||||||
|
import { personaStore, usePersonaStore } from '@/store/usePersonaStore';
|
||||||
|
|
||||||
const ATTIRE_GLB: Record<string, string> = {
|
const STATIC_ATTIRE_GLB: Record<string, string> = {
|
||||||
'emarati-kandura': '/Kandoura.glb',
|
'emarati-kandura': '/Kandoura.glb',
|
||||||
'industrial-vest': '/Vest.glb',
|
'industrial-vest': '/Vest.glb',
|
||||||
'business-suit': '/Suit.glb',
|
'business-suit': '/Suit.glb',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Preload all attire models so they're cached before user clicks
|
// Preload static attire models so they're cached before user clicks
|
||||||
Object.values(ATTIRE_GLB).forEach((path) => useGLTF.preload(path));
|
Object.values(STATIC_ATTIRE_GLB).forEach((p) => useGLTF.preload(p));
|
||||||
|
|
||||||
|
/** Merge static map with any custom GLBs stored in the persona store */
|
||||||
|
function buildAttireGlbMap(personas: { id: string; modelPath?: string }[]): Record<string, string> {
|
||||||
|
const dynamic: Record<string, string> = {};
|
||||||
|
personas.forEach((p) => {
|
||||||
|
if (p.modelPath && p.id !== 'none') dynamic[p.id] = p.modelPath;
|
||||||
|
});
|
||||||
|
return { ...dynamic, ...STATIC_ATTIRE_GLB }; // static takes priority
|
||||||
|
}
|
||||||
|
|
||||||
function easeInOutCubic(t: number): number {
|
function easeInOutCubic(t: number): number {
|
||||||
return t < 0.5
|
return t < 0.5
|
||||||
@ -78,6 +88,8 @@ export function RobotModel({ onError }: RobotModelProps) {
|
|||||||
|
|
||||||
const activeColors = useConfigStore((state) => state.activeColors);
|
const activeColors = useConfigStore((state) => state.activeColors);
|
||||||
const activePersonaAttire = useConfigStore((state) => state.activePersonaAttire);
|
const activePersonaAttire = useConfigStore((state) => state.activePersonaAttire);
|
||||||
|
// Subscribe to persona store so custom GLB paths are reactive
|
||||||
|
const personas = usePersonaStore((s) => s.personas);
|
||||||
|
|
||||||
// Detect attire change and trigger spin
|
// Detect attire change and trigger spin
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -174,7 +186,7 @@ export function RobotModel({ onError }: RobotModelProps) {
|
|||||||
});
|
});
|
||||||
}, [activeColors]);
|
}, [activeColors]);
|
||||||
|
|
||||||
const attireGlbPath = ATTIRE_GLB[displayedAttire] || null;
|
const attireGlbPath = buildAttireGlbMap(personas)[displayedAttire] || null;
|
||||||
|
|
||||||
const handleAttireLoaded = () => {
|
const handleAttireLoaded = () => {
|
||||||
setAttireReady(true);
|
setAttireReady(true);
|
||||||
|
|||||||
@ -6,6 +6,7 @@ export interface PersonaOption {
|
|||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
colors: { torso: string; legs: string };
|
colors: { torso: string; legs: string };
|
||||||
|
modelPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PersonaState {
|
export interface PersonaState {
|
||||||
@ -88,6 +89,7 @@ export const personaStore = createStore<PersonaStore>((set, get) => ({
|
|||||||
label: persona.label,
|
label: persona.label,
|
||||||
description: persona.description,
|
description: persona.description,
|
||||||
colors: persona.colors,
|
colors: persona.colors,
|
||||||
|
...(persona.modelPath ? { modelPath: persona.modelPath } : {}),
|
||||||
};
|
};
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const updated = [...state.personas, newPersona];
|
const updated = [...state.personas, newPersona];
|
||||||
|
|||||||
@ -5,6 +5,7 @@ export interface PricingItem {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
price: number;
|
price: number;
|
||||||
|
modelPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PricingState {
|
export interface PricingState {
|
||||||
@ -14,6 +15,7 @@ export interface PricingState {
|
|||||||
|
|
||||||
export interface PricingActions {
|
export interface PricingActions {
|
||||||
updatePrice: (itemId: string, newPrice: number) => void;
|
updatePrice: (itemId: string, newPrice: number) => void;
|
||||||
|
updateItem: (itemId: string, updates: Partial<Pick<PricingItem, 'label' | 'price' | 'modelPath'>>) => void;
|
||||||
addItem: (item: PricingItem) => void;
|
addItem: (item: PricingItem) => void;
|
||||||
removeItem: (itemId: string) => void;
|
removeItem: (itemId: string) => void;
|
||||||
resetPrices: () => void;
|
resetPrices: () => void;
|
||||||
@ -68,6 +70,16 @@ export const pricingStore = createStore<PricingStore>((set, get) => ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateItem: (itemId: string, updates: Partial<Pick<PricingItem, 'label' | 'price' | 'modelPath'>>) => {
|
||||||
|
set((state) => {
|
||||||
|
const updated = state.items.map((item) =>
|
||||||
|
item.id === itemId ? { ...item, ...updates } : item
|
||||||
|
);
|
||||||
|
saveToStorage(updated);
|
||||||
|
return { items: updated };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
resetPrices: () => {
|
resetPrices: () => {
|
||||||
saveToStorage(DEFAULT_ITEMS);
|
saveToStorage(DEFAULT_ITEMS);
|
||||||
set({ items: [...DEFAULT_ITEMS] });
|
set({ items: [...DEFAULT_ITEMS] });
|
||||||
@ -94,13 +106,12 @@ export const pricingStore = createStore<PricingStore>((set, get) => ({
|
|||||||
|
|
||||||
hydrate: () => {
|
hydrate: () => {
|
||||||
const stored = loadFromStorage();
|
const stored = loadFromStorage();
|
||||||
if (stored) {
|
if (stored && stored.length > 0) {
|
||||||
// Merge stored prices with defaults (in case new items were added)
|
// Use stored items directly (preserves custom labels, prices, added items).
|
||||||
const merged = DEFAULT_ITEMS.map((def) => {
|
// Re-add any default items that were never stored (fresh install gap).
|
||||||
const found = stored.find((s) => s.id === def.id);
|
const storedIds = new Set(stored.map((s) => s.id));
|
||||||
return found ? { ...def, price: found.price } : def;
|
const missing = DEFAULT_ITEMS.filter((d) => !storedIds.has(d.id));
|
||||||
});
|
set({ items: [...stored, ...missing], isHydrated: true });
|
||||||
set({ items: merged, isHydrated: true });
|
|
||||||
} else {
|
} else {
|
||||||
set({ isHydrated: true });
|
set({ isHydrated: true });
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user