fix: prevent duplicate persona entries (race condition in hydrate), remove accidental 54MB file
This commit is contained in:
parent
2ff21c5b54
commit
1e128466f3
127
CHANGELOG.md
Normal file
127
CHANGELOG.md
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
# Changelog — Lootah Robotics G1 Configurator
|
||||||
|
> تاريخ التغييرات — 20 أبريل 2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2ff21c5 — perf: compress GLBs 75%, add Draco decoder, loading spinner for attire
|
||||||
|
|
||||||
|
### المشكلة
|
||||||
|
- الموبايلات القديمة كانت تعلّق لأوقات طويلة عند تحميل الأزياء الجديدة
|
||||||
|
- التبديل بين الأزياء (Robot Doctor / Security Guard) كان يعلّق بدون أي مؤشر تحميل
|
||||||
|
|
||||||
|
### التغييرات
|
||||||
|
| الملف | التغيير |
|
||||||
|
|---|---|
|
||||||
|
| `public/models/robot-doctor.glb` | ضغط Draco: 32 MB → 8.5 MB (توفير 74%) |
|
||||||
|
| `public/models/security-guard.glb` | ضغط Draco: 29 MB → 6.85 MB (توفير 77%) |
|
||||||
|
| `public/draco/` | إضافة ملفات Draco decoder للمتصفح |
|
||||||
|
| `src/components/RobotModel.tsx` | تفعيل `useGLTF.setDecoderPath('/draco/')` |
|
||||||
|
| `src/components/RobotCanvas.tsx` | تفعيل Draco decoder + تخفيض DPR من 2x إلى 1.5x |
|
||||||
|
| `src/components/ScrollScene.tsx` | تفعيل Draco decoder |
|
||||||
|
| `src/components/ConfigPanel.tsx` | إضافة spinner عند تحميل زي جديد |
|
||||||
|
|
||||||
|
### لماذا كانت المشكلة موجودة؟
|
||||||
|
الملفات كانت مضغوطة بـ Draco لكن المتصفح لم يكن عنده الـ decoder لفك الضغط، فكان يفشل بصمت ويرجع للروبوت الأساسي.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## b2a484f — fix: dynamic attire buttons in ScrollOverlays + mobile touch support
|
||||||
|
|
||||||
|
### المشكلة
|
||||||
|
- أزرار الأزياء في صفحة الـ Landing (Kandura, Vest, Suit) كانت مكتوبة بشكل ثابت (hardcoded)
|
||||||
|
- أي زي جديد يضاف من الأدمن لا يظهر في الصفحة الرئيسية
|
||||||
|
- الأزرار كانت تعمل بـ hover فقط (لا تعمل على الموبايل بالضغط)
|
||||||
|
|
||||||
|
### التغييرات
|
||||||
|
| الملف | التغيير |
|
||||||
|
|---|---|
|
||||||
|
| `src/components/ScrollOverlays.tsx` | جلب الأزياء ديناميكياً من `/api/admin/pricing/` |
|
||||||
|
| `src/components/ScrollOverlays.tsx` | إضافة `onClick` بجانب `onMouseEnter` لدعم اللمس |
|
||||||
|
| `src/components/ScrollOverlays.tsx` | إضافة `pointerEvents: 'auto'` لأن الـ overlay كان `pointerEvents: 'none'` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 320b77b — fix: contacts API - use ADMIN_JWT_SECRET env var
|
||||||
|
|
||||||
|
### المشكلة
|
||||||
|
- صفحة Contacts في الأدمن كانت ترجع خطأ 500
|
||||||
|
|
||||||
|
### السبب
|
||||||
|
- Route الـ contacts كان يستخدم `JWT_SECRET` بينما باقي الـ routes تستخدم `ADMIN_JWT_SECRET`
|
||||||
|
- أي JWT مولّد بـ `ADMIN_JWT_SECRET` سيفشل التحقق عند استخدام متغير مختلف
|
||||||
|
|
||||||
|
### التغييرات
|
||||||
|
| الملف | التغيير |
|
||||||
|
|---|---|
|
||||||
|
| `src/app/api/admin/contacts/route.ts` | استخدام `ADMIN_JWT_SECRET` بدلاً من `JWT_SECRET` |
|
||||||
|
| `src/app/api/admin/contacts/route.ts` | إضافة رسالة خطأ واضحة إذا كان `ADMIN_JWT_SECRET` غير موجود |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 25ffbf4 — feat: add favicon and app icons for PWA support
|
||||||
|
|
||||||
|
### التغييرات
|
||||||
|
| الملف | التغيير |
|
||||||
|
|---|---|
|
||||||
|
| `public/favicon.ico` | أيقونة المتصفح |
|
||||||
|
| `public/apple-touch-icon.png` | أيقونة iOS Home Screen |
|
||||||
|
| `public/icon-192.png` | أيقونة PWA 192px |
|
||||||
|
| `public/icon-192-maskable.png` | أيقونة PWA maskable 192px |
|
||||||
|
| `public/icon-512.png` | أيقونة PWA 512px |
|
||||||
|
| `public/icon-512-maskable.png` | أيقونة PWA maskable 512px |
|
||||||
|
| `src/app/layout.tsx` | إضافة `<link rel="icon">` و `<link rel="apple-touch-icon">` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## e686d41 — fix: use configStore.getState().setPersonaAttire in ScrollOverlays
|
||||||
|
|
||||||
|
### المشكلة
|
||||||
|
- بناء Docker كان يفشل مع خطأ TypeScript:
|
||||||
|
```
|
||||||
|
Property 'getState' does not exist on type '<T>(selector: (state: ConfigStore) => T) => T'
|
||||||
|
```
|
||||||
|
|
||||||
|
### السبب
|
||||||
|
كان الكود يستخدم `useConfigStore.getState()` لكن `useConfigStore` هو React hook (دالة عادية) وليس Zustand store. فقط `configStore` المُصدَّر من vanilla Zustand يملك `.getState()`.
|
||||||
|
|
||||||
|
بالإضافة لذلك، اسم الدالة كان خاطئاً: `setActivePersonaAttire` بدلاً من `setPersonaAttire`.
|
||||||
|
|
||||||
|
### التغييرات
|
||||||
|
| الملف | التغيير |
|
||||||
|
|---|---|
|
||||||
|
| `src/components/ScrollOverlays.tsx` | `useConfigStore.getState().setActivePersonaAttire()` → `configStore.getState().setPersonaAttire()` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## e159965 — feat: add GET endpoint to retrieve contact requests with admin authentication
|
||||||
|
|
||||||
|
### التغييرات
|
||||||
|
| الملف | التغيير |
|
||||||
|
|---|---|
|
||||||
|
| `src/app/api/admin/contacts/route.ts` | إضافة GET endpoint لجلب طلبات التواصل مع التحقق من الأدمن |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ملاحظات عامة على البنية
|
||||||
|
|
||||||
|
### متغيرات البيئة المطلوبة
|
||||||
|
```
|
||||||
|
ADMIN_JWT_SECRET= # مطلوب لجميع routes الأدمن
|
||||||
|
DATABASE_URL= # Prisma / SQLite
|
||||||
|
STRIPE_SECRET_KEY= # للمدفوعات
|
||||||
|
```
|
||||||
|
|
||||||
|
### هيكل Stores
|
||||||
|
| Store | الوصف |
|
||||||
|
|---|---|
|
||||||
|
| `configStore` (vanilla Zustand) | الألوان والزي النشط — يدعم `.getState()` |
|
||||||
|
| `useConfigStore` (React hook) | wrapper لـ `configStore` للاستخدام داخل components |
|
||||||
|
| `personaStore` (vanilla Zustand) | قائمة الأزياء — تُحمَّل من API عند التهيئة |
|
||||||
|
| `pricingStore` | أسعار العناصر — تُزامَن مع قاعدة البيانات |
|
||||||
|
|
||||||
|
### تدفق الأزياء المرفوعة
|
||||||
|
1. الأدمن يرفع `.glb` من لوحة التحكم
|
||||||
|
2. يُضغط تلقائياً بـ Draco عبر `upload-model` route
|
||||||
|
3. يُحفظ المسار في قاعدة البيانات (`PricingItem.modelPath`)
|
||||||
|
4. عند تحميل الصفحة، `personaStore.hydrate()` يجلب الأزياء من `/api/admin/pricing/`
|
||||||
|
5. تظهر تلقائياً في `ConfigPanel` وفي `ScrollOverlays` (الصفحة الرئيسية)
|
||||||
Binary file not shown.
Binary file not shown.
@ -124,15 +124,25 @@ export const personaStore = createStore<PersonaStore>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
hydrate: () => {
|
hydrate: () => {
|
||||||
const stored = loadFromStorage();
|
// Guard: only hydrate once — prevents race condition duplicates when
|
||||||
if (stored && stored.length > 0) {
|
// called from multiple components at the same time.
|
||||||
|
if (get().isHydrated) return;
|
||||||
|
set({ isHydrated: true });
|
||||||
|
|
||||||
|
const raw = loadFromStorage();
|
||||||
|
// Deduplicate stored personas (keep last occurrence of each id)
|
||||||
|
const deduped = raw
|
||||||
|
? [...new Map(raw.map((p) => [p.id, p])).values()]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (deduped && deduped.length > 0) {
|
||||||
// Only re-inject truly built-in personas (those still in DEFAULT_PERSONAS) if missing.
|
// Only re-inject truly built-in personas (those still in DEFAULT_PERSONAS) if missing.
|
||||||
// Dynamic/uploaded personas that were deleted via the dashboard must NOT be re-added.
|
// Dynamic/uploaded personas that were deleted via the dashboard must NOT be re-added.
|
||||||
const storedIds = new Set(stored.map((s) => s.id));
|
const storedIds = new Set(deduped.map((s) => s.id));
|
||||||
const missing = DEFAULT_PERSONAS.filter((d) => !storedIds.has(d.id));
|
const missing = DEFAULT_PERSONAS.filter((d) => !storedIds.has(d.id));
|
||||||
set({ personas: [...stored, ...missing], isHydrated: true });
|
set({ personas: [...deduped, ...missing] });
|
||||||
} else {
|
} else {
|
||||||
set({ personas: [...DEFAULT_PERSONAS], isHydrated: true });
|
set({ personas: [...DEFAULT_PERSONAS] });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch pricing items from server DB and auto-register personas for all attire items
|
// Fetch pricing items from server DB and auto-register personas for all attire items
|
||||||
@ -173,7 +183,10 @@ export const personaStore = createStore<PersonaStore>((set, get) => ({
|
|||||||
|
|
||||||
if (newPersonas.length > 0) {
|
if (newPersonas.length > 0) {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const updated = [...state.personas, ...newPersonas];
|
// Deduplicate: merge by id, new personas take precedence for modelPath
|
||||||
|
const merged = new Map(state.personas.map((p) => [p.id, p]));
|
||||||
|
newPersonas.forEach((p) => { if (!merged.has(p.id)) merged.set(p.id, p); });
|
||||||
|
const updated = [...merged.values()];
|
||||||
saveToStorage(updated);
|
saveToStorage(updated);
|
||||||
return { personas: updated };
|
return { personas: updated };
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user