diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1bfadc4 --- /dev/null +++ b/CHANGELOG.md @@ -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` | إضافة `` و `` | + +--- + +## e686d41 — fix: use configStore.getState().setPersonaAttire in ScrollOverlays + +### المشكلة +- بناء Docker كان يفشل مع خطأ TypeScript: + ``` + Property 'getState' does not exist on type '(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` (الصفحة الرئيسية) diff --git a/public/models/security-guardglb.glb b/ChatGPT_Image_Apr_16_2026_12_3_Police_mannequin_wearing_uniform_and_cap_Prism_31_511a26f4.glb similarity index 64% rename from public/models/security-guardglb.glb rename to ChatGPT_Image_Apr_16_2026_12_3_Police_mannequin_wearing_uniform_and_cap_Prism_31_511a26f4.glb index 286b3f6..9ef59dd 100644 Binary files a/public/models/security-guardglb.glb and b/ChatGPT_Image_Apr_16_2026_12_3_Police_mannequin_wearing_uniform_and_cap_Prism_31_511a26f4.glb differ diff --git a/doc_Medical_professional_wearing_protective_gear_Hunyuan_3D_v31_ce4a382e (1).glb b/doc_Medical_professional_wearing_protective_gear_Hunyuan_3D_v31_ce4a382e (1).glb new file mode 100644 index 0000000..a0f5fbe Binary files /dev/null and b/doc_Medical_professional_wearing_protective_gear_Hunyuan_3D_v31_ce4a382e (1).glb differ diff --git a/src/store/usePersonaStore.ts b/src/store/usePersonaStore.ts index 8426f0f..25df2a5 100644 --- a/src/store/usePersonaStore.ts +++ b/src/store/usePersonaStore.ts @@ -124,15 +124,25 @@ export const personaStore = createStore((set, get) => ({ }, hydrate: () => { - const stored = loadFromStorage(); - if (stored && stored.length > 0) { + // Guard: only hydrate once — prevents race condition duplicates when + // 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. // 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)); - set({ personas: [...stored, ...missing], isHydrated: true }); + set({ personas: [...deduped, ...missing] }); } 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 @@ -173,7 +183,10 @@ export const personaStore = createStore((set, get) => ({ if (newPersonas.length > 0) { 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); return { personas: updated }; });