mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-10-26 06:19:36 +00:00
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:
@@ -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"
|
||||
@@ -9,3 +9,5 @@ uploads
|
||||
dist/
|
||||
.vscode/
|
||||
static/widget.min.js
|
||||
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<br> Modern, open source, self-hosted omnichannel customer support desk. Live chat, email, and more in a single binary.
|
||||
|
||||

|
||||

|
||||
|
||||
|
||||
Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**live demo**](https://demo.libredesk.io/).
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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())))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(/ /g, ' ').trim().length > 0 ||
|
||||
/<(img|table|iframe|video|hr|blockquote|details)\b/i.test(html)
|
||||
html
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/ /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> </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> </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
Reference in New Issue
Block a user