auto select and edit

This commit is contained in:
hamid zarghami
2025-07-15 12:40:39 +03:30
parent 66b1dc15d8
commit 558d03533b
38 changed files with 5269 additions and 296 deletions
+2 -2
View File
@@ -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'
+303
View File
@@ -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
View File
@@ -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": "منو"
},
+265
View File
@@ -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,
}),
}));
+145
View File
@@ -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;
">
&nbsp;
</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);
};
+64 -1
View File
@@ -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()}
+1
View File
@@ -11,5 +11,6 @@ export enum SideBarTab {
TEXT = "text",
BUTTON = "button",
IMAGE = "image",
SOCIAL = "social",
NONE = "none",
}
+54
View File
@@ -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
+71 -5
View File
@@ -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)}>
&nbsp;
</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 }) => {
const TextRenderer: FC<TextRendererProps> = ({ texts, itemId, sectionKey }) => {
const { selectedElement, setSelectedElement } = usePersonalityStore()
const handleTextClick = (e: React.MouseEvent, textId: string) => {
console.log('📝 TextRenderer - handleTextClick called:', {
textId,
itemId,
sectionKey,
event: e,
target: e.target,
currentTarget: e.currentTarget
});
// جلوگیری از propagation به parent handlers
e.stopPropagation()
e.preventDefault()
setSelectedElement({
type: ElementType.TEXT,
elementId: textId,
itemId,
sectionKey,
})
}
return (
<>
{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;
};
+216 -2
View File
@@ -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)}">&nbsp;</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;
}
};
+4
View File
@@ -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
View File
@@ -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')}
+2
View File
@@ -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/",
};