Compare commits

...

39 Commits

Author SHA1 Message Date
Abhinav Raut
6f62a77783 fix(ai): compute email recipients for AI and automated replies
- Add SendAutoReply method that automatically determines to/cc/bcc based on conversation history. Fixes AI assistant replies failing for email conversations while maintaining livechat compatibility.
2025-08-24 15:47:03 +05:30
Abhinav Raut
af1373272e fix(ai): assistants now reply on first msg
- previously replies started from 2nd msg
- enqueue completions on assignment + new msg
- correct get-latest-message query
2025-08-24 14:20:36 +05:30
Abhinav Raut
61e343de5b Merge branch 'feat/live-chat-channel' into help-articles-and-ai-responses 2025-08-24 02:27:42 +05:30
Abhinav Raut
c721d19b81 fix migration 2025-08-24 02:27:17 +05:30
Abhinav Raut
2ff5a945e2 Merge branch 'feat/live-chat-channel' into help-articles-and-ai-responses 2025-08-24 02:17:55 +05:30
Abhinav Raut
77111835cc fix component import 2025-08-24 02:17:28 +05:30
Abhinav Raut
5284b2ee15 fix build 2025-08-24 02:14:10 +05:30
Abhinav Raut
b1f8231f7d Merge branch 'feat/live-chat-channel' into help-articles-and-ai-responses 2025-08-24 02:12:32 +05:30
Abhinav Raut
45a77b1422 fix build 2025-08-24 02:01:21 +05:30
Abhinav Raut
9a77c8953c Merge branch 'main' into feat/live-chat-channel 2025-08-24 01:52:12 +05:30
Abhinav Raut
18d4a8fe3b feat: auto-remove pending outgoing widget messages after 10 seconds if they have a temporary ID 2025-08-23 19:24:14 +05:30
Abhinav Raut
a2234e908f make widget expand to full viewport height
update shadows for iframe and widget
2025-08-22 02:24:23 +05:30
Abhinav Raut
d7fe6153bb Center pre chat form title 2025-08-22 02:00:53 +05:30
Abhinav Raut
f786c4d962 tidy go mod 2025-08-22 01:43:41 +05:30
Abhinav Raut
cff5a6dfc2 disable file uploader for ai assisants 2025-08-22 01:43:18 +05:30
Abhinav Raut
d0df6f9322 feat: Implement rate limiting for AI conversation completion requests 2025-08-22 01:14:09 +05:30
Abhinav Raut
30902310dc feat: Add Markdown to HTML conversion and clean JSON response utility
- Implemented MarkdownToHTML function using goldmark for converting markdown content to HTML.
- Added CleanJSONResponse function to remove markdown code blocks from LLM responses.
- Updated stringutil tests to remove unnecessary test cases for empty strings and special characters.

refactor: Update SQL schema for knowledge base and help center

- Introduced ai_knowledge_type enum for knowledge base categorization.
- Added help_center_id reference in inboxes table.
- Enhanced help_centers table with default_locale column.
- Changed data types from INTEGER to INT for consistency across tables.
- Renamed ai_custom_answers table to ai_knowledge_base and adjusted its schema.

fix: Remove unnecessary CSS filter from default icon in widget

- Cleaned up widget.js by removing the brightness filter from the default icon styling.
2025-08-22 01:14:08 +05:30
Abhinav Raut
8bf0255b61 fix: conversation messages order in completion request 2025-08-22 01:12:52 +05:30
Abhinav Raut
f337f79f96 wip: AI responses and help articles 2025-08-22 01:12:52 +05:30
Abhinav Raut
68c2708464 feat: remove VisitorInfoForm component and integrate customizable pre-chat form.
- Deleted the VisitorInfoForm.vue component and its associated schema.
- Introduced a new preChatFormSchema.js to handle dynamic form validation.
- Updated ChatView.vue to conditionally display the PreChatForm based on user session and conversation state.
- Enhanced chat store to manage current conversation updates.
- Implemented WebSocket event handling for conversation updates.
- Updated localization files to include new terms related to the pre-chat form.
- Modified conversation management logic to support broadcasting updates to widget clients.
- Updated SQL queries to accommodate custom attributes for visitors.
2025-08-22 00:42:12 +05:30
Abhinav Raut
4f9fc029c0 show uploading state when file is being uploaded from widget 2025-08-19 03:22:12 +05:30
Abhinav Raut
6cfa93838a fix: remove unnecessary filter from default icon styling in widget 2025-08-19 03:01:28 +05:30
Abhinav Raut
f72f158cf0 - show thumbnail image in widget thread instead of the entire image
- update file imports to use shared-ui utils and remove redundant file.js
- Implement SignedURLStore interface for fs store
2025-08-19 03:01:21 +05:30
Abhinav Raut
1962abdc16 feat: implement rate limiting for public widget endpoints with Redis support 2025-08-19 01:58:13 +05:30
Abhinav Raut
081a5c615a fix: update main.js to import styles from shared-ui instead 2025-08-03 17:35:52 +05:30
Abhinav Raut
c35ab42b47 feat: configurable visitor information collection with a form before starting chat.
fix: Chat initialization failing due to the JWT authenticated user doesn't exist in the DB yet.

fix: Always upsert custom attribues instead of replacing.
2025-07-21 01:58:30 +05:30
Abhinav Raut
f05014f412 refactor: implement widget authentication middleware with standard HTTP headers
- Add widgetAuth middleware to handle JWT and inbox validation consistently
  - Move authentication logic from request body to standard HTTP headers:
    * JWT: Authorization: Bearer <token>
    * Inbox ID: X-Libredesk-Inbox-ID: <id>
  - Refactor all widget handlers to use middleware context instead of duplicate auth code
  - Frontend now sends auth headers via HTTP interceptor for all widget requests
2025-07-20 17:44:36 +05:30
Abhinav Raut
e2bba04669 Fix: Trusted domain validation for live chat widget, check the referrer header instead of origin.
- Removed the widgetOrigin middleware as it would have same origin as the iFrame URL, changed this to use `Referrer` header on initial iFrame load.
- Feat(agent-view): Added external_user_id display in the conversation sidebar.
2025-07-20 16:44:33 +05:30
Abhinav Raut
4beab72a11 feat: add external user ID support and secret field for inboxes.
Update user and inbox models, queries, and migrations
2025-07-20 16:42:03 +05:30
Abhinav Raut
26b3b30fca feat: add authenticated user support by passing JWT from parent to widget iframe.
feat: more methods to toggle wiget visibility
2025-07-20 16:40:44 +05:30
Abhinav Raut
11fd57adb0 update lucide-vue-next to version 0.525.0 2025-07-20 16:20:26 +05:30
Abhinav Raut
d4f644c531 feat translate widget app 2025-07-17 02:56:32 +05:30
Abhinav Raut
646bbc7efe wait for widget vue app to be ready before showing the widget icon
- show arrow down when when widget is open
2025-07-17 02:37:03 +05:30
Abhinav Raut
3c3709557e feat: Add loading indicators to chat components and improve spinner UI 2025-07-17 02:29:05 +05:30
Abhinav Raut
74732bfe91 feat: Add expand/collapse functionality to chat view 2025-07-17 01:49:22 +05:30
Abhinav Raut
8ee81c2d64 feat: Widget dark mode and chat reply expectation message in chat title.
feat: Add HTTP utility functions for trusted origin checks

feat: Implement typing status broadcasting for live chat clients and agents.

feat: Add support for signed URLs in media manager

fix: Update database migration to handle duplicate visitors with same email address.

feat: Add conversation subscription and typing message models for WebSocket communication

feat: Implement conversation subscription management in WebSocket hub this is used for broadcasting typing indicator.

feat: Revamp widget JavaScript to improve mobile responsiveness and show unread messages if any.
2025-07-17 01:06:54 +05:30
Abhinav Raut
282dc83439 fix set correct var name 2025-07-06 18:47:19 +05:30
Abhinav Raut
61a70f6b52 clean up live chat
move last message details in the `meta` JSONB column of conversations
2025-07-06 18:46:54 +05:30
Abhinav Raut
5b6a58fba0 wip: intercom like live chat with chat widget
- new vue app for serving live chat widget, created subdirectories inside frontend dir `main` and `widget`
- vite changes for both main app and widget app.
- new backend live chat channel
- apis for live chat widget
2025-06-29 04:59:55 +05:30
638 changed files with 22226 additions and 1929 deletions

View File

@@ -28,11 +28,24 @@ install-deps: $(STUFFBIN)
@echo "→ Installing frontend dependencies..." @echo "→ Installing frontend dependencies..."
@cd ${FRONTEND_DIR} && pnpm install @cd ${FRONTEND_DIR} && pnpm install
# Build the frontend for production. # Build the frontend for production (both apps).
.PHONY: frontend-build .PHONY: frontend-build
frontend-build: install-deps frontend-build: install-deps
@echo "→ Building frontend for production..." @echo "→ Building frontend for production - main app & widget..."
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build @export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build:main
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build:widget
# Build only the main frontend app.
.PHONY: frontend-build-main
frontend-build-main: install-deps
@echo "→ Building main frontend app for production..."
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build:main
# Build only the widget frontend app.
.PHONY: frontend-build-widget
frontend-build-widget: install-deps
@echo "→ Building widget frontend app for production..."
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build:widget
# Run the Go backend server in development mode. # Run the Go backend server in development mode.
.PHONY: run-backend .PHONY: run-backend
@@ -40,13 +53,29 @@ run-backend:
@echo "→ Running backend..." @echo "→ Running backend..."
CGO_ENABLED=0 go run -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -X 'github.com/abhinavxd/libredesk/internal/version.Version=${VERSION}' -X 'main.frontendDir=frontend/dist'" cmd/*.go CGO_ENABLED=0 go run -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -X 'github.com/abhinavxd/libredesk/internal/version.Version=${VERSION}' -X 'main.frontendDir=frontend/dist'" cmd/*.go
# Run the JS frontend server in development mode. # Run the JS frontend server in development mode (main app only).
.PHONY: run-frontend .PHONY: run-frontend
run-frontend: run-frontend:
@echo "→ Installing frontend dependencies (if not already installed)..." @echo "→ Installing frontend dependencies (if not already installed)..."
@cd ${FRONTEND_DIR} && pnpm install @cd ${FRONTEND_DIR} && pnpm install
@echo "→ Running frontend..." @echo "→ Running main frontend app..."
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev @export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev:main
# Run the main frontend app in development mode.
.PHONY: run-frontend-main
run-frontend-main:
@echo "→ Installing frontend dependencies (if not already installed)..."
@cd ${FRONTEND_DIR} && pnpm install
@echo "→ Running main frontend app..."
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev:main
# Run the widget frontend app in development mode.
.PHONY: run-frontend-widget
run-frontend-widget:
@echo "→ Installing frontend dependencies (if not already installed)..."
@cd ${FRONTEND_DIR} && pnpm install
@echo "→ Running widget frontend app..."
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev:widget
# Build the backend binary. # Build the backend binary.
.PHONY: build-backend .PHONY: build-backend

193
cmd/ai_assistants.go Normal file
View File

@@ -0,0 +1,193 @@
package main
import (
"encoding/json"
"strconv"
"github.com/abhinavxd/libredesk/internal/envelope"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
"github.com/valyala/fasthttp"
"github.com/volatiletech/null/v9"
"github.com/zerodha/fastglue"
)
type aiAssisantRequest struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
ProductName string `json:"product_name"`
ProductDescription string `json:"product_description"`
AnswerLength string `json:"answer_length"`
AnswerTone string `json:"answer_tone"`
HandOff bool `json:"hand_off"`
HandOffTeam int `json:"hand_off_team"`
Enabled bool `json:"enabled"`
}
// handleGetAIAssistants returns all AI assistants from the database.
func handleGetAIAssistants(r *fastglue.Request) error {
var app = r.Context.(*App)
assistants, err := app.user.GetAIAssistants()
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(assistants)
}
// handleGetAIAssistant returns a single AI assistant by ID.
func handleGetAIAssistant(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.invalid", "name", "`id`"), nil, envelope.InputError)
}
assistant, err := app.user.GetAIAssistant(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(assistant)
}
// handleCreateAIAssistant creates a new AI assistant in the database.
func handleCreateAIAssistant(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req = aiAssisantRequest{}
)
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := validateAIAssistantRequest(req, app); err != nil {
return sendErrorEnvelope(r, err)
}
// Prepare meta data
meta := umodels.AIAssistantMeta{
ProductName: req.ProductName,
ProductDescription: req.ProductDescription,
AnswerLength: req.AnswerLength,
AnswerTone: req.AnswerTone,
HandOff: req.HandOff,
HandOffTeam: req.HandOffTeam,
}
metaBytes, err := json.Marshal(meta)
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorMarshalling", "name", "{globals.terms.meta}"), err.Error(), envelope.GeneralError)
}
// Create AI assistant in the database
assistant := &umodels.User{
FirstName: req.FirstName,
LastName: req.LastName,
Email: null.NewString(req.Email, req.Email != ""),
AvatarURL: null.NewString(req.AvatarURL, req.AvatarURL != ""),
Type: umodels.UserTypeAIAssistant,
Enabled: true,
Meta: metaBytes,
}
if err := app.user.CreateAIAssistant(assistant); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(assistant)
}
// handleUpdateAIAssistant updates an existing AI assistant in the database.
func handleUpdateAIAssistant(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req = aiAssisantRequest{}
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := validateAIAssistantRequest(req, app); err != nil {
return sendErrorEnvelope(r, err)
}
// Prepare meta data
meta := umodels.AIAssistantMeta{
ProductName: req.ProductName,
ProductDescription: req.ProductDescription,
AnswerLength: req.AnswerLength,
AnswerTone: req.AnswerTone,
HandOff: req.HandOff,
HandOffTeam: req.HandOffTeam,
}
metaBytes, err := json.Marshal(meta)
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error encoding meta data", err.Error(), envelope.GeneralError)
}
// Update AI assistant in the database
assistant := umodels.User{
FirstName: req.FirstName,
LastName: req.LastName,
Email: null.NewString(req.Email, req.Email != ""),
AvatarURL: null.NewString(req.AvatarURL, req.AvatarURL != ""),
Enabled: req.Enabled,
Meta: metaBytes,
}
if err := app.user.UpdateAIAssistant(id, assistant); err != nil {
return sendErrorEnvelope(r, err)
}
// Return the updated assistant
updatedAssistant, err := app.user.GetAIAssistant(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(updatedAssistant)
}
// handleDeleteAIAssistant soft deletes an AI assistant from the database.
func handleDeleteAIAssistant(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := app.user.SoftDeleteAIAssistant(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// validateAIAssistantRequest validates the fields of an aiAssisantRequest.
func validateAIAssistantRequest(req aiAssisantRequest, app *App) error {
if req.FirstName == "" {
return envelope.NewError("validation_error", app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil)
}
if req.ProductName == "" {
return envelope.NewError("validation_error", app.i18n.Ts("globals.messages.empty", "name", "`product_name`"), nil)
}
if req.ProductDescription == "" {
return envelope.NewError("validation_error", app.i18n.Ts("globals.messages.empty", "name", "`product_description`"), nil)
}
if req.AnswerLength == "" {
return envelope.NewError("validation_error", app.i18n.Ts("globals.messages.empty", "name", "`answer_length`"), nil)
}
if req.AnswerTone == "" {
return envelope.NewError("validation_error", app.i18n.Ts("globals.messages.empty", "name", "`answer_tone`"), nil)
}
return nil
}

1088
cmd/chat.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -469,34 +469,16 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
conversation, err := enforceConversationAccess(app, uuid, user) _, err = enforceConversationAccess(app, uuid, user)
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
// Make sure a user is assigned before resolving conversation.
if status == cmodels.StatusResolved && conversation.AssignedUserID.Int == 0 {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("conversation.resolveWithoutAssignee"), nil))
}
// Update conversation status. // Update conversation status.
if err := app.conversation.UpdateConversationStatus(uuid, 0 /**status_id**/, status, snoozedUntil, user); err != nil { if err := app.conversation.UpdateConversationStatus(uuid, 0 /**status_id**/, status, snoozedUntil, user); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
// If status is `Resolved`, send CSAT survey if enabled on inbox.
if status == cmodels.StatusResolved {
// Check if CSAT is enabled on the inbox and send CSAT survey message.
inbox, err := app.inbox.GetDBRecord(conversation.InboxID)
if err != nil {
return sendErrorEnvelope(r, err)
}
if inbox.CSATEnabled {
if err := app.conversation.SendCSATReply(user.ID, *conversation); err != nil {
return sendErrorEnvelope(r, err)
}
}
}
return r.SendEnvelope(true) return r.SendEnvelope(true)
} }
@@ -583,7 +565,7 @@ func handleUpdateContactCustomAttributes(r *fastglue.Request) error {
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
if err := app.user.UpdateCustomAttributes(conversation.ContactID, attributes); err != nil { if err := app.user.SaveCustomAttributes(conversation.ContactID, attributes, false); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
// Broadcast update. // Broadcast update.
@@ -708,10 +690,8 @@ func handleCreateConversation(r *fastglue.Request) error {
// Find or create contact. // Find or create contact.
contact := umodels.User{ contact := umodels.User{
Email: null.StringFrom(req.Email), Email: null.StringFrom(req.Email),
SourceChannelID: null.StringFrom(req.Email),
FirstName: req.FirstName, FirstName: req.FirstName,
LastName: req.LastName, LastName: req.LastName,
InboxID: req.InboxID,
} }
if err := app.user.CreateContact(&contact); err != nil { if err := app.user.CreateContact(&contact); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.contact}"), nil)) return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.contact}"), nil))
@@ -720,7 +700,6 @@ func handleCreateConversation(r *fastglue.Request) error {
// Create conversation // Create conversation
conversationID, conversationUUID, err := app.conversation.CreateConversation( conversationID, conversationUUID, err := app.conversation.CreateConversation(
contact.ID, contact.ID,
contact.ContactChannelID,
req.InboxID, req.InboxID,
"", /** last_message **/ "", /** last_message **/
time.Now(), /** last_message_at **/ time.Now(), /** last_message_at **/
@@ -744,7 +723,7 @@ func handleCreateConversation(r *fastglue.Request) error {
} }
// Send reply to the created conversation. // Send reply to the created conversation.
if _, err := app.conversation.SendReply(media, req.InboxID, auser.ID /**sender_id**/, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil { if _, err := app.conversation.SendReply(media, req.InboxID, auser.ID, contact.ID, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
// Delete the conversation if reply fails. // Delete the conversation if reply fails.
if err := app.conversation.DeleteConversation(conversationUUID); err != nil { if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
app.lo.Error("error deleting conversation", "error", err) app.lo.Error("error deleting conversation", "error", err)

View File

@@ -3,9 +3,16 @@ package main
import ( import (
"strconv" "strconv"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue" "github.com/zerodha/fastglue"
) )
type csatResponse struct {
Rating int `json:"rating"`
Feedback string `json:"feedback"`
}
// handleShowCSAT renders the CSAT page for a given csat. // handleShowCSAT renders the CSAT page for a given csat.
func handleShowCSAT(r *fastglue.Request) error { func handleShowCSAT(r *fastglue.Request) error {
var ( var (
@@ -72,7 +79,7 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
}) })
} }
if ratingI < 1 || ratingI > 5 { if ratingI < 0 || ratingI > 5 {
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{ return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": map[string]interface{}{ "Data": map[string]interface{}{
"ErrorMessage": "Invalid `rating`", "ErrorMessage": "Invalid `rating`",
@@ -103,3 +110,36 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
}, },
}) })
} }
// handleSubmitCSATResponse handles CSAT response submission from the widget API.
func handleSubmitCSATResponse(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
req = csatResponse{}
)
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid JSON", nil, envelope.InputError)
}
if req.Rating < 0 || req.Rating > 5 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Rating must be between 0 and 5 (0 means no rating)", nil, envelope.InputError)
}
// At least one of rating or feedback must be provided
if req.Rating == 0 && req.Feedback == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Either rating or feedback must be provided", nil, envelope.InputError)
}
if uuid == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid UUID", nil, envelope.InputError)
}
// Update CSAT response
if err := app.csat.UpdateResponse(uuid, req.Rating, req.Feedback); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}

View File

@@ -28,22 +28,6 @@ var (
} }
) )
// handleGetCustomAttribute retrieves a custom attribute by its ID.
func handleGetCustomAttribute(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
attribute, err := app.customAttribute.Get(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(attribute)
}
// handleGetCustomAttributes retrieves all custom attributes from the database. // handleGetCustomAttributes retrieves all custom attributes from the database.
func handleGetCustomAttributes(r *fastglue.Request) error { func handleGetCustomAttributes(r *fastglue.Request) error {

View File

@@ -1,12 +1,16 @@
package main package main
import ( import (
"encoding/json"
"mime" "mime"
"net/http" "net/http"
"path" "path"
"path/filepath" "path/filepath"
"strings"
"github.com/abhinavxd/libredesk/internal/envelope" "github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/httputil"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
"github.com/abhinavxd/libredesk/internal/ws" "github.com/abhinavxd/libredesk/internal/ws"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
"github.com/zerodha/fastglue" "github.com/zerodha/fastglue"
@@ -89,6 +93,20 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.PUT("/api/v1/tags/{id}", perm(handleUpdateTag, "tags:manage")) g.PUT("/api/v1/tags/{id}", perm(handleUpdateTag, "tags:manage"))
g.DELETE("/api/v1/tags/{id}", perm(handleDeleteTag, "tags:manage")) g.DELETE("/api/v1/tags/{id}", perm(handleDeleteTag, "tags:manage"))
// AI Assistants.
g.GET("/api/v1/ai-assistants", perm(handleGetAIAssistants, "ai:manage"))
g.GET("/api/v1/ai-assistants/{id}", perm(handleGetAIAssistant, "ai:manage"))
g.POST("/api/v1/ai-assistants", perm(handleCreateAIAssistant, "ai:manage"))
g.PUT("/api/v1/ai-assistants/{id}", perm(handleUpdateAIAssistant, "ai:manage"))
g.DELETE("/api/v1/ai-assistants/{id}", perm(handleDeleteAIAssistant, "ai:manage"))
// AI Snippets.
g.GET("/api/v1/ai-snippets", perm(handleGetAISnippets, "ai:manage"))
g.GET("/api/v1/ai-snippets/{id}", perm(handleGetAISnippet, "ai:manage"))
g.POST("/api/v1/ai-snippets", perm(handleCreateAISnippet, "ai:manage"))
g.PUT("/api/v1/ai-snippets/{id}", perm(handleUpdateAISnippet, "ai:manage"))
g.DELETE("/api/v1/ai-snippets/{id}", perm(handleDeleteAISnippet, "ai:manage"))
// Macros. // Macros.
g.GET("/api/v1/macros", auth(handleGetMacros)) g.GET("/api/v1/macros", auth(handleGetMacros))
g.GET("/api/v1/macros/{id}", perm(handleGetMacro, "macros:manage")) g.GET("/api/v1/macros/{id}", perm(handleGetMacro, "macros:manage"))
@@ -202,20 +220,61 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// Custom attributes. // Custom attributes.
g.GET("/api/v1/custom-attributes", auth(handleGetCustomAttributes)) g.GET("/api/v1/custom-attributes", auth(handleGetCustomAttributes))
g.POST("/api/v1/custom-attributes", perm(handleCreateCustomAttribute, "custom_attributes:manage")) g.POST("/api/v1/custom-attributes", perm(handleCreateCustomAttribute, "custom_attributes:manage"))
g.GET("/api/v1/custom-attributes/{id}", perm(handleGetCustomAttribute, "custom_attributes:manage"))
g.PUT("/api/v1/custom-attributes/{id}", perm(handleUpdateCustomAttribute, "custom_attributes:manage")) g.PUT("/api/v1/custom-attributes/{id}", perm(handleUpdateCustomAttribute, "custom_attributes:manage"))
g.DELETE("/api/v1/custom-attributes/{id}", perm(handleDeleteCustomAttribute, "custom_attributes:manage")) g.DELETE("/api/v1/custom-attributes/{id}", perm(handleDeleteCustomAttribute, "custom_attributes:manage"))
// Actvity logs. // Actvity logs.
g.GET("/api/v1/activity-logs", perm(handleGetActivityLogs, "activity_logs:manage")) g.GET("/api/v1/activity-logs", perm(handleGetActivityLogs, "activity_logs:manage"))
// Help Centers.
g.GET("/api/v1/help-centers", auth(handleGetHelpCenters))
g.GET("/api/v1/help-centers/{id}", auth(handleGetHelpCenter))
g.GET("/api/v1/help-centers/{id}/tree", auth(handleGetHelpCenterTree))
g.POST("/api/v1/help-centers", perm(handleCreateHelpCenter, "help_center:manage"))
g.PUT("/api/v1/help-centers/{id}", perm(handleUpdateHelpCenter, "help_center:manage"))
g.DELETE("/api/v1/help-centers/{id}", perm(handleDeleteHelpCenter, "help_center:manage"))
// Collections.
g.GET("/api/v1/help-centers/{hc_id}/collections", auth(handleGetCollections))
g.GET("/api/v1/help-centers/{hc_id}/collections/{id}", auth(handleGetCollection))
g.POST("/api/v1/help-centers/{hc_id}/collections", perm(handleCreateCollection, "help_center:manage"))
g.PUT("/api/v1/help-centers/{hc_id}/collections/{id}", perm(handleUpdateCollection, "help_center:manage"))
g.DELETE("/api/v1/help-centers/{hc_id}/collections/{id}", perm(handleDeleteCollection, "help_center:manage"))
g.PUT("/api/v1/collections/{id}/toggle", perm(handleToggleCollection, "help_center:manage"))
// Articles.
g.GET("/api/v1/collections/{col_id}/articles", auth(handleGetArticles))
g.GET("/api/v1/collections/{col_id}/articles/{id}", auth(handleGetArticle))
g.POST("/api/v1/collections/{col_id}/articles", perm(handleCreateArticle, "help_center:manage"))
g.PUT("/api/v1/collections/{col_id}/articles/{id}", perm(handleUpdateArticle, "help_center:manage"))
g.PUT("/api/v1/articles/{id}", perm(handleUpdateArticleByID, "help_center:manage"))
g.DELETE("/api/v1/collections/{col_id}/articles/{id}", perm(handleDeleteArticle, "help_center:manage"))
g.PUT("/api/v1/articles/{id}/status", perm(handleUpdateArticleStatus, "help_center:manage"))
// CSAT.
g.POST("/api/v1/csat/{uuid}/response", handleSubmitCSATResponse)
// WebSocket. // WebSocket.
g.GET("/ws", auth(func(r *fastglue.Request) error { g.GET("/ws", auth(func(r *fastglue.Request) error {
return handleWS(r, hub) return handleWS(r, hub)
})) }))
// Live chat widget websocket.
g.GET("/widget/ws", handleWidgetWS)
// Widget APIs.
g.GET("/api/v1/widget/chat/settings/launcher", handleGetChatLauncherSettings)
g.GET("/api/v1/widget/chat/settings", handleGetChatSettings)
g.POST("/api/v1/widget/chat/conversations/init", rateLimitWidget(widgetAuth(handleChatInit)))
g.GET("/api/v1/widget/chat/conversations", rateLimitWidget(widgetAuth(handleGetConversations)))
g.POST("/api/v1/widget/chat/conversations/{uuid}/update-last-seen", rateLimitWidget(widgetAuth(handleChatUpdateLastSeen)))
g.GET("/api/v1/widget/chat/conversations/{uuid}", rateLimitWidget(widgetAuth(handleChatGetConversation)))
g.POST("/api/v1/widget/chat/conversations/{uuid}/message", rateLimitWidget(widgetAuth(handleChatSendMessage)))
g.POST("/api/v1/widget/media/upload", rateLimitWidget(widgetAuth(handleWidgetMediaUpload)))
// Frontend pages. // Frontend pages.
g.GET("/", notAuthPage(serveIndexPage)) g.GET("/", notAuthPage(serveIndexPage))
g.GET("/widget", serveWidgetIndexPage)
g.GET("/inboxes/{all:*}", authPage(serveIndexPage)) g.GET("/inboxes/{all:*}", authPage(serveIndexPage))
g.GET("/teams/{all:*}", authPage(serveIndexPage)) g.GET("/teams/{all:*}", authPage(serveIndexPage))
g.GET("/views/{all:*}", authPage(serveIndexPage)) g.GET("/views/{all:*}", authPage(serveIndexPage))
@@ -225,8 +284,12 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/account/{all:*}", authPage(serveIndexPage)) g.GET("/account/{all:*}", authPage(serveIndexPage))
g.GET("/reset-password", notAuthPage(serveIndexPage)) g.GET("/reset-password", notAuthPage(serveIndexPage))
g.GET("/set-password", notAuthPage(serveIndexPage)) g.GET("/set-password", notAuthPage(serveIndexPage))
// FIXME: Don't need three separate routes for the same thing.
// Assets and static files.
// FIXME: Reduce the number of routes.
g.GET("/widget.js", serveWidgetJS)
g.GET("/assets/{all:*}", serveFrontendStaticFiles) g.GET("/assets/{all:*}", serveFrontendStaticFiles)
g.GET("/widget/assets/{all:*}", serveWidgetStaticFiles)
g.GET("/images/{all:*}", serveFrontendStaticFiles) g.GET("/images/{all:*}", serveFrontendStaticFiles)
g.GET("/static/public/{all:*}", serveStaticFiles) g.GET("/static/public/{all:*}", serveStaticFiles)
@@ -263,6 +326,77 @@ func serveIndexPage(r *fastglue.Request) error {
return nil return nil
} }
// validateWidgetReferer validates the Referer header against trusted domains configured in the live chat inbox settings.
func validateWidgetReferer(app *App, r *fastglue.Request, inboxID int) error {
// Get the Referer header from the request
referer := string(r.RequestCtx.Request.Header.Peek("Referer"))
// If no referer header is present, allow direct access.
if referer == "" {
return nil
}
// Get inbox configuration
inbox, err := app.inbox.GetDBRecord(inboxID)
if err != nil {
app.lo.Error("error fetching inbox for referer check", "inbox_id", inboxID, "error", err)
return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.inbox}"), nil, envelope.NotFoundError)
}
if !inbox.Enabled {
return r.SendErrorEnvelope(http.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
}
// Parse the live chat config
var config livechat.Config
if err := json.Unmarshal(inbox.Config, &config); err != nil {
app.lo.Error("error parsing live chat config for referer check", "error", err)
return r.SendErrorEnvelope(http.StatusInternalServerError, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError)
}
// If trusted domains list is empty, allow all referers
if len(config.TrustedDomains) == 0 {
return nil
}
// Check if the referer matches any of the trusted domains
if !httputil.IsOriginTrusted(referer, config.TrustedDomains) {
app.lo.Warn("widget request from untrusted referer blocked",
"referer", referer,
"inbox_id", inboxID,
"trusted_domains", config.TrustedDomains)
return r.SendErrorEnvelope(http.StatusForbidden, "Widget not allowed from this origin: "+referer, nil, envelope.PermissionError)
}
app.lo.Debug("widget request from trusted referer allowed", "referer", referer, "inbox_id", inboxID)
return nil
}
// serveWidgetIndexPage serves the widget index page of the application.
func serveWidgetIndexPage(r *fastglue.Request) error {
app := r.Context.(*App)
// Extract inbox ID and validate trusted domains if present
inboxID := r.RequestCtx.QueryArgs().GetUintOrZero("inbox_id")
if err := validateWidgetReferer(app, r, inboxID); err != nil {
return err
}
// Prevent caching of the index page.
r.RequestCtx.Response.Header.Add("Cache-Control", "no-store, no-cache, must-revalidate, post-check=0, pre-check=0")
r.RequestCtx.Response.Header.Add("Pragma", "no-cache")
r.RequestCtx.Response.Header.Add("Expires", "-1")
// Serve the index.html file from the embedded filesystem.
file, err := app.fs.Get(path.Join(widgetDir, "index.html"))
if err != nil {
return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.file}"), nil, envelope.NotFoundError)
}
r.RequestCtx.Response.Header.Set("Content-Type", "text/html")
r.RequestCtx.SetBody(file.ReadBytes())
return nil
}
// serveStaticFiles serves static assets from the embedded filesystem. // serveStaticFiles serves static assets from the embedded filesystem.
func serveStaticFiles(r *fastglue.Request) error { func serveStaticFiles(r *fastglue.Request) error {
app := r.Context.(*App) app := r.Context.(*App)
@@ -311,6 +445,47 @@ func serveFrontendStaticFiles(r *fastglue.Request) error {
return nil return nil
} }
// serveWidgetStaticFiles serves widget static assets from the embedded filesystem.
func serveWidgetStaticFiles(r *fastglue.Request) error {
app := r.Context.(*App)
filePath := string(r.RequestCtx.Path())
finalPath := filepath.Join(widgetDir, strings.TrimPrefix(filePath, "/widget"))
file, err := app.fs.Get(finalPath)
if err != nil {
return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.file}"), nil, envelope.NotFoundError)
}
// Set the appropriate Content-Type based on the file extension.
ext := filepath.Ext(filePath)
contentType := mime.TypeByExtension(ext)
if contentType == "" {
contentType = http.DetectContentType(file.ReadBytes())
}
r.RequestCtx.Response.Header.Set("Content-Type", contentType)
r.RequestCtx.SetBody(file.ReadBytes())
return nil
}
// serveWidgetJS serves the widget JavaScript file.
func serveWidgetJS(r *fastglue.Request) error {
app := r.Context.(*App)
// Set appropriate headers for JavaScript
r.RequestCtx.Response.Header.Set("Content-Type", "application/javascript")
r.RequestCtx.Response.Header.Set("Cache-Control", "public, max-age=3600") // Cache for 1 hour
// Serve the widget.js file from the embedded filesystem.
file, err := app.fs.Get("static/widget.js")
if err != nil {
return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.file}"), nil, envelope.NotFoundError)
}
r.RequestCtx.SetBody(file.ReadBytes())
return nil
}
// sendErrorEnvelope sends a standardized error response to the client. // sendErrorEnvelope sends a standardized error response to the client.
func sendErrorEnvelope(r *fastglue.Request, err error) error { func sendErrorEnvelope(r *fastglue.Request, err error) error {
e, ok := err.(envelope.Error) e, ok := err.(envelope.Error)

548
cmd/helpcenter.go Normal file
View File

@@ -0,0 +1,548 @@
package main
import (
"strconv"
"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/stringutil"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
// Help Centers
// handleGetHelpCenters returns all help centers from the database.
func handleGetHelpCenters(r *fastglue.Request) error {
app := r.Context.(*App)
helpCenters, err := app.helpcenter.GetAllHelpCenters()
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(helpCenters)
}
// handleGetHelpCenter returns a specific help center by ID.
func handleGetHelpCenter(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.invalid", "name", "`id`"), nil, envelope.InputError)
}
helpCenter, err := app.helpcenter.GetHelpCenterByID(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(helpCenter)
}
// handleCreateHelpCenter creates a new help center.
func handleCreateHelpCenter(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req = helpcenter.HelpCenterCreateRequest{}
)
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := validateHelpCenter(r, &req); err != nil {
return err
}
helpCenter, err := app.helpcenter.CreateHelpCenter(req)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(helpCenter)
}
// handleUpdateHelpCenter updates an existing help center.
func handleUpdateHelpCenter(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req = helpcenter.HelpCenterUpdateRequest{}
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := validateHelpCenter(r, &req); err != nil {
return err
}
helpCenter, err := app.helpcenter.UpdateHelpCenter(id, req)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(helpCenter)
}
// handleDeleteHelpCenter deletes a help center.
func handleDeleteHelpCenter(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.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := app.helpcenter.DeleteHelpCenter(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// Collections
// handleGetCollections returns all collections for a help center.
func handleGetCollections(r *fastglue.Request) error {
var (
app = r.Context.(*App)
helpCenterID, _ = strconv.Atoi(r.RequestCtx.UserValue("hc_id").(string))
err error
)
if helpCenterID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`help_center_id`"), nil, envelope.InputError)
}
// Check for locale filter
locale := string(r.RequestCtx.QueryArgs().Peek("locale"))
var collections []hcmodels.Collection
if locale != "" {
collections, err = app.helpcenter.GetCollectionsByHelpCenterAndLocale(helpCenterID, locale)
} else {
collections, err = app.helpcenter.GetCollectionsByHelpCenter(helpCenterID)
}
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(collections)
}
// handleGetCollection returns a specific collection by ID.
func handleGetCollection(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.invalid", "name", "`id`"), nil, envelope.InputError)
}
collection, err := app.helpcenter.GetCollectionByID(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(collection)
}
// handleCreateCollection creates a new collection.
func handleCreateCollection(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req = helpcenter.CollectionCreateRequest{}
helpCenterID, err = strconv.Atoi(r.RequestCtx.UserValue("hc_id").(string))
)
if helpCenterID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`help_center_id`"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := validateCollection(r, &req); err != nil {
return err
}
// Generate slug.
req.Slug = stringutil.GenerateSlug(req.Name, true)
collection, err := app.helpcenter.CreateCollection(helpCenterID, req)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(collection)
}
// handleUpdateCollection updates an existing collection.
func handleUpdateCollection(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req = helpcenter.CollectionUpdateRequest{}
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := validateCollection(r, &req); err != nil {
return err
}
// Generate slug
req.Slug = stringutil.GenerateSlug(req.Name, true)
collection, err := app.helpcenter.UpdateCollection(id, req)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(collection)
}
// handleDeleteCollection deletes a collection.
func handleDeleteCollection(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.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := app.helpcenter.DeleteCollection(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleToggleCollection toggles the published status of a collection.
func handleToggleCollection(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.invalid", "name", "`id`"), nil, envelope.InputError)
}
collection, err := app.helpcenter.ToggleCollectionPublished(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(collection)
}
// Articles
// handleGetArticles returns all articles for a collection.
func handleGetArticles(r *fastglue.Request) error {
var (
app = r.Context.(*App)
collectionID, _ = strconv.Atoi(r.RequestCtx.UserValue("col_id").(string))
err error
)
if collectionID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`collection_id`"), nil, envelope.InputError)
}
// Check for locale filter
locale := string(r.RequestCtx.QueryArgs().Peek("locale"))
var articles []hcmodels.Article
if locale != "" {
articles, err = app.helpcenter.GetArticlesByCollectionAndLocale(collectionID, locale)
} else {
articles, err = app.helpcenter.GetArticlesByCollection(collectionID)
}
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(articles)
}
// handleGetArticle returns a specific article by ID.
func handleGetArticle(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.invalid", "name", "`id`"), nil, envelope.InputError)
}
article, err := app.helpcenter.GetArticleByID(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(article)
}
// handleCreateArticle creates a new article.
func handleCreateArticle(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req = helpcenter.ArticleCreateRequest{}
collectionID, _ = strconv.Atoi(r.RequestCtx.UserValue("col_id").(string))
)
if collectionID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`collection_id`"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := validateArticle(r, &req); err != nil {
return err
}
// Generate slug
req.Slug = stringutil.GenerateSlug(req.Title, true)
if req.Status == "" {
req.Status = hcmodels.ArticleStatusDraft
}
article, err := app.helpcenter.CreateArticle(collectionID, req)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(article)
}
// handleUpdateArticle updates an existing article.
func handleUpdateArticle(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req = helpcenter.ArticleUpdateRequest{}
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := validateArticle(r, &req); err != nil {
return err
}
// Generate slug
req.Slug = stringutil.GenerateSlug(req.Title, true)
if req.Status == "" {
req.Status = hcmodels.ArticleStatusDraft
}
article, err := app.helpcenter.UpdateArticle(id, req)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(article)
}
// handleUpdateArticleByID updates an existing article by its ID (allows collection changes).
func handleUpdateArticleByID(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req = helpcenter.ArticleUpdateRequest{}
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := validateArticle(r, &req); err != nil {
return err
}
// Generate slug
req.Slug = stringutil.GenerateSlug(req.Title, true)
if req.Status == "" {
req.Status = hcmodels.ArticleStatusDraft
}
article, err := app.helpcenter.UpdateArticle(id, req)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(article)
}
// handleDeleteArticle deletes an article.
func handleDeleteArticle(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.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := app.helpcenter.DeleteArticle(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleUpdateArticleStatus updates the status of an article.
func handleUpdateArticleStatus(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req = helpcenter.UpdateStatusRequest{}
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if req.Status == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`status`"), nil, envelope.InputError)
}
article, err := app.helpcenter.UpdateArticleStatus(id, req.Status)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(article)
}
// handleGetHelpCenterTree returns the complete tree structure for a help center.
func handleGetHelpCenterTree(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.invalid", "name", "`id`"), nil, envelope.InputError)
}
// Get locale from query parameter (optional)
locale := string(r.RequestCtx.QueryArgs().Peek("locale"))
tree, err := app.helpcenter.GetHelpCenterTree(id, locale)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(tree)
}
func validateHelpCenter(r *fastglue.Request, req any) error {
app := r.Context.(*App)
switch v := req.(type) {
case *helpcenter.HelpCenterCreateRequest:
if v.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
if v.Slug == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`slug`"), nil, envelope.InputError)
}
if v.PageTitle == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`page_title`"), nil, envelope.InputError)
}
if v.DefaultLocale == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`default_locale`"), nil, envelope.InputError)
}
case *helpcenter.HelpCenterUpdateRequest:
if v.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
if v.Slug == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`slug`"), nil, envelope.InputError)
}
if v.PageTitle == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`page_title`"), nil, envelope.InputError)
}
if v.DefaultLocale == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`default_locale`"), nil, envelope.InputError)
}
}
return nil
}
func validateCollection(r *fastglue.Request, req any) error {
app := r.Context.(*App)
switch v := req.(type) {
case *helpcenter.CollectionCreateRequest:
if v.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
if v.Locale == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`locale`"), nil, envelope.InputError)
}
case *helpcenter.CollectionUpdateRequest:
if v.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
if v.Locale == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`locale`"), nil, envelope.InputError)
}
}
return nil
}
func validateArticle(r *fastglue.Request, req any) error {
app := r.Context.(*App)
switch v := req.(type) {
case *helpcenter.ArticleCreateRequest:
if v.Title == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`title`"), nil, envelope.InputError)
}
if v.Content == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`content`"), nil, envelope.InputError)
}
if v.Locale == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`locale`"), nil, envelope.InputError)
}
case *helpcenter.ArticleUpdateRequest:
if v.Title == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`title`"), nil, envelope.InputError)
}
if v.Content == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`content`"), nil, envelope.InputError)
}
if v.Locale == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`locale`"), nil, envelope.InputError)
}
}
return nil
}

View File

@@ -1,10 +1,12 @@
package main package main
import ( import (
"encoding/json"
"net/mail" "net/mail"
"strconv" "strconv"
"github.com/abhinavxd/libredesk/internal/envelope" "github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
imodels "github.com/abhinavxd/libredesk/internal/inbox/models" imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
"github.com/zerodha/fastglue" "github.com/zerodha/fastglue"
@@ -154,10 +156,12 @@ func handleDeleteInbox(r *fastglue.Request) error {
// validateInbox validates the inbox // validateInbox validates the inbox
func validateInbox(app *App, inbox imodels.Inbox) error { func validateInbox(app *App, inbox imodels.Inbox) error {
// Validate from address. // Validate from address only for email channels.
if inbox.Channel == "email" {
if _, err := mail.ParseAddress(inbox.From); err != nil { if _, err := mail.ParseAddress(inbox.From); err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalidFromAddress"), nil) return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalidFromAddress"), nil)
} }
}
if len(inbox.Config) == 0 { if len(inbox.Config) == 0 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "config"), nil) return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "config"), nil)
} }
@@ -167,5 +171,17 @@ func validateInbox(app *App, inbox imodels.Inbox) error {
if inbox.Channel == "" { if inbox.Channel == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "channel"), nil) return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "channel"), nil)
} }
// 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)
}
}
}
return nil return nil
} }

View File

@@ -25,8 +25,10 @@ import (
"github.com/abhinavxd/libredesk/internal/conversation/status" "github.com/abhinavxd/libredesk/internal/conversation/status"
"github.com/abhinavxd/libredesk/internal/csat" "github.com/abhinavxd/libredesk/internal/csat"
customAttribute "github.com/abhinavxd/libredesk/internal/custom_attribute" customAttribute "github.com/abhinavxd/libredesk/internal/custom_attribute"
"github.com/abhinavxd/libredesk/internal/helpcenter"
"github.com/abhinavxd/libredesk/internal/inbox" "github.com/abhinavxd/libredesk/internal/inbox"
"github.com/abhinavxd/libredesk/internal/inbox/channel/email" "github.com/abhinavxd/libredesk/internal/inbox/channel/email"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
imodels "github.com/abhinavxd/libredesk/internal/inbox/models" imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
"github.com/abhinavxd/libredesk/internal/macro" "github.com/abhinavxd/libredesk/internal/macro"
"github.com/abhinavxd/libredesk/internal/media" "github.com/abhinavxd/libredesk/internal/media"
@@ -35,6 +37,7 @@ import (
notifier "github.com/abhinavxd/libredesk/internal/notification" notifier "github.com/abhinavxd/libredesk/internal/notification"
emailnotifier "github.com/abhinavxd/libredesk/internal/notification/providers/email" emailnotifier "github.com/abhinavxd/libredesk/internal/notification/providers/email"
"github.com/abhinavxd/libredesk/internal/oidc" "github.com/abhinavxd/libredesk/internal/oidc"
"github.com/abhinavxd/libredesk/internal/ratelimit"
"github.com/abhinavxd/libredesk/internal/report" "github.com/abhinavxd/libredesk/internal/report"
"github.com/abhinavxd/libredesk/internal/role" "github.com/abhinavxd/libredesk/internal/role"
"github.com/abhinavxd/libredesk/internal/search" "github.com/abhinavxd/libredesk/internal/search"
@@ -132,7 +135,8 @@ func initConstants() *constants {
// initFS initializes the stuffbin FileSystem. // initFS initializes the stuffbin FileSystem.
func initFS() stuffbin.FileSystem { func initFS() stuffbin.FileSystem {
var files = []string{ var files = []string{
"frontend/dist", "frontend/dist/main",
"frontend/dist/widget",
"i18n", "i18n",
"static", "static",
} }
@@ -249,6 +253,20 @@ func initTag(db *sqlx.DB, i18n *i18n.I18n) *tag.Manager {
return mgr return mgr
} }
// initHelpCenter inits helpcenter manager.
func initHelpCenter(db *sqlx.DB, i18n *i18n.I18n) *helpcenter.Manager {
var lo = initLogger("helpcenter_manager")
mgr, err := helpcenter.New(helpcenter.Opts{
DB: db,
Lo: lo,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing helpcenter: %v", err)
}
return mgr
}
// initViews inits view manager. // initViews inits view manager.
func initView(db *sqlx.DB) *view.Manager { func initView(db *sqlx.DB) *view.Manager {
var lo = initLogger("view_manager") var lo = initLogger("view_manager")
@@ -464,6 +482,7 @@ func initMedia(db *sqlx.DB, i18n *i18n.I18n) *media.Manager {
Lo: lo, Lo: lo,
DB: db, DB: db,
I18n: i18n, I18n: i18n,
Secret: ko.String("upload.secret"),
}) })
if err != nil { if err != nil {
log.Fatalf("error initializing media: %v", err) log.Fatalf("error initializing media: %v", err)
@@ -572,11 +591,41 @@ func initEmailInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, usrS
return inbox, nil return inbox, nil
} }
// initLiveChatInbox initializes the live chat inbox.
func initLiveChatInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore) (inbox.Inbox, error) {
var config livechat.Config
// Load JSON data into Koanf.
if err := ko.Load(rawbytes.Provider([]byte(inboxRecord.Config)), kjson.Parser()); err != nil {
return nil, fmt.Errorf("loading config: %w", err)
}
if err := ko.UnmarshalWithConf("", &config, koanf.UnmarshalConf{Tag: "json"}); err != nil {
return nil, fmt.Errorf("unmarshalling `%s` %s config: %w", inboxRecord.Channel, inboxRecord.Name, err)
}
inbox, err := livechat.New(msgStore, usrStore, livechat.Opts{
ID: inboxRecord.ID,
Config: config,
Lo: initLogger("livechat_inbox"),
})
if err != nil {
return nil, fmt.Errorf("initializing `%s` inbox: `%s` error : %w", inboxRecord.Channel, inboxRecord.Name, err)
}
log.Printf("`%s` inbox successfully initialized", inboxRecord.Name)
return inbox, nil
}
// initializeInboxes handles inbox initialization. // initializeInboxes handles inbox initialization.
func initializeInboxes(inboxR imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore) (inbox.Inbox, error) { func initializeInboxes(inboxR imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore) (inbox.Inbox, error) {
switch inboxR.Channel { switch inboxR.Channel {
case "email": case "email":
return initEmailInbox(inboxR, msgStore, usrStore) return initEmailInbox(inboxR, msgStore, usrStore)
case "livechat":
return initLiveChatInbox(inboxR, msgStore, usrStore)
default: default:
return nil, fmt.Errorf("unknown inbox channel: %s", inboxR.Channel) return nil, fmt.Errorf("unknown inbox channel: %s", inboxR.Channel)
} }
@@ -771,9 +820,39 @@ func initPriority(db *sqlx.DB, i18n *i18n.I18n) *priority.Manager {
} }
// initAI inits AI manager. // initAI inits AI manager.
func initAI(db *sqlx.DB, i18n *i18n.I18n) *ai.Manager { func initAI(db *sqlx.DB, i18n *i18n.I18n, conversationStore *conversation.Manager, helpCenterStore *helpcenter.Manager) *ai.Manager {
lo := initLogger("ai") lo := initLogger("ai")
m, err := ai.New(ai.Opts{
embeddingCfg := ai.EmbeddingConfig{
Provider: ko.String("ai.embedding.provider"),
URL: ko.String("ai.embedding.url"),
APIKey: ko.String("ai.embedding.api_key"),
Model: ko.String("ai.embedding.model"),
Timeout: ko.Duration("ai.embedding.timeout"),
}
chunkingCfg := ai.ChunkingConfig{
MaxTokens: ko.Int("ai.embedding.chunking.max_tokens"),
MinTokens: ko.Int("ai.embedding.chunking.min_tokens"),
OverlapTokens: ko.Int("ai.embedding.chunking.overlap_tokens"),
}
completionCfg := ai.CompletionConfig{
Provider: ko.String("ai.completion.provider"),
URL: ko.String("ai.completion.url"),
APIKey: ko.String("ai.completion.api_key"),
Model: ko.String("ai.completion.model"),
Timeout: ko.Duration("ai.completion.timeout"),
MaxTokens: ko.Int("ai.completion.max_tokens"),
Temperature: ko.Float64("ai.completion.temperature"),
}
workerCfg := ai.WorkerConfig{
Workers: ko.Int("ai.worker.workers"),
Capacity: ko.Int("ai.worker.capacity"),
}
m, err := ai.New(embeddingCfg, chunkingCfg, completionCfg, workerCfg, conversationStore, helpCenterStore, ai.Opts{
DB: db, DB: db,
Lo: lo, Lo: lo,
I18n: i18n, I18n: i18n,
@@ -894,3 +973,12 @@ func getLogLevel(lvl string) logf.Level {
return logf.InfoLevel return logf.InfoLevel
} }
} }
// initRateLimit initializes the rate limiter.
func initRateLimit(redisClient *redis.Client) *ratelimit.Limiter {
var config ratelimit.Config
if err := ko.UnmarshalWithConf("rate_limit", &config, koanf.UnmarshalConf{Tag: "toml"}); err != nil {
log.Fatalf("error unmarshalling rate limit config: %v", err)
}
return ratelimit.New(redisClient, config)
}

View File

@@ -13,6 +13,8 @@ import (
_ "time/tzdata" _ "time/tzdata"
_ "github.com/pgvector/pgvector-go"
activitylog "github.com/abhinavxd/libredesk/internal/activity_log" activitylog "github.com/abhinavxd/libredesk/internal/activity_log"
"github.com/abhinavxd/libredesk/internal/ai" "github.com/abhinavxd/libredesk/internal/ai"
auth_ "github.com/abhinavxd/libredesk/internal/auth" auth_ "github.com/abhinavxd/libredesk/internal/auth"
@@ -21,6 +23,7 @@ import (
"github.com/abhinavxd/libredesk/internal/colorlog" "github.com/abhinavxd/libredesk/internal/colorlog"
"github.com/abhinavxd/libredesk/internal/csat" "github.com/abhinavxd/libredesk/internal/csat"
customAttribute "github.com/abhinavxd/libredesk/internal/custom_attribute" customAttribute "github.com/abhinavxd/libredesk/internal/custom_attribute"
"github.com/abhinavxd/libredesk/internal/helpcenter"
"github.com/abhinavxd/libredesk/internal/macro" "github.com/abhinavxd/libredesk/internal/macro"
notifier "github.com/abhinavxd/libredesk/internal/notification" notifier "github.com/abhinavxd/libredesk/internal/notification"
"github.com/abhinavxd/libredesk/internal/report" "github.com/abhinavxd/libredesk/internal/report"
@@ -35,6 +38,7 @@ import (
"github.com/abhinavxd/libredesk/internal/inbox" "github.com/abhinavxd/libredesk/internal/inbox"
"github.com/abhinavxd/libredesk/internal/media" "github.com/abhinavxd/libredesk/internal/media"
"github.com/abhinavxd/libredesk/internal/oidc" "github.com/abhinavxd/libredesk/internal/oidc"
"github.com/abhinavxd/libredesk/internal/ratelimit"
"github.com/abhinavxd/libredesk/internal/role" "github.com/abhinavxd/libredesk/internal/role"
"github.com/abhinavxd/libredesk/internal/setting" "github.com/abhinavxd/libredesk/internal/setting"
"github.com/abhinavxd/libredesk/internal/tag" "github.com/abhinavxd/libredesk/internal/tag"
@@ -54,7 +58,8 @@ var (
ko = koanf.New(".") ko = koanf.New(".")
ctx = context.Background() ctx = context.Background()
appName = "libredesk" appName = "libredesk"
frontendDir = "frontend/dist" frontendDir = "frontend/dist/main"
widgetDir = "frontend/dist/widget"
// Injected at build time. // Injected at build time.
buildString string buildString string
@@ -94,6 +99,8 @@ type App struct {
customAttribute *customAttribute.Manager customAttribute *customAttribute.Manager
report *report.Manager report *report.Manager
webhook *webhook.Manager webhook *webhook.Manager
rateLimit *ratelimit.Limiter
helpcenter *helpcenter.Manager
// Global state that stores data on an available app update. // Global state that stores data on an available app update.
update *AppUpdate update *AppUpdate
@@ -201,10 +208,19 @@ func main() {
sla = initSLA(db, team, settings, businessHours, notifier, template, user, i18n) sla = initSLA(db, team, settings, businessHours, notifier, template, user, i18n)
conversation = initConversations(i18n, sla, status, priority, wsHub, notifier, db, inbox, user, team, media, settings, csat, automation, template, webhook) conversation = initConversations(i18n, sla, status, priority, wsHub, notifier, db, inbox, user, team, media, settings, csat, automation, template, webhook)
autoassigner = initAutoAssigner(team, user, conversation) autoassigner = initAutoAssigner(team, user, conversation)
rateLimiter = initRateLimit(rdb)
helpcenter = initHelpCenter(db, i18n)
ai = initAI(db, i18n, conversation, helpcenter)
) )
automation.SetConversationStore(conversation)
wsHub.SetConversationStore(conversation)
automation.SetConversationStore(conversation)
conversation.SetAIStore(ai)
helpcenter.SetAIStore(ai)
// Start inboxes.
startInboxes(ctx, inbox, conversation, user) startInboxes(ctx, inbox, conversation, user)
go automation.Run(ctx, automationWorkers) go automation.Run(ctx, automationWorkers)
go autoassigner.Run(ctx, autoAssignInterval) go autoassigner.Run(ctx, autoAssignInterval)
go conversation.Run(ctx, messageIncomingQWorkers, messageOutgoingQWorkers, messageOutgoingScanInterval) go conversation.Run(ctx, messageIncomingQWorkers, messageOutgoingQWorkers, messageOutgoingScanInterval)
@@ -215,6 +231,7 @@ func main() {
go sla.SendNotifications(ctx) go sla.SendNotifications(ctx)
go media.DeleteUnlinkedMedia(ctx) go media.DeleteUnlinkedMedia(ctx)
go user.MonitorAgentAvailability(ctx) go user.MonitorAgentAvailability(ctx)
go ai.StartConversationCompletions()
var app = &App{ var app = &App{
lo: lo, lo: lo,
@@ -246,8 +263,10 @@ func main() {
role: initRole(db, i18n), role: initRole(db, i18n),
tag: initTag(db, i18n), tag: initTag(db, i18n),
macro: initMacro(db, i18n), macro: initMacro(db, i18n),
ai: initAI(db, i18n), ai: ai,
webhook: webhook, webhook: webhook,
rateLimit: rateLimiter,
helpcenter: helpcenter,
} }
app.consts.Store(constants) app.consts.Store(constants)
@@ -295,6 +314,8 @@ func main() {
webhook.Close() webhook.Close()
colorlog.Red("Shutting down conversation...") colorlog.Red("Shutting down conversation...")
conversation.Close() conversation.Close()
colorlog.Red("Shutting down AI...")
app.ai.StopConversationCompletions()
colorlog.Red("Shutting down SLA...") colorlog.Red("Shutting down SLA...")
sla.Close() sla.Close()
colorlog.Red("Shutting down database...") colorlog.Red("Shutting down database...")

View File

@@ -143,14 +143,18 @@ func handleMediaUpload(r *fastglue.Request) error {
} }
// handleServeMedia serves uploaded media. // handleServeMedia serves uploaded media.
// Supports both authenticated agent access and unauthenticated access via signed URLs.
func handleServeMedia(r *fastglue.Request) error { func handleServeMedia(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
uuid = r.RequestCtx.UserValue("uuid").(string) uuid = r.RequestCtx.UserValue("uuid").(string)
) )
user, err := app.user.GetAgent(auser.ID, "") // Check if user is authenticated (agent access)
auser := r.RequestCtx.UserValue("user")
if auser != nil {
// Authenticated.
user, err := app.user.GetAgent(auser.(amodels.User).ID, "")
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
@@ -182,6 +186,8 @@ func handleServeMedia(r *fastglue.Request) error {
if !allowed { if !allowed {
return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.UnauthorizedError) return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.UnauthorizedError)
} }
}
// If no authenticated user, the middleware has already verified the request signature serve the file.
consts := app.consts.Load().(*constants) consts := app.consts.Load().(*constants)
switch consts.UploadProvider { switch consts.UploadProvider {
case "fs": case "fs":

View File

@@ -4,6 +4,7 @@ import (
"strconv" "strconv"
amodels "github.com/abhinavxd/libredesk/internal/auth/models" amodels "github.com/abhinavxd/libredesk/internal/auth/models"
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
"github.com/abhinavxd/libredesk/internal/envelope" "github.com/abhinavxd/libredesk/internal/envelope"
medModels "github.com/abhinavxd/libredesk/internal/media/models" medModels "github.com/abhinavxd/libredesk/internal/media/models"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
@@ -41,7 +42,7 @@ func handleGetMessages(r *fastglue.Request) error {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
messages, pageSize, err := app.conversation.GetConversationMessages(uuid, page, pageSize) messages, pageSize, err := app.conversation.GetConversationMessages(uuid, []string{cmodels.MessageIncoming, cmodels.MessageOutgoing, cmodels.MessageActivity}, nil, page, pageSize)
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
@@ -52,10 +53,11 @@ func handleGetMessages(r *fastglue.Request) error {
for j := range messages[i].Attachments { for j := range messages[i].Attachments {
messages[i].Attachments[j].URL = app.media.GetURL(messages[i].Attachments[j].UUID) messages[i].Attachments[j].URL = app.media.GetURL(messages[i].Attachments[j].UUID)
} }
// Redact CSAT survey link
messages[i].CensorCSATContent()
} }
// Process CSAT status for all messages (will only affect CSAT messages)
app.conversation.ProcessCSATStatus(messages)
return r.SendEnvelope(envelope.PageResults{ return r.SendEnvelope(envelope.PageResults{
Total: total, Total: total,
Results: messages, Results: messages,
@@ -89,8 +91,10 @@ func handleGetMessage(r *fastglue.Request) error {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
// Redact CSAT survey link // Process CSAT status for the message (will only affect CSAT messages)
message.CensorCSATContent() messages := []cmodels.Message{message}
app.conversation.ProcessCSATStatus(messages)
message = messages[0]
for j := range message.Attachments { for j := range message.Attachments {
message.Attachments[j].URL = app.media.GetURL(message.Attachments[j].UUID) message.Attachments[j].URL = app.media.GetURL(message.Attachments[j].UUID)
@@ -150,6 +154,15 @@ func handleSendMessage(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
} }
// Make sure the inbox is enabled.
inbox, err := app.inbox.GetDBRecord(conv.InboxID)
if err != nil {
return sendErrorEnvelope(r, err)
}
if !inbox.Enabled {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
}
// Prepare attachments. // Prepare attachments.
var media = make([]medModels.Media, 0, len(req.Attachments)) var media = make([]medModels.Media, 0, len(req.Attachments))
for _, id := range req.Attachments { for _, id := range req.Attachments {
@@ -168,7 +181,8 @@ func handleSendMessage(r *fastglue.Request) error {
} }
return r.SendEnvelope(message) return r.SendEnvelope(message)
} }
message, err := app.conversation.SendReply(media, conv.InboxID, user.ID, cuuid, req.Message, req.To, req.CC, req.BCC, map[string]any{} /**meta**/)
message, err := app.conversation.SendReply(media, conv.InboxID, user.ID, conv.ContactID, cuuid, req.Message, req.To, req.CC, req.BCC, map[string]any{} /**meta**/)
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }

View File

@@ -97,6 +97,23 @@ func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error { return func(r *fastglue.Request) error {
var app = r.Context.(*App) var app = r.Context.(*App)
// For media uploads, check if signature is provided in the query parameters, if so, verify it.
path := string(r.RequestCtx.Path())
if strings.HasPrefix(path, "/uploads/") {
signature := string(r.RequestCtx.QueryArgs().Peek("signature"))
expires := string(r.RequestCtx.QueryArgs().Peek("expires"))
if signature != "" && expires != "" {
if err := app.media.VerifySignature(r); err != nil {
app.lo.Error("error verifying media signature", "error",
err, "path", string(r.RequestCtx.Path()), "query", string(r.RequestCtx.QueryArgs().QueryString()))
return r.SendErrorEnvelope(http.StatusUnauthorized, "signature verification failed", nil, envelope.PermissionError)
}
return handler(r)
}
// If no signature, continue with normal authentication.
}
// Authenticate user using shared authentication logic // Authenticate user using shared authentication logic
user, err := authenticateUser(r, app) user, err := authenticateUser(r, app)
if err != nil { if err != nil {

108
cmd/snippets.go Normal file
View File

@@ -0,0 +1,108 @@
package main
import (
"strconv"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
// snippetReq represents the request payload for snippets creation and updates.
type snippetReq struct {
Content string `json:"content"`
Enabled bool `json:"enabled"`
}
// validateSnippetReq validates the snippet request payload.
func validateSnippetReq(r *fastglue.Request, snippetData *snippetReq) error {
var app = r.Context.(*App)
if snippetData.Content == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`content`"), nil, envelope.InputError)
}
return nil
}
// handleGetAISnippets returns all AI snippets from the database.
func handleGetAISnippets(r *fastglue.Request) error {
var app = r.Context.(*App)
snippets, err := app.ai.GetKnowledgeBaseItems()
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(snippets)
}
// handleGetAISnippet returns a single AI snippet by ID.
func handleGetAISnippet(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.invalid", "name", "`id`"), nil, envelope.InputError)
}
snippet, err := app.ai.GetKnowledgeBaseItem(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(snippet)
}
// handleCreateAISnippet creates a new AI snippet in the database.
func handleCreateAISnippet(r *fastglue.Request) error {
var (
app = r.Context.(*App)
snippetData snippetReq
)
if err := r.Decode(&snippetData, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := validateSnippetReq(r, &snippetData); err != nil {
return err
}
snippet, err := app.ai.CreateKnowledgeBaseItem("snippet", snippetData.Content, snippetData.Enabled)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(snippet)
}
// handleUpdateAISnippet updates an existing AI snippet in the database.
func handleUpdateAISnippet(r *fastglue.Request) error {
var (
app = r.Context.(*App)
snippetData snippetReq
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&snippetData, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := validateSnippetReq(r, &snippetData); err != nil {
return err
}
snippet, err := app.ai.UpdateKnowledgeBaseItem(id, "snippet", snippetData.Content, snippetData.Enabled)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(snippet)
}
// handleDeleteAISnippet deletes an AI snippet from the database.
func handleDeleteAISnippet(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.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := app.ai.DeleteKnowledgeBaseItem(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}

View File

@@ -35,6 +35,8 @@ var migList = []migFunc{
{"v0.5.0", migrations.V0_5_0}, {"v0.5.0", migrations.V0_5_0},
{"v0.6.0", migrations.V0_6_0}, {"v0.6.0", migrations.V0_6_0},
{"v0.7.0", migrations.V0_7_0}, {"v0.7.0", migrations.V0_7_0},
{"v0.8.0", migrations.V0_8_0},
{"v0.9.0", migrations.V0_9_0},
} }
// upgrade upgrades the database to the current version by running SQL migration files // upgrade upgrades the database to the current version by running SQL migration files

167
cmd/widget_middleware.go Normal file
View File

@@ -0,0 +1,167 @@
package main
import (
"fmt"
"strconv"
"strings"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
const (
// Context keys for storing authenticated widget data
ctxWidgetClaims = "widget_claims"
ctxWidgetInboxID = "widget_inbox_id"
ctxWidgetContactID = "widget_contact_id"
ctxWidgetInbox = "widget_inbox"
// Header sent in every widget request to identify the inbox
hdrWidgetInboxID = "X-Libredesk-Inbox-ID"
)
// widgetAuth middleware authenticates widget requests using JWT and inbox validation.
// It always validates the inbox from X-Libredesk-Inbox-ID header, and conditionally validates JWT.
// For /conversations/init without JWT, it allows visitor creation while still validating inbox.
func widgetAuth(next func(*fastglue.Request) error) func(*fastglue.Request) error {
return func(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
// Always extract and validate inbox_id from custom header
inboxIDHeader := string(r.RequestCtx.Request.Header.Peek(hdrWidgetInboxID))
if inboxIDHeader == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
}
inboxID, err := strconv.Atoi(inboxIDHeader)
if err != nil || inboxID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
}
// Always fetch and validate inbox
inbox, err := app.inbox.GetDBRecord(inboxID)
if err != nil {
app.lo.Error("error fetching inbox", "inbox_id", inboxID, "error", err)
return sendErrorEnvelope(r, err)
}
if !inbox.Enabled {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
}
// Check if inbox is the correct type for widget requests
if inbox.Channel != livechat.ChannelLiveChat {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
}
// Always store inbox data in context
r.RequestCtx.SetUserValue(ctxWidgetInboxID, inboxID)
r.RequestCtx.SetUserValue(ctxWidgetInbox, inbox)
// Extract JWT from Authorization header (Bearer token)
authHeader := string(r.RequestCtx.Request.Header.Peek("Authorization"))
// For init endpoint, allow requests without JWT (visitor creation)
if authHeader == "" && strings.Contains(string(r.RequestCtx.Path()), "/conversations/init") {
return next(r)
}
// For all other requests, require JWT
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError)
}
jwtToken := strings.TrimPrefix(authHeader, "Bearer ")
// Verify JWT using inbox secret
claims, err := verifyStandardJWT(jwtToken, inbox.Secret.String)
if err != nil {
app.lo.Error("invalid JWT", "jwt", jwtToken, "error", err)
return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError)
}
// Resolve user/contact ID from JWT claims
contactID, err := resolveUserIDFromClaims(app, claims)
if err != nil {
envErr, ok := err.(envelope.Error)
if ok && envErr.ErrorType != envelope.NotFoundError {
app.lo.Error("error resolving user ID from JWT claims", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil, envelope.GeneralError)
}
}
// Store authenticated data in request context for downstream handlers
r.RequestCtx.SetUserValue(ctxWidgetClaims, claims)
r.RequestCtx.SetUserValue(ctxWidgetContactID, contactID)
return next(r)
}
}
// Helper functions to extract authenticated data from request context
// getWidgetInboxID extracts inbox ID from request context
func getWidgetInboxID(r *fastglue.Request) (int, error) {
val := r.RequestCtx.UserValue(ctxWidgetInboxID)
if val == nil {
return 0, fmt.Errorf("widget middleware not applied: missing inbox ID in context")
}
inboxID, ok := val.(int)
if !ok {
return 0, fmt.Errorf("invalid inbox ID type in context")
}
return inboxID, nil
}
// getWidgetContactID extracts contact ID from request context
func getWidgetContactID(r *fastglue.Request) (int, error) {
val := r.RequestCtx.UserValue(ctxWidgetContactID)
if val == nil {
return 0, fmt.Errorf("widget middleware not applied: missing contact ID in context")
}
contactID, ok := val.(int)
if !ok {
return 0, fmt.Errorf("invalid contact ID type in context")
}
return contactID, nil
}
// getWidgetInbox extracts inbox model from request context
func getWidgetInbox(r *fastglue.Request) (imodels.Inbox, error) {
val := r.RequestCtx.UserValue(ctxWidgetInbox)
if val == nil {
return imodels.Inbox{}, fmt.Errorf("widget middleware not applied: missing inbox in context")
}
inbox, ok := val.(imodels.Inbox)
if !ok {
return imodels.Inbox{}, fmt.Errorf("invalid inbox type in context")
}
return inbox, nil
}
// getWidgetClaimsOptional extracts JWT claims from request context, returns nil if not set
func getWidgetClaimsOptional(r *fastglue.Request) *Claims {
val := r.RequestCtx.UserValue(ctxWidgetClaims)
if val == nil {
return nil
}
if claims, ok := val.(Claims); ok {
return &claims
}
return nil
}
// rateLimitWidget applies rate limiting to widget endpoints.
func rateLimitWidget(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
app := r.Context.(*App)
if err := app.rateLimit.CheckWidgetLimit(r.RequestCtx); err != nil {
return err
}
return handler(r)
}
}

272
cmd/widget_ws.go Normal file
View File

@@ -0,0 +1,272 @@
package main
import (
"encoding/json"
"fmt"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
"github.com/fasthttp/websocket"
"github.com/zerodha/fastglue"
)
// Widget WebSocket message types
const (
WidgetMsgTypeJoin = "join"
WidgetMsgTypeMessage = "message"
WidgetMsgTypeTyping = "typing"
WidgetMsgTypePing = "ping"
WidgetMsgTypePong = "pong"
WidgetMsgTypeError = "error"
WidgetMsgTypeNewMsg = "new_message"
WidgetMsgTypeStatus = "status"
WidgetMsgTypeJoined = "joined"
)
// WidgetMessage represents a message sent through the widget WebSocket
type WidgetMessage struct {
Type string `json:"type"`
JWT string `json:"jwt,omitempty"`
Data any `json:"data"`
}
type WidgetInboxJoinRequest struct {
InboxID int `json:"inbox_id"`
}
// WidgetMessageData represents a chat message through the widget
type WidgetMessageData struct {
ConversationUUID string `json:"conversation_uuid"`
Content string `json:"content"`
SenderName string `json:"sender_name,omitempty"`
SenderType string `json:"sender_type"`
Timestamp int64 `json:"timestamp"`
}
// WidgetTypingData represents typing indicator data
type WidgetTypingData struct {
ConversationUUID string `json:"conversation_uuid"`
IsTyping bool `json:"is_typing"`
}
// handleWidgetWS handles the widget WebSocket connection for live chat.
func handleWidgetWS(r *fastglue.Request) error {
var app = r.Context.(*App)
if err := upgrader.Upgrade(r.RequestCtx, func(conn *websocket.Conn) {
// To store client and live chat references for cleanup.
var client *livechat.Client
var liveChat *livechat.LiveChat
// Clean up client when connection closes.
defer func() {
conn.Close()
if client != nil && liveChat != nil {
liveChat.RemoveClient(client)
close(client.Channel)
app.lo.Debug("cleaned up client on websocket disconnect", "client_id", client.ID)
}
}()
// Read messages from the WebSocket connection.
for {
var msg WidgetMessage
if err := conn.ReadJSON(&msg); err != nil {
app.lo.Debug("widget websocket connection closed", "error", err)
break
}
switch msg.Type {
// Inbox join request.
case WidgetMsgTypeJoin:
var joinedClient *livechat.Client
var joinedLiveChat *livechat.LiveChat
var err error
if joinedClient, joinedLiveChat, err = handleInboxJoin(app, conn, &msg); err != nil {
app.lo.Error("error handling widget join", "error", err)
sendWidgetError(conn, "Failed to join conversation")
continue
}
// Store the client and livechat reference for cleanup.
client = joinedClient
liveChat = joinedLiveChat
// Typing.
case WidgetMsgTypeTyping:
if err := handleWidgetTyping(app, &msg); err != nil {
app.lo.Error("error handling widget typing", "error", err)
continue
}
// Ping.
case WidgetMsgTypePing:
if err := conn.WriteJSON(WidgetMessage{
Type: WidgetMsgTypePong,
}); err != nil {
app.lo.Error("error writing pong to widget client", "error", err)
}
}
}
}); err != nil {
app.lo.Error("error upgrading widget websocket connection", "error", err)
}
return nil
}
// handleInboxJoin handles a websocket join request for a live chat inbox.
func handleInboxJoin(app *App, conn *websocket.Conn, msg *WidgetMessage) (*livechat.Client, *livechat.LiveChat, error) {
joinDataBytes, err := json.Marshal(msg.Data)
if err != nil {
return nil, nil, fmt.Errorf("invalid join data: %w", err)
}
var joinData WidgetInboxJoinRequest
if err := json.Unmarshal(joinDataBytes, &joinData); err != nil {
return nil, nil, fmt.Errorf("invalid join data format: %w", err)
}
// Validate JWT with inbox secret
claims, err := validateWidgetMessageJWT(app, msg.JWT, joinData.InboxID)
if err != nil {
return nil, nil, fmt.Errorf("JWT validation failed: %w", err)
}
// Resolve user ID.
userID, err := resolveUserIDFromClaims(app, claims)
if err != nil {
return nil, nil, fmt.Errorf("failed to resolve user ID from claims: %w", err)
}
// Make sure inbox is active.
inbox, err := app.inbox.GetDBRecord(joinData.InboxID)
if err != nil {
return nil, nil, fmt.Errorf("inbox not found: %w", err)
}
if !inbox.Enabled {
return nil, nil, fmt.Errorf("inbox is not enabled")
}
// Get live chat inbox
lcInbox, err := app.inbox.Get(inbox.ID)
if err != nil {
return nil, nil, fmt.Errorf("live chat inbox not found: %w", err)
}
// Assert type.
liveChat, ok := lcInbox.(*livechat.LiveChat)
if !ok {
return nil, nil, fmt.Errorf("inbox is not a live chat inbox")
}
// Add client to live chat session
userIDStr := fmt.Sprintf("%d", userID)
client, err := liveChat.AddClient(userIDStr)
if err != nil {
app.lo.Error("error adding client to live chat", "error", err, "user_id", userIDStr)
return nil, nil, err
}
// Start listening for messages from the live chat channel.
go func() {
for msgData := range client.Channel {
if err := conn.WriteMessage(websocket.TextMessage, msgData); err != nil {
app.lo.Error("error forwarding message to widget client", "error", err)
return
}
}
}()
// Send join confirmation
joinResp := WidgetMessage{
Type: WidgetMsgTypeJoined,
Data: map[string]string{
"message": "namaste!",
},
}
if err := conn.WriteJSON(joinResp); err != nil {
return nil, nil, err
}
app.lo.Debug("widget client joined live chat", "user_id", userIDStr, "inbox_id", joinData.InboxID)
return client, liveChat, nil
}
// handleWidgetTyping handles typing indicators
func handleWidgetTyping(app *App, msg *WidgetMessage) error {
typingDataBytes, err := json.Marshal(msg.Data)
if err != nil {
app.lo.Error("error marshalling typing data", "error", err)
return fmt.Errorf("invalid typing data: %w", err)
}
var typingData WidgetTypingData
if err := json.Unmarshal(typingDataBytes, &typingData); err != nil {
app.lo.Error("error unmarshalling typing data", "error", err)
return fmt.Errorf("invalid typing data format: %w", err)
}
// Get conversation to retrieve inbox ID for JWT validation
if typingData.ConversationUUID == "" {
return fmt.Errorf("conversation UUID is required for typing messages")
}
conversation, err := app.conversation.GetConversation(0, typingData.ConversationUUID)
if err != nil {
app.lo.Error("error fetching conversation for typing", "conversation_uuid", typingData.ConversationUUID, "error", err)
return fmt.Errorf("conversation not found: %w", err)
}
// Validate JWT with inbox secret
claims, err := validateWidgetMessageJWT(app, msg.JWT, conversation.InboxID)
if err != nil {
return fmt.Errorf("JWT validation failed: %w", err)
}
userID := claims.UserID
// Broadcast typing status to agents via conversation manager
// Set broadcastToWidgets=false to avoid echoing back to widget clients
app.conversation.BroadcastTypingToConversation(typingData.ConversationUUID, typingData.IsTyping, false)
app.lo.Debug("Broadcasted typing data from widget user to agents", "user_id", userID, "is_typing", typingData.IsTyping, "conversation_uuid", typingData.ConversationUUID)
return nil
}
// validateWidgetMessageJWT validates the incoming widget message JWT using inbox secret
func validateWidgetMessageJWT(app *App, jwtToken string, inboxID int) (Claims, error) {
if jwtToken == "" {
return Claims{}, fmt.Errorf("JWT token is empty")
}
if inboxID <= 0 {
return Claims{}, fmt.Errorf("inbox ID is required for JWT validation")
}
// Get inbox to retrieve secret for JWT verification
inbox, err := app.inbox.GetDBRecord(inboxID)
if err != nil {
return Claims{}, fmt.Errorf("inbox not found: %w", err)
}
if !inbox.Secret.Valid || inbox.Secret.String == "" {
return Claims{}, fmt.Errorf("inbox secret not configured for JWT verification")
}
// Use the existing verifyStandardJWT function which properly validates with inbox secret
claims, err := verifyStandardJWT(jwtToken, inbox.Secret.String)
if err != nil {
return Claims{}, fmt.Errorf("JWT validation failed: %w", err)
}
return claims, nil
}
// sendWidgetError sends an error message to the widget client
func sendWidgetError(conn *websocket.Conn, message string) {
errorMsg := WidgetMessage{
Type: WidgetMsgTypeError,
Data: map[string]string{
"message": message,
},
}
conn.WriteJSON(errorMsg)
}

View File

@@ -122,3 +122,37 @@ unsnooze_interval = "5m"
[sla] [sla]
# How often to evaluate SLA compliance for conversations # How often to evaluate SLA compliance for conversations
evaluation_interval = "5m" evaluation_interval = "5m"
[rate_limit]
[rate_limit.widget]
enabled = true
requests_per_minute = 100
[ai]
[ai.embedding]
provider = "openai"
url = "https://api.openai.com/v1/embeddings"
api_key = "secret"
model = "text-embedding-3-small"
timeout = "20s"
[ai.embedding.chunking]
# Maximum tokens per chunk (increase for larger context models)
max_tokens = 2000
# Minimum tokens per chunk (smaller chunks may lack context)
min_tokens = 400
# Overlap tokens between chunks for context continuity
overlap_tokens = 150
[ai.completion]
provider = "openai"
url = "https://api.openai.com/v1/chat/completions"
api_key = "secret"
model = "gpt-oss:20b"
temperature = 0.2
max_tokens = 1000
timeout = "30s"
[ai.worker]
workers = 50
capacity = 10000

59
frontend/README-SETUP.md Normal file
View File

@@ -0,0 +1,59 @@
# Libredesk Frontend - Multi-App Setup
This frontend supports both the main Libredesk application and a chat widget as separate Vue applications sharing common UI components.
## Project Structure
```
frontend/
├── apps/
│ ├── main/ # Main Libredesk application
│ │ ├── src/
│ │ └── index.html
│ └── widget/ # Chat widget application
│ ├── src/
│ └── index.html
├── shared-ui/ # Shared UI components (shadcn/ui)
│ ├── components/
│ │ └── ui/ # shadcn/ui components
│ ├── lib/ # Utility functions
│ └── assets/ # Shared styles
└── package.json
```
## Development
Check Makefile for available commands.
## Shared UI Components
The `shared-ui` directory contains all the shadcn/ui components that can be used in both apps.
### Using Shared Components
```vue
<script setup>
import { Button } from '@shared-ui/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@shared-ui/components/ui/card'
import { Input } from '@shared-ui/components/ui/input'
</script>
<template>
<Card>
<CardHeader>
<CardTitle>Example Card</CardTitle>
</CardHeader>
<CardContent>
<Input placeholder="Type something..." />
<Button>Submit</Button>
</CardContent>
</Card>
</template>
```
### Path Aliases
- `@shared-ui` - Points to the shared-ui directory
- `@main` - Points to apps/main/src
- `@widget` - Points to apps/widget/src
- `@` - Points to the current app's src directory (context-dependent)

View File

@@ -112,26 +112,26 @@
<script setup> <script setup>
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { RouterView } from 'vue-router' import { RouterView } from 'vue-router'
import { useUserStore } from '@/stores/user' import { useUserStore } from './stores/user'
import { initWS } from '@/websocket.js' import { initWS } from './websocket.js'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { EMITTER_EVENTS } from './constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from './composables/useEmitter'
import { handleHTTPError } from '@/utils/http' import { handleHTTPError } from './utils/http'
import { useConversationStore } from './stores/conversation' import { useConversationStore } from './stores/conversation'
import { useInboxStore } from '@/stores/inbox' import { useInboxStore } from './stores/inbox'
import { useUsersStore } from '@/stores/users' import { useUsersStore } from './stores/users'
import { useTeamStore } from '@/stores/team' import { useTeamStore } from './stores/team'
import { useSlaStore } from '@/stores/sla' import { useSlaStore } from './stores/sla'
import { useMacroStore } from '@/stores/macro' import { useMacroStore } from './stores/macro'
import { useTagStore } from '@/stores/tag' import { useTagStore } from './stores/tag'
import { useCustomAttributeStore } from '@/stores/customAttributes' import { useCustomAttributeStore } from './stores/customAttributes'
import { useIdleDetection } from '@/composables/useIdleDetection' import { useIdleDetection } from './composables/useIdleDetection'
import PageHeader from './components/layout/PageHeader.vue' import PageHeader from './components/layout/PageHeader.vue'
import ViewForm from '@/features/view/ViewForm.vue' import ViewForm from '@/features/view/ViewForm.vue'
import AppUpdate from '@/components/update/AppUpdate.vue' import AppUpdate from '@main/components/update/AppUpdate.vue'
import api from '@/api' import api from './api'
import { toast as sooner } from 'vue-sonner' import { toast as sooner } from 'vue-sonner'
import Sidebar from '@/components/sidebar/Sidebar.vue' import Sidebar from '@main/components/sidebar/Sidebar.vue'
import Command from '@/features/command/CommandBox.vue' import Command from '@/features/command/CommandBox.vue'
import CreateConversation from '@/features/conversation/CreateConversation.vue' import CreateConversation from '@/features/conversation/CreateConversation.vue'
import { Inbox, Shield, FileLineChart, BookUser } from 'lucide-vue-next' import { Inbox, Shield, FileLineChart, BookUser } from 'lucide-vue-next'
@@ -147,9 +147,9 @@ import {
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
SidebarProvider SidebarProvider
} from '@/components/ui/sidebar' } from '@shared-ui/components/ui/sidebar'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '@shared-ui/components/ui/tooltip'
import SidebarNavUser from '@/components/sidebar/SidebarNavUser.vue' import SidebarNavUser from '@main/components/sidebar/SidebarNavUser.vue'
const route = useRoute() const route = useRoute()
const emitter = useEmitter() const emitter = useEmitter()

View File

@@ -5,8 +5,8 @@
<script setup> <script setup>
import { onMounted } from 'vue' import { onMounted } from 'vue'
import { RouterView } from 'vue-router' import { RouterView } from 'vue-router'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { EMITTER_EVENTS } from './constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from './composables/useEmitter'
import { toast as sooner } from 'vue-sonner' import { toast as sooner } from 'vue-sonner'
const emitter = useEmitter() const emitter = useEmitter()

View File

@@ -7,6 +7,6 @@
<script setup> <script setup>
import { RouterView } from 'vue-router' import { RouterView } from 'vue-router'
import { Toaster } from '@/components/ui/sonner' import { Toaster } from '@shared-ui/components/ui/sonner'
import { TooltipProvider } from '@/components/ui/tooltip' import { TooltipProvider } from '@shared-ui/components/ui/tooltip'
</script> </script>

View File

@@ -47,7 +47,6 @@ const createCustomAttribute = (data) =>
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
}) })
const getCustomAttribute = (id) => http.get(`/api/v1/custom-attributes/${id}`)
const updateCustomAttribute = (id, data) => const updateCustomAttribute = (id, data) =>
http.put(`/api/v1/custom-attributes/${id}`, data, { http.put(`/api/v1/custom-attributes/${id}`, data, {
headers: { headers: {
@@ -431,6 +430,96 @@ const generateAPIKey = (id) =>
const revokeAPIKey = (id) => http.delete(`/api/v1/agents/${id}/api-key`) const revokeAPIKey = (id) => http.delete(`/api/v1/agents/${id}/api-key`)
// Help center.
const getHelpCenters = () => http.get('/api/v1/help-centers')
const getHelpCenter = (id) => http.get(`/api/v1/help-centers/${id}`)
const createHelpCenter = (data) => http.post('/api/v1/help-centers', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateHelpCenter = (id, data) => http.put(`/api/v1/help-centers/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteHelpCenter = (id) => http.delete(`/api/v1/help-centers/${id}`)
const getHelpCenterTree = (id, params) => http.get(`/api/v1/help-centers/${id}/tree`, { params })
const getCollections = (helpCenterId, params) => http.get(`/api/v1/help-centers/${helpCenterId}/collections`, { params })
const getCollection = (id) => http.get(`/api/v1/help-centers/*/collections/${id}`)
const createCollection = (helpCenterId, data) => http.post(`/api/v1/help-centers/${helpCenterId}/collections`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateCollection = (helpCenterId, id, data) => http.put(`/api/v1/help-centers/${helpCenterId}/collections/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteCollection = (helpCenterId, id) => http.delete(`/api/v1/help-centers/${helpCenterId}/collections/${id}`)
const toggleCollection = (id) => http.put(`/api/v1/collections/${id}/toggle`)
const getArticles = (collectionId, params) => http.get(`/api/v1/collections/${collectionId}/articles`, { params })
const getArticle = (id) => http.get(`/api/v1/collections/*/articles/${id}`)
const createArticle = (collectionId, data) => http.post(`/api/v1/collections/${collectionId}/articles`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateArticle = (collectionId, id, data) => http.put(`/api/v1/collections/${collectionId}/articles/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateArticleByID = (id, data) => http.put(`/api/v1/articles/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteArticle = (collectionId, id) => http.delete(`/api/v1/collections/${collectionId}/articles/${id}`)
const updateArticleStatus = (id, data) => http.put(`/api/v1/articles/${id}/status`, data, {
headers: {
'Content-Type': 'application/json'
}
})
// AI Assistants
const getAIAssistants = () => http.get('/api/v1/ai-assistants')
const getAIAssistant = (id) => http.get(`/api/v1/ai-assistants/${id}`)
const createAIAssistant = (data) =>
http.post('/api/v1/ai-assistants', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateAIAssistant = (id, data) =>
http.put(`/api/v1/ai-assistants/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteAIAssistant = (id) => http.delete(`/api/v1/ai-assistants/${id}`)
// AI Snippets
const getAISnippets = () => http.get('/api/v1/ai-snippets')
const getAISnippet = (id) => http.get(`/api/v1/ai-snippets/${id}`)
const createAISnippet = (data) =>
http.post('/api/v1/ai-snippets', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateAISnippet = (id, data) =>
http.put(`/api/v1/ai-snippets/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteAISnippet = (id) => http.delete(`/api/v1/ai-snippets/${id}`)
export default { export default {
login, login,
deleteUser, deleteUser,
@@ -504,6 +593,18 @@ export default {
sendMessage, sendMessage,
retryMessage, retryMessage,
createUser, createUser,
// AI Assistants
getAIAssistants,
getAIAssistant,
createAIAssistant,
updateAIAssistant,
deleteAIAssistant,
// AI Snippets
getAISnippets,
getAISnippet,
createAISnippet,
updateAISnippet,
deleteAISnippet,
createInbox, createInbox,
updateInbox, updateInbox,
deleteInbox, deleteInbox,
@@ -554,7 +655,6 @@ export default {
createCustomAttribute, createCustomAttribute,
updateCustomAttribute, updateCustomAttribute,
deleteCustomAttribute, deleteCustomAttribute,
getCustomAttribute,
getContactNotes, getContactNotes,
createContactNote, createContactNote,
deleteContactNote, deleteContactNote,
@@ -567,5 +667,25 @@ export default {
toggleWebhook, toggleWebhook,
testWebhook, testWebhook,
generateAPIKey, generateAPIKey,
revokeAPIKey revokeAPIKey,
// Help Center
getHelpCenters,
getHelpCenter,
createHelpCenter,
updateHelpCenter,
deleteHelpCenter,
getHelpCenterTree,
getCollections,
getCollection,
createCollection,
updateCollection,
deleteCollection,
toggleCollection,
getArticles,
getArticle,
createArticle,
updateArticle,
updateArticleByID,
deleteArticle,
updateArticleStatus,
} }

View File

@@ -12,7 +12,7 @@
</template> </template>
<script setup> <script setup>
import { Button } from '@/components/ui/button' import { Button } from '@shared-ui/components/ui/button'
import { X } from 'lucide-vue-next' import { X } from 'lucide-vue-next'
defineProps({ defineProps({

View File

@@ -42,8 +42,8 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar' import { Avatar, AvatarImage, AvatarFallback } from '@shared-ui/components/ui/avatar'
import ComboBox from '@/components/ui/combobox/ComboBox.vue' import ComboBox from '@shared-ui/components/ui/combobox/ComboBox.vue'
const props = defineProps({ const props = defineProps({
modelValue: [String, Number, Object], modelValue: [String, Number, Object],

View File

@@ -51,7 +51,7 @@ import {
TableHead, TableHead,
TableHeader, TableHeader,
TableRow TableRow
} from '@/components/ui/table' } from '@shared-ui/components/ui/table'
const { t } = useI18n() const { t } = useI18n()
const props = defineProps({ const props = defineProps({

View File

@@ -4,12 +4,12 @@
:editor="editor" :editor="editor"
:tippy-options="{ duration: 100 }" :tippy-options="{ duration: 100 }"
v-if="editor" v-if="editor"
class="bg-background p-1 box will-change-transform" class="bg-background p-2 box will-change-transform max-w-fit"
> >
<div class="flex space-x-1 items-center"> <div class="flex gap-1 items-center justify-start whitespace-nowrap">
<DropdownMenu v-if="aiPrompts.length > 0"> <DropdownMenu v-if="aiPrompts.length > 0">
<DropdownMenuTrigger> <DropdownMenuTrigger>
<Button size="sm" variant="ghost" class="flex items-center justify-center"> <Button size="sm" variant="ghost" class="flex items-center justify-center" title="AI Prompts">
<span class="flex items-center"> <span class="flex items-center">
<span class="text-medium">AI</span> <span class="text-medium">AI</span>
<Bot size="14" class="ml-1" /> <Bot size="14" class="ml-1" />
@@ -27,11 +27,43 @@
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<!-- Heading Dropdown for Article Mode -->
<DropdownMenu v-if="editorType === 'article'">
<DropdownMenuTrigger>
<Button size="sm" variant="ghost" class="flex items-center justify-center" title="Heading Options">
<span class="flex items-center">
<Type size="14" />
<span class="ml-1 text-xs font-medium">{{ getCurrentHeadingText() }}</span>
<ChevronDown class="w-3 h-3 ml-1" />
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @select="setParagraph" title="Set Paragraph">
<span class="font-normal">Paragraph</span>
</DropdownMenuItem>
<DropdownMenuItem @select="() => setHeading(1)" title="Set Heading 1">
<span class="text-xl font-bold">Heading 1</span>
</DropdownMenuItem>
<DropdownMenuItem @select="() => setHeading(2)" title="Set Heading 2">
<span class="text-lg font-bold">Heading 2</span>
</DropdownMenuItem>
<DropdownMenuItem @select="() => setHeading(3)" title="Set Heading 3">
<span class="text-base font-semibold">Heading 3</span>
</DropdownMenuItem>
<DropdownMenuItem @select="() => setHeading(4)" title="Set Heading 4">
<span class="text-sm font-semibold">Heading 4</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@click.prevent="editor?.chain().focus().toggleBold().run()" @click.prevent="editor?.chain().focus().toggleBold().run()"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('bold') }" :class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('bold') }"
title="Bold"
> >
<Bold size="14" /> <Bold size="14" />
</Button> </Button>
@@ -40,6 +72,7 @@
variant="ghost" variant="ghost"
@click.prevent="editor?.chain().focus().toggleItalic().run()" @click.prevent="editor?.chain().focus().toggleItalic().run()"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('italic') }" :class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('italic') }"
title="Italic"
> >
<Italic size="14" /> <Italic size="14" />
</Button> </Button>
@@ -48,6 +81,7 @@
variant="ghost" variant="ghost"
@click.prevent="editor?.chain().focus().toggleBulletList().run()" @click.prevent="editor?.chain().focus().toggleBulletList().run()"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('bulletList') }" :class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('bulletList') }"
title="Bullet List"
> >
<List size="14" /> <List size="14" />
</Button> </Button>
@@ -57,6 +91,7 @@
variant="ghost" variant="ghost"
@click.prevent="editor?.chain().focus().toggleOrderedList().run()" @click.prevent="editor?.chain().focus().toggleOrderedList().run()"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('orderedList') }" :class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('orderedList') }"
title="Ordered List"
> >
<ListOrdered size="14" /> <ListOrdered size="14" />
</Button> </Button>
@@ -65,9 +100,32 @@
variant="ghost" variant="ghost"
@click.prevent="openLinkModal" @click.prevent="openLinkModal"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('link') }" :class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('link') }"
title="Insert Link"
> >
<LinkIcon size="14" /> <LinkIcon size="14" />
</Button> </Button>
<!-- Additional tools for Article Mode -->
<template v-if="editorType === 'article'">
<Button
size="sm"
variant="ghost"
@click.prevent="editor?.chain().focus().toggleCodeBlock().run()"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('codeBlock') }"
title="Code Block"
>
<Code size="14" />
</Button>
<Button
size="sm"
variant="ghost"
@click.prevent="editor?.chain().focus().toggleBlockquote().run()"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('blockquote') }"
title="Blockquote"
>
<Quote size="14" />
</Button>
</template>
<div v-if="showLinkInput" class="flex space-x-2 p-2 bg-background border rounded"> <div v-if="showLinkInput" class="flex space-x-2 p-2 bg-background border rounded">
<Input <Input
v-model="linkUrl" v-model="linkUrl"
@@ -75,10 +133,10 @@
placeholder="Enter link URL" placeholder="Enter link URL"
class="border p-1 text-sm w-[200px]" class="border p-1 text-sm w-[200px]"
/> />
<Button size="sm" @click="setLink"> <Button size="sm" @click="setLink" title="Set Link">
<Check size="14" /> <Check size="14" />
</Button> </Button>
<Button size="sm" @click="unsetLink"> <Button size="sm" @click="unsetLink" title="Unset Link">
<X size="14" /> <X size="14" />
</Button> </Button>
</div> </div>
@@ -100,16 +158,19 @@ import {
ListOrdered, ListOrdered,
Link as LinkIcon, Link as LinkIcon,
Check, Check,
X X,
Type,
Code,
Quote
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@shared-ui/components/ui/button'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@shared-ui/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input' import { Input } from '@shared-ui/components/ui/input'
import Placeholder from '@tiptap/extension-placeholder' import Placeholder from '@tiptap/extension-placeholder'
import Image from '@tiptap/extension-image' import Image from '@tiptap/extension-image'
import StarterKit from '@tiptap/starter-kit' import StarterKit from '@tiptap/starter-kit'
@@ -118,6 +179,8 @@ import Table from '@tiptap/extension-table'
import TableRow from '@tiptap/extension-table-row' import TableRow from '@tiptap/extension-table-row'
import TableCell from '@tiptap/extension-table-cell' import TableCell from '@tiptap/extension-table-cell'
import TableHeader from '@tiptap/extension-table-header' import TableHeader from '@tiptap/extension-table-header'
import { useTypingIndicator } from '@shared-ui/composables'
import { useConversationStore } from '@main/stores/conversation'
const textContent = defineModel('textContent', { default: '' }) const textContent = defineModel('textContent', { default: '' })
const htmlContent = defineModel('htmlContent', { default: '' }) const htmlContent = defineModel('htmlContent', { default: '' })
@@ -134,6 +197,11 @@ const props = defineProps({
aiPrompts: { aiPrompts: {
type: Array, type: Array,
default: () => [] default: () => []
},
editorType: {
type: String,
default: 'conversation',
validator: (value) => ['conversation', 'article'].includes(value)
} }
}) })
@@ -141,6 +209,10 @@ const emit = defineEmits(['send', 'aiPromptSelected'])
const emitPrompt = (key) => emit('aiPromptSelected', key) const emitPrompt = (key) => emit('aiPromptSelected', key)
// Set up typing indicator
const conversationStore = useConversationStore()
const { startTyping, stopTyping } = useTypingIndicator(conversationStore.sendTyping)
// To preseve the table styling in emails, need to set the table style inline. // To preseve the table styling in emails, need to set the table style inline.
// Created these custom extensions to set the table style inline. // Created these custom extensions to set the table style inline.
const CustomTable = Table.extend({ const CustomTable = Table.extend({
@@ -183,17 +255,39 @@ const CustomTableHeader = TableHeader.extend({
const isInternalUpdate = ref(false) const isInternalUpdate = ref(false)
const editor = useEditor({ // Configure extensions based on editor type
extensions: [ const getExtensions = () => {
StarterKit.configure(), const baseExtensions = [
StarterKit.configure({
heading: props.editorType === 'article' ? { levels: [1, 2, 3, 4] } : false
}),
Image.configure({ HTMLAttributes: { class: 'inline-image' } }), Image.configure({ HTMLAttributes: { class: 'inline-image' } }),
Placeholder.configure({ placeholder: () => props.placeholder }), Placeholder.configure({ placeholder: () => props.placeholder }),
Link, Link
]
// Add table extensions
if (props.editorType === 'article') {
baseExtensions.push(
CustomTable.configure({ resizable: true }),
TableRow,
CustomTableCell,
CustomTableHeader
)
} else {
baseExtensions.push(
CustomTable.configure({ resizable: false }), CustomTable.configure({ resizable: false }),
TableRow, TableRow,
CustomTableCell, CustomTableCell,
CustomTableHeader CustomTableHeader
], )
}
return baseExtensions
}
const editor = useEditor({
extensions: getExtensions(),
autofocus: props.autoFocus, autofocus: props.autoFocus,
content: htmlContent.value, content: htmlContent.value,
editorProps: { editorProps: {
@@ -201,6 +295,8 @@ const editor = useEditor({
handleKeyDown: (view, event) => { handleKeyDown: (view, event) => {
if (event.ctrlKey && event.key === 'Enter') { if (event.ctrlKey && event.key === 'Enter') {
emit('send') emit('send')
// Stop typing when sending
stopTyping()
return true return true
} }
} }
@@ -211,6 +307,13 @@ const editor = useEditor({
htmlContent.value = editor.getHTML() htmlContent.value = editor.getHTML()
textContent.value = editor.getText() textContent.value = editor.getText()
isInternalUpdate.value = false isInternalUpdate.value = false
// Trigger typing indicator when user types
startTyping()
},
onBlur: () => {
// Stop typing when editor loses focus
stopTyping()
} }
}) })
@@ -258,6 +361,32 @@ const unsetLink = () => {
editor.value?.chain().focus().unsetLink().run() editor.value?.chain().focus().unsetLink().run()
showLinkInput.value = false showLinkInput.value = false
} }
// Heading functions for article mode
const setHeading = (level) => {
editor.value?.chain().focus().toggleHeading({ level }).run()
}
const setParagraph = () => {
editor.value?.chain().focus().setParagraph().run()
}
const getCurrentHeadingLevel = () => {
if (!editor.value) return null
for (let level = 1; level <= 4; level++) {
if (editor.value.isActive('heading', { level })) {
return level
}
}
return null
}
const getCurrentHeadingText = () => {
const level = getCurrentHeadingLevel()
if (level) return `H${level}`
if (editor.value?.isActive('paragraph')) return 'P'
return 'T'
}
</script> </script>
<style lang="scss"> <style lang="scss">

View File

@@ -120,13 +120,13 @@ import {
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from '@/components/ui/select' } from '@shared-ui/components/ui/select'
import { Plus } from 'lucide-vue-next' import { Plus } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@shared-ui/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@shared-ui/components/ui/input'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import CloseButton from '@/components/button/CloseButton.vue' import CloseButton from '@main/components/button/CloseButton.vue'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue' import SelectComboBox from '@main/components/combobox/SelectCombobox.vue'
const props = defineProps({ const props = defineProps({
fields: { fields: {

View File

@@ -12,8 +12,8 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { Separator } from '@/components/ui/separator' import { Separator } from '@shared-ui/components/ui/separator'
import { SidebarTrigger } from '@/components/ui/sidebar' import { SidebarTrigger } from '@shared-ui/components/ui/sidebar'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
const route = useRoute() const route = useRoute()

View File

@@ -4,9 +4,9 @@ import {
reportsNavItems, reportsNavItems,
accountNavItems, accountNavItems,
contactNavItems contactNavItems
} from '@/constants/navigation' } from '../../constants/navigation'
import { RouterLink, useRoute, useRouter } from 'vue-router' import { RouterLink, useRoute, useRouter } from 'vue-router'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@shared-ui/components/ui/collapsible'
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -21,8 +21,8 @@ import {
SidebarMenuSubItem, SidebarMenuSubItem,
SidebarProvider, SidebarProvider,
SidebarRail SidebarRail
} from '@/components/ui/sidebar' } from '@shared-ui/components/ui/sidebar'
import { useAppSettingsStore } from '@/stores/appSettings' import { useAppSettingsStore } from '../../stores/appSettings'
import { import {
ChevronRight, ChevronRight,
EllipsisVertical, EllipsisVertical,
@@ -37,13 +37,13 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@shared-ui/components/ui/dropdown-menu'
import { filterNavItems } from '@/utils/nav-permissions' import { filterNavItems } from '../../utils/nav-permissions'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores/user' import { useUserStore } from '../../stores/user'
import { useConversationStore } from '@/stores/conversation' import { useConversationStore } from '../../stores/conversation'
defineProps({ defineProps({
userTeams: { type: Array, default: () => [] }, userTeams: { type: Array, default: () => [] },
@@ -289,7 +289,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarMenuSubItem v-for="child in item.children" :key="child.titleKey"> <SidebarMenuSubItem v-for="child in item.children" :key="child.titleKey">
<SidebarMenuButton size="sm" :isActive="isActiveParent(child.href)" asChild> <SidebarMenuButton size="sm" :isActive="isActiveParent(child.href)" asChild>
<router-link :to="child.href"> <router-link :to="child.href">
<span>{{ t(child.titleKey) }}</span> <span>{{ t(child.titleKey, child.isTitleKeyPlural === true ? 2 : 1) }}</span>
</router-link> </router-link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuSubItem> </SidebarMenuSubItem>

View File

@@ -118,12 +118,12 @@ import {
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@shared-ui/components/ui/dropdown-menu'
import { SidebarMenuButton } from '@/components/ui/sidebar' import { SidebarMenuButton } from '@shared-ui/components/ui/sidebar'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@shared-ui/components/ui/avatar'
import { Switch } from '@/components/ui/switch' import { Switch } from '@shared-ui/components/ui/switch'
import { ChevronsUpDown, CircleUserRound, LogOut, Moon, Sun } from 'lucide-vue-next' import { ChevronsUpDown, CircleUserRound, LogOut, Moon, Sun } from 'lucide-vue-next'
import { useUserStore } from '@/stores/user' import { useUserStore } from '../../stores/user'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useColorMode } from '@vueuse/core' import { useColorMode } from '@vueuse/core'

View File

@@ -71,8 +71,8 @@
<script setup> <script setup>
import { Trash2 } from 'lucide-vue-next' import { Trash2 } from 'lucide-vue-next'
import { defineEmits } from 'vue' import { defineEmits } from 'vue'
import { Button } from '@/components/ui/button' import { Button } from '@shared-ui/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@shared-ui/components/ui/skeleton'
defineProps({ defineProps({
headers: { headers: {

View File

@@ -20,6 +20,6 @@
</template> </template>
<script setup> <script setup>
import { useAppSettingsStore } from '@/stores/appSettings' import { useAppSettingsStore } from '../../stores/appSettings'
const appSettingsStore = useAppSettingsStore() const appSettingsStore = useAppSettingsStore()
</script> </script>

View File

@@ -1,6 +1,6 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useUsersStore } from '@/stores/users' import { useUsersStore } from '../stores/users'
import { FIELD_TYPE, FIELD_OPERATORS } from '@/constants/filterConfig' import { FIELD_TYPE, FIELD_OPERATORS } from '../constants/filterConfig'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
export function useActivityLogFilters () { export function useActivityLogFilters () {

View File

@@ -1,11 +1,11 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useConversationStore } from '@/stores/conversation' import { useConversationStore } from '../stores/conversation'
import { useInboxStore } from '@/stores/inbox' import { useInboxStore } from '../stores/inbox'
import { useUsersStore } from '@/stores/users' import { useUsersStore } from '../stores/users'
import { useTeamStore } from '@/stores/team' import { useTeamStore } from '../stores/team'
import { useSlaStore } from '@/stores/sla' import { useSlaStore } from '../stores/sla'
import { useCustomAttributeStore } from '@/stores/customAttributes' import { useCustomAttributeStore } from '../stores/customAttributes'
import { FIELD_TYPE, FIELD_OPERATORS } from '@/constants/filterConfig' import { FIELD_TYPE, FIELD_OPERATORS } from '../constants/filterConfig'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
export function useConversationFilters () { export function useConversationFilters () {

View File

@@ -1,8 +1,8 @@
import { ref, readonly } from 'vue' import { ref, readonly } from 'vue'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from './useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { EMITTER_EVENTS } from '../constants/emitterEvents.js'
import { handleHTTPError } from '@/utils/http' import { handleHTTPError } from '../utils/http'
import api from '@/api' import api from '../api'
/** /**
* Composable for handling file uploads * Composable for handling file uploads

View File

@@ -1,6 +1,6 @@
import { ref, onMounted, onBeforeUnmount, watch } from 'vue' import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { useUserStore } from '@/stores/user' import { useUserStore } from '../stores/user'
import { debounce } from '@/utils/debounce' import { debounce } from '../utils/debounce'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
export function useIdleDetection () { export function useIdleDetection () {

View File

@@ -1,5 +1,5 @@
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import { calculateSla } from '@/utils/sla' import { calculateSla } from '../utils/sla'
export function useSla (dueAt, actualAt) { export function useSla (dueAt, actualAt) {
const sla = ref(null) const sla = ref(null)

View File

@@ -82,6 +82,23 @@ export const adminNavItems = [
} }
] ]
}, },
{
titleKey: 'globals.terms.ai',
children: [
{
titleKey: 'globals.terms.aiAssistant',
isTitleKeyPlural: true,
href: '/admin/ai/assistants',
permission: 'ai:manage'
},
{
titleKey: 'globals.terms.snippet',
isTitleKeyPlural: true,
href: '/admin/ai/snippets',
permission: 'ai:manage'
},
]
},
{ {
titleKey: 'globals.terms.automation', titleKey: 'globals.terms.automation',
children: [ children: [
@@ -142,7 +159,17 @@ export const adminNavItems = [
permission: 'webhooks:manage' permission: 'webhooks:manage'
} }
] ]
},
{
titleKey: 'globals.terms.helpCenter',
children: [
{
titleKey: 'globals.terms.helpCenter',
href: '/admin/help-center',
permission: 'help_center:manage'
} }
]
},
] ]
export const accountNavItems = [ export const accountNavItems = [

View File

@@ -0,0 +1,13 @@
export const WS_EVENT = {
NEW_MESSAGE: 'new_message',
MESSAGE_PROP_UPDATE: 'message_prop_update',
CONVERSATION_PROP_UPDATE: 'conversation_prop_update',
CONVERSATION_SUBSCRIBE: 'conversation_subscribe',
CONVERSATION_SUBSCRIBED: 'conversation_subscribed',
TYPING: 'typing',
}
// Message types that should not be queued because they become stale quickly
export const WS_EPHEMERAL_TYPES = [
WS_EVENT.TYPING,
]

View File

@@ -148,7 +148,7 @@
<script setup> <script setup>
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import SimpleTable from '@/components/table/SimpleTable.vue' import SimpleTable from '@main/components/table/SimpleTable.vue'
import { import {
Pagination, Pagination,
PaginationEllipsis, PaginationEllipsis,
@@ -158,23 +158,23 @@ import {
PaginationListItem, PaginationListItem,
PaginationNext, PaginationNext,
PaginationPrev PaginationPrev
} from '@/components/ui/pagination' } from '@shared-ui/components/ui/pagination'
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from '@/components/ui/select' } from '@shared-ui/components/ui/select'
import FilterBuilder from '@/components/filter/FilterBuilder.vue' import FilterBuilder from '@main/components/filter/FilterBuilder.vue'
import { Button } from '@/components/ui/button' import { Button } from '@shared-ui/components/ui/button'
import { ListFilter, ArrowDownWideNarrow } from 'lucide-vue-next' import { ListFilter, ArrowDownWideNarrow } from 'lucide-vue-next'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@shared-ui/components/ui/popover'
import { useActivityLogFilters } from '@/composables/useActivityLogFilters' import { useActivityLogFilters } from '../../../composables/useActivityLogFilters'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { format } from 'date-fns' import { format } from 'date-fns'
import { getVisiblePages } from '@/utils/pagination' import { getVisiblePages } from '../../../utils/pagination'
import api from '@/api' import api from '../../../api'
const activityLogs = ref([]) const activityLogs = ref([])
const { t } = useI18n() const { t } = useI18n()

View File

@@ -304,17 +304,17 @@
<script setup> <script setup>
import { watch, onMounted, ref, computed } from 'vue' import { watch, onMounted, ref, computed } from 'vue'
import { Button } from '@/components/ui/button' import { Button } from '@shared-ui/components/ui/button/index.js'
import { useForm } from 'vee-validate' import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod' import { toTypedSchema } from '@vee-validate/zod'
import { createFormSchema } from './formSchema.js' import { createFormSchema } from './formSchema.js'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@shared-ui/components/ui/checkbox/index.js'
import { Label } from '@/components/ui/label' import { Label } from '@shared-ui/components/ui/label/index.js'
import { vAutoAnimate } from '@formkit/auto-animate/vue' import { vAutoAnimate } from '@formkit/auto-animate/vue'
import { Badge } from '@/components/ui/badge' import { Badge } from '@shared-ui/components/ui/badge/index.js'
import { Clock, LogIn, Key, RotateCcw, Trash2, Plus, Copy, AlertTriangle } from 'lucide-vue-next' import { Clock, LogIn, Key, RotateCcw, Trash2, Plus, Copy, AlertTriangle } from 'lucide-vue-next'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@shared-ui/components/ui/form/index.js'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@shared-ui/components/ui/avatar/index.js'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -322,9 +322,9 @@ import {
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from '@/components/ui/select' } from '@shared-ui/components/ui/select/index.js'
import { SelectTag } from '@/components/ui/select' import { SelectTag } from '@shared-ui/components/ui/select/index.js'
import { Input } from '@/components/ui/input' import { Input } from '@shared-ui/components/ui/input/index.js'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -332,13 +332,13 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle DialogTitle
} from '@/components/ui/dialog' } from '@shared-ui/components/ui/dialog/index.js'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Alert, AlertDescription, AlertTitle } from '@shared-ui/components/ui/alert/index.js'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from '../../../composables/useEmitter.js'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
import { format } from 'date-fns' import { format } from 'date-fns'
import api from '@/api' import api from '../../../api/index.js'
const props = defineProps({ const props = defineProps({
initialValues: { initialValues: {

View File

@@ -40,7 +40,7 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@shared-ui/components/ui/dropdown-menu'
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -50,13 +50,13 @@ import {
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle AlertDialogTitle
} from '@/components/ui/alert-dialog' } from '@shared-ui/components/ui/alert-dialog'
import { Button } from '@/components/ui/button' import { Button } from '@shared-ui/components/ui/button'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from '../../../composables/useEmitter'
import { handleHTTPError } from '@/utils/http' import { handleHTTPError } from '../../../utils/http'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
import api from '@/api' import api from '../../../api'
const alertOpen = ref(false) const alertOpen = ref(false)
const emit = useEmitter() const emit = useEmitter()

View File

@@ -0,0 +1,305 @@
<template>
<Spinner v-if="formLoading"></Spinner>
<form @submit="onSubmit" class="space-y-6 w-full" :class="{ 'opacity-50': formLoading }">
<!-- Enabled Field -->
<FormField v-slot="{ componentField, handleChange }" name="enabled" v-if="!isNewForm">
<FormItem class="flex flex-row items-center justify-between rounded-lg border p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{ t('globals.terms.enabled') }}</FormLabel>
<FormDescription>{{ t('ai.assistant.enabledDescription') }}</FormDescription>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
<!-- Name Field -->
<FormField v-slot="{ componentField }" name="first_name">
<FormItem>
<FormLabel>{{ t('globals.terms.name') }} <span class="text-red-500">*</span></FormLabel>
<FormControl>
<Input
type="text"
:placeholder="t('ai.assistant.namePlaceholder')"
v-bind="componentField"
/>
</FormControl>
<FormDescription>{{ t('ai.assistant.nameDescription') }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Avatar url -->
<FormField v-slot="{ componentField }" name="avatar_url">
<FormItem>
<FormLabel>{{ t('globals.terms.avatar') }} {{ t('globals.terms.url') }}</FormLabel>
<FormControl>
<Input
type="url"
v-bind="componentField"
/>
</FormControl>
<FormMessage></FormMessage>
</FormItem>
</FormField>
<!-- Product Name Field -->
<FormField v-slot="{ componentField }" name="product_name">
<FormItem>
<FormLabel
>{{ t('ai.assistant.productName') }} <span class="text-red-500">*</span></FormLabel
>
<FormControl>
<Input
type="text"
:placeholder="t('ai.assistant.productNamePlaceholder')"
v-bind="componentField"
/>
</FormControl>
<FormDescription>{{ t('ai.assistant.productNameDescription') }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Product Description Field -->
<FormField v-slot="{ componentField }" name="product_description">
<FormItem>
<FormLabel
>{{ t('ai.assistant.productDescription') }} <span class="text-red-500">*</span></FormLabel
>
<FormControl>
<Textarea
:placeholder="t('ai.assistant.productDescriptionPlaceholder')"
v-bind="componentField"
rows="4"
/>
</FormControl>
<FormDescription>{{ t('ai.assistant.productDescriptionDescription') }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Answer Length Field -->
<FormField v-slot="{ componentField }" name="answer_length">
<FormItem>
<FormLabel
>{{ t('ai.assistant.answerLength') }} <span class="text-red-500">*</span></FormLabel
>
<Select v-bind="componentField">
<FormControl>
<SelectTrigger>
<SelectValue :placeholder="t('ai.assistant.selectAnswerLength')" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="concise">{{ t('ai.assistant.answerLengthConcise') }}</SelectItem>
<SelectItem value="medium">{{ t('ai.assistant.answerLengthMedium') }}</SelectItem>
<SelectItem value="long">{{ t('ai.assistant.answerLengthLong') }}</SelectItem>
</SelectContent>
</Select>
<FormDescription>{{ t('ai.assistant.answerLengthDescription') }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Answer Tone Field -->
<FormField v-slot="{ componentField }" name="answer_tone">
<FormItem>
<FormLabel
>{{ t('ai.assistant.answerTone') }} <span class="text-red-500">*</span></FormLabel
>
<Select v-bind="componentField">
<FormControl>
<SelectTrigger>
<SelectValue :placeholder="t('ai.assistant.selectAnswerTone')" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="neutral">{{ t('ai.assistant.answerToneNeutral') }}</SelectItem>
<SelectItem value="friendly">{{ t('ai.assistant.answerToneFriendly') }}</SelectItem>
<SelectItem value="professional">{{
t('ai.assistant.answerToneProfessional')
}}</SelectItem>
<SelectItem value="humorous">{{ t('ai.assistant.answerToneHumorous') }}</SelectItem>
</SelectContent>
</Select>
<FormDescription>{{ t('ai.assistant.answerToneDescription') }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Enable Handoff Checkbox -->
<FormField v-slot="{ componentField, handleChange }" name="hand_off">
<FormItem class="flex flex-row items-center justify-between rounded-lg border p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{ t('ai.assistant.enableHandoff') }}</FormLabel>
<FormDescription>{{ t('ai.assistant.enableHandoffDescription') }}</FormDescription>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
<!-- Hand off team (conditional) -->
<FormField v-slot="{ componentField }" name="hand_off_team" v-if="form.values.hand_off">
<FormItem>
<FormLabel>{{ t('ai.assistant.conversationHandoffTeam') }}</FormLabel>
<FormControl>
<Select v-bind="componentField">
<FormControl>
<SelectTrigger>
<SelectValue
:placeholder="
t('globals.messages.select', { name: t('globals.terms.team').toLowerCase() })
"
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem
v-for="opt in teamStore.options"
:key="opt.value"
:value="parseInt(opt.value)"
>
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Submit Button -->
<div class="flex justify-end">
<Button type="submit" :disabled="formLoading">
<template v-if="formLoading">
<LoaderCircle class="w-4 h-4 mr-2 animate-spin" />
</template>
{{ isNewForm ? t('globals.messages.create') : t('globals.messages.update') }}
</Button>
</div>
</form>
</template>
<script setup>
import { computed, onMounted, watch } from 'vue'
import { toTypedSchema } from '@vee-validate/zod'
import { useForm } from 'vee-validate'
import { useI18n } from 'vue-i18n'
import { Button } from '@shared-ui/components/ui/button'
import { Input } from '@shared-ui/components/ui/input'
import { Textarea } from '@shared-ui/components/ui/textarea'
import { Switch } from '@shared-ui/components/ui/switch'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@shared-ui/components/ui/form'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@shared-ui/components/ui/select'
import { Spinner } from '@shared-ui/components/ui/spinner'
import { LoaderCircle } from 'lucide-vue-next'
import { createFormSchema } from './formSchema.js'
import { useTeamStore } from '@/stores/team'
const { t } = useI18n()
const teamStore = useTeamStore()
const props = defineProps({
initialValues: {
type: Object,
default: () => ({})
},
submitForm: {
type: Function,
required: true
},
isNewForm: {
type: Boolean,
default: false
},
isLoading: {
type: Boolean,
default: false
}
})
const formLoading = computed(() => props.isLoading)
const formSchema = toTypedSchema(createFormSchema(t))
const form = useForm({
validationSchema: formSchema,
initialValues: {
first_name: '',
last_name: '',
avatar_url: '',
product_name: '',
product_description: '',
answer_length: 'medium',
answer_tone: 'friendly',
hand_off: false,
hand_off_team: null,
enabled: true,
...props.initialValues
}
})
const onSubmit = form.handleSubmit((values) => {
props.submitForm(values)
})
// Parse meta fields if editing an existing assistant
onMounted(() => {
if (!props.isNewForm && props.initialValues?.meta) {
try {
const meta =
typeof props.initialValues.meta === 'string'
? JSON.parse(props.initialValues.meta)
: props.initialValues.meta
if (meta) {
form.setFieldValue('product_name', meta.product_name || '')
form.setFieldValue('product_description', meta.product_description || '')
form.setFieldValue('answer_length', meta.answer_length || 'medium')
form.setFieldValue('answer_tone', meta.answer_tone || 'friendly')
form.setFieldValue('hand_off', meta.hand_off || false)
form.setFieldValue('hand_off_team', meta.hand_off_team || null)
}
} catch (e) {
console.warn('Failed to parse AI assistant meta:', e)
}
}
})
// Watch for changes in initialValues (for edit mode)
watch(
() => props.initialValues,
(newValues) => {
if (newValues && Object.keys(newValues).length > 0) {
form.resetForm({
values: {
first_name: newValues.first_name || '',
last_name: newValues.last_name || '',
avatar_url: newValues.avatar_url || '',
hand_off: newValues.hand_off ?? false,
hand_off_team: newValues.hand_off_team || null,
enabled: newValues.enabled ?? true,
...newValues
}
})
}
},
{ deep: true, immediate: true }
)
</script>

View File

@@ -0,0 +1,81 @@
import { h } from 'vue'
import AIAssistantDataTableDropDown from '@/features/admin/ai-assistants/dataTableDropdown.vue'
import { format } from 'date-fns'
export const createColumns = (t) => [
{
accessorKey: 'first_name',
header: function () {
return h('div', { class: 'text-center' }, t('globals.terms.name'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('first_name'))
}
},
{
accessorKey: 'meta',
header: function () {
return h('div', { class: 'text-center' }, t('globals.terms.product'))
},
cell: function ({ row }) {
const meta = row.getValue('meta')
let productName = ''
try {
const parsedMeta = typeof meta === 'string' ? JSON.parse(meta) : meta
productName = parsedMeta?.product_name || ''
} catch (e) {
productName = ''
}
return h('div', { class: 'text-center font-medium' }, productName)
}
},
{
accessorKey: 'enabled',
header: function () {
return h('div', { class: 'text-center' }, t('globals.terms.enabled'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('enabled') ? t('globals.messages.yes') : t('globals.messages.no'))
}
},
{
accessorKey: 'created_at',
header: function () {
return h('div', { class: 'text-center' }, t('globals.terms.createdAt'))
},
cell: function ({ row }) {
return h(
'div',
{ class: 'text-center font-medium' },
format(row.getValue('created_at'), 'PPpp')
)
}
},
{
accessorKey: 'updated_at',
header: function () {
return h('div', { class: 'text-center' }, t('globals.terms.updatedAt'))
},
cell: function ({ row }) {
return h(
'div',
{ class: 'text-center font-medium' },
format(row.getValue('updated_at'), 'PPpp')
)
}
},
{
id: 'actions',
enableHiding: false,
cell: ({ row }) => {
const assistant = row.original
return h(
'div',
{ class: 'relative' },
h(AIAssistantDataTableDropDown, {
assistant
})
)
}
}
]

View File

@@ -0,0 +1,97 @@
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" class="w-8 h-8 p-0">
<span class="sr-only"></span>
<MoreHorizontal class="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="editAIAssistant(props.assistant.id)">{{
$t('globals.messages.edit')
}}</DropdownMenuItem>
<DropdownMenuItem @click="() => (alertOpen = true)">{{
$t('globals.messages.delete')
}}</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog :open="alertOpen" @update:open="alertOpen = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ $t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
<AlertDialogDescription>{{ $t('ai.assistant.deleteConfirmation') }}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{{ $t('globals.messages.cancel') }}</AlertDialogCancel>
<AlertDialogAction @click="handleDelete">{{
$t('globals.messages.delete')
}}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>
<script setup>
import { ref } from 'vue'
import { MoreHorizontal } from 'lucide-vue-next'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@shared-ui/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@shared-ui/components/ui/alert-dialog'
import { Button } from '@shared-ui/components/ui/button'
import { useRouter } from 'vue-router'
import { useEmitter } from '../../../composables/useEmitter'
import { handleHTTPError } from '../../../utils/http'
import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
import api from '../../../api'
const alertOpen = ref(false)
const emit = useEmitter()
const router = useRouter()
const props = defineProps({
assistant: {
type: Object,
required: true,
default: () => ({
id: ''
})
}
})
function editAIAssistant(id) {
router.push({ path: `/admin/ai/assistants/${id}/edit` })
}
async function handleDelete() {
try {
await api.deleteAIAssistant(props.assistant.id)
alertOpen.value = false
emitRefreshAssistantList()
} catch (error) {
emit.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
const emitRefreshAssistantList = () => {
emit.emit(EMITTER_EVENTS.REFRESH_LIST, {
model: 'ai_assistant'
})
}
</script>

View File

@@ -0,0 +1,89 @@
import * as z from 'zod'
export const createFormSchema = (t) => z.object({
first_name: z
.string({
required_error: t('globals.messages.required'),
})
.min(2, {
message: t('form.error.minmax', {
min: 2,
max: 100,
})
})
.max(100, {
message: t('form.error.minmax', {
min: 2,
max: 100,
})
}),
last_name: z.string().optional(),
avatar_url: z
.string()
.url({
message: t('globals.messages.invalidUrl'),
})
.optional()
.or(z.literal('')),
product_name: z
.string({
required_error: t('globals.messages.required'),
})
.min(2, {
message: t('form.error.minmax', {
min: 2,
max: 255,
})
})
.max(255, {
message: t('form.error.minmax', {
min: 2,
max: 255,
})
}),
product_description: z
.string({
required_error: t('globals.messages.required'),
})
.min(10, {
message: t('form.error.minmax', {
min: 10,
max: 1000,
})
})
.max(1000, {
message: t('form.error.minmax', {
min: 10,
max: 1000,
})
}),
answer_length: z
.enum(['concise', 'medium', 'long'], {
required_error: t('globals.messages.required'),
invalid_type_error: t('globals.messages.invalid', { name: t('ai.assistant.answerLength') })
}),
answer_tone: z
.enum(['neutral', 'friendly', 'professional', 'humorous'], {
required_error: t('globals.messages.required'),
invalid_type_error: t('globals.messages.invalid', { name: t('ai.assistant.answerTone') })
}),
enabled: z.boolean().optional().default(true),
hand_off: z.boolean().optional().default(false),
hand_off_team: z
.number()
.int({
message: t('globals.messages.invalid', { name: t('globals.terms.team') })
})
.optional()
.nullable()
.default(null),
})

View File

@@ -87,9 +87,9 @@
<script setup> <script setup>
import { toRefs } from 'vue' import { toRefs } from 'vue'
import { Button } from '@/components/ui/button' import { Button } from '@shared-ui/components/ui/button'
import CloseButton from '@/components/button/CloseButton.vue' import CloseButton from '@main/components/button/CloseButton.vue'
import { useTagStore } from '@/stores/tag' import { useTagStore } from '../../../stores/tag'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -97,13 +97,13 @@ import {
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from '@/components/ui/select' } from '@shared-ui/components/ui/select'
import { SelectTag } from '@/components/ui/select' import { SelectTag } from '@shared-ui/components/ui/select'
import { useConversationFilters } from '@/composables/useConversationFilters' import { useConversationFilters } from '../../../composables/useConversationFilters'
import { getTextFromHTML } from '@/utils/strings.js' import { getTextFromHTML } from '../../../utils/strings.js'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import Editor from '@/components/editor/TextEditor.vue' import Editor from '@main/components/editor/TextEditor.vue'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue' import SelectComboBox from '@main/components/combobox/SelectCombobox.vue'
const props = defineProps({ const props = defineProps({
actions: { actions: {

View File

@@ -34,7 +34,7 @@
</template> </template>
<script setup> <script setup>
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@shared-ui/components/ui/tabs'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import RuleTab from './RuleTab.vue' import RuleTab from './RuleTab.vue'

View File

@@ -190,10 +190,10 @@
<script setup> <script setup>
import { toRefs, computed, watch } from 'vue' import { toRefs, computed, watch } from 'vue'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@shared-ui/components/ui/checkbox'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' import { RadioGroup, RadioGroupItem } from '@shared-ui/components/ui/radio-group'
import { Button } from '@/components/ui/button' import { Button } from '@shared-ui/components/ui/button'
import CloseButton from '@/components/button/CloseButton.vue' import CloseButton from '@main/components/button/CloseButton.vue'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -202,19 +202,19 @@ import {
SelectLabel, SelectLabel,
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from '@/components/ui/select' } from '@shared-ui/components/ui/select'
import { import {
TagsInput, TagsInput,
TagsInputInput, TagsInputInput,
TagsInputItem, TagsInputItem,
TagsInputItemDelete, TagsInputItemDelete,
TagsInputItemText TagsInputItemText
} from '@/components/ui/tags-input' } from '@shared-ui/components/ui/tags-input'
import { Label } from '@/components/ui/label' import { Label } from '@shared-ui/components/ui/label'
import { Input } from '@/components/ui/input' import { Input } from '@shared-ui/components/ui/input'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useConversationFilters } from '@/composables/useConversationFilters' import { useConversationFilters } from '../../../composables/useConversationFilters'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue' import SelectComboBox from '@main/components/combobox/SelectCombobox.vue'
const props = defineProps({ const props = defineProps({
ruleGroup: { ruleGroup: {

View File

@@ -68,7 +68,7 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@shared-ui/components/ui/dropdown-menu'
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -78,10 +78,10 @@ import {
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle AlertDialogTitle
} from '@/components/ui/alert-dialog' } from '@shared-ui/components/ui/alert-dialog'
import { EllipsisVertical } from 'lucide-vue-next' import { EllipsisVertical } from 'lucide-vue-next'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Badge } from '@/components/ui/badge' import { Badge } from '@shared-ui/components/ui/badge'
const router = useRouter() const router = useRouter()
const alertOpen = ref(false) const alertOpen = ref(false)

View File

@@ -64,17 +64,17 @@
<script setup> <script setup>
import { ref, onMounted, watch } from 'vue' import { ref, onMounted, watch } from 'vue'
import RuleList from './RuleList.vue' import RuleList from './RuleList.vue'
import { Spinner } from '@/components/ui/spinner' import { Spinner } from '@shared-ui/components/ui/spinner'
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from '@/components/ui/select' } from '@shared-ui/components/ui/select'
import { Settings } from 'lucide-vue-next' import { Settings } from 'lucide-vue-next'
import draggable from 'vuedraggable' import draggable from 'vuedraggable'
import api from '@/api' import api from '../../../api'
const isLoading = ref(false) const isLoading = ref(false)
const rules = ref([]) const rules = ref([])

View File

@@ -167,23 +167,23 @@
<script setup> <script setup>
import { ref, watch, reactive, computed } from 'vue' import { ref, watch, reactive, computed } from 'vue'
import { Button } from '@/components/ui/button' import { Button } from '@shared-ui/components/ui/button/index.js'
import { useForm } from 'vee-validate' import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod' import { toTypedSchema } from '@vee-validate/zod'
import { createFormSchema } from './formSchema.js' import { createFormSchema } from './formSchema.js'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@shared-ui/components/ui/checkbox/index.js'
import { Label } from '@/components/ui/label' import { Label } from '@shared-ui/components/ui/label/index.js'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' import { RadioGroup, RadioGroupItem } from '@shared-ui/components/ui/radio-group/index.js'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@shared-ui/components/ui/form/index.js'
import { Calendar } from '@/components/ui/calendar' import { Calendar } from '@shared-ui/components/ui/calendar/index.js'
import { Input } from '@/components/ui/input' import { Input } from '@shared-ui/components/ui/input/index.js'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@shared-ui/components/ui/popover/index.js'
import { cn } from '@/lib/utils' import { cn } from '@shared-ui/lib/utils.js'
import { format } from 'date-fns' import { format } from 'date-fns'
import { WEEKDAYS } from '@/constants/date' import { WEEKDAYS } from '../../../constants/date.js'
import { Calendar as CalendarIcon } from 'lucide-vue-next' import { Calendar as CalendarIcon } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import SimpleTable from '@/components/table/SimpleTable.vue' import SimpleTable from '@main/components/table/SimpleTable.vue'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -192,7 +192,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger DialogTrigger
} from '@/components/ui/dialog' } from '@shared-ui/components/ui/dialog/index.js'
const props = defineProps({ const props = defineProps({
initialValues: { initialValues: {

View File

@@ -50,7 +50,7 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@shared-ui/components/ui/dropdown-menu'
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -60,13 +60,13 @@ import {
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle AlertDialogTitle
} from '@/components/ui/alert-dialog' } from '@shared-ui/components/ui/alert-dialog'
import { Button } from '@/components/ui/button' import { Button } from '@shared-ui/components/ui/button'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import api from '@/api' import api from '../../../api'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from '../../../composables/useEmitter'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
const { t } = useI18n() const { t } = useI18n()
const router = useRouter() const router = useRouter()

View File

@@ -150,14 +150,14 @@ import {
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage FormMessage
} from '@/components/ui/form' } from '@shared-ui/components/ui/form'
import { import {
TagsInput, TagsInput,
TagsInputInput, TagsInputInput,
TagsInputItem, TagsInputItem,
TagsInputItemDelete, TagsInputItemDelete,
TagsInputItemText TagsInputItemText
} from '@/components/ui/tags-input' } from '@shared-ui/components/ui/tags-input'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -165,8 +165,8 @@ import {
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from '@/components/ui/select' } from '@shared-ui/components/ui/select'
import { Input } from '@/components/ui/input' import { Input } from '@shared-ui/components/ui/input'
const props = defineProps({ const props = defineProps({
form: { form: {

View File

@@ -44,7 +44,7 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@shared-ui/components/ui/dropdown-menu'
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -54,12 +54,12 @@ import {
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle AlertDialogTitle
} from '@/components/ui/alert-dialog' } from '@shared-ui/components/ui/alert-dialog'
import { Button } from '@/components/ui/button' import { Button } from '@shared-ui/components/ui/button'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from '../../../composables/useEmitter'
import { handleHTTPError } from '@/utils/http' import { handleHTTPError } from '../../../utils/http'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
import api from '@/api' import api from '../../../api'
const alertOpen = ref(false) const alertOpen = ref(false)
const emit = useEmitter() const emit = useEmitter()

View File

@@ -171,7 +171,7 @@
<script setup> <script setup>
import { watch, ref, onMounted } from 'vue' import { watch, ref, onMounted } from 'vue'
import { Button } from '@/components/ui/button' import { Button } from '@shared-ui/components/ui/button/index.js'
import { useForm } from 'vee-validate' import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod' import { toTypedSchema } from '@vee-validate/zod'
import { createFormSchema } from './formSchema.js' import { createFormSchema } from './formSchema.js'
@@ -182,7 +182,7 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
FormDescription FormDescription
} from '@/components/ui/form' } from '@shared-ui/components/ui/form/index.js'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -190,21 +190,21 @@ import {
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from '@/components/ui/select' } from '@shared-ui/components/ui/select/index.js'
import { import {
TagsInput, TagsInput,
TagsInputInput, TagsInputInput,
TagsInputItem, TagsInputItem,
TagsInputItemDelete, TagsInputItemDelete,
TagsInputItemText TagsInputItemText
} from '@/components/ui/tags-input' } from '@shared-ui/components/ui/tags-input/index.js'
import { Input } from '@/components/ui/input' import { Input } from '@shared-ui/components/ui/input/index.js'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from '../../../composables/useEmitter.js'
import { handleHTTPError } from '@/utils/http' import { handleHTTPError } from '../../../utils/http.js'
import { timeZones } from '@/constants/timezones.js' import { timeZones } from '../../../constants/timezones.js'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import api from '@/api' import api from '../../../api/index.js'
const emitter = useEmitter() const emitter = useEmitter()
const { t } = useI18n() const { t } = useI18n()

View File

@@ -0,0 +1,376 @@
<template>
<Sheet :open="isOpen" @update:open="$emit('update:open', $event)">
<SheetContent class="!max-w-[80vw] sm:!max-w-[80vw] h-full p-0 flex flex-col">
<div class="flex-1 flex flex-col min-h-0">
<!-- Header -->
<div class="flex items-center justify-between p-6 border-b bg-card/50">
<div>
<h2 class="text-lg font-semibold">
{{ article ? 'Edit Article' : 'Create Article' }}
</h2>
<p class="text-sm text-muted-foreground mt-1">
{{ article ? `Last updated ${formatDatetime(new Date(article.updated_at))}` : 'Create a new help article' }}
</p>
</div>
</div>
<!-- Content -->
<div class="flex-1 flex min-h-0">
<!-- Main Content Area (75%) -->
<div class="flex-1 flex flex-col p-6 space-y-6 overflow-y-auto">
<Spinner v-if="formLoading" />
<form v-else @submit="onSubmit" class="space-y-6 flex-1 flex flex-col">
<!-- Title -->
<FormField v-slot="{ componentField }" name="title">
<FormItem>
<FormControl>
<Input
type="text"
placeholder="Enter article title..."
v-bind="componentField"
class="text-xl font-semibold border-0 px-0 py-3 shadow-none focus-visible:ring-0 placeholder:text-muted-foreground/60"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Content Editor -->
<FormField v-slot="{ componentField }" name="content">
<FormItem class="flex-1 flex flex-col">
<FormControl class="flex-1">
<div class="flex-1 flex flex-col">
<Editor
v-model:htmlContent="componentField.modelValue"
@update:htmlContent="(value) => componentField.onChange(value)"
:placeholder="t('editor.newLine')"
editorType="article"
class="min-h-[400px] border-0 px-0 shadow-none focus-visible:ring-0"
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Submit Button (Hidden - controlled by sidebar) -->
<button type="submit" class="hidden" ref="submitButton"></button>
</form>
</div>
<!-- Sidebar (25%) -->
<div class="w-80 border-l bg-muted/20 p-6 overflow-y-auto">
<div class="space-y-6">
<!-- Publish Actions -->
<div class="space-y-4">
<h3 class="font-medium text-sm text-muted-foreground uppercase tracking-wider">
Actions
</h3>
<div class="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
@click="$emit('cancel')"
class="flex-1"
>
Cancel
</Button>
<Button
type="button"
size="sm"
@click="handleSubmit"
:disabled="isLoading"
class="flex-1"
>
<Loader2Icon v-if="isLoading" class="h-4 w-4 mr-2 animate-spin" />
{{ submitLabel }}
</Button>
</div>
</div>
<!-- Status -->
<div class="space-y-3">
<h3 class="font-medium text-sm text-muted-foreground uppercase tracking-wider">
Status
</h3>
<FormField v-slot="{ componentField }" name="status">
<FormItem>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="published">Published</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription class="text-xs">
Only published articles are visible to users
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Collection -->
<div v-if="availableCollections.length > 0" class="space-y-3">
<h3 class="font-medium text-sm text-muted-foreground uppercase tracking-wider">
Collection
</h3>
<FormField v-slot="{ componentField }" name="collection_id">
<FormItem>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Select collection" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="collection in availableCollections"
:key="collection.id"
:value="collection.id"
>
{{ collection.name }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription class="text-xs">
Move this article to a different collection
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- AI Settings -->
<div class="space-y-3">
<h3 class="font-medium text-sm text-muted-foreground uppercase tracking-wider">
AI Settings
</h3>
<FormField v-slot="{ componentField }" name="ai_enabled">
<FormItem class="flex flex-row items-start space-x-3 space-y-0 border rounded-lg p-3">
<FormControl>
<Checkbox
:checked="componentField.modelValue"
@update:checked="componentField.onChange"
/>
</FormControl>
<div class="space-y-1 leading-none flex-1">
<FormLabel class="text-sm font-medium">
Allow AI assistants to use this article
</FormLabel>
<FormDescription class="text-xs">
Article must be published for this to take effect
</FormDescription>
</div>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Metadata -->
<div v-if="article" class="space-y-3">
<h3 class="font-medium text-sm text-muted-foreground uppercase tracking-wider">
Metadata
</h3>
<div class="space-y-3 text-sm">
<div class="flex justify-between py-2 border-b border-border/50">
<span class="text-muted-foreground">Created</span>
<span>{{ formatDatetime(new Date(article.created_at)) }}</span>
</div>
<div class="flex justify-between py-2 border-b border-border/50">
<span class="text-muted-foreground">Updated</span>
<span>{{ formatDatetime(new Date(article.updated_at)) }}</span>
</div>
<div v-if="article.view_count !== undefined" class="flex justify-between py-2 border-b border-border/50">
<span class="text-muted-foreground">Views</span>
<span>{{ article.view_count.toLocaleString() }}</span>
</div>
<div class="flex justify-between py-2">
<span class="text-muted-foreground">ID</span>
<span class="font-mono text-xs">#{{ article.id }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</SheetContent>
</Sheet>
</template>
<script setup>
import { ref, watch, onMounted, computed } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { Button } from '@shared-ui/components/ui/button'
import { Input } from '@shared-ui/components/ui/input'
import { Checkbox } from '@shared-ui/components/ui/checkbox'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@shared-ui/components/ui/select'
import {
Sheet,
SheetContent,
} from '@shared-ui/components/ui/sheet'
import { Spinner } from '@shared-ui/components/ui/spinner'
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription
} from '@shared-ui/components/ui/form/index.js'
import { Loader2 as Loader2Icon } from 'lucide-vue-next'
import { createArticleFormSchema } from './articleFormSchema.js'
import { useI18n } from 'vue-i18n'
import { getTextFromHTML } from '../../../utils/strings.js'
import Editor from '@main/components/editor/TextEditor.vue'
import api from '../../../api'
import { handleHTTPError } from '../../../utils/http'
import { useEmitter } from '../../../composables/useEmitter'
import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
import { formatDatetime } from '@shared-ui/utils/datetime.js'
const { t } = useI18n()
const props = defineProps({
isOpen: {
type: Boolean,
default: false
},
article: {
type: Object,
default: null
},
collectionId: {
type: Number,
default: null
},
submitForm: {
type: Function,
required: true
},
submitLabel: {
type: String,
default: ''
},
isLoading: {
type: Boolean,
default: false
},
locale: {
type: String,
default: 'en'
}
})
defineEmits(['update:open', 'cancel'])
const emitter = useEmitter()
const formLoading = ref(false)
const availableCollections = ref([])
const submitButton = ref(null)
const submitLabel = computed(() => {
return (
props.submitLabel ||
(props.article ? t('globals.messages.update') : t('globals.messages.create'))
)
})
const form = useForm({
validationSchema: toTypedSchema(createArticleFormSchema(t)),
initialValues: {
title: props.article?.title || '',
content: props.article?.content || '',
status: props.article?.status || 'draft',
collection_id: props.article?.collection_id || props.collectionId || null,
sort_order: props.article?.sort_order || 0,
ai_enabled: props.article?.ai_enabled || false
}
})
onMounted(async () => {
await fetchAvailableCollections()
})
watch(
() => [props.article, props.collectionId, props.locale],
async (newValues) => {
const [newArticle, newCollectionId] = newValues
// Re-fetch available collections when article, collectionId, or locale changes
await fetchAvailableCollections()
if (newArticle && Object.keys(newArticle).length > 0) {
form.setValues({
title: newArticle.title || '',
content: newArticle.content || '',
status: newArticle.status || 'draft',
collection_id: newArticle.collection_id || newCollectionId || null,
sort_order: newArticle.sort_order || 0,
ai_enabled: newArticle.ai_enabled || false
})
}
},
{ immediate: true }
)
const fetchAvailableCollections = async () => {
try {
let helpCenterId = null
if (props.article?.collection_id) {
// Editing existing article - get its collection first to find help center
const { data: collection } = await api.getCollection(props.article.collection_id)
helpCenterId = collection.data.help_center_id
} else if (props.collectionId) {
// Creating new article - get help center from provided collection
const { data: collection } = await api.getCollection(props.collectionId)
helpCenterId = collection.data.help_center_id
}
if (helpCenterId) {
// Filter collections by current locale
const { data: collections } = await api.getCollections(helpCenterId, { locale: props.locale })
// Allow selecting all published collections for the current locale
availableCollections.value = collections.data.filter((c) => c.is_published)
}
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
const onSubmit = form.handleSubmit(async (values) => {
const textContent = getTextFromHTML(values.content)
if (textContent.length === 0) {
values.content = ''
}
props.submitForm(values)
})
const handleSubmit = () => {
if (submitButton.value) {
submitButton.value.click()
}
}
</script>

View File

@@ -0,0 +1,352 @@
<template>
<Sheet :open="isOpen" @update:open="$emit('update:open', $event)">
<SheetContent class="!max-w-[60vw] sm:!max-w-[60vw] h-full p-0 flex flex-col">
<div class="flex-1 flex flex-col min-h-0">
<!-- Header -->
<div class="flex items-center justify-between p-6 border-b bg-card/50">
<div>
<h2 class="text-lg font-semibold">
{{ collection ? 'Edit Collection' : 'Create Collection' }}
</h2>
<p class="text-sm text-muted-foreground mt-1">
{{ collection ? `Last updated ${formatDatetime(new Date(collection.updated_at))}` : 'Create a new help collection' }}
</p>
</div>
</div>
<!-- Content -->
<div class="flex-1 flex min-h-0">
<!-- Main Content Area (70%) -->
<div class="flex-1 flex flex-col p-6 space-y-6 overflow-y-auto">
<Spinner v-if="formLoading" />
<form v-else @submit="onSubmit" class="space-y-6 flex-1 flex flex-col">
<!-- Name -->
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormControl>
<Input
type="text"
placeholder="Enter collection name..."
v-bind="componentField"
class="text-xl font-semibold border-0 px-0 py-3 shadow-none focus-visible:ring-0 placeholder:text-muted-foreground/60"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Description -->
<FormField v-slot="{ componentField }" name="description">
<FormItem class="flex-1">
<FormControl>
<Textarea
placeholder="Describe what this collection contains..."
rows="6"
v-bind="componentField"
class="border-0 px-0 py-2 shadow-none focus-visible:ring-0 resize-none placeholder:text-muted-foreground/60"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Submit Button (Hidden - controlled by sidebar) -->
<button type="submit" class="hidden" ref="submitButton"></button>
</form>
</div>
<!-- Sidebar (30%) -->
<div class="w-72 border-l bg-muted/20 p-6 overflow-y-auto">
<div class="space-y-6">
<!-- Publish Actions -->
<div class="space-y-4">
<h3 class="font-medium text-sm text-muted-foreground uppercase tracking-wider">
Actions
</h3>
<div class="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
@click="$emit('cancel')"
class="flex-1"
>
Cancel
</Button>
<Button
type="button"
size="sm"
@click="handleSubmit"
:disabled="isLoading"
class="flex-1"
>
<Loader2Icon v-if="isLoading" class="h-4 w-4 mr-2 animate-spin" />
{{ submitLabel }}
</Button>
</div>
</div>
<!-- Visibility -->
<div class="space-y-3">
<h3 class="font-medium text-sm text-muted-foreground uppercase tracking-wider">
Visibility
</h3>
<FormField v-slot="{ componentField }" name="is_published">
<FormItem class="flex flex-row items-start space-x-3 space-y-0 border rounded-lg p-3">
<FormControl>
<Checkbox
:checked="componentField.modelValue"
@update:checked="componentField.onChange"
/>
</FormControl>
<div class="space-y-1 leading-none flex-1">
<FormLabel class="text-sm font-medium">
Published
</FormLabel>
<FormDescription class="text-xs">
Published collections are visible to users
</FormDescription>
</div>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Parent Collection -->
<div v-if="availableParents.length > 0" class="space-y-3">
<h3 class="font-medium text-sm text-muted-foreground uppercase tracking-wider">
Parent Collection
</h3>
<FormField v-slot="{ componentField }" name="parent_id">
<FormItem>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Select parent (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="0">No parent (root level)</SelectItem>
<SelectItem v-for="parent in availableParents" :key="parent.id" :value="parent.id">
{{ parent.name }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription class="text-xs">
Collections can be nested up to 3 levels deep
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Articles Count -->
<div v-if="collection && collection.articles" class="space-y-3">
<h3 class="font-medium text-sm text-muted-foreground uppercase tracking-wider">
Articles
</h3>
<div class="border rounded-lg p-3">
<div class="flex items-center justify-between">
<span class="text-sm font-medium">Total Articles</span>
<Badge variant="outline">{{ collection.articles.length }}</Badge>
</div>
<p class="text-xs text-muted-foreground mt-2">
{{ collection.articles.filter(a => a.status === 'published').length }} published,
{{ collection.articles.filter(a => a.status === 'draft').length }} draft
</p>
</div>
</div>
<!-- Metadata -->
<div v-if="collection" class="space-y-3">
<h3 class="font-medium text-sm text-muted-foreground uppercase tracking-wider">
Metadata
</h3>
<div class="space-y-3 text-sm">
<div class="flex justify-between py-2 border-b border-border/50">
<span class="text-muted-foreground">Created</span>
<span>{{ formatDatetime(new Date(collection.created_at)) }}</span>
</div>
<div class="flex justify-between py-2 border-b border-border/50">
<span class="text-muted-foreground">Updated</span>
<span>{{ formatDatetime(new Date(collection.updated_at)) }}</span>
</div>
<div v-if="collection.view_count !== undefined" class="flex justify-between py-2 border-b border-border/50">
<span class="text-muted-foreground">Views</span>
<span>{{ collection.view_count.toLocaleString() }}</span>
</div>
<div class="flex justify-between py-2">
<span class="text-muted-foreground">ID</span>
<span class="font-mono text-xs">#{{ collection.id }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</SheetContent>
</Sheet>
</template>
<script setup>
import { ref, watch, onMounted, computed } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { Button } from '@shared-ui/components/ui/button'
import { Input } from '@shared-ui/components/ui/input'
import { Textarea } from '@shared-ui/components/ui/textarea'
import { Checkbox } from '@shared-ui/components/ui/checkbox'
import { Badge } from '@shared-ui/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@shared-ui/components/ui/select'
import {
Sheet,
SheetContent,
} from '@shared-ui/components/ui/sheet'
import { Spinner } from '@shared-ui/components/ui/spinner'
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription
} from '@shared-ui/components/ui/form/index.js'
import { Loader2 as Loader2Icon } from 'lucide-vue-next'
import { createCollectionFormSchema } from './collectionFormSchema.js'
import { useI18n } from 'vue-i18n'
import api from '../../../api'
import { handleHTTPError } from '../../../utils/http'
import { useEmitter } from '../../../composables/useEmitter'
import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
import { formatDatetime } from '@shared-ui/utils/datetime.js'
const { t } = useI18n()
const props = defineProps({
isOpen: {
type: Boolean,
default: false
},
collection: {
type: Object,
default: null
},
helpCenterId: {
type: Number,
required: true
},
parentId: {
type: Number,
default: null
},
submitForm: {
type: Function,
required: true
},
submitLabel: {
type: String,
default: ''
},
isLoading: {
type: Boolean,
default: false
},
locale: {
type: String,
default: 'en'
}
})
defineEmits(['update:open', 'cancel'])
const emitter = useEmitter()
const formLoading = ref(false)
const availableParents = ref([])
const submitButton = ref(null)
const submitLabel = computed(() => {
return (
props.submitLabel ||
(props.collection ? t('globals.messages.update') : t('globals.messages.create'))
)
})
const form = useForm({
validationSchema: toTypedSchema(createCollectionFormSchema(t)),
initialValues: {
name: props.collection?.name || '',
description: props.collection?.description || '',
parent_id: props.collection?.parent_id || props.parentId || null,
is_published: props.collection?.is_published ?? true,
sort_order: props.collection?.sort_order || 0
}
})
onMounted(async () => {
await fetchAvailableParents()
})
watch(
() => props.collection,
(newValues) => {
if (newValues && Object.keys(newValues).length > 0) {
form.setValues({
name: newValues.name || '',
description: newValues.description || '',
parent_id: newValues.parent_id || null,
is_published: newValues.is_published ?? true,
sort_order: newValues.sort_order || 0
})
}
},
{ immediate: true }
)
watch(
() => props.locale,
async () => {
await fetchAvailableParents()
}
)
const fetchAvailableParents = async () => {
try {
// Filter collections by current locale
const { data } = await api.getCollections(props.helpCenterId, { locale: props.locale })
availableParents.value = data.data.filter((collection) => {
// Exclude self and children from parent options
if (props.collection && collection.id === props.collection.id) return false
if (props.collection && collection.parent_id === props.collection.id) return false
return true
})
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
const onSubmit = form.handleSubmit(async (values) => {
props.submitForm(values)
})
const handleSubmit = () => {
if (submitButton.value) {
submitButton.value.click()
}
}
</script>

View File

@@ -0,0 +1,35 @@
<template>
<MenuCard @click="handleClick">
<template #title>
<BookOpen size="24" class="mr-2 text-primary" />
{{ helpCenter.name }}
</template>
<template #subtitle>
<p class="text-sm mb-3">{{ helpCenter.page_title }}</p>
</template>
<div class="mt-3 pt-3 border-t">
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>{{ helpCenter.view_count || 0 }} views</span>
</div>
</div>
</MenuCard>
</template>
<script setup>
import { defineEmits } from 'vue'
import MenuCard from '@shared-ui/components/ui/menu-card/MenuCard.vue'
import { BookOpen } from 'lucide-vue-next'
const props = defineProps({
helpCenter: {
type: Object,
required: true
}
})
const emit = defineEmits(['edit', 'delete', 'click'])
const handleClick = () => {
emit('click', props.helpCenter)
}
</script>

View File

@@ -0,0 +1,173 @@
<template>
<Spinner v-if="formLoading"></Spinner>
<form @submit="onSubmit" class="space-y-6 w-full" :class="{ 'opacity-50': formLoading }">
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>{{ t('globals.terms.name') }} *</FormLabel>
<FormControl>
<Input
type="text"
placeholder="Enter help center name"
v-bind="componentField"
@input="generateSlug"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="slug">
<FormItem>
<FormLabel>Slug *</FormLabel>
<FormControl>
<Input type="text" placeholder="help-center-slug" v-bind="componentField" />
</FormControl>
<FormDescription>
This will be used in the URL: /help/{{ form.values.slug || 'your-slug' }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="page_title">
<FormItem>
<FormLabel>Page Title *</FormLabel>
<FormControl>
<Input type="text" placeholder="Enter page title" v-bind="componentField" />
</FormControl>
<FormDescription> This will appear in the browser tab and search results </FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="default_locale">
<FormItem>
<FormLabel>Default Language *</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Select default language" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="language in LANGUAGES" :key="language.code" :value="language.code">
{{ language.nativeName }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
This will be the default language for new articles and collections
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<div class="flex justify-end space-x-2 pt-4">
<Button type="button" variant="outline" @click="$emit('cancel')"> Cancel </Button>
<Button type="submit" :isLoading="isLoading">
{{ submitLabel }}
</Button>
</div>
</form>
</template>
<script setup>
import { ref, watch, computed } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { LANGUAGES } from '@shared-ui/constants'
import { Button } from '@shared-ui/components/ui/button'
import { Input } from '@shared-ui/components/ui/input'
import { Spinner } from '@shared-ui/components/ui/spinner'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@shared-ui/components/ui/select'
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription
} from '@shared-ui/components/ui/form/index.js'
import { createHelpCenterFormSchema } from './helpCenterFormSchema.js'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps({
helpCenter: {
type: Object,
default: null
},
submitForm: {
type: Function,
required: true
},
submitLabel: {
type: String,
default: ''
},
isLoading: {
type: Boolean,
default: false
}
})
defineEmits(['cancel'])
const formLoading = ref(false)
const submitLabel = computed(() => {
return (
props.submitLabel ||
(props.helpCenter ? t('globals.messages.update') : t('globals.messages.create'))
)
})
const form = useForm({
validationSchema: toTypedSchema(createHelpCenterFormSchema(t)),
initialValues: {
name: props.helpCenter?.name || '',
slug: props.helpCenter?.slug || '',
page_title: props.helpCenter?.page_title || '',
default_locale: props.helpCenter?.default_locale || 'en'
}
})
const generateSlug = () => {
if (!props.helpCenter && form.values.name) {
form.setFieldValue(
'slug',
form.values.name
.toLowerCase()
.replace(/[^a-z0-9]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
)
}
}
const onSubmit = form.handleSubmit(async (values) => {
props.submitForm(values)
})
watch(
() => props.helpCenter,
(newValues) => {
if (newValues && Object.keys(newValues).length > 0) {
form.setValues({
name: newValues.name || '',
slug: newValues.slug || '',
page_title: newValues.page_title || '',
default_locale: newValues.default_locale || 'en'
})
}
},
{ immediate: true }
)
</script>

View File

@@ -0,0 +1,112 @@
<template>
<DropdownMenu :modal="false">
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
class="h-6 w-6 p-0"
@click.stop
>
<MoreHorizontalIcon class="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-48">
<DropdownMenuItem @click="handleEdit">
<PencilIcon class="mr-2 h-4 w-4" />
Edit {{ item.type === 'collection' ? 'Collection' : 'Article' }}
</DropdownMenuItem>
<template v-if="item.type === 'collection'">
<DropdownMenuSeparator />
<DropdownMenuItem @click="handleCreateCollection">
<FolderPlusIcon class="mr-2 h-4 w-4" />
Add Collection
</DropdownMenuItem>
<DropdownMenuItem @click="handleCreateArticle">
<DocumentPlusIcon class="mr-2 h-4 w-4" />
Add Article
</DropdownMenuItem>
</template>
<DropdownMenuSeparator />
<DropdownMenuItem @click="handleToggleStatus">
<template v-if="item.type === 'collection'">
<EyeIcon v-if="!item.is_published" class="mr-2 h-4 w-4" />
<EyeSlashIcon v-else class="mr-2 h-4 w-4" />
{{ item.is_published ? 'Unpublish' : 'Publish' }}
</template>
<template v-else>
<EyeIcon v-if="item.status === 'draft'" class="mr-2 h-4 w-4" />
<EyeSlashIcon v-else class="mr-2 h-4 w-4" />
{{ item.status === 'published' ? 'Unpublish' : 'Publish' }}
</template>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
@click="handleDelete"
class="text-destructive focus:text-destructive"
>
<TrashIcon class="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
<script setup>
import { Button } from '@shared-ui/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@shared-ui/components/ui/dropdown-menu'
import {
FilePlus as DocumentPlusIcon,
Eye as EyeIcon,
EyeOff as EyeSlashIcon,
FolderPlus as FolderPlusIcon,
MoreHorizontal as MoreHorizontalIcon,
Pencil as PencilIcon,
Trash as TrashIcon,
} from 'lucide-vue-next'
const props = defineProps({
item: {
type: Object,
required: true
}
})
const emit = defineEmits([
'create-collection',
'create-article',
'edit',
'delete',
'toggle-status'
])
const handleEdit = () => {
emit('edit', props.item)
}
const handleCreateCollection = () => {
emit('create-collection', props.item.id)
}
const handleCreateArticle = () => {
emit('create-article', props.item)
}
const handleDelete = () => {
emit('delete', props.item)
}
const handleToggleStatus = () => {
emit('toggle-status', props.item)
}
</script>

View File

@@ -0,0 +1,287 @@
<template>
<div>
<!-- Collection Node -->
<Collapsible v-if="item.type === 'collection'" v-model:open="isOpen">
<div
class="group tree-node"
:class="{
'tree-node--selected': isSelected,
'hover:shadow-sm': !isSelected
}"
@click="selectItem"
>
<div class="flex items-center gap-3">
<CollapsibleTrigger as-child @click.stop>
<ChevronRightIcon
class="h-4 w-4 transition-transform text-muted-foreground hover:text-foreground flex-shrink-0"
:class="{ 'rotate-90': isOpen }"
/>
</CollapsibleTrigger>
<div class="icon-container-folder">
<FolderIcon class="h-4.5 w-4.5 text-blue-600" />
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<h4 class="text-sm font-semibold truncate text-foreground">
{{ item.name }}
</h4>
<span
v-if="!item.is_published"
class="text-[10px] font-medium bg-yellow-100 text-yellow-800 px-1.5 py-0.5 rounded"
>
Draft
</span>
</div>
<p v-if="item.description" class="text-xs text-muted-foreground leading-tight line-clamp-2 max-w-xs">
{{ item.description }}
</p>
</div>
<div class="hover-actions ml-2">
<Badge
v-if="item.articles && item.articles.length > 0"
variant="outline"
class="text-xs px-2 py-0.5 font-normal bg-card/50 text-muted-foreground"
>
{{ item.articles.length }} {{ item.articles.length === 1 ? 'article' : 'articles' }}
</Badge>
<TreeDropdown
:item="item"
@create-collection="$emit('create-collection', item.id)"
@create-article="$emit('create-article', item)"
@edit="$emit('edit', $event)"
@delete="$emit('delete', $event)"
@toggle-status="$emit('toggle-status', $event)"
/>
</div>
</div>
</div>
<!-- Child Collections and Articles -->
<CollapsibleContent>
<div class="ml-10 mt-2 pl-2 border-l border-border/20">
<!-- Empty no child content -->
<div
v-if="!childCollections.length && !articles.length"
class="text-sm text-muted-foreground bg-muted/10 rounded-md py-3 px-4 text-center italic"
>
<FolderOpenIcon class="h-4 w-4 mx-auto mb-1.5 opacity-60" />
{{ $t('globals.messages.empty') }}
</div>
<!-- Articles -->
<div class="space-y-1.5">
<div
v-for="element in articles"
:key="element.id"
class="group tree-node--article"
:class="{
'tree-node--selected':
selectedItem?.id === element.id && selectedItem?.type === 'article'
}"
@click="selectArticle(element)"
>
<div class="flex items-center gap-2">
<div class="icon-container-article">
<DocumentTextIcon class="h-4 w-4 text-green-600" />
</div>
<div class="flex-1 min-w-0">
<h5 class="text-sm font-medium truncate text-foreground">
{{ element.title }}
</h5>
<p
v-if="element.description"
class="text-xs text-muted-foreground truncate mt-0.5"
>
{{ element.description }}
</p>
</div>
<div class="hover-actions--compact">
<Badge
:variant="getArticleStatusVariant(element.status)"
class="text-[11px] px-1.5 py-0.5 font-normal"
v-if="element.status"
>
{{ element.status.charAt(0).toUpperCase() + element.status.slice(1) }}
</Badge>
<TreeDropdown
:item="{ ...element, type: 'article' }"
@edit="$emit('edit', $event)"
@delete="$emit('delete', $event)"
@toggle-status="$emit('toggle-status', $event)"
/>
</div>
</div>
</div>
</div>
<!-- Child Collections -->
<div class="space-y-1.5">
<TreeNode
v-for="element in childCollections"
:key="element.id"
:item="{ ...element, type: 'collection' }"
:selected-item="selectedItem"
:level="level + 1"
@select="$emit('select', $event)"
@create-collection="$emit('create-collection', $event)"
@create-article="$emit('create-article', $event)"
@edit="$emit('edit', $event)"
@delete="$emit('delete', $event)"
@toggle-status="$emit('toggle-status', $event)"
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
<!-- Article Node (when at root level) -->
<div
v-else
class="group tree-node--article"
:class="{
'tree-node--selected': isSelected,
'hover:shadow-xs': !isSelected
}"
@click="selectItem"
>
<div class="flex items-center gap-2">
<div class="icon-container-article">
<DocumentTextIcon class="h-4 w-4 text-green-600" />
</div>
<div class="flex-1 min-w-0">
<h5 class="text-sm font-medium truncate text-foreground">
{{ item.title }}
</h5>
<p v-if="item.description" class="text-xs text-muted-foreground truncate mt-0.5">
{{ item.description }}
</p>
</div>
<div class="hover-actions--compact">
<Badge
:variant="getArticleStatusVariant(item.status)"
class="text-[11px] px-1.5 py-0.5 font-normal"
>
{{ item.status }}
</Badge>
<TreeDropdown
:item="item"
@edit="$emit('edit', $event)"
@delete="$emit('delete', $event)"
@toggle-status="$emit('toggle-status', $event)"
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { Badge } from '@shared-ui/components/ui/badge'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger
} from '@shared-ui/components/ui/collapsible'
import {
ChevronRight as ChevronRightIcon,
FileText as DocumentTextIcon,
Folder as FolderIcon,
FolderOpen as FolderOpenIcon
} from 'lucide-vue-next'
import TreeDropdown from './TreeDropdown.vue'
const props = defineProps({
item: {
type: Object,
required: true
},
selectedItem: {
type: Object,
default: null
},
level: {
type: Number,
default: 0
}
})
const emit = defineEmits([
'select',
'create-collection',
'create-article',
'edit',
'delete',
'toggle-status'
])
const isOpen = ref(true)
const isSelected = computed(() => {
if (!props.selectedItem) return false
return props.selectedItem.id === props.item.id && props.selectedItem.type === props.item.type
})
const childCollections = computed(() => props.item.children || [])
const articles = computed(() => props.item.articles || [])
const selectItem = () => {
emit('select', props.item)
}
const selectArticle = (article) => {
emit('select', { ...article, type: 'article' })
}
const getArticleStatusVariant = (status) => {
switch (status) {
case 'published':
return 'default'
case 'draft':
return 'secondary'
default:
return 'secondary'
}
}
</script>
<style scoped>
.tree-node {
@apply border border-transparent hover:border-border hover:bg-muted/20 rounded-lg p-3 transition-all duration-200 cursor-pointer;
}
.tree-node--article {
@apply border border-transparent hover:border-border hover:bg-muted/20 rounded-md p-2.5 transition-all duration-200 cursor-pointer;
}
.tree-node--selected {
@apply bg-accent/10 border-border shadow-sm ring-1 ring-accent/20;
}
.icon-container-folder {
@apply flex items-center justify-center w-9 h-9 rounded-lg bg-blue-50 border border-blue-100/70;
}
.icon-container-article {
@apply flex items-center justify-center w-7 h-7 rounded-md bg-green-50 border border-green-100/70;
}
.hover-actions {
@apply flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-150;
}
.hover-actions--compact {
@apply flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-150;
}
</style>

View File

@@ -0,0 +1,44 @@
<template>
<div class="space-y-3">
<TreeNode
v-for="element in collections"
:key="element.id"
:item="element"
:selected-item="selectedItem"
:level="0"
@select="$emit('select', $event)"
@create-collection="$emit('create-collection', $event)"
@create-article="$emit('create-article', $event)"
@edit="$emit('edit', $event)"
@delete="$emit('delete', $event)"
@toggle-status="$emit('toggle-status', $event)"
/>
</div>
</template>
<script setup>
import { computed } from 'vue'
import TreeNode from './TreeNode.vue'
const props = defineProps({
data: {
type: Array,
required: true
},
selectedItem: {
type: Object,
default: null
}
})
defineEmits([
'select',
'create-collection',
'create-article',
'edit',
'delete',
'toggle-status'
])
const collections = computed(() => props.data.map((item) => ({ ...item, type: 'collection' })))
</script>

View File

@@ -0,0 +1,10 @@
import * as z from 'zod'
export const createArticleFormSchema = (t) => z.object({
title: z.string().min(1, t('globals.messages.required')),
content: z.string().min(1, t('globals.messages.required')),
status: z.enum(['draft', 'published']).default('draft'),
collection_id: z.number().min(1, t('globals.messages.required')),
sort_order: z.number().default(0),
ai_enabled: z.boolean().default(false),
})

View File

@@ -0,0 +1,9 @@
import * as z from 'zod'
export const createCollectionFormSchema = (t) => z.object({
name: z.string().min(1, t('globals.messages.required')),
description: z.string().optional(),
parent_id: z.number().nullable().optional(),
is_published: z.boolean().default(true),
sort_order: z.number().default(0),
})

View File

@@ -0,0 +1,11 @@
import * as z from 'zod'
export const createHelpCenterFormSchema = (t) => z.object({
name: z.string().min(1, t('globals.messages.required')),
slug: z
.string()
.min(1, t('globals.messages.required'))
.regex(/^[a-z0-9-]+$/, 'Slug can only contain lowercase letters, numbers, and hyphens'),
page_title: z.string().min(1, t('globals.messages.required')),
default_locale: z.string().min(1, t('globals.messages.required')),
})

View File

@@ -12,6 +12,37 @@
</FormItem> </FormItem>
</FormField> </FormField>
<FormField v-slot="{ componentField }" name="help_center_id">
<FormItem>
<FormLabel>{{ $t('globals.terms.helpCenter') }}</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue
:placeholder="
t('globals.messages.select', {
name: $t('globals.terms.helpCenter').toLowerCase()
})
"
/>
</SelectTrigger>
<SelectContent>
<SelectItem :value="0">{{ $t('globals.terms.none') }}</SelectItem>
<SelectItem
v-for="helpCenter in helpCenters"
:key="helpCenter.id"
:value="helpCenter.id"
>
{{ helpCenter.name }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>{{ $t('admin.inbox.helpCenter.description') }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="from"> <FormField v-slot="{ componentField }" name="from">
<FormItem> <FormItem>
<FormLabel>{{ $t('globals.terms.fromEmailAddress') }}</FormLabel> <FormLabel>{{ $t('globals.terms.fromEmailAddress') }}</FormLabel>
@@ -85,11 +116,7 @@
<FormItem> <FormItem>
<FormLabel>{{ $t('admin.inbox.mailbox') }}</FormLabel> <FormLabel>{{ $t('admin.inbox.mailbox') }}</FormLabel>
<FormControl> <FormControl>
<Input <Input type="text" placeholder="INBOX" v-bind="componentField" />
type="text"
placeholder="INBOX"
v-bind="componentField"
/>
</FormControl> </FormControl>
<FormDescription> <FormDescription>
{{ $t('admin.inbox.mailbox.description') }} {{ $t('admin.inbox.mailbox.description') }}
@@ -349,10 +376,11 @@
</template> </template>
<script setup> <script setup>
import { watch, computed } from 'vue' import { watch, computed, ref, onMounted } from 'vue'
import { useForm } from 'vee-validate' import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod' import { toTypedSchema } from '@vee-validate/zod'
import { createFormSchema } from './formSchema.js' import { createFormSchema } from './formSchema.js'
import api from '@main/api'
import { import {
FormControl, FormControl,
FormField, FormField,
@@ -360,17 +388,17 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
FormDescription FormDescription
} from '@/components/ui/form' } from '@shared-ui/components/ui/form/index.js'
import { Input } from '@/components/ui/input' import { Input } from '@shared-ui/components/ui/input/index.js'
import { Switch } from '@/components/ui/switch' import { Switch } from '@shared-ui/components/ui/switch/index.js'
import { Button } from '@/components/ui/button' import { Button } from '@shared-ui/components/ui/button/index.js'
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from '@/components/ui/select' } from '@shared-ui/components/ui/select/index.js'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
const props = defineProps({ const props = defineProps({
@@ -393,10 +421,13 @@ const props = defineProps({
}) })
const { t } = useI18n() const { t } = useI18n()
const helpCenters = ref([])
const form = useForm({ const form = useForm({
validationSchema: toTypedSchema(createFormSchema(t)), validationSchema: toTypedSchema(createFormSchema(t)),
initialValues: { initialValues: {
name: '', name: '',
help_center_id: 0,
from: '', from: '',
enabled: true, enabled: true,
csat_enabled: false, csat_enabled: false,
@@ -446,4 +477,13 @@ watch(
}, },
{ deep: true, immediate: true } { deep: true, immediate: true }
) )
onMounted(async () => {
try {
const { data } = await api.getHelpCenters()
helpCenters.value = data.data
} catch (error) {
console.error('Error fetching help centers:', error)
}
})
</script> </script>

View File

@@ -48,7 +48,7 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@shared-ui/components/ui/dropdown-menu'
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -58,8 +58,8 @@ import {
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle AlertDialogTitle
} from '@/components/ui/alert-dialog' } from '@shared-ui/components/ui/alert-dialog'
import { Button } from '@/components/ui/button' import { Button } from '@shared-ui/components/ui/button'
const alertOpen = ref(false) const alertOpen = ref(false)
const props = defineProps({ const props = defineProps({

View File

@@ -0,0 +1,951 @@
<template>
<form @submit="onSubmit" class="space-y-6 w-full">
<!-- Main Tabs -->
<Tabs v-model="activeTab" class="w-full">
<TabsList class="grid w-full grid-cols-7">
<TabsTrigger value="general">{{ $t('admin.inbox.livechat.tabs.general') }}</TabsTrigger>
<TabsTrigger value="appearance">{{
$t('admin.inbox.livechat.tabs.appearance')
}}</TabsTrigger>
<TabsTrigger value="messages">{{ $t('admin.inbox.livechat.tabs.messages') }}</TabsTrigger>
<TabsTrigger value="features">{{ $t('admin.inbox.livechat.tabs.features') }}</TabsTrigger>
<TabsTrigger value="security">{{ $t('admin.inbox.livechat.tabs.security') }}</TabsTrigger>
<TabsTrigger value="prechat">{{ $t('admin.inbox.livechat.tabs.prechat') }}</TabsTrigger>
<TabsTrigger value="users">{{ $t('admin.inbox.livechat.tabs.users') }}</TabsTrigger>
</TabsList>
<div class="mt-6">
<!-- General Tab -->
<div v-show="activeTab === 'general'" class="space-y-6">
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>{{ $t('globals.terms.name') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="" v-bind="componentField" />
</FormControl>
<FormDescription>{{ $t('admin.inbox.name.description') }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="help_center_id">
<FormItem>
<FormLabel>{{ $t('admin.inbox.helpCenter') }}</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue :placeholder="t('admin.inbox.helpCenter.placeholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="0">{{ $t('globals.terms.none') }}</SelectItem>
<SelectItem
v-for="helpCenter in helpCenters"
:key="helpCenter.id"
:value="helpCenter.id"
>
{{ helpCenter.name }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>{{ $t('admin.inbox.helpCenter.description') }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField, handleChange }" name="enabled">
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{ $t('globals.terms.enabled') }}</FormLabel>
<FormDescription>{{ $t('admin.inbox.enabled.description') }}</FormDescription>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
<FormField v-slot="{ componentField, handleChange }" name="csat_enabled">
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{ $t('admin.inbox.csatSurveys') }}</FormLabel>
<FormDescription>
{{ $t('admin.inbox.csatSurveys.description_1') }}<br />
{{ $t('admin.inbox.csatSurveys.description_2') }}
</FormDescription>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="config.brand_name">
<FormItem>
<FormLabel>{{
$t('globals.terms.brand') + ' ' + $t('globals.terms.name').toLowerCase()
}}</FormLabel>
<FormControl>
<Input type="text" placeholder="" v-bind="componentField" />
</FormControl>
</FormItem>
</FormField>
<!-- Language -->
<FormField v-slot="{ componentField }" name="config.language">
<FormItem>
<FormLabel>{{ $t('globals.terms.language') }}</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Select language" />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="mr">Marathi</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>{{
$t('admin.inbox.livechat.language.description')
}}</FormDescription>
</FormItem>
</FormField>
</div>
<!-- Appearance Tab -->
<div v-show="activeTab === 'appearance'" class="space-y-6">
<!-- Logo URL -->
<FormField v-slot="{ componentField }" name="config.logo_url">
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.logoUrl') }}</FormLabel>
<FormControl>
<Input
type="url"
placeholder="https://example.com/logo.png"
v-bind="componentField"
/>
</FormControl>
<FormDescription>{{
$t('admin.inbox.livechat.logoUrl.description')
}}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Colors -->
<div class="space-y-4">
<h4 class="font-medium text-foreground">{{ $t('admin.inbox.livechat.colors') }}</h4>
<div class="grid grid-cols-2 gap-4">
<FormField v-slot="{ componentField }" name="config.colors.primary">
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.colors.primary') }}</FormLabel>
<FormControl>
<Input type="color" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
</div>
<!-- Dark mode -->
<FormField v-slot="{ componentField, handleChange }" name="config.dark_mode">
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{ $t('admin.inbox.livechat.darkMode') }}</FormLabel>
<FormDescription>{{
$t('admin.inbox.livechat.darkMode.description')
}}</FormDescription>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
<!-- Launcher Configuration -->
<div class="space-y-4">
<h4 class="font-medium text-foreground">{{ $t('admin.inbox.livechat.launcher') }}</h4>
<div class="grid grid-cols-2 gap-4">
<!-- Launcher Position -->
<FormField v-slot="{ componentField }" name="config.launcher.position">
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.launcher.position') }}</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Select position" />
</SelectTrigger>
<SelectContent>
<SelectItem value="left">{{
$t('admin.inbox.livechat.launcher.position.left')
}}</SelectItem>
<SelectItem value="right">{{
$t('admin.inbox.livechat.launcher.position.right')
}}</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Launcher Logo -->
<FormField v-slot="{ componentField }" name="config.launcher.logo_url">
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.launcher.logo') }}</FormLabel>
<FormControl>
<Input
type="url"
placeholder="https://example.com/launcher-logo.png"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<div class="grid grid-cols-2 gap-4">
<!-- Launcher Spacing Side -->
<FormField v-slot="{ componentField }" name="config.launcher.spacing.side">
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.launcher.spacing.side') }}</FormLabel>
<FormControl>
<Input type="number" placeholder="20" v-bind="componentField" />
</FormControl>
<FormDescription>{{
$t('admin.inbox.livechat.launcher.spacing.side.description')
}}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Launcher Spacing Bottom -->
<FormField v-slot="{ componentField }" name="config.launcher.spacing.bottom">
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.launcher.spacing.bottom') }}</FormLabel>
<FormControl>
<Input type="number" placeholder="20" v-bind="componentField" />
</FormControl>
<FormDescription>{{
$t('admin.inbox.livechat.launcher.spacing.bottom.description')
}}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</div>
</div>
</div>
<!-- Messages Tab -->
<div v-show="activeTab === 'messages'" class="space-y-6">
<FormField v-slot="{ componentField }" name="config.greeting_message">
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.greetingMessage') }}</FormLabel>
<FormControl>
<Textarea
v-bind="componentField"
placeholder="Welcome! How can we help you today?"
rows="2"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="config.introduction_message">
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.introductionMessage') }}</FormLabel>
<FormControl>
<Textarea v-bind="componentField" placeholder="We're here to help!" rows="2" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="config.chat_introduction">
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.chatIntroduction') }}</FormLabel>
<FormControl>
<Textarea
v-bind="componentField"
placeholder="Ask us anything, or share your feedback."
rows="2"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- External Links -->
<div class="space-y-4">
<h4 class="font-medium text-foreground">
{{ $t('admin.inbox.livechat.externalLinks') }}
</h4>
<FormField name="config.external_links">
<FormItem>
<div class="space-y-3">
<div
v-for="(link, index) in externalLinks"
:key="index"
class="flex items-center gap-2 p-3 border rounded"
>
<div class="flex-1 grid grid-cols-2 gap-2">
<Input
v-model="link.text"
placeholder="Link Text"
@input="updateExternalLinks"
/>
<Input
v-model="link.url"
placeholder="https://example.com"
@input="updateExternalLinks"
/>
</div>
<Button
type="button"
variant="ghost"
size="sm"
@click="removeExternalLink(index)"
>
<X class="w-4 h-4" />
</Button>
</div>
<Button type="button" variant="outline" size="sm" @click="addExternalLink">
<Plus class="w-4 h-4 mr-2" />
{{ $t('admin.inbox.livechat.externalLinks.add') }}
</Button>
</div>
<FormDescription>
{{ $t('admin.inbox.livechat.externalLinks.description') }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Notice Banner -->
<div class="space-y-4">
<h4 class="font-medium text-foreground">
{{ $t('admin.inbox.livechat.noticeBanner') }}
</h4>
<FormField
v-slot="{ componentField, handleChange }"
name="config.notice_banner.enabled"
>
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{
$t('admin.inbox.livechat.noticeBanner.enabled')
}}</FormLabel>
<FormDescription>{{
$t('admin.inbox.livechat.noticeBanner.enabled.description')
}}</FormDescription>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
<FormField
v-slot="{ componentField }"
name="config.notice_banner.text"
v-if="form.values.config?.notice_banner?.enabled"
>
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.noticeBanner.text') }}</FormLabel>
<FormControl>
<Textarea
v-bind="componentField"
placeholder="Our response times are slower than usual. We're working hard to get to your message."
rows="2"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
</div>
<!-- Features Tab -->
<div v-show="activeTab === 'features'" class="space-y-6">
<!-- Office Hours -->
<div class="space-y-4">
<h4 class="font-medium text-foreground">
{{ $t('admin.inbox.livechat.officeHours') }}
</h4>
<FormField
v-slot="{ componentField, handleChange }"
name="config.show_office_hours_in_chat"
>
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{
$t('admin.inbox.livechat.showOfficeHoursInChat')
}}</FormLabel>
<FormDescription>{{
$t('admin.inbox.livechat.showOfficeHoursInChat.description')
}}</FormDescription>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
<FormField
v-slot="{ componentField, handleChange }"
name="config.show_office_hours_after_assignment"
>
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{
$t('admin.inbox.livechat.showOfficeHoursAfterAssignment')
}}</FormLabel>
<FormDescription>{{
$t('admin.inbox.livechat.showOfficeHoursAfterAssignment.description')
}}</FormDescription>
</div>
<FormControl>
<Switch
:checked="componentField.modelValue"
@update:checked="handleChange"
:disabled="!form.values.config.show_office_hours_in_chat"
/>
</FormControl>
</FormItem>
</FormField>
<FormField
v-if="form.values.config.show_office_hours_in_chat"
v-slot="{ componentField }"
name="config.chat_reply_expectation_message"
>
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.chatReplyExpectationMessage') }}</FormLabel>
<FormControl>
<Input type="text" v-bind="componentField" />
</FormControl>
<FormDescription>
{{ $t('admin.inbox.livechat.chatReplyExpectationMessage.description') }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Chat Features -->
<div class="space-y-4">
<h4 class="font-medium text-foreground">{{ $t('admin.inbox.livechat.features') }}</h4>
<div class="space-y-3">
<FormField
v-slot="{ componentField, handleChange }"
name="config.features.file_upload"
>
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{
$t('admin.inbox.livechat.features.fileUpload')
}}</FormLabel>
<FormDescription>{{
$t('admin.inbox.livechat.features.fileUpload.description')
}}</FormDescription>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
<FormField v-slot="{ componentField, handleChange }" name="config.features.emoji">
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{
$t('admin.inbox.livechat.features.emoji')
}}</FormLabel>
<FormDescription>{{
$t('admin.inbox.livechat.features.emoji.description')
}}</FormDescription>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
</div>
</div>
</div>
<!-- Security Tab -->
<div v-show="activeTab === 'security'" class="space-y-6">
<!-- Secret Key (readonly) -->
<FormField v-slot="{ componentField }" name="secret">
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.secretKey') }}</FormLabel>
<FormControl>
<Input type="password" v-bind="componentField" />
</FormControl>
<FormDescription>{{
$t('admin.inbox.livechat.secretKey.description')
}}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Trusted Domains -->
<div class="space-y-4">
<h4 class="font-medium text-foreground">
{{ $t('admin.inbox.livechat.trustedDomains') }}
</h4>
<FormField v-slot="{ componentField }" name="config.trusted_domains">
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.trustedDomains.list') }}</FormLabel>
<FormControl>
<Textarea
v-bind="componentField"
placeholder="example.com&#10;subdomain.example.com&#10;another-domain.com"
rows="4"
/>
</FormControl>
<FormDescription>{{
$t('admin.inbox.livechat.trustedDomains.description')
}}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</div>
</div>
<!-- Pre-Chat Form Tab -->
<div v-show="activeTab === 'prechat'" class="space-y-6">
<PreChatFormConfig
:form="form"
:custom-attributes="customAttributes"
@fetch-custom-attributes="fetchCustomAttributes"
/>
</div>
<!-- Users Tab -->
<div v-show="activeTab === 'users'" class="space-y-6">
<Tabs :model-value="selectedUserTab" @update:model-value="selectedUserTab = $event">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="visitors">
{{ $t('admin.inbox.livechat.userSettings.visitors') }}
</TabsTrigger>
<TabsTrigger value="users">
{{ $t('admin.inbox.livechat.userSettings.users') }}
</TabsTrigger>
</TabsList>
<div class="space-y-4 mt-4">
<!-- Visitors Settings -->
<div v-show="selectedUserTab === 'visitors'" class="space-y-4">
<FormField
v-slot="{ componentField }"
name="config.visitors.start_conversation_button_text"
>
<FormItem>
<FormLabel>{{
$t('admin.inbox.livechat.startConversationButtonText')
}}</FormLabel>
<FormControl>
<Input v-bind="componentField" placeholder="Start conversation" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField
v-slot="{ componentField, handleChange }"
name="config.visitors.allow_start_conversation"
>
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{
$t('admin.inbox.livechat.allowStartConversation')
}}</FormLabel>
<FormDescription>{{
$t('admin.inbox.livechat.allowStartConversation.visitors.description')
}}</FormDescription>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
<FormField
v-slot="{ componentField, handleChange }"
name="config.visitors.prevent_multiple_conversations"
>
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{
$t('admin.inbox.livechat.preventMultipleConversations')
}}</FormLabel>
<FormDescription>{{
$t('admin.inbox.livechat.preventMultipleConversations.visitors.description')
}}</FormDescription>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="config.visitors.require_contact_info">
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.requireContactInfo') }}</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="disabled">
{{ $t('admin.inbox.livechat.requireContactInfo.disabled') }}
</SelectItem>
<SelectItem value="optional">
{{ $t('admin.inbox.livechat.requireContactInfo.optional') }}
</SelectItem>
<SelectItem value="required">
{{ $t('admin.inbox.livechat.requireContactInfo.required') }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>{{
$t('admin.inbox.livechat.requireContactInfo.visitors.description')
}}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField
v-if="form.values.config?.visitors?.require_contact_info !== 'disabled'"
v-slot="{ componentField }"
name="config.visitors.contact_info_message"
>
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.contactInfoMessage') }}</FormLabel>
<FormControl>
<Textarea
v-bind="componentField"
placeholder="Please provide your contact information so we can assist you better."
rows="2"
/>
</FormControl>
<FormDescription>{{
$t('admin.inbox.livechat.contactInfoMessage.visitors.description')
}}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Users Settings -->
<div v-show="selectedUserTab === 'users'" class="space-y-4">
<FormField
v-slot="{ componentField }"
name="config.users.start_conversation_button_text"
>
<FormItem>
<FormLabel>{{
$t('admin.inbox.livechat.startConversationButtonText')
}}</FormLabel>
<FormControl>
<Input v-bind="componentField" placeholder="Start conversation" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField
v-slot="{ componentField, handleChange }"
name="config.users.allow_start_conversation"
>
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{
$t('admin.inbox.livechat.allowStartConversation')
}}</FormLabel>
<FormDescription>{{
$t('admin.inbox.livechat.allowStartConversation.users.description')
}}</FormDescription>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
<FormField
v-slot="{ componentField, handleChange }"
name="config.users.prevent_multiple_conversations"
>
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{
$t('admin.inbox.livechat.preventMultipleConversations')
}}</FormLabel>
<FormDescription>{{
$t('admin.inbox.livechat.preventMultipleConversations.users.description')
}}</FormDescription>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
</div>
</div>
</Tabs>
</div>
</div>
</Tabs>
<Button type="submit" :is-loading="isLoading" :disabled="isLoading">
{{ submitLabel }}
</Button>
</form>
</template>
<script setup>
import { watch, computed, ref, onMounted } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { createFormSchema } from './livechatFormSchema.js'
import api from '@main/api'
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription
} from '@shared-ui/components/ui/form'
import { Input } from '@shared-ui/components/ui/input'
import { Textarea } from '@shared-ui/components/ui/textarea'
import { Switch } from '@shared-ui/components/ui/switch'
import { Button } from '@shared-ui/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@shared-ui/components/ui/select'
import { Tabs, TabsList, TabsTrigger } from '@shared-ui/components/ui/tabs'
import { Plus, X } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
import PreChatFormConfig from './PreChatFormConfig.vue'
const props = defineProps({
initialValues: {
type: Object,
default: () => ({})
},
submitForm: {
type: Function,
required: true
},
submitLabel: {
type: String,
default: ''
},
isLoading: {
type: Boolean,
default: false
}
})
const { t } = useI18n()
const activeTab = ref('general')
const selectedUserTab = ref('visitors')
const externalLinks = ref([])
const customAttributes = ref([])
const helpCenters = ref([])
const form = useForm({
validationSchema: toTypedSchema(createFormSchema(t)),
initialValues: {
name: '',
help_center_id: 0,
enabled: true,
secret: '',
csat_enabled: false,
config: {
brand_name: '',
dark_mode: false,
language: 'en',
logo_url: '',
launcher: {
position: 'right',
logo_url: '',
spacing: {
side: 20,
bottom: 20
}
},
greeting_message: '',
introduction_message: '',
chat_introduction: 'Ask us anything, or share your feedback.',
show_office_hours_in_chat: false,
show_office_hours_after_assignment: false,
chat_reply_expectation_message: 'We typically reply in 5 minutes.',
notice_banner: {
enabled: false,
text: 'Our response times are slower than usual. We regret the inconvenience caused.'
},
colors: {
primary: '#2563eb'
},
features: {
file_upload: true,
emoji: true
},
trusted_domains: '',
external_links: [],
visitors: {
start_conversation_button_text: 'Start conversation',
allow_start_conversation: true,
prevent_multiple_conversations: false
},
users: {
start_conversation_button_text: 'Start conversation',
allow_start_conversation: true,
prevent_multiple_conversations: false
},
prechat_form: {
enabled: false,
title: '',
fields: [
{
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
}
]
}
}
}
})
const submitLabel = computed(() => {
return props.submitLabel || t('globals.messages.save')
})
const addExternalLink = () => {
externalLinks.value.push({ text: '', url: '' })
updateExternalLinks()
}
const removeExternalLink = (index) => {
externalLinks.value.splice(index, 1)
updateExternalLinks()
}
const updateExternalLinks = () => {
form.setFieldValue('config.external_links', externalLinks.value)
}
const fetchCustomAttributes = async () => {
try {
// Fetch both contact and conversation custom attributes
const [contactAttrs, conversationAttrs] = await Promise.all([
api.getCustomAttributes('contact'),
api.getCustomAttributes('conversation')
])
customAttributes.value = [
...(contactAttrs.data?.data || []),
...(conversationAttrs.data?.data || [])
]
} catch (error) {
console.error('Error fetching custom attributes:', error)
customAttributes.value = []
}
}
const onSubmit = form.handleSubmit(async (values) => {
if (values.help_center_id === 0 || values.help_center_id === '') {
values.help_center_id = null
}
// Transform trusted_domains from textarea to array
if (values.config.trusted_domains) {
values.config.trusted_domains = values.config.trusted_domains
.split('\n')
.map((domain) => domain.trim())
.filter((domain) => domain)
} else {
values.config.trusted_domains = []
}
// Filter out incomplete external links before submission
if (values.config.external_links) {
values.config.external_links = values.config.external_links.filter(
(link) => link.text && link.url
)
}
await props.submitForm(values)
})
watch(
() => props.initialValues,
(newValues) => {
if (Object.keys(newValues).length === 0) {
return
}
// Transform trusted_domains array back to textarea format
if (newValues.config?.trusted_domains && Array.isArray(newValues.config.trusted_domains)) {
newValues.config.trusted_domains = newValues.config.trusted_domains.join('\n')
}
// Set external links for the reactive array
if (newValues.config?.external_links) {
externalLinks.value = [...newValues.config.external_links]
}
form.setValues(newValues)
},
{ deep: true, immediate: true }
)
// Fetch help centers on component mount
onMounted(async () => {
try {
const { data } = await api.getHelpCenters()
helpCenters.value = data.data
} catch (error) {
console.error('Error fetching help centers:', error)
}
})
</script>

View File

@@ -0,0 +1,268 @@
<template>
<div class="space-y-6">
<!-- Master Toggle -->
<FormField v-slot="{ componentField, handleChange }" name="config.prechat_form.enabled">
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{ $t('admin.inbox.livechat.prechatForm.enabled') }}</FormLabel>
<FormDescription>
{{ $t('admin.inbox.livechat.prechatForm.enabled.description') }}
</FormDescription>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
<!-- Form Configuration -->
<div v-if="form.values.config?.prechat_form?.enabled" class="space-y-6">
<!-- Form Title -->
<FormField v-slot="{ componentField }" name="config.prechat_form.title">
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.prechatForm.title') }}</FormLabel>
<FormControl>
<Input type="text" v-bind="componentField" placeholder="Tell us about yourself" />
</FormControl>
<FormDescription>
{{ $t('admin.inbox.livechat.prechatForm.title.description') }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Fields Configuration -->
<div class="space-y-4">
<div class="flex justify-between items-center">
<h4 class="font-medium text-foreground">
{{ $t('admin.inbox.livechat.prechatForm.fields') }}
</h4>
<Button
type="button"
variant="outline"
size="sm"
@click="$emit('fetch-custom-attributes')"
:disabled="availableCustomAttributes.length === 0"
>
<Plus class="w-4 h-4 mr-2" />
{{ $t('admin.inbox.livechat.prechatForm.addField') }}
</Button>
</div>
<!-- Field List -->
<div class="space-y-3">
<Draggable
v-model="draggableFields"
item-key="key"
:animation="200"
class="space-y-3"
>
<template #item="{ element: field, index }">
<div 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>
<div>
<div class="font-medium">{{ field.label }}</div>
<div class="text-sm text-muted-foreground">
{{ field.type }} {{ field.is_default ? '(Default)' : '(Custom)' }}
</div>
</div>
</div>
<div class="flex items-center space-x-2">
<FormField
:name="`config.prechat_form.fields.${index}.enabled`"
v-slot="{ componentField, handleChange }"
>
<FormControl>
<Switch
:checked="componentField.modelValue"
@update:checked="handleChange"
/>
</FormControl>
</FormField>
<Button
v-if="!field.is_default"
type="button"
variant="ghost"
size="sm"
@click="removeField(index)"
>
<X class="w-4 h-4" />
</Button>
</div>
</div>
<!-- Field Configuration -->
<div v-if="field.enabled" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<!-- Label -->
<FormField
:name="`config.prechat_form.fields.${index}.label`"
v-slot="{ componentField }"
>
<FormItem>
<FormLabel class="text-sm font-medium">{{ $t('globals.terms.label') }}</FormLabel>
<FormControl>
<Input
v-bind="componentField"
placeholder="Field label"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Placeholder -->
<FormField
:name="`config.prechat_form.fields.${index}.placeholder`"
v-slot="{ componentField }"
>
<FormItem>
<FormLabel class="text-sm font-medium">{{ $t('globals.terms.placeholder') }}</FormLabel>
<FormControl>
<Input
v-bind="componentField"
placeholder="Field placeholder"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Required -->
<FormField
:name="`config.prechat_form.fields.${index}.required`"
v-slot="{ componentField, handleChange }"
>
<FormItem>
<div class="flex items-center space-x-2">
<FormControl>
<Checkbox
:checked="componentField.modelValue"
@update:checked="handleChange"
/>
</FormControl>
<FormLabel class="text-sm">{{ $t('globals.terms.required') }}</FormLabel>
</div>
</FormItem>
</FormField>
</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 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>
</div>
</template>
<script setup>
import { computed, onMounted } from 'vue'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@shared-ui/components/ui/form'
import { Input } from '@shared-ui/components/ui/input'
import { Button } from '@shared-ui/components/ui/button'
import { Switch } from '@shared-ui/components/ui/switch'
import { Checkbox } from '@shared-ui/components/ui/checkbox'
import { Plus, X, GripVertical } from 'lucide-vue-next'
import Draggable from 'vuedraggable'
const props = defineProps({
form: {
type: Object,
required: true
},
customAttributes: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['fetch-custom-attributes'])
const formFields = computed(() => {
return props.form.values.config?.prechat_form?.fields || []
})
const availableCustomAttributes = computed(() => {
const usedIds = formFields.value
.filter(field => field.custom_attribute_id)
.map(field => field.custom_attribute_id)
return props.customAttributes.filter(attr => !usedIds.includes(attr.id))
})
const draggableFields = computed({
get() {
return formFields.value
},
set(newValue) {
const fieldsWithUpdatedOrder = newValue.map((field, index) => ({
...field,
order: index + 1
}))
props.form.setFieldValue('config.prechat_form.fields', fieldsWithUpdatedOrder)
}
})
const removeField = (index) => {
const fields = formFields.value.filter((_, i) => i !== index)
props.form.setFieldValue('config.prechat_form.fields', fields)
}
const addCustomAttributeToForm = (attribute) => {
const newField = {
key: attribute.key,
type: attribute.data_type,
label: attribute.name,
placeholder: '',
required: false,
enabled: false,
order: formFields.value.length + 1,
is_default: false,
custom_attribute_id: attribute.id
}
const fields = [...formFields.value, newField]
props.form.setFieldValue('config.prechat_form.fields', fields)
}
onMounted(() => {
emit('fetch-custom-attributes')
})
</script>

View File

@@ -1,8 +1,9 @@
import * as z from 'zod' import * as z from 'zod'
import { isGoDuration } from '@/utils/strings' import { isGoDuration } from '../../../utils/strings'
export const createFormSchema = (t) => z.object({ export const createFormSchema = (t) => z.object({
name: z.string().min(1, t('globals.messages.required')), name: z.string().min(1, t('globals.messages.required')),
help_center_id: z.number().optional(),
from: z.string().min(1, t('globals.messages.required')), from: z.string().min(1, t('globals.messages.required')),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
csat_enabled: z.boolean().optional(), csat_enabled: z.boolean().optional(),

View File

@@ -0,0 +1,85 @@
import { z } from 'zod'
export const createFormSchema = (t) => z.object({
name: z.string().min(1, { message: t('globals.messages.required') }),
help_center_id: z.number().optional(),
enabled: z.boolean(),
csat_enabled: z.boolean(),
secret: z.string(),
config: z.object({
brand_name: z.string().min(1, { message: t('globals.messages.required') }),
dark_mode: z.boolean(),
language: z.string().min(1, { message: t('globals.messages.required') }),
logo_url: z.string().url({
message: t('globals.messages.invalid', {
name: t('globals.terms.url').toLowerCase()
})
}).optional().or(z.literal('')),
launcher: z.object({
position: z.enum(['left', 'right']),
logo_url: z.string().url({
message: t('globals.messages.invalid', {
name: t('globals.terms.url').toLowerCase()
})
}).optional().or(z.literal('')),
spacing: z.object({
side: z.number().min(0),
bottom: z.number().min(0),
})
}),
greeting_message: z.string().optional(),
introduction_message: z.string().optional(),
chat_introduction: z.string(),
show_office_hours_in_chat: z.boolean(),
show_office_hours_after_assignment: z.boolean(),
notice_banner: z.object({
enabled: z.boolean(),
text: z.string().optional()
}),
colors: z.object({
primary: z.string().regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, {
message: t('globals.messages.invalid', {
name: t('globals.terms.colors').toLowerCase()
})
}),
}),
features: z.object({
file_upload: z.boolean(),
emoji: z.boolean(),
}),
trusted_domains: z.string().optional(),
external_links: z.array(z.object({
text: z.string().min(1),
url: z.string().url({
message: t('globals.messages.invalid', {
name: t('globals.terms.url').toLowerCase()
})
})
})),
visitors: z.object({
start_conversation_button_text: z.string(),
allow_start_conversation: z.boolean(),
prevent_multiple_conversations: z.boolean(),
}),
users: z.object({
start_conversation_button_text: z.string(),
allow_start_conversation: z.boolean(),
prevent_multiple_conversations: z.boolean(),
}),
prechat_form: z.object({
enabled: z.boolean(),
title: z.string().optional(),
fields: z.array(z.object({
key: z.string().min(1),
type: z.enum(['text', 'email', 'number', 'checkbox', 'date', 'link', 'list']),
label: z.string().min(1, { message: t('globals.messages.required') }),
placeholder: z.string().optional(),
required: z.boolean(),
enabled: z.boolean(),
order: z.number().min(1),
is_default: z.boolean(),
custom_attribute_id: z.number().optional()
}))
})
})
})

View File

@@ -129,7 +129,7 @@
</template> </template>
<script setup> <script setup>
import { Button } from '@/components/ui/button' import { Button } from '@shared-ui/components/ui/button'
import { Plus } from 'lucide-vue-next' import { Plus } from 'lucide-vue-next'
import { import {
Select, Select,
@@ -138,11 +138,11 @@ import {
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from '@/components/ui/select' } from '@shared-ui/components/ui/select'
import CloseButton from '@/components/button/CloseButton.vue' import CloseButton from '@main/components/button/CloseButton.vue'
import { SelectTag } from '@/components/ui/select' import { SelectTag } from '@shared-ui/components/ui/select'
import { useTagStore } from '@/stores/tag' import { useTagStore } from '../../../stores/tag'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue' import SelectComboBox from '@main/components/combobox/SelectCombobox.vue'
const model = defineModel('actions', { const model = defineModel('actions', {
type: Array, type: Array,

View File

@@ -150,17 +150,17 @@
import { ref, watch, computed } from 'vue' import { ref, watch, computed } from 'vue'
import { useForm } from 'vee-validate' import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod' import { toTypedSchema } from '@vee-validate/zod'
import { Button } from '@/components/ui/button' import { Button } from '@shared-ui/components/ui/button/index.js'
import { Spinner } from '@/components/ui/spinner' import { Spinner } from '@shared-ui/components/ui/spinner/index.js'
import { Input } from '@/components/ui/input' import { Input } from '@shared-ui/components/ui/input/index.js'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@shared-ui/components/ui/form/index.js'
import ActionBuilder from '@/features/admin/macros/ActionBuilder.vue' import ActionBuilder from '@/features/admin/macros/ActionBuilder.vue'
import { useConversationFilters } from '@/composables/useConversationFilters' import { useConversationFilters } from '../../../composables/useConversationFilters.js'
import { useUsersStore } from '@/stores/users' import { useUsersStore } from '../../../stores/users.js'
import { useTeamStore } from '@/stores/team' import { useTeamStore } from '../../../stores/team.js'
import { getTextFromHTML } from '@/utils/strings.js' import { getTextFromHTML } from '../../../utils/strings.js'
import { createFormSchema } from './formSchema.js' import { createFormSchema } from './formSchema.js'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue' import SelectComboBox from '@main/components/combobox/SelectCombobox.vue'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -169,9 +169,9 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
SelectTag SelectTag
} from '@/components/ui/select' } from '@shared-ui/components/ui/select/index.js'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import Editor from '@/components/editor/TextEditor.vue' import Editor from '@main/components/editor/TextEditor.vue'
const { macroActions } = useConversationFilters() const { macroActions } = useConversationFilters()
const { t } = useI18n() const { t } = useI18n()

View File

@@ -40,7 +40,7 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@shared-ui/components/ui/dropdown-menu'
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -50,12 +50,12 @@ import {
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle AlertDialogTitle
} from '@/components/ui/alert-dialog' } from '@shared-ui/components/ui/alert-dialog'
import { Button } from '@/components/ui/button' import { Button } from '@shared-ui/components/ui/button'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from '../../../composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import api from '@/api/index.js' import api from '../../../api/index.js'
const router = useRouter() const router = useRouter()
const emit = useEmitter() const emit = useEmitter()

View File

@@ -1,5 +1,5 @@
import * as z from 'zod' import * as z from 'zod'
import { getTextFromHTML } from '@/utils/strings.js' import { getTextFromHTML } from '../../../utils/strings.js'
const actionSchema = () => z.array( const actionSchema = () => z.array(
z.object({ z.object({

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