From 96d8bb91ec4af79fc250cb28c36fea52bee3750f Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Tue, 22 Sep 2026 02:13:41 +0530 Subject: [PATCH] improve proactive message setup Explain custom events and guide users when contact attributes are missing. Accept Go durations for campaign cooldowns and stop condition fields from losing focus. --- cmd/widget_campaign.go | 23 +++++++++- cmd/widget_campaign_test.go | 28 ++++++++++++ .../admin/inbox/LivechatInboxForm.vue | 10 ++--- .../features/admin/inbox/WidgetCampaigns.vue | 40 ++++++++++++----- .../features/admin/inbox/WidgetConditions.vue | 44 +++++++++++++++---- .../admin/inbox/livechatFormSchema.js | 6 ++- .../admin/inbox/livechatFormSchema.test.js | 13 +++++- .../apps/main/src/stores/customAttributes.js | 14 +++--- .../main/src/stores/customAttributes.test.js | 38 ++++++++++++++++ .../cypress/e2e/ui/livechatWidgetTabs.cy.js | 4 ++ i18n/en-US.json | 5 ++- internal/inbox/channel/livechat/livechat.go | 26 +++++------ .../livechat/proactive/eligibility_test.go | 14 +++--- .../channel/livechat/proactive/proactive.go | 4 +- .../livechat/proactive/proactive_test.go | 4 +- 15 files changed, 211 insertions(+), 62 deletions(-) create mode 100644 frontend/apps/main/src/stores/customAttributes.test.js diff --git a/cmd/widget_campaign.go b/cmd/widget_campaign.go index 2bfe1cf0..843d4bc5 100644 --- a/cmd/widget_campaign.go +++ b/cmd/widget_campaign.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "errors" "slices" "strconv" "strings" @@ -48,6 +49,10 @@ func handleWidgetCampaign(r *fastglue.Request) error { if err != nil { return sendErrorEnvelope(r, err) } + cooldown, err := parseCampaignCooldown(config.CampaignCooldown) + if err != nil { + return sendErrorEnvelope(r, campaignInputError(app)) + } if err := checkConversationPermissions(app, config, ctx.Visitor, contact.ID, inbox.ID); err != nil { return r.SendEnvelope(nil) } @@ -91,7 +96,7 @@ func handleWidgetCampaign(r *fastglue.Request) error { return sendErrorEnvelope(r, err) } for _, c := range candidates { - if proactive.Suppression(c.campaign, ctx, history, config.CampaignCooldownHours) != "" { + if proactive.Suppression(c.campaign, ctx, history, cooldown) != "" { continue } delivery, err := app.proactive.Reserve(inbox.ID, c.campaign, ctx, c.snapshot) @@ -236,7 +241,10 @@ func campaignInputError(app *App) error { } func validateWidgetFeatures(app *App, config livechat.Config) error { - if config.Help.HelpCenterID < 0 || len(config.Help.FeaturedIDs) > 10 || config.Previews.AutoHideSeconds < 0 || config.Previews.AutoHideSeconds > 300 || !slices.Contains([]string{"", "message", "generic"}, config.Previews.Content) || len(config.Campaigns) > 50 || config.CampaignCooldownHours < 0 || config.CampaignCooldownHours > 8760 { + if config.Help.HelpCenterID < 0 || len(config.Help.FeaturedIDs) > 10 || config.Previews.AutoHideSeconds < 0 || config.Previews.AutoHideSeconds > 300 || !slices.Contains([]string{"", "message", "generic"}, config.Previews.Content) || len(config.Campaigns) > 50 { + return campaignInputError(app) + } + if _, err := parseCampaignCooldown(config.CampaignCooldown); err != nil { return campaignInputError(app) } if config.Help.HelpCenterID > 0 { @@ -271,3 +279,14 @@ func validateWidgetFeatures(app *App, config livechat.Config) error { } return nil } + +func parseCampaignCooldown(value string) (time.Duration, error) { + duration, err := time.ParseDuration(value) + if err != nil { + return 0, err + } + if duration < 0 { + return 0, errors.New("campaign cooldown cannot be negative") + } + return duration, nil +} diff --git a/cmd/widget_campaign_test.go b/cmd/widget_campaign_test.go index 14008e85..38c012ab 100644 --- a/cmd/widget_campaign_test.go +++ b/cmd/widget_campaign_test.go @@ -2,6 +2,7 @@ package main import ( "testing" + "time" bhmodels "github.com/abhinavxd/libredesk/internal/business_hours/models" ) @@ -29,3 +30,30 @@ func TestWithinWorkingHours(t *testing.T) { }) } } + +func TestParseCampaignCooldown(t *testing.T) { + tests := []struct { + value string + want time.Duration + valid bool + }{ + {value: "0s", valid: true}, + {value: "10m", want: 10 * time.Minute, valid: true}, + {value: "1h30m", want: 90 * time.Minute, valid: true}, + {value: ""}, + {value: "tomorrow"}, + {value: "-1m"}, + } + + for _, tc := range tests { + t.Run(tc.value, func(t *testing.T) { + got, err := parseCampaignCooldown(tc.value) + if tc.valid && (err != nil || got != tc.want) { + t.Fatalf("got %v, %v; want %v", got, err, tc.want) + } + if !tc.valid && err == nil { + t.Fatalf("accepted %q", tc.value) + } + }) + } +} diff --git a/frontend/apps/main/src/features/admin/inbox/LivechatInboxForm.vue b/frontend/apps/main/src/features/admin/inbox/LivechatInboxForm.vue index 7fef6e11..44bbfe69 100644 --- a/frontend/apps/main/src/features/admin/inbox/LivechatInboxForm.vue +++ b/frontend/apps/main/src/features/admin/inbox/LivechatInboxForm.vue @@ -947,7 +947,7 @@ :model-value="form.values.config.campaigns || []" :inbox-id="initialValues.id || 0" :brand-name="form.values.config.brand_name || ''" - :cooldown="form.values.config.campaign_cooldown_hours ?? 24" + :cooldown="form.values.config.campaign_cooldown ?? '24h'" :show-errors="showCampaignErrors" @update:model-value="updateCampaigns" @update:cooldown="updateCampaignCooldown" @@ -1732,7 +1732,7 @@ const form = useForm({ help: defaultWidgetHelp(), previews: defaultWidgetPreviews(), campaigns: [], - campaign_cooldown_hours: 24, + campaign_cooldown: '24h', brand_name: '', website_url: '', theme: 'light', @@ -1890,8 +1890,8 @@ const hasCampaignErrors = computed(() => ) const updateCampaigns = (campaigns) => form.setFieldValue('config.campaigns', campaigns, hasCampaignErrors.value) -const updateCampaignCooldown = (hours) => - form.setFieldValue('config.campaign_cooldown_hours', hours, hasCampaignErrors.value) +const updateCampaignCooldown = (duration) => + form.setFieldValue('config.campaign_cooldown', duration, hasCampaignErrors.value) // home_apps in form.values only syncs on change events, so pull the live ref for the preview. const previewTheme = inject('livechatPreviewTheme', ref('light')) @@ -2130,7 +2130,7 @@ watch( ...audienceConfigs, prechat_form: pc, campaigns: newValues.config?.campaigns || [], - campaign_cooldown_hours: newValues.config?.campaign_cooldown_hours || 24, + campaign_cooldown: newValues.config?.campaign_cooldown || '24h', help: newValues.config?.help || defaultWidgetHelp(), previews: newValues.config?.previews || defaultWidgetPreviews(), features: { diff --git a/frontend/apps/main/src/features/admin/inbox/WidgetCampaigns.vue b/frontend/apps/main/src/features/admin/inbox/WidgetCampaigns.vue index fb108e50..7395eac6 100644 --- a/frontend/apps/main/src/features/admin/inbox/WidgetCampaigns.vue +++ b/frontend/apps/main/src/features/admin/inbox/WidgetCampaigns.vue @@ -1,4 +1,6 @@ diff --git a/frontend/apps/main/src/features/admin/inbox/livechatFormSchema.js b/frontend/apps/main/src/features/admin/inbox/livechatFormSchema.js index b21c4b4c..a8143a28 100644 --- a/frontend/apps/main/src/features/admin/inbox/livechatFormSchema.js +++ b/frontend/apps/main/src/features/admin/inbox/livechatFormSchema.js @@ -251,7 +251,11 @@ export const createFormSchema = (t) => linked_email_inbox_id: z.number().nullable().optional(), config: z.object({ campaigns: z.array(createCampaignSchema(t)).max(50).default([]), - campaign_cooldown_hours: rangeInteger(t, 0, 8760).default(24), + campaign_cooldown: z + .string() + .min(1, { message: t('globals.messages.required') }) + .refine(isGoDuration, { message: t('validation.invalidDuration') }) + .default('24h'), help: z .object({ help_center_id: z.number().int().min(0), diff --git a/frontend/apps/main/src/features/admin/inbox/livechatFormSchema.test.js b/frontend/apps/main/src/features/admin/inbox/livechatFormSchema.test.js index b41bee72..cbc96bd6 100644 --- a/frontend/apps/main/src/features/admin/inbox/livechatFormSchema.test.js +++ b/frontend/apps/main/src/features/admin/inbox/livechatFormSchema.test.js @@ -287,7 +287,7 @@ describe('Livechat Inbox Form Schema', () => { repeat_hours: 0 } ], - campaign_cooldown_hours: -1 + campaign_cooldown: 'tomorrow' }) ) expect(result.success).toBe(false) @@ -295,10 +295,19 @@ describe('Livechat Inbox Form Schema', () => { 'widget.campaignUrlLimit {}', 'validation.minmaxNumber {"min":0,"max":86400}', 'validation.minmaxNumber {"min":1,"max":8760}', - 'validation.minmaxNumber {"min":0,"max":8760}' + 'validation.invalidDuration {}' ]) }) + test.each(['0s', '10m', '1h', '1h30m'])( + 'campaign cooldown accepts %s', + (campaignCooldown) => { + expect(() => + schema.parse(withConfig({ campaign_cooldown: campaignCooldown })) + ).not.toThrow() + } + ) + test('quick replies reject more than six non-empty lines', () => { expect(() => schema.parse(withConfig({ quick_replies: '1\n2\n3\n4\n5\n6\n7' }))).toThrow() expect(() => schema.parse(withConfig({ quick_replies: '1\n2\n\n3\n4\n5\n6' }))).not.toThrow() diff --git a/frontend/apps/main/src/stores/customAttributes.js b/frontend/apps/main/src/stores/customAttributes.js index db3e8021..c8f7be1e 100644 --- a/frontend/apps/main/src/stores/customAttributes.js +++ b/frontend/apps/main/src/stores/customAttributes.js @@ -1,9 +1,9 @@ import { ref, computed } from 'vue' import { defineStore } from 'pinia' import { handleHTTPError } from '@shared-ui/utils/http.js' -import { useEmitter } from '../composables/useEmitter' -import { EMITTER_EVENTS } from '../constants/emitterEvents' -import api from '../api' +import api from '@/api' +import { EMITTER_EVENTS } from '@/constants/emitterEvents' +import { useEmitter } from '@/composables/useEmitter' export const useCustomAttributeStore = defineStore('customAttributes', () => { const attributes = ref([]) @@ -28,8 +28,7 @@ export const useCustomAttributeStore = defineStore('customAttributes', () => { }) let inflight = null let hasFetched = false - const fetchCustomAttributes = () => { - if (hasFetched) return Promise.resolve() + const loadCustomAttributes = () => { if (inflight) return inflight inflight = api.getCustomAttributes() .then(response => { @@ -45,10 +44,13 @@ export const useCustomAttributeStore = defineStore('customAttributes', () => { .finally(() => { inflight = null }) return inflight } + const fetchCustomAttributes = () => hasFetched ? Promise.resolve() : loadCustomAttributes() + const refreshCustomAttributes = () => loadCustomAttributes() return { attributes, conversationAttributeOptions, contactAttributeOptions, fetchCustomAttributes, + refreshCustomAttributes, } -}) \ No newline at end of file +}) diff --git a/frontend/apps/main/src/stores/customAttributes.test.js b/frontend/apps/main/src/stores/customAttributes.test.js new file mode 100644 index 00000000..8b92a1d1 --- /dev/null +++ b/frontend/apps/main/src/stores/customAttributes.test.js @@ -0,0 +1,38 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' + +vi.mock('@/api', () => ({ + default: { getCustomAttributes: vi.fn() } +})) + +vi.mock('@/composables/useEmitter', () => ({ + useEmitter: () => ({ emit: vi.fn() }) +})) + +import api from '@/api' +import { useCustomAttributeStore } from '@/stores/customAttributes' + +describe('custom attribute store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + it('refreshes attributes after the initial cached fetch', async () => { + api.getCustomAttributes + .mockResolvedValueOnce({ data: { data: [{ id: 1, applies_to: 'conversation' }] } }) + .mockResolvedValueOnce({ + data: { data: [{ id: 2, name: 'Plan', applies_to: 'contact' }] } + }) + const store = useCustomAttributeStore() + + await store.fetchCustomAttributes() + await store.fetchCustomAttributes() + await store.refreshCustomAttributes() + + expect(api.getCustomAttributes).toHaveBeenCalledTimes(2) + expect(store.contactAttributeOptions).toEqual([ + { id: 2, name: 'Plan', applies_to: 'contact', label: 'Plan', value: '2' } + ]) + }) +}) diff --git a/frontend/cypress/e2e/ui/livechatWidgetTabs.cy.js b/frontend/cypress/e2e/ui/livechatWidgetTabs.cy.js index e3d79b97..c2e4526c 100644 --- a/frontend/cypress/e2e/ui/livechatWidgetTabs.cy.js +++ b/frontend/cypress/e2e/ui/livechatWidgetTabs.cy.js @@ -129,6 +129,10 @@ describe('Live chat inbox form: proactive messages and help center tabs', () => cy.get('#campaign-exclude_urls').clear().type('/checkout/*') cy.get('#campaign-delay_seconds').type('{selectall}25') cy.get('#campaign-event').clear().type('pricing_viewed') + cy.contains( + 'Show this message only after your website tracks this event. Leave this field blank to show it without an event.' + ).should('be.visible') + cy.contains('code', 'window.Libredesk.trackEvent("pricing_viewed")').should('be.visible') selectOption('#campaign-repeat', 'After an interval') cy.get('#campaign-repeat_hours').type('{selectall}12') diff --git a/i18n/en-US.json b/i18n/en-US.json index a3ad2c24..8e140fa4 100644 --- a/i18n/en-US.json +++ b/i18n/en-US.json @@ -1676,6 +1676,7 @@ "webhook.sendTest": "Send test", "webhook.sentSuccessfully": "Webhook sent successfully", "widget.addCampaign": "Add proactive message", + "widget.addContactCustomAttribute": "Add contact custom attribute", "widget.addHelpHomeApp": "Add help articles", "widget.articleUnavailable": "Article unavailable", "widget.autoHideSeconds": "Hide previews after (seconds, 0 keeps them visible)", @@ -1702,8 +1703,10 @@ "widget.campaign.section.placement": "Where and when", "widget.campaign.session": "Once per session", "widget.campaign.users": "Signed-in users", - "widget.campaignCooldown": "Minimum hours between proactive messages", + "widget.campaignContactAttributesEmpty": "Visibility conditions use contact custom attributes. None are available.", + "widget.campaignCooldown": "Minimum time between proactive messages", "widget.campaignDeviceRequired": "Select at least one device. This proactive message cannot appear until desktop or mobile is enabled.", + "widget.campaignEventHint": "Show this message only after your website tracks this event. Leave this field blank to show it without an event.", "widget.campaignLimitReached": "You have reached the limit of 50 proactive messages. Delete one to add another.", "widget.campaignPriorityHint": "Messages are checked from top to bottom. The first matching message is shown.", "widget.campaignsHint": "Show a message on matching pages before the chat is opened. A conversation is created only when someone replies to it.", diff --git a/internal/inbox/channel/livechat/livechat.go b/internal/inbox/channel/livechat/livechat.go index 9d1e4975..e85f13ab 100644 --- a/internal/inbox/channel/livechat/livechat.go +++ b/internal/inbox/channel/livechat/livechat.go @@ -157,19 +157,19 @@ type PreChatFormConfig struct { // Config holds the live chat inbox configuration. type Config struct { - Campaigns []proactive.Campaign `json:"campaigns"` - CampaignCooldownHours int `json:"campaign_cooldown_hours"` - Help HelpConfig `json:"help"` - Previews PreviewConfig `json:"previews"` - BrandName string `json:"brand_name"` - WebsiteURL string `json:"website_url"` - Theme string `json:"theme"` - ShowPoweredBy bool `json:"show_powered_by"` - Language string `json:"language"` - FallbackLanguage string `json:"fallback_language"` - Users AudienceConfig `json:"users"` - Branding BrandingSet `json:"branding"` - Features struct { + Campaigns []proactive.Campaign `json:"campaigns"` + CampaignCooldown string `json:"campaign_cooldown"` + Help HelpConfig `json:"help"` + Previews PreviewConfig `json:"previews"` + BrandName string `json:"brand_name"` + WebsiteURL string `json:"website_url"` + Theme string `json:"theme"` + ShowPoweredBy bool `json:"show_powered_by"` + Language string `json:"language"` + FallbackLanguage string `json:"fallback_language"` + Users AudienceConfig `json:"users"` + Branding BrandingSet `json:"branding"` + Features struct { Emoji bool `json:"emoji"` FileUpload bool `json:"file_upload"` Transcript bool `json:"transcript"` diff --git a/internal/inbox/channel/livechat/proactive/eligibility_test.go b/internal/inbox/channel/livechat/proactive/eligibility_test.go index cd7b987a..53ce239b 100644 --- a/internal/inbox/channel/livechat/proactive/eligibility_test.go +++ b/internal/inbox/channel/livechat/proactive/eligibility_test.go @@ -36,33 +36,33 @@ func TestEligibilityAndSuppression(t *testing.T) { } } history := []Delivery{{CampaignID: "a", Displayed: true, CreatedAt: now.Add(-48 * time.Hour), SessionKey: "session"}} - if got := Suppression(campaign, ctx, history, 24); got != "repeat" { + if got := Suppression(campaign, ctx, history, 24*time.Hour); got != "repeat" { t.Fatalf("once: %q", got) } campaign.Repeat = "session" - if got := Suppression(campaign, ctx, history, 24); got != "repeat" { + if got := Suppression(campaign, ctx, history, 24*time.Hour); got != "repeat" { t.Fatalf("session: %q", got) } ctx.SessionKey = "new" - if got := Suppression(campaign, ctx, history, 24); got != "" { + if got := Suppression(campaign, ctx, history, 24*time.Hour); got != "" { t.Fatalf("new session: %q", got) } campaign.Repeat = "interval" campaign.RepeatHours = 72 - if got := Suppression(campaign, ctx, history, 24); got != "repeat" { + if got := Suppression(campaign, ctx, history, 24*time.Hour); got != "repeat" { t.Fatalf("interval: %q", got) } history[0].CampaignID = "other" history[0].CreatedAt = now.Add(-time.Hour) - if got := Suppression(campaign, ctx, history, 24); got != "cooldown" { + if got := Suppression(campaign, ctx, history, 90*time.Minute); got != "cooldown" { t.Fatalf("shared cooldown: %q", got) } history[0].Displayed = false - if got := Suppression(campaign, ctx, history, 24); got != "" { + if got := Suppression(campaign, ctx, history, 90*time.Minute); got != "" { t.Fatalf("expired reservation suppressed: %q", got) } history[0].CreatedAt = now.Add(-time.Second) - if got := Suppression(campaign, ctx, history, 24); got != "cooldown" { + if got := Suppression(campaign, ctx, history, 10*time.Minute); got != "cooldown" { t.Fatalf("concurrent reservation: %q", got) } } diff --git a/internal/inbox/channel/livechat/proactive/proactive.go b/internal/inbox/channel/livechat/proactive/proactive.go index abd227b8..b3d57458 100644 --- a/internal/inbox/channel/livechat/proactive/proactive.go +++ b/internal/inbox/channel/livechat/proactive/proactive.go @@ -112,7 +112,7 @@ func (m *Manager) error(err error) error { return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil) } -func Suppression(c Campaign, ctx Context, history []Delivery, cooldownHours int) string { +func Suppression(c Campaign, ctx Context, history []Delivery, cooldown time.Duration) string { for _, d := range history { if !d.Displayed && !d.Dismissed && !d.Replied && ctx.Now.Sub(d.CreatedAt) > time.Minute { continue @@ -123,7 +123,7 @@ func Suppression(c Campaign, ctx Context, history []Delivery, cooldownHours int) } continue } - if cooldownHours > 0 && ctx.Now.Sub(d.CreatedAt) < time.Duration(cooldownHours)*time.Hour { + if cooldown > 0 && ctx.Now.Sub(d.CreatedAt) < cooldown { return "cooldown" } } diff --git a/internal/inbox/channel/livechat/proactive/proactive_test.go b/internal/inbox/channel/livechat/proactive/proactive_test.go index 2b62d88d..4658e640 100644 --- a/internal/inbox/channel/livechat/proactive/proactive_test.go +++ b/internal/inbox/channel/livechat/proactive/proactive_test.go @@ -34,7 +34,7 @@ func TestConcurrentDeliveryAndOwnership(t *testing.T) { t.Error(err) return } - if Suppression(c, ctx, history, 24) != "" { + if Suppression(c, ctx, history, 24*time.Hour) != "" { return } delivery, err := m.Reserve(inboxID, c, ctx, Snapshot{Message: "Hello"}) @@ -83,7 +83,7 @@ func TestConcurrentDeliveryAndOwnership(t *testing.T) { if err != nil { t.Fatal(err) } - if Suppression(c, ctx, history, 24) != "repeat" { + if Suppression(c, ctx, history, 24*time.Hour) != "repeat" { t.Fatal("identified contact lost delivery history") } }