auto select and edit
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
VITE_TOKEN_NAME = 'dsc_token'
|
||||
VITE_REFRESH_TOKEN_NAME = 'dsc_refresh_token'
|
||||
VITE_DANAK_BASE_URL ='https://api.danakcorp.com'
|
||||
# VITE_BASE_URL = 'http://192.168.1.113:4000'
|
||||
VITE_BASE_URL = 'https://dmail-api.danakcorp.com'
|
||||
VITE_BASE_URL = 'http://192.168.1.113:4000'
|
||||
# VITE_BASE_URL = 'https://dmail-api.danakcorp.com'
|
||||
VITE_SERVICE_ID = 'e51afdc3-ea0b-49cf-8f49-2a6f131b024e'
|
||||
|
||||
VITE_LOGIN_URL = 'https://console.danakcorp.com/auth/login'
|
||||
|
||||
@@ -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": "لطفا یک بخش انتخاب کنید",
|
||||
"title1": "عنوان",
|
||||
"quota": "فضا/ استفاده شده ",
|
||||
"name_sign": "نام امضا"
|
||||
"name_sign": "نام امضا",
|
||||
"edit_mode": "حالت ویرایش",
|
||||
"update_text": "بروزرسانی متن",
|
||||
"update_button": "بروزرسانی دکمه",
|
||||
"update_image": "بروزرسانی تصویر",
|
||||
"current_image": "تصویر فعلی",
|
||||
"cancel": "لغو",
|
||||
"social_networks": "شبکههای اجتماعی",
|
||||
"social_network": "شبکه اجتماعی",
|
||||
"social_link": "لینک شبکه اجتماعی",
|
||||
"select_network": "انتخاب شبکه",
|
||||
"icon_style": "نوع آیکون",
|
||||
"icon_color": "رنگ آیکون",
|
||||
"add_social": "اضافه کردن شبکه اجتماعی",
|
||||
"update_social": "بروزرسانی شبکه اجتماعی",
|
||||
"delete": "حذف"
|
||||
},
|
||||
"save": "ذخیره",
|
||||
"export_html": "خروجی HTML",
|
||||
"export_success": "خروجی HTML با موفقیت کپی شد",
|
||||
"export_error": "خطا در تولید خروجی HTML",
|
||||
"app": {
|
||||
"menu": "منو"
|
||||
},
|
||||
|
||||
@@ -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 Signture from './signture/Signture'
|
||||
import { Paths } from '@/utils/Paths'
|
||||
import Button from '@/components/Button'
|
||||
import { TickCircle } from 'iconsax-react'
|
||||
import { usePersonalityStore } from './personality/store/Store'
|
||||
import { exportPersonalityToHTML } from './personality/utils/ExportHTML'
|
||||
import Input from '@/components/Input'
|
||||
import { useSaveTemplate } from './personality/hooks/usePersonalityData'
|
||||
import { toast } from '@/components/Toast'
|
||||
|
||||
const Setting: FC = () => {
|
||||
|
||||
const { t } = useTranslation()
|
||||
const { data } = usePersonalityStore()
|
||||
const location = useLocation()
|
||||
const [activeTab, setActiveTab] = useState<SettingTabEnum>(SettingTabEnum.SETTING_DOMAIN)
|
||||
const [templateName, setTemplateName] = useState('')
|
||||
const { mutate: saveTemplate, isPending } = useSaveTemplate()
|
||||
|
||||
// Map URL paths to setting tabs
|
||||
const getTabFromPath = (pathname: string): SettingTabEnum => {
|
||||
switch (pathname) {
|
||||
case Paths.settingMailServer:
|
||||
@@ -67,9 +77,62 @@ const Setting: FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!templateName) {
|
||||
toast('نام قالب را وارد کنید', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
const html = await exportPersonalityToHTML(data)
|
||||
saveTemplate({
|
||||
name: templateName,
|
||||
rawHtml: html,
|
||||
structure: data
|
||||
}, {
|
||||
onSuccess: (data) => {
|
||||
toast(data?.data?.message, 'success')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// const handleExportHTML = async () => {
|
||||
// try {
|
||||
// const html = await exportPersonalityToHTML(data)
|
||||
// const success = await copyHTMLToClipboard(html)
|
||||
|
||||
// if (success) {
|
||||
// toast(t('export_success'), 'success')
|
||||
// } else {
|
||||
// toast(t('export_error'), 'error')
|
||||
// }
|
||||
// } catch {
|
||||
// toast(t('export_error'), 'error')
|
||||
// }
|
||||
// }
|
||||
|
||||
return (
|
||||
<div className='mt-2 md:mt-4 px-2 md:px-0'>
|
||||
<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>
|
||||
{renderActiveTab()}
|
||||
|
||||
@@ -11,5 +11,6 @@ export enum SideBarTab {
|
||||
TEXT = "text",
|
||||
BUTTON = "button",
|
||||
IMAGE = "image",
|
||||
SOCIAL = "social",
|
||||
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 { Gallery, LinkSquare, Setting4, Text } from 'iconsax-react'
|
||||
import { Gallery, LinkSquare, Setting4, Text, Profile2User } from 'iconsax-react'
|
||||
import Logo from '@/assets/images/logo-small.svg'
|
||||
import SettingSideBar from './components/SettingSideBar'
|
||||
import { SideBarTab } from '../enum/SettingEnum'
|
||||
import TextSidebar from './components/TextSidebar'
|
||||
import ButtonSidebar from './components/ButtonSidebar'
|
||||
import ImageSideBar from './components/ImageSideBar'
|
||||
import SocialSidebar from './components/SocialSidebar'
|
||||
import { clx } from '@/helpers/utils'
|
||||
import { usePersonalityStore } from './store/Store'
|
||||
import { ElementType } from './types/Types'
|
||||
|
||||
const PersonalitySidebar: FC = () => {
|
||||
|
||||
const [active, setActive] = useState<SideBarTab>(SideBarTab.SETTING)
|
||||
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(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
@@ -42,6 +76,7 @@ const PersonalitySidebar: FC = () => {
|
||||
: active === SideBarTab.TEXT ? <TextSidebar />
|
||||
: active === SideBarTab.BUTTON ? <ButtonSidebar />
|
||||
: active === SideBarTab.IMAGE ? <ImageSideBar />
|
||||
: active === SideBarTab.SOCIAL ? <SocialSidebar />
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
@@ -54,10 +89,41 @@ const PersonalitySidebar: FC = () => {
|
||||
'flex flex-col gap-10 mt-16',
|
||||
active === SideBarTab.NONE && 'mt-4'
|
||||
)}>
|
||||
<Setting4 size={22} color='black' variant={SideBarTab.SETTING === active ? 'Bold' : 'Outline'} onClick={() => setActive(SideBarTab.SETTING)} className='cursor-pointer' />
|
||||
<Text size={22} color='black' variant={SideBarTab.TEXT === active ? 'Bold' : 'Outline'} onClick={() => setActive(SideBarTab.TEXT)} className='cursor-pointer' />
|
||||
<LinkSquare size={22} color='black' variant={SideBarTab.BUTTON === active ? 'Bold' : 'Outline'} onClick={() => setActive(SideBarTab.BUTTON)} className='cursor-pointer' />
|
||||
<Gallery size={22} color='black' variant={SideBarTab.IMAGE === active ? 'Bold' : 'Outline'} onClick={() => setActive(SideBarTab.IMAGE)} className='cursor-pointer' />
|
||||
<Setting4
|
||||
size={22}
|
||||
color='black'
|
||||
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>
|
||||
|
||||
@@ -1,13 +1,39 @@
|
||||
import { FC } from 'react'
|
||||
import { ButtonType, ButtonSize, HorizontalAlignment, VerticalAlignment } from '../types/Types'
|
||||
import { ButtonType, ButtonSize, HorizontalAlignment, VerticalAlignment, ElementType, PersonalityDataType } from '../types/Types'
|
||||
import { usePersonalityStore } from '../store/Store'
|
||||
|
||||
interface ButtonRendererProps {
|
||||
buttons: ButtonType[]
|
||||
itemId: string
|
||||
sectionKey: keyof PersonalityDataType
|
||||
}
|
||||
|
||||
const ButtonRenderer: FC<ButtonRendererProps> = ({ buttons }) => {
|
||||
const ButtonRenderer: FC<ButtonRendererProps> = ({ buttons, itemId, sectionKey }) => {
|
||||
const { selectedElement, setSelectedElement } = usePersonalityStore()
|
||||
|
||||
if (!buttons || buttons.length === 0) return null
|
||||
|
||||
const handleButtonClick = (e: React.MouseEvent, buttonId: string) => {
|
||||
console.log('🔘 ButtonRenderer - handleButtonClick called:', {
|
||||
buttonId,
|
||||
itemId,
|
||||
sectionKey,
|
||||
event: e,
|
||||
target: e.target,
|
||||
currentTarget: e.currentTarget
|
||||
});
|
||||
|
||||
e.stopPropagation() // Prevent bubbling to item/section handlers
|
||||
e.preventDefault() // جلوگیری از اجرای لینک
|
||||
|
||||
setSelectedElement({
|
||||
type: ElementType.BUTTON,
|
||||
elementId: buttonId,
|
||||
itemId,
|
||||
sectionKey,
|
||||
})
|
||||
}
|
||||
|
||||
// تبدیل enum به string برای HTML attributes
|
||||
const getHorizontalAlign = (alignment?: HorizontalAlignment) => {
|
||||
switch (alignment) {
|
||||
@@ -47,6 +73,18 @@ const ButtonRenderer: FC<ButtonRendererProps> = ({ buttons }) => {
|
||||
<tr>
|
||||
{buttons.slice(0, 2).map((button, buttonIndex) => (
|
||||
<td key={button.id} style={{ paddingRight: buttonIndex === 0 && buttons.length > 1 ? '4px' : '0' }}>
|
||||
<div
|
||||
data-element-type="button"
|
||||
onClick={(e) => handleButtonClick(e, button.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
outline: selectedElement?.elementId === button.id ? '2px dashed #0038FF' : 'none',
|
||||
outlineOffset: '2px',
|
||||
borderRadius: '4px',
|
||||
padding: '2px',
|
||||
display: 'inline-block'
|
||||
}}
|
||||
>
|
||||
<table cellPadding="0" cellSpacing="0" border={0} style={{
|
||||
borderCollapse: 'collapse',
|
||||
backgroundColor: button.backgroundColor,
|
||||
@@ -66,19 +104,22 @@ const ButtonRenderer: FC<ButtonRendererProps> = ({ buttons }) => {
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: 'fit-content',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
textOverflow: 'ellipsis',
|
||||
pointerEvents: 'none' // Prevent double click handling
|
||||
}}
|
||||
>
|
||||
<a href={button.link || '#'} style={{
|
||||
<span style={{
|
||||
color: button.textColor,
|
||||
textDecoration: 'none',
|
||||
pointerEvents: 'none'
|
||||
}}>
|
||||
{button.text}
|
||||
</a>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
@@ -3,16 +3,23 @@ import ColorPicker from '@/components/ColorPicker'
|
||||
import Input from '@/components/Input'
|
||||
import Select from '@/components/Select'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { AlignRight, AlignBottom, AlignHorizontally, AlignLeft, AlignTop, AlignVertically, Link2, Add } from 'iconsax-react'
|
||||
import { FC, useState } from 'react'
|
||||
import { AlignRight, AlignBottom, AlignHorizontally, AlignLeft, AlignTop, AlignVertically, Link2, Add, Edit, Trash } from 'iconsax-react'
|
||||
import { FC, useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { usePersonalityStore } from '../store/Store'
|
||||
import { ButtonSize, HorizontalAlignment, VerticalAlignment } from '../types/Types'
|
||||
import { ButtonSize, HorizontalAlignment, VerticalAlignment, ElementType } from '../types/Types'
|
||||
|
||||
const ButtonSidebar: FC = () => {
|
||||
|
||||
const { t } = useTranslation()
|
||||
const { addButtonToActiveItem } = usePersonalityStore()
|
||||
const {
|
||||
addButtonToActiveItem,
|
||||
selectedElement,
|
||||
setSelectedElement,
|
||||
data,
|
||||
updateButton,
|
||||
deleteButton
|
||||
} = usePersonalityStore()
|
||||
|
||||
const [buttonText, setButtonText] = useState<string>('')
|
||||
const [buttonLink, setButtonLink] = useState<string>('')
|
||||
@@ -25,6 +32,66 @@ const ButtonSidebar: FC = () => {
|
||||
const [horizontalAlignment, setHorizontalAlignment] = useState<HorizontalAlignment>(HorizontalAlignment.CENTER)
|
||||
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 = [
|
||||
{ label: 'کوچک', value: ButtonSize.SMALL },
|
||||
{ label: 'متوسط', value: ButtonSize.MEDIUM },
|
||||
@@ -58,9 +125,59 @@ const ButtonSidebar: FC = () => {
|
||||
setVerticalAlignment(VerticalAlignment.MIDDLE)
|
||||
}
|
||||
|
||||
const handleUpdateButton = () => {
|
||||
if (selectedElement && buttonText.trim()) {
|
||||
updateButton(
|
||||
selectedElement.sectionKey,
|
||||
selectedElement.itemId,
|
||||
selectedElement.elementId,
|
||||
{
|
||||
text: buttonText,
|
||||
link: buttonLink,
|
||||
size: buttonSize,
|
||||
isBorder,
|
||||
borderSize,
|
||||
textColor,
|
||||
backgroundColor,
|
||||
borderColor,
|
||||
alignment: horizontalAlignment,
|
||||
verticalAlignment,
|
||||
}
|
||||
);
|
||||
|
||||
// Exit edit mode
|
||||
setSelectedElement(null);
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteButton = () => {
|
||||
if (selectedElement) {
|
||||
deleteButton(
|
||||
selectedElement.sectionKey,
|
||||
selectedElement.itemId,
|
||||
selectedElement.elementId
|
||||
);
|
||||
|
||||
// Exit edit mode
|
||||
setSelectedElement(null);
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setSelectedElement(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<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'>
|
||||
<Input
|
||||
@@ -182,15 +299,48 @@ const ButtonSidebar: FC = () => {
|
||||
</div>
|
||||
|
||||
<div className='mt-7'>
|
||||
{isEditMode ? (
|
||||
<div className='space-y-3'>
|
||||
<Button
|
||||
onClick={handleUpdateButton}
|
||||
className='bg-[#0038FF] text-white w-full'
|
||||
disabled={!buttonText.trim()}
|
||||
>
|
||||
<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>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleAddButton}
|
||||
className='bg-[#ECEEF5] text-black'
|
||||
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>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AddCircle } from 'iconsax-react'
|
||||
import TextRenderer from './TextRenderer'
|
||||
import ButtonRenderer from './ButtonRenderer'
|
||||
import ImageRenderer from './ImageRenderer'
|
||||
import SocialRenderer from './SocialRenderer'
|
||||
import { SectionName, VerticalAlignment, HorizontalAlignment } from '../types/Types'
|
||||
|
||||
const ContentSection: FC = () => {
|
||||
@@ -12,12 +13,81 @@ const ContentSection: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { activeSection, activeItemIndex, data, setActiveSection, setActiveItemIndex } = usePersonalityStore()
|
||||
|
||||
const handleSectionClick = (section: SectionName) => {
|
||||
console.log('🟢 ContentSection - Current state:', {
|
||||
activeSection,
|
||||
activeItemIndex,
|
||||
contentColumnsCount: data.content.columnsCount,
|
||||
contentItemsLength: data.content.items.length
|
||||
});
|
||||
|
||||
const handleSectionClick = (section: SectionName, e: React.MouseEvent) => {
|
||||
console.log('🟢 ContentSection - handleSectionClick called:', {
|
||||
section,
|
||||
event: e,
|
||||
target: e.target,
|
||||
currentTarget: e.currentTarget
|
||||
});
|
||||
|
||||
// اگر کلیک روی فضای خالی باشد (نه روی content elements)
|
||||
const target = e.target as HTMLElement;
|
||||
const isClickOnContent = target.closest('[data-element-type]') !== null;
|
||||
|
||||
console.log('🟢 ContentSection - Section click analysis:', {
|
||||
isClickOnContent,
|
||||
targetTagName: target.tagName,
|
||||
targetClasses: target.className
|
||||
});
|
||||
|
||||
if (!isClickOnContent) {
|
||||
console.log('🟢 ContentSection - Setting active section:', section);
|
||||
setActiveSection(section)
|
||||
} else {
|
||||
console.log('🟢 ContentSection - Click on content element, not setting section');
|
||||
}
|
||||
}
|
||||
|
||||
const handleItemClick = (index: number) => {
|
||||
const handleItemClick = (index: number, e: React.MouseEvent) => {
|
||||
console.log('🟢 ContentSection - handleItemClick called:', {
|
||||
index,
|
||||
event: e,
|
||||
target: e.target,
|
||||
currentTarget: e.currentTarget
|
||||
});
|
||||
|
||||
// Check if click is on a content element (text, button, image, social)
|
||||
const target = e.target as HTMLElement;
|
||||
const isClickOnContent = target.closest('[data-element-type]') !== null;
|
||||
|
||||
console.log('🟢 ContentSection - Click analysis:', {
|
||||
isClickOnContent,
|
||||
targetTagName: target.tagName,
|
||||
targetClasses: target.className,
|
||||
closestDataElement: target.closest('[data-element-type]')
|
||||
});
|
||||
|
||||
// Only select column if not clicking on content elements
|
||||
if (!isClickOnContent) {
|
||||
console.log('🟢 ContentSection - Selecting column:', {
|
||||
index,
|
||||
section: SectionName.CONTENT,
|
||||
currentActiveSection: activeSection,
|
||||
currentActiveItemIndex: activeItemIndex
|
||||
});
|
||||
|
||||
e.stopPropagation()
|
||||
|
||||
// Only set active section if we're not already in content section
|
||||
if (activeSection !== SectionName.CONTENT) {
|
||||
console.log('🟢 ContentSection - Switching to content section');
|
||||
setActiveSection(SectionName.CONTENT)
|
||||
}
|
||||
|
||||
// Always set the active item index
|
||||
console.log('🟢 ContentSection - Setting active item index to:', index);
|
||||
setActiveItemIndex(index)
|
||||
} else {
|
||||
console.log('🟢 ContentSection - Click on content element, not selecting column');
|
||||
}
|
||||
}
|
||||
|
||||
// تابعهای helper برای تعیین alignment دکمهها
|
||||
@@ -48,7 +118,7 @@ const ContentSection: FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<tr onClick={() => handleSectionClick(SectionName.CONTENT)}>
|
||||
<tr onClick={(e) => handleSectionClick(SectionName.CONTENT, e)}>
|
||||
<td style={{ paddingTop: '16px' }}>
|
||||
{
|
||||
data.content.columnsCount > 0 ?
|
||||
@@ -58,7 +128,8 @@ const ContentSection: FC = () => {
|
||||
<td style={{
|
||||
padding: '10px',
|
||||
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' }}>
|
||||
<tbody>
|
||||
@@ -71,11 +142,8 @@ const ContentSection: FC = () => {
|
||||
return (
|
||||
<>
|
||||
<td
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleItemClick(index);
|
||||
}}
|
||||
key={index}
|
||||
onClick={(e) => handleItemClick(index, e)}
|
||||
style={{
|
||||
width: `${100 / data.content.columnsCount}%`,
|
||||
height: '246px',
|
||||
@@ -86,7 +154,7 @@ const ContentSection: FC = () => {
|
||||
backgroundRepeat: item?.style?.backgroundRepeat || 'no-repeat',
|
||||
backgroundPosition: item?.style?.backgroundPosition || 'center',
|
||||
cursor: 'pointer',
|
||||
padding: '0',
|
||||
padding: '8px',
|
||||
verticalAlign: 'top',
|
||||
borderRadius: '8px',
|
||||
position: 'relative'
|
||||
@@ -97,6 +165,26 @@ const ContentSection: FC = () => {
|
||||
height: '246px'
|
||||
}}>
|
||||
<tbody>
|
||||
{/* اگر column خالی است */}
|
||||
{(!item?.texts || item.texts.length === 0) &&
|
||||
(!item?.buttons || item.buttons.length === 0) &&
|
||||
(!item?.images || item.images.length === 0) &&
|
||||
(!item?.socials || item.socials.length === 0) ? (
|
||||
<tr>
|
||||
<td
|
||||
width="100%"
|
||||
height="246"
|
||||
style={{
|
||||
padding: '8px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={(e) => handleItemClick(index, e)}
|
||||
>
|
||||
{/* محتوای خالی برای columns خالی */}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
<>
|
||||
{/* اگر فقط دکمه داریم و متن نداریم */}
|
||||
{(!item?.texts || item.texts.length === 0) && item?.buttons && item.buttons.length > 0 ? (
|
||||
<tr>
|
||||
@@ -109,7 +197,11 @@ const ContentSection: FC = () => {
|
||||
padding: '8px'
|
||||
}}
|
||||
>
|
||||
<ButtonRenderer buttons={item.buttons} />
|
||||
<ButtonRenderer
|
||||
buttons={item.buttons}
|
||||
itemId={item?.id || ''}
|
||||
sectionKey="content"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
@@ -128,8 +220,21 @@ const ContentSection: FC = () => {
|
||||
lineHeight: '1.4'
|
||||
}}
|
||||
>
|
||||
<TextRenderer texts={item?.texts || []} />
|
||||
<ImageRenderer images={item?.images || []} />
|
||||
<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>
|
||||
{/* ردیف دکمهها */}
|
||||
@@ -144,12 +249,18 @@ const ContentSection: FC = () => {
|
||||
padding: '0 8px 8px 8px'
|
||||
}}
|
||||
>
|
||||
<ButtonRenderer buttons={item.buttons} />
|
||||
<ButtonRenderer
|
||||
buttons={item.buttons}
|
||||
itemId={item?.id || ''}
|
||||
sectionKey="content"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
@@ -171,14 +282,18 @@ const ContentSection: FC = () => {
|
||||
<table width="100%" cellPadding="0" cellSpacing="0" border={0}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style={{
|
||||
<td
|
||||
onClick={(e) => handleSectionClick(SectionName.CONTENT, e)}
|
||||
style={{
|
||||
height: '286px',
|
||||
border: activeSection === SectionName.CONTENT ? '1px dashed #0038FF' : '1px dashed #E5E7EB',
|
||||
borderRadius: '24px',
|
||||
textAlign: 'center',
|
||||
verticalAlign: 'middle',
|
||||
padding: '10px'
|
||||
}}>
|
||||
padding: '10px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<div style={{ color: '#888', fontSize: '14px', marginBottom: '8px' }}>
|
||||
{t('setting.content_email')}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AddCircle } from 'iconsax-react'
|
||||
import TextRenderer from './TextRenderer'
|
||||
import ButtonRenderer from './ButtonRenderer'
|
||||
import ImageRenderer from './ImageRenderer'
|
||||
import SocialRenderer from './SocialRenderer'
|
||||
import { SectionName, VerticalAlignment, HorizontalAlignment } from '../types/Types'
|
||||
|
||||
const FooterSection: FC = () => {
|
||||
@@ -12,12 +13,81 @@ const FooterSection: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { activeSection, activeItemIndex, data, setActiveSection, setActiveItemIndex } = usePersonalityStore()
|
||||
|
||||
const handleSectionClick = (section: SectionName) => {
|
||||
console.log('🔴 FooterSection - Current state:', {
|
||||
activeSection,
|
||||
activeItemIndex,
|
||||
footerColumnsCount: data.footer.columnsCount,
|
||||
footerItemsLength: data.footer.items.length
|
||||
});
|
||||
|
||||
const handleSectionClick = (section: SectionName, e: React.MouseEvent) => {
|
||||
console.log('🔴 FooterSection - handleSectionClick called:', {
|
||||
section,
|
||||
event: e,
|
||||
target: e.target,
|
||||
currentTarget: e.currentTarget
|
||||
});
|
||||
|
||||
// اگر کلیک روی فضای خالی باشد (نه روی content elements)
|
||||
const target = e.target as HTMLElement;
|
||||
const isClickOnContent = target.closest('[data-element-type]') !== null;
|
||||
|
||||
console.log('🔴 FooterSection - Section click analysis:', {
|
||||
isClickOnContent,
|
||||
targetTagName: target.tagName,
|
||||
targetClasses: target.className
|
||||
});
|
||||
|
||||
if (!isClickOnContent) {
|
||||
console.log('🔴 FooterSection - Setting active section:', section);
|
||||
setActiveSection(section)
|
||||
} else {
|
||||
console.log('🔴 FooterSection - Click on content element, not setting section');
|
||||
}
|
||||
}
|
||||
|
||||
const handleItemClick = (index: number) => {
|
||||
const handleItemClick = (index: number, e: React.MouseEvent) => {
|
||||
console.log('🔴 FooterSection - handleItemClick called:', {
|
||||
index,
|
||||
event: e,
|
||||
target: e.target,
|
||||
currentTarget: e.currentTarget
|
||||
});
|
||||
|
||||
// Check if click is on a content element (text, button, image, social)
|
||||
const target = e.target as HTMLElement;
|
||||
const isClickOnContent = target.closest('[data-element-type]') !== null;
|
||||
|
||||
console.log('🔴 FooterSection - Click analysis:', {
|
||||
isClickOnContent,
|
||||
targetTagName: target.tagName,
|
||||
targetClasses: target.className,
|
||||
closestDataElement: target.closest('[data-element-type]')
|
||||
});
|
||||
|
||||
// Only select column if not clicking on content elements
|
||||
if (!isClickOnContent) {
|
||||
console.log('🔴 FooterSection - Selecting column:', {
|
||||
index,
|
||||
section: SectionName.FOOTER,
|
||||
currentActiveSection: activeSection,
|
||||
currentActiveItemIndex: activeItemIndex
|
||||
});
|
||||
|
||||
e.stopPropagation()
|
||||
|
||||
// Only set active section if we're not already in footer section
|
||||
if (activeSection !== SectionName.FOOTER) {
|
||||
console.log('🔴 FooterSection - Switching to footer section');
|
||||
setActiveSection(SectionName.FOOTER)
|
||||
}
|
||||
|
||||
// Always set the active item index
|
||||
console.log('🔴 FooterSection - Setting active item index to:', index);
|
||||
setActiveItemIndex(index)
|
||||
} else {
|
||||
console.log('🔴 FooterSection - Click on content element, not selecting column');
|
||||
}
|
||||
}
|
||||
|
||||
// تابعهای helper برای تعیین alignment دکمهها
|
||||
@@ -48,7 +118,7 @@ const FooterSection: FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<tr onClick={() => handleSectionClick(SectionName.FOOTER)}>
|
||||
<tr onClick={(e) => handleSectionClick(SectionName.FOOTER, e)}>
|
||||
<td style={{ paddingTop: '16px' }}>
|
||||
{
|
||||
data.footer.columnsCount > 0 ?
|
||||
@@ -58,7 +128,8 @@ const FooterSection: FC = () => {
|
||||
<td style={{
|
||||
padding: '10px',
|
||||
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' }}>
|
||||
<tbody>
|
||||
@@ -71,11 +142,8 @@ const FooterSection: FC = () => {
|
||||
return (
|
||||
<>
|
||||
<td
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleItemClick(index);
|
||||
}}
|
||||
key={index}
|
||||
onClick={(e) => handleItemClick(index, e)}
|
||||
style={{
|
||||
width: `${100 / data.footer.columnsCount}%`,
|
||||
height: '123px',
|
||||
@@ -86,7 +154,7 @@ const FooterSection: FC = () => {
|
||||
backgroundRepeat: item?.style?.backgroundRepeat || 'no-repeat',
|
||||
backgroundPosition: item?.style?.backgroundPosition || 'center',
|
||||
cursor: 'pointer',
|
||||
padding: '0',
|
||||
padding: '8px',
|
||||
verticalAlign: 'top',
|
||||
borderRadius: '8px',
|
||||
position: 'relative'
|
||||
@@ -97,6 +165,26 @@ const FooterSection: FC = () => {
|
||||
height: '123px'
|
||||
}}>
|
||||
<tbody>
|
||||
{/* اگر column خالی است */}
|
||||
{(!item?.texts || item.texts.length === 0) &&
|
||||
(!item?.buttons || item.buttons.length === 0) &&
|
||||
(!item?.images || item.images.length === 0) &&
|
||||
(!item?.socials || item.socials.length === 0) ? (
|
||||
<tr>
|
||||
<td
|
||||
width="100%"
|
||||
height="123"
|
||||
style={{
|
||||
padding: '8px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={(e) => handleItemClick(index, e)}
|
||||
>
|
||||
{/* محتوای خالی برای columns خالی */}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
<>
|
||||
{/* اگر فقط دکمه داریم و متن نداریم */}
|
||||
{(!item?.texts || item.texts.length === 0) && item?.buttons && item.buttons.length > 0 ? (
|
||||
<tr>
|
||||
@@ -109,7 +197,11 @@ const FooterSection: FC = () => {
|
||||
padding: '8px'
|
||||
}}
|
||||
>
|
||||
<ButtonRenderer buttons={item.buttons} />
|
||||
<ButtonRenderer
|
||||
buttons={item.buttons}
|
||||
itemId={item?.id || ''}
|
||||
sectionKey="footer"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
@@ -128,8 +220,21 @@ const FooterSection: FC = () => {
|
||||
lineHeight: '1.4'
|
||||
}}
|
||||
>
|
||||
<TextRenderer texts={item?.texts || []} />
|
||||
<ImageRenderer images={item?.images || []} />
|
||||
<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>
|
||||
{/* ردیف دکمهها */}
|
||||
@@ -144,12 +249,18 @@ const FooterSection: FC = () => {
|
||||
padding: '0 8px 8px 8px'
|
||||
}}
|
||||
>
|
||||
<ButtonRenderer buttons={item.buttons} />
|
||||
<ButtonRenderer
|
||||
buttons={item.buttons}
|
||||
itemId={item?.id || ''}
|
||||
sectionKey="footer"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
@@ -171,14 +282,18 @@ const FooterSection: FC = () => {
|
||||
<table width="100%" cellPadding="0" cellSpacing="0" border={0}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style={{
|
||||
<td
|
||||
onClick={(e) => handleSectionClick(SectionName.FOOTER, e)}
|
||||
style={{
|
||||
height: '123px',
|
||||
border: activeSection === SectionName.FOOTER ? '1px dashed #0038FF' : '1px dashed #E5E7EB',
|
||||
borderRadius: '24px',
|
||||
textAlign: 'center',
|
||||
verticalAlign: 'middle',
|
||||
padding: '10px'
|
||||
}}>
|
||||
padding: '10px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<div style={{ color: '#888', fontSize: '14px', marginBottom: '8px' }}>
|
||||
{t('setting.footer_email')}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AddCircle } from 'iconsax-react'
|
||||
import TextRenderer from './TextRenderer'
|
||||
import ButtonRenderer from './ButtonRenderer'
|
||||
import ImageRenderer from './ImageRenderer'
|
||||
import SocialRenderer from './SocialRenderer'
|
||||
import { SectionName, VerticalAlignment, HorizontalAlignment } from '../types/Types'
|
||||
|
||||
const HeaderSection: FC = () => {
|
||||
@@ -12,12 +13,81 @@ const HeaderSection: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { activeSection, activeItemIndex, data, setActiveSection, setActiveItemIndex } = usePersonalityStore()
|
||||
|
||||
const handleSectionClick = (section: SectionName) => {
|
||||
console.log('🔵 HeaderSection - Current state:', {
|
||||
activeSection,
|
||||
activeItemIndex,
|
||||
headerColumnsCount: data.header.columnsCount,
|
||||
headerItemsLength: data.header.items.length
|
||||
});
|
||||
|
||||
const handleSectionClick = (section: SectionName, e: React.MouseEvent) => {
|
||||
console.log('🔵 HeaderSection - handleSectionClick called:', {
|
||||
section,
|
||||
event: e,
|
||||
target: e.target,
|
||||
currentTarget: e.currentTarget
|
||||
});
|
||||
|
||||
// اگر کلیک روی فضای خالی باشد (نه روی content elements)
|
||||
const target = e.target as HTMLElement;
|
||||
const isClickOnContent = target.closest('[data-element-type]') !== null;
|
||||
|
||||
console.log('🔵 HeaderSection - Section click analysis:', {
|
||||
isClickOnContent,
|
||||
targetTagName: target.tagName,
|
||||
targetClasses: target.className
|
||||
});
|
||||
|
||||
if (!isClickOnContent) {
|
||||
console.log('🔵 HeaderSection - Setting active section:', section);
|
||||
setActiveSection(section)
|
||||
} else {
|
||||
console.log('🔵 HeaderSection - Click on content element, not setting section');
|
||||
}
|
||||
}
|
||||
|
||||
const handleItemClick = (index: number) => {
|
||||
const handleItemClick = (index: number, e: React.MouseEvent) => {
|
||||
console.log('🔵 HeaderSection - handleItemClick called:', {
|
||||
index,
|
||||
event: e,
|
||||
target: e.target,
|
||||
currentTarget: e.currentTarget
|
||||
});
|
||||
|
||||
// Check if click is on a content element (text, button, image, social)
|
||||
const target = e.target as HTMLElement;
|
||||
const isClickOnContent = target.closest('[data-element-type]') !== null;
|
||||
|
||||
console.log('🔵 HeaderSection - Click analysis:', {
|
||||
isClickOnContent,
|
||||
targetTagName: target.tagName,
|
||||
targetClasses: target.className,
|
||||
closestDataElement: target.closest('[data-element-type]')
|
||||
});
|
||||
|
||||
// Only select column if not clicking on content elements
|
||||
if (!isClickOnContent) {
|
||||
console.log('🔵 HeaderSection - Selecting column:', {
|
||||
index,
|
||||
section: SectionName.HEADER,
|
||||
currentActiveSection: activeSection,
|
||||
currentActiveItemIndex: activeItemIndex
|
||||
});
|
||||
|
||||
e.stopPropagation()
|
||||
|
||||
// Only set active section if we're not already in header section
|
||||
if (activeSection !== SectionName.HEADER) {
|
||||
console.log('🔵 HeaderSection - Switching to header section');
|
||||
setActiveSection(SectionName.HEADER)
|
||||
}
|
||||
|
||||
// Always set the active item index
|
||||
console.log('🔵 HeaderSection - Setting active item index to:', index);
|
||||
setActiveItemIndex(index)
|
||||
} else {
|
||||
console.log('🔵 HeaderSection - Click on content element, not selecting column');
|
||||
}
|
||||
}
|
||||
|
||||
// تابعهای helper برای تعیین alignment دکمهها
|
||||
@@ -48,13 +118,14 @@ const HeaderSection: FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<tr onClick={() => handleSectionClick(SectionName.HEADER)}>
|
||||
<tr onClick={(e) => handleSectionClick(SectionName.HEADER, e)}>
|
||||
{
|
||||
data.header.columnsCount > 0 ?
|
||||
<td style={{
|
||||
padding: '10px',
|
||||
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' }}>
|
||||
<tbody>
|
||||
@@ -67,11 +138,8 @@ const HeaderSection: FC = () => {
|
||||
return (
|
||||
<>
|
||||
<td
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleItemClick(index);
|
||||
}}
|
||||
key={index}
|
||||
onClick={(e) => handleItemClick(index, e)}
|
||||
style={{
|
||||
width: `${100 / data.header.columnsCount}%`,
|
||||
height: '123px',
|
||||
@@ -82,7 +150,7 @@ const HeaderSection: FC = () => {
|
||||
backgroundRepeat: item?.style?.backgroundRepeat || 'no-repeat',
|
||||
backgroundPosition: item?.style?.backgroundPosition || 'center',
|
||||
cursor: 'pointer',
|
||||
padding: '0',
|
||||
padding: '8px',
|
||||
verticalAlign: 'top',
|
||||
borderRadius: '8px',
|
||||
position: 'relative'
|
||||
@@ -93,6 +161,26 @@ const HeaderSection: FC = () => {
|
||||
height: '123px'
|
||||
}}>
|
||||
<tbody>
|
||||
{/* اگر column خالی است */}
|
||||
{(!item?.texts || item.texts.length === 0) &&
|
||||
(!item?.buttons || item.buttons.length === 0) &&
|
||||
(!item?.images || item.images.length === 0) &&
|
||||
(!item?.socials || item.socials.length === 0) ? (
|
||||
<tr>
|
||||
<td
|
||||
width="100%"
|
||||
height="123"
|
||||
style={{
|
||||
padding: '8px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={(e) => handleItemClick(index, e)}
|
||||
>
|
||||
{/* محتوای خالی برای columns خالی */}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
<>
|
||||
{/* اگر فقط دکمه داریم و متن نداریم */}
|
||||
{(!item?.texts || item.texts.length === 0) && item?.buttons && item.buttons.length > 0 ? (
|
||||
<tr>
|
||||
@@ -105,7 +193,11 @@ const HeaderSection: FC = () => {
|
||||
padding: '8px'
|
||||
}}
|
||||
>
|
||||
<ButtonRenderer buttons={item.buttons} />
|
||||
<ButtonRenderer
|
||||
buttons={item.buttons}
|
||||
itemId={item?.id || ''}
|
||||
sectionKey="header"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
@@ -124,8 +216,21 @@ const HeaderSection: FC = () => {
|
||||
lineHeight: '1.4'
|
||||
}}
|
||||
>
|
||||
<TextRenderer texts={item?.texts || []} />
|
||||
<ImageRenderer images={item?.images || []} />
|
||||
<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>
|
||||
{/* ردیف دکمهها */}
|
||||
@@ -140,12 +245,18 @@ const HeaderSection: FC = () => {
|
||||
padding: '0 8px 8px 8px'
|
||||
}}
|
||||
>
|
||||
<ButtonRenderer buttons={item.buttons} />
|
||||
<ButtonRenderer
|
||||
buttons={item.buttons}
|
||||
itemId={item?.id || ''}
|
||||
sectionKey="header"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
@@ -161,14 +272,18 @@ const HeaderSection: FC = () => {
|
||||
</table>
|
||||
</td>
|
||||
:
|
||||
<td style={{
|
||||
<td
|
||||
onClick={(e) => handleSectionClick(SectionName.HEADER, e)}
|
||||
style={{
|
||||
height: '123px',
|
||||
border: activeSection === SectionName.HEADER ? '1px dashed #0038FF' : '1px dashed #E5E7EB',
|
||||
borderRadius: '24px',
|
||||
textAlign: 'center',
|
||||
verticalAlign: 'middle',
|
||||
padding: '10px'
|
||||
}}>
|
||||
padding: '10px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<div style={{ color: '#888', fontSize: '14px', marginBottom: '8px' }}>
|
||||
{t('setting.header_email')}
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
import { FC } from 'react'
|
||||
import { ImageType, ImageSize, HorizontalAlignment, VerticalAlignment } from '../types/Types'
|
||||
import { ImageType, ImageSize, HorizontalAlignment, VerticalAlignment, ElementType, PersonalityDataType } from '../types/Types'
|
||||
import { usePersonalityStore } from '../store/Store'
|
||||
|
||||
interface ImageRendererProps {
|
||||
images: ImageType[]
|
||||
itemId: string
|
||||
sectionKey: keyof PersonalityDataType
|
||||
}
|
||||
|
||||
const ImageRenderer: FC<ImageRendererProps> = ({ images }) => {
|
||||
const ImageRenderer: FC<ImageRendererProps> = ({ images, itemId, sectionKey }) => {
|
||||
const { selectedElement, setSelectedElement } = usePersonalityStore()
|
||||
|
||||
if (!images || images.length === 0) return null
|
||||
|
||||
const handleImageClick = (e: React.MouseEvent, imageId: string) => {
|
||||
// جلوگیری از propagation به parent handlers
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
console.log('Image clicked:', imageId)
|
||||
setSelectedElement({
|
||||
type: ElementType.IMAGE,
|
||||
elementId: imageId,
|
||||
itemId,
|
||||
sectionKey,
|
||||
})
|
||||
}
|
||||
|
||||
// تابعهای helper برای تبدیل enum به string
|
||||
const getHorizontalAlign = (alignment?: HorizontalAlignment) => {
|
||||
switch (alignment) {
|
||||
@@ -37,7 +55,8 @@ const ImageRenderer: FC<ImageRendererProps> = ({ images }) => {
|
||||
return {
|
||||
width: image.width || '100%',
|
||||
height: image.height || 'auto',
|
||||
border: '0'
|
||||
border: '0',
|
||||
pointerEvents: 'none' as const // Prevent double click handling
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,19 +99,30 @@ const ImageRenderer: FC<ImageRendererProps> = ({ images }) => {
|
||||
}
|
||||
|
||||
const renderImage = (image: ImageType) => {
|
||||
if (shouldUseBackgroundImage(image.size)) {
|
||||
const imageWrapper = (
|
||||
<div
|
||||
data-element-type="image"
|
||||
onClick={(e) => handleImageClick(e, image.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
outline: selectedElement?.elementId === image.id ? '2px dashed #0038FF' : 'none',
|
||||
outlineOffset: '2px',
|
||||
borderRadius: '4px',
|
||||
padding: '2px',
|
||||
display: 'inline-block',
|
||||
minWidth: '20px',
|
||||
minHeight: '20px'
|
||||
}}
|
||||
>
|
||||
{shouldUseBackgroundImage(image.size) ? (
|
||||
// برای pattern های repeat از background استفاده میکنیم
|
||||
return (
|
||||
<div style={getBackgroundStyle(image)}>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
) : (
|
||||
// برای تصاویر عادی
|
||||
if (image.size === ImageSize.CONTAIN) {
|
||||
image.size === ImageSize.CONTAIN ? (
|
||||
// برای contain از table wrapper استفاده میکنیم تا نسبت حفظ شود
|
||||
return (
|
||||
<table width={image.width || '100%'} cellPadding="0" cellSpacing="0" border={0}>
|
||||
<tbody>
|
||||
<tr>
|
||||
@@ -102,17 +132,14 @@ const ImageRenderer: FC<ImageRendererProps> = ({ images }) => {
|
||||
alt={image.alt || 'تصویر آپلود شده'}
|
||||
width={image.width || '100%'}
|
||||
height={image.height || 'auto'}
|
||||
style={{ border: '0' }}
|
||||
style={getImageStyle(image)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
) : (
|
||||
// برای cover و auto
|
||||
return (
|
||||
<img
|
||||
src={image.src}
|
||||
alt={image.alt || 'تصویر آپلود شده'}
|
||||
@@ -121,6 +148,11 @@ const ImageRenderer: FC<ImageRendererProps> = ({ images }) => {
|
||||
style={getImageStyle(image)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return imageWrapper
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import Button from '@/components/Button'
|
||||
import Select from '@/components/Select'
|
||||
import UploadBoxDraggble from '@/components/UploadBoxDraggble'
|
||||
import { Add, AlignBottom, AlignHorizontally, AlignLeft, AlignRight, AlignTop, AlignVertically } from 'iconsax-react'
|
||||
import { FC, useState } from 'react'
|
||||
import { Add, AlignBottom, AlignHorizontally, AlignLeft, AlignRight, AlignTop, AlignVertically, Edit, Trash } from 'iconsax-react'
|
||||
import { FC, useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { usePersonalityStore } from '../store/Store'
|
||||
import { ImageSize, HorizontalAlignment, VerticalAlignment } from '../types/Types'
|
||||
import { ImageSize, HorizontalAlignment, VerticalAlignment, ElementType } from '../types/Types'
|
||||
|
||||
const ImageSideBar: FC = () => {
|
||||
|
||||
const { t } = useTranslation()
|
||||
const { addImageToActiveItem, activeSection, activeItemIndex, data } = usePersonalityStore()
|
||||
const {
|
||||
addImageToActiveItem,
|
||||
activeSection,
|
||||
activeItemIndex,
|
||||
data,
|
||||
selectedElement,
|
||||
setSelectedElement,
|
||||
updateImage,
|
||||
deleteImage
|
||||
} = usePersonalityStore()
|
||||
|
||||
const [imageSrc, setImageSrc] = useState<string>('')
|
||||
const [imageSize, setImageSize] = useState<ImageSize>(ImageSize.COVER)
|
||||
@@ -19,6 +28,54 @@ const ImageSideBar: FC = () => {
|
||||
const [isLoading, setIsLoading] = 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 = [
|
||||
{ label: 'کاور - تمام فضا را پوشش دهد (Cover)', value: ImageSize.COVER },
|
||||
{ label: 'در مقیاس - در فضا جا شود (Contain)', value: ImageSize.CONTAIN },
|
||||
@@ -104,9 +161,62 @@ const ImageSideBar: FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateImage = () => {
|
||||
if (selectedElement && imageSrc) {
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
updateImage(
|
||||
selectedElement.sectionKey,
|
||||
selectedElement.itemId,
|
||||
selectedElement.elementId,
|
||||
{
|
||||
src: imageSrc,
|
||||
alt: 'عکس آپلود شده',
|
||||
size: imageSize,
|
||||
alignment: horizontalAlignment,
|
||||
verticalAlignment,
|
||||
}
|
||||
);
|
||||
|
||||
// Exit edit mode
|
||||
setSelectedElement(null);
|
||||
} catch (error) {
|
||||
console.error('Error updating image:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteImage = () => {
|
||||
if (selectedElement) {
|
||||
deleteImage(
|
||||
selectedElement.sectionKey,
|
||||
selectedElement.itemId,
|
||||
selectedElement.elementId
|
||||
);
|
||||
|
||||
// Exit edit mode
|
||||
setSelectedElement(null);
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setSelectedElement(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<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'>
|
||||
<UploadBoxDraggble
|
||||
@@ -116,6 +226,13 @@ const ImageSideBar: FC = () => {
|
||||
/>
|
||||
</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'>
|
||||
<Select
|
||||
items={sizeOptions}
|
||||
@@ -175,9 +292,41 @@ const ImageSideBar: FC = () => {
|
||||
</div>
|
||||
|
||||
<div className='mt-7'>
|
||||
{isEditMode ? (
|
||||
<div className='space-y-3'>
|
||||
<Button
|
||||
onClick={handleUpdateImage}
|
||||
className='bg-[#0038FF] text-white w-full'
|
||||
disabled={!imageSrc}
|
||||
loading={isLoading}
|
||||
>
|
||||
<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>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleAddImage}
|
||||
className='bg-[#ECEEF5] text-black'
|
||||
className='bg-[#ECEEF5] text-black w-full'
|
||||
disabled={!imageSrc}
|
||||
loading={isLoading}
|
||||
>
|
||||
@@ -186,6 +335,7 @@ const ImageSideBar: FC = () => {
|
||||
<span>{t('setting.add_image')}</span>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</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,15 +1,58 @@
|
||||
import { FC } from 'react'
|
||||
import { TextType } from '../types/Types'
|
||||
import { TextType, ElementType, PersonalityDataType } from '../types/Types'
|
||||
import { usePersonalityStore } from '../store/Store'
|
||||
|
||||
interface TextRendererProps {
|
||||
texts: TextType[]
|
||||
itemId: string
|
||||
sectionKey: keyof PersonalityDataType
|
||||
}
|
||||
|
||||
const TextRenderer: FC<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,
|
||||
})
|
||||
}
|
||||
|
||||
const TextRenderer: FC<TextRendererProps> = ({ texts }) => {
|
||||
return (
|
||||
<>
|
||||
{texts?.map((textItem, textIndex) => (
|
||||
<table key={textItem.id} width="100%" cellPadding="0" cellSpacing="0" border={0} style={{ borderCollapse: 'collapse' }}>
|
||||
<div
|
||||
key={textItem.id}
|
||||
data-element-type="text"
|
||||
onClick={(e) => handleTextClick(e, textItem.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
border: selectedElement?.elementId === textItem.id ? '2px dashed #0038FF' : '2px dashed transparent',
|
||||
borderRadius: '4px',
|
||||
padding: '2px',
|
||||
margin: '2px 0',
|
||||
transition: 'border-color 0.2s ease',
|
||||
minHeight: '20px',
|
||||
display: 'inline-block',
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
<table width="100%" cellPadding="0" cellSpacing="0" border={0} style={{ borderCollapse: 'collapse' }}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
@@ -18,13 +61,15 @@ const TextRenderer: FC<TextRendererProps> = ({ texts }) => {
|
||||
color: textItem.color || '#000000',
|
||||
fontSize: `${textItem.fontSize || 14}px`,
|
||||
lineHeight: '1.4',
|
||||
paddingBottom: textIndex < (texts?.length || 0) - 1 ? '4px' : '0'
|
||||
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 { Add, AlignBottom, AlignHorizontally, AlignLeft, AlignRight, AlignTop, AlignVertically } from 'iconsax-react';
|
||||
import { FC, useState } from 'react'
|
||||
import { Add, AlignBottom, AlignHorizontally, AlignLeft, AlignRight, AlignTop, AlignVertically, Edit, Trash } from 'iconsax-react';
|
||||
import { FC, useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ReactQuill from 'react-quill-new';
|
||||
import { usePersonalityStore } from '../store/Store';
|
||||
import ColorPicker from '@/components/ColorPicker';
|
||||
import { HorizontalAlignment, VerticalAlignment } from '../types/Types';
|
||||
import { HorizontalAlignment, VerticalAlignment, ElementType } from '../types/Types';
|
||||
|
||||
const TextSidebar: FC = () => {
|
||||
|
||||
@@ -14,7 +14,63 @@ const TextSidebar: FC = () => {
|
||||
const [color, setColor] = useState('#000');
|
||||
const [horizontalPosition, setHorizontalPosition] = useState<HorizontalAlignment>(HorizontalAlignment.CENTER);
|
||||
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) => {
|
||||
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 (
|
||||
<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'>
|
||||
<ReactQuill
|
||||
@@ -77,16 +177,19 @@ const TextSidebar: FC = () => {
|
||||
<div className='mt-3 flex gap-4'>
|
||||
<div
|
||||
onClick={() => setHorizontalPosition(HorizontalAlignment.RIGHT)}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<AlignRight size={20} color={horizontalPosition === HorizontalAlignment.RIGHT ? '#0038FF' : '#000'} />
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setHorizontalPosition(HorizontalAlignment.CENTER)}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<AlignVertically size={20} color={horizontalPosition === HorizontalAlignment.CENTER ? '#0038FF' : '#000'} />
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setHorizontalPosition(HorizontalAlignment.LEFT)}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<AlignLeft size={20} color={horizontalPosition === HorizontalAlignment.LEFT ? '#0038FF' : '#000'} />
|
||||
</div>
|
||||
@@ -100,16 +203,19 @@ const TextSidebar: FC = () => {
|
||||
<div className='mt-3 flex gap-4'>
|
||||
<div
|
||||
onClick={() => setVerticalPosition(VerticalAlignment.TOP)}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<AlignTop size={20} color={verticalPosition === VerticalAlignment.TOP ? '#0038FF' : '#000'} />
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setVerticalPosition(VerticalAlignment.MIDDLE)}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<AlignHorizontally size={20} color={verticalPosition === VerticalAlignment.MIDDLE ? '#0038FF' : '#000'} />
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setVerticalPosition(VerticalAlignment.BOTTOM)}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<AlignBottom size={20} color={verticalPosition === VerticalAlignment.BOTTOM ? '#0038FF' : '#000'} />
|
||||
</div>
|
||||
@@ -118,15 +224,48 @@ const TextSidebar: FC = () => {
|
||||
</div>
|
||||
|
||||
<div className='mt-7'>
|
||||
{isEditMode ? (
|
||||
<div className='space-y-3'>
|
||||
<Button
|
||||
onClick={handleUpdateText}
|
||||
className='bg-[#0038FF] text-white w-full'
|
||||
disabled={!value.trim()}
|
||||
>
|
||||
<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>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleAddText}
|
||||
className='bg-[#ECEEF5] text-black'
|
||||
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>
|
||||
|
||||
@@ -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,
|
||||
ButtonType,
|
||||
ImageType,
|
||||
SocialType,
|
||||
SectionName,
|
||||
HorizontalAlignment,
|
||||
VerticalAlignment,
|
||||
SelectedElement,
|
||||
} from "../types/Types";
|
||||
|
||||
export const usePersonalityStore = create<PersonalityStore>((set) => ({
|
||||
@@ -21,11 +23,45 @@ export const usePersonalityStore = create<PersonalityStore>((set) => ({
|
||||
|
||||
activeSection: "",
|
||||
activeItemIndex: 0,
|
||||
selectedElement: null,
|
||||
isEditMode: false,
|
||||
|
||||
setActiveSection: (section: SectionName) =>
|
||||
set({ activeSection: section, activeItemIndex: 0 }),
|
||||
set((state) => {
|
||||
console.log("🏪 Store - setActiveSection called:", {
|
||||
newSection: section,
|
||||
oldSection: state.activeSection,
|
||||
oldItemIndex: state.activeItemIndex,
|
||||
newItemIndex: 0,
|
||||
});
|
||||
|
||||
setActiveItemIndex: (index: number) => set({ activeItemIndex: index }),
|
||||
return {
|
||||
activeSection: section,
|
||||
activeItemIndex: 0,
|
||||
selectedElement: null,
|
||||
isEditMode: false,
|
||||
};
|
||||
}),
|
||||
|
||||
setActiveItemIndex: (index: number) =>
|
||||
set((state) => {
|
||||
console.log("🏪 Store - setActiveItemIndex called:", {
|
||||
newIndex: index,
|
||||
oldIndex: state.activeItemIndex,
|
||||
currentSection: state.activeSection,
|
||||
});
|
||||
|
||||
return {
|
||||
activeItemIndex: index,
|
||||
selectedElement: null,
|
||||
isEditMode: false,
|
||||
};
|
||||
}),
|
||||
|
||||
setSelectedElement: (element: SelectedElement) =>
|
||||
set({ selectedElement: element, isEditMode: !!element }),
|
||||
|
||||
setEditMode: (isEdit: boolean) => set({ isEditMode: isEdit }),
|
||||
|
||||
setColumnsCount: (
|
||||
sectionKey: keyof PersonalityDataType,
|
||||
@@ -42,6 +78,7 @@ export const usePersonalityStore = create<PersonalityStore>((set) => ({
|
||||
texts: [],
|
||||
buttons: [],
|
||||
images: [],
|
||||
socials: [],
|
||||
alignment: HorizontalAlignment.CENTER,
|
||||
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",
|
||||
}
|
||||
|
||||
// New enums for element selection
|
||||
export enum ElementType {
|
||||
TEXT = "text",
|
||||
BUTTON = "button",
|
||||
IMAGE = "image",
|
||||
SOCIAL = "social",
|
||||
}
|
||||
|
||||
// Social network types
|
||||
export enum SocialNetworkType {
|
||||
FACEBOOK = "facebook",
|
||||
TWITTER = "twitter",
|
||||
INSTAGRAM = "instagram",
|
||||
LINKEDIN = "linkedin",
|
||||
YOUTUBE = "youtube",
|
||||
TELEGRAM = "telegram",
|
||||
WHATSAPP = "whatsapp",
|
||||
TIKTOK = "tiktok",
|
||||
PINTEREST = "pinterest",
|
||||
SNAPCHAT = "snapchat",
|
||||
GITHUB = "github",
|
||||
DISCORD = "discord",
|
||||
}
|
||||
|
||||
export enum SocialIconSize {
|
||||
SMALL = "small",
|
||||
MEDIUM = "medium",
|
||||
LARGE = "large",
|
||||
}
|
||||
|
||||
export enum SocialIconStyle {
|
||||
CIRCLE = "circle",
|
||||
SQUARE = "square",
|
||||
ROUNDED = "rounded",
|
||||
}
|
||||
|
||||
// New type for selected element
|
||||
export type SelectedElement = {
|
||||
type: ElementType;
|
||||
elementId: string;
|
||||
itemId: string;
|
||||
sectionKey: keyof PersonalityDataType;
|
||||
} | null;
|
||||
|
||||
export type PersonalityDataType = {
|
||||
header: SectionType;
|
||||
content: SectionType;
|
||||
@@ -57,6 +101,7 @@ export type SectionItemType = {
|
||||
texts: TextType[];
|
||||
buttons: ButtonType[];
|
||||
images: ImageType[];
|
||||
socials: SocialType[];
|
||||
alignment?: HorizontalAlignment;
|
||||
verticalAlignment?: VerticalAlignment;
|
||||
style?: {
|
||||
@@ -104,13 +149,29 @@ export type ImageType = {
|
||||
verticalAlignment?: VerticalAlignment;
|
||||
};
|
||||
|
||||
export type SocialType = {
|
||||
id: string;
|
||||
networkType: SocialNetworkType;
|
||||
link: string;
|
||||
size: SocialIconSize;
|
||||
style: SocialIconStyle;
|
||||
color?: string;
|
||||
backgroundColor?: string;
|
||||
alignment?: HorizontalAlignment;
|
||||
verticalAlignment?: VerticalAlignment;
|
||||
};
|
||||
|
||||
export type PersonalityStore = {
|
||||
data: PersonalityDataType;
|
||||
activeSection: SectionName | "";
|
||||
activeItemIndex: number;
|
||||
selectedElement: SelectedElement; // New field for element selection
|
||||
isEditMode: boolean; // New field for edit mode
|
||||
|
||||
setActiveSection: (section: SectionName) => void;
|
||||
setActiveItemIndex: (index: number) => void;
|
||||
setSelectedElement: (element: SelectedElement) => void; // New method
|
||||
setEditMode: (isEdit: boolean) => void; // New method
|
||||
|
||||
setItems: (
|
||||
sectionKey: keyof PersonalityDataType,
|
||||
@@ -167,4 +228,93 @@ export type PersonalityStore = {
|
||||
imageId: string,
|
||||
updates: Partial<ImageType>
|
||||
) => 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 Setting from '@/pages/setting/Setting'
|
||||
import Home from '@/pages/home/Home'
|
||||
import List from '@/pages/setting/personality/List'
|
||||
import EmailBuilder from '@/pages/email-builder/EmailBuilder'
|
||||
const AppRouter: FC = () => {
|
||||
return (
|
||||
<Routes>
|
||||
@@ -13,6 +15,8 @@ const AppRouter: FC = () => {
|
||||
<Route path={Paths.settingAddress} element={<Setting />} />
|
||||
<Route path={Paths.settingPersonality} element={<Setting />} />
|
||||
<Route path={Paths.settingSignature} element={<Setting />} />
|
||||
<Route path={Paths.settingPersonalityList} element={<List />} />
|
||||
<Route path={Paths.emailBuilder} element={<EmailBuilder />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
+10
-3
@@ -62,12 +62,19 @@ const SideBar: FC = () => {
|
||||
/>
|
||||
|
||||
<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')}
|
||||
isActive={isActive(Paths.settingPersonality)}
|
||||
link={Paths.settingPersonality}
|
||||
isActive={isActive(Paths.settingPersonalityList)}
|
||||
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
|
||||
icon={<PenClose variant={isActive(Paths.settingSignature) ? 'Bold' : 'Outline'} color={isActive(Paths.settingSignature) ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
|
||||
title={t('setting.signature')}
|
||||
|
||||
@@ -12,6 +12,8 @@ export const Paths = {
|
||||
settingDomain: "/setting/domain",
|
||||
settingAddress: "/setting/address",
|
||||
settingPersonality: "/setting/personality",
|
||||
settingPersonalityList: "/setting/personality/list",
|
||||
settingSignature: "/setting/signature",
|
||||
emailBuilder: "/email-builder",
|
||||
detailEmail: "/mail/",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user