mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-25 16:01:57 +00:00
feat: full reply templates (search/reorder/duplicate/render + UI)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user