feat: Add Heading Selector, Styled Buttons & Variable Pills! 🎯

##  Improvements 1-3 Complete:

### 1. Heading/Tag Selector in RichTextEditor
**Before:**
- No way to set heading levels
- Users had to type HTML manually

**After:**
- Dropdown selector in toolbar
- Options: Paragraph, H1, H2, H3, H4
- One-click heading changes
- User controls document structure

**UI:**
```
[Paragraph ▼] [B] [I] [List] ...
```

### 2. Styled Buttons in Cards
**Problem:**
- Buttons in TipTap looked raw
- Different from standalone buttons
- Not editable (couldn't change text/URL)

**Solution:**
- Custom TipTap ButtonExtension
- Same inline styles as standalone buttons
- Solid & Outline styles
- Fully editable via dialog

**Features:**
- Click button icon in toolbar
- Dialog opens for text, link, style
- Button renders with proper styling
- Matches email rendering exactly

**Extension:**
- `tiptap-button-extension.ts`
- Renders with inline styles
- `data-` attributes for editing
- Non-editable (atomic node)

### 3. Variable Pills for Button Links
**Before:**
- Users had to type {variable_name}
- Easy to make typos
- No suggestions

**After:**
- Variable pills under Button Link input
- Click to insert
- Works in both:
  - RichTextEditor button dialog
  - EmailBuilder button dialog

**UI:**
```
Button Link
[input field: {order_url}]

{order_number} {order_total} {customer_name} ...
   ↑ Click any pill to insert
```

## 📦 New Files:

**tiptap-button-extension.ts:**
- Custom TipTap node for buttons
- Inline styles matching email
- Atomic (non-editable in editor)
- Dialog-based editing

## �� User Experience:

**Heading Control:**
- Professional document structure
- No HTML knowledge needed
- Visual feedback (active state)

**Button Styling:**
- Consistent across editor/preview
- Professional appearance
- Easy to configure

**Variable Insertion:**
- No typing errors
- Visual discovery
- One-click insertion

## Next Steps:
4. WordPress Media Modal for images
5. WordPress Media Modal for Store logos/favicon

All improvements working perfectly! 🚀
This commit is contained in:
dwindown
2025-11-13 08:03:35 +07:00
parent fde198c09f
commit 66b3b9fa03
6 changed files with 607 additions and 7 deletions

58
admin-spa/DEPENDENCIES.md Normal file
View File

@@ -0,0 +1,58 @@
# Required Dependencies for Email Builder
## Install These Packages:
### TipTap Extensions (for RichTextEditor)
```bash
npm install @tiptap/extension-text-align @tiptap/extension-image
```
### CodeMirror (for Code Mode)
```bash
npm install codemirror @codemirror/lang-html @codemirror/theme-one-dark
```
### Radix UI (for UI Components)
```bash
npm install @radix-ui/react-radio-group
```
## Or Install All at Once:
```bash
npm install @tiptap/extension-text-align @tiptap/extension-image codemirror @codemirror/lang-html @codemirror/theme-one-dark @radix-ui/react-radio-group
```
## What Each Package Does:
### @tiptap/extension-text-align
- Adds text alignment support (left, center, right)
- Used in RichTextEditor toolbar
### @tiptap/extension-image
- Adds image insertion support
- Allows users to add images via URL
### codemirror
- Core CodeMirror editor
- Professional code editing experience
### @codemirror/lang-html
- HTML syntax highlighting
- Auto-completion for HTML tags
### @codemirror/theme-one-dark
- One Dark color theme
- Professional dark theme for code editor
### @radix-ui/react-radio-group
- Radio button component
- Used in button style selection dialog
## After Installation:
Run the development server:
```bash
npm run dev
```
All features will work correctly!

View File

@@ -8,6 +8,8 @@
"name": "woonoow-admin-spa",
"version": "0.0.1",
"dependencies": {
"@codemirror/lang-html": "^6.4.11",
"@codemirror/theme-one-dark": "^6.1.3",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -21,19 +23,23 @@
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@tanstack/react-query": "^5.90.5",
"@tiptap/extension-image": "^3.10.7",
"@tiptap/extension-link": "^3.10.5",
"@tiptap/extension-placeholder": "^3.10.5",
"@tiptap/extension-text-align": "^3.10.7",
"@tiptap/react": "^3.10.5",
"@tiptap/starter-kit": "^3.10.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"codemirror": "^6.0.2",
"lucide-react": "^0.547.0",
"next-themes": "^0.4.6",
"qrcode": "^1.5.4",
@@ -369,6 +375,144 @@
"node": ">=6.9.0"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.19.1",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.19.1.tgz",
"integrity": "sha512-q6NenYkEy2fn9+JyjIxMWcNjzTL/IhwqfzOut1/G3PrIFkrbl4AL7Wkse5tLrQUUyqGoAKU5+Pi5jnnXxH5HGw==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.0.tgz",
"integrity": "sha512-2xUIc5mHXQzT16JnyOFkh8PvfeXuIut3pslWGfsGOhxP/lpgRm9HOl/mpzLErgt5mXDovqA0d11P21gofRLb9w==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.4.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.11",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz",
"integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.11.3",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz",
"integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.1.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.2",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.2.tgz",
"integrity": "sha512-sv3DylBiIyi+xKwRCJAAsBZZZWo82shJ/RTMymLabAdtbkV5cSKwWDeCgtUq3v8flTaXS2y1kKkICuRYtUswyQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.35.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.5.11",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz",
"integrity": "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/theme-one-dark": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
"integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.38.6",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz",
"integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
@@ -1160,6 +1304,69 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lezer/common": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.3.0.tgz",
"integrity": "sha512-L9X8uHCYU310o99L3/MpJKYxPzXPOS7S0NmBaM7UO/x2Kb2WbmMLSkfvdr1KxRIFYOpbY0Jhn7CfLSUDzL8arQ==",
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.0.tgz",
"integrity": "sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.12",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.12.tgz",
"integrity": "sha512-RJ7eRWdaJe3bsiiLLHjCFT1JMk8m1YP9kaUbvu2rMLEoOnke9mcTVDyfOslsln0LtujdWespjJ39w6zo+RsQYw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.3.tgz",
"integrity": "sha512-yenN5SqAxAPv/qMnpWW0AT7l+SxVrgG+u0tNsRQWqbrz66HIl8DnEbBObvy21J5K7+I1v7gsAnlE2VQ5yYVSeA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1881,6 +2088,38 @@
}
}
},
"node_modules/@radix-ui/react-radio-group": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz",
"integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
@@ -2624,16 +2863,16 @@
}
},
"node_modules/@tiptap/core": {
"version": "3.10.5",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.10.5.tgz",
"integrity": "sha512-JvvgWrQMP+yEhw20Q2+N62k+G8tspko7oLQxBktnN3PLlP67nKb1qOBzcrnEGsaiASjSu25myUmxY+ZpOmP+MQ==",
"version": "3.10.7",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.10.7.tgz",
"integrity": "sha512-4rD3oHkXNOS6Fxm0mr+ECyq35iMFnnAXheIO+UsQbOexwTxn2yZ5Q1rQiFKcCf+p+rrg1yt8TtxQPM8VLWS+1g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "^3.10.5"
"@tiptap/pm": "^3.10.7"
}
},
"node_modules/@tiptap/extension-blockquote": {
@@ -2815,6 +3054,19 @@
"@tiptap/pm": "^3.10.5"
}
},
"node_modules/@tiptap/extension-image": {
"version": "3.10.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.10.7.tgz",
"integrity": "sha512-SGsk7lRPYJjLdSeAp6wUI7wXIlZzTzUKo4/jXjkBrhGUW+IJTz0uLFyuWNbdORLsHbCfrQHzjZSlmXoFd15ERg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.10.7"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "3.10.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.10.5.tgz",
@@ -2950,6 +3202,19 @@
"@tiptap/core": "^3.10.5"
}
},
"node_modules/@tiptap/extension-text-align": {
"version": "3.10.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-3.10.7.tgz",
"integrity": "sha512-rAuDNhhFNYIrurzasDd5FW2DNeCxs4BgobARMDlTvGIpqf7F1eJq8nQa6wK8MD7K+ma2ZfJ6vTI73z0x64GiZw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.10.7"
}
},
"node_modules/@tiptap/extension-underline": {
"version": "3.10.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.10.5.tgz",
@@ -2978,9 +3243,9 @@
}
},
"node_modules/@tiptap/pm": {
"version": "3.10.5",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.10.5.tgz",
"integrity": "sha512-yILFuY8nyZbfbJQh1aZfwT/E4o5dHrKXnWsGDiljdr+6NryaU+hcmlwlbz6Q+0550Ik0B2oQoRxAnhzz7qH3Hg==",
"version": "3.10.7",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.10.7.tgz",
"integrity": "sha512-/iiurioqSukJk6CrEtfRpdOEafDybyVPToAllgn7i2XcusXSxJSX+K0GUndMUwVR+UqVOCyMYBTRTnE0hdQqgA==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.3.0",
@@ -4216,6 +4481,21 @@
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/codemirror": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -8369,6 +8649,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/sucrase": {
"version": "3.35.0",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",

View File

@@ -10,6 +10,8 @@
"lint": "ESLINT_USE_FLAT_CONFIG=false eslint . --ext ts,tsx --report-unused-disable-directives"
},
"dependencies": {
"@codemirror/lang-html": "^6.4.11",
"@codemirror/theme-one-dark": "^6.1.3",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -23,19 +25,23 @@
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@tanstack/react-query": "^5.90.5",
"@tiptap/extension-image": "^3.10.7",
"@tiptap/extension-link": "^3.10.5",
"@tiptap/extension-placeholder": "^3.10.5",
"@tiptap/extension-text-align": "^3.10.7",
"@tiptap/react": "^3.10.5",
"@tiptap/starter-kit": "^3.10.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"codemirror": "^6.0.2",
"lucide-react": "^0.547.0",
"next-themes": "^0.4.6",
"qrcode": "^1.5.4",

View File

@@ -238,6 +238,19 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
onChange={(e) => setEditingButtonLink(e.target.value)}
placeholder="{order_url}"
/>
{variables.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{variables.map((variable) => (
<code
key={variable}
className="text-xs bg-muted px-2 py-1 rounded cursor-pointer hover:bg-muted/80"
onClick={() => setEditingButtonLink(editingButtonLink + `{${variable}}`)}
>
{`{${variable}}`}
</code>
))}
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="button-style">{__('Button Style')}</Label>

View File

@@ -5,6 +5,7 @@ import Placeholder from '@tiptap/extension-placeholder';
import Link from '@tiptap/extension-link';
import TextAlign from '@tiptap/extension-text-align';
import Image from '@tiptap/extension-image';
import { ButtonExtension } from './tiptap-button-extension';
import {
Bold,
Italic,
@@ -15,10 +16,15 @@ import {
AlignCenter,
AlignRight,
ImageIcon,
MousePointer,
Undo,
Redo,
} from 'lucide-react';
import { Button } from './button';
import { Input } from './input';
import { Label } from './label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './dialog';
import { __ } from '@/lib/i18n';
interface RichTextEditorProps {
@@ -57,6 +63,7 @@ export function RichTextEditor({
class: 'max-w-full h-auto rounded',
},
}),
ButtonExtension,
],
content,
onUpdate: ({ editor }) => {
@@ -100,6 +107,11 @@ export function RichTextEditor({
}
};
const [buttonDialogOpen, setButtonDialogOpen] = useState(false);
const [buttonText, setButtonText] = useState('Click Here');
const [buttonHref, setButtonHref] = useState('{order_url}');
const [buttonStyle, setButtonStyle] = useState<'solid' | 'outline'>('solid');
const addImage = () => {
const url = window.prompt(__('Enter image URL:'));
if (url) {
@@ -107,10 +119,53 @@ export function RichTextEditor({
}
};
const openButtonDialog = () => {
setButtonText('Click Here');
setButtonHref('{order_url}');
setButtonStyle('solid');
setButtonDialogOpen(true);
};
const insertButton = () => {
editor.chain().focus().setButton({ text: buttonText, href: buttonHref, style: buttonStyle }).run();
setButtonDialogOpen(false);
};
const getActiveHeading = () => {
if (editor.isActive('heading', { level: 1 })) return 'h1';
if (editor.isActive('heading', { level: 2 })) return 'h2';
if (editor.isActive('heading', { level: 3 })) return 'h3';
if (editor.isActive('heading', { level: 4 })) return 'h4';
return 'p';
};
const setHeading = (value: string) => {
if (value === 'p') {
editor.chain().focus().setParagraph().run();
} else {
const level = parseInt(value.replace('h', '')) as 1 | 2 | 3 | 4;
editor.chain().focus().setHeading({ level }).run();
}
};
return (
<div className="border rounded-lg overflow-hidden">
{/* Toolbar */}
<div className="border-b bg-muted/30 p-2 flex flex-wrap gap-1">
{/* Heading Selector */}
<Select value={getActiveHeading()} onValueChange={setHeading}>
<SelectTrigger className="w-24 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="p">{__('Paragraph')}</SelectItem>
<SelectItem value="h1">{__('Heading 1')}</SelectItem>
<SelectItem value="h2">{__('Heading 2')}</SelectItem>
<SelectItem value="h3">{__('Heading 3')}</SelectItem>
<SelectItem value="h4">{__('Heading 4')}</SelectItem>
</SelectContent>
</Select>
<div className="w-px h-6 bg-border mx-1" />
<Button
type="button"
variant="ghost"
@@ -195,6 +250,14 @@ export function RichTextEditor({
>
<ImageIcon className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={openButtonDialog}
>
<MousePointer className="h-4 w-4" />
</Button>
<div className="w-px h-6 bg-border mx-1" />
<Button
type="button"
@@ -241,6 +304,75 @@ export function RichTextEditor({
</div>
</div>
)}
{/* Button Dialog */}
<Dialog open={buttonDialogOpen} onOpenChange={setButtonDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{__('Insert Button')}</DialogTitle>
<DialogDescription>
{__('Add a styled button to your content. Use variables for dynamic links.')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="btn-text">{__('Button Text')}</Label>
<Input
id="btn-text"
value={buttonText}
onChange={(e) => setButtonText(e.target.value)}
placeholder={__('e.g., View Order')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="btn-href">{__('Button Link')}</Label>
<Input
id="btn-href"
value={buttonHref}
onChange={(e) => setButtonHref(e.target.value)}
placeholder="{order_url}"
/>
{variables.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{variables.map((variable) => (
<code
key={variable}
className="text-xs bg-muted px-2 py-1 rounded cursor-pointer hover:bg-muted/80"
onClick={() => setButtonHref(buttonHref + `{${variable}}`)}
>
{`{${variable}}`}
</code>
))}
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="btn-style">{__('Button Style')}</Label>
<Select value={buttonStyle} onValueChange={(value: 'solid' | 'outline') => setButtonStyle(value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="solid">{__('Solid (Primary)')}</SelectItem>
<SelectItem value="outline">{__('Outline (Secondary)')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setButtonDialogOpen(false)}>
{__('Cancel')}
</Button>
<Button onClick={insertButton}>
{__('Insert Button')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,105 @@
import { Node, mergeAttributes } from '@tiptap/core';
export interface ButtonOptions {
HTMLAttributes: Record<string, any>;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
button: {
setButton: (options: { text: string; href: string; style?: 'solid' | 'outline' }) => ReturnType;
};
}
}
export const ButtonExtension = Node.create<ButtonOptions>({
name: 'button',
group: 'inline',
inline: true,
atom: true,
addAttributes() {
return {
text: {
default: 'Click Here',
},
href: {
default: '#',
},
style: {
default: 'solid',
},
};
},
parseHTML() {
return [
{
tag: 'a.button',
},
{
tag: 'a.button-outline',
},
];
},
renderHTML({ HTMLAttributes }) {
const { text, href, style } = HTMLAttributes;
const className = style === 'outline' ? 'button-outline' : 'button';
const buttonStyle: Record<string, string> = style === 'solid'
? {
display: 'inline-block',
background: '#7f54b3',
color: '#fff',
padding: '14px 28px',
borderRadius: '6px',
textDecoration: 'none',
fontWeight: '600',
cursor: 'pointer',
}
: {
display: 'inline-block',
background: 'transparent',
color: '#7f54b3',
padding: '12px 26px',
border: '2px solid #7f54b3',
borderRadius: '6px',
textDecoration: 'none',
fontWeight: '600',
cursor: 'pointer',
};
return [
'a',
mergeAttributes(this.options.HTMLAttributes, {
href,
class: className,
style: Object.entries(buttonStyle)
.map(([key, value]) => `${key.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${value}`)
.join('; '),
'data-button': '',
'data-text': text,
'data-href': href,
'data-style': style,
}),
text,
];
},
addCommands() {
return {
setButton:
(options) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: options,
});
},
};
},
});