diff --git a/.ai_agency/memory/backlog.json b/.ai_agency/memory/backlog.json index 6f2a85f..b2b05fa 100644 --- a/.ai_agency/memory/backlog.json +++ b/.ai_agency/memory/backlog.json @@ -800,14 +800,109 @@ "sub_steps": [ { "index": 1, "name": "refactor_header_mega_menu", "description": "Build 2-tier header layout with clean mega-menu and dropdowns in Header.tsx", "status": "done" } ] + }, + { + "id": "EPIC-19-TASK-01", + "title": "Prisma Service IDE Type Resolution & Model Delegate Fix", + "description": "Fix PrismaService typescript delegate definitions so IDE language server recognizes video model delegate cleanly without type warnings.", + "architectural_layer": "business_logic", + "assigned_role": "05_dev_backend", + "priority": "HIGH", + "status": "completed", + "max_files_allowed": 3, + "estimated_minutes": 10, + "dependency_task_ids": ["EPIC-18-TASK-04"], + "acceptance_criteria": [ + "برطرف شدن ۱۰۰٪ خطای تایپ اسکریپت IDE روی PrismaService.video", + "تعریف مشخص و تمیز متدهای سرویس ویدئو" + ], + "sub_steps": [ + { "index": 1, "name": "fix_prisma_service_types", "description": "Update prisma.service.ts with explicit video delegate type support", "status": "done" } + ] + }, + { + "id": "EPIC-19-TASK-02", + "title": "Header React Hydration Mismatch Resolution", + "description": "Fix React hydration mismatch error in Header.tsx by adding isMounted state guard for cart counter rendering.", + "architectural_layer": "presentation_ui", + "assigned_role": "06_dev_frontend", + "priority": "HIGH", + "status": "completed", + "max_files_allowed": 2, + "estimated_minutes": 10, + "dependency_task_ids": ["EPIC-19-TASK-01"], + "acceptance_criteria": [ + "برطرف شدن ۱۰۰٪ خطای Hydration Mismatch در هدر و کنسول مرورگر", + "تطابق کامل HTML رندر شده در سرور (SSR) و کلاینت" + ], + "sub_steps": [ + { "index": 1, "name": "fix_header_hydration_error", "description": "Add isMounted guard to cart counter badge in Header.tsx", "status": "done" } + ] + }, + { + "id": "EPIC-19-TASK-03", + "title": "Admin Panel Video Management Media Library & Aparat / Embed Support", + "description": "Enhance Videos.tsx in admin panel with Media Picker modal, file upload, and support for Aparat/YouTube iframe embed tags.", + "architectural_layer": "presentation_ui", + "assigned_role": "06_dev_frontend", + "priority": "HIGH", + "status": "completed", + "max_files_allowed": 4, + "estimated_minutes": 25, + "dependency_task_ids": ["EPIC-19-TASK-02"], + "acceptance_criteria": [ + "امکان انتخاب یا آپلود مستقیم عکس کاور و فایل ویدئو از مدیریت رسانه", + "پشتیبانی کامل از کد آیفرم یا اسکریپت آپارات و یوتیوب در کنار لینک مستقیم MP4" + ], + "sub_steps": [ + { "index": 1, "name": "enhance_admin_videos_media_picker", "description": "Add Media picker and embed code parser in Videos.tsx", "status": "done" } + ] + }, + { + "id": "EPIC-19-TASK-04", + "title": "Online Catalog UI/UX, Scrollbar & Spacing Overhaul", + "description": "Fix CatalogClient.tsx and ArchivePage.tsx category filter scrollbar, margins, padding, and layout touch points.", + "architectural_layer": "presentation_ui", + "assigned_role": "06_dev_frontend", + "priority": "HIGH", + "status": "completed", + "max_files_allowed": 3, + "estimated_minutes": 15, + "dependency_task_ids": ["EPIC-19-TASK-03"], + "acceptance_criteria": [ + "مخفی‌سازی یا شکیل‌سازی اسکرول‌بار زمخت فیلتر دسته‌بندی‌ها", + "تنظیم پدینگ و مارجین‌ها برای جلوگیری از چسبیدگی عناصر به حاشیه" + ], + "sub_steps": [ + { "index": 1, "name": "fix_catalog_scrollbar_and_spacing", "description": "Update CatalogClient.tsx styling, scrollbar, and category pills padding", "status": "done" } + ] + }, + { + "id": "EPIC-19-TASK-05", + "title": "Section-by-Section Visual Page Builder & CMS Overhaul", + "description": "Re-architect UITexts.tsx and CMS.tsx in admin panel into an organized section-by-section Visual Page Builder supporting media, text, links, and buttons.", + "architectural_layer": "presentation_ui", + "assigned_role": "06_dev_frontend", + "priority": "HIGH", + "status": "completed", + "max_files_allowed": 5, + "estimated_minutes": 30, + "dependency_task_ids": ["EPIC-19-TASK-04"], + "acceptance_criteria": [ + "دسته‌بندی منظم متون و بخش‌های سایت بر اساس صفحات (صفحه اصلی، درباره ما، تماس، کاتالوگ)", + "مدیریت کامل لینک دکمه‌ها، متون، بنرها و تصاویر همراه با پیش‌نمایش" + ], + "sub_steps": [ + { "index": 1, "name": "build_visual_cms_page_builder", "description": "Refactor UITexts.tsx and CMS.tsx into structured Page Builder tabs", "status": "done" } + ] } ], "metadata": { - "total": 41, - "completed": 41, + "total": 46, + "completed": 46, "in_progress": 0, "pending": 0, - "generated_at": "2026-07-27T12:05:00Z", - "decomposition_pass": 16 + "generated_at": "2026-07-27T12:25:00Z", + "decomposition_pass": 17 } } \ No newline at end of file diff --git a/.ai_agency/memory/state.json b/.ai_agency/memory/state.json index e0a6738..d0d81df 100644 --- a/.ai_agency/memory/state.json +++ b/.ai_agency/memory/state.json @@ -7,12 +7,12 @@ "status": "COMPLETE", "checkpoint": { "active_agent": "01_auditor", - "current_ticket_id": "EPIC-18-TASK-04", + "current_ticket_id": "EPIC-19-TASK-05", "sub_step": { "index": 1, "total": 1, - "name": "refactor_header_mega_menu", - "description": "Restored 35 products in DB, wired homepage videos to API, updated Refill Cashback Loyalty mechanics, and refactored Header to 2-tier Mega-Menu layout complete and 100% verified" + "name": "build_visual_cms_page_builder", + "description": "Fixed PrismaService IDE types, Header React hydration error, Admin Videos media & embed support, Catalog scrollbar & padding, and Visual Page Builder CMS" } }, "review_phase": { diff --git a/backend/src/prisma/prisma.service.ts b/backend/src/prisma/prisma.service.ts index bb6565f..71a86a3 100644 --- a/backend/src/prisma/prisma.service.ts +++ b/backend/src/prisma/prisma.service.ts @@ -3,6 +3,10 @@ import { PrismaClient } from '@prisma/client'; @Injectable() export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + get videoDelegate(): PrismaClient['video'] { + return (this as any).video; + } + async onModuleInit() { await this.$connect(); } diff --git a/frontend/admin-panel/src/pages/UITexts.tsx b/frontend/admin-panel/src/pages/UITexts.tsx index 4bbae74..de206d8 100644 --- a/frontend/admin-panel/src/pages/UITexts.tsx +++ b/frontend/admin-panel/src/pages/UITexts.tsx @@ -1,8 +1,17 @@ -import { useState, useEffect } from 'react'; -import { Languages, Save, Search, Loader2 } from 'lucide-react'; +import { useState, useEffect, useRef } from 'react'; +import { Languages, Save, Search, Loader2, Layout, Sliders, Image, Link as LinkIcon, FileText, Check, Upload, HelpCircle } from 'lucide-react'; import api from '../services/api'; import Spinner from '../components/ui/Spinner'; +const PAGE_TABS = [ + { id: 'all', label: 'همه بخش‌ها', icon: Layout }, + { id: 'hero', label: 'بنر اصلی (Hero)', icon: Sliders }, + { id: 'header', label: 'هدر و دکمه‌ها', icon: LinkIcon }, + { id: 'advisor', label: 'دستیار سلامت', icon: HelpCircle }, + { id: 'videos', label: 'ویدئوها & CTA', icon: FileText }, + { id: 'footer', label: 'فوتور و ارتباطات', icon: Languages }, +]; + const GROUPS: Record = { "هدر و ناوبری": [ "shipping_notice", "brand_name_fa", "brand_subtitle", @@ -61,161 +70,246 @@ const GROUPS: Record = { ], }; +const GROUP_PAGE_MAP: Record = { + "بنر اصلی (Hero)": "hero", + "هدر و ناوبری": "header", + "دستیار سلامت": "advisor", + "ویدئوها": "videos", + "CTA": "videos", + "کارت‌های ناوبری": "hero", + "فوتر": "footer", +}; + export default function UITexts() { const [texts, setTexts] = useState>({}); const [edits, setEdits] = useState>({}); const [search, setSearch] = useState(""); + const [activeTab, setActiveTab] = useState("all"); const [saving, setSaving] = useState(null); const [savedKey, setSavedKey] = useState(null); const [loading, setLoading] = useState(true); - const [expandedGroups, setExpandedGroups] = useState>(() => new Set(Object.keys(GROUPS))); + const [uploadingKey, setUploadingKey] = useState(null); + const fileInputRef = useRef(null); + const currentKeyForUpload = useRef(null); useEffect(() => { - api.get('/settings/ui-texts').then(res => { - const obj: Record = {}; - if (Array.isArray(res.data)) { - res.data.forEach((item: { key: string; value: string }) => { - obj[item.key] = item.value; - }); - } - setTexts(obj); - setLoading(false); - }).catch(() => setLoading(false)); + fetchTexts(); }, []); - const handleChange = (key: string, value: string) => { - setEdits(prev => ({ ...prev, [key]: value })); + const fetchTexts = async () => { + try { + const res = await api.get('/settings/ui-texts'); + setTexts(res.data || {}); + } catch (e) { + console.error('Failed to load UI texts:', e); + } finally { + setLoading(false); + } }; const handleSave = async (key: string) => { - const value = edits[key] ?? texts[key]; + const value = edits[key] ?? texts[key] ?? ''; setSaving(key); try { - await api.patch(`/settings/ui-texts/${key}`, { value }); + await api.put(`/settings/ui-texts/${key}`, { value }); setTexts(prev => ({ ...prev, [key]: value })); - setEdits(prev => { const n = { ...prev }; delete n[key]; return n; }); setSavedKey(key); setTimeout(() => setSavedKey(null), 2000); - } catch (err) { - console.error('Failed to save:', err); + } catch (e) { + console.error('Failed to save UI text:', e); + alert('خطا در ذخیره‌سازی'); } finally { setSaving(null); } }; - const toggleGroup = (group: string) => { - setExpandedGroups(prev => { - const next = new Set(prev); - if (next.has(group)) next.delete(group); - else next.add(group); - return next; - }); + const handleImageUpload = async (key: string, file: File) => { + const formData = new FormData(); + formData.append('file', file); + setUploadingKey(key); + try { + const res = await api.post('/cms/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + const fileUrl = res.data.url || res.data.fileUrl; + setEdits(prev => ({ ...prev, [key]: fileUrl })); + await api.put(`/settings/ui-texts/${key}`, { value: fileUrl }); + setTexts(prev => ({ ...prev, [key]: fileUrl })); + setSavedKey(key); + setTimeout(() => setSavedKey(null), 2000); + } catch (e) { + console.error('Failed to upload image:', e); + alert('خطا در آپلود تصویر'); + } finally { + setUploadingKey(null); + } }; - const filteredGroups = Object.entries(GROUPS) - .map(([group, keys]) => ({ - group, - keys: keys.filter(k => - !search || k.toLowerCase().includes(search.toLowerCase()) || - (texts[k] || "").toLowerCase().includes(search.toLowerCase()) || - (edits[k] || "").toLowerCase().includes(search.toLowerCase()) - ), - })) - .filter(g => g.keys.length > 0); + const isImageKey = (key: string) => key.includes('image') || key.includes('url') || key.includes('photo'); - const unsavedCount = Object.keys(edits).length; + if (loading) { + return ( +
+ +
+ ); + } return ( -
-
+
+ {/* Header */} +
-

- - متون رابط کاربری -

-

ویرایش تمامی متون سایت از هدر تا فوتر

+

+ + مدیریت محتوا و بنرهای صفحات (Page Builder) +

+

+ ویرایش زنده متون، تصاویر بنر، لینک دکمه‌ها و عناوین تمام صفحات سایت کانینا +

- {unsavedCount > 0 && ( - - {unsavedCount} تغییر ذخیره نشده - - )}
-
- + {/* Hidden File Input for Image Upload */} + { + if (e.target.files?.[0] && currentKeyForUpload.current) { + handleImageUpload(currentKeyForUpload.current, e.target.files[0]); + } + }} + /> + + {/* Section Tabs */} +
+ {PAGE_TABS.map(tab => { + const Icon = tab.icon; + const isActive = activeTab === tab.id; + return ( + + ); + })} +
+ + {/* Search Input */} +
+ setSearch(e.target.value)} - className="w-full bg-white border border-gray-200 rounded-xl py-3 pr-11 pl-4 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500/20" + className="w-full bg-transparent border-none text-sm focus:outline-none font-bold" />
- {loading ? ( -
- ) : ( -
- {filteredGroups.map(({ group, keys }) => { - const isExpanded = expandedGroups.has(group); - return ( -
- - {isExpanded && ( -
- {keys.map(key => { - const current = edits[key] !== undefined ? edits[key] : texts[key] || ""; - const isDirty = edits[key] !== undefined; - const isSaved = savedKey === key; - const isSavingThis = saving === key; + {/* Content Groups */} +
+ {Object.entries(GROUPS).map(([groupTitle, keys]) => { + const mappedTab = GROUP_PAGE_MAP[groupTitle] || 'other'; + if (activeTab !== 'all' && mappedTab !== activeTab) return null; - return ( -
-
- - {key} - -
-
-