diff --git a/apps/web/package.json b/apps/web/package.json index 8697ee6..d28f368 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -52,5 +52,14 @@ "valibot": "^1.2.0", "vite": "^8.0.0" }, - "dependencies": {} + "dependencies": { + "@tiptap/core": "^3.22.2", + "@tiptap/extension-link": "^3.22.2", + "@tiptap/extension-placeholder": "^3.22.2", + "@tiptap/extension-text-align": "^3.22.2", + "@tiptap/extension-underline": "^3.22.2", + "@tiptap/pm": "^3.22.2", + "@tiptap/starter-kit": "^3.22.2", + "@tiptap/suggestion": "^3.22.2" + } } diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 77fa5e6..f9e81be 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -168,3 +168,168 @@ body { @apply transition-all duration-100 ease-out; } } + +/* ─────────────────────────────────────────────────── + TipTap Email Editor + ─────────────────────────────────────────────────── */ + +.tiptap-editor .tiptap { + min-height: 24rem; + padding: 1.5rem; + outline: none; + font-family: var(--font-sans); + font-size: 0.9375rem; + line-height: 1.7; + color: var(--color-foreground); +} + +.tiptap-editor .tiptap:focus-visible { + outline: none; +} + +/* Paragraphs */ +.tiptap-editor .tiptap p { + margin-top: 0; + margin-bottom: 0.75em; +} + +.tiptap-editor .tiptap > :first-child { + margin-top: 0; +} + +/* Headings — match the app's uppercase bold style */ +.tiptap-editor .tiptap h1, +.tiptap-editor .tiptap h2, +.tiptap-editor .tiptap h3 { + font-family: var(--font-sans); + font-weight: 700; + text-transform: uppercase; + letter-spacing: -0.02em; + margin-bottom: 0.5em; + margin-top: 1em; + color: var(--color-foreground); +} + +.tiptap-editor .tiptap h1 { + font-size: 1.75rem; + line-height: 1.2; +} + +.tiptap-editor .tiptap h2 { + font-size: 1.375rem; + line-height: 1.25; +} + +.tiptap-editor .tiptap h3 { + font-size: 1.125rem; + line-height: 1.3; +} + +/* Lists */ +.tiptap-editor .tiptap ul { + list-style-type: disc; + padding-left: 1.5em; + margin-bottom: 0.75em; +} + +.tiptap-editor .tiptap ol { + list-style-type: decimal; + padding-left: 1.5em; + margin-bottom: 0.75em; +} + +.tiptap-editor .tiptap li { + margin-bottom: 0.25em; +} + +/* Links */ +.tiptap-editor .tiptap a { + color: var(--color-primary-600); + text-decoration: underline; + text-underline-offset: 2px; +} + +/* Horizontal rule */ +.tiptap-editor .tiptap hr { + border: none; + border-top: 2px solid var(--color-border); + margin: 1.5em 0; +} + +/* Blockquote */ +.tiptap-editor .tiptap blockquote { + border-left: 3px solid var(--color-secondary-400); + padding-left: 1em; + margin-left: 0; + margin-bottom: 0.75em; + color: var(--color-neutral-600); + font-style: italic; +} + +/* Template variable pills */ +.tiptap-template-variable { + display: inline; + background-color: var(--color-secondary-100); + color: var(--color-secondary-800); + border: 1px solid var(--color-secondary-300); + padding: 1px 6px; + font-family: var(--font-mono); + font-size: 0.8125rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + white-space: nowrap; + user-select: none; +} + +.tiptap-template-variable[data-preview-state="resolved"] { + background-color: #fef08a; + color: var(--color-neutral-900); + border-color: #facc15; + text-transform: none; + letter-spacing: 0; +} + +.tiptap-template-variable[data-preview-state="missing"] { + background-color: #fee2e2; + color: #991b1b; + border-color: #fca5a5; +} + +.tiptap-template-variable[data-preview-state="raw"] { + background-color: var(--color-secondary-100); + color: var(--color-secondary-800); + border-color: var(--color-secondary-300); +} + +/* Email button blocks in editor */ +.tiptap-editor .tiptap [data-type="email-button"] { + text-align: center; + padding: 1em 0; +} + +.tiptap-editor .tiptap [data-type="email-button"] a { + text-decoration: none; +} + +.tiptap-editor + .tiptap + [data-type="email-button"].ProseMirror-selectednode { + outline: 2px solid var(--color-primary-500); + outline-offset: 4px; +} + +/* Selected node outline */ +.tiptap-editor .tiptap .ProseMirror-selectednode { + outline: 2px solid var(--color-primary-400); + outline-offset: 2px; +} + +/* Placeholder */ +.tiptap-editor .tiptap p.is-editor-empty:first-child::before { + content: attr(data-placeholder); + color: var(--color-neutral-400); + pointer-events: none; + float: left; + height: 0; +} diff --git a/apps/web/src/lib/components/editor/editor-toolbar.svelte b/apps/web/src/lib/components/editor/editor-toolbar.svelte new file mode 100644 index 0000000..95e8072 --- /dev/null +++ b/apps/web/src/lib/components/editor/editor-toolbar.svelte @@ -0,0 +1,592 @@ + + +
Hello {{lead.name}}
", + { + placeId: "place-1", + name: "Acme Corp", + }, + [] + ); + + expect(result).toContain(" { + const result = highlightResolved( + "Hello {{lead.email}}
", + { + placeId: "place-1", + name: "Acme Corp", + email: null, + }, + [] + ); + + expect(result).toContain("{{lead.email}}"); + expect(result).toContain("#fee2e2"); + }); +}); + +describe("getTemplateVariablePreview", () => { + it("returns the raw token when no lead is selected", () => { + expect(getTemplateVariablePreview("lead.name", null)).toEqual({ + text: "{{lead.name}}", + state: "raw", + }); + }); + + it("returns the raw token when preview is explicitly cleared", () => { + expect(getTemplateVariablePreview("lead.email", null)).toEqual({ + text: "{{lead.email}}", + state: "raw", + }); + }); + + it("returns the resolved lead value when present", () => { + expect( + getTemplateVariablePreview("lead.name", { + placeId: "place-1", + name: "Acme Corp", + }) + ).toEqual({ + text: "Acme Corp", + state: "resolved", + }); + }); + + it("marks known lead variables as missing when empty", () => { + expect( + getTemplateVariablePreview("lead.email", { + placeId: "place-1", + name: "Acme Corp", + email: null, + }) + ).toEqual({ + text: "{{lead.email}}", + state: "missing", + }); + }); + + it("keeps custom variables as raw tokens", () => { + expect( + getTemplateVariablePreview("custom.industry", { + placeId: "place-1", + name: "Acme Corp", + }) + ).toEqual({ + text: "{{custom.industry}}", + state: "raw", + }); + }); +}); diff --git a/apps/web/src/lib/components/editor/tiptap-editor-commands.test.ts b/apps/web/src/lib/components/editor/tiptap-editor-commands.test.ts new file mode 100644 index 0000000..cd0507f --- /dev/null +++ b/apps/web/src/lib/components/editor/tiptap-editor-commands.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "bun:test"; +import { Editor } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; +import Link from "@tiptap/extension-link"; +import Underline from "@tiptap/extension-underline"; +import TextAlign from "@tiptap/extension-text-align"; +import Placeholder from "@tiptap/extension-placeholder"; +import { TemplateVariable } from "./tiptap-template-variable"; +import { EmailButton } from "./tiptap-email-button"; + +function createEditor(content = "Hello
") { + const element = document.createElement("div"); + document.body.appendChild(element); + + return new Editor({ + element, + extensions: [ + StarterKit.configure({ + heading: { levels: [1, 2, 3] }, + link: false, + underline: false, + }), + Underline, + Link, + TextAlign.configure({ + types: ["heading", "paragraph"], + }), + Placeholder, + TemplateVariable, + EmailButton, + ], + content, + }); +} + +describe("TipTap editor commands", () => { + it("inserts a template variable inline", () => { + const editor = createEditor(); + + editor.commands.focus("end"); + const inserted = editor.commands.insertTemplateVariable("lead.name"); + + expect(inserted).toBe(true); + expect(editor.getHTML()).toContain('data-type="template-variable"'); + expect(editor.getHTML()).toContain("{{lead.name}}"); + + editor.destroy(); + }); + + it("inserts an email button block", () => { + const editor = createEditor(); + + editor.commands.focus("end"); + const inserted = editor.commands.insertEmailButton({ + text: "Book a Call", + url: "https://example.com", + backgroundColor: "#c4520a", + textColor: "#ffffff", + }); + + expect(inserted).toBe(true); + expect(editor.getHTML()).toContain('data-type="email-button"'); + expect(editor.getHTML()).toContain("Book a Call"); + expect(editor.getHTML()).toContain("https://example.com"); + + editor.destroy(); + }); + + it("applies a link to selected text", () => { + const editor = createEditor("Hello world
"); + + editor.commands.setTextSelection({ from: 1, to: 6 }); + const inserted = editor.commands.setLink({ + href: "https://example.com", + }); + + expect(inserted).toBe(true); + expect(editor.getHTML()).toContain('href="https://example.com"'); + + editor.destroy(); + }); +}); diff --git a/apps/web/src/lib/components/editor/tiptap-email-button.ts b/apps/web/src/lib/components/editor/tiptap-email-button.ts new file mode 100644 index 0000000..adc0b7e --- /dev/null +++ b/apps/web/src/lib/components/editor/tiptap-email-button.ts @@ -0,0 +1,175 @@ +import { Node, mergeAttributes } from "@tiptap/core"; + +export type EmailButtonOptions = { + /** Extra HTML attributes added to every rendered node. */ + HTMLAttributes: RecordHello {{lead.name}}
"; + const result = preprocessTemplateHtml(input); + expect(result).toBe( + 'Hello {{lead.name}}
' + ); + }); + + it("wraps multiple template variables", () => { + const input = "{{lead.name}} — {{lead.email}}
"; + const result = preprocessTemplateHtml(input); + expect(result).toContain( + 'data-variable-id="lead.name">{{lead.name}}' + ); + expect(result).toContain( + 'data-variable-id="lead.email">{{lead.email}}' + ); + }); + + it("wraps custom field variables", () => { + const input = "{{custom.industry}}"; + const result = preprocessTemplateHtml(input); + expect(result).toBe( + '{{custom.industry}}' + ); + }); + + it("handles text with no variables", () => { + const input = "Hello world
"; + const result = preprocessTemplateHtml(input); + expect(result).toBe("Hello world
"); + }); + + it("handles empty string", () => { + expect(preprocessTemplateHtml("")).toBe(""); + }); + + it("handles variables with underscores", () => { + const input = "{{lead.google_maps_url}}"; + const result = preprocessTemplateHtml(input); + expect(result).toContain('data-variable-id="lead.google_maps_url"'); + }); + + it("does not wrap incomplete braces", () => { + const input = "{{incomplete"; + const result = preprocessTemplateHtml(input); + expect(result).toBe("{{incomplete"); + }); +}); + +describe("postprocessTemplateHtml", () => { + it("converts a span back to raw template token", () => { + const input = + 'Hello {{lead.name}}
'; + const result = postprocessTemplateHtml(input); + expect(result).toBe("Hello {{lead.name}}
"); + }); + + it("converts multiple spans back", () => { + const input = + '{{lead.name}} and {{lead.email}}'; + const result = postprocessTemplateHtml(input); + expect(result).toBe("{{lead.name}} and {{lead.email}}"); + }); + + it("handles HTML with no template variable spans", () => { + const input = "Hello world
"; + const result = postprocessTemplateHtml(input); + expect(result).toBe("Hello world
"); + }); + + it("handles empty string", () => { + expect(postprocessTemplateHtml("")).toBe(""); + }); + + it("handles custom field spans", () => { + const input = + '{{custom.industry}}'; + const result = postprocessTemplateHtml(input); + expect(result).toBe("{{custom.industry}}"); + }); +}); + +describe("round-trip: preprocess -> postprocess", () => { + it("preserves raw template HTML through round-trip", () => { + const original = + "Hello {{lead.name}}, your email is {{lead.email}}
"; + const preprocessed = preprocessTemplateHtml(original); + const postprocessed = postprocessTemplateHtml(preprocessed); + expect(postprocessed).toBe(original); + }); + + it("preserves HTML with no variables through round-trip", () => { + const original = "No variables here
"; + const preprocessed = preprocessTemplateHtml(original); + const postprocessed = postprocessTemplateHtml(preprocessed); + expect(postprocessed).toBe(original); + }); + + it("preserves mixed content through round-trip", () => { + const original = + 'Hi {{lead.name}},
Visit our site
{{custom.offer}}
'; + const preprocessed = preprocessTemplateHtml(original); + const postprocessed = postprocessTemplateHtml(preprocessed); + expect(postprocessed).toBe(original); + }); +}); + +describe("normalizeTemplateEditorHtml", () => { + it("treats empty string as empty content", () => { + expect(normalizeTemplateEditorHtml("")).toBe(""); + expect(normalizeTemplateEditorHtml(" ")).toBe(""); + }); + + it("normalizes TipTap empty paragraphs to empty string", () => { + expect(normalizeTemplateEditorHtml("")).toBe(""); + expect(normalizeTemplateEditorHtml("Hello
\n")).toBe( + "Hello
" + ); + }); +}); diff --git a/apps/web/src/lib/components/editor/tiptap-template-variable.ts b/apps/web/src/lib/components/editor/tiptap-template-variable.ts new file mode 100644 index 0000000..c852c0e --- /dev/null +++ b/apps/web/src/lib/components/editor/tiptap-template-variable.ts @@ -0,0 +1,146 @@ +import { Node, mergeAttributes } from "@tiptap/core"; + +export type TemplateVariableOptions = { + /** Extra HTML attributes added to every rendered node. */ + HTMLAttributes: Record- Preview will appear here as you type... -
- {/if} -- ⚠ Some placeholders have missing data +
+ Missing placeholder data
{token}
+ {token}
— empty for {missingCount} of {leads.length} lead{leads.length ===
1
? ""
@@ -352,8 +440,11 @@
{/if}
- Tip: type {{ in the subject or body to insert a variable (e.g. lead name, address).
+ >
+ in the body to insert a variable, or use the
+ {{
+ toolbar button.
Hello,
- -Your message here...
- -Best regards
" + placeholder="Start writing your email..." /> {#if errorMessage} diff --git a/apps/web/src/routes/(app)/projects/[id]/initiatives/new/initiative-email-form.svelte b/apps/web/src/routes/(app)/projects/[id]/initiatives/new/initiative-email-form.svelte index 8eac7e0..0e8ab31 100644 --- a/apps/web/src/routes/(app)/projects/[id]/initiatives/new/initiative-email-form.svelte +++ b/apps/web/src/routes/(app)/projects/[id]/initiatives/new/initiative-email-form.svelte @@ -85,11 +85,7 @@ subjectName={createInitiativeEmail.fields.subject.as("text").name} {projectId} {leads} - placeholder="Hello,
- -Your message here...
- -Best regards
" + placeholder="Start writing your email..." /> {#if errorMessage} diff --git a/apps/web/src/routes/(app)/projects/[id]/project.test.ts b/apps/web/src/routes/(app)/projects/[id]/project.test.ts index 2aa79a0..1c9123d 100644 --- a/apps/web/src/routes/(app)/projects/[id]/project.test.ts +++ b/apps/web/src/routes/(app)/projects/[id]/project.test.ts @@ -3,6 +3,7 @@ import { render, screen, waitFor } from "@testing-library/svelte"; import { createFormMock, createQueryMock, + createQueryResult, createCommandMock, mockSvelteKitModules, } from "../../../../test-helpers/sveltekit-mocks"; @@ -117,15 +118,19 @@ describe("Project page", () => { session: { activeOrganizationId: "org-1", }, - organizations: [{ id: "org-1", name: "Test Org", slug: "test-org" }] as { id: string; name: string; slug: string }[], + organizations: [ + { id: "org-1", name: "Test Org", slug: "test-org" }, + ] as { id: string; name: string; slug: string }[], } as const; beforeEach(() => { mockGetProjectData.mockClear(); mockGetProjectInitiatives.mockClear(); - mockGetProjectData.mockImplementation(() => Promise.resolve(mockProject)); + mockGetProjectData.mockImplementation(() => + createQueryResult(mockProject) + ); mockGetProjectInitiatives.mockImplementation(() => - Promise.resolve(mockInitiatives), + createQueryResult(mockInitiatives) ); }); @@ -136,7 +141,7 @@ describe("Project page", () => { }); await waitFor(() => { expect( - screen.getByRole("heading", { name: "Test Project" }), + screen.getByRole("heading", { name: "Test Project" }) ).toBeTruthy(); }); }); @@ -147,9 +152,7 @@ describe("Project page", () => { data: mockData, }); await waitFor(() => { - expect( - screen.getByText("A test project description"), - ).toBeTruthy(); + expect(screen.getByText("A test project description")).toBeTruthy(); }); }); diff --git a/apps/web/src/test-helpers/sveltekit-mocks.ts b/apps/web/src/test-helpers/sveltekit-mocks.ts index 68089df..c78d272 100644 --- a/apps/web/src/test-helpers/sveltekit-mocks.ts +++ b/apps/web/src/test-helpers/sveltekit-mocks.ts @@ -1,5 +1,14 @@ import { mock } from "bun:test"; +export type QueryResult