auto select and edit
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
VITE_TOKEN_NAME = 'dsc_token'
|
VITE_TOKEN_NAME = 'dsc_token'
|
||||||
VITE_REFRESH_TOKEN_NAME = 'dsc_refresh_token'
|
VITE_REFRESH_TOKEN_NAME = 'dsc_refresh_token'
|
||||||
VITE_DANAK_BASE_URL ='https://api.danakcorp.com'
|
VITE_DANAK_BASE_URL ='https://api.danakcorp.com'
|
||||||
# VITE_BASE_URL = 'http://192.168.1.113:4000'
|
VITE_BASE_URL = 'http://192.168.1.113:4000'
|
||||||
VITE_BASE_URL = 'https://dmail-api.danakcorp.com'
|
# VITE_BASE_URL = 'https://dmail-api.danakcorp.com'
|
||||||
VITE_SERVICE_ID = 'e51afdc3-ea0b-49cf-8f49-2a6f131b024e'
|
VITE_SERVICE_ID = 'e51afdc3-ea0b-49cf-8f49-2a6f131b024e'
|
||||||
|
|
||||||
VITE_LOGIN_URL = 'https://console.danakcorp.com/auth/login'
|
VITE_LOGIN_URL = 'https://console.danakcorp.com/auth/login'
|
||||||
|
|||||||
@@ -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<string, any>) => {
|
||||||
|
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<string, any>) => {
|
||||||
|
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 `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html dir="${options.rtlSupport ? 'rtl' : 'ltr'}">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${template.name}</title>
|
||||||
|
${styles}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table width="600" cellpadding="0" cellspacing="0" border="0">
|
||||||
|
${bodyHTML}
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 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<Block>) => 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.*
|
||||||
+20
-1
@@ -136,8 +136,27 @@
|
|||||||
"select_section": "لطفا یک بخش انتخاب کنید",
|
"select_section": "لطفا یک بخش انتخاب کنید",
|
||||||
"title1": "عنوان",
|
"title1": "عنوان",
|
||||||
"quota": "فضا/ استفاده شده ",
|
"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": {
|
"app": {
|
||||||
"menu": "منو"
|
"menu": "منو"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="h-screen bg-gray-100 flex flex-col">
|
||||||
|
{/* Top Toolbar */}
|
||||||
|
<div className="bg-white border-b border-gray-200 px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||||
|
<span className="text-white font-bold text-sm">📧</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold text-gray-900">
|
||||||
|
سازنده قالب ایمیل
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Email Template Builder v2.0
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={template.name}
|
||||||
|
onChange={(e) => updateTemplateName(e.target.value)}
|
||||||
|
placeholder="نام قالب"
|
||||||
|
className="w-48"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={togglePreview}
|
||||||
|
className="w-fit"
|
||||||
|
>
|
||||||
|
{isPreviewMode ? (
|
||||||
|
<>
|
||||||
|
<EyeSlash size={16} />
|
||||||
|
<span>ویرایش</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Eye size={16} />
|
||||||
|
<span>پیشنمایش</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setShowSettings(!showSettings)}
|
||||||
|
className="w-fit"
|
||||||
|
>
|
||||||
|
<Setting4 size={16} />
|
||||||
|
<span>تنظیمات</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleExportHTML}
|
||||||
|
className="w-fit"
|
||||||
|
>
|
||||||
|
<DocumentDownload size={16} />
|
||||||
|
<span>خروجی HTML</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="w-fit"
|
||||||
|
>
|
||||||
|
<Save2 size={16} />
|
||||||
|
<span>ذخیره</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleReset}
|
||||||
|
className="w-fit border-red-200 text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Refresh size={16} />
|
||||||
|
<span>ریست</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="flex-1 flex gap-6 p-6 overflow-hidden">
|
||||||
|
{/* Left Sidebar - Block Library */}
|
||||||
|
<div className="w-80 flex-shrink-0">
|
||||||
|
<BlockLibrary />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Canvas */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<Canvas />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Sidebar - Settings (conditional) */}
|
||||||
|
{showSettings && (
|
||||||
|
<div className="w-80 flex-shrink-0">
|
||||||
|
<div className="bg-white rounded-4xl p-6 h-full">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
تنظیمات قالب
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
عرض قالب (پیکسل)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={template.settings.width.toString()}
|
||||||
|
onChange={(e) => {
|
||||||
|
const width = parseInt(e.target.value) || 600
|
||||||
|
useEmailBuilderStore.getState().updateTemplateSettings({ width })
|
||||||
|
}}
|
||||||
|
min="300"
|
||||||
|
max="800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
رنگ پسزمینه
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="color"
|
||||||
|
value={template.settings.backgroundColor}
|
||||||
|
onChange={(e) => {
|
||||||
|
useEmailBuilderStore.getState().updateTemplateSettings({
|
||||||
|
backgroundColor: e.target.value
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
فونت
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={template.settings.fontFamily}
|
||||||
|
onChange={(e) => {
|
||||||
|
useEmailBuilderStore.getState().updateTemplateSettings({
|
||||||
|
fontFamily: e.target.value
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-lg"
|
||||||
|
>
|
||||||
|
<option value="Vazir, Arial, sans-serif">وزیر</option>
|
||||||
|
<option value="Tahoma, Arial, sans-serif">تاهوما</option>
|
||||||
|
<option value="Arial, sans-serif">Arial</option>
|
||||||
|
<option value="Georgia, serif">Georgia</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="rtl"
|
||||||
|
checked={template.settings.rtl}
|
||||||
|
onChange={(e) => {
|
||||||
|
useEmailBuilderStore.getState().updateTemplateSettings({
|
||||||
|
rtl: e.target.checked
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<label htmlFor="rtl" className="text-sm font-medium text-gray-700">
|
||||||
|
راستچین (RTL)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 p-4 bg-gray-50 rounded-xl">
|
||||||
|
<h4 className="font-medium text-gray-700 mb-2">📊 آمار قالب</h4>
|
||||||
|
<div className="text-sm text-gray-600 space-y-1">
|
||||||
|
<div>تعداد بلوکها: {template.blocks.length}</div>
|
||||||
|
<div>متغیرها: {template.variables.length}</div>
|
||||||
|
<div>عرض: {template.settings.width}px</div>
|
||||||
|
<div>جهت: {template.settings.rtl ? 'راستچین' : 'چپچین'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Bar */}
|
||||||
|
<div className="bg-white border-t border-gray-200 px-6 py-2">
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span>
|
||||||
|
📦 {template.blocks.length} بلوک
|
||||||
|
</span>
|
||||||
|
{template.variables.length > 0 && (
|
||||||
|
<span>
|
||||||
|
🔧 {template.variables.length} متغیر
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
📏 {template.settings.width}px
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{isPreviewMode ? '👁️ حالت پیشنمایش' : '✏️ حالت ویرایش'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EmailBuilder
|
||||||
@@ -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<ButtonBlockType>) => void
|
||||||
|
onSelect: () => void
|
||||||
|
isEditing?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const ButtonBlock: FC<ButtonBlockProps> = ({
|
||||||
|
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<string, string> = {
|
||||||
|
name: 'احمد محمدی',
|
||||||
|
url: 'https://example.com',
|
||||||
|
company: 'شرکت نمونه'
|
||||||
|
}
|
||||||
|
return sampleData[key] || match
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={containerStyle}
|
||||||
|
onClick={onSelect}
|
||||||
|
className="button-block-wrapper"
|
||||||
|
>
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="space-y-2 p-2 bg-gray-50 rounded">
|
||||||
|
<Input
|
||||||
|
label="متن دکمه"
|
||||||
|
value={localText}
|
||||||
|
onChange={(e) => handleTextChange(e.target.value)}
|
||||||
|
placeholder="متن دکمه را وارد کنید"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="آدرس لینک"
|
||||||
|
value={localUrl}
|
||||||
|
onChange={(e) => handleUrlChange(e.target.value)}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
style={blockStyle}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (!isSelected) {
|
||||||
|
onSelect()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{processVariables(localText)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Block type indicator */}
|
||||||
|
{isSelected && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-8px',
|
||||||
|
right: '8px',
|
||||||
|
backgroundColor: '#0038FF',
|
||||||
|
color: 'white',
|
||||||
|
padding: '2px 6px',
|
||||||
|
fontSize: '10px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
zIndex: 10
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
دکمه
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ButtonBlock
|
||||||
@@ -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<ImageBlockType>) => void
|
||||||
|
onSelect: () => void
|
||||||
|
isEditing?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImageBlock: FC<ImageBlockProps> = ({
|
||||||
|
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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
style={containerStyle}
|
||||||
|
onClick={onSelect}
|
||||||
|
className="image-block-wrapper"
|
||||||
|
>
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="space-y-3 p-2 bg-gray-50 rounded">
|
||||||
|
<UploadBoxDraggble
|
||||||
|
label="آپلود تصویر"
|
||||||
|
onChange={handleImageUpload}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="آدرس تصویر"
|
||||||
|
value={localSrc}
|
||||||
|
onChange={(e) => handleSrcChange(e.target.value)}
|
||||||
|
placeholder="https://example.com/image.jpg"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="متن جایگزین (Alt Text)"
|
||||||
|
value={localAlt}
|
||||||
|
onChange={(e) => handleAltChange(e.target.value)}
|
||||||
|
placeholder="توضیح تصویر"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="لینک تصویر (اختیاری)"
|
||||||
|
value={localUrl}
|
||||||
|
onChange={(e) => handleUrlChange(e.target.value)}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{localUrl ? (
|
||||||
|
<a
|
||||||
|
href={localUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={processedSrc}
|
||||||
|
alt={localAlt}
|
||||||
|
style={imageStyle}
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.target as HTMLImageElement
|
||||||
|
target.src = 'https://via.placeholder.com/400x200?text=تصویر+یافت+نشد'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={processedSrc}
|
||||||
|
alt={localAlt}
|
||||||
|
style={imageStyle}
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.target as HTMLImageElement
|
||||||
|
target.src = 'https://via.placeholder.com/400x200?text=تصویر+یافت+نشد'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Block type indicator */}
|
||||||
|
{isSelected && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-8px',
|
||||||
|
right: '8px',
|
||||||
|
backgroundColor: '#0038FF',
|
||||||
|
color: 'white',
|
||||||
|
padding: '2px 6px',
|
||||||
|
fontSize: '10px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
zIndex: 10
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
تصویر
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ImageBlock
|
||||||
@@ -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<SpacerBlockType>) => void
|
||||||
|
onSelect: () => void
|
||||||
|
isEditing?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const SpacerBlock: FC<SpacerBlockProps> = ({
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
style={spacerStyle}
|
||||||
|
onClick={onSelect}
|
||||||
|
className="spacer-block-wrapper"
|
||||||
|
>
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="w-full p-2 bg-gray-50 rounded">
|
||||||
|
<Input
|
||||||
|
label="ارتفاع فاصله (پیکسل)"
|
||||||
|
type="number"
|
||||||
|
value={localHeight}
|
||||||
|
onChange={(e) => handleHeightChange(e.target.value)}
|
||||||
|
placeholder="24"
|
||||||
|
min="1"
|
||||||
|
max="200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Visual indicator when not selected */}
|
||||||
|
{!isSelected && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
color: '#9ca3af',
|
||||||
|
fontWeight: 'normal',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.8)',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: '2px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
فاصله {block.style.height}px
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Block type indicator */}
|
||||||
|
{isSelected && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-8px',
|
||||||
|
right: '8px',
|
||||||
|
backgroundColor: '#0038FF',
|
||||||
|
color: 'white',
|
||||||
|
padding: '2px 6px',
|
||||||
|
fontSize: '10px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
zIndex: 10
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
فاصله
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SpacerBlock
|
||||||
@@ -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<TextBlockType>) => void
|
||||||
|
onSelect: () => void
|
||||||
|
isEditing?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const TextBlock: FC<TextBlockProps> = ({
|
||||||
|
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<string, string> = {
|
||||||
|
name: 'احمد محمدی',
|
||||||
|
email: 'ahmad@example.com',
|
||||||
|
company: 'شرکت نمونه',
|
||||||
|
date: '۱۴۰۳/۱۲/۰۱'
|
||||||
|
}
|
||||||
|
return sampleData[key] || match
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={blockStyle}
|
||||||
|
onClick={onSelect}
|
||||||
|
className="text-block-wrapper"
|
||||||
|
>
|
||||||
|
{isEditing ? (
|
||||||
|
<ReactQuill
|
||||||
|
modules={{
|
||||||
|
toolbar: [
|
||||||
|
[{ header: '1' }, { header: '2' }, { font: [] }],
|
||||||
|
[{ list: 'ordered' }, { list: 'bullet' }],
|
||||||
|
['bold', 'italic', 'underline'],
|
||||||
|
['link'],
|
||||||
|
[{ align: [] }],
|
||||||
|
['clean']
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
theme="snow"
|
||||||
|
value={localContent}
|
||||||
|
onChange={handleContentChange}
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
minHeight: '60px'
|
||||||
|
}}
|
||||||
|
className='text-sm'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: processVariables(localContent)
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
minHeight: '20px',
|
||||||
|
width: '100%'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Block type indicator */}
|
||||||
|
{isSelected && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-8px',
|
||||||
|
right: '8px',
|
||||||
|
backgroundColor: '#0038FF',
|
||||||
|
color: 'white',
|
||||||
|
padding: '2px 6px',
|
||||||
|
fontSize: '10px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
متن
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TextBlock
|
||||||
@@ -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<Block>) => void
|
||||||
|
onSelect: () => void
|
||||||
|
isEditing?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const BlockRenderer: FC<BlockRendererProps> = ({
|
||||||
|
block,
|
||||||
|
isSelected,
|
||||||
|
onUpdate,
|
||||||
|
onSelect,
|
||||||
|
isEditing
|
||||||
|
}) => {
|
||||||
|
|
||||||
|
switch (block.type) {
|
||||||
|
case BlockType.TEXT:
|
||||||
|
return (
|
||||||
|
<TextBlock
|
||||||
|
block={block}
|
||||||
|
isSelected={isSelected}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
onSelect={onSelect}
|
||||||
|
isEditing={isEditing}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
case BlockType.BUTTON:
|
||||||
|
return (
|
||||||
|
<ButtonBlock
|
||||||
|
block={block}
|
||||||
|
isSelected={isSelected}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
onSelect={onSelect}
|
||||||
|
isEditing={isEditing}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
case BlockType.IMAGE:
|
||||||
|
return (
|
||||||
|
<ImageBlock
|
||||||
|
block={block}
|
||||||
|
isSelected={isSelected}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
onSelect={onSelect}
|
||||||
|
isEditing={isEditing}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
case BlockType.SPACER:
|
||||||
|
return (
|
||||||
|
<SpacerBlock
|
||||||
|
block={block}
|
||||||
|
isSelected={isSelected}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
onSelect={onSelect}
|
||||||
|
isEditing={isEditing}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
case BlockType.CONTAINER:
|
||||||
|
// TODO: Implement ContainerBlock
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: isSelected ? '2px dashed #0038FF' : '1px dashed #e5e7eb',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '16px',
|
||||||
|
margin: '8px 0',
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
minHeight: '80px',
|
||||||
|
position: 'relative',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
onClick={onSelect}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: 'center', color: '#6b7280', fontSize: '14px' }}>
|
||||||
|
Container Block (در حال توسعه)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isSelected && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-8px',
|
||||||
|
right: '8px',
|
||||||
|
backgroundColor: '#0038FF',
|
||||||
|
color: 'white',
|
||||||
|
padding: '2px 6px',
|
||||||
|
fontSize: '10px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
zIndex: 10
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
کانتینر
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BlockRenderer
|
||||||
@@ -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<CanvasProps> = ({ 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 (
|
||||||
|
<div className={`bg-gray-50 p-6 rounded-4xl ${className}`}>
|
||||||
|
{/* Canvas Header */}
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">
|
||||||
|
{template.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{template.blocks.length} بلوک • عرض {template.settings.width}px
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedBlockId && (
|
||||||
|
<div className="text-sm text-blue-600 bg-blue-50 px-3 py-1 rounded-lg">
|
||||||
|
بلوک انتخاب شده
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Canvas Area */}
|
||||||
|
<div
|
||||||
|
className="canvas-area bg-white border-2 border-dashed border-gray-300 rounded-xl p-4 min-h-[400px] overflow-auto"
|
||||||
|
onClick={handleCanvasClick}
|
||||||
|
style={{
|
||||||
|
maxHeight: 'calc(100vh - 200px)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={canvasStyle}>
|
||||||
|
{template.blocks.length === 0 ? (
|
||||||
|
<div className="text-center py-16 text-gray-500">
|
||||||
|
<div className="text-4xl mb-4">📧</div>
|
||||||
|
<h4 className="text-lg font-medium mb-2">قالب خالی است</h4>
|
||||||
|
<p className="text-sm">
|
||||||
|
از سایدبار سمت راست، بلوکهای مورد نیاز را اضافه کنید
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{template.blocks.map((block) => (
|
||||||
|
<div key={block.id} className="block-wrapper">
|
||||||
|
<BlockRenderer
|
||||||
|
block={block}
|
||||||
|
isSelected={selectedBlockId === block.id}
|
||||||
|
onUpdate={(updates) => updateBlock(block.id, updates)}
|
||||||
|
onSelect={() => selectBlock(block.id)}
|
||||||
|
isEditing={selectedBlockId === block.id && !isPreviewMode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Canvas Footer */}
|
||||||
|
<div className="mt-4 flex items-center justify-between text-xs text-gray-500">
|
||||||
|
<div>
|
||||||
|
{template.variables.length > 0 && (
|
||||||
|
<span>
|
||||||
|
{template.variables.length} متغیر تعریف شده
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<span>📱 موبایل: {template.settings.width <= 480 ? '✅' : '⚠️'}</span>
|
||||||
|
<span>🖥️ دسکتاپ: ✅</span>
|
||||||
|
<span>📧 ایمیل: ✅</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Instructions */}
|
||||||
|
{template.blocks.length > 0 && (
|
||||||
|
<div className="mt-4 p-3 bg-blue-50 rounded-lg">
|
||||||
|
<p className="text-xs text-blue-700">
|
||||||
|
💡 <strong>راهنما:</strong> روی بلوکها کلیک کنید تا ویرایش شوند •
|
||||||
|
کلید Delete برای حذف • Escape برای لغو انتخاب
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Canvas
|
||||||
@@ -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: <Text size={24} />,
|
||||||
|
description: 'اضافه کردن متن و محتوای نوشتاری',
|
||||||
|
color: '#3b82f6'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: BlockType.BUTTON,
|
||||||
|
name: 'دکمه',
|
||||||
|
icon: <LinkSquare size={24} />,
|
||||||
|
description: 'دکمه قابل کلیک با لینک',
|
||||||
|
color: '#10b981'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: BlockType.IMAGE,
|
||||||
|
name: 'تصویر',
|
||||||
|
icon: <Gallery size={24} />,
|
||||||
|
description: 'افزودن تصویر یا عکس',
|
||||||
|
color: '#f59e0b'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: BlockType.SPACER,
|
||||||
|
name: 'فاصله',
|
||||||
|
icon: <ArrangeVertical size={24} />,
|
||||||
|
description: 'اضافه کردن فاصله عمودی',
|
||||||
|
color: '#6b7280'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: BlockType.CONTAINER,
|
||||||
|
name: 'کانتینر',
|
||||||
|
icon: <Box1 size={24} />,
|
||||||
|
description: 'گروهبندی سایر المانها',
|
||||||
|
color: '#8b5cf6'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const BlockLibrary: FC<BlockLibraryProps> = ({ className = '' }) => {
|
||||||
|
const { addBlock } = useEmailBuilderStore()
|
||||||
|
|
||||||
|
const handleAddBlock = (type: BlockType) => {
|
||||||
|
addBlock(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`bg-white rounded-4xl p-6 ${className}`}>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">بلوکها</h3>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
المانهای مورد نیاز را به قالب اضافه کنید
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{blockDefinitions.map((blockDef) => (
|
||||||
|
<div
|
||||||
|
key={blockDef.type}
|
||||||
|
className="block-library-item group cursor-pointer p-3 border-2 border-gray-200 rounded-xl hover:border-blue-300 hover:bg-blue-50 transition-all duration-200"
|
||||||
|
onClick={() => 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'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center text-white"
|
||||||
|
style={{ backgroundColor: blockDef.color }}
|
||||||
|
>
|
||||||
|
{blockDef.icon}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="font-medium text-gray-900 group-hover:text-gray-700">
|
||||||
|
{blockDef.name}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-gray-500 mt-1 group-hover:text-gray-600">
|
||||||
|
{blockDef.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-shrink-0 text-gray-400 group-hover:text-gray-600">
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 4v16m8-8H4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 p-4 bg-gray-50 rounded-xl">
|
||||||
|
<h4 className="font-medium text-gray-700 mb-2">💡 راهنما</h4>
|
||||||
|
<ul className="text-xs text-gray-600 space-y-1">
|
||||||
|
<li>• روی هر بلوک کلیک کنید تا اضافه شود</li>
|
||||||
|
<li>• برای ویرایش، روی بلوک در کانوس کلیک کنید</li>
|
||||||
|
<li>• برای حذف، بلوک را انتخاب کرده و Delete بزنید</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BlockLibrary
|
||||||
@@ -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<Block>) => 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<Template["settings"]>) => void;
|
||||||
|
|
||||||
|
// Variables
|
||||||
|
addVariable: (variable: Omit<TemplateVariable, "key">) => void;
|
||||||
|
updateVariable: (key: string, updates: Partial<TemplateVariable>) => 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<string, never>,
|
||||||
|
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<EmailBuilderStore>((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,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
@@ -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<BlockStyle>;
|
||||||
|
tablet?: Partial<BlockStyle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseBlock {
|
||||||
|
id: string;
|
||||||
|
type: BlockType;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
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<string, never>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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 `
|
||||||
|
<tr>
|
||||||
|
<td style="
|
||||||
|
font-size: ${block.style.fontSize}px;
|
||||||
|
font-weight: ${block.style.fontWeight};
|
||||||
|
color: ${block.style.color};
|
||||||
|
text-align: ${block.style.textAlign};
|
||||||
|
line-height: ${block.style.lineHeight};
|
||||||
|
font-family: ${block.style.fontFamily || "Arial, sans-serif"};
|
||||||
|
padding: ${block.style.padding?.top || 8}px ${
|
||||||
|
block.style.padding?.right || 16
|
||||||
|
}px ${block.style.padding?.bottom || 8}px ${
|
||||||
|
block.style.padding?.left || 16
|
||||||
|
}px;
|
||||||
|
margin: ${block.style.margin?.top || 0}px ${
|
||||||
|
block.style.margin?.right || 0
|
||||||
|
}px ${block.style.margin?.bottom || 0}px ${
|
||||||
|
block.style.margin?.left || 0
|
||||||
|
}px;
|
||||||
|
background-color: ${block.style.backgroundColor || "transparent"};
|
||||||
|
border-radius: ${block.style.borderRadius || 0}px;
|
||||||
|
">
|
||||||
|
${block.data.content}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
|
||||||
|
case BlockType.BUTTON:
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td style="text-align: ${block.style.textAlign}; padding: ${
|
||||||
|
block.style.margin?.top || 8
|
||||||
|
}px 0;">
|
||||||
|
<table cellpadding="0" cellspacing="0" border="0" style="display: inline-block;">
|
||||||
|
<tr>
|
||||||
|
<td style="
|
||||||
|
background-color: ${block.style.backgroundColor};
|
||||||
|
border-radius: ${block.style.borderRadius || 6}px;
|
||||||
|
padding: ${block.style.padding?.top || 12}px ${
|
||||||
|
block.style.padding?.right || 24
|
||||||
|
}px ${block.style.padding?.bottom || 12}px ${
|
||||||
|
block.style.padding?.left || 24
|
||||||
|
}px;
|
||||||
|
">
|
||||||
|
<a href="${block.data.url}" style="
|
||||||
|
color: ${block.style.textColor};
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: ${block.style.fontSize}px;
|
||||||
|
font-weight: ${block.style.fontWeight};
|
||||||
|
display: inline-block;
|
||||||
|
">
|
||||||
|
${block.data.text}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
|
||||||
|
case BlockType.IMAGE: {
|
||||||
|
const imageHTML = `
|
||||||
|
<img src="${block.data.src}"
|
||||||
|
alt="${block.data.alt}"
|
||||||
|
style="
|
||||||
|
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
|
||||||
|
};
|
||||||
|
object-fit: ${block.style.objectFit};
|
||||||
|
border-radius: ${block.style.borderRadius || 0}px;
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
" />
|
||||||
|
`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td style="
|
||||||
|
text-align: ${block.style.textAlign};
|
||||||
|
padding: ${block.style.margin?.top || 8}px 0;
|
||||||
|
">
|
||||||
|
${
|
||||||
|
block.data.url
|
||||||
|
? `<a href="${block.data.url}">${imageHTML}</a>`
|
||||||
|
: imageHTML
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case BlockType.SPACER:
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td style="
|
||||||
|
height: ${block.style.height}px;
|
||||||
|
line-height: ${block.style.height}px;
|
||||||
|
font-size: 1px;
|
||||||
|
">
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
|
||||||
|
case BlockType.CONTAINER:
|
||||||
|
// TODO: Implement container rendering
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td style="
|
||||||
|
background-color: ${block.style.backgroundColor || "#f8f9fa"};
|
||||||
|
border-radius: ${block.style.borderRadius || 8}px;
|
||||||
|
padding: ${block.style.padding?.top || 16}px ${
|
||||||
|
block.style.padding?.right || 16
|
||||||
|
}px ${block.style.padding?.bottom || 16}px ${
|
||||||
|
block.style.padding?.left || 16
|
||||||
|
}px;
|
||||||
|
margin: ${block.style.margin?.top || 8}px 0;
|
||||||
|
">
|
||||||
|
<!-- Container content would go here -->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate CSS for email clients
|
||||||
|
const generateEmailCSS = (
|
||||||
|
template: Template,
|
||||||
|
options: ExportOptions
|
||||||
|
): string => {
|
||||||
|
if (!options.includeCSS) return "";
|
||||||
|
|
||||||
|
return `
|
||||||
|
<style type="text/css">
|
||||||
|
/* Email Client Resets */
|
||||||
|
body, table, td, p, a, li, blockquote {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
table, td {
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
-ms-interpolation-mode: bicubic;
|
||||||
|
border: 0;
|
||||||
|
outline: none;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* RTL Support */
|
||||||
|
${
|
||||||
|
options.rtlSupport
|
||||||
|
? `
|
||||||
|
.rtl-content {
|
||||||
|
direction: rtl;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
.email-container {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 600px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-hide {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-center {
|
||||||
|
text-align: center !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Main export function
|
||||||
|
export const exportToHTML = (
|
||||||
|
template: Template,
|
||||||
|
options: Partial<ExportOptions> = {}
|
||||||
|
): string => {
|
||||||
|
const finalOptions = { ...defaultExportOptions, ...options };
|
||||||
|
|
||||||
|
const css = generateEmailCSS(template, finalOptions);
|
||||||
|
const bodyHTML = template.blocks
|
||||||
|
.map((block) => renderBlockToHTML(block))
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="${finalOptions.rtlSupport ? "fa" : "en"}" dir="${
|
||||||
|
finalOptions.rtlSupport ? "rtl" : "ltr"
|
||||||
|
}">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="x-apple-disable-message-reformatting">
|
||||||
|
<title>${template.name}</title>
|
||||||
|
${css}
|
||||||
|
</head>
|
||||||
|
<body style="
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
font-family: ${template.settings.fontFamily};
|
||||||
|
direction: ${template.settings.rtl ? "rtl" : "ltr"};
|
||||||
|
">
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<![endif]-->
|
||||||
|
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: 0; padding: 0;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 20px 0;">
|
||||||
|
<table class="email-container ${
|
||||||
|
finalOptions.rtlSupport ? "rtl-content" : ""
|
||||||
|
}"
|
||||||
|
role="presentation"
|
||||||
|
cellpadding="0"
|
||||||
|
cellspacing="0"
|
||||||
|
border="0"
|
||||||
|
width="${template.settings.width}"
|
||||||
|
style="
|
||||||
|
max-width: ${template.settings.width}px;
|
||||||
|
background-color: ${template.settings.backgroundColor};
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
">
|
||||||
|
${bodyHTML}
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (finalOptions.minify) {
|
||||||
|
return html.replace(/\s+/g, " ").replace(/>\s+</g, "><").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);
|
||||||
|
};
|
||||||
@@ -9,13 +9,23 @@ import Address from './address/Address'
|
|||||||
import PersonalitySidebar from './personality/SideBar'
|
import PersonalitySidebar from './personality/SideBar'
|
||||||
import Signture from './signture/Signture'
|
import Signture from './signture/Signture'
|
||||||
import { Paths } from '@/utils/Paths'
|
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 Setting: FC = () => {
|
||||||
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const { data } = usePersonalityStore()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [activeTab, setActiveTab] = useState<SettingTabEnum>(SettingTabEnum.SETTING_DOMAIN)
|
const [activeTab, setActiveTab] = useState<SettingTabEnum>(SettingTabEnum.SETTING_DOMAIN)
|
||||||
|
const [templateName, setTemplateName] = useState('')
|
||||||
|
const { mutate: saveTemplate, isPending } = useSaveTemplate()
|
||||||
|
|
||||||
// Map URL paths to setting tabs
|
|
||||||
const getTabFromPath = (pathname: string): SettingTabEnum => {
|
const getTabFromPath = (pathname: string): SettingTabEnum => {
|
||||||
switch (pathname) {
|
switch (pathname) {
|
||||||
case Paths.settingMailServer:
|
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 (
|
return (
|
||||||
<div className='mt-2 md:mt-4 px-2 md:px-0'>
|
<div className='mt-2 md:mt-4 px-2 md:px-0'>
|
||||||
<h1 className='text-lg mb-4 md:mb-0'>{t('setting.title')}</h1>
|
<div className='flex justify-between items-center'>
|
||||||
|
<h1 className='text-lg mb-4 md:mb-0'>{t('setting.title')}</h1>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<Input
|
||||||
|
placeholder='نام قالب'
|
||||||
|
value={templateName}
|
||||||
|
onChange={(e) => setTemplateName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant='secondary'
|
||||||
|
className='border border-black w-fit px-7'
|
||||||
|
onClick={handleSave}
|
||||||
|
loading={isPending}
|
||||||
|
>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<TickCircle size={18} color='black' />
|
||||||
|
{t('save')}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{renderActiveTab()}
|
{renderActiveTab()}
|
||||||
|
|||||||
@@ -11,5 +11,6 @@ export enum SideBarTab {
|
|||||||
TEXT = "text",
|
TEXT = "text",
|
||||||
BUTTON = "button",
|
BUTTON = "button",
|
||||||
IMAGE = "image",
|
IMAGE = "image",
|
||||||
|
SOCIAL = "social",
|
||||||
NONE = "none",
|
NONE = "none",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className='mt-2 md:mt-4 px-2 md:px-0'>
|
||||||
|
<div className='flex justify-between items-center'>
|
||||||
|
<div>قالب ها</div>
|
||||||
|
<Link
|
||||||
|
to={Paths.settingPersonality}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
className='w-fit px-5'
|
||||||
|
>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<AddCircle size={18} color='white' />
|
||||||
|
<div className='text-sm'>اضافه کردن قالب جدید</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-9 flex flex-wrap gap-10'>
|
||||||
|
{
|
||||||
|
data?.data?.templates?.map((item: TemplateResponseType) => {
|
||||||
|
return (
|
||||||
|
<Templete
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
selectedTemplateId={selectedTemplateId}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default List
|
||||||
@@ -1,17 +1,51 @@
|
|||||||
import { FC, useState, useRef, useEffect } from 'react'
|
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 Logo from '@/assets/images/logo-small.svg'
|
||||||
import SettingSideBar from './components/SettingSideBar'
|
import SettingSideBar from './components/SettingSideBar'
|
||||||
import { SideBarTab } from '../enum/SettingEnum'
|
import { SideBarTab } from '../enum/SettingEnum'
|
||||||
import TextSidebar from './components/TextSidebar'
|
import TextSidebar from './components/TextSidebar'
|
||||||
import ButtonSidebar from './components/ButtonSidebar'
|
import ButtonSidebar from './components/ButtonSidebar'
|
||||||
import ImageSideBar from './components/ImageSideBar'
|
import ImageSideBar from './components/ImageSideBar'
|
||||||
|
import SocialSidebar from './components/SocialSidebar'
|
||||||
import { clx } from '@/helpers/utils'
|
import { clx } from '@/helpers/utils'
|
||||||
|
import { usePersonalityStore } from './store/Store'
|
||||||
|
import { ElementType } from './types/Types'
|
||||||
|
|
||||||
const PersonalitySidebar: FC = () => {
|
const PersonalitySidebar: FC = () => {
|
||||||
|
|
||||||
const [active, setActive] = useState<SideBarTab>(SideBarTab.SETTING)
|
const [active, setActive] = useState<SideBarTab>(SideBarTab.SETTING)
|
||||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
const sidebarRef = useRef<HTMLDivElement>(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(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
@@ -42,7 +76,8 @@ const PersonalitySidebar: FC = () => {
|
|||||||
: active === SideBarTab.TEXT ? <TextSidebar />
|
: active === SideBarTab.TEXT ? <TextSidebar />
|
||||||
: active === SideBarTab.BUTTON ? <ButtonSidebar />
|
: active === SideBarTab.BUTTON ? <ButtonSidebar />
|
||||||
: active === SideBarTab.IMAGE ? <ImageSideBar />
|
: active === SideBarTab.IMAGE ? <ImageSideBar />
|
||||||
: null
|
: active === SideBarTab.SOCIAL ? <SocialSidebar />
|
||||||
|
: null
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -54,10 +89,41 @@ const PersonalitySidebar: FC = () => {
|
|||||||
'flex flex-col gap-10 mt-16',
|
'flex flex-col gap-10 mt-16',
|
||||||
active === SideBarTab.NONE && 'mt-4'
|
active === SideBarTab.NONE && 'mt-4'
|
||||||
)}>
|
)}>
|
||||||
<Setting4 size={22} color='black' variant={SideBarTab.SETTING === active ? 'Bold' : 'Outline'} onClick={() => setActive(SideBarTab.SETTING)} className='cursor-pointer' />
|
<Setting4
|
||||||
<Text size={22} color='black' variant={SideBarTab.TEXT === active ? 'Bold' : 'Outline'} onClick={() => setActive(SideBarTab.TEXT)} className='cursor-pointer' />
|
size={22}
|
||||||
<LinkSquare size={22} color='black' variant={SideBarTab.BUTTON === active ? 'Bold' : 'Outline'} onClick={() => setActive(SideBarTab.BUTTON)} className='cursor-pointer' />
|
color='black'
|
||||||
<Gallery size={22} color='black' variant={SideBarTab.IMAGE === active ? 'Bold' : 'Outline'} onClick={() => setActive(SideBarTab.IMAGE)} className='cursor-pointer' />
|
variant={SideBarTab.SETTING === active ? 'Bold' : 'Outline'}
|
||||||
|
onClick={() => setActive(SideBarTab.SETTING)}
|
||||||
|
className='cursor-pointer'
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
size={22}
|
||||||
|
color={selectedElement?.type === ElementType.TEXT ? '#0038FF' : 'black'}
|
||||||
|
variant={SideBarTab.TEXT === active ? 'Bold' : 'Outline'}
|
||||||
|
onClick={() => setActive(SideBarTab.TEXT)}
|
||||||
|
className='cursor-pointer'
|
||||||
|
/>
|
||||||
|
<LinkSquare
|
||||||
|
size={22}
|
||||||
|
color={selectedElement?.type === ElementType.BUTTON ? '#0038FF' : 'black'}
|
||||||
|
variant={SideBarTab.BUTTON === active ? 'Bold' : 'Outline'}
|
||||||
|
onClick={() => setActive(SideBarTab.BUTTON)}
|
||||||
|
className='cursor-pointer'
|
||||||
|
/>
|
||||||
|
<Gallery
|
||||||
|
size={22}
|
||||||
|
color={selectedElement?.type === ElementType.IMAGE ? '#0038FF' : 'black'}
|
||||||
|
variant={SideBarTab.IMAGE === active ? 'Bold' : 'Outline'}
|
||||||
|
onClick={() => setActive(SideBarTab.IMAGE)}
|
||||||
|
className='cursor-pointer'
|
||||||
|
/>
|
||||||
|
<Profile2User
|
||||||
|
size={22}
|
||||||
|
color={selectedElement?.type === ElementType.SOCIAL ? '#0038FF' : 'black'}
|
||||||
|
variant={SideBarTab.SOCIAL === active ? 'Bold' : 'Outline'}
|
||||||
|
onClick={() => setActive(SideBarTab.SOCIAL)}
|
||||||
|
className='cursor-pointer'
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,39 @@
|
|||||||
import { FC } from 'react'
|
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 {
|
interface ButtonRendererProps {
|
||||||
buttons: ButtonType[]
|
buttons: ButtonType[]
|
||||||
|
itemId: string
|
||||||
|
sectionKey: keyof PersonalityDataType
|
||||||
}
|
}
|
||||||
|
|
||||||
const ButtonRenderer: FC<ButtonRendererProps> = ({ buttons }) => {
|
const ButtonRenderer: FC<ButtonRendererProps> = ({ buttons, itemId, sectionKey }) => {
|
||||||
|
const { selectedElement, setSelectedElement } = usePersonalityStore()
|
||||||
|
|
||||||
if (!buttons || buttons.length === 0) return null
|
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
|
// تبدیل enum به string برای HTML attributes
|
||||||
const getHorizontalAlign = (alignment?: HorizontalAlignment) => {
|
const getHorizontalAlign = (alignment?: HorizontalAlignment) => {
|
||||||
switch (alignment) {
|
switch (alignment) {
|
||||||
@@ -47,38 +73,53 @@ const ButtonRenderer: FC<ButtonRendererProps> = ({ buttons }) => {
|
|||||||
<tr>
|
<tr>
|
||||||
{buttons.slice(0, 2).map((button, buttonIndex) => (
|
{buttons.slice(0, 2).map((button, buttonIndex) => (
|
||||||
<td key={button.id} style={{ paddingRight: buttonIndex === 0 && buttons.length > 1 ? '4px' : '0' }}>
|
<td key={button.id} style={{ paddingRight: buttonIndex === 0 && buttons.length > 1 ? '4px' : '0' }}>
|
||||||
<table cellPadding="0" cellSpacing="0" border={0} style={{
|
<div
|
||||||
borderCollapse: 'collapse',
|
data-element-type="button"
|
||||||
backgroundColor: button.backgroundColor,
|
onClick={(e) => handleButtonClick(e, button.id)}
|
||||||
border: button.isBorder ? `${button.borderSize}px solid ${button.borderColor}` : 'none',
|
style={{
|
||||||
borderRadius: '4px'
|
cursor: 'pointer',
|
||||||
}}>
|
outline: selectedElement?.elementId === button.id ? '2px dashed #0038FF' : 'none',
|
||||||
<tbody>
|
outlineOffset: '2px',
|
||||||
<tr>
|
borderRadius: '4px',
|
||||||
<td
|
padding: '2px',
|
||||||
align="center"
|
display: 'inline-block'
|
||||||
valign="middle"
|
}}
|
||||||
style={{
|
>
|
||||||
padding: button.size === ButtonSize.SMALL ? '2px 4px' : button.size === ButtonSize.MEDIUM ? '4px 8px' : '8px 12px',
|
<table cellPadding="0" cellSpacing="0" border={0} style={{
|
||||||
fontSize: button.size === ButtonSize.SMALL ? '11px' : button.size === ButtonSize.MEDIUM ? '13px' : '15px',
|
borderCollapse: 'collapse',
|
||||||
color: button.textColor,
|
backgroundColor: button.backgroundColor,
|
||||||
textDecoration: 'none',
|
border: button.isBorder ? `${button.borderSize}px solid ${button.borderColor}` : 'none',
|
||||||
whiteSpace: 'nowrap',
|
borderRadius: '4px'
|
||||||
maxWidth: 'fit-content',
|
}}>
|
||||||
overflow: 'hidden',
|
<tbody>
|
||||||
textOverflow: 'ellipsis'
|
<tr>
|
||||||
}}
|
<td
|
||||||
>
|
align="center"
|
||||||
<a href={button.link || '#'} style={{
|
valign="middle"
|
||||||
color: button.textColor,
|
style={{
|
||||||
textDecoration: 'none',
|
padding: button.size === ButtonSize.SMALL ? '2px 4px' : button.size === ButtonSize.MEDIUM ? '4px 8px' : '8px 12px',
|
||||||
}}>
|
fontSize: button.size === ButtonSize.SMALL ? '11px' : button.size === ButtonSize.MEDIUM ? '13px' : '15px',
|
||||||
{button.text}
|
color: button.textColor,
|
||||||
</a>
|
textDecoration: 'none',
|
||||||
</td>
|
whiteSpace: 'nowrap',
|
||||||
</tr>
|
maxWidth: 'fit-content',
|
||||||
</tbody>
|
overflow: 'hidden',
|
||||||
</table>
|
textOverflow: 'ellipsis',
|
||||||
|
pointerEvents: 'none' // Prevent double click handling
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
color: button.textColor,
|
||||||
|
textDecoration: 'none',
|
||||||
|
pointerEvents: 'none'
|
||||||
|
}}>
|
||||||
|
{button.text}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -3,16 +3,23 @@ import ColorPicker from '@/components/ColorPicker'
|
|||||||
import Input from '@/components/Input'
|
import Input from '@/components/Input'
|
||||||
import Select from '@/components/Select'
|
import Select from '@/components/Select'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { AlignRight, AlignBottom, AlignHorizontally, AlignLeft, AlignTop, AlignVertically, Link2, Add } from 'iconsax-react'
|
import { AlignRight, AlignBottom, AlignHorizontally, AlignLeft, AlignTop, AlignVertically, Link2, Add, Edit, Trash } from 'iconsax-react'
|
||||||
import { FC, useState } from 'react'
|
import { FC, useState, useEffect } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { usePersonalityStore } from '../store/Store'
|
import { usePersonalityStore } from '../store/Store'
|
||||||
import { ButtonSize, HorizontalAlignment, VerticalAlignment } from '../types/Types'
|
import { ButtonSize, HorizontalAlignment, VerticalAlignment, ElementType } from '../types/Types'
|
||||||
|
|
||||||
const ButtonSidebar: FC = () => {
|
const ButtonSidebar: FC = () => {
|
||||||
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { addButtonToActiveItem } = usePersonalityStore()
|
const {
|
||||||
|
addButtonToActiveItem,
|
||||||
|
selectedElement,
|
||||||
|
setSelectedElement,
|
||||||
|
data,
|
||||||
|
updateButton,
|
||||||
|
deleteButton
|
||||||
|
} = usePersonalityStore()
|
||||||
|
|
||||||
const [buttonText, setButtonText] = useState<string>('')
|
const [buttonText, setButtonText] = useState<string>('')
|
||||||
const [buttonLink, setButtonLink] = useState<string>('')
|
const [buttonLink, setButtonLink] = useState<string>('')
|
||||||
@@ -25,6 +32,66 @@ const ButtonSidebar: FC = () => {
|
|||||||
const [horizontalAlignment, setHorizontalAlignment] = useState<HorizontalAlignment>(HorizontalAlignment.CENTER)
|
const [horizontalAlignment, setHorizontalAlignment] = useState<HorizontalAlignment>(HorizontalAlignment.CENTER)
|
||||||
const [verticalAlignment, setVerticalAlignment] = useState<VerticalAlignment>(VerticalAlignment.MIDDLE)
|
const [verticalAlignment, setVerticalAlignment] = useState<VerticalAlignment>(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 = [
|
const sizeOptions = [
|
||||||
{ label: 'کوچک', value: ButtonSize.SMALL },
|
{ label: 'کوچک', value: ButtonSize.SMALL },
|
||||||
{ label: 'متوسط', value: ButtonSize.MEDIUM },
|
{ label: 'متوسط', value: ButtonSize.MEDIUM },
|
||||||
@@ -58,9 +125,59 @@ const ButtonSidebar: FC = () => {
|
|||||||
setVerticalAlignment(VerticalAlignment.MIDDLE)
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className='text-lg'>{t('setting.button')}</div>
|
<div className='text-lg flex items-center justify-between'>
|
||||||
|
{t('setting.button')}
|
||||||
|
{isEditMode && (
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<span className='text-sm text-blue-600'>{t('setting.edit_mode')}</span>
|
||||||
|
<Edit size={16} color='#0038FF' />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='mt-8'>
|
<div className='mt-8'>
|
||||||
<Input
|
<Input
|
||||||
@@ -182,15 +299,48 @@ const ButtonSidebar: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='mt-7'>
|
<div className='mt-7'>
|
||||||
<Button
|
{isEditMode ? (
|
||||||
onClick={handleAddButton}
|
<div className='space-y-3'>
|
||||||
className='bg-[#ECEEF5] text-black'
|
<Button
|
||||||
>
|
onClick={handleUpdateButton}
|
||||||
<div className='flex justify-center items-center gap-2'>
|
className='bg-[#0038FF] text-white w-full'
|
||||||
<Add size={24} color='black' />
|
disabled={!buttonText.trim()}
|
||||||
<span>{t('setting.add_button')}</span>
|
>
|
||||||
|
<div className='flex justify-center items-center gap-2'>
|
||||||
|
<Edit size={18} color='white' />
|
||||||
|
<span>{t('setting.update_button')}</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<Button
|
||||||
|
onClick={handleDeleteButton}
|
||||||
|
className='bg-red-500 text-white flex-1'
|
||||||
|
>
|
||||||
|
<div className='flex justify-center items-center gap-1'>
|
||||||
|
<Trash size={16} color='white' />
|
||||||
|
<span className='text-sm'>{t('setting.delete')}</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
className='bg-gray-500 text-white flex-1'
|
||||||
|
>
|
||||||
|
<span className='text-sm'>{t('setting.cancel')}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={handleAddButton}
|
||||||
|
className='bg-[#ECEEF5] text-black w-full'
|
||||||
|
disabled={!buttonText.trim()}
|
||||||
|
>
|
||||||
|
<div className='flex justify-center items-center gap-2'>
|
||||||
|
<Add size={24} color='black' />
|
||||||
|
<span>{t('setting.add_button')}</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { AddCircle } from 'iconsax-react'
|
|||||||
import TextRenderer from './TextRenderer'
|
import TextRenderer from './TextRenderer'
|
||||||
import ButtonRenderer from './ButtonRenderer'
|
import ButtonRenderer from './ButtonRenderer'
|
||||||
import ImageRenderer from './ImageRenderer'
|
import ImageRenderer from './ImageRenderer'
|
||||||
|
import SocialRenderer from './SocialRenderer'
|
||||||
import { SectionName, VerticalAlignment, HorizontalAlignment } from '../types/Types'
|
import { SectionName, VerticalAlignment, HorizontalAlignment } from '../types/Types'
|
||||||
|
|
||||||
const ContentSection: FC = () => {
|
const ContentSection: FC = () => {
|
||||||
@@ -12,12 +13,81 @@ const ContentSection: FC = () => {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { activeSection, activeItemIndex, data, setActiveSection, setActiveItemIndex } = usePersonalityStore()
|
const { activeSection, activeItemIndex, data, setActiveSection, setActiveItemIndex } = usePersonalityStore()
|
||||||
|
|
||||||
const handleSectionClick = (section: SectionName) => {
|
console.log('🟢 ContentSection - Current state:', {
|
||||||
setActiveSection(section)
|
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) => {
|
const handleItemClick = (index: number, e: React.MouseEvent) => {
|
||||||
setActiveItemIndex(index)
|
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 دکمهها
|
// تابعهای helper برای تعیین alignment دکمهها
|
||||||
@@ -48,7 +118,7 @@ const ContentSection: FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr onClick={() => handleSectionClick(SectionName.CONTENT)}>
|
<tr onClick={(e) => handleSectionClick(SectionName.CONTENT, e)}>
|
||||||
<td style={{ paddingTop: '16px' }}>
|
<td style={{ paddingTop: '16px' }}>
|
||||||
{
|
{
|
||||||
data.content.columnsCount > 0 ?
|
data.content.columnsCount > 0 ?
|
||||||
@@ -58,7 +128,8 @@ const ContentSection: FC = () => {
|
|||||||
<td style={{
|
<td style={{
|
||||||
padding: '10px',
|
padding: '10px',
|
||||||
border: activeSection === SectionName.CONTENT ? '1px dashed #0038FF' : '1px dashed #E5E7EB',
|
border: activeSection === SectionName.CONTENT ? '1px dashed #0038FF' : '1px dashed #E5E7EB',
|
||||||
borderRadius: '24px'
|
borderRadius: '24px',
|
||||||
|
cursor: 'pointer'
|
||||||
}}>
|
}}>
|
||||||
<table width="100%" cellPadding="0" cellSpacing="0" border={0} style={{ tableLayout: 'fixed' }}>
|
<table width="100%" cellPadding="0" cellSpacing="0" border={0} style={{ tableLayout: 'fixed' }}>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -71,11 +142,8 @@ const ContentSection: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<td
|
<td
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleItemClick(index);
|
|
||||||
}}
|
|
||||||
key={index}
|
key={index}
|
||||||
|
onClick={(e) => handleItemClick(index, e)}
|
||||||
style={{
|
style={{
|
||||||
width: `${100 / data.content.columnsCount}%`,
|
width: `${100 / data.content.columnsCount}%`,
|
||||||
height: '246px',
|
height: '246px',
|
||||||
@@ -86,7 +154,7 @@ const ContentSection: FC = () => {
|
|||||||
backgroundRepeat: item?.style?.backgroundRepeat || 'no-repeat',
|
backgroundRepeat: item?.style?.backgroundRepeat || 'no-repeat',
|
||||||
backgroundPosition: item?.style?.backgroundPosition || 'center',
|
backgroundPosition: item?.style?.backgroundPosition || 'center',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
padding: '0',
|
padding: '8px',
|
||||||
verticalAlign: 'top',
|
verticalAlign: 'top',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
position: 'relative'
|
position: 'relative'
|
||||||
@@ -97,56 +165,99 @@ const ContentSection: FC = () => {
|
|||||||
height: '246px'
|
height: '246px'
|
||||||
}}>
|
}}>
|
||||||
<tbody>
|
<tbody>
|
||||||
{/* اگر فقط دکمه داریم و متن نداریم */}
|
{/* اگر column خالی است */}
|
||||||
{(!item?.texts || item.texts.length === 0) && item?.buttons && item.buttons.length > 0 ? (
|
{(!item?.texts || item.texts.length === 0) &&
|
||||||
|
(!item?.buttons || item.buttons.length === 0) &&
|
||||||
|
(!item?.images || item.images.length === 0) &&
|
||||||
|
(!item?.socials || item.socials.length === 0) ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
width="100%"
|
width="100%"
|
||||||
height="246"
|
height="246"
|
||||||
align={getButtonHorizontalAlign(item.buttons[0])}
|
|
||||||
valign={getButtonVerticalAlign(item.buttons[0])}
|
|
||||||
style={{
|
style={{
|
||||||
padding: '8px'
|
padding: '8px',
|
||||||
|
cursor: 'pointer'
|
||||||
}}
|
}}
|
||||||
|
onClick={(e) => handleItemClick(index, e)}
|
||||||
>
|
>
|
||||||
<ButtonRenderer buttons={item.buttons} />
|
{/* محتوای خالی برای columns خالی */}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* ردیف اصلی برای متن */}
|
{/* اگر فقط دکمه داریم و متن نداریم */}
|
||||||
<tr>
|
{(!item?.texts || item.texts.length === 0) && item?.buttons && item.buttons.length > 0 ? (
|
||||||
<td
|
|
||||||
width="100%"
|
|
||||||
height={item?.buttons && item.buttons.length > 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'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TextRenderer texts={item?.texts || []} />
|
|
||||||
<ImageRenderer images={item?.images || []} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/* ردیف دکمهها */}
|
|
||||||
{item?.buttons && item.buttons.length > 0 && (
|
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
width="100%"
|
width="100%"
|
||||||
height="27"
|
height="246"
|
||||||
align={getButtonHorizontalAlign(item.buttons[0])}
|
align={getButtonHorizontalAlign(item.buttons[0])}
|
||||||
valign={getButtonVerticalAlign(item.buttons[0])}
|
valign={getButtonVerticalAlign(item.buttons[0])}
|
||||||
style={{
|
style={{
|
||||||
padding: '0 8px 8px 8px'
|
padding: '8px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ButtonRenderer buttons={item.buttons} />
|
<ButtonRenderer
|
||||||
|
buttons={item.buttons}
|
||||||
|
itemId={item?.id || ''}
|
||||||
|
sectionKey="content"
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* ردیف اصلی برای متن */}
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
width="100%"
|
||||||
|
height={item?.buttons && item.buttons.length > 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'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextRenderer
|
||||||
|
texts={item?.texts || []}
|
||||||
|
itemId={item?.id || ''}
|
||||||
|
sectionKey="content"
|
||||||
|
/>
|
||||||
|
<ImageRenderer
|
||||||
|
images={item?.images || []}
|
||||||
|
itemId={item?.id || ''}
|
||||||
|
sectionKey="content"
|
||||||
|
/>
|
||||||
|
<SocialRenderer
|
||||||
|
socials={item?.socials || []}
|
||||||
|
itemId={item?.id || ''}
|
||||||
|
sectionKey="content"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/* ردیف دکمهها */}
|
||||||
|
{item?.buttons && item.buttons.length > 0 && (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
width="100%"
|
||||||
|
height="27"
|
||||||
|
align={getButtonHorizontalAlign(item.buttons[0])}
|
||||||
|
valign={getButtonVerticalAlign(item.buttons[0])}
|
||||||
|
style={{
|
||||||
|
padding: '0 8px 8px 8px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ButtonRenderer
|
||||||
|
buttons={item.buttons}
|
||||||
|
itemId={item?.id || ''}
|
||||||
|
sectionKey="content"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -171,14 +282,18 @@ const ContentSection: FC = () => {
|
|||||||
<table width="100%" cellPadding="0" cellSpacing="0" border={0}>
|
<table width="100%" cellPadding="0" cellSpacing="0" border={0}>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style={{
|
<td
|
||||||
height: '286px',
|
onClick={(e) => handleSectionClick(SectionName.CONTENT, e)}
|
||||||
border: activeSection === SectionName.CONTENT ? '1px dashed #0038FF' : '1px dashed #E5E7EB',
|
style={{
|
||||||
borderRadius: '24px',
|
height: '286px',
|
||||||
textAlign: 'center',
|
border: activeSection === SectionName.CONTENT ? '1px dashed #0038FF' : '1px dashed #E5E7EB',
|
||||||
verticalAlign: 'middle',
|
borderRadius: '24px',
|
||||||
padding: '10px'
|
textAlign: 'center',
|
||||||
}}>
|
verticalAlign: 'middle',
|
||||||
|
padding: '10px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div style={{ color: '#888', fontSize: '14px', marginBottom: '8px' }}>
|
<div style={{ color: '#888', fontSize: '14px', marginBottom: '8px' }}>
|
||||||
{t('setting.content_email')}
|
{t('setting.content_email')}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { AddCircle } from 'iconsax-react'
|
|||||||
import TextRenderer from './TextRenderer'
|
import TextRenderer from './TextRenderer'
|
||||||
import ButtonRenderer from './ButtonRenderer'
|
import ButtonRenderer from './ButtonRenderer'
|
||||||
import ImageRenderer from './ImageRenderer'
|
import ImageRenderer from './ImageRenderer'
|
||||||
|
import SocialRenderer from './SocialRenderer'
|
||||||
import { SectionName, VerticalAlignment, HorizontalAlignment } from '../types/Types'
|
import { SectionName, VerticalAlignment, HorizontalAlignment } from '../types/Types'
|
||||||
|
|
||||||
const FooterSection: FC = () => {
|
const FooterSection: FC = () => {
|
||||||
@@ -12,12 +13,81 @@ const FooterSection: FC = () => {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { activeSection, activeItemIndex, data, setActiveSection, setActiveItemIndex } = usePersonalityStore()
|
const { activeSection, activeItemIndex, data, setActiveSection, setActiveItemIndex } = usePersonalityStore()
|
||||||
|
|
||||||
const handleSectionClick = (section: SectionName) => {
|
console.log('🔴 FooterSection - Current state:', {
|
||||||
setActiveSection(section)
|
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) => {
|
const handleItemClick = (index: number, e: React.MouseEvent) => {
|
||||||
setActiveItemIndex(index)
|
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 دکمهها
|
// تابعهای helper برای تعیین alignment دکمهها
|
||||||
@@ -48,7 +118,7 @@ const FooterSection: FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr onClick={() => handleSectionClick(SectionName.FOOTER)}>
|
<tr onClick={(e) => handleSectionClick(SectionName.FOOTER, e)}>
|
||||||
<td style={{ paddingTop: '16px' }}>
|
<td style={{ paddingTop: '16px' }}>
|
||||||
{
|
{
|
||||||
data.footer.columnsCount > 0 ?
|
data.footer.columnsCount > 0 ?
|
||||||
@@ -58,7 +128,8 @@ const FooterSection: FC = () => {
|
|||||||
<td style={{
|
<td style={{
|
||||||
padding: '10px',
|
padding: '10px',
|
||||||
border: activeSection === SectionName.FOOTER ? '1px dashed #0038FF' : '1px dashed #E5E7EB',
|
border: activeSection === SectionName.FOOTER ? '1px dashed #0038FF' : '1px dashed #E5E7EB',
|
||||||
borderRadius: '24px'
|
borderRadius: '24px',
|
||||||
|
cursor: 'pointer'
|
||||||
}}>
|
}}>
|
||||||
<table width="100%" cellPadding="0" cellSpacing="0" border={0} style={{ tableLayout: 'fixed' }}>
|
<table width="100%" cellPadding="0" cellSpacing="0" border={0} style={{ tableLayout: 'fixed' }}>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -71,11 +142,8 @@ const FooterSection: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<td
|
<td
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleItemClick(index);
|
|
||||||
}}
|
|
||||||
key={index}
|
key={index}
|
||||||
|
onClick={(e) => handleItemClick(index, e)}
|
||||||
style={{
|
style={{
|
||||||
width: `${100 / data.footer.columnsCount}%`,
|
width: `${100 / data.footer.columnsCount}%`,
|
||||||
height: '123px',
|
height: '123px',
|
||||||
@@ -86,7 +154,7 @@ const FooterSection: FC = () => {
|
|||||||
backgroundRepeat: item?.style?.backgroundRepeat || 'no-repeat',
|
backgroundRepeat: item?.style?.backgroundRepeat || 'no-repeat',
|
||||||
backgroundPosition: item?.style?.backgroundPosition || 'center',
|
backgroundPosition: item?.style?.backgroundPosition || 'center',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
padding: '0',
|
padding: '8px',
|
||||||
verticalAlign: 'top',
|
verticalAlign: 'top',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
position: 'relative'
|
position: 'relative'
|
||||||
@@ -97,56 +165,99 @@ const FooterSection: FC = () => {
|
|||||||
height: '123px'
|
height: '123px'
|
||||||
}}>
|
}}>
|
||||||
<tbody>
|
<tbody>
|
||||||
{/* اگر فقط دکمه داریم و متن نداریم */}
|
{/* اگر column خالی است */}
|
||||||
{(!item?.texts || item.texts.length === 0) && item?.buttons && item.buttons.length > 0 ? (
|
{(!item?.texts || item.texts.length === 0) &&
|
||||||
|
(!item?.buttons || item.buttons.length === 0) &&
|
||||||
|
(!item?.images || item.images.length === 0) &&
|
||||||
|
(!item?.socials || item.socials.length === 0) ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
width="100%"
|
width="100%"
|
||||||
height="123"
|
height="123"
|
||||||
align={getButtonHorizontalAlign(item.buttons[0])}
|
|
||||||
valign={getButtonVerticalAlign(item.buttons[0])}
|
|
||||||
style={{
|
style={{
|
||||||
padding: '8px'
|
padding: '8px',
|
||||||
|
cursor: 'pointer'
|
||||||
}}
|
}}
|
||||||
|
onClick={(e) => handleItemClick(index, e)}
|
||||||
>
|
>
|
||||||
<ButtonRenderer buttons={item.buttons} />
|
{/* محتوای خالی برای columns خالی */}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* ردیف اصلی برای متن */}
|
{/* اگر فقط دکمه داریم و متن نداریم */}
|
||||||
<tr>
|
{(!item?.texts || item.texts.length === 0) && item?.buttons && item.buttons.length > 0 ? (
|
||||||
<td
|
|
||||||
width="100%"
|
|
||||||
height={item?.buttons && item.buttons.length > 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'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TextRenderer texts={item?.texts || []} />
|
|
||||||
<ImageRenderer images={item?.images || []} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/* ردیف دکمهها */}
|
|
||||||
{item?.buttons && item.buttons.length > 0 && (
|
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
width="100%"
|
width="100%"
|
||||||
height="27"
|
height="123"
|
||||||
align={getButtonHorizontalAlign(item.buttons[0])}
|
align={getButtonHorizontalAlign(item.buttons[0])}
|
||||||
valign={getButtonVerticalAlign(item.buttons[0])}
|
valign={getButtonVerticalAlign(item.buttons[0])}
|
||||||
style={{
|
style={{
|
||||||
padding: '0 8px 8px 8px'
|
padding: '8px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ButtonRenderer buttons={item.buttons} />
|
<ButtonRenderer
|
||||||
|
buttons={item.buttons}
|
||||||
|
itemId={item?.id || ''}
|
||||||
|
sectionKey="footer"
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* ردیف اصلی برای متن */}
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
width="100%"
|
||||||
|
height={item?.buttons && item.buttons.length > 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'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextRenderer
|
||||||
|
texts={item?.texts || []}
|
||||||
|
itemId={item?.id || ''}
|
||||||
|
sectionKey="footer"
|
||||||
|
/>
|
||||||
|
<ImageRenderer
|
||||||
|
images={item?.images || []}
|
||||||
|
itemId={item?.id || ''}
|
||||||
|
sectionKey="footer"
|
||||||
|
/>
|
||||||
|
<SocialRenderer
|
||||||
|
socials={item?.socials || []}
|
||||||
|
itemId={item?.id || ''}
|
||||||
|
sectionKey="footer"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/* ردیف دکمهها */}
|
||||||
|
{item?.buttons && item.buttons.length > 0 && (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
width="100%"
|
||||||
|
height="27"
|
||||||
|
align={getButtonHorizontalAlign(item.buttons[0])}
|
||||||
|
valign={getButtonVerticalAlign(item.buttons[0])}
|
||||||
|
style={{
|
||||||
|
padding: '0 8px 8px 8px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ButtonRenderer
|
||||||
|
buttons={item.buttons}
|
||||||
|
itemId={item?.id || ''}
|
||||||
|
sectionKey="footer"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -171,14 +282,18 @@ const FooterSection: FC = () => {
|
|||||||
<table width="100%" cellPadding="0" cellSpacing="0" border={0}>
|
<table width="100%" cellPadding="0" cellSpacing="0" border={0}>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style={{
|
<td
|
||||||
height: '123px',
|
onClick={(e) => handleSectionClick(SectionName.FOOTER, e)}
|
||||||
border: activeSection === SectionName.FOOTER ? '1px dashed #0038FF' : '1px dashed #E5E7EB',
|
style={{
|
||||||
borderRadius: '24px',
|
height: '123px',
|
||||||
textAlign: 'center',
|
border: activeSection === SectionName.FOOTER ? '1px dashed #0038FF' : '1px dashed #E5E7EB',
|
||||||
verticalAlign: 'middle',
|
borderRadius: '24px',
|
||||||
padding: '10px'
|
textAlign: 'center',
|
||||||
}}>
|
verticalAlign: 'middle',
|
||||||
|
padding: '10px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div style={{ color: '#888', fontSize: '14px', marginBottom: '8px' }}>
|
<div style={{ color: '#888', fontSize: '14px', marginBottom: '8px' }}>
|
||||||
{t('setting.footer_email')}
|
{t('setting.footer_email')}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { AddCircle } from 'iconsax-react'
|
|||||||
import TextRenderer from './TextRenderer'
|
import TextRenderer from './TextRenderer'
|
||||||
import ButtonRenderer from './ButtonRenderer'
|
import ButtonRenderer from './ButtonRenderer'
|
||||||
import ImageRenderer from './ImageRenderer'
|
import ImageRenderer from './ImageRenderer'
|
||||||
|
import SocialRenderer from './SocialRenderer'
|
||||||
import { SectionName, VerticalAlignment, HorizontalAlignment } from '../types/Types'
|
import { SectionName, VerticalAlignment, HorizontalAlignment } from '../types/Types'
|
||||||
|
|
||||||
const HeaderSection: FC = () => {
|
const HeaderSection: FC = () => {
|
||||||
@@ -12,12 +13,81 @@ const HeaderSection: FC = () => {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { activeSection, activeItemIndex, data, setActiveSection, setActiveItemIndex } = usePersonalityStore()
|
const { activeSection, activeItemIndex, data, setActiveSection, setActiveItemIndex } = usePersonalityStore()
|
||||||
|
|
||||||
const handleSectionClick = (section: SectionName) => {
|
console.log('🔵 HeaderSection - Current state:', {
|
||||||
setActiveSection(section)
|
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) => {
|
const handleItemClick = (index: number, e: React.MouseEvent) => {
|
||||||
setActiveItemIndex(index)
|
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 دکمهها
|
// تابعهای helper برای تعیین alignment دکمهها
|
||||||
@@ -48,13 +118,14 @@ const HeaderSection: FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr onClick={() => handleSectionClick(SectionName.HEADER)}>
|
<tr onClick={(e) => handleSectionClick(SectionName.HEADER, e)}>
|
||||||
{
|
{
|
||||||
data.header.columnsCount > 0 ?
|
data.header.columnsCount > 0 ?
|
||||||
<td style={{
|
<td style={{
|
||||||
padding: '10px',
|
padding: '10px',
|
||||||
border: activeSection === SectionName.HEADER ? '1px dashed #0038FF' : '1px dashed #E5E7EB',
|
border: activeSection === SectionName.HEADER ? '1px dashed #0038FF' : '1px dashed #E5E7EB',
|
||||||
borderRadius: '24px'
|
borderRadius: '24px',
|
||||||
|
cursor: 'pointer'
|
||||||
}}>
|
}}>
|
||||||
<table width="100%" cellPadding="0" cellSpacing="0" border={0} style={{ tableLayout: 'fixed' }}>
|
<table width="100%" cellPadding="0" cellSpacing="0" border={0} style={{ tableLayout: 'fixed' }}>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -67,11 +138,8 @@ const HeaderSection: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<td
|
<td
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleItemClick(index);
|
|
||||||
}}
|
|
||||||
key={index}
|
key={index}
|
||||||
|
onClick={(e) => handleItemClick(index, e)}
|
||||||
style={{
|
style={{
|
||||||
width: `${100 / data.header.columnsCount}%`,
|
width: `${100 / data.header.columnsCount}%`,
|
||||||
height: '123px',
|
height: '123px',
|
||||||
@@ -82,7 +150,7 @@ const HeaderSection: FC = () => {
|
|||||||
backgroundRepeat: item?.style?.backgroundRepeat || 'no-repeat',
|
backgroundRepeat: item?.style?.backgroundRepeat || 'no-repeat',
|
||||||
backgroundPosition: item?.style?.backgroundPosition || 'center',
|
backgroundPosition: item?.style?.backgroundPosition || 'center',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
padding: '0',
|
padding: '8px',
|
||||||
verticalAlign: 'top',
|
verticalAlign: 'top',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
position: 'relative'
|
position: 'relative'
|
||||||
@@ -93,56 +161,99 @@ const HeaderSection: FC = () => {
|
|||||||
height: '123px'
|
height: '123px'
|
||||||
}}>
|
}}>
|
||||||
<tbody>
|
<tbody>
|
||||||
{/* اگر فقط دکمه داریم و متن نداریم */}
|
{/* اگر column خالی است */}
|
||||||
{(!item?.texts || item.texts.length === 0) && item?.buttons && item.buttons.length > 0 ? (
|
{(!item?.texts || item.texts.length === 0) &&
|
||||||
|
(!item?.buttons || item.buttons.length === 0) &&
|
||||||
|
(!item?.images || item.images.length === 0) &&
|
||||||
|
(!item?.socials || item.socials.length === 0) ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
width="100%"
|
width="100%"
|
||||||
height="123"
|
height="123"
|
||||||
align={getButtonHorizontalAlign(item.buttons[0])}
|
|
||||||
valign={getButtonVerticalAlign(item.buttons[0])}
|
|
||||||
style={{
|
style={{
|
||||||
padding: '8px'
|
padding: '8px',
|
||||||
|
cursor: 'pointer'
|
||||||
}}
|
}}
|
||||||
|
onClick={(e) => handleItemClick(index, e)}
|
||||||
>
|
>
|
||||||
<ButtonRenderer buttons={item.buttons} />
|
{/* محتوای خالی برای columns خالی */}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* ردیف اصلی برای متن */}
|
{/* اگر فقط دکمه داریم و متن نداریم */}
|
||||||
<tr>
|
{(!item?.texts || item.texts.length === 0) && item?.buttons && item.buttons.length > 0 ? (
|
||||||
<td
|
|
||||||
width="100%"
|
|
||||||
height={item?.buttons && item.buttons.length > 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'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TextRenderer texts={item?.texts || []} />
|
|
||||||
<ImageRenderer images={item?.images || []} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/* ردیف دکمهها */}
|
|
||||||
{item?.buttons && item.buttons.length > 0 && (
|
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
width="100%"
|
width="100%"
|
||||||
height="27"
|
height="123"
|
||||||
align={getButtonHorizontalAlign(item.buttons[0])}
|
align={getButtonHorizontalAlign(item.buttons[0])}
|
||||||
valign={getButtonVerticalAlign(item.buttons[0])}
|
valign={getButtonVerticalAlign(item.buttons[0])}
|
||||||
style={{
|
style={{
|
||||||
padding: '0 8px 8px 8px'
|
padding: '8px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ButtonRenderer buttons={item.buttons} />
|
<ButtonRenderer
|
||||||
|
buttons={item.buttons}
|
||||||
|
itemId={item?.id || ''}
|
||||||
|
sectionKey="header"
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* ردیف اصلی برای متن */}
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
width="100%"
|
||||||
|
height={item?.buttons && item.buttons.length > 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'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextRenderer
|
||||||
|
texts={item?.texts || []}
|
||||||
|
itemId={item?.id || ''}
|
||||||
|
sectionKey="header"
|
||||||
|
/>
|
||||||
|
<ImageRenderer
|
||||||
|
images={item?.images || []}
|
||||||
|
itemId={item?.id || ''}
|
||||||
|
sectionKey="header"
|
||||||
|
/>
|
||||||
|
<SocialRenderer
|
||||||
|
socials={item?.socials || []}
|
||||||
|
itemId={item?.id || ''}
|
||||||
|
sectionKey="header"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/* ردیف دکمهها */}
|
||||||
|
{item?.buttons && item.buttons.length > 0 && (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
width="100%"
|
||||||
|
height="27"
|
||||||
|
align={getButtonHorizontalAlign(item.buttons[0])}
|
||||||
|
valign={getButtonVerticalAlign(item.buttons[0])}
|
||||||
|
style={{
|
||||||
|
padding: '0 8px 8px 8px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ButtonRenderer
|
||||||
|
buttons={item.buttons}
|
||||||
|
itemId={item?.id || ''}
|
||||||
|
sectionKey="header"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -161,14 +272,18 @@ const HeaderSection: FC = () => {
|
|||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
:
|
:
|
||||||
<td style={{
|
<td
|
||||||
height: '123px',
|
onClick={(e) => handleSectionClick(SectionName.HEADER, e)}
|
||||||
border: activeSection === SectionName.HEADER ? '1px dashed #0038FF' : '1px dashed #E5E7EB',
|
style={{
|
||||||
borderRadius: '24px',
|
height: '123px',
|
||||||
textAlign: 'center',
|
border: activeSection === SectionName.HEADER ? '1px dashed #0038FF' : '1px dashed #E5E7EB',
|
||||||
verticalAlign: 'middle',
|
borderRadius: '24px',
|
||||||
padding: '10px'
|
textAlign: 'center',
|
||||||
}}>
|
verticalAlign: 'middle',
|
||||||
|
padding: '10px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div style={{ color: '#888', fontSize: '14px', marginBottom: '8px' }}>
|
<div style={{ color: '#888', fontSize: '14px', marginBottom: '8px' }}>
|
||||||
{t('setting.header_email')}
|
{t('setting.header_email')}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,31 @@
|
|||||||
import { FC } from 'react'
|
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 {
|
interface ImageRendererProps {
|
||||||
images: ImageType[]
|
images: ImageType[]
|
||||||
|
itemId: string
|
||||||
|
sectionKey: keyof PersonalityDataType
|
||||||
}
|
}
|
||||||
|
|
||||||
const ImageRenderer: FC<ImageRendererProps> = ({ images }) => {
|
const ImageRenderer: FC<ImageRendererProps> = ({ images, itemId, sectionKey }) => {
|
||||||
|
const { selectedElement, setSelectedElement } = usePersonalityStore()
|
||||||
|
|
||||||
if (!images || images.length === 0) return null
|
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
|
// تابعهای helper برای تبدیل enum به string
|
||||||
const getHorizontalAlign = (alignment?: HorizontalAlignment) => {
|
const getHorizontalAlign = (alignment?: HorizontalAlignment) => {
|
||||||
switch (alignment) {
|
switch (alignment) {
|
||||||
@@ -37,7 +55,8 @@ const ImageRenderer: FC<ImageRendererProps> = ({ images }) => {
|
|||||||
return {
|
return {
|
||||||
width: image.width || '100%',
|
width: image.width || '100%',
|
||||||
height: image.height || 'auto',
|
height: image.height || 'auto',
|
||||||
border: '0'
|
border: '0',
|
||||||
|
pointerEvents: 'none' as const // Prevent double click handling
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,47 +99,60 @@ const ImageRenderer: FC<ImageRendererProps> = ({ images }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderImage = (image: ImageType) => {
|
const renderImage = (image: ImageType) => {
|
||||||
if (shouldUseBackgroundImage(image.size)) {
|
const imageWrapper = (
|
||||||
// برای pattern های repeat از background استفاده میکنیم
|
<div
|
||||||
return (
|
data-element-type="image"
|
||||||
<div style={getBackgroundStyle(image)}>
|
onClick={(e) => handleImageClick(e, image.id)}
|
||||||
|
style={{
|
||||||
</div>
|
cursor: 'pointer',
|
||||||
)
|
outline: selectedElement?.elementId === image.id ? '2px dashed #0038FF' : 'none',
|
||||||
}
|
outlineOffset: '2px',
|
||||||
|
borderRadius: '4px',
|
||||||
// برای تصاویر عادی
|
padding: '2px',
|
||||||
if (image.size === ImageSize.CONTAIN) {
|
display: 'inline-block',
|
||||||
// برای contain از table wrapper استفاده میکنیم تا نسبت حفظ شود
|
minWidth: '20px',
|
||||||
return (
|
minHeight: '20px'
|
||||||
<table width={image.width || '100%'} cellPadding="0" cellSpacing="0" border={0}>
|
}}
|
||||||
<tbody>
|
>
|
||||||
<tr>
|
{shouldUseBackgroundImage(image.size) ? (
|
||||||
<td align="center" valign="middle" style={{ textAlign: 'center' }}>
|
// برای pattern های repeat از background استفاده میکنیم
|
||||||
<img
|
<div style={getBackgroundStyle(image)}>
|
||||||
src={image.src}
|
|
||||||
alt={image.alt || 'تصویر آپلود شده'}
|
</div>
|
||||||
width={image.width || '100%'}
|
) : (
|
||||||
height={image.height || 'auto'}
|
// برای تصاویر عادی
|
||||||
style={{ border: '0' }}
|
image.size === ImageSize.CONTAIN ? (
|
||||||
/>
|
// برای contain از table wrapper استفاده میکنیم تا نسبت حفظ شود
|
||||||
</td>
|
<table width={image.width || '100%'} cellPadding="0" cellSpacing="0" border={0}>
|
||||||
</tr>
|
<tbody>
|
||||||
</tbody>
|
<tr>
|
||||||
</table>
|
<td align="center" valign="middle" style={{ textAlign: 'center' }}>
|
||||||
)
|
<img
|
||||||
}
|
src={image.src}
|
||||||
|
alt={image.alt || 'تصویر آپلود شده'}
|
||||||
// برای cover و auto
|
width={image.width || '100%'}
|
||||||
return (
|
height={image.height || 'auto'}
|
||||||
<img
|
style={getImageStyle(image)}
|
||||||
src={image.src}
|
/>
|
||||||
alt={image.alt || 'تصویر آپلود شده'}
|
</td>
|
||||||
width={image.width || '100%'}
|
</tr>
|
||||||
height={image.height || 'auto'}
|
</tbody>
|
||||||
style={getImageStyle(image)}
|
</table>
|
||||||
/>
|
) : (
|
||||||
|
// برای cover و auto
|
||||||
|
<img
|
||||||
|
src={image.src}
|
||||||
|
alt={image.alt || 'تصویر آپلود شده'}
|
||||||
|
width={image.width || '100%'}
|
||||||
|
height={image.height || 'auto'}
|
||||||
|
style={getImageStyle(image)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return imageWrapper
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
import Button from '@/components/Button'
|
import Button from '@/components/Button'
|
||||||
import Select from '@/components/Select'
|
import Select from '@/components/Select'
|
||||||
import UploadBoxDraggble from '@/components/UploadBoxDraggble'
|
import UploadBoxDraggble from '@/components/UploadBoxDraggble'
|
||||||
import { Add, AlignBottom, AlignHorizontally, AlignLeft, AlignRight, AlignTop, AlignVertically } from 'iconsax-react'
|
import { Add, AlignBottom, AlignHorizontally, AlignLeft, AlignRight, AlignTop, AlignVertically, Edit, Trash } from 'iconsax-react'
|
||||||
import { FC, useState } from 'react'
|
import { FC, useState, useEffect } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { usePersonalityStore } from '../store/Store'
|
import { usePersonalityStore } from '../store/Store'
|
||||||
import { ImageSize, HorizontalAlignment, VerticalAlignment } from '../types/Types'
|
import { ImageSize, HorizontalAlignment, VerticalAlignment, ElementType } from '../types/Types'
|
||||||
|
|
||||||
const ImageSideBar: FC = () => {
|
const ImageSideBar: FC = () => {
|
||||||
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { addImageToActiveItem, activeSection, activeItemIndex, data } = usePersonalityStore()
|
const {
|
||||||
|
addImageToActiveItem,
|
||||||
|
activeSection,
|
||||||
|
activeItemIndex,
|
||||||
|
data,
|
||||||
|
selectedElement,
|
||||||
|
setSelectedElement,
|
||||||
|
updateImage,
|
||||||
|
deleteImage
|
||||||
|
} = usePersonalityStore()
|
||||||
|
|
||||||
const [imageSrc, setImageSrc] = useState<string>('')
|
const [imageSrc, setImageSrc] = useState<string>('')
|
||||||
const [imageSize, setImageSize] = useState<ImageSize>(ImageSize.COVER)
|
const [imageSize, setImageSize] = useState<ImageSize>(ImageSize.COVER)
|
||||||
@@ -19,6 +28,54 @@ const ImageSideBar: FC = () => {
|
|||||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||||
const [isReset, setIsReset] = useState<boolean>(false)
|
const [isReset, setIsReset] = useState<boolean>(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 = [
|
const sizeOptions = [
|
||||||
{ label: 'کاور - تمام فضا را پوشش دهد (Cover)', value: ImageSize.COVER },
|
{ label: 'کاور - تمام فضا را پوشش دهد (Cover)', value: ImageSize.COVER },
|
||||||
{ label: 'در مقیاس - در فضا جا شود (Contain)', value: ImageSize.CONTAIN },
|
{ 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className='text-lg'>{t('setting.image')}</div>
|
<div className='text-lg flex items-center justify-between'>
|
||||||
|
{t('setting.image')}
|
||||||
|
{isEditMode && (
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<span className='text-sm text-blue-600'>{t('setting.edit_mode')}</span>
|
||||||
|
<Edit size={16} color='#0038FF' />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='mt-8'>
|
<div className='mt-8'>
|
||||||
<UploadBoxDraggble
|
<UploadBoxDraggble
|
||||||
@@ -116,6 +226,13 @@ const ImageSideBar: FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isEditMode && imageSrc && (
|
||||||
|
<div className='mt-5'>
|
||||||
|
<div className='text-sm mb-2'>{t('setting.current_image')}</div>
|
||||||
|
<img src={imageSrc} alt="پیشنمایش" className='w-full h-32 object-cover rounded border' />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className='mt-5'>
|
<div className='mt-5'>
|
||||||
<Select
|
<Select
|
||||||
items={sizeOptions}
|
items={sizeOptions}
|
||||||
@@ -175,17 +292,50 @@ const ImageSideBar: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='mt-7'>
|
<div className='mt-7'>
|
||||||
<Button
|
{isEditMode ? (
|
||||||
onClick={handleAddImage}
|
<div className='space-y-3'>
|
||||||
className='bg-[#ECEEF5] text-black'
|
<Button
|
||||||
disabled={!imageSrc}
|
onClick={handleUpdateImage}
|
||||||
loading={isLoading}
|
className='bg-[#0038FF] text-white w-full'
|
||||||
>
|
disabled={!imageSrc}
|
||||||
<div className='flex justify-center items-center gap-2'>
|
loading={isLoading}
|
||||||
<Add size={24} color='black' />
|
>
|
||||||
<span>{t('setting.add_image')}</span>
|
<div className='flex justify-center items-center gap-2'>
|
||||||
|
<Edit size={18} color='white' />
|
||||||
|
<span>{t('setting.update_image')}</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<Button
|
||||||
|
onClick={handleDeleteImage}
|
||||||
|
className='bg-red-500 text-white flex-1'
|
||||||
|
>
|
||||||
|
<div className='flex justify-center items-center gap-1'>
|
||||||
|
<Trash size={16} color='white' />
|
||||||
|
<span className='text-sm'>{t('setting.delete')}</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
className='bg-gray-500 text-white flex-1'
|
||||||
|
>
|
||||||
|
<span className='text-sm'>{t('setting.cancel')}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={handleAddImage}
|
||||||
|
className='bg-[#ECEEF5] text-black w-full'
|
||||||
|
disabled={!imageSrc}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
<div className='flex justify-center items-center gap-2'>
|
||||||
|
<Add size={24} color='black' />
|
||||||
|
<span>{t('setting.add_image')}</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { FC } from 'react'
|
||||||
|
import { SocialType, SocialNetworkType, SocialIconSize, SocialIconStyle, ElementType, PersonalityDataType, HorizontalAlignment, VerticalAlignment } from '../types/Types'
|
||||||
|
import { usePersonalityStore } from '../store/Store'
|
||||||
|
|
||||||
|
interface SocialRendererProps {
|
||||||
|
socials: SocialType[]
|
||||||
|
itemId: string
|
||||||
|
sectionKey: keyof PersonalityDataType
|
||||||
|
}
|
||||||
|
|
||||||
|
const SocialRenderer: FC<SocialRendererProps> = ({ socials, itemId, sectionKey }) => {
|
||||||
|
const { selectedElement, setSelectedElement } = usePersonalityStore()
|
||||||
|
|
||||||
|
if (!socials || socials.length === 0) return null
|
||||||
|
|
||||||
|
const handleSocialClick = (e: React.MouseEvent, socialId: string) => {
|
||||||
|
// جلوگیری از propagation به parent handlers
|
||||||
|
e.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
console.log('Social clicked:', socialId)
|
||||||
|
setSelectedElement({
|
||||||
|
type: ElementType.SOCIAL,
|
||||||
|
elementId: socialId,
|
||||||
|
itemId,
|
||||||
|
sectionKey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSocialIcon = (networkType: SocialNetworkType) => {
|
||||||
|
const getSvgIcon = (networkType: SocialNetworkType) => {
|
||||||
|
switch (networkType) {
|
||||||
|
case SocialNetworkType.FACEBOOK:
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="100%" height="100%" fill="currentColor">
|
||||||
|
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case SocialNetworkType.TWITTER:
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="100%" height="100%" fill="currentColor">
|
||||||
|
<path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case SocialNetworkType.INSTAGRAM:
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="100%" height="100%" fill="currentColor">
|
||||||
|
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case SocialNetworkType.LINKEDIN:
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="100%" height="100%" fill="currentColor">
|
||||||
|
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case SocialNetworkType.YOUTUBE:
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="100%" height="100%" fill="currentColor">
|
||||||
|
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case SocialNetworkType.TELEGRAM:
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="100%" height="100%" fill="currentColor">
|
||||||
|
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case SocialNetworkType.WHATSAPP:
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="100%" height="100%" fill="currentColor">
|
||||||
|
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893A11.821 11.821 0 0020.525 3.488" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case SocialNetworkType.TIKTOK:
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="100%" height="100%" fill="currentColor">
|
||||||
|
<path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case SocialNetworkType.PINTEREST:
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="100%" height="100%" fill="currentColor">
|
||||||
|
<path d="M12.017 0C5.396 0 .029 5.367.029 11.987c0 5.079 3.158 9.417 7.618 11.174-.105-.949-.199-2.403.041-3.439.219-.937 1.406-5.957 1.406-5.957s-.359-.72-.359-1.781c0-1.663.967-2.911 2.168-2.911 1.024 0 1.518.769 1.518 1.688 0 1.029-.653 2.567-.992 3.992-.285 1.193.6 2.165 1.775 2.165 2.128 0 3.768-2.245 3.768-5.487 0-2.861-2.063-4.869-5.008-4.869-3.41 0-5.409 2.562-5.409 5.199 0 1.033.394 2.143.889 2.741.097.118.112.222.085.345-.09.375-.293 1.199-.334 1.363-.053.225-.172.271-.402.165-1.495-.69-2.433-2.878-2.433-4.646 0-3.776 2.748-7.252 7.92-7.252 4.158 0 7.392 2.967 7.392 6.923 0 4.135-2.607 7.462-6.233 7.462-1.214 0-2.357-.629-2.746-1.378l-.748 2.853c-.271 1.043-1.002 2.35-1.492 3.146C9.57 23.812 10.763 24.009 12.017 24c6.624 0 11.99-5.367 11.99-12C24.007 5.367 18.641.001.017 0z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case SocialNetworkType.SNAPCHAT:
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="100%" height="100%" fill="currentColor">
|
||||||
|
<path d="M12.017 0C5.396 0 .029 5.367.029 11.987c0 5.079 3.158 9.417 7.618 11.174-.105-.949-.199-2.403.041-3.439.219-.937 1.406-5.957 1.406-5.957s-.359-.72-.359-1.781c0-1.663.967-2.911 2.168-2.911 1.024 0 1.518.769 1.518 1.688 0 1.029-.653 2.567-.992 3.992-.285 1.193.6 2.165 1.775 2.165 2.128 0 3.768-2.245 3.768-5.487 0-2.861-2.063-4.869-5.008-4.869-3.41 0-5.409 2.562-5.409 5.199 0 1.033.394 2.143.889 2.741.097.118.112.222.085.345-.09.375-.293 1.199-.334 1.363-.053.225-.172.271-.402.165-1.495-.69-2.433-2.878-2.433-4.646 0-3.776 2.748-7.252 7.92-7.252 4.158 0 7.392 2.967 7.392 6.923 0 4.135-2.607 7.462-6.233 7.462-1.214 0-2.357-.629-2.746-1.378l-.748 2.853c-.271 1.043-1.002 2.35-1.492 3.146C9.57 23.812 10.763 24.009 12.017 24c6.624 0 11.99-5.367 11.99-12C24.007 5.367 18.641.001.017 0z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case SocialNetworkType.GITHUB:
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="100%" height="100%" fill="currentColor">
|
||||||
|
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case SocialNetworkType.DISCORD:
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="100%" height="100%" fill="currentColor">
|
||||||
|
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419-.0002 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1568 2.4189Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="100%" height="100%" fill="currentColor">
|
||||||
|
<path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return getSvgIcon(networkType)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSocialColor = (networkType: SocialNetworkType) => {
|
||||||
|
const colors = {
|
||||||
|
[SocialNetworkType.FACEBOOK]: '#1877F2',
|
||||||
|
[SocialNetworkType.TWITTER]: '#1DA1F2',
|
||||||
|
[SocialNetworkType.INSTAGRAM]: '#E4405F',
|
||||||
|
[SocialNetworkType.LINKEDIN]: '#0A66C2',
|
||||||
|
[SocialNetworkType.YOUTUBE]: '#FF0000',
|
||||||
|
[SocialNetworkType.TELEGRAM]: '#0088CC',
|
||||||
|
[SocialNetworkType.WHATSAPP]: '#25D366',
|
||||||
|
[SocialNetworkType.TIKTOK]: '#000000',
|
||||||
|
[SocialNetworkType.PINTEREST]: '#BD081C',
|
||||||
|
[SocialNetworkType.SNAPCHAT]: '#FFFC00',
|
||||||
|
[SocialNetworkType.GITHUB]: '#333333',
|
||||||
|
[SocialNetworkType.DISCORD]: '#5865F2',
|
||||||
|
}
|
||||||
|
return colors[networkType] || '#000000'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getIconSize = (size: SocialIconSize) => {
|
||||||
|
switch (size) {
|
||||||
|
case SocialIconSize.SMALL:
|
||||||
|
return { width: '24px', height: '24px', fontSize: '16px' }
|
||||||
|
case SocialIconSize.MEDIUM:
|
||||||
|
return { width: '32px', height: '32px', fontSize: '20px' }
|
||||||
|
case SocialIconSize.LARGE:
|
||||||
|
return { width: '40px', height: '40px', fontSize: '24px' }
|
||||||
|
default:
|
||||||
|
return { width: '32px', height: '32px', fontSize: '20px' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getIconStyle = (social: SocialType) => {
|
||||||
|
const size = getIconSize(social.size)
|
||||||
|
const baseStyle = {
|
||||||
|
...size,
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: social.color || getSocialColor(social.networkType),
|
||||||
|
backgroundColor: social.backgroundColor || 'transparent',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
margin: '2px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
pointerEvents: 'none' as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (social.style) {
|
||||||
|
case SocialIconStyle.CIRCLE:
|
||||||
|
return {
|
||||||
|
...baseStyle,
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: `1px solid ${social.color || getSocialColor(social.networkType)}`,
|
||||||
|
}
|
||||||
|
case SocialIconStyle.SQUARE:
|
||||||
|
return {
|
||||||
|
...baseStyle,
|
||||||
|
borderRadius: '0',
|
||||||
|
border: `1px solid ${social.color || getSocialColor(social.networkType)}`,
|
||||||
|
}
|
||||||
|
case SocialIconStyle.ROUNDED:
|
||||||
|
return {
|
||||||
|
...baseStyle,
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: `1px solid ${social.color || getSocialColor(social.networkType)}`,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return baseStyle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table width="100%" cellPadding="0" cellSpacing="0" border={0}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
align={getHorizontalAlign(socials[0]?.alignment)}
|
||||||
|
valign={getVerticalAlign(socials[0]?.verticalAlignment)}
|
||||||
|
style={{ padding: '4px 0' }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: getHorizontalAlign(socials[0]?.alignment), gap: '4px', flexWrap: 'wrap' }}>
|
||||||
|
{socials.map((social) => (
|
||||||
|
<div
|
||||||
|
key={social.id}
|
||||||
|
data-element-type="social"
|
||||||
|
onClick={(e) => 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'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={getIconStyle(social)}>
|
||||||
|
{getSocialIcon(social.networkType)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SocialRenderer
|
||||||
@@ -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>(SocialNetworkType.INSTAGRAM)
|
||||||
|
const [link, setLink] = useState<string>('')
|
||||||
|
const [size, setSize] = useState<SocialIconSize>(SocialIconSize.MEDIUM)
|
||||||
|
const [style, setStyle] = useState<SocialIconStyle>(SocialIconStyle.CIRCLE)
|
||||||
|
const [color, setColor] = useState<string>('#000000')
|
||||||
|
const [backgroundColor, setBackgroundColor] = useState<string>('#ffffff')
|
||||||
|
const [horizontalAlignment, setHorizontalAlignment] = useState<HorizontalAlignment>(HorizontalAlignment.CENTER)
|
||||||
|
const [verticalAlignment, setVerticalAlignment] = useState<VerticalAlignment>(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 (
|
||||||
|
<div>
|
||||||
|
<div className='text-lg flex items-center justify-between'>
|
||||||
|
{t('setting.social_networks')}
|
||||||
|
{isEditMode && (
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<span className='text-sm text-blue-600'>{t('setting.edit_mode')}</span>
|
||||||
|
<Edit size={16} color='#0038FF' />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-8'>
|
||||||
|
<Select
|
||||||
|
label={t('setting.social_network')}
|
||||||
|
items={networkOptions}
|
||||||
|
value={networkType}
|
||||||
|
onChange={(e) => setNetworkType(e.target.value as SocialNetworkType)}
|
||||||
|
placeholder={t('setting.select_network')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-5'>
|
||||||
|
<Input
|
||||||
|
label={t('setting.social_link')}
|
||||||
|
value={link}
|
||||||
|
onChange={(e) => setLink(e.target.value)}
|
||||||
|
endIcon={<Link2 size={18} color='#888' />}
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-5'>
|
||||||
|
<Select
|
||||||
|
label={t('setting.size')}
|
||||||
|
items={sizeOptions}
|
||||||
|
value={size}
|
||||||
|
onChange={(e) => setSize(e.target.value as SocialIconSize)}
|
||||||
|
placeholder={t('setting.select')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-5'>
|
||||||
|
<Select
|
||||||
|
label={t('setting.icon_style')}
|
||||||
|
items={styleOptions}
|
||||||
|
value={style}
|
||||||
|
onChange={(e) => setStyle(e.target.value as SocialIconStyle)}
|
||||||
|
placeholder={t('setting.select')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-5'>
|
||||||
|
<ColorPicker
|
||||||
|
label={t('setting.icon_color')}
|
||||||
|
defaultColor={color}
|
||||||
|
changeColor={setColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-5'>
|
||||||
|
<ColorPicker
|
||||||
|
label={t('setting.background_color')}
|
||||||
|
defaultColor={backgroundColor}
|
||||||
|
changeColor={setBackgroundColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-5 flex justify-between'>
|
||||||
|
<div>
|
||||||
|
<div className='text-sm'>
|
||||||
|
{t('setting.horizontal_position')}
|
||||||
|
</div>
|
||||||
|
<div className='mt-3 flex gap-4'>
|
||||||
|
<div
|
||||||
|
onClick={() => setHorizontalAlignment(HorizontalAlignment.RIGHT)}
|
||||||
|
className='cursor-pointer'
|
||||||
|
>
|
||||||
|
<AlignRight size={20} color={horizontalAlignment === HorizontalAlignment.RIGHT ? '#0038FF' : '#000'} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={() => setHorizontalAlignment(HorizontalAlignment.CENTER)}
|
||||||
|
className='cursor-pointer'
|
||||||
|
>
|
||||||
|
<AlignVertically size={20} color={horizontalAlignment === HorizontalAlignment.CENTER ? '#0038FF' : '#000'} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={() => setHorizontalAlignment(HorizontalAlignment.LEFT)}
|
||||||
|
className='cursor-pointer'
|
||||||
|
>
|
||||||
|
<AlignLeft size={20} color={horizontalAlignment === HorizontalAlignment.LEFT ? '#0038FF' : '#000'} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className='text-sm'>
|
||||||
|
{t('setting.vertical_position')}
|
||||||
|
</div>
|
||||||
|
<div className='mt-3 flex gap-4'>
|
||||||
|
<div
|
||||||
|
onClick={() => setVerticalAlignment(VerticalAlignment.TOP)}
|
||||||
|
className='cursor-pointer'
|
||||||
|
>
|
||||||
|
<AlignTop size={20} color={verticalAlignment === VerticalAlignment.TOP ? '#0038FF' : '#000'} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={() => setVerticalAlignment(VerticalAlignment.MIDDLE)}
|
||||||
|
className='cursor-pointer'
|
||||||
|
>
|
||||||
|
<AlignHorizontally size={20} color={verticalAlignment === VerticalAlignment.MIDDLE ? '#0038FF' : '#000'} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={() => setVerticalAlignment(VerticalAlignment.BOTTOM)}
|
||||||
|
className='cursor-pointer'
|
||||||
|
>
|
||||||
|
<AlignBottom size={20} color={verticalAlignment === VerticalAlignment.BOTTOM ? '#0038FF' : '#000'} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-7'>
|
||||||
|
{isEditMode ? (
|
||||||
|
<div className='space-y-3'>
|
||||||
|
<Button
|
||||||
|
onClick={handleUpdateSocial}
|
||||||
|
className='bg-[#0038FF] text-white w-full'
|
||||||
|
disabled={!link.trim()}
|
||||||
|
>
|
||||||
|
<div className='flex justify-center items-center gap-2'>
|
||||||
|
<Edit size={18} color='white' />
|
||||||
|
<span>{t('setting.update_social')}</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<Button
|
||||||
|
onClick={handleDeleteSocial}
|
||||||
|
className='bg-red-500 text-white flex-1'
|
||||||
|
>
|
||||||
|
<div className='flex justify-center items-center gap-1'>
|
||||||
|
<Trash size={16} color='white' />
|
||||||
|
<span className='text-sm'>{t('setting.delete')}</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
className='bg-gray-500 text-white flex-1'
|
||||||
|
>
|
||||||
|
<span className='text-sm'>{t('setting.cancel')}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={handleAddSocial}
|
||||||
|
className='bg-[#ECEEF5] text-black w-full'
|
||||||
|
disabled={!link.trim()}
|
||||||
|
>
|
||||||
|
<div className='flex justify-center items-center gap-2'>
|
||||||
|
<Add size={24} color='black' />
|
||||||
|
<span>{t('setting.add_social')}</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SocialSidebar
|
||||||
@@ -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 (
|
||||||
|
<div key={item.id} className='flex-1 bg-white rounded-3xl p-4'>
|
||||||
|
<div className='h-[140px] bg-gray-200 rounded-3xl'></div>
|
||||||
|
<div className='mt-4 flex justify-between items-center'>
|
||||||
|
<div className='flex gap-2 items-center'>
|
||||||
|
<Radio isActive={isActive} onChange={handleChangeSelectedTemplate} value={item.id} />
|
||||||
|
<div className='text-sm'>{item.name}</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex gap-2 items-center'>
|
||||||
|
<Brush2 size={20} color='black' />
|
||||||
|
<Trash size={20} color='red' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Templete
|
||||||
@@ -1,30 +1,75 @@
|
|||||||
import { FC } from 'react'
|
import { FC } from 'react'
|
||||||
import { TextType } from '../types/Types'
|
import { TextType, ElementType, PersonalityDataType } from '../types/Types'
|
||||||
|
import { usePersonalityStore } from '../store/Store'
|
||||||
|
|
||||||
interface TextRendererProps {
|
interface TextRendererProps {
|
||||||
texts: TextType[]
|
texts: TextType[]
|
||||||
|
itemId: string
|
||||||
|
sectionKey: keyof PersonalityDataType
|
||||||
}
|
}
|
||||||
|
|
||||||
const TextRenderer: FC<TextRendererProps> = ({ texts }) => {
|
const TextRenderer: FC<TextRendererProps> = ({ 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{texts?.map((textItem, textIndex) => (
|
{texts?.map((textItem, textIndex) => (
|
||||||
<table key={textItem.id} width="100%" cellPadding="0" cellSpacing="0" border={0} style={{ borderCollapse: 'collapse' }}>
|
<div
|
||||||
<tbody>
|
key={textItem.id}
|
||||||
<tr>
|
data-element-type="text"
|
||||||
<td
|
onClick={(e) => handleTextClick(e, textItem.id)}
|
||||||
align={textItem.alignment || 'center'}
|
style={{
|
||||||
style={{
|
cursor: 'pointer',
|
||||||
color: textItem.color || '#000000',
|
border: selectedElement?.elementId === textItem.id ? '2px dashed #0038FF' : '2px dashed transparent',
|
||||||
fontSize: `${textItem.fontSize || 14}px`,
|
borderRadius: '4px',
|
||||||
lineHeight: '1.4',
|
padding: '2px',
|
||||||
paddingBottom: textIndex < (texts?.length || 0) - 1 ? '4px' : '0'
|
margin: '2px 0',
|
||||||
}}
|
transition: 'border-color 0.2s ease',
|
||||||
dangerouslySetInnerHTML={{ __html: textItem.text }}
|
minHeight: '20px',
|
||||||
/>
|
display: 'inline-block',
|
||||||
</tr>
|
width: '100%'
|
||||||
</tbody>
|
}}
|
||||||
</table>
|
>
|
||||||
|
<table width="100%" cellPadding="0" cellSpacing="0" border={0} style={{ borderCollapse: 'collapse' }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
align={textItem.alignment || 'center'}
|
||||||
|
style={{
|
||||||
|
color: textItem.color || '#000000',
|
||||||
|
fontSize: `${textItem.fontSize || 14}px`,
|
||||||
|
lineHeight: '1.4',
|
||||||
|
paddingBottom: textIndex < (texts?.length || 0) - 1 ? '4px' : '0',
|
||||||
|
pointerEvents: 'none' // Prevent double click handling
|
||||||
|
}}
|
||||||
|
dangerouslySetInnerHTML={{ __html: textItem.text }}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import { Add, AlignBottom, AlignHorizontally, AlignLeft, AlignRight, AlignTop, AlignVertically } from 'iconsax-react';
|
import { Add, AlignBottom, AlignHorizontally, AlignLeft, AlignRight, AlignTop, AlignVertically, Edit, Trash } from 'iconsax-react';
|
||||||
import { FC, useState } from 'react'
|
import { FC, useState, useEffect } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import ReactQuill from 'react-quill-new';
|
import ReactQuill from 'react-quill-new';
|
||||||
import { usePersonalityStore } from '../store/Store';
|
import { usePersonalityStore } from '../store/Store';
|
||||||
import ColorPicker from '@/components/ColorPicker';
|
import ColorPicker from '@/components/ColorPicker';
|
||||||
import { HorizontalAlignment, VerticalAlignment } from '../types/Types';
|
import { HorizontalAlignment, VerticalAlignment, ElementType } from '../types/Types';
|
||||||
|
|
||||||
const TextSidebar: FC = () => {
|
const TextSidebar: FC = () => {
|
||||||
|
|
||||||
@@ -14,7 +14,63 @@ const TextSidebar: FC = () => {
|
|||||||
const [color, setColor] = useState('#000');
|
const [color, setColor] = useState('#000');
|
||||||
const [horizontalPosition, setHorizontalPosition] = useState<HorizontalAlignment>(HorizontalAlignment.CENTER);
|
const [horizontalPosition, setHorizontalPosition] = useState<HorizontalAlignment>(HorizontalAlignment.CENTER);
|
||||||
const [verticalPosition, setVerticalPosition] = useState<VerticalAlignment>(VerticalAlignment.MIDDLE);
|
const [verticalPosition, setVerticalPosition] = useState<VerticalAlignment>(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) => {
|
const handleChange = (value: string) => {
|
||||||
setValue(value);
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className='text-lg'>{t('setting.text')}</div>
|
<div className='text-lg flex items-center justify-between'>
|
||||||
|
{t('setting.text')}
|
||||||
|
{isEditMode && (
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<span className='text-sm text-blue-600'>{t('setting.edit_mode')}</span>
|
||||||
|
<Edit size={16} color='#0038FF' />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='mt-8'>
|
<div className='mt-8'>
|
||||||
<ReactQuill
|
<ReactQuill
|
||||||
@@ -77,16 +177,19 @@ const TextSidebar: FC = () => {
|
|||||||
<div className='mt-3 flex gap-4'>
|
<div className='mt-3 flex gap-4'>
|
||||||
<div
|
<div
|
||||||
onClick={() => setHorizontalPosition(HorizontalAlignment.RIGHT)}
|
onClick={() => setHorizontalPosition(HorizontalAlignment.RIGHT)}
|
||||||
|
className='cursor-pointer'
|
||||||
>
|
>
|
||||||
<AlignRight size={20} color={horizontalPosition === HorizontalAlignment.RIGHT ? '#0038FF' : '#000'} />
|
<AlignRight size={20} color={horizontalPosition === HorizontalAlignment.RIGHT ? '#0038FF' : '#000'} />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={() => setHorizontalPosition(HorizontalAlignment.CENTER)}
|
onClick={() => setHorizontalPosition(HorizontalAlignment.CENTER)}
|
||||||
|
className='cursor-pointer'
|
||||||
>
|
>
|
||||||
<AlignVertically size={20} color={horizontalPosition === HorizontalAlignment.CENTER ? '#0038FF' : '#000'} />
|
<AlignVertically size={20} color={horizontalPosition === HorizontalAlignment.CENTER ? '#0038FF' : '#000'} />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={() => setHorizontalPosition(HorizontalAlignment.LEFT)}
|
onClick={() => setHorizontalPosition(HorizontalAlignment.LEFT)}
|
||||||
|
className='cursor-pointer'
|
||||||
>
|
>
|
||||||
<AlignLeft size={20} color={horizontalPosition === HorizontalAlignment.LEFT ? '#0038FF' : '#000'} />
|
<AlignLeft size={20} color={horizontalPosition === HorizontalAlignment.LEFT ? '#0038FF' : '#000'} />
|
||||||
</div>
|
</div>
|
||||||
@@ -100,16 +203,19 @@ const TextSidebar: FC = () => {
|
|||||||
<div className='mt-3 flex gap-4'>
|
<div className='mt-3 flex gap-4'>
|
||||||
<div
|
<div
|
||||||
onClick={() => setVerticalPosition(VerticalAlignment.TOP)}
|
onClick={() => setVerticalPosition(VerticalAlignment.TOP)}
|
||||||
|
className='cursor-pointer'
|
||||||
>
|
>
|
||||||
<AlignTop size={20} color={verticalPosition === VerticalAlignment.TOP ? '#0038FF' : '#000'} />
|
<AlignTop size={20} color={verticalPosition === VerticalAlignment.TOP ? '#0038FF' : '#000'} />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={() => setVerticalPosition(VerticalAlignment.MIDDLE)}
|
onClick={() => setVerticalPosition(VerticalAlignment.MIDDLE)}
|
||||||
|
className='cursor-pointer'
|
||||||
>
|
>
|
||||||
<AlignHorizontally size={20} color={verticalPosition === VerticalAlignment.MIDDLE ? '#0038FF' : '#000'} />
|
<AlignHorizontally size={20} color={verticalPosition === VerticalAlignment.MIDDLE ? '#0038FF' : '#000'} />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={() => setVerticalPosition(VerticalAlignment.BOTTOM)}
|
onClick={() => setVerticalPosition(VerticalAlignment.BOTTOM)}
|
||||||
|
className='cursor-pointer'
|
||||||
>
|
>
|
||||||
<AlignBottom size={20} color={verticalPosition === VerticalAlignment.BOTTOM ? '#0038FF' : '#000'} />
|
<AlignBottom size={20} color={verticalPosition === VerticalAlignment.BOTTOM ? '#0038FF' : '#000'} />
|
||||||
</div>
|
</div>
|
||||||
@@ -118,15 +224,48 @@ const TextSidebar: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='mt-7'>
|
<div className='mt-7'>
|
||||||
<Button
|
{isEditMode ? (
|
||||||
onClick={handleAddText}
|
<div className='space-y-3'>
|
||||||
className='bg-[#ECEEF5] text-black'
|
<Button
|
||||||
>
|
onClick={handleUpdateText}
|
||||||
<div className='flex justify-center items-center gap-2'>
|
className='bg-[#0038FF] text-white w-full'
|
||||||
<Add size={24} color='black' />
|
disabled={!value.trim()}
|
||||||
<span>{t('setting.add_text')}</span>
|
>
|
||||||
|
<div className='flex justify-center items-center gap-2'>
|
||||||
|
<Edit size={18} color='white' />
|
||||||
|
<span>{t('setting.update_text')}</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<Button
|
||||||
|
onClick={handleDeleteText}
|
||||||
|
className='bg-red-500 text-white flex-1'
|
||||||
|
>
|
||||||
|
<div className='flex justify-center items-center gap-1'>
|
||||||
|
<Trash size={16} color='white' />
|
||||||
|
<span className='text-sm'>{t('setting.delete')}</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
className='bg-gray-500 text-white flex-1'
|
||||||
|
>
|
||||||
|
<span className='text-sm'>{t('setting.cancel')}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={handleAddText}
|
||||||
|
className='bg-[#ECEEF5] text-black w-full'
|
||||||
|
disabled={!value.trim()}
|
||||||
|
>
|
||||||
|
<div className='flex justify-center items-center gap-2'>
|
||||||
|
<Add size={24} color='black' />
|
||||||
|
<span>{t('setting.add_text')}</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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<TemplatesResponseType>({
|
||||||
|
queryKey: ["templates"],
|
||||||
|
queryFn: api.getTemplates,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSetSelectedTemplate = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => api.setSelectedTemplate(id),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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<TemplatesResponseType> => {
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -7,9 +7,11 @@ import {
|
|||||||
TextType,
|
TextType,
|
||||||
ButtonType,
|
ButtonType,
|
||||||
ImageType,
|
ImageType,
|
||||||
|
SocialType,
|
||||||
SectionName,
|
SectionName,
|
||||||
HorizontalAlignment,
|
HorizontalAlignment,
|
||||||
VerticalAlignment,
|
VerticalAlignment,
|
||||||
|
SelectedElement,
|
||||||
} from "../types/Types";
|
} from "../types/Types";
|
||||||
|
|
||||||
export const usePersonalityStore = create<PersonalityStore>((set) => ({
|
export const usePersonalityStore = create<PersonalityStore>((set) => ({
|
||||||
@@ -21,11 +23,45 @@ export const usePersonalityStore = create<PersonalityStore>((set) => ({
|
|||||||
|
|
||||||
activeSection: "",
|
activeSection: "",
|
||||||
activeItemIndex: 0,
|
activeItemIndex: 0,
|
||||||
|
selectedElement: null,
|
||||||
|
isEditMode: false,
|
||||||
|
|
||||||
setActiveSection: (section: SectionName) =>
|
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: (
|
setColumnsCount: (
|
||||||
sectionKey: keyof PersonalityDataType,
|
sectionKey: keyof PersonalityDataType,
|
||||||
@@ -42,6 +78,7 @@ export const usePersonalityStore = create<PersonalityStore>((set) => ({
|
|||||||
texts: [],
|
texts: [],
|
||||||
buttons: [],
|
buttons: [],
|
||||||
images: [],
|
images: [],
|
||||||
|
socials: [],
|
||||||
alignment: HorizontalAlignment.CENTER,
|
alignment: HorizontalAlignment.CENTER,
|
||||||
verticalAlignment: VerticalAlignment.MIDDLE,
|
verticalAlignment: VerticalAlignment.MIDDLE,
|
||||||
}
|
}
|
||||||
@@ -373,4 +410,181 @@ export const usePersonalityStore = create<PersonalityStore>((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<SocialType, "id">
|
||||||
|
) =>
|
||||||
|
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<SocialType, "id">) =>
|
||||||
|
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<SocialType>
|
||||||
|
) =>
|
||||||
|
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,
|
||||||
|
})),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -39,6 +39,50 @@ export enum SectionName {
|
|||||||
FOOTER = "footer",
|
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 = {
|
export type PersonalityDataType = {
|
||||||
header: SectionType;
|
header: SectionType;
|
||||||
content: SectionType;
|
content: SectionType;
|
||||||
@@ -57,6 +101,7 @@ export type SectionItemType = {
|
|||||||
texts: TextType[];
|
texts: TextType[];
|
||||||
buttons: ButtonType[];
|
buttons: ButtonType[];
|
||||||
images: ImageType[];
|
images: ImageType[];
|
||||||
|
socials: SocialType[];
|
||||||
alignment?: HorizontalAlignment;
|
alignment?: HorizontalAlignment;
|
||||||
verticalAlignment?: VerticalAlignment;
|
verticalAlignment?: VerticalAlignment;
|
||||||
style?: {
|
style?: {
|
||||||
@@ -104,13 +149,29 @@ export type ImageType = {
|
|||||||
verticalAlignment?: VerticalAlignment;
|
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 = {
|
export type PersonalityStore = {
|
||||||
data: PersonalityDataType;
|
data: PersonalityDataType;
|
||||||
activeSection: SectionName | "";
|
activeSection: SectionName | "";
|
||||||
activeItemIndex: number;
|
activeItemIndex: number;
|
||||||
|
selectedElement: SelectedElement; // New field for element selection
|
||||||
|
isEditMode: boolean; // New field for edit mode
|
||||||
|
|
||||||
setActiveSection: (section: SectionName) => void;
|
setActiveSection: (section: SectionName) => void;
|
||||||
setActiveItemIndex: (index: number) => void;
|
setActiveItemIndex: (index: number) => void;
|
||||||
|
setSelectedElement: (element: SelectedElement) => void; // New method
|
||||||
|
setEditMode: (isEdit: boolean) => void; // New method
|
||||||
|
|
||||||
setItems: (
|
setItems: (
|
||||||
sectionKey: keyof PersonalityDataType,
|
sectionKey: keyof PersonalityDataType,
|
||||||
@@ -167,4 +228,93 @@ export type PersonalityStore = {
|
|||||||
imageId: string,
|
imageId: string,
|
||||||
updates: Partial<ImageType>
|
updates: Partial<ImageType>
|
||||||
) => void;
|
) => void;
|
||||||
|
// Social network methods
|
||||||
|
addSocial: (
|
||||||
|
sectionKey: keyof PersonalityDataType,
|
||||||
|
itemId: string,
|
||||||
|
social: Omit<SocialType, "id">
|
||||||
|
) => void;
|
||||||
|
addSocialToActiveItem: (social: Omit<SocialType, "id">) => void;
|
||||||
|
updateSocial: (
|
||||||
|
sectionKey: keyof PersonalityDataType,
|
||||||
|
itemId: string,
|
||||||
|
socialId: string,
|
||||||
|
updates: Partial<SocialType>
|
||||||
|
) => 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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,511 @@
|
|||||||
|
import {
|
||||||
|
PersonalityDataType,
|
||||||
|
HorizontalAlignment,
|
||||||
|
VerticalAlignment,
|
||||||
|
ButtonSize,
|
||||||
|
ImageSize,
|
||||||
|
TextType,
|
||||||
|
ButtonType,
|
||||||
|
ImageType,
|
||||||
|
SectionType,
|
||||||
|
} from "../types/Types";
|
||||||
|
|
||||||
|
// تابع برای تبدیل تصویر به base64
|
||||||
|
const imageToBase64 = async (imageUrl: string): Promise<string> => {
|
||||||
|
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<PersonalityDataType> => {
|
||||||
|
const convertSectionImages = async (
|
||||||
|
section: SectionType
|
||||||
|
): Promise<SectionType> => {
|
||||||
|
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<string> => {
|
||||||
|
// تبدیل تصاویر به 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) =>
|
||||||
|
`<table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse: collapse;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="${textItem.alignment || "center"}" style="color: ${
|
||||||
|
textItem.color || "#000000"
|
||||||
|
}; font-size: ${
|
||||||
|
textItem.fontSize || 14
|
||||||
|
}px; line-height: 1.4; padding-bottom: ${
|
||||||
|
textIndex < (texts?.length || 0) - 1 ? "4px" : "0"
|
||||||
|
};">
|
||||||
|
${textItem.text}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>`
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render ButtonRenderer equivalent
|
||||||
|
const renderButtons = (buttons: ButtonType[]) => {
|
||||||
|
if (!buttons || buttons.length === 0) return "";
|
||||||
|
|
||||||
|
return `<table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse: collapse;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="${getHorizontalAlign(
|
||||||
|
buttons[0]?.alignment
|
||||||
|
)}" valign="${getVerticalAlign(
|
||||||
|
buttons[0]?.verticalAlignment
|
||||||
|
)}" style="width: 100%;">
|
||||||
|
<table cellpadding="0" cellspacing="0" border="0" style="border-collapse: collapse;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
${buttons
|
||||||
|
.slice(0, 2)
|
||||||
|
.map(
|
||||||
|
(button, buttonIndex) =>
|
||||||
|
`<td style="padding-right: ${
|
||||||
|
buttonIndex === 0 && buttons.length > 1 ? "4px" : "0"
|
||||||
|
};">
|
||||||
|
<table cellpadding="0" cellspacing="0" border="0" style="border-collapse: collapse; background-color: ${
|
||||||
|
button.backgroundColor
|
||||||
|
}; border: ${
|
||||||
|
button.isBorder
|
||||||
|
? `${button.borderSize}px solid ${button.borderColor}`
|
||||||
|
: "none"
|
||||||
|
}; border-radius: 4px;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="middle" style="padding: ${
|
||||||
|
button.size === ButtonSize.SMALL
|
||||||
|
? "2px 4px"
|
||||||
|
: button.size === ButtonSize.MEDIUM
|
||||||
|
? "4px 8px"
|
||||||
|
: "8px 12px"
|
||||||
|
}; font-size: ${
|
||||||
|
button.size === ButtonSize.SMALL
|
||||||
|
? "11px"
|
||||||
|
: button.size === ButtonSize.MEDIUM
|
||||||
|
? "13px"
|
||||||
|
: "15px"
|
||||||
|
}; color: ${
|
||||||
|
button.textColor
|
||||||
|
}; text-decoration: none; white-space: nowrap; max-width: fit-content; overflow: hidden; text-overflow: ellipsis;">
|
||||||
|
<a href="${button.link || "#"}" style="color: ${
|
||||||
|
button.textColor
|
||||||
|
}; text-decoration: none;">
|
||||||
|
${button.text}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>`
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 `<div style="${getBackgroundStyle(image)}"> </div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (image.size === ImageSize.CONTAIN) {
|
||||||
|
return `<table width="${
|
||||||
|
image.width || "100%"
|
||||||
|
}" cellpadding="0" cellspacing="0" border="0">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="middle" style="text-align: center;">
|
||||||
|
<img src="${image.src}" alt="${
|
||||||
|
image.alt || "تصویر آپلود شده"
|
||||||
|
}" width="${image.width || "100%"}" height="${
|
||||||
|
image.height || "auto"
|
||||||
|
}" style="border: 0;" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<img src="${image.src}" alt="${
|
||||||
|
image.alt || "تصویر آپلود شده"
|
||||||
|
}" width="${image.width || "100%"}" height="${
|
||||||
|
image.height || "auto"
|
||||||
|
}" style="border: 0;" />`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return images
|
||||||
|
.map(
|
||||||
|
(image, imageIndex) =>
|
||||||
|
`<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="${getHorizontalAlign(
|
||||||
|
image.alignment
|
||||||
|
)}" valign="${getVerticalAlign(
|
||||||
|
image.verticalAlignment
|
||||||
|
)}" style="padding-bottom: ${
|
||||||
|
imageIndex < images.length - 1 ? "4px" : "0"
|
||||||
|
};">
|
||||||
|
${renderImage(image)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>`
|
||||||
|
)
|
||||||
|
.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 `<tr>
|
||||||
|
${paddingTop ? `<td style="padding-top: ${paddingTop};">` : "<td>"}
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px; border-radius: 24px;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="table-layout: fixed;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
${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 = `<td style="width: ${
|
||||||
|
100 / section.columnsCount
|
||||||
|
}%; height: ${sectionHeight}; background-color: ${
|
||||||
|
item?.backgroundColor || "#ffffff"
|
||||||
|
}; ${
|
||||||
|
item?.backgroundImage
|
||||||
|
? `background-image: url(${
|
||||||
|
item.backgroundImage
|
||||||
|
}); background-size: ${
|
||||||
|
item?.style?.backgroundSize || "cover"
|
||||||
|
}; background-repeat: ${
|
||||||
|
item?.style?.backgroundRepeat || "no-repeat"
|
||||||
|
}; background-position: ${
|
||||||
|
item?.style?.backgroundPosition || "center"
|
||||||
|
};`
|
||||||
|
: ""
|
||||||
|
} padding: 0; vertical-align: top; border-radius: 8px; position: relative;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse: collapse; height: ${sectionHeight};">
|
||||||
|
<tbody>`;
|
||||||
|
|
||||||
|
// اگر فقط دکمه داریم و متن نداریم
|
||||||
|
if (!hasTexts && hasButtons) {
|
||||||
|
result += `<tr>
|
||||||
|
<td width="100%" height="${sectionHeight}" align="${getButtonHorizontalAlign(
|
||||||
|
item.buttons[0]
|
||||||
|
)}" valign="${getButtonVerticalAlign(
|
||||||
|
item.buttons[0]
|
||||||
|
)}" style="padding: 8px;">
|
||||||
|
${renderButtons(item.buttons)}
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
} else {
|
||||||
|
// ردیف اصلی برای متن و تصاویر
|
||||||
|
const mainHeight = hasButtons
|
||||||
|
? sectionHeight === "123px"
|
||||||
|
? "60"
|
||||||
|
: sectionHeight === "246px"
|
||||||
|
? "203"
|
||||||
|
: "40"
|
||||||
|
: sectionHeight === "123px"
|
||||||
|
? "87"
|
||||||
|
: sectionHeight === "246px"
|
||||||
|
? "230"
|
||||||
|
: "67";
|
||||||
|
|
||||||
|
result += `<tr>
|
||||||
|
<td width="100%" height="${mainHeight}" 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; font-size: 14px; line-height: 1.4;">
|
||||||
|
${renderTexts(item?.texts || [])}
|
||||||
|
${renderImages(item?.images || [])}
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
|
||||||
|
// ردیف دکمهها
|
||||||
|
if (hasButtons) {
|
||||||
|
result += `<tr>
|
||||||
|
<td width="100%" height="27" align="${getButtonHorizontalAlign(
|
||||||
|
item.buttons[0]
|
||||||
|
)}" valign="${getButtonVerticalAlign(
|
||||||
|
item.buttons[0]
|
||||||
|
)}" style="padding: 0 8px 8px 8px;">
|
||||||
|
${renderButtons(item.buttons)}
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result += `</tbody>
|
||||||
|
</table>
|
||||||
|
</td>`;
|
||||||
|
|
||||||
|
// فاصله بین ستونها
|
||||||
|
if (index < section.columnsCount - 1) {
|
||||||
|
result += `<td style="width: 16px;"></td>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
).join("")}
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
} else {
|
||||||
|
// حالت خالی
|
||||||
|
const emptyMessages = {
|
||||||
|
"123px": "سربرگ ایمیل",
|
||||||
|
"246px": "محتوای ایمیل",
|
||||||
|
};
|
||||||
|
const emptyMessage =
|
||||||
|
emptyMessages[sectionHeight as keyof typeof emptyMessages] ||
|
||||||
|
"فوتر ایمیل";
|
||||||
|
|
||||||
|
return `<tr>
|
||||||
|
${paddingTop ? `<td style="padding-top: ${paddingTop};">` : "<td>"}
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="height: ${
|
||||||
|
sectionHeight === "246px" ? "286px" : sectionHeight
|
||||||
|
}; border-radius: 24px; text-align: center; vertical-align: middle; padding: 10px;">
|
||||||
|
<div style="color: #888; font-size: 14px; margin-bottom: 8px;">
|
||||||
|
${emptyMessage}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate complete HTML exactly like Personality.tsx structure
|
||||||
|
const html = `<div style="max-width: 600px; margin: 0 auto; direction: rtl;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse: collapse;">
|
||||||
|
<tbody>
|
||||||
|
${renderSection(dataWithBase64Images.header, "123px")}
|
||||||
|
${renderSection(dataWithBase64Images.content, "246px", "16px")}
|
||||||
|
${renderSection(dataWithBase64Images.footer, "123px", "16px")}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
return html;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const copyHTMLToClipboard = async (html: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(html);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to copy HTML to clipboard:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -3,6 +3,8 @@ import { Paths } from '@/utils/Paths'
|
|||||||
import { Routes, Route } from 'react-router-dom'
|
import { Routes, Route } from 'react-router-dom'
|
||||||
import Setting from '@/pages/setting/Setting'
|
import Setting from '@/pages/setting/Setting'
|
||||||
import Home from '@/pages/home/Home'
|
import Home from '@/pages/home/Home'
|
||||||
|
import List from '@/pages/setting/personality/List'
|
||||||
|
import EmailBuilder from '@/pages/email-builder/EmailBuilder'
|
||||||
const AppRouter: FC = () => {
|
const AppRouter: FC = () => {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -13,6 +15,8 @@ const AppRouter: FC = () => {
|
|||||||
<Route path={Paths.settingAddress} element={<Setting />} />
|
<Route path={Paths.settingAddress} element={<Setting />} />
|
||||||
<Route path={Paths.settingPersonality} element={<Setting />} />
|
<Route path={Paths.settingPersonality} element={<Setting />} />
|
||||||
<Route path={Paths.settingSignature} element={<Setting />} />
|
<Route path={Paths.settingSignature} element={<Setting />} />
|
||||||
|
<Route path={Paths.settingPersonalityList} element={<List />} />
|
||||||
|
<Route path={Paths.emailBuilder} element={<EmailBuilder />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -62,12 +62,19 @@ const SideBar: FC = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<SideBarItem
|
<SideBarItem
|
||||||
icon={<Brush2 variant={isActive(Paths.settingPersonality) ? 'Bold' : 'Outline'} color={isActive(Paths.settingPersonality) ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
|
icon={<Brush2 variant={isActive(Paths.settingPersonalityList) ? 'Bold' : 'Outline'} color={isActive(Paths.settingPersonalityList) ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
|
||||||
title={t('setting.personality')}
|
title={t('setting.personality')}
|
||||||
isActive={isActive(Paths.settingPersonality)}
|
isActive={isActive(Paths.settingPersonalityList)}
|
||||||
link={Paths.settingPersonality}
|
link={Paths.settingPersonalityList}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* <SideBarItem
|
||||||
|
icon={<Mobile variant={isActive(Paths.emailBuilder) ? 'Bold' : 'Outline'} color={isActive(Paths.emailBuilder) ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
|
||||||
|
title="سازنده قالب ایمیل"
|
||||||
|
isActive={isActive(Paths.emailBuilder)}
|
||||||
|
link={Paths.emailBuilder}
|
||||||
|
/> */}
|
||||||
|
|
||||||
<SideBarItem
|
<SideBarItem
|
||||||
icon={<PenClose variant={isActive(Paths.settingSignature) ? 'Bold' : 'Outline'} color={isActive(Paths.settingSignature) ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
|
icon={<PenClose variant={isActive(Paths.settingSignature) ? 'Bold' : 'Outline'} color={isActive(Paths.settingSignature) ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
|
||||||
title={t('setting.signature')}
|
title={t('setting.signature')}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export const Paths = {
|
|||||||
settingDomain: "/setting/domain",
|
settingDomain: "/setting/domain",
|
||||||
settingAddress: "/setting/address",
|
settingAddress: "/setting/address",
|
||||||
settingPersonality: "/setting/personality",
|
settingPersonality: "/setting/personality",
|
||||||
|
settingPersonalityList: "/setting/personality/list",
|
||||||
settingSignature: "/setting/signature",
|
settingSignature: "/setting/signature",
|
||||||
|
emailBuilder: "/email-builder",
|
||||||
detailEmail: "/mail/",
|
detailEmail: "/mail/",
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user