feat: full reply templates (search/reorder/duplicate/render + UI)

This commit is contained in:
Matthew Meszaros
2026-05-24 12:01:37 +00:00
parent 939f2160d9
commit 9bce2ad29e
19 changed files with 1427 additions and 160 deletions
+97 -2
View File
@@ -10,7 +10,8 @@ import (
"github.com/warmbly/warmbly/internal/models"
)
// ListTemplates lists all reply templates for the organization
// ListTemplates lists all reply templates for the organization, optionally
// filtered by `?q=` against name and subject.
// GET /templates
func (h *Handler) ListTemplates(c *gin.Context) {
orgID := middleware.GetOrganizationID(c)
@@ -19,7 +20,10 @@ func (h *Handler) ListTemplates(c *gin.Context) {
return
}
templates, xerr := h.TemplateService.List(c.Request.Context(), *orgID)
var q models.ListReplyTemplatesQuery
_ = c.ShouldBindQuery(&q)
templates, xerr := h.TemplateService.List(c.Request.Context(), *orgID, q.Search)
if xerr != nil {
errx.Handle(c, xerr)
return
@@ -134,3 +138,94 @@ func (h *Handler) DeleteTemplate(c *gin.Context) {
c.Status(http.StatusNoContent)
}
// DuplicateTemplate clones a template, appending " (copy)" to the name
// and placing it at the end of the org's list.
// POST /templates/:id/duplicate
func (h *Handler) DuplicateTemplate(c *gin.Context) {
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.Handle(c, errx.New(errx.BadRequest, "no organization selected"))
return
}
userID, err := middleware.GetUserUUID(c)
if err != nil {
errx.Handle(c, errx.ErrUser)
return
}
templateID, err := uuid.Parse(c.Param("id"))
if err != nil {
errx.Handle(c, errx.ErrUuid)
return
}
tmpl, xerr := h.TemplateService.Duplicate(c.Request.Context(), *orgID, userID, templateID)
if xerr != nil {
errx.Handle(c, xerr)
return
}
c.JSON(http.StatusOK, tmpl)
}
// ReorderTemplates reassigns positions across the org's templates so they
// line up with the order in the request body.
// PATCH /templates/reorder
func (h *Handler) ReorderTemplates(c *gin.Context) {
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.Handle(c, errx.New(errx.BadRequest, "no organization selected"))
return
}
var data models.ReorderReplyTemplates
if err := c.ShouldBindJSON(&data); err != nil {
errx.Handle(c, errx.ErrInvalid)
return
}
if xerr := h.TemplateService.Reorder(c.Request.Context(), *orgID, data.IDs); xerr != nil {
errx.Handle(c, xerr)
return
}
templates, xerr := h.TemplateService.List(c.Request.Context(), *orgID, "")
if xerr != nil {
errx.Handle(c, xerr)
return
}
c.JSON(http.StatusOK, models.ReplyTemplatesResult{Data: templates})
}
// RenderTemplate expands {{.Key}} placeholders in subject + body fields
// using a caller-supplied variable map. Used by Unibox to preview a reply
// before scheduling the send.
// POST /templates/:id/render
func (h *Handler) RenderTemplate(c *gin.Context) {
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.Handle(c, errx.New(errx.BadRequest, "no organization selected"))
return
}
templateID, err := uuid.Parse(c.Param("id"))
if err != nil {
errx.Handle(c, errx.ErrUuid)
return
}
var data models.RenderReplyTemplateRequest
// Body is optional: missing/empty body just renders all placeholders empty.
_ = c.ShouldBindJSON(&data)
rendered, xerr := h.TemplateService.Render(c.Request.Context(), *orgID, templateID, data.Variables)
if xerr != nil {
errx.Handle(c, xerr)
return
}
c.JSON(http.StatusOK, rendered)
}
+3
View File
@@ -337,9 +337,12 @@ func Run(
{
templates.GET("", h.ListTemplates)
templates.POST("", h.CreateTemplate)
templates.PATCH("/reorder", h.ReorderTemplates)
templates.GET("/:id", h.GetTemplate)
templates.PATCH("/:id", h.UpdateTemplate)
templates.DELETE("/:id", h.DeleteTemplate)
templates.POST("/:id/duplicate", h.DuplicateTemplate)
templates.POST("/:id/render", h.RenderTemplate)
}
// CRM routes (require org)
+100 -5
View File
@@ -2,6 +2,7 @@ package template
import (
"context"
"strings"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/errx"
@@ -12,9 +13,12 @@ import (
type TemplateService interface {
Create(ctx context.Context, orgID, userID uuid.UUID, data *models.CreateReplyTemplate) (*models.ReplyTemplate, *errx.Error)
GetByID(ctx context.Context, orgID, templateID uuid.UUID) (*models.ReplyTemplate, *errx.Error)
List(ctx context.Context, orgID uuid.UUID) ([]models.ReplyTemplate, *errx.Error)
List(ctx context.Context, orgID uuid.UUID, search string) ([]models.ReplyTemplate, *errx.Error)
Update(ctx context.Context, orgID, templateID uuid.UUID, data *models.UpdateReplyTemplate) (*models.ReplyTemplate, *errx.Error)
Delete(ctx context.Context, orgID, templateID uuid.UUID) *errx.Error
Duplicate(ctx context.Context, orgID, userID, templateID uuid.UUID) (*models.ReplyTemplate, *errx.Error)
Reorder(ctx context.Context, orgID uuid.UUID, ids []uuid.UUID) *errx.Error
Render(ctx context.Context, orgID, templateID uuid.UUID, vars map[string]string) (*models.RenderedReplyTemplate, *errx.Error)
}
type templateService struct {
@@ -26,6 +30,7 @@ func NewService(repo repository.TemplateRepository) TemplateService {
}
func (s *templateService) Create(ctx context.Context, orgID, userID uuid.UUID, data *models.CreateReplyTemplate) (*models.ReplyTemplate, *errx.Error) {
data.Name = strings.TrimSpace(data.Name)
if data.Name == "" {
return nil, errx.New(errx.BadRequest, "name is required")
}
@@ -53,8 +58,8 @@ func (s *templateService) GetByID(ctx context.Context, orgID, templateID uuid.UU
return t, nil
}
func (s *templateService) List(ctx context.Context, orgID uuid.UUID) ([]models.ReplyTemplate, *errx.Error) {
templates, err := s.repo.List(ctx, orgID)
func (s *templateService) List(ctx context.Context, orgID uuid.UUID, search string) ([]models.ReplyTemplate, *errx.Error) {
templates, err := s.repo.List(ctx, orgID, search)
if err != nil {
return nil, errx.InternalError()
}
@@ -67,8 +72,15 @@ func (s *templateService) List(ctx context.Context, orgID uuid.UUID) ([]models.R
}
func (s *templateService) Update(ctx context.Context, orgID, templateID uuid.UUID, data *models.UpdateReplyTemplate) (*models.ReplyTemplate, *errx.Error) {
if data.Name != nil && len(*data.Name) > 255 {
return nil, errx.New(errx.BadRequest, "name must be at most 255 characters")
if data.Name != nil {
trimmed := strings.TrimSpace(*data.Name)
if trimmed == "" {
return nil, errx.New(errx.BadRequest, "name cannot be empty")
}
if len(trimmed) > 255 {
return nil, errx.New(errx.BadRequest, "name must be at most 255 characters")
}
data.Name = &trimmed
}
t, err := s.repo.Update(ctx, orgID, templateID, data)
@@ -89,3 +101,86 @@ func (s *templateService) Delete(ctx context.Context, orgID, templateID uuid.UUI
return nil
}
func (s *templateService) Duplicate(ctx context.Context, orgID, userID, templateID uuid.UUID) (*models.ReplyTemplate, *errx.Error) {
t, err := s.repo.Duplicate(ctx, orgID, userID, templateID)
if err != nil {
return nil, errx.InternalError()
}
if t == nil {
return nil, errx.ErrNotFound
}
return t, nil
}
func (s *templateService) Reorder(ctx context.Context, orgID uuid.UUID, ids []uuid.UUID) *errx.Error {
if len(ids) == 0 {
return errx.New(errx.BadRequest, "ids cannot be empty")
}
seen := make(map[uuid.UUID]struct{}, len(ids))
for _, id := range ids {
if _, dup := seen[id]; dup {
return errx.New(errx.BadRequest, "duplicate id in reorder list")
}
seen[id] = struct{}{}
}
if err := s.repo.Reorder(ctx, orgID, ids); err != nil {
return errx.InternalError()
}
return nil
}
// Render expands {{.Key}} placeholders in subject + body fields against the
// provided variable map. Missing keys are replaced with an empty string,
// matching the cold-email render semantics in internal/tasks/template.go.
func (s *templateService) Render(ctx context.Context, orgID, templateID uuid.UUID, vars map[string]string) (*models.RenderedReplyTemplate, *errx.Error) {
t, xerr := s.GetByID(ctx, orgID, templateID)
if xerr != nil {
return nil, xerr
}
return &models.RenderedReplyTemplate{
Subject: RenderString(t.Subject, vars),
BodyHTML: RenderString(t.BodyHTML, vars),
BodyPlain: RenderString(t.BodyPlain, vars),
}, nil
}
// RenderString replaces {{.Key}} placeholders in s with values from vars.
// Unknown keys are dropped (rendered as empty string) so previews don't
// leak the placeholder syntax to recipients.
func RenderString(s string, vars map[string]string) string {
if s == "" {
return s
}
out := s
for k, v := range vars {
out = strings.ReplaceAll(out, "{{."+k+"}}", v)
}
// Drop any remaining {{.X}} placeholders so users never see raw syntax.
out = stripPlaceholders(out)
return out
}
// stripPlaceholders removes any leftover {{.Anything}} tokens. It is
// intentionally permissive — anything between {{. and the next }} (no
// nested braces) is treated as a placeholder.
func stripPlaceholders(s string) string {
for {
start := strings.Index(s, "{{.")
if start < 0 {
return s
}
end := strings.Index(s[start:], "}}")
if end < 0 {
return s
}
s = s[:start] + s[start+end+2:]
}
}
+316
View File
@@ -0,0 +1,316 @@
package template
import (
"context"
"errors"
"testing"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
)
func TestRenderString_BasicSubstitution(t *testing.T) {
out := RenderString("Hi {{.FirstName}} at {{.Company}}", map[string]string{
"FirstName": "Ada",
"Company": "Acme",
})
if out != "Hi Ada at Acme" {
t.Fatalf("got %q", out)
}
}
func TestRenderString_MissingPlaceholdersStripped(t *testing.T) {
// Unknown placeholders must NOT leak the {{.Key}} syntax into output.
out := RenderString("Hi {{.FirstName}} from {{.Unknown}}!", map[string]string{
"FirstName": "Ada",
})
if out != "Hi Ada from !" {
t.Fatalf("got %q", out)
}
}
func TestRenderString_Empty(t *testing.T) {
if got := RenderString("", map[string]string{"a": "b"}); got != "" {
t.Fatalf("expected empty, got %q", got)
}
}
func TestRenderString_NoPlaceholdersUnchanged(t *testing.T) {
src := "Just a regular sentence."
if got := RenderString(src, nil); got != src {
t.Fatalf("got %q", got)
}
}
// fakeRepo is a minimal in-memory TemplateRepository for service tests.
// Only behaviors exercised by the tests are implemented faithfully.
type fakeRepo struct {
items map[uuid.UUID]*models.ReplyTemplate
listErr error
updateErr error
reorderHistory [][]uuid.UUID
}
func newFakeRepo() *fakeRepo {
return &fakeRepo{items: map[uuid.UUID]*models.ReplyTemplate{}}
}
func (f *fakeRepo) Create(_ context.Context, orgID, userID uuid.UUID, data *models.CreateReplyTemplate) (*models.ReplyTemplate, error) {
t := &models.ReplyTemplate{
ID: uuid.New(),
OrganizationID: orgID,
UserID: userID,
Name: data.Name,
Subject: data.Subject,
BodyHTML: data.BodyHTML,
BodyPlain: data.BodyPlain,
Position: len(f.items) + 1,
}
f.items[t.ID] = t
return t, nil
}
func (f *fakeRepo) GetByID(_ context.Context, orgID, templateID uuid.UUID) (*models.ReplyTemplate, error) {
t, ok := f.items[templateID]
if !ok || t.OrganizationID != orgID {
return nil, nil
}
return t, nil
}
func (f *fakeRepo) List(_ context.Context, orgID uuid.UUID, _ string) ([]models.ReplyTemplate, error) {
if f.listErr != nil {
return nil, f.listErr
}
out := []models.ReplyTemplate{}
for _, t := range f.items {
if t.OrganizationID == orgID {
out = append(out, *t)
}
}
return out, nil
}
func (f *fakeRepo) Update(_ context.Context, orgID, templateID uuid.UUID, data *models.UpdateReplyTemplate) (*models.ReplyTemplate, error) {
if f.updateErr != nil {
return nil, f.updateErr
}
t, ok := f.items[templateID]
if !ok || t.OrganizationID != orgID {
return nil, nil
}
if data.Name != nil {
t.Name = *data.Name
}
if data.Subject != nil {
t.Subject = *data.Subject
}
if data.BodyHTML != nil {
t.BodyHTML = *data.BodyHTML
}
if data.BodyPlain != nil {
t.BodyPlain = *data.BodyPlain
}
return t, nil
}
func (f *fakeRepo) Delete(_ context.Context, orgID, templateID uuid.UUID) error {
if t, ok := f.items[templateID]; ok && t.OrganizationID == orgID {
delete(f.items, templateID)
}
return nil
}
func (f *fakeRepo) Duplicate(ctx context.Context, orgID, userID, templateID uuid.UUID) (*models.ReplyTemplate, error) {
src, _ := f.GetByID(ctx, orgID, templateID)
if src == nil {
return nil, nil
}
return f.Create(ctx, orgID, userID, &models.CreateReplyTemplate{
Name: src.Name + " (copy)",
Subject: src.Subject,
BodyHTML: src.BodyHTML,
BodyPlain: src.BodyPlain,
})
}
func (f *fakeRepo) Reorder(_ context.Context, orgID uuid.UUID, ids []uuid.UUID) error {
f.reorderHistory = append(f.reorderHistory, ids)
for i, id := range ids {
if t, ok := f.items[id]; ok && t.OrganizationID == orgID {
t.Position = i + 1
}
}
return nil
}
func TestService_Create_TrimsAndRejectsEmpty(t *testing.T) {
svc := NewService(newFakeRepo())
ctx := context.Background()
orgID, userID := uuid.New(), uuid.New()
if _, xerr := svc.Create(ctx, orgID, userID, &models.CreateReplyTemplate{Name: " "}); xerr == nil {
t.Fatal("expected error for whitespace-only name")
}
t1, xerr := svc.Create(ctx, orgID, userID, &models.CreateReplyTemplate{Name: " Hello "})
if xerr != nil {
t.Fatalf("create failed: %v", xerr)
}
if t1.Name != "Hello" {
t.Fatalf("expected name trimmed to 'Hello', got %q", t1.Name)
}
}
func TestService_Update_RejectsEmptyAndOverlongName(t *testing.T) {
repo := newFakeRepo()
svc := NewService(repo)
ctx := context.Background()
orgID, userID := uuid.New(), uuid.New()
t1, _ := svc.Create(ctx, orgID, userID, &models.CreateReplyTemplate{Name: "Initial"})
empty := " "
if _, xerr := svc.Update(ctx, orgID, t1.ID, &models.UpdateReplyTemplate{Name: &empty}); xerr == nil {
t.Fatal("expected error for whitespace-only name")
}
long := make([]byte, 256)
for i := range long {
long[i] = 'a'
}
s := string(long)
if _, xerr := svc.Update(ctx, orgID, t1.ID, &models.UpdateReplyTemplate{Name: &s}); xerr == nil {
t.Fatal("expected error for >255 char name")
}
}
func TestService_Update_NotFound(t *testing.T) {
svc := NewService(newFakeRepo())
ctx := context.Background()
name := "x"
_, xerr := svc.Update(ctx, uuid.New(), uuid.New(), &models.UpdateReplyTemplate{Name: &name})
if xerr == nil || xerr.Code != errx.NotFound {
t.Fatalf("expected NotFound, got %v", xerr)
}
}
func TestService_List_InternalErrorBubbles(t *testing.T) {
repo := newFakeRepo()
repo.listErr = errors.New("boom")
svc := NewService(repo)
_, xerr := svc.List(context.Background(), uuid.New(), "")
if xerr == nil || xerr.Code != errx.Internal {
t.Fatalf("expected Internal, got %v", xerr)
}
}
func TestService_List_NilCoercedToEmptySlice(t *testing.T) {
svc := NewService(newFakeRepo())
out, xerr := svc.List(context.Background(), uuid.New(), "")
if xerr != nil {
t.Fatalf("unexpected err: %v", xerr)
}
if out == nil || len(out) != 0 {
t.Fatalf("expected empty slice, got %#v", out)
}
}
func TestService_Duplicate(t *testing.T) {
repo := newFakeRepo()
svc := NewService(repo)
ctx := context.Background()
orgID, userID := uuid.New(), uuid.New()
t1, _ := svc.Create(ctx, orgID, userID, &models.CreateReplyTemplate{Name: "Original", Subject: "Re:"})
dup, xerr := svc.Duplicate(ctx, orgID, userID, t1.ID)
if xerr != nil {
t.Fatalf("duplicate failed: %v", xerr)
}
if dup.ID == t1.ID {
t.Fatal("duplicate should have a fresh ID")
}
if dup.Name != "Original (copy)" {
t.Fatalf("expected name 'Original (copy)', got %q", dup.Name)
}
if dup.Subject != "Re:" {
t.Fatalf("subject should be cloned, got %q", dup.Subject)
}
}
func TestService_Reorder_RejectsEmpty(t *testing.T) {
svc := NewService(newFakeRepo())
if xerr := svc.Reorder(context.Background(), uuid.New(), nil); xerr == nil {
t.Fatal("expected error for nil ids")
}
}
func TestService_Reorder_RejectsDuplicates(t *testing.T) {
svc := NewService(newFakeRepo())
id := uuid.New()
if xerr := svc.Reorder(context.Background(), uuid.New(), []uuid.UUID{id, id}); xerr == nil {
t.Fatal("expected error for duplicate ids")
}
}
func TestService_Reorder_AppliesOrder(t *testing.T) {
repo := newFakeRepo()
svc := NewService(repo)
ctx := context.Background()
orgID, userID := uuid.New(), uuid.New()
a, _ := svc.Create(ctx, orgID, userID, &models.CreateReplyTemplate{Name: "A"})
b, _ := svc.Create(ctx, orgID, userID, &models.CreateReplyTemplate{Name: "B"})
c, _ := svc.Create(ctx, orgID, userID, &models.CreateReplyTemplate{Name: "C"})
if xerr := svc.Reorder(ctx, orgID, []uuid.UUID{c.ID, a.ID, b.ID}); xerr != nil {
t.Fatalf("reorder failed: %v", xerr)
}
got, _ := svc.GetByID(ctx, orgID, c.ID)
if got.Position != 1 {
t.Fatalf("expected c at pos 1, got %d", got.Position)
}
got, _ = svc.GetByID(ctx, orgID, b.ID)
if got.Position != 3 {
t.Fatalf("expected b at pos 3, got %d", got.Position)
}
}
func TestService_Render_ExpandsTemplate(t *testing.T) {
repo := newFakeRepo()
svc := NewService(repo)
ctx := context.Background()
orgID, userID := uuid.New(), uuid.New()
t1, _ := svc.Create(ctx, orgID, userID, &models.CreateReplyTemplate{
Name: "Hello",
Subject: "Re: {{.Topic}}",
BodyHTML: "<p>Hi {{.FirstName}}</p>",
BodyPlain: "Hi {{.FirstName}}",
})
out, xerr := svc.Render(ctx, orgID, t1.ID, map[string]string{
"Topic": "Pricing",
"FirstName": "Ada",
})
if xerr != nil {
t.Fatalf("render failed: %v", xerr)
}
if out.Subject != "Re: Pricing" {
t.Fatalf("subject: %q", out.Subject)
}
if out.BodyHTML != "<p>Hi Ada</p>" {
t.Fatalf("html: %q", out.BodyHTML)
}
if out.BodyPlain != "Hi Ada" {
t.Fatalf("plain: %q", out.BodyPlain)
}
}
func TestService_Render_NotFound(t *testing.T) {
svc := NewService(newFakeRepo())
_, xerr := svc.Render(context.Background(), uuid.New(), uuid.New(), nil)
if xerr == nil || xerr.Code != errx.NotFound {
t.Fatalf("expected NotFound, got %v", xerr)
}
}
+26
View File
@@ -36,3 +36,29 @@ type UpdateReplyTemplate struct {
type ReplyTemplatesResult struct {
Data []ReplyTemplate `json:"data"`
}
// ListReplyTemplatesQuery accepts optional ?q= search filter that matches
// against name and subject (case-insensitive).
type ListReplyTemplatesQuery struct {
Search string `form:"q"`
}
// ReorderReplyTemplates carries the new ordering for an organization's
// reply templates. Every ID in IDs is repositioned in the listed order
// (1-indexed). Templates not in the list are left untouched.
type ReorderReplyTemplates struct {
IDs []uuid.UUID `json:"ids" binding:"required"`
}
// RenderReplyTemplateRequest expands template variables (e.g. {{.FirstName}})
// against an arbitrary key/value map. Used by Unibox to preview a reply.
type RenderReplyTemplateRequest struct {
Variables map[string]string `json:"variables"`
}
// RenderedReplyTemplate is the rendered output returned to the client.
type RenderedReplyTemplate struct {
Subject string `json:"subject"`
BodyHTML string `json:"body_html"`
BodyPlain string `json:"body_plain"`
}
+69 -8
View File
@@ -15,9 +15,11 @@ import (
type TemplateRepository interface {
Create(ctx context.Context, orgID, userID uuid.UUID, data *models.CreateReplyTemplate) (*models.ReplyTemplate, error)
GetByID(ctx context.Context, orgID, templateID uuid.UUID) (*models.ReplyTemplate, error)
List(ctx context.Context, orgID uuid.UUID) ([]models.ReplyTemplate, error)
List(ctx context.Context, orgID uuid.UUID, search string) ([]models.ReplyTemplate, error)
Update(ctx context.Context, orgID, templateID uuid.UUID, data *models.UpdateReplyTemplate) (*models.ReplyTemplate, error)
Delete(ctx context.Context, orgID, templateID uuid.UUID) error
Duplicate(ctx context.Context, orgID, userID, templateID uuid.UUID) (*models.ReplyTemplate, error)
Reorder(ctx context.Context, orgID uuid.UUID, ids []uuid.UUID) error
}
type templateRepository struct {
@@ -94,17 +96,25 @@ func (r *templateRepository) GetByID(ctx context.Context, orgID, templateID uuid
return t, err
}
// List retrieves reply templates for an organization with a hard limit to prevent unbounded queries
func (r *templateRepository) List(ctx context.Context, orgID uuid.UUID) ([]models.ReplyTemplate, error) {
query := `
// List retrieves reply templates for an organization with a hard limit to prevent unbounded queries.
// If search is non-empty, name and subject are matched case-insensitively.
func (r *templateRepository) List(ctx context.Context, orgID uuid.UUID, search string) ([]models.ReplyTemplate, error) {
args := []interface{}{orgID}
where := "organization_id = $1"
if s := strings.TrimSpace(search); s != "" {
args = append(args, "%"+s+"%")
where += " AND (name ILIKE $2 OR subject ILIKE $2)"
}
query := fmt.Sprintf(`
SELECT id, organization_id, user_id, name, subject, body_html, body_plain, position, created_at, updated_at
FROM reply_templates
WHERE organization_id = $1
ORDER BY position ASC
WHERE %s
ORDER BY position ASC, created_at ASC
LIMIT 500
`
`, where)
rows, err := r.db.Query(ctx, query, orgID)
rows, err := r.db.Query(ctx, query, args...)
if err != nil {
return nil, err
}
@@ -202,5 +212,56 @@ func (r *templateRepository) Delete(ctx context.Context, orgID, templateID uuid.
return err
}
// Duplicate copies an existing template under the calling user, appending
// " (copy)" to the name and placing it at the end of the org's list.
func (r *templateRepository) Duplicate(ctx context.Context, orgID, userID, templateID uuid.UUID) (*models.ReplyTemplate, error) {
src, err := r.GetByID(ctx, orgID, templateID)
if err != nil {
return nil, err
}
if src == nil {
return nil, nil
}
name := src.Name + " (copy)"
if len(name) > 255 {
name = name[:255]
}
return r.Create(ctx, orgID, userID, &models.CreateReplyTemplate{
Name: name,
Subject: src.Subject,
BodyHTML: src.BodyHTML,
BodyPlain: src.BodyPlain,
})
}
// Reorder updates positions to match the given ID order. IDs not owned by
// the org are silently ignored. The operation runs in a single transaction
// so a partial failure leaves positions unchanged.
func (r *templateRepository) Reorder(ctx context.Context, orgID uuid.UUID, ids []uuid.UUID) error {
if len(ids) == 0 {
return nil
}
tx, err := r.db.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
for i, id := range ids {
_, err := tx.Exec(ctx,
`UPDATE reply_templates SET position = $1, updated_at = NOW() WHERE id = $2 AND organization_id = $3`,
i+1, id, orgID,
)
if err != nil {
return err
}
}
return tx.Commit(ctx)
}
// Ensure the type satisfies the interface at compile time
var _ TemplateRepository = (*templateRepository)(nil)
+690 -113
View File
@@ -1,11 +1,26 @@
// Templates — gallery preview.
// Templates — reusable subject + body for replies and cold opens.
//
// The page is empty for now (no backend yet) but it shouldn't look
// like every other "coming soon" page. Instead we show a faux
// gallery of sample template cards so the user gets a sense of what
// templates look like before they build any.
// Layout mirrors the rest of the app (slate-900, hairline dividers, 12.5px
// text). A list of template rows on top, a modal editor that opens in
// place. Drag-style reordering is replaced with up/down chevrons so the
// page stays accessible without a dnd lib.
import { PlusIcon, MailOpenIcon, ReplyIcon, MousePointerClickIcon } from "lucide-react";
import React from "react";
import {
CopyIcon,
FileTextIcon,
Loader2Icon,
MoreHorizontalIcon,
PencilIcon,
PlusIcon,
SearchIcon,
TrashIcon,
XIcon,
ChevronUpIcon,
ChevronDownIcon,
} from "lucide-react";
import toast from "react-hot-toast";
import { AnimatePresence, motion } from "framer-motion";
import {
Page,
PageBody,
@@ -15,60 +30,57 @@ import {
StatStrip,
TopbarAction,
} from "@/components/layout/Page";
import { comingSoon } from "@/lib/helper/comingSoon";
import { Label, TextInput } from "@/components/ui/field";
import useTemplates from "@/lib/api/hooks/app/templates/useTemplates";
import useCreateTemplate from "@/lib/api/hooks/app/templates/useCreateTemplate";
import useUpdateTemplate from "@/lib/api/hooks/app/templates/useUpdateTemplate";
import useDeleteTemplate from "@/lib/api/hooks/app/templates/useDeleteTemplate";
import useDuplicateTemplate from "@/lib/api/hooks/app/templates/useDuplicateTemplate";
import useReorderTemplates from "@/lib/api/hooks/app/templates/useReorderTemplates";
import useClickOutside from "@/hooks/useClickOutside";
import { useConfirm } from "@/hooks/context/confirm";
import type Template from "@/lib/api/models/app/templates/Template";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
const SAMPLE_TEMPLATES = [
{
name: "Cold intro · Product",
subject: "Quick question, {{first_name}}",
preview:
"Hi {{first_name}}, I noticed {{company}} just shipped {{recent_announcement}}",
tag: "Sales",
used: 12,
},
{
name: "Follow-up · After 3 days",
subject: "Re: Quick question",
preview:
"Hey {{first_name}} — circling back on this. Worth a 15-min chat next Tue?",
tag: "Sales",
used: 8,
},
{
name: "Re-engagement · 30 days",
subject: "Still on your radar?",
preview:
"It's been a while — wanted to share a couple of new things at {{our_company}}…",
tag: "Nurture",
used: 4,
},
{
name: "Meeting confirm",
subject: "Looking forward to {{date}}",
preview:
"Just confirming our {{day}} call at {{time}}. Calendar invite attached.",
tag: "Ops",
used: 21,
},
{
name: "Resource share",
subject: "Thought you'd find this useful",
preview:
"Pulled together a short breakdown of {{topic}} you mentioned…",
tag: "Nurture",
used: 3,
},
{
name: "Renewal nudge",
subject: "Two weeks left on your plan",
preview:
"Quick heads up — your {{plan}} plan renews on {{date}}. Want to upgrade?",
tag: "Ops",
used: 6,
},
const VARIABLE_HINTS = [
"{{.FirstName}}",
"{{.LastName}}",
"{{.Email}}",
"{{.Company}}",
"{{.Phone}}",
];
export default function TemplatesPage() {
const [search, setSearch] = React.useState("");
const [debouncedSearch, setDebouncedSearch] = React.useState("");
React.useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search), 200);
return () => clearTimeout(t);
}, [search]);
const query = useTemplates(debouncedSearch || undefined);
const list = query.data ?? [];
const [editor, setEditor] = React.useState<
| { mode: "create" }
| { mode: "edit"; template: Template }
| null
>(null);
const lastEdited = React.useMemo(() => {
if (list.length === 0) return "—";
const max = list.reduce(
(acc, t) => Math.max(acc, new Date(t.updated_at).getTime()),
0,
);
if (!max) return "—";
return new Date(max).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}, [list]);
return (
<Page>
<PageTopbar
@@ -77,74 +89,639 @@ export default function TemplatesPage() {
>
<TopbarAction
icon={<PlusIcon className="w-3 h-3" />}
onClick={() => comingSoon("Templates")}
onClick={() => setEditor({ mode: "create" })}
>
New template
</TopbarAction>
</PageTopbar>
<StatStrip cols={4}>
<Stat label="Saved" value={0} sub="reusable drafts" />
<Stat label="Used this week" value={0} sub="campaign attaches" accent={false} />
<Stat label="Avg open" value="—%" sub="across templates" />
<Stat label="Avg reply" value="—%" sub="across templates" last />
<Stat label="Saved" value={list.length} sub="reusable drafts" />
<Stat label="Searching" value={debouncedSearch ? "yes" : "no"} sub="filter active" />
<Stat label="Last edited" value={lastEdited} sub="any template" />
<Stat label="Variables" value={VARIABLE_HINTS.length} sub="built-in" last />
</StatStrip>
<SectionBar label="Gallery preview" />
<PageBody className="px-5 py-5">
<div className="rounded-md border border-dashed border-slate-300 bg-slate-50/40 p-4 mb-4">
<p className="text-[12px] text-slate-700 leading-relaxed">
Templates aren't shipped yet — here's a peek at the shape.
Each card holds a subject, a preview, an audience tag and
a usage counter, so you can find the right one fast from
any campaign.
</p>
</div>
<div
aria-hidden
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 opacity-70 pointer-events-none select-none"
>
{SAMPLE_TEMPLATES.map((t, i) => (
<div
key={i}
className="rounded-md border border-slate-200 bg-white p-3 flex flex-col gap-2"
<div className="px-5 py-3 border-b border-slate-200/60 flex items-center gap-2">
<div className="relative flex-1 max-w-[360px]">
<SearchIcon className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-slate-400 pointer-events-none" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by name or subject…"
className="w-full h-7 pl-7 pr-7 rounded-md border border-slate-200 bg-white text-[12.5px] text-slate-900 placeholder:text-slate-400 outline-none transition-colors focus:border-sky-400 focus:ring-2 focus:ring-sky-100"
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
aria-label="Clear search"
className="absolute right-1 top-1/2 -translate-y-1/2 size-5 rounded text-slate-400 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center justify-center"
>
<div className="flex items-center gap-1.5">
<span className="text-[10px] uppercase tracking-[0.1em] font-medium text-slate-500 bg-slate-100 rounded-sm px-1 py-px">
{t.tag}
</span>
<span className="ml-auto font-mono text-[10px] text-slate-400 tabular-nums">
{t.used} uses
</span>
</div>
<div className="text-[12.5px] font-semibold text-slate-900 leading-snug">
{t.name}
</div>
<div className="text-[11.5px] text-slate-600 truncate">
{t.subject}
</div>
<div className="text-[11px] text-slate-400 leading-relaxed line-clamp-3">
{t.preview}
</div>
<div className="mt-1 flex items-center gap-3 text-[10.5px] text-slate-400 font-mono">
<span className="inline-flex items-center gap-1">
<MailOpenIcon className="w-2.5 h-2.5" />
%
</span>
<span className="inline-flex items-center gap-1">
<MousePointerClickIcon className="w-2.5 h-2.5" />
%
</span>
<span className="inline-flex items-center gap-1">
<ReplyIcon className="w-2.5 h-2.5" />
%
</span>
</div>
</div>
))}
<XIcon className="w-3 h-3" />
</button>
)}
</div>
<span className="text-[11px] text-slate-400 font-mono tabular-nums ml-auto">
{list.length} {list.length === 1 ? "template" : "templates"}
</span>
</div>
<SectionBar label={query.isPending ? "Loading…" : `${list.length} templates`} />
<PageBody className="px-5 py-5">
{query.isPending ? (
<SkeletonRows />
) : list.length === 0 ? (
debouncedSearch ? (
<EmptySearch
search={debouncedSearch}
onClear={() => setSearch("")}
/>
) : (
<EmptyState onCreate={() => setEditor({ mode: "create" })} />
)
) : (
<TemplateList
templates={list}
onEdit={(t) => setEditor({ mode: "edit", template: t })}
/>
)}
</PageBody>
<TemplateEditor
state={editor}
onClose={() => setEditor(null)}
/>
</Page>
);
}
function SkeletonRows() {
return (
<div className="space-y-2">
{[0, 1, 2].map((i) => (
<div key={i} className="h-16 rounded-md bg-slate-100 animate-pulse" />
))}
</div>
);
}
function EmptyState({ onCreate }: { onCreate: () => void }) {
return (
<div className="rounded-md border border-dashed border-slate-300 bg-slate-50/40 p-8 text-center">
<div className="mx-auto size-9 rounded-md bg-white border border-slate-200 flex items-center justify-center mb-3">
<FileTextIcon className="w-4 h-4 text-slate-400" />
</div>
<h3 className="text-[13px] font-semibold text-slate-900 mb-1">
No templates yet
</h3>
<p className="text-[12px] text-slate-500 max-w-md mx-auto mb-4 leading-relaxed">
Save the replies and openers you send over and over.
Drop variables like {VARIABLE_HINTS[0]} or {VARIABLE_HINTS[3]} so each
send picks up the right contact details.
</p>
<button
type="button"
onClick={onCreate}
className="h-7 px-3 rounded-md bg-slate-900 hover:bg-slate-800 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors"
>
<PlusIcon className="w-3 h-3" />
Create first template
</button>
</div>
);
}
function EmptySearch({ search, onClear }: { search: string; onClear: () => void }) {
return (
<div className="rounded-md border border-dashed border-slate-300 bg-slate-50/40 p-6 text-center">
<p className="text-[12px] text-slate-500 mb-3">
No templates match <span className="font-mono text-slate-700">"{search}"</span>.
</p>
<button
type="button"
onClick={onClear}
className="h-7 px-3 rounded-md border border-slate-200 hover:border-slate-300 text-[12px] text-slate-700 hover:text-slate-900 inline-flex items-center gap-1.5 transition-colors"
>
Clear search
</button>
</div>
);
}
function TemplateList({
templates,
onEdit,
}: {
templates: Template[];
onEdit: (t: Template) => void;
}) {
const reorder = useReorderTemplates();
async function move(idx: number, dir: -1 | 1) {
const next = idx + dir;
if (next < 0 || next >= templates.length) return;
const ids = templates.map((t) => t.id);
const [moved] = ids.splice(idx, 1);
ids.splice(next, 0, moved);
try {
await reorder.mutateAsync(ids);
} catch (e) {
toast.error(buildError(e as AppError));
}
}
return (
<div className="rounded-md border border-slate-200 bg-white divide-y divide-slate-200 overflow-hidden">
{templates.map((t, idx) => (
<TemplateRow
key={t.id}
template={t}
index={idx}
total={templates.length}
onEdit={() => onEdit(t)}
onMoveUp={() => move(idx, -1)}
onMoveDown={() => move(idx, 1)}
reordering={reorder.isPending}
/>
))}
</div>
);
}
function TemplateRow({
template,
index,
total,
onEdit,
onMoveUp,
onMoveDown,
reordering,
}: {
template: Template;
index: number;
total: number;
onEdit: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
reordering: boolean;
}) {
const duplicate = useDuplicateTemplate();
const del = useDeleteTemplate();
const confirm = useConfirm();
const [menuOpen, setMenuOpen] = React.useState(false);
const menuRef = React.useRef<HTMLDivElement>(null);
useClickOutside(menuRef, () => setMenuOpen(false));
const preview = previewText(template);
async function doDuplicate() {
setMenuOpen(false);
try {
await toast.promise(duplicate.mutateAsync(template.id), {
loading: "Duplicating…",
success: "Template duplicated",
error: (e: AppError) => buildError(e),
});
} catch {
/* surfaced */
}
}
function doDelete() {
setMenuOpen(false);
confirm?.show(`Delete template "${template.name}"? This can't be undone.`, async () => {
try {
await toast.promise(del.mutateAsync(template.id), {
loading: "Deleting…",
success: "Template deleted",
error: (e: AppError) => buildError(e),
});
} catch {
/* surfaced */
}
});
}
return (
<div className="px-4 py-3 flex items-start gap-3 hover:bg-slate-50/60 transition-colors">
<div className="flex flex-col items-center gap-0.5 pt-1 shrink-0">
<button
type="button"
onClick={onMoveUp}
disabled={index === 0 || reordering}
aria-label="Move up"
className="size-5 rounded text-slate-400 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center justify-center disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-slate-400 transition-colors"
>
<ChevronUpIcon className="w-3 h-3" />
</button>
<span className="text-[10px] font-mono text-slate-400 tabular-nums leading-none">
{index + 1}
</span>
<button
type="button"
onClick={onMoveDown}
disabled={index === total - 1 || reordering}
aria-label="Move down"
className="size-5 rounded text-slate-400 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center justify-center disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-slate-400 transition-colors"
>
<ChevronDownIcon className="w-3 h-3" />
</button>
</div>
<button
type="button"
onClick={onEdit}
className="flex-1 min-w-0 text-left"
>
<div className="flex items-center gap-2 mb-0.5">
<span className="text-[12.5px] font-semibold text-slate-900 truncate">
{template.name}
</span>
<span className="text-[10.5px] font-mono text-slate-400 tabular-nums shrink-0">
edited {formatRelative(template.updated_at)}
</span>
</div>
{template.subject && (
<div className="text-[11.5px] text-slate-600 truncate mb-0.5">
<span className="text-slate-400 mr-1">Subject:</span>
{template.subject}
</div>
)}
{preview && (
<div className="text-[11px] text-slate-500 line-clamp-2 leading-relaxed">
{preview}
</div>
)}
</button>
<div ref={menuRef} className="relative shrink-0">
<button
type="button"
onClick={() => setMenuOpen((o) => !o)}
aria-label="Template menu"
className="size-7 rounded-md text-slate-400 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center justify-center transition-colors"
>
<MoreHorizontalIcon className="w-3.5 h-3.5" />
</button>
<AnimatePresence>
{menuOpen && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.12 }}
className="absolute top-full right-0 mt-1 z-20 w-44 rounded-md border border-slate-200 bg-white shadow-[0_12px_32px_-8px_rgba(15,23,42,0.18)] py-1"
>
<button
type="button"
onClick={() => {
setMenuOpen(false);
onEdit();
}}
className="w-full px-2.5 h-7 flex items-center gap-2 text-[12px] text-slate-700 hover:bg-slate-100 transition-colors"
>
<PencilIcon className="w-3 h-3 text-slate-400" />
Edit
</button>
<button
type="button"
onClick={doDuplicate}
disabled={duplicate.isPending}
className="w-full px-2.5 h-7 flex items-center gap-2 text-[12px] text-slate-700 hover:bg-slate-100 transition-colors"
>
<CopyIcon className="w-3 h-3 text-slate-400" />
Duplicate
</button>
<div className="h-px bg-slate-100 my-1" />
<button
type="button"
onClick={doDelete}
disabled={del.isPending}
className="w-full px-2.5 h-7 flex items-center gap-2 text-[12px] text-red-600 hover:bg-red-50 transition-colors"
>
<TrashIcon className="w-3 h-3" />
Delete
</button>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}
function previewText(t: Template) {
if (t.body_plain) return t.body_plain;
if (t.body_html) return stripHTML(t.body_html);
return "";
}
function stripHTML(s: string) {
return s
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/\s+/g, " ")
.trim();
}
function formatRelative(date: Date | string) {
const d = typeof date === "string" ? new Date(date) : date;
const diff = Date.now() - d.getTime();
const mins = Math.floor(diff / 60_000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}d ago`;
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
type EditorState =
| { mode: "create" }
| { mode: "edit"; template: Template }
| null;
function TemplateEditor({
state,
onClose,
}: {
state: EditorState;
onClose: () => void;
}) {
const create = useCreateTemplate();
const update = useUpdateTemplate();
const [name, setName] = React.useState("");
const [subject, setSubject] = React.useState("");
const [bodyPlain, setBodyPlain] = React.useState("");
const [bodyHTML, setBodyHTML] = React.useState("");
const [showHTML, setShowHTML] = React.useState(false);
React.useEffect(() => {
if (!state) return;
if (state.mode === "edit") {
setName(state.template.name);
setSubject(state.template.subject);
setBodyPlain(state.template.body_plain);
setBodyHTML(state.template.body_html);
setShowHTML(Boolean(state.template.body_html));
} else {
setName("");
setSubject("");
setBodyPlain("");
setBodyHTML("");
setShowHTML(false);
}
}, [state]);
async function submit() {
const trimmedName = name.trim();
if (!trimmedName) {
toast.error("Name is required");
return;
}
const payload = {
name: trimmedName,
subject,
body_plain: bodyPlain,
body_html: showHTML ? bodyHTML : "",
};
try {
if (!state) return;
if (state.mode === "create") {
await toast.promise(create.mutateAsync(payload), {
loading: "Creating template…",
success: "Template created",
error: (e: AppError) => buildError(e),
});
} else {
await toast.promise(
update.mutateAsync({ id: state.template.id, data: payload }),
{
loading: "Saving…",
success: "Template saved",
error: (e: AppError) => buildError(e),
},
);
}
onClose();
} catch {
/* surfaced */
}
}
const pending = create.isPending || update.isPending;
const open = state !== null;
const isEdit = state?.mode === "edit";
function insertVariable(field: "subject" | "plain" | "html", token: string) {
const setter =
field === "subject" ? setSubject : field === "plain" ? setBodyPlain : setBodyHTML;
const current =
field === "subject" ? subject : field === "plain" ? bodyPlain : bodyHTML;
setter(current + token);
}
return (
<AnimatePresence>
{open && (
<motion.div
key="overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
onClick={onClose}
className="fixed inset-0 z-[110] flex items-center justify-center bg-slate-900/30 backdrop-blur-[2px] px-4"
>
<motion.div
key="card"
initial={{ y: 8, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 8, opacity: 0 }}
transition={{ duration: 0.16 }}
onClick={(e) => e.stopPropagation()}
className="w-full max-w-[640px] rounded-lg bg-white border border-slate-200 shadow-[0_24px_48px_-12px_rgba(15,23,42,0.18)] overflow-hidden"
>
<div className="h-12 px-4 border-b border-slate-200 flex items-center gap-2.5">
<div className="size-5 rounded bg-slate-100 text-slate-600 flex items-center justify-center">
<FileTextIcon className="w-3 h-3" />
</div>
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">
{isEdit ? "Edit" : "New"}
</span>
<div className="h-4 w-px bg-slate-200" />
<span className="text-[12.5px] text-slate-900 font-medium">Template</span>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="ml-auto size-7 rounded-md text-slate-500 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center justify-center transition-colors"
>
<XIcon className="w-3.5 h-3.5" />
</button>
</div>
<div className="px-4 py-4 space-y-3 max-h-[68vh] overflow-y-auto">
<div>
<Label>Name</Label>
<TextInput
value={name}
onChange={setName}
placeholder="Cold intro · Product"
autoFocus={!isEdit}
className="w-full"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<Label className="!mb-0">Subject</Label>
<VariableMenu onPick={(v) => insertVariable("subject", v)} />
</div>
<TextInput
value={subject}
onChange={setSubject}
placeholder="Quick question, {{.FirstName}}"
className="w-full"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<Label className="!mb-0">Body · plain text</Label>
<VariableMenu onPick={(v) => insertVariable("plain", v)} />
</div>
<textarea
value={bodyPlain}
onChange={(e) => setBodyPlain(e.target.value)}
placeholder="Hi {{.FirstName}}, …"
rows={8}
className="w-full rounded-md border border-slate-200 bg-white text-[12.5px] text-slate-900 placeholder:text-slate-400 outline-none transition-colors focus:border-sky-400 focus:ring-2 focus:ring-sky-100 px-2.5 py-2 resize-y leading-relaxed font-mono"
/>
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<label className="text-[11px] text-slate-600 inline-flex items-center gap-1.5 cursor-pointer select-none">
<input
type="checkbox"
checked={showHTML}
onChange={(e) => setShowHTML(e.target.checked)}
className="size-3 accent-slate-900"
/>
Also send an HTML body
</label>
{showHTML && (
<div className="ml-auto">
<VariableMenu onPick={(v) => insertVariable("html", v)} />
</div>
)}
</div>
{showHTML && (
<textarea
value={bodyHTML}
onChange={(e) => setBodyHTML(e.target.value)}
placeholder="<p>Hi {{.FirstName}}, …</p>"
rows={6}
className="w-full rounded-md border border-slate-200 bg-slate-50 text-[12px] text-slate-900 placeholder:text-slate-400 outline-none transition-colors focus:border-sky-400 focus:ring-2 focus:ring-sky-100 px-2.5 py-2 resize-y leading-relaxed font-mono"
/>
)}
</div>
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-2.5">
<p className="text-[10.5px] text-slate-500 mb-1.5 uppercase tracking-[0.1em] font-medium">
Built-in variables
</p>
<div className="flex flex-wrap gap-1">
{VARIABLE_HINTS.map((v) => (
<code
key={v}
className="text-[10.5px] font-mono text-slate-700 bg-white border border-slate-200 rounded px-1.5 py-0.5"
>
{v}
</code>
))}
</div>
<p className="text-[10.5px] text-slate-400 mt-1.5 leading-relaxed">
Custom contact fields work the same way:{" "}
<code className="font-mono">{"{{.YourField}}"}</code>.
Unknown variables render as empty strings.
</p>
</div>
</div>
<div className="px-3 h-12 border-t border-slate-200 flex items-center gap-1.5">
<button
type="button"
onClick={onClose}
className="ml-auto h-7 px-2.5 rounded-md text-[12px] text-slate-700 hover:text-slate-900 hover:bg-slate-100 transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={submit}
disabled={pending}
className="h-7 px-3 rounded-md bg-slate-900 hover:bg-slate-800 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-60"
>
{pending && <Loader2Icon className="w-3 h-3 animate-spin" />}
{isEdit ? "Save changes" : "Create template"}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
function VariableMenu({ onPick }: { onPick: (token: string) => void }) {
const [open, setOpen] = React.useState(false);
const ref = React.useRef<HTMLDivElement>(null);
useClickOutside(ref, () => setOpen(false));
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="text-[10.5px] text-slate-500 hover:text-slate-900 inline-flex items-center gap-1 h-5 px-1.5 rounded hover:bg-slate-100 transition-colors"
>
+ variable
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.12 }}
className="absolute top-full right-0 mt-1 z-30 w-44 rounded-md border border-slate-200 bg-white shadow-[0_12px_32px_-8px_rgba(15,23,42,0.18)] py-1"
>
{VARIABLE_HINTS.map((v) => (
<button
key={v}
type="button"
onClick={() => {
onPick(v);
setOpen(false);
}}
className="w-full px-2.5 h-7 flex items-center gap-2 text-[11.5px] font-mono text-slate-700 hover:bg-slate-100 transition-colors text-left"
>
{v}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
@@ -1,11 +1,12 @@
import type Template from "@/lib/api/models/app/templates/Template";
import type { CreateTemplateInput } from "@/lib/api/models/app/templates/Template";
import Request from "../../Request";
export default async function createTemplate(data: { name: string; subject: string; body: string; variables?: string[] }): Promise<Template> {
export default async function createTemplate(data: CreateTemplateInput): Promise<Template> {
return await Request<Template>({
method: "POST",
url: `/templates`,
data,
authorization: true,
})
});
}
@@ -0,0 +1,10 @@
import type Template from "@/lib/api/models/app/templates/Template";
import Request from "../../Request";
export default async function duplicateTemplate(id: string): Promise<Template> {
return await Request<Template>({
method: "POST",
url: `/templates/${id}/duplicate`,
authorization: true,
});
}
@@ -1,10 +1,15 @@
import type Template from "@/lib/api/models/app/templates/Template";
import type { TemplatesResult } from "@/lib/api/models/app/templates/Template";
import Request from "../../Request";
export default async function listTemplates(): Promise<Template[]> {
return await Request<Template[]>({
export default async function listTemplates(search?: string): Promise<Template[]> {
const url = search && search.trim()
? `/templates?q=${encodeURIComponent(search.trim())}`
: `/templates`;
const res = await Request<TemplatesResult>({
method: "GET",
url: `/templates`,
url,
authorization: true,
})
});
return res.data ?? [];
}
@@ -0,0 +1,14 @@
import type { RenderedTemplate } from "@/lib/api/models/app/templates/Template";
import Request from "../../Request";
export default async function renderTemplate(
id: string,
variables: Record<string, string>,
): Promise<RenderedTemplate> {
return await Request<RenderedTemplate>({
method: "POST",
url: `/templates/${id}/render`,
data: { variables },
authorization: true,
});
}
@@ -0,0 +1,13 @@
import type Template from "@/lib/api/models/app/templates/Template";
import type { TemplatesResult } from "@/lib/api/models/app/templates/Template";
import Request from "../../Request";
export default async function reorderTemplates(ids: string[]): Promise<Template[]> {
const res = await Request<TemplatesResult>({
method: "PATCH",
url: `/templates/reorder`,
data: { ids },
authorization: true,
});
return res.data ?? [];
}
@@ -1,11 +1,12 @@
import type Template from "@/lib/api/models/app/templates/Template";
import type { UpdateTemplateInput } from "@/lib/api/models/app/templates/Template";
import Request from "../../Request";
export default async function updateTemplate(id: string, data: Partial<Template>): Promise<Template> {
export default async function updateTemplate(id: string, data: UpdateTemplateInput): Promise<Template> {
return await Request<Template>({
method: "PATCH",
url: `/templates/${id}`,
data,
authorization: true,
})
});
}
@@ -1,15 +1,14 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { CreateTemplateInput } from "@/lib/api/models/app/templates/Template";
import createTemplate from "@/lib/api/client/app/templates/createTemplate";
export default function useCreateTemplate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { name: string; subject: string; body: string; variables?: string[] }) => createTemplate(data),
mutationFn: (data: CreateTemplateInput) => createTemplate(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["templates", "list"],
})
}
})
queryClient.invalidateQueries({ queryKey: ["templates"] });
},
});
}
@@ -0,0 +1,13 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import duplicateTemplate from "@/lib/api/client/app/templates/duplicateTemplate";
export default function useDuplicateTemplate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => duplicateTemplate(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["templates"] });
},
});
}
@@ -0,0 +1,13 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import reorderTemplates from "@/lib/api/client/app/templates/reorderTemplates";
export default function useReorderTemplates() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (ids: string[]) => reorderTemplates(ids),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["templates"] });
},
});
}
@@ -1,9 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import listTemplates from "@/lib/api/client/app/templates/listTemplates";
export default function useTemplates() {
export default function useTemplates(search?: string) {
return useQuery({
queryKey: ["templates", "list"],
queryFn: () => listTemplates(),
})
queryKey: ["templates", "list", search ?? ""],
queryFn: () => listTemplates(search),
});
}
@@ -1,16 +1,14 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type Template from "@/lib/api/models/app/templates/Template";
import type { UpdateTemplateInput } from "@/lib/api/models/app/templates/Template";
import updateTemplate from "@/lib/api/client/app/templates/updateTemplate";
export default function useUpdateTemplate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Template> }) => updateTemplate(id, data),
mutationFn: ({ id, data }: { id: string; data: UpdateTemplateInput }) => updateTemplate(id, data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["templates"],
})
}
})
queryClient.invalidateQueries({ queryKey: ["templates"] });
},
});
}
@@ -1,9 +1,36 @@
export default interface Template {
id: string
name: string
subject: string
body: string
variables?: string[]
created_at: Date
updated_at: Date
id: string;
organization_id: string;
user_id: string;
name: string;
subject: string;
body_html: string;
body_plain: string;
position: number;
created_at: Date;
updated_at: Date;
}
export interface TemplatesResult {
data: Template[];
}
export interface CreateTemplateInput {
name: string;
subject?: string;
body_html?: string;
body_plain?: string;
}
export interface UpdateTemplateInput {
name?: string;
subject?: string;
body_html?: string;
body_plain?: string;
}
export interface RenderedTemplate {
subject: string;
body_html: string;
body_plain: string;
}