From 558d03533b4c9a686925ed1840d863a06809d2de Mon Sep 17 00:00:00 2001 From: hamid zarghami Date: Tue, 15 Jul 2025 12:40:39 +0330 Subject: [PATCH] auto select and edit --- .env | 4 +- mvp-implementation-plan.md | 303 +++++++++++ src/langs/fa.json | 21 +- src/pages/email-builder/EmailBuilder.tsx | 265 +++++++++ .../components/blocks/ButtonBlock.tsx | 143 +++++ .../components/blocks/ImageBlock.tsx | 187 +++++++ .../components/blocks/SpacerBlock.tsx | 111 ++++ .../components/blocks/TextBlock.tsx | 128 +++++ .../components/canvas/BlockRenderer.tsx | 112 ++++ .../components/canvas/Canvas.tsx | 142 +++++ .../components/panels/BlockLibrary.tsx | 139 +++++ src/pages/email-builder/store/builderStore.ts | 315 +++++++++++ src/pages/email-builder/types/blocks.ts | 145 +++++ src/pages/email-builder/utils/htmlExporter.ts | 306 +++++++++++ src/pages/setting/Setting.tsx | 67 ++- src/pages/setting/enum/SettingEnum.ts | 1 + src/pages/setting/personality/List.tsx | 54 ++ src/pages/setting/personality/SideBar.tsx | 78 ++- .../personality/components/ButtonRenderer.tsx | 109 ++-- .../personality/components/ButtonSidebar.tsx | 176 +++++- .../personality/components/ContentSection.tsx | 211 ++++++-- .../personality/components/FooterSection.tsx | 211 ++++++-- .../personality/components/HeaderSection.tsx | 211 ++++++-- .../personality/components/ImageRenderer.tsx | 118 ++-- .../personality/components/ImageSideBar.tsx | 180 +++++- .../personality/components/SocialRenderer.tsx | 248 +++++++++ .../personality/components/SocialSidebar.tsx | 351 ++++++++++++ .../personality/components/Templete.tsx | 46 ++ .../personality/components/TextRenderer.tsx | 81 ++- .../personality/components/TextSidebar.tsx | 165 +++++- .../personality/hooks/usePersonalityData.ts | 22 + .../setting/personality/service/Service.ts | 17 + src/pages/setting/personality/store/Store.ts | 218 +++++++- src/pages/setting/personality/types/Types.ts | 150 +++++ .../setting/personality/utils/ExportHTML.ts | 511 ++++++++++++++++++ src/router/AppRouter.tsx | 4 + src/shared/SideBar.tsx | 13 +- src/utils/Paths.ts | 2 + 38 files changed, 5269 insertions(+), 296 deletions(-) create mode 100644 mvp-implementation-plan.md create mode 100644 src/pages/email-builder/EmailBuilder.tsx create mode 100644 src/pages/email-builder/components/blocks/ButtonBlock.tsx create mode 100644 src/pages/email-builder/components/blocks/ImageBlock.tsx create mode 100644 src/pages/email-builder/components/blocks/SpacerBlock.tsx create mode 100644 src/pages/email-builder/components/blocks/TextBlock.tsx create mode 100644 src/pages/email-builder/components/canvas/BlockRenderer.tsx create mode 100644 src/pages/email-builder/components/canvas/Canvas.tsx create mode 100644 src/pages/email-builder/components/panels/BlockLibrary.tsx create mode 100644 src/pages/email-builder/store/builderStore.ts create mode 100644 src/pages/email-builder/types/blocks.ts create mode 100644 src/pages/email-builder/utils/htmlExporter.ts create mode 100644 src/pages/setting/personality/List.tsx create mode 100644 src/pages/setting/personality/components/SocialRenderer.tsx create mode 100644 src/pages/setting/personality/components/SocialSidebar.tsx create mode 100644 src/pages/setting/personality/components/Templete.tsx create mode 100644 src/pages/setting/personality/hooks/usePersonalityData.ts create mode 100644 src/pages/setting/personality/service/Service.ts create mode 100644 src/pages/setting/personality/utils/ExportHTML.ts diff --git a/.env b/.env index cbb7fa7..2663e7e 100644 --- a/.env +++ b/.env @@ -1,8 +1,8 @@ VITE_TOKEN_NAME = 'dsc_token' VITE_REFRESH_TOKEN_NAME = 'dsc_refresh_token' VITE_DANAK_BASE_URL ='https://api.danakcorp.com' -# VITE_BASE_URL = 'http://192.168.1.113:4000' -VITE_BASE_URL = 'https://dmail-api.danakcorp.com' +VITE_BASE_URL = 'http://192.168.1.113:4000' +# VITE_BASE_URL = 'https://dmail-api.danakcorp.com' VITE_SERVICE_ID = 'e51afdc3-ea0b-49cf-8f49-2a6f131b024e' VITE_LOGIN_URL = 'https://console.danakcorp.com/auth/login' diff --git a/mvp-implementation-plan.md b/mvp-implementation-plan.md new file mode 100644 index 0000000..0aeae15 --- /dev/null +++ b/mvp-implementation-plan.md @@ -0,0 +1,303 @@ +# 🎯 DanakMail Template Builder MVP - Implementation Plan + +## 📋 Executive Summary + +This MVP combines the best features of Waypoint's template builder with your current Persian email template system to create a powerful, RTL-compatible email template builder. + +## 🎨 Key Features to Implement + +### Phase 1: Core Block System (Week 1-2) +- ✅ **Text Block**: Rich text editing with Persian support +- ✅ **Button Block**: Customizable CTAs with styling +- ✅ **Image Block**: Upload/URL with responsive options +- ✅ **Container Block**: Layout wrapper with styling +- ✅ **Spacer Block**: Vertical spacing control + +### Phase 2: Drag & Drop Interface (Week 3-4) +- 🔄 **Block Library**: Sidebar with draggable blocks +- 🔄 **Canvas Area**: Drop zone for template building +- 🔄 **Block Management**: Add, remove, reorder blocks +- 🔄 **Properties Panel**: Context-sensitive editing + +### Phase 3: Template Variables (Week 5-6) +- 📝 **Variable System**: Dynamic content placeholders +- 📝 **Test Data**: Preview with sample data +- 📝 **Conditional Logic**: Show/hide based on data +- 📝 **Loop Support**: Repeat blocks with arrays + +### Phase 4: Export & Preview (Week 7-8) +- 📤 **HTML Export**: Email-compatible HTML +- 📤 **Live Preview**: Real-time template preview +- 📤 **Mobile Preview**: Responsive design testing +- 📤 **Email Client Testing**: Gmail, Outlook compatibility + +## 🏗️ Architecture Overview + +``` +src/ +├── pages/ +│ └── email-builder/ +│ ├── EmailBuilder.tsx # Main builder interface +│ ├── components/ +│ │ ├── blocks/ # Block components +│ │ │ ├── TextBlock.tsx +│ │ │ ├── ButtonBlock.tsx +│ │ │ ├── ImageBlock.tsx +│ │ │ ├── ContainerBlock.tsx +│ │ │ └── SpacerBlock.tsx +│ │ ├── panels/ # Side panels +│ │ │ ├── BlockLibrary.tsx +│ │ │ ├── PropertiesPanel.tsx +│ │ │ └── VariablesPanel.tsx +│ │ ├── canvas/ # Canvas area +│ │ │ ├── Canvas.tsx +│ │ │ ├── DropZone.tsx +│ │ │ └── BlockRenderer.tsx +│ │ └── preview/ # Preview system +│ │ ├── LivePreview.tsx +│ │ ├── MobilePreview.tsx +│ │ └── EmailPreview.tsx +│ ├── hooks/ +│ │ ├── useEmailBuilder.ts +│ │ ├── useDragDrop.ts +│ │ └── useTemplateExport.ts +│ ├── store/ +│ │ ├── builderStore.ts +│ │ └── templatesStore.ts +│ ├── types/ +│ │ ├── blocks.ts +│ │ ├── template.ts +│ │ └── export.ts +│ └── utils/ +│ ├── blockRenderers.ts +│ ├── htmlExporter.ts +│ └── templateValidator.ts +``` + +## 🔧 Technical Implementation + +### 1. Block Interface Definition + +```typescript +// src/pages/email-builder/types/blocks.ts +interface BaseBlock { + id: string + type: BlockType + data: BlockData + style: BlockStyle + responsive?: ResponsiveSettings +} + +interface TextBlock extends BaseBlock { + type: 'text' + data: { + content: string + variables?: string[] + } + style: { + fontSize: number + fontWeight: string + color: string + textAlign: 'left' | 'center' | 'right' + lineHeight: number + } +} + +interface ButtonBlock extends BaseBlock { + type: 'button' + data: { + text: string + url: string + variables?: string[] + } + style: { + backgroundColor: string + textColor: string + borderRadius: number + padding: Spacing + fontSize: number + } +} +``` + +### 2. Drag & Drop Implementation + +```typescript +// src/pages/email-builder/hooks/useDragDrop.ts +import { useDragDropManager } from '@dnd-kit/core' + +export const useDragDrop = () => { + const { addBlock, moveBlock, removeBlock } = useEmailBuilderStore() + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event + + if (active.data.current?.type === 'library-block') { + // Add new block from library + addBlock(active.data.current.blockType, over?.id) + } else if (active.data.current?.type === 'canvas-block') { + // Reorder existing blocks + moveBlock(active.id, over?.id) + } + } + + return { handleDragEnd } +} +``` + +### 3. Template Variable System + +```typescript +// src/pages/email-builder/utils/templateProcessor.ts +export const processTemplate = (template: Template, variables: Record) => { + return template.blocks.map(block => { + switch (block.type) { + case 'text': + return { + ...block, + data: { + ...block.data, + content: replaceVariables(block.data.content, variables) + } + } + case 'button': + return { + ...block, + data: { + ...block.data, + text: replaceVariables(block.data.text, variables), + url: replaceVariables(block.data.url, variables) + } + } + default: + return block + } + }) +} + +const replaceVariables = (content: string, variables: Record) => { + return content.replace(/\{\{(\w+)\}\}/g, (match, key) => { + return variables[key] || match + }) +} +``` + +### 4. HTML Export System + +```typescript +// src/pages/email-builder/utils/htmlExporter.ts +export const exportToHTML = (template: Template, options: ExportOptions) => { + const styles = generateEmailStyles(template, options) + const bodyHTML = renderBlocks(template.blocks, options) + + return ` + + + + + + ${template.name} + ${styles} + + + + + + +
+ + ${bodyHTML} +
+
+ + + ` +} +``` + +## 📊 Comparison Matrix + +| Feature | Current System | Waypoint | MVP Target | +|---------|---------------|----------|------------| +| RTL Support | ✅ Full | ❌ None | ✅ Full | +| Drag & Drop | ❌ None | ✅ Full | ✅ Full | +| Block System | ⚠️ Limited | ✅ Rich | ✅ Rich | +| Variables | ❌ None | ✅ LiquidJS | ✅ Simple | +| Email Client Support | ⚠️ Basic | ✅ Full | ✅ Full | +| Persian UI | ✅ Full | ❌ None | ✅ Full | +| Template Library | ✅ Basic | ✅ Rich | ✅ Rich | +| Live Preview | ⚠️ Basic | ✅ Advanced | ✅ Advanced | + +## 🚀 Development Timeline + +### Week 1-2: Foundation +- [ ] Setup new email builder structure +- [ ] Implement basic block types +- [ ] Create block components +- [ ] Setup Zustand store + +### Week 3-4: Drag & Drop +- [ ] Install @dnd-kit/core +- [ ] Implement block library sidebar +- [ ] Create canvas drop zone +- [ ] Add block management + +### Week 5-6: Advanced Features +- [ ] Template variables system +- [ ] Properties panel +- [ ] Test data scenarios +- [ ] Conditional rendering + +### Week 7-8: Export & Polish +- [ ] HTML export optimization +- [ ] Email client testing +- [ ] Mobile responsive preview +- [ ] Performance optimization + +## 💻 Getting Started + +1. **Install Dependencies**: +```bash +npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities +npm install react-beautiful-dnd @types/react-beautiful-dnd +``` + +2. **Update Store Structure**: +```typescript +// Extend existing personality store +interface EmailBuilderStore extends PersonalityStore { + blocks: Block[] + template: Template + variables: TemplateVariable[] + addBlock: (type: BlockType, position?: number) => void + updateBlock: (id: string, updates: Partial) => void + removeBlock: (id: string) => void +} +``` + +3. **Migration Path**: +- Keep existing personality system +- Add new email builder alongside +- Gradually migrate users to new system +- Maintain backward compatibility + +## 🎯 Success Metrics + +- **User Adoption**: 80% of users try new builder within first month +- **Template Creation Time**: 50% reduction compared to current system +- **Email Compatibility**: 99% rendering across major clients +- **Mobile Responsiveness**: Perfect rendering on all devices +- **Persian RTL Support**: Flawless right-to-left layout + +## 🔮 Future Enhancements (Post-MVP) + +1. **AI-Powered Templates**: Generate templates from text descriptions +2. **Collaboration**: Real-time editing with team members +3. **Analytics**: Template performance tracking +4. **A/B Testing**: Compare template variants +5. **Animation Support**: CSS animations for email elements +6. **Component Library**: Reusable design system components + +--- + +*This MVP plan provides a clear roadmap to build a world-class email template builder that combines Waypoint's advanced features with your system's Persian/RTL strengths.* \ No newline at end of file diff --git a/src/langs/fa.json b/src/langs/fa.json index a90e032..b8b00d0 100644 --- a/src/langs/fa.json +++ b/src/langs/fa.json @@ -136,8 +136,27 @@ "select_section": "لطفا یک بخش انتخاب کنید", "title1": "عنوان", "quota": "فضا/ استفاده شده ", - "name_sign": "نام امضا" + "name_sign": "نام امضا", + "edit_mode": "حالت ویرایش", + "update_text": "بروزرسانی متن", + "update_button": "بروزرسانی دکمه", + "update_image": "بروزرسانی تصویر", + "current_image": "تصویر فعلی", + "cancel": "لغو", + "social_networks": "شبکه‌های اجتماعی", + "social_network": "شبکه اجتماعی", + "social_link": "لینک شبکه اجتماعی", + "select_network": "انتخاب شبکه", + "icon_style": "نوع آیکون", + "icon_color": "رنگ آیکون", + "add_social": "اضافه کردن شبکه اجتماعی", + "update_social": "بروزرسانی شبکه اجتماعی", + "delete": "حذف" }, + "save": "ذخیره", + "export_html": "خروجی HTML", + "export_success": "خروجی HTML با موفقیت کپی شد", + "export_error": "خطا در تولید خروجی HTML", "app": { "menu": "منو" }, diff --git a/src/pages/email-builder/EmailBuilder.tsx b/src/pages/email-builder/EmailBuilder.tsx new file mode 100644 index 0000000..38e8a65 --- /dev/null +++ b/src/pages/email-builder/EmailBuilder.tsx @@ -0,0 +1,265 @@ +import { FC, useState } from 'react' +import { useEmailBuilderStore } from './store/builderStore' +import BlockLibrary from './components/panels/BlockLibrary' +import Canvas from './components/canvas/Canvas' +import Button from '@/components/Button' +import Input from '@/components/Input' +import { + Eye, + EyeSlash, + DocumentDownload, + Save2, + Refresh, + Setting4 +} from 'iconsax-react' + +const EmailBuilder: FC = () => { + const { + template, + isPreviewMode, + togglePreview, + updateTemplateName, + exportTemplate, + resetTemplate + } = useEmailBuilderStore() + + const [showSettings, setShowSettings] = useState(false) + + const handleSave = () => { + // TODO: Integrate with existing template save API + const exportedTemplate = exportTemplate() + console.log('Template to save:', exportedTemplate) + // Here you would call your existing template save API + } + + const handleExportHTML = () => { + import('./utils/htmlExporter').then(({ exportToHTML, downloadFile }) => { + const html = exportToHTML(template) + downloadFile(html, `${template.name}.html`, 'text/html') + }) + } + + const handleReset = () => { + if (confirm('آیا مطمئن هستید که می‌خواهید قالب را ریست کنید؟')) { + resetTemplate() + } + } + + return ( +
+ {/* Top Toolbar */} +
+
+
+
+
+ 📧 +
+
+

+ سازنده قالب ایمیل +

+

+ Email Template Builder v2.0 +

+
+
+ +
+ updateTemplateName(e.target.value)} + placeholder="نام قالب" + className="w-48" + /> +
+
+ +
+ + + + + + + + + +
+
+
+ + {/* Main Content */} +
+ {/* Left Sidebar - Block Library */} +
+ +
+ + {/* Main Canvas */} +
+ +
+ + {/* Right Sidebar - Settings (conditional) */} + {showSettings && ( +
+
+

+ تنظیمات قالب +

+ +
+
+ + { + const width = parseInt(e.target.value) || 600 + useEmailBuilderStore.getState().updateTemplateSettings({ width }) + }} + min="300" + max="800" + /> +
+ +
+ + { + useEmailBuilderStore.getState().updateTemplateSettings({ + backgroundColor: e.target.value + }) + }} + /> +
+ +
+ + +
+ +
+ { + useEmailBuilderStore.getState().updateTemplateSettings({ + rtl: e.target.checked + }) + }} + className="rounded" + /> + +
+
+ +
+

📊 آمار قالب

+
+
تعداد بلوک‌ها: {template.blocks.length}
+
متغیرها: {template.variables.length}
+
عرض: {template.settings.width}px
+
جهت: {template.settings.rtl ? 'راست‌چین' : 'چپ‌چین'}
+
+
+
+
+ )} +
+ + {/* Status Bar */} +
+
+
+ + 📦 {template.blocks.length} بلوک + + {template.variables.length > 0 && ( + + 🔧 {template.variables.length} متغیر + + )} + + 📏 {template.settings.width}px + +
+ +
+ {isPreviewMode ? '👁️ حالت پیش‌نمایش' : '✏️ حالت ویرایش'} +
+
+
+
+ ) +} + +export default EmailBuilder \ No newline at end of file diff --git a/src/pages/email-builder/components/blocks/ButtonBlock.tsx b/src/pages/email-builder/components/blocks/ButtonBlock.tsx new file mode 100644 index 0000000..5e42dcd --- /dev/null +++ b/src/pages/email-builder/components/blocks/ButtonBlock.tsx @@ -0,0 +1,143 @@ +import { FC, useState } from 'react' +import { ButtonBlock as ButtonBlockType } from '../../types/blocks' +import Input from '@/components/Input' + +interface ButtonBlockProps { + block: ButtonBlockType + isSelected?: boolean + onUpdate: (updates: Partial) => void + onSelect: () => void + isEditing?: boolean +} + +const ButtonBlock: FC = ({ + block, + isSelected, + onUpdate, + onSelect, + isEditing = false +}) => { + const [localText, setLocalText] = useState(block.data.text) + const [localUrl, setLocalUrl] = useState(block.data.url) + + const handleTextChange = (text: string) => { + setLocalText(text) + onUpdate({ + data: { + ...block.data, + text + } + }) + } + + const handleUrlChange = (url: string) => { + setLocalUrl(url) + onUpdate({ + data: { + ...block.data, + url + } + }) + } + + const blockStyle = { + display: 'inline-block', + backgroundColor: block.style.backgroundColor, + color: block.style.textColor, + fontSize: `${block.style.fontSize}px`, + fontWeight: block.style.fontWeight, + textAlign: block.style.textAlign as 'left' | 'center' | 'right', + textDecoration: 'none', + borderRadius: block.style.borderRadius ? `${block.style.borderRadius}px` : '6px', + padding: block.style.padding ? + `${block.style.padding.top}px ${block.style.padding.right}px ${block.style.padding.bottom}px ${block.style.padding.left}px` : + '12px 24px', + margin: block.style.margin ? + `${block.style.margin.top}px ${block.style.margin.right}px ${block.style.margin.bottom}px ${block.style.margin.left}px` : + '8px 0', + border: 'none', + cursor: 'pointer', + width: typeof block.style.width === 'number' ? `${block.style.width}px` : block.style.width, + minWidth: '80px', + position: 'relative' as const + } + + const containerStyle = { + border: isSelected ? '2px dashed #0038FF' : '2px dashed transparent', + borderRadius: '8px', + padding: '4px', + textAlign: block.style.textAlign as 'left' | 'center' | 'right', + position: 'relative' as const + } + + // Process variables in text for preview + const processVariables = (content: string) => { + return content.replace(/\{\{(\w+)\}\}/g, (match, key) => { + const sampleData: Record = { + name: 'احمد محمدی', + url: 'https://example.com', + company: 'شرکت نمونه' + } + return sampleData[key] || match + }) + } + + return ( +
+ {isEditing ? ( +
+ handleTextChange(e.target.value)} + placeholder="متن دکمه را وارد کنید" + /> + handleUrlChange(e.target.value)} + placeholder="https://example.com" + /> +
+ ) : ( + + )} + + {/* Block type indicator */} + {isSelected && ( +
+ دکمه +
+ )} +
+ ) +} + +export default ButtonBlock \ No newline at end of file diff --git a/src/pages/email-builder/components/blocks/ImageBlock.tsx b/src/pages/email-builder/components/blocks/ImageBlock.tsx new file mode 100644 index 0000000..0410ef4 --- /dev/null +++ b/src/pages/email-builder/components/blocks/ImageBlock.tsx @@ -0,0 +1,187 @@ +import { FC, useState } from 'react' +import { ImageBlock as ImageBlockType } from '../../types/blocks' +import Input from '@/components/Input' +import UploadBoxDraggble from '@/components/UploadBoxDraggble' + +interface ImageBlockProps { + block: ImageBlockType + isSelected?: boolean + onUpdate: (updates: Partial) => void + onSelect: () => void + isEditing?: boolean +} + +const ImageBlock: FC = ({ + block, + isSelected, + onUpdate, + onSelect, + isEditing = false +}) => { + const [localSrc, setLocalSrc] = useState(block.data.src) + const [localAlt, setLocalAlt] = useState(block.data.alt) + const [localUrl, setLocalUrl] = useState(block.data.url || '') + + const handleSrcChange = (src: string) => { + setLocalSrc(src) + onUpdate({ + data: { + ...block.data, + src + } + }) + } + + const handleAltChange = (alt: string) => { + setLocalAlt(alt) + onUpdate({ + data: { + ...block.data, + alt + } + }) + } + + const handleUrlChange = (url: string) => { + setLocalUrl(url) + onUpdate({ + data: { + ...block.data, + url + } + }) + } + + const handleImageUpload = (files: File[]) => { + if (files.length > 0) { + const file = files[0] + const imageUrl = URL.createObjectURL(file) + handleSrcChange(imageUrl) + } + } + + const imageStyle = { + width: typeof block.style.width === 'number' ? `${block.style.width}px` : block.style.width, + height: typeof block.style.height === 'number' ? `${block.style.height}px` : block.style.height, + objectFit: block.style.objectFit as 'cover' | 'contain' | 'fill', + borderRadius: block.style.borderRadius ? `${block.style.borderRadius}px` : '0', + maxWidth: '100%', + display: 'block', + margin: block.style.margin ? + `${block.style.margin.top}px ${block.style.margin.right}px ${block.style.margin.bottom}px ${block.style.margin.left}px` : + '8px 0' + } + + const containerStyle = { + border: isSelected ? '2px dashed #0038FF' : '2px dashed transparent', + borderRadius: '8px', + padding: '4px', + textAlign: block.style.textAlign as 'left' | 'center' | 'right', + position: 'relative' as const, + display: 'inline-block', + minHeight: '60px', + minWidth: '80px' + } + + // Process variables in src for preview + const processVariables = (content: string) => { + return content.replace(/\{\{(\w+)\}\}/g, (match, key) => { + const sampleData: Record = { + userImage: 'https://via.placeholder.com/150x150?text=تصویر+کاربر', + companyLogo: 'https://via.placeholder.com/200x80?text=لوگو+شرکت', + productImage: 'https://via.placeholder.com/300x200?text=تصویر+محصول' + } + return sampleData[key] || match + }) + } + + const processedSrc = processVariables(localSrc) + + return ( +
+ {isEditing ? ( +
+ + handleSrcChange(e.target.value)} + placeholder="https://example.com/image.jpg" + /> + handleAltChange(e.target.value)} + placeholder="توضیح تصویر" + /> + handleUrlChange(e.target.value)} + placeholder="https://example.com" + /> +
+ ) : ( +
+ {localUrl ? ( + e.stopPropagation()} + > + {localAlt} { + const target = e.target as HTMLImageElement + target.src = 'https://via.placeholder.com/400x200?text=تصویر+یافت+نشد' + }} + /> + + ) : ( + {localAlt} { + const target = e.target as HTMLImageElement + target.src = 'https://via.placeholder.com/400x200?text=تصویر+یافت+نشد' + }} + /> + )} +
+ )} + + {/* Block type indicator */} + {isSelected && ( +
+ تصویر +
+ )} +
+ ) +} + +export default ImageBlock \ No newline at end of file diff --git a/src/pages/email-builder/components/blocks/SpacerBlock.tsx b/src/pages/email-builder/components/blocks/SpacerBlock.tsx new file mode 100644 index 0000000..435d698 --- /dev/null +++ b/src/pages/email-builder/components/blocks/SpacerBlock.tsx @@ -0,0 +1,111 @@ +import { FC, useState } from 'react' +import { SpacerBlock as SpacerBlockType } from '../../types/blocks' +import Input from '@/components/Input' + +interface SpacerBlockProps { + block: SpacerBlockType + isSelected?: boolean + onUpdate: (updates: Partial) => void + onSelect: () => void + isEditing?: boolean +} + +const SpacerBlock: FC = ({ + block, + isSelected, + onUpdate, + onSelect, + isEditing = false +}) => { + const [localHeight, setLocalHeight] = useState(block.style.height.toString()) + + const handleHeightChange = (heightStr: string) => { + const height = parseInt(heightStr) || 24 + setLocalHeight(heightStr) + onUpdate({ + style: { + ...block.style, + height + } + }) + } + + const spacerStyle = { + height: `${block.style.height}px`, + backgroundColor: isSelected ? '#f0f8ff' : 'transparent', + border: isSelected ? '2px dashed #0038FF' : '1px dashed #e5e7eb', + borderRadius: '4px', + margin: block.style.margin ? + `${block.style.margin.top}px ${block.style.margin.right}px ${block.style.margin.bottom}px ${block.style.margin.left}px` : + '0', + position: 'relative' as const, + cursor: 'pointer', + minHeight: '12px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '100%' + } + + return ( +
+ {isEditing ? ( +
+ handleHeightChange(e.target.value)} + placeholder="24" + min="1" + max="200" + /> +
+ ) : ( + <> + {/* Visual indicator when not selected */} + {!isSelected && ( +
+ فاصله {block.style.height}px +
+ )} + + )} + + {/* Block type indicator */} + {isSelected && ( +
+ فاصله +
+ )} +
+ ) +} + +export default SpacerBlock \ No newline at end of file diff --git a/src/pages/email-builder/components/blocks/TextBlock.tsx b/src/pages/email-builder/components/blocks/TextBlock.tsx new file mode 100644 index 0000000..f51b984 --- /dev/null +++ b/src/pages/email-builder/components/blocks/TextBlock.tsx @@ -0,0 +1,128 @@ +import { FC, useState } from 'react' +import { TextBlock as TextBlockType } from '../../types/blocks' +import ReactQuill from 'react-quill-new' + +interface TextBlockProps { + block: TextBlockType + isSelected?: boolean + onUpdate: (updates: Partial) => void + onSelect: () => void + isEditing?: boolean +} + +const TextBlock: FC = ({ + block, + isSelected, + onUpdate, + onSelect, + isEditing = false +}) => { + const [localContent, setLocalContent] = useState(block.data.content) + + const handleContentChange = (content: string) => { + setLocalContent(content) + onUpdate({ + data: { + ...block.data, + content + } + }) + } + + const blockStyle = { + fontSize: `${block.style.fontSize}px`, + fontWeight: block.style.fontWeight, + color: block.style.color, + textAlign: block.style.textAlign as 'left' | 'center' | 'right', + lineHeight: block.style.lineHeight, + fontFamily: block.style.fontFamily, + backgroundColor: block.style.backgroundColor, + padding: block.style.padding ? + `${block.style.padding.top}px ${block.style.padding.right}px ${block.style.padding.bottom}px ${block.style.padding.left}px` : + '8px', + margin: block.style.margin ? + `${block.style.margin.top}px ${block.style.margin.right}px ${block.style.margin.bottom}px ${block.style.margin.left}px` : + '0', + borderRadius: block.style.borderRadius ? `${block.style.borderRadius}px` : '0', + border: isSelected ? '2px dashed #0038FF' : 'none', + cursor: 'pointer', + minHeight: '40px', + position: 'relative' as const + } + + // Process variables in content for preview + const processVariables = (content: string) => { + return content.replace(/\{\{(\w+)\}\}/g, (match, key) => { + // Replace with sample data for preview + const sampleData: Record = { + name: 'احمد محمدی', + email: 'ahmad@example.com', + company: 'شرکت نمونه', + date: '۱۴۰۳/۱۲/۰۱' + } + return sampleData[key] || match + }) + } + + return ( +
+ {isEditing ? ( + + ) : ( +
+ )} + + {/* Block type indicator */} + {isSelected && ( +
+ متن +
+ )} +
+ ) +} + +export default TextBlock \ No newline at end of file diff --git a/src/pages/email-builder/components/canvas/BlockRenderer.tsx b/src/pages/email-builder/components/canvas/BlockRenderer.tsx new file mode 100644 index 0000000..a9d7015 --- /dev/null +++ b/src/pages/email-builder/components/canvas/BlockRenderer.tsx @@ -0,0 +1,112 @@ +import { FC } from 'react' +import { Block, BlockType } from '../../types/blocks' +import TextBlock from '../blocks/TextBlock' +import ButtonBlock from '../blocks/ButtonBlock' +import ImageBlock from '../blocks/ImageBlock' +import SpacerBlock from '../blocks/SpacerBlock' + +interface BlockRendererProps { + block: Block + isSelected?: boolean + onUpdate: (updates: Partial) => void + onSelect: () => void + isEditing?: boolean +} + +const BlockRenderer: FC = ({ + block, + isSelected, + onUpdate, + onSelect, + isEditing +}) => { + + switch (block.type) { + case BlockType.TEXT: + return ( + + ) + + case BlockType.BUTTON: + return ( + + ) + + case BlockType.IMAGE: + return ( + + ) + + case BlockType.SPACER: + return ( + + ) + + case BlockType.CONTAINER: + // TODO: Implement ContainerBlock + return ( +
+
+ Container Block (در حال توسعه) +
+ + {isSelected && ( +
+ کانتینر +
+ )} +
+ ) + } +} + +export default BlockRenderer \ No newline at end of file diff --git a/src/pages/email-builder/components/canvas/Canvas.tsx b/src/pages/email-builder/components/canvas/Canvas.tsx new file mode 100644 index 0000000..be983cf --- /dev/null +++ b/src/pages/email-builder/components/canvas/Canvas.tsx @@ -0,0 +1,142 @@ +import { FC, useEffect } from 'react' +import { useEmailBuilderStore } from '../../store/builderStore' +import BlockRenderer from './BlockRenderer' + +interface CanvasProps { + className?: string +} + +const Canvas: FC = ({ className = '' }) => { + const { + template, + selectedBlockId, + selectBlock, + updateBlock, + removeBlock, + isPreviewMode + } = useEmailBuilderStore() + + // Handle keyboard events + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (selectedBlockId && (e.key === 'Delete' || e.key === 'Backspace')) { + e.preventDefault() + removeBlock(selectedBlockId) + } + + if (e.key === 'Escape') { + selectBlock(null) + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [selectedBlockId, removeBlock, selectBlock]) + + // Handle canvas click (deselect blocks) + const handleCanvasClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + selectBlock(null) + } + } + + const canvasStyle = { + width: `${template.settings.width}px`, + maxWidth: '100%', + backgroundColor: template.settings.backgroundColor, + fontFamily: template.settings.fontFamily, + direction: template.settings.rtl ? 'rtl' as const : 'ltr' as const, + minHeight: '400px', + margin: '0 auto', + padding: '20px', + borderRadius: '12px', + boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)', + position: 'relative' as const + } + + return ( +
+ {/* Canvas Header */} +
+
+

+ {template.name} +

+

+ {template.blocks.length} بلوک • عرض {template.settings.width}px +

+
+ + {selectedBlockId && ( +
+ بلوک انتخاب شده +
+ )} +
+ + {/* Canvas Area */} +
+
+ {template.blocks.length === 0 ? ( +
+
📧
+

قالب خالی است

+

+ از سایدبار سمت راست، بلوک‌های مورد نیاز را اضافه کنید +

+
+ ) : ( +
+ {template.blocks.map((block) => ( +
+ updateBlock(block.id, updates)} + onSelect={() => selectBlock(block.id)} + isEditing={selectedBlockId === block.id && !isPreviewMode} + /> +
+ ))} +
+ )} +
+
+ + {/* Canvas Footer */} +
+
+ {template.variables.length > 0 && ( + + {template.variables.length} متغیر تعریف شده + + )} +
+ +
+ 📱 موبایل: {template.settings.width <= 480 ? '✅' : '⚠️'} + 🖥️ دسکتاپ: ✅ + 📧 ایمیل: ✅ +
+
+ + {/* Instructions */} + {template.blocks.length > 0 && ( +
+

+ 💡 راهنما: روی بلوک‌ها کلیک کنید تا ویرایش شوند • + کلید Delete برای حذف • Escape برای لغو انتخاب +

+
+ )} +
+ ) +} + +export default Canvas \ No newline at end of file diff --git a/src/pages/email-builder/components/panels/BlockLibrary.tsx b/src/pages/email-builder/components/panels/BlockLibrary.tsx new file mode 100644 index 0000000..b41a9b1 --- /dev/null +++ b/src/pages/email-builder/components/panels/BlockLibrary.tsx @@ -0,0 +1,139 @@ +import { FC, ReactElement } from 'react' +import { BlockType } from '../../types/blocks' +import { useEmailBuilderStore } from '../../store/builderStore' +import { Text, LinkSquare, Gallery, Box1, ArrangeVertical } from 'iconsax-react' + +interface BlockLibraryProps { + className?: string +} + +interface BlockDefinition { + type: BlockType + name: string + icon: ReactElement + description: string + color: string +} + +const blockDefinitions: BlockDefinition[] = [ + { + type: BlockType.TEXT, + name: 'متن', + icon: , + description: 'اضافه کردن متن و محتوای نوشتاری', + color: '#3b82f6' + }, + { + type: BlockType.BUTTON, + name: 'دکمه', + icon: , + description: 'دکمه قابل کلیک با لینک', + color: '#10b981' + }, + { + type: BlockType.IMAGE, + name: 'تصویر', + icon: , + description: 'افزودن تصویر یا عکس', + color: '#f59e0b' + }, + { + type: BlockType.SPACER, + name: 'فاصله', + icon: , + description: 'اضافه کردن فاصله عمودی', + color: '#6b7280' + }, + { + type: BlockType.CONTAINER, + name: 'کانتینر', + icon: , + description: 'گروه‌بندی سایر المان‌ها', + color: '#8b5cf6' + } +] + +const BlockLibrary: FC = ({ className = '' }) => { + const { addBlock } = useEmailBuilderStore() + + const handleAddBlock = (type: BlockType) => { + addBlock(type) + } + + return ( +
+
+

بلوک‌ها

+

+ المان‌های مورد نیاز را به قالب اضافه کنید +

+
+ +
+ {blockDefinitions.map((blockDef) => ( +
handleAddBlock(blockDef.type)} + style={{ + borderColor: '#e5e7eb' + }} + onMouseEnter={(e) => { + e.currentTarget.style.borderColor = blockDef.color + e.currentTarget.style.backgroundColor = `${blockDef.color}08` + }} + onMouseLeave={(e) => { + e.currentTarget.style.borderColor = '#e5e7eb' + e.currentTarget.style.backgroundColor = 'transparent' + }} + > +
+
+ {blockDef.icon} +
+ +
+

+ {blockDef.name} +

+

+ {blockDef.description} +

+
+ +
+ + + +
+
+
+ ))} +
+ +
+

💡 راهنما

+
    +
  • • روی هر بلوک کلیک کنید تا اضافه شود
  • +
  • • برای ویرایش، روی بلوک در کانوس کلیک کنید
  • +
  • • برای حذف، بلوک را انتخاب کرده و Delete بزنید
  • +
+
+
+ ) +} + +export default BlockLibrary \ No newline at end of file diff --git a/src/pages/email-builder/store/builderStore.ts b/src/pages/email-builder/store/builderStore.ts new file mode 100644 index 0000000..22c5058 --- /dev/null +++ b/src/pages/email-builder/store/builderStore.ts @@ -0,0 +1,315 @@ +import { create } from "zustand"; +import { v4 as uuidv4 } from "uuid"; +import { Block, BlockType, Template, TemplateVariable } from "../types/blocks"; + +interface EmailBuilderStore { + // Template state + template: Template; + selectedBlockId: string | null; + isPreviewMode: boolean; + + // Actions + setTemplate: (template: Template) => void; + + // Block management + addBlock: (type: BlockType, position?: number) => void; + updateBlock: (id: string, updates: Partial) => void; + removeBlock: (id: string) => void; + moveBlock: (fromIndex: number, toIndex: number) => void; + selectBlock: (id: string | null) => void; + + // Template management + updateTemplateName: (name: string) => void; + updateTemplateSettings: (settings: Partial) => void; + + // Variables + addVariable: (variable: Omit) => void; + updateVariable: (key: string, updates: Partial) => void; + removeVariable: (key: string) => void; + + // Preview + togglePreview: () => void; + + // Export + exportTemplate: () => Template; + importTemplate: (template: Template) => void; + + // Reset + resetTemplate: () => void; +} + +const defaultTemplate: Template = { + id: uuidv4(), + name: "قالب جدید", + description: "", + blocks: [], + variables: [], + settings: { + width: 600, + backgroundColor: "#ffffff", + fontFamily: "Vazir, Arial, sans-serif", + rtl: true, + }, +}; + +const createDefaultBlock = (type: BlockType): Block => { + const baseId = uuidv4(); + + switch (type) { + case BlockType.TEXT: + return { + id: baseId, + type: BlockType.TEXT, + data: { + content: "متن نمونه - برای ویرایش کلیک کنید", + variables: [], + }, + style: { + fontSize: 14, + fontWeight: "normal", + color: "#000000", + textAlign: "right", + lineHeight: 1.5, + fontFamily: "Vazir, Arial, sans-serif", + padding: { top: 8, right: 16, bottom: 8, left: 16 }, + margin: { top: 0, right: 0, bottom: 0, left: 0 }, + }, + }; + + case BlockType.BUTTON: + return { + id: baseId, + type: BlockType.BUTTON, + data: { + text: "کلیک کنید", + url: "https://example.com", + variables: [], + }, + style: { + backgroundColor: "#0038FF", + textColor: "#ffffff", + fontSize: 14, + fontWeight: "bold", + width: "auto", + textAlign: "center", + borderRadius: 6, + padding: { top: 12, right: 24, bottom: 12, left: 24 }, + margin: { top: 8, right: 0, bottom: 8, left: 0 }, + }, + }; + + case BlockType.IMAGE: + return { + id: baseId, + type: BlockType.IMAGE, + data: { + src: "https://via.placeholder.com/400x200?text=تصویر+نمونه", + alt: "تصویر نمونه", + url: "", + variables: [], + }, + style: { + width: "100%", + height: "auto", + objectFit: "cover", + textAlign: "center", + borderRadius: 8, + margin: { top: 8, right: 0, bottom: 8, left: 0 }, + }, + }; + + case BlockType.SPACER: + return { + id: baseId, + type: BlockType.SPACER, + data: {} as Record, + style: { + height: 24, + margin: { top: 0, right: 0, bottom: 0, left: 0 }, + }, + }; + + case BlockType.CONTAINER: + return { + id: baseId, + type: BlockType.CONTAINER, + data: { + children: [], + }, + style: { + backgroundColor: "#f8f9fa", + borderRadius: 8, + padding: { top: 16, right: 16, bottom: 16, left: 16 }, + margin: { top: 8, right: 0, bottom: 8, left: 0 }, + direction: "column", + justifyContent: "flex-start", + alignItems: "flex-start", + }, + }; + + default: + throw new Error(`Unknown block type: ${type}`); + } +}; + +export const useEmailBuilderStore = create((set, get) => ({ + template: defaultTemplate, + selectedBlockId: null, + isPreviewMode: false, + + setTemplate: (template) => set({ template }), + + addBlock: (type, position) => + set((state) => { + const newBlock = createDefaultBlock(type); + const blocks = [...state.template.blocks]; + + if (position !== undefined) { + blocks.splice(position, 0, newBlock); + } else { + blocks.push(newBlock); + } + + return { + template: { + ...state.template, + blocks, + }, + selectedBlockId: newBlock.id, + }; + }), + + updateBlock: (id, updates) => + set((state) => { + const blocks = state.template.blocks.map((block) => + block.id === id ? ({ ...block, ...updates } as Block) : block + ); + + return { + template: { + ...state.template, + blocks, + }, + }; + }), + + removeBlock: (id) => + set((state) => { + const blocks = state.template.blocks.filter((block) => block.id !== id); + + return { + template: { + ...state.template, + blocks, + }, + selectedBlockId: + state.selectedBlockId === id ? null : state.selectedBlockId, + }; + }), + + moveBlock: (fromIndex, toIndex) => + set((state) => { + const blocks = [...state.template.blocks]; + const [movedBlock] = blocks.splice(fromIndex, 1); + blocks.splice(toIndex, 0, movedBlock); + + return { + template: { + ...state.template, + blocks, + }, + }; + }), + + selectBlock: (id) => set({ selectedBlockId: id }), + + updateTemplateName: (name) => + set((state) => ({ + template: { + ...state.template, + name, + }, + })), + + updateTemplateSettings: (settings) => + set((state) => ({ + template: { + ...state.template, + settings: { + ...state.template.settings, + ...settings, + }, + }, + })), + + addVariable: (variable) => + set((state) => { + const key = variable.description.toLowerCase().replace(/\s+/g, "_"); + const newVariable: TemplateVariable = { + ...variable, + key, + }; + + return { + template: { + ...state.template, + variables: [...state.template.variables, newVariable], + }, + }; + }), + + updateVariable: (key, updates) => + set((state) => { + const variables = state.template.variables.map((variable) => + variable.key === key ? { ...variable, ...updates } : variable + ); + + return { + template: { + ...state.template, + variables, + }, + }; + }), + + removeVariable: (key) => + set((state) => { + const variables = state.template.variables.filter( + (variable) => variable.key !== key + ); + + return { + template: { + ...state.template, + variables, + }, + }; + }), + + togglePreview: () => + set((state) => ({ + isPreviewMode: !state.isPreviewMode, + selectedBlockId: null, + })), + + exportTemplate: () => { + return get().template; + }, + + importTemplate: (template) => + set({ + template, + selectedBlockId: null, + isPreviewMode: false, + }), + + resetTemplate: () => + set({ + template: { + ...defaultTemplate, + id: uuidv4(), + }, + selectedBlockId: null, + isPreviewMode: false, + }), +})); diff --git a/src/pages/email-builder/types/blocks.ts b/src/pages/email-builder/types/blocks.ts new file mode 100644 index 0000000..d2b1896 --- /dev/null +++ b/src/pages/email-builder/types/blocks.ts @@ -0,0 +1,145 @@ +export enum BlockType { + TEXT = "text", + BUTTON = "button", + IMAGE = "image", + CONTAINER = "container", + SPACER = "spacer", +} + +export interface Spacing { + top: number; + right: number; + bottom: number; + left: number; +} + +export interface ResponsiveSettings { + mobile?: Partial; + tablet?: Partial; +} + +export interface BaseBlock { + id: string; + type: BlockType; + data: Record; + style: BlockStyle; + responsive?: ResponsiveSettings; +} + +export interface BlockStyle { + margin?: Spacing; + padding?: Spacing; + backgroundColor?: string; + borderRadius?: number; + border?: { + width: number; + style: string; + color: string; + }; +} + +export interface TextBlock extends BaseBlock { + type: BlockType.TEXT; + data: { + content: string; + variables?: string[]; + }; + style: BlockStyle & { + fontSize: number; + fontWeight: string; + color: string; + textAlign: "left" | "center" | "right"; + lineHeight: number; + fontFamily?: string; + }; +} + +export interface ButtonBlock extends BaseBlock { + type: BlockType.BUTTON; + data: { + text: string; + url: string; + variables?: string[]; + }; + style: BlockStyle & { + textColor: string; + fontSize: number; + fontWeight: string; + width?: number | "auto" | "100%"; + textAlign: "left" | "center" | "right"; + }; +} + +export interface ImageBlock extends BaseBlock { + type: BlockType.IMAGE; + data: { + src: string; + alt: string; + url?: string; + variables?: string[]; + }; + style: BlockStyle & { + width: number | "auto" | "100%"; + height: number | "auto"; + objectFit: "cover" | "contain" | "fill"; + textAlign: "left" | "center" | "right"; + }; +} + +export interface ContainerBlock extends BaseBlock { + type: BlockType.CONTAINER; + data: { + children: Block[]; + }; + style: BlockStyle & { + maxWidth?: number; + direction?: "row" | "column"; + justifyContent?: "flex-start" | "center" | "flex-end" | "space-between"; + alignItems?: "flex-start" | "center" | "flex-end"; + }; +} + +export interface SpacerBlock extends BaseBlock { + type: BlockType.SPACER; + data: Record; + style: BlockStyle & { + height: number; + }; +} + +export type Block = + | TextBlock + | ButtonBlock + | ImageBlock + | ContainerBlock + | SpacerBlock; + +// Template related types +export interface TemplateVariable { + key: string; + defaultValue: string; + type: "text" | "url" | "image" | "number"; + description: string; +} + +export interface Template { + id: string; + name: string; + description?: string; + blocks: Block[]; + variables: TemplateVariable[]; + settings: { + width: number; + backgroundColor: string; + fontFamily: string; + rtl: boolean; + }; +} + +export interface ExportOptions { + format: "html" | "json"; + includeCSS: boolean; + optimizeForEmailClients: boolean; + rtlSupport: boolean; + minify: boolean; +} diff --git a/src/pages/email-builder/utils/htmlExporter.ts b/src/pages/email-builder/utils/htmlExporter.ts new file mode 100644 index 0000000..39250e0 --- /dev/null +++ b/src/pages/email-builder/utils/htmlExporter.ts @@ -0,0 +1,306 @@ +import { Template, Block, BlockType, ExportOptions } from "../types/blocks"; + +// Default export options +const defaultExportOptions: ExportOptions = { + format: "html", + includeCSS: true, + optimizeForEmailClients: true, + rtlSupport: true, + minify: false, +}; + +// Convert blocks to HTML +const renderBlockToHTML = (block: Block): string => { + switch (block.type) { + case BlockType.TEXT: + return ` + + + ${block.data.content} + + + `; + + case BlockType.BUTTON: + return ` + + + + + + +
+ + ${block.data.text} + +
+ + + `; + + case BlockType.IMAGE: { + const imageHTML = ` + ${block.data.alt} + `; + + return ` + + + ${ + block.data.url + ? `${imageHTML}` + : imageHTML + } + + + `; + } + + case BlockType.SPACER: + return ` + + +   + + + `; + + case BlockType.CONTAINER: + // TODO: Implement container rendering + return ` + + + + + + `; + + default: + return ""; + } +}; + +// Generate CSS for email clients +const generateEmailCSS = ( + template: Template, + options: ExportOptions +): string => { + if (!options.includeCSS) return ""; + + return ` + + `; +}; + +// Main export function +export const exportToHTML = ( + template: Template, + options: Partial = {} +): string => { + const finalOptions = { ...defaultExportOptions, ...options }; + + const css = generateEmailCSS(template, finalOptions); + const bodyHTML = template.blocks + .map((block) => renderBlockToHTML(block)) + .join(""); + + const html = ` + + + + + + + + ${template.name} + ${css} + + + + + + + + +
+ + ${bodyHTML} + +
+ + + + + `; + + if (finalOptions.minify) { + return html.replace(/\s+/g, " ").replace(/>\s+<").trim(); + } + + return html; +}; + +// Export JSON +export const exportToJSON = (template: Template): string => { + return JSON.stringify(template, null, 2); +}; + +// Download file helper +export const downloadFile = ( + content: string, + filename: string, + mimeType: string = "text/plain" +) => { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +}; diff --git a/src/pages/setting/Setting.tsx b/src/pages/setting/Setting.tsx index b68947c..ac93edd 100644 --- a/src/pages/setting/Setting.tsx +++ b/src/pages/setting/Setting.tsx @@ -9,13 +9,23 @@ import Address from './address/Address' import PersonalitySidebar from './personality/SideBar' import Signture from './signture/Signture' import { Paths } from '@/utils/Paths' +import Button from '@/components/Button' +import { TickCircle } from 'iconsax-react' +import { usePersonalityStore } from './personality/store/Store' +import { exportPersonalityToHTML } from './personality/utils/ExportHTML' +import Input from '@/components/Input' +import { useSaveTemplate } from './personality/hooks/usePersonalityData' +import { toast } from '@/components/Toast' const Setting: FC = () => { + const { t } = useTranslation() + const { data } = usePersonalityStore() const location = useLocation() const [activeTab, setActiveTab] = useState(SettingTabEnum.SETTING_DOMAIN) + const [templateName, setTemplateName] = useState('') + const { mutate: saveTemplate, isPending } = useSaveTemplate() - // Map URL paths to setting tabs const getTabFromPath = (pathname: string): SettingTabEnum => { switch (pathname) { case Paths.settingMailServer: @@ -67,9 +77,62 @@ const Setting: FC = () => { } }; + const handleSave = async () => { + if (!templateName) { + toast('نام قالب را وارد کنید', 'error') + return + } + + const html = await exportPersonalityToHTML(data) + saveTemplate({ + name: templateName, + rawHtml: html, + structure: data + }, { + onSuccess: (data) => { + toast(data?.data?.message, 'success') + } + }) + } + + // const handleExportHTML = async () => { + // try { + // const html = await exportPersonalityToHTML(data) + // const success = await copyHTMLToClipboard(html) + + // if (success) { + // toast(t('export_success'), 'success') + // } else { + // toast(t('export_error'), 'error') + // } + // } catch { + // toast(t('export_error'), 'error') + // } + // } + return (
-

{t('setting.title')}

+
+

{t('setting.title')}

+
+ setTemplateName(e.target.value)} + /> + +
+
{renderActiveTab()} diff --git a/src/pages/setting/enum/SettingEnum.ts b/src/pages/setting/enum/SettingEnum.ts index 952e899..c8466b0 100644 --- a/src/pages/setting/enum/SettingEnum.ts +++ b/src/pages/setting/enum/SettingEnum.ts @@ -11,5 +11,6 @@ export enum SideBarTab { TEXT = "text", BUTTON = "button", IMAGE = "image", + SOCIAL = "social", NONE = "none", } diff --git a/src/pages/setting/personality/List.tsx b/src/pages/setting/personality/List.tsx new file mode 100644 index 0000000..a7ddd62 --- /dev/null +++ b/src/pages/setting/personality/List.tsx @@ -0,0 +1,54 @@ +import Button from '@/components/Button' +import { AddCircle } from 'iconsax-react' +import { FC } from 'react' +import { useGetTemplates } from './hooks/usePersonalityData' +import { Link } from 'react-router-dom' +import { Paths } from '@/utils/Paths' +import { TemplateResponseType } from './types/Types' +import Templete from './components/Templete' + +const List: FC = () => { + const { data } = useGetTemplates() + + // پیدا کردن template انتخاب شده + const selectedTemplateId = data?.data?.templates?.find( + (template: TemplateResponseType) => template.selected + )?.id + + return ( +
+
+
قالب ها
+ + + +
+ +
+ { + data?.data?.templates?.map((item: TemplateResponseType) => { + return ( + + ) + }) + } + +
+
+ ) +} + +export default List \ No newline at end of file diff --git a/src/pages/setting/personality/SideBar.tsx b/src/pages/setting/personality/SideBar.tsx index 7b3730d..7c7713f 100644 --- a/src/pages/setting/personality/SideBar.tsx +++ b/src/pages/setting/personality/SideBar.tsx @@ -1,17 +1,51 @@ import { FC, useState, useRef, useEffect } from 'react' -import { Gallery, LinkSquare, Setting4, Text } from 'iconsax-react' +import { Gallery, LinkSquare, Setting4, Text, Profile2User } from 'iconsax-react' import Logo from '@/assets/images/logo-small.svg' import SettingSideBar from './components/SettingSideBar' import { SideBarTab } from '../enum/SettingEnum' import TextSidebar from './components/TextSidebar' import ButtonSidebar from './components/ButtonSidebar' import ImageSideBar from './components/ImageSideBar' +import SocialSidebar from './components/SocialSidebar' import { clx } from '@/helpers/utils' +import { usePersonalityStore } from './store/Store' +import { ElementType } from './types/Types' const PersonalitySidebar: FC = () => { const [active, setActive] = useState(SideBarTab.SETTING) const sidebarRef = useRef(null) + const { selectedElement } = usePersonalityStore() + + // Auto-switch to relevant tab when element is selected + useEffect(() => { + if (selectedElement) { + console.log('Element selected, switching tab:', selectedElement); + switch (selectedElement.type) { + case ElementType.TEXT: + console.log('Switching to TEXT tab'); + setActive(SideBarTab.TEXT) + break + case ElementType.BUTTON: + console.log('Switching to BUTTON tab'); + setActive(SideBarTab.BUTTON) + break + case ElementType.IMAGE: + console.log('Switching to IMAGE tab'); + setActive(SideBarTab.IMAGE) + break + case ElementType.SOCIAL: + console.log('Switching to SOCIAL tab'); + setActive(SideBarTab.SOCIAL) + break + default: + console.log('Unknown element type'); + break + } + } else { + console.log('No element selected'); + } + }, [selectedElement]) useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -42,7 +76,8 @@ const PersonalitySidebar: FC = () => { : active === SideBarTab.TEXT ? : active === SideBarTab.BUTTON ? : active === SideBarTab.IMAGE ? - : null + : active === SideBarTab.SOCIAL ? + : null }
)} @@ -54,10 +89,41 @@ const PersonalitySidebar: FC = () => { 'flex flex-col gap-10 mt-16', active === SideBarTab.NONE && 'mt-4' )}> - setActive(SideBarTab.SETTING)} className='cursor-pointer' /> - setActive(SideBarTab.TEXT)} className='cursor-pointer' /> - setActive(SideBarTab.BUTTON)} className='cursor-pointer' /> - setActive(SideBarTab.IMAGE)} className='cursor-pointer' /> + setActive(SideBarTab.SETTING)} + className='cursor-pointer' + /> + setActive(SideBarTab.TEXT)} + className='cursor-pointer' + /> + setActive(SideBarTab.BUTTON)} + className='cursor-pointer' + /> + setActive(SideBarTab.IMAGE)} + className='cursor-pointer' + /> + setActive(SideBarTab.SOCIAL)} + className='cursor-pointer' + />
diff --git a/src/pages/setting/personality/components/ButtonRenderer.tsx b/src/pages/setting/personality/components/ButtonRenderer.tsx index b68f3e2..7e134b1 100644 --- a/src/pages/setting/personality/components/ButtonRenderer.tsx +++ b/src/pages/setting/personality/components/ButtonRenderer.tsx @@ -1,13 +1,39 @@ import { FC } from 'react' -import { ButtonType, ButtonSize, HorizontalAlignment, VerticalAlignment } from '../types/Types' +import { ButtonType, ButtonSize, HorizontalAlignment, VerticalAlignment, ElementType, PersonalityDataType } from '../types/Types' +import { usePersonalityStore } from '../store/Store' interface ButtonRendererProps { buttons: ButtonType[] + itemId: string + sectionKey: keyof PersonalityDataType } -const ButtonRenderer: FC = ({ buttons }) => { +const ButtonRenderer: FC = ({ buttons, itemId, sectionKey }) => { + const { selectedElement, setSelectedElement } = usePersonalityStore() + if (!buttons || buttons.length === 0) return null + const handleButtonClick = (e: React.MouseEvent, buttonId: string) => { + console.log('🔘 ButtonRenderer - handleButtonClick called:', { + buttonId, + itemId, + sectionKey, + event: e, + target: e.target, + currentTarget: e.currentTarget + }); + + e.stopPropagation() // Prevent bubbling to item/section handlers + e.preventDefault() // جلوگیری از اجرای لینک + + setSelectedElement({ + type: ElementType.BUTTON, + elementId: buttonId, + itemId, + sectionKey, + }) + } + // تبدیل enum به string برای HTML attributes const getHorizontalAlign = (alignment?: HorizontalAlignment) => { switch (alignment) { @@ -47,38 +73,53 @@ const ButtonRenderer: FC = ({ buttons }) => { {buttons.slice(0, 2).map((button, buttonIndex) => ( 1 ? '4px' : '0' }}> - - - - - - -
- - {button.text} - -
+
handleButtonClick(e, button.id)} + style={{ + cursor: 'pointer', + outline: selectedElement?.elementId === button.id ? '2px dashed #0038FF' : 'none', + outlineOffset: '2px', + borderRadius: '4px', + padding: '2px', + display: 'inline-block' + }} + > + + + + + + +
+ + {button.text} + +
+
))} diff --git a/src/pages/setting/personality/components/ButtonSidebar.tsx b/src/pages/setting/personality/components/ButtonSidebar.tsx index 54cc5ff..72d96c6 100644 --- a/src/pages/setting/personality/components/ButtonSidebar.tsx +++ b/src/pages/setting/personality/components/ButtonSidebar.tsx @@ -3,16 +3,23 @@ import ColorPicker from '@/components/ColorPicker' import Input from '@/components/Input' import Select from '@/components/Select' import { Checkbox } from '@/components/ui/checkbox' -import { AlignRight, AlignBottom, AlignHorizontally, AlignLeft, AlignTop, AlignVertically, Link2, Add } from 'iconsax-react' -import { FC, useState } from 'react' +import { AlignRight, AlignBottom, AlignHorizontally, AlignLeft, AlignTop, AlignVertically, Link2, Add, Edit, Trash } from 'iconsax-react' +import { FC, useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { usePersonalityStore } from '../store/Store' -import { ButtonSize, HorizontalAlignment, VerticalAlignment } from '../types/Types' +import { ButtonSize, HorizontalAlignment, VerticalAlignment, ElementType } from '../types/Types' const ButtonSidebar: FC = () => { const { t } = useTranslation() - const { addButtonToActiveItem } = usePersonalityStore() + const { + addButtonToActiveItem, + selectedElement, + setSelectedElement, + data, + updateButton, + deleteButton + } = usePersonalityStore() const [buttonText, setButtonText] = useState('') const [buttonLink, setButtonLink] = useState('') @@ -25,6 +32,66 @@ const ButtonSidebar: FC = () => { const [horizontalAlignment, setHorizontalAlignment] = useState(HorizontalAlignment.CENTER) const [verticalAlignment, setVerticalAlignment] = useState(VerticalAlignment.MIDDLE) + const isEditMode = selectedElement?.type === ElementType.BUTTON; + + // Load selected button data when element is selected + useEffect(() => { + if (isEditMode && selectedElement) { + console.log('Loading button data for editing:', selectedElement); + + try { + const sectionData = data[selectedElement.sectionKey]; + console.log('Section data:', sectionData); + + if (!sectionData || !sectionData.items) { + console.error('Section data or items not found'); + return; + } + + const item = sectionData.items.find(item => item.id === selectedElement.itemId); + console.log('Found item:', item); + + if (!item) { + console.error('Item not found'); + return; + } + + const button = item.buttons?.find(b => b.id === selectedElement.elementId); + console.log('Found button:', button); + + if (button) { + setButtonText(button.text); + setButtonLink(button.link); + setButtonSize(button.size); + setIsBorder(button.isBorder); + setBorderSize(button.borderSize); + setTextColor(button.textColor); + setBackgroundColor(button.backgroundColor); + setBorderColor(button.borderColor); + setHorizontalAlignment(button.alignment || HorizontalAlignment.CENTER); + setVerticalAlignment(button.verticalAlignment || VerticalAlignment.MIDDLE); + console.log('Button data loaded successfully'); + } else { + console.error('Button element not found'); + } + } catch (error) { + console.error('Error loading button data:', error); + } + } else { + // Reset form when not in edit mode + setButtonText('') + setButtonLink('') + setButtonSize(ButtonSize.MEDIUM) + setIsBorder(false) + setBorderSize(1) + setTextColor('#fff') + setBackgroundColor('#000') + setBorderColor('#fff') + setHorizontalAlignment(HorizontalAlignment.CENTER) + setVerticalAlignment(VerticalAlignment.MIDDLE) + } + }, [selectedElement, isEditMode, data]); + const sizeOptions = [ { label: 'کوچک', value: ButtonSize.SMALL }, { label: 'متوسط', value: ButtonSize.MEDIUM }, @@ -58,9 +125,59 @@ const ButtonSidebar: FC = () => { setVerticalAlignment(VerticalAlignment.MIDDLE) } + const handleUpdateButton = () => { + if (selectedElement && buttonText.trim()) { + updateButton( + selectedElement.sectionKey, + selectedElement.itemId, + selectedElement.elementId, + { + text: buttonText, + link: buttonLink, + size: buttonSize, + isBorder, + borderSize, + textColor, + backgroundColor, + borderColor, + alignment: horizontalAlignment, + verticalAlignment, + } + ); + + // Exit edit mode + setSelectedElement(null); + } + } + + const handleDeleteButton = () => { + if (selectedElement) { + deleteButton( + selectedElement.sectionKey, + selectedElement.itemId, + selectedElement.elementId + ); + + // Exit edit mode + setSelectedElement(null); + } + } + + const handleCancelEdit = () => { + setSelectedElement(null); + } + return (
-
{t('setting.button')}
+
+ {t('setting.button')} + {isEditMode && ( +
+ {t('setting.edit_mode')} + +
+ )} +
{
- +
+ + +
- + ) : ( + + )}
diff --git a/src/pages/setting/personality/components/ContentSection.tsx b/src/pages/setting/personality/components/ContentSection.tsx index 6ecb060..b411b66 100644 --- a/src/pages/setting/personality/components/ContentSection.tsx +++ b/src/pages/setting/personality/components/ContentSection.tsx @@ -5,6 +5,7 @@ import { AddCircle } from 'iconsax-react' import TextRenderer from './TextRenderer' import ButtonRenderer from './ButtonRenderer' import ImageRenderer from './ImageRenderer' +import SocialRenderer from './SocialRenderer' import { SectionName, VerticalAlignment, HorizontalAlignment } from '../types/Types' const ContentSection: FC = () => { @@ -12,12 +13,81 @@ const ContentSection: FC = () => { const { t } = useTranslation() const { activeSection, activeItemIndex, data, setActiveSection, setActiveItemIndex } = usePersonalityStore() - const handleSectionClick = (section: SectionName) => { - setActiveSection(section) + console.log('🟢 ContentSection - Current state:', { + activeSection, + activeItemIndex, + contentColumnsCount: data.content.columnsCount, + contentItemsLength: data.content.items.length + }); + + const handleSectionClick = (section: SectionName, e: React.MouseEvent) => { + console.log('🟢 ContentSection - handleSectionClick called:', { + section, + event: e, + target: e.target, + currentTarget: e.currentTarget + }); + + // اگر کلیک روی فضای خالی باشد (نه روی content elements) + const target = e.target as HTMLElement; + const isClickOnContent = target.closest('[data-element-type]') !== null; + + console.log('🟢 ContentSection - Section click analysis:', { + isClickOnContent, + targetTagName: target.tagName, + targetClasses: target.className + }); + + if (!isClickOnContent) { + console.log('🟢 ContentSection - Setting active section:', section); + setActiveSection(section) + } else { + console.log('🟢 ContentSection - Click on content element, not setting section'); + } } - const handleItemClick = (index: number) => { - setActiveItemIndex(index) + const handleItemClick = (index: number, e: React.MouseEvent) => { + console.log('🟢 ContentSection - handleItemClick called:', { + index, + event: e, + target: e.target, + currentTarget: e.currentTarget + }); + + // Check if click is on a content element (text, button, image, social) + const target = e.target as HTMLElement; + const isClickOnContent = target.closest('[data-element-type]') !== null; + + console.log('🟢 ContentSection - Click analysis:', { + isClickOnContent, + targetTagName: target.tagName, + targetClasses: target.className, + closestDataElement: target.closest('[data-element-type]') + }); + + // Only select column if not clicking on content elements + if (!isClickOnContent) { + console.log('🟢 ContentSection - Selecting column:', { + index, + section: SectionName.CONTENT, + currentActiveSection: activeSection, + currentActiveItemIndex: activeItemIndex + }); + + e.stopPropagation() + + // Only set active section if we're not already in content section + if (activeSection !== SectionName.CONTENT) { + console.log('🟢 ContentSection - Switching to content section'); + setActiveSection(SectionName.CONTENT) + } + + // Always set the active item index + console.log('🟢 ContentSection - Setting active item index to:', index); + setActiveItemIndex(index) + } else { + console.log('🟢 ContentSection - Click on content element, not selecting column'); + } } // تابع‌های helper برای تعیین alignment دکمه‌ها @@ -48,7 +118,7 @@ const ContentSection: FC = () => { } return ( - handleSectionClick(SectionName.CONTENT)}> + handleSectionClick(SectionName.CONTENT, e)}> { data.content.columnsCount > 0 ? @@ -58,7 +128,8 @@ const ContentSection: FC = () => { @@ -71,11 +142,8 @@ const ContentSection: FC = () => { return ( <> - {/* اگر فقط دکمه داریم و متن نداریم */} - {(!item?.texts || item.texts.length === 0) && item?.buttons && item.buttons.length > 0 ? ( + {/* اگر column خالی است */} + {(!item?.texts || item.texts.length === 0) && + (!item?.buttons || item.buttons.length === 0) && + (!item?.images || item.images.length === 0) && + (!item?.socials || item.socials.length === 0) ? ( ) : ( <> - {/* ردیف اصلی برای متن */} - - - - {/* ردیف دکمه‌ها */} - {item?.buttons && item.buttons.length > 0 && ( + {/* اگر فقط دکمه داریم و متن نداریم */} + {(!item?.texts || item.texts.length === 0) && item?.buttons && item.buttons.length > 0 ? ( + ) : ( + <> + {/* ردیف اصلی برای متن */} + + + + {/* ردیف دکمه‌ها */} + {item?.buttons && item.buttons.length > 0 && ( + + + + )} + )} )} @@ -171,14 +282,18 @@ const ContentSection: FC = () => {
{ - e.stopPropagation(); - handleItemClick(index); - }} key={index} + onClick={(e) => handleItemClick(index, e)} style={{ width: `${100 / data.content.columnsCount}%`, height: '246px', @@ -86,7 +154,7 @@ const ContentSection: FC = () => { backgroundRepeat: item?.style?.backgroundRepeat || 'no-repeat', backgroundPosition: item?.style?.backgroundPosition || 'center', cursor: 'pointer', - padding: '0', + padding: '8px', verticalAlign: 'top', borderRadius: '8px', position: 'relative' @@ -97,56 +165,99 @@ const ContentSection: FC = () => { height: '246px' }}>
handleItemClick(index, e)} > - + {/* محتوای خالی برای columns خالی */}
0 ? "203" : "230"} - align={item?.texts?.[0]?.alignment || 'center'} - valign={item?.texts?.[0]?.verticalAlignment === VerticalAlignment.TOP ? 'top' : - item?.texts?.[0]?.verticalAlignment === VerticalAlignment.BOTTOM ? 'bottom' : 'middle'} - style={{ - padding: '8px', - fontSize: '14px', - lineHeight: '1.4' - }} - > - - -
- +
0 ? "203" : "230"} + align={item?.texts?.[0]?.alignment || 'center'} + valign={item?.texts?.[0]?.verticalAlignment === VerticalAlignment.TOP ? 'top' : + item?.texts?.[0]?.verticalAlignment === VerticalAlignment.BOTTOM ? 'bottom' : 'middle'} + style={{ + padding: '8px', + fontSize: '14px', + lineHeight: '1.4' + }} + > + + + +
+ +
- handleSectionClick(SectionName.FOOTER)}> + handleSectionClick(SectionName.FOOTER, e)}>
+ handleSectionClick(SectionName.CONTENT, e)} + style={{ + height: '286px', + border: activeSection === SectionName.CONTENT ? '1px dashed #0038FF' : '1px dashed #E5E7EB', + borderRadius: '24px', + textAlign: 'center', + verticalAlign: 'middle', + padding: '10px', + cursor: 'pointer' + }} + >
{t('setting.content_email')}
diff --git a/src/pages/setting/personality/components/FooterSection.tsx b/src/pages/setting/personality/components/FooterSection.tsx index 311f580..6377fa9 100644 --- a/src/pages/setting/personality/components/FooterSection.tsx +++ b/src/pages/setting/personality/components/FooterSection.tsx @@ -5,6 +5,7 @@ import { AddCircle } from 'iconsax-react' import TextRenderer from './TextRenderer' import ButtonRenderer from './ButtonRenderer' import ImageRenderer from './ImageRenderer' +import SocialRenderer from './SocialRenderer' import { SectionName, VerticalAlignment, HorizontalAlignment } from '../types/Types' const FooterSection: FC = () => { @@ -12,12 +13,81 @@ const FooterSection: FC = () => { const { t } = useTranslation() const { activeSection, activeItemIndex, data, setActiveSection, setActiveItemIndex } = usePersonalityStore() - const handleSectionClick = (section: SectionName) => { - setActiveSection(section) + console.log('🔴 FooterSection - Current state:', { + activeSection, + activeItemIndex, + footerColumnsCount: data.footer.columnsCount, + footerItemsLength: data.footer.items.length + }); + + const handleSectionClick = (section: SectionName, e: React.MouseEvent) => { + console.log('🔴 FooterSection - handleSectionClick called:', { + section, + event: e, + target: e.target, + currentTarget: e.currentTarget + }); + + // اگر کلیک روی فضای خالی باشد (نه روی content elements) + const target = e.target as HTMLElement; + const isClickOnContent = target.closest('[data-element-type]') !== null; + + console.log('🔴 FooterSection - Section click analysis:', { + isClickOnContent, + targetTagName: target.tagName, + targetClasses: target.className + }); + + if (!isClickOnContent) { + console.log('🔴 FooterSection - Setting active section:', section); + setActiveSection(section) + } else { + console.log('🔴 FooterSection - Click on content element, not setting section'); + } } - const handleItemClick = (index: number) => { - setActiveItemIndex(index) + const handleItemClick = (index: number, e: React.MouseEvent) => { + console.log('🔴 FooterSection - handleItemClick called:', { + index, + event: e, + target: e.target, + currentTarget: e.currentTarget + }); + + // Check if click is on a content element (text, button, image, social) + const target = e.target as HTMLElement; + const isClickOnContent = target.closest('[data-element-type]') !== null; + + console.log('🔴 FooterSection - Click analysis:', { + isClickOnContent, + targetTagName: target.tagName, + targetClasses: target.className, + closestDataElement: target.closest('[data-element-type]') + }); + + // Only select column if not clicking on content elements + if (!isClickOnContent) { + console.log('🔴 FooterSection - Selecting column:', { + index, + section: SectionName.FOOTER, + currentActiveSection: activeSection, + currentActiveItemIndex: activeItemIndex + }); + + e.stopPropagation() + + // Only set active section if we're not already in footer section + if (activeSection !== SectionName.FOOTER) { + console.log('🔴 FooterSection - Switching to footer section'); + setActiveSection(SectionName.FOOTER) + } + + // Always set the active item index + console.log('🔴 FooterSection - Setting active item index to:', index); + setActiveItemIndex(index) + } else { + console.log('🔴 FooterSection - Click on content element, not selecting column'); + } } // تابع‌های helper برای تعیین alignment دکمه‌ها @@ -48,7 +118,7 @@ const FooterSection: FC = () => { } return ( -
{ data.footer.columnsCount > 0 ? @@ -58,7 +128,8 @@ const FooterSection: FC = () => { @@ -71,11 +142,8 @@ const FooterSection: FC = () => { return ( <> - {/* اگر فقط دکمه داریم و متن نداریم */} - {(!item?.texts || item.texts.length === 0) && item?.buttons && item.buttons.length > 0 ? ( + {/* اگر column خالی است */} + {(!item?.texts || item.texts.length === 0) && + (!item?.buttons || item.buttons.length === 0) && + (!item?.images || item.images.length === 0) && + (!item?.socials || item.socials.length === 0) ? ( ) : ( <> - {/* ردیف اصلی برای متن */} - - - - {/* ردیف دکمه‌ها */} - {item?.buttons && item.buttons.length > 0 && ( + {/* اگر فقط دکمه داریم و متن نداریم */} + {(!item?.texts || item.texts.length === 0) && item?.buttons && item.buttons.length > 0 ? ( + ) : ( + <> + {/* ردیف اصلی برای متن */} + + + + {/* ردیف دکمه‌ها */} + {item?.buttons && item.buttons.length > 0 && ( + + + + )} + )} )} @@ -171,14 +282,18 @@ const FooterSection: FC = () => {
{ - e.stopPropagation(); - handleItemClick(index); - }} key={index} + onClick={(e) => handleItemClick(index, e)} style={{ width: `${100 / data.footer.columnsCount}%`, height: '123px', @@ -86,7 +154,7 @@ const FooterSection: FC = () => { backgroundRepeat: item?.style?.backgroundRepeat || 'no-repeat', backgroundPosition: item?.style?.backgroundPosition || 'center', cursor: 'pointer', - padding: '0', + padding: '8px', verticalAlign: 'top', borderRadius: '8px', position: 'relative' @@ -97,56 +165,99 @@ const FooterSection: FC = () => { height: '123px' }}>
handleItemClick(index, e)} > - + {/* محتوای خالی برای columns خالی */}
0 ? "40" : "67"} - align={item?.texts?.[0]?.alignment || 'center'} - valign={item?.texts?.[0]?.verticalAlignment === VerticalAlignment.TOP ? 'top' : - item?.texts?.[0]?.verticalAlignment === VerticalAlignment.BOTTOM ? 'bottom' : 'middle'} - style={{ - padding: '8px', - fontSize: '14px', - lineHeight: '1.4' - }} - > - - -
- +
0 ? "40" : "67"} + align={item?.texts?.[0]?.alignment || 'center'} + valign={item?.texts?.[0]?.verticalAlignment === VerticalAlignment.TOP ? 'top' : + item?.texts?.[0]?.verticalAlignment === VerticalAlignment.BOTTOM ? 'bottom' : 'middle'} + style={{ + padding: '8px', + fontSize: '14px', + lineHeight: '1.4' + }} + > + + + +
+ +
- handleSectionClick(SectionName.HEADER)}> + handleSectionClick(SectionName.HEADER, e)}> { data.header.columnsCount > 0 ? : - + ${paddingTop ? ` + `; + } else { + // حالت خالی + const emptyMessages = { + "123px": "سربرگ ایمیل", + "246px": "محتوای ایمیل", + }; + const emptyMessage = + emptyMessages[sectionHeight as keyof typeof emptyMessages] || + "فوتر ایمیل"; + + return ` + ${paddingTop ? ` + `; + } + }; + + // Generate complete HTML exactly like Personality.tsx structure + const html = `
+
+ handleSectionClick(SectionName.FOOTER, e)} + style={{ + height: '123px', + border: activeSection === SectionName.FOOTER ? '1px dashed #0038FF' : '1px dashed #E5E7EB', + borderRadius: '24px', + textAlign: 'center', + verticalAlign: 'middle', + padding: '10px', + cursor: 'pointer' + }} + >
{t('setting.footer_email')}
diff --git a/src/pages/setting/personality/components/HeaderSection.tsx b/src/pages/setting/personality/components/HeaderSection.tsx index 2e3b2ff..eddcc4b 100644 --- a/src/pages/setting/personality/components/HeaderSection.tsx +++ b/src/pages/setting/personality/components/HeaderSection.tsx @@ -5,6 +5,7 @@ import { AddCircle } from 'iconsax-react' import TextRenderer from './TextRenderer' import ButtonRenderer from './ButtonRenderer' import ImageRenderer from './ImageRenderer' +import SocialRenderer from './SocialRenderer' import { SectionName, VerticalAlignment, HorizontalAlignment } from '../types/Types' const HeaderSection: FC = () => { @@ -12,12 +13,81 @@ const HeaderSection: FC = () => { const { t } = useTranslation() const { activeSection, activeItemIndex, data, setActiveSection, setActiveItemIndex } = usePersonalityStore() - const handleSectionClick = (section: SectionName) => { - setActiveSection(section) + console.log('🔵 HeaderSection - Current state:', { + activeSection, + activeItemIndex, + headerColumnsCount: data.header.columnsCount, + headerItemsLength: data.header.items.length + }); + + const handleSectionClick = (section: SectionName, e: React.MouseEvent) => { + console.log('🔵 HeaderSection - handleSectionClick called:', { + section, + event: e, + target: e.target, + currentTarget: e.currentTarget + }); + + // اگر کلیک روی فضای خالی باشد (نه روی content elements) + const target = e.target as HTMLElement; + const isClickOnContent = target.closest('[data-element-type]') !== null; + + console.log('🔵 HeaderSection - Section click analysis:', { + isClickOnContent, + targetTagName: target.tagName, + targetClasses: target.className + }); + + if (!isClickOnContent) { + console.log('🔵 HeaderSection - Setting active section:', section); + setActiveSection(section) + } else { + console.log('🔵 HeaderSection - Click on content element, not setting section'); + } } - const handleItemClick = (index: number) => { - setActiveItemIndex(index) + const handleItemClick = (index: number, e: React.MouseEvent) => { + console.log('🔵 HeaderSection - handleItemClick called:', { + index, + event: e, + target: e.target, + currentTarget: e.currentTarget + }); + + // Check if click is on a content element (text, button, image, social) + const target = e.target as HTMLElement; + const isClickOnContent = target.closest('[data-element-type]') !== null; + + console.log('🔵 HeaderSection - Click analysis:', { + isClickOnContent, + targetTagName: target.tagName, + targetClasses: target.className, + closestDataElement: target.closest('[data-element-type]') + }); + + // Only select column if not clicking on content elements + if (!isClickOnContent) { + console.log('🔵 HeaderSection - Selecting column:', { + index, + section: SectionName.HEADER, + currentActiveSection: activeSection, + currentActiveItemIndex: activeItemIndex + }); + + e.stopPropagation() + + // Only set active section if we're not already in header section + if (activeSection !== SectionName.HEADER) { + console.log('🔵 HeaderSection - Switching to header section'); + setActiveSection(SectionName.HEADER) + } + + // Always set the active item index + console.log('🔵 HeaderSection - Setting active item index to:', index); + setActiveItemIndex(index) + } else { + console.log('🔵 HeaderSection - Click on content element, not selecting column'); + } } // تابع‌های helper برای تعیین alignment دکمه‌ها @@ -48,13 +118,14 @@ const HeaderSection: FC = () => { } return ( -
@@ -67,11 +138,8 @@ const HeaderSection: FC = () => { return ( <> - {/* اگر فقط دکمه داریم و متن نداریم */} - {(!item?.texts || item.texts.length === 0) && item?.buttons && item.buttons.length > 0 ? ( + {/* اگر column خالی است */} + {(!item?.texts || item.texts.length === 0) && + (!item?.buttons || item.buttons.length === 0) && + (!item?.images || item.images.length === 0) && + (!item?.socials || item.socials.length === 0) ? ( ) : ( <> - {/* ردیف اصلی برای متن */} - - - - {/* ردیف دکمه‌ها */} - {item?.buttons && item.buttons.length > 0 && ( + {/* اگر فقط دکمه داریم و متن نداریم */} + {(!item?.texts || item.texts.length === 0) && item?.buttons && item.buttons.length > 0 ? ( + ) : ( + <> + {/* ردیف اصلی برای متن */} + + + + {/* ردیف دکمه‌ها */} + {item?.buttons && item.buttons.length > 0 && ( + + + + )} + )} )} @@ -161,14 +272,18 @@ const HeaderSection: FC = () => {
{ - e.stopPropagation(); - handleItemClick(index); - }} key={index} + onClick={(e) => handleItemClick(index, e)} style={{ width: `${100 / data.header.columnsCount}%`, height: '123px', @@ -82,7 +150,7 @@ const HeaderSection: FC = () => { backgroundRepeat: item?.style?.backgroundRepeat || 'no-repeat', backgroundPosition: item?.style?.backgroundPosition || 'center', cursor: 'pointer', - padding: '0', + padding: '8px', verticalAlign: 'top', borderRadius: '8px', position: 'relative' @@ -93,56 +161,99 @@ const HeaderSection: FC = () => { height: '123px' }}>
handleItemClick(index, e)} > - + {/* محتوای خالی برای columns خالی */}
0 ? "60" : "87"} - align={item?.texts?.[0]?.alignment || 'center'} - valign={item?.texts?.[0]?.verticalAlignment === VerticalAlignment.TOP ? 'top' : - item?.texts?.[0]?.verticalAlignment === VerticalAlignment.BOTTOM ? 'bottom' : 'middle'} - style={{ - padding: '8px', - fontSize: '14px', - lineHeight: '1.4' - }} - > - - -
- +
0 ? "60" : "87"} + align={item?.texts?.[0]?.alignment || 'center'} + valign={item?.texts?.[0]?.verticalAlignment === VerticalAlignment.TOP ? 'top' : + item?.texts?.[0]?.verticalAlignment === VerticalAlignment.BOTTOM ? 'bottom' : 'middle'} + style={{ + padding: '8px', + fontSize: '14px', + lineHeight: '1.4' + }} + > + + + +
+ +
+ handleSectionClick(SectionName.HEADER, e)} + style={{ + height: '123px', + border: activeSection === SectionName.HEADER ? '1px dashed #0038FF' : '1px dashed #E5E7EB', + borderRadius: '24px', + textAlign: 'center', + verticalAlign: 'middle', + padding: '10px', + cursor: 'pointer' + }} + >
{t('setting.header_email')}
diff --git a/src/pages/setting/personality/components/ImageRenderer.tsx b/src/pages/setting/personality/components/ImageRenderer.tsx index f6f7056..32fa449 100644 --- a/src/pages/setting/personality/components/ImageRenderer.tsx +++ b/src/pages/setting/personality/components/ImageRenderer.tsx @@ -1,13 +1,31 @@ import { FC } from 'react' -import { ImageType, ImageSize, HorizontalAlignment, VerticalAlignment } from '../types/Types' +import { ImageType, ImageSize, HorizontalAlignment, VerticalAlignment, ElementType, PersonalityDataType } from '../types/Types' +import { usePersonalityStore } from '../store/Store' interface ImageRendererProps { images: ImageType[] + itemId: string + sectionKey: keyof PersonalityDataType } -const ImageRenderer: FC = ({ images }) => { +const ImageRenderer: FC = ({ images, itemId, sectionKey }) => { + const { selectedElement, setSelectedElement } = usePersonalityStore() + if (!images || images.length === 0) return null + const handleImageClick = (e: React.MouseEvent, imageId: string) => { + // جلوگیری از propagation به parent handlers + e.stopPropagation() + e.preventDefault() + console.log('Image clicked:', imageId) + setSelectedElement({ + type: ElementType.IMAGE, + elementId: imageId, + itemId, + sectionKey, + }) + } + // تابع‌های helper برای تبدیل enum به string const getHorizontalAlign = (alignment?: HorizontalAlignment) => { switch (alignment) { @@ -37,7 +55,8 @@ const ImageRenderer: FC = ({ images }) => { return { width: image.width || '100%', height: image.height || 'auto', - border: '0' + border: '0', + pointerEvents: 'none' as const // Prevent double click handling } } @@ -80,47 +99,60 @@ const ImageRenderer: FC = ({ images }) => { } const renderImage = (image: ImageType) => { - if (shouldUseBackgroundImage(image.size)) { - // برای pattern های repeat از background استفاده می‌کنیم - return ( -
-   -
- ) - } - - // برای تصاویر عادی - if (image.size === ImageSize.CONTAIN) { - // برای contain از table wrapper استفاده می‌کنیم تا نسبت حفظ شود - return ( - - - - - - -
- {image.alt -
- ) - } - - // برای cover و auto - return ( - {image.alt + const imageWrapper = ( +
handleImageClick(e, image.id)} + style={{ + cursor: 'pointer', + outline: selectedElement?.elementId === image.id ? '2px dashed #0038FF' : 'none', + outlineOffset: '2px', + borderRadius: '4px', + padding: '2px', + display: 'inline-block', + minWidth: '20px', + minHeight: '20px' + }} + > + {shouldUseBackgroundImage(image.size) ? ( + // برای pattern های repeat از background استفاده می‌کنیم +
+   +
+ ) : ( + // برای تصاویر عادی + image.size === ImageSize.CONTAIN ? ( + // برای contain از table wrapper استفاده می‌کنیم تا نسبت حفظ شود + + + + + + +
+ {image.alt +
+ ) : ( + // برای cover و auto + {image.alt + ) + )} +
) + + return imageWrapper } return ( diff --git a/src/pages/setting/personality/components/ImageSideBar.tsx b/src/pages/setting/personality/components/ImageSideBar.tsx index bfff861..6aff37e 100644 --- a/src/pages/setting/personality/components/ImageSideBar.tsx +++ b/src/pages/setting/personality/components/ImageSideBar.tsx @@ -1,16 +1,25 @@ import Button from '@/components/Button' import Select from '@/components/Select' import UploadBoxDraggble from '@/components/UploadBoxDraggble' -import { Add, AlignBottom, AlignHorizontally, AlignLeft, AlignRight, AlignTop, AlignVertically } from 'iconsax-react' -import { FC, useState } from 'react' +import { Add, AlignBottom, AlignHorizontally, AlignLeft, AlignRight, AlignTop, AlignVertically, Edit, Trash } from 'iconsax-react' +import { FC, useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { usePersonalityStore } from '../store/Store' -import { ImageSize, HorizontalAlignment, VerticalAlignment } from '../types/Types' +import { ImageSize, HorizontalAlignment, VerticalAlignment, ElementType } from '../types/Types' const ImageSideBar: FC = () => { const { t } = useTranslation() - const { addImageToActiveItem, activeSection, activeItemIndex, data } = usePersonalityStore() + const { + addImageToActiveItem, + activeSection, + activeItemIndex, + data, + selectedElement, + setSelectedElement, + updateImage, + deleteImage + } = usePersonalityStore() const [imageSrc, setImageSrc] = useState('') const [imageSize, setImageSize] = useState(ImageSize.COVER) @@ -19,6 +28,54 @@ const ImageSideBar: FC = () => { const [isLoading, setIsLoading] = useState(false) const [isReset, setIsReset] = useState(false) + const isEditMode = selectedElement?.type === ElementType.IMAGE; + + // Load selected image data when element is selected + useEffect(() => { + if (isEditMode && selectedElement) { + console.log('Loading image data for editing:', selectedElement); + + try { + const sectionData = data[selectedElement.sectionKey]; + console.log('Section data:', sectionData); + + if (!sectionData || !sectionData.items) { + console.error('Section data or items not found'); + return; + } + + const item = sectionData.items.find(item => item.id === selectedElement.itemId); + console.log('Found item:', item); + + if (!item) { + console.error('Item not found'); + return; + } + + const image = item.images?.find(img => img.id === selectedElement.elementId); + console.log('Found image:', image); + + if (image) { + setImageSrc(image.src); + setImageSize(image.size); + setHorizontalAlignment(image.alignment || HorizontalAlignment.CENTER); + setVerticalAlignment(image.verticalAlignment || VerticalAlignment.MIDDLE); + console.log('Image data loaded successfully'); + } else { + console.error('Image element not found'); + } + } catch (error) { + console.error('Error loading image data:', error); + } + } else { + // Reset form when not in edit mode + setImageSrc('') + setImageSize(ImageSize.COVER) + setHorizontalAlignment(HorizontalAlignment.CENTER) + setVerticalAlignment(VerticalAlignment.MIDDLE) + } + }, [selectedElement, isEditMode, data]); + const sizeOptions = [ { label: 'کاور - تمام فضا را پوشش دهد (Cover)', value: ImageSize.COVER }, { label: 'در مقیاس - در فضا جا شود (Contain)', value: ImageSize.CONTAIN }, @@ -104,9 +161,62 @@ const ImageSideBar: FC = () => { } } + const handleUpdateImage = () => { + if (selectedElement && imageSrc) { + setIsLoading(true) + + try { + updateImage( + selectedElement.sectionKey, + selectedElement.itemId, + selectedElement.elementId, + { + src: imageSrc, + alt: 'عکس آپلود شده', + size: imageSize, + alignment: horizontalAlignment, + verticalAlignment, + } + ); + + // Exit edit mode + setSelectedElement(null); + } catch (error) { + console.error('Error updating image:', error) + } finally { + setIsLoading(false) + } + } + } + + const handleDeleteImage = () => { + if (selectedElement) { + deleteImage( + selectedElement.sectionKey, + selectedElement.itemId, + selectedElement.elementId + ); + + // Exit edit mode + setSelectedElement(null); + } + } + + const handleCancelEdit = () => { + setSelectedElement(null); + } + return (
-
{t('setting.image')}
+
+ {t('setting.image')} + {isEditMode && ( +
+ {t('setting.edit_mode')} + +
+ )} +
{ />
+ {isEditMode && imageSrc && ( +
+
{t('setting.current_image')}
+ پیش‌نمایش +
+ )} +
+ + + + + +
+
+ {socials.map((social) => ( +
handleSocialClick(e, social.id)} + style={{ + cursor: 'pointer', + outline: selectedElement?.elementId === social.id ? '2px dashed #0038FF' : 'none', + outlineOffset: '2px', + borderRadius: '4px', + padding: '2px', + display: 'inline-block' + }} + > +
+ {getSocialIcon(social.networkType)} +
+
+ ))} +
+
+ ) +} + +export default SocialRenderer \ No newline at end of file diff --git a/src/pages/setting/personality/components/SocialSidebar.tsx b/src/pages/setting/personality/components/SocialSidebar.tsx new file mode 100644 index 0000000..6ff4def --- /dev/null +++ b/src/pages/setting/personality/components/SocialSidebar.tsx @@ -0,0 +1,351 @@ +import Button from '@/components/Button' +import ColorPicker from '@/components/ColorPicker' +import Input from '@/components/Input' +import Select from '@/components/Select' +import { AlignRight, AlignBottom, AlignHorizontally, AlignLeft, AlignTop, AlignVertically, Add, Edit, Trash, Link2 } from 'iconsax-react' +import { FC, useState, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { usePersonalityStore } from '../store/Store' +import { SocialNetworkType, SocialIconSize, SocialIconStyle, HorizontalAlignment, VerticalAlignment, ElementType } from '../types/Types' + +const SocialSidebar: FC = () => { + + const { t } = useTranslation() + const { + addSocialToActiveItem, + selectedElement, + setSelectedElement, + data, + updateSocial, + deleteSocial + } = usePersonalityStore() + + const [networkType, setNetworkType] = useState(SocialNetworkType.INSTAGRAM) + const [link, setLink] = useState('') + const [size, setSize] = useState(SocialIconSize.MEDIUM) + const [style, setStyle] = useState(SocialIconStyle.CIRCLE) + const [color, setColor] = useState('#000000') + const [backgroundColor, setBackgroundColor] = useState('#ffffff') + const [horizontalAlignment, setHorizontalAlignment] = useState(HorizontalAlignment.CENTER) + const [verticalAlignment, setVerticalAlignment] = useState(VerticalAlignment.MIDDLE) + + const isEditMode = selectedElement?.type === ElementType.SOCIAL; + + // Load selected social data when element is selected + useEffect(() => { + if (isEditMode && selectedElement) { + console.log('Loading social data for editing:', selectedElement); + + try { + const sectionData = data[selectedElement.sectionKey]; + console.log('Section data:', sectionData); + + if (!sectionData || !sectionData.items) { + console.error('Section data or items not found'); + return; + } + + const item = sectionData.items.find(item => item.id === selectedElement.itemId); + console.log('Found item:', item); + + if (!item) { + console.error('Item not found'); + return; + } + + const social = item.socials?.find(s => s.id === selectedElement.elementId); + console.log('Found social:', social); + + if (social) { + setNetworkType(social.networkType); + setLink(social.link); + setSize(social.size); + setStyle(social.style); + setColor(social.color || '#000000'); + setBackgroundColor(social.backgroundColor || '#ffffff'); + setHorizontalAlignment(social.alignment || HorizontalAlignment.CENTER); + setVerticalAlignment(social.verticalAlignment || VerticalAlignment.MIDDLE); + console.log('Social data loaded successfully'); + } else { + console.error('Social element not found'); + } + } catch (error) { + console.error('Error loading social data:', error); + } + } else { + // Reset form when not in edit mode + setNetworkType(SocialNetworkType.INSTAGRAM) + setLink('') + setSize(SocialIconSize.MEDIUM) + setStyle(SocialIconStyle.CIRCLE) + setColor('#000000') + setBackgroundColor('#ffffff') + setHorizontalAlignment(HorizontalAlignment.CENTER) + setVerticalAlignment(VerticalAlignment.MIDDLE) + } + }, [selectedElement, isEditMode, data]); + + const networkOptions = [ + { label: 'Instagram', value: SocialNetworkType.INSTAGRAM }, + { label: 'Facebook', value: SocialNetworkType.FACEBOOK }, + { label: 'Twitter', value: SocialNetworkType.TWITTER }, + { label: 'LinkedIn', value: SocialNetworkType.LINKEDIN }, + { label: 'YouTube', value: SocialNetworkType.YOUTUBE }, + { label: 'Telegram', value: SocialNetworkType.TELEGRAM }, + { label: 'WhatsApp', value: SocialNetworkType.WHATSAPP }, + { label: 'TikTok', value: SocialNetworkType.TIKTOK }, + { label: 'Pinterest', value: SocialNetworkType.PINTEREST }, + { label: 'Snapchat', value: SocialNetworkType.SNAPCHAT }, + { label: 'GitHub', value: SocialNetworkType.GITHUB }, + { label: 'Discord', value: SocialNetworkType.DISCORD }, + ] + + const sizeOptions = [ + { label: 'کوچک', value: SocialIconSize.SMALL }, + { label: 'متوسط', value: SocialIconSize.MEDIUM }, + { label: 'بزرگ', value: SocialIconSize.LARGE } + ] + + const styleOptions = [ + { label: 'دایره', value: SocialIconStyle.CIRCLE }, + { label: 'مربع', value: SocialIconStyle.SQUARE }, + { label: 'گرد', value: SocialIconStyle.ROUNDED } + ] + + const handleAddSocial = () => { + if (link.trim()) { + addSocialToActiveItem({ + networkType, + link, + size, + style, + color, + backgroundColor, + alignment: horizontalAlignment, + verticalAlignment, + }) + + // Reset form after adding + setNetworkType(SocialNetworkType.INSTAGRAM) + setLink('') + setSize(SocialIconSize.MEDIUM) + setStyle(SocialIconStyle.CIRCLE) + setColor('#000000') + setBackgroundColor('#ffffff') + setHorizontalAlignment(HorizontalAlignment.CENTER) + setVerticalAlignment(VerticalAlignment.MIDDLE) + } + } + + const handleUpdateSocial = () => { + if (selectedElement && link.trim()) { + updateSocial( + selectedElement.sectionKey, + selectedElement.itemId, + selectedElement.elementId, + { + networkType, + link, + size, + style, + color, + backgroundColor, + alignment: horizontalAlignment, + verticalAlignment, + } + ); + + // Exit edit mode + setSelectedElement(null); + } + } + + const handleDeleteSocial = () => { + if (selectedElement) { + deleteSocial( + selectedElement.sectionKey, + selectedElement.itemId, + selectedElement.elementId + ); + + // Exit edit mode + setSelectedElement(null); + } + } + + const handleCancelEdit = () => { + setSelectedElement(null); + } + + return ( +
+
+ {t('setting.social_networks')} + {isEditMode && ( +
+ {t('setting.edit_mode')} + +
+ )} +
+ +
+ setLink(e.target.value)} + endIcon={} + placeholder="https://..." + /> +
+ +
+ setStyle(e.target.value as SocialIconStyle)} + placeholder={t('setting.select')} + /> +
+ +
+ +
+ +
+ +
+ +
+
+
+ {t('setting.horizontal_position')} +
+
+
setHorizontalAlignment(HorizontalAlignment.RIGHT)} + className='cursor-pointer' + > + +
+
setHorizontalAlignment(HorizontalAlignment.CENTER)} + className='cursor-pointer' + > + +
+
setHorizontalAlignment(HorizontalAlignment.LEFT)} + className='cursor-pointer' + > + +
+
+
+ +
+
+ {t('setting.vertical_position')} +
+
+
setVerticalAlignment(VerticalAlignment.TOP)} + className='cursor-pointer' + > + +
+
setVerticalAlignment(VerticalAlignment.MIDDLE)} + className='cursor-pointer' + > + +
+
setVerticalAlignment(VerticalAlignment.BOTTOM)} + className='cursor-pointer' + > + +
+
+
+
+ +
+ {isEditMode ? ( +
+ +
+ + +
+
+ ) : ( + + )} +
+ +
+ ) +} + +export default SocialSidebar \ No newline at end of file diff --git a/src/pages/setting/personality/components/Templete.tsx b/src/pages/setting/personality/components/Templete.tsx new file mode 100644 index 0000000..fdee955 --- /dev/null +++ b/src/pages/setting/personality/components/Templete.tsx @@ -0,0 +1,46 @@ +import { FC, useEffect, useState } from 'react' +import { TemplateResponseType } from '../types/Types' +import Radio from '@/components/Radio' +import { Brush2, Trash } from 'iconsax-react' +import { useSetSelectedTemplate } from '../hooks/usePersonalityData' + +const Templete: FC<{ + item: TemplateResponseType + selectedTemplateId?: string +}> = ({ item, selectedTemplateId }) => { + + const [isActive, setIsActive] = useState(item.selected) + + const { mutate: setSelectedTemplate } = useSetSelectedTemplate() + + const handleChangeSelectedTemplate = (id: string) => { + setSelectedTemplate(id, { + }) + } + + useEffect(() => { + if (selectedTemplateId) { + setIsActive(selectedTemplateId === item.id) + } else { + setIsActive(item.selected) + } + }, [item.selected, selectedTemplateId, item.id]) + + return ( +
+
+
+
+ +
{item.name}
+
+
+ + +
+
+
+ ) +} + +export default Templete \ No newline at end of file diff --git a/src/pages/setting/personality/components/TextRenderer.tsx b/src/pages/setting/personality/components/TextRenderer.tsx index ea0355f..d80177c 100644 --- a/src/pages/setting/personality/components/TextRenderer.tsx +++ b/src/pages/setting/personality/components/TextRenderer.tsx @@ -1,30 +1,75 @@ import { FC } from 'react' -import { TextType } from '../types/Types' +import { TextType, ElementType, PersonalityDataType } from '../types/Types' +import { usePersonalityStore } from '../store/Store' interface TextRendererProps { texts: TextType[] + itemId: string + sectionKey: keyof PersonalityDataType } -const TextRenderer: FC = ({ texts }) => { +const TextRenderer: FC = ({ texts, itemId, sectionKey }) => { + const { selectedElement, setSelectedElement } = usePersonalityStore() + + const handleTextClick = (e: React.MouseEvent, textId: string) => { + console.log('📝 TextRenderer - handleTextClick called:', { + textId, + itemId, + sectionKey, + event: e, + target: e.target, + currentTarget: e.currentTarget + }); + + // جلوگیری از propagation به parent handlers + e.stopPropagation() + e.preventDefault() + + setSelectedElement({ + type: ElementType.TEXT, + elementId: textId, + itemId, + sectionKey, + }) + } + return ( <> {texts?.map((textItem, textIndex) => ( - - - - - -
-
+
handleTextClick(e, textItem.id)} + style={{ + cursor: 'pointer', + border: selectedElement?.elementId === textItem.id ? '2px dashed #0038FF' : '2px dashed transparent', + borderRadius: '4px', + padding: '2px', + margin: '2px 0', + transition: 'border-color 0.2s ease', + minHeight: '20px', + display: 'inline-block', + width: '100%' + }} + > + + + + + +
+
+
))} ) diff --git a/src/pages/setting/personality/components/TextSidebar.tsx b/src/pages/setting/personality/components/TextSidebar.tsx index 09bc9a2..9eba9bb 100644 --- a/src/pages/setting/personality/components/TextSidebar.tsx +++ b/src/pages/setting/personality/components/TextSidebar.tsx @@ -1,11 +1,11 @@ import Button from '@/components/Button'; -import { Add, AlignBottom, AlignHorizontally, AlignLeft, AlignRight, AlignTop, AlignVertically } from 'iconsax-react'; -import { FC, useState } from 'react' +import { Add, AlignBottom, AlignHorizontally, AlignLeft, AlignRight, AlignTop, AlignVertically, Edit, Trash } from 'iconsax-react'; +import { FC, useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import ReactQuill from 'react-quill-new'; import { usePersonalityStore } from '../store/Store'; import ColorPicker from '@/components/ColorPicker'; -import { HorizontalAlignment, VerticalAlignment } from '../types/Types'; +import { HorizontalAlignment, VerticalAlignment, ElementType } from '../types/Types'; const TextSidebar: FC = () => { @@ -14,7 +14,63 @@ const TextSidebar: FC = () => { const [color, setColor] = useState('#000'); const [horizontalPosition, setHorizontalPosition] = useState(HorizontalAlignment.CENTER); const [verticalPosition, setVerticalPosition] = useState(VerticalAlignment.MIDDLE); - const { addTextToActiveItem } = usePersonalityStore(); + + const { + addTextToActiveItem, + selectedElement, + setSelectedElement, + data, + updateText, + deleteText + } = usePersonalityStore(); + + const isEditMode = selectedElement?.type === ElementType.TEXT; + + // Load selected text data when element is selected + useEffect(() => { + if (isEditMode && selectedElement) { + console.log('Loading text data for editing:', selectedElement); + + try { + const sectionData = data[selectedElement.sectionKey]; + console.log('Section data:', sectionData); + + if (!sectionData || !sectionData.items) { + console.error('Section data or items not found'); + return; + } + + const item = sectionData.items.find(item => item.id === selectedElement.itemId); + console.log('Found item:', item); + + if (!item) { + console.error('Item not found'); + return; + } + + const text = item.texts?.find(t => t.id === selectedElement.elementId); + console.log('Found text:', text); + + if (text) { + setValue(text.text); + setColor(text.color || '#000'); + setHorizontalPosition(text.alignment || HorizontalAlignment.CENTER); + setVerticalPosition(text.verticalAlignment || VerticalAlignment.MIDDLE); + console.log('Text data loaded successfully'); + } else { + console.error('Text element not found'); + } + } catch (error) { + console.error('Error loading text data:', error); + } + } else { + // Reset form when not in edit mode + setValue(''); + setColor('#000'); + setHorizontalPosition(HorizontalAlignment.CENTER); + setVerticalPosition(VerticalAlignment.MIDDLE); + } + }, [selectedElement, isEditMode, data]); const handleChange = (value: string) => { setValue(value); @@ -37,9 +93,53 @@ const TextSidebar: FC = () => { } } + const handleUpdateText = () => { + if (selectedElement && value.trim()) { + updateText( + selectedElement.sectionKey, + selectedElement.itemId, + selectedElement.elementId, + { + text: value, + color: color, + alignment: horizontalPosition, + verticalAlignment: verticalPosition, + } + ); + + // Exit edit mode + setSelectedElement(null); + } + } + + const handleDeleteText = () => { + if (selectedElement) { + deleteText( + selectedElement.sectionKey, + selectedElement.itemId, + selectedElement.elementId + ); + + // Exit edit mode + setSelectedElement(null); + } + } + + const handleCancelEdit = () => { + setSelectedElement(null); + } + return (
-
{t('setting.text')}
+
+ {t('setting.text')} + {isEditMode && ( +
+ {t('setting.edit_mode')} + +
+ )} +
{
setHorizontalPosition(HorizontalAlignment.RIGHT)} + className='cursor-pointer' >
setHorizontalPosition(HorizontalAlignment.CENTER)} + className='cursor-pointer' >
setHorizontalPosition(HorizontalAlignment.LEFT)} + className='cursor-pointer' >
@@ -100,16 +203,19 @@ const TextSidebar: FC = () => {
setVerticalPosition(VerticalAlignment.TOP)} + className='cursor-pointer' >
setVerticalPosition(VerticalAlignment.MIDDLE)} + className='cursor-pointer' >
setVerticalPosition(VerticalAlignment.BOTTOM)} + className='cursor-pointer' >
@@ -118,15 +224,48 @@ const TextSidebar: FC = () => {
- +
+ + +
- + ) : ( + + )}
diff --git a/src/pages/setting/personality/hooks/usePersonalityData.ts b/src/pages/setting/personality/hooks/usePersonalityData.ts new file mode 100644 index 0000000..9249d1d --- /dev/null +++ b/src/pages/setting/personality/hooks/usePersonalityData.ts @@ -0,0 +1,22 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import * as api from "../service/Service"; +import { TemplateType, TemplatesResponseType } from "../types/Types"; + +export const useSaveTemplate = () => { + return useMutation({ + mutationFn: (params: TemplateType) => api.saveTemplate(params), + }); +}; + +export const useGetTemplates = () => { + return useQuery({ + queryKey: ["templates"], + queryFn: api.getTemplates, + }); +}; + +export const useSetSelectedTemplate = () => { + return useMutation({ + mutationFn: (id: string) => api.setSelectedTemplate(id), + }); +}; diff --git a/src/pages/setting/personality/service/Service.ts b/src/pages/setting/personality/service/Service.ts new file mode 100644 index 0000000..989539c --- /dev/null +++ b/src/pages/setting/personality/service/Service.ts @@ -0,0 +1,17 @@ +import axios from "@/config/axios"; +import { TemplateType, TemplatesResponseType } from "../types/Types"; + +export const saveTemplate = async (params: TemplateType) => { + const { data } = await axios.post("/templates", params); + return data; +}; + +export const getTemplates = async (): Promise => { + const { data } = await axios.get("/templates"); + return data; +}; + +export const setSelectedTemplate = async (id: string) => { + const { data } = await axios.patch(`/templates/${id}/set-selected`); + return data; +}; diff --git a/src/pages/setting/personality/store/Store.ts b/src/pages/setting/personality/store/Store.ts index 6de33b8..5dbc02c 100644 --- a/src/pages/setting/personality/store/Store.ts +++ b/src/pages/setting/personality/store/Store.ts @@ -7,9 +7,11 @@ import { TextType, ButtonType, ImageType, + SocialType, SectionName, HorizontalAlignment, VerticalAlignment, + SelectedElement, } from "../types/Types"; export const usePersonalityStore = create((set) => ({ @@ -21,11 +23,45 @@ export const usePersonalityStore = create((set) => ({ activeSection: "", activeItemIndex: 0, + selectedElement: null, + isEditMode: false, setActiveSection: (section: SectionName) => - set({ activeSection: section, activeItemIndex: 0 }), + set((state) => { + console.log("🏪 Store - setActiveSection called:", { + newSection: section, + oldSection: state.activeSection, + oldItemIndex: state.activeItemIndex, + newItemIndex: 0, + }); - setActiveItemIndex: (index: number) => set({ activeItemIndex: index }), + return { + activeSection: section, + activeItemIndex: 0, + selectedElement: null, + isEditMode: false, + }; + }), + + setActiveItemIndex: (index: number) => + set((state) => { + console.log("🏪 Store - setActiveItemIndex called:", { + newIndex: index, + oldIndex: state.activeItemIndex, + currentSection: state.activeSection, + }); + + return { + activeItemIndex: index, + selectedElement: null, + isEditMode: false, + }; + }), + + setSelectedElement: (element: SelectedElement) => + set({ selectedElement: element, isEditMode: !!element }), + + setEditMode: (isEdit: boolean) => set({ isEditMode: isEdit }), setColumnsCount: ( sectionKey: keyof PersonalityDataType, @@ -42,6 +78,7 @@ export const usePersonalityStore = create((set) => ({ texts: [], buttons: [], images: [], + socials: [], alignment: HorizontalAlignment.CENTER, verticalAlignment: VerticalAlignment.MIDDLE, } @@ -373,4 +410,181 @@ export const usePersonalityStore = create((set) => ({ }, }, })), + + deleteText: ( + sectionKey: keyof PersonalityDataType, + itemId: string, + textId: string + ) => + set((state) => ({ + data: { + ...state.data, + [sectionKey]: { + ...state.data[sectionKey], + items: state.data[sectionKey].items.map((item) => + item.id === itemId + ? { + ...item, + texts: item.texts.filter((text) => text.id !== textId), + } + : item + ), + }, + }, + selectedElement: null, + isEditMode: false, + })), + + deleteButton: ( + sectionKey: keyof PersonalityDataType, + itemId: string, + buttonId: string + ) => + set((state) => ({ + data: { + ...state.data, + [sectionKey]: { + ...state.data[sectionKey], + items: state.data[sectionKey].items.map((item) => + item.id === itemId + ? { + ...item, + buttons: item.buttons.filter( + (button) => button.id !== buttonId + ), + } + : item + ), + }, + }, + selectedElement: null, + isEditMode: false, + })), + + deleteImage: ( + sectionKey: keyof PersonalityDataType, + itemId: string, + imageId: string + ) => + set((state) => ({ + data: { + ...state.data, + [sectionKey]: { + ...state.data[sectionKey], + items: state.data[sectionKey].items.map((item) => + item.id === itemId + ? { + ...item, + images: item.images.filter((image) => image.id !== imageId), + } + : item + ), + }, + }, + selectedElement: null, + isEditMode: false, + })), + + addSocial: ( + sectionKey: keyof PersonalityDataType, + itemId: string, + social: Omit + ) => + set((state) => ({ + data: { + ...state.data, + [sectionKey]: { + ...state.data[sectionKey], + items: state.data[sectionKey].items.map((item) => + item.id === itemId + ? { + ...item, + socials: [...item.socials, { ...social, id: uuidv4() }], + } + : item + ), + }, + }, + })), + + addSocialToActiveItem: (social: Omit) => + set((state) => { + if (!state.activeSection) return state; + + const sectionKey = state.activeSection as keyof PersonalityDataType; + const items = state.data[sectionKey].items; + const activeIndex = state.activeItemIndex; + + if (activeIndex >= items.length) return state; + + const updatedItems = items.map((item, index) => + index === activeIndex + ? { + ...item, + socials: [...item.socials, { ...social, id: uuidv4() }], + } + : item + ); + + return { + data: { + ...state.data, + [sectionKey]: { + ...state.data[sectionKey], + items: updatedItems, + }, + }, + }; + }), + + updateSocial: ( + sectionKey: keyof PersonalityDataType, + itemId: string, + socialId: string, + updates: Partial + ) => + set((state) => ({ + data: { + ...state.data, + [sectionKey]: { + ...state.data[sectionKey], + items: state.data[sectionKey].items.map((item) => + item.id === itemId + ? { + ...item, + socials: item.socials.map((social) => + social.id === socialId ? { ...social, ...updates } : social + ), + } + : item + ), + }, + }, + })), + + deleteSocial: ( + sectionKey: keyof PersonalityDataType, + itemId: string, + socialId: string + ) => + set((state) => ({ + data: { + ...state.data, + [sectionKey]: { + ...state.data[sectionKey], + items: state.data[sectionKey].items.map((item) => + item.id === itemId + ? { + ...item, + socials: item.socials.filter( + (social) => social.id !== socialId + ), + } + : item + ), + }, + }, + selectedElement: null, + isEditMode: false, + })), })); diff --git a/src/pages/setting/personality/types/Types.ts b/src/pages/setting/personality/types/Types.ts index 5f101c2..459f723 100644 --- a/src/pages/setting/personality/types/Types.ts +++ b/src/pages/setting/personality/types/Types.ts @@ -39,6 +39,50 @@ export enum SectionName { FOOTER = "footer", } +// New enums for element selection +export enum ElementType { + TEXT = "text", + BUTTON = "button", + IMAGE = "image", + SOCIAL = "social", +} + +// Social network types +export enum SocialNetworkType { + FACEBOOK = "facebook", + TWITTER = "twitter", + INSTAGRAM = "instagram", + LINKEDIN = "linkedin", + YOUTUBE = "youtube", + TELEGRAM = "telegram", + WHATSAPP = "whatsapp", + TIKTOK = "tiktok", + PINTEREST = "pinterest", + SNAPCHAT = "snapchat", + GITHUB = "github", + DISCORD = "discord", +} + +export enum SocialIconSize { + SMALL = "small", + MEDIUM = "medium", + LARGE = "large", +} + +export enum SocialIconStyle { + CIRCLE = "circle", + SQUARE = "square", + ROUNDED = "rounded", +} + +// New type for selected element +export type SelectedElement = { + type: ElementType; + elementId: string; + itemId: string; + sectionKey: keyof PersonalityDataType; +} | null; + export type PersonalityDataType = { header: SectionType; content: SectionType; @@ -57,6 +101,7 @@ export type SectionItemType = { texts: TextType[]; buttons: ButtonType[]; images: ImageType[]; + socials: SocialType[]; alignment?: HorizontalAlignment; verticalAlignment?: VerticalAlignment; style?: { @@ -104,13 +149,29 @@ export type ImageType = { verticalAlignment?: VerticalAlignment; }; +export type SocialType = { + id: string; + networkType: SocialNetworkType; + link: string; + size: SocialIconSize; + style: SocialIconStyle; + color?: string; + backgroundColor?: string; + alignment?: HorizontalAlignment; + verticalAlignment?: VerticalAlignment; +}; + export type PersonalityStore = { data: PersonalityDataType; activeSection: SectionName | ""; activeItemIndex: number; + selectedElement: SelectedElement; // New field for element selection + isEditMode: boolean; // New field for edit mode setActiveSection: (section: SectionName) => void; setActiveItemIndex: (index: number) => void; + setSelectedElement: (element: SelectedElement) => void; // New method + setEditMode: (isEdit: boolean) => void; // New method setItems: ( sectionKey: keyof PersonalityDataType, @@ -167,4 +228,93 @@ export type PersonalityStore = { imageId: string, updates: Partial ) => void; + // Social network methods + addSocial: ( + sectionKey: keyof PersonalityDataType, + itemId: string, + social: Omit + ) => void; + addSocialToActiveItem: (social: Omit) => void; + updateSocial: ( + sectionKey: keyof PersonalityDataType, + itemId: string, + socialId: string, + updates: Partial + ) => void; + deleteSocial: ( + sectionKey: keyof PersonalityDataType, + itemId: string, + socialId: string + ) => void; + // New methods for deleting elements + deleteText: ( + sectionKey: keyof PersonalityDataType, + itemId: string, + textId: string + ) => void; + deleteButton: ( + sectionKey: keyof PersonalityDataType, + itemId: string, + buttonId: string + ) => void; + deleteImage: ( + sectionKey: keyof PersonalityDataType, + itemId: string, + imageId: string + ) => void; +}; + +export type TemplateType = { + name: string; + rawHtml: string; + structure: PersonalityDataType; +}; + +// API Response Types +export type BusinessType = { + id: string; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + danakSubscriptionId: string; + name: string; + slug: string; + logoUrl: string | null; + quota: number; + usedQuota: string; + remainingQuota: string; + domain: string; + emailSignature: string; +}; + +export type TemplateResponseType = { + id: string; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + name: string; + currentVersion: string; + selected: boolean; + business: BusinessType; +}; + +export type PagerType = { + page: number; + limit: number; + totalItems: number; + totalPages: number; + prevPage: boolean; + nextPage: boolean; +}; + +export type TemplatesResponseDataType = { + pager: PagerType; + templates: TemplateResponseType[]; + pagination: boolean; +}; + +export type TemplatesResponseType = { + statusCode: number; + success: boolean; + data: TemplatesResponseDataType; }; diff --git a/src/pages/setting/personality/utils/ExportHTML.ts b/src/pages/setting/personality/utils/ExportHTML.ts new file mode 100644 index 0000000..c1e9f20 --- /dev/null +++ b/src/pages/setting/personality/utils/ExportHTML.ts @@ -0,0 +1,511 @@ +import { + PersonalityDataType, + HorizontalAlignment, + VerticalAlignment, + ButtonSize, + ImageSize, + TextType, + ButtonType, + ImageType, + SectionType, +} from "../types/Types"; + +// تابع برای تبدیل تصویر به base64 +const imageToBase64 = async (imageUrl: string): Promise => { + try { + const response = await fetch(imageUrl); + const blob = await response.blob(); + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + } catch (error) { + console.error("Error converting image to base64:", error); + return imageUrl; // در صورت خطا، URL اصلی را برگردان + } +}; + +// تابع برای تبدیل همه تصاویر در داده‌ها به base64 +const convertImagesToBase64 = async ( + data: PersonalityDataType +): Promise => { + const convertSectionImages = async ( + section: SectionType + ): Promise => { + const items = await Promise.all( + section.items.map(async (item) => { + // تبدیل تصاویر در images array + const images = await Promise.all( + item.images.map(async (image) => ({ + ...image, + src: await imageToBase64(image.src), + })) + ); + + // تبدیل background image + let backgroundImage = item.backgroundImage; + if (backgroundImage) { + backgroundImage = await imageToBase64(backgroundImage); + } + + return { + ...item, + images, + backgroundImage, + }; + }) + ); + + return { + ...section, + items, + }; + }; + + const [header, content, footer] = await Promise.all([ + convertSectionImages(data.header), + convertSectionImages(data.content), + convertSectionImages(data.footer), + ]); + + return { + header, + content, + footer, + }; +}; + +export const exportPersonalityToHTML = async ( + data: PersonalityDataType +): Promise => { + // تبدیل تصاویر به base64 + const dataWithBase64Images = await convertImagesToBase64(data); + + // Helper functions + const getHorizontalAlign = (alignment?: HorizontalAlignment) => { + switch (alignment) { + case HorizontalAlignment.LEFT: + return "left"; + case HorizontalAlignment.RIGHT: + return "right"; + case HorizontalAlignment.CENTER: + default: + return "center"; + } + }; + + const getVerticalAlign = (verticalAlignment?: VerticalAlignment) => { + switch (verticalAlignment) { + case VerticalAlignment.TOP: + return "top"; + case VerticalAlignment.BOTTOM: + return "bottom"; + case VerticalAlignment.MIDDLE: + default: + return "middle"; + } + }; + + // Render TextRenderer equivalent + const renderTexts = (texts: TextType[]) => { + if (!texts || texts.length === 0) return ""; + + return texts + .map( + (textItem, textIndex) => + ` + + + + + +
+ ${textItem.text} +
` + ) + .join(""); + }; + + // Render ButtonRenderer equivalent + const renderButtons = (buttons: ButtonType[]) => { + if (!buttons || buttons.length === 0) return ""; + + return ` + + + + + +
+ + + + ${buttons + .slice(0, 2) + .map( + (button, buttonIndex) => + `` + ) + .join("")} + + +
+ + + + + + +
+ + ${button.text} + +
+
+
`; + }; + + // Render ImageRenderer equivalent + const renderImages = (images: ImageType[]) => { + if (!images || images.length === 0) return ""; + + const shouldUseBackgroundImage = (imageSize: ImageSize) => { + return ( + imageSize === ImageSize.REPEAT || + imageSize === ImageSize.REPEAT_X || + imageSize === ImageSize.REPEAT_Y + ); + }; + + const getBackgroundStyle = (image: ImageType) => { + let backgroundRepeat = "no-repeat"; + + switch (image.size) { + case ImageSize.REPEAT: + backgroundRepeat = "repeat"; + break; + case ImageSize.REPEAT_X: + backgroundRepeat = "repeat-x"; + break; + case ImageSize.REPEAT_Y: + backgroundRepeat = "repeat-y"; + break; + default: + backgroundRepeat = "no-repeat"; + break; + } + + const horizontal = + image.alignment === HorizontalAlignment.LEFT + ? "left" + : image.alignment === HorizontalAlignment.RIGHT + ? "right" + : "center"; + const vertical = + image.verticalAlignment === VerticalAlignment.TOP + ? "top" + : image.verticalAlignment === VerticalAlignment.BOTTOM + ? "bottom" + : "center"; + + return `background-image: url(${ + image.src + }); background-position: ${horizontal} ${vertical}; background-repeat: ${backgroundRepeat}; width: ${ + image.width || "100%" + }; height: ${image.height || "100px"};`; + }; + + const renderImage = (image: ImageType) => { + if (shouldUseBackgroundImage(image.size)) { + return `
 
`; + } + + if (image.size === ImageSize.CONTAIN) { + return ` + + + + + +
+ ${
+          image.alt || +
`; + } + + return `${
+        image.alt || `; + }; + + return images + .map( + (image, imageIndex) => + ` + + + + + +
+ ${renderImage(image)} +
` + ) + .join(""); + }; + + // Helper functions for button alignment (same as in React components) + const getButtonHorizontalAlign = (button?: { + alignment?: HorizontalAlignment; + }) => { + if (!button || !button.alignment) return "center"; + switch (button.alignment) { + case HorizontalAlignment.LEFT: + return "left"; + case HorizontalAlignment.RIGHT: + return "right"; + case HorizontalAlignment.CENTER: + default: + return "center"; + } + }; + + const getButtonVerticalAlign = (button?: { + verticalAlignment?: VerticalAlignment; + }) => { + if (!button || !button.verticalAlignment) return "middle"; + switch (button.verticalAlignment) { + case VerticalAlignment.TOP: + return "top"; + case VerticalAlignment.BOTTOM: + return "bottom"; + case VerticalAlignment.MIDDLE: + default: + return "middle"; + } + }; + + // Render section equivalent (HeaderSection, ContentSection, FooterSection) + const renderSection = ( + section: SectionType, + sectionHeight: string, + paddingTop = "" + ) => { + if (section.columnsCount > 0) { + return `
` : ""} + + + + + + +
+ + + + ${Array.from( + { length: section.columnsCount }, + (_, index) => { + const item = section.items[index]; + const hasButtons = + item?.buttons && item.buttons.length > 0; + const hasTexts = + item?.texts && item.texts.length > 0; + + let result = ``; + + // فاصله بین ستون‌ها + if (index < section.columnsCount - 1) { + result += ``; + } + + return result; + } + ).join("")} + + +
+ + `; + + // اگر فقط دکمه داریم و متن نداریم + if (!hasTexts && hasButtons) { + result += ` + + `; + } else { + // ردیف اصلی برای متن و تصاویر + const mainHeight = hasButtons + ? sectionHeight === "123px" + ? "60" + : sectionHeight === "246px" + ? "203" + : "40" + : sectionHeight === "123px" + ? "87" + : sectionHeight === "246px" + ? "230" + : "67"; + + result += ` + + `; + + // ردیف دکمه‌ها + if (hasButtons) { + result += ` + + `; + } + } + + result += ` +
+ ${renderButtons(item.buttons)} +
+ ${renderTexts(item?.texts || [])} + ${renderImages(item?.images || [])} +
+ ${renderButtons(item.buttons)} +
+
+
+
` : ""} + + + + + + +
+
+ ${emptyMessage} +
+
+
+ + ${renderSection(dataWithBase64Images.header, "123px")} + ${renderSection(dataWithBase64Images.content, "246px", "16px")} + ${renderSection(dataWithBase64Images.footer, "123px", "16px")} + +
+ `; + + return html; +}; + +export const copyHTMLToClipboard = async (html: string): Promise => { + try { + await navigator.clipboard.writeText(html); + return true; + } catch (error) { + console.error("Failed to copy HTML to clipboard:", error); + return false; + } +}; diff --git a/src/router/AppRouter.tsx b/src/router/AppRouter.tsx index 674886c..919f21a 100644 --- a/src/router/AppRouter.tsx +++ b/src/router/AppRouter.tsx @@ -3,6 +3,8 @@ import { Paths } from '@/utils/Paths' import { Routes, Route } from 'react-router-dom' import Setting from '@/pages/setting/Setting' import Home from '@/pages/home/Home' +import List from '@/pages/setting/personality/List' +import EmailBuilder from '@/pages/email-builder/EmailBuilder' const AppRouter: FC = () => { return ( @@ -13,6 +15,8 @@ const AppRouter: FC = () => { } /> } /> } /> + } /> + } /> ) } diff --git a/src/shared/SideBar.tsx b/src/shared/SideBar.tsx index 66da8d7..384bda2 100644 --- a/src/shared/SideBar.tsx +++ b/src/shared/SideBar.tsx @@ -62,12 +62,19 @@ const SideBar: FC = () => { /> } + icon={} title={t('setting.personality')} - isActive={isActive(Paths.settingPersonality)} - link={Paths.settingPersonality} + isActive={isActive(Paths.settingPersonalityList)} + link={Paths.settingPersonalityList} /> + {/* } + title="سازنده قالب ایمیل" + isActive={isActive(Paths.emailBuilder)} + link={Paths.emailBuilder} + /> */} + } title={t('setting.signature')} diff --git a/src/utils/Paths.ts b/src/utils/Paths.ts index 8d0316c..ae2b22a 100644 --- a/src/utils/Paths.ts +++ b/src/utils/Paths.ts @@ -12,6 +12,8 @@ export const Paths = { settingDomain: "/setting/domain", settingAddress: "/setting/address", settingPersonality: "/setting/personality", + settingPersonalityList: "/setting/personality/list", settingSignature: "/setting/signature", + emailBuilder: "/email-builder", detailEmail: "/mail/", };