Merge remote-tracking branch 'origin/main' into feat/inbox-aliases

# Conflicts:
#	frontend/apps/main/src/features/conversation/ReplyBox.vue
#	frontend/apps/main/src/features/conversation/ReplyBoxContent.vue
#	internal/conversation/conversation.go
#	internal/migrations/v2.9.0.go
This commit is contained in:
Abhinav Raut
2026-09-27 11:11:55 +05:30
381 changed files with 30610 additions and 5913 deletions
+24 -3
View File
@@ -3,12 +3,22 @@ name: CI
on:
push:
branches: [main]
paths-ignore: ['**.md', 'docs/**', 'LICENSE', '.github/ISSUE_TEMPLATE/**']
pull_request:
branches: [main]
paths-ignore: ['**.md', 'docs/**', 'LICENSE', '.github/ISSUE_TEMPLATE/**']
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build-and-test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5]
services:
db:
image: postgres:17-alpine
@@ -41,13 +51,22 @@ jobs:
with:
go-version: "1.25.0"
- name: Install pnpm
run: npm install -g pnpm@9.15.3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: "20"
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install pnpm
run: npm install -g pnpm@9.15.3
- name: Cache the cypress binary
uses: actions/cache@v4
with:
path: ~/.cache/Cypress
key: cypress-${{ runner.os }}-${{ hashFiles('frontend/pnpm-lock.yaml') }}
restore-keys: cypress-${{ runner.os }}-
- name: Install cypress deps
run: sudo apt-get update && sudo apt-get install -y libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2t64 libxtst6 xauth xvfb
@@ -89,4 +108,6 @@ jobs:
sleep 1
done
cd frontend
pnpm run test:e2e:ci
specs=$(find cypress/e2e -type f -regextype posix-extended -regex '.*\.(cy|spec)\.(js|jsx|ts|tsx)$' | sort | awk -v s=${{ matrix.shard }} -v n=5 'NR % n == s % n' | paste -sd,)
echo "shard ${{ matrix.shard }} runs: $specs"
pnpm exec cypress run --e2e --headless --spec "$specs"
+2
View File
@@ -9,3 +9,5 @@ uploads
dist/
.vscode/
static/widget.min.js
.playwright-mcp/
+1
View File
@@ -9,6 +9,7 @@ Thanks for your interest in contributing.
- Bug fixes and documentation improvements can go straight to a PR, no prior discussion needed.
- Anything larger should start with an issue. See below for why.
- Keep changes focused on a single concern wherever possible.
- For translations, please contribute through [Crowdin](https://crowdin.com/project/libredesk) instead of opening PRs for translation changes.
## Pull Requests
+12 -8
View File
@@ -10,7 +10,8 @@ BUILDSTR := ${VERSION} (\#${LAST_COMMIT} $(shell date -u +"%Y-%m-%dT%H:%M:%S%z")
BIN := libredesk
FRONTEND_DIR := frontend
FRONTEND_DIST := ${FRONTEND_DIR}/dist
STATIC := ${FRONTEND_DIST} i18n schema.sql static
WIDGET_JS_MIN := static/widget.min.js
STATIC := ${FRONTEND_DIST} i18n schema.sql static/email-templates static/public ${WIDGET_JS_MIN}:static/widget.js
GOPATH ?= $(HOME)/go
STUFFBIN ?= $(GOPATH)/bin/stuffbin
@@ -30,10 +31,12 @@ install-deps: $(STUFFBIN)
# Build the frontend for production (both apps).
.PHONY: frontend-build
frontend-build: install-deps
@echo "→ Building frontend for production - main app & widget..."
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build:main
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build:widget
frontend-build: frontend-build-main frontend-build-widget
.PHONY: minify-widget-js
minify-widget-js: install-deps
@echo "→ Minifying widget loader..."
@cd ${FRONTEND_DIR} && pnpm build:widget-loader
# Build only the main frontend app.
.PHONY: frontend-build-main
@@ -81,18 +84,19 @@ run-frontend-widget:
.PHONY: build-backend
build-backend: $(STUFFBIN)
@echo "→ Building backend..."
@CGO_ENABLED=0 go build -a \
@CGO_ENABLED=0 go build \
-ldflags="-X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -X 'github.com/abhinavxd/libredesk/internal/version.Version=${VERSION}' -s -w" \
-o ${BIN} cmd/*.go
# Main build target: builds both frontend and backend, then stuffs static assets into the binary.
.PHONY: build
build: frontend-build build-backend stuff
build: frontend-build build-backend
@$(MAKE) --no-print-directory stuff
@echo "→ Build successful. Current version: $(VERSION)"
# Stuff static assets into the binary using stuffbin.
.PHONY: stuff
stuff: $(STUFFBIN)
stuff: $(STUFFBIN) minify-widget-js
@echo "→ Stuffing static assets into binary..."
@$(STUFFBIN) -a stuff -in ${BIN} -out ${BIN} ${STATIC}
+1 -1
View File
@@ -16,7 +16,7 @@
<br> Modern, open source, self-hosted omnichannel customer support desk. Live chat, email, and more in a single binary.
![image](https://libredesk.io/hero-dark.png?q=5)
![image](https://libredesk.io/hero-dark.png?v=1)
Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**live demo**](https://demo.libredesk.io/).
+4 -3
View File
@@ -4,9 +4,10 @@ The open-source customer support platform you actually own. One inbox for email,
## Near Term
- WhatsApp channel - WIP
- Web / Customer Portal to manage tickets - WIP
- 2FA for password login - WIP
- Telegram channel - TODO
## Mid Term
- Portal to manage tickets - TODO
- Conversation reports - TODO
- Extensive conversation reports - TODO
- Contact merging - TODO
- 2FA for password login - TODO
+197 -17
View File
@@ -45,6 +45,11 @@ type suggestTagsReq struct {
ConversationUUID string `json:"conversation_uuid"`
}
type promptReq struct {
Title string `json:"title"`
Content string `json:"content"`
}
type snippetReq struct {
Title string `json:"title"`
Content string `json:"content"`
@@ -55,6 +60,11 @@ type snippetImportReq struct {
URL string `json:"url"`
}
type aiToolReq struct {
aimodels.Tool
RequiresAgentApproval *bool `json:"requires_agent_approval"`
}
// handleAICompletion runs a stored prompt over the supplied content (reply-box actions).
func handleAICompletion(r *fastglue.Request) error {
var (
@@ -81,6 +91,65 @@ func handleGetAIPrompts(r *fastglue.Request) error {
return r.SendEnvelope(resp)
}
func handleGetAIPrompt(r *fastglue.Request) error {
app := r.Context.(*App)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError)
}
p, err := app.ai.GetPromptByID(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(p)
}
func handleCreateAIPrompt(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req promptReq
)
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
}
p, err := app.ai.CreatePrompt(req.Title, req.Content)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(p)
}
func handleUpdateAIPrompt(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req promptReq
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
}
p, err := app.ai.UpdatePrompt(id, req.Title, req.Content)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(p)
}
func handleDeleteAIPrompt(r *fastglue.Request) error {
app := r.Context.(*App)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError)
}
if err := app.ai.DeletePrompt(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleGetAIConfig returns the sanitized provider config for a type (completion/embedding).
func handleGetAIConfig(r *fastglue.Request) error {
app := r.Context.(*App)
@@ -150,12 +219,14 @@ func handleGetAITool(r *fastglue.Request) error {
func handleCreateAITool(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req aimodels.Tool
req aiToolReq
)
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
}
tool, err := app.ai.CreateTool(req)
toolInput := req.Tool
toolInput.RequiresAgentApproval = req.RequiresAgentApproval == nil || *req.RequiresAgentApproval
tool, err := app.ai.CreateTool(toolInput)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -165,7 +236,7 @@ func handleCreateAITool(r *fastglue.Request) error {
func handleUpdateAITool(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req aimodels.Tool
req aiToolReq
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil {
@@ -174,7 +245,17 @@ func handleUpdateAITool(r *fastglue.Request) error {
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
}
tool, err := app.ai.UpdateTool(id, req)
toolInput := req.Tool
if req.RequiresAgentApproval == nil {
existing, err := app.ai.GetTool(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
toolInput.RequiresAgentApproval = existing.RequiresAgentApproval
} else {
toolInput.RequiresAgentApproval = *req.RequiresAgentApproval
}
tool, err := app.ai.UpdateTool(id, toolInput)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -288,14 +369,30 @@ func handleAIGenerateReply(r *fastglue.Request) error {
}
transcript = conversationTranscript(app, req.ConversationUUID)
}
resp, err := app.ai.GenerateReply(r.RequestCtx, transcript, req.Instruction, ai.ToolContext{}, generateReplyTools(app, user, conv))
toolIDs := []int(nil)
tctx := ai.ToolContext{}
scope := ai.AgentRunScope{AgentID: auser.ID, Surface: aimodels.ToolInvocationReply}
if conv != nil {
toolIDs, err = app.ai.GetEnabledGenerateReplyToolIDs()
if err != nil {
return sendErrorEnvelope(r, err)
}
tctx = agentToolContext(conv)
scope.ConversationID = conv.ID
scope.ConversationUUID = conv.UUID
app.ai.ClearPendingAgentRuns(scope)
}
resp, err := app.ai.GenerateReply(r.RequestCtx, transcript, req.Instruction, tctx, generateReplyTools(app, user, conv), toolIDs, scope)
if err != nil {
return sendErrorEnvelope(r, err)
}
if strings.TrimSpace(resp) == "" {
if resp.Status == aimodels.AgentRunCompleted && strings.TrimSpace(resp.Content) == "" {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("ai.emptyResponse"), nil))
}
return r.SendEnvelope(stringutil.Markdown2HTML(resp))
if resp.Status == aimodels.AgentRunCompleted {
resp.Content = stringutil.Markdown2HTML(resp.Content)
}
return r.SendEnvelope(resp)
}
// handleAISummarizeConversation summarizes a conversation and posts the summary as the requesting agent's private note.
@@ -402,22 +499,46 @@ func handleAICopilot(r *fastglue.Request) error {
}
history = append(history, aimodels.ChatMessage{Role: aimodels.RoleUser, Content: req.Message})
convoContext := conversationTranscript(app, req.ConversationUUID)
resp, err := app.ai.Copilot(r.RequestCtx, convoContext, history, ai.ToolContext{}, copilotTools(app, user, conv), persona)
scope := ai.AgentRunScope{AgentID: auser.ID, ConversationID: conv.ID, ConversationUUID: conv.UUID, Surface: aimodels.ToolInvocationCopilot, UserMessage: req.Message}
app.ai.ClearPendingAgentRuns(scope)
toolIDs, err := app.ai.GetEnabledCopilotToolIDs()
if err != nil {
return sendErrorEnvelope(r, err)
}
if strings.TrimSpace(resp) == "" {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("ai.emptyResponse"), nil))
}
// Persist the exchange only after a successful reply, so a failed or empty call leaves no orphaned turn.
if err := app.ai.SaveCopilotMessage(conv.ID, auser.ID, aimodels.RoleUser, req.Message); err != nil {
convoContext := conversationTranscript(app, req.ConversationUUID)
resp, err := app.ai.Copilot(r.RequestCtx, convoContext, history, agentToolContext(conv), copilotTools(app, user, conv), persona, toolIDs, scope)
if err != nil {
return sendErrorEnvelope(r, err)
}
if err := app.ai.SaveCopilotMessage(conv.ID, auser.ID, aimodels.RoleAssistant, resp); err != nil {
app.lo.Error("error saving copilot reply", "error", err)
if resp.Status == aimodels.AgentRunCompleted && strings.TrimSpace(resp.Content) == "" {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("ai.emptyResponse"), nil))
}
return r.SendEnvelope(stringutil.Markdown2HTML(resp))
if resp.Status == aimodels.AgentRunCompleted {
if err := app.ai.SaveCopilotMessage(conv.ID, auser.ID, aimodels.RoleUser, req.Message); err != nil {
return sendErrorEnvelope(r, err)
}
if err := app.ai.SaveCopilotMessage(conv.ID, auser.ID, aimodels.RoleAssistant, resp.Content); err != nil {
app.lo.Error("error saving copilot reply", "error", err)
}
resp.Content = stringutil.Markdown2HTML(resp.Content)
}
return r.SendEnvelope(resp)
}
func handleApproveAIToolRun(r *fastglue.Request) error {
resp, err := decideAIToolRun(r, true)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(resp)
}
func handleDeclineAIToolRun(r *fastglue.Request) error {
resp, err := decideAIToolRun(r, false)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(resp)
}
// handleGetCopilotMessages returns the requesting agent's persisted copilot chat for a conversation.
@@ -442,6 +563,13 @@ func handleGetCopilotMessages(r *fastglue.Request) error {
msgs[i].Content = stringutil.Markdown2HTML(msgs[i].Content)
}
}
scope := ai.AgentRunScope{AgentID: auser.ID, ConversationID: conv.ID, ConversationUUID: conv.UUID, Surface: aimodels.ToolInvocationCopilot}
if userMessage, approval := app.ai.PendingAgentApproval(scope); approval != nil {
if userMessage != "" {
msgs = append(msgs, aimodels.CopilotMessage{Role: aimodels.RoleUser, Content: userMessage})
}
msgs = append(msgs, aimodels.CopilotMessage{Role: aimodels.RoleApproval, Approval: approval})
}
return r.SendEnvelope(msgs)
}
@@ -460,6 +588,7 @@ func handleClearCopilotMessages(r *fastglue.Request) error {
if err := app.ai.ClearCopilotMessages(conv.ID, auser.ID); err != nil {
return sendErrorEnvelope(r, err)
}
app.ai.ClearPendingAgentRuns(ai.AgentRunScope{AgentID: auser.ID, ConversationID: conv.ID, ConversationUUID: conv.UUID, Surface: aimodels.ToolInvocationCopilot})
return r.SendEnvelope(true)
}
@@ -484,3 +613,54 @@ func conversationTranscript(app *App, uuid string) string {
}
return cmodels.Transcript(msgs, maxTranscriptMessages)
}
func decideAIToolRun(r *fastglue.Request, approved bool) (aimodels.AgentRunResult, error) {
app := r.Context.(*App)
auser := r.RequestCtx.UserValue("user").(amodels.User)
runID := r.RequestCtx.UserValue("id").(string)
scope, err := app.ai.PendingAgentRunScope(runID, auser.ID)
if err != nil {
return aimodels.AgentRunResult{}, err
}
if scope.ConversationUUID != "" {
conv, err := enforceAIConversationAccess(r, scope.ConversationUUID)
if err != nil {
return aimodels.AgentRunResult{}, err
}
if conv.ID != scope.ConversationID {
return aimodels.AgentRunResult{}, envelope.NewError(envelope.PermissionError, app.i18n.T("ai.toolApprovalUnavailable"), nil)
}
}
resp, resumedScope, err := app.ai.ResumeAgentRun(r.RequestCtx, runID, auser.ID, approved)
if err != nil {
return aimodels.AgentRunResult{}, err
}
if resp.Status == aimodels.AgentRunCompleted && strings.TrimSpace(resp.Content) == "" {
return aimodels.AgentRunResult{}, envelope.NewError(envelope.GeneralError, app.i18n.T("ai.emptyResponse"), nil)
}
if resp.Status == aimodels.AgentRunCompleted {
if resumedScope.Surface == aimodels.ToolInvocationCopilot {
if err := app.ai.SaveCopilotMessage(resumedScope.ConversationID, auser.ID, aimodels.RoleUser, resumedScope.UserMessage); err != nil {
app.lo.Error("error saving copilot message", "error", err)
} else if err := app.ai.SaveCopilotMessage(resumedScope.ConversationID, auser.ID, aimodels.RoleAssistant, resp.Content); err != nil {
app.lo.Error("error saving copilot reply", "error", err)
}
}
resp.Content = stringutil.Markdown2HTML(resp.Content)
}
return resp, nil
}
func agentToolContext(conv *cmodels.Conversation) ai.ToolContext {
return ai.ToolContext{
ContactID: conv.Contact.ID,
ContactExternalID: conv.Contact.ExternalUserID.String,
ContactType: conv.Contact.Type,
ConversationUUID: conv.UUID,
InboxID: conv.InboxID,
ContactEmail: func() string { return conv.Contact.Email.String },
// The agent picked this conversation and reviews the call before it runs, so the customer's
// own OTP state does not gate an agent-initiated lookup.
Verified: func() bool { return true },
}
}
+1 -1
View File
@@ -232,7 +232,7 @@ func (t *searchContactsTool) Execute(ctx context.Context, args string) (string,
if !allowed {
return "You do not have permission to search contacts.", nil
}
results, err := t.app.search.Contacts(email)
results, err := t.app.search.Contacts(email, maxContactSearchLimit)
if err != nil {
return "", err
}
+180 -18
View File
@@ -1,6 +1,7 @@
package main
import (
"cmp"
"context"
"crypto/rand"
"encoding/base64"
@@ -21,6 +22,7 @@ import (
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat/proactive"
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
"github.com/abhinavxd/libredesk/internal/stringutil"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
@@ -82,11 +84,20 @@ type customAttributeWidget struct {
}
type chatInitReq struct {
Message string `json:"message"`
DeliveryID string `json:"delivery_id"`
BrowserKey string `json:"browser_key"`
Message string `json:"message"`
FormData map[string]any `json:"form_data"`
}
type handoffFormReq struct {
FormData map[string]any `json:"form_data"`
}
type chatSettingsResponse struct {
Campaigns *struct{} `json:"campaigns,omitempty"`
HasCampaigns bool `json:"has_campaigns"`
CampaignDelaySeconds int `json:"campaign_delay_seconds"`
livechat.Config
// Hide server-side fields from the public widget response.
TrustedDomains *struct{} `json:"trusted_domains,omitempty"`
@@ -114,10 +125,7 @@ func handleGetChatLauncherSettings(r *fastglue.Request) error {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, err.Error(), nil))
}
return r.SendEnvelope(map[string]any{
"launcher": config.Launcher,
"colors": config.Colors,
})
return r.SendEnvelope(chatLauncherSettings(config))
}
// handleGetChatSettings returns the live chat settings for the widget
@@ -128,9 +136,16 @@ func handleGetChatSettings(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, err.Error(), nil))
}
customAttributes := map[int]customAttributeWidget(nil)
if config.PreChatForm.Enabled {
config, customAttributes = filterPreChatForms(config, app)
}
response := chatSettingsResponse{
Config: config,
Config: config,
HasCampaigns: slices.ContainsFunc(config.Campaigns, func(c proactive.Campaign) bool { return c.Enabled }),
CampaignDelaySeconds: earliestCampaignDelay(config.Campaigns),
CustomAttributes: customAttributes,
}
// Get business hours data if office hours feature is enabled.
@@ -163,15 +178,6 @@ func handleGetChatSettings(r *fastglue.Request) error {
}
}
// Filter out pre-chat form fields for which custom attributes don't exist anymore.
if config.PreChatForm.Enabled && len(config.PreChatForm.Fields) > 0 {
filteredFields, customAttributes := filterPreChatFormFields(config.PreChatForm.Fields, app)
response.PreChatForm.Fields = filteredFields
if len(customAttributes) > 0 {
response.CustomAttributes = customAttributes
}
}
return r.SendEnvelope(response)
}
@@ -211,6 +217,11 @@ func handleChatInit(r *fastglue.Request) error {
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError)
}
initialConfig := resolveInitialChatConfig(config, getWidgetIsVisitor(r))
if req.DeliveryID != "" {
return handleWidgetCampaignReply(r, req, inbox, initialConfig)
}
// Check if user is already authenticated (has session token).
contactID, _ = getWidgetContactID(r)
@@ -219,11 +230,11 @@ func handleChatInit(r *fastglue.Request) error {
// Custom attributes from JWT were already saved during /auth/exchange.
// Only process form-level attributes here.
isVisitor = getWidgetIsVisitor(r)
conversationAttrs = saveContactAttrsAndCollectConvoAttrs(app, contactID, nil, req.FormData, config)
conversationAttrs = saveContactAttrsAndCollectConvoAttrs(app, contactID, nil, req.FormData, initialConfig)
} else {
// New visitor - create visitor and session token.
isVisitor = true
visitor, newSessionToken, conversationAttrs, err = createVisitorContact(app, req.FormData, config, inbox)
visitor, newSessionToken, conversationAttrs, err = createVisitorContact(app, req.FormData, initialConfig, inbox)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -283,7 +294,7 @@ func handleChatInit(r *fastglue.Request) error {
}
// Process post-message hooks for the new conversation and initial message.
if err := app.conversation.ProcessIncomingMessageHooks(conversationUUID, true); err != nil {
if err := app.conversation.ProcessIncomingMessageHooks(message, true); err != nil {
app.lo.Error("error processing incoming message hooks for initial message", "conversation_uuid", conversationUUID, "error", err)
}
@@ -510,6 +521,18 @@ func handleGetConversations(r *fastglue.Request) error {
app.lo.Error("error fetching conversations for contact", "contact_id", contactID, "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError)
}
unreadMessages, err := app.conversation.GetContactUnreadPreviewMessages(contactID, inbox.ID, 3)
if err != nil {
return sendErrorEnvelope(r, err)
}
for _, message := range unreadMessages {
for i := range chatConversations {
if chatConversations[i].UUID == message.ConversationUUID {
chatConversations[i].UnreadMessages = append(chatConversations[i].UnreadMessages, message)
break
}
}
}
return r.SendEnvelope(chatConversations)
}
@@ -566,6 +589,70 @@ func handleChatSendMessage(r *fastglue.Request) error {
return sendChatMessageResponse(app, r, message.UUID)
}
// handleChatSubmitHandoffForm saves the form the AI agent asks the visitor to fill before it hands the chat to a human agent.
func handleChatSubmitHandoffForm(r *fastglue.Request) error {
var (
app = r.Context.(*App)
conversationUUID = r.RequestCtx.UserValue("uuid").(string)
req handoffFormReq
)
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
}
contactID, conversation, err := getContactConversation(r, conversationUUID)
if err != nil {
return sendErrorEnvelope(r, err)
}
config, err := getWidgetConfig(r)
if err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil))
}
config = config.ResolvePreChatForm(getWidgetIsVisitor(r))
if !config.PreChatForm.Enabled || !config.PreChatForm.HandoffOnly {
return r.SendErrorEnvelope(fasthttp.StatusConflict, app.i18n.T("ai.agent.handoffFormUnavailable"), nil, envelope.ConflictError)
}
config.PreChatForm.Fields, _ = filterPreChatFormFields(config.PreChatForm.Fields, app)
if err := validateRequiredFormFields(req.FormData, config, app); err != nil {
return sendErrorEnvelope(r, err)
}
contact, err := app.user.GetContactOrVisitor(contactID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
// An identified contact's details come from the JWT, which resyncs them on every exchange, so the form only fills blanks.
var identified *umodels.User
if !getWidgetIsVisitor(r) {
identified = &contact
}
name, email, phone, countryCode, err := validateFormData(app, req.FormData, config, identified)
if err != nil {
return sendErrorEnvelope(r, err)
}
name = cmp.Or(name, contact.FirstName)
email = cmp.Or(email, contact.Email.String)
phone = cmp.Or(phone, contact.PhoneNumber.String)
countryCode = cmp.Or(countryCode, contact.PhoneNumberCountryCode.String)
if name != "" || email != "" || phone != "" {
if err := app.user.UpdateContactBasicInfo(contactID, name, contact.LastName, email, phone, countryCode); err != nil {
return sendErrorEnvelope(r, err)
}
}
conversationAttrs := saveContactAttrsAndCollectConvoAttrs(app, contactID, nil, req.FormData, config)
if len(conversationAttrs) > 0 {
merged := make(map[string]any)
_ = json.Unmarshal(conversation.CustomAttributes, &merged)
maps.Copy(merged, conversationAttrs)
if err := app.conversation.UpdateConversationCustomAttributes(conversation.UUID, merged); err != nil {
return sendErrorEnvelope(r, err)
}
}
if err := app.aiAgent.CompleteHandoff(conversation); err != nil {
app.lo.Error("error completing AI handoff form", "conversation_uuid", conversation.UUID, "error", err)
return r.SendErrorEnvelope(fasthttp.StatusConflict, app.i18n.T("ai.agent.handoffFormUnavailable"), nil, envelope.ConflictError)
}
return r.SendEnvelope(true)
}
// handleWidgetMediaUpload handles media uploads for the widget.
func handleWidgetMediaUpload(r *fastglue.Request) error {
app := r.Context.(*App)
@@ -694,6 +781,7 @@ func sendChatMessageResponse(app *App, r *fastglue.Request, messageUUID string)
author.Email = null.String{}
return r.SendEnvelope(cmodels.ChatMessage{
ID: message.ID,
UUID: message.UUID,
CreatedAt: message.CreatedAt,
Content: message.Content,
@@ -1211,6 +1299,19 @@ func validateFormData(app *App, formData map[string]any, config livechat.Config,
return finalName, finalEmail, finalPhone, finalPhoneCountryCode, nil
}
func validateRequiredFormFields(formData map[string]any, config livechat.Config, app *App) error {
for _, field := range config.PreChatForm.Fields {
if !field.Enabled || !field.Required || field.Type == "checkbox" {
continue
}
value, exists := formData[field.Key]
if !exists || !isFormFieldValuePresent(field, value) {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", field.Label), nil)
}
}
return nil
}
func resolveFormField(formData map[string]any, key, existing string) string {
if existing != "" {
return existing
@@ -1221,6 +1322,28 @@ func resolveFormField(formData map[string]any, key, existing string) string {
return ""
}
func isFormFieldValuePresent(field livechat.PreChatFormField, value any) bool {
switch field.Type {
case "checkbox":
_, ok := value.(bool)
return ok
case "number":
number, ok := value.(float64)
return ok && !math.IsNaN(number) && !math.IsInf(number, 0)
default:
text, ok := value.(string)
return ok && strings.TrimSpace(text) != ""
}
}
func resolveInitialChatConfig(config livechat.Config, isVisitor bool) livechat.Config {
config = config.ResolvePreChatForm(isVisitor)
if config.PreChatForm.HandoffOnly {
config.PreChatForm.Enabled = false
}
return config
}
// filterPreChatFormFields filters out pre-chat form fields that reference non-existent custom attributes while retaining the default fields
func filterPreChatFormFields(fields []livechat.PreChatFormField, app *App) ([]livechat.PreChatFormField, map[int]customAttributeWidget) {
if len(fields) == 0 {
@@ -1276,6 +1399,27 @@ func filterPreChatFormFields(fields []livechat.PreChatFormField, app *App) ([]li
return filteredFields, existingCustomAttrs
}
func filterPreChatForms(config livechat.Config, app *App) (livechat.Config, map[int]customAttributeWidget) {
customAttributes := make(map[int]customAttributeWidget)
filter := func(fields []livechat.PreChatFormField) []livechat.PreChatFormField {
filtered, attributes := filterPreChatFormFields(fields, app)
maps.Copy(customAttributes, attributes)
return filtered
}
config.PreChatForm.Fields = filter(config.PreChatForm.Fields)
if config.PreChatForm.Visitors != nil && config.PreChatForm.Visitors.Fields != nil {
config.PreChatForm.Visitors.Fields = filter(config.PreChatForm.Visitors.Fields)
}
if config.PreChatForm.Users != nil && config.PreChatForm.Users.Fields != nil {
config.PreChatForm.Users.Fields = filter(config.PreChatForm.Users.Fields)
}
if len(customAttributes) == 0 {
return config, nil
}
return config, customAttributes
}
// getSessionDuration returns the configured session TTL for authenticated users.
// Falls back to defaultSessionTTL if the config value is empty or invalid.
// Enforces a minimum of 1 hour.
@@ -1309,3 +1453,21 @@ func canReply(r *fastglue.Request, conversation cmodels.Conversation) error {
}
return nil
}
func launcherBranding(b livechat.Branding) map[string]any {
return map[string]any{
"colors": b.Colors,
"launcher": b.Launcher,
}
}
func chatLauncherSettings(config livechat.Config) map[string]any {
return map[string]any{
"theme": config.Theme,
"launcher": config.Launcher,
"branding": map[string]any{
"light": launcherBranding(config.Branding.Light),
"dark": launcherBranding(config.Branding.Dark),
},
}
}
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"math"
"testing"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
)
func TestIsFormFieldValuePresent(t *testing.T) {
tests := []struct {
name string
field livechat.PreChatFormField
value any
want bool
}{
{name: "text", field: livechat.PreChatFormField{Type: "text"}, value: "answer", want: true},
{name: "blank text", field: livechat.PreChatFormField{Type: "text"}, value: " "},
{name: "wrong text type", field: livechat.PreChatFormField{Type: "text"}, value: []string{"answer"}},
{name: "number", field: livechat.PreChatFormField{Type: "number"}, value: float64(0), want: true},
{name: "invalid number", field: livechat.PreChatFormField{Type: "number"}, value: math.NaN()},
{name: "checkbox", field: livechat.PreChatFormField{Type: "checkbox"}, value: false, want: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := isFormFieldValuePresent(tc.field, tc.value); got != tc.want {
t.Fatalf("got %v, want %v", got, tc.want)
}
})
}
}
func TestInitialChatConfigDisablesHandoffOnlyForm(t *testing.T) {
var config livechat.Config
config.PreChatForm.Enabled = true
config.PreChatForm.HandoffOnly = true
initial := resolveInitialChatConfig(config, true)
if initial.PreChatForm.Enabled {
t.Fatal("handoff-only form remained enabled for initial chat")
}
if !config.PreChatForm.Enabled {
t.Fatal("input config was mutated")
}
}
func TestInitialChatConfigUsesAudienceSetting(t *testing.T) {
var config livechat.Config
config.PreChatForm.Enabled = true
showVisitors := true
showUsers := false
config.PreChatForm.Visitors = &livechat.AudiencePreChatFormConfig{Enabled: &showVisitors}
config.PreChatForm.Users = &livechat.AudiencePreChatFormConfig{Enabled: &showUsers}
if !resolveInitialChatConfig(config, true).PreChatForm.Enabled {
t.Fatal("visitor pre-chat form was disabled")
}
if resolveInitialChatConfig(config, false).PreChatForm.Enabled {
t.Fatal("user pre-chat form remained enabled")
}
}
func TestValidateHandoffFormUsesSubmittedContactDetails(t *testing.T) {
var config livechat.Config
config.PreChatForm.Enabled = true
config.PreChatForm.Fields = []livechat.PreChatFormField{{
Key: "name",
Type: "text",
Enabled: true,
Required: true,
IsDefault: true,
}}
name, _, _, _, err := validateFormData(nil, map[string]any{"name": "QA Visitor"}, config, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if name != "QA Visitor" {
t.Fatalf("got name %q", name)
}
}
+20
View File
@@ -4,9 +4,12 @@ import (
"encoding/json"
"github.com/abhinavxd/libredesk/internal/envelope"
oidcmodels "github.com/abhinavxd/libredesk/internal/oidc/models"
"github.com/zerodha/fastglue"
)
const localLoginEnabledKey = "auth.local_login_enabled"
// handleGetConfig returns the public configuration needed for app initialization, this includes minimal app settings and enabled SSO providers (without secrets).
func handleGetConfig(r *fastglue.Request) error {
var app = r.Context.(*App)
@@ -58,6 +61,23 @@ func handleGetConfig(r *fastglue.Request) error {
// Add SSO providers to the response
publicSettings["app.sso_providers"] = enabledProviders
publicSettings["auth.local_login_enabled"] = isLocalLoginEnabled(oidcProviders)
return r.SendEnvelope(publicSettings)
}
func localLoginDisabledInConfig() bool {
return ko.Exists(localLoginEnabledKey) && !ko.Bool(localLoginEnabledKey)
}
func isLocalLoginEnabled(providers []oidcmodels.OIDC) bool {
if !localLoginDisabledInConfig() {
return true
}
for _, provider := range providers {
if provider.Enabled {
return false
}
}
return true
}
+41
View File
@@ -0,0 +1,41 @@
package main
import (
"testing"
oidcmodels "github.com/abhinavxd/libredesk/internal/oidc/models"
)
func TestIsLocalLoginEnabled(t *testing.T) {
tests := []struct {
name string
configured *bool
providers []oidcmodels.OIDC
want bool
}{
{name: "defaults to enabled", want: true},
{name: "remains enabled when configured", configured: boolPointer(true), want: true},
{name: "remains enabled without an enabled OIDC provider", configured: boolPointer(false), providers: []oidcmodels.OIDC{{Enabled: false}}, want: true},
{name: "is disabled with an enabled OIDC provider", configured: boolPointer(false), providers: []oidcmodels.OIDC{{Enabled: true}}, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ko.Delete(localLoginEnabledKey)
if tt.configured != nil {
if err := ko.Set(localLoginEnabledKey, *tt.configured); err != nil {
t.Fatalf("set config: %v", err)
}
}
t.Cleanup(func() { ko.Delete(localLoginEnabledKey) })
if got := isLocalLoginEnabled(tt.providers); got != tt.want {
t.Fatalf("isLocalLoginEnabled() = %v, want %v", got, tt.want)
}
})
}
}
func boolPointer(value bool) *bool {
return &value
}
+70 -71
View File
@@ -2,6 +2,7 @@ package main
import (
"fmt"
"mime/multipart"
"path/filepath"
"strconv"
"strings"
@@ -24,6 +25,23 @@ type blockContactReq struct {
Enabled bool `json:"enabled"`
}
func handleCreateContact(r *fastglue.Request) error {
var app = r.Context.(*App)
contact, _, err := contactFromForm(r)
if err != nil {
return sendErrorEnvelope(r, err)
}
if err := app.user.CreateContact(&contact); err != nil {
return sendErrorEnvelope(r, err)
}
created, err := app.user.GetContactOrVisitor(contact.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(created)
}
// handleGetContacts returns a list of contacts from the database.
func handleGetContacts(r *fastglue.Request) error {
var (
@@ -81,75 +99,9 @@ func handleUpdateContact(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
form, err := r.RequestCtx.MultipartForm()
contactToUpdate, form, err := contactFromForm(r)
if err != nil {
app.lo.Error("error parsing form data", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("errors.parsingRequest"), nil, envelope.GeneralError)
}
// Parse form data
firstName := ""
if v, ok := form.Value["first_name"]; ok && len(v) > 0 {
firstName = string(v[0])
}
lastName := ""
if v, ok := form.Value["last_name"]; ok && len(v) > 0 {
lastName = string(v[0])
}
email := ""
if v, ok := form.Value["email"]; ok && len(v) > 0 {
email = strings.TrimSpace(string(v[0]))
}
phoneNumber := ""
if v, ok := form.Value["phone_number"]; ok && len(v) > 0 {
phoneNumber = string(v[0])
}
phoneNumberCountryCode := ""
if v, ok := form.Value["phone_number_country_code"]; ok && len(v) > 0 {
phoneNumberCountryCode = string(v[0])
}
country := ""
if v, ok := form.Value["country"]; ok && len(v) > 0 {
country = string(v[0])
}
avatarURL := ""
if v, ok := form.Value["avatar_url"]; ok && len(v) > 0 {
avatarURL = string(v[0])
}
// Set nulls to empty strings.
if avatarURL == "null" {
avatarURL = ""
}
if phoneNumberCountryCode == "null" {
phoneNumberCountryCode = ""
}
if phoneNumber == "null" {
phoneNumber = ""
}
if country == "null" {
country = ""
}
// Validate mandatory fields.
if email == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "email"), nil, envelope.InputError)
}
if !stringutil.ValidEmail(email) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("validation.invalidEmail"), nil, envelope.InputError)
}
if firstName == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "first_name"), nil, envelope.InputError)
}
contactToUpdate := models.User{
FirstName: firstName,
LastName: lastName,
Email: null.StringFrom(email),
AvatarURL: null.NewString(avatarURL, avatarURL != ""),
PhoneNumber: null.NewString(phoneNumber, phoneNumber != ""),
PhoneNumberCountryCode: null.NewString(phoneNumberCountryCode, phoneNumberCountryCode != ""),
Country: null.NewString(country, country != ""),
return sendErrorEnvelope(r, err)
}
if err := app.user.UpdateContact(id, contactToUpdate); err != nil {
@@ -157,7 +109,7 @@ func handleUpdateContact(r *fastglue.Request) error {
}
// Delete avatar?
if avatarURL == "" && contact.AvatarURL.Valid {
if !contactToUpdate.AvatarURL.Valid && contact.AvatarURL.Valid {
fileName := filepath.Base(contact.AvatarURL.String)
app.media.Delete(fileName)
contact.AvatarURL.Valid = false
@@ -165,8 +117,7 @@ func handleUpdateContact(r *fastglue.Request) error {
}
// Upload avatar?
files, ok := form.File["files"]
if ok && len(files) > 0 {
if files := form.File["files"]; len(files) > 0 {
if err := uploadUserAvatar(r, contact, files); err != nil {
return sendErrorEnvelope(r, err)
}
@@ -363,3 +314,51 @@ func handleBlockContact(r *fastglue.Request) error {
}
return r.SendEnvelope(contact)
}
func contactFromForm(r *fastglue.Request) (models.User, *multipart.Form, error) {
var app = r.Context.(*App)
form, err := r.RequestCtx.MultipartForm()
if err != nil {
app.lo.Error("error parsing form data", "error", err)
return models.User{}, nil, envelope.NewError(envelope.GeneralError, app.i18n.T("errors.parsingRequest"), nil)
}
value := func(key string) string {
v := ""
if vals := form.Value[key]; len(vals) > 0 {
v = strings.TrimSpace(vals[0])
}
// The edit page sends cleared fields as the string "null".
if v == "null" {
return ""
}
return v
}
optional := func(key string) null.String {
v := value(key)
return null.NewString(v, v != "")
}
email := value("email")
if email == "" {
return models.User{}, nil, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "email"), nil)
}
if !stringutil.ValidEmail(email) {
return models.User{}, nil, envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidEmail"), nil)
}
firstName := value("first_name")
if firstName == "" {
return models.User{}, nil, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "first_name"), nil)
}
return models.User{
FirstName: firstName,
LastName: value("last_name"),
Email: null.StringFrom(email),
AvatarURL: optional("avatar_url"),
PhoneNumber: optional("phone_number"),
PhoneNumberCountryCode: optional("phone_number_country_code"),
Country: optional("country"),
}, form, nil
}
+1 -1
View File
@@ -780,7 +780,7 @@ func handleRemoveUserAssignee(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
if err = app.conversation.RemoveConversationAssignee(uuid, "user", user); err != nil {
if err = app.conversation.UnassignConversationUser(uuid, user); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
+20 -8
View File
@@ -86,9 +86,15 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
}
if err := app.csat.UpdateResponse(uuid, rating, feedback, metaJSON); err != nil {
// Surface only known, user-safe messages; never leak raw driver errors
// (e.g. a malformed UUID triggers a Postgres syntax error string).
msg := app.i18n.T("globals.messages.somethingWentWrong")
if e, ok := err.(envelope.Error); ok {
msg = e.Error()
}
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": map[string]interface{}{
"ErrorMessage": err.Error(),
"ErrorMessage": msg,
},
})
}
@@ -152,10 +158,7 @@ func handleSubmitCSATResponse(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid UUID", nil, envelope.InputError)
}
// Trim feedback if it exceeds max length.
if len(req.Feedback) > maxCsatFeedbackLength {
req.Feedback = req.Feedback[:maxCsatFeedbackLength]
}
req.Feedback = truncateFeedback(req.Feedback)
// Update CSAT response
if err := app.csat.UpdateResponse(uuid, req.Rating, req.Feedback, nil); err != nil {
@@ -187,9 +190,7 @@ func validateCSATForm(r *fastglue.Request) (int, string, json.RawMessage, string
return 0, "", nil, "csat.pleaseFillRequired"
}
if len(feedback) > maxCsatFeedbackLength {
feedback = feedback[:maxCsatFeedbackLength]
}
feedback = truncateFeedback(feedback)
// Collect extra form fields into meta, skipping the known fields.
meta := make(map[string]string)
@@ -220,3 +221,14 @@ func validateCSATForm(r *fastglue.Request) (int, string, json.RawMessage, string
return rating, feedback, metaJSON, ""
}
func truncateFeedback(s string) string {
if len(s) <= maxCsatFeedbackLength {
return s
}
r := []rune(s)
if len(r) <= maxCsatFeedbackLength {
return s
}
return string(r[:maxCsatFeedbackLength])
}
+98
View File
@@ -1,11 +1,13 @@
package main
import (
"encoding/json"
"slices"
"strconv"
cmodels "github.com/abhinavxd/libredesk/internal/custom_attribute/models"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
@@ -109,6 +111,9 @@ func handleDeleteCustomAttribute(r *fastglue.Request) error {
if err != nil || id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError)
}
if err := removeAttributeFromPreChatForms(app, id); err != nil {
return sendErrorEnvelope(r, err)
}
if err = app.customAttribute.Delete(id); err != nil {
return sendErrorEnvelope(r, err)
}
@@ -137,3 +142,96 @@ func validateCustomAttribute(app *App, attribute cmodels.CustomAttribute) error
}
return nil
}
func removeAttributeFromPreChatForms(app *App, attributeID int) error {
inboxes, err := app.inbox.GetAll()
if err != nil {
return err
}
var failed int
for _, inb := range inboxes {
if inb.Channel != livechat.ChannelLiveChat {
continue
}
config, changed, err := stripPreChatFormAttribute(inb.Config, attributeID)
if err != nil {
app.lo.Error("error parsing live chat config", "id", inb.ID, "error", err)
failed++
continue
}
if !changed {
continue
}
if err := app.inbox.UpdateConfig(inb.ID, config); err != nil {
failed++
continue
}
if err := reloadInbox(app, inb.ID); err != nil {
app.lo.Error("error reloading inbox", "id", inb.ID, "error", err)
failed++
}
}
if failed > 0 {
app.lo.Error("error clearing deleted custom attribute from live chat inboxes", "inboxes", failed)
return envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)
}
return nil
}
func stripPreChatFormAttribute(raw json.RawMessage, attributeID int) (json.RawMessage, bool, error) {
var config map[string]any
if len(raw) == 0 {
return raw, false, nil
}
if err := json.Unmarshal(raw, &config); err != nil {
return raw, false, err
}
form, ok := config["prechat_form"].(map[string]any)
if !ok {
return raw, false, nil
}
var changed bool
for _, holder := range []map[string]any{form, asObject(form["visitors"]), asObject(form["users"])} {
if holder == nil {
continue
}
fields, ok := holder["fields"].([]any)
if !ok {
continue
}
kept := make([]any, 0, len(fields))
for _, field := range fields {
if attributeIDOf(field) == attributeID {
changed = true
continue
}
kept = append(kept, field)
}
holder["fields"] = kept
}
if !changed {
return raw, false, nil
}
updated, err := json.Marshal(config)
if err != nil {
return raw, false, err
}
return updated, true, nil
}
func asObject(value any) map[string]any {
object, _ := value.(map[string]any)
return object
}
func attributeIDOf(field any) int {
object, ok := field.(map[string]any)
if !ok {
return 0
}
id, ok := object["custom_attribute_id"].(float64)
if !ok {
return 0
}
return int(id)
}
+60
View File
@@ -0,0 +1,60 @@
package main
import (
"encoding/json"
"testing"
)
func TestStripPreChatFormAttribute(t *testing.T) {
raw := json.RawMessage(`{
"brand_name":"Acme",
"prechat_form":{
"fields":[{"key":"name"},{"key":"plan","custom_attribute_id":7}],
"visitors":{"fields":[{"key":"plan","custom_attribute_id":7},{"key":"email"}]},
"users":{"fields":[{"key":"email"}]}
}
}`)
updated, changed, err := stripPreChatFormAttribute(raw, 7)
if err != nil {
t.Fatal(err)
}
if !changed {
t.Fatal("expected config to change")
}
var config struct {
BrandName string `json:"brand_name"`
PreChatForm struct {
Fields []map[string]any `json:"fields"`
Visitors struct {
Fields []map[string]any `json:"fields"`
} `json:"visitors"`
Users struct {
Fields []map[string]any `json:"fields"`
} `json:"users"`
} `json:"prechat_form"`
}
if err := json.Unmarshal(updated, &config); err != nil {
t.Fatal(err)
}
if config.BrandName != "Acme" {
t.Fatalf("brand name = %q", config.BrandName)
}
if len(config.PreChatForm.Fields) != 1 || config.PreChatForm.Fields[0]["key"] != "name" {
t.Fatalf("fields = %v", config.PreChatForm.Fields)
}
if len(config.PreChatForm.Visitors.Fields) != 1 || config.PreChatForm.Visitors.Fields[0]["key"] != "email" {
t.Fatalf("visitor fields = %v", config.PreChatForm.Visitors.Fields)
}
if len(config.PreChatForm.Users.Fields) != 1 {
t.Fatalf("user fields = %v", config.PreChatForm.Users.Fields)
}
if _, changed, err := stripPreChatFormAttribute(raw, 8); err != nil || changed {
t.Fatalf("unused attribute changed = %v, err = %v", changed, err)
}
if _, changed, err := stripPreChatFormAttribute(json.RawMessage(`{}`), 7); err != nil || changed {
t.Fatalf("config without a pre-chat form changed = %v, err = %v", changed, err)
}
}
+52 -1
View File
@@ -91,6 +91,10 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/api/v1/messages/search", perm(handleSearchMessages, "messages:read"))
g.GET("/api/v1/contacts/search", perm(handleSearchContacts, "contacts:read"))
// New paginated search bar routes with better filter support and pagination.
g.GET("/api/v1/search/conversations", perm(handlePaginatedSearchConversations, "conversations:read"))
g.GET("/api/v1/search/messages", perm(handlePaginatedSearchMessages, "messages:read"))
// Views.
g.GET("/api/v1/views/me", perm(handleGetUserViews, "view:manage"))
g.POST("/api/v1/views/me", perm(handleCreateUserView, "view:manage"))
@@ -152,6 +156,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// Contacts.
g.GET("/api/v1/contacts", perm(handleGetContacts, "contacts:read_all"))
g.POST("/api/v1/contacts", perm(handleCreateContact, "contacts:write"))
g.GET("/api/v1/contacts/{id}", perm(handleGetContact, "contacts:read"))
g.PUT("/api/v1/contacts/{id}", perm(handleUpdateContact, "contacts:write"))
g.PUT("/api/v1/contacts/{id}/block", perm(handleBlockContact, "contacts:block"))
@@ -183,6 +188,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// Inboxes.
g.GET("/api/v1/inboxes", auth(handleGetInboxes))
g.GET("/api/v1/inboxes/{id}/campaign-stats", perm(handleCampaignStats, "inboxes:manage"))
g.GET("/api/v1/inboxes/{id}", perm(handleGetInbox, "inboxes:manage"))
g.POST("/api/v1/inboxes", perm(handleCreateInbox, "inboxes:manage"))
g.PUT("/api/v1/inboxes/{id}/toggle", perm(handleToggleInbox, "inboxes:manage"))
@@ -252,6 +258,10 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// AI completions.
g.GET("/api/v1/ai/prompts", auth(handleGetAIPrompts))
g.GET("/api/v1/ai/prompts/{id}", perm(handleGetAIPrompt, "ai:manage"))
g.POST("/api/v1/ai/prompts", perm(handleCreateAIPrompt, "ai:manage"))
g.PUT("/api/v1/ai/prompts/{id}", perm(handleUpdateAIPrompt, "ai:manage"))
g.DELETE("/api/v1/ai/prompts/{id}", perm(handleDeleteAIPrompt, "ai:manage"))
g.POST("/api/v1/ai/completion", auth(handleAICompletion))
// AI provider config (completion / embedding).
@@ -278,6 +288,8 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.POST("/api/v1/ai/summarize", perm(handleAISummarizeConversation, "messages:write_private"))
g.POST("/api/v1/ai/suggest-tags", auth(handleAISuggestTags))
g.POST("/api/v1/ai/copilot", auth(handleAICopilot))
g.POST("/api/v1/ai/tool-runs/{id}/approve", auth(handleApproveAIToolRun))
g.POST("/api/v1/ai/tool-runs/{id}/decline", auth(handleDeclineAIToolRun))
g.GET("/api/v1/ai/copilot/messages", auth(handleGetCopilotMessages))
g.DELETE("/api/v1/ai/copilot/messages", auth(handleClearCopilotMessages))
@@ -321,6 +333,9 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.PUT("/api/v1/articles/{id}", perm(clearsHCCache(handleUpdateArticle), "help_center:manage"))
g.PUT("/api/v1/articles/{id}/collection", perm(clearsHCCache(handleMoveArticle), "help_center:manage"))
g.PUT("/api/v1/articles/{id}/status", perm(clearsHCCache(handleUpdateArticleStatus), "help_center:manage"))
g.GET("/api/v1/help-centers/{id}/linkable-articles", perm(handleGetLinkableArticles, "help_center:manage"))
g.PUT("/api/v1/articles/{id}/link-translation", perm(clearsHCCache(handleLinkArticleTranslation), "help_center:manage"))
g.PUT("/api/v1/articles/{id}/unlink-translation", perm(clearsHCCache(handleUnlinkArticleTranslation), "help_center:manage"))
g.GET("/api/v1/help-centers/{id}/insights", perm(handleGetHelpCenterInsights, "help_center:manage"))
// Public help center JSON API.
@@ -349,6 +364,10 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.PUT("/api/v1/notifications/read-all", auth(handleMarkAllNotificationsAsRead))
g.DELETE("/api/v1/notifications/{id}", auth(handleDeleteNotification))
g.DELETE("/api/v1/notifications", auth(handleDeleteAllNotifications))
g.GET("/api/v1/notifications/preferences", auth(handleGetNotificationPreferences))
g.PUT("/api/v1/notifications/preferences", auth(handleUpdateNotificationPreferences))
g.POST("/api/v1/notifications/push-subscriptions", auth(handleCreatePushSubscription))
g.DELETE("/api/v1/notifications/push-subscriptions", auth(handleDeletePushSubscription))
// WebSocket.
g.GET("/ws", auth(func(r *fastglue.Request) error {
@@ -361,13 +380,20 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// Widget APIs.
g.GET("/api/v1/widget/chat/settings/launcher", rateLimit(validateWidgetInbox(handleGetChatLauncherSettings), "widget"))
g.GET("/api/v1/widget/chat/settings", rateLimit(validateWidgetInbox(handleGetChatSettings), "widget"))
g.POST("/api/v1/widget/chat/campaigns/next", rateLimit(optionalWidgetAuth(handleWidgetCampaign), "widget"))
g.POST("/api/v1/widget/chat/campaigns/event", rateLimit(optionalWidgetAuth(handleWidgetCampaignEvent), "widget"))
g.GET("/api/v1/widget/chat/help", rateLimit(optionalWidgetAuth(handleWidgetHelp), "widget"))
g.GET("/api/v1/widget/chat/help/search", rateLimit(optionalWidgetAuth(handleWidgetHelpSearch), "widget"))
g.GET("/api/v1/widget/chat/help/articles/{article_slug}", rateLimit(optionalWidgetAuth(handleWidgetHelpArticle), "widget"))
g.POST("/api/v1/widget/chat/auth/exchange", rateLimit(validateWidgetInbox(handleAuthExchange), "widget"))
g.GET("/api/v1/widget/chat/auth/me", rateLimit(widgetAuth(handleWidgetAuthMe), "widget"))
g.POST("/api/v1/widget/chat/conversations/init", rateLimit(widgetAuth(handleChatInit), "widget"))
g.GET("/api/v1/widget/chat/conversations", rateLimit(widgetAuth(handleGetConversations), "widget"))
g.POST("/api/v1/widget/chat/conversations/{uuid}/update-last-seen", rateLimit(widgetAuth(handleChatUpdateLastSeen), "widget"))
g.GET("/api/v1/widget/chat/conversations/{uuid}", rateLimit(widgetAuth(handleChatGetConversation), "widget"))
g.GET("/api/v1/widget/chat/conversations/{uuid}/transcript", rateLimit(widgetAuth(handleWidgetTranscript), "widget"))
g.POST("/api/v1/widget/chat/conversations/{uuid}/message", rateLimit(widgetAuth(handleChatSendMessage), "widget"))
g.POST("/api/v1/widget/chat/conversations/{uuid}/handoff-form", rateLimit(widgetAuth(handleChatSubmitHandoffForm), "widget"))
g.POST("/api/v1/widget/media/upload", rateLimit(widgetAuth(handleWidgetMediaUpload), "widget"))
// getAndHead registers both methods: uptime checkers and link validators probe with HEAD.
@@ -395,6 +421,8 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/assets/{all:*}", serveFrontendStaticFiles)
g.GET("/widget/assets/{all:*}", serveWidgetStaticFiles)
g.GET("/images/{all:*}", serveFrontendStaticFiles)
g.GET("/manifest.webmanifest", serveManifest)
g.GET("/sw.js", serveServiceWorker)
g.GET("/static/public/{all:*}", serveStaticFiles)
// Public pages.
@@ -514,6 +542,27 @@ func serveFrontendStaticFiles(r *fastglue.Request) error {
return nil
}
func serveManifest(r *fastglue.Request) error {
return serveMainFrontendFile(r, "manifest.webmanifest", "application/manifest+json", "no-cache")
}
func serveServiceWorker(r *fastglue.Request) error {
r.RequestCtx.Response.Header.Set("Service-Worker-Allowed", "/")
return serveMainFrontendFile(r, "sw.js", "application/javascript", "no-cache")
}
func serveMainFrontendFile(r *fastglue.Request, name, contentType, cacheControl string) error {
app := r.Context.(*App)
file, err := app.fs.Get(filepath.Join(frontendDir, name))
if err != nil {
return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.T("validation.notFoundFile"), nil, envelope.NotFoundError)
}
r.RequestCtx.Response.Header.Set("Content-Type", contentType)
r.RequestCtx.Response.Header.Set("Cache-Control", cacheControl)
r.RequestCtx.SetBody(file.ReadBytes())
return nil
}
// serveWidgetStaticFiles serves widget static assets from the embedded filesystem.
func serveWidgetStaticFiles(r *fastglue.Request) error {
app := r.Context.(*App)
@@ -542,7 +591,9 @@ func serveWidgetJS(r *fastglue.Request) error {
app := r.Context.(*App)
r.RequestCtx.Response.Header.Set("Content-Type", "application/javascript")
r.RequestCtx.Response.Header.Set("Cache-Control", "no-cache")
r.RequestCtx.Response.Header.Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
r.RequestCtx.Response.Header.Set("Pragma", "no-cache")
r.RequestCtx.Response.Header.Set("Expires", "0")
file, err := app.fs.Get("static/widget.js")
if err != nil {
+188 -15
View File
@@ -1,6 +1,7 @@
package main
import (
"cmp"
"encoding/json"
"encoding/xml"
"fmt"
@@ -21,6 +22,7 @@ import (
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/helpcenter"
hcmodels "github.com/abhinavxd/libredesk/internal/helpcenter/models"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
"github.com/abhinavxd/libredesk/internal/media"
"github.com/abhinavxd/libredesk/internal/stringutil"
realip "github.com/ferluci/fast-realip"
@@ -138,11 +140,22 @@ type localeLink struct {
Path string
}
type helpArticleResponse struct {
hcmodels.Article
Translations []hcmodels.ArticleTranslation `json:"translations"`
}
type previewTOCItem struct {
ID string
Title string
}
type colorSchemeResult struct {
dark bool
showToggle bool
followSystem bool
}
func (w helpCenterCacheLogWriter) Write(p []byte) (int, error) {
w.lo.Error("help center page cache: " + strings.TrimSpace(string(p)))
return len(p), nil
@@ -255,7 +268,7 @@ func handleHelpCenterPreview(r *fastglue.Request) error {
"Data": map[string]interface{}{
"Title": helpCenter.PageTitle,
"LandingHero": true,
"HelpCenter": helpCenterTemplateData(app, helpCenter, locale),
"HelpCenter": helpCenterTemplateData(app, r, helpCenter, locale),
"Tree": tree.Tree,
"Popular": popular,
},
@@ -445,7 +458,11 @@ func handleGetArticle(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(article)
translations, err := app.helpcenter.GetArticleTranslations(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(helpArticleResponse{Article: article, Translations: translations})
}
// handleCreateArticle creates a new article.
@@ -570,6 +587,67 @@ func handleUpdateArticleStatus(r *fastglue.Request) error {
return r.SendEnvelope(article)
}
// handleGetLinkableArticles lists articles in the other locales that can still be linked as a translation.
func handleGetLinkableArticles(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
excludeLocale = strings.TrimSpace(string(r.RequestCtx.QueryArgs().Peek("exclude_locale")))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
}
if excludeLocale == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`exclude_locale`"), nil, envelope.InputError)
}
articles, err := app.helpcenter.GetLinkableArticles(id, excludeLocale)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(articles)
}
// handleLinkArticleTranslation moves an existing article into this article's translation group.
func handleLinkArticleTranslation(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req = struct {
TranslationOfID int `json:"translation_of_id"`
}{}
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
}
if req.TranslationOfID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`translation_of_id`"), nil, envelope.InputError)
}
article, err := app.helpcenter.LinkArticleTranslation(id, req.TranslationOfID)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(article)
}
// handleUnlinkArticleTranslation detaches an article from its translation group.
func handleUnlinkArticleTranslation(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
}
article, err := app.helpcenter.UnlinkArticleTranslation(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(article)
}
// handleRedirectHelpCenterHome redirects bare /hc/{slug} to the default-locale home so the locale is always in the path.
func handleRedirectHelpCenterHome(r *fastglue.Request) error {
var (
@@ -583,8 +661,12 @@ func handleRedirectHelpCenterHome(r *fastglue.Request) error {
if redirectHelpCenterCanonicalHost(r, helpCenter) {
return nil
}
uri := helpCenterHomePath(helpCenter, helpCenter.DefaultLocale)
if qs := r.RequestCtx.URI().QueryString(); len(qs) > 0 {
uri += "?" + string(qs)
}
// 302, not 301: the default locale is mutable and browsers cache permanent redirects.
redirectPath(r.RequestCtx, helpCenterHomePath(helpCenter, helpCenter.DefaultLocale), fasthttp.StatusFound)
redirectPath(r.RequestCtx, uri, fasthttp.StatusFound)
return nil
}
@@ -620,7 +702,7 @@ func handleShowHelpCenterHome(r *fastglue.Request) error {
theme = helpCenterTheme(tree.HelpCenter)
metaDescription = firstNonEmpty(tree.HelpCenter.MetaDescription, theme.Header.Heading)
)
data := helpCenterTemplateData(app, tree.HelpCenter, locale)
data := helpCenterTemplateData(app, r, tree.HelpCenter, locale)
return renderHelpCenterPage(r, hcPageName(helpCenter, "help-center"), map[string]interface{}{
"L": localeI18n(app, locale),
"Data": map[string]interface{}{
@@ -674,7 +756,7 @@ func handleShowHelpCenterCollection(r *fastglue.Request) error {
root = helpCenterBaseURL(app, helpCenter)
pathFor = func(l string) string { return collectionPath(helpCenter, l, collection.Slug) }
)
data := helpCenterTemplateData(app, helpCenter, locale)
data := helpCenterTemplateData(app, r, helpCenter, locale)
return renderHelpCenterPage(r, hcPageName(helpCenter, "help-collection"), map[string]interface{}{
"L": localeI18n(app, locale),
"Data": map[string]interface{}{
@@ -736,18 +818,27 @@ func handleShowHelpCenterArticle(r *fastglue.Request) error {
if err != nil {
related = nil
}
translated, err := app.helpcenter.GetPublishedArticleLocales(slug, article.Slug)
translations, err := app.helpcenter.GetPublishedArticleTranslations(slug, article.ID)
if err != nil {
translated = []string{locale}
translations = nil
}
translated := make([]string, 0, len(translations)+1)
slugByLocale := map[string]string{locale: article.Slug}
for _, t := range translations {
translated = append(translated, t.Locale)
slugByLocale[t.Locale] = t.Slug
}
if !slices.Contains(translated, locale) {
translated = append(translated, locale)
}
var (
root = helpCenterBaseURL(app, helpCenter)
pathFor = func(l string) string { return articlePath(helpCenter, l, article.Slug) }
pathFor = func(l string) string { return articlePath(helpCenter, l, slugByLocale[l]) }
metaDescription = firstNonEmpty(article.MetaDescription, article.Excerpt)
metaTitle = firstNonEmpty(article.MetaTitle, fmt.Sprintf("%s - %s", article.Title, helpCenter.Name))
ogImage = absoluteURL(root, publicAssetPaths(app, firstNonEmpty(article.MetaImageURL, helpCenterTheme(helpCenter).LogoURL)))
)
data := helpCenterTemplateData(app, helpCenter, locale)
data := helpCenterTemplateData(app, r, helpCenter, locale)
return renderHelpCenterPage(r, hcPageName(helpCenter, "help-article"), map[string]interface{}{
"L": localeI18n(app, locale),
"Data": map[string]interface{}{
@@ -770,6 +861,7 @@ func handleShowHelpCenterArticle(r *fastglue.Request) error {
"Tree": sidebarTree(app, helpCenter, locale),
"ActiveCollection": collection.Slug,
"ActiveArticle": article.Slug,
"NoIndex": isEmbedRequest(r),
"Content": template.HTML(publicAssetPaths(app, stringutil.DeferOffscreenImages(article.Content))),
},
})
@@ -803,7 +895,7 @@ func handleHelpCenterSearch(r *fastglue.Request) error {
}
}
var (
data = helpCenterTemplateData(app, helpCenter, locale)
data = helpCenterTemplateData(app, r, helpCenter, locale)
lcl = localeI18n(app, locale)
pathFor = func(l string) string { return searchPath(helpCenter, l) }
)
@@ -1153,9 +1245,11 @@ func cacheHCPage(h fastglue.FastRequestHandler, noIndex bool) fastglue.FastReque
}
r.RequestCtx.Response.Header.Set("X-Cache", status)
r.RequestCtx.Response.Header.Set("X-Content-Type-Options", "nosniff")
r.RequestCtx.Response.Header.Set("X-Frame-Options", "SAMEORIGIN")
if !isEmbedRequest(r) {
r.RequestCtx.Response.Header.Set("X-Frame-Options", "SAMEORIGIN")
}
r.RequestCtx.Response.Header.Set("Referrer-Policy", "strict-origin-when-cross-origin")
if noIndex || isMarkdownRequest(r) {
if noIndex || isEmbedRequest(r) || isMarkdownRequest(r) {
r.RequestCtx.Response.Header.Set("X-Robots-Tag", noIndexHeader)
}
}
@@ -1163,6 +1257,11 @@ func cacheHCPage(h fastglue.FastRequestHandler, noIndex bool) fastglue.FastReque
}
}
// isEmbedRequest reports whether the page is framed by the chat widget.
func isEmbedRequest(r *fastglue.Request) bool {
return r.RequestCtx.QueryArgs().GetBool("embed")
}
func isMarkdownRequest(r *fastglue.Request) bool {
slug, _ := r.RequestCtx.UserValue("article_slug").(string)
return strings.HasSuffix(slug, markdownSlugExtension)
@@ -1678,7 +1777,7 @@ func sidebarTree(app *App, hc hcmodels.HelpCenter, locale string) []hcmodels.Tre
}
// helpCenterTemplateData shapes a help center row for the public templates.
func helpCenterTemplateData(app *App, hc hcmodels.HelpCenter, locale string) map[string]interface{} {
func helpCenterTemplateData(app *App, r *fastglue.Request, hc hcmodels.HelpCenter, locale string) map[string]interface{} {
theme := helpCenterTheme(hc)
theme.Favicon = publicAssetPaths(app, theme.Favicon)
theme.Header.BackgroundImage = publicAssetPaths(app, theme.Header.BackgroundImage)
@@ -1686,6 +1785,8 @@ func helpCenterTemplateData(app *App, hc hcmodels.HelpCenter, locale string) map
if pageTemplate == "" {
pageTemplate = hcmodels.TemplateClassic
}
embed := isEmbedRequest(r)
scheme := resolveColorScheme(theme.ColorScheme, string(r.RequestCtx.QueryArgs().Peek("theme")), embed)
return map[string]interface{}{
"Slug": hc.Slug,
"Name": hc.Name,
@@ -1695,7 +1796,11 @@ func helpCenterTemplateData(app *App, hc hcmodels.HelpCenter, locale string) map
"PageTitle": hc.PageTitle,
"HeaderText": theme.Header.Heading,
"LogoURL": publicAssetPaths(app, theme.LogoURL),
"LogoURLDark": publicAssetPaths(app, cmp.Or(theme.LogoURLDark, theme.LogoURL)),
"Color": theme.Color,
"ColorDark": cmp.Or(theme.ColorDark, theme.Color),
"ShowThemeToggle": scheme.showToggle,
"FollowSystemTheme": scheme.followSystem,
"DefaultLocale": hc.DefaultLocale,
"CurrentLocale": locale,
"OGLocale": strings.ReplaceAll(locale, "-", "_"),
@@ -1704,15 +1809,31 @@ func helpCenterTemplateData(app *App, hc hcmodels.HelpCenter, locale string) map
"NavLinks": theme.NavLinks,
"Theme": theme,
"ThemeCSS": buildThemeCSSVars(theme),
"ThemeCSSDark": buildDarkThemeCSSVars(theme),
"AnnouncementKey": announcementKey(hc.Slug, theme.Announcement),
"TaglineHTML": template.HTML(helpcenter.RenderInlineMarkdown(theme.Tagline)),
"FooterTaglineHTML": template.HTML(helpcenter.RenderInlineMarkdown(theme.Footer.Tagline)),
"AnnouncementHTML": template.HTML(helpcenter.RenderInlineMarkdown(theme.Announcement.Text)),
"CustomCSS": template.CSS(hc.CustomCSS),
"CustomJS": template.JS(hc.CustomJS),
"WidgetInboxUUID": livechatWidgetInboxUUID(app, hc),
"WidgetRootURL": helpCenterRootURL(app),
"Embed": embed,
"Dark": scheme.dark,
}
}
func livechatWidgetInboxUUID(app *App, hc hcmodels.HelpCenter) string {
if !hc.LivechatInboxID.Valid {
return ""
}
inb, err := app.inbox.GetDBRecord(hc.LivechatInboxID.Int)
if err != nil || !inb.Enabled || inb.Channel != livechat.ChannelLiveChat {
return ""
}
return inb.UUID
}
// announcementKey keys the dismissal on help center and content, so an edited announcement reappears for visitors who dismissed the old one.
func announcementKey(hcSlug string, a hcmodels.AnnouncementTheme) string {
if a.Text == "" {
@@ -1762,6 +1883,38 @@ func buildThemeCSSVars(t hcmodels.Theme) template.CSS {
return template.CSS(b.String())
}
// buildDarkThemeCSSVars swaps in the dark footer colors and resets light-only colors to the dark defaults.
func buildDarkThemeCSSVars(t hcmodels.Theme) template.CSS {
var b strings.Builder
if t.Header.TextColor != "" && !hasHeaderBackground(t.Header) {
b.WriteString("--hc-header-text:initial;")
}
writeDarkVar(&b, "--hc-footer-bg", t.Footer.BackgroundColorDark, t.Footer.BackgroundColor)
writeDarkVar(&b, "--hc-footer-text", t.Footer.TextColorDark, t.Footer.TextColor)
return template.CSS(b.String())
}
func writeDarkVar(b *strings.Builder, name, dark, light string) {
switch {
case dark != "":
fmt.Fprintf(b, "%s:%s;", name, dark)
case light != "":
fmt.Fprintf(b, "%s:initial;", name)
}
}
func hasHeaderBackground(h hcmodels.HeaderTheme) bool {
switch h.BackgroundType {
case "image":
return h.BackgroundImage != ""
case "gradient":
return h.GradientFrom != "" && h.GradientTo != ""
case "solid":
return h.BackgroundColor != ""
}
return false
}
// renderHelpCenterNotFound renders the help center's themed 404, falling back to the
// generic error page when the help center is nil.
func renderHelpCenterNotFound(r *fastglue.Request, hc *hcmodels.HelpCenter) error {
@@ -1788,7 +1941,7 @@ func renderHelpCenterStatusPage(r *fastglue.Request, hc *hcmodels.HelpCenter, st
if !ok {
locale = helpCenter.DefaultLocale
}
data := helpCenterTemplateData(app, helpCenter, locale)
data := helpCenterTemplateData(app, r, helpCenter, locale)
lcl := localeI18n(app, locale)
r.RequestCtx.Response.Header.Set("X-Robots-Tag", noIndexHeader)
rerr := app.tmpl.RenderWebPage(r.RequestCtx, hcPageName(helpCenter, "help-notfound"), map[string]interface{}{
@@ -1832,6 +1985,15 @@ func validateHelpCenter(app *App, req *helpcenter.HelpCenterRequest) error {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`page_title`"), nil)
}
req.Theme = json.RawMessage(publicAssetPaths(app, string(req.Theme)))
if req.LivechatInboxID.Valid {
inb, err := app.inbox.GetDBRecord(req.LivechatInboxID.Int)
if err != nil {
return err
}
if inb.Channel != livechat.ChannelLiveChat {
return envelope.NewError(envelope.InputError, app.i18n.T("helpCenter.invalidLivechatInbox"), nil)
}
}
return nil
}
@@ -1970,7 +2132,7 @@ func renderHelpCenterArticlePreview(r *fastglue.Request, helpCenter hcmodels.Hel
"Data": map[string]interface{}{
"Title": article.Title,
"ModifiedTime": article.UpdatedAt.Format(time.RFC3339),
"HelpCenter": helpCenterTemplateData(app, helpCenter, locale),
"HelpCenter": helpCenterTemplateData(app, r, helpCenter, locale),
"Article": article,
"AuthorInitial": authorInitial(article),
"Collection": collection,
@@ -1996,3 +2158,14 @@ func redirectPath(ctx *fasthttp.RequestCtx, uri string, statusCode int) {
ctx.SetStatusCode(statusCode)
ctx.Response.SetBodyString("")
}
// resolveColorScheme lets the widget embed and the admin preview override the admin's scheme.
func resolveColorScheme(scheme, requested string, embed bool) colorSchemeResult {
res := colorSchemeResult{dark: scheme == hcmodels.ColorSchemeDark}
if requested != "" || embed {
res.dark = requested == hcmodels.ColorSchemeDark
}
res.showToggle = scheme == hcmodels.ColorSchemeSystem && !embed
res.followSystem = res.showToggle && requested == ""
return res
}
+67
View File
@@ -0,0 +1,67 @@
package main
import (
"testing"
hcmodels "github.com/abhinavxd/libredesk/internal/helpcenter/models"
)
func TestResolveColorScheme(t *testing.T) {
const (
system = hcmodels.ColorSchemeSystem
light = hcmodels.ColorSchemeLight
dark = hcmodels.ColorSchemeDark
)
tests := []struct {
name string
scheme string
requested string
embed bool
want colorSchemeResult
}{
{"light site", light, "", false, colorSchemeResult{}},
{"dark site", dark, "", false, colorSchemeResult{dark: true}},
{"system site", system, "", false, colorSchemeResult{showToggle: true, followSystem: true}},
{"preview dark of a light site", light, dark, false, colorSchemeResult{dark: true}},
{"preview light of a dark site", dark, light, false, colorSchemeResult{}},
{"preview of a system site keeps the toggle", system, dark, false, colorSchemeResult{dark: true, showToggle: true}},
{"embed in a light widget on a dark site", dark, "", true, colorSchemeResult{}},
{"embed in a dark widget", light, dark, true, colorSchemeResult{dark: true}},
{"embed on a system site", system, dark, true, colorSchemeResult{dark: true}},
{"unknown requested value", dark, "purple", false, colorSchemeResult{}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := resolveColorScheme(tt.scheme, tt.requested, tt.embed); got != tt.want {
t.Errorf("resolveColorScheme(%q, %q, %v) = %+v, want %+v", tt.scheme, tt.requested, tt.embed, got, tt.want)
}
})
}
}
func TestBuildDarkThemeCSSVars(t *testing.T) {
tests := []struct {
name string
header hcmodels.HeaderTheme
footer hcmodels.FooterTheme
want string
}{
{"nothing set", hcmodels.HeaderTheme{}, hcmodels.FooterTheme{}, ""},
{"header text on default header", hcmodels.HeaderTheme{TextColor: "#000"}, hcmodels.FooterTheme{}, "--hc-header-text:initial;"},
{"header text on solid header", hcmodels.HeaderTheme{BackgroundType: "solid", BackgroundColor: "#fff", TextColor: "#000"}, hcmodels.FooterTheme{}, ""},
{"header text on image header", hcmodels.HeaderTheme{BackgroundType: "image", BackgroundImage: "/a.png", TextColor: "#000"}, hcmodels.FooterTheme{}, ""},
{"header text on half-set gradient", hcmodels.HeaderTheme{BackgroundType: "gradient", GradientFrom: "#fff", TextColor: "#000"}, hcmodels.FooterTheme{}, "--hc-header-text:initial;"},
{"footer text on default footer", hcmodels.HeaderTheme{}, hcmodels.FooterTheme{TextColor: "#000"}, "--hc-footer-text:initial;"},
{"light footer colors only", hcmodels.HeaderTheme{}, hcmodels.FooterTheme{BackgroundColor: "#fff", TextColor: "#000"}, "--hc-footer-bg:initial;--hc-footer-text:initial;"},
{"dark footer colors", hcmodels.HeaderTheme{}, hcmodels.FooterTheme{BackgroundColor: "#fff", TextColor: "#000", BackgroundColorDark: "#111", TextColorDark: "#eee"}, "--hc-footer-bg:#111;--hc-footer-text:#eee;"},
{"dark footer text only", hcmodels.HeaderTheme{}, hcmodels.FooterTheme{TextColorDark: "#eee"}, "--hc-footer-text:#eee;"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := string(buildDarkThemeCSSVars(hcmodels.Theme{Header: tt.header, Footer: tt.footer}))
if got != tt.want {
t.Errorf("buildDarkThemeCSSVars() = %q, want %q", got, tt.want)
}
})
}
}
+156 -84
View File
@@ -6,6 +6,7 @@ import (
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/httputil"
@@ -17,6 +18,13 @@ import (
"github.com/zerodha/fastglue"
)
var hexColorRegex = regexp.MustCompile(`^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$`)
const (
minLauncherIconScale = 40
maxLauncherIconScale = 100
)
// handleGetInboxes returns all inboxes
func handleGetInboxes(r *fastglue.Request) error {
var app = r.Context.(*App)
@@ -43,6 +51,18 @@ func handleGetInbox(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
if inbox.Channel == livechat.ChannelLiveChat {
var config livechat.Config
if err := json.Unmarshal(inbox.Config, &config); err != nil {
app.lo.Error("error parsing live chat config", "id", id, "error", err)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil))
}
inbox.Config, err = json.Marshal(config)
if err != nil {
app.lo.Error("error encoding live chat config", "id", id, "error", err)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil))
}
}
if err := inbox.ClearPasswords(); err != nil {
app.lo.Error("error clearing inbox passwords from response", "error", err)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil))
@@ -257,110 +277,133 @@ func validateInbox(app *App, inbox *imodels.Inbox, id int) error {
// Validate livechat-specific configuration
if inbox.Channel == livechat.ChannelLiveChat {
var config livechat.Config
if err := json.Unmarshal(inbox.Config, &config); err == nil {
// ShowOfficeHoursAfterAssignment cannot be enabled if ShowOfficeHoursInChat is disabled
if config.ShowOfficeHoursAfterAssignment && !config.ShowOfficeHoursInChat {
return envelope.NewError(envelope.InputError, "`show_office_hours_after_assignment` cannot be enabled when `show_office_hours_in_chat` is disabled", nil)
if err := json.Unmarshal(inbox.Config, &config); err != nil {
return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidValue"), nil)
}
if err := validateLiveChatSessionDuration(app, config.SessionDuration); err != nil {
return err
}
if err := validateWidgetFeatures(app, config); err != nil {
return err
}
// ShowOfficeHoursAfterAssignment cannot be enabled if ShowOfficeHoursInChat is disabled
if config.ShowOfficeHoursAfterAssignment && !config.ShowOfficeHoursInChat {
return envelope.NewError(envelope.InputError, "`show_office_hours_after_assignment` cannot be enabled when `show_office_hours_in_chat` is disabled", nil)
}
// Validate continuity settings - required when linked email inbox is set.
if inbox.LinkedEmailInboxID.Valid && inbox.LinkedEmailInboxID.Int > 0 {
if config.Continuity.OfflineThreshold == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "offline_threshold"), nil)
}
// Validate continuity settings - required when linked email inbox is set.
if inbox.LinkedEmailInboxID.Valid && inbox.LinkedEmailInboxID.Int > 0 {
if config.Continuity.OfflineThreshold == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "offline_threshold"), nil)
}
if config.Continuity.MinEmailInterval == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "min_email_interval"), nil)
}
if config.Continuity.MaxMessagesPerEmail == 0 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "max_messages_per_email"), nil)
}
if config.Continuity.MinEmailInterval == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "min_email_interval"), nil)
}
if config.Continuity.OfflineThreshold != "" {
d, err := time.ParseDuration(config.Continuity.OfflineThreshold)
if err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("validation.invalidDuration", "name", "offline_threshold"), nil)
}
if d < time.Minute {
return envelope.NewError(envelope.InputError, app.i18n.Ts("validation.minDuration", "name", "offline_threshold", "min", "1m"), nil)
}
if config.Continuity.MaxMessagesPerEmail == 0 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "max_messages_per_email"), nil)
}
if config.Continuity.MinEmailInterval != "" {
d, err := time.ParseDuration(config.Continuity.MinEmailInterval)
if err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("validation.invalidDuration", "name", "min_email_interval"), nil)
}
if d < time.Minute {
return envelope.NewError(envelope.InputError, app.i18n.Ts("validation.minDuration", "name", "min_email_interval", "min", "1m"), nil)
}
}
if config.Continuity.OfflineThreshold != "" {
d, err := time.ParseDuration(config.Continuity.OfflineThreshold)
if err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("validation.invalidDuration", "name", "offline_threshold"), nil)
}
if config.Continuity.MaxMessagesPerEmail != 0 {
if config.Continuity.MaxMessagesPerEmail < 1 || config.Continuity.MaxMessagesPerEmail > 100 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("validation.minmaxNumber", "min", "1", "max", "100"), nil)
}
if d < time.Minute {
return envelope.NewError(envelope.InputError, app.i18n.Ts("validation.minDuration", "name", "offline_threshold", "min", "1m"), nil)
}
}
if config.Continuity.MinEmailInterval != "" {
d, err := time.ParseDuration(config.Continuity.MinEmailInterval)
if err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("validation.invalidDuration", "name", "min_email_interval"), nil)
}
if d < time.Minute {
return envelope.NewError(envelope.InputError, app.i18n.Ts("validation.minDuration", "name", "min_email_interval", "min", "1m"), nil)
}
}
if config.Continuity.MaxMessagesPerEmail != 0 {
if config.Continuity.MaxMessagesPerEmail < 1 || config.Continuity.MaxMessagesPerEmail > 100 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("validation.minmaxNumber", "min", "1", "max", "100"), nil)
}
}
// Validate colors.
hexColorRegex := regexp.MustCompile(`^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$`)
if config.Colors.Primary == "" {
for _, replies := range [][]string{config.Visitors.QuickReplies, config.Users.QuickReplies} {
if err := validateQuickReplies(app, replies); err != nil {
return err
}
}
for _, branding := range []livechat.Branding{config.Branding.Light, config.Branding.Dark} {
if branding.Colors.Primary == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "primary color"), nil)
}
if !hexColorRegex.MatchString(config.Colors.Primary) {
if !hexColorRegex.MatchString(branding.Colors.Primary) {
return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidColor"), nil)
}
// Validate launcher position.
if config.Launcher.Position != "left" && config.Launcher.Position != "right" {
return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidValue"), nil)
if branding.Launcher.Color != "" && !hexColorRegex.MatchString(branding.Launcher.Color) {
return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidColor"), nil)
}
// Validate launcher spacing: clamp to a sane range so a fat-fingered value doesn't push the launcher off-screen.
if config.Launcher.Spacing.Side < 0 || config.Launcher.Spacing.Side > 200 ||
config.Launcher.Spacing.Bottom < 0 || config.Launcher.Spacing.Bottom > 200 {
return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidValue"), nil)
}
// Validate home apps.
for _, ha := range config.HomeApps {
if ha.URL != "" && !httputil.IsValidHTTPURL(ha.URL) {
return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidUrl"), nil)
}
if ha.Type == livechat.HomeAppAnnouncement && ha.ImageURL != "" && !httputil.IsValidHTTPURL(ha.ImageURL) {
return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidUrl"), nil)
}
}
// Validate home screen background image URL.
if config.HomeScreen.Background.ImageURL != "" && !httputil.IsValidHTTPURL(config.HomeScreen.Background.ImageURL) {
if branding.HomeScreen.Background.ImageURL != "" && !httputil.IsValidHTTPURL(branding.HomeScreen.Background.ImageURL) {
return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidUrl"), nil)
}
// Validate URLs if set.
for _, u := range []string{config.LogoURL, config.Launcher.LogoURL, config.WebsiteURL} {
for _, u := range []string{branding.LogoURL, branding.Launcher.LogoURL} {
if u != "" && !httputil.IsValidHTTPURL(u) {
return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidUrl"), nil)
}
}
}
// Validate trusted domains.
// Valid formats: example.com, *.example.com, sub.example.com, example.com:8080
for _, domain := range config.TrustedDomains {
d := strings.TrimSpace(domain)
if d == "" {
continue
}
if strings.Contains(d, "://") || strings.Contains(d, "/") || strings.Contains(d, " ") {
return envelope.NewError(envelope.InputError, app.i18n.Ts("validation.invalidDomain", "domain", d), nil)
}
// Wildcard must be at the start followed by a dot.
if strings.Contains(d, "*") && !strings.HasPrefix(d, "*.") {
return envelope.NewError(envelope.InputError, app.i18n.Ts("validation.invalidDomain", "domain", d), nil)
}
if config.Theme != livechat.ThemeSystem && config.Theme != livechat.ThemeLight && config.Theme != livechat.ThemeDark {
return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidValue"), nil)
}
// Validate launcher position.
if config.Launcher.Position != "left" && config.Launcher.Position != "right" {
return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidValue"), nil)
}
if config.Launcher.IconScale < minLauncherIconScale || config.Launcher.IconScale > maxLauncherIconScale {
return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidValue"), nil)
}
// Validate launcher spacing: clamp to a sane range so a fat-fingered value doesn't push the launcher off-screen.
if config.Launcher.Spacing.Side < 0 || config.Launcher.Spacing.Side > 200 ||
config.Launcher.Spacing.Bottom < 0 || config.Launcher.Spacing.Bottom > 200 {
return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidValue"), nil)
}
for _, ha := range config.HomeApps {
if ha.URL != "" && !httputil.IsValidHTTPURL(ha.URL) {
return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidUrl"), nil)
}
if ha.ImageURL != "" && !httputil.IsValidHTTPURL(ha.ImageURL) {
return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidUrl"), nil)
}
}
// Validate blocked IPs entries.
for _, entry := range config.BlockedIPs {
if !httputil.ValidateIPOrCIDR(entry) {
return envelope.NewError(envelope.InputError, app.i18n.Ts("validation.invalidIPOrCIDR", "entry", entry), nil)
}
if config.WebsiteURL != "" && !httputil.IsValidHTTPURL(config.WebsiteURL) {
return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidUrl"), nil)
}
// Validate trusted domains.
// Valid formats: example.com, *.example.com, sub.example.com, example.com:8080
for _, domain := range config.TrustedDomains {
d := strings.TrimSpace(domain)
if d == "" {
continue
}
if strings.Contains(d, "://") || strings.Contains(d, "/") || strings.Contains(d, " ") {
return envelope.NewError(envelope.InputError, app.i18n.Ts("validation.invalidDomain", "domain", d), nil)
}
// Wildcard must be at the start followed by a dot.
if strings.Contains(d, "*") && !strings.HasPrefix(d, "*.") {
return envelope.NewError(envelope.InputError, app.i18n.Ts("validation.invalidDomain", "domain", d), nil)
}
}
// Validate blocked IPs entries.
for _, entry := range config.BlockedIPs {
if !httputil.ValidateIPOrCIDR(entry) {
return envelope.NewError(envelope.InputError, app.i18n.Ts("validation.invalidIPOrCIDR", "entry", entry), nil)
}
}
@@ -391,6 +434,35 @@ func validateInbox(app *App, inbox *imodels.Inbox, id int) error {
return nil
}
func validateQuickReplies(app *App, replies []string) error {
if len(replies) > 6 {
return envelope.NewError(envelope.InputError, app.i18n.T("admin.inbox.livechat.quickReplies.limit"), nil)
}
for _, reply := range replies {
if strings.TrimSpace(reply) == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "quick reply"), nil)
}
if utf8.RuneCountInString(reply) > 120 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.maxLength", "max", "120"), nil)
}
}
return nil
}
func validateLiveChatSessionDuration(app *App, value string) error {
if value == "" {
return nil
}
duration, err := time.ParseDuration(value)
if err != nil {
return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidDuration"), nil)
}
if duration < time.Hour {
return envelope.NewError(envelope.InputError, app.i18n.Ts("validation.minDuration", "name", "session_duration", "min", "1h"), nil)
}
return nil
}
// validateEmailConfig validates the email inbox configuration.
func validateEmailConfig(app *App, configJSON json.RawMessage) error {
var cfg imodels.Config
+57
View File
@@ -0,0 +1,57 @@
package main
import (
"strings"
"testing"
)
func TestValidateQuickReplies(t *testing.T) {
app := newValidatorTestApp(t)
tests := []struct {
name string
replies []string
wantErr bool
}{
{name: "unicode limit", replies: []string{strings.Repeat("界", 120)}},
{name: "unicode over limit", replies: []string{strings.Repeat("界", 121)}, wantErr: true},
{name: "blank", replies: []string{" "}, wantErr: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := validateQuickReplies(app, tc.replies)
if (err != nil) != tc.wantErr {
t.Fatalf("got error %v, want error %v", err, tc.wantErr)
}
})
}
}
func TestValidateLiveChatSessionDuration(t *testing.T) {
app := newValidatorTestApp(t)
tests := []struct {
name string
value string
wantErr bool
}{
{name: "legacy empty"},
{name: "one hour", value: "1h"},
{name: "one hour in minutes", value: "60m"},
{name: "one hour in seconds", value: "3600s"},
{name: "combined duration", value: "1h30m"},
{name: "zero", value: "0", wantErr: true},
{name: "below one hour", value: "59m59s", wantErr: true},
{name: "below one hour in seconds", value: "3599s", wantErr: true},
{name: "negative", value: "-1h", wantErr: true},
{name: "invalid", value: "soon", wantErr: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := validateLiveChatSessionDuration(app, tc.value)
if (err != nil) != tc.wantErr {
t.Fatalf("got error %v, want error %v", err, tc.wantErr)
}
})
}
}
+70 -12
View File
@@ -33,12 +33,14 @@ import (
"github.com/abhinavxd/libredesk/internal/inbox"
"github.com/abhinavxd/libredesk/internal/inbox/channel/email"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat/proactive"
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
"github.com/abhinavxd/libredesk/internal/macro"
"github.com/abhinavxd/libredesk/internal/media"
fs "github.com/abhinavxd/libredesk/internal/media/stores/localfs"
"github.com/abhinavxd/libredesk/internal/media/stores/s3"
notifier "github.com/abhinavxd/libredesk/internal/notification"
notificationchannels "github.com/abhinavxd/libredesk/internal/notification/channels"
emailnotifier "github.com/abhinavxd/libredesk/internal/notification/providers/email"
"github.com/abhinavxd/libredesk/internal/oidc"
"github.com/abhinavxd/libredesk/internal/ratelimit"
@@ -1049,7 +1051,7 @@ func initHelpCenter(db *sqlx.DB, i18n *i18n.I18n, indexer helpcenter.ArticleInde
}
// initAIAgent inits the autonomous AI agent manager.
func initAIAgent(db *sqlx.DB, i18n *i18n.I18n, aiManager *ai.Manager, convo *conversation.Manager, mediaManager *media.Manager, settingManager *setting.Manager, userManager *user.Manager, notifierService *notifier.Service, rdb *redis.Client) *aiagent.Manager {
func initAIAgent(db *sqlx.DB, i18n *i18n.I18n, aiManager *ai.Manager, convo *conversation.Manager, inboxManager *inbox.Manager, mediaManager *media.Manager, settingManager *setting.Manager, userManager *user.Manager, notifierService *notifier.Service, rdb *redis.Client) *aiagent.Manager {
m, err := aiagent.New(aiagent.Opts{
DB: db,
Lo: initLogger("ai_agent"),
@@ -1057,7 +1059,7 @@ func initAIAgent(db *sqlx.DB, i18n *i18n.I18n, aiManager *ai.Manager, convo *con
QueueSize: cmp.Or(ko.Int("ai_agent.queue_size"), 1000),
MaxSteps: min(max(cmp.Or(ko.Int("ai_agent.max_steps"), 6), 1), 20),
MaxHistoryMessages: min(max(cmp.Or(ko.Int("ai_agent.max_history_messages"), 30), 5), 100),
}, aiManager, convo, mediaManager, settingManager, userManager, notifierService, rdb)
}, aiManager, convo, inboxManager, mediaManager, settingManager, userManager, notifierService, rdb)
if err != nil {
log.Fatalf("error initializing AI agent manager: %v", err)
}
@@ -1065,12 +1067,15 @@ func initAIAgent(db *sqlx.DB, i18n *i18n.I18n, aiManager *ai.Manager, convo *con
}
// initSearch inits search manager.
func initSearch(db *sqlx.DB, i18n *i18n.I18n) *search.Manager {
func initSearch(db *sqlx.DB, i18n *i18n.I18n, convo *conversation.Manager) *search.Manager {
lo := initLogger("search")
m, err := search.New(search.Opts{
DB: db,
Lo: lo,
I18n: i18n,
DB: db,
Lo: lo,
I18n: i18n,
FilterFields: conversation.ListFilterAllowedFields,
FilterRenderers: conversation.ListFilterRenderers,
FilterLocation: convo.FilterLocation,
})
if err != nil {
log.Fatalf("error initializing search manager: %v", err)
@@ -1168,6 +1173,19 @@ func initUserNotification(db *sqlx.DB, i18n *i18n.I18n) *notifier.UserNotificati
return m
}
// initNotificationPreference inits the notification preference manager.
func initNotificationPreference(db *sqlx.DB, i18n *i18n.I18n) *notifier.PreferenceManager {
m, err := notifier.NewPreferenceManager(notifier.PreferenceManagerOpts{
DB: db,
Lo: initLogger("notification-preference"),
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing notification preference manager: %v", err)
}
return m
}
// initImporter inits the importer manager.
func initImporter(i18n *i18n.I18n) *importer.Importer {
return importer.New(importer.Opts{
@@ -1176,14 +1194,46 @@ func initImporter(i18n *i18n.I18n) *importer.Importer {
})
}
func initNotificationEmailQueue(db *sqlx.DB, outbound *notifier.Service) *notifier.EmailQueue {
q, err := notifier.NewEmailQueue(notifier.EmailQueueOpts{
DB: db,
Outbound: outbound,
Lo: initLogger("notification-email-queue"),
})
if err != nil {
log.Fatalf("error initializing notification email queue: %v", err)
}
return q
}
func initPushNotification(db *sqlx.DB, settings *setting.Manager, i18n *i18n.I18n) *notifier.PushManager {
m, err := notifier.NewPushManager(notifier.PushManagerOpts{
DB: db,
Settings: settings,
Lo: initLogger("push-notification"),
I18n: i18n,
RootURL: ko.String("app.root_url"),
Concurrency: ko.MustInt("notification.concurrency"),
QueueSize: ko.MustInt("notification.queue_size"),
})
if err != nil {
log.Fatalf("error initializing push notification manager: %v", err)
}
return m
}
// initNotifDispatcher initializes the notification dispatcher.
func initNotifDispatcher(userNotification *notifier.UserNotificationManager, outbound *notifier.Service, wsHub *ws.Hub, emailEnabled bool) *notifier.Dispatcher {
func initNotifDispatcher(userNotification *notifier.UserNotificationManager, prefs *notifier.PreferenceManager, push *notifier.PushManager, emailQueue *notifier.EmailQueue, wsHub *ws.Hub, emailEnabled bool) *notifier.Dispatcher {
providers := []notificationchannels.Provider{
notificationchannels.NewInApp(userNotification, wsHub, initLogger("notification-in-app")),
}
if emailEnabled {
providers = append(providers, notificationchannels.NewEmail(emailQueue))
}
providers = append(providers, notificationchannels.NewPush(push))
return notifier.NewDispatcher(notifier.DispatcherOpts{
InApp: userNotification,
Outbound: outbound,
WSHub: wsHub,
EmailEnabled: emailEnabled,
Lo: initLogger("notification-dispatcher"),
Pipeline: notificationchannels.NewPipeline(providers...),
Prefs: prefs,
})
}
@@ -1270,3 +1320,11 @@ func initRateLimit(redisClient *redis.Client) *ratelimit.Limiter {
return limiter
}
func initProactive(db *sqlx.DB, i18n *i18n.I18n) *proactive.Manager {
manager, err := proactive.New(proactive.Opts{DB: db, I18n: i18n, Lo: initLogger("proactive")})
if err != nil {
log.Fatalf("error initializing proactive messages: %v", err)
}
return manager
}
+11
View File
@@ -21,6 +21,17 @@ func handleLogin(r *fastglue.Request) error {
loginReq loginRequest
)
if localLoginDisabledInConfig() {
oidcProviders, err := app.oidc.GetAll()
if err != nil {
return sendErrorEnvelope(r, err)
}
if !isLocalLoginEnabled(oidcProviders) {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("auth.localLoginDisabled"), nil, envelope.PermissionError)
}
app.lo.Warn("local login is disabled in config but no OIDC provider is enabled, allowing password login")
}
// Decode JSON request.
if err := r.Decode(&loginReq, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
+17 -3
View File
@@ -26,6 +26,7 @@ import (
"github.com/abhinavxd/libredesk/internal/colorlog"
"github.com/abhinavxd/libredesk/internal/csat"
customAttribute "github.com/abhinavxd/libredesk/internal/custom_attribute"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat/proactive"
"github.com/abhinavxd/libredesk/internal/macro"
notifier "github.com/abhinavxd/libredesk/internal/notification"
"github.com/abhinavxd/libredesk/internal/report"
@@ -94,6 +95,7 @@ const (
// App is the global app context which is passed and injected in the http handlers.
type App struct {
proactive *proactive.Manager
ctx context.Context
fs stuffbin.FileSystem
consts atomic.Value
@@ -126,6 +128,8 @@ type App struct {
activityLog *activitylog.Manager
notifier *notifier.Service
userNotification *notifier.UserNotificationManager
notificationPref *notifier.PreferenceManager
pushNotification *notifier.PushManager
customAttribute *customAttribute.Manager
report *report.Manager
webhook *webhook.Manager
@@ -249,12 +253,15 @@ func main() {
wsHub = initWS(user)
notifier = initNotifier()
userNotification = initUserNotification(db, i18n)
notifDispatcher = initNotifDispatcher(userNotification, notifier, wsHub, ko.Bool("notification.email.enabled"))
notificationPreference = initNotificationPreference(db, i18n)
pushNotification = initPushNotification(db, settings, i18n)
notificationEmailQueue = initNotificationEmailQueue(db, notifier)
notifDispatcher = initNotifDispatcher(userNotification, notificationPreference, pushNotification, notificationEmailQueue, wsHub, ko.Bool("notification.email.enabled"))
automation = initAutomationEngine(db, i18n)
ai = initAI(ctx, db, i18n, ssrfControl)
sla = initSLA(db, team, settings, businessHours, template, user, i18n, notifDispatcher)
conversation = initConversations(i18n, sla, status, priority, wsHub, db, inbox, user, team, media, settings, csat, automation, template, webhook, notifDispatcher)
aiAgent = initAIAgent(db, i18n, ai, conversation, media, settings, user, notifier, rdb)
aiAgent = initAIAgent(db, i18n, ai, conversation, inbox, media, settings, user, notifier, rdb)
helpCenter = initHelpCenter(db, i18n, ai)
autoassigner = initAutoAssigner(team, user, conversation)
rateLimiter = initRateLimit(rdb)
@@ -285,6 +292,10 @@ func main() {
go conversation.RunDraftCleaner(ctx, draftRetentionDuration)
go userNotification.RunNotificationCleaner(ctx)
go helpCenter.RunSearchLogCleaner(ctx)
if ko.Bool("notification.email.enabled") {
go notificationEmailQueue.Run(ctx)
}
go pushNotification.Run(ctx)
go aiAgent.Run(ctx, cmp.Or(ko.Int("ai_agent.worker_count"), 10))
go ai.Run(ctx)
@@ -315,7 +326,7 @@ func main() {
authz: initAuthz(i18n),
view: initView(db, i18n),
report: initReport(db, i18n),
search: initSearch(db, i18n),
search: initSearch(db, i18n, conversation),
role: initRole(db, i18n),
tag: initTag(db, i18n),
macro: initMacro(db, i18n),
@@ -325,10 +336,13 @@ func main() {
importer: initImporter(i18n),
webhook: webhook,
contextLink: initContextLink(db, i18n),
proactive: initProactive(db, i18n),
rateLimit: rateLimiter,
redis: rdb,
fc: initFastCache(rdb),
userNotification: userNotification,
notificationPref: notificationPreference,
pushNotification: pushNotification,
wsHub: wsHub,
}
app.consts.Store(constants)
+9 -12
View File
@@ -255,22 +255,19 @@ func serveMediaFile(r *fastglue.Request, app *App, uuid string, media *mmodels.M
consts := app.consts.Load().(*constants)
switch consts.UploadProvider {
case "fs":
disposition := "attachment"
// Inline images/videos/pdfs. SVG excluded.
if !forceDownload &&
media.ContentType != "image/svg+xml" &&
(strings.HasPrefix(media.ContentType, "image/") ||
strings.HasPrefix(media.ContentType, "video/") ||
media.ContentType == "application/pdf") {
disposition = "inline"
// Older rows hold content types exactly as the client sent them.
contentType := mmodels.NormalizeContentType(media.ContentType)
disposition := mmodels.ContentDisposition(contentType)
if forceDownload {
disposition = mmodels.DispositionAttachment
}
r.RequestCtx.Response.Header.Set("Content-Type", media.ContentType)
r.RequestCtx.Response.Header.Set("Content-Type", contentType)
r.RequestCtx.Response.Header.Set("Content-Disposition", mime.FormatMediaType(disposition, map[string]string{"filename": media.Filename}))
r.RequestCtx.Response.Header.Set("X-Content-Type-Options", "nosniff")
// Sandbox SVGs.
if media.ContentType == "image/svg+xml" {
// Chrome's video and PDF viewers break in a sandbox, and inline types run no script anyway.
// Downloads are sandboxed for webviews that render attachments inline.
if disposition == mmodels.DispositionAttachment {
r.RequestCtx.Response.Header.Set("Content-Security-Policy", "sandbox")
}
r.RequestCtx.Response.Header.Set("Cache-Control", fmt.Sprintf("%s, max-age=%d, immutable", cacheVisibility(media.Private), int(mediaCacheTTL.Seconds())))
+67
View File
@@ -3,7 +3,14 @@ package main
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
mmodels "github.com/abhinavxd/libredesk/internal/media/models"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
func TestPrepareImageUploadAllowsOversizedImageWithoutThumbnail(t *testing.T) {
@@ -31,3 +38,63 @@ func TestPrepareImageUploadAllowsOversizedImageWithoutThumbnail(t *testing.T) {
t.Fatalf("dimensions = %dx%d, want 10000x5000", meta.Width, meta.Height)
}
}
func TestServeMediaFileSandboxesDownloads(t *testing.T) {
const uuid = "0b7a3c1e-2f4d-4b8a-9c6e-1d2f3a4b5c6d"
dir := t.TempDir()
svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`)
if err := os.WriteFile(filepath.Join(dir, uuid), svg, 0o600); err != nil {
t.Fatal(err)
}
if err := ko.Set("upload.fs.upload_path", dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ko.Delete("upload.fs.upload_path") })
app := &App{}
app.consts.Store(&constants{UploadProvider: "fs"})
tests := []struct {
name string
contentType string
download bool
wantType string
wantDisposition string
wantSandbox bool
}{
{"svg", "image/svg+xml", false, "image/svg+xml", "attachment", true},
{"svg with bad param", "image/svg+xml; x", false, "image/svg+xml", "attachment", true},
{"other xml image", "image/x+xml", false, "image/x+xml", "attachment", true},
{"unparseable", "text/plain,image/svg+xml", false, "application/octet-stream", "attachment", true},
{"html", "text/html", false, "text/html", "attachment", true},
{"png", "image/png", false, "image/png", "inline", false},
{"png forced download", "image/png", true, "image/png", "attachment", true},
{"video", "video/mp4", false, "video/mp4", "inline", false},
{"video forced download", "video/mp4", true, "video/mp4", "attachment", true},
{"pdf", "application/pdf", false, "application/pdf", "inline", false},
{"pdf forced download", "application/pdf", true, "application/pdf", "attachment", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := &fastglue.Request{RequestCtx: &fasthttp.RequestCtx{}, Context: app}
if tt.download {
req.RequestCtx.Request.SetRequestURI("/uploads/" + uuid + "?download=1")
}
media := &mmodels.Media{ContentType: tt.contentType, Filename: "file", Private: true}
if err := serveMediaFile(req, app, uuid, media); err != nil {
t.Fatal(err)
}
h := &req.RequestCtx.Response.Header
if got := string(h.ContentType()); got != tt.wantType {
t.Fatalf("Content-Type = %q, want %q", got, tt.wantType)
}
if got := string(h.Peek("Content-Disposition")); !strings.HasPrefix(got, tt.wantDisposition) {
t.Fatalf("Content-Disposition = %q, want %s", got, tt.wantDisposition)
}
if got := string(h.Peek("Content-Security-Policy")) == "sandbox"; got != tt.wantSandbox {
t.Fatalf("sandboxed = %v, want %v", got, tt.wantSandbox)
}
})
}
}
+51
View File
@@ -0,0 +1,51 @@
package main
import (
"strings"
"testing"
"github.com/knadh/stuffbin"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
func TestServePWAFiles(t *testing.T) {
fs, err := stuffbin.NewLocalFS("/",
"../frontend/public/manifest.webmanifest:frontend/dist/main/manifest.webmanifest",
"../frontend/public/sw.js:frontend/dist/main/sw.js",
)
if err != nil {
t.Fatalf("creating test filesystem: %v", err)
}
tests := []struct {
name string
handler fastglue.FastRequestHandler
contentType string
wantServiceWorkerHdr bool
}{
{"manifest", serveManifest, "application/manifest+json", false},
{"service worker", serveServiceWorker, "application/javascript", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctx := &fasthttp.RequestCtx{}
req := &fastglue.Request{RequestCtx: ctx, Context: &App{fs: fs}}
if err := tc.handler(req); err != nil {
t.Fatalf("serving file: %v", err)
}
if got := string(ctx.Response.Header.ContentType()); got != tc.contentType {
t.Errorf("content type = %q, want %q", got, tc.contentType)
}
if got := string(ctx.Response.Header.Peek("Cache-Control")); got != "no-cache" {
t.Errorf("cache control = %q, want no-cache", got)
}
if tc.wantServiceWorkerHdr && string(ctx.Response.Header.Peek("Service-Worker-Allowed")) != "/" {
t.Error("service worker scope header is missing")
}
if strings.TrimSpace(string(ctx.Response.Body())) == "" {
t.Error("response body is empty")
}
})
}
}
+136 -68
View File
@@ -2,100 +2,168 @@ package main
import (
"fmt"
"slices"
"strconv"
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
authzmodels "github.com/abhinavxd/libredesk/internal/authz/models"
"github.com/abhinavxd/libredesk/internal/envelope"
searchmanager "github.com/abhinavxd/libredesk/internal/search"
smodels "github.com/abhinavxd/libredesk/internal/search/models"
"github.com/zerodha/fastglue"
)
const (
minSearchQueryLength = 3
maxConversationSearchLimit = 1000
maxMessageSearchLimit = 30
maxContactSearchLimit = 15
)
// handleSearchConversations searches conversations based on the query.
type searchPageResults struct {
Results any `json:"results"`
PerPage int `json:"per_page"`
HasMore bool `json:"has_more"`
NextCursor string `json:"next_cursor"`
}
func handleSearchConversations(r *fastglue.Request) error {
app, user, q, err := searchInputs(r)
app, user, term, err := searchTerm(r)
if err != nil {
return sendErrorEnvelope(r, err)
}
results, err := app.search.Conversations(q)
scope, err := readScope(app, user.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
uuids := make([]string, len(results))
for i, c := range results {
uuids[i] = c.UUID
}
allowed, err := app.conversation.FilterAuthorizedListUUIDs(user.ID, uuids)
if err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil))
}
set := uuidSet(allowed)
out := make([]smodels.ConversationResult, 0, len(allowed))
for _, c := range results {
if _, ok := set[c.UUID]; ok {
out = append(out, c)
}
}
return r.SendEnvelope(out)
}
// handleSearchMessages searches messages based on the query.
func handleSearchMessages(r *fastglue.Request) error {
app, user, q, err := searchInputs(r)
if err != nil {
return sendErrorEnvelope(r, err)
}
results, err := app.search.Messages(q)
if err != nil {
return sendErrorEnvelope(r, err)
}
uuids := make([]string, len(results))
for i, m := range results {
uuids[i] = m.ConversationUUID
}
allowed, err := app.conversation.FilterAuthorizedListUUIDs(user.ID, uuids)
if err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil))
}
set := uuidSet(allowed)
out := make([]smodels.MessageResult, 0, len(allowed))
for _, m := range results {
if _, ok := set[m.ConversationUUID]; ok {
out = append(out, m)
}
}
return r.SendEnvelope(out)
}
// handleSearchContacts searches contacts based on the query.
func handleSearchContacts(r *fastglue.Request) error {
app, _, q, err := searchInputs(r)
if err != nil {
return sendErrorEnvelope(r, err)
}
results, err := app.search.Contacts(q)
results, err := app.search.ConversationFirstPage(term, scope, searchLimit(r, maxConversationSearchLimit))
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(results)
}
func searchInputs(r *fastglue.Request) (*App, amodels.User, string, error) {
app := r.Context.(*App)
user, _ := r.RequestCtx.UserValue("user").(amodels.User)
q := string(r.RequestCtx.QueryArgs().Peek("query"))
if len(q) < minSearchQueryLength {
return app, user, "", envelope.NewError(envelope.InputError, app.i18n.Ts("search.minQueryLength", "length", fmt.Sprintf("%d", minSearchQueryLength)), nil)
func handleSearchMessages(r *fastglue.Request) error {
app, user, term, err := searchTerm(r)
if err != nil {
return sendErrorEnvelope(r, err)
}
return app, user, q, nil
scope, err := readScope(app, user.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
results, err := app.search.MessageFirstPage(term, scope, searchLimit(r, maxMessageSearchLimit))
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(results)
}
func uuidSet(uuids []string) map[string]struct{} {
s := make(map[string]struct{}, len(uuids))
for _, u := range uuids {
s[u] = struct{}{}
func handleSearchContacts(r *fastglue.Request) error {
app, _, term, err := searchTerm(r)
if err != nil {
return sendErrorEnvelope(r, err)
}
return s
results, err := app.search.Contacts(term, searchLimit(r, maxContactSearchLimit))
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(results)
}
func handlePaginatedSearchConversations(r *fastglue.Request) error {
app, user, q, err := searchInputs(r)
if err != nil {
return sendErrorEnvelope(r, err)
}
scope, err := readScope(app, user.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
results, hasMore, nextCursor, err := app.search.Conversations(q, scope)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(searchPageResults{
Results: results,
PerPage: q.PageSize,
HasMore: hasMore,
NextCursor: nextCursor,
})
}
func handlePaginatedSearchMessages(r *fastglue.Request) error {
app, user, q, err := searchInputs(r)
if err != nil {
return sendErrorEnvelope(r, err)
}
scope, err := readScope(app, user.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
results, hasMore, nextCursor, err := app.search.Messages(q, scope)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(searchPageResults{
Results: results,
PerPage: q.PageSize,
HasMore: hasMore,
NextCursor: nextCursor,
})
}
func searchInputs(r *fastglue.Request) (*App, amodels.User, smodels.Query, error) {
app, user, term, err := searchTerm(r)
if err != nil {
return app, user, smodels.Query{}, err
}
_, pageSize := getPagination(r)
query := searchmanager.NormalizeQuery(smodels.Query{
Term: term,
Filters: string(r.RequestCtx.QueryArgs().Peek("filters")),
Cursor: string(r.RequestCtx.QueryArgs().Peek("cursor")),
Sort: smodels.Sort(r.RequestCtx.QueryArgs().Peek("sort")),
PageSize: pageSize,
})
return app, user, query, nil
}
func searchTerm(r *fastglue.Request) (*App, amodels.User, string, error) {
app := r.Context.(*App)
user, _ := r.RequestCtx.UserValue("user").(amodels.User)
term := string(r.RequestCtx.QueryArgs().Peek("query"))
if len(term) < minSearchQueryLength {
return app, user, "", envelope.NewError(envelope.InputError, app.i18n.Ts("search.minQueryLength", "length", fmt.Sprintf("%d", minSearchQueryLength)), nil)
}
return app, user, term, nil
}
func searchLimit(r *fastglue.Request, max int) int {
limit, err := strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("limit")))
if err != nil || limit < 1 || limit > max {
return max
}
return limit
}
func readScope(app *App, agentID int) (smodels.ReadScope, error) {
agent, err := app.user.GetAgentCachedOrLoad(agentID)
if err != nil {
return smodels.ReadScope{}, err
}
if !agent.Enabled {
return smodels.ReadScope{}, nil
}
return smodels.ReadScope{
UserID: agent.ID,
TeamIDs: agent.Teams.IDs(),
Read: slices.Contains(agent.Permissions, authzmodels.PermConversationsRead),
ReadAll: slices.Contains(agent.Permissions, authzmodels.PermConversationsReadAll),
ReadAssigned: slices.Contains(agent.Permissions, authzmodels.PermConversationsReadAssigned),
ReadTeamAll: slices.Contains(agent.Permissions, authzmodels.PermConversationsReadTeamAll),
ReadTeamInbox: slices.Contains(agent.Permissions, authzmodels.PermConversationsReadTeamInbox),
ReadUnassigned: slices.Contains(agent.Permissions, authzmodels.PermConversationsReadUnassigned),
}, nil
}
+4
View File
@@ -6,6 +6,7 @@ import (
"strings"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/httputil"
"github.com/abhinavxd/libredesk/internal/setting/models"
"github.com/abhinavxd/libredesk/internal/stringutil"
"github.com/valyala/fasthttp"
@@ -57,6 +58,9 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error {
}
// Trim whitespace and trailing slash from root URL.
req.RootURL = strings.TrimRight(strings.TrimSpace(req.RootURL), "/")
if !httputil.IsValidHTTPURL(req.RootURL) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("admin.general.rootURL.valid"), nil, envelope.InputError)
}
// Get current language before update.
app.Lock()
+2 -1
View File
@@ -47,7 +47,8 @@ var migList = []migFunc{
{"v2.5.0", migrations.V2_5_0},
{"v2.6.0", migrations.V2_6_0},
{"v2.8.0", migrations.V2_8_0},
{"v2.9.0", migrations.V2_9_0},
{"v2.9.0-rc.8", migrations.V2_9_0},
{"v2.9.0-rc.9", migrations.V2_9_0_RC9},
}
// upgrade upgrades the database to the current version by running SQL migration files
+71
View File
@@ -5,6 +5,8 @@ import (
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/envelope"
notifier "github.com/abhinavxd/libredesk/internal/notification"
nmodels "github.com/abhinavxd/libredesk/internal/notification/models"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
@@ -97,3 +99,72 @@ func handleDeleteAllNotifications(r *fastglue.Request) error {
}
return r.SendEnvelope(true)
}
func handleGetNotificationPreferences(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
prefs, err := app.notificationPref.GetMatrix(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
pushEndpoints, err := app.pushNotification.Endpoints(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(map[string]any{
"preferences": prefs,
"email_enabled": ko.Bool("notification.email.enabled"),
"vapid_public_key": app.pushNotification.PublicKey(),
"push_endpoints": pushEndpoints,
})
}
func handleUpdateNotificationPreferences(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
prefs []nmodels.NotificationPreference
)
if err := r.Decode(&prefs, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
}
if err := app.notificationPref.Update(auser.ID, prefs); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
func handleCreatePushSubscription(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
subscription notifier.PushSubscriptionInput
)
if err := r.Decode(&subscription, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
}
if err := app.pushNotification.Upsert(auser.ID, subscription); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
func handleDeletePushSubscription(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
request struct {
Endpoint string `json:"endpoint"`
}
)
if err := r.Decode(&request, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
}
if err := app.pushNotification.Delete(auser.ID, request.Endpoint); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
+295
View File
@@ -0,0 +1,295 @@
package main
import (
"encoding/json"
"errors"
"slices"
"strconv"
"strings"
"time"
bhmodels "github.com/abhinavxd/libredesk/internal/business_hours/models"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat/proactive"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
"github.com/google/uuid"
"github.com/zerodha/fastglue"
)
type campaignEventRequest struct {
DeliveryID string `json:"delivery_id"`
BrowserKey string `json:"browser_key"`
Event string `json:"event"`
}
type campaignCandidate struct {
campaign proactive.Campaign
snapshot proactive.Snapshot
}
// handleWidgetCampaign is called by the widget as the visitor browses the site and returns the campaign message to pop up for this visitor right now, or nothing.
func handleWidgetCampaign(r *fastglue.Request) error {
app := r.Context.(*App)
var ctx proactive.Context
if err := r.Decode(&ctx, "json"); err != nil || !validCampaignKey(ctx.BrowserKey) || !validCampaignKey(ctx.SessionKey) || len(ctx.URL) > 4096 || ctx.ActiveSeconds < 0 || ctx.ActiveSeconds > 86400 {
return sendErrorEnvelope(r, campaignInputError(app))
}
ctx.Now = time.Now()
ctx.Visitor = getWidgetIsVisitor(r)
contact, err := widgetContact(r)
if err != nil {
return sendErrorEnvelope(r, err)
}
ctx.ContactID = contact.ID
inbox, err := getWidgetInbox(r)
if err != nil {
return sendErrorEnvelope(r, err)
}
config, err := getWidgetConfig(r)
if err != nil {
return sendErrorEnvelope(r, err)
}
cooldown, err := parseCampaignCooldown(config.CampaignCooldown)
if err != nil {
return sendErrorEnvelope(r, campaignInputError(app))
}
if err := checkConversationPermissions(app, config, ctx.Visitor, contact.ID, inbox.ID); err != nil {
return r.SendEnvelope(nil)
}
loc, err := time.LoadLocation(app.setting.GetAppTimezone())
if err != nil {
return sendErrorEnvelope(r, campaignInputError(app))
}
var candidates []campaignCandidate
for _, campaign := range config.Campaigns {
var hoursErr error
within := func() bool {
ok, err := campaignWithinHours(app, campaign, ctx.Now, loc)
hoursErr = err
return ok
}
reason := campaign.IneligibleReason(ctx, app.automation.MatchesContact(campaign.Conditions, contact), within)
if hoursErr != nil {
app.lo.Warn("skipping campaign with invalid business hours", "campaign_id", campaign.ID, "business_hours_id", campaign.BusinessHoursID)
continue
}
if reason != "" {
continue
}
sender, err := campaignSender(app, campaign.SenderID)
if err != nil {
continue
}
snapshot := proactive.Snapshot{Message: campaign.Message, Sender: strings.TrimSpace(sender.FullName()), Avatar: sender.AvatarURL.String, SenderID: sender.ID, TeamID: campaign.TeamID}
if campaign.SenderID == 0 {
snapshot.Sender = config.BrandName
}
candidates = append(candidates, campaignCandidate{campaign, snapshot})
}
if len(candidates) == 0 {
return r.SendEnvelope(nil)
}
unlock := app.proactive.Lock()
defer unlock()
history, err := app.proactive.History(inbox.ID, ctx)
if err != nil {
return sendErrorEnvelope(r, err)
}
for _, c := range candidates {
if proactive.Suppression(c.campaign, ctx, history, cooldown) != "" {
continue
}
delivery, err := app.proactive.Reserve(inbox.ID, c.campaign, ctx, c.snapshot)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(delivery)
}
return r.SendEnvelope(nil)
}
// handleWidgetCampaignEvent is called by the widget when a campaign message is shown, opened or closed, for the campaign stats.
func handleWidgetCampaignEvent(r *fastglue.Request) error {
app := r.Context.(*App)
var req campaignEventRequest
if err := r.Decode(&req, "json"); err != nil || !validCampaignKey(req.DeliveryID) || !validCampaignKey(req.BrowserKey) || !slices.Contains([]string{"displayed", "opened", "dismissed"}, req.Event) {
return sendErrorEnvelope(r, campaignInputError(app))
}
inbox, err := getWidgetInbox(r)
if err != nil {
return sendErrorEnvelope(r, err)
}
contactID, _ := getWidgetContactID(r)
unlock := app.proactive.Lock()
defer unlock()
delivery, err := app.proactive.Get(req.DeliveryID, inbox.ID, req.BrowserKey, contactID)
if err != nil {
return sendErrorEnvelope(r, err)
}
if req.Event == "displayed" {
config, err := getWidgetConfig(r)
if err != nil {
return sendErrorEnvelope(r, err)
}
active := slices.ContainsFunc(config.Campaigns, func(c proactive.Campaign) bool {
return c.ID == delivery.CampaignID && c.Enabled
})
if !active || delivery.Dismissed || delivery.Replied || time.Since(delivery.CreatedAt) > time.Minute {
return sendErrorEnvelope(r, envelope.NewError(envelope.ConflictError, app.i18n.T("widget.invitationExpired"), nil))
}
}
if err := app.proactive.RecordEvent(delivery.ID, req.Event); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleCampaignStats returns shown, opened, dismissed and replied counts for each campaign in an inbox over a date range.
func handleCampaignStats(r *fastglue.Request) error {
app := r.Context.(*App)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id <= 0 {
return sendErrorEnvelope(r, campaignInputError(app))
}
loc, err := time.LoadLocation(app.setting.GetAppTimezone())
if err != nil {
return sendErrorEnvelope(r, campaignInputError(app))
}
from, err := time.ParseInLocation(time.DateOnly, string(r.RequestCtx.QueryArgs().Peek("from")), loc)
if err != nil {
return sendErrorEnvelope(r, campaignInputError(app))
}
to, err := time.ParseInLocation(time.DateOnly, string(r.RequestCtx.QueryArgs().Peek("to")), loc)
if err != nil || to.Before(from) {
return sendErrorEnvelope(r, campaignInputError(app))
}
stats, err := app.proactive.Stats(id, from, to.AddDate(0, 0, 1))
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(stats)
}
func campaignWithinHours(app *App, c proactive.Campaign, now time.Time, loc *time.Location) (bool, error) {
if c.BusinessHours == "any" {
return true, nil
}
hours, err := app.businessHours.Get(c.BusinessHoursID)
if err != nil {
return false, envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidValue"), nil)
}
if hours.IsAlwaysOpen {
return true, nil
}
local := now.In(loc)
var holidays []bhmodels.Holiday
var working map[string]bhmodels.WorkingHours
if err := json.Unmarshal(hours.Holidays, &holidays); err != nil {
return false, campaignInputError(app)
}
if err := json.Unmarshal(hours.Hours, &working); err != nil {
return false, campaignInputError(app)
}
for _, holiday := range holidays {
if holiday.Date == local.Format(time.DateOnly) {
return false, nil
}
}
day, ok := working[local.Weekday().String()]
if !ok {
return false, nil
}
clock := local.Format("15:04")
return withinWorkingHours(clock, day), nil
}
func withinWorkingHours(clock string, day bhmodels.WorkingHours) bool {
if day.Open == day.Close {
return false
}
if day.Open < day.Close {
return clock >= day.Open && clock < day.Close
}
return clock >= day.Open || clock < day.Close
}
func campaignSender(app *App, id int) (umodels.User, error) {
if id == 0 {
return app.user.GetSystemUser()
}
return app.user.GetAgent(id, "")
}
func earliestCampaignDelay(campaigns []proactive.Campaign) int {
delay, found := 0, false
for _, campaign := range campaigns {
if !campaign.Enabled {
continue
}
if !found || campaign.DelaySeconds < delay {
delay, found = campaign.DelaySeconds, true
}
}
return delay
}
func validCampaignKey(key string) bool {
id, err := uuid.Parse(key)
return err == nil && id != uuid.Nil
}
func campaignInputError(app *App) error {
return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidValue"), nil)
}
func validateWidgetFeatures(app *App, config livechat.Config) error {
if config.Help.HelpCenterID < 0 || len(config.Help.FeaturedIDs) > 10 || len(config.Campaigns) > 50 {
return campaignInputError(app)
}
if _, err := parseCampaignCooldown(config.CampaignCooldown); err != nil {
return campaignInputError(app)
}
if config.Help.HelpCenterID > 0 {
if _, err := app.helpcenter.GetHelpCenterByID(config.Help.HelpCenterID); err != nil {
return err
}
}
loc, err := time.LoadLocation(app.setting.GetAppTimezone())
if err != nil {
return campaignInputError(app)
}
ids := make(map[string]bool)
for _, campaign := range config.Campaigns {
if err := campaign.Validate(); err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("admin.inbox.livechat.campaignInvalid", "name", campaign.Name, "field", err.Error()), nil)
}
if ids[campaign.ID] {
return campaignInputError(app)
}
ids[campaign.ID] = true
if _, err := campaignSender(app, campaign.SenderID); err != nil {
return err
}
if campaign.TeamID > 0 {
if _, err := app.team.Get(campaign.TeamID); err != nil {
return err
}
}
if _, err := campaignWithinHours(app, campaign, time.Now(), loc); err != nil {
return err
}
}
return nil
}
func parseCampaignCooldown(value string) (time.Duration, error) {
duration, err := time.ParseDuration(value)
if err != nil {
return 0, err
}
if duration < 0 {
return 0, errors.New("campaign cooldown cannot be negative")
}
return duration, nil
}
+109
View File
@@ -0,0 +1,109 @@
package main
import (
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
realip "github.com/ferluci/fast-realip"
"github.com/zerodha/fastglue"
)
// handleWidgetCampaignReply runs when a visitor replies to a campaign pop-up, adding the reply to its conversation or starting a new one.
func handleWidgetCampaignReply(r *fastglue.Request, req chatInitReq, inbox imodels.Inbox, config livechat.Config) error {
app := r.Context.(*App)
if !validCampaignKey(req.DeliveryID) || !validCampaignKey(req.BrowserKey) {
return sendErrorEnvelope(r, campaignInputError(app))
}
unlock := app.proactive.Lock()
defer unlock()
contact, err := widgetContact(r)
if err != nil {
return sendErrorEnvelope(r, err)
}
delivery, err := app.proactive.Get(req.DeliveryID, inbox.ID, req.BrowserKey, contact.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
if delivery.Replied && delivery.ConversationUUID == "" {
return sendErrorEnvelope(r, envelope.NewError(envelope.NotFoundError, app.i18n.T("globals.messages.notFound"), nil))
}
if !delivery.Displayed {
return sendErrorEnvelope(r, envelope.NewError(envelope.ConflictError, app.i18n.T("widget.invitationExpired"), nil))
}
var sessionToken string
var attrs map[string]any
visitor := getWidgetIsVisitor(r)
if delivery.ConversationUUID != "" {
conversation, err := app.conversation.GetConversation(0, delivery.ConversationUUID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
if contact.ID != conversation.ContactID {
if contact.ID > 0 {
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, app.i18n.T("widget.conversationNotPermitted"), nil))
}
contact, err = app.user.GetContactOrVisitor(conversation.ContactID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
if contact.Type != "visitor" {
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, app.i18n.T("widget.conversationNotPermitted"), nil))
}
sessionToken, err = generateSessionToken(app, contact.ID, inbox.ID, true, "", defaultSessionTTL)
if err != nil {
return sendErrorEnvelope(r, err)
}
visitor = true
}
if err := canReply(r, conversation); err != nil {
return sendErrorEnvelope(r, err)
}
message := cmodels.Message{ConversationUUID: conversation.UUID, ConversationID: conversation.ID, SenderID: contact.ID, Type: cmodels.MessageIncoming, SenderType: cmodels.SenderTypeContact, Status: cmodels.MessageStatusReceived, Content: req.Message, ContentType: cmodels.ContentTypeText}
if _, err := app.conversation.ProcessIncomingLiveChatMessage(message); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.errorSendingMessage"), nil))
}
} else {
if contact.ID == 0 {
contact, sessionToken, attrs, err = createVisitorContact(app, req.FormData, config, inbox)
if err != nil {
return sendErrorEnvelope(r, err)
}
visitor = true
} else {
attrs = saveContactAttrsAndCollectConvoAttrs(app, contact.ID, nil, req.FormData, config)
}
if err := checkConversationPermissions(app, config, visitor, contact.ID, inbox.ID); err != nil {
return sendErrorEnvelope(r, err)
}
meta := map[string]any{"ip": realip.FromRequest(r.RequestCtx), "user_agent": string(r.RequestCtx.Request.Header.Peek("User-Agent")), "campaign_id": delivery.CampaignID}
reply, err := app.conversation.CreateProactiveConversation(delivery, contact.ID, req.Message, attrs, meta, maxChatConversationsPerContact, chatConversationRateLimitWindow)
if err != nil {
if _, ok := err.(envelope.Error); ok {
return sendErrorEnvelope(r, err)
}
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.errorSendingMessage"), nil))
}
delivery.ConversationUUID = reply.ConversationUUID
if reply.ID > 0 {
if err := app.conversation.ProcessIncomingMessageHooks(reply, true); err != nil {
app.lo.Error("error processing proactive reply hooks", "error", err)
}
}
}
conversation, err := app.conversation.GetConversation(0, delivery.ConversationUUID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
resp, err := buildConversationResponseWithBusinessHours(app, conversation)
if err != nil {
return sendErrorEnvelope(r, err)
}
response := map[string]any{"conversation": resp.Conversation, "messages": resp.Messages, "business_hours_id": resp.BusinessHoursID, "working_hours_utc_offset": resp.WorkingHoursUTCOffset}
if sessionToken != "" {
response["session_token"] = sessionToken
response["user"] = map[string]any{"user_id": contact.ID, "is_visitor": visitor, "first_name": contact.FirstName, "last_name": contact.LastName}
}
r.RequestCtx.Response.Header.Set("Cache-Control", "no-store")
return r.SendEnvelope(response)
}
+59
View File
@@ -0,0 +1,59 @@
package main
import (
"testing"
"time"
bhmodels "github.com/abhinavxd/libredesk/internal/business_hours/models"
)
func TestWithinWorkingHours(t *testing.T) {
tests := []struct {
name string
clock string
day bhmodels.WorkingHours
want bool
}{
{name: "normal open", clock: "09:00", day: bhmodels.WorkingHours{Open: "09:00", Close: "17:00"}, want: true},
{name: "normal close", clock: "17:00", day: bhmodels.WorkingHours{Open: "09:00", Close: "17:00"}},
{name: "overnight evening", clock: "23:00", day: bhmodels.WorkingHours{Open: "22:00", Close: "02:00"}, want: true},
{name: "overnight morning", clock: "01:00", day: bhmodels.WorkingHours{Open: "22:00", Close: "02:00"}, want: true},
{name: "overnight closed", clock: "12:00", day: bhmodels.WorkingHours{Open: "22:00", Close: "02:00"}},
{name: "same time closed", clock: "09:00", day: bhmodels.WorkingHours{Open: "09:00", Close: "09:00"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := withinWorkingHours(tc.clock, tc.day); got != tc.want {
t.Fatalf("got %v, want %v", got, tc.want)
}
})
}
}
func TestParseCampaignCooldown(t *testing.T) {
tests := []struct {
value string
want time.Duration
valid bool
}{
{value: "0s", valid: true},
{value: "10m", want: 10 * time.Minute, valid: true},
{value: "1h30m", want: 90 * time.Minute, valid: true},
{value: ""},
{value: "tomorrow"},
{value: "-1m"},
}
for _, tc := range tests {
t.Run(tc.value, func(t *testing.T) {
got, err := parseCampaignCooldown(tc.value)
if tc.valid && (err != nil || got != tc.want) {
t.Fatalf("got %v, %v; want %v", got, err, tc.want)
}
if !tc.valid && err == nil {
t.Fatalf("accepted %q", tc.value)
}
})
}
}
+147
View File
@@ -0,0 +1,147 @@
package main
import (
"slices"
"strings"
"github.com/abhinavxd/libredesk/internal/envelope"
hcmodels "github.com/abhinavxd/libredesk/internal/helpcenter/models"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
"github.com/zerodha/fastglue"
)
func optionalWidgetAuth(next func(*fastglue.Request) error) func(*fastglue.Request) error {
return func(r *fastglue.Request) error {
if len(r.RequestCtx.Request.Header.Peek("Authorization")) == 0 {
return validateWidgetInbox(next)(r)
}
return widgetAuth(next)(r)
}
}
func handleWidgetHelp(r *fastglue.Request) error {
app := r.Context.(*App)
helpCenter, audience, err := widgetHelpCenter(r)
if err != nil {
return sendErrorEnvelope(r, err)
}
if helpCenter.ID == 0 {
return r.SendEnvelope(nil)
}
locale := resolveQueryLocale(r, helpCenter)
tree, err := app.helpcenter.GetPublicTree(helpCenter, locale)
if err != nil {
return sendErrorEnvelope(r, err)
}
if len(tree.Tree) == 0 && locale != helpCenter.DefaultLocale {
locale = helpCenter.DefaultLocale
tree, err = app.helpcenter.GetPublicTree(helpCenter, locale)
if err != nil {
return sendErrorEnvelope(r, err)
}
}
hideTreeAuthors(helpCenterTheme(helpCenter), tree.Tree)
popular, err := app.helpcenter.GetPopularArticles(helpCenter.Slug, locale, 5)
if err != nil {
return sendErrorEnvelope(r, err)
}
for i := range popular {
hideArticleAuthor(helpCenterTheme(helpCenter), &popular[i])
}
config, _ := getWidgetConfig(r)
return r.SendEnvelope(map[string]any{
"slug": helpCenter.Slug, "name": helpCenter.Name, "locale": locale,
"url": helpCenterBaseURL(app, helpCenter) + helpCenterPathPrefix(helpCenter),
"tree": tree.Tree, "popular": popular, "audience": audience, "locales": helpCenterLocales(helpCenter),
"featured_ids": config.Help.FeaturedIDs,
})
}
func handleWidgetHelpSearch(r *fastglue.Request) error {
app := r.Context.(*App)
helpCenter, _, err := widgetHelpCenter(r)
if err != nil {
return sendErrorEnvelope(r, err)
}
if helpCenter.ID == 0 {
return r.SendEnvelope([]hcmodels.Article{})
}
query := strings.TrimSpace(string(r.RequestCtx.QueryArgs().Peek("q")))
if query == "" {
return r.SendEnvelope([]hcmodels.Article{})
}
articles, err := app.helpcenter.SearchPublishedArticles(helpCenter.Slug, query, resolveQueryLocale(r, helpCenter), publicSearchLimit)
if err != nil {
return sendErrorEnvelope(r, err)
}
app.helpcenter.LogSearch(helpCenter.ID, query, len(articles))
for i := range articles {
hideArticleAuthor(helpCenterTheme(helpCenter), &articles[i])
}
return r.SendEnvelope(articles)
}
func handleWidgetHelpArticle(r *fastglue.Request) error {
app := r.Context.(*App)
helpCenter, _, err := widgetHelpCenter(r)
if err != nil {
return sendErrorEnvelope(r, err)
}
if helpCenter.ID == 0 {
return sendErrorEnvelope(r, envelope.NewError(envelope.NotFoundError, app.i18n.T("globals.messages.notFound"), nil))
}
slug := r.RequestCtx.UserValue("article_slug").(string)
locale := resolveQueryLocale(r, helpCenter)
article, err := app.helpcenter.GetPublishedArticle(helpCenter.Slug, slug, locale)
if envErr, ok := err.(envelope.Error); ok && envErr.ErrorType == envelope.NotFoundError && locale != helpCenter.DefaultLocale {
article, err = app.helpcenter.GetPublishedArticle(helpCenter.Slug, slug, helpCenter.DefaultLocale)
}
if err != nil {
return sendErrorEnvelope(r, err)
}
hideArticleAuthor(helpCenterTheme(helpCenter), &article)
translations, err := app.helpcenter.GetPublishedArticleTranslations(helpCenter.Slug, article.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
app.helpcenter.IncrementArticleViewCount(article.ID)
return r.SendEnvelope(helpArticleResponse{Article: article, Translations: translations})
}
func widgetHelpCenter(r *fastglue.Request) (hcmodels.HelpCenter, livechat.HelpAudience, error) {
app := r.Context.(*App)
config, err := getWidgetConfig(r)
if err != nil {
return hcmodels.HelpCenter{}, livechat.HelpAudience{}, err
}
audience := config.Help.Visitors
if !getWidgetIsVisitor(r) {
audience = config.Help.Users
}
if config.Help.HelpCenterID == 0 || (!audience.Tab && !hasHelpHomeApp(config)) {
return hcmodels.HelpCenter{}, audience, nil
}
helpCenter, err := app.helpcenter.GetHelpCenterByID(config.Help.HelpCenterID)
if err != nil {
return hcmodels.HelpCenter{}, audience, err
}
if !helpCenter.IsActive {
return hcmodels.HelpCenter{}, audience, nil
}
return helpCenter, audience, nil
}
func widgetContact(r *fastglue.Request) (umodels.User, error) {
id, err := getWidgetContactID(r)
if err != nil {
return umodels.User{}, nil
}
return r.Context.(*App).user.GetContactOrVisitor(id, "")
}
func hasHelpHomeApp(config livechat.Config) bool {
return slices.ContainsFunc(config.HomeApps, func(app livechat.HomeApp) bool {
return app.Type == livechat.HomeAppHelp
})
}
+42
View File
@@ -0,0 +1,42 @@
package main
import (
"fmt"
"mime"
"time"
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/stringutil"
"github.com/zerodha/fastglue"
)
const maxTranscriptDownloadMessages = 1000
func handleWidgetTranscript(r *fastglue.Request) error {
app := r.Context.(*App)
config, err := getWidgetConfig(r)
if err != nil {
return sendErrorEnvelope(r, err)
}
if !config.Features.Transcript {
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, app.i18n.T("status.deniedPermission"), nil))
}
uuid := r.RequestCtx.UserValue("uuid").(string)
_, conversation, err := getContactConversation(r, uuid)
if err != nil {
return sendErrorEnvelope(r, err)
}
private := false
messages, err := app.conversation.GetAllConversationMessages(uuid, &private, []string{cmodels.MessageIncoming, cmodels.MessageOutgoing}, maxTranscriptDownloadMessages)
if err != nil {
return sendErrorEnvelope(r, err)
}
filename := fmt.Sprintf("transcript-%s.txt", stringutil.SanitizeFilename(conversation.ReferenceNumber))
r.RequestCtx.Response.Header.Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename}))
r.RequestCtx.Response.Header.Set("Cache-Control", "private, no-store")
r.RequestCtx.Response.Header.Set("X-Content-Type-Options", "nosniff")
r.RequestCtx.SetContentType("text/plain; charset=utf-8")
r.RequestCtx.SetBody(app.conversation.BuildTranscript(conversation, messages, time.Now()))
return nil
}
+7
View File
@@ -34,6 +34,13 @@ read_buffer_size = 65536
# Keepalive settings.
keepalive_timeout = "10s"
[auth]
# Allow users to sign in with their libredesk email and password.
# Disable this only after configuring and enabling at least one OIDC provider.
# libredesk automatically keeps local login available when no OIDC provider is enabled to prevent lockout.
# If an enabled provider is unavailable or misconfigured, set auth.local_login_enabled = true to restore password login.
local_login_enabled = true
# File upload provider to use, either `fs` or `s3`.
[upload]
provider = "fs"
+5 -2
View File
@@ -5,8 +5,11 @@
<meta charset="UTF-8" />
<meta name="robots" content="noindex, nofollow" />
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
<meta name="theme-color" content="hsl(152 39% 30%)" />
<link rel="manifest" href="/manifest.webmanifest" crossorigin="use-credentials" />
<link rel="apple-touch-icon" href="/images/pwa-icon-192.png" />
<link rel="preload" href="/static/public/static/fonts/Geist-Variable.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/static/public/static/fonts.css">
<link rel="stylesheet" href="/static/public/static/fonts.css" vite-ignore>
</head>
<body>
@@ -14,4 +17,4 @@
<script type="module" src="/src/main.js"></script>
</body>
</html>
</html>
+37 -20
View File
@@ -35,12 +35,12 @@
<div class="flex-1 min-w-0">
<Sidebar
:userTeams="userStore.teams"
:userViews="userViews"
:userViews="viewStore.views"
:sharedViews="sharedViewStore.sharedViewList"
@create-view="createView"
@edit-view="editView"
@delete-view="deleteView"
@create-conversation="() => (openCreateConversationDialog = true)"
@create-conversation="openCreateConversation()"
>
<div class="flex flex-col h-full rounded-lg overflow-hidden bg-background">
<ConnectionBanner />
@@ -63,7 +63,13 @@
<Command />
<!-- Create conversation dialog -->
<CreateConversation v-model="openCreateConversationDialog" v-if="openCreateConversationDialog" />
<CreateConversation
v-if="openCreateConversationDialog"
v-model="openCreateConversationDialog"
:initial-contact="createConversationContact"
/>
<KeyboardShortcutsDialog v-model:open="showShortcuts" />
</template>
<script setup>
@@ -86,6 +92,10 @@ import { useTagStore } from './stores/tag'
import { useCustomAttributeStore } from './stores/customAttributes'
import { useIdleDetection } from './composables/useIdleDetection'
import { useNotificationStore } from './stores/notification'
import { useAiPromptStore } from '@main/stores/aiPrompt'
import { useViewStore } from './stores/view'
import { useKeyboardShortcutsDialog } from './composables/useKeyboardShortcutsDialog'
import KeyboardShortcutsDialog from './components/KeyboardShortcutsDialog.vue'
import { initAudioContext } from '@shared-ui/composables/useNotificationSound'
import PageHeader from './components/layout/PageHeader.vue'
import ViewForm from '@/features/view/ViewForm.vue'
@@ -113,6 +123,7 @@ import NotificationBell from '@main/components/sidebar/NotificationBell.vue'
import PrimaryNavItems from '@main/components/sidebar/PrimaryNavItems.vue'
import { useIsMobile } from '@shared-ui/composables'
import api from '@main/api'
import { usePushNotifications } from '@/composables/usePushNotifications'
const route = useRoute()
const emitter = useEmitter()
@@ -148,12 +159,16 @@ const slaStore = useSlaStore()
const sharedViewStore = useSharedViewStore()
const tagStore = useTagStore()
const customAttributeStore = useCustomAttributeStore()
const userViews = ref([])
const viewStore = useViewStore()
const { open: showShortcuts } = useKeyboardShortcutsDialog()
const view = ref({})
const openCreateViewForm = ref(false)
const openCreateConversationDialog = ref(false)
const createConversationContact = ref(null)
const { t } = useI18n()
const notificationStore = useNotificationStore()
const aiPromptStore = useAiPromptStore()
const pushNotifications = usePushNotifications()
// Update browser tab title with unread notification count.
// Watch both unreadCount and route so the prefix is preserved after navigation.
@@ -177,16 +192,23 @@ document.addEventListener('touchstart', unlockAudio)
onMounted(() => {
initToaster()
listenViewRefresh()
emitter.on(EMITTER_EVENTS.OPEN_CREATE_CONVERSATION, openCreateConversation)
emitter.on(EMITTER_EVENTS.OPEN_VIEW_FORM, createView)
initStores()
})
const openCreateConversation = ({ contact = null } = {}) => {
createConversationContact.value = contact
openCreateConversationDialog.value = true
}
// Initialize data stores
const initStores = async () => {
if (!userStore.userID) {
await userStore.getCurrentUser()
}
await Promise.allSettled([
getUserViews(),
viewStore.fetchViews(),
sharedViewStore.loadSharedViews(),
conversationStore.fetchStatuses(),
conversationStore.fetchPriorities(),
@@ -196,10 +218,17 @@ const initStores = async () => {
inboxStore.fetchInboxes(),
slaStore.fetchSlas(),
tagStore.fetchTags(),
customAttributeStore.fetchCustomAttributes()
customAttributeStore.fetchCustomAttributes(),
aiPromptStore.fetchPrompts(),
refreshPushSubscription()
])
}
const refreshPushSubscription = async () => {
const { data } = await api.getNotificationPreferences()
await pushNotifications.refresh(data.data.vapid_public_key, data.data.push_endpoints)
}
const createView = () => {
view.value = {}
openCreateViewForm.value = true
@@ -225,18 +254,6 @@ const deleteView = async (view) => {
}
}
const getUserViews = async () => {
try {
const response = await api.getCurrentUserViews()
userViews.value = response.data.data
} catch (err) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(err).message
})
}
}
const initToaster = () => {
emitter.on(EMITTER_EVENTS.SHOW_TOAST, (message) => {
if (!message.description) return
@@ -260,12 +277,12 @@ const refreshViews = async (data) => {
openCreateViewForm.value = false
// TODO: move model to constants.
if (data?.model === 'view') {
await getUserViews()
await viewStore.fetchViews()
if (data.id) conversationStore.fetchViewCount(data.id)
else conversationStore.fetchSidebarCounts({ force: true })
const openID = route.params.viewID
// If the open view was edited its filters may have changed, refetch.
if (openID && userViews.value.some((v) => String(v.id) === String(openID))) {
if (openID && viewStore.views.some((v) => String(v.id) === String(openID))) {
// Reset list and fetch conversations.
conversationStore.resetConversations()
conversationStore.fetchConversationsList(true, CONVERSATION_LIST_TYPE.VIEW, 0, [], openID)
+45 -4
View File
@@ -67,8 +67,8 @@ const updateCustomAttribute = (id, data) =>
}
})
const deleteCustomAttribute = (id) => http.delete(`/api/v1/custom-attributes/${id}`)
const searchConversations = (params) => http.get('/api/v1/conversations/search', { params })
const searchMessages = (params) => http.get('/api/v1/messages/search', { params })
const searchConversations = (params) => http.get('/api/v1/search/conversations', { params })
const searchMessages = (params) => http.get('/api/v1/search/messages', { params })
const searchContacts = (params) => http.get('/api/v1/contacts/search', { params })
const getEmailNotificationSettings = () => http.get('/api/v1/settings/notifications/email')
const updateEmailNotificationSettings = (data) =>
@@ -204,6 +204,12 @@ const updateRole = (id, data) =>
const deleteRole = (id) => http.delete(`/api/v1/roles/${id}`)
const getContacts = (params) => http.get('/api/v1/contacts', { params })
const getContact = (id) => http.get(`/api/v1/contacts/${id}`)
const createContact = (data) =>
http.post('/api/v1/contacts', data, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
const updateContact = (id, data) =>
http.put(`/api/v1/contacts/${id}`, data, {
headers: {
@@ -460,6 +466,10 @@ const updateSharedView = (id, data) =>
const deleteSharedView = (id) => http.delete(`/api/v1/shared-views/${id}`)
const getAiPrompts = () => http.get('/api/v1/ai/prompts')
const getAIPrompt = (id) => http.get(`/api/v1/ai/prompts/${id}`)
const createAIPrompt = (data) => http.post('/api/v1/ai/prompts', data)
const updateAIPrompt = (id, data) => http.put(`/api/v1/ai/prompts/${id}`, data)
const deleteAIPrompt = (id) => http.delete(`/api/v1/ai/prompts/${id}`)
const aiCompletion = (data) => http.post('/api/v1/ai/completion', data, {
timeout: AI_TIMEOUT,
headers: {
@@ -508,6 +518,10 @@ const aiGenerateReply = (data) => http.post('/api/v1/ai/generate-reply', data, {
const aiSummarizeConversation = (data) => http.post('/api/v1/ai/summarize', data, { timeout: AI_TIMEOUT })
const aiSuggestTags = (data) => http.post('/api/v1/ai/suggest-tags', data, { timeout: AI_TIMEOUT })
const aiCopilot = (data) => http.post('/api/v1/ai/copilot', data, { timeout: AI_TIMEOUT })
const approveAIToolRun = (id) =>
http.post(`/api/v1/ai/tool-runs/${id}/approve`, {}, { timeout: AI_TIMEOUT })
const declineAIToolRun = (id) =>
http.post(`/api/v1/ai/tool-runs/${id}/decline`, {}, { timeout: AI_TIMEOUT })
const getCopilotMessages = (conversationUUID) =>
http.get('/api/v1/ai/copilot/messages', { params: { conversation_uuid: conversationUUID } })
const clearCopilotMessages = (conversationUUID) =>
@@ -519,10 +533,10 @@ const createHelpCenter = (data) => http.post('/api/v1/help-centers', data)
const updateHelpCenter = (id, data) => http.put(`/api/v1/help-centers/${id}`, data)
const deleteHelpCenter = (id) => http.delete(`/api/v1/help-centers/${id}`)
const toggleHelpCenter = (id) => http.put(`/api/v1/help-centers/${id}/toggle`)
const previewHelpCenter = (id, data, page) =>
const previewHelpCenter = (id, data, page, theme) =>
http.post(`/api/v1/help-centers/${id}/preview`, data, {
responseType: 'text',
params: page ? { page } : {}
params: { ...(page ? { page } : {}), ...(theme ? { theme } : {}) }
})
const getHelpCenterTree = (id, locale) =>
http.get(`/api/v1/help-centers/${id}/tree`, { params: locale ? { locale } : {} })
@@ -547,6 +561,12 @@ const updateArticle = (id, data) => http.put(`/api/v1/articles/${id}`, data)
const deleteArticle = (collectionId, id) =>
http.delete(`/api/v1/collections/${collectionId}/articles/${id}`)
const updateArticleStatus = (id, data) => http.put(`/api/v1/articles/${id}/status`, data)
const unlinkArticleTranslation = (id) => http.put(`/api/v1/articles/${id}/unlink-translation`)
const linkArticleTranslation = (id, data) => http.put(`/api/v1/articles/${id}/link-translation`, data)
const getLinkableArticles = (helpCenterId, excludeLocale) =>
http.get(`/api/v1/help-centers/${helpCenterId}/linkable-articles`, {
params: { exclude_locale: excludeLocale }
})
const getHelpCenterInsights = (id) => http.get(`/api/v1/help-centers/${id}/insights`)
const getContactNotes = (id) => http.get(`/api/v1/contacts/${id}/notes`)
const createContactNote = (id, data) => http.post(`/api/v1/contacts/${id}/notes`, data, {
@@ -614,8 +634,15 @@ const markNotificationAsRead = (id) => http.put(`/api/v1/notifications/${id}/rea
const markAllNotificationsAsRead = () => http.put('/api/v1/notifications/read-all')
const deleteNotification = (id) => http.delete(`/api/v1/notifications/${id}`)
const deleteAllNotifications = () => http.delete('/api/v1/notifications')
const getNotificationPreferences = () => http.get('/api/v1/notifications/preferences')
const updateNotificationPreferences = (data) => http.put('/api/v1/notifications/preferences', data)
const createPushSubscription = (data) => http.post('/api/v1/notifications/push-subscriptions', data)
const deletePushSubscription = (endpoint) => http.delete('/api/v1/notifications/push-subscriptions', { data: { endpoint } })
const getCampaignStats = (id, params) => http.get(`/api/v1/inboxes/${id}/campaign-stats`, { params })
export default {
getCampaignStats,
login,
deleteUser,
importAgents,
@@ -748,6 +775,10 @@ export default {
updateSharedView,
deleteSharedView,
getAiPrompts,
getAIPrompt,
createAIPrompt,
updateAIPrompt,
deleteAIPrompt,
aiCompletion,
getAIConfig,
updateAIConfig,
@@ -792,6 +823,9 @@ export default {
updateArticle,
deleteArticle,
updateArticleStatus,
unlinkArticleTranslation,
linkArticleTranslation,
getLinkableArticles,
getHelpCenterInsights,
getAIFaqSuggestions,
approveAIFaqSuggestion,
@@ -802,6 +836,8 @@ export default {
aiSummarizeConversation,
aiSuggestTags,
aiCopilot,
approveAIToolRun,
declineAIToolRun,
getCopilotMessages,
clearCopilotMessages,
searchConversations,
@@ -810,6 +846,7 @@ export default {
removeAssignee,
getContacts,
getContact,
createContact,
updateContact,
blockContact,
deleteContact,
@@ -848,5 +885,9 @@ export default {
markAllNotificationsAsRead,
deleteNotification,
deleteAllNotifications,
getNotificationPreferences,
updateNotificationPreferences,
createPushSubscription,
deletePushSubscription,
getContactPageVisits
}
@@ -50,16 +50,38 @@ const { t } = useI18n()
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform)
const mod = isMac ? '⌘' : 'Ctrl'
const alt = isMac ? '⌥' : 'Alt'
const groups = computed(() => [
{
title: t('globals.terms.general'),
items: [{ label: t('shortcuts.openCommandBar'), keys: [mod, 'K'] }]
items: [
{ label: t('shortcuts.openCommandBar'), keys: [mod, 'K'] },
{ label: t('navigation.keyboardShortcuts'), keys: [mod, '/'] },
{ label: t('conversation.newConversation'), keys: [alt, 'C'] }
]
},
{
title: t('globals.terms.conversation', 2),
items: [
{ label: t('shortcuts.previousConversation'), keys: [alt, 'J'] },
{ label: t('shortcuts.nextConversation'), keys: [alt, 'K'] },
{ label: t('globals.terms.snooze'), keys: [alt, 'Z'] },
{ label: t('actions.setPriority'), keys: [alt, 'P'] },
{ label: t('actions.assignAgent'), keys: [alt, 'A'] },
{ label: t('command.switchToReply'), keys: [alt, 'R'] },
{ label: t('command.switchToPrivateNote'), keys: [alt, 'N'] },
{ label: t('conversation.bulkActions.selectConversation'), keys: [alt, 'X'] },
{ label: t('globals.terms.resolve'), keys: [alt, 'E'] },
{ label: t('globals.terms.reopen'), keys: [alt, 'O'] }
]
},
{
title: t('shortcuts.replyEditor'),
items: [
{ label: t('actions.openMacros'), keys: [mod, 'M'] },
{ label: t('shortcuts.toggleReplyType'), keys: [mod, 'P'] },
{ label: t('shortcuts.minimizeReplyBox'), keys: [alt, 'M'] },
{ label: t('actions.sendReply'), keys: ['Ctrl', 'Enter'] },
{ label: t('globals.terms.bold'), keys: [mod, 'B'] },
{ label: t('globals.terms.italic'), keys: [mod, 'I'] }
@@ -0,0 +1,29 @@
<template>
<Tabs v-model="theme">
<TabsList class="h-8 p-0.5">
<TabsTrigger
value="light"
class="px-2"
data-preview-theme="light"
:aria-label="$t('globals.terms.light')"
>
<Sun class="size-3.5" />
</TabsTrigger>
<TabsTrigger
value="dark"
class="px-2"
data-preview-theme="dark"
:aria-label="$t('globals.terms.dark')"
>
<Moon class="size-3.5" />
</TabsTrigger>
</TabsList>
</Tabs>
</template>
<script setup>
import { Tabs, TabsList, TabsTrigger } from '@shared-ui/components/ui/tabs'
import { Sun, Moon } from 'lucide-vue-next'
const theme = defineModel({ type: String, default: 'light' })
</script>
@@ -1,20 +1,19 @@
<template>
<div class="w-full space-y-3">
<div v-if="searchable" class="relative max-w-xs">
<Search class="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
v-model="globalFilter"
:placeholder="searchPlaceholder || t('globals.terms.search')"
class="pl-8"
/>
</div>
<SearchInput
v-if="searchable"
v-model="globalFilter"
class="max-w-xs"
:placeholder="searchPlaceholder || t('globals.terms.search')"
:clear-label="t('globals.messages.clearSearch')"
/>
<div
ref="scrollContainer"
class="relative overflow-auto rounded-lg border border-border bg-card shadow-sm"
:style="{ maxHeight }"
>
<table class="w-full caption-bottom text-sm">
<table class="w-full caption-bottom text-sm sm:w-max sm:min-w-full">
<TableHeader class="sticky top-0 z-10 bg-card">
<TableRow
v-for="headerGroup in table.getHeaderGroups()"
@@ -22,12 +21,13 @@
class="border-b border-border bg-muted/40 hover:bg-muted/40"
>
<TableHead
v-for="header in headerGroup.headers"
v-for="(header, headerIndex) in headerGroup.headers"
:key="header.id"
class="h-11 px-4 text-center text-sm font-medium text-muted-foreground"
class="h-11 px-4 text-center text-sm font-medium text-muted-foreground sm:whitespace-nowrap"
:class="{
'group cursor-pointer select-none transition-colors hover:text-foreground':
header.column.getCanSort()
header.column.getCanSort(),
'max-sm:hidden': isHiddenOnPhone(header.column.id, headerIndex)
}"
@click="header.column.getToggleSortingHandler()?.($event)"
>
@@ -64,10 +64,13 @@
class="group/row border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30 data-[state=selected]:bg-muted"
>
<TableCell
v-for="cell in rows[virtualRow.index].getVisibleCells()"
v-for="(cell, cellIndex) in rows[virtualRow.index].getVisibleCells()"
:key="cell.id"
class="px-4 py-3 text-center text-sm"
:class="cell.column.id === 'actions' ? actionCellClass : ''"
class="px-4 py-3 text-center text-sm sm:whitespace-nowrap"
:class="[
cell.column.id === 'actions' ? actionCellClass : '',
isHiddenOnPhone(cell.column.id, cellIndex) && 'max-sm:hidden'
]"
>
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</TableCell>
@@ -112,7 +115,8 @@ import {
import { useVirtualizer } from '@tanstack/vue-virtual'
import { useI18n } from 'vue-i18n'
import { computed, ref } from 'vue'
import { ArrowUpDown, ChevronDown, ChevronUp, Ghost, Search } from 'lucide-vue-next'
import { ArrowUpDown, ChevronDown, ChevronUp, Ghost } from 'lucide-vue-next'
import SearchInput from '@shared-ui/components/SearchInput.vue'
import {
TableBody,
TableCell,
@@ -120,7 +124,6 @@ import {
TableHeader,
TableRow
} from '@shared-ui/components/ui/table'
import { Input } from '@shared-ui/components/ui/input'
const { t } = useI18n()
@@ -128,6 +131,9 @@ const { t } = useI18n()
const actionCellClass =
'transition-opacity duration-150 can-hover:opacity-0 can-hover:group-hover/row:opacity-100 focus-within:!opacity-100 [&:has([data-state=open])]:!opacity-100 max-md:[&_button]:size-11'
const isHiddenOnPhone = (columnId, index) =>
(index > 1 && columnId !== 'actions') || ['created_at', 'updated_at'].includes(columnId)
const props = defineProps({
columns: Array,
data: Array,
@@ -205,7 +211,7 @@ const rowVirtualizer = useVirtualizer({
const virtualRows = computed(() => rowVirtualizer.value.getVirtualItems())
const totalSize = computed(() => rowVirtualizer.value.getTotalSize())
const paddingTop = computed(() => (virtualRows.value[0]?.start ?? 0))
const paddingTop = computed(() => virtualRows.value[0]?.start ?? 0)
const paddingBottom = computed(() => {
const last = virtualRows.value[virtualRows.value.length - 1]
return last ? totalSize.value - last.end : 0
@@ -20,7 +20,11 @@
</div>
</Teleport>
<EditorContent :editor="editor" class="hc-prose flex-1 min-h-0 overflow-y-auto" />
<EditorContent
:editor="editor"
class="hc-prose flex-1 min-h-0 overflow-y-auto"
:style="{ '--hc-editor-accent': accentColor || null, '--hc-editor-accent-dark': accentColorDark || null }"
/>
<input
ref="imageInput"
@@ -58,7 +62,9 @@ const props = defineProps({
disabled: { type: Boolean, default: false },
enableInlineImages: { type: Boolean, default: false },
linkedModel: { type: String, default: 'messages' },
toolbarTarget: { type: null, default: null }
toolbarTarget: { type: null, default: null },
accentColor: { type: String, default: '' },
accentColorDark: { type: String, default: '' }
})
const emit = defineEmits(['send', 'filesDropped'])
@@ -104,12 +110,23 @@ defineExpose({ focus })
<style lang="scss">
.tiptap {
--hc-accent: hsl(var(--primary));
--hc-accent: var(--hc-editor-accent, hsl(var(--primary)));
--hc-accent-ink: color-mix(in srgb, var(--hc-accent), #0d1117 46%);
--hc-accent-fill: color-mix(in srgb, var(--hc-accent), #0d1117 46%);
--hc-border: hsl(var(--border));
--hc-accent-tint: hsl(var(--primary) / 0.08);
--hc-bg: hsl(var(--background));
--hc-accent-tint: color-mix(in srgb, var(--hc-accent) 8%, var(--hc-bg));
--hc-muted: hsl(var(--muted-foreground));
table td,
table th {
border-color: var(--hc-border);
}
table th {
background: var(--hc-accent-tint);
}
details.hc-details > .hc-details-content {
margin-top: 0.5rem;
}
@@ -124,4 +141,10 @@ defineExpose({ focus })
outline-offset: 2px;
}
}
.dark .tiptap {
--hc-accent: var(--hc-editor-accent-dark, var(--hc-editor-accent, hsl(var(--primary))));
--hc-accent-ink: color-mix(in srgb, var(--hc-accent), #ffffff 34%);
--hc-accent-tint: color-mix(in srgb, var(--hc-accent) 12%, var(--hc-bg));
}
</style>
@@ -12,9 +12,9 @@
>
<EditorToolbar
:editor="editor"
:ai-prompts="aiPrompts"
show-ai
@open-link="linkDialog?.open()"
@ai-prompt="emitPrompt"
@ai-generation-change="emit('aiGenerationChange', $event)"
/>
</BubbleMenu>
<EditorContent :editor="editor" class="native-html" />
@@ -41,20 +41,25 @@ const props = defineProps({
insertContent: String,
messageType: String,
autoFocus: { type: Boolean, default: true },
aiPrompts: { type: Array, default: () => [] },
disabled: { type: Boolean, default: false },
enableMentions: { type: Boolean, default: false },
getSuggestions: { type: Function, default: null },
enableConversationReferences: { type: Boolean, default: false },
getConversationSuggestions: { type: Function, default: null },
enableInlineImages: { type: Boolean, default: false },
linkedModel: { type: String, default: 'messages' }
})
const emit = defineEmits(['send', 'aiPromptSelected', 'mentionsChanged', 'filesDropped'])
const emit = defineEmits([
'send',
'aiGenerationChange',
'mentionsChanged',
'filesDropped',
'toggleMessageType'
])
const linkDialog = ref(null)
const emitPrompt = (key) => emit('aiPromptSelected', key)
// Suppress the formatting bubble when an image node is selected so it
// doesn't fight with the image's own size/remove toolbar.
const shouldShowBubble = ({ editor: e, state }) => {
@@ -85,10 +90,13 @@ const { editor, extractMentions, focus } = useTextEditor({
linkedModel: props.linkedModel,
getSuggestions: props.getSuggestions,
enableMentions: () => props.enableMentions,
getConversationSuggestions: props.getConversationSuggestions,
conversationReferencesEnabled: () => props.enableConversationReferences,
onSend: () => {
emit('send')
stopTyping()
},
onToggleMessageType: () => emit('toggleMessageType'),
onUpdate: () => {
startTyping()
if (props.enableMentions) emit('mentionsChanged', extractMentions())
@@ -0,0 +1,77 @@
<template>
<div
v-if="items.length > 0"
class="conversation-reference-list bg-background border rounded-lg shadow-lg overflow-hidden max-h-60 overflow-y-auto"
>
<button
v-for="(item, index) in items"
:key="item.id"
class="conversation-reference-item w-full text-left px-3 py-2 hover:bg-muted"
:class="{ 'bg-muted': index === selectedIndex }"
@click="selectItem(index)"
>
<div class="flex items-center gap-2">
<span class="font-medium">#{{ item.label }}</span>
<span class="text-xs text-muted-foreground">{{ item.status }}</span>
</div>
<div v-if="item.subject" class="text-sm text-muted-foreground truncate">
{{ item.subject }}
</div>
</button>
</div>
<div v-else class="conversation-reference-list bg-background border rounded-lg shadow-lg p-3">
<span v-if="query.length < MIN_REFERENCE_QUERY_LENGTH" class="text-sm text-muted-foreground">
{{
$t('search.minQueryLength', {
length: MIN_REFERENCE_QUERY_LENGTH
})
}}
</span>
<span v-else class="text-sm text-muted-foreground">{{
$t('globals.messages.noResultsFound')
}}</span>
</div>
</template>
<script setup>
import { ref, watch, nextTick } from 'vue'
import { MIN_REFERENCE_QUERY_LENGTH } from './conversationReference'
const props = defineProps({
items: { type: Array, default: () => [] },
command: { type: Function, required: true },
query: { type: String, default: '' }
})
const selectedIndex = ref(0)
const selectItem = (index) => props.items[index] && props.command(props.items[index])
watch(
() => props.items,
() => {
selectedIndex.value = 0
}
)
watch(selectedIndex, () =>
nextTick(() =>
document.querySelector('.conversation-reference-item.bg-muted')?.scrollIntoView({ block: 'nearest' })
)
)
defineExpose({
onKeyDown: ({ event }) => {
if (!props.items.length) return false
if (event.key === 'ArrowUp')
selectedIndex.value = (selectedIndex.value + props.items.length - 1) % props.items.length
else if (event.key === 'ArrowDown')
selectedIndex.value = (selectedIndex.value + 1) % props.items.length
else if (event.key === 'Enter') selectItem(selectedIndex.value)
else return false
return true
}
})
</script>
<style scoped>
.conversation-reference-list {
min-width: 240px;
max-width: 360px;
}
</style>
@@ -1,8 +1,14 @@
<template>
<div class="flex flex-wrap gap-1 items-center">
<DropdownMenu v-if="aiPrompts.length > 0">
<DropdownMenu v-if="showAi && aiPromptStore.prompts.length > 0">
<DropdownMenuTrigger as-child>
<Button type="button" size="sm" variant="ghost" class="flex items-center justify-center">
<Button
type="button"
size="sm"
variant="ghost"
class="flex items-center justify-center"
:disabled="isGenerating"
>
<span class="flex items-center">
<span class="text-medium">AI</span>
<Bot size="14" class="ml-1" />
@@ -12,9 +18,9 @@
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
v-for="prompt in aiPrompts"
v-for="prompt in aiPromptStore.prompts"
:key="prompt.key"
@select="emit('aiPrompt', prompt.key)"
@select="complete(prompt.key)"
>
{{ prompt.title }}
</DropdownMenuItem>
@@ -331,6 +337,7 @@
</template>
<script setup>
import { ref } from 'vue'
import {
ChevronDown,
Bold,
@@ -363,16 +370,20 @@ import {
DropdownMenuTrigger
} from '@shared-ui/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@shared-ui/components/ui/tooltip'
import { useAiPromptStore } from '@main/stores/aiPrompt'
import { codeBlockLanguages } from './codeLanguages'
const props = defineProps({
editor: { type: Object, default: null },
showArticleTools: { type: Boolean, default: false },
aiPrompts: { type: Array, default: () => [] },
showAi: { type: Boolean, default: false },
enableInlineImages: { type: Boolean, default: false }
})
const emit = defineEmits(['openLink', 'openYoutube', 'openImage', 'aiPrompt'])
const emit = defineEmits(['openLink', 'openYoutube', 'openImage', 'aiGenerationChange'])
const aiPromptStore = useAiPromptStore()
const isGenerating = ref(false)
const alignments = [
{ dir: 'left', icon: AlignLeft, label: 'editor.tooltip.alignLeft' },
@@ -398,4 +409,18 @@ const getCurrentHeadingText = () => {
}
return 'P'
}
const complete = async (promptKey) => {
if (!props.editor || isGenerating.value) return
isGenerating.value = true
emit('aiGenerationChange', true)
try {
const content = await aiPromptStore.complete(promptKey, props.editor.getHTML())
if (content !== null) props.editor.commands.setContent(content, true)
} finally {
isGenerating.value = false
emit('aiGenerationChange', false)
}
}
</script>
@@ -0,0 +1,29 @@
import api from '@main/api'
export const MIN_REFERENCE_QUERY_LENGTH = 3
const SUGGESTION_LIMIT = 10
export const createLatestConversationSuggestionFetcher = (fetchSuggestions) => {
let latestRequestID = 0
return async (query) => {
const requestID = ++latestRequestID
const suggestions = await fetchSuggestions(query)
return requestID === latestRequestID ? suggestions : []
}
}
export const getConversationSuggestions = async (query, search = api.searchConversations) => {
const reference = query.trim()
if (reference.length < MIN_REFERENCE_QUERY_LENGTH) return []
const response = await search({ query: reference, page_size: SUGGESTION_LIMIT })
return (response.data?.data?.results || [])
.filter((conversation) => conversation.reference_number === reference)
.map((conversation) => ({
id: conversation.uuid,
label: conversation.reference_number,
subject: conversation.subject,
status: conversation.status
}))
}
@@ -0,0 +1,63 @@
import { describe, expect, it, vi } from 'vitest'
import * as conversationReference from './conversationReference'
const { getConversationSuggestions } = conversationReference
describe('conversation reference suggestions', () => {
it('does not search until three characters are entered', async () => {
const search = vi.fn()
await expect(getConversationSuggestions('10', search)).resolves.toEqual([])
expect(search).not.toHaveBeenCalled()
})
it('only maps an exact reference number match to a suggestion', async () => {
const search = vi.fn().mockResolvedValue({
data: {
data: {
results: [
{
uuid: 'email-match-conversation-uuid',
reference_number: '109',
subject: 'Reference appears in contact email',
status: 'Open'
},
{
uuid: 'conversation-uuid',
reference_number: '108',
subject: 'Payment failed',
status: 'Open'
}
]
}
}
})
await expect(getConversationSuggestions('108', search)).resolves.toEqual([
{ id: 'conversation-uuid', label: '108', subject: 'Payment failed', status: 'Open' }
])
})
it('asks the server for a capped page', async () => {
const search = vi.fn().mockResolvedValue({ data: { data: { results: [] } } })
await getConversationSuggestions('108', search)
expect(search).toHaveBeenCalledWith({ query: '108', page_size: 10 })
})
it('discards a response superseded by a newer query', async () => {
let resolveFirst
let resolveSecond
const fetchSuggestions = vi
.fn()
.mockImplementationOnce(() => new Promise((resolve) => (resolveFirst = resolve)))
.mockImplementationOnce(() => new Promise((resolve) => (resolveSecond = resolve)))
const getLatestSuggestions =
conversationReference.createLatestConversationSuggestionFetcher(fetchSuggestions)
const firstRequest = getLatestSuggestions('108')
const secondRequest = getLatestSuggestions('109')
resolveSecond([{ label: '109' }])
await expect(secondRequest).resolves.toEqual([{ label: '109' }])
resolveFirst([{ label: '108' }])
await expect(firstRequest).resolves.toEqual([])
})
})
@@ -0,0 +1,20 @@
import Mention from '@tiptap/extension-mention'
export const conversationReferenceHref = (uuid) =>
`/inboxes/all/conversation/${encodeURIComponent(uuid)}`
export const ConversationReference = Mention.extend({
name: 'conversationReference',
// Link's a[href] mark rule is registered first at the default priority and would swallow the anchor.
parseHTML: () => [{ tag: 'a.ld-conversation-reference', priority: 51 }],
renderText: ({ node }) => `#${node.attrs.label}`,
renderHTML: ({ node, HTMLAttributes }) => [
'a',
{
...HTMLAttributes,
href: conversationReferenceHref(node.attrs.id),
class: [HTMLAttributes.class, 'ld-conversation-reference'].filter(Boolean).join(' ')
},
`#${node.attrs.label}`
]
})
@@ -0,0 +1,39 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { Editor } from '@tiptap/vue-3'
import { buildConversationExtensions } from './editorExtensions'
import { conversationReferenceHref } from './conversationReferenceExtension'
describe('conversation reference editor extension', () => {
it('builds a root-relative conversation link', () => {
expect(conversationReferenceHref('abc')).toBe('/inboxes/all/conversation/abc')
})
it('round-trips a conversation reference through HTML in the conversation editor', () => {
const content =
'<p><a data-id="conversation-uuid" data-label="108" href="/inboxes/all/conversation/conversation-uuid" class="ld-conversation-reference">#108</a></p>'
const editor = new Editor({
extensions: buildConversationExtensions({ getPlaceholder: () => '' }),
content
})
expect(editor.getJSON().content[0].content[0]).toMatchObject({
type: 'conversationReference',
attrs: { id: 'conversation-uuid', label: '108' }
})
expect(editor.getHTML()).toContain('href="/inboxes/all/conversation/conversation-uuid"')
editor.destroy()
})
it('derives the link from the conversation id, ignoring a stored href', () => {
const content =
'<p><a data-id="conversation-uuid" data-label="108" href="https://evil.example/x" class="ld-conversation-reference">#108</a></p>'
const editor = new Editor({
extensions: buildConversationExtensions({ getPlaceholder: () => '' }),
content
})
expect(editor.getHTML()).toContain('href="/inboxes/all/conversation/conversation-uuid"')
expect(editor.getHTML()).not.toContain('evil.example')
editor.destroy()
})
})
@@ -0,0 +1,10 @@
import ConversationReferenceList from './ConversationReferenceList.vue'
import { createSuggestionRenderer } from './suggestionRenderer'
export default {
char: '#',
allowSpaces: false,
allow: ({ editor }) => editor.options.editorProps?.conversationReferencesEnabled?.() ?? false,
items: ({ query, editor }) => editor.options.editorProps?.getConversationSuggestions?.(query) ?? [],
render: () => createSuggestionRenderer(ConversationReferenceList)
}
@@ -17,6 +17,8 @@ import { Callout } from './extensions/Callout'
import { Details, DetailsSummary, DetailsContent } from './extensions/Collapsible'
import { TrailingNode } from './extensions/TrailingNode'
import mentionSuggestion from './mentionSuggestion'
import conversationReferenceSuggestion from './conversationReferenceSuggestion'
import { ConversationReference } from './conversationReferenceExtension'
const lowlight = createLowlight(codeGrammars)
@@ -142,6 +144,7 @@ export function buildConversationExtensions({ getPlaceholder }) {
HTMLAttributes: { class: 'ld-mention' },
suggestion: mentionSuggestion
}),
ConversationReference.configure({ suggestion: conversationReferenceSuggestion }),
CustomTable.configure({ resizable: false }),
TableRow,
CustomTableCell,
@@ -34,13 +34,13 @@
td,
th {
border: 1px solid #dee2e6;
border: 1px solid hsl(var(--border));
padding: 6px 8px;
vertical-align: top;
}
th {
background: #f8f9fa;
background: hsl(var(--muted));
font-weight: bold;
text-align: left;
}
@@ -55,7 +55,8 @@
}
}
.ld-mention {
.ld-mention,
.ld-conversation-reference {
background-color: hsl(var(--primary) / 0.1);
border-radius: 0.25rem;
padding: 0 0.25rem;
@@ -1,111 +1,10 @@
import { VueRenderer } from '@tiptap/vue-3'
import MentionList from './MentionList.vue'
import { createSuggestionRenderer } from './suggestionRenderer'
export default {
char: '@',
allowSpaces: true,
allow: ({ editor }) => {
const enableMentions = editor.options.editorProps?.enableMentions
return enableMentions ? enableMentions() : false
},
items: async ({ query, editor }) => {
// Get the suggestion handler from editor options
const getSuggestions = editor.options.editorProps?.getSuggestions
if (!getSuggestions) return []
const items = await getSuggestions(query)
return items
},
render: () => {
let component
let popup
return {
onStart: (props) => {
component = new VueRenderer(MentionList, {
props: {
...props,
query: props.query
},
editor: props.editor
})
if (!props.clientRect) {
return
}
// Create popup container with CSS positioning
popup = document.createElement('div')
popup.style.position = 'fixed'
popup.style.zIndex = '9999'
if (component.element) {
popup.appendChild(component.element)
}
document.body.appendChild(popup)
// Position the popup
updatePosition(popup, props.clientRect)
},
onUpdate: (props) => {
component.updateProps({
...props,
query: props.query
})
if (!props.clientRect || !popup) {
return
}
updatePosition(popup, props.clientRect)
},
onKeyDown: (props) => {
if (props.event.key === 'Escape') {
if (popup) {
popup.style.display = 'none'
}
return true
}
return component.ref?.onKeyDown(props)
},
onExit: () => {
if (popup && popup.parentNode) {
popup.parentNode.removeChild(popup)
}
component.destroy()
}
}
}
}
function updatePosition(popup, clientRect) {
const rect = clientRect()
if (!rect) return
// Position below the cursor
popup.style.left = `${rect.left}px`
popup.style.top = `${rect.bottom + 4}px`
// Ensure popup doesn't go off-screen to the right
requestAnimationFrame(() => {
const popupRect = popup.getBoundingClientRect()
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
// Adjust horizontal position if needed
if (popupRect.right > viewportWidth) {
popup.style.left = `${viewportWidth - popupRect.width - 8}px`
}
// If popup goes below viewport, show above cursor
if (popupRect.bottom > viewportHeight) {
popup.style.top = `${rect.top - popupRect.height - 4}px`
}
})
allow: ({ editor }) => editor.options.editorProps?.enableMentions?.() ?? false,
items: ({ query, editor }) => editor.options.editorProps?.getSuggestions?.(query) ?? [],
render: () => createSuggestionRenderer(MentionList)
}
@@ -0,0 +1,64 @@
import { VueRenderer } from '@tiptap/vue-3'
export function createSuggestionRenderer(ListComponent) {
let component
let popup
let dismissed = false
return {
onStart: (props) => {
dismissed = false
component = new VueRenderer(ListComponent, {
props: { ...props, query: props.query },
editor: props.editor
})
if (!props.clientRect) return
popup = document.createElement('div')
popup.style.position = 'fixed'
popup.style.zIndex = '9999'
if (component.element) popup.appendChild(component.element)
document.body.appendChild(popup)
updatePosition(popup, props.clientRect)
},
onUpdate: (props) => {
component.updateProps({ ...props, query: props.query })
if (!props.clientRect || !popup) return
updatePosition(popup, props.clientRect)
},
onKeyDown: (props) => {
if (dismissed) return false
if (props.event.key === 'Escape') {
dismissed = true
if (popup) popup.style.display = 'none'
return true
}
return component?.ref?.onKeyDown(props) ?? false
},
onExit: () => {
popup?.remove()
component.destroy()
}
}
}
function updatePosition(popup, clientRect) {
const rect = clientRect()
if (!rect) return
popup.style.left = `${rect.left}px`
popup.style.top = `${rect.bottom + 4}px`
requestAnimationFrame(() => {
const popupRect = popup.getBoundingClientRect()
if (popupRect.right > window.innerWidth) {
popup.style.left = `${window.innerWidth - popupRect.width - 8}px`
}
if (popupRect.bottom > window.innerHeight) {
popup.style.top = `${rect.top - popupRect.height - 4}px`
}
})
}
@@ -0,0 +1,12 @@
import { describe, expect, it, vi } from 'vitest'
import { createSuggestionRenderer } from './suggestionRenderer'
vi.mock('@tiptap/vue-3', () => ({ VueRenderer: vi.fn() }))
describe('createSuggestionRenderer', () => {
it('ignores key events before the suggestion list starts', () => {
const renderer = createSuggestionRenderer({})
expect(renderer.onKeyDown({ event: { key: 'Enter' } })).toBe(false)
})
})
@@ -13,7 +13,10 @@ export function useTextEditor({
linkedModel = 'messages',
getSuggestions = null,
enableMentions = () => false,
getConversationSuggestions = null,
conversationReferencesEnabled = () => false,
onSend = () => {},
onToggleMessageType = null,
onUpdate = () => {},
onBlur = () => {},
onOtherFiles = () => {}
@@ -52,6 +55,8 @@ export function useTextEditor({
attributes: { class: 'outline-none' },
getSuggestions,
enableMentions,
getConversationSuggestions,
conversationReferencesEnabled,
handlePaste,
handleDrop,
handleKeyDown: (view, event) => {
@@ -63,6 +68,17 @@ export function useTextEditor({
onSend()
return true
}
if (
onToggleMessageType &&
(event.ctrlKey || event.metaKey) &&
!event.shiftKey &&
!event.altKey &&
event.key.toLowerCase() === 'p'
) {
event.preventDefault()
onToggleMessageType()
return true
}
}
},
onUpdate: ({ editor }) => {
@@ -3,6 +3,7 @@
<Popover v-if="range" v-model:open="rangeOpen">
<PopoverTrigger as-child>
<Button
type="button"
variant="outline"
:class="
cn(
@@ -11,8 +12,8 @@
)
"
>
<CalendarIcon class="mr-2 h-4 w-4" />
{{ rangeLabel || t('globals.terms.pickDate') }}
<CalendarIcon class="mr-2 h-4 w-4" aria-hidden="true" />
{{ rangeLabel || placeholder || t('globals.terms.pickDate') }}
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0">
@@ -26,6 +27,7 @@
<Popover v-else v-model:open="open">
<PopoverTrigger as-child>
<Button
type="button"
variant="outline"
:class="
cn(
@@ -34,8 +36,8 @@
)
"
>
<CalendarIcon class="mr-2 h-4 w-4" />
{{ modelValue ? formatDisplay(modelValue) : t('globals.terms.pickDate') }}
<CalendarIcon class="mr-2 h-4 w-4" aria-hidden="true" />
{{ modelValue ? formatDisplay(modelValue) : placeholder || t('globals.terms.pickDate') }}
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0">
@@ -64,7 +66,8 @@ const { t } = useI18n()
const modelValue = defineModel({ type: String, default: '' })
defineProps({
range: { type: Boolean, default: false }
range: { type: Boolean, default: false },
placeholder: { type: String, default: '' }
})
const open = ref(false)
@@ -17,37 +17,45 @@
</Select>
</div>
<div class="flex items-center gap-1">
<nav class="flex items-center gap-1" :aria-label="t('globals.terms.pagination')">
<Button
type="button"
variant="ghost"
size="sm"
class="h-8 w-8 p-0"
:class="EDGE_PAGE_BUTTON_CLASS"
:aria-label="t('globals.messages.firstPage')"
:disabled="page <= 1"
@click="goToPage(1)"
>
<ChevronsLeft class="h-4 w-4" />
<ChevronsLeft class="h-4 w-4" aria-hidden="true" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
class="h-8 w-8 p-0"
:class="PAGE_BUTTON_CLASS"
:aria-label="t('globals.messages.previousPage')"
:disabled="page <= 1"
@click="goToPage(page - 1)"
>
<ChevronLeft class="h-4 w-4" />
<ChevronLeft class="h-4 w-4" aria-hidden="true" />
</Button>
<div class="flex items-center bg-muted rounded-lg p-1">
<template v-for="pageNumber in visiblePages" :key="pageNumber">
<span
v-if="pageNumber === '...'"
aria-hidden="true"
class="flex items-center justify-center h-7 w-7 text-sm text-muted-foreground select-none"
>
...
</span>
<button
v-else
type="button"
@click="goToPage(pageNumber)"
:aria-label="`${t('globals.terms.page')} ${pageNumber}`"
:aria-current="pageNumber === page ? 'page' : undefined"
class="h-7 min-w-7 px-2 rounded-md text-sm font-medium transition-all duration-150 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
:class="
pageNumber === page
@@ -61,29 +69,36 @@
</div>
<Button
type="button"
variant="ghost"
size="sm"
class="h-8 w-8 p-0"
:class="PAGE_BUTTON_CLASS"
:aria-label="t('globals.messages.nextPage')"
:disabled="page >= totalPages"
@click="goToPage(page + 1)"
>
<ChevronRight class="h-4 w-4" />
<ChevronRight class="h-4 w-4" aria-hidden="true" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
class="h-8 w-8 p-0"
:class="EDGE_PAGE_BUTTON_CLASS"
:aria-label="t('globals.messages.lastPage')"
:disabled="page >= totalPages"
@click="goToPage(totalPages)"
>
<ChevronsRight class="h-4 w-4" />
<ChevronsRight class="h-4 w-4" aria-hidden="true" />
</Button>
</div>
</nav>
</div>
</div>
</template>
<script setup>
const EDGE_PAGE_BUTTON_CLASS = 'hidden h-8 w-8 p-0 sm:inline-flex'
const PAGE_BUTTON_CLASS = 'h-8 w-8 p-0'
import { computed } from 'vue'
import { Button } from '@shared-ui/components/ui/button'
import {
@@ -5,7 +5,7 @@
<Bell class="h-5 w-5" />
<span
v-if="notificationStore.unreadCount > 0"
class="absolute top-0.5 left-6 md:left-auto md:right-0.5 inline-flex size-3.5 items-center justify-center rounded-full bg-destructive text-[9px] font-medium text-destructive-foreground"
class="absolute top-0.5 left-6 lg:left-auto lg:right-0.5 inline-flex size-3.5 items-center justify-center rounded-full bg-destructive text-[9px] font-medium text-destructive-foreground"
>
{{ notificationStore.unreadCount > 99 ? '99' : notificationStore.unreadCount }}
</span>
@@ -62,9 +62,9 @@
<div class="flex gap-2.5">
<!-- Icon based on notification type -->
<component
:is="getNotificationIcon(notification.notification_type)"
:is="getNotificationStyle(notification.notification_type).icon"
class="flex-shrink-0 h-4 w-4 mt-0.5"
:class="getNotificationIconClass(notification.notification_type)"
:class="getNotificationStyle(notification.notification_type).class"
/>
<!-- Content -->
@@ -119,6 +119,17 @@
</Button>
</div>
</div>
<div class="flex justify-end border-t border-border p-1">
<router-link
:to="{ name: 'account-notifications' }"
:class="buttonVariants({ variant: 'ghost', size: 'sm' })"
@click="emit('close')"
>
<Settings aria-hidden="true" />
<span>{{ t('notification.settings') }}</span>
</router-link>
</div>
</div>
</template>
@@ -132,12 +143,15 @@ import {
CheckCheck,
X,
Trash2,
Settings,
AtSign,
UserPlus,
MessageSquare,
RotateCcw,
AlertTriangle,
AlertCircle
} from 'lucide-vue-next'
import { Button } from '@shared-ui/components/ui/button'
import { Button, buttonVariants } from '@shared-ui/components/ui/button'
import { Skeleton } from '@shared-ui/components/ui/skeleton'
import { useNotificationStore } from '@main/stores/notification'
import { getRelativeTime } from '@shared-ui/utils/datetime.js'
@@ -149,24 +163,22 @@ const { t } = useI18n()
const notificationStore = useNotificationStore()
const getNotificationIcon = (type) => {
const icons = {
mention: AtSign,
assignment: UserPlus,
sla_warning: AlertTriangle,
sla_breach: AlertCircle
}
return icons[type] || Bell
const notificationStyles = {
mention: { icon: AtSign, class: 'text-primary' },
assignment: { icon: UserPlus, class: 'text-accent-foreground' },
new_reply: { icon: MessageSquare, class: 'text-primary' },
new_reply_participating: { icon: MessageSquare, class: 'text-primary' },
conversation_reopened: { icon: RotateCcw, class: 'text-accent-foreground' }
}
const getNotificationIconClass = (type) => {
const classes = {
mention: 'text-primary',
assignment: 'text-accent-foreground',
sla_warning: 'text-destructive',
sla_breach: 'text-destructive'
const getNotificationStyle = (type) => {
if (type?.startsWith('sla_')) {
return {
icon: type.endsWith('_breach') ? AlertCircle : AlertTriangle,
class: 'text-destructive'
}
}
return classes[type] || 'text-muted-foreground'
return notificationStyles[type] || { icon: Bell, class: 'text-muted-foreground' }
}
const handleNotificationClick = async (notification) => {
@@ -5,7 +5,7 @@ import {
accountNavItems,
contactNavItems
} from '../../constants/navigation'
import { useRoute, useRouter } from 'vue-router'
import { useRoute } from 'vue-router'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@shared-ui/components/ui/collapsible'
import { Badge } from '@shared-ui/components/ui/badge'
import {
@@ -32,66 +32,8 @@ import {
CircleDashed,
List,
AtSign,
Settings,
Clock,
Timer,
Inbox as InboxIcon,
CircleDot,
Tag,
SlidersHorizontal,
Eye,
Zap,
Workflow,
UserRound,
UsersRound,
Shield,
ScrollText,
Mail,
FileText,
KeyRound,
Webhook,
Link,
BarChart3,
CircleUser,
Contact,
Sparkles,
NotebookText,
Wrench,
Bot,
Lightbulb,
BookOpen
} from 'lucide-vue-next'
const navIconMap = {
Settings,
Clock,
Timer,
Inbox: InboxIcon,
CircleDot,
Tag,
SlidersHorizontal,
Eye,
Zap,
Workflow,
UserRound,
UsersRound,
Shield,
ScrollText,
Mail,
FileText,
KeyRound,
Webhook,
Link,
BarChart3,
CircleUser,
Contact,
Sparkles,
NotebookText,
Wrench,
Bot,
Lightbulb,
BookOpen
}
import {
DropdownMenu,
DropdownMenuContent,
@@ -118,7 +60,8 @@ import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@main/stores/user'
import { useConversationStore } from '@main/stores/conversation'
import { useIsMobile } from '@shared-ui/composables'
import { navIconMap } from '@main/constants/navIcons'
import { useInboxNavigation } from '@main/composables/useInboxNavigation'
defineProps({
userTeams: { type: Array, default: () => [] },
@@ -129,8 +72,6 @@ const userStore = useUserStore()
const conversationStore = useConversationStore()
const settingsStore = useAppSettingsStore()
const route = useRoute()
const router = useRouter()
const isMobile = useIsMobile()
const { t } = useI18n()
const emit = defineEmits(['createView', 'editView', 'deleteView', 'createConversation'])
@@ -142,11 +83,18 @@ const isInboxRoute = (path) => {
return path.startsWith('/inboxes')
}
const sidebarProviderRef = ref(null)
// An open mobile drawer treats taps on the view form as outside clicks and closes it.
const closeMobileSidebar = () => sidebarProviderRef.value?.setOpenMobile(false)
const openCreateViewDialog = () => {
closeMobileSidebar()
emit('createView')
}
const editView = (view) => {
closeMobileSidebar()
emit('editView', view)
}
@@ -163,61 +111,7 @@ const handleDeleteView = () => {
}
}
const keepConversationOpen = () =>
!isMobile.value &&
conversationStore.isConversationOpen &&
Boolean(conversationStore.conversation.data?.uuid)
const navigateToInbox = (type) => {
if (keepConversationOpen()) {
router.push({
name: 'inbox-conversation',
params: {
type,
uuid: conversationStore.conversation.data.uuid
}
})
} else {
router.push({
name: 'inbox',
params: { type }
})
}
}
const navigateToTeamInbox = (teamID) => {
if (keepConversationOpen()) {
router.push({
name: 'team-inbox-conversation',
params: {
teamID,
uuid: conversationStore.conversation.data.uuid
}
})
} else {
router.push({
name: 'team-inbox',
params: { teamID }
})
}
}
const navigateToViewInbox = (viewID) => {
if (keepConversationOpen()) {
router.push({
name: 'view-inbox-conversation',
params: {
viewID,
uuid: conversationStore.conversation.data.uuid
}
})
} else {
router.push({
name: 'view-inbox',
params: { viewID }
})
}
}
const { navigateToInbox, navigateToTeamInbox, navigateToViewInbox } = useInboxNavigation()
const filteredAdminNavItems = computed(() => filterNavItems(adminNavItems, userStore.can))
const filteredReportsNavItems = computed(() => filterNavItems(reportsNavItems, userStore.can))
@@ -264,6 +158,7 @@ onMounted(() => {
<template>
<SidebarProvider
ref="sidebarProviderRef"
style="--sidebar-width: 14rem"
:default-open="sidebarOpen"
v-on:update:open="sidebarOpen = $event"
@@ -95,7 +95,7 @@
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem @click="showShortcuts = true">
<DropdownMenuItem @click="shortcutsDialog.show">
<Keyboard size="18" class="mr-2" />
{{ t('navigation.keyboardShortcuts') }}
</DropdownMenuItem>
@@ -108,7 +108,6 @@
</DropdownMenuContent>
</DropdownMenu>
<KeyboardShortcutsDialog v-model:open="showShortcuts" />
</template>
<script setup>
@@ -130,19 +129,16 @@ import { ChevronsUpDown, CircleUserRound, Keyboard, LogOut } from 'lucide-vue-ne
import { useUserStore } from '@main/stores/user'
import { useIsMobile } from '@shared-ui/composables'
import { useRouter } from 'vue-router'
import KeyboardShortcutsDialog from '@main/components/KeyboardShortcutsDialog.vue'
import { useKeyboardShortcutsDialog } from '@main/composables/useKeyboardShortcutsDialog'
import { useLogout } from '@main/composables/useLogout'
import { useColorMode } from '@vueuse/core'
import { ref } from 'vue'
const isMobile = useIsMobile()
const mode = useColorMode()
const userStore = useUserStore()
const router = useRouter()
const { t } = useI18n()
const showShortcuts = ref(false)
const logout = () => {
window.location.href = '/logout'
}
const shortcutsDialog = useKeyboardShortcutsDialog()
const logout = useLogout()
</script>
@@ -0,0 +1,58 @@
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useConversationStore } from '@/stores/conversation'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents'
import { TAG_ACTION } from '@/constants/conversation'
import api from '@/api'
const bulkLoading = ref(false)
export function useBulkActions() {
const conversationStore = useConversationStore()
const emitter = useEmitter()
const { t } = useI18n()
const runBulkAction = async (actionFn) => {
const uuids = [...conversationStore.selectedUUIDs]
bulkLoading.value = true
const results = await Promise.allSettled(uuids.map((uuid) => actionFn(uuid)))
bulkLoading.value = false
const hasFailures = results.some((r) => r.status === 'rejected')
conversationStore.clearSelection()
conversationStore.fetchFirstPageConversations()
conversationStore.fetchSidebarCounts({ force: true })
if (hasFailures) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
title: t('globals.terms.error', 1),
description: t('conversation.bulkActions.failedToast')
})
} else {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('conversation.bulkActions.successToast')
})
}
}
const bulkAssign = (assigneeType, assigneeValue) => {
if (assigneeValue === 'none') {
return runBulkAction((uuid) => api.removeAssignee(uuid, assigneeType))
}
const assigneeId = parseInt(assigneeValue, 10)
return runBulkAction((uuid) =>
api.updateAssignee(uuid, assigneeType, { assignee_id: assigneeId })
)
}
const bulkAddTag = (tag) =>
runBulkAction((uuid) => conversationStore.updateConversationTags(uuid, TAG_ACTION.ADD, [tag]))
const bulkUpdateStatus = (status) =>
runBulkAction((uuid) => api.updateConversationStatus(uuid, { status }))
return { bulkLoading, runBulkAction, bulkAssign, bulkAddTag, bulkUpdateStatus }
}
@@ -26,7 +26,7 @@ export function useConversationFilters () {
'text': FIELD_OPERATORS.TEXT_AUTOMATION,
'number': FIELD_OPERATORS.NUMBER,
'checkbox': FIELD_OPERATORS.BOOLEAN,
'date': FIELD_OPERATORS.DATE,
'date': FIELD_OPERATORS.DATE_AUTOMATION,
'link': FIELD_OPERATORS.TEXT_AUTOMATION,
'list': FIELD_OPERATORS.SELECT,
}
@@ -0,0 +1,28 @@
import { useRouter } from 'vue-router'
import { useIsMobile } from '@shared-ui/composables'
import { useConversationStore } from '@main/stores/conversation'
export function useInboxNavigation() {
const router = useRouter()
const isMobile = useIsMobile()
const conversationStore = useConversationStore()
const openConversationUUID = () =>
!isMobile.value && conversationStore.isConversationOpen
? conversationStore.conversation.data?.uuid
: null
const navigate = (listName, params) => {
const uuid = openConversationUUID()
if (uuid) {
return router.push({ name: `${listName}-conversation`, params: { ...params, uuid } })
}
return router.push({ name: listName, params })
}
const navigateToInbox = (type) => navigate('inbox', { type })
const navigateToTeamInbox = (teamID) => navigate('team-inbox', { teamID })
const navigateToViewInbox = (viewID) => navigate('view-inbox', { viewID })
return { navigateToInbox, navigateToTeamInbox, navigateToViewInbox }
}
@@ -0,0 +1,10 @@
import { ref } from 'vue'
const open = ref(false)
export function useKeyboardShortcutsDialog() {
const show = () => {
open.value = true
}
return { open, show }
}
@@ -0,0 +1,10 @@
import { usePushNotifications } from '@main/composables/usePushNotifications'
const defaultBrowser = typeof window === 'undefined' ? {} : window
export const createLogout = (pushNotifications, browser = defaultBrowser) => async () => {
await Promise.resolve(pushNotifications.disable()).catch(() => {})
browser.location.href = '/logout'
}
export const useLogout = () => createLogout(usePushNotifications())
@@ -0,0 +1,23 @@
import { describe, expect, it, vi } from 'vitest'
import { createLogout } from './useLogout'
describe('createLogout', () => {
it('clears the push association before logging out', async () => {
const pushNotifications = { disable: vi.fn().mockResolvedValue() }
const browser = { location: { href: '' } }
await createLogout(pushNotifications, browser)()
expect(pushNotifications.disable).toHaveBeenCalled()
expect(browser.location.href).toBe('/logout')
})
it('logs out when clearing the push association fails', async () => {
const pushNotifications = { disable: vi.fn().mockRejectedValue(new Error('offline')) }
const browser = { location: { href: '' } }
await createLogout(pushNotifications, browser)()
expect(browser.location.href).toBe('/logout')
})
})
@@ -0,0 +1,82 @@
import { ref } from 'vue'
import api from '@main/api'
const defaultBrowser = typeof window === 'undefined' ? {} : window
export const urlBase64ToUint8Array = (value) => {
const padding = '='.repeat((4 - value.length % 4) % 4)
const raw = atob((value + padding).replace(/-/g, '+').replace(/_/g, '/'))
return Uint8Array.from(raw, character => character.charCodeAt(0))
}
export const createPushNotifications = (apiClient = api, browser = defaultBrowser) => {
const supported = ref(Boolean(browser.Notification && browser.navigator?.serviceWorker && browser.PushManager))
const permission = ref(browser.Notification?.permission || 'unsupported')
const enabled = ref(false)
let registration = null
const registerWorker = async () => {
registration ??= await browser.navigator.serviceWorker.register('/sw.js')
return registration
}
const existingWorker = async () => {
registration ??= await browser.navigator.serviceWorker.getRegistration('/sw.js')
return registration
}
const save = async (subscription) => {
const value = subscription.toJSON()
await apiClient.createPushSubscription({
endpoint: value.endpoint,
p256dh: value.keys.p256dh,
auth: value.keys.auth
})
}
const refresh = async (vapidPublicKey, savedEndpoints = []) => {
if (!supported.value || !vapidPublicKey) return
const worker = await registerWorker()
const subscription = await worker.pushManager.getSubscription()
enabled.value = Boolean(subscription) && savedEndpoints.includes(subscription.endpoint)
if (enabled.value) await save(subscription)
}
const enable = async (vapidPublicKey) => {
if (!supported.value || !vapidPublicKey) return false
permission.value = await browser.Notification.requestPermission()
if (permission.value !== 'granted') return false
await registerWorker()
const worker = await browser.navigator.serviceWorker.ready
const existing = await worker.pushManager.getSubscription()
const subscription = existing || await worker.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
})
await save(subscription)
enabled.value = true
return true
}
const disable = async () => {
if (!supported.value) return
const worker = await existingWorker()
const subscription = await worker?.pushManager.getSubscription()
if (!subscription) {
enabled.value = false
return
}
try {
await apiClient.deletePushSubscription(subscription.endpoint)
} finally {
await subscription.unsubscribe()
enabled.value = false
}
}
return { supported, permission, enabled, refresh, enable, disable }
}
const pushNotifications = createPushNotifications()
export const usePushNotifications = () => pushNotifications
@@ -0,0 +1,151 @@
import { describe, expect, it, vi } from 'vitest'
import { createPushNotifications } from './usePushNotifications'
const makeBrowser = ({ permission = 'default', subscription = null } = {}) => {
const pushManager = {
getSubscription: vi.fn().mockResolvedValue(subscription),
subscribe: vi.fn()
}
const registration = { pushManager }
const browser = {
Notification: {
permission,
requestPermission: vi.fn().mockResolvedValue(permission)
},
navigator: {
serviceWorker: {
register: vi.fn().mockResolvedValue(registration),
getRegistration: vi.fn().mockResolvedValue(registration),
ready: Promise.resolve(registration)
}
},
PushManager: function PushManager () {}
}
return { browser, registration, pushManager }
}
const subscription = {
endpoint: 'https://push.example/subscription',
toJSON: () => ({
endpoint: 'https://push.example/subscription',
keys: { p256dh: 'public-key', auth: 'auth-secret' }
}),
unsubscribe: vi.fn().mockResolvedValue(true)
}
describe('createPushNotifications', () => {
it('reports unsupported browsers without registering a worker', async () => {
const api = { createPushSubscription: vi.fn() }
const state = createPushNotifications(api, {})
await state.refresh('vapid-key')
expect(state.supported.value).toBe(false)
expect(api.createPushSubscription).not.toHaveBeenCalled()
})
it('refreshes an existing subscription without requesting permission', async () => {
const { browser } = makeBrowser({ permission: 'granted', subscription })
const api = { createPushSubscription: vi.fn().mockResolvedValue({}) }
const state = createPushNotifications(api, browser)
await state.refresh('vapid-key', [subscription.endpoint])
expect(browser.Notification.requestPermission).not.toHaveBeenCalled()
expect(api.createPushSubscription).toHaveBeenCalledWith({
endpoint: subscription.endpoint,
p256dh: 'public-key',
auth: 'auth-secret'
})
expect(state.enabled.value).toBe(true)
})
it('does not claim a browser subscription the server does not hold for this user', async () => {
const { browser } = makeBrowser({ permission: 'granted', subscription })
const api = { createPushSubscription: vi.fn().mockResolvedValue({}) }
const state = createPushNotifications(api, browser)
await state.refresh('vapid-key', ['https://push.example/someone-else'])
expect(api.createPushSubscription).not.toHaveBeenCalled()
expect(state.enabled.value).toBe(false)
})
it('subscribes only after permission is granted', async () => {
const { browser, pushManager } = makeBrowser({ permission: 'default' })
browser.Notification.requestPermission.mockResolvedValue('granted')
pushManager.subscribe.mockResolvedValue(subscription)
const api = { createPushSubscription: vi.fn().mockResolvedValue({}) }
const state = createPushNotifications(api, browser)
await state.enable('AQID')
expect(pushManager.subscribe).toHaveBeenCalledWith({
userVisibleOnly: true,
applicationServerKey: new Uint8Array([1, 2, 3])
})
expect(state.enabled.value).toBe(true)
})
it('does not subscribe when permission is denied', async () => {
const { browser, pushManager } = makeBrowser({ permission: 'default' })
browser.Notification.requestPermission.mockResolvedValue('denied')
const state = createPushNotifications({}, browser)
await state.enable('AQID')
expect(pushManager.subscribe).not.toHaveBeenCalled()
expect(state.permission.value).toBe('denied')
})
it('waits for the first worker to become active before subscribing', async () => {
const { browser, registration, pushManager } = makeBrowser({ permission: 'granted' })
let resolveReady
browser.navigator.serviceWorker.ready = new Promise(resolve => {
resolveReady = resolve
})
pushManager.subscribe.mockResolvedValue(subscription)
const api = { createPushSubscription: vi.fn().mockResolvedValue({}) }
const state = createPushNotifications(api, browser)
const enabling = state.enable('AQID')
await vi.waitFor(() => expect(browser.navigator.serviceWorker.register).toHaveBeenCalledOnce())
expect(pushManager.subscribe).not.toHaveBeenCalled()
expect(api.createPushSubscription).not.toHaveBeenCalled()
expect(state.enabled.value).toBe(false)
resolveReady(registration)
await expect(enabling).resolves.toBe(true)
expect(pushManager.subscribe).toHaveBeenCalledOnce()
expect(api.createPushSubscription).toHaveBeenCalledOnce()
expect(state.enabled.value).toBe(true)
})
it('unsubscribes the current browser and deletes its endpoint', async () => {
const { browser } = makeBrowser({ permission: 'granted', subscription })
const api = {
createPushSubscription: vi.fn().mockResolvedValue({}),
deletePushSubscription: vi.fn().mockResolvedValue({})
}
const state = createPushNotifications(api, browser)
await state.refresh('vapid-key', [subscription.endpoint])
await state.disable()
expect(subscription.unsubscribe).toHaveBeenCalled()
expect(api.deletePushSubscription).toHaveBeenCalledWith(subscription.endpoint)
expect(state.enabled.value).toBe(false)
})
it('unsubscribes the browser when clearing the server association fails', async () => {
subscription.unsubscribe.mockClear()
const { browser } = makeBrowser({ permission: 'granted', subscription })
const api = { deletePushSubscription: vi.fn().mockRejectedValue(new Error('offline')) }
const state = createPushNotifications(api, browser)
await expect(state.disable()).rejects.toThrow('offline')
expect(subscription.unsubscribe).toHaveBeenCalledOnce()
})
})
@@ -4,8 +4,27 @@ export const EMITTER_EVENTS = {
SHOW_TOAST: 'show-toast',
SHOW_SOONER: 'show-sooner',
NEW_MESSAGE: 'new-message',
SET_NESTED_COMMAND: 'set-nested-command',
CONVERSATION_SIDEBAR_TOGGLE: 'conversation-sidebar-toggle',
SCROLL_TO_MESSAGE: 'scroll-to-message',
COPILOT_INSERT_REPLY: 'copilot-insert-reply'
}
COPILOT_INSERT_REPLY: 'copilot-insert-reply',
OPEN_CREATE_CONVERSATION: 'open-create-conversation',
OPEN_VIEW_FORM: 'open-view-form',
REPLY_BOX_FOCUS: 'reply-box-focus',
REPLY_BOX_SET_TYPE: 'reply-box-set-type',
REPLY_BOX_TOGGLE_MINIMIZE: 'reply-box-toggle-minimize',
CONVERSATION_ACTION: 'conversation-action',
CONTACT_ACTION: 'contact-action'
}
export const CONVERSATION_ACTIONS = {
DOWNLOAD_TRANSCRIPT: 'download-transcript',
SUGGEST_TAGS: 'suggest-tags',
SUMMARIZE: 'summarize'
}
export const CONTACT_ACTIONS = {
TOGGLE_BLOCK: 'toggle-block',
EXPORT: 'export',
DELETE: 'delete',
ADD_NOTE: 'add-note'
}
@@ -75,6 +75,15 @@ export const FIELD_OPERATORS = {
OPERATOR.LESS_THAN,
OPERATOR.BETWEEN
],
// "between" is only implemented by the SQL filter builder, not the automation evaluator.
DATE_AUTOMATION: [
OPERATOR.EQUALS,
OPERATOR.NOT_EQUALS,
OPERATOR.SET,
OPERATOR.NOT_SET,
OPERATOR.GREATER_THAN,
OPERATOR.LESS_THAN
],
NUMBER: [OPERATOR.EQUALS, OPERATOR.NOT_EQUALS, OPERATOR.GREATER_THAN, OPERATOR.LESS_THAN],
MULTI_SELECT: [OPERATOR.CONTAINS, OPERATOR.NOT_CONTAINS, OPERATOR.SET, OPERATOR.NOT_SET]
}
@@ -0,0 +1,66 @@
import {
BarChart3,
Bell,
Bot,
BookOpen,
CircleDot,
CircleUser,
Clock,
Contact,
Eye,
FileText,
Inbox,
KeyRound,
Lightbulb,
Link,
Mail,
NotebookText,
ScrollText,
Settings,
Shield,
SlidersHorizontal,
Sparkles,
Tag,
Timer,
UserRound,
UsersRound,
WandSparkles,
Webhook,
Workflow,
Wrench,
Zap
} from 'lucide-vue-next'
// Icon names referenced by string in navigation.js.
export const navIconMap = {
BarChart3,
Bell,
Bot,
BookOpen,
CircleDot,
CircleUser,
Clock,
Contact,
Eye,
FileText,
Inbox,
KeyRound,
Lightbulb,
Link,
Mail,
NotebookText,
ScrollText,
Settings,
Shield,
SlidersHorizontal,
Sparkles,
Tag,
Timer,
UserRound,
UsersRound,
WandSparkles,
Webhook,
Workflow,
Wrench,
Zap
}
@@ -20,6 +20,7 @@ export const adminNavItems = [
{
titleKey: 'globals.terms.businessHour',
href: '/admin/business-hours',
createRouteName: 'new-business-hours',
permission: 'business_hours:manage',
isTitleKeyPlural: true,
icon: 'Clock'
@@ -27,6 +28,7 @@ export const adminNavItems = [
{
titleKey: 'globals.terms.slaPolicy',
href: '/admin/sla',
createRouteName: 'new-sla',
permission: 'sla:manage',
isTitleKeyPlural: true,
icon: 'Timer'
@@ -63,6 +65,13 @@ export const adminNavItems = [
isTitleKeyPlural: true,
icon: 'NotebookText'
},
{
titleKey: 'admin.ai.editorPrompts',
href: '/admin/ai/editor-prompts',
permission: 'ai:manage',
isTitleKeyPlural: true,
icon: 'WandSparkles'
},
{
titleKey: 'admin.ai.suggestions',
href: '/admin/ai/suggestions',
@@ -73,6 +82,7 @@ export const adminNavItems = [
{
titleKey: 'admin.ai.tools',
href: '/admin/ai/tools',
createRouteName: 'new-ai-tool',
permission: 'ai:manage',
isTitleKeyPlural: true,
icon: 'Wrench'
@@ -80,6 +90,7 @@ export const adminNavItems = [
{
titleKey: 'admin.ai.assistants',
href: '/admin/ai/assistants',
createRouteName: 'new-ai-assistant',
permission: 'ai:manage',
isTitleKeyPlural: true,
icon: 'Bot'
@@ -93,6 +104,7 @@ export const adminNavItems = [
{
titleKey: 'globals.terms.inbox',
href: '/admin/inboxes',
createRouteName: 'new-inbox',
permission: 'inboxes:manage',
isTitleKeyPlural: true,
icon: 'Inbox'
@@ -127,6 +139,7 @@ export const adminNavItems = [
{
titleKey: 'globals.terms.sharedView',
href: '/admin/conversations/shared-views',
createRouteName: 'new-shared-view',
permission: 'shared_views:manage',
isTitleKeyPlural: true,
icon: 'Eye'
@@ -139,6 +152,7 @@ export const adminNavItems = [
{
titleKey: 'globals.terms.macro',
href: '/admin/conversations/macros',
createRouteName: 'new-macro',
permission: 'macros:manage',
isTitleKeyPlural: true,
icon: 'Zap'
@@ -146,6 +160,7 @@ export const adminNavItems = [
{
titleKey: 'globals.terms.automation',
href: '/admin/automations',
createRouteName: 'new-automation',
permission: 'automations:manage',
isTitleKeyPlural: true,
icon: 'Workflow'
@@ -159,6 +174,7 @@ export const adminNavItems = [
{
titleKey: 'globals.terms.agent',
href: '/admin/teams/agents',
createRouteName: 'new-agent',
permission: 'users:manage',
isTitleKeyPlural: true,
icon: 'UserRound'
@@ -166,6 +182,7 @@ export const adminNavItems = [
{
titleKey: 'globals.terms.team',
href: '/admin/teams/teams',
createRouteName: 'new-team',
permission: 'teams:manage',
isTitleKeyPlural: true,
icon: 'UsersRound'
@@ -173,6 +190,7 @@ export const adminNavItems = [
{
titleKey: 'globals.terms.role',
href: '/admin/teams/roles',
createRouteName: 'new-role',
permission: 'roles:manage',
isTitleKeyPlural: true,
icon: 'Shield'
@@ -199,6 +217,7 @@ export const adminNavItems = [
{
titleKey: 'globals.terms.template',
href: '/admin/templates',
createRouteName: 'new-template',
permission: 'templates:manage',
isTitleKeyPlural: true,
icon: 'FileText'
@@ -211,6 +230,7 @@ export const adminNavItems = [
{
titleKey: 'globals.terms.sso',
href: '/admin/sso',
createRouteName: 'new-sso',
permission: 'oidc:manage',
icon: 'KeyRound'
}
@@ -223,6 +243,7 @@ export const adminNavItems = [
{
titleKey: 'globals.terms.webhook',
href: '/admin/webhooks',
createRouteName: 'new-webhook',
permission: 'webhooks:manage',
isTitleKeyPlural: true,
icon: 'Webhook'
@@ -230,6 +251,7 @@ export const adminNavItems = [
{
titleKey: 'globals.terms.contextLink',
href: '/admin/context-links',
createRouteName: 'new-context-link',
permission: 'context_links:manage',
isTitleKeyPlural: true,
icon: 'Link'
@@ -243,6 +265,12 @@ export const accountNavItems = [
titleKey: 'globals.terms.profile',
href: '/account/profile',
icon: 'CircleUser'
},
{
titleKey: 'globals.terms.notification',
href: '/account/notifications',
isTitleKeyPlural: true,
icon: 'Bell'
}
]
@@ -19,6 +19,7 @@ export const timeZones = {
"Istanbul, Europe (UTC+03:00)": "Europe/Istanbul",
"Dubai, Asia (UTC+04:00)": "Asia/Dubai",
"Kolkata, Asia (UTC+05:30)": "Asia/Kolkata",
"Kathmandu, Asia (UTC+05:45)": "Asia/Kathmandu",
"Bangkok, Asia (UTC+07:00)": "Asia/Bangkok",
"Singapore, Asia (UTC+08:00)": "Asia/Singapore",
"Shanghai, Asia (UTC+08:00)": "Asia/Shanghai",
@@ -33,4 +34,4 @@ export const timeZones = {
"Lagos, Africa (UTC+01:00)": "Africa/Lagos",
"Nairobi, Africa (UTC+03:00)": "Africa/Nairobi",
"Johannesburg, Africa (UTC+02:00)": "Africa/Johannesburg"
}
}
@@ -80,6 +80,7 @@
</div>
<PaginationBar
class="-mx-6 sm:-mx-8 -mb-6 sm:-mb-8 px-6 sm:px-8"
v-model:page="page"
v-model:per-page="perPage"
:total-pages="totalPages"
@@ -0,0 +1,98 @@
<template>
<form @submit="onSubmit" novalidate class="space-y-6 w-full">
<FormField v-slot="{ componentField }" name="title">
<FormItem>
<FormLabel>{{ t('globals.terms.title') }}</FormLabel>
<FormControl>
<Input
type="text"
:placeholder="t('admin.ai.editorPrompt.titlePlaceholder')"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="content">
<FormItem>
<FormLabel>{{ t('globals.terms.content') }}</FormLabel>
<FormControl>
<Textarea
class="min-h-[240px] max-h-[60vh]"
:placeholder="t('admin.ai.editorPrompt.contentPlaceholder')"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<DialogFooter class="mt-10">
<Button type="submit" :isLoading="formLoading">
{{ isEditing ? t('globals.messages.save') : t('globals.messages.create') }}
</Button>
</DialogFooter>
</form>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import { Button } from '@shared-ui/components/ui/button/index.js'
import { Input } from '@shared-ui/components/ui/input/index.js'
import { Textarea } from '@shared-ui/components/ui/textarea/index.js'
import { DialogFooter } from '@shared-ui/components/ui/dialog/index.js'
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@shared-ui/components/ui/form/index.js'
import { useI18n } from 'vue-i18n'
const props = defineProps({
initialValues: { type: Object, default: () => ({}) },
isEditing: { type: Boolean, default: false },
submitForm: { type: Function, required: true }
})
const { t } = useI18n()
const formLoading = ref(false)
const form = useForm({
validationSchema: toTypedSchema(
z.object({
title: z
.string({ required_error: t('globals.messages.required') })
.min(1, { message: t('globals.messages.required') })
.max(140),
content: z
.string({ required_error: t('globals.messages.required') })
.min(1, { message: t('globals.messages.required') })
})
),
initialValues: { title: '', content: '' }
})
watch(
() => props.initialValues,
(values) => {
form.setValues({ title: values.title || '', content: values.content || '' }, false)
form.setErrors({})
},
{ immediate: true, deep: true }
)
const onSubmit = form.handleSubmit(async (values) => {
try {
formLoading.value = true
await props.submitForm({ title: values.title, content: values.content })
} finally {
formLoading.value = false
}
})
</script>
@@ -31,7 +31,11 @@
<FormItem class="md:col-span-2">
<FormLabel>{{ t('globals.terms.url') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="https://api.example.com/orders/lookup" v-bind="componentField" />
<Input
type="text"
placeholder="https://api.example.com/orders/lookup"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -119,6 +123,44 @@
</FormItem>
</FormField>
<div class="space-y-5 rounded-lg border border-border p-4">
<div>
<h3 class="font-medium text-sm text-muted-foreground">{{ t('globals.terms.agentAccess') }}</h3>
</div>
<FormField v-slot="{ componentField, handleChange }" name="copilot_enabled">
<FormItem>
<SwitchField
:title="t('admin.ai.tool.availableInCopilot')"
:checked="componentField.modelValue"
@update:checked="handleChange"
/>
</FormItem>
</FormField>
<FormField v-slot="{ componentField, handleChange }" name="generate_reply_enabled">
<FormItem>
<SwitchField
:title="t('admin.ai.tool.availableInGenerateReply')"
:checked="componentField.modelValue"
@update:checked="handleChange"
/>
</FormItem>
</FormField>
<FormField v-slot="{ componentField, handleChange }" name="requires_agent_approval">
<FormItem>
<SwitchField
:title="t('admin.ai.tool.requireAgentApproval')"
:description="t('admin.ai.tool.requireAgentApprovalHint')"
:checked="componentField.modelValue"
:disabled="!hasAgentSurface"
@update:checked="handleChange"
/>
</FormItem>
</FormField>
</div>
<div class="flex justify-end mt-10">
<Button type="submit" :isLoading="formLoading">
{{ isEditing ? t('globals.messages.save') : t('globals.messages.create') }}
@@ -156,7 +198,7 @@
</template>
<script setup>
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
@@ -233,7 +275,10 @@ const form = useForm({
}),
parameters: z.string().optional(),
enabled: z.boolean().optional(),
requires_verification: z.boolean().default(true)
requires_verification: z.boolean(),
copilot_enabled: z.boolean(),
generate_reply_enabled: z.boolean(),
requires_agent_approval: z.boolean()
})
),
initialValues: {
@@ -244,10 +289,16 @@ const form = useForm({
headers: [],
parameters: '',
enabled: true,
requires_verification: true
requires_verification: true,
copilot_enabled: false,
generate_reply_enabled: false,
requires_agent_approval: true
}
})
const hasAgentSurface = computed(
() => form.values.copilot_enabled || form.values.generate_reply_enabled
)
const insertParametersExample = () => {
form.setFieldValue('parameters', parametersPlaceholder, false)
}
@@ -286,7 +337,10 @@ watch(
? JSON.stringify(values.parameters, null, 2)
: '',
enabled: values.enabled ?? true,
requires_verification: values.requires_verification ?? true
requires_verification: values.requires_verification ?? true,
copilot_enabled: values.copilot_enabled ?? false,
generate_reply_enabled: values.generate_reply_enabled ?? false,
requires_agent_approval: values.requires_agent_approval ?? true
},
false
)
@@ -319,6 +373,9 @@ const onSubmit = form.handleSubmit(async (values) => {
method: values.method || 'POST',
enabled: !!values.enabled,
requires_verification: values.requires_verification !== false,
copilot_enabled: !!values.copilot_enabled,
generate_reply_enabled: !!values.generate_reply_enabled,
requires_agent_approval: values.requires_agent_approval !== false,
auth: { headers },
parameters
})
@@ -0,0 +1,38 @@
import { h } from 'vue'
import { format } from 'date-fns'
import dropdown from './editorPromptDropdown.vue'
export const createEditorPromptColumns = (t, { onEdit } = {}) => [
{
accessorKey: 'title',
header: () => h('div', { class: 'text-center' }, t('globals.terms.title')),
cell: ({ row }) =>
h(
'div',
{ class: 'text-center' },
onEdit
? h(
'span',
{
class: 'text-foreground font-medium hover:underline cursor-pointer',
onClick: () => onEdit(row.original)
},
row.getValue('title')
)
: row.getValue('title')
)
},
{
accessorKey: 'updated_at',
enableGlobalFilter: false,
header: () => h('div', { class: 'text-center' }, t('globals.terms.updatedAt')),
cell: ({ row }) =>
h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
},
{
id: 'actions',
enableHiding: false,
enableSorting: false,
cell: ({ row }) => h('div', { class: 'relative' }, h(dropdown, { prompt: row.original }))
}
]
@@ -0,0 +1,94 @@
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" class="w-8 h-8 p-0">
<span class="sr-only">{{ t('globals.terms.openMenu') }}</span>
<MoreVertical class="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="editPrompt">
<Pencil class="mr-2 h-4 w-4" />
{{ t('globals.messages.edit') }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
@click="() => (alertOpen = true)"
class="text-destructive focus:text-destructive"
>
<Trash class="mr-2 h-4 w-4" />
{{ t('globals.messages.delete') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog :open="alertOpen" @update:open="alertOpen = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
<AlertDialogDescription>
{{ t('admin.ai.editorPrompt.deleteConfirmation') }}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{{ t('globals.messages.cancel') }}</AlertDialogCancel>
<AlertDialogAction variant="destructive" @click="deletePrompt">{{
t('globals.messages.delete')
}}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>
<script setup>
import { ref } from 'vue'
import { MoreVertical, Pencil, Trash } from 'lucide-vue-next'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@shared-ui/components/ui/dropdown-menu/index.js'
import { Button } from '@shared-ui/components/ui/button/index.js'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@shared-ui/components/ui/alert-dialog/index.js'
import { useEmitter } from '@/composables/useEmitter.js'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { handleHTTPError } from '@shared-ui/utils/http.js'
import { useI18n } from 'vue-i18n'
import api from '@/api'
const { t } = useI18n()
const alertOpen = ref(false)
const emitter = useEmitter()
const props = defineProps({
prompt: { type: Object, required: true }
})
const editPrompt = () => {
emitter.emit(EMITTER_EVENTS.EDIT_MODEL, { model: 'ai_editor_prompts', data: props.prompt })
}
const deletePrompt = async () => {
try {
await api.deleteAIPrompt(props.prompt.id)
alertOpen.value = false
emitter.emit(EMITTER_EVENTS.REFRESH_LIST, { model: 'ai_editor_prompts' })
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
</script>
@@ -9,7 +9,7 @@
<div class="space-y-3">
<div class="flex items-start justify-between gap-5">
<div class="flex gap-5 flex-1 min-w-0">
<div class="w-56 shrink-0">
<div :class="fieldClass">
<!-- Type -->
<Select
v-model="action.type"
@@ -35,14 +35,14 @@
<!-- Value -->
<div
v-if="action.type && conversationActions[action.type]?.type === 'tag'"
class="flex-1 min-w-0"
class="flex-1 min-w-0 max-w-md"
>
<SelectTagCombobox multiple v-model="action.value" />
</div>
<div
v-if="action.type && conversationActions[action.type]?.type === 'recipients'"
class="flex-1 min-w-0 space-y-3"
class="flex-1 min-w-0 max-w-md space-y-3"
>
<SelectTag
:modelValue="action.recipients || []"
@@ -66,7 +66,7 @@
</div>
<div
class="flex-1 min-w-0"
:class="fieldClass"
v-if="action.type && conversationActions[action.type]?.type === 'select'"
>
<SelectAgentCombobox
@@ -89,7 +89,7 @@
</div>
<div
class="flex gap-3 flex-1 min-w-0"
class="flex gap-3 flex-1 min-w-0 max-w-2xl"
v-if="action.type && conversationActions[action.type]?.type === 'webhook'"
>
<div class="flex-1 min-w-0">
@@ -114,7 +114,7 @@
</div>
<div
class="flex-1 min-w-0"
:class="fieldClass"
v-if="action.type && conversationActions[action.type]?.type === 'text'"
>
<Input
@@ -185,6 +185,8 @@ const props = defineProps({
}
})
const fieldClass = 'flex-1 min-w-0 max-w-xs'
const { actions } = toRefs(props)
const { t } = useI18n()
const emit = defineEmits(['update-actions', 'add-action', 'remove-action'])
@@ -1,15 +1,13 @@
<template>
<Tabs default-value="new_conversation" v-model="selectedTab">
<TabsList class="grid w-full grid-cols-3 mb-5">
<TabsTrigger value="new_conversation">
{{
$t('conversation.newConversation')
}}
<TabsList class="flex w-full justify-start overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden mb-5 sm:grid sm:grid-cols-3">
<TabsTrigger value="new_conversation" :class="AUTOMATION_TAB_CLASS">
{{ $t('conversation.newConversation') }}
</TabsTrigger>
<TabsTrigger value="conversation_update">
<TabsTrigger value="conversation_update" :class="AUTOMATION_TAB_CLASS">
{{ $t('admin.automation.conversationUpdate') }}
</TabsTrigger>
<TabsTrigger value="time_trigger">
<TabsTrigger value="time_trigger" :class="AUTOMATION_TAB_CLASS">
{{ $t('admin.automation.timeTriggers') }}
</TabsTrigger>
</TabsList>
@@ -32,6 +30,8 @@
</template>
<script setup>
const AUTOMATION_TAB_CLASS = 'shrink-0 sm:min-w-0'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@shared-ui/components/ui/tabs'
import { useI18n } from 'vue-i18n'
import RuleTab from './RuleTab.vue'
@@ -42,18 +42,18 @@
v-model="rule.field"
@update:modelValue="(value) => handleFieldChange(value, index)"
>
<SelectTrigger class="w-56">
<SelectTrigger :class="fieldClass">
<SelectValue :placeholder="t('placeholders.selectField')" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<!-- Conversation fields -->
<SelectLabel>{{ $t('globals.terms.conversation') }}</SelectLabel>
<SelectLabel v-if="!contactsOnly">{{ $t('globals.terms.conversation') }}</SelectLabel>
<SelectItem v-for="(field, key) in currentFilters" :key="key" :value="key">
{{ field.label }}
</SelectItem>
<!-- Contact custom attributes -->
<SelectLabel>{{ $t('globals.terms.contact') }}</SelectLabel>
<SelectLabel v-if="hasContactCustomAttributes">{{ $t('globals.terms.contact') }}</SelectLabel>
<SelectItem
v-for="(field, key) in contactCustomAttributes"
:key="key"
@@ -70,7 +70,7 @@
v-model="rule.operator"
@update:modelValue="(value) => handleOperatorChange(value, index)"
>
<SelectTrigger class="w-56">
<SelectTrigger :class="fieldClass">
<SelectValue :placeholder="t('placeholders.selectOperator')" />
</SelectTrigger>
<SelectContent>
@@ -87,7 +87,7 @@
</Select>
<!-- Value -->
<div v-if="showInput(index)" class="flex-1">
<div v-if="showInput(index)" :class="fieldClass">
<!-- Plain text input -->
<Input
type="text"
@@ -180,8 +180,7 @@
</Select>
</div>
<!-- Placeholder for spacing -->
<div v-else class="flex-1"></div>
<div class="flex-1"></div>
<!-- Remove condition -->
<CloseButton :onClose="() => removeCondition(index)" />
@@ -231,13 +230,14 @@ import {
import { Label } from '@shared-ui/components/ui/label'
import { Input } from '@shared-ui/components/ui/input'
import { useI18n } from 'vue-i18n'
import { useConversationFilters } from '../../../composables/useConversationFilters'
import { useConversationFilters } from '@/composables/useConversationFilters'
import SelectComboBox from '@main/components/combobox/SelectCombobox.vue'
import SelectAgentCombobox from '@main/components/combobox/SelectAgentCombobox.vue'
import SelectTeamCombobox from '@main/components/combobox/SelectTeamCombobox.vue'
import { operatorLabel } from '@/constants/filterConfig'
const props = defineProps({
contactsOnly: { type: Boolean, default: false },
ruleGroup: {
type: Object,
required: true
@@ -252,18 +252,22 @@ const props = defineProps({
}
})
const fieldClass = 'flex-1 min-w-0 max-w-xs'
const fieldTypeConstants = {
conversation: 'conversation',
contact_custom_attribute: 'contact_custom_attribute'
}
const { conversationFilters, newConversationFilters, contactCustomAttributes } =
useConversationFilters()
const hasContactCustomAttributes = computed(() => Object.keys(contactCustomAttributes.value).length > 0)
const { ruleGroup } = toRefs(props)
const emit = defineEmits(['update-group', 'add-condition', 'remove-condition'])
const { t } = useI18n()
// Computed property to get the correct filters based on type
const currentFilters = computed(() => {
if (props.contactsOnly) return {}
if (props.type === 'new_conversation') return newConversationFilters.value
if (props.type === 'conversation_update') return conversationFilters.value
// previous_* values only exist on conversation update events.
@@ -306,11 +310,8 @@ const handleFieldChange = (value, ruleIndex) => {
}
const handleOperatorChange = (value, ruleIndex) => {
if (['contains', 'not contains'].includes(value)) {
ruleGroup.value.rules[ruleIndex].value = []
} else {
ruleGroup.value.rules[ruleIndex].value = ''
}
// Every operator stores its value as a comma separated string, multi-value ones included.
ruleGroup.value.rules[ruleIndex].value = ''
ruleGroup.value.rules[ruleIndex].operator = value
emitUpdate()
}
@@ -36,7 +36,17 @@
<div class="space-y-4">
<div v-if="type === 'new_conversation'">
<draggable v-model="rules" class="space-y-6" item-key="id" @end="onDragEnd">
<draggable
v-model="rules"
class="space-y-6"
item-key="id"
:animation="200"
:force-fallback="true"
fallback-on-body
:fallback-tolerance="3"
ghost-class="drag-ghost"
@end="onDragEnd"
>
<template #item="{ element }">
<div class="draggable-item">
<RuleList :rule="element" @delete-rule="deleteRule" @toggle-rule="toggleRule" />
@@ -5,12 +5,35 @@
<div class="flex items-center justify-between p-6 border-b bg-card/50">
<div>
<SheetTitle>
{{ article ? t('helpCenter.editArticle') : t('helpCenter.newArticle') }}
{{
translationSource
? t('helpCenter.addTranslation')
: article
? t('helpCenter.editArticle')
: t('helpCenter.newArticle')
}}
</SheetTitle>
<SheetDescription v-if="article" class="mt-1">
{{ t('globals.terms.lastUpdated') }}:
{{ format(new Date(article.updated_at), 'PPpp') }}
<SheetDescription v-if="translationSource" class="mt-1">
{{ t('helpCenter.translationOf', { title: translationSource.title }) }}
</SheetDescription>
<div v-if="loadedArticle" class="mt-1 flex items-center gap-3">
<SheetDescription>
{{ t('globals.terms.lastUpdated') }}:
{{ formatDate(loadedArticle.updated_at) }}
</SheetDescription>
<Button
v-if="articleUrl"
as="a"
variant="link"
:href="articleUrl"
target="_blank"
rel="noopener"
class="h-auto gap-1 p-0 font-normal"
>
{{ t('globals.messages.viewArticle') }}
<ExternalLink class="size-3.5" aria-hidden="true" />
</Button>
</div>
</div>
</div>
@@ -50,6 +73,8 @@
enableInlineImages
linkedModel="help_articles"
:toolbarTarget="toolbarSlot"
:accent-color="helpCenterColor"
:accent-color-dark="helpCenterColorDark"
class="min-h-[400px] border-0 px-0 shadow-none focus-visible:ring-0"
/>
</div>
@@ -89,6 +114,89 @@
</div>
</div>
<div v-if="loadedArticle && helpCenterLocales.length > 1" class="space-y-3">
<h3 class="font-medium text-sm text-muted-foreground">
{{ t('globals.terms.translation', 2) }}
</h3>
<p class="text-sm text-muted-foreground">
{{ t('helpCenter.translationsHint') }}
</p>
<div class="space-y-2">
<div v-for="locale in translatedLocales" :key="locale" class="flex gap-2">
<Button
type="button"
variant="outline"
class="h-auto min-w-0 flex-1 justify-start gap-3 px-3 py-2.5 text-left font-normal disabled:opacity-100"
:class="{ 'font-medium': locale === loadedArticle.locale }"
:disabled="locale === loadedArticle.locale"
:aria-current="locale === loadedArticle.locale ? 'page' : undefined"
@click="selectTranslationLocale(locale)"
>
<span class="min-w-0 flex-1 truncate">{{ languageName(locale) }}</span>
<span class="text-xs uppercase text-muted-foreground">{{ locale }}</span>
<Check
v-if="locale === loadedArticle.locale"
class="size-4 shrink-0"
aria-hidden="true"
/>
<ChevronRight
v-else
class="size-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
</Button>
<Tooltip v-if="translatedLocales.length > 1">
<TooltipTrigger as-child>
<Button
type="button"
variant="outline"
size="icon"
class="h-auto shrink-0 text-muted-foreground hover:text-destructive"
:aria-label="t('helpCenter.unlinkTranslation')"
@click="askUnlink(locale)"
>
<Unlink aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent>
{{ t('helpCenter.unlinkTranslation') }}
</TooltipContent>
</Tooltip>
</div>
</div>
<Select
v-if="missingLocales.length"
:model-value="''"
@update:model-value="selectTranslationLocale"
>
<SelectTrigger class="w-full">
<div class="flex items-center gap-2">
<Plus class="size-4" aria-hidden="true" />
<SelectValue :placeholder="t('helpCenter.addTranslation')" />
</div>
</SelectTrigger>
<SelectContent>
<SelectItem v-for="locale in missingLocales" :key="locale" :value="locale">
{{ languageName(locale) }} ({{ locale }})
</SelectItem>
</SelectContent>
</Select>
<Button
v-if="missingLocales.length"
type="button"
variant="ghost"
size="sm"
class="w-full"
@click="openLinkDialog"
>
<Link2 class="mr-2 size-4" aria-hidden="true" />
{{ t('helpCenter.linkExistingArticle') }}
</Button>
</div>
<div class="space-y-3">
<h3 class="font-medium text-sm text-muted-foreground">
{{ t('globals.terms.status') }}
@@ -122,6 +230,16 @@
<p v-if="localeCollections.length === 0" class="text-sm text-muted-foreground">
{{ t('helpCenter.noCollectionsInLanguage') }}
</p>
<Button
v-if="localeCollections.length === 0 && translationSource"
type="button"
variant="outline"
size="sm"
@click="emit('create-translation-collection', { locale: form.values.locale })"
>
<Plus class="mr-2 size-4" aria-hidden="true" />
{{ t('helpCenter.newCollection') }}
</Button>
<FormField v-slot="{ componentField }" name="collection_id">
<FormItem>
@@ -183,7 +301,7 @@
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="loc in helpCenterLocales" :key="loc" :value="loc">
<SelectItem v-for="loc in selectableLocales" :key="loc" :value="loc">
{{ loc }}
</SelectItem>
</SelectContent>
@@ -256,26 +374,35 @@
</FormField>
</div>
<div v-if="article" class="space-y-3 text-sm border-t pt-4">
<div v-if="loadedArticle" class="space-y-3 text-sm border-t pt-4">
<div v-if="loadedArticle?.created_by_name" class="flex justify-between py-1">
<span class="text-muted-foreground">{{ t('helpCenter.createdBy') }}</span>
<span>{{ loadedArticle.created_by_name }}</span>
</div>
<div v-if="loadedArticle?.helpful_count !== undefined" class="flex justify-between py-1">
<div
v-if="loadedArticle?.helpful_count !== undefined"
class="flex justify-between py-1"
>
<span class="text-muted-foreground">{{ t('globals.terms.feedback') }}</span>
<span>👍 {{ loadedArticle.helpful_count }} · 👎 {{ loadedArticle.not_helpful_count }}</span>
<span
>👍 {{ loadedArticle.helpful_count }} · 👎
{{ loadedArticle.not_helpful_count }}</span
>
</div>
<div class="flex justify-between py-1">
<span class="text-muted-foreground">{{ t('globals.terms.createdAt') }}</span>
<span>{{ format(new Date(article.created_at), 'PPpp') }}</span>
<span>{{ formatDate(loadedArticle.created_at) }}</span>
</div>
<div class="flex justify-between py-1">
<span class="text-muted-foreground">{{ t('globals.terms.updatedAt') }}</span>
<span>{{ format(new Date(article.updated_at), 'PPpp') }}</span>
<span>{{ formatDate(loadedArticle.updated_at) }}</span>
</div>
<div v-if="article.view_count !== undefined" class="flex justify-between py-1">
<div
v-if="loadedArticle.view_count !== undefined"
class="flex justify-between py-1"
>
<span class="text-muted-foreground">{{ t('globals.terms.view', 2) }}</span>
<span>{{ article.view_count.toLocaleString() }}</span>
<span>{{ loadedArticle.view_count.toLocaleString() }}</span>
</div>
</div>
</div>
@@ -284,6 +411,58 @@
</div>
</SheetContent>
</Sheet>
<Dialog :open="showLinkDialog" @update:open="showLinkDialog = $event">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{{ t('helpCenter.linkExistingArticle') }}</DialogTitle>
<DialogDescription>{{ t('helpCenter.linkExistingArticleHint') }}</DialogDescription>
</DialogHeader>
<div class="space-y-4">
<Spinner v-if="isLoadingLinkable" />
<p v-else-if="!linkableArticleItems.length" class="text-sm text-muted-foreground">
{{ t('helpCenter.noLinkableArticles') }}
</p>
<SelectComboBox
v-else
v-model="linkArticleID"
:items="linkableArticleItems"
:placeholder="t('placeholders.selectArticle')"
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" @click="showLinkDialog = false">
{{ t('globals.messages.cancel') }}
</Button>
<Button type="button" :disabled="!linkArticleID || isLinking" @click="confirmLink">
{{ t('globals.terms.link') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog :open="showUnlinkDialog" @update:open="showUnlinkDialog = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
<AlertDialogDescription>
{{
t('helpCenter.unlinkTranslationConfirmation', {
language: languageName(unlinkLocale)
})
}}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{{ t('globals.messages.cancel') }}</AlertDialogCancel>
<AlertDialogAction variant="destructive" @click="confirmUnlink">{{
t('globals.messages.unlink')
}}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>
<script setup>
@@ -303,6 +482,24 @@ import {
} from '@shared-ui/components/ui/select'
import SelectAgentCombobox from '@/components/combobox/SelectAgentCombobox.vue'
import { Sheet, SheetContent, SheetTitle, SheetDescription } from '@shared-ui/components/ui/sheet'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@shared-ui/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@shared-ui/components/ui/alert-dialog'
import {
FormControl,
FormDescription,
@@ -316,14 +513,18 @@ import { useI18n } from 'vue-i18n'
import Editor from '@main/components/editor/ArticleEditor.vue'
import { highlightCodeBlocks } from '@main/components/editor/highlightCodeBlocks'
import { Spinner } from '@shared-ui/components/ui/spinner'
import SelectComboBox from '@main/components/combobox/SelectCombobox.vue'
import api from '@/api'
import { handleHTTPError } from '@shared-ui/utils/http.js'
import { useEmitter } from '@/composables/useEmitter.js'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useUserStore } from '@/stores/user'
import { format } from 'date-fns'
import { useAppSettingsStore } from '@/stores/appSettings'
import { format, isValid } from 'date-fns'
import { Check, ChevronRight, ExternalLink, Link2, Plus, Unlink } from 'lucide-vue-next'
import { Tooltip, TooltipContent, TooltipTrigger } from '@shared-ui/components/ui/tooltip'
const { t } = useI18n()
const { t, locale: uiLocale } = useI18n()
const props = defineProps({
isOpen: {
@@ -346,6 +547,18 @@ const props = defineProps({
type: String,
default: ''
},
helpCenterSlug: {
type: String,
default: ''
},
helpCenterColor: {
type: String,
default: ''
},
helpCenterColorDark: {
type: String,
default: ''
},
helpCenterLocales: {
type: Array,
default: () => ['en']
@@ -354,6 +567,14 @@ const props = defineProps({
type: String,
default: ''
},
translationSource: {
type: Object,
default: null
},
createdCollection: {
type: Object,
default: null
},
submitForm: {
type: Function,
required: true
@@ -364,12 +585,25 @@ const props = defineProps({
}
})
defineEmits(['update:open', 'cancel'])
const emit = defineEmits([
'update:open',
'cancel',
'create-translation',
'create-translation-collection',
'open-translation'
])
const emitter = useEmitter()
const userStore = useUserStore()
const appSettingsStore = useAppSettingsStore()
const isLoadingArticle = ref(false)
const showUnlinkDialog = ref(false)
const unlinkLocale = ref('')
const showLinkDialog = ref(false)
const isLinking = ref(false)
const isLoadingLinkable = ref(false)
const linkArticleID = ref('')
const linkableArticles = ref([])
const availableCollections = ref([])
const editorText = ref('')
const toolbarSlot = ref(null)
@@ -382,6 +616,59 @@ const submitLabel = computed(() =>
props.article ? t('globals.messages.update') : t('globals.messages.create')
)
const linkedLocales = computed(
() =>
new Set(
(loadedArticle.value?.translations || props.translationSource?.translations || []).map(
({ locale }) => locale
)
)
)
const selectableLocales = computed(() => {
if (props.translationSource) {
return props.helpCenterLocales.filter((locale) => !linkedLocales.value.has(locale))
}
if (props.article) {
return props.helpCenterLocales.filter(
(locale) => locale === loadedArticle.value?.locale || !linkedLocales.value.has(locale)
)
}
return props.helpCenterLocales
})
const translationByLocale = computed(
() =>
new Map(
(loadedArticle.value?.translations || []).map((translation) => [
translation.locale,
translation
])
)
)
const translatedLocales = computed(() =>
props.helpCenterLocales.filter((locale) => translationByLocale.value.has(locale))
)
const missingLocales = computed(() =>
props.helpCenterLocales.filter((locale) => !translationByLocale.value.has(locale))
)
const languageDisplayNames = computed(
() => new Intl.DisplayNames([uiLocale.value], { type: 'language' })
)
const languageName = (locale) => languageDisplayNames.value.of(locale) || locale
const articleUrl = computed(() => {
if (!props.helpCenterSlug || loadedArticle.value?.status !== 'published') return ''
const root = (appSettingsStore.settings?.['app.root_url'] || window.location.origin).replace(
/\/$/,
''
)
return `${root}/hc/${props.helpCenterSlug}/${loadedArticle.value.locale}/articles/${loadedArticle.value.slug}`
})
const formatDate = (value) => {
const date = new Date(value)
return isValid(date) ? format(date, 'PPpp') : '-'
}
const toFormValues = () => {
const article = loadedArticle.value || props.article
return {
@@ -392,7 +679,12 @@ const toFormValues = () => {
sort_order: article?.sort_order || 0,
ai_enabled: article?.ai_enabled || false,
author_id: String(article?.author_id || (props.article ? '' : userStore.userID) || ''),
locale: article?.locale || props.defaultLocale || props.helpCenterLocales?.[0] || 'en',
locale:
article?.locale ||
props.translationSource?.locale ||
props.defaultLocale ||
selectableLocales.value[0] ||
'en',
excerpt: article?.excerpt || '',
meta_title: article?.meta_title || '',
meta_description: article?.meta_description || '',
@@ -442,7 +734,7 @@ const metaDescriptionPlaceholder = computed(() => (form.values.excerpt || '').tr
// can't fill the form after another article was opened.
let loadSeq = 0
watch(
() => [props.article, props.collectionId, props.isOpen],
() => [props.article, props.collectionId, props.translationSource, props.isOpen],
async () => {
if (!props.isOpen) return
const seq = ++loadSeq
@@ -460,6 +752,15 @@ watch(
{ immediate: true }
)
watch(
() => props.createdCollection,
async (collection) => {
if (!collection || !props.isOpen) return
await fetchAvailableCollections()
form.setFieldValue('collection_id', String(collection.id), false)
}
)
const fetchAvailableCollections = async () => {
try {
const { data } = await api.getCollections(props.helpCenterId)
@@ -486,11 +787,96 @@ const fetchArticle = async () => {
}
}
const linkableArticleItems = computed(() =>
linkableArticles.value
.filter((article) => !translationByLocale.value.has(article.locale))
.map((article) => ({
label: `${article.title} - ${languageName(article.locale)}`,
value: String(article.id)
}))
)
const openLinkDialog = async () => {
linkArticleID.value = ''
linkableArticles.value = []
showLinkDialog.value = true
isLoadingLinkable.value = true
try {
const { data } = await api.getLinkableArticles(props.helpCenterId, loadedArticle.value.locale)
linkableArticles.value = data.data || []
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
isLoadingLinkable.value = false
}
}
const confirmLink = async () => {
isLinking.value = true
try {
await api.linkArticleTranslation(Number(linkArticleID.value), {
translation_of_id: loadedArticle.value.id
})
loadedArticle.value = await fetchArticle()
showLinkDialog.value = false
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('helpCenter.translationLinked')
})
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
isLinking.value = false
}
}
const askUnlink = (locale) => {
unlinkLocale.value = locale
showUnlinkDialog.value = true
}
const confirmUnlink = async () => {
showUnlinkDialog.value = false
try {
await api.unlinkArticleTranslation(translationByLocale.value.get(unlinkLocale.value).id)
loadedArticle.value = await fetchArticle()
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('helpCenter.translationUnlinked')
})
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
const selectTranslationLocale = (locale) => {
if (form.meta.value.dirty) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('helpCenter.saveBeforeSwitchingTranslation')
})
return
}
const translation = translationByLocale.value.get(locale)
if (translation) {
emit('open-translation', translation)
return
}
emit('create-translation', { article: loadedArticle.value, locale })
}
const onSubmit = form.handleSubmit(async (values) => {
props.submitForm({
...values,
content: highlightCodeBlocks(values.content),
author_id: values.author_id ? Number(values.author_id) : null
author_id: values.author_id ? Number(values.author_id) : null,
translation_of_id: props.translationSource?.id || undefined
})
})
</script>
@@ -9,7 +9,7 @@
</SheetTitle>
<SheetDescription v-if="collection" class="mt-1">
{{ t('globals.terms.lastUpdated') }}:
{{ format(new Date(collection.updated_at), 'PPpp') }}
{{ formatDate(collection.updated_at) }}
</SheetDescription>
</div>
</div>
@@ -156,11 +156,11 @@
<div v-if="collection" class="space-y-3 text-sm border-t pt-4">
<div class="flex justify-between py-1">
<span class="text-muted-foreground">{{ t('globals.terms.createdAt') }}</span>
<span>{{ format(new Date(collection.created_at), 'PPpp') }}</span>
<span>{{ formatDate(collection.created_at) }}</span>
</div>
<div class="flex justify-between py-1">
<span class="text-muted-foreground">{{ t('globals.terms.updatedAt') }}</span>
<span>{{ format(new Date(collection.updated_at), 'PPpp') }}</span>
<span>{{ formatDate(collection.updated_at) }}</span>
</div>
<div v-if="collection.articles" class="flex justify-between py-1">
<span class="text-muted-foreground">{{ t('globals.terms.article', 2) }}</span>
@@ -205,9 +205,13 @@ import api from '@/api'
import { handleHTTPError } from '@shared-ui/utils/http.js'
import { useEmitter } from '@/composables/useEmitter.js'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { format } from 'date-fns'
import { format, isValid } from 'date-fns'
const { t } = useI18n()
const formatDate = (value) => {
const date = new Date(value)
return isValid(date) ? format(date, 'PPpp') : '-'
}
const props = defineProps({
isOpen: {
@@ -40,6 +40,31 @@
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="livechat_inbox_id">
<FormItem>
<FormLabel>{{ t('globals.terms.liveChatInbox') }}</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue :placeholder="t('placeholders.selectInbox')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{{ t('globals.terms.none') }}</SelectItem>
<SelectItem
v-for="option in inboxStore.livechatOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>{{ t('helpCenter.livechatInboxHint') }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="page_title">
<FormItem>
<FormLabel>{{ t('helpCenter.pageTitle') }}</FormLabel>
@@ -151,26 +176,6 @@
:open="openSection === 'brand'"
@toggle="toggleSection('brand')"
>
<FormField v-slot="{ componentField }" name="theme.logo_url">
<FormItem>
<FormLabel>{{ t('globals.terms.logoUrl') }}</FormLabel>
<FormControl>
<Input type="text" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="theme.color">
<FormItem>
<FormLabel>{{ t('globals.terms.primaryColor') }}</FormLabel>
<FormControl>
<Input type="color" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="theme.favicon">
<FormItem>
<FormLabel>{{ t('admin.general.faviconURL') }}</FormLabel>
@@ -184,6 +189,71 @@
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="theme.color_scheme">
<FormItem>
<FormLabel>{{ t('globals.terms.theme') }}</FormLabel>
<FormControl>
<RadioGroup v-bind="componentField" class="flex gap-4">
<div class="flex items-center space-x-2">
<RadioGroupItem id="hc-scheme-system" value="system" />
<Label for="hc-scheme-system">{{ t('globals.terms.matchSystem') }}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroupItem id="hc-scheme-light" value="light" />
<Label for="hc-scheme-light">{{ t('globals.terms.light') }}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroupItem id="hc-scheme-dark" value="dark" />
<Label for="hc-scheme-dark">{{ t('globals.terms.dark') }}</Label>
</div>
</RadioGroup>
</FormControl>
<FormDescription>{{ t('globals.messages.matchSystemHint') }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<Tabs :model-value="editingScheme" @update:model-value="editingScheme = $event">
<TabsList class="w-full h-10">
<TabsTrigger value="light" class="flex-1 gap-2 h-8">
<Sun class="size-4" />
{{ t('globals.terms.light') }}
</TabsTrigger>
<TabsTrigger value="dark" class="flex-1 gap-2 h-8">
<Moon class="size-4" />
{{ t('globals.terms.dark') }}
</TabsTrigger>
</TabsList>
</Tabs>
<FormField
v-slot="{ componentField }"
:name="editingScheme === 'dark' ? 'theme.logo_url_dark' : 'theme.logo_url'"
keep-value
>
<FormItem>
<FormLabel>{{ t('globals.terms.logoUrl') }}</FormLabel>
<FormControl>
<Input type="text" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField
v-slot="{ componentField }"
:name="editingScheme === 'dark' ? 'theme.color_dark' : 'theme.color'"
keep-value
>
<FormItem>
<FormLabel>{{ t('globals.terms.primaryColor') }}</FormLabel>
<FormControl>
<Input type="color" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</CollapsibleSection>
<CollapsibleSection
@@ -512,21 +582,58 @@
:open="openSection === 'footer'"
@toggle="toggleSection('footer')"
>
<Tabs :model-value="editingScheme" @update:model-value="editingScheme = $event">
<TabsList class="w-full h-10">
<TabsTrigger value="light" class="flex-1 gap-2 h-8">
<Sun class="size-4" />
{{ t('globals.terms.light') }}
</TabsTrigger>
<TabsTrigger value="dark" class="flex-1 gap-2 h-8">
<Moon class="size-4" />
{{ t('globals.terms.dark') }}
</TabsTrigger>
</TabsList>
</Tabs>
<div class="flex gap-4">
<FormField v-slot="{ componentField }" name="theme.footer.background_color">
<FormField
v-slot="{ componentField }"
:name="
editingScheme === 'dark'
? 'theme.footer.background_color_dark'
: 'theme.footer.background_color'
"
keep-value
>
<FormItem class="flex-1">
<FormLabel>{{ t('globals.messages.backgroundColor') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="#ffffff" v-bind="componentField" />
<Input
type="text"
:placeholder="editingScheme === 'dark' ? '#16181b' : '#ffffff'"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="theme.footer.text_color">
<FormField
v-slot="{ componentField }"
:name="
editingScheme === 'dark'
? 'theme.footer.text_color_dark'
: 'theme.footer.text_color'
"
keep-value
>
<FormItem class="flex-1">
<FormLabel>{{ t('globals.terms.textColor') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="#909aa5" v-bind="componentField" />
<Input
type="text"
:placeholder="editingScheme === 'dark' ? '#82878e' : '#909aa5'"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -631,7 +738,7 @@
</template>
<script setup>
import { watch, computed, ref, onMounted, nextTick } from 'vue'
import { watch, computed, ref, inject, onMounted, nextTick } from 'vue'
import { useForm, useFieldArray } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { Button } from '@shared-ui/components/ui/button'
@@ -654,11 +761,13 @@ import {
FormMessage,
FormDescription
} from '@shared-ui/components/ui/form/index.js'
import { X } from 'lucide-vue-next'
import { X, Sun, Moon } from 'lucide-vue-next'
import { RadioGroup, RadioGroupItem } from '@shared-ui/components/ui/radio-group'
import { Tabs, TabsList, TabsTrigger } from '@shared-ui/components/ui/tabs'
import CollapsibleSection from './CollapsibleSection.vue'
import LinkListField from './LinkListField.vue'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
import { useInboxStore } from '@/stores/inbox'
import { createHelpCenterFormSchema } from './helpCenterFormSchema.js'
import api from '@/api'
import { useI18n } from 'vue-i18n'
@@ -669,6 +778,8 @@ const FIELD_LOCATION = [
['theme.tagline', 'appearance', 'header'],
['theme.announcement', 'appearance', 'announcement'],
['theme.logo_url', 'appearance', 'brand'],
['theme.logo_url_dark', 'appearance', 'brand'],
['theme.color_scheme', 'appearance', 'brand'],
['theme.color', 'appearance', 'brand'],
['theme.favicon', 'appearance', 'brand'],
['theme.nav_links', 'general', ''],
@@ -681,6 +792,17 @@ const FIELD_LOCATION = [
['custom_js', 'appearance', 'code']
]
const SCHEME_FIELDS = {
'theme.logo_url': 'light',
'theme.color': 'light',
'theme.logo_url_dark': 'dark',
'theme.color_dark': 'dark',
'theme.footer.background_color': 'light',
'theme.footer.text_color': 'light',
'theme.footer.background_color_dark': 'dark',
'theme.footer.text_color_dark': 'dark'
}
const props = defineProps({
helpCenter: {
type: Object,
@@ -699,6 +821,7 @@ const props = defineProps({
const emit = defineEmits(['cancel', 'change'])
const { t } = useI18n()
const inboxStore = useInboxStore()
const submitLabel = computed(() =>
props.helpCenter ? t('globals.messages.update') : t('globals.messages.create')
@@ -719,6 +842,7 @@ const toFormValues = (hc) => ({
slug: hc?.slug || '',
template: hc?.template === 'docs' ? 'docs' : 'classic',
custom_domain: hc?.custom_domain || '',
livechat_inbox_id: hc?.livechat_inbox_id ? String(hc.livechat_inbox_id) : 'none',
page_title: hc?.page_title || '',
meta_description: hc?.meta_description || '',
custom_css: hc?.custom_css || '',
@@ -727,8 +851,11 @@ const toFormValues = (hc) => ({
allowed_locales:
Array.isArray(hc?.allowed_locales) && hc.allowed_locales.length ? hc.allowed_locales : ['en'],
theme: {
color_scheme: hc?.theme?.color_scheme || 'light',
color: hc?.theme?.color || '#1f93ff',
color_dark: hc?.theme?.color_dark || hc?.theme?.color || '#1f93ff',
logo_url: hc?.theme?.logo_url || '',
logo_url_dark: hc?.theme?.logo_url_dark || '',
nav_links: Array.isArray(hc?.theme?.nav_links) ? hc.theme.nav_links : [],
favicon: hc?.theme?.favicon || '',
tagline: hc?.theme?.tagline || '',
@@ -764,6 +891,8 @@ const toFormValues = (hc) => ({
footer: {
background_color: hc?.theme?.footer?.background_color || '',
text_color: hc?.theme?.footer?.text_color || '',
background_color_dark: hc?.theme?.footer?.background_color_dark || '',
text_color_dark: hc?.theme?.footer?.text_color_dark || '',
tagline: hc?.theme?.footer?.tagline || ''
},
footer_links: Array.isArray(hc?.theme?.footer_links) ? hc.theme.footer_links : [],
@@ -797,6 +926,15 @@ const {
const isClassic = computed(() => form.values.template === 'classic')
const editingScheme = inject('helpCenterPreviewScheme', ref('light'))
watch(
() => form.values.theme?.color_scheme,
(scheme) => {
editingScheme.value = scheme === 'dark' ? 'dark' : 'light'
},
{ immediate: true }
)
const supportedLocales = ref([])
onMounted(async () => {
@@ -839,6 +977,10 @@ const toPayload = (values) => {
const payload = JSON.parse(JSON.stringify(values))
const allowed = cleanLocales(payload.allowed_locales)
payload.allowed_locales = allowed.length ? allowed : ['en']
payload.livechat_inbox_id =
payload.livechat_inbox_id && payload.livechat_inbox_id !== 'none'
? Number(payload.livechat_inbox_id)
: null
if (payload.theme?.layout) {
payload.theme.layout.columns = Number(payload.theme.layout.columns) || 2
}
@@ -857,6 +999,7 @@ const onSubmit = form.handleSubmit(
if (match) {
activeTab.value = match[1]
openSection.value = match[2]
if (SCHEME_FIELDS[firstKey]) editingScheme.value = SCHEME_FIELDS[firstKey]
} else if (firstKey) {
activeTab.value = 'general'
}
@@ -2,12 +2,14 @@ import * as z from 'zod'
// An untouched editor serializes to '<p></p>', which passes a length check.
const hasArticleContent = (html) =>
html.replace(/<[^>]*>/g, '').replace(/&nbsp;/g, ' ').trim().length > 0 ||
/<(img|table|iframe|video|hr|blockquote|details)\b/i.test(html)
html
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/g, ' ')
.trim().length > 0 || /<(img|table|iframe|video|hr|blockquote|details)\b/i.test(html)
export const createArticleFormSchema = (t) =>
z.object({
title: z.string().min(1, t('globals.messages.required')),
title: z.string().trim().min(1, t('globals.messages.required')),
content: z.string().refine(hasArticleContent, t('globals.messages.required')),
status: z.enum(['draft', 'published']).default('draft'),
collection_id: z.coerce.number().min(1, t('globals.messages.required')),
@@ -5,117 +5,125 @@ const mockT = (key, params) => `${key} ${JSON.stringify(params || {})}`
const schema = createArticleFormSchema(mockT)
const validForm = {
title: 'How to reset your password',
content: '<p>Click the reset link.</p>',
collection_id: 1
title: 'How to reset your password',
content: '<p>Click the reset link.</p>',
collection_id: 1
}
describe('Help Center Article Form Schema', () => {
test('valid minimal form', () => {
expect(() => schema.parse(validForm)).not.toThrow()
})
test('valid minimal form', () => {
expect(() => schema.parse(validForm)).not.toThrow()
})
test('valid complete form', () => {
expect(() => schema.parse({
...validForm,
status: 'published',
sort_order: 3,
ai_enabled: true,
locale: 'de',
author_id: '7',
excerpt: 'Reset flow',
meta_title: 'Reset',
meta_description: 'How to reset',
meta_image_url: 'https://cdn.example.com/a.png'
})).not.toThrow()
})
test('valid complete form', () => {
expect(() =>
schema.parse({
...validForm,
status: 'published',
sort_order: 3,
ai_enabled: true,
locale: 'de',
author_id: '7',
excerpt: 'Reset flow',
meta_title: 'Reset',
meta_description: 'How to reset',
meta_image_url: 'https://cdn.example.com/a.png'
})
).not.toThrow()
})
test('title missing', () => {
const { title, ...form } = validForm
expect(() => schema.parse(form)).toThrow()
})
test('title missing', () => {
const { title, ...form } = validForm
expect(() => schema.parse(form)).toThrow()
})
test('title empty string', () => {
expect(() => schema.parse({ ...validForm, title: '' })).toThrow()
})
test('title empty string', () => {
expect(() => schema.parse({ ...validForm, title: '' })).toThrow()
})
test('title at minimum length', () => {
expect(() => schema.parse({ ...validForm, title: 'a' })).not.toThrow()
})
test('title with only whitespace is rejected', () => {
expect(() => schema.parse({ ...validForm, title: ' ' })).toThrow()
})
test('content missing', () => {
const { content, ...form } = validForm
expect(() => schema.parse(form)).toThrow()
})
test('title at minimum length', () => {
expect(() => schema.parse({ ...validForm, title: 'a' })).not.toThrow()
})
test('content empty string', () => {
expect(() => schema.parse({ ...validForm, content: '' })).toThrow()
})
test('content missing', () => {
const { content, ...form } = validForm
expect(() => schema.parse(form)).toThrow()
})
test('untouched editor markup rejected', () => {
expect(() => schema.parse({ ...validForm, content: '<p></p>' })).toThrow()
})
test('content empty string', () => {
expect(() => schema.parse({ ...validForm, content: '' })).toThrow()
})
test('whitespace only content rejected', () => {
expect(() => schema.parse({ ...validForm, content: '<p> </p>' })).toThrow()
expect(() => schema.parse({ ...validForm, content: '<p>&nbsp;</p>' })).toThrow()
})
test('untouched editor markup rejected', () => {
expect(() => schema.parse({ ...validForm, content: '<p></p>' })).toThrow()
})
test('embed only content accepted', () => {
expect(() => schema.parse({ ...validForm, content: '<p><img src="a.png"></p>' })).not.toThrow()
expect(() => schema.parse({ ...validForm, content: '<table><tr><td></td></tr></table>' })).not.toThrow()
})
test('whitespace only content rejected', () => {
expect(() => schema.parse({ ...validForm, content: '<p> </p>' })).toThrow()
expect(() => schema.parse({ ...validForm, content: '<p>&nbsp;</p>' })).toThrow()
})
test('collection_id missing', () => {
const { collection_id, ...form } = validForm
expect(() => schema.parse(form)).toThrow()
})
test('embed only content accepted', () => {
expect(() => schema.parse({ ...validForm, content: '<p><img src="a.png"></p>' })).not.toThrow()
expect(() =>
schema.parse({ ...validForm, content: '<table><tr><td></td></tr></table>' })
).not.toThrow()
})
test('collection_id below minimum', () => {
expect(() => schema.parse({ ...validForm, collection_id: 0 })).toThrow()
})
test('collection_id missing', () => {
const { collection_id, ...form } = validForm
expect(() => schema.parse(form)).toThrow()
})
test('collection_id coerced from string', () => {
expect(schema.parse({ ...validForm, collection_id: '2' }).collection_id).toBe(2)
})
test('collection_id below minimum', () => {
expect(() => schema.parse({ ...validForm, collection_id: 0 })).toThrow()
})
test('collection_id rejects non numeric string', () => {
expect(() => schema.parse({ ...validForm, collection_id: 'abc' })).toThrow()
})
test('collection_id coerced from string', () => {
expect(schema.parse({ ...validForm, collection_id: '2' }).collection_id).toBe(2)
})
test('status invalid value', () => {
expect(() => schema.parse({ ...validForm, status: 'archived' })).toThrow()
})
test('collection_id rejects non numeric string', () => {
expect(() => schema.parse({ ...validForm, collection_id: 'abc' })).toThrow()
})
test('status defaults to draft', () => {
expect(schema.parse(validForm).status).toBe('draft')
})
test('status invalid value', () => {
expect(() => schema.parse({ ...validForm, status: 'archived' })).toThrow()
})
test('sort_order defaults to 0', () => {
expect(schema.parse(validForm).sort_order).toBe(0)
})
test('status defaults to draft', () => {
expect(schema.parse(validForm).status).toBe('draft')
})
test('ai_enabled defaults to false', () => {
expect(schema.parse(validForm).ai_enabled).toBe(false)
})
test('sort_order defaults to 0', () => {
expect(schema.parse(validForm).sort_order).toBe(0)
})
test('locale defaults to en', () => {
expect(schema.parse(validForm).locale).toBe('en')
})
test('ai_enabled defaults to false', () => {
expect(schema.parse(validForm).ai_enabled).toBe(false)
})
test('meta fields default to empty strings', () => {
const parsed = schema.parse(validForm)
expect(parsed.excerpt).toBe('')
expect(parsed.meta_title).toBe('')
expect(parsed.meta_description).toBe('')
expect(parsed.meta_image_url).toBe('')
})
test('locale defaults to en', () => {
expect(schema.parse(validForm).locale).toBe('en')
})
test('author_id optional', () => {
expect(() => schema.parse(validForm)).not.toThrow()
})
test('meta fields default to empty strings', () => {
const parsed = schema.parse(validForm)
expect(parsed.excerpt).toBe('')
expect(parsed.meta_title).toBe('')
expect(parsed.meta_description).toBe('')
expect(parsed.meta_image_url).toBe('')
})
test('empty object', () => {
expect(() => schema.parse({})).toThrow()
})
test('author_id optional', () => {
expect(() => schema.parse(validForm)).not.toThrow()
})
test('empty object', () => {
expect(() => schema.parse({})).toThrow()
})
})
@@ -2,7 +2,7 @@ import * as z from 'zod'
export const createCollectionFormSchema = (t) =>
z.object({
name: z.string().min(1, t('globals.messages.required')),
name: z.string().trim().min(1, t('globals.messages.required')),
description: z.string().optional(),
icon: z.string().optional(),
locale: z.string().default('en'),
@@ -5,71 +5,77 @@ const mockT = (key, params) => `${key} ${JSON.stringify(params || {})}`
const schema = createCollectionFormSchema(mockT)
const validForm = {
name: 'Getting started'
name: 'Getting started'
}
describe('Help Center Collection Form Schema', () => {
test('valid minimal form', () => {
expect(() => schema.parse(validForm)).not.toThrow()
})
test('valid minimal form', () => {
expect(() => schema.parse(validForm)).not.toThrow()
})
test('valid complete form', () => {
expect(() => schema.parse({
name: 'Getting started',
description: 'Basics',
icon: 'book',
locale: 'fr',
parent_id: 4,
is_published: false,
sort_order: 2
})).not.toThrow()
})
test('valid complete form', () => {
expect(() =>
schema.parse({
name: 'Getting started',
description: 'Basics',
icon: 'book',
locale: 'fr',
parent_id: 4,
is_published: false,
sort_order: 2
})
).not.toThrow()
})
test('name missing', () => {
expect(() => schema.parse({})).toThrow()
})
test('name missing', () => {
expect(() => schema.parse({})).toThrow()
})
test('name empty string', () => {
expect(() => schema.parse({ name: '' })).toThrow()
})
test('name empty string', () => {
expect(() => schema.parse({ name: '' })).toThrow()
})
test('name at minimum length', () => {
expect(() => schema.parse({ name: 'a' })).not.toThrow()
})
test('name with only whitespace is rejected', () => {
expect(() => schema.parse({ name: ' ' })).toThrow()
})
test('name not a string', () => {
expect(() => schema.parse({ name: 1 })).toThrow()
})
test('name at minimum length', () => {
expect(() => schema.parse({ name: 'a' })).not.toThrow()
})
test('locale defaults to en', () => {
expect(schema.parse(validForm).locale).toBe('en')
})
test('name not a string', () => {
expect(() => schema.parse({ name: 1 })).toThrow()
})
test('is_published defaults to true', () => {
expect(schema.parse(validForm).is_published).toBe(true)
})
test('locale defaults to en', () => {
expect(schema.parse(validForm).locale).toBe('en')
})
test('sort_order defaults to 0', () => {
expect(schema.parse(validForm).sort_order).toBe(0)
})
test('is_published defaults to true', () => {
expect(schema.parse(validForm).is_published).toBe(true)
})
test('sort_order must be a number', () => {
expect(() => schema.parse({ ...validForm, sort_order: '2' })).toThrow()
})
test('sort_order defaults to 0', () => {
expect(schema.parse(validForm).sort_order).toBe(0)
})
test('parent_id accepts null', () => {
expect(() => schema.parse({ ...validForm, parent_id: null })).not.toThrow()
})
test('sort_order must be a number', () => {
expect(() => schema.parse({ ...validForm, sort_order: '2' })).toThrow()
})
test('parent_id coerced from string', () => {
expect(schema.parse({ ...validForm, parent_id: '4' }).parent_id).toBe(4)
})
test('parent_id accepts null', () => {
expect(() => schema.parse({ ...validForm, parent_id: null })).not.toThrow()
})
test('parent_id rejects non numeric string', () => {
expect(() => schema.parse({ ...validForm, parent_id: 'abc' })).toThrow()
})
test('parent_id coerced from string', () => {
expect(schema.parse({ ...validForm, parent_id: '4' }).parent_id).toBe(4)
})
test('description and icon optional', () => {
expect(() => schema.parse(validForm)).not.toThrow()
})
test('parent_id rejects non numeric string', () => {
expect(() => schema.parse({ ...validForm, parent_id: 'abc' })).toThrow()
})
test('description and icon optional', () => {
expect(() => schema.parse(validForm)).not.toThrow()
})
})
@@ -64,15 +64,18 @@ const createBaseSchema = (t) => {
return z.object({
name: z
.string()
.trim()
.min(1, t('globals.messages.required'))
.refine(withinLength(MAX_NAME), t('globals.messages.maxLength', { max: MAX_NAME })),
slug: z
.string()
.trim()
.min(1, t('globals.messages.required'))
.max(200, t('helpCenter.invalidSlug'))
.regex(/^[a-z0-9_-]+$/, t('helpCenter.invalidSlug')),
page_title: z
.string()
.trim()
.min(1, t('globals.messages.required'))
.refine(
withinLength(MAX_PAGE_TITLE),
@@ -93,6 +96,7 @@ const createBaseSchema = (t) => {
t('helpCenter.invalidCustomDomain')
)
.optional(),
livechat_inbox_id: z.string().optional(),
custom_css: z.string().optional(),
custom_js: z.string().optional(),
default_locale: z
@@ -111,8 +115,11 @@ const createBaseSchema = (t) => {
.default(['en']),
theme: z
.object({
color_scheme: z.enum(['system', 'light', 'dark']).optional(),
color: z.string().optional(),
color_dark: optionalHexColor,
logo_url: optionalURL,
logo_url_dark: optionalURL,
nav_links: linkArray,
favicon: optionalURL,
tagline: z.string().optional(),
@@ -155,6 +162,8 @@ const createBaseSchema = (t) => {
.object({
background_color: optionalHexColor,
text_color: optionalHexColor,
background_color_dark: optionalHexColor,
text_color_dark: optionalHexColor,
tagline: z.string().optional()
})
.optional(),
@@ -6,254 +6,336 @@ const schema = createHelpCenterFormSchema(mockT)
const basicsSchema = createHelpCenterBasicsSchema(mockT)
const validForm = {
name: 'Docs',
slug: 'docs',
page_title: 'Help center'
name: 'Docs',
slug: 'docs',
page_title: 'Help center'
}
describe('Help Center Form Schema', () => {
test('valid minimal form', () => {
expect(() => schema.parse(validForm)).not.toThrow()
})
test('valid minimal form', () => {
expect(() => schema.parse(validForm)).not.toThrow()
})
test('valid complete form', () => {
expect(() => schema.parse({
...validForm,
template: 'docs',
meta_description: 'Everything about our product',
custom_domain: 'https://help.example.com',
custom_css: 'body { color: red }',
custom_js: 'console.log(1)',
default_locale: 'fr',
allowed_locales: ['en', 'fr'],
theme: {
color: 'blue',
logo_url: '/uploads/logo.png',
favicon: 'https://cdn.example.com/f.ico',
tagline: 'We are here',
nav_links: [{ label: 'Home', url: '/' }],
header: {
heading: 'Hi',
background_type: 'gradient',
background_color: '#fff',
gradient_from: '#000000',
gradient_to: '#ffffffcc',
background_image: '/uploads/bg.png',
text_color: '#123'
},
layout: { collections: 'grid', columns: 3, show_popular_articles: true, popular_articles_label: 'Popular' },
cards: { hide_description: false, hide_count: true, show_authors: true, show_icon_tile: true, icon_position: 'top' },
announcement: { text: 'New release', link_label: 'Read', link_url: '/blog' },
footer: { background_color: '#111111', text_color: '#eeeeee', tagline: 'Bye' },
footer_links: [{ label: 'Terms', url: 'https://example.com/terms' }],
social_links: [{ platform: 'x', url: 'https://x.com/example' }],
article: { hide_toc: false, hide_related: true, show_author: true }
}
})).not.toThrow()
})
test('name missing', () => {
const { name, ...form } = validForm
expect(() => schema.parse(form)).toThrow()
})
test('name empty string', () => {
expect(() => schema.parse({ ...validForm, name: '' })).toThrow()
})
test('name too long', () => {
expect(() => schema.parse({ ...validForm, name: 'a'.repeat(201) })).toThrow()
})
test('name at maximum length', () => {
expect(() => schema.parse({ ...validForm, name: 'a'.repeat(200) })).not.toThrow()
})
test('name length counts emoji as one character', () => {
expect(() => schema.parse({ ...validForm, name: '😀'.repeat(200) })).not.toThrow()
})
test('slug missing', () => {
const { slug, ...form } = validForm
expect(() => schema.parse(form)).toThrow()
})
test('slug empty string', () => {
expect(() => schema.parse({ ...validForm, slug: '' })).toThrow()
})
test('slug with uppercase rejected', () => {
expect(() => schema.parse({ ...validForm, slug: 'Docs' })).toThrow()
})
test('slug with space rejected', () => {
expect(() => schema.parse({ ...validForm, slug: 'my docs' })).toThrow()
})
test('slug with dash and underscore accepted', () => {
expect(() => schema.parse({ ...validForm, slug: 'my-docs_2' })).not.toThrow()
})
test('slug too long', () => {
expect(() => schema.parse({ ...validForm, slug: 'a'.repeat(201) })).toThrow()
})
test('page_title missing', () => {
const { page_title, ...form } = validForm
expect(() => schema.parse(form)).toThrow()
})
test('page_title empty string', () => {
expect(() => schema.parse({ ...validForm, page_title: '' })).toThrow()
})
test('page_title too long', () => {
expect(() => schema.parse({ ...validForm, page_title: 'a'.repeat(201) })).toThrow()
})
test('template defaults to classic', () => {
expect(schema.parse(validForm).template).toBe('classic')
})
test('template invalid value', () => {
expect(() => schema.parse({ ...validForm, template: 'minimal' })).toThrow()
})
test('meta_description too long', () => {
expect(() => schema.parse({ ...validForm, meta_description: 'a'.repeat(501) })).toThrow()
})
test('meta_description at maximum length', () => {
expect(() => schema.parse({ ...validForm, meta_description: 'a'.repeat(500) })).not.toThrow()
})
test('custom_domain without protocol rejected', () => {
expect(() => schema.parse({ ...validForm, custom_domain: 'help.example.com' })).toThrow()
})
test('custom_domain with a path rejected', () => {
expect(() => schema.parse({ ...validForm, custom_domain: 'https://help.example.com/docs' })).toThrow()
})
test('custom_domain empty string accepted', () => {
expect(() => schema.parse({ ...validForm, custom_domain: '' })).not.toThrow()
})
test('default_locale defaults to en', () => {
expect(schema.parse(validForm).default_locale).toBe('en')
})
test('allowed_locales defaults to en', () => {
expect(schema.parse(validForm).allowed_locales).toEqual(['en'])
})
test('default_locale must be in allowed_locales', () => {
expect(() => schema.parse({ ...validForm, default_locale: 'fr', allowed_locales: ['en'] })).toThrow()
})
test('invalid locale format', () => {
expect(() => schema.parse({ ...validForm, default_locale: 'english', allowed_locales: ['english'] })).toThrow()
})
test('region locale accepted', () => {
expect(() => schema.parse({ ...validForm, default_locale: 'pt-BR', allowed_locales: ['pt-BR'] })).not.toThrow()
})
test('allowed_locales empty array', () => {
expect(() => schema.parse({ ...validForm, allowed_locales: [] })).toThrow()
})
test('theme logo_url protocol relative rejected', () => {
expect(() => schema.parse({ ...validForm, theme: { logo_url: '//cdn.example.com/l.png' } })).toThrow()
})
test('theme logo_url root relative accepted', () => {
expect(() => schema.parse({ ...validForm, theme: { logo_url: '/uploads/l.png' } })).not.toThrow()
})
test('theme logo_url empty string accepted', () => {
expect(() => schema.parse({ ...validForm, theme: { logo_url: '' } })).not.toThrow()
})
test('theme header background_color invalid hex', () => {
expect(() => schema.parse({ ...validForm, theme: { header: { background_color: 'red' } } })).toThrow()
})
test('theme header background_color accepts 3, 4, 6 and 8 digit hex', () => {
for (const background_color of ['#fff', '#fff0', '#ffffff', '#ffffff00']) {
expect(() => schema.parse({ ...validForm, theme: { header: { background_color } } })).not.toThrow()
test('valid complete form', () => {
expect(() =>
schema.parse({
...validForm,
template: 'docs',
meta_description: 'Everything about our product',
custom_domain: 'https://help.example.com',
custom_css: 'body { color: red }',
custom_js: 'console.log(1)',
default_locale: 'fr',
allowed_locales: ['en', 'fr'],
theme: {
color: 'blue',
logo_url: '/uploads/logo.png',
favicon: 'https://cdn.example.com/f.ico',
tagline: 'We are here',
nav_links: [{ label: 'Home', url: '/' }],
header: {
heading: 'Hi',
background_type: 'gradient',
background_color: '#fff',
gradient_from: '#000000',
gradient_to: '#ffffffcc',
background_image: '/uploads/bg.png',
text_color: '#123'
},
layout: {
collections: 'grid',
columns: 3,
show_popular_articles: true,
popular_articles_label: 'Popular'
},
cards: {
hide_description: false,
hide_count: true,
show_authors: true,
show_icon_tile: true,
icon_position: 'top'
},
announcement: { text: 'New release', link_label: 'Read', link_url: '/blog' },
footer: { background_color: '#111111', text_color: '#eeeeee', tagline: 'Bye' },
footer_links: [{ label: 'Terms', url: 'https://example.com/terms' }],
social_links: [{ platform: 'x', url: 'https://x.com/example' }],
article: { hide_toc: false, hide_related: true, show_author: true }
}
})
})
).not.toThrow()
})
test('nav link requires label and url', () => {
expect(() => schema.parse({ ...validForm, theme: { nav_links: [{ label: '', url: '/' }] } })).toThrow()
expect(() => schema.parse({ ...validForm, theme: { nav_links: [{ label: 'Home', url: '' }] } })).toThrow()
expect(() => schema.parse({ ...validForm, theme: { nav_links: [{ label: 'Home', url: 'home' }] } })).toThrow()
})
test('theme color scheme and dark branding', () => {
const withTheme = (theme) => ({ ...validForm, theme })
for (const color_scheme of ['system', 'light', 'dark']) {
expect(() => schema.parse(withTheme({ color_scheme }))).not.toThrow()
}
expect(() => schema.parse(withTheme({ color_scheme: 'auto' }))).toThrow()
expect(() => schema.parse(withTheme({ color_dark: '#46ce8e' }))).not.toThrow()
expect(() => schema.parse(withTheme({ color_dark: 'green' }))).toThrow()
expect(() => schema.parse(withTheme({ logo_url_dark: 'not a url' }))).toThrow()
})
test('social link requires platform and url', () => {
expect(() => schema.parse({ ...validForm, theme: { social_links: [{ platform: '', url: 'https://x.com/a' }] } })).toThrow()
expect(() => schema.parse({ ...validForm, theme: { social_links: [{ platform: 'x', url: 'x.com/a' }] } })).toThrow()
})
test('dark footer colors', () => {
const withFooter = (footer) => ({ ...validForm, theme: { footer } })
expect(() =>
schema.parse(withFooter({ background_color_dark: '#16181b', text_color_dark: '#e5e5e5' }))
).not.toThrow()
expect(() => schema.parse(withFooter({ background_color_dark: '' }))).not.toThrow()
expect(() => schema.parse(withFooter({ background_color_dark: 'black' }))).toThrow()
expect(() => schema.parse(withFooter({ text_color_dark: '#zzz' }))).toThrow()
})
test('card icon_position invalid value', () => {
expect(() => schema.parse({ ...validForm, theme: { cards: { icon_position: 'bottom' } } })).toThrow()
})
test('name missing', () => {
const { name, ...form } = validForm
expect(() => schema.parse(form)).toThrow()
})
test('announcement link without text rejected', () => {
expect(() => schema.parse({
...validForm,
theme: { announcement: { text: '', link_label: 'Read', link_url: '/blog' } }
})).toThrow()
})
test('name empty string', () => {
expect(() => schema.parse({ ...validForm, name: '' })).toThrow()
})
test('announcement text with only whitespace rejected when a link is set', () => {
expect(() => schema.parse({
...validForm,
theme: { announcement: { text: ' ', link_url: '/blog' } }
})).toThrow()
})
test('required text fields reject whitespace-only values', () => {
expect(() => schema.parse({ ...validForm, name: ' ' })).toThrow()
expect(() => schema.parse({ ...validForm, slug: ' ' })).toThrow()
expect(() => schema.parse({ ...validForm, page_title: ' ' })).toThrow()
})
test('announcement with no text and no link accepted', () => {
expect(() => schema.parse({ ...validForm, theme: { announcement: {} } })).not.toThrow()
})
test('name too long', () => {
expect(() => schema.parse({ ...validForm, name: 'a'.repeat(201) })).toThrow()
})
test('announcement with text and link accepted', () => {
expect(() => schema.parse({
...validForm,
theme: { announcement: { text: 'Hi', link_label: 'Read', link_url: '/blog' } }
})).not.toThrow()
})
test('name at maximum length', () => {
expect(() => schema.parse({ ...validForm, name: 'a'.repeat(200) })).not.toThrow()
})
test('theme optional', () => {
expect(() => schema.parse(validForm)).not.toThrow()
})
test('name length counts emoji as one character', () => {
expect(() => schema.parse({ ...validForm, name: '😀'.repeat(200) })).not.toThrow()
})
test('empty object', () => {
expect(() => schema.parse({})).toThrow()
})
test('slug missing', () => {
const { slug, ...form } = validForm
expect(() => schema.parse(form)).toThrow()
})
test('slug empty string', () => {
expect(() => schema.parse({ ...validForm, slug: '' })).toThrow()
})
test('slug with uppercase rejected', () => {
expect(() => schema.parse({ ...validForm, slug: 'Docs' })).toThrow()
})
test('slug with space rejected', () => {
expect(() => schema.parse({ ...validForm, slug: 'my docs' })).toThrow()
})
test('slug with dash and underscore accepted', () => {
expect(() => schema.parse({ ...validForm, slug: 'my-docs_2' })).not.toThrow()
})
test('slug too long', () => {
expect(() => schema.parse({ ...validForm, slug: 'a'.repeat(201) })).toThrow()
})
test('page_title missing', () => {
const { page_title, ...form } = validForm
expect(() => schema.parse(form)).toThrow()
})
test('page_title empty string', () => {
expect(() => schema.parse({ ...validForm, page_title: '' })).toThrow()
})
test('page_title too long', () => {
expect(() => schema.parse({ ...validForm, page_title: 'a'.repeat(201) })).toThrow()
})
test('template defaults to classic', () => {
expect(schema.parse(validForm).template).toBe('classic')
})
test('template invalid value', () => {
expect(() => schema.parse({ ...validForm, template: 'minimal' })).toThrow()
})
test('meta_description too long', () => {
expect(() => schema.parse({ ...validForm, meta_description: 'a'.repeat(501) })).toThrow()
})
test('meta_description at maximum length', () => {
expect(() => schema.parse({ ...validForm, meta_description: 'a'.repeat(500) })).not.toThrow()
})
test('custom_domain without protocol rejected', () => {
expect(() => schema.parse({ ...validForm, custom_domain: 'help.example.com' })).toThrow()
})
test('custom_domain with a path rejected', () => {
expect(() =>
schema.parse({ ...validForm, custom_domain: 'https://help.example.com/docs' })
).toThrow()
})
test('custom_domain empty string accepted', () => {
expect(() => schema.parse({ ...validForm, custom_domain: '' })).not.toThrow()
})
test('default_locale defaults to en', () => {
expect(schema.parse(validForm).default_locale).toBe('en')
})
test('allowed_locales defaults to en', () => {
expect(schema.parse(validForm).allowed_locales).toEqual(['en'])
})
test('default_locale must be in allowed_locales', () => {
expect(() =>
schema.parse({ ...validForm, default_locale: 'fr', allowed_locales: ['en'] })
).toThrow()
})
test('invalid locale format', () => {
expect(() =>
schema.parse({ ...validForm, default_locale: 'english', allowed_locales: ['english'] })
).toThrow()
})
test('region locale accepted', () => {
expect(() =>
schema.parse({ ...validForm, default_locale: 'pt-BR', allowed_locales: ['pt-BR'] })
).not.toThrow()
})
test('allowed_locales empty array', () => {
expect(() => schema.parse({ ...validForm, allowed_locales: [] })).toThrow()
})
test('theme logo_url protocol relative rejected', () => {
expect(() =>
schema.parse({ ...validForm, theme: { logo_url: '//cdn.example.com/l.png' } })
).toThrow()
})
test('theme logo_url root relative accepted', () => {
expect(() =>
schema.parse({ ...validForm, theme: { logo_url: '/uploads/l.png' } })
).not.toThrow()
})
test('theme logo_url empty string accepted', () => {
expect(() => schema.parse({ ...validForm, theme: { logo_url: '' } })).not.toThrow()
})
test('theme header background_color invalid hex', () => {
expect(() =>
schema.parse({ ...validForm, theme: { header: { background_color: 'red' } } })
).toThrow()
})
test('theme header background_color accepts 3, 4, 6 and 8 digit hex', () => {
for (const background_color of ['#fff', '#fff0', '#ffffff', '#ffffff00']) {
expect(() =>
schema.parse({ ...validForm, theme: { header: { background_color } } })
).not.toThrow()
}
})
test('nav link requires label and url', () => {
expect(() =>
schema.parse({ ...validForm, theme: { nav_links: [{ label: '', url: '/' }] } })
).toThrow()
expect(() =>
schema.parse({ ...validForm, theme: { nav_links: [{ label: 'Home', url: '' }] } })
).toThrow()
expect(() =>
schema.parse({ ...validForm, theme: { nav_links: [{ label: 'Home', url: 'home' }] } })
).toThrow()
})
test('social link requires platform and url', () => {
expect(() =>
schema.parse({
...validForm,
theme: { social_links: [{ platform: '', url: 'https://x.com/a' }] }
})
).toThrow()
expect(() =>
schema.parse({ ...validForm, theme: { social_links: [{ platform: 'x', url: 'x.com/a' }] } })
).toThrow()
})
test('card icon_position invalid value', () => {
expect(() =>
schema.parse({ ...validForm, theme: { cards: { icon_position: 'bottom' } } })
).toThrow()
})
test('announcement link without text rejected', () => {
expect(() =>
schema.parse({
...validForm,
theme: { announcement: { text: '', link_label: 'Read', link_url: '/blog' } }
})
).toThrow()
})
test('announcement text with only whitespace rejected when a link is set', () => {
expect(() =>
schema.parse({
...validForm,
theme: { announcement: { text: ' ', link_url: '/blog' } }
})
).toThrow()
})
test('announcement with no text and no link accepted', () => {
expect(() => schema.parse({ ...validForm, theme: { announcement: {} } })).not.toThrow()
})
test('announcement with text and link accepted', () => {
expect(() =>
schema.parse({
...validForm,
theme: { announcement: { text: 'Hi', link_label: 'Read', link_url: '/blog' } }
})
).not.toThrow()
})
test('theme optional', () => {
expect(() => schema.parse(validForm)).not.toThrow()
})
test('empty object', () => {
expect(() => schema.parse({})).toThrow()
})
})
describe('Help Center Basics Schema', () => {
test('valid form', () => {
expect(() => basicsSchema.parse(validForm)).not.toThrow()
})
test('valid form', () => {
expect(() => basicsSchema.parse(validForm)).not.toThrow()
})
test('drops the fields outside basics', () => {
const parsed = basicsSchema.parse({ ...validForm, custom_css: 'body{}' })
expect(parsed).toEqual({ name: 'Docs', slug: 'docs', page_title: 'Help center', template: 'classic' })
test('drops the fields outside basics', () => {
const parsed = basicsSchema.parse({ ...validForm, custom_css: 'body{}' })
expect(parsed).toEqual({
name: 'Docs',
slug: 'docs',
page_title: 'Help center',
template: 'classic'
})
})
test('name still required', () => {
const { name, ...form } = validForm
expect(() => basicsSchema.parse(form)).toThrow()
})
test('name still required', () => {
const { name, ...form } = validForm
expect(() => basicsSchema.parse(form)).toThrow()
})
test('slug still validated', () => {
expect(() => basicsSchema.parse({ ...validForm, slug: 'Bad Slug' })).toThrow()
})
test('slug still validated', () => {
expect(() => basicsSchema.parse({ ...validForm, slug: 'Bad Slug' })).toThrow()
})
test('locales not required', () => {
expect(basicsSchema.parse(validForm).default_locale).toBeUndefined()
})
test('locales not required', () => {
expect(basicsSchema.parse(validForm).default_locale).toBeUndefined()
})
})
File diff suppressed because it is too large Load Diff
@@ -1,16 +1,18 @@
<template>
<div class="relative w-full h-[750px] rounded-xl border border-border bg-muted overflow-hidden">
<div
class="relative w-full h-[calc(100vh-13rem)] min-h-[620px] max-h-[880px] rounded-xl border border-border bg-muted overflow-hidden"
>
<!-- Widget window, themed independently of the admin app. -->
<transition name="ld-preview-window">
<div
v-if="open"
class="absolute top-4 left-3 right-3 mx-auto max-w-[360px]"
class="absolute top-4 inset-x-0 mx-auto max-w-[400px]"
:style="{ bottom: windowBottom + 'px' }"
>
<div
class="libredesk-widget-preview flex flex-col h-full bg-background text-foreground rounded-2xl overflow-hidden shadow-2xl border border-border"
class="libredesk-widget-preview flex flex-col h-full bg-background text-foreground rounded-2xl overflow-hidden border border-border"
:style="[primaryStyle, { boxShadow: IFRAME_BOX_SHADOW }]"
:class="isDark ? 'dark' : 'light'"
:style="primaryStyle"
>
<!-- Chat view -->
<template v-if="view === 'chat'">
@@ -18,7 +20,8 @@
<div class="flex items-center p-2 border-b border-border gap-3 shrink-0">
<button
type="button"
class="flex items-center justify-center size-8 rounded-md hover:bg-accent text-foreground"
:aria-label="$t('globals.messages.back')"
:class="BACK_BUTTON_CLASS"
@click="view = 'messages'"
>
<ArrowLeft :size="18" />
@@ -52,11 +55,8 @@
<!-- Pre-chat form -->
<template v-if="showPrechat && !openedExisting">
<div class="flex-1 min-h-0 overflow-y-auto p-4 space-y-4">
<div
v-if="config.prechat_form?.title"
class="text-xl text-foreground mb-2 text-center"
>
{{ config.prechat_form.title }}
<div v-if="prechatConfig.title" class="text-xl text-foreground mb-2 text-center">
{{ prechatConfig.title }}
</div>
<div v-for="field in prechatFields" :key="field.key" class="space-y-2">
<div v-if="field.type === 'checkbox'" class="flex items-start gap-3">
@@ -132,6 +132,20 @@
<p class="text-sm text-secondary-foreground">{{ config.notice_banner.text }}</p>
</div>
</div>
<div
v-if="!openedExisting && quickReplies.length"
class="flex flex-wrap justify-end gap-2 mt-auto"
>
<Button
v-for="reply in quickReplies"
:key="reply"
type="button"
variant="outline"
size="sm"
class="h-auto max-w-full rounded-full py-1.5 text-left whitespace-normal shadow-md"
>{{ reply }}</Button
>
</div>
<template v-if="openedExisting">
<div
v-for="m in sampleMessages"
@@ -159,7 +173,7 @@
<!-- Message input -->
<div class="border-t border-border shrink-0">
<div class="p-2">
<div class="border border-input rounded-lg bg-background">
<div class="border border-input rounded-md bg-background">
<div class="p-2">
<textarea
:placeholder="$t('globals.terms.typeMessage')"
@@ -194,78 +208,161 @@
</div>
</template>
<!-- Home + Messages (share the bottom nav) -->
<template v-else>
<div class="flex-1 min-h-0 relative">
<!-- Home -->
<div v-if="view === 'home'" class="h-full overflow-y-auto flex flex-col">
<div class="relative" :style="headerStyle">
<div class="p-6">
<img
v-if="config.logo_url"
:src="config.logo_url"
:alt="config.brand_name"
class="max-h-7 max-w-full"
/>
<div class="mt-16 font-bold text-3xl leading-tight" :class="textColorClass">
<h2 class="break-words">{{ parsedGreeting }}</h2>
<p class="mt-1 font-semibold" :class="subTextColorClass">
{{ parsedIntroduction }}
</p>
</div>
</div>
<div v-if="canStartConversation" class="relative z-10 px-4 pb-4">
<Button
type="button"
class="w-full flex items-center justify-center gap-1"
@click="startNew"
>
{{ startButtonText }}
<ArrowRight :size="16" />
</Button>
</div>
<div v-if="view === 'home'" class="relative h-full bg-background">
<div
class="pointer-events-none absolute inset-x-0 top-0"
:style="[headerStyle, { height: `${headerHeight}px` }]"
>
<div
v-if="showFade"
class="absolute bottom-0 left-0 right-0 h-16 pointer-events-none"
class="absolute inset-x-0 bottom-0 h-20"
:style="fadeStyle"
></div>
</div>
<div v-if="homeApps.length" class="flex flex-col gap-3 p-4 bg-background">
<template v-for="(item, index) in homeApps" :key="index">
<Card
v-if="item.type === 'announcement'"
class="overflow-hidden rounded-md hover:bg-accent transition-colors"
>
<div class="relative h-full overflow-y-auto">
<div ref="headerRef">
<div class="px-7 pb-7 pt-7">
<img
v-if="item.image_url"
:src="item.image_url"
:alt="item.title"
class="w-full h-auto"
v-if="config.logo_url"
:src="config.logo_url"
:alt="config.brand_name"
class="max-h-7 max-w-full"
/>
<CardContent class="p-3 text-sm">
<div class="font-bold">
{{ item.title || $t('globals.terms.announcement') }}
</div>
<div v-if="item.description" class="text-muted-foreground mt-1">
{{ item.description }}
</div>
</CardContent>
</Card>
<Card v-else class="rounded-md hover:bg-accent transition-colors">
<CardContent class="p-4">
<div class="flex justify-between items-center">
<span class="text-sm text-primary font-medium">{{
item.text || item.url
}}</span>
<ExternalLink :size="18" class="text-muted-foreground" />
</div>
</CardContent>
</Card>
</template>
<div class="mt-20" :class="textColorClass">
<h2
class="text-3xl font-semibold leading-tight tracking-tight break-words"
:class="subTextColorClass"
>
{{ parsedGreeting }}
</h2>
<p
class="mt-2 text-3xl font-semibold leading-tight tracking-tight break-words"
>
{{ parsedIntroduction }}
</p>
</div>
</div>
<div v-if="canStartConversation" class="relative z-10 px-4 pb-5">
<Button
type="button"
size="lg"
class="w-full font-semibold shadow-md"
@click="startNew"
>
{{ startButtonText }}
<ArrowRight :size="16" aria-hidden="true" />
</Button>
</div>
</div>
<div v-if="homeItems.length" class="flex flex-col gap-3 px-4 pt-1 pb-5">
<template v-for="(item, index) in homeItems" :key="index">
<section
v-if="item.type === 'help'"
class="space-y-1 rounded-xl border border-border/80 bg-card p-2 shadow-sm"
>
<Button
type="button"
variant="outline"
class="h-10 w-full justify-between px-2"
@click="view = 'help'"
>
{{ $t('widget.searchArticles') }}
<Search class="size-4" aria-hidden="true" />
</Button>
<Button
v-for="article in featuredArticles"
:key="article.id"
type="button"
variant="ghost"
class="w-full h-auto justify-start px-2 py-1.5 text-left whitespace-normal"
@click="view = 'help'"
>
{{ article.title }}
</Button>
</section>
<Card
v-else-if="item.type === 'announcement'"
class="overflow-hidden rounded-xl border-border/80 shadow-sm transition-[background-color,box-shadow] can-hover:hover:bg-accent can-hover:hover:shadow-md"
>
<img
v-if="item.image_url"
:src="item.image_url"
:alt="item.title"
class="w-full h-auto"
/>
<CardContent class="p-4 text-sm">
<div class="font-semibold leading-snug">
{{ item.title || $t('globals.terms.announcement') }}
</div>
<div
v-if="item.description"
class="mt-1 text-muted-foreground leading-relaxed"
>
{{ item.description }}
</div>
</CardContent>
</Card>
<Card
v-else
class="rounded-xl border-border/80 shadow-sm transition-[background-color,box-shadow] can-hover:hover:bg-accent can-hover:hover:shadow-md"
>
<CardContent class="flex items-center gap-3 p-4">
<span
class="min-w-0 flex-1 text-sm font-medium leading-snug text-foreground"
>{{ item.text || item.url }}</span
>
<ExternalLink
:size="15"
class="shrink-0 text-muted-foreground"
aria-hidden="true"
/>
</CardContent>
</Card>
</template>
</div>
</div>
</div>
<!-- Help -->
<div v-else-if="view === 'help'" class="h-full flex flex-col">
<header class="flex items-center gap-2 border-b border-border p-3 shrink-0">
<button
v-if="helpCollection"
type="button"
:aria-label="$t('globals.messages.back')"
:class="BACK_BUTTON_CLASS"
@click="helpPath.pop()"
>
<ArrowLeft :size="16" />
</button>
<h3 class="font-medium truncate">{{ $t('globals.terms.help') }}</h3>
</header>
<div class="relative p-3 border-b border-border shrink-0">
<Search
class="absolute left-6 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<Input class="pl-10" :placeholder="$t('widget.searchArticles')" readonly />
</div>
<div class="flex-1 min-h-0 overflow-y-auto">
<p
v-if="!helpCollections.length && !helpArticles.length"
class="p-4 text-sm text-muted-foreground"
>
{{ $t('widget.noArticles') }}
</p>
<HelpCollectionList
v-else
:collection="helpCollection"
:collections="helpCollections"
:articles="helpArticles"
@collection="helpPath.push($event)"
/>
</div>
</div>
<!-- Messages -->
<div v-else class="h-full flex flex-col relative">
<div class="flex items-center justify-center p-4 border-b border-border shrink-0">
@@ -274,8 +371,9 @@
</h3>
</div>
<div class="flex-1 overflow-y-auto pb-20">
<div
class="p-4 border-b border-border hover:bg-accent/50 cursor-pointer transition-colors"
<button
type="button"
class="w-full p-4 border-b border-border hover:bg-accent/50 cursor-pointer transition-colors text-left"
@click="openExisting"
>
<div class="flex items-center gap-3">
@@ -294,7 +392,7 @@
</div>
<ChevronRight class="w-4 h-4 text-muted-foreground shrink-0" />
</div>
</div>
</button>
</div>
<div v-if="canStartFromMessages" class="absolute bottom-0 inset-x-0">
<div
@@ -307,47 +405,106 @@
</div>
</div>
<!-- Bottom nav -->
<div class="grid grid-cols-2 border-t border-border bg-background shrink-0">
<div class="flex border-t border-border bg-background shrink-0">
<button
type="button"
class="flex flex-col items-center gap-1 py-2"
:class="view === 'home' ? 'text-foreground' : 'text-muted-foreground'"
:class="[
PREVIEW_NAV_TAB_CLASS,
view === 'home' ? 'text-primary' : 'text-muted-foreground'
]"
@click="view = 'home'"
>
<House class="w-5 h-5" />
<House class="w-5 h-5" aria-hidden="true" />
<span class="text-xs font-medium">{{ $t('globals.terms.home') }}</span>
</button>
<button
type="button"
class="flex flex-col items-center gap-1 py-2"
:class="view === 'messages' ? 'text-foreground' : 'text-muted-foreground'"
:class="[
PREVIEW_NAV_TAB_CLASS,
view === 'messages' ? 'text-primary' : 'text-muted-foreground'
]"
@click="view = 'messages'"
>
<MessagesSquare class="w-5 h-5" />
<MessagesSquare class="w-5 h-5" aria-hidden="true" />
<span class="text-xs font-medium">{{ $t('globals.terms.message', 2) }}</span>
</button>
<button
v-if="showHelpTab"
type="button"
:class="[
PREVIEW_NAV_TAB_CLASS,
view === 'help' ? 'text-primary' : 'text-muted-foreground'
]"
@click="view = 'help'"
>
<CircleQuestionMark class="w-5 h-5" aria-hidden="true" />
<span class="text-xs font-medium">{{ $t('globals.terms.help') }}</span>
</button>
</div>
</template>
</div>
</div>
</transition>
<!-- Proactive message, mirrors the card widget.js renders above the launcher. -->
<div v-if="campaign && !open" class="absolute" :style="campaignStyle">
<div
class="flex items-start border border-border rounded-xl bg-background text-foreground overflow-hidden shadow-lg"
:class="isDark ? 'dark' : 'light'"
>
<div class="flex items-start gap-2.5 flex-1 min-w-0 p-3 text-left">
<img
v-if="campaign.avatar"
:src="campaign.avatar"
alt=""
class="size-7 rounded-full object-cover shrink-0"
/>
<span class="min-w-0">
<span class="block font-semibold text-sm">{{ campaign.sender }}</span>
<span class="block text-sm line-clamp-3">{{ campaignText }}</span>
</span>
</div>
<div class="flex items-center justify-center size-11 text-muted-foreground shrink-0">
<X :size="16" />
</div>
</div>
</div>
<!-- Launcher -->
<button
type="button"
class="absolute flex items-center justify-center rounded-full shadow-lg transition-transform hover:scale-105"
:aria-label="open ? $t('globals.messages.closeChat') : $t('globals.messages.openChat')"
class="absolute flex items-center justify-center rounded-full transition-transform hover:scale-105"
:style="launcherStyle"
@click="open = !open"
>
<ChevronDown v-if="open" :size="26" :style="{ color: launcherIconColor }" />
<img v-else :src="launcherLogo" alt="" class="w-full h-full rounded-full object-cover" />
<ChevronDown
v-if="open"
:size="26"
:style="{ color: launcherIconColor }"
aria-hidden="true"
/>
<img
v-else
:src="launcherLogo"
alt=""
:class="launcherIconFull ? 'rounded-full object-cover' : 'object-contain'"
:style="launcherIconStyle"
/>
</button>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
const IFRAME_BOX_SHADOW = 'rgba(9, 14, 21, 0.9) 0px 5px 40px 0px'
const LAUNCHER_DROP_SHADOW =
'drop-shadow(rgba(9, 14, 21, 0.54) 0px 1px 6px) drop-shadow(rgba(9, 14, 21, 0.9) 0px 2px 32px)'
const BACK_BUTTON_CLASS =
'flex items-center justify-center size-8 rounded-md hover:bg-accent text-foreground'
const PREVIEW_NAV_TAB_CLASS = 'flex w-full flex-col items-center justify-center gap-1 py-4'
import { computed, ref, watch } from 'vue'
import { useElementSize } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { Button } from '@shared-ui/components/ui/button'
import { Card, CardContent } from '@shared-ui/components/ui/card'
@@ -356,6 +513,7 @@ import { Textarea } from '@shared-ui/components/ui/textarea'
import {
House,
MessagesSquare,
CircleQuestionMark,
ArrowRight,
ArrowLeft,
ArrowUp,
@@ -365,14 +523,19 @@ import {
AlertTriangle,
Paperclip,
Smile,
Maximize2
Maximize2,
Search,
X
} from 'lucide-vue-next'
import HelpCollectionList from '@shared-ui/components/HelpCollectionList.vue'
import { hexToHSL, getContrastingHSL } from '@shared-ui/utils/color'
import { renderTemplate } from '@shared-ui/utils/string'
const DEFAULT_LAUNCHER_LOGO = '/static/public/launcher-logo.png'
const HEX_COLOR = /^#([0-9a-f]{6}|[0-9a-f]{3})$/i
const LAUNCHER_SIZE = 52
const DEFAULT_LAUNCHER_ICON_SCALE = 100
const SNIPPET_LENGTH = 240
const props = defineProps({
userType: {
@@ -388,6 +551,9 @@ const props = defineProps({
const { t } = useI18n()
const open = ref(true)
const headerRef = ref(null)
const { height: headerHeight } = useElementSize(headerRef)
const view = ref('home')
// The preview can't send/receive, so a sample conversation stands in for a real one.
const openedExisting = ref(false)
@@ -401,7 +567,7 @@ const openExisting = () => {
view.value = 'chat'
}
const isDark = computed(() => Boolean(props.config.dark_mode))
const isDark = computed(() => Boolean(props.config.dark))
const primaryStyle = computed(() => {
const primary = props.config.colors?.primary
@@ -465,7 +631,10 @@ const showFade = computed(
Boolean(props.config.home_screen?.background?.type) &&
Boolean(props.config.home_screen?.fade_background)
)
const fadeStyle = { background: 'linear-gradient(to bottom, transparent, hsl(var(--background)))' }
const fadeStyle = {
background:
'linear-gradient(to bottom, transparent 0%, hsl(var(--background) / 0.08) 20%, hsl(var(--background) / 0.32) 45%, hsl(var(--background) / 0.72) 72%, hsl(var(--background)) 100%)'
}
const userTypeConfig = computed(() => props.config[props.userType] || {})
@@ -498,15 +667,68 @@ const sampleLastMessage = computed(
() => sampleMessages.value[sampleMessages.value.length - 1].content
)
const homeApps = computed(() => props.config.home_apps || [])
const helpConfig = computed(() => props.config.help || {})
const helpAudience = computed(() => helpConfig.value[props.userType] || {})
const helpEnabled = computed(() => Boolean(helpConfig.value.help_center_id))
const showHelpTab = computed(() => helpEnabled.value && Boolean(helpAudience.value.tab))
const featuredArticles = computed(() =>
(helpConfig.value.featured_ids || [])
.map((id) => (props.config.help_articles || []).find((article) => article.id === id))
.filter(Boolean)
)
const helpPath = ref([])
const helpCollection = computed(() => helpPath.value.at(-1))
const helpCollections = computed(
() => helpCollection.value?.children || props.config.help_tree || []
)
const helpArticles = computed(() => helpCollection.value?.articles || [])
watch(
() => helpConfig.value.help_center_id,
() => {
helpPath.value = []
}
)
const homeItems = computed(() =>
(props.config.home_apps || []).filter((item) => item.type !== 'help' || helpEnabled.value)
)
const campaign = computed(() => props.config.preview_campaign || null)
watch(
() => campaign.value?.id ?? null,
(campaignId) => {
if (campaignId !== null) open.value = false
}
)
const campaignText = computed(() => campaign.value?.message?.slice(0, SNIPPET_LENGTH) || '')
const quickReplies = computed(() => {
const replies = userTypeConfig.value.quick_replies
if (Array.isArray(replies)) return replies
return typeof replies === 'string'
? replies
.split('\n')
.map((reply) => reply.trim())
.filter(Boolean)
: []
})
const prechatConfig = computed(() => {
const shared = props.config.prechat_form || {}
const audience = shared[props.userType]
return audience && typeof audience === 'object' ? audience : shared
})
const prechatFields = computed(() =>
(props.config.prechat_form?.fields || [])
(prechatConfig.value.fields || [])
.filter((f) => f.enabled)
.sort((a, b) => (a.order || 0) - (b.order || 0))
)
const showPrechat = computed(
() => Boolean(props.config.prechat_form?.enabled) && prechatFields.value.length > 0
() =>
Boolean(props.config.prechat_form?.enabled) &&
(prechatConfig.value.enabled ?? true) &&
prechatFields.value.length > 0
)
const onLeft = computed(() => props.config.launcher?.position === 'left')
@@ -516,6 +738,14 @@ const clampSpacing = (value) => Math.min(Number(value) || 20, 40)
const clampedSide = computed(() => clampSpacing(props.config.launcher?.spacing?.side))
const clampedBottom = computed(() => clampSpacing(props.config.launcher?.spacing?.bottom))
const windowBottom = computed(() => clampedBottom.value + LAUNCHER_SIZE + 12)
const launcherIconScale = computed(
() => Number(props.config.launcher?.icon_scale) || DEFAULT_LAUNCHER_ICON_SCALE
)
const launcherIconFull = computed(() => launcherIconScale.value >= 100)
const launcherIconStyle = computed(() => ({
width: launcherIconScale.value + '%',
height: launcherIconScale.value + '%'
}))
const launcherColor = computed(() => {
const c = props.config.launcher?.color
@@ -524,7 +754,13 @@ const launcherColor = computed(() => {
return HEX_COLOR.test(primary) ? primary : '#000000'
})
const launcherIconColor = computed(() => `hsl(${getContrastingHSL(launcherColor.value)})`)
const campaignStyle = computed(() => ({
bottom: windowBottom.value + 'px',
[onLeft.value ? 'left' : 'right']: clampedSide.value + 'px',
width: `min(280px, calc(100% - ${clampedSide.value * 2}px))`
}))
const launcherStyle = computed(() => ({
filter: LAUNCHER_DROP_SHADOW,
width: LAUNCHER_SIZE + 'px',
height: LAUNCHER_SIZE + 'px',
backgroundColor: launcherColor.value,
@@ -8,142 +8,209 @@
@update:checked="prechatConfig.enabled = $event"
/>
<!-- Form Configuration -->
<div v-if="prechatConfig.enabled" class="space-y-6">
<!-- Form Title -->
<div>
<label class="text-sm font-medium">
{{ $t('admin.inbox.livechat.prechatForm.title') }}
</label>
<Input
type="text"
v-model="prechatConfig.title"
:placeholder="$t('placeholders.tellUsAboutYourself')"
class="mt-1"
<SwitchField
v-if="prechatConfig.enabled"
:title="$t('admin.inbox.livechat.prechatForm.handoffOnly')"
:description="$t('admin.inbox.livechat.prechatForm.handoffOnly.description')"
:checked="prechatConfig.handoff_only"
@update:checked="prechatConfig.handoff_only = $event"
/>
<Tabs v-if="prechatConfig.enabled" v-model="selectedAudience">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="visitors">
{{ $t('admin.inbox.livechat.userSettings.visitors') }}
</TabsTrigger>
<TabsTrigger value="users">
{{ $t('globals.terms.users') }}
</TabsTrigger>
</TabsList>
<div class="mt-4 space-y-6">
<SwitchField
:title="$t('admin.inbox.livechat.prechatForm.audienceEnabled')"
:checked="audienceConfig.enabled"
@update:checked="audienceConfig.enabled = $event"
/>
</div>
<!-- Fields Configuration -->
<div class="space-y-4">
<h4 class="font-medium text-foreground">
{{ $t('admin.inbox.livechat.prechatForm.fields') }}
</h4>
<div v-if="audienceConfig.enabled" class="space-y-6">
<div>
<Label>
{{ $t('admin.inbox.livechat.prechatForm.title') }}
</Label>
<Input
type="text"
v-model="audienceConfig.title"
:placeholder="$t('placeholders.tellUsAboutYourself')"
class="mt-1"
/>
</div>
<!-- Field List -->
<div class="space-y-3">
<Draggable
v-model="draggableFields"
:item-key="(field) => field.key || `field_${field.custom_attribute_id || 'unknown'}`"
:animation="200"
class="space-y-3"
>
<template #item="{ element: field, index }">
<div :key="field.key || `field-${index}`" class="border rounded-lg p-4 space-y-4">
<!-- Field Header -->
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="cursor-move text-muted-foreground">
<GripVertical class="w-4 h-4" />
<div class="space-y-4">
<h4 class="font-medium text-foreground">
{{ $t('admin.inbox.livechat.prechatForm.fields') }}
</h4>
<div class="space-y-3">
<Draggable
v-model="draggableFields"
:item-key="
(field) => field.key || `field_${field.custom_attribute_id || 'unknown'}`
"
:animation="200"
handle=".drag-handle"
:force-fallback="true"
fallback-on-body
:fallback-tolerance="3"
ghost-class="drag-ghost"
class="space-y-3"
>
<template #item="{ element: field, index }">
<div :key="field.key || `field-${index}`" class="border rounded-lg p-4 space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="drag-handle cursor-move text-muted-foreground">
<GripVertical class="w-4 h-4" aria-hidden="true" />
</div>
<ReorderButtons
:index="index"
:length="draggableFields.length"
@move="moveField(index, $event)"
/>
<div>
<div class="font-medium">{{ field.label }}</div>
<div class="text-sm text-muted-foreground">
{{ field.type }} ({{
field.is_default
? $t('globals.terms.default')
: $t('globals.terms.custom')
}})
</div>
</div>
</div>
<div class="flex items-center space-x-2">
<Switch v-model:checked="field.enabled" />
<Button
v-if="!field.is_default"
type="button"
variant="ghost"
size="sm"
:aria-label="$t('globals.terms.remove')"
@click="removeField(index)"
>
<X class="w-4 h-4" aria-hidden="true" />
</Button>
</div>
</div>
<div>
<div class="font-medium">{{ field.label }}</div>
<div class="text-sm text-muted-foreground">
{{ field.type }} ({{ field.is_default ? $t('globals.terms.default') : $t('globals.terms.custom') }})
<div v-if="field.enabled" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<Label>{{ $t('globals.terms.label') }}</Label>
<Input
v-model="field.label"
:placeholder="$t('placeholders.fieldLabel')"
class="mt-1"
/>
</div>
<div>
<Label>
{{ $t('globals.terms.placeholder') }}
</Label>
<Input
v-model="field.placeholder"
:placeholder="$t('placeholders.fieldPlaceholder')"
class="mt-1"
/>
</div>
</div>
<div v-if="field.type !== 'checkbox'" class="flex items-center space-x-2">
<Checkbox v-model:checked="field.required" />
<Label class="font-normal">{{ $t('globals.terms.required') }}</Label>
</div>
</div>
</div>
<div class="flex items-center space-x-2">
<Switch v-model:checked="field.enabled" />
<Button
v-if="!field.is_default"
type="button"
variant="ghost"
size="sm"
@click="removeField(index)"
>
<X class="w-4 h-4" />
</Button>
</div>
</div>
</template>
</Draggable>
<!-- Field Configuration -->
<div v-if="field.enabled" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<!-- Label -->
<div>
<label class="text-sm font-medium">{{ $t('globals.terms.label') }}</label>
<Input v-model="field.label" :placeholder="$t('placeholders.fieldLabel')" class="mt-1" />
</div>
<div v-if="formFields.length === 0" class="text-center py-8 text-muted-foreground">
{{ $t('admin.inbox.livechat.prechatForm.noFields') }}
</div>
</div>
<!-- Placeholder -->
<div>
<label class="text-sm font-medium">
{{ $t('globals.terms.placeholder') }}
</label>
<Input
v-model="field.placeholder"
:placeholder="$t('placeholders.fieldPlaceholder')"
class="mt-1"
/>
</div>
</div>
<!-- Required -->
<div v-if="field.type !== 'checkbox'" class="flex items-center space-x-2">
<Checkbox v-model:checked="field.required" />
<label class="text-sm">{{ $t('globals.terms.required') }}</label>
<div v-if="availableCustomAttributes.length > 0" class="space-y-3">
<h5 class="font-medium text-sm">
{{ $t('admin.inbox.livechat.prechatForm.availableFields') }}
</h5>
<div class="grid grid-cols-2 gap-2 max-h-48 overflow-y-auto">
<div
v-for="attr in availableCustomAttributes"
:key="attr.id"
class="flex items-center space-x-2 p-2 border rounded-md cursor-pointer hover:bg-accent"
@click="addCustomAttributeToForm(attr)"
>
<div class="flex-1">
<div class="font-medium text-sm">{{ attr.name }}</div>
<div class="text-xs text-muted-foreground">{{ attr.data_type }}</div>
</div>
<Plus class="w-4 h-4 text-muted-foreground" />
</div>
</div>
</template>
</Draggable>
<!-- Empty State -->
<div v-if="formFields.length === 0" class="text-center py-8 text-muted-foreground">
{{ $t('admin.inbox.livechat.prechatForm.noFields') }}
</div>
</div>
<!-- Custom Attributes Selection -->
<div v-if="availableCustomAttributes.length > 0" class="space-y-3">
<h5 class="font-medium text-sm">
{{ $t('admin.inbox.livechat.prechatForm.availableFields') }}
</h5>
<div class="grid grid-cols-2 gap-2 max-h-48 overflow-y-auto">
<div
v-for="attr in availableCustomAttributes"
:key="attr.id"
class="flex items-center space-x-2 p-2 border rounded-md cursor-pointer hover:bg-accent"
@click="addCustomAttributeToForm(attr)"
>
<div class="flex-1">
<div class="font-medium text-sm">{{ attr.name }}</div>
<div class="text-xs text-muted-foreground">{{ attr.data_type }}</div>
</div>
<Plus class="w-4 h-4 text-muted-foreground" />
</div>
</div>
</div>
</div>
</div>
</Tabs>
</div>
</template>
<script>
export const getDefaultPrechatFields = () => [
{ key: 'name', type: 'text', label: 'Full name', placeholder: 'Enter your name', required: true, enabled: true, order: 1, is_default: true },
{ key: 'email', type: 'email', label: 'Email address', placeholder: 'your@email.com', required: true, enabled: true, order: 2, is_default: true },
{ key: 'phone', type: 'phone', label: 'Phone number', placeholder: '', required: false, enabled: false, order: 3, is_default: true }
{
key: 'name',
type: 'text',
label: 'Full name',
placeholder: 'Enter your name',
required: true,
enabled: true,
order: 1,
is_default: true
},
{
key: 'email',
type: 'email',
label: 'Email address',
placeholder: 'your@email.com',
required: true,
enabled: true,
order: 2,
is_default: true
},
{
key: 'phone',
type: 'phone',
label: 'Phone number',
placeholder: '',
required: false,
enabled: false,
order: 3,
is_default: true
}
]
</script>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { Input } from '@shared-ui/components/ui/input'
import { Label } from '@shared-ui/components/ui/label'
import { Button } from '@shared-ui/components/ui/button'
import { Switch } from '@shared-ui/components/ui/switch'
import SwitchField from '@shared-ui/components/SwitchField.vue'
import ReorderButtons from '@shared-ui/components/ReorderButtons.vue'
import { Checkbox } from '@shared-ui/components/ui/checkbox'
import { Tabs, TabsList, TabsTrigger } from '@shared-ui/components/ui/tabs'
import { Plus, X, GripVertical } from 'lucide-vue-next'
import Draggable from 'vuedraggable'
import api from '@/api'
@@ -151,15 +218,28 @@ import api from '@/api'
const prechatConfig = defineModel({
default: () => ({
enabled: false,
handoff_only: false,
title: '',
fields: getDefaultPrechatFields()
fields: getDefaultPrechatFields(),
visitors: {
enabled: true,
title: '',
fields: getDefaultPrechatFields()
},
users: {
enabled: true,
title: '',
fields: getDefaultPrechatFields()
}
})
})
const customAttributes = ref([])
const selectedAudience = ref('visitors')
const audienceConfig = computed(() => prechatConfig.value[selectedAudience.value])
const formFields = computed(() => {
return prechatConfig.value.fields || []
return audienceConfig.value.fields || []
})
const availableCustomAttributes = computed(() => {
@@ -172,20 +252,27 @@ const availableCustomAttributes = computed(() => {
const draggableFields = computed({
get() {
return prechatConfig.value.fields || []
return audienceConfig.value.fields || []
},
set(newValue) {
const fieldsWithUpdatedOrder = newValue.map((field, index) => ({
...field,
order: index + 1
}))
prechatConfig.value.fields = fieldsWithUpdatedOrder
audienceConfig.value.fields = fieldsWithUpdatedOrder
}
})
const moveField = (index, direction) => {
const fields = [...draggableFields.value]
const [field] = fields.splice(index, 1)
fields.splice(index + direction, 0, field)
draggableFields.value = fields
}
const removeField = (index) => {
const fields = formFields.value.filter((_, i) => i !== index)
prechatConfig.value.fields = fields
audienceConfig.value.fields = fields
}
const addCustomAttributeToForm = (attribute) => {
@@ -202,7 +289,7 @@ const addCustomAttributeToForm = (attribute) => {
}
const fields = [...formFields.value, newField]
prechatConfig.value.fields = fields
audienceConfig.value.fields = fields
}
const fetchCustomAttributes = async () => {
@@ -221,23 +308,18 @@ const fetchCustomAttributes = async () => {
// Build lookup map for custom attributes
const customAttrMap = new Map(customAttributes.value.map((attr) => [attr.id, attr]))
// Clean up orphaned fields and sync labels/types from current custom attribute definitions
const cleanedFields = (prechatConfig.value.fields || []).filter((field) => {
if (field.is_default) return true
if (field.custom_attribute_id && customAttrMap.has(field.custom_attribute_id)) return true
return false
}).map((field) => {
if (!field.is_default && field.custom_attribute_id) {
const attr = customAttrMap.get(field.custom_attribute_id)
if (attr) {
field.label = attr.name
field.type = attr.data_type
}
}
return field
})
prechatConfig.value.fields = cleanedFields
for (const audience of ['visitors', 'users']) {
prechatConfig.value[audience].fields = (prechatConfig.value[audience].fields || [])
.filter(
(field) =>
field.is_default ||
(field.custom_attribute_id && customAttrMap.has(field.custom_attribute_id))
)
.map((field) => {
const attr = customAttrMap.get(field.custom_attribute_id)
return attr ? { ...field, label: attr.name, type: attr.data_type } : field
})
}
} catch (error) {
console.error('Error fetching custom attributes:', error)
customAttributes.value = []
@@ -0,0 +1,777 @@
<script setup>
const HINT_CLASS = 'text-xs text-muted-foreground'
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import api from '@/api'
import { useUsersStore } from '@/stores/users'
import { Button } from '@shared-ui/components/ui/button'
import { Badge } from '@shared-ui/components/ui/badge'
import { Input } from '@shared-ui/components/ui/input'
import { Textarea } from '@shared-ui/components/ui/textarea'
import { Label } from '@shared-ui/components/ui/label'
import { Switch } from '@shared-ui/components/ui/switch'
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem
} from '@shared-ui/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@shared-ui/components/ui/table'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@shared-ui/components/ui/alert-dialog'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger
} from '@shared-ui/components/ui/collapsible'
import Draggable from 'vuedraggable'
import ReorderButtons from '@shared-ui/components/ReorderButtons.vue'
import SwitchField from '@shared-ui/components/SwitchField.vue'
import SelectAgentCombobox from '@/components/combobox/SelectAgentCombobox.vue'
import SelectTeamCombobox from '@/components/combobox/SelectTeamCombobox.vue'
import WidgetConditions from './WidgetConditions.vue'
import { createCampaignSchema, defaultCampaign, newCampaignId } from './livechatFormSchema.js'
import { handleHTTPError } from '@shared-ui/utils/http'
import { isGoDuration } from '@shared-ui/utils/string'
import {
ChevronRight,
ChevronDown,
GripVertical,
Plus,
CircleCheck,
CircleSlash
} from 'lucide-vue-next'
const MAX_CAMPAIGNS = 50
const STAT_KEYS = ['displayed', 'opened', 'dismissed', 'replied']
const STAT_TRANSLATION_KEYS = {
displayed: 'globals.terms.displayed',
opened: 'globals.terms.opened',
dismissed: 'globals.terms.dismissed',
replied: 'globals.terms.replied'
}
const AUDIENCE_TRANSLATION_KEYS = {
all: 'globals.terms.everyone',
visitors: 'admin.inbox.livechat.userSettings.visitors',
users: 'globals.terms.signedInUser'
}
const SELECT_FIELDS = [
{
key: 'audience',
labelKey: 'globals.terms.audience',
values: ['all', 'visitors', 'users']
}
]
const DEVICES = ['desktop', 'mobile']
const props = defineProps({
modelValue: { type: Array, default: () => [] },
inboxId: { type: Number, default: 0 },
brandName: { type: String, default: '' },
cooldown: { type: String, default: '24h' },
showErrors: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue', 'update:cooldown', 'update:preview'])
const { t } = useI18n()
const usersStore = useUsersStore()
const root = ref(null)
const selected = ref('')
const campaign = computed(() => props.modelValue.find((item) => item.id === selected.value))
const businessHours = ref([])
const error = ref('')
const pendingDelete = ref(null)
const stats = ref([])
const from = ref(new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10))
const to = ref(new Date().toISOString().slice(0, 10))
const brandSenderName = computed(() => props.brandName.trim() || t('widget.brandBot'))
const campaignEventExample = computed(
() => `window.Libredesk.trackEvent(${JSON.stringify(campaign.value?.event || 'pricing_viewed')})`
)
const senderName = (id) => {
const agent = usersStore.options.find((item) => String(item.value) === String(id))
return agent?.label || brandSenderName.value
}
const campaignErrors = computed(() => {
if (!props.showErrors || !campaign.value) return []
const result = createCampaignSchema(t).safeParse(campaign.value)
if (result.success) return []
return result.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message
}))
})
const campaignFieldError = (...fields) =>
campaignErrors.value.find((error) => fields.some((field) => error.field === field))?.message || ''
const campaignSectionError = (field) =>
campaignErrors.value.find((error) => error.field === field || error.field.startsWith(`${field}.`))
?.message || ''
const cooldownError = computed(() => {
if (!props.showErrors) return ''
return isGoDuration(props.cooldown) ? '' : t('validation.invalidDuration')
})
const audienceLabel = (audience) => t(AUDIENCE_TRANSLATION_KEYS[audience], 2)
const pagesLabel = (item) =>
item.include_urls.filter(Boolean).length
? item.include_urls.filter(Boolean).join(', ')
: t('globals.messages.all')
// The rail preview mirrors what widget.js renders above the launcher for this campaign.
watch(
[campaign, () => usersStore.options],
() => {
if (!campaign.value) {
emit('update:preview', null)
return
}
const agent = usersStore.options.find(
(item) => String(item.value) === String(campaign.value.sender_id)
)
emit('update:preview', {
id: campaign.value.id,
sender: senderName(campaign.value.sender_id),
avatar: agent?.avatar_url || '',
message: campaign.value.message
})
},
{ deep: true, immediate: true }
)
const update = (key, value) => {
emit(
'update:modelValue',
props.modelValue.map((item) => {
if (item.id !== selected.value) return item
const next = { ...item, [key]: value }
if (
key === 'repeat' &&
value !== 'interval' &&
!(
Number.isInteger(next.repeat_hours) &&
next.repeat_hours >= 1 &&
next.repeat_hours <= 8760
)
) {
next.repeat_hours = defaultCampaign().repeat_hours
}
return next
})
)
}
const toggleEnabled = (id, enabled) =>
emit(
'update:modelValue',
props.modelValue.map((item) => (item.id === id ? { ...item, enabled } : item))
)
const orderedCampaigns = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const moveCampaign = (index, direction) => {
const campaigns = [...props.modelValue]
const [campaign] = campaigns.splice(index, 1)
campaigns.splice(index + direction, 0, campaign)
emit('update:modelValue', campaigns)
}
const add = (source) => {
const item = source
? {
...JSON.parse(JSON.stringify(source)),
id: newCampaignId(),
enabled: false,
name: `${source.name} (${t('globals.terms.copy')})`
}
: defaultCampaign()
emit('update:modelValue', [...props.modelValue, item])
selected.value = item.id
}
// Radix clears pendingDelete as the dialog closes, so the id comes from the open editor.
const remove = () => {
const id = selected.value
emit(
'update:modelValue',
props.modelValue.filter((item) => item.id !== id)
)
selected.value = ''
pendingDelete.value = null
}
const showInvalidField = async (field) => {
if (field === 'config.campaign_cooldown') {
root.value?.querySelector('#campaign-cooldown')?.focus()
return
}
const firstInvalid = props.modelValue
.map((item) => ({ item, result: createCampaignSchema(t).safeParse(item) }))
.find(({ result }) => !result.success)
if (!firstInvalid) return
selected.value = firstInvalid.item.id
await nextTick()
const fieldName = firstInvalid.result.error.issues[0]?.path.join('.') || field
const targetID = [
'name',
'message',
'include_urls',
'exclude_urls',
'delay_seconds',
'event',
'repeat_hours'
].includes(fieldName)
? `campaign-${fieldName}`
: ''
const section = fieldName.startsWith('conditions')
? '[data-campaign-conditions]'
: fieldName === 'desktop'
? '[data-campaign-devices]'
: fieldName === 'business_hours_id'
? '[data-campaign-business-hours]'
: ''
const target = targetID
? root.value?.querySelector(`#${targetID}`)
: section
? root.value?.querySelector(section)
: null
if (targetID) target?.focus?.()
target?.scrollIntoView({ block: 'nearest' })
}
defineExpose({ showInvalidField })
const visibleStats = computed(() =>
stats.value.filter(
(row) =>
props.modelValue.some((item) => item.id === row.campaign_id) ||
STAT_KEYS.some((key) => row[key] > 0)
)
)
const refreshStats = async () => {
if (!props.inboxId) return
try {
stats.value = (
await api.getCampaignStats(props.inboxId, { from: from.value, to: to.value })
).data.data
} catch (err) {
error.value = handleHTTPError(err).message
}
}
onMounted(async () => {
try {
const response = await api.getAllBusinessHours()
const data = response.data.data
businessHours.value = data.results || data
} catch (err) {
error.value = handleHTTPError(err).message
}
await refreshStats()
})
</script>
<template>
<div ref="root" class="space-y-6">
<p v-if="error" role="alert" class="text-sm text-destructive">{{ error }}</p>
<!-- Campaign list -->
<template v-if="!campaign">
<p class="text-sm text-muted-foreground">{{ t('widget.campaignsHint') }}</p>
<div class="space-y-2 max-w-md">
<Label for="campaign-cooldown">{{ t('widget.campaignCooldown') }}</Label>
<Input
id="campaign-cooldown"
type="text"
placeholder="1h"
:model-value="cooldown"
aria-describedby="campaign-cooldown-hint"
@update:model-value="emit('update:cooldown', $event)"
:aria-invalid="!!cooldownError"
/>
<p id="campaign-cooldown-hint" :class="HINT_CLASS">
{{ t('globals.messages.golangDurationHoursMinutes') }}
</p>
<p v-if="cooldownError" role="alert" class="text-sm text-destructive">
{{ cooldownError }}
</p>
</div>
<div class="space-y-4">
<h4 class="text-base font-semibold text-foreground">
{{ t('globals.terms.proactiveMessage', 2) }}
</h4>
<p v-if="modelValue.length > 1" class="text-sm text-muted-foreground">
{{ t('widget.campaignPriorityHint') }}
</p>
<Draggable
v-if="modelValue.length"
v-model="orderedCampaigns"
item-key="id"
:animation="200"
handle=".drag-handle"
:force-fallback="true"
fallback-on-body
:fallback-tolerance="3"
ghost-class="drag-ghost"
class="space-y-2"
>
<template #item="{ element: item, index }">
<div
class="flex items-center gap-3 border rounded-md hover:bg-accent/50 transition-colors"
>
<div
v-if="modelValue.length > 1"
class="drag-handle ml-2 cursor-move text-muted-foreground"
>
<GripVertical class="size-4" aria-hidden="true" />
</div>
<ReorderButtons
v-if="modelValue.length > 1"
:index="index"
:length="modelValue.length"
@move="moveCampaign(index, $event)"
/>
<Switch
:class="modelValue.length > 1 ? '' : 'ml-3'"
:checked="item.enabled"
:aria-label="t('globals.terms.enabled')"
@update:checked="toggleEnabled(item.id, $event)"
/>
<button
type="button"
class="flex min-w-0 flex-1 items-center gap-3 py-3 pr-3 text-left"
@click="selected = item.id"
>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-foreground truncate">
{{ item.name || t('widget.newCampaign') }}
</span>
<Badge variant="secondary" class="gap-1 shrink-0">
<component :is="item.enabled ? CircleCheck : CircleSlash" class="size-3" />
{{ item.enabled ? t('globals.terms.enabled') : t('globals.terms.paused') }}
</Badge>
</div>
<p class="text-xs text-muted-foreground truncate mt-0.5">
{{ audienceLabel(item.audience) }} · {{ pagesLabel(item) }}
</p>
</div>
<ChevronRight class="size-4 text-muted-foreground shrink-0" />
</button>
</div>
</template>
</Draggable>
<Button
type="button"
variant="outline"
size="sm"
:disabled="modelValue.length >= MAX_CAMPAIGNS"
@click="add()"
>
<Plus class="size-4" />
{{ t('widget.addCampaign') }}
</Button>
<p v-if="modelValue.length >= MAX_CAMPAIGNS" :class="HINT_CLASS">
{{ t('widget.campaignLimitReached') }}
</p>
</div>
<!-- Results -->
<Collapsible v-if="inboxId" class="space-y-4">
<CollapsibleTrigger
type="button"
class="flex items-center gap-2 text-base font-semibold text-foreground group"
>
<ChevronDown class="size-4 transition-transform group-data-[state=closed]:-rotate-90" />
{{ t('widget.campaignStats') }}
</CollapsibleTrigger>
<CollapsibleContent class="space-y-4">
<div class="flex flex-wrap gap-3 items-end">
<div class="space-y-2">
<Label for="campaign-stats-from">{{ t('globals.terms.from') }}</Label>
<Input id="campaign-stats-from" v-model="from" type="date" />
</div>
<div class="space-y-2">
<Label for="campaign-stats-to">{{ t('globals.terms.to') }}</Label>
<Input id="campaign-stats-to" v-model="to" type="date" />
</div>
<Button type="button" variant="outline" @click="refreshStats">
{{ t('globals.terms.refresh') }}
</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>{{ t('globals.terms.name') }}</TableHead>
<TableHead v-for="key in STAT_KEYS" :key="key" class="text-right">
{{ t(STAT_TRANSLATION_KEYS[key]) }}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="row in visibleStats" :key="row.campaign_id">
<TableCell>
{{
modelValue.find((item) => item.id === row.campaign_id)?.name ||
t('widget.deletedCampaign')
}}
</TableCell>
<TableCell v-for="key in STAT_KEYS" :key="key" class="text-right tabular-nums">
{{ row[key] }}
</TableCell>
</TableRow>
<TableRow v-if="!visibleStats.length">
<TableCell :colspan="STAT_KEYS.length + 1" class="text-muted-foreground">
{{ t('globals.messages.noResultsFound') }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</CollapsibleContent>
</Collapsible>
</template>
<!-- Campaign editor -->
<template v-else>
<div data-campaign-editor class="flex items-center justify-between gap-2">
<Button type="button" variant="ghost" size="sm" class="-ml-2" @click="selected = ''">
<ChevronRight class="size-4 rotate-180" />
{{ t('widget.backToCampaigns') }}
</Button>
<div class="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
:disabled="modelValue.length >= MAX_CAMPAIGNS"
@click="add(campaign)"
>
{{ t('globals.terms.duplicate') }}
</Button>
<Button
type="button"
variant="destructive"
size="sm"
@click="pendingDelete = campaign.id"
>
{{ t('globals.messages.delete') }}
</Button>
</div>
</div>
<SwitchField
:title="t('globals.terms.enabled')"
:checked="campaign.enabled"
@update:checked="update('enabled', $event)"
/>
<!-- Message -->
<div class="space-y-4">
<h4 class="text-base font-semibold text-foreground">{{ t('globals.terms.message', 1) }}</h4>
<div class="space-y-2">
<Label for="campaign-name">{{ t('globals.terms.name') }}</Label>
<Input
id="campaign-name"
:model-value="campaign.name"
maxlength="128"
@update:model-value="update('name', $event)"
:aria-invalid="!!campaignFieldError('name')"
/>
<p v-if="campaignFieldError('name')" role="alert" class="text-sm text-destructive">
{{ campaignFieldError('name') }}
</p>
</div>
<div class="space-y-2">
<Label for="campaign-message">{{ t('globals.terms.message', 1) }}</Label>
<Textarea
id="campaign-message"
:model-value="campaign.message"
maxlength="10000"
rows="4"
@update:model-value="update('message', $event)"
:aria-invalid="!!campaignFieldError('message')"
/>
<p v-if="campaignFieldError('message')" role="alert" class="text-sm text-destructive">
{{ campaignFieldError('message') }}
</p>
</div>
<div class="grid sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="campaign-sender">{{ t('globals.terms.sender') }}</Label>
<SelectAgentCombobox
id="campaign-sender"
:model-value="campaign.sender_id === 0 ? 'brand' : String(campaign.sender_id)"
:prepend-items="[{ value: 'brand', label: brandSenderName }]"
@update:model-value="update('sender_id', $event === 'brand' ? 0 : Number($event))"
/>
</div>
<div class="space-y-2">
<Label for="campaign-team">{{ t('widget.replyTeam') }}</Label>
<SelectTeamCombobox
id="campaign-team"
:model-value="campaign.team_id === 0 ? 'none' : String(campaign.team_id)"
include-none
@update:model-value="update('team_id', $event === 'none' ? 0 : Number($event))"
/>
</div>
<div v-for="field in SELECT_FIELDS" :key="field.key" class="space-y-2">
<Label :for="`campaign-${field.key}`">{{ t(field.labelKey) }}</Label>
<Select
:model-value="campaign[field.key]"
@update:model-value="update(field.key, $event)"
>
<SelectTrigger :id="`campaign-${field.key}`"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem v-for="value in field.values" :key="value" :value="value">
{{ audienceLabel(value) }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<!-- Who sees it -->
<div class="space-y-4">
<h4 class="text-base font-semibold text-foreground">
{{ t('widget.campaign.section.audience') }}
</h4>
<div data-campaign-devices class="space-y-3">
<p class="text-sm font-medium text-foreground">{{ t('globals.terms.device', 2) }}</p>
<SwitchField
v-for="device in DEVICES"
:key="device"
:title="t(`globals.terms.${device}`)"
:checked="campaign[device]"
@update:checked="update(device, $event)"
/>
<p v-if="campaignFieldError('desktop')" role="alert" class="text-sm text-destructive">
{{ campaignFieldError('desktop') }}
</p>
</div>
<div data-campaign-conditions class="space-y-2">
<p class="text-sm font-medium text-foreground">{{ t('globals.messages.visibleWhen') }}</p>
<WidgetConditions
:model-value="campaign.conditions"
@update:model-value="update('conditions', $event)"
/>
<p
v-if="campaignSectionError('conditions')"
role="alert"
class="text-sm text-destructive"
>
{{ campaignSectionError('conditions') }}
</p>
</div>
</div>
<!-- Where and when -->
<div class="space-y-4">
<h4 class="text-base font-semibold text-foreground">
{{ t('widget.campaign.section.placement') }}
</h4>
<div v-for="key in ['include_urls', 'exclude_urls']" :key="key" class="space-y-2">
<Label :for="`campaign-${key}`">{{ t(`widget.campaign.${key}`) }}</Label>
<Textarea
:id="`campaign-${key}`"
:model-value="campaign[key].join('\n')"
:placeholder="
key === 'include_urls' ? '/pricing\nhttps://example.com/docs/*' : '/checkout/*'
"
rows="3"
@update:model-value="update(key, $event.split('\n'))"
:aria-invalid="!!campaignFieldError(key)"
/>
<p :class="HINT_CLASS">{{ t('widget.urlPatternsHint') }}</p>
<p v-if="campaignFieldError(key)" role="alert" class="text-sm text-destructive">
{{ campaignFieldError(key) }}
</p>
</div>
<div class="grid sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="campaign-delay_seconds">{{ t('widget.campaign.delay_seconds') }}</Label>
<Input
id="campaign-delay_seconds"
type="number"
min="0"
max="86400"
:model-value="campaign.delay_seconds"
@update:model-value="update('delay_seconds', Number($event))"
:aria-invalid="!!campaignFieldError('delay_seconds')"
/>
<p
v-if="campaignFieldError('delay_seconds')"
role="alert"
class="text-sm text-destructive"
>
{{ campaignFieldError('delay_seconds') }}
</p>
</div>
<div class="space-y-2">
<Label for="campaign-event">{{ t('widget.campaign.event') }}</Label>
<Input
id="campaign-event"
:model-value="campaign.event"
placeholder="pricing_viewed"
maxlength="128"
aria-describedby="campaign-event-hint campaign-event-example"
@update:model-value="update('event', $event)"
/>
<p id="campaign-event-hint" :class="HINT_CLASS">
{{ t('widget.campaignEventHint') }}
</p>
<code
id="campaign-event-example"
class="block overflow-x-auto rounded-md border bg-muted px-3 py-2 text-xs"
>{{ campaignEventExample }}</code
>
</div>
<div class="space-y-2">
<Label for="campaign-business_hours">{{ t('widget.campaign.business_hours') }}</Label>
<Select
:model-value="campaign.business_hours"
@update:model-value="update('business_hours', $event)"
>
<SelectTrigger id="campaign-business_hours"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem
v-for="value in ['any', 'inside', 'outside']"
:key="value"
:value="value"
>
{{ t(`widget.campaign.${value}`) }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div
v-if="campaign.business_hours !== 'any'"
data-campaign-business-hours
class="space-y-2"
>
<Label for="campaign-hours">{{ t('globals.terms.businessHour', 2) }}</Label>
<Select
:model-value="String(campaign.business_hours_id)"
@update:model-value="update('business_hours_id', Number($event))"
>
<SelectTrigger id="campaign-hours"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem v-for="item in businessHours" :key="item.id" :value="String(item.id)">
{{ item.name }}
</SelectItem>
</SelectContent>
</Select>
<p
v-if="campaignFieldError('business_hours_id')"
role="alert"
class="text-sm text-destructive"
>
{{ campaignFieldError('business_hours_id') }}
</p>
</div>
</div>
</div>
<!-- Repeat -->
<div class="space-y-4">
<h4 class="text-base font-semibold text-foreground">
{{ t('globals.terms.repeat') }}
</h4>
<div class="grid sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="campaign-repeat">{{ t('widget.campaign.repeat') }}</Label>
<Select :model-value="campaign.repeat" @update:model-value="update('repeat', $event)">
<SelectTrigger id="campaign-repeat"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem
v-for="value in ['once', 'session', 'interval']"
:key="value"
:value="value"
>
{{ t(`widget.campaign.${value}`) }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="campaign.repeat === 'interval'" class="space-y-2">
<Label for="campaign-repeat_hours">{{ t('widget.campaign.repeat_hours') }}</Label>
<Input
id="campaign-repeat_hours"
type="number"
min="1"
max="8760"
:model-value="campaign.repeat_hours"
@update:model-value="update('repeat_hours', Number($event))"
:aria-invalid="!!campaignFieldError('repeat_hours')"
/>
<p
v-if="campaignFieldError('repeat_hours')"
role="alert"
class="text-sm text-destructive"
>
{{ campaignFieldError('repeat_hours') }}
</p>
</div>
</div>
</div>
</template>
<AlertDialog
:open="!!pendingDelete"
@update:open="pendingDelete = $event ? pendingDelete : null"
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
<AlertDialogDescription>{{ t('confirm.deleteCampaign') }}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel type="button">{{ t('globals.messages.cancel') }}</AlertDialogCancel>
<AlertDialogAction type="button" variant="destructive" @click="remove">
{{ t('globals.messages.delete') }}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>

Some files were not shown because too many files have changed in this diff Show More