Merge pull request #731 from abhinavxd/fix/inbox-card-alignment

align inbox channel card headings
This commit is contained in:
Abhinav Raut
2026-10-22 12:19:04 +05:30
committed by GitHub
27 changed files with 438 additions and 197 deletions
+13 -11
View File
@@ -191,27 +191,29 @@ func campaignWithinHours(app *App, c proactive.Campaign, now time.Time, loc *tim
if err := json.Unmarshal(hours.Hours, &working); err != nil {
return false, campaignInputError(app)
}
previousDate := local.AddDate(0, 0, -1)
previous := working[previousDate.Weekday().String()]
for _, holiday := range holidays {
if holiday.Date == local.Format(time.DateOnly) {
return false, nil
}
if holiday.Date == previousDate.Format(time.DateOnly) {
previous = bhmodels.WorkingHours{}
}
}
day, ok := working[local.Weekday().String()]
if !ok {
return false, nil
}
day := working[local.Weekday().String()]
clock := local.Format("15:04")
return withinWorkingHours(clock, day), nil
return withinWorkingHours(clock, day, previous), nil
}
func withinWorkingHours(clock string, day bhmodels.WorkingHours) bool {
if day.Open == day.Close {
return false
func withinWorkingHours(clock string, day, previous bhmodels.WorkingHours) bool {
if day.Open < day.Close && clock >= day.Open && clock < day.Close {
return true
}
if day.Open < day.Close {
return clock >= day.Open && clock < day.Close
if day.Open > day.Close && clock >= day.Open {
return true
}
return clock >= day.Open || clock < day.Close
return previous.Open > previous.Close && clock < previous.Close
}
func campaignSender(app *App, id int) (umodels.User, error) {
+67 -6
View File
@@ -4,33 +4,94 @@ import (
"testing"
"time"
bh "github.com/abhinavxd/libredesk/internal/business_hours"
bhmodels "github.com/abhinavxd/libredesk/internal/business_hours/models"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat/proactive"
"github.com/abhinavxd/libredesk/internal/testutil"
"github.com/jmoiron/sqlx/types"
"github.com/volatiletech/null/v9"
)
func TestWithinWorkingHours(t *testing.T) {
tests := []struct {
name string
clock string
day bhmodels.WorkingHours
want bool
name string
clock string
day bhmodels.WorkingHours
previous bhmodels.WorkingHours
want bool
}{
{name: "normal open", clock: "09:00", day: bhmodels.WorkingHours{Open: "09:00", Close: "17:00"}, want: true},
{name: "normal close", clock: "17:00", day: bhmodels.WorkingHours{Open: "09:00", Close: "17:00"}},
{name: "overnight evening", clock: "23:00", day: bhmodels.WorkingHours{Open: "22:00", Close: "02:00"}, want: true},
{name: "overnight morning", clock: "01:00", day: bhmodels.WorkingHours{Open: "22:00", Close: "02:00"}, want: true},
{name: "overnight morning", clock: "01:00", previous: bhmodels.WorkingHours{Open: "22:00", Close: "02:00"}, want: true},
{name: "overnight midnight", clock: "00:00", previous: bhmodels.WorkingHours{Open: "22:00", Close: "02:00"}, want: true},
{name: "overnight close", clock: "02:00", previous: bhmodels.WorkingHours{Open: "22:00", Close: "02:00"}},
{name: "today overnight has not opened", clock: "01:00", day: bhmodels.WorkingHours{Open: "22:00", Close: "02:00"}},
{name: "previous daytime does not carry over", clock: "01:00", previous: bhmodels.WorkingHours{Open: "09:00", Close: "17:00"}},
{name: "today daytime and previous overnight", clock: "01:00", day: bhmodels.WorkingHours{Open: "09:00", Close: "17:00"}, previous: bhmodels.WorkingHours{Open: "22:00", Close: "02:00"}, want: true},
{name: "overnight closed", clock: "12:00", day: bhmodels.WorkingHours{Open: "22:00", Close: "02:00"}},
{name: "same time closed", clock: "09:00", day: bhmodels.WorkingHours{Open: "09:00", Close: "09:00"}},
{name: "no schedule", clock: "01:00"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := withinWorkingHours(tc.clock, tc.day); got != tc.want {
if got := withinWorkingHours(tc.clock, tc.day, tc.previous); got != tc.want {
t.Fatalf("got %v, want %v", got, tc.want)
}
})
}
}
func TestCampaignWithinHours(t *testing.T) {
db := testutil.NewDB(t, "widget_campaign_hours")
i18n := testutil.NewI18n(t)
manager, err := bh.New(bh.Opts{DB: db, I18n: i18n})
if err != nil {
t.Fatal(err)
}
hours, err := manager.Create("Overnight", null.String{} /* description */, false /* isAlwaysOpen */, types.JSONText(`{"Friday":{"open":"22:00","close":"02:00"},"Sunday":{"open":"22:00","close":"02:00"},"Monday":{"open":"09:00","close":"17:00"}}`), types.JSONText(`[{"date":"2026-10-31"},{"date":"2026-11-06"},{"date":"2026-11-08"}]`))
if err != nil {
t.Fatal(err)
}
app := &App{businessHours: manager, i18n: i18n}
campaign := proactive.Campaign{BusinessHours: "inside", BusinessHoursID: hours.ID}
loc, err := time.LoadLocation("Asia/Kolkata")
if err != nil {
t.Fatal(err)
}
tests := []struct {
name string
now string
want bool
}{
{name: "Friday opening", now: "2026-10-23T16:30:00Z", want: true},
{name: "Saturday early morning without Saturday hours", now: "2026-10-23T19:30:00Z", want: true},
{name: "Saturday closing", now: "2026-10-23T20:30:00Z"},
{name: "Friday early morning before opening", now: "2026-10-22T19:30:00Z"},
{name: "Sunday overnight carries into Monday", now: "2026-10-25T19:30:00Z", want: true},
{name: "holiday blocks previous overnight hours", now: "2026-10-30T19:30:00Z"},
{name: "holiday cancels evening shift", now: "2026-11-06T17:30:00Z"},
{name: "holiday shift cannot carry into midnight", now: "2026-11-06T18:30:00Z"},
{name: "holiday shift cannot carry into an unscheduled day", now: "2026-11-06T19:30:00Z"},
{name: "holiday shift cannot carry into a scheduled day", now: "2026-11-08T19:30:00Z"},
{name: "day after holiday opens at its own hours", now: "2026-11-09T03:30:00Z", want: true},
{name: "next week overnight shift remains open", now: "2026-11-13T19:30:00Z", want: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
now, err := time.Parse(time.RFC3339, tc.now)
if err != nil {
t.Fatal(err)
}
got, err := campaignWithinHours(app, campaign, now, loc)
if err != nil || got != tc.want {
t.Fatalf("got %v, %v, want %v", got, err, tc.want)
}
})
}
}
func TestParseCampaignCooldown(t *testing.T) {
tests := []struct {
value string
@@ -2,7 +2,7 @@
<Button
type="button"
variant="outline"
class="h-full w-full min-w-0 flex-col items-stretch whitespace-normal bg-card p-4 text-left font-normal text-card-foreground"
class="h-full w-full min-w-0 flex-col items-stretch justify-start whitespace-normal bg-card p-4 text-left font-normal text-card-foreground"
@click="handleClick">
<span class="mb-2 flex items-center">
<img v-if="typeof icon === 'string'" :src="icon" alt="" class="w-6 h-6 mr-2" />
@@ -1,5 +1,5 @@
<template>
<div class="sticky bottom-0 bg-background border-t border-border px-4 py-3 mt-auto">
<div class="sticky bottom-0 bg-card border-t border-border px-4 py-3 mt-auto">
<div class="flex flex-col sm:flex-row items-center justify-between gap-4">
<div class="flex items-center gap-3">
<span class="text-sm text-muted-foreground tabular-nums">
@@ -1,71 +1,71 @@
<template>
<table class="min-w-full table-fixed divide-y divide-border">
<thead class="bg-muted">
<tr>
<th
v-for="(header, index) in headers"
:key="index"
scope="col"
class="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider"
>
{{ header }}
</th>
<th v-if="showDelete" scope="col" class="relative px-6 py-3"></th>
</tr>
</thead>
<tbody class="bg-background divide-y divide-border">
<!-- Loading State -->
<template v-if="loading">
<tr v-for="i in skeletonRows" :key="`skeleton-${i}`" class="hover:bg-accent">
<td
v-for="(header, index) in headers"
:key="`skeleton-cell-${index}`"
class="px-6 py-3 text-sm font-medium text-foreground whitespace-normal break-words"
>
<Skeleton class="h-4 w-[85%]" />
</td>
<td v-if="showDelete" class="px-6 py-4 text-sm text-muted-foreground">
<Skeleton class="h-8 w-8 rounded-md" />
</td>
</tr>
</template>
<!-- No Results State -->
<template v-else-if="data.length === 0">
<div class="relative overflow-auto rounded-lg border border-border bg-card shadow-sm">
<table class="min-w-full table-fixed divide-y divide-border">
<thead class="bg-muted/40">
<tr>
<td :colspan="headers.length + (showDelete ? 1 : 0)" class="px-6 py-12 text-center">
<div class="flex flex-col items-center space-y-4">
<span class="text-md text-muted-foreground">
{{
$t('globals.messages.noResultsFound')
}}
</span>
</div>
</td>
</tr>
</template>
<!-- Data Rows -->
<template v-else>
<tr v-for="(item, index) in data" :key="index" class="hover:bg-accent">
<td
v-for="key in keys"
:key="key"
class="p-4 text-sm text-foreground whitespace-normal break-words"
<th
v-for="(header, index) in headers"
:key="index"
scope="col"
class="h-11 px-4 text-left text-sm font-medium text-muted-foreground"
>
<slot :name="`cell-${key}`" :value="item[key]" :item="item">
{{ item[key] }}
</slot>
</td>
<td v-if="showDelete" class="px-6 py-4 text-sm text-muted-foreground">
<Button size="xs" variant="ghost" @click.prevent="deleteItem(item)">
<Trash2 class="h-4 w-4" />
</Button>
</td>
{{ header }}
</th>
<th v-if="showDelete" scope="col" class="relative px-6 py-3"></th>
</tr>
</template>
</tbody>
</table>
</thead>
<tbody class="bg-card divide-y divide-border">
<!-- Loading State -->
<template v-if="loading">
<tr v-for="i in skeletonRows" :key="`skeleton-${i}`" class="hover:bg-accent">
<td
v-for="(header, index) in headers"
:key="`skeleton-cell-${index}`"
class="px-4 py-3 text-sm text-foreground whitespace-normal break-words"
>
<Skeleton class="h-4 w-[85%]" />
</td>
<td v-if="showDelete" class="px-6 py-4 text-sm text-muted-foreground">
<Skeleton class="h-8 w-8 rounded-md" />
</td>
</tr>
</template>
<!-- No Results State -->
<template v-else-if="data.length === 0">
<tr>
<td :colspan="headers.length + (showDelete ? 1 : 0)" class="px-6 py-12 text-center">
<div class="flex flex-col items-center space-y-4">
<span class="text-md text-muted-foreground">
{{ $t('globals.messages.noResultsFound') }}
</span>
</div>
</td>
</tr>
</template>
<!-- Data Rows -->
<template v-else>
<tr v-for="(item, index) in data" :key="index" class="hover:bg-accent">
<td
v-for="key in keys"
:key="key"
class="px-4 py-3 text-sm text-foreground whitespace-normal break-words"
>
<slot :name="`cell-${key}`" :value="item[key]" :item="item">
{{ item[key] }}
</slot>
</td>
<td v-if="showDelete" class="px-6 py-4 text-sm text-muted-foreground">
<Button type="button" size="xs" variant="ghost" @click.prevent="deleteItem(item)">
<Trash2 class="h-4 w-4" />
</Button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
<script setup>
@@ -172,7 +172,7 @@
</FormField>
</FormGrid>
</fieldset>
<div class="sticky bottom-0 z-10 flex border-t border-border bg-card py-3">
<div class="flex border-t border-border pt-4">
<Button type="submit" :isLoading="formLoading">{{ submitLabel }}</Button>
</div>
</form>
@@ -780,7 +780,7 @@
</AccordionItem>
</Accordion>
<div class="sticky bottom-0 z-10 !mt-0 border-t bg-background py-4">
<div class="border-t border-border pt-4">
<Button type="submit" :is-loading="isLoading" :disabled="isLoading">
{{ submitLabel }}
</Button>
@@ -1729,13 +1729,19 @@ const jsApiSnippet = computed(() => {
return `window.Libredesk.show();
window.Libredesk.hide();
window.Libredesk.toggle();
window.Libredesk.isVisible();
window.Libredesk.setLanguage('en-US');
window.Libredesk.trackEvent('checkout_started');
window.Libredesk.setUser('SIGNED_JWT_TOKEN');
window.Libredesk.logout();
window.Libredesk.onShow(function() {});
window.Libredesk.onHide(function() {});
window.Libredesk.onUnreadCountChange(function(count) {});`
window.Libredesk.onUnreadCountChange(function(count) {});
window.Libredesk.destroy();`
})
function defaultBranding(theme) {
@@ -63,7 +63,7 @@
</AlertDialogContent>
</AlertDialog>
<div class="rounded-t-xl bg-background text-foreground">
<div class="text-foreground">
<!-- Fullscreen editor -->
<Dialog :open="isEditorFullscreen" @update:open="isEditorFullscreen = false">
<DialogContent
@@ -142,7 +142,7 @@
<!-- Main Editor non-fullscreen -->
<div
class="bg-background text-card-foreground box m-2 px-2 pt-2 flex flex-col relative"
class="bg-card text-card-foreground box m-2 px-2 pt-2 flex flex-col relative"
:class="{ '!bg-private': messageType === 'private_note', 'ai-generating': isGenerating }"
v-if="!isCollapsed && !isEditorFullscreen"
>
@@ -787,14 +787,4 @@ watch(
},
{ deep: true, immediate: true }
)
// Media files and macro state are restored per draft by the draft manager; resetting here would race ahead of the save and drop them.
watch(
() => conversationStore.current?.uuid,
() => {
setTimeout(() => {
activeContentRef()?.focus()
}, 100)
}
)
</script>
@@ -165,7 +165,7 @@
:message-type="messageType"
:placeholder="isCramped ? t('globals.terms.typeMessage') : t('editor.hint.full')"
:insertContent="insertContent"
:autoFocus="true"
:autoFocus="false"
:disabled="isDraftLoading"
:enableMentions="messageType === 'private_note'"
:enableConversationReferences="messageType === 'private_note'"
@@ -502,14 +502,20 @@ watch(
} else if (newType === 'private_note') {
macroStore.setCurrentView('adding_private_note')
}
// Focus editor on tab change
setTimeout(() => {
editorRef.value?.focus()
}, 50)
},
{ immediate: true }
)
watch(
[messageType, () => props.isDraftLoading, () => conversationStore.current?.uuid],
async () => {
await nextTick()
if (props.isDraftLoading || document.activeElement?.matches('input, textarea')) return
editorRef.value?.focus()
},
{ immediate: true, flush: 'post' }
)
// Expose focus method for parent components
const focus = () => {
editorRef.value?.focus()
@@ -1,5 +1,5 @@
<template>
<div class="sticky bottom-0 mt-auto bg-background pb-4 pt-3">
<div class="sticky bottom-0 mt-auto bg-card pb-4 pt-3">
<div
class="flex items-center justify-between gap-4 rounded-md border border-border px-4 py-3 shadow-sm"
>
@@ -11,18 +11,14 @@
<div class="flex items-center justify-end gap-2">
<div class="flex items-center gap-2">
<Tabs v-model="previewUserType">
<AdminToolbar>
<template #left>
<TabsList class="w-max justify-start h-8 p-0.5">
<TabsTrigger value="visitors" class="text-xs">
{{ $t('admin.inbox.livechat.userSettings.visitors') }}
</TabsTrigger>
<TabsTrigger value="users" class="text-xs">
{{ $t('globals.terms.users') }}
</TabsTrigger>
</TabsList>
</template>
</AdminToolbar>
<TabsList class="w-max justify-start h-8 p-0.5">
<TabsTrigger value="visitors" class="text-xs">
{{ $t('admin.inbox.livechat.userSettings.visitors') }}
</TabsTrigger>
<TabsTrigger value="users" class="text-xs">
{{ $t('globals.terms.users') }}
</TabsTrigger>
</TabsList>
</Tabs>
<PreviewThemeToggle v-model="previewTheme" />
</div>
@@ -84,7 +80,6 @@
</template>
<script setup>
import AdminToolbar from '@main/layouts/admin/AdminToolbar.vue'
import { ref, provide } from 'vue'
import AdminSplitLayout from '@/layouts/admin/AdminSplitLayout.vue'
import LivechatWidgetPreview from '@/features/admin/inbox/LivechatWidgetPreview.vue'
+1 -1
View File
@@ -214,7 +214,7 @@ const setupParentMessageListeners = () => {
widgetStore.navigateToHome()
signalWidgetLoaded()
} else if (event.data.type === 'CAMPAIGN_CONTEXT') {
await proactive.next(event.data.context)
await proactive.next(event.data.context, event.data.pageGeneration)
} else if (event.data.type === 'CAMPAIGN_EVENT') {
await proactive.event(event.data.event, event.data.id)
} else if (event.data.type === 'PAGE_VISIT') {
+2 -2
View File
@@ -31,7 +31,7 @@ export const useProactiveStore = defineStore('proactive', () => {
const abandon = () => {
pending.value = null
}
const next = async (context) => {
const next = async (context, pageGeneration) => {
if (!browserKey || busy || invitation.value || pending.value || !widget.config.has_campaigns || widget.isOpen || chat.getConversations.some(item => item.unread_message_count > 0)) return
busy = true
const request = generation
@@ -40,7 +40,7 @@ export const useProactiveStore = defineStore('proactive', () => {
if (request !== generation || !response.data.data || widget.isOpen) return
const delivery = response.data.data
invitation.value = delivery
postToParent({ type: 'SHOW_CAMPAIGN', delivery })
postToParent({ type: 'SHOW_CAMPAIGN', delivery, url: context.url, pageGeneration })
} catch { return false }
finally { if (request === generation) busy = false }
}
@@ -0,0 +1,38 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import api from '@widget/api/index.js'
import { useProactiveStore } from './proactive.js'
vi.mock('@widget/api/index.js', () => ({ default: { nextCampaign: vi.fn(), campaignEvent: vi.fn() } }))
vi.mock('@widget/store/widget.js', () => ({ useWidgetStore: () => ({ config: { has_campaigns: true }, isOpen: false }) }))
vi.mock('@widget/store/chat.js', () => ({ useChatStore: () => ({ getConversations: [] }) }))
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
window.history.replaceState({}, '', '/?parent_origin=https://example.com')
})
describe('campaign delivery', () => {
test('sends the requested page URL with a delayed response', async () => {
const store = useProactiveStore()
store.setBrowserKey('00000000-0000-4000-8000-000000000001')
store.setSessionKey('00000000-0000-4000-8000-000000000002')
let resolve
api.nextCampaign.mockImplementationOnce(() => new Promise(done => { resolve = done }))
const post = vi.spyOn(window.parent, 'postMessage').mockImplementation(() => {})
const delivery = { id: 'delivery' }
const request = store.next({ url: 'https://example.com/pricing' }, 7 /* pageGeneration */)
resolve({ data: { data: delivery } })
await request
expect(post).toHaveBeenCalledWith({
type: 'SHOW_CAMPAIGN', delivery, url: 'https://example.com/pricing', pageGeneration: 7
}, 'https://example.com')
await store.event('dropped', delivery.id)
expect(store.invitation).toBeNull()
expect(api.campaignEvent).not.toHaveBeenCalled()
})
})
+109
View File
@@ -0,0 +1,109 @@
// @vitest-environment jsdom
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
const source = readFileSync(resolve(import.meta.dirname, '../../../../static/widget.js'), 'utf8')
let widget
beforeEach(() => {
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval', 'setTimeout', 'clearTimeout', 'performance'] })
window.history.replaceState({}, '', '/pricing')
delete window.__libredeskWidgetLoaded
window.eval(source)
vi.spyOn(window.Libredesk.prototype, 'init').mockImplementation(() => {})
widget = new window.Libredesk({ baseURL: window.location.origin, inboxID: 'test' })
vi.spyOn(widget, 'renderPreviews').mockImplementation(() => { widget.previewHost = {} })
vi.spyOn(widget, 'postToIframe').mockImplementation(() => {})
Object.defineProperty(document, 'hidden', { configurable: true, value: false })
})
afterEach(() => {
widget.stopPageTracking()
vi.restoreAllMocks()
vi.useRealTimers()
})
describe('campaign navigation', () => {
const deliver = (url, pageGeneration = widget.campaignPageGeneration) => {
const contentWindow = {}
widget.iframe = { contentWindow }
widget.handleMessage({
source: contentWindow,
origin: window.location.origin,
data: { type: 'SHOW_CAMPAIGN', delivery: { id: 'pricing-message' }, url, pageGeneration }
})
}
test('drops a delayed response after navigation before the next timer tick', () => {
const requestedURL = window.location.href
window.history.pushState({}, '', '/checkout')
deliver(requestedURL)
expect(widget.campaignData).toBeNull()
expect(widget.renderPreviews).not.toHaveBeenCalled()
expect(widget.postToIframe).toHaveBeenCalledWith({ type: 'CAMPAIGN_EVENT', event: 'dropped', id: 'pricing-message' })
})
test('shows responses for the current page', () => {
deliver(window.location.href)
expect(widget.campaignData).toEqual({ id: 'pricing-message' })
expect(widget.postToIframe).toHaveBeenCalledWith({ type: 'CAMPAIGN_EVENT', event: 'displayed', id: 'pricing-message' })
})
test.each(['pushState', 'replaceState'])('rejects earlier visits after returning to the same URL with %s', method => {
widget.startPageTracking()
const requestedURL = window.location.href
const requestedGeneration = widget.campaignPageGeneration
for (let visit = 0; visit < 2; visit++) {
window.history[method]({}, '', '/checkout')
window.history[method]({}, '', '/pricing')
}
widget.renderPreviews.mockClear()
deliver(requestedURL, requestedGeneration)
expect(widget.campaignData).toBeNull()
expect(widget.renderPreviews).not.toHaveBeenCalled()
expect(widget.postToIframe).toHaveBeenCalledWith({ type: 'CAMPAIGN_EVENT', event: 'dropped', id: 'pricing-message' })
deliver(window.location.href)
expect(widget.campaignData).toEqual({ id: 'pricing-message' })
expect(widget.postToIframe).toHaveBeenCalledWith({ type: 'CAMPAIGN_EVENT', event: 'displayed', id: 'pricing-message' })
})
test.each([false, true])('preserves an immediate event with page tracking enabled: %s', pageTracking => {
if (pageTracking) widget.startPageTracking()
widget.campaignDelay = 3
widget.startCampaignTracking()
widget.trackEvent('old-page')
vi.advanceTimersByTime(60000)
widget.postToIframe.mockClear()
window.history.pushState({}, '', '/checkout')
widget.trackEvent('checkout-viewed')
vi.advanceTimersByTime(3000)
expect(widget.postToIframe).toHaveBeenCalledWith({ type: 'CAMPAIGN_CONTEXT', pageGeneration: widget.campaignPageGeneration, context: {
url: window.location.href, mobile: widget.isMobile, active_seconds: 3, event: 'checkout-viewed'
} })
})
test('clears old page events and visible messages on navigation', () => {
widget.startPageTracking()
widget.startCampaignTracking()
widget.trackEvent('pricing-viewed')
widget.campaignData = { id: 'pricing-message' }
window.history.pushState({}, '', '/checkout')
vi.advanceTimersByTime(2000)
expect(widget.campaignData).toBeNull()
expect(widget.postToIframe).toHaveBeenCalledWith({ type: 'CAMPAIGN_CONTEXT', pageGeneration: widget.campaignPageGeneration, context: {
url: window.location.href, mobile: widget.isMobile, active_seconds: 2, event: ''
} })
})
})
@@ -351,7 +351,11 @@ describe('Conversation lifecycle', () => {
for (const field of ['CC', 'BCC']) {
it(`keeps ${field} open and focused while replacing an address`, () => {
cy.intercept('GET', '**/api/v1/drafts', (req) => {
req.continue((res) => res.setDelay(1000))
}).as('loadDrafts')
openConversation(ccConversationUuid)
cy.wait('@loadDrafts')
cy.get('.tiptap.ProseMirror[contenteditable="true"]').should('be.focused')
if (field === 'BCC') {
cy.contains('button', /^BCC$/).click()
+2 -2
View File
@@ -431,7 +431,7 @@ describe('Email inbox form', () => {
cy.get('@updateInbox.all').should('have.length', 0)
})
it('fits a narrow viewport and keeps the save action visible', () => {
it('fits a narrow viewport and keeps the save action reachable', () => {
cy.viewport(390, 844)
cy.visit(editPath)
field('aliases[0].email').should('be.visible')
@@ -446,7 +446,7 @@ describe('Email inbox form', () => {
cy.get('form').should(($form) => {
expect($form[0].scrollWidth).to.be.at.most($form[0].clientWidth)
})
cy.get('form button[type="submit"]').should('be.visible')
cy.get('form button[type="submit"]').scrollIntoView().should('be.visible')
cy.screenshot('email-inbox-mobile-connection', { capture: 'viewport' })
})
@@ -8,7 +8,7 @@
variant="outline"
role="combobox"
:aria-expanded="open"
:class="['w-full justify-between px-3', buttonClass]"
:class="['w-full justify-between bg-transparent px-3', buttonClass]"
>
<span class="flex min-w-0 flex-1 items-center text-left">
<slot name="selected" :selected="selectedItem">
@@ -5,7 +5,7 @@
type="button"
variant="outline"
role="combobox"
class="w-full justify-between font-normal"
class="w-full justify-between bg-transparent font-normal"
:aria-expanded="open"
:disabled="disabled"
>
@@ -1,7 +1,7 @@
<script setup>
import { computed } from 'vue'
import { TagsInputRoot, useForwardPropsEmits } from 'radix-vue'
import { cn } from '../../../lib/utils'
import { cn } from '@shared-ui/lib/utils'
const props = defineProps({
modelValue: { type: Array, required: false },
@@ -39,7 +39,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
v-bind="forwarded"
:class="
cn(
'flex flex-wrap gap-2 items-center rounded-md border border-input bg-background px-3 py-1.5 text-sm',
'flex flex-wrap gap-2 items-center rounded-md border border-input bg-transparent px-3 py-1.5 text-sm',
props.class
)
"
+1 -1
View File
@@ -128,7 +128,7 @@ func TestAutomationNotifyUsesSystemLayout(t *testing.T) {
}
for _, want := range []string{
htmltemplate.HTMLEscapeString(message),
"<html>",
"<!doctype html>",
"</html>",
"support.example.com/images/logo.png",
"Powered by",
+1 -1
View File
@@ -265,7 +265,7 @@ func TestRenderNotificationEmailUsesSystemLayout(t *testing.T) {
if strings.Contains(got, "<script>") || !strings.Contains(got, "{{ .Author.FirstName }}") {
t.Fatal("message content was interpreted as HTML or template code")
}
if !strings.Contains(got, "<html>") || !strings.Contains(got, "</html>") || !strings.Contains(got, "support.example.com/images/logo.png") {
if !strings.Contains(got, "<!doctype html>") || !strings.Contains(got, "</html>") || !strings.Contains(got, "support.example.com/images/logo.png") {
t.Fatal("notification lost the system layout")
}
})
+65 -42
View File
@@ -1,6 +1,6 @@
{{ define "header" }}
<!doctype html>
<html>
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
@@ -10,7 +10,7 @@
body {
background-color: #f4f4f5;
font-family: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
font-size: 15px;
font-size: 14px;
line-height: 1.6;
margin: 0;
color: #09090b;
@@ -20,24 +20,32 @@
.wrap {
background-color: #ffffff;
padding: 40px;
max-width: 560px;
margin: 0 auto;
padding: 28px;
border-radius: 8px;
border: 1px solid #e4e4e7;
}
.header {
padding-bottom: 24px;
margin-bottom: 28px;
padding-bottom: 16px;
margin-bottom: 20px;
border-bottom: 1px solid #e4e4e7;
line-height: 1;
}
.content {
overflow-wrap: anywhere;
word-wrap: break-word;
}
.content > :last-child {
margin-bottom: 0;
}
.footer {
text-align: center;
font-size: 12px;
color: #71717a;
padding: 24px 0 8px;
padding: 16px 0 0;
}
.footer a {
@@ -50,33 +58,29 @@
text-decoration: none;
}
.gutter {
padding: 30px;
}
.button {
background-color: #18181b;
color: #fafafa !important;
display: inline-block;
border-radius: 6px;
padding: 12px 32px;
padding: 10px 20px;
text-align: center;
text-decoration: none;
font-weight: 500;
font-size: 14px;
letter-spacing: 0.01em;
line-height: 20px;
}
h1 {
font-size: 22px;
font-weight: 700;
font-size: 20px;
font-weight: 600;
color: #09090b;
margin: 0 0 8px;
line-height: 1.3;
}
p {
margin: 0 0 16px;
margin: 0 0 14px;
color: #09090b;
}
@@ -90,6 +94,16 @@
height: auto;
}
blockquote {
margin-left: 0;
margin-right: 0;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
}
a {
color: #0066cc;
text-decoration: none;
@@ -100,42 +114,51 @@
}
@media screen and (max-width: 600px) {
.wrap {
max-width: 100%;
border-radius: 0;
border-left: none;
border-right: none;
}
.gutter {
padding: 16px;
.email-shell {
padding: 16px 12px !important;
}
.wrap {
padding: 28px 20px;
padding: 24px 20px !important;
}
.button {
padding: 12px 20px !important;
}
}
</style>
</head>
<body style="background-color: #f4f4f5;">
<div class="gutter">&nbsp;</div>
<div class="wrap">
<div class="header">
{{ if ne LogoURL "" }}
<img src="{{ LogoURL }}" alt="{{ SiteName }}" style="max-height: 25px; width: auto;">
{{ else }}
<img src="{{ RootURL }}/images/logo.png" alt="{{ SiteName }}" style="max-height: 25px; width: auto;">
{{ end }}
</div>
<body style="margin: 0; background-color: #f4f4f5; font-family: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.6; color: #09090b;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="width: 100%; background-color: #f4f4f5;">
<tr>
<td class="email-shell" align="center" style="padding: 32px 16px;">
<table role="presentation" width="560" cellpadding="0" cellspacing="0" style="width: 100%; max-width: 560px; table-layout: fixed;">
<tr>
<td class="wrap" style="padding: 28px; background-color: #ffffff; border: 1px solid #e4e4e7; border-radius: 8px; text-align: left;">
<div class="header" style="padding-bottom: 16px; margin-bottom: 20px; border-bottom: 1px solid #e4e4e7; line-height: 1;">
{{ if ne LogoURL "" }}
<img src="{{ LogoURL }}" alt="{{ SiteName }}" height="24" style="display: block; height: 24px; width: auto; max-width: 100%; border: 0;">
{{ else }}
<img src="{{ RootURL }}/images/logo.png" alt="{{ SiteName }}" height="24" style="display: block; height: 24px; width: auto; max-width: 100%; border: 0;">
{{ end }}
</div>
<div class="content" style="overflow-wrap: anywhere; word-wrap: break-word;">
{{ end }}
{{ define "footer" }}
</div>
<div class="footer">
<span>Powered by <a href="https://libredesk.io/" target="_blank">libredesk</a></span>
</div>
<div class="gutter">&nbsp;</div>
</div>
</td>
</tr>
<tr>
<td class="footer" align="center" style="padding-top: 16px; text-align: center; font-size: 12px; line-height: 18px; color: #71717a;">
<span>Powered by <a href="https://libredesk.io/" target="_blank" style="color: #71717a; text-decoration: none;">libredesk</a></span>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+3 -3
View File
@@ -5,10 +5,10 @@
<p>We received a request to reset your password. Click the button below to create a new one:</p>
<div style="text-align: center; margin: 28px 0;">
<div style="margin: 24px 0;">
<a href="{{ RootURL }}/set-password?token={{ .ResetToken }}" class="button"
style="background-color: #18181b; color: #fafafa; display: inline-block; border-radius: 6px; padding: 12px 32px; text-decoration: none; font-weight: 500; font-size: 14px;">
Reset Password
style="background-color: #18181b; color: #fafafa; display: inline-block; border-radius: 6px; padding: 10px 20px; line-height: 20px; text-decoration: none; font-weight: 500; font-size: 14px;">
Reset password
</a>
</div>
+3 -3
View File
@@ -13,10 +13,10 @@
<p>Click the button below to set your password and get started:</p>
<div style="text-align: center; margin: 28px 0;">
<div style="margin: 24px 0;">
<a href="{{ RootURL }}/set-password?token={{ .ResetToken }}" class="button"
style="background-color: #18181b; color: #fafafa; display: inline-block; border-radius: 6px; padding: 12px 32px; text-decoration: none; font-weight: 500; font-size: 14px;">
Set Your Password
style="background-color: #18181b; color: #fafafa; display: inline-block; border-radius: 6px; padding: 10px 20px; line-height: 20px; text-decoration: none; font-weight: 500; font-size: 14px;">
Set your password
</a>
</div>
+28 -21
View File
@@ -56,8 +56,12 @@
this.campaignData = null;
this.campaignActiveSeconds = 0;
this.campaignURL = location.href;
this.campaignPageGeneration = 0;
this.campaignEvent = "";
this.campaignDelay = 0;
this.campaignElapsed = 0;
this.campaignAsked = false;
this.campaignGap = this.CAMPAIGN_POLL_MIN;
this.campaignBrowserKey = this.getCookie(this.getCookieName('campaign')) || this.randomKey();
this.setCookie(this.getCookieName('campaign'), this.campaignBrowserKey);
this.campaignSessionKey = this.getCookie(this.getCookieName('campaign-session')) || this.randomKey();
@@ -490,7 +494,7 @@
switch (event.data.type) {
case 'SHOW_CAMPAIGN':
if (this.isChatVisible || this.unreadCount > 0 || this.hideLauncher) {
if (event.data.url !== location.href || event.data.pageGeneration !== this.campaignPageGeneration || this.isChatVisible || this.unreadCount > 0 || this.hideLauncher) {
this.dropCampaign(event.data.delivery.id);
break;
}
@@ -757,6 +761,7 @@
const url = window.location.href;
if (url === self._lastPageURL) return;
self._lastPageURL = url;
self.resetCampaignPage();
// Defer to let SPA frameworks update document.title after route change.
setTimeout(() => { self.sendPageInfo(); }, 100);
};
@@ -918,39 +923,41 @@
this.previewHost = host;
}
resetCampaignPage () {
if (location.href === this.campaignURL) return;
this.campaignURL = location.href;
this.campaignPageGeneration++;
this.campaignActiveSeconds = 0;
this.campaignEvent = '';
this.campaignElapsed = 0;
this.campaignAsked = false;
this.campaignGap = Math.max(this.CAMPAIGN_POLL_MIN, this.campaignDelay);
if (this.campaignData) this.dropCampaign(this.campaignData.id);
this.renderPreviews();
}
startCampaignTracking () {
if (this.campaignInterval) return;
let last = performance.now();
let elapsed = 0;
let asked = false;
const firstGap = () => Math.max(this.CAMPAIGN_POLL_MIN, this.campaignDelay);
let gap = firstGap();
this.campaignGap = Math.max(this.CAMPAIGN_POLL_MIN, this.campaignDelay);
this.campaignInterval = setInterval(() => {
const now = performance.now();
const delta = Math.min((now - last) / 1000, 2);
last = now;
if (location.href !== this.campaignURL) {
this.campaignURL = location.href;
this.campaignActiveSeconds = 0;
this.campaignEvent = '';
elapsed = 0;
asked = false;
gap = firstGap();
if (this.campaignData) this.dropCampaign(this.campaignData.id);
this.renderPreviews();
}
this.resetCampaignPage();
if (document.hidden || this.isChatVisible || this.hideLauncher) return;
this.campaignActiveSeconds += delta;
elapsed += delta;
if (elapsed < gap || this.unreadCount > 0 || this.campaignData) return;
elapsed = 0;
gap = asked ? Math.min(gap * 2, this.CAMPAIGN_POLL_MAX) : this.CAMPAIGN_POLL_MIN;
asked = true;
this.postToIframe({ type: 'CAMPAIGN_CONTEXT', context: { url: location.href, mobile: this.isMobile, active_seconds: Math.floor(this.campaignActiveSeconds), event: this.campaignEvent } });
this.campaignElapsed += delta;
if (this.campaignElapsed < this.campaignGap || this.unreadCount > 0 || this.campaignData) return;
this.campaignElapsed = 0;
this.campaignGap = this.campaignAsked ? Math.min(this.campaignGap * 2, this.CAMPAIGN_POLL_MAX) : this.CAMPAIGN_POLL_MIN;
this.campaignAsked = true;
this.postToIframe({ type: 'CAMPAIGN_CONTEXT', pageGeneration: this.campaignPageGeneration, context: { url: location.href, mobile: this.isMobile, active_seconds: Math.floor(this.campaignActiveSeconds), event: this.campaignEvent } });
}, 1000);
}
trackEvent (name) {
this.resetCampaignPage();
if (typeof name === 'string' && name.length <= 128) this.campaignEvent = name;
}