Email Templates Management Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Add template management functionality to the /email-schedules page, enabling admins to create, manage, and reuse email content through snippet templates and variable format templates.
Architecture: Feature-based implementation within src/features/email-schedules/. New database tables with admin-only RLS policies. Tab-based UI added to existing page. Integration with ComposeModal via toolbar insert panels.
Tech Stack: React, TypeScript, Supabase (PostgreSQL), TipTap (RichTextEditor), Handlebars (template rendering), Vitest (testing)
Task 1: Database Migration - Create Tables
Files:
- Create:
supabase/migrations/20260122100000_add_email_templates.sql
Step 1: Create migration file
Create the SQL migration with both tables and RLS policies:
-- Email Templates Migration
-- Creates tables for snippet templates and variable format templates
-- =============================================================================
-- Snippet Templates Table
-- =============================================================================
CREATE TABLE email_snippet_templates (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
subject text,
body text NOT NULL,
tags text[] DEFAULT '{}',
created_by uuid REFERENCES auth.users(id),
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
-- Index for tag filtering (GIN for array containment queries)
CREATE INDEX idx_snippet_templates_tags ON email_snippet_templates USING GIN (tags);
-- Enable RLS
ALTER TABLE email_snippet_templates ENABLE ROW LEVEL SECURITY;
-- Admin-only policy for snippet templates
CREATE POLICY "Admins can manage snippet templates"
ON email_snippet_templates
FOR ALL
USING (is_admin())
WITH CHECK (is_admin());
-- Trigger to update updated_at on changes
CREATE TRIGGER update_email_snippet_templates_updated_at
BEFORE UPDATE ON email_snippet_templates
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- =============================================================================
-- Variable Format Templates Table
-- =============================================================================
CREATE TABLE email_variable_templates (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
variable_name text NOT NULL,
name text NOT NULL,
html_template text NOT NULL,
is_default boolean DEFAULT false,
created_by uuid REFERENCES auth.users(id),
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
-- Ensure only one default per variable name
CREATE UNIQUE INDEX idx_variable_templates_default
ON email_variable_templates (variable_name)
WHERE is_default = true;
-- Enable RLS
ALTER TABLE email_variable_templates ENABLE ROW LEVEL SECURITY;
-- Admin-only policy for variable templates
CREATE POLICY "Admins can manage variable templates"
ON email_variable_templates
FOR ALL
USING (is_admin())
WITH CHECK (is_admin());
-- Trigger to update updated_at on changes
CREATE TRIGGER update_email_variable_templates_updated_at
BEFORE UPDATE ON email_variable_templates
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- =============================================================================
-- Comments
-- =============================================================================
COMMENT ON TABLE email_snippet_templates IS 'Reusable email content fragments with tag-based organization';
COMMENT ON COLUMN email_snippet_templates.name IS 'Display name for the snippet';
COMMENT ON COLUMN email_snippet_templates.subject IS 'Optional subject line to apply when inserting';
COMMENT ON COLUMN email_snippet_templates.body IS 'Rich text content (HTML)';
COMMENT ON COLUMN email_snippet_templates.tags IS 'Tag array for categorization and filtering';
COMMENT ON TABLE email_variable_templates IS 'HTML format templates for dynamic email variables';
COMMENT ON COLUMN email_variable_templates.variable_name IS 'Variable identifier (e.g., summary_flow_meter)';
COMMENT ON COLUMN email_variable_templates.name IS 'Display name for this format';
COMMENT ON COLUMN email_variable_templates.html_template IS 'Handlebars HTML template';
COMMENT ON COLUMN email_variable_templates.is_default IS 'Whether this is the default format for the variable';Step 2: Apply migration to local database
Run: pnpm supabase:push Expected: Migration applied successfully
Step 3: Generate TypeScript types
Run: pnpm supabase:types Expected: Types regenerated in src/lib/supabaseTypes.ts
Step 4: Commit
git add supabase/migrations/20260122100000_add_email_templates.sql src/lib/supabaseTypes.ts
git commit -m "feat(db): add email templates tables with RLS"Task 2: Add Type Definitions
Files:
- Modify:
src/features/email-schedules/types.ts
Step 1: Add snippet template types
Add after the existing type definitions (at end of file):
// =============================================================================
// Email Template Types
// =============================================================================
/**
* Snippet template for reusable email content fragments
*/
export interface SnippetTemplate {
id: string;
name: string;
subject: string | null;
body: string;
tags: string[];
createdBy: string | null;
createdAt: string;
updatedAt: string;
}
export interface SnippetTemplateInput {
name: string;
subject?: string | null;
body: string;
tags?: string[];
}
export interface SnippetTemplateUpdate extends Partial<SnippetTemplateInput> {}
/**
* Variable format template for dynamic email variables
*/
export interface VariableTemplate {
id: string;
variableName: string;
name: string;
htmlTemplate: string;
isDefault: boolean;
createdBy: string | null;
createdAt: string;
updatedAt: string;
}
export interface VariableTemplateInput {
variableName: string;
name: string;
htmlTemplate: string;
isDefault?: boolean;
}
export interface VariableTemplateUpdate extends Partial<Omit<VariableTemplateInput, 'variableName'>> {}
/**
* Known variable names that support format templates
*/
export type KnownVariableName = 'summary_flow_meter';
/**
* Field metadata for variable template editor
*/
export interface VariableField {
name: string;
type: 'string' | 'number' | 'array';
description: string;
arrayItemFields?: VariableField[];
}
/**
* Variable definition with available fields
*/
export interface VariableDefinition {
variableName: KnownVariableName;
displayName: string;
description: string;
fields: VariableField[];
}Step 2: Verify no TypeScript errors
Run: pnpm build:check Expected: Build passes with no type errors
Step 3: Commit
git add src/features/email-schedules/types.ts
git commit -m "feat(types): add email template type definitions"Task 3: Create Snippet Template Service
Files:
- Create:
src/features/email-schedules/services/snippetTemplateService.ts - Create:
src/features/email-schedules/services/snippetTemplateService.test.ts
Step 1: Write failing test for service
Create test file src/features/email-schedules/services/snippetTemplateService.test.ts:
import { describe, it, expect, vi, beforeEach } from "vitest";
import { SnippetTemplateService } from "./snippetTemplateService";
import type { SnippetTemplate, SnippetTemplateInput } from "../types";
// Mock supabase
const mockSelect = vi.fn();
const mockInsert = vi.fn();
const mockUpdate = vi.fn();
const mockDelete = vi.fn();
const mockEq = vi.fn();
const mockOrder = vi.fn();
const mockSingle = vi.fn();
const mockContains = vi.fn();
const mockIlike = vi.fn();
vi.mock("@/lib/supabase", () => ({
supabase: {
from: vi.fn(() => ({
select: mockSelect,
insert: mockInsert,
update: mockUpdate,
delete: mockDelete,
})),
},
}));
describe("SnippetTemplateService", () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset mock chain
mockSelect.mockReturnValue({
eq: mockEq,
order: mockOrder,
contains: mockContains,
ilike: mockIlike,
});
mockInsert.mockReturnValue({ select: mockSelect });
mockUpdate.mockReturnValue({ eq: mockEq });
mockDelete.mockReturnValue({ eq: mockEq });
mockEq.mockReturnValue({
order: mockOrder,
select: mockSelect,
eq: mockEq,
});
mockOrder.mockResolvedValue({ data: [], error: null });
mockSelect.mockReturnValue({ single: mockSingle, eq: mockEq, order: mockOrder });
mockSingle.mockResolvedValue({ data: null, error: null });
mockContains.mockReturnValue({ order: mockOrder });
mockIlike.mockReturnValue({ order: mockOrder });
});
const mockTemplateRow = {
id: "tpl-1",
name: "Weekly Intro",
subject: "Weekly Update",
body: "<p>Hello team,</p>",
tags: ["weekly", "intro"],
created_by: "user-123",
created_at: "2026-01-22T00:00:00Z",
updated_at: "2026-01-22T00:00:00Z",
};
describe("list", () => {
it("returns empty array when no templates", async () => {
mockOrder.mockResolvedValue({ data: [], error: null });
const result = await SnippetTemplateService.list();
expect(result).toEqual([]);
});
it("returns mapped templates", async () => {
mockOrder.mockResolvedValue({
data: [mockTemplateRow],
error: null,
});
const result = await SnippetTemplateService.list();
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
id: "tpl-1",
name: "Weekly Intro",
subject: "Weekly Update",
body: "<p>Hello team,</p>",
tags: ["weekly", "intro"],
createdBy: "user-123",
createdAt: "2026-01-22T00:00:00Z",
updatedAt: "2026-01-22T00:00:00Z",
});
});
it("throws error on database failure", async () => {
mockOrder.mockResolvedValue({
data: null,
error: { message: "Database error" },
});
await expect(SnippetTemplateService.list()).rejects.toThrow(
"Failed to fetch snippet templates"
);
});
});
describe("create", () => {
const input: SnippetTemplateInput = {
name: "Weekly Intro",
subject: "Weekly Update",
body: "<p>Hello team,</p>",
tags: ["weekly", "intro"],
};
it("creates and returns template", async () => {
mockInsert.mockReturnValue({
select: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({
data: mockTemplateRow,
error: null,
}),
}),
});
const result = await SnippetTemplateService.create(input);
expect(result.id).toBe("tpl-1");
expect(result.name).toBe("Weekly Intro");
});
it("throws error when name is empty", async () => {
await expect(
SnippetTemplateService.create({ ...input, name: "" })
).rejects.toThrow("Template name is required");
});
it("throws error when body is empty", async () => {
await expect(
SnippetTemplateService.create({ ...input, body: "" })
).rejects.toThrow("Template body is required");
});
});
describe("update", () => {
it("updates template with partial input", async () => {
mockUpdate.mockReturnValue({
eq: vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({
data: { ...mockTemplateRow, name: "Updated Name" },
error: null,
}),
}),
}),
});
const result = await SnippetTemplateService.update("tpl-1", {
name: "Updated Name",
});
expect(result.name).toBe("Updated Name");
});
});
describe("remove", () => {
it("deletes template successfully", async () => {
mockDelete.mockReturnValue({
eq: vi.fn().mockResolvedValue({ error: null }),
});
await expect(
SnippetTemplateService.remove("tpl-1")
).resolves.toBeUndefined();
});
});
describe("listByTag", () => {
it("filters templates by tag", async () => {
mockContains.mockReturnValue({
order: vi.fn().mockResolvedValue({
data: [mockTemplateRow],
error: null,
}),
});
const result = await SnippetTemplateService.listByTag("weekly");
expect(result).toHaveLength(1);
});
});
});Step 2: Run test to verify it fails
Run: pnpm test:unit src/features/email-schedules/services/snippetTemplateService.test.ts Expected: FAIL with "Cannot find module './snippetTemplateService'"
Step 3: Write service implementation
Create src/features/email-schedules/services/snippetTemplateService.ts:
import { supabase } from "@/lib/supabase";
import type { Database } from "@/lib/supabaseTypes";
import type {
SnippetTemplate,
SnippetTemplateInput,
SnippetTemplateUpdate,
} from "../types";
type SnippetTemplateRow = Database["public"]["Tables"]["email_snippet_templates"]["Row"];
type SnippetTemplateInsert = Database["public"]["Tables"]["email_snippet_templates"]["Insert"];
function mapRow(row: SnippetTemplateRow): SnippetTemplate {
return {
id: row.id,
name: row.name,
subject: row.subject,
body: row.body,
tags: row.tags ?? [],
createdBy: row.created_by,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export const SnippetTemplateService = {
/**
* List all snippet templates, ordered by updated_at descending
*/
async list(): Promise<SnippetTemplate[]> {
const { data, error } = await supabase
.from("email_snippet_templates")
.select("*")
.order("updated_at", { ascending: false })
.returns<SnippetTemplateRow[]>();
if (error) {
console.error("Failed to fetch snippet templates", error);
throw new Error(`Failed to fetch snippet templates: ${error.message}`);
}
return (data || []).map(mapRow);
},
/**
* List snippet templates filtered by tag
*/
async listByTag(tag: string): Promise<SnippetTemplate[]> {
const { data, error } = await supabase
.from("email_snippet_templates")
.select("*")
.contains("tags", [tag])
.order("updated_at", { ascending: false })
.returns<SnippetTemplateRow[]>();
if (error) {
console.error("Failed to fetch snippet templates by tag", error);
throw new Error(`Failed to fetch snippet templates: ${error.message}`);
}
return (data || []).map(mapRow);
},
/**
* Search snippet templates by name (case-insensitive)
*/
async search(query: string): Promise<SnippetTemplate[]> {
const { data, error } = await supabase
.from("email_snippet_templates")
.select("*")
.ilike("name", `%${query}%`)
.order("updated_at", { ascending: false })
.returns<SnippetTemplateRow[]>();
if (error) {
console.error("Failed to search snippet templates", error);
throw new Error(`Failed to search snippet templates: ${error.message}`);
}
return (data || []).map(mapRow);
},
/**
* Get a single snippet template by ID
*/
async getById(id: string): Promise<SnippetTemplate | null> {
const { data, error } = await supabase
.from("email_snippet_templates")
.select("*")
.eq("id", id)
.single()
.returns<SnippetTemplateRow>();
if (error) {
if (error.code === "PGRST116") {
return null; // Not found
}
console.error("Failed to fetch snippet template", error);
throw new Error(`Failed to fetch snippet template: ${error.message}`);
}
return data ? mapRow(data) : null;
},
/**
* Create a new snippet template
*/
async create(input: SnippetTemplateInput): Promise<SnippetTemplate> {
const trimmedName = input.name?.trim();
if (!trimmedName) {
throw new Error("Template name is required");
}
const trimmedBody = input.body?.trim();
if (!trimmedBody) {
throw new Error("Template body is required");
}
const payload: SnippetTemplateInsert = {
name: trimmedName,
subject: input.subject?.trim() || null,
body: trimmedBody,
tags: input.tags ?? [],
};
const { data, error } = await supabase
.from("email_snippet_templates")
.insert(payload)
.select("*")
.single()
.returns<SnippetTemplateRow>();
if (error) {
console.error("Failed to create snippet template", error);
throw new Error(`Failed to create snippet template: ${error.message}`);
}
return mapRow(data);
},
/**
* Update an existing snippet template
*/
async update(id: string, updates: SnippetTemplateUpdate): Promise<SnippetTemplate> {
const payload: Partial<SnippetTemplateInsert> = {};
if (updates.name !== undefined) {
const trimmedName = updates.name.trim();
if (!trimmedName) {
throw new Error("Template name cannot be empty");
}
payload.name = trimmedName;
}
if (updates.subject !== undefined) {
payload.subject = updates.subject?.trim() || null;
}
if (updates.body !== undefined) {
const trimmedBody = updates.body.trim();
if (!trimmedBody) {
throw new Error("Template body cannot be empty");
}
payload.body = trimmedBody;
}
if (updates.tags !== undefined) {
payload.tags = updates.tags;
}
const { data, error } = await supabase
.from("email_snippet_templates")
.update(payload)
.eq("id", id)
.select("*")
.single()
.returns<SnippetTemplateRow>();
if (error) {
console.error("Failed to update snippet template", error);
throw new Error(`Failed to update snippet template: ${error.message}`);
}
return mapRow(data);
},
/**
* Delete a snippet template
*/
async remove(id: string): Promise<void> {
const { error } = await supabase
.from("email_snippet_templates")
.delete()
.eq("id", id);
if (error) {
console.error("Failed to delete snippet template", error);
throw new Error(`Failed to delete snippet template: ${error.message}`);
}
},
/**
* Get all unique tags from existing templates
*/
async getAllTags(): Promise<string[]> {
const { data, error } = await supabase
.from("email_snippet_templates")
.select("tags")
.returns<{ tags: string[] }[]>();
if (error) {
console.error("Failed to fetch tags", error);
throw new Error(`Failed to fetch tags: ${error.message}`);
}
const tagSet = new Set<string>();
for (const row of data || []) {
for (const tag of row.tags || []) {
tagSet.add(tag);
}
}
return Array.from(tagSet).sort();
},
};Step 4: Run test to verify it passes
Run: pnpm test:unit src/features/email-schedules/services/snippetTemplateService.test.ts Expected: All tests PASS
Step 5: Commit
git add src/features/email-schedules/services/snippetTemplateService.ts src/features/email-schedules/services/snippetTemplateService.test.ts
git commit -m "feat(service): add snippet template service with tests"Task 4: Create Variable Template Service
Files:
- Create:
src/features/email-schedules/services/variableTemplateService.ts - Create:
src/features/email-schedules/services/variableTemplateService.test.ts
Step 1: Write failing test for service
Create test file src/features/email-schedules/services/variableTemplateService.test.ts:
import { describe, it, expect, vi, beforeEach } from "vitest";
import { VariableTemplateService, VARIABLE_DEFINITIONS } from "./variableTemplateService";
import type { VariableTemplateInput } from "../types";
// Mock supabase
const mockSelect = vi.fn();
const mockInsert = vi.fn();
const mockUpdate = vi.fn();
const mockDelete = vi.fn();
const mockEq = vi.fn();
const mockOrder = vi.fn();
const mockSingle = vi.fn();
vi.mock("@/lib/supabase", () => ({
supabase: {
from: vi.fn(() => ({
select: mockSelect,
insert: mockInsert,
update: mockUpdate,
delete: mockDelete,
})),
},
}));
describe("VariableTemplateService", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSelect.mockReturnValue({ eq: mockEq, order: mockOrder });
mockInsert.mockReturnValue({ select: mockSelect });
mockUpdate.mockReturnValue({ eq: mockEq });
mockDelete.mockReturnValue({ eq: mockEq });
mockEq.mockReturnValue({
order: mockOrder,
select: mockSelect,
eq: mockEq,
});
mockOrder.mockResolvedValue({ data: [], error: null });
mockSelect.mockReturnValue({ single: mockSingle, eq: mockEq, order: mockOrder });
mockSingle.mockResolvedValue({ data: null, error: null });
});
const mockTemplateRow = {
id: "vtpl-1",
variable_name: "summary_flow_meter",
name: "Default Table",
html_template: "<table>{{#each daily_summary}}<tr>...</tr>{{/each}}</table>",
is_default: true,
created_by: "user-123",
created_at: "2026-01-22T00:00:00Z",
updated_at: "2026-01-22T00:00:00Z",
};
describe("list", () => {
it("returns empty array when no templates", async () => {
mockOrder.mockResolvedValue({ data: [], error: null });
const result = await VariableTemplateService.list();
expect(result).toEqual([]);
});
it("returns mapped templates", async () => {
mockOrder.mockResolvedValue({
data: [mockTemplateRow],
error: null,
});
const result = await VariableTemplateService.list();
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
id: "vtpl-1",
variableName: "summary_flow_meter",
name: "Default Table",
htmlTemplate: "<table>{{#each daily_summary}}<tr>...</tr>{{/each}}</table>",
isDefault: true,
createdBy: "user-123",
createdAt: "2026-01-22T00:00:00Z",
updatedAt: "2026-01-22T00:00:00Z",
});
});
});
describe("listByVariable", () => {
it("filters templates by variable name", async () => {
mockEq.mockReturnValue({
order: vi.fn().mockResolvedValue({
data: [mockTemplateRow],
error: null,
}),
});
const result = await VariableTemplateService.listByVariable("summary_flow_meter");
expect(result).toHaveLength(1);
expect(result[0]?.variableName).toBe("summary_flow_meter");
});
});
describe("create", () => {
const input: VariableTemplateInput = {
variableName: "summary_flow_meter",
name: "Custom Table",
htmlTemplate: "<table>...</table>",
isDefault: false,
};
it("creates and returns template", async () => {
mockInsert.mockReturnValue({
select: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({
data: { ...mockTemplateRow, id: "vtpl-2", name: "Custom Table", is_default: false },
error: null,
}),
}),
});
const result = await VariableTemplateService.create(input);
expect(result.name).toBe("Custom Table");
expect(result.isDefault).toBe(false);
});
it("throws error when name is empty", async () => {
await expect(
VariableTemplateService.create({ ...input, name: "" })
).rejects.toThrow("Template name is required");
});
it("throws error when variable name is empty", async () => {
await expect(
VariableTemplateService.create({ ...input, variableName: "" })
).rejects.toThrow("Variable name is required");
});
});
describe("setDefault", () => {
it("sets template as default and clears other defaults", async () => {
// Mock clearing existing defaults
mockUpdate.mockReturnValueOnce({
eq: vi.fn().mockResolvedValue({ error: null }),
});
// Mock setting new default
mockUpdate.mockReturnValueOnce({
eq: vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({
data: { ...mockTemplateRow, id: "vtpl-2", is_default: true },
error: null,
}),
}),
}),
});
const result = await VariableTemplateService.setDefault("vtpl-2", "summary_flow_meter");
expect(result.isDefault).toBe(true);
});
});
describe("VARIABLE_DEFINITIONS", () => {
it("includes summary_flow_meter variable", () => {
const flowMeterDef = VARIABLE_DEFINITIONS.find(
(d) => d.variableName === "summary_flow_meter"
);
expect(flowMeterDef).toBeDefined();
expect(flowMeterDef?.displayName).toBe("Flow Meter Summary");
expect(flowMeterDef?.fields).toContainEqual(
expect.objectContaining({ name: "site_name", type: "string" })
);
});
});
});Step 2: Run test to verify it fails
Run: pnpm test:unit src/features/email-schedules/services/variableTemplateService.test.ts Expected: FAIL with "Cannot find module './variableTemplateService'"
Step 3: Write service implementation
Create src/features/email-schedules/services/variableTemplateService.ts:
import { supabase } from "@/lib/supabase";
import type { Database } from "@/lib/supabaseTypes";
import type {
VariableTemplate,
VariableTemplateInput,
VariableTemplateUpdate,
VariableDefinition,
KnownVariableName,
} from "../types";
type VariableTemplateRow = Database["public"]["Tables"]["email_variable_templates"]["Row"];
type VariableTemplateInsert = Database["public"]["Tables"]["email_variable_templates"]["Insert"];
/**
* Variable definitions with field metadata for template editor
*/
export const VARIABLE_DEFINITIONS: VariableDefinition[] = [
{
variableName: "summary_flow_meter",
displayName: "Flow Meter Summary",
description: "Water usage summary for a mine site",
fields: [
{ name: "site_name", type: "string", description: "Mine site name" },
{ name: "total_litres", type: "number", description: "Total water usage in litres" },
{ name: "record_count", type: "number", description: "Number of records in the period" },
{ name: "date_range_label", type: "string", description: "Human-readable date range" },
{
name: "daily_summary",
type: "array",
description: "Daily breakdown of usage",
arrayItemFields: [
{ name: "date", type: "string", description: "Date (YYYY-MM-DD)" },
{ name: "total_litres", type: "number", description: "Daily water usage" },
{ name: "record_count", type: "number", description: "Daily record count" },
],
},
{
name: "recent_events",
type: "array",
description: "Last 10 dispensing events",
arrayItemFields: [
{ name: "datetime", type: "string", description: "Event timestamp" },
{ name: "asset_display_id", type: "string", description: "Flow meter display name" },
{ name: "litres", type: "number", description: "Litres dispensed" },
],
},
],
},
];
/**
* Default HTML template for flow meter summary variable
*/
export const DEFAULT_FLOW_METER_TEMPLATE = `<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
<thead>
<tr style="background: #f1f5f9;">
<th style="padding: 10px 12px; text-align: left; border-bottom: 2px solid #e2e8f0; font-size: 11px; color: #64748b; text-transform: uppercase;">Date</th>
<th style="padding: 10px 12px; text-align: right; border-bottom: 2px solid #e2e8f0; font-size: 11px; color: #64748b; text-transform: uppercase;">Litres</th>
</tr>
</thead>
<tbody>
{{#each daily_summary}}
<tr>
<td style="padding: 10px 12px; border-bottom: 1px solid #e2e8f0;">{{date}}</td>
<td style="padding: 10px 12px; text-align: right; border-bottom: 1px solid #e2e8f0; font-family: monospace;">{{total_litres}}</td>
</tr>
{{/each}}
</tbody>
<tfoot>
<tr style="background: #f1f5f9; font-weight: 600;">
<td style="padding: 10px 12px; border-top: 2px solid #e2e8f0;">Total</td>
<td style="padding: 10px 12px; text-align: right; border-top: 2px solid #e2e8f0; font-family: monospace;">{{total_litres}}</td>
</tr>
</tfoot>
</table>`;
function mapRow(row: VariableTemplateRow): VariableTemplate {
return {
id: row.id,
variableName: row.variable_name,
name: row.name,
htmlTemplate: row.html_template,
isDefault: row.is_default,
createdBy: row.created_by,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export const VariableTemplateService = {
/**
* List all variable templates, ordered by variable_name then updated_at
*/
async list(): Promise<VariableTemplate[]> {
const { data, error } = await supabase
.from("email_variable_templates")
.select("*")
.order("variable_name", { ascending: true })
.order("updated_at", { ascending: false })
.returns<VariableTemplateRow[]>();
if (error) {
console.error("Failed to fetch variable templates", error);
throw new Error(`Failed to fetch variable templates: ${error.message}`);
}
return (data || []).map(mapRow);
},
/**
* List variable templates for a specific variable
*/
async listByVariable(variableName: string): Promise<VariableTemplate[]> {
const { data, error } = await supabase
.from("email_variable_templates")
.select("*")
.eq("variable_name", variableName)
.order("is_default", { ascending: false })
.order("updated_at", { ascending: false })
.returns<VariableTemplateRow[]>();
if (error) {
console.error("Failed to fetch variable templates", error);
throw new Error(`Failed to fetch variable templates: ${error.message}`);
}
return (data || []).map(mapRow);
},
/**
* Get a single variable template by ID
*/
async getById(id: string): Promise<VariableTemplate | null> {
const { data, error } = await supabase
.from("email_variable_templates")
.select("*")
.eq("id", id)
.single()
.returns<VariableTemplateRow>();
if (error) {
if (error.code === "PGRST116") {
return null;
}
console.error("Failed to fetch variable template", error);
throw new Error(`Failed to fetch variable template: ${error.message}`);
}
return data ? mapRow(data) : null;
},
/**
* Get the default template for a variable
*/
async getDefault(variableName: string): Promise<VariableTemplate | null> {
const { data, error } = await supabase
.from("email_variable_templates")
.select("*")
.eq("variable_name", variableName)
.eq("is_default", true)
.single()
.returns<VariableTemplateRow>();
if (error) {
if (error.code === "PGRST116") {
return null;
}
console.error("Failed to fetch default template", error);
throw new Error(`Failed to fetch default template: ${error.message}`);
}
return data ? mapRow(data) : null;
},
/**
* Create a new variable template
*/
async create(input: VariableTemplateInput): Promise<VariableTemplate> {
const trimmedVariableName = input.variableName?.trim();
if (!trimmedVariableName) {
throw new Error("Variable name is required");
}
const trimmedName = input.name?.trim();
if (!trimmedName) {
throw new Error("Template name is required");
}
const trimmedTemplate = input.htmlTemplate?.trim();
if (!trimmedTemplate) {
throw new Error("HTML template is required");
}
const payload: VariableTemplateInsert = {
variable_name: trimmedVariableName,
name: trimmedName,
html_template: trimmedTemplate,
is_default: input.isDefault ?? false,
};
const { data, error } = await supabase
.from("email_variable_templates")
.insert(payload)
.select("*")
.single()
.returns<VariableTemplateRow>();
if (error) {
console.error("Failed to create variable template", error);
throw new Error(`Failed to create variable template: ${error.message}`);
}
return mapRow(data);
},
/**
* Update an existing variable template
*/
async update(id: string, updates: VariableTemplateUpdate): Promise<VariableTemplate> {
const payload: Partial<VariableTemplateInsert> = {};
if (updates.name !== undefined) {
const trimmedName = updates.name.trim();
if (!trimmedName) {
throw new Error("Template name cannot be empty");
}
payload.name = trimmedName;
}
if (updates.htmlTemplate !== undefined) {
const trimmedTemplate = updates.htmlTemplate.trim();
if (!trimmedTemplate) {
throw new Error("HTML template cannot be empty");
}
payload.html_template = trimmedTemplate;
}
if (updates.isDefault !== undefined) {
payload.is_default = updates.isDefault;
}
const { data, error } = await supabase
.from("email_variable_templates")
.update(payload)
.eq("id", id)
.select("*")
.single()
.returns<VariableTemplateRow>();
if (error) {
console.error("Failed to update variable template", error);
throw new Error(`Failed to update variable template: ${error.message}`);
}
return mapRow(data);
},
/**
* Set a template as the default for its variable (clears other defaults)
*/
async setDefault(id: string, variableName: string): Promise<VariableTemplate> {
// First, clear any existing default for this variable
const { error: clearError } = await supabase
.from("email_variable_templates")
.update({ is_default: false })
.eq("variable_name", variableName);
if (clearError) {
console.error("Failed to clear existing defaults", clearError);
throw new Error(`Failed to set default: ${clearError.message}`);
}
// Set the new default
const { data, error } = await supabase
.from("email_variable_templates")
.update({ is_default: true })
.eq("id", id)
.select("*")
.single()
.returns<VariableTemplateRow>();
if (error) {
console.error("Failed to set default template", error);
throw new Error(`Failed to set default: ${error.message}`);
}
return mapRow(data);
},
/**
* Delete a variable template
*/
async remove(id: string): Promise<void> {
const { error } = await supabase
.from("email_variable_templates")
.delete()
.eq("id", id);
if (error) {
console.error("Failed to delete variable template", error);
throw new Error(`Failed to delete variable template: ${error.message}`);
}
},
/**
* Get the definition for a known variable
*/
getVariableDefinition(variableName: KnownVariableName): VariableDefinition | undefined {
return VARIABLE_DEFINITIONS.find((d) => d.variableName === variableName);
},
/**
* Get all available variable definitions
*/
getAvailableVariables(): VariableDefinition[] {
return VARIABLE_DEFINITIONS;
},
};Step 4: Run test to verify it passes
Run: pnpm test:unit src/features/email-schedules/services/variableTemplateService.test.ts Expected: All tests PASS
Step 5: Commit
git add src/features/email-schedules/services/variableTemplateService.ts src/features/email-schedules/services/variableTemplateService.test.ts
git commit -m "feat(service): add variable template service with tests"Task 5: Create Snippet Templates Hook
Files:
- Create:
src/features/email-schedules/hooks/useSnippetTemplates.ts
Step 1: Create hook implementation
import { useCallback, useEffect, useState } from "react";
import { SnippetTemplateService } from "../services/snippetTemplateService";
import type {
SnippetTemplate,
SnippetTemplateInput,
SnippetTemplateUpdate,
} from "../types";
export function useSnippetTemplates(enabled = true) {
const [templates, setTemplates] = useState<SnippetTemplate[]>([]);
const [allTags, setAllTags] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
if (!enabled) return;
setLoading(true);
try {
const [templatesData, tagsData] = await Promise.all([
SnippetTemplateService.list(),
SnippetTemplateService.getAllTags(),
]);
setTemplates(templatesData);
setAllTags(tagsData);
setError(null);
} catch (err) {
console.error("Failed to load snippet templates", err);
setError(
err instanceof Error ? err.message : "Failed to load snippet templates"
);
} finally {
setLoading(false);
}
}, [enabled]);
useEffect(() => {
void refresh();
}, [refresh]);
const createTemplate = useCallback(async (input: SnippetTemplateInput) => {
const created = await SnippetTemplateService.create(input);
setTemplates((prev) => [created, ...prev]);
// Refresh tags in case new tags were added
const tags = await SnippetTemplateService.getAllTags();
setAllTags(tags);
return created;
}, []);
const updateTemplate = useCallback(
async (id: string, updates: SnippetTemplateUpdate) => {
const updated = await SnippetTemplateService.update(id, updates);
setTemplates((prev) =>
prev.map((item) => (item.id === id ? updated : item))
);
// Refresh tags in case tags were changed
const tags = await SnippetTemplateService.getAllTags();
setAllTags(tags);
return updated;
},
[]
);
const deleteTemplate = useCallback(async (id: string) => {
await SnippetTemplateService.remove(id);
setTemplates((prev) => prev.filter((item) => item.id !== id));
// Refresh tags in case a tag is no longer used
const tags = await SnippetTemplateService.getAllTags();
setAllTags(tags);
}, []);
const filterByTag = useCallback(async (tag: string | null) => {
setLoading(true);
try {
const data = tag
? await SnippetTemplateService.listByTag(tag)
: await SnippetTemplateService.list();
setTemplates(data);
setError(null);
} catch (err) {
console.error("Failed to filter templates", err);
setError(
err instanceof Error ? err.message : "Failed to filter templates"
);
} finally {
setLoading(false);
}
}, []);
const searchTemplates = useCallback(async (query: string) => {
if (!query.trim()) {
await refresh();
return;
}
setLoading(true);
try {
const data = await SnippetTemplateService.search(query);
setTemplates(data);
setError(null);
} catch (err) {
console.error("Failed to search templates", err);
setError(
err instanceof Error ? err.message : "Failed to search templates"
);
} finally {
setLoading(false);
}
}, [refresh]);
return {
templates,
allTags,
loading,
error,
refresh,
createTemplate,
updateTemplate,
deleteTemplate,
filterByTag,
searchTemplates,
};
}Step 2: Verify no TypeScript errors
Run: pnpm build:check Expected: Build passes with no type errors
Step 3: Commit
git add src/features/email-schedules/hooks/useSnippetTemplates.ts
git commit -m "feat(hook): add useSnippetTemplates hook"Task 6: Create Variable Templates Hook
Files:
- Create:
src/features/email-schedules/hooks/useVariableTemplates.ts
Step 1: Create hook implementation
import { useCallback, useEffect, useState } from "react";
import { VariableTemplateService, VARIABLE_DEFINITIONS } from "../services/variableTemplateService";
import type {
VariableTemplate,
VariableTemplateInput,
VariableTemplateUpdate,
VariableDefinition,
KnownVariableName,
} from "../types";
export function useVariableTemplates(enabled = true) {
const [templates, setTemplates] = useState<VariableTemplate[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
if (!enabled) return;
setLoading(true);
try {
const data = await VariableTemplateService.list();
setTemplates(data);
setError(null);
} catch (err) {
console.error("Failed to load variable templates", err);
setError(
err instanceof Error ? err.message : "Failed to load variable templates"
);
} finally {
setLoading(false);
}
}, [enabled]);
useEffect(() => {
void refresh();
}, [refresh]);
const createTemplate = useCallback(async (input: VariableTemplateInput) => {
const created = await VariableTemplateService.create(input);
setTemplates((prev) => [...prev, created]);
return created;
}, []);
const updateTemplate = useCallback(
async (id: string, updates: VariableTemplateUpdate) => {
const updated = await VariableTemplateService.update(id, updates);
setTemplates((prev) =>
prev.map((item) => (item.id === id ? updated : item))
);
return updated;
},
[]
);
const deleteTemplate = useCallback(async (id: string) => {
await VariableTemplateService.remove(id);
setTemplates((prev) => prev.filter((item) => item.id !== id));
}, []);
const setDefault = useCallback(
async (id: string, variableName: string) => {
const updated = await VariableTemplateService.setDefault(id, variableName);
// Update local state to reflect the new default
setTemplates((prev) =>
prev.map((item) => {
if (item.variableName === variableName) {
return { ...item, isDefault: item.id === id };
}
return item;
})
);
return updated;
},
[]
);
/**
* Get templates grouped by variable name
*/
const getTemplatesGroupedByVariable = useCallback(() => {
const grouped: Record<string, VariableTemplate[]> = {};
for (const template of templates) {
if (!grouped[template.variableName]) {
grouped[template.variableName] = [];
}
grouped[template.variableName].push(template);
}
return grouped;
}, [templates]);
/**
* Get templates for a specific variable
*/
const getTemplatesForVariable = useCallback(
(variableName: string) => {
return templates.filter((t) => t.variableName === variableName);
},
[templates]
);
/**
* Get the default template for a variable (or first available)
*/
const getDefaultTemplate = useCallback(
(variableName: string): VariableTemplate | undefined => {
const varTemplates = templates.filter((t) => t.variableName === variableName);
return varTemplates.find((t) => t.isDefault) || varTemplates[0];
},
[templates]
);
/**
* Get all available variable definitions
*/
const getAvailableVariables = useCallback((): VariableDefinition[] => {
return VARIABLE_DEFINITIONS;
}, []);
/**
* Get definition for a specific variable
*/
const getVariableDefinition = useCallback(
(variableName: KnownVariableName): VariableDefinition | undefined => {
return VARIABLE_DEFINITIONS.find((d) => d.variableName === variableName);
},
[]
);
return {
templates,
loading,
error,
refresh,
createTemplate,
updateTemplate,
deleteTemplate,
setDefault,
getTemplatesGroupedByVariable,
getTemplatesForVariable,
getDefaultTemplate,
getAvailableVariables,
getVariableDefinition,
};
}Step 2: Verify no TypeScript errors
Run: pnpm build:check Expected: Build passes with no type errors
Step 3: Commit
git add src/features/email-schedules/hooks/useVariableTemplates.ts
git commit -m "feat(hook): add useVariableTemplates hook"Task 7: Create SnippetTemplatesTab Component
Files:
- Create:
src/features/email-schedules/components/SnippetTemplatesTab.tsx
Step 1: Create component
import { useState } from "react";
import { useSnippetTemplates } from "../hooks/useSnippetTemplates";
import type { SnippetTemplate, SnippetTemplateInput } from "../types";
import { SnippetTemplateModal } from "./SnippetTemplateModal";
// Icons
const PlusIcon = () => (
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path d="M12 4v16m8-8H4" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg>
);
const PencilIcon = () => (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg>
);
const TrashIcon = () => (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg>
);
const SearchIcon = () => (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg>
);
const TagIcon = () => (
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg>
);
function formatDate(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "-";
return date.toLocaleDateString("en-AU", {
day: "numeric",
month: "short",
year: "numeric",
});
}
export function SnippetTemplatesTab() {
const {
templates,
allTags,
loading,
error,
refresh,
createTemplate,
updateTemplate,
deleteTemplate,
filterByTag,
searchTemplates,
} = useSnippetTemplates();
const [modalOpen, setModalOpen] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<SnippetTemplate | null>(null);
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [submitting, setSubmitting] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const handleCreate = () => {
setEditingTemplate(null);
setModalOpen(true);
};
const handleEdit = (template: SnippetTemplate) => {
setEditingTemplate(template);
setModalOpen(true);
};
const handleDelete = async (id: string) => {
const confirmed = window.confirm("Delete this snippet template? This cannot be undone.");
if (!confirmed) return;
setDeletingId(id);
try {
await deleteTemplate(id);
} catch (err) {
console.error("Failed to delete template", err);
alert(err instanceof Error ? err.message : "Failed to delete template");
} finally {
setDeletingId(null);
}
};
const handleSubmit = async (input: SnippetTemplateInput) => {
setSubmitting(true);
try {
if (editingTemplate) {
await updateTemplate(editingTemplate.id, input);
} else {
await createTemplate(input);
}
setModalOpen(false);
setEditingTemplate(null);
} catch (err) {
console.error("Failed to save template", err);
alert(err instanceof Error ? err.message : "Failed to save template");
} finally {
setSubmitting(false);
}
};
const handleTagFilter = async (tag: string | null) => {
setSelectedTag(tag);
setSearchQuery("");
await filterByTag(tag);
};
const handleSearch = async (query: string) => {
setSearchQuery(query);
setSelectedTag(null);
await searchTemplates(query);
};
if (error) {
return (
<div className="rounded-lg border border-red-200 bg-red-50 p-4">
<p className="text-sm text-red-700">{error}</p>
<button
className="mt-2 text-sm text-red-600 underline hover:no-underline"
type="button"
onClick={() => void refresh()}
>
Try again
</button>
</div>
);
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-default-800">Snippet Templates</h3>
<p className="text-sm text-default-500">
Reusable email content fragments that can be inserted into emails
</p>
</div>
<button
className="btn bg-primary text-white inline-flex items-center gap-2"
type="button"
onClick={handleCreate}
>
<PlusIcon />
New Snippet
</button>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
{/* Search */}
<div className="relative flex-1 min-w-[200px] max-w-[300px]">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-default-400">
<SearchIcon />
</span>
<input
className="form-input pl-9 w-full"
placeholder="Search snippets..."
type="text"
value={searchQuery}
onChange={(e) => void handleSearch(e.target.value)}
/>
</div>
{/* Tag filter */}
<select
className="form-input w-auto"
value={selectedTag ?? ""}
onChange={(e) => void handleTagFilter(e.target.value || null)}
>
<option value="">All Tags</option>
{allTags.map((tag) => (
<option key={tag} value={tag}>
{tag}
</option>
))}
</select>
</div>
{/* Table */}
<div className="overflow-x-auto rounded-lg border border-default-200">
<table className="w-full">
<thead className="bg-default-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-default-500">
Name
</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-default-500">
Tags
</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-default-500">
Updated
</th>
<th className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-default-500">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-default-200">
{loading ? (
<tr>
<td className="px-4 py-8 text-center text-default-500" colSpan={4}>
Loading...
</td>
</tr>
) : templates.length === 0 ? (
<tr>
<td className="px-4 py-8 text-center text-default-500" colSpan={4}>
No snippet templates found. Create one to get started.
</td>
</tr>
) : (
templates.map((template) => (
<tr key={template.id} className="hover:bg-default-50">
<td className="px-4 py-3">
<div>
<p className="font-medium text-default-800">{template.name}</p>
{template.subject && (
<p className="text-sm text-default-500 truncate max-w-[300px]">
Subject: {template.subject}
</p>
)}
</div>
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{template.tags.length > 0 ? (
template.tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 rounded-full bg-default-100 px-2 py-0.5 text-xs text-default-600"
>
<TagIcon />
{tag}
</span>
))
) : (
<span className="text-sm text-default-400">No tags</span>
)}
</div>
</td>
<td className="px-4 py-3 text-sm text-default-500">
{formatDate(template.updatedAt)}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<button
className="rounded p-1.5 text-default-500 hover:bg-default-100 hover:text-primary"
title="Edit"
type="button"
onClick={() => handleEdit(template)}
>
<PencilIcon />
</button>
<button
className="rounded p-1.5 text-default-500 hover:bg-red-50 hover:text-red-600"
disabled={deletingId === template.id}
title="Delete"
type="button"
onClick={() => void handleDelete(template.id)}
>
{deletingId === template.id ? (
<span className="animate-spin">⏳</span>
) : (
<TrashIcon />
)}
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Modal */}
<SnippetTemplateModal
editingTemplate={editingTemplate}
existingTags={allTags}
isOpen={modalOpen}
submitting={submitting}
onClose={() => {
setModalOpen(false);
setEditingTemplate(null);
}}
onSubmit={handleSubmit}
/>
</div>
);
}Step 2: Verify no TypeScript errors
Run: pnpm build:check Expected: Build passes (SnippetTemplateModal doesn't exist yet - we'll create it in the next task)
Step 3: Commit
git add src/features/email-schedules/components/SnippetTemplatesTab.tsx
git commit -m "feat(ui): add SnippetTemplatesTab component"Task 8: Create SnippetTemplateModal Component
Files:
- Create:
src/features/email-schedules/components/SnippetTemplateModal.tsx
Step 1: Create component
import { useEffect, useState, type FormEvent } from "react";
import type { SnippetTemplate, SnippetTemplateInput } from "../types";
type Props = {
isOpen: boolean;
editingTemplate: SnippetTemplate | null;
existingTags: string[];
submitting: boolean;
onSubmit: (input: SnippetTemplateInput) => Promise<void>;
onClose: () => void;
};
type FormState = {
name: string;
subject: string;
body: string;
tags: string[];
newTag: string;
};
function createDefaultForm(): FormState {
return {
name: "",
subject: "",
body: "",
tags: [],
newTag: "",
};
}
const TagIcon = () => (
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg>
);
const XIcon = () => (
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path d="M6 18L18 6M6 6l12 12" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg>
);
export function SnippetTemplateModal({
isOpen,
editingTemplate,
existingTags,
submitting,
onSubmit,
onClose,
}: Props) {
const [formState, setFormState] = useState<FormState>(createDefaultForm());
useEffect(() => {
if (!isOpen) return;
if (editingTemplate) {
setFormState({
name: editingTemplate.name,
subject: editingTemplate.subject ?? "",
body: editingTemplate.body,
tags: editingTemplate.tags,
newTag: "",
});
} else {
setFormState(createDefaultForm());
}
}, [editingTemplate, isOpen]);
const handleSubmit = async (event?: FormEvent<HTMLFormElement>) => {
event?.preventDefault();
const input: SnippetTemplateInput = {
name: formState.name,
subject: formState.subject || null,
body: formState.body,
tags: formState.tags,
};
await onSubmit(input);
};
const handleAddTag = () => {
const tag = formState.newTag.trim().toLowerCase();
if (tag && !formState.tags.includes(tag)) {
setFormState((prev) => ({
...prev,
tags: [...prev.tags, tag],
newTag: "",
}));
}
};
const handleRemoveTag = (tag: string) => {
setFormState((prev) => ({
...prev,
tags: prev.tags.filter((t) => t !== tag),
}));
};
const handleSelectExistingTag = (tag: string) => {
if (!formState.tags.includes(tag)) {
setFormState((prev) => ({
...prev,
tags: [...prev.tags, tag],
}));
}
};
const handleClose = () => {
setFormState(createDefaultForm());
onClose();
};
if (!isOpen) return null;
const availableExistingTags = existingTags.filter(
(tag) => !formState.tags.includes(tag)
);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
aria-label="Close modal"
className="absolute inset-0 bg-black/50"
role="button"
tabIndex={0}
onClick={handleClose}
onKeyDown={(e) => {
if (e.key === "Escape") handleClose();
}}
/>
<div className="relative z-10 w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-lg bg-white shadow-xl">
<div className="sticky top-0 flex items-center justify-between border-b border-default-200 bg-white px-6 py-4">
<h3 className="text-lg font-semibold text-default-800">
{editingTemplate ? "Edit Snippet Template" : "New Snippet Template"}
</h3>
<button
aria-label="Close"
className="rounded-full p-2 hover:bg-default-100"
type="button"
onClick={handleClose}
>
<svg
className="h-5 w-5 text-default-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M6 18L18 6M6 6l12 12"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
</button>
</div>
<form className="p-6 space-y-4" onSubmit={handleSubmit}>
{/* Name */}
<div className="space-y-1">
<label className="text-sm font-medium text-default-700">
Template Name <span className="text-red-500">*</span>
</label>
<input
required
className="form-input"
placeholder="e.g., Weekly Intro, Safety Notice"
type="text"
value={formState.name}
onChange={(e) =>
setFormState((prev) => ({ ...prev, name: e.target.value }))
}
/>
</div>
{/* Subject (optional) */}
<div className="space-y-1">
<label className="text-sm font-medium text-default-700">
Subject Line (optional)
</label>
<input
className="form-input"
placeholder="Optional subject to apply when inserting"
type="text"
value={formState.subject}
onChange={(e) =>
setFormState((prev) => ({ ...prev, subject: e.target.value }))
}
/>
<p className="text-xs text-default-500">
If provided, this will replace the email subject when the snippet is inserted.
</p>
</div>
{/* Body */}
<div className="space-y-1">
<label className="text-sm font-medium text-default-700">
Content <span className="text-red-500">*</span>
</label>
<textarea
required
className="form-input min-h-[200px] font-mono text-sm"
placeholder="Enter HTML content for the snippet..."
value={formState.body}
onChange={(e) =>
setFormState((prev) => ({ ...prev, body: e.target.value }))
}
/>
<p className="text-xs text-default-500">
HTML is supported. Content will be inserted at cursor position in the email editor.
</p>
</div>
{/* Tags */}
<div className="space-y-2">
<label className="text-sm font-medium text-default-700">Tags</label>
{/* Current tags */}
<div className="flex flex-wrap gap-2">
{formState.tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-3 py-1 text-sm text-primary"
>
<TagIcon />
{tag}
<button
className="ml-1 rounded-full p-0.5 hover:bg-primary/20"
type="button"
onClick={() => handleRemoveTag(tag)}
>
<XIcon />
</button>
</span>
))}
{formState.tags.length === 0 && (
<span className="text-sm text-default-400">No tags added</span>
)}
</div>
{/* Add new tag */}
<div className="flex items-center gap-2">
<input
className="form-input flex-1"
placeholder="Add a new tag..."
type="text"
value={formState.newTag}
onChange={(e) =>
setFormState((prev) => ({ ...prev, newTag: e.target.value }))
}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddTag();
}
}}
/>
<button
className="btn border border-default-200 text-default-700"
type="button"
onClick={handleAddTag}
>
Add Tag
</button>
</div>
{/* Existing tags to add */}
{availableExistingTags.length > 0 && (
<div className="space-y-1">
<p className="text-xs text-default-500">Or select an existing tag:</p>
<div className="flex flex-wrap gap-1">
{availableExistingTags.map((tag) => (
<button
key={tag}
className="inline-flex items-center gap-1 rounded-full border border-default-200 px-2.5 py-0.5 text-xs text-default-600 hover:bg-default-50"
type="button"
onClick={() => handleSelectExistingTag(tag)}
>
<TagIcon />
{tag}
</button>
))}
</div>
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-3 border-t border-default-200 pt-4">
<button
className="btn border border-default-200 text-default-700"
type="button"
onClick={handleClose}
>
Cancel
</button>
<button
className="btn bg-primary text-white"
disabled={submitting}
type="submit"
>
{submitting
? "Saving..."
: editingTemplate
? "Update Template"
: "Create Template"}
</button>
</div>
</form>
</div>
</div>
);
}Step 2: Verify no TypeScript errors
Run: pnpm build:check Expected: Build passes with no type errors
Step 3: Commit
git add src/features/email-schedules/components/SnippetTemplateModal.tsx
git commit -m "feat(ui): add SnippetTemplateModal component"Task 9: Create VariableTemplatesTab Component
Files:
- Create:
src/features/email-schedules/components/VariableTemplatesTab.tsx
Step 1: Create component
import { useState } from "react";
import { useVariableTemplates } from "../hooks/useVariableTemplates";
import type { VariableTemplate, VariableTemplateInput, KnownVariableName } from "../types";
import { VariableTemplateModal } from "./VariableTemplateModal";
// Icons
const PlusIcon = () => (
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path d="M12 4v16m8-8H4" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg>
);
const PencilIcon = () => (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg>
);
const TrashIcon = () => (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg>
);
const StarIcon = ({ filled }: { filled: boolean }) => (
<svg
className={`h-4 w-4 ${filled ? "fill-amber-400 text-amber-400" : "text-default-300"}`}
fill={filled ? "currentColor" : "none"}
stroke="currentColor"
viewBox="0 0 24 24"
>
<path d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg>
);
function formatDate(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "-";
return date.toLocaleDateString("en-AU", {
day: "numeric",
month: "short",
year: "numeric",
});
}
export function VariableTemplatesTab() {
const {
templates,
loading,
error,
refresh,
createTemplate,
updateTemplate,
deleteTemplate,
setDefault,
getTemplatesGroupedByVariable,
getAvailableVariables,
getVariableDefinition,
} = useVariableTemplates();
const [modalOpen, setModalOpen] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<VariableTemplate | null>(null);
const [selectedVariable, setSelectedVariable] = useState<KnownVariableName | null>(null);
const [submitting, setSubmitting] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [settingDefaultId, setSettingDefaultId] = useState<string | null>(null);
const availableVariables = getAvailableVariables();
const groupedTemplates = getTemplatesGroupedByVariable();
const handleCreate = (variableName: KnownVariableName) => {
setEditingTemplate(null);
setSelectedVariable(variableName);
setModalOpen(true);
};
const handleEdit = (template: VariableTemplate) => {
setEditingTemplate(template);
setSelectedVariable(template.variableName as KnownVariableName);
setModalOpen(true);
};
const handleDelete = async (id: string) => {
const confirmed = window.confirm("Delete this variable template? This cannot be undone.");
if (!confirmed) return;
setDeletingId(id);
try {
await deleteTemplate(id);
} catch (err) {
console.error("Failed to delete template", err);
alert(err instanceof Error ? err.message : "Failed to delete template");
} finally {
setDeletingId(null);
}
};
const handleSetDefault = async (id: string, variableName: string) => {
setSettingDefaultId(id);
try {
await setDefault(id, variableName);
} catch (err) {
console.error("Failed to set default", err);
alert(err instanceof Error ? err.message : "Failed to set default");
} finally {
setSettingDefaultId(null);
}
};
const handleSubmit = async (input: VariableTemplateInput) => {
setSubmitting(true);
try {
if (editingTemplate) {
await updateTemplate(editingTemplate.id, {
name: input.name,
htmlTemplate: input.htmlTemplate,
isDefault: input.isDefault,
});
} else {
await createTemplate(input);
}
setModalOpen(false);
setEditingTemplate(null);
setSelectedVariable(null);
} catch (err) {
console.error("Failed to save template", err);
alert(err instanceof Error ? err.message : "Failed to save template");
} finally {
setSubmitting(false);
}
};
if (error) {
return (
<div className="rounded-lg border border-red-200 bg-red-50 p-4">
<p className="text-sm text-red-700">{error}</p>
<button
className="mt-2 text-sm text-red-600 underline hover:no-underline"
type="button"
onClick={() => void refresh()}
>
Try again
</button>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h3 className="text-lg font-semibold text-default-800">Variable Format Templates</h3>
<p className="text-sm text-default-500">
Define HTML templates for dynamic email variables like {"{{summary_flow_meter}}"}
</p>
</div>
{loading ? (
<div className="py-8 text-center text-default-500">Loading...</div>
) : (
/* Variable sections */
availableVariables.map((varDef) => {
const varTemplates = groupedTemplates[varDef.variableName] ?? [];
return (
<div
key={varDef.variableName}
className="rounded-lg border border-default-200 overflow-hidden"
>
{/* Variable header */}
<div className="flex items-center justify-between bg-default-50 px-4 py-3 border-b border-default-200">
<div>
<h4 className="font-semibold text-default-800">
{varDef.displayName}
</h4>
<p className="text-sm text-default-500">
<code className="bg-default-100 px-1.5 py-0.5 rounded text-xs">
{`{{${varDef.variableName}}}`}
</code>
{" — "}
{varDef.description}
</p>
</div>
<button
className="btn border border-default-200 text-default-700 inline-flex items-center gap-2 text-sm"
type="button"
onClick={() => handleCreate(varDef.variableName)}
>
<PlusIcon />
Add Format
</button>
</div>
{/* Templates table */}
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-default-50/50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium uppercase tracking-wider text-default-500">
Format Name
</th>
<th className="px-4 py-2 text-left text-xs font-medium uppercase tracking-wider text-default-500">
Default
</th>
<th className="px-4 py-2 text-left text-xs font-medium uppercase tracking-wider text-default-500">
Updated
</th>
<th className="px-4 py-2 text-right text-xs font-medium uppercase tracking-wider text-default-500">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-default-200">
{varTemplates.length === 0 ? (
<tr>
<td
className="px-4 py-6 text-center text-default-500 text-sm"
colSpan={4}
>
No format templates defined. The default format will be used.
</td>
</tr>
) : (
varTemplates.map((template) => (
<tr key={template.id} className="hover:bg-default-50">
<td className="px-4 py-3">
<p className="font-medium text-default-800">
{template.name}
</p>
</td>
<td className="px-4 py-3">
<button
className="p-1 rounded hover:bg-default-100"
disabled={settingDefaultId === template.id || template.isDefault}
title={template.isDefault ? "This is the default format" : "Set as default"}
type="button"
onClick={() =>
!template.isDefault &&
void handleSetDefault(template.id, template.variableName)
}
>
{settingDefaultId === template.id ? (
<span className="animate-spin text-sm">⏳</span>
) : (
<StarIcon filled={template.isDefault} />
)}
</button>
</td>
<td className="px-4 py-3 text-sm text-default-500">
{formatDate(template.updatedAt)}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<button
className="rounded p-1.5 text-default-500 hover:bg-default-100 hover:text-primary"
title="Edit"
type="button"
onClick={() => handleEdit(template)}
>
<PencilIcon />
</button>
<button
className="rounded p-1.5 text-default-500 hover:bg-red-50 hover:text-red-600"
disabled={deletingId === template.id}
title="Delete"
type="button"
onClick={() => void handleDelete(template.id)}
>
{deletingId === template.id ? (
<span className="animate-spin text-sm">⏳</span>
) : (
<TrashIcon />
)}
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
})
)}
{/* Modal */}
{selectedVariable && (
<VariableTemplateModal
editingTemplate={editingTemplate}
isOpen={modalOpen}
submitting={submitting}
variableDefinition={getVariableDefinition(selectedVariable)}
variableName={selectedVariable}
onClose={() => {
setModalOpen(false);
setEditingTemplate(null);
setSelectedVariable(null);
}}
onSubmit={handleSubmit}
/>
)}
</div>
);
}Step 2: Verify no TypeScript errors
Run: pnpm build:check Expected: Build passes (VariableTemplateModal doesn't exist yet - we'll create it next)
Step 3: Commit
git add src/features/email-schedules/components/VariableTemplatesTab.tsx
git commit -m "feat(ui): add VariableTemplatesTab component"Task 10: Create VariableTemplateModal Component
Files:
- Create:
src/features/email-schedules/components/VariableTemplateModal.tsx
Step 1: Create component
import { useEffect, useState, type FormEvent } from "react";
import type {
VariableTemplate,
VariableTemplateInput,
VariableDefinition,
KnownVariableName,
} from "../types";
import { DEFAULT_FLOW_METER_TEMPLATE } from "../services/variableTemplateService";
type Props = {
isOpen: boolean;
variableName: KnownVariableName;
variableDefinition: VariableDefinition | undefined;
editingTemplate: VariableTemplate | null;
submitting: boolean;
onSubmit: (input: VariableTemplateInput) => Promise<void>;
onClose: () => void;
};
type FormState = {
name: string;
htmlTemplate: string;
isDefault: boolean;
};
function getDefaultTemplate(variableName: KnownVariableName): string {
switch (variableName) {
case "summary_flow_meter":
return DEFAULT_FLOW_METER_TEMPLATE;
default:
return "";
}
}
function createDefaultForm(variableName: KnownVariableName): FormState {
return {
name: "",
htmlTemplate: getDefaultTemplate(variableName),
isDefault: false,
};
}
const ChevronRightIcon = () => (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path d="M9 5l7 7-7 7" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg>
);
export function VariableTemplateModal({
isOpen,
variableName,
variableDefinition,
editingTemplate,
submitting,
onSubmit,
onClose,
}: Props) {
const [formState, setFormState] = useState<FormState>(createDefaultForm(variableName));
const [showFieldPanel, setShowFieldPanel] = useState(true);
useEffect(() => {
if (!isOpen) return;
if (editingTemplate) {
setFormState({
name: editingTemplate.name,
htmlTemplate: editingTemplate.htmlTemplate,
isDefault: editingTemplate.isDefault,
});
} else {
setFormState(createDefaultForm(variableName));
}
}, [editingTemplate, isOpen, variableName]);
const handleSubmit = async (event?: FormEvent<HTMLFormElement>) => {
event?.preventDefault();
const input: VariableTemplateInput = {
variableName,
name: formState.name,
htmlTemplate: formState.htmlTemplate,
isDefault: formState.isDefault,
};
await onSubmit(input);
};
const handleClose = () => {
setFormState(createDefaultForm(variableName));
onClose();
};
const insertField = (fieldPath: string) => {
const placeholder = `{{${fieldPath}}}`;
setFormState((prev) => ({
...prev,
htmlTemplate: prev.htmlTemplate + placeholder,
}));
};
const insertEachBlock = (fieldPath: string) => {
const block = `{{#each ${fieldPath}}}\n <tr>\n <!-- Add fields here -->\n </tr>\n{{/each}}`;
setFormState((prev) => ({
...prev,
htmlTemplate: prev.htmlTemplate + "\n" + block,
}));
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
aria-label="Close modal"
className="absolute inset-0 bg-black/50"
role="button"
tabIndex={0}
onClick={handleClose}
onKeyDown={(e) => {
if (e.key === "Escape") handleClose();
}}
/>
<div className="relative z-10 w-full max-w-5xl max-h-[90vh] overflow-y-auto rounded-lg bg-white shadow-xl">
<div className="sticky top-0 flex items-center justify-between border-b border-default-200 bg-white px-6 py-4 z-10">
<div>
<h3 className="text-lg font-semibold text-default-800">
{editingTemplate ? "Edit Format Template" : "New Format Template"}
</h3>
<p className="text-sm text-default-500">
Variable: <code className="bg-default-100 px-1.5 py-0.5 rounded text-xs">{`{{${variableName}}}`}</code>
</p>
</div>
<button
aria-label="Close"
className="rounded-full p-2 hover:bg-default-100"
type="button"
onClick={handleClose}
>
<svg
className="h-5 w-5 text-default-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M6 18L18 6M6 6l12 12"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
</button>
</div>
<form className="p-6 space-y-4" onSubmit={handleSubmit}>
{/* Name */}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1">
<label className="text-sm font-medium text-default-700">
Template Name <span className="text-red-500">*</span>
</label>
<input
required
className="form-input"
placeholder="e.g., Table Format, Compact List"
type="text"
value={formState.name}
onChange={(e) =>
setFormState((prev) => ({ ...prev, name: e.target.value }))
}
/>
</div>
<div className="space-y-1 flex items-end">
<label className="flex items-center gap-2 cursor-pointer">
<input
checked={formState.isDefault}
className="form-checkbox"
type="checkbox"
onChange={(e) =>
setFormState((prev) => ({ ...prev, isDefault: e.target.checked }))
}
/>
<span className="text-sm text-default-700">Set as default format</span>
</label>
</div>
</div>
{/* Editor with field panel */}
<div className="grid gap-4 lg:grid-cols-3">
{/* Field panel */}
{showFieldPanel && variableDefinition && (
<div className="lg:col-span-1 border border-default-200 rounded-lg overflow-hidden">
<div className="bg-default-50 px-3 py-2 border-b border-default-200">
<h4 className="text-sm font-medium text-default-700">Available Fields</h4>
<p className="text-xs text-default-500">Click to insert</p>
</div>
<div className="p-2 max-h-[400px] overflow-y-auto">
{variableDefinition.fields.map((field) => (
<div key={field.name} className="mb-2">
{field.type === "array" ? (
<div className="rounded border border-default-200 overflow-hidden">
<div
className="flex items-center justify-between px-2 py-1.5 bg-default-50 cursor-pointer hover:bg-default-100"
onClick={() => insertEachBlock(field.name)}
>
<div>
<code className="text-xs text-primary font-medium">{field.name}</code>
<span className="ml-1.5 text-xs text-default-500">(array)</span>
</div>
<span className="text-xs text-default-400">+ each</span>
</div>
{field.arrayItemFields && (
<div className="p-1.5 space-y-1 bg-white">
{field.arrayItemFields.map((subField) => (
<button
key={subField.name}
className="w-full text-left px-2 py-1 rounded text-xs hover:bg-default-50"
type="button"
onClick={() => insertField(subField.name)}
>
<code className="text-primary">{subField.name}</code>
<span className="ml-1 text-default-500">({subField.type})</span>
</button>
))}
</div>
)}
</div>
) : (
<button
className="w-full text-left px-2 py-1.5 rounded hover:bg-default-50"
type="button"
onClick={() => insertField(field.name)}
>
<code className="text-xs text-primary font-medium">{field.name}</code>
<span className="ml-1.5 text-xs text-default-500">({field.type})</span>
<p className="text-xs text-default-400 mt-0.5">{field.description}</p>
</button>
)}
</div>
))}
</div>
</div>
)}
{/* HTML editor */}
<div className={`space-y-1 ${showFieldPanel ? "lg:col-span-2" : "lg:col-span-3"}`}>
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-default-700">
HTML Template (Handlebars) <span className="text-red-500">*</span>
</label>
<button
className="text-xs text-default-500 hover:text-default-700 flex items-center gap-1"
type="button"
onClick={() => setShowFieldPanel(!showFieldPanel)}
>
{showFieldPanel ? "Hide" : "Show"} Fields
<ChevronRightIcon />
</button>
</div>
<textarea
required
className="form-input min-h-[400px] font-mono text-sm"
placeholder="Enter Handlebars HTML template..."
value={formState.htmlTemplate}
onChange={(e) =>
setFormState((prev) => ({ ...prev, htmlTemplate: e.target.value }))
}
/>
<p className="text-xs text-default-500">
Use Handlebars syntax: {"{{field_name}}"} for values, {"{{#each array}}...{{/each}}"} for loops
</p>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-3 border-t border-default-200 pt-4">
<button
className="btn border border-default-200 text-default-700"
type="button"
onClick={handleClose}
>
Cancel
</button>
<button
className="btn bg-primary text-white"
disabled={submitting}
type="submit"
>
{submitting
? "Saving..."
: editingTemplate
? "Update Template"
: "Create Template"}
</button>
</div>
</form>
</div>
</div>
);
}Step 2: Verify no TypeScript errors
Run: pnpm build:check Expected: Build passes with no type errors
Step 3: Commit
git add src/features/email-schedules/components/VariableTemplateModal.tsx
git commit -m "feat(ui): add VariableTemplateModal component with field panel"Task 11: Integrate Tabs into Email Schedules Page
Files:
- Modify:
src/app/(admin)/(pages)/email-schedules/index.tsx
Step 1: Add tab state and imports
At the top of the file, add imports:
import { SnippetTemplatesTab } from "@/features/email-schedules/components/SnippetTemplatesTab";
import { VariableTemplatesTab } from "@/features/email-schedules/components/VariableTemplatesTab";Step 2: Add tab state after existing useState hooks
Find the line with const [submitting, setSubmitting] = useState(false); and add after it:
const [activeTab, setActiveTab] = useState<"schedules" | "snippets" | "variables">("schedules");Step 3: Add tab navigation before stats cards
Replace the {/* Info Banner */} section with tab navigation:
{/* Tab Navigation */}
<div className="mb-6 border-b border-default-200">
<nav className="flex gap-6" aria-label="Tabs">
<button
className={`pb-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === "schedules"
? "border-primary text-primary"
: "border-transparent text-default-500 hover:text-default-700"
}`}
type="button"
onClick={() => setActiveTab("schedules")}
>
Schedules
</button>
<button
className={`pb-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === "snippets"
? "border-primary text-primary"
: "border-transparent text-default-500 hover:text-default-700"
}`}
type="button"
onClick={() => setActiveTab("snippets")}
>
Snippets
</button>
<button
className={`pb-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === "variables"
? "border-primary text-primary"
: "border-transparent text-default-500 hover:text-default-700"
}`}
type="button"
onClick={() => setActiveTab("variables")}
>
Variable Formats
</button>
</nav>
</div>Step 4: Wrap existing content in conditional rendering
Wrap the existing page content (Info Banner, Stats Cards, Action Buttons, Table) with:
{activeTab === "schedules" && (
<>
{/* Info Banner */}
{/* ... existing content ... */}
</>
)}
{activeTab === "snippets" && <SnippetTemplatesTab />}
{activeTab === "variables" && <VariableTemplatesTab />}Step 5: Verify no TypeScript errors
Run: pnpm build:check Expected: Build passes with no type errors
Step 6: Run dev server and test
Run: pnpm dev Expected: Navigate to /email-schedules, tabs should be visible and switch content
Step 7: Commit
git add src/app/\\(admin\\)/\\(pages\\)/email-schedules/index.tsx
git commit -m "feat(page): integrate template tabs into email schedules page"Task 12: Create TemplateInsertPanel Component for ComposeModal
Files:
- Create:
src/features/email-schedules/components/TemplateInsertPanel.tsx
Step 1: Create component
import { useState, useEffect } from "react";
import { SnippetTemplateService } from "../services/snippetTemplateService";
import type { SnippetTemplate } from "../types";
type Props = {
isOpen: boolean;
onClose: () => void;
onInsert: (content: string, subject?: string | null) => void;
};
const SearchIcon = () => (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg>
);
const EyeIcon = () => (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
<path d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg>
);
const PlusIcon = () => (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path d="M12 4v16m8-8H4" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg>
);
const TagIcon = () => (
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg>
);
export function TemplateInsertPanel({ isOpen, onClose, onInsert }: Props) {
const [templates, setTemplates] = useState<SnippetTemplate[]>([]);
const [allTags, setAllTags] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [previewTemplate, setPreviewTemplate] = useState<SnippetTemplate | null>(null);
useEffect(() => {
if (!isOpen) return;
const loadTemplates = async () => {
setLoading(true);
try {
const [templatesData, tagsData] = await Promise.all([
SnippetTemplateService.list(),
SnippetTemplateService.getAllTags(),
]);
setTemplates(templatesData);
setAllTags(tagsData);
} catch (err) {
console.error("Failed to load templates", err);
} finally {
setLoading(false);
}
};
void loadTemplates();
}, [isOpen]);
const handleTagFilter = async (tag: string | null) => {
setSelectedTag(tag);
setSearchQuery("");
setLoading(true);
try {
const data = tag
? await SnippetTemplateService.listByTag(tag)
: await SnippetTemplateService.list();
setTemplates(data);
} catch (err) {
console.error("Failed to filter templates", err);
} finally {
setLoading(false);
}
};
const handleSearch = async (query: string) => {
setSearchQuery(query);
setSelectedTag(null);
if (!query.trim()) {
setLoading(true);
try {
const data = await SnippetTemplateService.list();
setTemplates(data);
} finally {
setLoading(false);
}
return;
}
setLoading(true);
try {
const data = await SnippetTemplateService.search(query);
setTemplates(data);
} catch (err) {
console.error("Failed to search templates", err);
} finally {
setLoading(false);
}
};
const handleQuickInsert = (template: SnippetTemplate) => {
onInsert(template.body, template.subject);
onClose();
};
const handlePreviewInsert = () => {
if (previewTemplate) {
onInsert(previewTemplate.body, previewTemplate.subject);
onClose();
}
};
if (!isOpen) return null;
return (
<div className="absolute top-full left-0 right-0 mt-2 bg-white border border-default-200 rounded-lg shadow-lg z-50 max-h-[400px] overflow-hidden flex flex-col">
{/* Header */}
<div className="px-4 py-3 border-b border-default-200 flex items-center justify-between">
<h4 className="font-medium text-default-800">Insert Snippet</h4>
<button
className="p-1 rounded hover:bg-default-100"
type="button"
onClick={onClose}
>
<svg className="h-4 w-4 text-default-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path d="M6 18L18 6M6 6l12 12" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg>
</button>
</div>
{/* Filters */}
<div className="px-4 py-2 border-b border-default-200 flex items-center gap-2">
<select
className="form-input text-sm py-1.5 w-auto"
value={selectedTag ?? ""}
onChange={(e) => void handleTagFilter(e.target.value || null)}
>
<option value="">All Tags</option>
{allTags.map((tag) => (
<option key={tag} value={tag}>{tag}</option>
))}
</select>
<div className="relative flex-1">
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-default-400">
<SearchIcon />
</span>
<input
className="form-input text-sm py-1.5 pl-8 w-full"
placeholder="Search..."
type="text"
value={searchQuery}
onChange={(e) => void handleSearch(e.target.value)}
/>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="p-4 text-center text-default-500 text-sm">Loading...</div>
) : templates.length === 0 ? (
<div className="p-4 text-center text-default-500 text-sm">
No snippets found. Create snippets in the Snippets tab.
</div>
) : previewTemplate ? (
/* Preview mode */
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<button
className="text-sm text-default-500 hover:text-default-700"
type="button"
onClick={() => setPreviewTemplate(null)}
>
← Back to list
</button>
<button
className="btn bg-primary text-white text-sm py-1.5 px-3"
type="button"
onClick={handlePreviewInsert}
>
Insert
</button>
</div>
<h5 className="font-medium text-default-800 mb-1">{previewTemplate.name}</h5>
{previewTemplate.subject && (
<p className="text-sm text-default-500 mb-2">
Subject: {previewTemplate.subject}
</p>
)}
<div
className="prose prose-sm max-w-none border border-default-200 rounded p-3 bg-default-50"
dangerouslySetInnerHTML={{ __html: previewTemplate.body }}
/>
</div>
) : (
/* List mode */
<table className="w-full">
<tbody className="divide-y divide-default-200">
{templates.map((template) => (
<tr key={template.id} className="hover:bg-default-50">
<td className="px-4 py-2">
<p className="font-medium text-default-800 text-sm">{template.name}</p>
<div className="flex flex-wrap gap-1 mt-1">
{template.tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-0.5 rounded bg-default-100 px-1.5 py-0.5 text-xs text-default-500"
>
<TagIcon />
{tag}
</span>
))}
</div>
</td>
<td className="px-4 py-2 text-right">
<div className="flex items-center justify-end gap-1">
<button
className="p-1.5 rounded text-default-500 hover:bg-default-100 hover:text-default-700"
title="Preview"
type="button"
onClick={() => setPreviewTemplate(template)}
>
<EyeIcon />
</button>
<button
className="p-1.5 rounded text-default-500 hover:bg-primary/10 hover:text-primary"
title="Quick insert"
type="button"
onClick={() => handleQuickInsert(template)}
>
<PlusIcon />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}Step 2: Verify no TypeScript errors
Run: pnpm build:check Expected: Build passes with no type errors
Step 3: Commit
git add src/features/email-schedules/components/TemplateInsertPanel.tsx
git commit -m "feat(ui): add TemplateInsertPanel for ComposeModal"Task 13: Add Insert Snippet Button to RichTextEditor Toolbar
Files:
- Modify:
src/features/email-schedules/components/RichTextEditor.tsx
Step 1: Import TemplateInsertPanel
Add import at the top:
import { TemplateInsertPanel } from "./TemplateInsertPanel";Step 2: Add state for panel visibility
In the RichTextEditor component, after the existing state declarations, add:
const [snippetPanelOpen, setSnippetPanelOpen] = useState(false);Step 3: Add handler to insert content
Add after the existing handlers:
const handleInsertSnippet = useCallback(
(content: string, subject?: string | null) => {
if (!editor) return;
editor.chain().focus().insertContent(content).run();
// Note: Subject application would need to be handled by parent component
},
[editor]
);Step 4: Update Toolbar component to accept new props
Modify the Toolbar function signature:
function Toolbar({
editor,
onImageUpload,
uploading,
onOpenSnippetPanel,
snippetPanelOpen,
}: {
editor: Editor | null;
onImageUpload: () => void;
uploading: boolean;
onOpenSnippetPanel: () => void;
snippetPanelOpen: boolean;
}) {Step 5: Add snippet button to toolbar
In the Toolbar component, after the image upload button, add:
<div className="w-px h-5 bg-default-200 mx-1" />
<ToolbarButton
active={snippetPanelOpen}
title="Insert Snippet"
onClick={onOpenSnippetPanel}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
</ToolbarButton>Step 6: Update Toolbar usage in return statement
Update the Toolbar component usage:
<Toolbar
editor={editor}
uploading={uploading}
snippetPanelOpen={snippetPanelOpen}
onImageUpload={handleImageButtonClick}
onOpenSnippetPanel={() => setSnippetPanelOpen(!snippetPanelOpen)}
/>Step 7: Add TemplateInsertPanel to return
After the Toolbar component and before the editor wrapper div, add:
{snippetPanelOpen && (
<div className="relative">
<TemplateInsertPanel
isOpen={snippetPanelOpen}
onClose={() => setSnippetPanelOpen(false)}
onInsert={handleInsertSnippet}
/>
</div>
)}Step 8: Verify no TypeScript errors
Run: pnpm build:check Expected: Build passes with no type errors
Step 9: Commit
git add src/features/email-schedules/components/RichTextEditor.tsx
git commit -m "feat(ui): add snippet insert button to RichTextEditor toolbar"Task 14: Build and Final Verification
Step 1: Run full build
Run: pnpm build:check Expected: Build passes with no errors
Step 2: Run all tests
Run: pnpm test:unit Expected: All tests pass
Step 3: Run linter
Run: pnpm lint Expected: No linting errors (or acceptable warnings)
Step 4: Start dev server and manual test
Run: pnpm dev Expected:
- Navigate to
/email-schedules - All three tabs (Schedules, Snippets, Variable Formats) work
- Can create/edit/delete snippet templates
- Can create/edit/delete variable format templates
- Insert Snippet button appears in ComposeModal's rich text editor
- Can insert snippets into email body
Step 5: Final commit
git add -A
git commit -m "feat(email-templates): complete email templates implementation"Summary
This implementation plan adds:
- Database Layer - Two new tables with admin-only RLS policies
- Type Definitions - TypeScript interfaces for all template types
- Services - CRUD operations for snippet and variable templates
- Hooks - React hooks wrapping services with state management
- UI Components:
- SnippetTemplatesTab - List and manage snippet templates
- SnippetTemplateModal - Create/edit snippets with tag management
- VariableTemplatesTab - List and manage variable format templates
- VariableTemplateModal - Create/edit templates with field picker
- TemplateInsertPanel - Insert snippets in ComposeModal
- Integration - Tab navigation in email-schedules page, toolbar button in RichTextEditor
Not Implemented (per design doc "Out of Scope"):
- Variable parameter passing
- Edge Function modification for dynamic formats (separate task)
- Non-admin access to templates
- Template versioning/history