From 66b3b9fa032a67d7f69f8dacfcb1ac1e7db3839b Mon Sep 17 00:00:00 2001 From: dwindown Date: Thu, 13 Nov 2025 08:03:35 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20Add=20Heading=20Selector,=20Styled=20Bu?= =?UTF-8?q?ttons=20&=20Variable=20Pills!=20=F0=9F=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ✅ 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! 🚀 --- admin-spa/DEPENDENCIES.md | 58 ++++ admin-spa/package-lock.json | 300 +++++++++++++++++- admin-spa/package.json | 6 + .../components/EmailBuilder/EmailBuilder.tsx | 13 + .../src/components/ui/rich-text-editor.tsx | 132 ++++++++ .../components/ui/tiptap-button-extension.ts | 105 ++++++ 6 files changed, 607 insertions(+), 7 deletions(-) create mode 100644 admin-spa/DEPENDENCIES.md create mode 100644 admin-spa/src/components/ui/tiptap-button-extension.ts diff --git a/admin-spa/DEPENDENCIES.md b/admin-spa/DEPENDENCIES.md new file mode 100644 index 0000000..b17bfdd --- /dev/null +++ b/admin-spa/DEPENDENCIES.md @@ -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! diff --git a/admin-spa/package-lock.json b/admin-spa/package-lock.json index 3191478..7c29f73 100644 --- a/admin-spa/package-lock.json +++ b/admin-spa/package-lock.json @@ -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", diff --git a/admin-spa/package.json b/admin-spa/package.json index d656a6b..c9c99fb 100644 --- a/admin-spa/package.json +++ b/admin-spa/package.json @@ -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", diff --git a/admin-spa/src/components/EmailBuilder/EmailBuilder.tsx b/admin-spa/src/components/EmailBuilder/EmailBuilder.tsx index bf8e6cc..38bb184 100644 --- a/admin-spa/src/components/EmailBuilder/EmailBuilder.tsx +++ b/admin-spa/src/components/EmailBuilder/EmailBuilder.tsx @@ -238,6 +238,19 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP onChange={(e) => setEditingButtonLink(e.target.value)} placeholder="{order_url}" /> + {variables.length > 0 && ( +
+ {variables.map((variable) => ( + setEditingButtonLink(editingButtonLink + `{${variable}}`)} + > + {`{${variable}}`} + + ))} +
+ )}
diff --git a/admin-spa/src/components/ui/rich-text-editor.tsx b/admin-spa/src/components/ui/rich-text-editor.tsx index e9a71c9..72d4620 100644 --- a/admin-spa/src/components/ui/rich-text-editor.tsx +++ b/admin-spa/src/components/ui/rich-text-editor.tsx @@ -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 (
{/* Toolbar */}
+ {/* Heading Selector */} + +
+
)} + + {/* Button Dialog */} + + + + {__('Insert Button')} + + {__('Add a styled button to your content. Use variables for dynamic links.')} + + + +
+
+ + setButtonText(e.target.value)} + placeholder={__('e.g., View Order')} + /> +
+ +
+ + setButtonHref(e.target.value)} + placeholder="{order_url}" + /> + {variables.length > 0 && ( +
+ {variables.map((variable) => ( + setButtonHref(buttonHref + `{${variable}}`)} + > + {`{${variable}}`} + + ))} +
+ )} +
+ +
+ + +
+
+ + + + + +
+
); } diff --git a/admin-spa/src/components/ui/tiptap-button-extension.ts b/admin-spa/src/components/ui/tiptap-button-extension.ts new file mode 100644 index 0000000..14e076b --- /dev/null +++ b/admin-spa/src/components/ui/tiptap-button-extension.ts @@ -0,0 +1,105 @@ +import { Node, mergeAttributes } from '@tiptap/core'; + +export interface ButtonOptions { + HTMLAttributes: Record; +} + +declare module '@tiptap/core' { + interface Commands { + button: { + setButton: (options: { text: string; href: string; style?: 'solid' | 'outline' }) => ReturnType; + }; + } +} + +export const ButtonExtension = Node.create({ + 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 = 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, + }); + }, + }; + }, +});