{{ theme.skipToContentLabel || 'Skip to content' }}

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:

sql
-- 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

bash
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):

typescript
// =============================================================================
// 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

bash
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:

typescript
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:

typescript
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

bash
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:

typescript
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:

typescript
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

bash
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

typescript
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

bash
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

typescript
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

bash
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

typescript
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

bash
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

typescript
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

bash
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

typescript
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

bash
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

typescript
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

bash
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:

typescript
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:

typescript
const [activeTab, setActiveTab] = useState<"schedules" | "snippets" | "variables">("schedules");

Step 3: Add tab navigation before stats cards

Replace the {/* Info Banner */} section with tab navigation:

typescript
{/* 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:

typescript
{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

bash
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

typescript
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

bash
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:

typescript
import { TemplateInsertPanel } from "./TemplateInsertPanel";

Step 2: Add state for panel visibility

In the RichTextEditor component, after the existing state declarations, add:

typescript
const [snippetPanelOpen, setSnippetPanelOpen] = useState(false);

Step 3: Add handler to insert content

Add after the existing handlers:

typescript
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:

typescript
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:

typescript
<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:

typescript
<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:

typescript
{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

bash
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

bash
git add -A
git commit -m "feat(email-templates): complete email templates implementation"

Summary

This implementation plan adds:

  1. Database Layer - Two new tables with admin-only RLS policies
  2. Type Definitions - TypeScript interfaces for all template types
  3. Services - CRUD operations for snippet and variable templates
  4. Hooks - React hooks wrapping services with state management
  5. 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
  6. 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