Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
676493eff2 | ||
|
|
b0d8eb3a2b | ||
|
|
655db56be9 | ||
|
|
7f56cd92f8 | ||
|
|
c07529c2b0 | ||
|
|
d48503bdcf | ||
|
|
d35b0a9465 | ||
|
|
83873d49ae | ||
|
|
6ca38e10e9 | ||
|
|
1a1dfd09cb | ||
|
|
35d0a7f1a7 | ||
|
|
47867c0b8a | ||
|
|
530125d4c5 | ||
|
|
7679004bb4 | ||
|
|
ae3ac33049 | ||
|
|
714f24de11 | ||
|
|
3038e672f8 | ||
|
|
7aad3c9c6b | ||
|
|
c6113852d7 | ||
|
|
94e930a141 |
@@ -89,7 +89,7 @@ gem 'wisper', '2.0.0'
|
||||
##--- gems for channels ---##
|
||||
gem 'facebook-messenger'
|
||||
gem 'line-bot-api'
|
||||
gem 'twilio-ruby', '~> 5.66'
|
||||
gem 'twilio-ruby'
|
||||
# twitty will handle subscription of twitter account events
|
||||
# gem 'twitty', git: 'https://github.com/chatwoot/twitty'
|
||||
gem 'twitty', '~> 0.1.5'
|
||||
@@ -212,6 +212,8 @@ group :development do
|
||||
gem 'stackprof'
|
||||
# Should install the associated chrome extension to view query logs
|
||||
gem 'meta_request', '>= 0.8.3'
|
||||
|
||||
gem 'tidewave'
|
||||
end
|
||||
|
||||
group :test do
|
||||
|
||||
+60
-17
@@ -230,6 +230,35 @@ GEM
|
||||
addressable (~> 2.8)
|
||||
drb (2.2.3)
|
||||
dry-cli (1.1.0)
|
||||
dry-configurable (1.3.0)
|
||||
dry-core (~> 1.1)
|
||||
zeitwerk (~> 2.6)
|
||||
dry-core (1.1.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
logger
|
||||
zeitwerk (~> 2.6)
|
||||
dry-inflector (1.2.0)
|
||||
dry-initializer (3.2.0)
|
||||
dry-logic (1.6.0)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0)
|
||||
dry-core (~> 1.1)
|
||||
zeitwerk (~> 2.6)
|
||||
dry-schema (1.14.1)
|
||||
concurrent-ruby (~> 1.0)
|
||||
dry-configurable (~> 1.0, >= 1.0.1)
|
||||
dry-core (~> 1.1)
|
||||
dry-initializer (~> 3.2)
|
||||
dry-logic (~> 1.5)
|
||||
dry-types (~> 1.8)
|
||||
zeitwerk (~> 2.6)
|
||||
dry-types (1.8.3)
|
||||
bigdecimal (~> 3.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
dry-core (~> 1.0)
|
||||
dry-inflector (~> 1.0)
|
||||
dry-logic (~> 1.4)
|
||||
zeitwerk (~> 2.6)
|
||||
ecma-re-validator (0.4.0)
|
||||
regexp_parser (~> 2.2)
|
||||
elastic-apm (4.6.2)
|
||||
@@ -252,8 +281,10 @@ GEM
|
||||
railties (>= 5.0.0)
|
||||
faker (3.2.0)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.9.0)
|
||||
faraday-net_http (>= 2.0, < 3.2)
|
||||
faraday (2.13.1)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
faraday-follow_redirects (0.3.0)
|
||||
faraday (>= 1, < 3)
|
||||
faraday-mashify (0.1.1)
|
||||
@@ -261,13 +292,20 @@ GEM
|
||||
hashie
|
||||
faraday-multipart (1.0.4)
|
||||
multipart-post (~> 2)
|
||||
faraday-net_http (3.1.0)
|
||||
net-http
|
||||
faraday-net_http (3.4.0)
|
||||
net-http (>= 0.5.0)
|
||||
faraday-net_http_persistent (2.1.0)
|
||||
faraday (~> 2.5)
|
||||
net-http-persistent (~> 4.0)
|
||||
faraday-retry (2.2.1)
|
||||
faraday (~> 2.0)
|
||||
fast-mcp (1.5.0)
|
||||
addressable (~> 2.8)
|
||||
base64
|
||||
dry-schema (~> 1.14)
|
||||
json (~> 2.0)
|
||||
mime-types (~> 3.4)
|
||||
rack (~> 3.1)
|
||||
fcm (1.0.8)
|
||||
faraday (>= 1.0.0, < 3.0)
|
||||
googleauth (~> 1)
|
||||
@@ -421,7 +459,7 @@ GEM
|
||||
judoscale-sidekiq (1.8.2)
|
||||
judoscale-ruby (= 1.8.2)
|
||||
sidekiq (>= 5.0)
|
||||
jwt (2.8.1)
|
||||
jwt (2.10.1)
|
||||
base64
|
||||
kaminari (1.2.2)
|
||||
activesupport (>= 4.1.0)
|
||||
@@ -503,7 +541,7 @@ GEM
|
||||
mutex_m (0.3.0)
|
||||
neighbor (0.2.3)
|
||||
activerecord (>= 5.2)
|
||||
net-http (0.4.1)
|
||||
net-http (0.6.0)
|
||||
uri
|
||||
net-http-persistent (4.0.2)
|
||||
connection_pool (~> 2.2)
|
||||
@@ -591,7 +629,7 @@ GEM
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (2.2.15)
|
||||
rack (3.2.0)
|
||||
rack-attack (6.7.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-contrib (2.5.0)
|
||||
@@ -600,19 +638,20 @@ GEM
|
||||
rack (>= 2.0.0)
|
||||
rack-mini-profiler (3.2.0)
|
||||
rack (>= 1.2.0)
|
||||
rack-protection (3.2.0)
|
||||
rack-protection (4.1.1)
|
||||
base64 (>= 0.1.0)
|
||||
rack (~> 2.2, >= 2.2.4)
|
||||
logger (>= 1.6.0)
|
||||
rack (>= 3.0.0, < 4)
|
||||
rack-proxy (0.7.7)
|
||||
rack
|
||||
rack-session (1.0.2)
|
||||
rack (< 3)
|
||||
rack-session (2.1.1)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.1.0)
|
||||
rack (>= 1.3)
|
||||
rack-timeout (0.6.3)
|
||||
rackup (1.0.1)
|
||||
rack (< 3)
|
||||
webrick
|
||||
rackup (2.2.1)
|
||||
rack (>= 3)
|
||||
rails (7.1.5.2)
|
||||
actioncable (= 7.1.5.2)
|
||||
actionmailbox (= 7.1.5.2)
|
||||
@@ -843,13 +882,17 @@ GEM
|
||||
telephone_number (1.4.20)
|
||||
test-prof (1.2.1)
|
||||
thor (1.4.0)
|
||||
tidewave (0.2.0)
|
||||
fast-mcp (~> 1.5.0)
|
||||
rack (>= 2.0)
|
||||
rails (>= 7.1.0)
|
||||
tilt (2.3.0)
|
||||
time_diff (0.3.0)
|
||||
activesupport
|
||||
i18n
|
||||
timeout (0.4.3)
|
||||
trailblazer-option (0.1.2)
|
||||
twilio-ruby (5.77.0)
|
||||
twilio-ruby (7.6.0)
|
||||
faraday (>= 0.9, < 3.0)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
nokogiri (>= 1.6, < 2.0)
|
||||
@@ -896,7 +939,6 @@ GEM
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
webrick (1.9.1)
|
||||
websocket-driver (0.7.7)
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
@@ -1040,8 +1082,9 @@ DEPENDENCIES
|
||||
stripe
|
||||
telephone_number
|
||||
test-prof
|
||||
tidewave
|
||||
time_diff
|
||||
twilio-ruby (~> 5.66)
|
||||
twilio-ruby
|
||||
twitty (~> 0.1.5)
|
||||
tzinfo-data
|
||||
uglifier
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.4.2
|
||||
3.4.3
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
|
||||
before_action :validate_feature_enabled!
|
||||
before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? }
|
||||
|
||||
# POST /api/v1/accounts/:account_id/whatsapp/authorization
|
||||
@@ -65,15 +64,6 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def validate_feature_enabled!
|
||||
return if Current.account.feature_whatsapp_embedded_signup?
|
||||
|
||||
render json: {
|
||||
success: false,
|
||||
error: 'WhatsApp embedded signup is not enabled for this account'
|
||||
}, status: :forbidden
|
||||
end
|
||||
|
||||
def validate_embedded_signup_params!
|
||||
missing_params = []
|
||||
missing_params << 'code' if params[:code].blank?
|
||||
|
||||
@@ -37,7 +37,6 @@ export const FEATURE_FLAGS = {
|
||||
REPORT_V4: 'report_v4',
|
||||
CHANNEL_INSTAGRAM: 'channel_instagram',
|
||||
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
||||
WHATSAPP_EMBEDDED_SIGNUP: 'whatsapp_embedded_signup',
|
||||
CAPTAIN_V2: 'captain_integration_v2',
|
||||
};
|
||||
|
||||
|
||||
@@ -272,8 +272,8 @@
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create WhatsApp Channel",
|
||||
"EMBEDDED_SIGNUP": {
|
||||
"TITLE": "Quick Setup with Meta",
|
||||
"DESC": "You will be redirected to Meta to log into your WhatsApp Business account. Having admin access will help make the setup smooth and easy.",
|
||||
"TITLE": "Quick setup with Meta",
|
||||
"DESC": "Use the WhatsApp Embedded Signup flow to quickly connect new numbers. You will be redirected to Meta to log into your WhatsApp Business account. Having admin access will help make the setup smooth and easy.",
|
||||
"BENEFITS": {
|
||||
"TITLE": "Benefits of Embedded Signup:",
|
||||
"EASY_SETUP": "No manual configuration required",
|
||||
@@ -296,7 +296,10 @@
|
||||
"INVALID_BUSINESS_DATA": "Invalid business data received from Facebook. Please try again.",
|
||||
"SIGNUP_ERROR": "Signup error occurred",
|
||||
"AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.",
|
||||
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured"
|
||||
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured",
|
||||
"MANUAL_FALLBACK": "If your number is already linked to an Embedded Signup, or if you're a tech provider onboarding your own number, please use the",
|
||||
"MANUAL_SETUP_LINK": "manual setup flow",
|
||||
"MANUAL_FALLBACK_INSTEAD": "instead"
|
||||
},
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
|
||||
@@ -330,13 +333,14 @@
|
||||
"LABEL": "API Key Secret",
|
||||
"PLACEHOLDER": "Enter your Twilio API Key Secret",
|
||||
"REQUIRED": "API Key Secret is required"
|
||||
},
|
||||
"TWIML_APP_SID": {
|
||||
"LABEL": "TwiML App SID",
|
||||
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
|
||||
"REQUIRED": "TwiML App SID is required"
|
||||
}
|
||||
},
|
||||
"CONFIGURATION": {
|
||||
"TWILIO_VOICE_URL_TITLE": "Twilio Voice URL",
|
||||
"TWILIO_VOICE_URL_SUBTITLE": "Configure this URL as the Voice URL on your Twilio phone number and TwiML App.",
|
||||
"TWILIO_STATUS_URL_TITLE": "Twilio Status Callback URL",
|
||||
"TWILIO_STATUS_URL_SUBTITLE": "Configure this URL as the Status Callback URL on your Twilio phone number."
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create Voice Channel",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "We were not able to create the voice channel"
|
||||
|
||||
@@ -116,16 +116,22 @@ export default {
|
||||
key: 'collaborators',
|
||||
name: this.$t('INBOX_MGMT.TABS.COLLABORATORS'),
|
||||
},
|
||||
{
|
||||
key: 'businesshours',
|
||||
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
|
||||
},
|
||||
{
|
||||
key: 'csat',
|
||||
name: this.$t('INBOX_MGMT.TABS.CSAT'),
|
||||
},
|
||||
];
|
||||
|
||||
if (!this.isAVoiceChannel) {
|
||||
visibleToAllChannelTabs = [
|
||||
...visibleToAllChannelTabs,
|
||||
{
|
||||
key: 'businesshours',
|
||||
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
|
||||
},
|
||||
{
|
||||
key: 'csat',
|
||||
name: this.$t('INBOX_MGMT.TABS.CSAT'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (this.isAWebWidgetInbox) {
|
||||
visibleToAllChannelTabs = [
|
||||
...visibleToAllChannelTabs,
|
||||
@@ -144,6 +150,7 @@ export default {
|
||||
this.isATwilioChannel ||
|
||||
this.isALineChannel ||
|
||||
this.isAPIInbox ||
|
||||
this.isAVoiceChannel ||
|
||||
(this.isAnEmailChannel && !this.inbox.provider) ||
|
||||
this.shouldShowWhatsAppConfiguration ||
|
||||
this.isAWebWidgetInbox
|
||||
@@ -676,7 +683,7 @@ export default {
|
||||
}}
|
||||
</p>
|
||||
</label>
|
||||
<div class="pb-4">
|
||||
<div v-if="!isAVoiceChannel" class="pb-4">
|
||||
<label>
|
||||
{{ $t('INBOX_MGMT.HELP_CENTER.LABEL') }}
|
||||
</label>
|
||||
|
||||
@@ -22,7 +22,6 @@ const state = reactive({
|
||||
authToken: '',
|
||||
apiKeySid: '',
|
||||
apiKeySecret: '',
|
||||
twimlAppSid: '',
|
||||
});
|
||||
|
||||
const uiFlags = useMapGetter('inboxes/getUIFlags');
|
||||
@@ -33,7 +32,6 @@ const validationRules = {
|
||||
authToken: { required },
|
||||
apiKeySid: { required },
|
||||
apiKeySecret: { required },
|
||||
twimlAppSid: { required },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(validationRules, state);
|
||||
@@ -55,9 +53,6 @@ const formErrors = computed(() => ({
|
||||
apiKeySecret: v$.value.apiKeySecret?.$error
|
||||
? t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.REQUIRED')
|
||||
: '',
|
||||
twimlAppSid: v$.value.twimlAppSid?.$error
|
||||
? t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.REQUIRED')
|
||||
: '',
|
||||
}));
|
||||
|
||||
function getProviderConfig() {
|
||||
@@ -67,7 +62,6 @@ function getProviderConfig() {
|
||||
api_key_sid: state.apiKeySid,
|
||||
api_key_secret: state.apiKeySecret,
|
||||
};
|
||||
if (state.twimlAppSid) config.outgoing_application_sid = state.twimlAppSid;
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -160,17 +154,6 @@ async function createChannel() {
|
||||
@blur="v$.apiKeySecret?.$touch"
|
||||
/>
|
||||
|
||||
<Input
|
||||
v-model="state.twimlAppSid"
|
||||
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.LABEL')"
|
||||
:placeholder="
|
||||
t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.PLACEHOLDER')
|
||||
"
|
||||
:message="formErrors.twimlAppSid"
|
||||
:message-type="formErrors.twimlAppSid ? 'error' : 'info'"
|
||||
@blur="v$.twimlAppSid?.$touch"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<NextButton
|
||||
:is-loading="uiFlags.isCreating"
|
||||
|
||||
@@ -2,23 +2,22 @@
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import Twilio from './Twilio.vue';
|
||||
import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
|
||||
import CloudWhatsapp from './CloudWhatsapp.vue';
|
||||
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const PROVIDER_TYPES = {
|
||||
WHATSAPP: 'whatsapp',
|
||||
TWILIO: 'twilio',
|
||||
WHATSAPP_CLOUD: 'whatsapp_cloud',
|
||||
WHATSAPP_EMBEDDED: 'whatsapp_embedded',
|
||||
WHATSAPP_MANUAL: 'whatsapp_manual',
|
||||
THREE_SIXTY_DIALOG: '360dialog',
|
||||
};
|
||||
|
||||
@@ -29,14 +28,6 @@ const hasWhatsappAppId = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const isWhatsappEmbeddedSignupEnabled = computed(() => {
|
||||
const accountId = route.params.accountId;
|
||||
return store.getters['accounts/isFeatureEnabledonAccount'](
|
||||
accountId,
|
||||
FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP
|
||||
);
|
||||
});
|
||||
|
||||
const selectedProvider = computed(() => route.query.provider);
|
||||
|
||||
const showProviderSelection = computed(() => !selectedProvider.value);
|
||||
@@ -66,28 +57,11 @@ const selectProvider = providerValue => {
|
||||
});
|
||||
};
|
||||
|
||||
const shouldShowEmbeddedSignup = provider => {
|
||||
// Check if the feature is enabled for the account
|
||||
if (!isWhatsappEmbeddedSignupEnabled.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
(provider === PROVIDER_TYPES.WHATSAPP && hasWhatsappAppId.value) ||
|
||||
provider === PROVIDER_TYPES.WHATSAPP_EMBEDDED
|
||||
);
|
||||
};
|
||||
|
||||
const shouldShowCloudWhatsapp = provider => {
|
||||
// If embedded signup feature is enabled and app ID is configured, don't show cloud whatsapp
|
||||
if (isWhatsappEmbeddedSignupEnabled.value && hasWhatsappAppId.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Show cloud whatsapp when:
|
||||
// 1. Provider is whatsapp AND
|
||||
// 2. Either no app ID is configured OR embedded signup feature is disabled
|
||||
return provider === PROVIDER_TYPES.WHATSAPP;
|
||||
return (
|
||||
provider === PROVIDER_TYPES.WHATSAPP_MANUAL ||
|
||||
(provider === PROVIDER_TYPES.WHATSAPP && !hasWhatsappAppId.value)
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -138,10 +112,45 @@ const shouldShowCloudWhatsapp = provider => {
|
||||
|
||||
<div v-else-if="showConfiguration">
|
||||
<div class="px-6 py-5 rounded-2xl border bg-n-solid-2 border-n-weak">
|
||||
<WhatsappEmbeddedSignup
|
||||
v-if="shouldShowEmbeddedSignup(selectedProvider)"
|
||||
/>
|
||||
<!-- Show embedded signup if app ID is configured -->
|
||||
<div
|
||||
v-if="
|
||||
hasWhatsappAppId && selectedProvider === PROVIDER_TYPES.WHATSAPP
|
||||
"
|
||||
>
|
||||
<WhatsappEmbeddedSignup />
|
||||
|
||||
<!-- Manual setup fallback option -->
|
||||
<div class="pt-6 mt-6 border-t border-n-weak">
|
||||
<p class="mb-4 text-sm text-n-slate-11">
|
||||
{{
|
||||
$t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.MANUAL_FALLBACK')
|
||||
}}
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
color="slate"
|
||||
@click="selectProvider(PROVIDER_TYPES.WHATSAPP_MANUAL)"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.MANUAL_SETUP_LINK'
|
||||
)
|
||||
}}
|
||||
</Button>
|
||||
{{
|
||||
$t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.MANUAL_FALLBACK_INSTEAD'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Show manual setup -->
|
||||
<CloudWhatsapp v-else-if="shouldShowCloudWhatsapp(selectedProvider)" />
|
||||
|
||||
<!-- Other providers -->
|
||||
<Twilio
|
||||
v-else-if="selectedProvider === PROVIDER_TYPES.TWILIO"
|
||||
type="whatsapp"
|
||||
|
||||
+19
@@ -126,6 +126,25 @@ export default {
|
||||
<woot-code :script="inbox.callback_webhook_url" lang="html" />
|
||||
</SettingsSection>
|
||||
</div>
|
||||
<div v-else-if="isAVoiceChannel" class="mx-8">
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
|
||||
:sub-title="
|
||||
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_SUBTITLE')
|
||||
"
|
||||
>
|
||||
<woot-code :script="inbox.voice_call_webhook_url" lang="html" />
|
||||
</SettingsSection>
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_TITLE')"
|
||||
:sub-title="
|
||||
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_SUBTITLE')
|
||||
"
|
||||
>
|
||||
<woot-code :script="inbox.voice_status_webhook_url" lang="html" />
|
||||
</SettingsSection>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isALineChannel" class="mx-8">
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.ADD.LINE_CHANNEL.API_CALLBACK.TITLE')"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import './design-system/histoire.scss';
|
||||
import { defineSetupVue3 } from '@histoire/plugin-vue';
|
||||
import i18nMessages from 'dashboard/i18n';
|
||||
import dashboardI18n from 'dashboard/i18n';
|
||||
import widgetI18n from 'widget/i18n';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import { vResizeObserver } from '@vueuse/components';
|
||||
import store from 'dashboard/store';
|
||||
@@ -9,10 +10,30 @@ import VueDOMPurifyHTML from 'vue-dompurify-html';
|
||||
import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer.js';
|
||||
import { directive as onClickaway } from 'vue3-click-away';
|
||||
|
||||
function mergeMessages(...sources) {
|
||||
return sources.reduce((acc, src) => {
|
||||
Object.keys(src).forEach(key => {
|
||||
if (
|
||||
acc[key] &&
|
||||
typeof acc[key] === 'object' &&
|
||||
typeof src[key] === 'object'
|
||||
) {
|
||||
acc[key] = mergeMessages(acc[key], src[key]);
|
||||
} else {
|
||||
acc[key] = src[key];
|
||||
}
|
||||
});
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false, // https://github.com/intlify/vue-i18n/issues/1902
|
||||
locale: 'en',
|
||||
messages: i18nMessages,
|
||||
messages: mergeMessages(
|
||||
structuredClone(dashboardI18n),
|
||||
structuredClone(widgetI18n)
|
||||
),
|
||||
});
|
||||
|
||||
export const setupVue3 = defineSetupVue3(({ app }) => {
|
||||
|
||||
@@ -50,35 +50,11 @@ const updateCampaignReadStatus = baseDomain => {
|
||||
});
|
||||
};
|
||||
|
||||
const sanitizeURL = url => {
|
||||
if (url === '') return '';
|
||||
|
||||
try {
|
||||
// any invalid url will not be accepted
|
||||
// example - JaVaScRiP%0at:alert(document.domain)"
|
||||
// this has an obfuscated javascript protocol
|
||||
const parsedURL = new URL(url);
|
||||
|
||||
// filter out dangerous protocols like `javascript`, `data`, `vbscript`
|
||||
if (!['https', 'http'].includes(parsedURL.protocol)) {
|
||||
throw new Error('Invalid Protocol');
|
||||
}
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Invalid URL', e);
|
||||
}
|
||||
|
||||
return 'about:blank'; // blank page URL
|
||||
};
|
||||
|
||||
export const IFrameHelper = {
|
||||
getUrl({ baseUrl, websiteToken }) {
|
||||
baseUrl = sanitizeURL(baseUrl);
|
||||
return `${baseUrl}/widget?website_token=${websiteToken}`;
|
||||
},
|
||||
createFrame: ({ baseUrl, websiteToken }) => {
|
||||
baseUrl = sanitizeURL(baseUrl);
|
||||
|
||||
if (IFrameHelper.getAppFrame()) {
|
||||
return;
|
||||
}
|
||||
@@ -126,12 +102,10 @@ export const IFrameHelper = {
|
||||
window.onmessage = e => {
|
||||
if (
|
||||
typeof e.data !== 'string' ||
|
||||
e.data.indexOf('chatwoot-widget:') !== 0 ||
|
||||
e.origin !== window.location.origin
|
||||
e.data.indexOf('chatwoot-widget:') !== 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = JSON.parse(e.data.replace('chatwoot-widget:', ''));
|
||||
if (typeof IFrameHelper.events[message.event] === 'function') {
|
||||
IFrameHelper.events[message.event](message);
|
||||
@@ -166,9 +140,7 @@ export const IFrameHelper = {
|
||||
},
|
||||
|
||||
setupAudioListeners: () => {
|
||||
let { baseUrl = '' } = window.$chatwoot;
|
||||
baseUrl = sanitizeURL(baseUrl);
|
||||
|
||||
const { baseUrl = '' } = window.$chatwoot;
|
||||
getAlertAudio(baseUrl, { type: 'widget', alertTone: 'ding' }).then(() =>
|
||||
initOnEvents.forEach(event => {
|
||||
document.removeEventListener(
|
||||
@@ -262,7 +234,6 @@ export const IFrameHelper = {
|
||||
},
|
||||
|
||||
popoutChatWindow: ({ baseUrl, websiteToken, locale }) => {
|
||||
baseUrl = sanitizeURL(baseUrl);
|
||||
const cwCookie = Cookies.get('cw_conversation');
|
||||
window.$chatwoot.toggle('close');
|
||||
popoutChatWindow(baseUrl, websiteToken, locale, cwCookie);
|
||||
|
||||
@@ -57,15 +57,15 @@ export default {
|
||||
isALineChannel() {
|
||||
return this.channelType === INBOX_TYPES.LINE;
|
||||
},
|
||||
isAVoiceChannel() {
|
||||
return this.channelType === INBOX_TYPES.VOICE;
|
||||
},
|
||||
isAnEmailChannel() {
|
||||
return this.channelType === INBOX_TYPES.EMAIL;
|
||||
},
|
||||
isATelegramChannel() {
|
||||
return this.channelType === INBOX_TYPES.TELEGRAM;
|
||||
},
|
||||
isAVoiceChannel() {
|
||||
return this.channelType === INBOX_TYPES.VOICE;
|
||||
},
|
||||
isATwilioSMSChannel() {
|
||||
const { medium: medium = '' } = this.inbox;
|
||||
return this.isATwilioChannel && medium === 'sms';
|
||||
|
||||
@@ -4,12 +4,10 @@ import { setHeader } from 'widget/helpers/axios';
|
||||
import addHours from 'date-fns/addHours';
|
||||
import { IFrameHelper, RNHelper } from 'widget/helpers/utils';
|
||||
import configMixin from './mixins/configMixin';
|
||||
import availabilityMixin from 'widget/mixins/availability';
|
||||
import { getLocale } from './helpers/urlParamsHelper';
|
||||
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
|
||||
import { isEmptyObject } from 'widget/helpers/utils';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import routerMixin from './mixins/routerMixin';
|
||||
import {
|
||||
getExtraSpaceToScroll,
|
||||
loadedEventConfig,
|
||||
@@ -20,6 +18,8 @@ import {
|
||||
ON_UNREAD_MESSAGE_CLICK,
|
||||
} from './constants/widgetBusEvents';
|
||||
import { useDarkMode } from 'widget/composables/useDarkMode';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAvailability } from 'widget/composables/useAvailability';
|
||||
import { SDK_SET_BUBBLE_VISIBILITY } from '../shared/constants/sharedFrameEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
@@ -28,10 +28,13 @@ export default {
|
||||
components: {
|
||||
Spinner,
|
||||
},
|
||||
mixins: [availabilityMixin, configMixin, routerMixin],
|
||||
mixins: [configMixin],
|
||||
setup() {
|
||||
const { prefersDarkMode } = useDarkMode();
|
||||
return { prefersDarkMode };
|
||||
const router = useRouter();
|
||||
const { isInWorkingHours } = useAvailability();
|
||||
|
||||
return { prefersDarkMode, router, isInWorkingHours };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -157,15 +160,17 @@ export default {
|
||||
this.setUnreadView();
|
||||
});
|
||||
emitter.on(ON_UNREAD_MESSAGE_CLICK, () => {
|
||||
this.replaceRoute('messages').then(() => this.unsetUnreadView());
|
||||
this.router
|
||||
.replace({ name: 'messages' })
|
||||
.then(() => this.unsetUnreadView());
|
||||
});
|
||||
},
|
||||
registerCampaignEvents() {
|
||||
emitter.on(ON_CAMPAIGN_MESSAGE_CLICK, () => {
|
||||
if (this.shouldShowPreChatForm) {
|
||||
this.replaceRoute('prechat-form');
|
||||
this.router.replace({ name: 'prechat-form' });
|
||||
} else {
|
||||
this.replaceRoute('messages');
|
||||
this.router.replace({ name: 'messages' });
|
||||
emitter.emit('execute-campaign', {
|
||||
campaignId: this.activeCampaign.id,
|
||||
});
|
||||
@@ -176,7 +181,7 @@ export default {
|
||||
const { customAttributes, campaignId } = campaignDetails;
|
||||
const { websiteToken } = window.chatwootWebChannel;
|
||||
this.executeCampaign({ campaignId, websiteToken, customAttributes });
|
||||
this.replaceRoute('messages');
|
||||
this.router.replace({ name: 'messages' });
|
||||
});
|
||||
emitter.on('snooze-campaigns', () => {
|
||||
const expireBy = addHours(new Date(), 1);
|
||||
@@ -192,7 +197,7 @@ export default {
|
||||
!messageCount &&
|
||||
!shouldSnoozeCampaign;
|
||||
if (this.isIFrame && isCampaignReadyToExecute) {
|
||||
this.replaceRoute('campaigns').then(() => {
|
||||
this.router.replace({ name: 'campaigns' }).then(() => {
|
||||
this.setIframeHeight(true);
|
||||
IFrameHelper.sendMessage({ event: 'setUnreadMode' });
|
||||
});
|
||||
@@ -207,7 +212,7 @@ export default {
|
||||
unreadMessageCount > 0 &&
|
||||
!this.isWidgetOpen
|
||||
) {
|
||||
this.replaceRoute('unread-messages').then(() => {
|
||||
this.router.replace({ name: 'unread-messages' }).then(() => {
|
||||
this.setIframeHeight(true);
|
||||
IFrameHelper.sendMessage({ event: 'setUnreadMode' });
|
||||
});
|
||||
@@ -263,7 +268,7 @@ export default {
|
||||
this.initCampaigns({
|
||||
currentURL: referrerURL,
|
||||
websiteToken,
|
||||
isInBusinessHours: this.isInBusinessHours,
|
||||
isInBusinessHours: this.isInWorkingHours,
|
||||
});
|
||||
window.referrerURL = referrerURL;
|
||||
this.setReferrerHost(referrerHost);
|
||||
@@ -314,12 +319,12 @@ export default {
|
||||
['unread-messages', 'campaigns'].includes(this.$route.name);
|
||||
|
||||
if (shouldShowMessageView) {
|
||||
this.replaceRoute('messages');
|
||||
this.router.replace({ name: 'messages' });
|
||||
}
|
||||
if (shouldShowHomeView) {
|
||||
this.$store.dispatch('conversation/setUserLastSeen');
|
||||
this.unsetUnreadView();
|
||||
this.replaceRoute('home');
|
||||
this.router.replace({ name: 'home' });
|
||||
}
|
||||
if (!message.isOpen) {
|
||||
this.resetCampaign();
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import GroupedAvatars from 'widget/components/GroupedAvatars.vue';
|
||||
import AvailabilityText from './AvailabilityText.vue';
|
||||
import { useAvailability } from 'widget/composables/useAvailability';
|
||||
|
||||
const props = defineProps({
|
||||
agents: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
showHeader: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showAvatars: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
textClasses: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const availableMessage = useMapGetter('appConfig/getAvailableMessage');
|
||||
const unavailableMessage = useMapGetter('appConfig/getUnavailableMessage');
|
||||
|
||||
const {
|
||||
currentTime,
|
||||
hasOnlineAgents,
|
||||
isOnline,
|
||||
inboxConfig,
|
||||
isInWorkingHours,
|
||||
} = useAvailability(props.agents);
|
||||
|
||||
const workingHours = computed(() => inboxConfig.value.workingHours || []);
|
||||
const workingHoursEnabled = computed(
|
||||
() => inboxConfig.value.workingHoursEnabled || false
|
||||
);
|
||||
const utcOffset = computed(
|
||||
() => inboxConfig.value.utcOffset || inboxConfig.value.timezone || 'UTC'
|
||||
);
|
||||
const replyTime = computed(
|
||||
() => inboxConfig.value.replyTime || 'in_a_few_minutes'
|
||||
);
|
||||
|
||||
// If online or in working hours
|
||||
const isAvailable = computed(
|
||||
() => isOnline.value || (workingHoursEnabled.value && isInWorkingHours.value)
|
||||
);
|
||||
|
||||
const headerText = computed(() =>
|
||||
isAvailable.value
|
||||
? availableMessage.value || t('TEAM_AVAILABILITY.ONLINE')
|
||||
: unavailableMessage.value || t('TEAM_AVAILABILITY.OFFLINE')
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div v-if="showHeader" class="font-medium text-n-slate-12">
|
||||
{{ headerText }}
|
||||
</div>
|
||||
|
||||
<AvailabilityText
|
||||
:time="currentTime"
|
||||
:utc-offset="utcOffset"
|
||||
:working-hours="workingHours"
|
||||
:working-hours-enabled="workingHoursEnabled"
|
||||
:has-online-agents="hasOnlineAgents"
|
||||
:reply-time="replyTime"
|
||||
:is-online="isOnline"
|
||||
:is-in-working-hours="isInWorkingHours"
|
||||
:class="textClasses"
|
||||
class="text-n-slate-11"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<GroupedAvatars v-if="showAvatars && isOnline" :users="agents" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,217 @@
|
||||
<script setup>
|
||||
import AvailabilityText from './AvailabilityText.vue';
|
||||
|
||||
// Base time for consistent testing: Monday, July 15, 2024, 10:00:00 UTC
|
||||
const baseTime = new Date('2024-07-15T10:00:00.000Z');
|
||||
const utcOffset = '+00:00'; // UTC
|
||||
|
||||
const defaultProps = {
|
||||
time: baseTime,
|
||||
utcOffset,
|
||||
workingHours: [
|
||||
{
|
||||
dayOfWeek: 0,
|
||||
openHour: 9,
|
||||
openMinutes: 0,
|
||||
closeHour: 17,
|
||||
closeMinutes: 0,
|
||||
closedAllDay: false,
|
||||
}, // Sunday
|
||||
{
|
||||
dayOfWeek: 1,
|
||||
openHour: 9,
|
||||
openMinutes: 0,
|
||||
closeHour: 17,
|
||||
closeMinutes: 0,
|
||||
closedAllDay: false,
|
||||
}, // Monday (current day)
|
||||
{
|
||||
dayOfWeek: 2,
|
||||
openHour: 9,
|
||||
openMinutes: 0,
|
||||
closeHour: 17,
|
||||
closeMinutes: 0,
|
||||
closedAllDay: false,
|
||||
}, // Tuesday
|
||||
{
|
||||
dayOfWeek: 3,
|
||||
openHour: 9,
|
||||
openMinutes: 0,
|
||||
closeHour: 17,
|
||||
closeMinutes: 0,
|
||||
closedAllDay: false,
|
||||
}, // Wednesday
|
||||
{
|
||||
dayOfWeek: 4,
|
||||
openHour: 9,
|
||||
openMinutes: 0,
|
||||
closeHour: 17,
|
||||
closeMinutes: 0,
|
||||
closedAllDay: false,
|
||||
}, // Thursday
|
||||
{
|
||||
dayOfWeek: 5,
|
||||
openHour: 9,
|
||||
openMinutes: 0,
|
||||
closeHour: 17,
|
||||
closeMinutes: 0,
|
||||
closedAllDay: false,
|
||||
}, // Friday
|
||||
{
|
||||
dayOfWeek: 6,
|
||||
openHour: 9,
|
||||
openMinutes: 0,
|
||||
closeHour: 17,
|
||||
closeMinutes: 0,
|
||||
closedAllDay: true,
|
||||
}, // Saturday (closed)
|
||||
],
|
||||
workingHoursEnabled: true,
|
||||
replyTime: 'in_a_few_minutes',
|
||||
isOnline: true,
|
||||
isInWorkingHours: true,
|
||||
};
|
||||
|
||||
const createVariant = (
|
||||
title,
|
||||
propsOverride = {},
|
||||
isOnlineOverride = null,
|
||||
isInWorkingHoursOverride = null
|
||||
) => {
|
||||
const props = { ...defaultProps, ...propsOverride };
|
||||
if (isOnlineOverride !== null) props.isOnline = isOnlineOverride;
|
||||
if (isInWorkingHoursOverride !== null)
|
||||
props.isInWorkingHours = isInWorkingHoursOverride;
|
||||
|
||||
// Adjust time for specific scenarios
|
||||
if (title.includes('Back Tomorrow')) {
|
||||
// Set time to just after closing on Monday to trigger 'Back Tomorrow' (Tuesday)
|
||||
props.time = new Date('2024-07-15T17:01:00.000Z');
|
||||
props.isInWorkingHours = false;
|
||||
props.isOnline = false;
|
||||
}
|
||||
if (title.includes('Back Multiple Days Away')) {
|
||||
// Set time to Friday evening to trigger 'Back on Sunday' (as Saturday is closed)
|
||||
props.time = new Date('2024-07-19T18:00:00.000Z');
|
||||
props.isInWorkingHours = false;
|
||||
props.isOnline = false;
|
||||
}
|
||||
if (title.includes('Back Same Day - In Minutes')) {
|
||||
// Monday 16:50, next slot is 17:00 (in 10 minutes)
|
||||
// To make this specific, let's assume the next slot is within the hour
|
||||
// For this, we need to be outside working hours but a slot is available soon.
|
||||
// Let's say current time is 8:50 AM, office opens at 9:00 AM.
|
||||
props.time = new Date('2024-07-15T08:50:00.000Z');
|
||||
props.isInWorkingHours = false;
|
||||
props.isOnline = false;
|
||||
}
|
||||
if (title.includes('Back Same Day - In Hours')) {
|
||||
// Monday 07:30 AM, office opens at 9:00 AM (in 1.5 hours, rounds to 2 hours)
|
||||
props.time = new Date('2024-07-15T07:30:00.000Z');
|
||||
props.isInWorkingHours = false;
|
||||
props.isOnline = false;
|
||||
}
|
||||
if (title.includes('in 1 hour')) {
|
||||
// Monday 08:00 AM, office opens at 9:00 AM (exactly in 1 hour)
|
||||
// At exactly 1 hour difference, remainingMinutes = 0
|
||||
props.time = new Date('2024-07-15T08:00:00.000Z');
|
||||
props.isInWorkingHours = false;
|
||||
props.isOnline = false;
|
||||
}
|
||||
if (title.includes('Back Same Day - At Time')) {
|
||||
// Monday 05:00 AM, office opens at 9:00 AM (at 9:00 AM)
|
||||
props.time = new Date('2024-07-15T05:00:00.000Z');
|
||||
props.isInWorkingHours = false;
|
||||
props.isOnline = false;
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
props,
|
||||
};
|
||||
};
|
||||
|
||||
const variants = [
|
||||
createVariant(
|
||||
'Working Hours Disabled - Online',
|
||||
{ workingHoursEnabled: false },
|
||||
true,
|
||||
true
|
||||
),
|
||||
createVariant(
|
||||
'Working Hours Disabled - Offline',
|
||||
{ workingHoursEnabled: false },
|
||||
false,
|
||||
false
|
||||
),
|
||||
createVariant(
|
||||
'All Day Closed - Offline',
|
||||
{
|
||||
workingHours: defaultProps.workingHours.map(wh => ({
|
||||
...wh,
|
||||
closedAllDay: true,
|
||||
})),
|
||||
},
|
||||
false,
|
||||
false
|
||||
),
|
||||
createVariant('Online and In Working Hours', {}, true, true),
|
||||
createVariant(
|
||||
'No Next Slot Available (e.g., all future slots closed or empty workingHours)',
|
||||
{ workingHours: [] }, // No working hours defined
|
||||
false,
|
||||
false
|
||||
),
|
||||
createVariant(
|
||||
'Back Tomorrow',
|
||||
{},
|
||||
false,
|
||||
false // Time will be adjusted by createVariant
|
||||
),
|
||||
createVariant('Back Multiple Days Away (e.g., on Sunday)', {}, false, false),
|
||||
createVariant(
|
||||
'Back Same Day - In Minutes (e.g., in 10 minutes)',
|
||||
{},
|
||||
false,
|
||||
false
|
||||
),
|
||||
createVariant(
|
||||
'Back Same Day - In Hours (e.g., in 2 hours)',
|
||||
{},
|
||||
false,
|
||||
false
|
||||
),
|
||||
createVariant(
|
||||
'Back Same Day - Exactly an Hour (e.g., in 1 hour)',
|
||||
{},
|
||||
false,
|
||||
false
|
||||
),
|
||||
createVariant(
|
||||
'Back Same Day - At Time (e.g., at 09:00 AM)',
|
||||
{},
|
||||
false,
|
||||
false
|
||||
),
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Widget/Components/Availability/AvailabilityText"
|
||||
:layout="{ type: 'grid', width: 300 }"
|
||||
>
|
||||
<Variant v-for="(variant, i) in variants" :key="i" :title="variant.title">
|
||||
<AvailabilityText
|
||||
:time="variant.props.time"
|
||||
:utc-offset="variant.props.utcOffset"
|
||||
:working-hours="variant.props.workingHours"
|
||||
:working-hours-enabled="variant.props.workingHoursEnabled"
|
||||
:reply-time="variant.props.replyTime"
|
||||
:is-online="variant.props.isOnline"
|
||||
:is-in-working-hours="variant.props.isInWorkingHours"
|
||||
class="text-n-slate-11"
|
||||
/>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,178 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { getTime } from 'dashboard/routes/dashboard/settings/inbox/helpers/businessHour.js';
|
||||
import { findNextAvailableSlotDetails } from 'widget/helpers/availabilityHelpers';
|
||||
|
||||
const props = defineProps({
|
||||
time: {
|
||||
type: Date,
|
||||
required: true,
|
||||
},
|
||||
utcOffset: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
workingHours: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
workingHoursEnabled: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
replyTime: {
|
||||
type: String,
|
||||
default: 'in_a_few_minutes',
|
||||
},
|
||||
isOnline: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
isInWorkingHours: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const MINUTE_ROUNDING_INTERVAL = 5;
|
||||
const HOUR_THRESHOLD_FOR_EXACT_TIME = 3;
|
||||
const MINUTES_IN_HOUR = 60;
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const dayNames = computed(() => [
|
||||
t('DAY_NAMES.SUNDAY'),
|
||||
t('DAY_NAMES.MONDAY'),
|
||||
t('DAY_NAMES.TUESDAY'),
|
||||
t('DAY_NAMES.WEDNESDAY'),
|
||||
t('DAY_NAMES.THURSDAY'),
|
||||
t('DAY_NAMES.FRIDAY'),
|
||||
t('DAY_NAMES.SATURDAY'),
|
||||
]);
|
||||
|
||||
// Check if all days in working hours are closed
|
||||
const allDayClosed = computed(() => {
|
||||
if (!props.workingHours.length) return false;
|
||||
return props.workingHours.every(slot => slot.closedAllDay);
|
||||
});
|
||||
|
||||
const replyTimeMessage = computed(() => {
|
||||
const replyTimeKey = `REPLY_TIME.${props.replyTime.toUpperCase()}`;
|
||||
return t(replyTimeKey);
|
||||
});
|
||||
|
||||
const nextSlot = computed(() => {
|
||||
if (
|
||||
!props.workingHoursEnabled ||
|
||||
allDayClosed.value ||
|
||||
(props.isInWorkingHours && props.isOnline)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const slot = findNextAvailableSlotDetails(
|
||||
props.time,
|
||||
props.utcOffset,
|
||||
props.workingHours
|
||||
);
|
||||
if (!slot) return null;
|
||||
|
||||
return {
|
||||
...slot,
|
||||
hoursUntilOpen: Math.floor(slot.minutesUntilOpen / MINUTES_IN_HOUR),
|
||||
remainingMinutes: slot.minutesUntilOpen % MINUTES_IN_HOUR,
|
||||
};
|
||||
});
|
||||
|
||||
const roundedMinutesUntilOpen = computed(() => {
|
||||
if (!nextSlot.value) return 0;
|
||||
return (
|
||||
Math.ceil(nextSlot.value.remainingMinutes / MINUTE_ROUNDING_INTERVAL) *
|
||||
MINUTE_ROUNDING_INTERVAL
|
||||
);
|
||||
});
|
||||
|
||||
const adjustedHoursUntilOpen = computed(() => {
|
||||
if (!nextSlot.value) return 0;
|
||||
return nextSlot.value.remainingMinutes > 0
|
||||
? nextSlot.value.hoursUntilOpen + 1
|
||||
: nextSlot.value.hoursUntilOpen;
|
||||
});
|
||||
|
||||
const formattedOpeningTime = computed(() => {
|
||||
if (!nextSlot.value) return '';
|
||||
return getTime(
|
||||
nextSlot.value.config.openHour || 0,
|
||||
nextSlot.value.config.openMinutes || 0
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span>
|
||||
<!-- 1. If currently in working hours, show reply time -->
|
||||
<template v-if="isInWorkingHours">
|
||||
{{ replyTimeMessage }}
|
||||
</template>
|
||||
|
||||
<!-- 2. Else, if working hours are disabled, show based on online status -->
|
||||
<template v-else-if="!workingHoursEnabled">
|
||||
{{
|
||||
isOnline
|
||||
? replyTimeMessage
|
||||
: t('TEAM_AVAILABILITY.BACK_AS_SOON_AS_POSSIBLE')
|
||||
}}
|
||||
</template>
|
||||
|
||||
<!-- 3. Else (not in working hours, but working hours ARE enabled) -->
|
||||
<!-- Check if all configured slots are 'closedAllDay' -->
|
||||
<template v-else-if="allDayClosed">
|
||||
{{ t('TEAM_AVAILABILITY.BACK_AS_SOON_AS_POSSIBLE') }}
|
||||
</template>
|
||||
|
||||
<!-- 4. Else (not in WH, WH enabled, not allDayClosed), calculate next slot -->
|
||||
<template v-else-if="!nextSlot">
|
||||
{{ t('REPLY_TIME.BACK_IN_SOME_TIME') }}
|
||||
</template>
|
||||
|
||||
<!-- Tomorrow -->
|
||||
<template v-else-if="nextSlot.daysUntilOpen === 1">
|
||||
{{ t('REPLY_TIME.BACK_TOMORROW') }}
|
||||
</template>
|
||||
|
||||
<!-- Multiple days away (eg: on Monday) -->
|
||||
<template v-else-if="nextSlot.daysUntilOpen > 1">
|
||||
{{
|
||||
t('REPLY_TIME.BACK_ON_DAY', {
|
||||
day: dayNames[nextSlot.config.dayOfWeek],
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
|
||||
<!-- Same day - less than 1 hour (eg: in 5 minutes) -->
|
||||
<template v-else-if="nextSlot.hoursUntilOpen === 0">
|
||||
{{
|
||||
t('REPLY_TIME.BACK_IN_MINUTES', {
|
||||
time: `${roundedMinutesUntilOpen}`,
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
|
||||
<!-- Same day - less than 3 hours (eg: in 2 hours) -->
|
||||
<template
|
||||
v-else-if="nextSlot.hoursUntilOpen < HOUR_THRESHOLD_FOR_EXACT_TIME"
|
||||
>
|
||||
{{ t('REPLY_TIME.BACK_IN_HOURS', adjustedHoursUntilOpen) }}
|
||||
</template>
|
||||
|
||||
<!-- Same day - 3+ hours away (eg: at 10:00 AM) -->
|
||||
<template v-else>
|
||||
{{
|
||||
t('REPLY_TIME.BACK_AT_TIME', {
|
||||
time: formattedOpeningTime,
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
@@ -6,7 +6,7 @@ import FooterReplyTo from 'widget/components/FooterReplyTo.vue';
|
||||
import ChatInputWrap from 'widget/components/ChatInputWrap.vue';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { sendEmailTranscript } from 'widget/api/conversation';
|
||||
import routerMixin from 'widget/mixins/routerMixin';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { IFrameHelper } from '../helpers/utils';
|
||||
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
@@ -17,7 +17,10 @@ export default {
|
||||
CustomButton,
|
||||
FooterReplyTo,
|
||||
},
|
||||
mixins: [routerMixin],
|
||||
setup() {
|
||||
const router = useRouter();
|
||||
return { router };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
inReplyTo: null,
|
||||
@@ -77,7 +80,7 @@ export default {
|
||||
this.inReplyTo = null;
|
||||
},
|
||||
startNewConversation() {
|
||||
this.replaceRoute('prechat-form');
|
||||
this.router.replace({ name: 'prechat-form' });
|
||||
IFrameHelper.sendMessage({
|
||||
event: 'onEvent',
|
||||
eventIdentifier: CHATWOOT_ON_START_CONVERSATION,
|
||||
|
||||
@@ -1,55 +1,26 @@
|
||||
<script>
|
||||
import availabilityMixin from 'widget/mixins/availability';
|
||||
import nextAvailabilityTime from 'widget/mixins/nextAvailabilityTime';
|
||||
<script setup>
|
||||
import { toRef } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
import HeaderActions from './HeaderActions.vue';
|
||||
import routerMixin from 'widget/mixins/routerMixin';
|
||||
import AvailabilityContainer from 'widget/components/Availability/AvailabilityContainer.vue';
|
||||
import { useAvailability } from 'widget/composables/useAvailability';
|
||||
|
||||
export default {
|
||||
name: 'ChatHeader',
|
||||
components: {
|
||||
FluentIcon,
|
||||
HeaderActions,
|
||||
},
|
||||
mixins: [nextAvailabilityTime, availabilityMixin, routerMixin],
|
||||
props: {
|
||||
avatarUrl: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showPopoutButton: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showBackButton: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
availableAgents: {
|
||||
type: Array,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
isOnline() {
|
||||
const { workingHoursEnabled } = this.channelConfig;
|
||||
const anyAgentOnline = this.availableAgents.length > 0;
|
||||
const props = defineProps({
|
||||
avatarUrl: { type: String, default: '' },
|
||||
title: { type: String, default: '' },
|
||||
showPopoutButton: { type: Boolean, default: false },
|
||||
showBackButton: { type: Boolean, default: false },
|
||||
availableAgents: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
if (workingHoursEnabled) {
|
||||
return this.isInBetweenTheWorkingHours;
|
||||
}
|
||||
return anyAgentOnline;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onBackButtonClick() {
|
||||
this.replaceRoute('home');
|
||||
},
|
||||
},
|
||||
const availableAgents = toRef(props, 'availableAgents');
|
||||
|
||||
const router = useRouter();
|
||||
const { isOnline } = useAvailability(availableAgents);
|
||||
|
||||
const onBackButtonClick = () => {
|
||||
router.replace({ name: 'home' });
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -79,9 +50,12 @@ export default {
|
||||
${isOnline ? 'bg-n-teal-10' : 'hidden'}`"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-xs leading-3 text-n-slate-11">
|
||||
{{ replyWaitMessage }}
|
||||
</div>
|
||||
<AvailabilityContainer
|
||||
:agents="availableAgents"
|
||||
:show-header="false"
|
||||
:show-avatars="false"
|
||||
text-classes="text-xs leading-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HeaderActions :show-popout-button="showPopoutButton" />
|
||||
|
||||
@@ -6,7 +6,6 @@ import { getContrastingTextColor } from '@chatwoot/utils';
|
||||
import { isEmptyObject } from 'widget/helpers/utils';
|
||||
import { getRegexp } from 'shared/helpers/Validators';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import routerMixin from 'widget/mixins/routerMixin';
|
||||
import configMixin from 'widget/mixins/configMixin';
|
||||
import { FormKit, createInput } from '@formkit/vue';
|
||||
import PhoneInput from 'widget/components/Form/PhoneInput.vue';
|
||||
@@ -17,7 +16,7 @@ export default {
|
||||
Spinner,
|
||||
FormKit,
|
||||
},
|
||||
mixins: [routerMixin, configMixin],
|
||||
mixins: [configMixin],
|
||||
props: {
|
||||
options: {
|
||||
type: Object,
|
||||
|
||||
@@ -1,74 +1,27 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { getContrastingTextColor } from '@chatwoot/utils';
|
||||
import nextAvailabilityTime from 'widget/mixins/nextAvailabilityTime';
|
||||
import configMixin from 'widget/mixins/configMixin';
|
||||
import availabilityMixin from 'widget/mixins/availability';
|
||||
<script setup>
|
||||
import { IFrameHelper } from 'widget/helpers/utils';
|
||||
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
|
||||
import GroupedAvatars from 'widget/components/GroupedAvatars.vue';
|
||||
import AvailabilityContainer from 'widget/components/Availability/AvailabilityContainer.vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
|
||||
export default {
|
||||
name: 'TeamAvailability',
|
||||
components: {
|
||||
GroupedAvatars,
|
||||
},
|
||||
mixins: [configMixin, nextAvailabilityTime, availabilityMixin],
|
||||
props: {
|
||||
availableAgents: {
|
||||
type: Array,
|
||||
default: () => {},
|
||||
},
|
||||
hasConversation: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['startConversation'],
|
||||
const props = defineProps({
|
||||
availableAgents: { type: Array, default: () => [] },
|
||||
hasConversation: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
computed: {
|
||||
...mapGetters({
|
||||
widgetColor: 'appConfig/getWidgetColor',
|
||||
availableMessage: 'appConfig/getAvailableMessage',
|
||||
unavailableMessage: 'appConfig/getUnavailableMessage',
|
||||
}),
|
||||
textColor() {
|
||||
return getContrastingTextColor(this.widgetColor);
|
||||
},
|
||||
agentAvatars() {
|
||||
return this.availableAgents.map(agent => ({
|
||||
name: agent.name,
|
||||
avatar: agent.avatar_url,
|
||||
id: agent.id,
|
||||
}));
|
||||
},
|
||||
headerMessage() {
|
||||
return this.isOnline
|
||||
? this.availableMessage || this.$t('TEAM_AVAILABILITY.ONLINE')
|
||||
: this.unavailableMessage || this.$t('TEAM_AVAILABILITY.OFFLINE');
|
||||
},
|
||||
isOnline() {
|
||||
const { workingHoursEnabled } = this.channelConfig;
|
||||
const anyAgentOnline = this.availableAgents.length > 0;
|
||||
const emit = defineEmits(['startConversation']);
|
||||
|
||||
if (workingHoursEnabled) {
|
||||
return this.isInBetweenTheWorkingHours;
|
||||
}
|
||||
return anyAgentOnline;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
startConversation() {
|
||||
this.$emit('startConversation');
|
||||
if (!this.hasConversation) {
|
||||
IFrameHelper.sendMessage({
|
||||
event: 'onEvent',
|
||||
eventIdentifier: CHATWOOT_ON_START_CONVERSATION,
|
||||
data: { hasConversation: false },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
const widgetColor = useMapGetter('appConfig/getWidgetColor');
|
||||
|
||||
const startConversation = () => {
|
||||
emit('startConversation');
|
||||
if (!props.hasConversation) {
|
||||
IFrameHelper.sendMessage({
|
||||
event: 'onEvent',
|
||||
eventIdentifier: CHATWOOT_ON_START_CONVERSATION,
|
||||
data: { hasConversation: false },
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -76,17 +29,8 @@ export default {
|
||||
<div
|
||||
class="flex flex-col gap-3 w-full shadow outline-1 outline outline-n-container rounded-xl bg-n-background dark:bg-n-solid-2 px-5 py-4"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="font-medium text-n-slate-12 line-clamp-2">
|
||||
{{ headerMessage }}
|
||||
</div>
|
||||
<div class="text-n-slate-11">
|
||||
{{ replyWaitMessage }}
|
||||
</div>
|
||||
</div>
|
||||
<GroupedAvatars v-if="isOnline" :users="availableAgents" />
|
||||
</div>
|
||||
<AvailabilityContainer :agents="availableAgents" show-header show-avatars />
|
||||
|
||||
<button
|
||||
class="inline-flex items-center gap-1 font-medium text-n-slate-12"
|
||||
:style="{ color: widgetColor }"
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { ref } from 'vue';
|
||||
import { useAvailability } from '../useAvailability';
|
||||
|
||||
const mockIsOnline = vi.fn();
|
||||
const mockIsInWorkingHours = vi.fn();
|
||||
const mockUseCamelCase = vi.fn(obj => obj);
|
||||
|
||||
vi.mock('widget/helpers/availabilityHelpers', () => ({
|
||||
isOnline: (...args) => mockIsOnline(...args),
|
||||
isInWorkingHours: (...args) => mockIsInWorkingHours(...args),
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/useTransformKeys', () => ({
|
||||
useCamelCase: obj => mockUseCamelCase(obj),
|
||||
}));
|
||||
|
||||
describe('useAvailability', () => {
|
||||
const originalWindow = window.chatwootWebChannel;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset mocks to return true by default
|
||||
mockIsOnline.mockReturnValue(true);
|
||||
mockIsInWorkingHours.mockReturnValue(true);
|
||||
mockUseCamelCase.mockImplementation(obj => obj);
|
||||
|
||||
window.chatwootWebChannel = {
|
||||
workingHours: [],
|
||||
workingHoursEnabled: false,
|
||||
timezone: 'UTC',
|
||||
utcOffset: 'UTC',
|
||||
replyTime: 'in_a_few_minutes',
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.chatwootWebChannel = originalWindow;
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should initialize with default values', () => {
|
||||
const { availableAgents, hasOnlineAgents, isInWorkingHours, isOnline } =
|
||||
useAvailability();
|
||||
|
||||
expect(availableAgents.value).toEqual([]);
|
||||
expect(hasOnlineAgents.value).toBe(false);
|
||||
expect(isInWorkingHours.value).toBe(true);
|
||||
expect(isOnline.value).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with agents', () => {
|
||||
it('should handle agents array', () => {
|
||||
const agents = [{ id: 1 }, { id: 2 }];
|
||||
const { availableAgents, hasOnlineAgents } = useAvailability(agents);
|
||||
|
||||
expect(availableAgents.value).toEqual(agents);
|
||||
expect(hasOnlineAgents.value).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle reactive agents', () => {
|
||||
const agents = ref([{ id: 1 }]);
|
||||
const { hasOnlineAgents } = useAvailability(agents);
|
||||
|
||||
expect(hasOnlineAgents.value).toBe(true);
|
||||
|
||||
agents.value = [];
|
||||
expect(hasOnlineAgents.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('working hours', () => {
|
||||
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
|
||||
|
||||
beforeEach(() => {
|
||||
window.chatwootWebChannel = {
|
||||
workingHours,
|
||||
workingHoursEnabled: true,
|
||||
utcOffset: '+05:30',
|
||||
};
|
||||
});
|
||||
|
||||
it('should check working hours', () => {
|
||||
mockIsInWorkingHours.mockReturnValueOnce(true);
|
||||
const { isInWorkingHours } = useAvailability();
|
||||
const result = isInWorkingHours.value;
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockIsInWorkingHours).toHaveBeenCalledWith(
|
||||
expect.any(Date),
|
||||
'+05:30',
|
||||
workingHours
|
||||
);
|
||||
});
|
||||
|
||||
it('should determine online status based on working hours and agents', () => {
|
||||
mockIsOnline.mockReturnValueOnce(true);
|
||||
const { isOnline } = useAvailability([{ id: 1 }]);
|
||||
const result = isOnline.value;
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockIsOnline).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.any(Date),
|
||||
'+05:30',
|
||||
workingHours,
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('config changes', () => {
|
||||
it('should react to window.chatwootWebChannel changes', () => {
|
||||
const { inboxConfig } = useAvailability();
|
||||
|
||||
window.chatwootWebChannel = {
|
||||
...window.chatwootWebChannel,
|
||||
replyTime: 'in_a_day',
|
||||
};
|
||||
|
||||
expect(inboxConfig.value.replyTime).toBe('in_a_day');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { computed, toRef } from 'vue';
|
||||
import {
|
||||
isOnline as checkIsOnline,
|
||||
isInWorkingHours as checkInWorkingHours,
|
||||
} from 'widget/helpers/availabilityHelpers';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
|
||||
const DEFAULT_TIMEZONE = 'UTC';
|
||||
const DEFAULT_REPLY_TIME = 'in_a_few_minutes';
|
||||
|
||||
/**
|
||||
* Composable for availability-related logic
|
||||
* @param {Ref|Array} agents - Available agents (can be ref or raw array)
|
||||
* @returns {Object} Availability utilities and computed properties
|
||||
*/
|
||||
export function useAvailability(agents = []) {
|
||||
const availableAgents = toRef(agents);
|
||||
|
||||
const channelConfig = computed(() => window.chatwootWebChannel || {});
|
||||
|
||||
const inboxConfig = computed(() => ({
|
||||
workingHours: channelConfig.value.workingHours?.map(useCamelCase) || [],
|
||||
workingHoursEnabled: channelConfig.value.workingHoursEnabled || false,
|
||||
timezone: channelConfig.value.timezone || DEFAULT_TIMEZONE,
|
||||
utcOffset:
|
||||
channelConfig.value.utcOffset ||
|
||||
channelConfig.value.timezone ||
|
||||
DEFAULT_TIMEZONE,
|
||||
replyTime: channelConfig.value.replyTime || DEFAULT_REPLY_TIME,
|
||||
}));
|
||||
|
||||
const currentTime = computed(() => new Date());
|
||||
|
||||
const hasOnlineAgents = computed(() => {
|
||||
const agentList = availableAgents.value || [];
|
||||
return Array.isArray(agentList) ? agentList.length > 0 : false;
|
||||
});
|
||||
|
||||
const isInWorkingHours = computed(() =>
|
||||
checkInWorkingHours(
|
||||
currentTime.value,
|
||||
inboxConfig.value.utcOffset,
|
||||
inboxConfig.value.workingHours
|
||||
)
|
||||
);
|
||||
|
||||
// Check if online (considering both working hours and agents)
|
||||
const isOnline = computed(() =>
|
||||
checkIsOnline(
|
||||
inboxConfig.value.workingHoursEnabled,
|
||||
currentTime.value,
|
||||
inboxConfig.value.utcOffset,
|
||||
inboxConfig.value.workingHours,
|
||||
hasOnlineAgents.value
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
channelConfig,
|
||||
inboxConfig,
|
||||
|
||||
currentTime,
|
||||
availableAgents,
|
||||
hasOnlineAgents,
|
||||
|
||||
isOnline,
|
||||
isInWorkingHours,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { utcToZonedTime } from 'date-fns-tz';
|
||||
|
||||
// Constants
|
||||
const DAYS_IN_WEEK = 7;
|
||||
const MINUTES_IN_HOUR = 60;
|
||||
const MINUTES_IN_DAY = 24 * 60;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helper utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get date in timezone
|
||||
* @private
|
||||
* @param {Date|string} time
|
||||
* @param {string} utcOffset
|
||||
* @returns {Date}
|
||||
*/
|
||||
const getDateInTimezone = (time, utcOffset) => {
|
||||
const dateString = time instanceof Date ? time.toISOString() : time;
|
||||
try {
|
||||
return utcToZonedTime(dateString, utcOffset);
|
||||
} catch (error) {
|
||||
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`Invalid timezone: ${utcOffset}, falling back to user timezone: ${userTimezone}`
|
||||
);
|
||||
return utcToZonedTime(dateString, userTimezone);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert time to minutes
|
||||
* @private
|
||||
* @param {number} hours
|
||||
* @param {number} minutes
|
||||
* @returns {number}
|
||||
*/
|
||||
const toMinutes = (hours = 0, minutes = 0) => hours * MINUTES_IN_HOUR + minutes;
|
||||
|
||||
/**
|
||||
* Get today's config
|
||||
* @private
|
||||
* @param {Date|string} time
|
||||
* @param {string} utcOffset
|
||||
* @param {Array} workingHours
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
const getTodayConfig = (time, utcOffset, workingHours) => {
|
||||
const date = getDateInTimezone(time, utcOffset);
|
||||
const dayOfWeek = date.getDay();
|
||||
return workingHours.find(slot => slot.dayOfWeek === dayOfWeek) || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if current time is within working range, handling midnight crossing
|
||||
* @private
|
||||
* @param {number} currentMinutes
|
||||
* @param {number} openMinutes
|
||||
* @param {number} closeMinutes
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isTimeWithinRange = (currentMinutes, openMinutes, closeMinutes) => {
|
||||
const crossesMidnight = closeMinutes <= openMinutes;
|
||||
|
||||
return crossesMidnight
|
||||
? currentMinutes >= openMinutes || currentMinutes < closeMinutes
|
||||
: currentMinutes >= openMinutes && currentMinutes < closeMinutes;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a map keyed by `dayOfWeek` for all slots that are NOT closed all day.
|
||||
* @private
|
||||
*
|
||||
* @param {Array<Object>} workingHours - Full array of working-hour slot configs.
|
||||
* @returns {Map<number, Object>} Map where the key is the numeric day (0-6) and the value is the slot config.
|
||||
*/
|
||||
const getOpenDaysMap = workingHours =>
|
||||
new Map(
|
||||
(workingHours || [])
|
||||
.filter(slot => !slot.closedAllDay)
|
||||
.map(slot => [slot.dayOfWeek, slot])
|
||||
);
|
||||
|
||||
/**
|
||||
* Determine if today's slot is still upcoming.
|
||||
* @private
|
||||
* Returns an object with details if the slot is yet to open, otherwise `null`.
|
||||
*
|
||||
* @param {number} currentDay - `Date#getDay()` value (0-6) for current time.
|
||||
* @param {number} currentMinutes - Minutes since midnight for current time.
|
||||
* @param {Map<number, Object>} openDays - Map produced by `getOpenDaysMap`.
|
||||
* @returns {Object|null} Slot details (config, minutesUntilOpen, etc.) or `null`.
|
||||
*/
|
||||
const checkTodayAvailability = (currentDay, currentMinutes, openDays) => {
|
||||
const todayConfig = openDays.get(currentDay);
|
||||
if (!todayConfig || todayConfig.openAllDay) return null;
|
||||
|
||||
const todayOpenMinutes = toMinutes(
|
||||
todayConfig.openHour ?? 0,
|
||||
todayConfig.openMinutes ?? 0
|
||||
);
|
||||
|
||||
// Haven't opened yet today
|
||||
if (currentMinutes < todayOpenMinutes) {
|
||||
return {
|
||||
config: todayConfig,
|
||||
minutesUntilOpen: todayOpenMinutes - currentMinutes,
|
||||
daysUntilOpen: 0,
|
||||
dayOfWeek: currentDay,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Search the upcoming days (including tomorrow) for the next open slot.
|
||||
* @private
|
||||
*
|
||||
* @param {number} currentDay - Day index (0-6) representing today.
|
||||
* @param {number} currentMinutes - Minutes since midnight for current time.
|
||||
* @param {Map<number, Object>} openDays - Map of open day configs.
|
||||
* @returns {Object|null} Details of the next slot or `null` if none found.
|
||||
*/
|
||||
const findNextSlot = (currentDay, currentMinutes, openDays) =>
|
||||
Array.from({ length: DAYS_IN_WEEK }, (_, i) => i + 1)
|
||||
.map(daysAhead => {
|
||||
const targetDay = (currentDay + daysAhead) % DAYS_IN_WEEK;
|
||||
const config = openDays.get(targetDay);
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
// Calculate minutes until this slot opens
|
||||
const slotOpenMinutes = config.openAllDay
|
||||
? 0
|
||||
: toMinutes(config.openHour ?? 0, config.openMinutes ?? 0);
|
||||
const minutesUntilOpen =
|
||||
MINUTES_IN_DAY -
|
||||
currentMinutes + // remaining mins today
|
||||
(daysAhead - 1) * MINUTES_IN_DAY + // full days between
|
||||
slotOpenMinutes; // opening on target day
|
||||
|
||||
return {
|
||||
config,
|
||||
minutesUntilOpen,
|
||||
daysUntilOpen: daysAhead,
|
||||
dayOfWeek: targetDay,
|
||||
};
|
||||
})
|
||||
.find(Boolean) || null;
|
||||
|
||||
/**
|
||||
* Convert slot details from inbox timezone to user timezone
|
||||
* @private
|
||||
* @param {Date} time - Current time
|
||||
* @param {string} inboxTz - Inbox timezone
|
||||
* @param {Object} slotDetails - Original slot details
|
||||
* @returns {Object} Slot details with user timezone adjustments
|
||||
*/
|
||||
const convertSlotToUserTimezone = (time, inboxTz, slotDetails) => {
|
||||
if (!slotDetails) return null;
|
||||
|
||||
const userTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
// If timezones match, no conversion needed
|
||||
if (inboxTz === userTz) return slotDetails;
|
||||
|
||||
// Calculate when the slot opens (absolute time)
|
||||
const now = time instanceof Date ? time : new Date(time);
|
||||
const openingTime = new Date(
|
||||
now.getTime() + slotDetails.minutesUntilOpen * 60000
|
||||
);
|
||||
|
||||
// Convert to user timezone
|
||||
const openingInUserTz = getDateInTimezone(openingTime, userTz);
|
||||
const nowInUserTz = getDateInTimezone(now, userTz);
|
||||
|
||||
// Calculate days difference in user timezone
|
||||
const openingDate = new Date(openingInUserTz);
|
||||
openingDate.setHours(0, 0, 0, 0);
|
||||
const todayDate = new Date(nowInUserTz);
|
||||
todayDate.setHours(0, 0, 0, 0);
|
||||
const daysUntilOpen = Math.round((openingDate - todayDate) / 86400000);
|
||||
|
||||
// Return with user timezone adjustments
|
||||
return {
|
||||
...slotDetails,
|
||||
config: {
|
||||
...slotDetails.config,
|
||||
openHour: openingInUserTz.getHours(),
|
||||
openMinutes: openingInUserTz.getMinutes(),
|
||||
dayOfWeek: openingInUserTz.getDay(),
|
||||
},
|
||||
daysUntilOpen,
|
||||
dayOfWeek: openingInUserTz.getDay(),
|
||||
};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exported functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Check if open all day
|
||||
* @param {Date|string} time
|
||||
* @param {string} utcOffset
|
||||
* @param {Array} workingHours
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isOpenAllDay = (time, utcOffset, workingHours = []) => {
|
||||
const todayConfig = getTodayConfig(time, utcOffset, workingHours);
|
||||
return todayConfig?.openAllDay === true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if closed all day
|
||||
* @param {Date|string} time
|
||||
* @param {string} utcOffset
|
||||
* @param {Array} workingHours
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isClosedAllDay = (time, utcOffset, workingHours = []) => {
|
||||
const todayConfig = getTodayConfig(time, utcOffset, workingHours);
|
||||
return todayConfig?.closedAllDay === true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if in working hours
|
||||
* @param {Date|string} time
|
||||
* @param {string} utcOffset
|
||||
* @param {Array} workingHours
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isInWorkingHours = (time, utcOffset, workingHours = []) => {
|
||||
if (!workingHours.length) return false;
|
||||
|
||||
const todayConfig = getTodayConfig(time, utcOffset, workingHours);
|
||||
if (!todayConfig) return false;
|
||||
|
||||
// Handle all-day states
|
||||
if (todayConfig.openAllDay) return true;
|
||||
if (todayConfig.closedAllDay) return false;
|
||||
|
||||
// Check time-based availability
|
||||
const date = getDateInTimezone(time, utcOffset);
|
||||
const currentMinutes = toMinutes(date.getHours(), date.getMinutes());
|
||||
|
||||
const openMinutes = toMinutes(
|
||||
todayConfig.openHour ?? 0,
|
||||
todayConfig.openMinutes ?? 0
|
||||
);
|
||||
const closeMinutes = toMinutes(
|
||||
todayConfig.closeHour ?? 0,
|
||||
todayConfig.closeMinutes ?? 0
|
||||
);
|
||||
|
||||
return isTimeWithinRange(currentMinutes, openMinutes, closeMinutes);
|
||||
};
|
||||
|
||||
/**
|
||||
* Find next available slot with detailed information
|
||||
* Returns times adjusted to user's timezone for display
|
||||
* @param {Date|string} time
|
||||
* @param {string} utcOffset
|
||||
* @param {Array} workingHours
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
export const findNextAvailableSlotDetails = (
|
||||
time,
|
||||
utcOffset,
|
||||
workingHours = []
|
||||
) => {
|
||||
const date = getDateInTimezone(time, utcOffset);
|
||||
const currentDay = date.getDay();
|
||||
const currentMinutes = toMinutes(date.getHours(), date.getMinutes());
|
||||
|
||||
const openDays = getOpenDaysMap(workingHours);
|
||||
|
||||
// No open days at all
|
||||
if (openDays.size === 0) return null;
|
||||
|
||||
// Check today first
|
||||
const todaySlot = checkTodayAvailability(
|
||||
currentDay,
|
||||
currentMinutes,
|
||||
openDays
|
||||
);
|
||||
|
||||
// Find the slot (today or next)
|
||||
const slotDetails =
|
||||
todaySlot || findNextSlot(currentDay, currentMinutes, openDays);
|
||||
|
||||
// Convert to user timezone for display
|
||||
return convertSlotToUserTimezone(time, utcOffset, slotDetails);
|
||||
};
|
||||
|
||||
/**
|
||||
* Find minutes until next available slot
|
||||
* @param {Date|string} time
|
||||
* @param {string} utcOffset
|
||||
* @param {Array} workingHours
|
||||
* @returns {number|null}
|
||||
*/
|
||||
export const findNextAvailableSlotDiff = (
|
||||
time,
|
||||
utcOffset,
|
||||
workingHours = []
|
||||
) => {
|
||||
if (isInWorkingHours(time, utcOffset, workingHours)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const nextSlot = findNextAvailableSlotDetails(time, utcOffset, workingHours);
|
||||
return nextSlot ? nextSlot.minutesUntilOpen : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if online
|
||||
* @param {boolean} workingHoursEnabled
|
||||
* @param {Date|string} time
|
||||
* @param {string} utcOffset
|
||||
* @param {Array} workingHours
|
||||
* @param {boolean} hasOnlineAgents
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isOnline = (
|
||||
workingHoursEnabled,
|
||||
time,
|
||||
utcOffset,
|
||||
workingHours,
|
||||
hasOnlineAgents
|
||||
) => {
|
||||
if (!workingHoursEnabled) {
|
||||
return hasOnlineAgents;
|
||||
}
|
||||
|
||||
const inWorkingHours = isInWorkingHours(time, utcOffset, workingHours);
|
||||
return inWorkingHours && hasOnlineAgents;
|
||||
};
|
||||
@@ -0,0 +1,580 @@
|
||||
import { utcToZonedTime } from 'date-fns-tz';
|
||||
import {
|
||||
isOpenAllDay,
|
||||
isClosedAllDay,
|
||||
isInWorkingHours,
|
||||
findNextAvailableSlotDetails,
|
||||
findNextAvailableSlotDiff,
|
||||
isOnline,
|
||||
} from '../availabilityHelpers';
|
||||
|
||||
// Mock date-fns-tz
|
||||
vi.mock('date-fns-tz', () => ({
|
||||
utcToZonedTime: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('availabilityHelpers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('isOpenAllDay', () => {
|
||||
it('should return true when slot is marked as open_all_day', () => {
|
||||
const mockDate = new Date('2024-01-15T10:00:00.000Z'); // Monday
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [{ dayOfWeek: 1, openAllDay: true }];
|
||||
|
||||
expect(isOpenAllDay(new Date(), 'UTC', workingHours)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when slot is not open_all_day', () => {
|
||||
const mockDate = new Date('2024-01-15T10:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [
|
||||
{ dayOfWeek: 1, openHour: 9, closeHour: 17, openAllDay: false },
|
||||
];
|
||||
|
||||
expect(isOpenAllDay(new Date(), 'UTC', workingHours)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when no config exists for the day', () => {
|
||||
const mockDate = new Date('2024-01-15T10:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [
|
||||
{ dayOfWeek: 2, openHour: 9, closeHour: 17 }, // Tuesday config
|
||||
];
|
||||
|
||||
expect(isOpenAllDay(new Date(), 'UTC', workingHours)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isClosedAllDay', () => {
|
||||
it('should return true when slot is marked as closed_all_day', () => {
|
||||
const mockDate = new Date('2024-01-15T10:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [{ dayOfWeek: 1, closedAllDay: true }];
|
||||
|
||||
expect(isClosedAllDay(new Date(), 'UTC', workingHours)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when slot is not closed_all_day', () => {
|
||||
const mockDate = new Date('2024-01-15T10:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [
|
||||
{ dayOfWeek: 1, openHour: 9, closeHour: 17, closedAllDay: false },
|
||||
];
|
||||
|
||||
expect(isClosedAllDay(new Date(), 'UTC', workingHours)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when no config exists for the day', () => {
|
||||
const mockDate = new Date('2024-01-15T10:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [{ dayOfWeek: 2, openHour: 9, closeHour: 17 }];
|
||||
|
||||
expect(isClosedAllDay(new Date(), 'UTC', workingHours)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInWorkingHours', () => {
|
||||
it('should return false when no working hours are configured', () => {
|
||||
expect(isInWorkingHours(new Date(), 'UTC', [])).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when open_all_day is true', () => {
|
||||
const mockDate = new Date('2024-01-15T10:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(10);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [{ dayOfWeek: 1, openAllDay: true }];
|
||||
|
||||
expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when closed_all_day is true', () => {
|
||||
const mockDate = new Date('2024-01-15T10:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(10);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [{ dayOfWeek: 1, closedAllDay: true }];
|
||||
|
||||
expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when current time is within working hours', () => {
|
||||
const mockDate = new Date('2024-01-15T10:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(10);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [
|
||||
{
|
||||
dayOfWeek: 1,
|
||||
openHour: 9,
|
||||
openMinutes: 0,
|
||||
closeHour: 17,
|
||||
closeMinutes: 0,
|
||||
},
|
||||
];
|
||||
|
||||
expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when current time is before opening', () => {
|
||||
const mockDate = new Date('2024-01-15T08:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(8);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
|
||||
|
||||
expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when current time is after closing', () => {
|
||||
const mockDate = new Date('2024-01-15T18:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(18);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
|
||||
|
||||
expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle minutes in time comparison', () => {
|
||||
const mockDate = new Date('2024-01-15T09:30:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(9);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(30);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [
|
||||
{
|
||||
dayOfWeek: 1,
|
||||
openHour: 9,
|
||||
openMinutes: 15,
|
||||
closeHour: 17,
|
||||
closeMinutes: 30,
|
||||
},
|
||||
];
|
||||
|
||||
expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when no config for current day', () => {
|
||||
const mockDate = new Date('2024-01-15T10:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(10);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [
|
||||
{ dayOfWeek: 2, openHour: 9, closeHour: 17 }, // Only Tuesday
|
||||
];
|
||||
|
||||
expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findNextAvailableSlotDetails', () => {
|
||||
it('should return null when no open days exist', () => {
|
||||
const mockDate = new Date('2024-01-15T10:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(10);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [
|
||||
{ dayOfWeek: 0, closedAllDay: true },
|
||||
{ dayOfWeek: 1, closedAllDay: true },
|
||||
{ dayOfWeek: 2, closedAllDay: true },
|
||||
{ dayOfWeek: 3, closedAllDay: true },
|
||||
{ dayOfWeek: 4, closedAllDay: true },
|
||||
{ dayOfWeek: 5, closedAllDay: true },
|
||||
{ dayOfWeek: 6, closedAllDay: true },
|
||||
];
|
||||
|
||||
expect(
|
||||
findNextAvailableSlotDetails(new Date(), 'UTC', workingHours)
|
||||
).toBe(null);
|
||||
});
|
||||
|
||||
it('should return today slot when not opened yet', () => {
|
||||
const mockDate = new Date('2024-01-15T08:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(8);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [
|
||||
{ dayOfWeek: 1, openHour: 9, openMinutes: 30, closeHour: 17 },
|
||||
];
|
||||
|
||||
const result = findNextAvailableSlotDetails(
|
||||
new Date(),
|
||||
'UTC',
|
||||
workingHours
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
config: workingHours[0],
|
||||
minutesUntilOpen: 90, // 1.5 hours = 90 minutes
|
||||
daysUntilOpen: 0,
|
||||
dayOfWeek: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return tomorrow slot when today is past closing', () => {
|
||||
const mockDate = new Date('2024-01-15T18:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(18);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [
|
||||
{ dayOfWeek: 1, openHour: 9, closeHour: 17 },
|
||||
{ dayOfWeek: 2, openHour: 9, closeHour: 17 },
|
||||
];
|
||||
|
||||
const result = findNextAvailableSlotDetails(
|
||||
new Date(),
|
||||
'UTC',
|
||||
workingHours
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
config: workingHours[1],
|
||||
minutesUntilOpen: 900, // 15 hours = 900 minutes
|
||||
daysUntilOpen: 1,
|
||||
dayOfWeek: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip closed days and find next open day', () => {
|
||||
const mockDate = new Date('2024-01-15T18:00:00.000Z'); // Monday evening
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(18);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [
|
||||
{ dayOfWeek: 1, openHour: 9, closeHour: 17 },
|
||||
{ dayOfWeek: 2, closedAllDay: true },
|
||||
{ dayOfWeek: 3, closedAllDay: true },
|
||||
{ dayOfWeek: 4, openHour: 10, closeHour: 16 },
|
||||
];
|
||||
|
||||
const result = findNextAvailableSlotDetails(
|
||||
new Date(),
|
||||
'UTC',
|
||||
workingHours
|
||||
);
|
||||
|
||||
// Monday 18:00 to Thursday 10:00
|
||||
// Rest of Monday: 6 hours (18:00 to 24:00) = 360 minutes
|
||||
// Tuesday: 24 hours = 1440 minutes
|
||||
// Wednesday: 24 hours = 1440 minutes
|
||||
// Thursday morning: 10 hours = 600 minutes
|
||||
// Total: 360 + 1440 + 1440 + 600 = 3840 minutes
|
||||
expect(result).toEqual({
|
||||
config: workingHours[3],
|
||||
minutesUntilOpen: 3840, // 64 hours = 3840 minutes
|
||||
daysUntilOpen: 3,
|
||||
dayOfWeek: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle open_all_day slots', () => {
|
||||
const mockDate = new Date('2024-01-15T18:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(18);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [
|
||||
{ dayOfWeek: 1, openHour: 9, closeHour: 17 },
|
||||
{ dayOfWeek: 2, openAllDay: true },
|
||||
];
|
||||
|
||||
const result = findNextAvailableSlotDetails(
|
||||
new Date(),
|
||||
'UTC',
|
||||
workingHours
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
config: workingHours[1],
|
||||
minutesUntilOpen: 360, // 6 hours to midnight = 360 minutes
|
||||
daysUntilOpen: 1,
|
||||
dayOfWeek: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should wrap around week correctly', () => {
|
||||
const mockDate = new Date('2024-01-20T18:00:00.000Z'); // Saturday evening
|
||||
mockDate.getDay = vi.fn().mockReturnValue(6);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(18);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [
|
||||
{ dayOfWeek: 1, openHour: 9, closeHour: 17 }, // Monday
|
||||
];
|
||||
|
||||
const result = findNextAvailableSlotDetails(
|
||||
new Date(),
|
||||
'UTC',
|
||||
workingHours
|
||||
);
|
||||
|
||||
// Saturday 18:00 to Monday 9:00
|
||||
// Rest of Saturday: 6 hours = 360 minutes
|
||||
// Sunday: 24 hours = 1440 minutes
|
||||
// Monday morning: 9 hours = 540 minutes
|
||||
// Total: 360 + 1440 + 540 = 2340 minutes
|
||||
expect(result).toEqual({
|
||||
config: workingHours[0],
|
||||
minutesUntilOpen: 2340, // 39 hours = 2340 minutes
|
||||
daysUntilOpen: 2,
|
||||
dayOfWeek: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle today open_all_day correctly', () => {
|
||||
const mockDate = new Date('2024-01-15T10:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(10);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [
|
||||
{ dayOfWeek: 1, openAllDay: true },
|
||||
{ dayOfWeek: 2, openHour: 9, closeHour: 17 },
|
||||
];
|
||||
|
||||
// Should skip today since it's open_all_day and look for next slot
|
||||
const result = findNextAvailableSlotDetails(
|
||||
new Date(),
|
||||
'UTC',
|
||||
workingHours
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
config: workingHours[1],
|
||||
minutesUntilOpen: 1380, // Rest of today + 9 hours tomorrow = 1380 minutes
|
||||
daysUntilOpen: 1,
|
||||
dayOfWeek: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findNextAvailableSlotDiff', () => {
|
||||
it('should return 0 when currently in working hours', () => {
|
||||
const mockDate = new Date('2024-01-15T10:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(10);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
|
||||
|
||||
expect(findNextAvailableSlotDiff(new Date(), 'UTC', workingHours)).toBe(
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
it('should return minutes until next slot when not in working hours', () => {
|
||||
const mockDate = new Date('2024-01-15T08:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(8);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
|
||||
|
||||
expect(findNextAvailableSlotDiff(new Date(), 'UTC', workingHours)).toBe(
|
||||
60
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null when no next slot available', () => {
|
||||
const mockDate = new Date('2024-01-15T10:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(10);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [
|
||||
{ dayOfWeek: 0, closedAllDay: true },
|
||||
{ dayOfWeek: 1, closedAllDay: true },
|
||||
{ dayOfWeek: 2, closedAllDay: true },
|
||||
{ dayOfWeek: 3, closedAllDay: true },
|
||||
{ dayOfWeek: 4, closedAllDay: true },
|
||||
{ dayOfWeek: 5, closedAllDay: true },
|
||||
{ dayOfWeek: 6, closedAllDay: true },
|
||||
];
|
||||
|
||||
expect(findNextAvailableSlotDiff(new Date(), 'UTC', workingHours)).toBe(
|
||||
null
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOnline', () => {
|
||||
it('should return agent status when working hours disabled', () => {
|
||||
expect(isOnline(false, new Date(), 'UTC', [], true)).toBe(true);
|
||||
expect(isOnline(false, new Date(), 'UTC', [], false)).toBe(false);
|
||||
});
|
||||
|
||||
it('should check both working hours and agents when enabled', () => {
|
||||
const mockDate = new Date('2024-01-15T10:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(10);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
|
||||
|
||||
// In working hours + agents available = online
|
||||
expect(isOnline(true, new Date(), 'UTC', workingHours, true)).toBe(true);
|
||||
|
||||
// In working hours but no agents = offline
|
||||
expect(isOnline(true, new Date(), 'UTC', workingHours, false)).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false when outside working hours even with agents', () => {
|
||||
const mockDate = new Date('2024-01-15T08:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(8);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
|
||||
|
||||
expect(isOnline(true, new Date(), 'UTC', workingHours, true)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle open_all_day with agents', () => {
|
||||
const mockDate = new Date('2024-01-15T02:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(2);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [{ dayOfWeek: 1, openAllDay: true }];
|
||||
|
||||
expect(isOnline(true, new Date(), 'UTC', workingHours, true)).toBe(true);
|
||||
expect(isOnline(true, new Date(), 'UTC', workingHours, false)).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle string date input', () => {
|
||||
const mockDate = new Date('2024-01-15T10:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(10);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
|
||||
|
||||
expect(
|
||||
isOnline(true, '2024-01-15T10:00:00.000Z', 'UTC', workingHours, true)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Timezone handling', () => {
|
||||
it('should correctly handle different timezones', () => {
|
||||
const mockDate = new Date('2024-01-15T15:30:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(15);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(30);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
|
||||
|
||||
expect(isInWorkingHours(new Date(), 'Asia/Kolkata', workingHours)).toBe(
|
||||
true
|
||||
);
|
||||
expect(vi.mocked(utcToZonedTime)).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'Asia/Kolkata'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle UTC offset format', () => {
|
||||
const mockDate = new Date('2024-01-15T10:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(10);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
|
||||
|
||||
expect(isInWorkingHours(new Date(), '+05:30', workingHours)).toBe(true);
|
||||
expect(vi.mocked(utcToZonedTime)).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'+05:30'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle working hours at exact boundaries', () => {
|
||||
// Test at exact opening time
|
||||
const mockDate1 = new Date('2024-01-15T09:00:00.000Z');
|
||||
mockDate1.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate1.getHours = vi.fn().mockReturnValue(9);
|
||||
mockDate1.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate1);
|
||||
|
||||
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
|
||||
|
||||
expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(true);
|
||||
|
||||
// Test at exact closing time
|
||||
const mockDate2 = new Date('2024-01-15T17:00:00.000Z');
|
||||
mockDate2.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate2.getHours = vi.fn().mockReturnValue(17);
|
||||
mockDate2.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate2);
|
||||
|
||||
expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle one minute before closing', () => {
|
||||
const mockDate = new Date('2024-01-15T16:59:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(16);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(59);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
|
||||
|
||||
expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -19,13 +19,19 @@
|
||||
},
|
||||
"TEAM_AVAILABILITY": {
|
||||
"ONLINE": "We are online",
|
||||
"OFFLINE": "We are away at the moment"
|
||||
"OFFLINE": "We are away at the moment",
|
||||
"BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible"
|
||||
},
|
||||
"REPLY_TIME": {
|
||||
"IN_A_FEW_MINUTES": "Typically replies in a few minutes",
|
||||
"IN_A_FEW_HOURS": "Typically replies in a few hours",
|
||||
"IN_A_DAY": "Typically replies in a day",
|
||||
"BACK_IN": "We will be back online"
|
||||
"BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours",
|
||||
"BACK_IN_MINUTES": "We will be back online in {time} minutes",
|
||||
"BACK_AT_TIME": "We will be back online at {time}",
|
||||
"BACK_ON_DAY": "We will be back online on {day}",
|
||||
"BACK_TOMORROW": "We will be back online tomorrow",
|
||||
"BACK_IN_SOME_TIME": "We will be back online in some time"
|
||||
},
|
||||
"DAY_NAMES": {
|
||||
"SUNDAY": "Sunday",
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { utcToZonedTime } from 'date-fns-tz';
|
||||
import { isTimeAfter } from 'shared/helpers/DateHelper';
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
channelConfig() {
|
||||
return window.chatwootWebChannel;
|
||||
},
|
||||
replyTime() {
|
||||
return window.chatwootWebChannel.replyTime;
|
||||
},
|
||||
replyTimeStatus() {
|
||||
switch (this.replyTime) {
|
||||
case 'in_a_few_minutes':
|
||||
return this.$t('REPLY_TIME.IN_A_FEW_MINUTES');
|
||||
case 'in_a_few_hours':
|
||||
return this.$t('REPLY_TIME.IN_A_FEW_HOURS');
|
||||
case 'in_a_day':
|
||||
return this.$t('REPLY_TIME.IN_A_DAY');
|
||||
default:
|
||||
return this.$t('REPLY_TIME.IN_A_FEW_HOURS');
|
||||
}
|
||||
},
|
||||
replyWaitMessage() {
|
||||
const { workingHoursEnabled } = this.channelConfig;
|
||||
if (workingHoursEnabled) {
|
||||
return this.isOnline
|
||||
? this.replyTimeStatus
|
||||
: `${this.$t('REPLY_TIME.BACK_IN')} ${this.timeLeftToBackInOnline}`;
|
||||
}
|
||||
return this.isOnline
|
||||
? this.replyTimeStatus
|
||||
: this.$t('TEAM_AVAILABILITY.OFFLINE');
|
||||
},
|
||||
outOfOfficeMessage() {
|
||||
return this.channelConfig.outOfOfficeMessage;
|
||||
},
|
||||
isInBetweenTheWorkingHours() {
|
||||
const {
|
||||
openHour,
|
||||
openMinute,
|
||||
closeHour,
|
||||
closeMinute,
|
||||
closedAllDay,
|
||||
openAllDay,
|
||||
} = this.currentDayAvailability;
|
||||
|
||||
if (openAllDay) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (closedAllDay) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { utcOffset } = this.channelConfig;
|
||||
const today = this.getDateWithOffset(utcOffset);
|
||||
const currentHours = today.getHours();
|
||||
const currentMinutes = today.getMinutes();
|
||||
const isAfterStartTime = isTimeAfter(
|
||||
currentHours,
|
||||
currentMinutes,
|
||||
openHour,
|
||||
openMinute
|
||||
);
|
||||
const isBeforeEndTime = isTimeAfter(
|
||||
closeHour,
|
||||
closeMinute,
|
||||
currentHours,
|
||||
currentMinutes
|
||||
);
|
||||
return isAfterStartTime && isBeforeEndTime;
|
||||
},
|
||||
currentDayAvailability() {
|
||||
const { utcOffset } = this.channelConfig;
|
||||
const dayOfTheWeek = this.getDateWithOffset(utcOffset).getDay();
|
||||
const [workingHourConfig = {}] = this.channelConfig.workingHours.filter(
|
||||
workingHour => workingHour.day_of_week === dayOfTheWeek
|
||||
);
|
||||
return {
|
||||
closedAllDay: workingHourConfig.closed_all_day,
|
||||
openHour: workingHourConfig.open_hour,
|
||||
openMinute: workingHourConfig.open_minutes,
|
||||
closeHour: workingHourConfig.close_hour,
|
||||
closeMinute: workingHourConfig.close_minutes,
|
||||
openAllDay: workingHourConfig.open_all_day,
|
||||
};
|
||||
},
|
||||
isInBusinessHours() {
|
||||
const { workingHoursEnabled } = this.channelConfig;
|
||||
return workingHoursEnabled ? this.isInBetweenTheWorkingHours : true;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
getDateWithOffset(utcOffset) {
|
||||
return utcToZonedTime(new Date().toISOString(), utcOffset);
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,250 +0,0 @@
|
||||
import {
|
||||
timeSlotParse,
|
||||
defaultTimeSlot,
|
||||
} from 'dashboard/routes/dashboard/settings/inbox/helpers/businessHour.js';
|
||||
import { utcToZonedTime } from 'date-fns-tz';
|
||||
import { generateRelativeTime } from 'shared/helpers/DateHelper';
|
||||
|
||||
const MINUTE_ROUNDING_FACTOR = 5;
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
dayNames: [
|
||||
this.$t('DAY_NAMES.SUNDAY'),
|
||||
this.$t('DAY_NAMES.MONDAY'),
|
||||
this.$t('DAY_NAMES.TUESDAY'),
|
||||
this.$t('DAY_NAMES.WEDNESDAY'),
|
||||
this.$t('DAY_NAMES.THURSDAY'),
|
||||
this.$t('DAY_NAMES.FRIDAY'),
|
||||
this.$t('DAY_NAMES.SATURDAY'),
|
||||
],
|
||||
timeSlots: [...defaultTimeSlot],
|
||||
timeSlot: {},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
channelConfig() {
|
||||
return window.chatwootWebChannel;
|
||||
},
|
||||
workingHours() {
|
||||
return this.channelConfig.workingHours;
|
||||
},
|
||||
newDateWithTimeZone() {
|
||||
return utcToZonedTime(new Date(), this.timeZoneValue);
|
||||
},
|
||||
presentHour() {
|
||||
return this.newDateWithTimeZone.getHours();
|
||||
},
|
||||
presentMinute() {
|
||||
return this.newDateWithTimeZone.getMinutes();
|
||||
},
|
||||
currentDay() {
|
||||
const date = this.newDateWithTimeZone;
|
||||
const day = date.getDay();
|
||||
const currentDay = Object.keys(this.dayNames).find(
|
||||
key => this.dayNames[key] === this.dayNames[day]
|
||||
);
|
||||
return Number(currentDay);
|
||||
},
|
||||
timeZoneValue() {
|
||||
return this.channelConfig.timezone;
|
||||
},
|
||||
languageCode() {
|
||||
return window.chatwootWebChannel.locale;
|
||||
},
|
||||
currentDayWorkingHours() {
|
||||
return this.workingHours.find(
|
||||
slot => slot.day_of_week === this.currentDay
|
||||
);
|
||||
},
|
||||
nextDayWorkingHours() {
|
||||
let nextDay = this.getNextDay(this.currentDay);
|
||||
let nextWorkingHour = this.getNextWorkingHour(nextDay);
|
||||
|
||||
// It gets the next working hour for the next day. If there is no working hour for the next day,
|
||||
// it keeps iterating through the days of the week until it finds the next working hour.
|
||||
while (!nextWorkingHour) {
|
||||
nextDay = this.getNextDay(nextDay);
|
||||
nextWorkingHour = this.getNextWorkingHour(nextDay);
|
||||
}
|
||||
return nextWorkingHour;
|
||||
},
|
||||
currentDayTimings() {
|
||||
const {
|
||||
open_hour: openHour,
|
||||
open_minutes: openMinute,
|
||||
close_hour: closeHour,
|
||||
} = this.currentDayWorkingHours ?? {};
|
||||
return {
|
||||
openHour,
|
||||
openMinute,
|
||||
closeHour,
|
||||
};
|
||||
},
|
||||
nextDayTimings() {
|
||||
const { open_hour: openHour, open_minutes: openMinute } =
|
||||
this.nextDayWorkingHours ?? {};
|
||||
return {
|
||||
openHour,
|
||||
openMinute,
|
||||
};
|
||||
},
|
||||
dayDiff() {
|
||||
// Here this is used to get the difference between current day and next working day
|
||||
const nextDay = this.nextDayWorkingHours.day_of_week;
|
||||
const totalDays = 6;
|
||||
return nextDay > this.currentDay
|
||||
? nextDay - this.currentDay - 1
|
||||
: totalDays - this.currentDay + nextDay;
|
||||
},
|
||||
dayNameOfNextWorkingDay() {
|
||||
return this.dayNames[this.nextDayWorkingHours.day_of_week];
|
||||
},
|
||||
hoursAndMinutesBackInOnline() {
|
||||
if (this.presentHour >= this.currentDayTimings.closeHour) {
|
||||
return this.getHoursAndMinutesUntilNextDayOpen(
|
||||
this.nextDayWorkingHours.open_all_day
|
||||
? 0
|
||||
: this.nextDayTimings.openHour,
|
||||
this.nextDayTimings.openMinute,
|
||||
this.currentDayTimings.closeHour
|
||||
);
|
||||
}
|
||||
return this.getHoursAndMinutesUntilNextDayOpen(
|
||||
this.currentDayTimings.openHour,
|
||||
this.currentDayTimings.openMinute,
|
||||
this.currentDayTimings.closeHour
|
||||
);
|
||||
},
|
||||
exactTimeInAmPm() {
|
||||
return `${
|
||||
this.timeSlot.day === this.currentDay ? `at ${this.timeSlot.from}` : ''
|
||||
}`;
|
||||
},
|
||||
hoursAndMinutesLeft() {
|
||||
const { hoursLeft, minutesLeft } = this.hoursAndMinutesBackInOnline;
|
||||
|
||||
const timeLeftChars = [];
|
||||
|
||||
if (hoursLeft > 0) {
|
||||
const roundedUpHoursLeft = minutesLeft > 0 ? hoursLeft + 1 : hoursLeft;
|
||||
const hourRelative = generateRelativeTime(
|
||||
roundedUpHoursLeft,
|
||||
'hour',
|
||||
this.languageCode
|
||||
);
|
||||
timeLeftChars.push(`${hourRelative}`);
|
||||
}
|
||||
|
||||
if (minutesLeft > 0 && hoursLeft === 0) {
|
||||
const roundedUpMinLeft =
|
||||
Math.ceil(minutesLeft / MINUTE_ROUNDING_FACTOR) *
|
||||
MINUTE_ROUNDING_FACTOR;
|
||||
const minRelative = generateRelativeTime(
|
||||
roundedUpMinLeft,
|
||||
'minutes',
|
||||
this.languageCode
|
||||
);
|
||||
timeLeftChars.push(`${minRelative}`);
|
||||
}
|
||||
|
||||
return timeLeftChars.join(' ');
|
||||
},
|
||||
hoursAndMinutesToBack() {
|
||||
const { hoursLeft, minutesLeft } = this.hoursAndMinutesBackInOnline;
|
||||
if (hoursLeft >= 3) {
|
||||
return this.exactTimeInAmPm;
|
||||
}
|
||||
if (hoursLeft > 0 || minutesLeft > 0) {
|
||||
return this.hoursAndMinutesLeft;
|
||||
}
|
||||
return 'in some time';
|
||||
},
|
||||
timeLeftToBackInOnline() {
|
||||
if (
|
||||
this.hoursAndMinutesBackInOnline.hoursLeft >= 24 ||
|
||||
(this.timeSlot.day !== this.currentDay && this.dayDiff === 0)
|
||||
) {
|
||||
const hourRelative = generateRelativeTime(
|
||||
this.dayDiff + 1,
|
||||
'days',
|
||||
this.languageCode
|
||||
);
|
||||
return `${hourRelative}`;
|
||||
}
|
||||
if (
|
||||
this.dayDiff >= 1 &&
|
||||
this.presentHour >= this.currentDayTimings.closeHour
|
||||
) {
|
||||
return `on ${this.dayNameOfNextWorkingDay}`;
|
||||
}
|
||||
return this.hoursAndMinutesToBack;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setTimeSlot();
|
||||
},
|
||||
methods: {
|
||||
getNextDay(day) {
|
||||
// This code calculates the next day of the week based on the current day. If the current day is Saturday (6), then the next day will be Sunday (0).
|
||||
return (day + 1) % 7;
|
||||
},
|
||||
getNextWorkingHour(day) {
|
||||
const workingHour = this.workingHours.find(
|
||||
slot => slot.day_of_week === day
|
||||
);
|
||||
if (workingHour && !workingHour.closed_all_day) {
|
||||
return workingHour;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getHoursAndMinutesUntilNextDayOpen(
|
||||
openHour, // If the present time is after the closing time of the current day, then the openHour will be the opening hour of the next day else it will be the opening hour of the current day.
|
||||
openMinutes, // If the present time is after the closing time of the current day, then the openMinutes will be the opening minutes of the next day else it will be the opening minutes of the current day.
|
||||
closeHour // The closeHour will be the closing hour of the current day. It will be used to calculate the time remaining until the next day's opening hours.
|
||||
) {
|
||||
// This code calculates the time remaining until the next day's opening hours,
|
||||
// given the current time, the opening hours, and the closing hours of the current day.
|
||||
if (closeHour < openHour) {
|
||||
openHour += 24;
|
||||
}
|
||||
let diffMinutes =
|
||||
openHour * 60 +
|
||||
openMinutes -
|
||||
(this.presentHour * 60 + this.presentMinute);
|
||||
diffMinutes = diffMinutes < 0 ? diffMinutes + 24 * 60 : diffMinutes;
|
||||
const [hoursLeft, minutesLeft] = [
|
||||
Math.floor(diffMinutes / 60),
|
||||
diffMinutes % 60,
|
||||
];
|
||||
|
||||
// It returns the remaining time in hours and minutes as an object with keys hours and minutes.
|
||||
return { hoursLeft, minutesLeft };
|
||||
},
|
||||
setTimeSlot() {
|
||||
// It checks if the working hours feature is enabled for the store.
|
||||
|
||||
const timeSlots = this.workingHours;
|
||||
|
||||
// If the present hour is after the closing hour of the current day,
|
||||
// then the next day's working hours will be used to calculate the time remaining until the next day's opening hours,
|
||||
// else the current day's working hours will be used
|
||||
const currentSlot =
|
||||
this.presentHour >= this.currentDayTimings.closeHour
|
||||
? this.nextDayWorkingHours
|
||||
: this.currentDayWorkingHours;
|
||||
|
||||
// It parses the working hours to get the time slots in AM/PM format.
|
||||
const slots = timeSlotParse(timeSlots).length
|
||||
? timeSlotParse(timeSlots)
|
||||
: defaultTimeSlot;
|
||||
this.timeSlots = slots;
|
||||
|
||||
// It finds the time slot for the current slot.
|
||||
this.timeSlot = this.timeSlots.find(
|
||||
slot => slot.day === currentSlot.day_of_week
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
export default {
|
||||
methods: {
|
||||
async replaceRoute(name, params = {}) {
|
||||
if (this.$route.name !== name) {
|
||||
return this.$router.replace({ name, params });
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,87 +0,0 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import availabilityMixin from '../availability';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
global.chatwootWebChannel = {
|
||||
workingHoursEnabled: true,
|
||||
workingHours: [
|
||||
{
|
||||
day_of_week: 3,
|
||||
closed_all_day: false,
|
||||
open_hour: 8,
|
||||
open_minutes: 30,
|
||||
close_hour: 17,
|
||||
close_minutes: 35,
|
||||
open_all_day: false,
|
||||
},
|
||||
{
|
||||
day_of_week: 4,
|
||||
closed_all_day: false,
|
||||
open_hour: 8,
|
||||
open_minutes: 30,
|
||||
close_hour: 17,
|
||||
close_minutes: 30,
|
||||
open_all_day: false,
|
||||
},
|
||||
],
|
||||
utcOffset: '-07:00',
|
||||
};
|
||||
|
||||
let Component;
|
||||
|
||||
describe('availabilityMixin', () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
Component = defineComponent({
|
||||
mixins: [availabilityMixin],
|
||||
render() {
|
||||
return h('div');
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns valid isInBetweenWorkingHours if in different timezone', () => {
|
||||
vi.useFakeTimers().setSystemTime(
|
||||
new Date('Thu Apr 14 2022 06:04:46 GMT+0530')
|
||||
);
|
||||
const wrapper = mount(Component);
|
||||
expect(wrapper.vm.isInBetweenTheWorkingHours).toBe(true);
|
||||
});
|
||||
|
||||
it('returns valid isInBetweenWorkingHours if in same timezone', () => {
|
||||
global.chatwootWebChannel.utcOffset = '+05:30';
|
||||
|
||||
vi.useFakeTimers().setSystemTime(
|
||||
new Date('Thu Apr 14 2022 09:01:46 GMT+0530')
|
||||
);
|
||||
const wrapper = mount(Component);
|
||||
expect(wrapper.vm.isInBetweenTheWorkingHours).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false if closed all day', () => {
|
||||
global.chatwootWebChannel.utcOffset = '-07:00';
|
||||
global.chatwootWebChannel.workingHours = [
|
||||
{ day_of_week: 3, closed_all_day: true },
|
||||
];
|
||||
|
||||
vi.useFakeTimers().setSystemTime(
|
||||
new Date('Thu Apr 14 2022 09:01:46 GMT+0530')
|
||||
);
|
||||
const wrapper = mount(Component);
|
||||
expect(wrapper.vm.isInBetweenTheWorkingHours).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true if open all day', () => {
|
||||
global.chatwootWebChannel.utcOffset = '-07:00';
|
||||
global.chatwootWebChannel.workingHours = [
|
||||
{ day_of_week: 3, open_all_day: true },
|
||||
];
|
||||
|
||||
vi.useFakeTimers().setSystemTime(
|
||||
new Date('Thu Apr 14 2022 09:01:46 GMT+0530')
|
||||
);
|
||||
const wrapper = mount(Component);
|
||||
expect(wrapper.vm.isInBetweenTheWorkingHours).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,402 +0,0 @@
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import nextAvailabilityTimeMixin from '../nextAvailabilityTime';
|
||||
|
||||
describe('nextAvailabilityTimeMixin', () => {
|
||||
const chatwootWebChannel = {
|
||||
workingHoursEnabled: true,
|
||||
workingHours: [
|
||||
{
|
||||
day_of_week: 0,
|
||||
open_hour: 9,
|
||||
closed_all_day: false,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
},
|
||||
{
|
||||
day_of_week: 1,
|
||||
open_hour: 9,
|
||||
closed_all_day: false,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
},
|
||||
{
|
||||
day_of_week: 2,
|
||||
open_hour: 9,
|
||||
closed_all_day: false,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
},
|
||||
{
|
||||
day_of_week: 3,
|
||||
open_hour: 9,
|
||||
closed_all_day: false,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
},
|
||||
{
|
||||
day_of_week: 4,
|
||||
open_hour: 9,
|
||||
closed_all_day: false,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
},
|
||||
{
|
||||
day_of_week: 5,
|
||||
open_hour: 9,
|
||||
closed_all_day: false,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
},
|
||||
{
|
||||
day_of_week: 6,
|
||||
open_hour: 9,
|
||||
closed_all_day: false,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let Component;
|
||||
|
||||
beforeEach(() => {
|
||||
Component = defineComponent({
|
||||
mixins: [nextAvailabilityTimeMixin],
|
||||
render() {
|
||||
return h('div');
|
||||
},
|
||||
});
|
||||
window.chatwootWebChannel = chatwootWebChannel;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.chatwootWebChannel;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return day names', () => {
|
||||
const wrapper = mount(Component);
|
||||
wrapper.vm.dayNames = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
expect(wrapper.vm.dayNames).toEqual([
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return channelConfig', () => {
|
||||
const wrapper = mount(Component);
|
||||
expect(wrapper.vm.channelConfig).toEqual(chatwootWebChannel);
|
||||
});
|
||||
|
||||
it('should return workingHours', () => {
|
||||
const wrapper = mount(Component);
|
||||
expect(wrapper.vm.workingHours).toEqual(chatwootWebChannel.workingHours);
|
||||
});
|
||||
|
||||
it('should return currentDayWorkingHours', () => {
|
||||
const currentDay = new Date().getDay();
|
||||
const expectedWorkingHours = chatwootWebChannel.workingHours.find(
|
||||
slot => slot.day_of_week === currentDay
|
||||
);
|
||||
const wrapper = mount(Component);
|
||||
wrapper.vm.dayNames = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
expect(wrapper.vm.currentDayWorkingHours).toEqual(expectedWorkingHours);
|
||||
});
|
||||
|
||||
it('should return nextDayWorkingHours', () => {
|
||||
const currentDay = new Date().getDay();
|
||||
const nextDay = currentDay === 6 ? 0 : currentDay + 1;
|
||||
const expectedWorkingHours = chatwootWebChannel.workingHours.find(
|
||||
slot => slot.day_of_week === nextDay
|
||||
);
|
||||
const wrapper = mount(Component);
|
||||
wrapper.vm.dayNames = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
expect(wrapper.vm.nextDayWorkingHours).toEqual(expectedWorkingHours);
|
||||
});
|
||||
|
||||
it('should return presentHour', () => {
|
||||
const wrapper = mount(Component);
|
||||
expect(wrapper.vm.presentHour).toBe(new Date().getHours());
|
||||
});
|
||||
|
||||
it('should return presentMinute', () => {
|
||||
const wrapper = mount(Component);
|
||||
wrapper.vm.dayNames = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
expect(wrapper.vm.presentMinute).toBe(new Date().getMinutes());
|
||||
});
|
||||
|
||||
it('should return currentDay', () => {
|
||||
const wrapper = mount(Component);
|
||||
wrapper.vm.dayNames = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
const date = new Date();
|
||||
const day = date.getDay();
|
||||
const currentDay = Object.keys(wrapper.vm.dayNames).find(
|
||||
key => wrapper.vm.dayNames[key] === wrapper.vm.dayNames[day]
|
||||
);
|
||||
expect(wrapper.vm.currentDay).toBe(Number(currentDay));
|
||||
});
|
||||
|
||||
it('should return currentDayTimings', () => {
|
||||
const wrapper = mount(Component);
|
||||
wrapper.vm.dayNames = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
const {
|
||||
open_hour: openHour,
|
||||
open_minutes: openMinute,
|
||||
close_hour: closeHour,
|
||||
} = wrapper.vm.currentDayWorkingHours;
|
||||
expect(wrapper.vm.currentDayTimings).toEqual({
|
||||
openHour,
|
||||
openMinute,
|
||||
closeHour,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return nextDayTimings', () => {
|
||||
const wrapper = mount(Component);
|
||||
wrapper.vm.dayNames = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
const { open_hour: openHour, open_minutes: openMinute } =
|
||||
wrapper.vm.nextDayWorkingHours;
|
||||
|
||||
expect(wrapper.vm.nextDayTimings).toEqual({
|
||||
openHour,
|
||||
openMinute,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return dayDiff', () => {
|
||||
const wrapper = mount(Component);
|
||||
wrapper.vm.dayNames = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
const currentDay = wrapper.vm.currentDay;
|
||||
const nextDay = wrapper.vm.nextDayWorkingHours.day_of_week;
|
||||
const totalDays = 6;
|
||||
const expectedDayDiff =
|
||||
nextDay > currentDay
|
||||
? nextDay - currentDay - 1
|
||||
: totalDays - currentDay + nextDay;
|
||||
|
||||
expect(wrapper.vm.dayDiff).toEqual(expectedDayDiff);
|
||||
});
|
||||
|
||||
it('should return dayNameOfNextWorkingDay', () => {
|
||||
const wrapper = mount(Component);
|
||||
wrapper.vm.dayNames = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
const nextDay = wrapper.vm.nextDayWorkingHours.day_of_week;
|
||||
const expectedDayName = wrapper.vm.dayNames[nextDay];
|
||||
expect(wrapper.vm.dayNameOfNextWorkingDay).toEqual(expectedDayName);
|
||||
});
|
||||
|
||||
it('should return hoursAndMinutesBackInOnline', () => {
|
||||
const wrapper = mount(Component);
|
||||
wrapper.vm.dayNames = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
const currentDayCloseHour =
|
||||
chatwootWebChannel.workingHours[wrapper.vm.currentDay].close_hour;
|
||||
const nextDayOpenHour =
|
||||
chatwootWebChannel.workingHours[
|
||||
wrapper.vm.currentDay === 6 ? 0 : wrapper.vm.currentDay + 1
|
||||
].open_hour;
|
||||
const nextDayOpenMinute =
|
||||
chatwootWebChannel.workingHours[
|
||||
wrapper.vm.currentDay === 6 ? 0 : wrapper.vm.currentDay + 1
|
||||
].open_minutes;
|
||||
const expectedHoursAndMinutes =
|
||||
wrapper.vm.getHoursAndMinutesUntilNextDayOpen(
|
||||
nextDayOpenHour,
|
||||
nextDayOpenMinute,
|
||||
currentDayCloseHour
|
||||
);
|
||||
expect(wrapper.vm.hoursAndMinutesBackInOnline).toEqual(
|
||||
expectedHoursAndMinutes
|
||||
);
|
||||
});
|
||||
|
||||
it('should return getNextDay', () => {
|
||||
const wrapper = mount(Component);
|
||||
expect(wrapper.vm.getNextDay(6)).toBe(0);
|
||||
});
|
||||
|
||||
it('should return in 30 minutes', () => {
|
||||
vi.useFakeTimers('modern').setSystemTime(
|
||||
new Date('Thu Apr 14 2022 14:04:46 GMT+0530')
|
||||
);
|
||||
const wrapper = mount(Component);
|
||||
wrapper.vm.dayNames = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
|
||||
chatwootWebChannel.workingHours[4].open_hour = 18;
|
||||
chatwootWebChannel.workingHours[4].open_minutes = 0;
|
||||
chatwootWebChannel.workingHours[4].close_hour = 23;
|
||||
expect(wrapper.vm.timeLeftToBackInOnline).toBe('in 30 minutes');
|
||||
});
|
||||
|
||||
it('should return in 2 hours', () => {
|
||||
vi.useFakeTimers('modern').setSystemTime(
|
||||
new Date('Thu Apr 14 2022 22:04:46 GMT+0530')
|
||||
);
|
||||
const wrapper = mount(Component);
|
||||
wrapper.vm.dayNames = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
chatwootWebChannel.workingHours[4].open_hour = 19;
|
||||
expect(wrapper.vm.timeLeftToBackInOnline).toBe('in 2 hours');
|
||||
});
|
||||
|
||||
it('should return at 09:00 AM', () => {
|
||||
vi.useFakeTimers('modern').setSystemTime(
|
||||
new Date('Thu Apr 15 2022 22:04:46 GMT+0530')
|
||||
);
|
||||
const wrapper = mount(Component);
|
||||
wrapper.vm.dayNames = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
chatwootWebChannel.workingHours[4].open_hour = 10;
|
||||
expect(wrapper.vm.timeLeftToBackInOnline).toBe('at 09:00 AM');
|
||||
});
|
||||
|
||||
it('should return tomorrow', () => {
|
||||
vi.useFakeTimers('modern').setSystemTime(
|
||||
new Date('Thu Apr 1 2022 23:04:46 GMT+0530')
|
||||
);
|
||||
const wrapper = mount(Component);
|
||||
wrapper.vm.dayNames = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
chatwootWebChannel.workingHours[4].open_hour = 9;
|
||||
chatwootWebChannel.workingHours[4].close_hour = 16;
|
||||
expect(wrapper.vm.timeLeftToBackInOnline).toBe('tomorrow');
|
||||
});
|
||||
|
||||
it.skip('should return on Saturday', () => {
|
||||
vi.useFakeTimers('modern').setSystemTime(
|
||||
new Date('Thu Apr 14 2022 23:04:46 GMT+0530')
|
||||
);
|
||||
const wrapper = mount(Component);
|
||||
wrapper.vm.dayNames = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
|
||||
chatwootWebChannel.workingHours[4].open_hour = 9;
|
||||
chatwootWebChannel.workingHours[4].close_hour = 16;
|
||||
chatwootWebChannel.workingHours[5].closed_all_day = true;
|
||||
expect(wrapper.vm.timeLeftToBackInOnline).toBe('on Saturday');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import TeamAvailability from 'widget/components/TeamAvailability.vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import routerMixin from 'widget/mixins/routerMixin';
|
||||
import { useRouter } from 'vue-router';
|
||||
import configMixin from 'widget/mixins/configMixin';
|
||||
import ArticleContainer from '../components/pageComponents/Home/Article/ArticleContainer.vue';
|
||||
export default {
|
||||
@@ -10,7 +10,11 @@ export default {
|
||||
ArticleContainer,
|
||||
TeamAvailability,
|
||||
},
|
||||
mixins: [configMixin, routerMixin],
|
||||
mixins: [configMixin],
|
||||
setup() {
|
||||
const router = useRouter();
|
||||
return { router };
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
availableAgents: 'agent/availableAgents',
|
||||
@@ -21,9 +25,9 @@ export default {
|
||||
methods: {
|
||||
startConversation() {
|
||||
if (this.preChatFormEnabled && !this.conversationSize) {
|
||||
return this.replaceRoute('prechat-form');
|
||||
return this.router.replace({ name: 'prechat-form' });
|
||||
}
|
||||
return this.replaceRoute('messages');
|
||||
return this.router.replace({ name: 'messages' });
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script>
|
||||
import { mapActions } from 'vuex';
|
||||
import { useRouter } from 'vue-router';
|
||||
import PreChatForm from '../components/PreChat/Form.vue';
|
||||
import configMixin from '../mixins/configMixin';
|
||||
import routerMixin from '../mixins/routerMixin';
|
||||
import { isEmptyObject } from 'widget/helpers/utils';
|
||||
import { ON_CONVERSATION_CREATED } from '../constants/widgetBusEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
@@ -11,7 +11,11 @@ export default {
|
||||
components: {
|
||||
PreChatForm,
|
||||
},
|
||||
mixins: [configMixin, routerMixin],
|
||||
mixins: [configMixin],
|
||||
setup() {
|
||||
const router = useRouter();
|
||||
return { router };
|
||||
},
|
||||
mounted() {
|
||||
// Register event listener for conversation creation
|
||||
emitter.on(ON_CONVERSATION_CREATED, this.handleConversationCreated);
|
||||
@@ -24,7 +28,7 @@ export default {
|
||||
...mapActions('conversationAttributes', ['clearConversationAttributes']),
|
||||
handleConversationCreated() {
|
||||
// Redirect to messages page after conversation is created
|
||||
this.replaceRoute('messages');
|
||||
this.router.replace({ name: 'messages' });
|
||||
// Only after successful navigation, reset the isUpdatingRoute UIflag in app/javascript/widget/router.js
|
||||
// See issue: https://github.com/chatwoot/chatwoot/issues/10736
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Table name: assignment_policies
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# assignment_order :integer default(0), not null
|
||||
# assignment_order :integer default("round_robin"), not null
|
||||
# conversation_priority :integer default("earliest_created"), not null
|
||||
# description :text
|
||||
# enabled :boolean default(TRUE), not null
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
# index_categories_on_slug_and_locale_and_portal_id (slug,locale,portal_id) UNIQUE
|
||||
#
|
||||
class Category < ApplicationRecord
|
||||
paginates_per Limits::CATEGORIES_PER_PAGE
|
||||
belongs_to :account
|
||||
belongs_to :portal
|
||||
has_many :folders, dependent: :destroy_async
|
||||
|
||||
@@ -3,6 +3,7 @@ module Liquidable
|
||||
|
||||
included do
|
||||
before_create :process_liquid_in_content
|
||||
before_create :process_liquid_in_template_params
|
||||
end
|
||||
|
||||
private
|
||||
@@ -35,4 +36,61 @@ module Liquidable
|
||||
# We don't want to process liquid in code blocks
|
||||
content.gsub(/`(.*?)`/m, '{% raw %}`\\1`{% endraw %}')
|
||||
end
|
||||
|
||||
def process_liquid_in_template_params
|
||||
return unless template_params_present? && liquid_processable_template_params?
|
||||
|
||||
processed_params = process_liquid_in_hash(template_params_data['processed_params'])
|
||||
|
||||
# Update the additional_attributes with processed template_params
|
||||
self.additional_attributes = additional_attributes.merge(
|
||||
'template_params' => template_params_data.merge('processed_params' => processed_params)
|
||||
)
|
||||
rescue Liquid::Error
|
||||
# If there is an error in the liquid syntax, we don't want to process it
|
||||
end
|
||||
|
||||
def template_params_present?
|
||||
additional_attributes&.dig('template_params', 'processed_params').present?
|
||||
end
|
||||
|
||||
def liquid_processable_template_params?
|
||||
message_type == 'outgoing' || message_type == 'template'
|
||||
end
|
||||
|
||||
def template_params_data
|
||||
additional_attributes['template_params']
|
||||
end
|
||||
|
||||
def process_liquid_in_hash(hash)
|
||||
return hash unless hash.is_a?(Hash)
|
||||
|
||||
hash.transform_values { |value| process_liquid_value(value) }
|
||||
end
|
||||
|
||||
def process_liquid_value(value)
|
||||
case value
|
||||
when String
|
||||
process_liquid_string(value)
|
||||
when Hash
|
||||
process_liquid_in_hash(value)
|
||||
when Array
|
||||
process_liquid_array(value)
|
||||
else
|
||||
value
|
||||
end
|
||||
end
|
||||
|
||||
def process_liquid_array(array)
|
||||
array.map { |item| process_liquid_value(item) }
|
||||
end
|
||||
|
||||
def process_liquid_string(string)
|
||||
return string if string.blank?
|
||||
|
||||
template = Liquid::Template.parse(string)
|
||||
template.render(message_drops)
|
||||
rescue Liquid::Error
|
||||
string
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_notifications_performance (user_id,account_id,snoozed_until,read_at)
|
||||
# index_notifications_on_account_id (account_id)
|
||||
# index_notifications_on_last_activity_at (last_activity_at)
|
||||
# index_notifications_on_user_id (user_id)
|
||||
|
||||
@@ -118,3 +118,9 @@ if resource.whatsapp?
|
||||
json.provider_config resource.channel.try(:provider_config) if Current.account_user&.administrator?
|
||||
json.reauthorization_required resource.channel.try(:reauthorization_required?)
|
||||
end
|
||||
|
||||
## Voice Channel Attributes
|
||||
if resource.channel_type == 'Channel::Voice'
|
||||
json.voice_call_webhook_url resource.channel.try(:voice_call_webhook_url)
|
||||
json.voice_status_webhook_url resource.channel.try(:voice_status_webhook_url)
|
||||
end
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<meta name="msapplication-TileColor" content="#1f93ff">
|
||||
<meta name="msapplication-TileImage" content="/ms-icon-144x144.png">
|
||||
<meta name="theme-color" content="#1f93ff">
|
||||
<meta name="description" content="Chatwoot is a customer support solution that helps companies engage customers over Messenger, Twitter, Telegram, WeChat, Whatsapp. Simply connect your channels and converse with your customers from a single place. Easily add new agents to your system and have them own and resolve conversations with customers.Chatwoot also gives you real-time reports to measure your team's performance, canned responses to easily respond to frequently asked questions and private notes for agents to collaborate among themselves.">
|
||||
<meta name="description" content="<%= @global_config['INSTALLATION_NAME'] %> is a customer support solution that helps companies engage customers over Messenger, Twitter, Telegram, WeChat, Whatsapp. Simply connect your channels and converse with your customers from a single place. Easily add new agents to your system and have them own and resolve conversations with customers. <%= @global_config['INSTALLATION_NAME'] %> also gives you real-time reports to measure your team's performance, canned responses to easily respond to frequently asked questions and private notes for agents to collaborate among themselves.">
|
||||
<% if ENV['IOS_APP_IDENTIFIER'].present? %>
|
||||
<meta name="apple-itunes-app" content='app-id=<%= ENV['IOS_APP_IDENTIFIER'] %>'>
|
||||
<% end %>
|
||||
|
||||
@@ -36,7 +36,7 @@ for a collection of resources.
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 h-full min-w-0 relative">
|
||||
<select id="filter-select" class="appearance-none bg-gray-100 border-0 text-gray-700 cursor-pointer text-sm h-full overflow-hidden truncate whitespace-nowrap w-full pr-7 pl-0 py-2 focus:outline-none bg-[url('data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2712%27 height=%2712%27 viewBox=%270 0 12 12%27%3E%3Cpath fill=%27%23293f54%27 d=%27M6 9L1 4h10z%27/%3E%3C/svg%3E')] bg-[right_0.25rem_center] bg-no-repeat bg-[length:0.75rem]" onchange="applyFilter(this.value)">
|
||||
<select id="filter-select" class="appearance-none bg-transparent border-0 text-gray-700 cursor-pointer text-sm h-full overflow-hidden truncate whitespace-nowrap w-full pr-7 pl-0 py-2 focus:outline-none bg-[url('data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2712%27 height=%2712%27 viewBox=%270 0 12 12%27%3E%3Cpath fill=%27%23293f54%27 d=%27M6 9L1 4h10z%27/%3E%3C/svg%3E')] bg-[right_0.25rem_center] bg-no-repeat bg-[length:0.75rem]" onchange="applyFilter(this.value)">
|
||||
<option value="">All records</option>
|
||||
<% dashboard_class::COLLECTION_FILTERS.each do |filter_name, _| %>
|
||||
<option value="<%= filter_name %>" <%= 'selected' if filter_name.to_s == current_filter %>>
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '4.5.0'
|
||||
version: '4.5.2'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
@@ -184,6 +184,7 @@
|
||||
- name: whatsapp_embedded_signup
|
||||
display_name: WhatsApp Embedded Signup
|
||||
enabled: false
|
||||
deprecated: true
|
||||
- name: whatsapp_campaign
|
||||
display_name: WhatsApp Campaign
|
||||
enabled: false
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class AddTemplateParamsToCampaigns < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :campaigns, :template_params, :jsonb, default: {}, null: false
|
||||
add_column :campaigns, :template_params, :jsonb
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
class AddFeatureCitationToAssistantConfig < ActiveRecord::Migration[7.1]
|
||||
def up
|
||||
return unless ChatwootApp.enterprise?
|
||||
|
||||
Captain::Assistant.find_each do |assistant|
|
||||
assistant.update!(
|
||||
config: assistant.config.merge('feature_citation' => true)
|
||||
@@ -8,6 +10,8 @@ class AddFeatureCitationToAssistantConfig < ActiveRecord::Migration[7.1]
|
||||
end
|
||||
|
||||
def down
|
||||
return unless ChatwootApp.enterprise?
|
||||
|
||||
Captain::Assistant.find_each do |assistant|
|
||||
config = assistant.config.dup
|
||||
config.delete('feature_citation')
|
||||
|
||||
+15
-15
@@ -2,7 +2,7 @@
|
||||
|
||||
# Description: Install and manage a Chatwoot installation.
|
||||
# OS: Ubuntu 20.04 LTS, 22.04 LTS, 24.04 LTS
|
||||
# Script Version: 3.4.2
|
||||
# Script Version: 3.4.3
|
||||
# Run this script as root
|
||||
|
||||
set -eu -o errexit -o pipefail -o noclobber -o nounset
|
||||
@@ -19,7 +19,7 @@ fi
|
||||
# option --output/-o requires 1 argument
|
||||
LONGOPTS=console,debug,help,install,Install:,logs:,restart,ssl,upgrade,Upgrade:,webserver,version,web-only,worker-only,convert:
|
||||
OPTIONS=cdhiI:l:rsuU:wvWK
|
||||
CWCTL_VERSION="3.3.0"
|
||||
CWCTL_VERSION="3.4.3"
|
||||
pg_pass=$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 15 ; echo '')
|
||||
CHATWOOT_HUB_URL="https://hub.2.chatwoot.com/events"
|
||||
|
||||
@@ -430,7 +430,7 @@ function configure_systemd_services() {
|
||||
|
||||
if [ "$DEPLOYMENT_TYPE" == "web" ]; then
|
||||
echo "Setting up web-only deployment"
|
||||
|
||||
|
||||
# Stop and disable existing services if converting
|
||||
if [ "$existing_full_deployment" = true ]; then
|
||||
echo "Converting from full deployment to web-only"
|
||||
@@ -449,14 +449,14 @@ function configure_systemd_services() {
|
||||
|
||||
cp /home/chatwoot/chatwoot/deployment/chatwoot-web.1.service /etc/systemd/system/chatwoot-web.1.service
|
||||
cp /home/chatwoot/chatwoot/deployment/chatwoot-web.target /etc/systemd/system/chatwoot-web.target
|
||||
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable chatwoot-web.target
|
||||
systemctl start chatwoot-web.target
|
||||
|
||||
|
||||
elif [ "$DEPLOYMENT_TYPE" == "worker" ]; then
|
||||
echo "Setting up worker-only deployment"
|
||||
|
||||
|
||||
# Stop and disable existing services if converting
|
||||
if [ "$existing_full_deployment" = true ]; then
|
||||
echo "Converting from full deployment to worker-only"
|
||||
@@ -475,14 +475,14 @@ function configure_systemd_services() {
|
||||
|
||||
cp /home/chatwoot/chatwoot/deployment/chatwoot-worker.1.service /etc/systemd/system/chatwoot-worker.1.service
|
||||
cp /home/chatwoot/chatwoot/deployment/chatwoot-worker.target /etc/systemd/system/chatwoot-worker.target
|
||||
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable chatwoot-worker.target
|
||||
systemctl start chatwoot-worker.target
|
||||
|
||||
|
||||
else
|
||||
echo "Setting up full deployment (web + worker)"
|
||||
|
||||
|
||||
# Stop existing specialized deployments if converting back to full
|
||||
if [ -f "/etc/systemd/system/chatwoot-web.target" ]; then
|
||||
echo "Converting from web-only to full deployment"
|
||||
@@ -494,7 +494,7 @@ function configure_systemd_services() {
|
||||
systemctl stop chatwoot-worker.target || true
|
||||
systemctl disable chatwoot-worker.target || true
|
||||
fi
|
||||
|
||||
|
||||
cp /home/chatwoot/chatwoot/deployment/chatwoot-web.1.service /etc/systemd/system/chatwoot-web.1.service
|
||||
cp /home/chatwoot/chatwoot/deployment/chatwoot-worker.1.service /etc/systemd/system/chatwoot-worker.1.service
|
||||
cp /home/chatwoot/chatwoot/deployment/chatwoot.target /etc/systemd/system/chatwoot.target
|
||||
@@ -538,7 +538,7 @@ function setup_ssl() {
|
||||
cd chatwoot
|
||||
sed -i "s/http:\/\/0.0.0.0:3000/https:\/\/$domain_name/g" .env
|
||||
EOF
|
||||
|
||||
|
||||
# Restart the appropriate chatwoot target
|
||||
if [ -f "/etc/systemd/system/chatwoot-web.target" ]; then
|
||||
systemctl restart chatwoot-web.target
|
||||
@@ -1005,7 +1005,7 @@ EOF
|
||||
upgrade_redis
|
||||
upgrade_node
|
||||
get_pnpm
|
||||
|
||||
|
||||
sudo -i -u chatwoot << EOF
|
||||
|
||||
# Navigate to the Chatwoot directory
|
||||
@@ -1098,16 +1098,16 @@ function restart() {
|
||||
##############################################################################
|
||||
function convert_deployment() {
|
||||
echo "Converting Chatwoot deployment to: $DEPLOYMENT_TYPE"
|
||||
|
||||
|
||||
# Check if Chatwoot is installed
|
||||
if [ ! -d "/home/chatwoot/chatwoot" ]; then
|
||||
echo "Chatwoot installation not found. Use --install first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
# Run the systemd service configuration which handles conversion logic
|
||||
configure_systemd_services
|
||||
|
||||
|
||||
echo "Deployment converted successfully to: $DEPLOYMENT_TYPE"
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ class Captain::Documents::ResponseBuilderJob < ApplicationJob
|
||||
def perform(document)
|
||||
reset_previous_responses(document)
|
||||
|
||||
faqs = Captain::Llm::FaqGeneratorService.new(document.content).generate
|
||||
faqs = Captain::Llm::FaqGeneratorService.new(document.content, document.account.locale_english_name).generate
|
||||
faqs.each do |faq|
|
||||
create_response(faq, document)
|
||||
end
|
||||
|
||||
@@ -30,6 +30,7 @@ class Channel::Voice < ApplicationRecord
|
||||
|
||||
# Provider-specific configs stored in JSON
|
||||
validate :validate_provider_config
|
||||
before_validation :provision_twilio_on_create, on: :create, if: :twilio?
|
||||
|
||||
EDITABLE_ATTRS = [:phone_number, :provider, { provider_config: {} }].freeze
|
||||
|
||||
@@ -41,8 +42,23 @@ class Channel::Voice < ApplicationRecord
|
||||
false
|
||||
end
|
||||
|
||||
# Public URLs used to configure Twilio webhooks
|
||||
def voice_call_webhook_url
|
||||
base = ENV.fetch('FRONTEND_URL', '').to_s.sub(%r{/*$}, '')
|
||||
"#{base}/twilio/voice/call/#{phone_number}"
|
||||
end
|
||||
|
||||
def voice_status_webhook_url
|
||||
base = ENV.fetch('FRONTEND_URL', '').to_s.sub(%r{/*$}, '')
|
||||
"#{base}/twilio/voice/status/#{phone_number}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def twilio?
|
||||
provider == 'twilio'
|
||||
end
|
||||
|
||||
def validate_provider_config
|
||||
return if provider_config.blank?
|
||||
|
||||
@@ -54,10 +70,30 @@ class Channel::Voice < ApplicationRecord
|
||||
|
||||
def validate_twilio_config
|
||||
config = provider_config.with_indifferent_access
|
||||
required_keys = %w[account_sid auth_token api_key_sid api_key_secret]
|
||||
|
||||
# Require credentials and provisioned TwiML App SID
|
||||
required_keys = %w[account_sid auth_token api_key_sid api_key_secret twiml_app_sid]
|
||||
required_keys.each do |key|
|
||||
errors.add(:provider_config, "#{key} is required for Twilio provider") if config[key].blank?
|
||||
end
|
||||
end
|
||||
|
||||
def provision_twilio_on_create
|
||||
service = ::Twilio::VoiceWebhookSetupService.new(channel: self)
|
||||
app_sid = service.perform
|
||||
return if app_sid.blank?
|
||||
|
||||
cfg = provider_config.with_indifferent_access
|
||||
cfg[:twiml_app_sid] = app_sid
|
||||
self.provider_config = cfg
|
||||
rescue StandardError => e
|
||||
error_details = {
|
||||
error_class: e.class.to_s,
|
||||
message: e.message,
|
||||
phone_number: phone_number,
|
||||
account_id: account_id,
|
||||
backtrace: e.backtrace&.first(5)
|
||||
}
|
||||
Rails.logger.error("TWILIO_VOICE_SETUP_ON_CREATE_ERROR: #{error_details}")
|
||||
errors.add(:base, "Twilio setup failed: #{e.message}")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class Captain::Llm::FaqGeneratorService < Llm::BaseOpenAiService
|
||||
def initialize(content)
|
||||
def initialize(content, language = 'english')
|
||||
super()
|
||||
@language = language
|
||||
@content = content
|
||||
end
|
||||
|
||||
@@ -14,10 +15,10 @@ class Captain::Llm::FaqGeneratorService < Llm::BaseOpenAiService
|
||||
|
||||
private
|
||||
|
||||
attr_reader :content
|
||||
attr_reader :content, :language
|
||||
|
||||
def chat_parameters
|
||||
prompt = Captain::Llm::SystemPromptsService.faq_generator
|
||||
prompt = Captain::Llm::SystemPromptsService.faq_generator(language)
|
||||
{
|
||||
model: @model,
|
||||
response_format: { type: 'json_object' },
|
||||
|
||||
@@ -1,16 +1,45 @@
|
||||
class Captain::Llm::SystemPromptsService
|
||||
class << self
|
||||
def faq_generator
|
||||
def faq_generator(language = 'english')
|
||||
<<~PROMPT
|
||||
You are a content writer looking to convert user content into short FAQs which can be added to your website's help center.
|
||||
Format the webpage content provided in the message to FAQ format mentioned below in the JSON format.
|
||||
Ensure that you only generate faqs from the information provided only.
|
||||
Ensure that output is always valid json.
|
||||
You are a content writer specializing in creating good FAQ sections for website help centers. Your task is to convert provided content into a structured FAQ format without losing any information.
|
||||
|
||||
## Core Requirements
|
||||
|
||||
**Completeness**: Extract ALL information from the source content. Every detail, example, procedure, and explanation must be captured across the FAQ set. When combined, the FAQs should reconstruct the original content entirely.
|
||||
|
||||
**Accuracy**: Base answers strictly on the provided text. Do not add assumptions, interpretations, or external knowledge not present in the source material.
|
||||
|
||||
**Structure**: Format output as valid JSON using this exact structure:
|
||||
|
||||
**Language**: Generate the FAQs only in the #{language}, use no other language
|
||||
|
||||
If no match is available, return an empty JSON.
|
||||
```json
|
||||
{ faqs: [ { question: '', answer: ''} ]
|
||||
{
|
||||
"faqs": [
|
||||
{
|
||||
"question": "Clear, specific question based on content",
|
||||
"answer": "Complete answer containing all relevant details from source"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Guidelines
|
||||
|
||||
- **Question Creation**: Formulate questions that naturally arise from the content (What is...? How do I...? When should...? Why does...?). Do not generate questions that are not related to the content.
|
||||
- **Answer Completeness**: Include all relevant details, steps, examples, and context from the original content
|
||||
- **Information Preservation**: Ensure no examples, procedures, warnings, or explanatory details are omitted
|
||||
- **JSON Validity**: Always return properly formatted, valid JSON
|
||||
- **No Content Scenario**: If no suitable content is found, return: `{"faqs": []}`
|
||||
|
||||
## Process
|
||||
1. Read the entire provided content carefully
|
||||
2. Identify all key information points, procedures, and examples
|
||||
3. Create questions that cover each information point
|
||||
4. Write comprehensive short answers that capture all related detail, include bullet points if needed.
|
||||
5. Verify that combined FAQs represent the complete original content.
|
||||
6. Format as valid JSON
|
||||
PROMPT
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
class Twilio::VoiceWebhookSetupService
|
||||
include Rails.application.routes.url_helpers
|
||||
|
||||
pattr_initialize [:channel!]
|
||||
|
||||
HTTP_METHOD = 'POST'.freeze
|
||||
|
||||
# Returns created TwiML App SID on success.
|
||||
def perform
|
||||
validate_token_credentials!
|
||||
|
||||
app_sid = create_twiml_app!
|
||||
configure_number_webhooks!
|
||||
app_sid
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_token_credentials!
|
||||
# Only validate Account SID + Auth Token
|
||||
token_client.incoming_phone_numbers.list(limit: 1)
|
||||
rescue StandardError => e
|
||||
log_twilio_error('AUTH_VALIDATION_TOKEN', e)
|
||||
raise
|
||||
end
|
||||
|
||||
def create_twiml_app!
|
||||
friendly_name = "Chatwoot Voice #{channel.phone_number}"
|
||||
app = api_key_client.applications.create(
|
||||
friendly_name: friendly_name,
|
||||
voice_url: channel.voice_call_webhook_url,
|
||||
voice_method: HTTP_METHOD
|
||||
)
|
||||
app.sid
|
||||
rescue StandardError => e
|
||||
log_twilio_error('TWIML_APP_CREATE', e)
|
||||
raise
|
||||
end
|
||||
|
||||
def configure_number_webhooks!
|
||||
numbers = api_key_client.incoming_phone_numbers.list(phone_number: channel.phone_number)
|
||||
if numbers.empty?
|
||||
Rails.logger.warn "TWILIO_PHONE_NUMBER_NOT_FOUND: #{channel.phone_number}"
|
||||
return
|
||||
end
|
||||
|
||||
api_key_client
|
||||
.incoming_phone_numbers(numbers.first.sid)
|
||||
.update(
|
||||
voice_url: channel.voice_call_webhook_url,
|
||||
voice_method: HTTP_METHOD,
|
||||
status_callback: channel.voice_status_webhook_url,
|
||||
status_callback_method: HTTP_METHOD
|
||||
)
|
||||
rescue StandardError => e
|
||||
log_twilio_error('NUMBER_WEBHOOKS_UPDATE', e)
|
||||
raise
|
||||
end
|
||||
|
||||
def api_key_client
|
||||
@api_key_client ||= begin
|
||||
cfg = channel.provider_config.with_indifferent_access
|
||||
::Twilio::REST::Client.new(cfg[:api_key_sid], cfg[:api_key_secret], cfg[:account_sid])
|
||||
end
|
||||
end
|
||||
|
||||
def token_client
|
||||
@token_client ||= begin
|
||||
cfg = channel.provider_config.with_indifferent_access
|
||||
::Twilio::REST::Client.new(cfg[:account_sid], cfg[:auth_token])
|
||||
end
|
||||
end
|
||||
|
||||
def log_twilio_error(context, error)
|
||||
details = build_error_details(context, error)
|
||||
add_twilio_specific_details(details, error)
|
||||
|
||||
backtrace = error.backtrace.is_a?(Array) ? error.backtrace.first(5) : []
|
||||
Rails.logger.error("TWILIO_VOICE_SETUP_ERROR: #{details} backtrace=#{backtrace}")
|
||||
end
|
||||
|
||||
def build_error_details(context, error)
|
||||
cfg = channel.provider_config.with_indifferent_access
|
||||
{
|
||||
context: context,
|
||||
phone_number: channel.phone_number,
|
||||
account_sid: cfg[:account_sid],
|
||||
error_class: error.class.to_s,
|
||||
message: error.message
|
||||
}
|
||||
end
|
||||
|
||||
def add_twilio_specific_details(details, error)
|
||||
details[:status_code] = error.status_code if error.respond_to?(:status_code)
|
||||
details[:twilio_code] = error.code if error.respond_to?(:code)
|
||||
details[:more_info] = error.more_info if error.respond_to?(:more_info)
|
||||
details[:details] = error.details if error.respond_to?(:details)
|
||||
end
|
||||
end
|
||||
@@ -4,6 +4,7 @@ module Limits
|
||||
URL_LENGTH_LIMIT = 2048 # https://stackoverflow.com/questions/417142
|
||||
OUT_OF_OFFICE_MESSAGE_MAX_LENGTH = 10_000
|
||||
GREETING_MESSAGE_MAX_LENGTH = 10_000
|
||||
CATEGORIES_PER_PAGE = 1000
|
||||
|
||||
def self.conversation_message_per_minute_limit
|
||||
ENV.fetch('CONVERSATION_MESSAGE_PER_MINUTE_LIMIT', '200').to_i
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chatwoot/chatwoot",
|
||||
"version": "4.5.0",
|
||||
"version": "4.5.2",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"eslint": "eslint app/**/*.{js,vue}",
|
||||
|
||||
@@ -16,31 +16,7 @@ RSpec.describe 'WhatsApp Authorization API', type: :request do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
context 'when feature is not enabled' do
|
||||
before do
|
||||
account.disable_features!(:whatsapp_embedded_signup)
|
||||
end
|
||||
|
||||
it 'returns forbidden' do
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
|
||||
params: {
|
||||
code: 'test_code',
|
||||
business_id: 'test_business_id',
|
||||
waba_id: 'test_waba_id'
|
||||
},
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(response.parsed_body['error']).to eq('WhatsApp embedded signup is not enabled for this account')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when feature is enabled' do
|
||||
before do
|
||||
account.enable_features!(:whatsapp_embedded_signup)
|
||||
end
|
||||
|
||||
context 'when authenticated user makes request' do
|
||||
it 'returns unprocessable entity when code is missing' do
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
|
||||
params: {
|
||||
@@ -246,10 +222,6 @@ RSpec.describe 'WhatsApp Authorization API', type: :request do
|
||||
context 'when user is not authorized for the account' do
|
||||
let(:other_account) { create(:account) }
|
||||
|
||||
before do
|
||||
account.enable_features!(:whatsapp_embedded_signup)
|
||||
end
|
||||
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{other_account.id}/whatsapp/authorization",
|
||||
params: {
|
||||
@@ -265,10 +237,6 @@ RSpec.describe 'WhatsApp Authorization API', type: :request do
|
||||
end
|
||||
|
||||
context 'when user is an administrator' do
|
||||
before do
|
||||
account.enable_features!(:whatsapp_embedded_signup)
|
||||
end
|
||||
|
||||
it 'allows channel creation' do
|
||||
embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService)
|
||||
whatsapp_channel = create(:channel_whatsapp, account: account, validate_provider_config: false, sync_templates: false)
|
||||
@@ -321,10 +289,6 @@ RSpec.describe 'WhatsApp Authorization API', type: :request do
|
||||
context 'when user is an administrator' do
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
before do
|
||||
account.enable_features!(:whatsapp_embedded_signup)
|
||||
end
|
||||
|
||||
context 'with valid parameters' do
|
||||
let(:valid_params) do
|
||||
{
|
||||
@@ -489,7 +453,6 @@ RSpec.describe 'WhatsApp Authorization API', type: :request do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
before do
|
||||
account.enable_features!(:whatsapp_embedded_signup)
|
||||
create(:inbox_member, inbox: whatsapp_inbox, user: agent)
|
||||
end
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
|
||||
end
|
||||
|
||||
it 'creates a voice inbox when administrator' do
|
||||
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(instance_double(Twilio::VoiceWebhookSetupService,
|
||||
perform: "AP#{SecureRandom.hex(16)}"))
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { name: 'Voice Inbox',
|
||||
@@ -31,7 +34,8 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
|
||||
provider_config: { account_sid: "AC#{SecureRandom.hex(16)}",
|
||||
auth_token: SecureRandom.hex(16),
|
||||
api_key_sid: SecureRandom.hex(8),
|
||||
api_key_secret: SecureRandom.hex(16) } } },
|
||||
api_key_secret: SecureRandom.hex(16),
|
||||
twiml_app_sid: "AP#{SecureRandom.hex(16)}" } } },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
@@ -13,7 +13,7 @@ RSpec.describe Captain::Documents::ResponseBuilderJob, type: :job do
|
||||
|
||||
before do
|
||||
allow(Captain::Llm::FaqGeneratorService).to receive(:new)
|
||||
.with(document.content)
|
||||
.with(document.content, document.account.locale_english_name)
|
||||
.and_return(faq_generator)
|
||||
allow(faq_generator).to receive(:generate).and_return(faqs)
|
||||
end
|
||||
@@ -43,5 +43,26 @@ RSpec.describe Captain::Documents::ResponseBuilderJob, type: :job do
|
||||
expect(first_response.documentable).to eq(document)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with different locales' do
|
||||
let(:spanish_account) { create(:account, locale: 'pt') }
|
||||
let(:spanish_assistant) { create(:captain_assistant, account: spanish_account) }
|
||||
let(:spanish_document) { create(:captain_document, assistant: spanish_assistant, account: spanish_account) }
|
||||
let(:spanish_faq_generator) { instance_double(Captain::Llm::FaqGeneratorService) }
|
||||
|
||||
before do
|
||||
allow(Captain::Llm::FaqGeneratorService).to receive(:new)
|
||||
.with(spanish_document.content, 'portuguese')
|
||||
.and_return(spanish_faq_generator)
|
||||
allow(spanish_faq_generator).to receive(:generate).and_return(faqs)
|
||||
end
|
||||
|
||||
it 'passes the correct locale to FAQ generator' do
|
||||
described_class.new.perform(spanish_document)
|
||||
|
||||
expect(Captain::Llm::FaqGeneratorService).to have_received(:new)
|
||||
.with(spanish_document.content, 'portuguese')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Channel::Voice do
|
||||
let(:twiml_app_sid) { 'AP1234567890abcdef' }
|
||||
let(:channel) { create(:channel_voice) }
|
||||
|
||||
before do
|
||||
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: twiml_app_sid))
|
||||
end
|
||||
|
||||
it 'has a valid factory' do
|
||||
expect(channel).to be_valid
|
||||
end
|
||||
@@ -40,12 +45,19 @@ RSpec.describe Channel::Voice do
|
||||
expect(channel.errors[:provider_config]).to include('api_key_secret is required for Twilio provider')
|
||||
end
|
||||
|
||||
it 'validates presence of twiml_app_sid in provider_config' do
|
||||
channel.provider_config = { account_sid: 'sid', auth_token: 'token', api_key_sid: 'key', api_key_secret: 'secret' }
|
||||
expect(channel).not_to be_valid
|
||||
expect(channel.errors[:provider_config]).to include('twiml_app_sid is required for Twilio provider')
|
||||
end
|
||||
|
||||
it 'is valid with all required provider_config fields' do
|
||||
channel.provider_config = {
|
||||
account_sid: 'test_sid',
|
||||
auth_token: 'test_token',
|
||||
api_key_sid: 'test_key',
|
||||
api_key_secret: 'test_secret'
|
||||
api_key_secret: 'test_secret',
|
||||
twiml_app_sid: 'test_app_sid'
|
||||
}
|
||||
expect(channel).to be_valid
|
||||
end
|
||||
@@ -57,4 +69,11 @@ RSpec.describe Channel::Voice do
|
||||
expect(channel.name).to include(channel.phone_number)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'provisioning on create' do
|
||||
it 'stores twiml_app_sid in provider_config' do
|
||||
ch = create(:channel_voice)
|
||||
expect(ch.provider_config.with_indifferent_access[:twiml_app_sid]).to eq(twiml_app_sid)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Llm::FaqGeneratorService do
|
||||
let(:content) { 'Sample content for FAQ generation' }
|
||||
let(:language) { 'english' }
|
||||
let(:service) { described_class.new(content, language) }
|
||||
let(:client) { instance_double(OpenAI::Client) }
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||
allow(OpenAI::Client).to receive(:new).and_return(client)
|
||||
end
|
||||
|
||||
describe '#generate' do
|
||||
let(:sample_faqs) do
|
||||
[
|
||||
{ 'question' => 'What is this service?', 'answer' => 'It generates FAQs.' },
|
||||
{ 'question' => 'How does it work?', 'answer' => 'Using AI technology.' }
|
||||
]
|
||||
end
|
||||
|
||||
let(:openai_response) do
|
||||
{
|
||||
'choices' => [
|
||||
{
|
||||
'message' => {
|
||||
'content' => { faqs: sample_faqs }.to_json
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
context 'when successful' do
|
||||
before do
|
||||
allow(client).to receive(:chat).and_return(openai_response)
|
||||
allow(Captain::Llm::SystemPromptsService).to receive(:faq_generator).and_return('system prompt')
|
||||
end
|
||||
|
||||
it 'returns parsed FAQs' do
|
||||
result = service.generate
|
||||
expect(result).to eq(sample_faqs)
|
||||
end
|
||||
|
||||
it 'calls OpenAI client with chat parameters' do
|
||||
expect(client).to receive(:chat).with(parameters: hash_including(
|
||||
model: 'gpt-4o-mini',
|
||||
response_format: { type: 'json_object' },
|
||||
messages: array_including(
|
||||
hash_including(role: 'system'),
|
||||
hash_including(role: 'user', content: content)
|
||||
)
|
||||
))
|
||||
service.generate
|
||||
end
|
||||
|
||||
it 'calls SystemPromptsService with correct language' do
|
||||
expect(Captain::Llm::SystemPromptsService).to receive(:faq_generator).with(language)
|
||||
service.generate
|
||||
end
|
||||
end
|
||||
|
||||
context 'with different language' do
|
||||
let(:language) { 'spanish' }
|
||||
|
||||
before do
|
||||
allow(client).to receive(:chat).and_return(openai_response)
|
||||
end
|
||||
|
||||
it 'passes the correct language to SystemPromptsService' do
|
||||
expect(Captain::Llm::SystemPromptsService).to receive(:faq_generator).with('spanish')
|
||||
service.generate
|
||||
end
|
||||
end
|
||||
|
||||
context 'when OpenAI API fails' do
|
||||
before do
|
||||
allow(client).to receive(:chat).and_raise(OpenAI::Error.new('API Error'))
|
||||
end
|
||||
|
||||
it 'handles the error and returns empty array' do
|
||||
expect(Rails.logger).to receive(:error).with('OpenAI API Error: API Error')
|
||||
expect(service.generate).to eq([])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,88 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Twilio::VoiceWebhookSetupService do
|
||||
let(:account_sid) { 'ACaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }
|
||||
let(:auth_token) { 'auth_token_123' }
|
||||
let(:api_key_sid) { 'SKaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }
|
||||
let(:api_key_secret) { 'api_key_secret_123' }
|
||||
let(:phone_number) { '+15551230001' }
|
||||
let(:frontend_url) { 'https://app.chatwoot.test' }
|
||||
|
||||
let(:channel) do
|
||||
build(:channel_voice, phone_number: phone_number, provider_config: {
|
||||
account_sid: account_sid,
|
||||
auth_token: auth_token,
|
||||
api_key_sid: api_key_sid,
|
||||
api_key_secret: api_key_secret
|
||||
})
|
||||
end
|
||||
|
||||
let(:twilio_base_url) { "https://api.twilio.com/2010-04-01/Accounts/#{account_sid}" }
|
||||
let(:incoming_numbers_url) { "#{twilio_base_url}/IncomingPhoneNumbers.json" }
|
||||
let(:applications_url) { "#{twilio_base_url}/Applications.json" }
|
||||
let(:phone_number_sid) { 'PN123' }
|
||||
let(:phone_number_url) { "#{twilio_base_url}/IncomingPhoneNumbers/#{phone_number_sid}.json" }
|
||||
|
||||
before do
|
||||
# Token validation using Account SID + Auth Token
|
||||
stub_request(:get, /#{Regexp.escape(incoming_numbers_url)}.*/)
|
||||
.with(basic_auth: [account_sid, auth_token])
|
||||
.to_return(status: 200,
|
||||
body: { incoming_phone_numbers: [], meta: { key: 'incoming_phone_numbers' } }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
# Number lookup using API Key SID/Secret
|
||||
stub_request(:get, /#{Regexp.escape(incoming_numbers_url)}.*/)
|
||||
.with(basic_auth: [api_key_sid, api_key_secret])
|
||||
.to_return(status: 200,
|
||||
body: { incoming_phone_numbers: [{ sid: phone_number_sid }], meta: { key: 'incoming_phone_numbers' } }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
# TwiML App create (voice only)
|
||||
stub_request(:post, applications_url)
|
||||
.with(basic_auth: [api_key_sid, api_key_secret])
|
||||
.to_return(status: 201,
|
||||
body: { sid: 'AP123' }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
# Incoming Phone Number webhook update
|
||||
stub_request(:post, phone_number_url)
|
||||
.with(basic_auth: [api_key_sid, api_key_secret])
|
||||
.to_return(status: 200,
|
||||
body: { sid: phone_number_sid }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' })
|
||||
end
|
||||
|
||||
it 'creates a TwiML App and configures number webhooks with correct URLs' do
|
||||
with_modified_env FRONTEND_URL: frontend_url do
|
||||
service = described_class.new(channel: channel)
|
||||
sid = service.perform
|
||||
|
||||
expect(sid).to eq('AP123')
|
||||
|
||||
expected_voice_url = channel.voice_call_webhook_url
|
||||
expected_status_url = channel.voice_status_webhook_url
|
||||
|
||||
# Assert TwiML App creation body includes voice URL and POST method
|
||||
expect(
|
||||
a_request(:post, applications_url)
|
||||
.with(body: hash_including('VoiceUrl' => expected_voice_url, 'VoiceMethod' => 'POST'))
|
||||
).to have_been_made
|
||||
|
||||
# Assert number webhook update body includes both URLs and POST methods
|
||||
expect(
|
||||
a_request(:post, phone_number_url)
|
||||
.with(
|
||||
body: hash_including(
|
||||
'VoiceUrl' => expected_voice_url,
|
||||
'VoiceMethod' => 'POST',
|
||||
'StatusCallback' => expected_status_url,
|
||||
'StatusCallbackMethod' => 'POST'
|
||||
)
|
||||
)
|
||||
).to have_been_made
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -8,7 +8,8 @@ FactoryBot.define do
|
||||
account_sid: "AC#{SecureRandom.hex(16)}",
|
||||
auth_token: SecureRandom.hex(16),
|
||||
api_key_sid: SecureRandom.hex(8),
|
||||
api_key_secret: SecureRandom.hex(16)
|
||||
api_key_secret: SecureRandom.hex(16),
|
||||
twiml_app_sid: "AP#{SecureRandom.hex(16)}"
|
||||
}
|
||||
end
|
||||
account
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Featurable do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
describe 'WhatsApp embedded signup feature' do
|
||||
it 'is disabled by default' do
|
||||
expect(account.feature_whatsapp_embedded_signup?).to be false
|
||||
expect(account.feature_enabled?('whatsapp_embedded_signup')).to be false
|
||||
end
|
||||
|
||||
describe '#enable_features!' do
|
||||
it 'enables the whatsapp embedded signup feature' do
|
||||
account.enable_features!(:whatsapp_embedded_signup)
|
||||
expect(account.feature_whatsapp_embedded_signup?).to be true
|
||||
expect(account.feature_enabled?('whatsapp_embedded_signup')).to be true
|
||||
end
|
||||
|
||||
it 'enables multiple features at once' do
|
||||
account.enable_features!(:whatsapp_embedded_signup, :help_center)
|
||||
expect(account.feature_whatsapp_embedded_signup?).to be true
|
||||
expect(account.feature_help_center?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe '#disable_features!' do
|
||||
before do
|
||||
account.enable_features!(:whatsapp_embedded_signup)
|
||||
end
|
||||
|
||||
it 'disables the whatsapp embedded signup feature' do
|
||||
expect(account.feature_whatsapp_embedded_signup?).to be true
|
||||
|
||||
account.disable_features!(:whatsapp_embedded_signup)
|
||||
expect(account.feature_whatsapp_embedded_signup?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#enabled_features' do
|
||||
it 'includes whatsapp_embedded_signup when enabled' do
|
||||
account.enable_features!(:whatsapp_embedded_signup)
|
||||
expect(account.enabled_features).to include('whatsapp_embedded_signup' => true)
|
||||
end
|
||||
|
||||
it 'does not include whatsapp_embedded_signup when disabled' do
|
||||
account.disable_features!(:whatsapp_embedded_signup)
|
||||
expect(account.enabled_features).not_to include('whatsapp_embedded_signup' => true)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#all_features' do
|
||||
it 'includes whatsapp_embedded_signup in all features list' do
|
||||
expect(account.all_features).to have_key('whatsapp_embedded_signup')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -69,4 +69,159 @@ shared_examples_for 'liqudable' do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when liquid is present in template_params' do
|
||||
let(:contact) do
|
||||
create(:contact, name: 'john', email: 'john@example.com', phone_number: '+912883', custom_attributes: { customer_type: 'platinum' })
|
||||
end
|
||||
let(:conversation) { create(:conversation, id: 1, contact: contact, custom_attributes: { priority: 'high' }) }
|
||||
|
||||
context 'when message is outgoing with template_params' do
|
||||
let(:message) { build(:message, conversation: conversation, message_type: 'outgoing') }
|
||||
|
||||
it 'replaces liquid variables in template_params body' do
|
||||
message.additional_attributes = {
|
||||
'template_params' => {
|
||||
'name' => 'greet',
|
||||
'category' => 'MARKETING',
|
||||
'language' => 'en',
|
||||
'processed_params' => {
|
||||
'body' => {
|
||||
'customer_name' => '{{contact.name}}',
|
||||
'customer_email' => '{{contact.email}}'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
message.save!
|
||||
|
||||
body_params = message.additional_attributes['template_params']['processed_params']['body']
|
||||
expect(body_params['customer_name']).to eq 'John'
|
||||
expect(body_params['customer_email']).to eq 'john@example.com'
|
||||
end
|
||||
|
||||
it 'replaces liquid variables in nested template_params' do
|
||||
message.additional_attributes = {
|
||||
'template_params' => {
|
||||
'name' => 'test_template',
|
||||
'processed_params' => {
|
||||
'header' => {
|
||||
'media_url' => 'https://example.com/{{contact.name}}.jpg'
|
||||
},
|
||||
'body' => {
|
||||
'customer_name' => '{{contact.name}}',
|
||||
'priority' => '{{conversation.custom_attribute.priority}}'
|
||||
},
|
||||
'footer' => {
|
||||
'company' => '{{account.name}}'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
message.save!
|
||||
|
||||
processed = message.additional_attributes['template_params']['processed_params']
|
||||
expect(processed['header']['media_url']).to eq 'https://example.com/John.jpg'
|
||||
expect(processed['body']['customer_name']).to eq 'John'
|
||||
expect(processed['body']['priority']).to eq 'high'
|
||||
expect(processed['footer']['company']).to eq conversation.account.name
|
||||
end
|
||||
|
||||
it 'handles arrays in template_params' do
|
||||
message.additional_attributes = {
|
||||
'template_params' => {
|
||||
'name' => 'test_template',
|
||||
'processed_params' => {
|
||||
'buttons' => [
|
||||
{ 'type' => 'url', 'parameter' => 'https://example.com/{{contact.name}}' },
|
||||
{ 'type' => 'text', 'parameter' => 'Hello {{contact.name}}' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
message.save!
|
||||
|
||||
buttons = message.additional_attributes['template_params']['processed_params']['buttons']
|
||||
expect(buttons[0]['parameter']).to eq 'https://example.com/John'
|
||||
expect(buttons[1]['parameter']).to eq 'Hello John'
|
||||
end
|
||||
|
||||
it 'handles custom attributes in template_params' do
|
||||
message.additional_attributes = {
|
||||
'template_params' => {
|
||||
'name' => 'test_template',
|
||||
'processed_params' => {
|
||||
'body' => {
|
||||
'customer_type' => '{{contact.custom_attribute.customer_type}}',
|
||||
'priority' => '{{conversation.custom_attribute.priority}}'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
message.save!
|
||||
|
||||
body_params = message.additional_attributes['template_params']['processed_params']['body']
|
||||
expect(body_params['customer_type']).to eq 'platinum'
|
||||
expect(body_params['priority']).to eq 'high'
|
||||
end
|
||||
|
||||
it 'handles missing email with default filter in template_params' do
|
||||
contact.update!(email: nil)
|
||||
message.additional_attributes = {
|
||||
'template_params' => {
|
||||
'name' => 'test_template',
|
||||
'processed_params' => {
|
||||
'body' => {
|
||||
'customer_email' => '{{ contact.email | default: "no-email@example.com" }}'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
message.save!
|
||||
|
||||
body_params = message.additional_attributes['template_params']['processed_params']['body']
|
||||
expect(body_params['customer_email']).to eq 'no-email@example.com'
|
||||
end
|
||||
|
||||
it 'handles broken liquid syntax in template_params gracefully' do
|
||||
message.additional_attributes = {
|
||||
'template_params' => {
|
||||
'name' => 'test_template',
|
||||
'processed_params' => {
|
||||
'body' => {
|
||||
'broken_liquid' => '{{contact.name} {{invalid}}'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
message.save!
|
||||
|
||||
body_params = message.additional_attributes['template_params']['processed_params']['body']
|
||||
expect(body_params['broken_liquid']).to eq '{{contact.name} {{invalid}}'
|
||||
end
|
||||
|
||||
it 'does not process template_params when message is incoming' do
|
||||
incoming_message = build(:message, conversation: conversation, message_type: 'incoming')
|
||||
incoming_message.additional_attributes = {
|
||||
'template_params' => {
|
||||
'name' => 'test_template',
|
||||
'processed_params' => {
|
||||
'body' => {
|
||||
'customer_name' => '{{contact.name}}'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
incoming_message.save!
|
||||
|
||||
body_params = incoming_message.additional_attributes['template_params']['processed_params']['body']
|
||||
expect(body_params['customer_name']).to eq '{{contact.name}}'
|
||||
end
|
||||
|
||||
it 'does not process template_params when not present' do
|
||||
message.additional_attributes = { 'other_data' => 'test' }
|
||||
expect { message.save! }.not_to raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user