mirror of
				https://github.com/abhinavxd/libredesk.git
				synced 2025-10-31 12:03:33 +00:00 
			
		
		
		
	Compare commits
	
		
			39 Commits
		
	
	
		
			fix/empty-
			...
			help-artic
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 6f62a77783 | ||
|  | af1373272e | ||
|  | 61e343de5b | ||
|  | c721d19b81 | ||
|  | 2ff5a945e2 | ||
|  | 77111835cc | ||
|  | 5284b2ee15 | ||
|  | b1f8231f7d | ||
|  | 45a77b1422 | ||
|  | 9a77c8953c | ||
|  | 18d4a8fe3b | ||
|  | a2234e908f | ||
|  | d7fe6153bb | ||
|  | f786c4d962 | ||
|  | cff5a6dfc2 | ||
|  | d0df6f9322 | ||
|  | 30902310dc | ||
|  | 8bf0255b61 | ||
|  | f337f79f96 | ||
|  | 68c2708464 | ||
|  | 4f9fc029c0 | ||
|  | 6cfa93838a | ||
|  | f72f158cf0 | ||
|  | 1962abdc16 | ||
|  | 081a5c615a | ||
|  | c35ab42b47 | ||
|  | f05014f412 | ||
|  | e2bba04669 | ||
|  | 4beab72a11 | ||
|  | 26b3b30fca | ||
|  | 11fd57adb0 | ||
|  | d4f644c531 | ||
|  | 646bbc7efe | ||
|  | 3c3709557e | ||
|  | 74732bfe91 | ||
|  | 8ee81c2d64 | ||
|  | 282dc83439 | ||
|  | 61a70f6b52 | ||
|  | 5b6a58fba0 | 
							
								
								
									
										43
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										43
									
								
								Makefile
									
									
									
									
									
								
							| @@ -15,7 +15,7 @@ GOPATH ?= $(HOME)/go | ||||
| STUFFBIN ?= $(GOPATH)/bin/stuffbin | ||||
|  | ||||
| # The default target to run when `make` is executed. | ||||
| .DEFAULT_GOAL := build   | ||||
| .DEFAULT_GOAL := build  | ||||
|  | ||||
| # Install stuffbin if it doesn't exist. | ||||
| $(STUFFBIN): | ||||
| @@ -28,11 +28,24 @@ install-deps: $(STUFFBIN) | ||||
| 	@echo "→ Installing frontend dependencies..." | ||||
| 	@cd ${FRONTEND_DIR} && pnpm install | ||||
|  | ||||
| # Build the frontend for production. | ||||
| # Build the frontend for production (both apps). | ||||
| .PHONY: frontend-build | ||||
| frontend-build: install-deps | ||||
| 	@echo "→ Building frontend for production..." | ||||
| 	@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build | ||||
| 	@echo "→ Building frontend for production - main app & widget..." | ||||
| 	@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build:main | ||||
| 	@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build:widget | ||||
|  | ||||
| # 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. | ||||
| .PHONY: run-backend | ||||
| @@ -40,13 +53,29 @@ run-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 | ||||
|  | ||||
| # Run the JS frontend server in development mode. | ||||
| # Run the JS frontend server in development mode (main app only). | ||||
| .PHONY: run-frontend | ||||
| run-frontend: | ||||
| 	@echo "→ Installing frontend dependencies (if not already installed)..." | ||||
| 	@cd ${FRONTEND_DIR} && pnpm install | ||||
| 	@echo "→ Running frontend..." | ||||
| 	@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev | ||||
| 	@echo "→ Running main frontend app..." | ||||
| 	@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. | ||||
| .PHONY: build-backend | ||||
|   | ||||
							
								
								
									
										193
									
								
								cmd/ai_assistants.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										193
									
								
								cmd/ai_assistants.go
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										1088
									
								
								cmd/chat.go
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -469,34 +469,16 @@ func handleUpdateConversationStatus(r *fastglue.Request) error { | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| 	conversation, err := enforceConversationAccess(app, uuid, user) | ||||
| 	_, err = enforceConversationAccess(app, uuid, user) | ||||
| 	if err != nil { | ||||
| 		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. | ||||
| 	if err := app.conversation.UpdateConversationStatus(uuid, 0 /**status_id**/, status, snoozedUntil, user); err != nil { | ||||
| 		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) | ||||
| } | ||||
|  | ||||
| @@ -583,7 +565,7 @@ func handleUpdateContactCustomAttributes(r *fastglue.Request) error { | ||||
| 	if err != nil { | ||||
| 		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) | ||||
| 	} | ||||
| 	// Broadcast update. | ||||
| @@ -707,11 +689,9 @@ func handleCreateConversation(r *fastglue.Request) error { | ||||
|  | ||||
| 	// Find or create contact. | ||||
| 	contact := umodels.User{ | ||||
| 		Email:           null.StringFrom(req.Email), | ||||
| 		SourceChannelID: null.StringFrom(req.Email), | ||||
| 		FirstName:       req.FirstName, | ||||
| 		LastName:        req.LastName, | ||||
| 		InboxID:         req.InboxID, | ||||
| 		Email:     null.StringFrom(req.Email), | ||||
| 		FirstName: req.FirstName, | ||||
| 		LastName:  req.LastName, | ||||
| 	} | ||||
| 	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)) | ||||
| @@ -720,7 +700,6 @@ func handleCreateConversation(r *fastglue.Request) error { | ||||
| 	// Create conversation | ||||
| 	conversationID, conversationUUID, err := app.conversation.CreateConversation( | ||||
| 		contact.ID, | ||||
| 		contact.ContactChannelID, | ||||
| 		req.InboxID, | ||||
| 		"",         /** last_message **/ | ||||
| 		time.Now(), /** last_message_at **/ | ||||
| @@ -744,7 +723,7 @@ func handleCreateConversation(r *fastglue.Request) error { | ||||
| 	} | ||||
|  | ||||
| 	// 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. | ||||
| 		if err := app.conversation.DeleteConversation(conversationUUID); err != nil { | ||||
| 			app.lo.Error("error deleting conversation", "error", err) | ||||
|   | ||||
							
								
								
									
										44
									
								
								cmd/csat.go
									
									
									
									
									
								
							
							
						
						
									
										44
									
								
								cmd/csat.go
									
									
									
									
									
								
							| @@ -3,9 +3,16 @@ package main | ||||
| import ( | ||||
| 	"strconv" | ||||
|  | ||||
| 	"github.com/abhinavxd/libredesk/internal/envelope" | ||||
| 	"github.com/valyala/fasthttp" | ||||
| 	"github.com/zerodha/fastglue" | ||||
| ) | ||||
|  | ||||
| type csatResponse struct { | ||||
| 	Rating   int    `json:"rating"` | ||||
| 	Feedback string `json:"feedback"` | ||||
| } | ||||
|  | ||||
| // handleShowCSAT renders the CSAT page for a given csat. | ||||
| func handleShowCSAT(r *fastglue.Request) error { | ||||
| 	var ( | ||||
| @@ -42,7 +49,7 @@ func handleShowCSAT(r *fastglue.Request) error { | ||||
|  | ||||
| 	return app.tmpl.RenderWebPage(r.RequestCtx, "csat", map[string]interface{}{ | ||||
| 		"Data": map[string]interface{}{ | ||||
| 			"Title":    "Rate your interaction with us", | ||||
| 			"Title": "Rate your interaction with us", | ||||
| 			"CSAT": map[string]interface{}{ | ||||
| 				"UUID": csat.UUID, | ||||
| 			}, | ||||
| @@ -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{}{ | ||||
| 			"Data": map[string]interface{}{ | ||||
| 				"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) | ||||
| } | ||||
|   | ||||
| @@ -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. | ||||
| func handleGetCustomAttributes(r *fastglue.Request) error { | ||||
|   | ||||
							
								
								
									
										179
									
								
								cmd/handlers.go
									
									
									
									
									
								
							
							
						
						
									
										179
									
								
								cmd/handlers.go
									
									
									
									
									
								
							| @@ -1,12 +1,16 @@ | ||||
| package main | ||||
|  | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"mime" | ||||
| 	"net/http" | ||||
| 	"path" | ||||
| 	"path/filepath" | ||||
| 	"strings" | ||||
|  | ||||
| 	"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/valyala/fasthttp" | ||||
| 	"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.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. | ||||
| 	g.GET("/api/v1/macros", auth(handleGetMacros)) | ||||
| 	g.GET("/api/v1/macros/{id}", perm(handleGetMacro, "macros:manage")) | ||||
| @@ -202,20 +220,61 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { | ||||
| 	// Custom attributes. | ||||
| 	g.GET("/api/v1/custom-attributes", auth(handleGetCustomAttributes)) | ||||
| 	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.DELETE("/api/v1/custom-attributes/{id}", perm(handleDeleteCustomAttribute, "custom_attributes:manage")) | ||||
|  | ||||
| 	// Actvity logs. | ||||
| 	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. | ||||
| 	g.GET("/ws", auth(func(r *fastglue.Request) error { | ||||
| 		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. | ||||
| 	g.GET("/", notAuthPage(serveIndexPage)) | ||||
| 	g.GET("/widget", serveWidgetIndexPage) | ||||
| 	g.GET("/inboxes/{all:*}", authPage(serveIndexPage)) | ||||
| 	g.GET("/teams/{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("/reset-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("/widget/assets/{all:*}", serveWidgetStaticFiles) | ||||
| 	g.GET("/images/{all:*}", serveFrontendStaticFiles) | ||||
| 	g.GET("/static/public/{all:*}", serveStaticFiles) | ||||
|  | ||||
| @@ -263,6 +326,77 @@ func serveIndexPage(r *fastglue.Request) error { | ||||
| 	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. | ||||
| func serveStaticFiles(r *fastglue.Request) error { | ||||
| 	app := r.Context.(*App) | ||||
| @@ -311,6 +445,47 @@ func serveFrontendStaticFiles(r *fastglue.Request) error { | ||||
| 	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. | ||||
| func sendErrorEnvelope(r *fastglue.Request, err error) error { | ||||
| 	e, ok := err.(envelope.Error) | ||||
|   | ||||
							
								
								
									
										548
									
								
								cmd/helpcenter.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										548
									
								
								cmd/helpcenter.go
									
									
									
									
									
										Normal 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 | ||||
| } | ||||
| @@ -1,10 +1,12 @@ | ||||
| package main | ||||
|  | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"net/mail" | ||||
| 	"strconv" | ||||
|  | ||||
| 	"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" | ||||
| @@ -154,9 +156,11 @@ func handleDeleteInbox(r *fastglue.Request) error { | ||||
|  | ||||
| // validateInbox validates the inbox | ||||
| func validateInbox(app *App, inbox imodels.Inbox) error { | ||||
| 	// Validate from address. | ||||
| 	if _, err := mail.ParseAddress(inbox.From); err != nil { | ||||
| 		return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalidFromAddress"), nil) | ||||
| 	// Validate from address only for email channels. | ||||
| 	if inbox.Channel == "email" { | ||||
| 		if _, err := mail.ParseAddress(inbox.From); err != nil { | ||||
| 			return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalidFromAddress"), nil) | ||||
| 		} | ||||
| 	} | ||||
| 	if len(inbox.Config) == 0 { | ||||
| 		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 == "" { | ||||
| 		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 | ||||
| } | ||||
|   | ||||
							
								
								
									
										102
									
								
								cmd/init.go
									
									
									
									
									
								
							
							
						
						
									
										102
									
								
								cmd/init.go
									
									
									
									
									
								
							| @@ -25,8 +25,10 @@ import ( | ||||
| 	"github.com/abhinavxd/libredesk/internal/conversation/status" | ||||
| 	"github.com/abhinavxd/libredesk/internal/csat" | ||||
| 	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/channel/email" | ||||
| 	"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat" | ||||
| 	imodels "github.com/abhinavxd/libredesk/internal/inbox/models" | ||||
| 	"github.com/abhinavxd/libredesk/internal/macro" | ||||
| 	"github.com/abhinavxd/libredesk/internal/media" | ||||
| @@ -35,6 +37,7 @@ import ( | ||||
| 	notifier "github.com/abhinavxd/libredesk/internal/notification" | ||||
| 	emailnotifier "github.com/abhinavxd/libredesk/internal/notification/providers/email" | ||||
| 	"github.com/abhinavxd/libredesk/internal/oidc" | ||||
| 	"github.com/abhinavxd/libredesk/internal/ratelimit" | ||||
| 	"github.com/abhinavxd/libredesk/internal/report" | ||||
| 	"github.com/abhinavxd/libredesk/internal/role" | ||||
| 	"github.com/abhinavxd/libredesk/internal/search" | ||||
| @@ -132,7 +135,8 @@ func initConstants() *constants { | ||||
| // initFS initializes the stuffbin FileSystem. | ||||
| func initFS() stuffbin.FileSystem { | ||||
| 	var files = []string{ | ||||
| 		"frontend/dist", | ||||
| 		"frontend/dist/main", | ||||
| 		"frontend/dist/widget", | ||||
| 		"i18n", | ||||
| 		"static", | ||||
| 	} | ||||
| @@ -249,6 +253,20 @@ func initTag(db *sqlx.DB, i18n *i18n.I18n) *tag.Manager { | ||||
| 	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. | ||||
| func initView(db *sqlx.DB) *view.Manager { | ||||
| 	var lo = initLogger("view_manager") | ||||
| @@ -460,10 +478,11 @@ func initMedia(db *sqlx.DB, i18n *i18n.I18n) *media.Manager { | ||||
| 	} | ||||
|  | ||||
| 	media, err := media.New(media.Opts{ | ||||
| 		Store: store, | ||||
| 		Lo:    lo, | ||||
| 		DB:    db, | ||||
| 		I18n:  i18n, | ||||
| 		Store:  store, | ||||
| 		Lo:     lo, | ||||
| 		DB:     db, | ||||
| 		I18n:   i18n, | ||||
| 		Secret: ko.String("upload.secret"), | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| 		log.Fatalf("error initializing media: %v", err) | ||||
| @@ -572,11 +591,41 @@ func initEmailInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, usrS | ||||
| 	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. | ||||
| func initializeInboxes(inboxR imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore) (inbox.Inbox, error) { | ||||
| 	switch inboxR.Channel { | ||||
| 	case "email": | ||||
| 		return initEmailInbox(inboxR, msgStore, usrStore) | ||||
| 	case "livechat": | ||||
| 		return initLiveChatInbox(inboxR, msgStore, usrStore) | ||||
| 	default: | ||||
| 		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. | ||||
| 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") | ||||
| 	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, | ||||
| 		Lo:   lo, | ||||
| 		I18n: i18n, | ||||
| @@ -894,3 +973,12 @@ func getLogLevel(lvl string) logf.Level { | ||||
| 		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) | ||||
| } | ||||
|   | ||||
							
								
								
									
										27
									
								
								cmd/main.go
									
									
									
									
									
								
							
							
						
						
									
										27
									
								
								cmd/main.go
									
									
									
									
									
								
							| @@ -13,6 +13,8 @@ import ( | ||||
|  | ||||
| 	_ "time/tzdata" | ||||
|  | ||||
| 	_ "github.com/pgvector/pgvector-go" | ||||
|  | ||||
| 	activitylog "github.com/abhinavxd/libredesk/internal/activity_log" | ||||
| 	"github.com/abhinavxd/libredesk/internal/ai" | ||||
| 	auth_ "github.com/abhinavxd/libredesk/internal/auth" | ||||
| @@ -21,6 +23,7 @@ import ( | ||||
| 	"github.com/abhinavxd/libredesk/internal/colorlog" | ||||
| 	"github.com/abhinavxd/libredesk/internal/csat" | ||||
| 	customAttribute "github.com/abhinavxd/libredesk/internal/custom_attribute" | ||||
| 	"github.com/abhinavxd/libredesk/internal/helpcenter" | ||||
| 	"github.com/abhinavxd/libredesk/internal/macro" | ||||
| 	notifier "github.com/abhinavxd/libredesk/internal/notification" | ||||
| 	"github.com/abhinavxd/libredesk/internal/report" | ||||
| @@ -35,6 +38,7 @@ import ( | ||||
| 	"github.com/abhinavxd/libredesk/internal/inbox" | ||||
| 	"github.com/abhinavxd/libredesk/internal/media" | ||||
| 	"github.com/abhinavxd/libredesk/internal/oidc" | ||||
| 	"github.com/abhinavxd/libredesk/internal/ratelimit" | ||||
| 	"github.com/abhinavxd/libredesk/internal/role" | ||||
| 	"github.com/abhinavxd/libredesk/internal/setting" | ||||
| 	"github.com/abhinavxd/libredesk/internal/tag" | ||||
| @@ -54,7 +58,8 @@ var ( | ||||
| 	ko          = koanf.New(".") | ||||
| 	ctx         = context.Background() | ||||
| 	appName     = "libredesk" | ||||
| 	frontendDir = "frontend/dist" | ||||
| 	frontendDir = "frontend/dist/main" | ||||
| 	widgetDir   = "frontend/dist/widget" | ||||
|  | ||||
| 	// Injected at build time. | ||||
| 	buildString   string | ||||
| @@ -94,6 +99,8 @@ type App struct { | ||||
| 	customAttribute *customAttribute.Manager | ||||
| 	report          *report.Manager | ||||
| 	webhook         *webhook.Manager | ||||
| 	rateLimit       *ratelimit.Limiter | ||||
| 	helpcenter      *helpcenter.Manager | ||||
|  | ||||
| 	// Global state that stores data on an available app update. | ||||
| 	update *AppUpdate | ||||
| @@ -201,10 +208,19 @@ func main() { | ||||
| 		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) | ||||
| 		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) | ||||
|  | ||||
| 	go automation.Run(ctx, automationWorkers) | ||||
| 	go autoassigner.Run(ctx, autoAssignInterval) | ||||
| 	go conversation.Run(ctx, messageIncomingQWorkers, messageOutgoingQWorkers, messageOutgoingScanInterval) | ||||
| @@ -215,6 +231,7 @@ func main() { | ||||
| 	go sla.SendNotifications(ctx) | ||||
| 	go media.DeleteUnlinkedMedia(ctx) | ||||
| 	go user.MonitorAgentAvailability(ctx) | ||||
| 	go ai.StartConversationCompletions() | ||||
|  | ||||
| 	var app = &App{ | ||||
| 		lo:              lo, | ||||
| @@ -246,8 +263,10 @@ func main() { | ||||
| 		role:            initRole(db, i18n), | ||||
| 		tag:             initTag(db, i18n), | ||||
| 		macro:           initMacro(db, i18n), | ||||
| 		ai:              initAI(db, i18n), | ||||
| 		ai:              ai, | ||||
| 		webhook:         webhook, | ||||
| 		rateLimit:       rateLimiter, | ||||
| 		helpcenter:      helpcenter, | ||||
| 	} | ||||
| 	app.consts.Store(constants) | ||||
|  | ||||
| @@ -295,6 +314,8 @@ func main() { | ||||
| 	webhook.Close() | ||||
| 	colorlog.Red("Shutting down conversation...") | ||||
| 	conversation.Close() | ||||
| 	colorlog.Red("Shutting down AI...") | ||||
| 	app.ai.StopConversationCompletions() | ||||
| 	colorlog.Red("Shutting down SLA...") | ||||
| 	sla.Close() | ||||
| 	colorlog.Red("Shutting down database...") | ||||
|   | ||||
							
								
								
									
										60
									
								
								cmd/media.go
									
									
									
									
									
								
							
							
						
						
									
										60
									
								
								cmd/media.go
									
									
									
									
									
								
							| @@ -143,45 +143,51 @@ func handleMediaUpload(r *fastglue.Request) error { | ||||
| } | ||||
|  | ||||
| // handleServeMedia serves uploaded media. | ||||
| // Supports both authenticated agent access and unauthenticated access via signed URLs. | ||||
| func handleServeMedia(r *fastglue.Request) error { | ||||
| 	var ( | ||||
| 		app   = r.Context.(*App) | ||||
| 		auser = r.RequestCtx.UserValue("user").(amodels.User) | ||||
| 		uuid  = r.RequestCtx.UserValue("uuid").(string) | ||||
| 		app  = r.Context.(*App) | ||||
| 		uuid = r.RequestCtx.UserValue("uuid").(string) | ||||
| 	) | ||||
|  | ||||
| 	user, err := app.user.GetAgent(auser.ID, "") | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
|  | ||||
| 	// Fetch media from DB. | ||||
| 	media, err := app.media.Get(0, strings.TrimPrefix(uuid, thumbPrefix)) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
|  | ||||
| 	// Check if the user has permission to access the linked model. | ||||
| 	allowed, err := app.authz.EnforceMediaAccess(user, media.Model.String) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
|  | ||||
| 	// For messages, check access to the conversation this message is part of. | ||||
| 	if media.Model.String == "messages" { | ||||
| 		conversation, err := app.conversation.GetConversationByMessageID(media.ModelID.Int) | ||||
| 	// 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 { | ||||
| 			return sendErrorEnvelope(r, err) | ||||
| 		} | ||||
| 		allowed, err = app.authz.EnforceConversationAccess(user, conversation) | ||||
|  | ||||
| 		// Fetch media from DB. | ||||
| 		media, err := app.media.Get(0, strings.TrimPrefix(uuid, thumbPrefix)) | ||||
| 		if err != nil { | ||||
| 			return sendErrorEnvelope(r, err) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if !allowed { | ||||
| 		return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.UnauthorizedError) | ||||
| 		// Check if the user has permission to access the linked model. | ||||
| 		allowed, err := app.authz.EnforceMediaAccess(user, media.Model.String) | ||||
| 		if err != nil { | ||||
| 			return sendErrorEnvelope(r, err) | ||||
| 		} | ||||
|  | ||||
| 		// For messages, check access to the conversation this message is part of. | ||||
| 		if media.Model.String == "messages" { | ||||
| 			conversation, err := app.conversation.GetConversationByMessageID(media.ModelID.Int) | ||||
| 			if err != nil { | ||||
| 				return sendErrorEnvelope(r, err) | ||||
| 			} | ||||
| 			allowed, err = app.authz.EnforceConversationAccess(user, conversation) | ||||
| 			if err != nil { | ||||
| 				return sendErrorEnvelope(r, err) | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		if !allowed { | ||||
| 			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) | ||||
| 	switch consts.UploadProvider { | ||||
| 	case "fs": | ||||
|   | ||||
| @@ -4,6 +4,7 @@ import ( | ||||
| 	"strconv" | ||||
|  | ||||
| 	amodels "github.com/abhinavxd/libredesk/internal/auth/models" | ||||
| 	cmodels "github.com/abhinavxd/libredesk/internal/conversation/models" | ||||
| 	"github.com/abhinavxd/libredesk/internal/envelope" | ||||
| 	medModels "github.com/abhinavxd/libredesk/internal/media/models" | ||||
| 	"github.com/valyala/fasthttp" | ||||
| @@ -41,7 +42,7 @@ func handleGetMessages(r *fastglue.Request) error { | ||||
| 		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 { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| @@ -52,10 +53,11 @@ func handleGetMessages(r *fastglue.Request) error { | ||||
| 		for j := range messages[i].Attachments { | ||||
| 			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{ | ||||
| 		Total:      total, | ||||
| 		Results:    messages, | ||||
| @@ -89,8 +91,10 @@ func handleGetMessage(r *fastglue.Request) error { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
|  | ||||
| 	// Redact CSAT survey link | ||||
| 	message.CensorCSATContent() | ||||
| 	// Process CSAT status for the message (will only affect CSAT messages) | ||||
| 	messages := []cmodels.Message{message} | ||||
| 	app.conversation.ProcessCSATStatus(messages) | ||||
| 	message = messages[0] | ||||
|  | ||||
| 	for j := range message.Attachments { | ||||
| 		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) | ||||
| 	} | ||||
|  | ||||
| 	// 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. | ||||
| 	var media = make([]medModels.Media, 0, len(req.Attachments)) | ||||
| 	for _, id := range req.Attachments { | ||||
| @@ -168,7 +181,8 @@ func handleSendMessage(r *fastglue.Request) error { | ||||
| 		} | ||||
| 		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 { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
|   | ||||
| @@ -97,6 +97,23 @@ func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler { | ||||
| 	return func(r *fastglue.Request) error { | ||||
| 		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 | ||||
| 		user, err := authenticateUser(r, app) | ||||
| 		if err != nil { | ||||
|   | ||||
							
								
								
									
										108
									
								
								cmd/snippets.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								cmd/snippets.go
									
									
									
									
									
										Normal 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) | ||||
| } | ||||
| @@ -35,6 +35,8 @@ var migList = []migFunc{ | ||||
| 	{"v0.5.0", migrations.V0_5_0}, | ||||
| 	{"v0.6.0", migrations.V0_6_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 | ||||
|   | ||||
							
								
								
									
										167
									
								
								cmd/widget_middleware.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										167
									
								
								cmd/widget_middleware.go
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										272
									
								
								cmd/widget_ws.go
									
									
									
									
									
										Normal 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) | ||||
| } | ||||
| @@ -122,3 +122,37 @@ unsnooze_interval = "5m" | ||||
| [sla] | ||||
| # How often to evaluate SLA compliance for conversations | ||||
| 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
									
								
							
							
						
						
									
										59
									
								
								frontend/README-SETUP.md
									
									
									
									
									
										Normal 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) | ||||
| @@ -112,26 +112,26 @@ | ||||
| <script setup> | ||||
| import { onMounted, ref } from 'vue' | ||||
| import { RouterView } from 'vue-router' | ||||
| import { useUserStore } from '@/stores/user' | ||||
| import { initWS } from '@/websocket.js' | ||||
| import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' | ||||
| import { useEmitter } from '@/composables/useEmitter' | ||||
| import { handleHTTPError } from '@/utils/http' | ||||
| import { useUserStore } from './stores/user' | ||||
| import { initWS } from './websocket.js' | ||||
| import { EMITTER_EVENTS } from './constants/emitterEvents.js' | ||||
| import { useEmitter } from './composables/useEmitter' | ||||
| import { handleHTTPError } from './utils/http' | ||||
| import { useConversationStore } from './stores/conversation' | ||||
| import { useInboxStore } from '@/stores/inbox' | ||||
| import { useUsersStore } from '@/stores/users' | ||||
| import { useTeamStore } from '@/stores/team' | ||||
| import { useSlaStore } from '@/stores/sla' | ||||
| import { useMacroStore } from '@/stores/macro' | ||||
| import { useTagStore } from '@/stores/tag' | ||||
| import { useCustomAttributeStore } from '@/stores/customAttributes' | ||||
| import { useIdleDetection } from '@/composables/useIdleDetection' | ||||
| import { useInboxStore } from './stores/inbox' | ||||
| import { useUsersStore } from './stores/users' | ||||
| import { useTeamStore } from './stores/team' | ||||
| import { useSlaStore } from './stores/sla' | ||||
| import { useMacroStore } from './stores/macro' | ||||
| import { useTagStore } from './stores/tag' | ||||
| import { useCustomAttributeStore } from './stores/customAttributes' | ||||
| import { useIdleDetection } from './composables/useIdleDetection' | ||||
| import PageHeader from './components/layout/PageHeader.vue' | ||||
| import ViewForm from '@/features/view/ViewForm.vue' | ||||
| import AppUpdate from '@/components/update/AppUpdate.vue' | ||||
| import api from '@/api' | ||||
| import AppUpdate from '@main/components/update/AppUpdate.vue' | ||||
| import api from './api' | ||||
| 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 CreateConversation from '@/features/conversation/CreateConversation.vue' | ||||
| import { Inbox, Shield, FileLineChart, BookUser } from 'lucide-vue-next' | ||||
| @@ -147,9 +147,9 @@ import { | ||||
|   SidebarMenuButton, | ||||
|   SidebarMenuItem, | ||||
|   SidebarProvider | ||||
| } from '@/components/ui/sidebar' | ||||
| import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' | ||||
| import SidebarNavUser from '@/components/sidebar/SidebarNavUser.vue' | ||||
| } from '@shared-ui/components/ui/sidebar' | ||||
| import { Tooltip, TooltipContent, TooltipTrigger } from '@shared-ui/components/ui/tooltip' | ||||
| import SidebarNavUser from '@main/components/sidebar/SidebarNavUser.vue' | ||||
| 
 | ||||
| const route = useRoute() | ||||
| const emitter = useEmitter() | ||||
| @@ -5,8 +5,8 @@ | ||||
| <script setup> | ||||
| import { onMounted } from 'vue' | ||||
| import { RouterView } from 'vue-router' | ||||
| import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' | ||||
| import { useEmitter } from '@/composables/useEmitter' | ||||
| import { EMITTER_EVENTS } from './constants/emitterEvents.js' | ||||
| import { useEmitter } from './composables/useEmitter' | ||||
| import { toast as sooner } from 'vue-sonner' | ||||
| 
 | ||||
| const emitter = useEmitter() | ||||
| @@ -7,6 +7,6 @@ | ||||
| 
 | ||||
| <script setup> | ||||
| import { RouterView } from 'vue-router' | ||||
| import { Toaster } from '@/components/ui/sonner' | ||||
| import { TooltipProvider } from '@/components/ui/tooltip' | ||||
| import { Toaster } from '@shared-ui/components/ui/sonner' | ||||
| import { TooltipProvider } from '@shared-ui/components/ui/tooltip' | ||||
| </script> | ||||
| @@ -47,7 +47,6 @@ const createCustomAttribute = (data) => | ||||
|       'Content-Type': 'application/json' | ||||
|     } | ||||
|   }) | ||||
| const getCustomAttribute = (id) => http.get(`/api/v1/custom-attributes/${id}`) | ||||
| const updateCustomAttribute = (id, data) => | ||||
|   http.put(`/api/v1/custom-attributes/${id}`, data, { | ||||
|     headers: { | ||||
| @@ -431,6 +430,96 @@ const generateAPIKey = (id) => | ||||
| 
 | ||||
| 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 { | ||||
|   login, | ||||
|   deleteUser, | ||||
| @@ -504,6 +593,18 @@ export default { | ||||
|   sendMessage, | ||||
|   retryMessage, | ||||
|   createUser, | ||||
|   // AI Assistants
 | ||||
|   getAIAssistants, | ||||
|   getAIAssistant, | ||||
|   createAIAssistant, | ||||
|   updateAIAssistant, | ||||
|   deleteAIAssistant, | ||||
|   // AI Snippets
 | ||||
|   getAISnippets, | ||||
|   getAISnippet, | ||||
|   createAISnippet, | ||||
|   updateAISnippet, | ||||
|   deleteAISnippet, | ||||
|   createInbox, | ||||
|   updateInbox, | ||||
|   deleteInbox, | ||||
| @@ -554,7 +655,6 @@ export default { | ||||
|   createCustomAttribute, | ||||
|   updateCustomAttribute, | ||||
|   deleteCustomAttribute, | ||||
|   getCustomAttribute, | ||||
|   getContactNotes, | ||||
|   createContactNote, | ||||
|   deleteContactNote, | ||||
| @@ -567,5 +667,25 @@ export default { | ||||
|   toggleWebhook, | ||||
|   testWebhook, | ||||
|   generateAPIKey, | ||||
|   revokeAPIKey | ||||
|   revokeAPIKey, | ||||
|   // Help Center
 | ||||
|   getHelpCenters, | ||||
|   getHelpCenter, | ||||
|   createHelpCenter, | ||||
|   updateHelpCenter, | ||||
|   deleteHelpCenter, | ||||
|   getHelpCenterTree, | ||||
|   getCollections, | ||||
|   getCollection, | ||||
|   createCollection, | ||||
|   updateCollection, | ||||
|   deleteCollection, | ||||
|   toggleCollection, | ||||
|   getArticles, | ||||
|   getArticle, | ||||
|   createArticle, | ||||
|   updateArticle, | ||||
|   updateArticleByID, | ||||
|   deleteArticle, | ||||
|   updateArticleStatus, | ||||
| } | ||||
| @@ -12,7 +12,7 @@ | ||||
| </template> | ||||
| 
 | ||||
| <script setup> | ||||
| import { Button } from '@/components/ui/button' | ||||
| import { Button } from '@shared-ui/components/ui/button' | ||||
| import { X } from 'lucide-vue-next' | ||||
| 
 | ||||
| defineProps({ | ||||
| @@ -42,8 +42,8 @@ | ||||
| 
 | ||||
| <script setup> | ||||
| import { computed } from 'vue' | ||||
| import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar' | ||||
| import ComboBox from '@/components/ui/combobox/ComboBox.vue' | ||||
| import { Avatar, AvatarImage, AvatarFallback } from '@shared-ui/components/ui/avatar' | ||||
| import ComboBox from '@shared-ui/components/ui/combobox/ComboBox.vue' | ||||
| 
 | ||||
| const props = defineProps({ | ||||
|   modelValue: [String, Number, Object], | ||||
| @@ -51,7 +51,7 @@ import { | ||||
|   TableHead, | ||||
|   TableHeader, | ||||
|   TableRow | ||||
| } from '@/components/ui/table' | ||||
| } from '@shared-ui/components/ui/table' | ||||
| 
 | ||||
| const { t } = useI18n() | ||||
| const props = defineProps({ | ||||
| @@ -4,12 +4,12 @@ | ||||
|       :editor="editor" | ||||
|       :tippy-options="{ duration: 100 }" | ||||
|       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"> | ||||
|           <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="text-medium">AI</span> | ||||
|                 <Bot size="14" class="ml-1" /> | ||||
| @@ -27,11 +27,43 @@ | ||||
|             </DropdownMenuItem> | ||||
|           </DropdownMenuContent> | ||||
|         </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 | ||||
|           size="sm" | ||||
|           variant="ghost" | ||||
|           @click.prevent="editor?.chain().focus().toggleBold().run()" | ||||
|           :class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('bold') }" | ||||
|           title="Bold" | ||||
|         > | ||||
|           <Bold size="14" /> | ||||
|         </Button> | ||||
| @@ -40,6 +72,7 @@ | ||||
|           variant="ghost" | ||||
|           @click.prevent="editor?.chain().focus().toggleItalic().run()" | ||||
|           :class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('italic') }" | ||||
|           title="Italic" | ||||
|         > | ||||
|           <Italic size="14" /> | ||||
|         </Button> | ||||
| @@ -48,6 +81,7 @@ | ||||
|           variant="ghost" | ||||
|           @click.prevent="editor?.chain().focus().toggleBulletList().run()" | ||||
|           :class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('bulletList') }" | ||||
|           title="Bullet List" | ||||
|         > | ||||
|           <List size="14" /> | ||||
|         </Button> | ||||
| @@ -57,6 +91,7 @@ | ||||
|           variant="ghost" | ||||
|           @click.prevent="editor?.chain().focus().toggleOrderedList().run()" | ||||
|           :class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('orderedList') }" | ||||
|           title="Ordered List" | ||||
|         > | ||||
|           <ListOrdered size="14" /> | ||||
|         </Button> | ||||
| @@ -65,9 +100,32 @@ | ||||
|           variant="ghost" | ||||
|           @click.prevent="openLinkModal" | ||||
|           :class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('link') }" | ||||
|           title="Insert Link" | ||||
|         > | ||||
|           <LinkIcon size="14" /> | ||||
|         </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"> | ||||
|           <Input | ||||
|             v-model="linkUrl" | ||||
| @@ -75,10 +133,10 @@ | ||||
|             placeholder="Enter link URL" | ||||
|             class="border p-1 text-sm w-[200px]" | ||||
|           /> | ||||
|           <Button size="sm" @click="setLink"> | ||||
|           <Button size="sm" @click="setLink" title="Set Link"> | ||||
|             <Check size="14" /> | ||||
|           </Button> | ||||
|           <Button size="sm" @click="unsetLink"> | ||||
|           <Button size="sm" @click="unsetLink" title="Unset Link"> | ||||
|             <X size="14" /> | ||||
|           </Button> | ||||
|         </div> | ||||
| @@ -100,16 +158,19 @@ import { | ||||
|   ListOrdered, | ||||
|   Link as LinkIcon, | ||||
|   Check, | ||||
|   X | ||||
|   X, | ||||
|   Type, | ||||
|   Code, | ||||
|   Quote | ||||
| } from 'lucide-vue-next' | ||||
| import { Button } from '@/components/ui/button' | ||||
| import { Button } from '@shared-ui/components/ui/button' | ||||
| import { | ||||
|   DropdownMenu, | ||||
|   DropdownMenuContent, | ||||
|   DropdownMenuItem, | ||||
|   DropdownMenuTrigger | ||||
| } from '@/components/ui/dropdown-menu' | ||||
| import { Input } from '@/components/ui/input' | ||||
| } from '@shared-ui/components/ui/dropdown-menu' | ||||
| import { Input } from '@shared-ui/components/ui/input' | ||||
| import Placeholder from '@tiptap/extension-placeholder' | ||||
| import Image from '@tiptap/extension-image' | ||||
| import StarterKit from '@tiptap/starter-kit' | ||||
| @@ -118,6 +179,8 @@ import Table from '@tiptap/extension-table' | ||||
| import TableRow from '@tiptap/extension-table-row' | ||||
| import TableCell from '@tiptap/extension-table-cell' | ||||
| 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 htmlContent = defineModel('htmlContent', { default: '' }) | ||||
| @@ -134,6 +197,11 @@ const props = defineProps({ | ||||
|   aiPrompts: { | ||||
|     type: Array, | ||||
|     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) | ||||
| 
 | ||||
| // 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. | ||||
| // Created these custom extensions to set the table style inline. | ||||
| const CustomTable = Table.extend({ | ||||
| @@ -183,17 +255,39 @@ const CustomTableHeader = TableHeader.extend({ | ||||
| 
 | ||||
| const isInternalUpdate = ref(false) | ||||
| 
 | ||||
| const editor = useEditor({ | ||||
|   extensions: [ | ||||
|     StarterKit.configure(), | ||||
| // Configure extensions based on editor type | ||||
| const getExtensions = () => { | ||||
|   const baseExtensions = [ | ||||
|     StarterKit.configure({ | ||||
|       heading: props.editorType === 'article' ? { levels: [1, 2, 3, 4] } : false | ||||
|     }), | ||||
|     Image.configure({ HTMLAttributes: { class: 'inline-image' } }), | ||||
|     Placeholder.configure({ placeholder: () => props.placeholder }), | ||||
|     Link, | ||||
|     CustomTable.configure({ resizable: false }), | ||||
|     TableRow, | ||||
|     CustomTableCell, | ||||
|     CustomTableHeader | ||||
|   ], | ||||
|     Link | ||||
|   ] | ||||
| 
 | ||||
|   // Add table extensions | ||||
|   if (props.editorType === 'article') { | ||||
|     baseExtensions.push( | ||||
|       CustomTable.configure({ resizable: true }), | ||||
|       TableRow, | ||||
|       CustomTableCell, | ||||
|       CustomTableHeader | ||||
|     ) | ||||
|   } else { | ||||
|     baseExtensions.push( | ||||
|       CustomTable.configure({ resizable: false }), | ||||
|       TableRow, | ||||
|       CustomTableCell, | ||||
|       CustomTableHeader | ||||
|     ) | ||||
|   } | ||||
| 
 | ||||
|   return baseExtensions | ||||
| } | ||||
| 
 | ||||
| const editor = useEditor({ | ||||
|   extensions: getExtensions(), | ||||
|   autofocus: props.autoFocus, | ||||
|   content: htmlContent.value, | ||||
|   editorProps: { | ||||
| @@ -201,6 +295,8 @@ const editor = useEditor({ | ||||
|     handleKeyDown: (view, event) => { | ||||
|       if (event.ctrlKey && event.key === 'Enter') { | ||||
|         emit('send') | ||||
|         // Stop typing when sending | ||||
|         stopTyping() | ||||
|         return true | ||||
|       } | ||||
|     } | ||||
| @@ -211,6 +307,13 @@ const editor = useEditor({ | ||||
|     htmlContent.value = editor.getHTML() | ||||
|     textContent.value = editor.getText() | ||||
|     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() | ||||
|   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> | ||||
| 
 | ||||
| <style lang="scss"> | ||||
| @@ -120,13 +120,13 @@ import { | ||||
|   SelectItem, | ||||
|   SelectTrigger, | ||||
|   SelectValue | ||||
| } from '@/components/ui/select' | ||||
| } from '@shared-ui/components/ui/select' | ||||
| import { Plus } from 'lucide-vue-next' | ||||
| import { Button } from '@/components/ui/button' | ||||
| import { Input } from '@/components/ui/input' | ||||
| import { Button } from '@shared-ui/components/ui/button' | ||||
| import { Input } from '@shared-ui/components/ui/input' | ||||
| import { useI18n } from 'vue-i18n' | ||||
| import CloseButton from '@/components/button/CloseButton.vue' | ||||
| import SelectComboBox from '@/components/combobox/SelectCombobox.vue' | ||||
| import CloseButton from '@main/components/button/CloseButton.vue' | ||||
| import SelectComboBox from '@main/components/combobox/SelectCombobox.vue' | ||||
| 
 | ||||
| const props = defineProps({ | ||||
|   fields: { | ||||
| @@ -12,8 +12,8 @@ | ||||
| 
 | ||||
| <script setup> | ||||
| import { computed } from 'vue' | ||||
| import { Separator } from '@/components/ui/separator' | ||||
| import { SidebarTrigger } from '@/components/ui/sidebar' | ||||
| import { Separator } from '@shared-ui/components/ui/separator' | ||||
| import { SidebarTrigger } from '@shared-ui/components/ui/sidebar' | ||||
| import { useRoute } from 'vue-router' | ||||
| 
 | ||||
| const route = useRoute() | ||||
| @@ -4,9 +4,9 @@ import { | ||||
|   reportsNavItems, | ||||
|   accountNavItems, | ||||
|   contactNavItems | ||||
| } from '@/constants/navigation' | ||||
| } from '../../constants/navigation' | ||||
| 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 { | ||||
|   Sidebar, | ||||
|   SidebarContent, | ||||
| @@ -21,8 +21,8 @@ import { | ||||
|   SidebarMenuSubItem, | ||||
|   SidebarProvider, | ||||
|   SidebarRail | ||||
| } from '@/components/ui/sidebar' | ||||
| import { useAppSettingsStore } from '@/stores/appSettings' | ||||
| } from '@shared-ui/components/ui/sidebar' | ||||
| import { useAppSettingsStore } from '../../stores/appSettings' | ||||
| import { | ||||
|   ChevronRight, | ||||
|   EllipsisVertical, | ||||
| @@ -37,13 +37,13 @@ import { | ||||
|   DropdownMenuContent, | ||||
|   DropdownMenuItem, | ||||
|   DropdownMenuTrigger | ||||
| } from '@/components/ui/dropdown-menu' | ||||
| import { filterNavItems } from '@/utils/nav-permissions' | ||||
| } from '@shared-ui/components/ui/dropdown-menu' | ||||
| import { filterNavItems } from '../../utils/nav-permissions' | ||||
| import { useStorage } from '@vueuse/core' | ||||
| import { computed, ref, watch } from 'vue' | ||||
| import { useI18n } from 'vue-i18n' | ||||
| import { useUserStore } from '@/stores/user' | ||||
| import { useConversationStore } from '@/stores/conversation' | ||||
| import { useUserStore } from '../../stores/user' | ||||
| import { useConversationStore } from '../../stores/conversation' | ||||
| 
 | ||||
| defineProps({ | ||||
|   userTeams: { type: Array, default: () => [] }, | ||||
| @@ -289,7 +289,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true) | ||||
|                       <SidebarMenuSubItem v-for="child in item.children" :key="child.titleKey"> | ||||
|                         <SidebarMenuButton size="sm" :isActive="isActiveParent(child.href)" asChild> | ||||
|                           <router-link :to="child.href"> | ||||
|                             <span>{{ t(child.titleKey) }}</span> | ||||
|                             <span>{{ t(child.titleKey, child.isTitleKeyPlural === true ? 2 : 1) }}</span> | ||||
|                           </router-link> | ||||
|                         </SidebarMenuButton> | ||||
|                       </SidebarMenuSubItem> | ||||
| @@ -118,12 +118,12 @@ import { | ||||
|   DropdownMenuLabel, | ||||
|   DropdownMenuSeparator, | ||||
|   DropdownMenuTrigger | ||||
| } from '@/components/ui/dropdown-menu' | ||||
| import { SidebarMenuButton } from '@/components/ui/sidebar' | ||||
| import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' | ||||
| import { Switch } from '@/components/ui/switch' | ||||
| } from '@shared-ui/components/ui/dropdown-menu' | ||||
| import { SidebarMenuButton } from '@shared-ui/components/ui/sidebar' | ||||
| import { Avatar, AvatarFallback, AvatarImage } from '@shared-ui/components/ui/avatar' | ||||
| import { Switch } from '@shared-ui/components/ui/switch' | ||||
| 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 { useColorMode } from '@vueuse/core' | ||||
| @@ -71,8 +71,8 @@ | ||||
| <script setup> | ||||
| import { Trash2 } from 'lucide-vue-next' | ||||
| import { defineEmits } from 'vue' | ||||
| import { Button } from '@/components/ui/button' | ||||
| import { Skeleton } from '@/components/ui/skeleton' | ||||
| import { Button } from '@shared-ui/components/ui/button' | ||||
| import { Skeleton } from '@shared-ui/components/ui/skeleton' | ||||
| 
 | ||||
| defineProps({ | ||||
|   headers: { | ||||
| @@ -20,6 +20,6 @@ | ||||
| </template> | ||||
| 
 | ||||
| <script setup> | ||||
| import { useAppSettingsStore } from '@/stores/appSettings' | ||||
| import { useAppSettingsStore } from '../../stores/appSettings' | ||||
| const appSettingsStore = useAppSettingsStore() | ||||
| </script> | ||||
| @@ -1,6 +1,6 @@ | ||||
| import { computed } from 'vue' | ||||
| import { useUsersStore } from '@/stores/users' | ||||
| import { FIELD_TYPE, FIELD_OPERATORS } from '@/constants/filterConfig' | ||||
| import { useUsersStore } from '../stores/users' | ||||
| import { FIELD_TYPE, FIELD_OPERATORS } from '../constants/filterConfig' | ||||
| import { useI18n } from 'vue-i18n' | ||||
| 
 | ||||
| export function useActivityLogFilters () { | ||||
| @@ -1,11 +1,11 @@ | ||||
| import { computed } from 'vue' | ||||
| import { useConversationStore } from '@/stores/conversation' | ||||
| import { useInboxStore } from '@/stores/inbox' | ||||
| import { useUsersStore } from '@/stores/users' | ||||
| import { useTeamStore } from '@/stores/team' | ||||
| import { useSlaStore } from '@/stores/sla' | ||||
| import { useCustomAttributeStore } from '@/stores/customAttributes' | ||||
| import { FIELD_TYPE, FIELD_OPERATORS } from '@/constants/filterConfig' | ||||
| import { useConversationStore } from '../stores/conversation' | ||||
| import { useInboxStore } from '../stores/inbox' | ||||
| import { useUsersStore } from '../stores/users' | ||||
| import { useTeamStore } from '../stores/team' | ||||
| import { useSlaStore } from '../stores/sla' | ||||
| import { useCustomAttributeStore } from '../stores/customAttributes' | ||||
| import { FIELD_TYPE, FIELD_OPERATORS } from '../constants/filterConfig' | ||||
| import { useI18n } from 'vue-i18n' | ||||
| 
 | ||||
| export function useConversationFilters () { | ||||
| @@ -1,8 +1,8 @@ | ||||
| import { ref, readonly } from 'vue' | ||||
| import { useEmitter } from '@/composables/useEmitter' | ||||
| import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' | ||||
| import { handleHTTPError } from '@/utils/http' | ||||
| import api from '@/api' | ||||
| import { useEmitter } from './useEmitter' | ||||
| import { EMITTER_EVENTS } from '../constants/emitterEvents.js' | ||||
| import { handleHTTPError } from '../utils/http' | ||||
| import api from '../api' | ||||
| 
 | ||||
| /** | ||||
|  * Composable for handling file uploads | ||||
| @@ -1,6 +1,6 @@ | ||||
| import { ref, onMounted, onBeforeUnmount, watch } from 'vue' | ||||
| import { useUserStore } from '@/stores/user' | ||||
| import { debounce } from '@/utils/debounce' | ||||
| import { useUserStore } from '../stores/user' | ||||
| import { debounce } from '../utils/debounce' | ||||
| import { useStorage } from '@vueuse/core' | ||||
| 
 | ||||
| export function useIdleDetection () { | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { ref, onMounted, onUnmounted } from 'vue' | ||||
| import { calculateSla } from '@/utils/sla' | ||||
| import { calculateSla } from '../utils/sla' | ||||
| 
 | ||||
| export function useSla (dueAt, actualAt) { | ||||
|     const sla = ref(null) | ||||
| @@ -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', | ||||
|     children: [ | ||||
| @@ -142,7 +159,17 @@ export const adminNavItems = [ | ||||
|         permission: 'webhooks:manage' | ||||
|       } | ||||
|     ] | ||||
|   } | ||||
|   }, | ||||
|   { | ||||
|     titleKey: 'globals.terms.helpCenter', | ||||
|     children: [ | ||||
|       { | ||||
|         titleKey: 'globals.terms.helpCenter', | ||||
|         href: '/admin/help-center', | ||||
|         permission: 'help_center:manage' | ||||
|       } | ||||
|     ] | ||||
|   }, | ||||
| ] | ||||
| 
 | ||||
| export const accountNavItems = [ | ||||
							
								
								
									
										13
									
								
								frontend/apps/main/src/constants/websocket.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								frontend/apps/main/src/constants/websocket.js
									
									
									
									
									
										Normal 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, | ||||
| ] | ||||
| @@ -148,7 +148,7 @@ | ||||
| 
 | ||||
| <script setup> | ||||
| import { ref, computed, onMounted, watch } from 'vue' | ||||
| import SimpleTable from '@/components/table/SimpleTable.vue' | ||||
| import SimpleTable from '@main/components/table/SimpleTable.vue' | ||||
| import { | ||||
|   Pagination, | ||||
|   PaginationEllipsis, | ||||
| @@ -158,23 +158,23 @@ import { | ||||
|   PaginationListItem, | ||||
|   PaginationNext, | ||||
|   PaginationPrev | ||||
| } from '@/components/ui/pagination' | ||||
| } from '@shared-ui/components/ui/pagination' | ||||
| import { | ||||
|   Select, | ||||
|   SelectContent, | ||||
|   SelectItem, | ||||
|   SelectTrigger, | ||||
|   SelectValue | ||||
| } from '@/components/ui/select' | ||||
| import FilterBuilder from '@/components/filter/FilterBuilder.vue' | ||||
| import { Button } from '@/components/ui/button' | ||||
| } from '@shared-ui/components/ui/select' | ||||
| import FilterBuilder from '@main/components/filter/FilterBuilder.vue' | ||||
| import { Button } from '@shared-ui/components/ui/button' | ||||
| import { ListFilter, ArrowDownWideNarrow } from 'lucide-vue-next' | ||||
| import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' | ||||
| import { useActivityLogFilters } from '@/composables/useActivityLogFilters' | ||||
| import { Popover, PopoverContent, PopoverTrigger } from '@shared-ui/components/ui/popover' | ||||
| import { useActivityLogFilters } from '../../../composables/useActivityLogFilters' | ||||
| import { useI18n } from 'vue-i18n' | ||||
| import { format } from 'date-fns' | ||||
| import { getVisiblePages } from '@/utils/pagination' | ||||
| import api from '@/api' | ||||
| import { getVisiblePages } from '../../../utils/pagination' | ||||
| import api from '../../../api' | ||||
| 
 | ||||
| const activityLogs = ref([]) | ||||
| const { t } = useI18n() | ||||
| @@ -304,17 +304,17 @@ | ||||
| 
 | ||||
| <script setup> | ||||
| 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 { toTypedSchema } from '@vee-validate/zod' | ||||
| import { createFormSchema } from './formSchema.js' | ||||
| import { Checkbox } from '@/components/ui/checkbox' | ||||
| import { Label } from '@/components/ui/label' | ||||
| import { Checkbox } from '@shared-ui/components/ui/checkbox/index.js' | ||||
| import { Label } from '@shared-ui/components/ui/label/index.js' | ||||
| 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 { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' | ||||
| import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' | ||||
| import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@shared-ui/components/ui/form/index.js' | ||||
| import { Avatar, AvatarFallback, AvatarImage } from '@shared-ui/components/ui/avatar/index.js' | ||||
| import { | ||||
|   Select, | ||||
|   SelectContent, | ||||
| @@ -322,9 +322,9 @@ import { | ||||
|   SelectItem, | ||||
|   SelectTrigger, | ||||
|   SelectValue | ||||
| } from '@/components/ui/select' | ||||
| import { SelectTag } from '@/components/ui/select' | ||||
| import { Input } from '@/components/ui/input' | ||||
| } from '@shared-ui/components/ui/select/index.js' | ||||
| import { SelectTag } from '@shared-ui/components/ui/select/index.js' | ||||
| import { Input } from '@shared-ui/components/ui/input/index.js' | ||||
| import { | ||||
|   Dialog, | ||||
|   DialogContent, | ||||
| @@ -332,13 +332,13 @@ import { | ||||
|   DialogFooter, | ||||
|   DialogHeader, | ||||
|   DialogTitle | ||||
| } from '@/components/ui/dialog' | ||||
| import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' | ||||
| } from '@shared-ui/components/ui/dialog/index.js' | ||||
| import { Alert, AlertDescription, AlertTitle } from '@shared-ui/components/ui/alert/index.js' | ||||
| import { useI18n } from 'vue-i18n' | ||||
| import { useEmitter } from '@/composables/useEmitter' | ||||
| import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' | ||||
| import { useEmitter } from '../../../composables/useEmitter.js' | ||||
| import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js' | ||||
| import { format } from 'date-fns' | ||||
| import api from '@/api' | ||||
| import api from '../../../api/index.js' | ||||
| 
 | ||||
| const props = defineProps({ | ||||
|   initialValues: { | ||||
| @@ -40,7 +40,7 @@ import { | ||||
|   DropdownMenuContent, | ||||
|   DropdownMenuItem, | ||||
|   DropdownMenuTrigger | ||||
| } from '@/components/ui/dropdown-menu' | ||||
| } from '@shared-ui/components/ui/dropdown-menu' | ||||
| import { | ||||
|   AlertDialog, | ||||
|   AlertDialogAction, | ||||
| @@ -50,13 +50,13 @@ import { | ||||
|   AlertDialogFooter, | ||||
|   AlertDialogHeader, | ||||
|   AlertDialogTitle | ||||
| } from '@/components/ui/alert-dialog' | ||||
| import { Button } from '@/components/ui/button' | ||||
| } 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' | ||||
| 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() | ||||
| @@ -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> | ||||
| @@ -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 | ||||
|         }) | ||||
|       ) | ||||
|     } | ||||
|   } | ||||
| ] | ||||
| @@ -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> | ||||
| @@ -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), | ||||
| }) | ||||
| @@ -87,9 +87,9 @@ | ||||
| 
 | ||||
| <script setup> | ||||
| import { toRefs } from 'vue' | ||||
| import { Button } from '@/components/ui/button' | ||||
| import CloseButton from '@/components/button/CloseButton.vue' | ||||
| import { useTagStore } from '@/stores/tag' | ||||
| import { Button } from '@shared-ui/components/ui/button' | ||||
| import CloseButton from '@main/components/button/CloseButton.vue' | ||||
| import { useTagStore } from '../../../stores/tag' | ||||
| import { | ||||
|   Select, | ||||
|   SelectContent, | ||||
| @@ -97,13 +97,13 @@ import { | ||||
|   SelectItem, | ||||
|   SelectTrigger, | ||||
|   SelectValue | ||||
| } from '@/components/ui/select' | ||||
| import { SelectTag } from '@/components/ui/select' | ||||
| import { useConversationFilters } from '@/composables/useConversationFilters' | ||||
| import { getTextFromHTML } from '@/utils/strings.js' | ||||
| } from '@shared-ui/components/ui/select' | ||||
| import { SelectTag } from '@shared-ui/components/ui/select' | ||||
| import { useConversationFilters } from '../../../composables/useConversationFilters' | ||||
| import { getTextFromHTML } from '../../../utils/strings.js' | ||||
| import { useI18n } from 'vue-i18n' | ||||
| import Editor from '@/components/editor/TextEditor.vue' | ||||
| import SelectComboBox from '@/components/combobox/SelectCombobox.vue' | ||||
| import Editor from '@main/components/editor/TextEditor.vue' | ||||
| import SelectComboBox from '@main/components/combobox/SelectCombobox.vue' | ||||
| 
 | ||||
| const props = defineProps({ | ||||
|   actions: { | ||||
| @@ -34,7 +34,7 @@ | ||||
| </template> | ||||
| 
 | ||||
| <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 RuleTab from './RuleTab.vue' | ||||
| 
 | ||||
| @@ -190,10 +190,10 @@ | ||||
| 
 | ||||
| <script setup> | ||||
| import { toRefs, computed, watch } from 'vue' | ||||
| import { Checkbox } from '@/components/ui/checkbox' | ||||
| import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' | ||||
| import { Button } from '@/components/ui/button' | ||||
| import CloseButton from '@/components/button/CloseButton.vue' | ||||
| import { Checkbox } from '@shared-ui/components/ui/checkbox' | ||||
| import { RadioGroup, RadioGroupItem } from '@shared-ui/components/ui/radio-group' | ||||
| import { Button } from '@shared-ui/components/ui/button' | ||||
| import CloseButton from '@main/components/button/CloseButton.vue' | ||||
| import { | ||||
|   Select, | ||||
|   SelectContent, | ||||
| @@ -202,19 +202,19 @@ import { | ||||
|   SelectLabel, | ||||
|   SelectTrigger, | ||||
|   SelectValue | ||||
| } from '@/components/ui/select' | ||||
| } from '@shared-ui/components/ui/select' | ||||
| import { | ||||
|   TagsInput, | ||||
|   TagsInputInput, | ||||
|   TagsInputItem, | ||||
|   TagsInputItemDelete, | ||||
|   TagsInputItemText | ||||
| } from '@/components/ui/tags-input' | ||||
| import { Label } from '@/components/ui/label' | ||||
| import { Input } from '@/components/ui/input' | ||||
| } from '@shared-ui/components/ui/tags-input' | ||||
| import { Label } from '@shared-ui/components/ui/label' | ||||
| import { Input } from '@shared-ui/components/ui/input' | ||||
| import { useI18n } from 'vue-i18n' | ||||
| import { useConversationFilters } from '@/composables/useConversationFilters' | ||||
| import SelectComboBox from '@/components/combobox/SelectCombobox.vue' | ||||
| import { useConversationFilters } from '../../../composables/useConversationFilters' | ||||
| import SelectComboBox from '@main/components/combobox/SelectCombobox.vue' | ||||
| 
 | ||||
| const props = defineProps({ | ||||
|   ruleGroup: { | ||||
| @@ -68,7 +68,7 @@ import { | ||||
|   DropdownMenuContent, | ||||
|   DropdownMenuItem, | ||||
|   DropdownMenuTrigger | ||||
| } from '@/components/ui/dropdown-menu' | ||||
| } from '@shared-ui/components/ui/dropdown-menu' | ||||
| import { | ||||
|   AlertDialog, | ||||
|   AlertDialogAction, | ||||
| @@ -78,10 +78,10 @@ import { | ||||
|   AlertDialogFooter, | ||||
|   AlertDialogHeader, | ||||
|   AlertDialogTitle | ||||
| } from '@/components/ui/alert-dialog' | ||||
| } from '@shared-ui/components/ui/alert-dialog' | ||||
| import { EllipsisVertical } from 'lucide-vue-next' | ||||
| import { useRouter } from 'vue-router' | ||||
| import { Badge } from '@/components/ui/badge' | ||||
| import { Badge } from '@shared-ui/components/ui/badge' | ||||
| 
 | ||||
| const router = useRouter() | ||||
| const alertOpen = ref(false) | ||||
| @@ -64,17 +64,17 @@ | ||||
| <script setup> | ||||
| import { ref, onMounted, watch } from 'vue' | ||||
| import RuleList from './RuleList.vue' | ||||
| import { Spinner } from '@/components/ui/spinner' | ||||
| import { Spinner } from '@shared-ui/components/ui/spinner' | ||||
| import { | ||||
|   Select, | ||||
|   SelectContent, | ||||
|   SelectItem, | ||||
|   SelectTrigger, | ||||
|   SelectValue | ||||
| } from '@/components/ui/select' | ||||
| } from '@shared-ui/components/ui/select' | ||||
| import { Settings } from 'lucide-vue-next' | ||||
| import draggable from 'vuedraggable' | ||||
| import api from '@/api' | ||||
| import api from '../../../api' | ||||
| 
 | ||||
| const isLoading = ref(false) | ||||
| const rules = ref([]) | ||||
| @@ -167,23 +167,23 @@ | ||||
| 
 | ||||
| <script setup> | ||||
| 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 { toTypedSchema } from '@vee-validate/zod' | ||||
| import { createFormSchema } from './formSchema.js' | ||||
| import { Checkbox } from '@/components/ui/checkbox' | ||||
| import { Label } from '@/components/ui/label' | ||||
| import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' | ||||
| import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' | ||||
| import { Calendar } from '@/components/ui/calendar' | ||||
| import { Input } from '@/components/ui/input' | ||||
| import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' | ||||
| import { cn } from '@/lib/utils' | ||||
| import { Checkbox } from '@shared-ui/components/ui/checkbox/index.js' | ||||
| import { Label } from '@shared-ui/components/ui/label/index.js' | ||||
| import { RadioGroup, RadioGroupItem } from '@shared-ui/components/ui/radio-group/index.js' | ||||
| import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@shared-ui/components/ui/form/index.js' | ||||
| import { Calendar } from '@shared-ui/components/ui/calendar/index.js' | ||||
| import { Input } from '@shared-ui/components/ui/input/index.js' | ||||
| import { Popover, PopoverContent, PopoverTrigger } from '@shared-ui/components/ui/popover/index.js' | ||||
| import { cn } from '@shared-ui/lib/utils.js' | ||||
| 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 { useI18n } from 'vue-i18n' | ||||
| import SimpleTable from '@/components/table/SimpleTable.vue' | ||||
| import SimpleTable from '@main/components/table/SimpleTable.vue' | ||||
| import { | ||||
|   Dialog, | ||||
|   DialogContent, | ||||
| @@ -192,7 +192,7 @@ import { | ||||
|   DialogHeader, | ||||
|   DialogTitle, | ||||
|   DialogTrigger | ||||
| } from '@/components/ui/dialog' | ||||
| } from '@shared-ui/components/ui/dialog/index.js' | ||||
| 
 | ||||
| const props = defineProps({ | ||||
|   initialValues: { | ||||
| @@ -50,7 +50,7 @@ import { | ||||
|   DropdownMenuContent, | ||||
|   DropdownMenuItem, | ||||
|   DropdownMenuTrigger | ||||
| } from '@/components/ui/dropdown-menu' | ||||
| } from '@shared-ui/components/ui/dropdown-menu' | ||||
| import { | ||||
|   AlertDialog, | ||||
|   AlertDialogAction, | ||||
| @@ -60,13 +60,13 @@ import { | ||||
|   AlertDialogFooter, | ||||
|   AlertDialogHeader, | ||||
|   AlertDialogTitle | ||||
| } from '@/components/ui/alert-dialog' | ||||
| import { Button } from '@/components/ui/button' | ||||
| } from '@shared-ui/components/ui/alert-dialog' | ||||
| import { Button } from '@shared-ui/components/ui/button' | ||||
| import { useRouter } from 'vue-router' | ||||
| import api from '@/api' | ||||
| import { useEmitter } from '@/composables/useEmitter' | ||||
| import api from '../../../api' | ||||
| import { useEmitter } from '../../../composables/useEmitter' | ||||
| import { useI18n } from 'vue-i18n' | ||||
| import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' | ||||
| import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js' | ||||
| 
 | ||||
| const { t } = useI18n() | ||||
| const router = useRouter() | ||||
| @@ -150,14 +150,14 @@ import { | ||||
|   FormItem, | ||||
|   FormLabel, | ||||
|   FormMessage | ||||
| } from '@/components/ui/form' | ||||
| } from '@shared-ui/components/ui/form' | ||||
| import { | ||||
|   TagsInput, | ||||
|   TagsInputInput, | ||||
|   TagsInputItem, | ||||
|   TagsInputItemDelete, | ||||
|   TagsInputItemText | ||||
| } from '@/components/ui/tags-input' | ||||
| } from '@shared-ui/components/ui/tags-input' | ||||
| import { | ||||
|   Select, | ||||
|   SelectContent, | ||||
| @@ -165,8 +165,8 @@ import { | ||||
|   SelectItem, | ||||
|   SelectTrigger, | ||||
|   SelectValue | ||||
| } from '@/components/ui/select' | ||||
| import { Input } from '@/components/ui/input' | ||||
| } from '@shared-ui/components/ui/select' | ||||
| import { Input } from '@shared-ui/components/ui/input' | ||||
| 
 | ||||
| const props = defineProps({ | ||||
|   form: { | ||||
| @@ -44,7 +44,7 @@ import { | ||||
|   DropdownMenuContent, | ||||
|   DropdownMenuItem, | ||||
|   DropdownMenuTrigger | ||||
| } from '@/components/ui/dropdown-menu' | ||||
| } from '@shared-ui/components/ui/dropdown-menu' | ||||
| import { | ||||
|   AlertDialog, | ||||
|   AlertDialogAction, | ||||
| @@ -54,12 +54,12 @@ import { | ||||
|   AlertDialogFooter, | ||||
|   AlertDialogHeader, | ||||
|   AlertDialogTitle | ||||
| } from '@/components/ui/alert-dialog' | ||||
| import { Button } from '@/components/ui/button' | ||||
| import { useEmitter } from '@/composables/useEmitter' | ||||
| import { handleHTTPError } from '@/utils/http' | ||||
| import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' | ||||
| import api from '@/api' | ||||
| } from '@shared-ui/components/ui/alert-dialog' | ||||
| import { Button } from '@shared-ui/components/ui/button' | ||||
| 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() | ||||
| @@ -171,7 +171,7 @@ | ||||
| 
 | ||||
| <script setup> | ||||
| 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 { toTypedSchema } from '@vee-validate/zod' | ||||
| import { createFormSchema } from './formSchema.js' | ||||
| @@ -182,7 +182,7 @@ import { | ||||
|   FormLabel, | ||||
|   FormMessage, | ||||
|   FormDescription | ||||
| } from '@/components/ui/form' | ||||
| } from '@shared-ui/components/ui/form/index.js' | ||||
| import { | ||||
|   Select, | ||||
|   SelectContent, | ||||
| @@ -190,21 +190,21 @@ import { | ||||
|   SelectItem, | ||||
|   SelectTrigger, | ||||
|   SelectValue | ||||
| } from '@/components/ui/select' | ||||
| } from '@shared-ui/components/ui/select/index.js' | ||||
| import { | ||||
|   TagsInput, | ||||
|   TagsInputInput, | ||||
|   TagsInputItem, | ||||
|   TagsInputItemDelete, | ||||
|   TagsInputItemText | ||||
| } from '@/components/ui/tags-input' | ||||
| import { Input } from '@/components/ui/input' | ||||
| import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' | ||||
| import { useEmitter } from '@/composables/useEmitter' | ||||
| import { handleHTTPError } from '@/utils/http' | ||||
| import { timeZones } from '@/constants/timezones.js' | ||||
| } from '@shared-ui/components/ui/tags-input/index.js' | ||||
| import { Input } from '@shared-ui/components/ui/input/index.js' | ||||
| import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js' | ||||
| import { useEmitter } from '../../../composables/useEmitter.js' | ||||
| import { handleHTTPError } from '../../../utils/http.js' | ||||
| import { timeZones } from '../../../constants/timezones.js' | ||||
| import { useI18n } from 'vue-i18n' | ||||
| import api from '@/api' | ||||
| import api from '../../../api/index.js' | ||||
| 
 | ||||
| const emitter = useEmitter() | ||||
| const { t } = useI18n() | ||||
| @@ -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> | ||||
| @@ -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> | ||||
| @@ -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> | ||||
| @@ -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> | ||||
| @@ -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> | ||||
							
								
								
									
										287
									
								
								frontend/apps/main/src/features/admin/help-center/TreeNode.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										287
									
								
								frontend/apps/main/src/features/admin/help-center/TreeNode.vue
									
									
									
									
									
										Normal 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> | ||||
| @@ -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> | ||||
| @@ -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), | ||||
| }) | ||||
| @@ -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), | ||||
| }) | ||||
| @@ -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')), | ||||
| }) | ||||
| @@ -12,6 +12,37 @@ | ||||
|       </FormItem> | ||||
|     </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"> | ||||
|       <FormItem> | ||||
|         <FormLabel>{{ $t('globals.terms.fromEmailAddress') }}</FormLabel> | ||||
| @@ -85,11 +116,7 @@ | ||||
|         <FormItem> | ||||
|           <FormLabel>{{ $t('admin.inbox.mailbox') }}</FormLabel> | ||||
|           <FormControl> | ||||
|             <Input | ||||
|               type="text" | ||||
|               placeholder="INBOX" | ||||
|               v-bind="componentField" | ||||
|             /> | ||||
|             <Input type="text" placeholder="INBOX" v-bind="componentField" /> | ||||
|           </FormControl> | ||||
|           <FormDescription> | ||||
|             {{ $t('admin.inbox.mailbox.description') }} | ||||
| @@ -349,10 +376,11 @@ | ||||
| </template> | ||||
| 
 | ||||
| <script setup> | ||||
| import { watch, computed } from 'vue' | ||||
| import { watch, computed, ref, onMounted } from 'vue' | ||||
| import { useForm } from 'vee-validate' | ||||
| import { toTypedSchema } from '@vee-validate/zod' | ||||
| import { createFormSchema } from './formSchema.js' | ||||
| import api from '@main/api' | ||||
| import { | ||||
|   FormControl, | ||||
|   FormField, | ||||
| @@ -360,17 +388,17 @@ import { | ||||
|   FormLabel, | ||||
|   FormMessage, | ||||
|   FormDescription | ||||
| } from '@/components/ui/form' | ||||
| import { Input } from '@/components/ui/input' | ||||
| import { Switch } from '@/components/ui/switch' | ||||
| import { Button } from '@/components/ui/button' | ||||
| } from '@shared-ui/components/ui/form/index.js' | ||||
| import { Input } from '@shared-ui/components/ui/input/index.js' | ||||
| import { Switch } from '@shared-ui/components/ui/switch/index.js' | ||||
| import { Button } from '@shared-ui/components/ui/button/index.js' | ||||
| import { | ||||
|   Select, | ||||
|   SelectContent, | ||||
|   SelectItem, | ||||
|   SelectTrigger, | ||||
|   SelectValue | ||||
| } from '@/components/ui/select' | ||||
| } from '@shared-ui/components/ui/select/index.js' | ||||
| import { useI18n } from 'vue-i18n' | ||||
| 
 | ||||
| const props = defineProps({ | ||||
| @@ -393,10 +421,13 @@ const props = defineProps({ | ||||
| }) | ||||
| 
 | ||||
| const { t } = useI18n() | ||||
| const helpCenters = ref([]) | ||||
| 
 | ||||
| const form = useForm({ | ||||
|   validationSchema: toTypedSchema(createFormSchema(t)), | ||||
|   initialValues: { | ||||
|     name: '', | ||||
|     help_center_id: 0, | ||||
|     from: '', | ||||
|     enabled: true, | ||||
|     csat_enabled: false, | ||||
| @@ -446,4 +477,13 @@ watch( | ||||
|   }, | ||||
|   { 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> | ||||
| @@ -48,7 +48,7 @@ import { | ||||
|   DropdownMenuContent, | ||||
|   DropdownMenuItem, | ||||
|   DropdownMenuTrigger | ||||
| } from '@/components/ui/dropdown-menu' | ||||
| } from '@shared-ui/components/ui/dropdown-menu' | ||||
| import { | ||||
|   AlertDialog, | ||||
|   AlertDialogAction, | ||||
| @@ -58,8 +58,8 @@ import { | ||||
|   AlertDialogFooter, | ||||
|   AlertDialogHeader, | ||||
|   AlertDialogTitle | ||||
| } from '@/components/ui/alert-dialog' | ||||
| import { Button } from '@/components/ui/button' | ||||
| } from '@shared-ui/components/ui/alert-dialog' | ||||
| import { Button } from '@shared-ui/components/ui/button' | ||||
| 
 | ||||
| const alertOpen = ref(false) | ||||
| const props = defineProps({ | ||||
| @@ -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
subdomain.example.com
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> | ||||
| @@ -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> | ||||
| @@ -1,8 +1,9 @@ | ||||
| import * as z from 'zod' | ||||
| import { isGoDuration } from '@/utils/strings' | ||||
| import { isGoDuration } from '../../../utils/strings' | ||||
| 
 | ||||
| export const createFormSchema = (t) => z.object({ | ||||
|   name: z.string().min(1, t('globals.messages.required')), | ||||
|   help_center_id: z.number().optional(), | ||||
|   from: z.string().min(1, t('globals.messages.required')), | ||||
|   enabled: z.boolean().optional(), | ||||
|   csat_enabled: z.boolean().optional(), | ||||
| @@ -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() | ||||
|       })) | ||||
|     }) | ||||
|   }) | ||||
| }) | ||||
| @@ -129,7 +129,7 @@ | ||||
| </template> | ||||
| 
 | ||||
| <script setup> | ||||
| import { Button } from '@/components/ui/button' | ||||
| import { Button } from '@shared-ui/components/ui/button' | ||||
| import { Plus } from 'lucide-vue-next' | ||||
| import { | ||||
|   Select, | ||||
| @@ -138,11 +138,11 @@ import { | ||||
|   SelectItem, | ||||
|   SelectTrigger, | ||||
|   SelectValue | ||||
| } from '@/components/ui/select' | ||||
| import CloseButton from '@/components/button/CloseButton.vue' | ||||
| import { SelectTag } from '@/components/ui/select' | ||||
| import { useTagStore } from '@/stores/tag' | ||||
| import SelectComboBox from '@/components/combobox/SelectCombobox.vue' | ||||
| } from '@shared-ui/components/ui/select' | ||||
| import CloseButton from '@main/components/button/CloseButton.vue' | ||||
| import { SelectTag } from '@shared-ui/components/ui/select' | ||||
| import { useTagStore } from '../../../stores/tag' | ||||
| import SelectComboBox from '@main/components/combobox/SelectCombobox.vue' | ||||
| 
 | ||||
| const model = defineModel('actions', { | ||||
|   type: Array, | ||||
| @@ -150,17 +150,17 @@ | ||||
| import { ref, watch, computed } from 'vue' | ||||
| import { useForm } from 'vee-validate' | ||||
| import { toTypedSchema } from '@vee-validate/zod' | ||||
| import { Button } from '@/components/ui/button' | ||||
| import { Spinner } from '@/components/ui/spinner' | ||||
| import { Input } from '@/components/ui/input' | ||||
| import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' | ||||
| import { Button } from '@shared-ui/components/ui/button/index.js' | ||||
| import { Spinner } from '@shared-ui/components/ui/spinner/index.js' | ||||
| import { Input } from '@shared-ui/components/ui/input/index.js' | ||||
| import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@shared-ui/components/ui/form/index.js' | ||||
| import ActionBuilder from '@/features/admin/macros/ActionBuilder.vue' | ||||
| import { useConversationFilters } from '@/composables/useConversationFilters' | ||||
| import { useUsersStore } from '@/stores/users' | ||||
| import { useTeamStore } from '@/stores/team' | ||||
| import { getTextFromHTML } from '@/utils/strings.js' | ||||
| import { useConversationFilters } from '../../../composables/useConversationFilters.js' | ||||
| import { useUsersStore } from '../../../stores/users.js' | ||||
| import { useTeamStore } from '../../../stores/team.js' | ||||
| import { getTextFromHTML } from '../../../utils/strings.js' | ||||
| import { createFormSchema } from './formSchema.js' | ||||
| import SelectComboBox from '@/components/combobox/SelectCombobox.vue' | ||||
| import SelectComboBox from '@main/components/combobox/SelectCombobox.vue' | ||||
| import { | ||||
|   Select, | ||||
|   SelectContent, | ||||
| @@ -169,9 +169,9 @@ import { | ||||
|   SelectTrigger, | ||||
|   SelectValue, | ||||
|   SelectTag | ||||
| } from '@/components/ui/select' | ||||
| } from '@shared-ui/components/ui/select/index.js' | ||||
| import { useI18n } from 'vue-i18n' | ||||
| import Editor from '@/components/editor/TextEditor.vue' | ||||
| import Editor from '@main/components/editor/TextEditor.vue' | ||||
| 
 | ||||
| const { macroActions } = useConversationFilters() | ||||
| const { t } = useI18n() | ||||
| @@ -40,7 +40,7 @@ import { | ||||
|   DropdownMenuContent, | ||||
|   DropdownMenuItem, | ||||
|   DropdownMenuTrigger | ||||
| } from '@/components/ui/dropdown-menu' | ||||
| } from '@shared-ui/components/ui/dropdown-menu' | ||||
| import { | ||||
|   AlertDialog, | ||||
|   AlertDialogAction, | ||||
| @@ -50,12 +50,12 @@ import { | ||||
|   AlertDialogFooter, | ||||
|   AlertDialogHeader, | ||||
|   AlertDialogTitle | ||||
| } from '@/components/ui/alert-dialog' | ||||
| import { Button } from '@/components/ui/button' | ||||
| import { useEmitter } from '@/composables/useEmitter' | ||||
| import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' | ||||
| } from '@shared-ui/components/ui/alert-dialog' | ||||
| import { Button } from '@shared-ui/components/ui/button' | ||||
| import { useEmitter } from '../../../composables/useEmitter' | ||||
| import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js' | ||||
| import { useRouter } from 'vue-router' | ||||
| import api from '@/api/index.js' | ||||
| import api from '../../../api/index.js' | ||||
| 
 | ||||
| const router = useRouter() | ||||
| const emit = useEmitter() | ||||
| @@ -1,5 +1,5 @@ | ||||
| import * as z from 'zod' | ||||
| import { getTextFromHTML } from '@/utils/strings.js' | ||||
| import { getTextFromHTML } from '../../../utils/strings.js' | ||||
| 
 | ||||
| const actionSchema = () => z.array( | ||||
|   z.object({ | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user