mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-11 13:28:57 +00:00
feat: add CSAT sending functionality and improve error handling in templates
feat: automation action to send CSAT. - minor refactors.
This commit is contained in:
+9
-31
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -361,7 +360,7 @@ func handleUpdateConversationUserAssignee(r *fastglue.Request) error {
|
||||
// Evaluate automation rules.
|
||||
app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationUserAssigned)
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
return r.SendEnvelope("User assigned successfully")
|
||||
}
|
||||
|
||||
// handleUpdateTeamAssignee updates the team assigned to a conversation.
|
||||
@@ -496,9 +495,16 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
|
||||
|
||||
// If status is `Resolved`, send CSAT survey if enabled on inbox.
|
||||
if status == cmodels.StatusResolved {
|
||||
if err := sendCSATSurvey(app, conversation, user); err != nil {
|
||||
// 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("Status updated successfully")
|
||||
}
|
||||
@@ -596,34 +602,6 @@ func setSLADeadlines(app *App, conversation *cmodels.Conversation) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send CSAT survey email when enabled for inbox
|
||||
func sendCSATSurvey(app *App, conversation *cmodels.Conversation, user umodels.User) error {
|
||||
// Check if CSAT is enabled on the inbox.
|
||||
inbox, err := app.inbox.GetDBRecord(conversation.InboxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !inbox.CSATEnabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create CSAT survey.
|
||||
csatR, err := app.csat.Create(conversation.ID, conversation.AssignedUserID.Int)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Send CSAT survey to user as a reply.
|
||||
consts := app.consts.Load().(*constants)
|
||||
csatURL := fmt.Sprintf("%s/csat/%s", consts.AppBaseURL, csatR.UUID)
|
||||
messageContent := fmt.Sprintf("Please rate your experience with us: <a href=\"%s\">Rate now</a>", csatURL)
|
||||
// Store is_csat meta to identify and filter CSAT replies from agent view
|
||||
meta := map[string]interface{}{
|
||||
"is_csat": true,
|
||||
}
|
||||
return app.conversation.SendReply(nil, user.ID, conversation.UUID, messageContent, []string{}, []string{}, meta)
|
||||
}
|
||||
|
||||
// handleRemoveUserAssignee removes the user assigned to a conversation.
|
||||
func handleRemoveUserAssignee(r *fastglue.Request) error {
|
||||
var (
|
||||
|
||||
+45
-21
@@ -3,8 +3,6 @@ package main
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
@@ -15,38 +13,43 @@ func handleShowCSAT(r *fastglue.Request) error {
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
)
|
||||
|
||||
if uuid == "" {
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
|
||||
"error_message": "Page not found",
|
||||
})
|
||||
}
|
||||
|
||||
csat, err := app.csat.Get(uuid)
|
||||
if err != nil {
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
|
||||
"error_message": "CSAT not found",
|
||||
"Data": map[string]interface{}{
|
||||
"ErrorMessage": "Page not found",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if csat.ResponseTimestamp.Valid {
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{
|
||||
"message": "You've already submitted your feedback",
|
||||
"Data": map[string]interface{}{
|
||||
"Title": "Thank you!",
|
||||
"Message": "We appreciate you taking the time to submit your feedback.",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
conversation, err := app.conversation.GetConversation(csat.ConversationID, "")
|
||||
if err != nil {
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
|
||||
"error_message": "Conversation not found",
|
||||
"Data": map[string]interface{}{
|
||||
"ErrorMessage": "Page not found",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "csat", map[string]interface{}{
|
||||
"csat": map[string]interface{}{
|
||||
"uuid": csat.UUID,
|
||||
},
|
||||
"conversation": map[string]interface{}{
|
||||
"subject": conversation.Subject.String,
|
||||
"Data": map[string]interface{}{
|
||||
"Title": "Rate your interaction with us",
|
||||
"CSAT": map[string]interface{}{
|
||||
"UUID": csat.UUID,
|
||||
},
|
||||
"Conversation": map[string]interface{}{
|
||||
"Subject": conversation.Subject.String,
|
||||
"ReferenceNumber": conversation.ReferenceNumber,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -62,20 +65,41 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
|
||||
|
||||
ratingI, err := strconv.Atoi(string(rating))
|
||||
if err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `rating`", nil, envelope.InputError)
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
|
||||
"Data": map[string]interface{}{
|
||||
"ErrorMessage": "Invalid `rating`",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if ratingI < 1 || ratingI > 5 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "`rating` should be between 1 and 5", nil, envelope.InputError)
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
|
||||
"Data": map[string]interface{}{
|
||||
"ErrorMessage": "Invalid `rating`",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if uuid == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty `uuid`", nil, envelope.InputError)
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
|
||||
"Data": map[string]interface{}{
|
||||
"ErrorMessage": "Invalid `uuid`",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if err := app.csat.UpdateResponse(uuid, ratingI, feedback); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
|
||||
"Data": map[string]interface{}{
|
||||
"ErrorMessage": err.Error(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return r.SendEnvelope("CSAT response updated")
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{
|
||||
"Data": map[string]interface{}{
|
||||
"Title": "Thank you!",
|
||||
"Message": "We appreciate you taking the time to submit your feedback.",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
+1
-1
@@ -195,7 +195,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
||||
|
||||
// Public pages.
|
||||
g.GET("/csat/{uuid}", handleShowCSAT)
|
||||
g.POST("/csat/{uuid}", fastglue.ReqLenRangeParams(handleUpdateCSATResponse, map[string][2]int{"feedback": {1, 1000}}))
|
||||
g.POST("/csat/{uuid}", handleUpdateCSATResponse)
|
||||
|
||||
// Health check.
|
||||
g.GET("/health", handleHealthCheck)
|
||||
|
||||
+8
-1
@@ -61,6 +61,7 @@ import (
|
||||
// constants holds the app constants.
|
||||
type constants struct {
|
||||
AppBaseURL string
|
||||
FaviconURL string
|
||||
LogoURL string
|
||||
SiteName string
|
||||
UploadProvider string
|
||||
@@ -111,6 +112,7 @@ func initFlags() {
|
||||
func initConstants() *constants {
|
||||
return &constants{
|
||||
AppBaseURL: ko.String("app.root_url"),
|
||||
FaviconURL: ko.String("app.favicon_url"),
|
||||
LogoURL: ko.String("app.logo_url"),
|
||||
SiteName: ko.String("app.site_name"),
|
||||
UploadProvider: ko.MustString("upload.provider"),
|
||||
@@ -206,10 +208,12 @@ func initConversations(
|
||||
userStore *user.Manager,
|
||||
teamStore *team.Manager,
|
||||
mediaStore *media.Manager,
|
||||
settings *setting.Manager,
|
||||
csat *csat.Manager,
|
||||
automationEngine *automation.Engine,
|
||||
template *tmpl.Manager,
|
||||
) *conversation.Manager {
|
||||
c, err := conversation.New(hub, i18n, notif, sla, status, priority, inboxStore, userStore, teamStore, mediaStore, automationEngine, template, conversation.Opts{
|
||||
c, err := conversation.New(hub, i18n, notif, sla, status, priority, inboxStore, userStore, teamStore, mediaStore, settings, csat, automationEngine, template, conversation.Opts{
|
||||
DB: db,
|
||||
Lo: initLogger("conversation_manager"),
|
||||
OutgoingMessageQueueSize: ko.MustInt("message.outgoing_queue_size"),
|
||||
@@ -326,6 +330,9 @@ func getTmplFuncs(consts *constants) template.FuncMap {
|
||||
"RootURL": func() string {
|
||||
return consts.AppBaseURL
|
||||
},
|
||||
"FaviconURL": func() string {
|
||||
return consts.FaviconURL
|
||||
},
|
||||
"Date": func(layout string) string {
|
||||
if layout == "" {
|
||||
layout = time.ANSIC
|
||||
|
||||
+2
-1
@@ -147,6 +147,7 @@ func main() {
|
||||
rdb = initRedis()
|
||||
constants = initConstants()
|
||||
i18n = initI18n(fs)
|
||||
csat = initCSAT(db)
|
||||
oidc = initOIDC(db, settings)
|
||||
status = initStatus(db)
|
||||
priority = initPriority(db)
|
||||
@@ -160,7 +161,7 @@ func main() {
|
||||
notifier = initNotifier(user)
|
||||
automation = initAutomationEngine(db)
|
||||
sla = initSLA(db, team, settings, businessHours)
|
||||
conversation = initConversations(i18n, sla, status, priority, wsHub, notifier, db, inbox, user, team, media, automation, template)
|
||||
conversation = initConversations(i18n, sla, status, priority, wsHub, notifier, db, inbox, user, team, media, settings, csat, automation, template)
|
||||
autoassigner = initAutoAssigner(team, user, conversation)
|
||||
)
|
||||
|
||||
|
||||
@@ -333,7 +333,7 @@ const handleSave = async (values) => {
|
||||
router.push('/admin/automations')
|
||||
}
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Saved',
|
||||
title: 'Success',
|
||||
description: 'Rule saved successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -35,7 +35,7 @@ const updateMacro = async (payload) => {
|
||||
formLoading.value = true
|
||||
await api.updateMacro(macro.value.id, payload)
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Saved',
|
||||
title: 'Success',
|
||||
description: 'Macro updated successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -54,7 +54,7 @@ const updateInbox = async (payload) => {
|
||||
isLoading.value = true
|
||||
await api.updateInbox(inbox.value.id, payload)
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Saved',
|
||||
title: 'Success',
|
||||
description: 'Inbox updated succcessfully'
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -146,7 +146,7 @@ async function createInbox (payload) {
|
||||
isLoading.value = true
|
||||
await api.createInbox(payload)
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Saved',
|
||||
title: 'Success',
|
||||
description: 'Inbox created successfully'
|
||||
})
|
||||
router.push('/admin/inboxes')
|
||||
|
||||
@@ -55,7 +55,7 @@ const submitForm = async (values) => {
|
||||
formLoading.value = true
|
||||
await api.updateRole(props.id, values)
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Saved',
|
||||
title: 'Success',
|
||||
description: 'Role updated successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -29,7 +29,7 @@ const submitForm = async (values) => {
|
||||
formLoading.value = true
|
||||
await api.createRole(values)
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Saved',
|
||||
title: 'Success',
|
||||
description: "Role created successfully"
|
||||
})
|
||||
router.push('/admin/teams/roles')
|
||||
|
||||
@@ -176,6 +176,9 @@ export function useConversationFilters () {
|
||||
label: 'Send reply',
|
||||
type: FIELD_TYPE.RICHTEXT
|
||||
},
|
||||
send_csat: {
|
||||
label: 'Send CSAT',
|
||||
},
|
||||
set_sla: {
|
||||
label: 'Set SLA',
|
||||
type: FIELD_TYPE.SELECT,
|
||||
|
||||
@@ -336,7 +336,7 @@ func (e *Engine) handleUpdateConversation(conversationUUID, eventType string) {
|
||||
|
||||
// handleTimeTrigger handles time trigger events.
|
||||
func (e *Engine) handleTimeTrigger() {
|
||||
e.lo.Debug("handling time trigger")
|
||||
e.lo.Debug("handling time triggers")
|
||||
thirtyDaysAgo := time.Now().Add(-30 * 24 * time.Hour)
|
||||
conversations, err := e.conversationStore.GetConversationsCreatedAfter(thirtyDaysAgo)
|
||||
if err != nil {
|
||||
@@ -349,7 +349,13 @@ func (e *Engine) handleTimeTrigger() {
|
||||
return
|
||||
}
|
||||
e.lo.Debug("fetched conversations for evaluating time triggers", "conversations_count", len(conversations), "rules_count", len(rules))
|
||||
for _, conversation := range conversations {
|
||||
for _, c := range conversations {
|
||||
// Fetch entire conversation.
|
||||
conversation, err := e.conversationStore.GetConversation(0, c.UUID)
|
||||
if err != nil {
|
||||
e.lo.Error("error fetching conversation for time trigger", "uuid", c.UUID, "error", err)
|
||||
continue
|
||||
}
|
||||
e.evalConversationRules(rules, conversation)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ const (
|
||||
ActionReply = "send_reply"
|
||||
ActionSetSLA = "set_sla"
|
||||
ActionSetTags = "set_tags"
|
||||
ActionSendCSAT = "send_csat"
|
||||
|
||||
OperatorAnd = "AND"
|
||||
OperatorOR = "OR"
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/abhinavxd/libredesk/internal/conversation/models"
|
||||
pmodels "github.com/abhinavxd/libredesk/internal/conversation/priority/models"
|
||||
smodels "github.com/abhinavxd/libredesk/internal/conversation/status/models"
|
||||
csatModels "github.com/abhinavxd/libredesk/internal/csat/models"
|
||||
"github.com/abhinavxd/libredesk/internal/dbutil"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/abhinavxd/libredesk/internal/inbox"
|
||||
@@ -42,6 +43,7 @@ var (
|
||||
ErrConversationNotFound = errors.New("conversation not found")
|
||||
ConversationsListAllowedFilterFields = []string{"status_id", "priority_id", "assigned_team_id", "assigned_user_id", "inbox_id"}
|
||||
ConversationStatusesFilterFields = []string{"id", "name"}
|
||||
csatReplyMessage = "Please rate your experience with us: <a href=\"%s\">Rate now</a>"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -58,6 +60,8 @@ type Manager struct {
|
||||
statusStore statusStore
|
||||
priorityStore priorityStore
|
||||
slaStore slaStore
|
||||
settingsStore settingsStore
|
||||
csatStore csatStore
|
||||
notifier *notifier.Service
|
||||
lo *logf.Logger
|
||||
db *sqlx.DB
|
||||
@@ -109,6 +113,15 @@ type inboxStore interface {
|
||||
Get(int) (inbox.Inbox, error)
|
||||
}
|
||||
|
||||
type settingsStore interface {
|
||||
GetAppRootURL() (string, error)
|
||||
}
|
||||
|
||||
type csatStore interface {
|
||||
Create(conversationID int) (csatModels.CSATResponse, error)
|
||||
MakePublicURL(appBaseURL, uuid string) string
|
||||
}
|
||||
|
||||
// Opts holds the options for creating a new Manager.
|
||||
type Opts struct {
|
||||
DB *sqlx.DB
|
||||
@@ -122,13 +135,15 @@ func New(
|
||||
wsHub *ws.Hub,
|
||||
i18n *i18n.I18n,
|
||||
notifier *notifier.Service,
|
||||
sla slaStore,
|
||||
status statusStore,
|
||||
priority priorityStore,
|
||||
slaStore slaStore,
|
||||
statusStore statusStore,
|
||||
priorityStore priorityStore,
|
||||
inboxStore inboxStore,
|
||||
userStore userStore,
|
||||
teamStore teamStore,
|
||||
mediaStore mediaStore,
|
||||
settingsStore settingsStore,
|
||||
csatStore csatStore,
|
||||
automation *automation.Engine,
|
||||
template *template.Manager,
|
||||
opts Opts) (*Manager, error) {
|
||||
@@ -147,9 +162,11 @@ func New(
|
||||
userStore: userStore,
|
||||
teamStore: teamStore,
|
||||
mediaStore: mediaStore,
|
||||
slaStore: sla,
|
||||
statusStore: status,
|
||||
priorityStore: priority,
|
||||
settingsStore: settingsStore,
|
||||
csatStore: csatStore,
|
||||
slaStore: slaStore,
|
||||
statusStore: statusStore,
|
||||
priorityStore: priorityStore,
|
||||
automation: automation,
|
||||
template: template,
|
||||
db: opts.DB,
|
||||
@@ -759,71 +776,54 @@ func (m *Manager) ApplySLA(conversationUUID string, conversationID, assignedTeam
|
||||
|
||||
// ApplyAction applies an action to a conversation, this can be called from multiple packages across the app to perform actions on conversations.
|
||||
// all actions are executed on behalf of the provided user if the user is not provided, system user is used.
|
||||
func (m *Manager) ApplyAction(action amodels.RuleAction, conversation models.Conversation, user umodels.User) error {
|
||||
if len(action.Value) == 0 {
|
||||
m.lo.Warn("no value provided for action", "action", action.Type, "conversation_uuid", conversation.UUID)
|
||||
return fmt.Errorf("no value provided for action %s", action.Type)
|
||||
func (m *Manager) ApplyAction(action amodels.RuleAction, conv models.Conversation, user umodels.User) error {
|
||||
// CSAT action does not require a value.
|
||||
if len(action.Value) == 0 && action.Type != amodels.ActionSendCSAT {
|
||||
return fmt.Errorf("empty value for action %s", action.Type)
|
||||
}
|
||||
|
||||
// If user is not provided, use system user.
|
||||
// Fall back to system user if user is not provided.
|
||||
if user.ID == 0 {
|
||||
systemUser, err := m.userStore.GetSystemUser()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not apply %s action. could not fetch system user: %w", action.Type, err)
|
||||
var err error
|
||||
if user, err = m.userStore.GetSystemUser(); err != nil {
|
||||
return fmt.Errorf("get system user: %w", err)
|
||||
}
|
||||
user = systemUser
|
||||
}
|
||||
|
||||
m.lo.Debug("executing action",
|
||||
"type", action.Type,
|
||||
"value", action.Value,
|
||||
"conv_uuid", conv.UUID,
|
||||
"user_id", user.ID,
|
||||
)
|
||||
|
||||
switch action.Type {
|
||||
case amodels.ActionAssignTeam:
|
||||
m.lo.Debug("executing assign team action", "value", action.Value[0], "conversation_uuid", conversation.UUID)
|
||||
teamID, _ := strconv.Atoi(action.Value[0])
|
||||
if err := m.UpdateConversationTeamAssignee(conversation.UUID, teamID, user); err != nil {
|
||||
return fmt.Errorf("could not apply %s action: %w", action.Type, err)
|
||||
}
|
||||
return m.UpdateConversationTeamAssignee(conv.UUID, teamID, user)
|
||||
case amodels.ActionAssignUser:
|
||||
m.lo.Debug("executing assign user action", "value", action.Value[0], "conversation_uuid", conversation.UUID)
|
||||
agentID, _ := strconv.Atoi(action.Value[0])
|
||||
if err := m.UpdateConversationUserAssignee(conversation.UUID, agentID, user); err != nil {
|
||||
return fmt.Errorf("could not apply %s action: %w", action.Type, err)
|
||||
}
|
||||
return m.UpdateConversationUserAssignee(conv.UUID, agentID, user)
|
||||
case amodels.ActionSetPriority:
|
||||
m.lo.Debug("executing set priority action", "value", action.Value[0], "conversation_uuid", conversation.UUID)
|
||||
priorityID, _ := strconv.Atoi(action.Value[0])
|
||||
if err := m.UpdateConversationPriority(conversation.UUID, priorityID, "", user); err != nil {
|
||||
return fmt.Errorf("could not apply %s action: %w", action.Type, err)
|
||||
}
|
||||
return m.UpdateConversationPriority(conv.UUID, priorityID, "", user)
|
||||
case amodels.ActionSetStatus:
|
||||
m.lo.Debug("executing set status action", "value", action.Value[0], "conversation_uuid", conversation.UUID)
|
||||
statusID, _ := strconv.Atoi(action.Value[0])
|
||||
if err := m.UpdateConversationStatus(conversation.UUID, statusID, "", "", user); err != nil {
|
||||
return fmt.Errorf("could not apply %s action: %w", action.Type, err)
|
||||
}
|
||||
return m.UpdateConversationStatus(conv.UUID, statusID, "", "", user)
|
||||
case amodels.ActionSendPrivateNote:
|
||||
m.lo.Debug("executing send private note action", "value", action.Value[0], "conversation_uuid", conversation.UUID)
|
||||
if err := m.SendPrivateNote([]mmodels.Media{}, user.ID, conversation.UUID, action.Value[0]); err != nil {
|
||||
return fmt.Errorf("could not apply %s action: %w", action.Type, err)
|
||||
}
|
||||
return m.SendPrivateNote([]mmodels.Media{}, user.ID, conv.UUID, action.Value[0])
|
||||
case amodels.ActionReply:
|
||||
m.lo.Debug("executing reply action", "value", action.Value[0], "conversation_uuid", conversation.UUID)
|
||||
if err := m.SendReply([]mmodels.Media{}, user.ID, conversation.UUID, action.Value[0], []string{}, []string{}, map[string]interface{}{}); err != nil {
|
||||
return fmt.Errorf("could not apply %s action: %w", action.Type, err)
|
||||
}
|
||||
return m.SendReply([]mmodels.Media{}, user.ID, conv.UUID, action.Value[0], nil, nil, nil)
|
||||
case amodels.ActionSetSLA:
|
||||
m.lo.Debug("executing apply SLA action", "value", action.Value[0], "conversation_uuid", conversation.UUID)
|
||||
slaPolicyID, _ := strconv.Atoi(action.Value[0])
|
||||
if err := m.ApplySLA(conversation.UUID, conversation.ID, conversation.AssignedTeamID.Int, slaPolicyID, user); err != nil {
|
||||
return fmt.Errorf("could not apply %s action: %w", action.Type, err)
|
||||
}
|
||||
slaID, _ := strconv.Atoi(action.Value[0])
|
||||
return m.ApplySLA(conv.UUID, conv.ID, conv.AssignedTeamID.Int, slaID, user)
|
||||
case amodels.ActionSetTags:
|
||||
m.lo.Debug("executing set tags action", "value", action.Value, "conversation_uuid", conversation.UUID)
|
||||
if err := m.UpsertConversationTags(conversation.UUID, action.Value); err != nil {
|
||||
return fmt.Errorf("could not apply %s action: %w", action.Type, err)
|
||||
}
|
||||
return m.UpsertConversationTags(conv.UUID, action.Value)
|
||||
case amodels.ActionSendCSAT:
|
||||
return m.SendCSATReply(user.ID, conv)
|
||||
default:
|
||||
return fmt.Errorf("unrecognized action type %s", action.Type)
|
||||
return fmt.Errorf("unknown action: %s", action.Type)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveConversationAssignee removes the assignee from the conversation.
|
||||
@@ -835,13 +835,30 @@ func (m *Manager) RemoveConversationAssignee(uuid, typ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendCSATReply sends a CSAT reply message to a conversation.
|
||||
func (m *Manager) SendCSATReply(actorUserID int, conversation models.Conversation) error {
|
||||
appRootURL, err := m.settingsStore.GetAppRootURL()
|
||||
if err != nil {
|
||||
return envelope.NewError(envelope.GeneralError, "Error fetching app root URL", nil)
|
||||
}
|
||||
csat, err := m.csatStore.Create(conversation.ID)
|
||||
if err != nil {
|
||||
return envelope.NewError(envelope.GeneralError, "Error creating CSAT", nil)
|
||||
}
|
||||
csatPublicURL := m.csatStore.MakePublicURL(appRootURL, csat.UUID)
|
||||
message := fmt.Sprintf(csatReplyMessage, csatPublicURL)
|
||||
// Store `is_csat` meta to identify and filter CSAT public url from the message.
|
||||
meta := map[string]interface{}{
|
||||
"is_csat": true,
|
||||
}
|
||||
return m.SendReply([]mmodels.Media{}, actorUserID, conversation.UUID, message, nil, nil, meta)
|
||||
}
|
||||
|
||||
// addConversationParticipant adds a user as participant to a conversation.
|
||||
func (c *Manager) addConversationParticipant(userID int, conversationUUID string) error {
|
||||
if _, err := c.q.InsertConversationParticipant.Exec(userID, conversationUUID); err != nil {
|
||||
if !dbutil.IsUniqueViolationError(err) {
|
||||
c.lo.Error("error adding conversation participant", "user_id", userID, "conversation_uuid", conversationUUID, "error", err)
|
||||
return fmt.Errorf("adding conversation participant: %w", err)
|
||||
}
|
||||
if _, err := c.q.InsertConversationParticipant.Exec(userID, conversationUUID); err != nil && !dbutil.IsUniqueViolationError(err) {
|
||||
c.lo.Error("error adding conversation participant", "user_id", userID, "conversation_uuid", conversationUUID, "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, "Error adding conversation participant", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -331,7 +331,7 @@ func (m *Manager) InsertMessage(message *models.Message) error {
|
||||
message.Status = MessageStatusSent
|
||||
}
|
||||
|
||||
if message.Meta == "" {
|
||||
if message.Meta == "" || message.Meta == "null" {
|
||||
message.Meta = "{}"
|
||||
}
|
||||
|
||||
@@ -352,7 +352,7 @@ func (m *Manager) InsertMessage(message *models.Message) error {
|
||||
|
||||
// Add this user as a participant.
|
||||
if err := m.addConversationParticipant(message.SenderID, message.ConversationUUID); err != nil {
|
||||
return envelope.NewError(envelope.GeneralError, err.Error(), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// Update conversation last message details in conversation.
|
||||
@@ -472,7 +472,7 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error {
|
||||
}
|
||||
in.Message.SenderID = in.Contact.ID
|
||||
|
||||
// Conversations exists for this message?
|
||||
// Conversations exists for this message?
|
||||
conversationID, err := m.findConversationID([]string{in.Message.SourceID.String})
|
||||
if err != nil && err != ErrConversationNotFound {
|
||||
return err
|
||||
@@ -633,7 +633,6 @@ func (m *Manager) uploadMessageAttachments(message *models.Message) []error {
|
||||
m.lo.Error("error uploading thumbnail", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
return uploadErr
|
||||
}
|
||||
|
||||
@@ -135,30 +135,8 @@ WHERE
|
||||
-- name: get-conversations-created-after
|
||||
SELECT
|
||||
c.id,
|
||||
c.created_at,
|
||||
c.updated_at,
|
||||
c.closed_at,
|
||||
c.resolved_at,
|
||||
p.name as priority,
|
||||
s.name as status,
|
||||
c.uuid,
|
||||
c.reference_number,
|
||||
c.first_reply_at,
|
||||
u.first_name as first_name,
|
||||
u.last_name as last_name,
|
||||
u.email as email,
|
||||
u.avatar_url as avatar_url,
|
||||
(SELECT COALESCE(
|
||||
(SELECT json_agg(t.name)
|
||||
FROM tags t
|
||||
INNER JOIN conversation_tags ct ON ct.tag_id = t.id
|
||||
WHERE ct.conversation_id = c.id),
|
||||
'[]'::json
|
||||
)) AS tags
|
||||
c.uuid
|
||||
FROM conversations c
|
||||
JOIN users u ON c.contact_id = u.id
|
||||
LEFT JOIN conversation_statuses s ON c.status_id = s.id
|
||||
LEFT JOIN conversation_priorities p ON c.priority_id = p.id
|
||||
WHERE c.created_at > $1;
|
||||
|
||||
-- name: get-contact-conversations
|
||||
|
||||
+15
-6
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/csat/models"
|
||||
"github.com/abhinavxd/libredesk/internal/dbutil"
|
||||
@@ -15,10 +16,14 @@ import (
|
||||
|
||||
var (
|
||||
//go:embed queries.sql
|
||||
efs embed.FS
|
||||
efs embed.FS
|
||||
ErrCSATAlreadyExists = errors.New("CSAT already exists")
|
||||
)
|
||||
|
||||
const (
|
||||
csatURL = "%s/csat/%s"
|
||||
)
|
||||
|
||||
// Manager manages CSAT.
|
||||
type Manager struct {
|
||||
q queries
|
||||
@@ -51,15 +56,14 @@ func New(opts Opts) (*Manager, error) {
|
||||
}
|
||||
|
||||
// Create creates a new CSAT for the given conversation ID.
|
||||
func (m *Manager) Create(conversationID, assignedAgentID int) (models.CSATResponse, error) {
|
||||
func (m *Manager) Create(conversationID int) (models.CSATResponse, error) {
|
||||
var (
|
||||
uuid string
|
||||
rsp models.CSATResponse
|
||||
)
|
||||
err := m.q.Insert.QueryRow(conversationID, assignedAgentID).Scan(&uuid)
|
||||
if err != nil {
|
||||
if err := m.q.Insert.QueryRow(conversationID).Scan(&uuid); err != nil {
|
||||
m.lo.Error("error creating CSAT", "error", err)
|
||||
return rsp, envelope.NewError(envelope.GeneralError, "Error creating CSAT", nil)
|
||||
return rsp, envelope.NewError(envelope.GeneralError, "Error creating CSAT survey", nil)
|
||||
}
|
||||
return m.Get(uuid)
|
||||
}
|
||||
@@ -92,7 +96,12 @@ func (m *Manager) UpdateResponse(uuid string, score int, feedback string) error
|
||||
_, err = m.q.Update.Exec(uuid, score, feedback)
|
||||
if err != nil {
|
||||
m.lo.Error("error updating CSAT", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, "Error updating CSAT", nil)
|
||||
return envelope.NewError(envelope.GeneralError, "Error saving CSAT response", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MakePublicURL returns the public URL for the given CSAT UUID.
|
||||
func (m *Manager) MakePublicURL(appBaseURL, uuid string) string {
|
||||
return fmt.Sprintf(csatURL, appBaseURL, uuid)
|
||||
}
|
||||
|
||||
+4
-14
@@ -10,7 +10,6 @@ import (
|
||||
"github.com/abhinavxd/libredesk/internal/oidc/models"
|
||||
"github.com/abhinavxd/libredesk/internal/stringutil"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/jmoiron/sqlx/types"
|
||||
"github.com/zerodha/logf"
|
||||
)
|
||||
|
||||
@@ -44,7 +43,7 @@ type queries struct {
|
||||
}
|
||||
|
||||
type settingsStore interface {
|
||||
Get(key string) (types.JSONText, error)
|
||||
GetAppRootURL() (string, error)
|
||||
}
|
||||
|
||||
// New creates and returns a new instance of the oidc Manager.
|
||||
@@ -69,11 +68,12 @@ func (o *Manager) Get(id int, includeSecret bool) (models.OIDC, error) {
|
||||
}
|
||||
// Set logo and redirect URL.
|
||||
oidc.SetProviderLogo()
|
||||
rootURL, err := o.getAppRootURL()
|
||||
rootURL, err := o.setting.GetAppRootURL()
|
||||
if err != nil {
|
||||
return models.OIDC{}, err
|
||||
}
|
||||
oidc.RedirectURI = fmt.Sprintf(rootURL+redirectURL, oidc.ID)
|
||||
// If secret is not to be included, replace it with dummy characters.
|
||||
if oidc.ClientSecret != "" && !includeSecret {
|
||||
oidc.ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10)
|
||||
}
|
||||
@@ -89,7 +89,7 @@ func (o *Manager) GetAll() ([]models.OIDC, error) {
|
||||
}
|
||||
|
||||
// Get root URL of the app.
|
||||
rootURL, err := o.getAppRootURL()
|
||||
rootURL, err := o.setting.GetAppRootURL()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -148,13 +148,3 @@ func (o *Manager) Delete(id int) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getAppRootURL returns the root URL of the app.
|
||||
func (o *Manager) getAppRootURL() (string, error) {
|
||||
rootURL, err := o.setting.Get("app.root_url")
|
||||
if err != nil {
|
||||
o.lo.Error("error fetching root URL", "error", err)
|
||||
return "", envelope.NewError(envelope.GeneralError, "Error fetching root URL", nil)
|
||||
}
|
||||
return strings.Trim(string(rootURL), "\""), nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ package setting
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/dbutil"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
@@ -112,3 +113,13 @@ func (m *Manager) Get(key string) (types.JSONText, error) {
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// GetAppRootURL returns the root URL of the app.
|
||||
func (m *Manager) GetAppRootURL() (string, error) {
|
||||
rootURL, err := m.Get("app.root_url")
|
||||
if err != nil {
|
||||
m.lo.Error("error fetching root URL", "error", err)
|
||||
return "", envelope.NewError(envelope.GeneralError, "Error fetching app root URL", nil)
|
||||
}
|
||||
return strings.Trim(string(rootURL), "\""), nil
|
||||
}
|
||||
|
||||
@@ -76,8 +76,8 @@ func RandomNumericString(n int) (string, error) {
|
||||
}
|
||||
|
||||
// GetPathFromURL extracts the path from a URL.
|
||||
func GetPathFromURL(rawURL string) (string, error) {
|
||||
parsedURL, err := url.Parse(rawURL)
|
||||
func GetPathFromURL(u string) (string, error) {
|
||||
parsedURL, err := url.Parse(u)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -154,5 +154,9 @@ func (m *Manager) RenderWebPage(ctx *fasthttp.RequestCtx, tmplFile string, data
|
||||
defer m.mutex.RUnlock()
|
||||
ctx.SetContentType("text/html; charset=utf-8")
|
||||
ctx.SetStatusCode(fasthttp.StatusOK)
|
||||
// Add no-cache headers
|
||||
ctx.Response.Header.Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
||||
ctx.Response.Header.Set("Pragma", "no-cache")
|
||||
ctx.Response.Header.Set("Expires", "0")
|
||||
return m.webTpls.ExecuteTemplate(ctx, tmplFile, data)
|
||||
}
|
||||
|
||||
+110
-136
@@ -1,54 +1,79 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600&display=swap");
|
||||
|
||||
:root {
|
||||
--primary-color: #1a1a1d;
|
||||
--secondary-color: #f4f4f5;
|
||||
--text-color: #0a0a0b;
|
||||
--background-color: #ffffff;
|
||||
--border-color: #e5e5e6;
|
||||
--shadow-color: rgba(26, 26, 29, 0.1);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #f9f9f9;
|
||||
font-family: 'Inter', 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
font-family: "Plus Jakarta Sans", "Inter", sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 26px;
|
||||
color: #111;
|
||||
line-height: 1.6;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
a {
|
||||
color: #18181b;
|
||||
text-decoration-color: #abcbfb;
|
||||
color: var(--secondary-color);
|
||||
text-decoration: none;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #111;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
label {
|
||||
cursor: pointer;
|
||||
color: #444;
|
||||
color: var(--text-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
font-weight: 400;
|
||||
}
|
||||
.section {
|
||||
margin-bottom: 45px;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
input[type='text'],
|
||||
input[type='email'],
|
||||
input[type='password'],
|
||||
select {
|
||||
padding: 10px 15px;
|
||||
border: 1px solid #888;
|
||||
border-radius: 3px;
|
||||
width: 100%;
|
||||
box-shadow: 2px 2px 0 #f3f3f3;
|
||||
border: 1px solid #ddd;
|
||||
font-size: 1em;
|
||||
.section {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
input:focus {
|
||||
border-color: #18181b;
|
||||
|
||||
input[type="text"],
|
||||
input[type="email"],
|
||||
input[type="password"],
|
||||
select {
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.375rem;
|
||||
width: 100%;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--secondary-color);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
input:focus::placeholder {
|
||||
@@ -56,181 +81,130 @@ input:focus::placeholder {
|
||||
}
|
||||
|
||||
input[disabled] {
|
||||
opacity: 0.5;
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff5722;
|
||||
color: #ef4444;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
background: #18181b;
|
||||
padding: 15px 30px;
|
||||
border-radius: 3px;
|
||||
background: var(--primary-color);
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: #ffff;
|
||||
color: #ffffff;
|
||||
display: inline-block;
|
||||
min-width: 150px;
|
||||
font-size: 1.1em;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
transition: background-color 0.3s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: #333;
|
||||
color: #fff;
|
||||
}
|
||||
background: black;
|
||||
color: #ffffff;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.button.button-outline {
|
||||
background: #fff;
|
||||
border: 1px solid #18181b;
|
||||
color: #18181b;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.button.button-outline:hover {
|
||||
border-color: #333;
|
||||
background-color: #333;
|
||||
color: #fff;
|
||||
background-color: var(--primary-color);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin: 60px auto 15px auto;
|
||||
max-width: 550px;
|
||||
margin: 3rem auto;
|
||||
max-width: 600px;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
background: #fff;
|
||||
padding: 40px;
|
||||
box-shadow: 2px 2px 0 #f3f3f3;
|
||||
border: 1px solid #eee;
|
||||
background: #ffffff;
|
||||
padding: 2.5rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px var(--shadow-color);
|
||||
}
|
||||
|
||||
.header {
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-bottom: 15px;
|
||||
margin-bottom: 30px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.header .logo img {
|
||||
width: auto;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.unsub-all {
|
||||
margin-top: 30px;
|
||||
padding-top: 30px;
|
||||
border-top: 1px solid #eee;
|
||||
.row {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.row {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.lists {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
}
|
||||
.lists li {
|
||||
margin: 0 0 5px 0;
|
||||
}
|
||||
.lists .description {
|
||||
margin: 0 0 15px 0;
|
||||
font-size: 0.875em;
|
||||
line-height: 1.3rem;
|
||||
color: #888;
|
||||
margin-left: 25px;
|
||||
}
|
||||
.form .nonce {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form .captcha {
|
||||
margin-top: 30px;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.archive {
|
||||
list-style-type: none;
|
||||
margin: 25px 0 0 0;
|
||||
margin: 1.5rem 0 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.archive .date {
|
||||
display: block;
|
||||
color: #666;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
.archive li {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.feed {
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.home-options {
|
||||
margin-top: 30px;
|
||||
}
|
||||
.home-options a {
|
||||
margin: 0 7px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
.pg-page {
|
||||
display: inline-block;
|
||||
padding: 0 10px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.pg-page.pg-selected {
|
||||
text-decoration: underline;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.login .submit {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.login button {
|
||||
width: 100%;
|
||||
vertical-align: middle;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.login button img {
|
||||
max-width: 24px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
#btn-back {
|
||||
display: none;
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
footer.container {
|
||||
margin-top: 15px;
|
||||
margin-top: 2rem;
|
||||
text-align: center;
|
||||
color: #aaa;
|
||||
font-size: 0.775em;
|
||||
margin-top: 30px;
|
||||
margin-bottom: 30px;
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: #aaa;
|
||||
color: #9ca3af;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
footer a:hover {
|
||||
color: #111;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.green {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 650px) {
|
||||
.wrap {
|
||||
margin: 0;
|
||||
padding: 30px;
|
||||
max-width: none;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin: 1.5rem auto;
|
||||
}
|
||||
}
|
||||
|
||||
.green {
|
||||
color: green;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,87 +2,106 @@
|
||||
{{ template "header" . }}
|
||||
<div class="csat-container">
|
||||
<div class="csat-header">
|
||||
<h1>How was your experience?</h1>
|
||||
<p class="conversation-subject">Re: {{ .conversation.subject }}</p>
|
||||
<h1>Rate your experience</h1>
|
||||
<p class="conversation-subject"><i>{{ .Data.Conversation.Subject }}</i> - #{{ .Data.Conversation.ReferenceNumber
|
||||
}}</p>
|
||||
</div>
|
||||
|
||||
<form action="{{ .RootURL }}/csat/{{ .csat.uuid }}" method="POST" class="csat-form">
|
||||
<form action="/csat/{{ .Data.CSAT.UUID }}" method="POST" class="csat-form" novalidate>
|
||||
<div class="rating-container">
|
||||
<label class="rating-label">How would you rate your interaction?</label>
|
||||
<label class="rating-label">We would greatly appreciate if you could rate your recent interaction with us to
|
||||
help us improve the quality of our services.</label>
|
||||
<div class="rating-options">
|
||||
<input type="radio" id="rating-1" name="rating" value="1" required>
|
||||
<label for="rating-1" class="rating-option">
|
||||
<label for="rating-1" class="rating-option" tabindex="0">
|
||||
<div class="emoji-wrapper">
|
||||
<span class="emoji">😢</span>
|
||||
</div>
|
||||
<span class="rating-text">Very Dissatisfied</span>
|
||||
<span class="rating-text">Poor</span>
|
||||
</label>
|
||||
|
||||
<input type="radio" id="rating-2" name="rating" value="2">
|
||||
<label for="rating-2" class="rating-option">
|
||||
<label for="rating-2" class="rating-option" tabindex="0">
|
||||
<div class="emoji-wrapper">
|
||||
<span class="emoji">😕</span>
|
||||
</div>
|
||||
<span class="rating-text">Dissatisfied</span>
|
||||
<span class="rating-text">Fair</span>
|
||||
</label>
|
||||
|
||||
<input type="radio" id="rating-3" name="rating" value="3">
|
||||
<label for="rating-3" class="rating-option">
|
||||
<label for="rating-3" class="rating-option" tabindex="0">
|
||||
<div class="emoji-wrapper">
|
||||
<span class="emoji">😊</span>
|
||||
</div>
|
||||
<span class="rating-text">Neutral</span>
|
||||
<span class="rating-text">Good</span>
|
||||
</label>
|
||||
|
||||
<input type="radio" id="rating-4" name="rating" value="4">
|
||||
<label for="rating-4" class="rating-option">
|
||||
<label for="rating-4" class="rating-option" tabindex="0">
|
||||
<div class="emoji-wrapper">
|
||||
<span class="emoji">😃</span>
|
||||
</div>
|
||||
<span class="rating-text">Satisfied</span>
|
||||
<span class="rating-text">Great</span>
|
||||
</label>
|
||||
|
||||
<input type="radio" id="rating-5" name="rating" value="5">
|
||||
<label for="rating-5" class="rating-option">
|
||||
<label for="rating-5" class="rating-option" tabindex="0">
|
||||
<div class="emoji-wrapper">
|
||||
<span class="emoji">🤩</span>
|
||||
</div>
|
||||
<span class="rating-text">Very Satisfied</span>
|
||||
<span class="rating-text">Excellent</span>
|
||||
</label>
|
||||
</div>
|
||||
<!-- Validation message for rating -->
|
||||
<div class="validation-message" id="ratingValidationMessage"
|
||||
style="display: none; color: #dc2626; text-align: center; margin-top: 10px; font-size: 0.9em;">
|
||||
Please select a rating before submitting.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="feedback-container">
|
||||
<label for="feedback" class="feedback-label">What made you feel this way? (optional)</label>
|
||||
<textarea id="feedback" name="feedback" placeholder="Help us understand your experience better..." rows="4"
|
||||
maxlength="1000" onkeyup="updateCharCount(this)"></textarea>
|
||||
<label for="feedback" class="feedback-label">Additional feedback (optional)</label>
|
||||
<textarea id="feedback" name="feedback" placeholder="Share your thoughts..." rows="6" maxlength="1000"
|
||||
onkeyup="updateCharCount(this)"></textarea>
|
||||
<div class="char-counter">
|
||||
<span id="charCount">0</span>/1000 characters
|
||||
<span id="charCount">0</span>/1000
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function updateCharCount (textarea) {
|
||||
const counter = document.getElementById('charCount');
|
||||
const count = textarea.value.length;
|
||||
counter.textContent = count;
|
||||
|
||||
// Add visual feedback as user approaches limit
|
||||
const charCounter = counter.parentElement;
|
||||
if (count > 900) {
|
||||
charCounter.classList.add('near-limit');
|
||||
} else {
|
||||
charCounter.classList.remove('near-limit');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="button submit-button">
|
||||
Submit Feedback
|
||||
</button>
|
||||
<button type="submit" class="button submit-button">Submit</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function updateCharCount (textarea) {
|
||||
const counter = document.getElementById('charCount');
|
||||
const count = textarea.value.length;
|
||||
counter.textContent = count;
|
||||
|
||||
const charCounter = counter.parentElement;
|
||||
if (count > 900) {
|
||||
charCounter.classList.add('near-limit');
|
||||
} else {
|
||||
charCounter.classList.remove('near-limit');
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure a rating is selected before submitting the form
|
||||
document.querySelector('.csat-form').addEventListener('submit', function (e) {
|
||||
const rating = document.querySelector('input[name="rating"]:checked');
|
||||
const feedback = document.querySelector('#feedback');
|
||||
if (!rating) {
|
||||
e.preventDefault();
|
||||
validationMsg.style.display = 'block';
|
||||
|
||||
// Hide the message after 10 seconds
|
||||
setTimeout(() => {
|
||||
validationMsg.style.display = 'none';
|
||||
}, 10000);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.csat-container {
|
||||
background: #fff;
|
||||
@@ -150,6 +169,11 @@
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.rating-option:focus {
|
||||
outline: 2px solid #0055d4;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.rating-options input[type="radio"]:checked+.rating-option {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
@@ -243,58 +267,49 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
padding: 15px 30px;
|
||||
font-size: 1.1em;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 650px) {
|
||||
.char-counter {
|
||||
font-size: 0.85em;
|
||||
|
||||
.csat-container {
|
||||
margin: 0;
|
||||
padding: 30px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
.rating-options {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rating-option {
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
gap: 15px;
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
padding: 15px 30px;
|
||||
font-size: 1.1em;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.submit-button:hover {
|
||||
background-color: #004bb8;
|
||||
transform: translateY(-1px);
|
||||
.emoji-wrapper {
|
||||
margin-bottom: 0;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 650px) {
|
||||
.csat-container {
|
||||
margin: 0;
|
||||
padding: 30px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.rating-options {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rating-option {
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
gap: 15px;
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.emoji-wrapper {
|
||||
margin-bottom: 0;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-size: 1.8em;
|
||||
}
|
||||
|
||||
.rating-text {
|
||||
text-align: left;
|
||||
}
|
||||
.emoji {
|
||||
font-size: 1.8em;
|
||||
}
|
||||
|
||||
.rating-text {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{{ template "footer" . }}
|
||||
{{ template "footer" }}
|
||||
{{ end }}
|
||||
@@ -2,8 +2,8 @@
|
||||
{{ template "header" . }}
|
||||
|
||||
<section class="center">
|
||||
{{ if .error_message }}
|
||||
<p class="error">{{ .error_message }}</p>
|
||||
{{ if .Data.ErrorMessage }}
|
||||
<p class="error">{{ .Data.ErrorMessage }}</p>
|
||||
{{ end }}
|
||||
</section>
|
||||
|
||||
|
||||
@@ -3,28 +3,25 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<title>{{ .Data.Title }} - {{ .SiteName }}</title>
|
||||
<meta name="description" content="{{ .Data.Description }}" />
|
||||
<title>{{ .Data.Title }} - {{ SiteName }}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
|
||||
|
||||
<link href="/static/public/static/style.css?v={{ .AssetVersion }}" rel="stylesheet" type="text/css" />
|
||||
<link href="/public/custom.css?v={{ .AssetVersion }}" rel="stylesheet" type="text/css">
|
||||
<script src="/public/custom.js?v={{ .AssetVersion }}" async defer></script>
|
||||
<link href="/static/public/static/style.css" rel="stylesheet" type="text/css" />
|
||||
|
||||
{{ if ne .FaviconURL "" }}
|
||||
<link rel="icon" href="{{ .FaviconURL }}" type="image/x-icon" />
|
||||
{{ if ne FaviconURL "" }}
|
||||
<link rel="icon" href="{{ FaviconURL }}" type="image/x-icon" />
|
||||
{{ else }}
|
||||
<link rel="icon" href="/public/static/favicon.png?v={{ .AssetVersion }}" type="image/png" />
|
||||
<link rel="icon" href="/public/static/favicon.png" type="image/png" />
|
||||
{{ end }}
|
||||
</head>
|
||||
<body>
|
||||
<div class="container wrap">
|
||||
<header class="header">
|
||||
<div class="logo">
|
||||
{{ if ne .LogoURL "" }}
|
||||
<img src="{{ LogoURL }}" alt="{{ .Data.Title }}" /></a>
|
||||
{{ if ne LogoURL "" }}
|
||||
<img src="{{ LogoURL }}" alt="{{ SiteName }}" />
|
||||
{{ else }}
|
||||
<img src="/public/static/logo.svg?v={{ .AssetVersion }}" alt="{{ .Data.Title }}" />
|
||||
<img src="/public/static/logo.svg" alt="{{ SiteName }}" />
|
||||
{{ end }}
|
||||
</a>
|
||||
</div>
|
||||
@@ -33,7 +30,6 @@
|
||||
|
||||
{{ define "footer" }}
|
||||
</div>
|
||||
|
||||
<footer class="container">
|
||||
Powered by <a target="_blank" rel="noreferrer" href="https://libredesk.io/">LibreDesk</a>
|
||||
</footer>
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
{{ template "header" . }}
|
||||
|
||||
<section class="center">
|
||||
{{ if .message }}
|
||||
<p class="green">{{ .message }}</p>
|
||||
<h1>{{ .Data.Title }}</h1>
|
||||
{{ if .Data.Message }}
|
||||
<p class="green">{{ .Data.Message }}</p>
|
||||
{{ end }}
|
||||
|
||||
</section>
|
||||
|
||||
{{ template "footer" . }}
|
||||
|
||||
Reference in New Issue
Block a user