Initial commit: Add logistics and order_detail message types
Some checks failed
Lock Threads / action (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot EE docker images / merge (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot CE docker images / merge (push) Has been cancelled
Run Chatwoot CE spec / lint-backend (push) Has been cancelled
Run Chatwoot CE spec / lint-frontend (push) Has been cancelled
Run Chatwoot CE spec / frontend-tests (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (0, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (1, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (10, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (11, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (12, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (13, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (14, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (15, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (2, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (3, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (4, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (5, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (6, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (7, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (8, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (9, 16) (push) Has been cancelled
Run Linux nightly installer / nightly (push) Has been cancelled

- Add Logistics component with progress tracking
- Add OrderDetail component for order information
- Support data-driven steps and actions
- Add blue color scale to widget SCSS
- Fix node overflow and progress bar rendering issues
- Add English translations for dashboard components

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Liang XJ
2026-01-26 11:16:56 +08:00
commit 092fb2e083
7646 changed files with 975643 additions and 0 deletions

View File

@@ -0,0 +1,97 @@
<script setup>
import { ref, reactive, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { helpers } from '@vuelidate/validators';
import { isValidDomain } from '@chatwoot/utils';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const props = defineProps({
mode: {
type: String,
default: 'add',
},
customDomain: {
type: String,
default: '',
},
});
const emit = defineEmits(['addCustomDomain']);
const { t } = useI18n();
const dialogRef = ref(null);
const formState = reactive({
customDomain: props.customDomain,
});
const rules = {
customDomain: {
isValidDomain: helpers.withMessage(
() =>
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.FORMAT_ERROR'
),
isValidDomain
),
},
};
const v$ = useVuelidate(rules, formState);
watch(
() => props.customDomain,
newVal => {
formState.customDomain = newVal;
}
);
const handleDialogConfirm = async () => {
const isFormCorrect = await v$.value.$validate();
if (!isFormCorrect) return;
emit('addCustomDomain', formState.customDomain);
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
:title="
t(
`HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.${props.mode.toUpperCase()}_HEADER`
)
"
:confirm-button-label="
t(
`HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.${props.mode.toUpperCase()}_CONFIRM_BUTTON_LABEL`
)
"
@confirm="handleDialogConfirm"
>
<Input
v-model="formState.customDomain"
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.LABEL'
)
"
:placeholder="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.PLACEHOLDER'
)
"
:message="
v$.customDomain.$error ? v$.customDomain.$errors[0].$message : ''
"
:message-type="v$.customDomain.$error ? 'error' : 'info'"
@blur="v$.customDomain.$touch()"
/>
</Dialog>
</template>

View File

@@ -0,0 +1,51 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
defineProps({
activePortalName: {
type: String,
required: true,
},
});
const emit = defineEmits(['deletePortal']);
const { t } = useI18n();
const dialogRef = ref(null);
const handleDialogConfirm = () => {
emit('deletePortal');
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="alert"
:title="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.DIALOG.HEADER',
{
portalName: activePortalName,
}
)
"
:description="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.DIALOG.DESCRIPTION'
)
"
:confirm-button-label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.DIALOG.CONFIRM_BUTTON_LABEL'
)
"
@confirm="handleDialogConfirm"
/>
</template>

View File

@@ -0,0 +1,179 @@
<script setup>
import { reactive, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { getHostNameFromURL } from 'dashboard/helper/URLHelper';
import { email, required } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
customDomain: {
type: String,
default: '',
},
});
const emit = defineEmits(['send', 'close']);
const { t } = useI18n();
const state = reactive({
email: '',
});
const validationRules = {
email: { email, required },
};
const v$ = useVuelidate(validationRules, state);
const domain = computed(() => {
const { hostURL, helpCenterURL } = window?.chatwootConfig || {};
return getHostNameFromURL(helpCenterURL) || getHostNameFromURL(hostURL) || '';
});
const subdomainCNAME = computed(
() => `${props.customDomain} CNAME ${domain.value}`
);
const handleCopy = async e => {
e.stopPropagation();
await copyTextToClipboard(subdomainCNAME.value);
useAlert(
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.COPY'
)
);
};
const dialogRef = ref(null);
const resetForm = () => {
v$.value.$reset();
state.email = '';
};
const onClose = () => {
resetForm();
emit('close');
};
const handleSend = async () => {
const isFormCorrect = await v$.value.$validate();
if (!isFormCorrect) return;
emit('send', state.email);
onClose();
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
:show-cancel-button="false"
:show-confirm-button="false"
@close="resetForm"
>
<NextButton
icon="i-lucide-x"
sm
ghost
slate
class="flex-shrink-0 absolute top-2 ltr:right-2 rtl:left-2"
@click="onClose"
/>
<div class="flex flex-col gap-6 divide-y divide-n-strong">
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2 ltr:pr-10 rtl:pl-10">
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HEADER'
)
}}
</h3>
<p class="mb-0 text-sm text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.DESCRIPTION'
)
}}
</p>
</div>
<div class="flex items-center gap-3 w-full">
<span
class="min-h-10 px-3 py-2.5 inline-flex items-center w-full text-sm bg-transparent border rounded-lg text-n-slate-11 border-n-strong"
>
{{ subdomainCNAME }}
</span>
<NextButton
faded
slate
type="button"
icon="i-lucide-copy"
class="flex-shrink-0"
@click="handleCopy"
/>
</div>
</div>
<div class="flex flex-col gap-6 pt-6">
<div class="flex flex-col gap-2 ltr:pr-10 rtl:pl-10">
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.HEADER'
)
}}
</h3>
<p class="mb-0 text-sm text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.DESCRIPTION'
)
}}
</p>
</div>
<form
class="flex items-start gap-3 w-full"
@submit.prevent="handleSend"
>
<Input
v-model="state.email"
:placeholder="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.PLACEHOLDER'
)
"
:message="
v$.email.$error
? t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.ERROR'
)
: ''
"
:message-type="v$.email.$error ? 'error' : 'info'"
class="w-full"
@blur="v$.email.$touch()"
/>
<NextButton
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.SEND_BUTTON'
)
"
type="submit"
class="flex-shrink-0"
/>
</form>
</div>
</div>
</Dialog>
</template>

View File

@@ -0,0 +1,348 @@
<script setup>
import { reactive, watch, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { buildPortalURL } from 'dashboard/helper/portalHelper';
import { useAlert } from 'dashboard/composables';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
import { uploadFile } from 'dashboard/helper/uploadHelper';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength, helpers, url } from '@vuelidate/validators';
import { isValidSlug } from 'shared/helpers/Validators';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import ColorPicker from 'dashboard/components-next/colorpicker/ColorPicker.vue';
const props = defineProps({
activePortal: {
type: Object,
required: true,
},
isFetching: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['updatePortal']);
const { t } = useI18n();
const store = useStore();
const getters = useStoreGetters();
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const state = reactive({
name: '',
headerText: '',
pageTitle: '',
slug: '',
widgetColor: '',
homePageLink: '',
liveChatWidgetInboxId: '',
logoUrl: '',
avatarBlobId: '',
});
const originalState = reactive({ ...state });
const liveChatWidgets = computed(() => {
const inboxes = store.getters['inboxes/getInboxes'];
const widgetOptions = inboxes
.filter(inbox => inbox.channel_type === 'Channel::WebWidget')
.map(inbox => ({
value: inbox.id,
label: inbox.name,
}));
return [
{
value: '',
label: t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.NONE_OPTION'),
},
...widgetOptions,
];
});
const rules = {
name: { required, minLength: minLength(2) },
slug: {
required: helpers.withMessage(
() => t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.ERROR'),
required
),
isValidSlug: helpers.withMessage(
() => t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.FORMAT_ERROR'),
isValidSlug
),
},
homePageLink: { url },
};
const v$ = useVuelidate(rules, state);
const nameError = computed(() =>
v$.value.name.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.ERROR') : ''
);
const slugError = computed(() => {
return v$.value.slug.$errors[0]?.$message || '';
});
const homePageLinkError = computed(() =>
v$.value.homePageLink.$error
? t('HELP_CENTER.PORTAL_SETTINGS.FORM.HOME_PAGE_LINK.ERROR')
: ''
);
const isUpdatingPortal = computed(() => {
const slug = props.activePortal?.slug;
if (slug) return getters['portals/uiFlagsIn'].value(slug)?.isUpdating;
return false;
});
watch(
() => props.activePortal,
newVal => {
if (newVal && !props.isFetching) {
Object.assign(state, {
name: newVal.name,
headerText: newVal.header_text,
pageTitle: newVal.page_title,
widgetColor: newVal.color,
homePageLink: newVal.homepage_link,
slug: newVal.slug,
liveChatWidgetInboxId: newVal.inbox?.id || '',
});
if (newVal.logo) {
const {
logo: { file_url: logoURL, blob_id: blobId },
} = newVal;
state.logoUrl = logoURL;
state.avatarBlobId = blobId;
} else {
state.logoUrl = '';
state.avatarBlobId = '';
}
Object.assign(originalState, state);
}
},
{ immediate: true, deep: true }
);
const hasChanges = computed(() => {
return JSON.stringify(state) !== JSON.stringify(originalState);
});
const handleUpdatePortal = () => {
const portal = {
id: props.activePortal?.id,
slug: state.slug,
name: state.name,
color: state.widgetColor,
page_title: state.pageTitle,
header_text: state.headerText,
homepage_link: state.homePageLink,
blob_id: state.avatarBlobId,
inbox_id: state.liveChatWidgetInboxId,
};
emit('updatePortal', portal);
};
async function uploadLogoToStorage({ file }) {
try {
const { fileUrl, blobId } = await uploadFile(file);
if (fileUrl) {
state.logoUrl = fileUrl;
state.avatarBlobId = blobId;
}
useAlert(t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.IMAGE_UPLOAD_SUCCESS'));
} catch (error) {
useAlert(t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.IMAGE_UPLOAD_ERROR'));
}
}
async function deleteLogo() {
try {
const portalSlug = props.activePortal?.slug;
await store.dispatch('portals/deleteLogo', {
portalSlug,
});
useAlert(t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.IMAGE_DELETE_SUCCESS'));
} catch (error) {
useAlert(
error?.message ||
t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.IMAGE_DELETE_ERROR')
);
}
}
const handleAvatarUpload = file => {
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
uploadLogoToStorage(file);
} else {
const errorKey =
'HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.IMAGE_UPLOAD_SIZE_ERROR';
useAlert(t(errorKey, { size: MAXIMUM_FILE_UPLOAD_SIZE }));
}
};
const handleAvatarDelete = () => {
state.logoUrl = '';
state.avatarBlobId = '';
deleteLogo();
};
</script>
<template>
<div class="flex flex-col w-full gap-4">
<div class="flex flex-col w-full gap-2">
<label class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50">
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.LABEL') }}
</label>
<Avatar
:src="state.logoUrl"
:name="state.name"
:size="72"
allow-upload
icon-name="i-lucide-building-2"
@upload="handleAvatarUpload"
@delete="handleAvatarDelete"
/>
</div>
<div class="flex flex-col w-full gap-4">
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.NAME.LABEL') }}
</label>
<Input
v-model="state.name"
:placeholder="t('HELP_CENTER.PORTAL_SETTINGS.FORM.NAME.PLACEHOLDER')"
:message-type="nameError ? 'error' : 'info'"
:message="nameError"
custom-input-class="!bg-transparent dark:!bg-transparent"
@input="v$.name.$touch()"
@blur="v$.name.$touch()"
/>
</div>
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.HEADER_TEXT.LABEL') }}
</label>
<Input
v-model="state.headerText"
:placeholder="
t('HELP_CENTER.PORTAL_SETTINGS.FORM.HEADER_TEXT.PLACEHOLDER')
"
custom-input-class="!bg-transparent dark:!bg-transparent"
/>
</div>
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap text-n-slate-12 py-2.5"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.PAGE_TITLE.LABEL') }}
</label>
<Input
v-model="state.pageTitle"
:placeholder="
t('HELP_CENTER.PORTAL_SETTINGS.FORM.PAGE_TITLE.PLACEHOLDER')
"
custom-input-class="!bg-transparent dark:!bg-transparent"
/>
</div>
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap text-n-slate-12 py-2.5"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.HOME_PAGE_LINK.LABEL') }}
</label>
<Input
v-model="state.homePageLink"
:placeholder="
t('HELP_CENTER.PORTAL_SETTINGS.FORM.HOME_PAGE_LINK.PLACEHOLDER')
"
:message-type="homePageLinkError ? 'error' : 'info'"
:message="homePageLinkError"
custom-input-class="!bg-transparent dark:!bg-transparent"
@input="v$.homePageLink.$touch()"
@blur="v$.homePageLink.$touch()"
/>
</div>
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.SLUG.LABEL') }}
</label>
<Input
v-model="state.slug"
:placeholder="t('HELP_CENTER.PORTAL_SETTINGS.FORM.SLUG.PLACEHOLDER')"
:message-type="slugError ? 'error' : 'info'"
:message="slugError || buildPortalURL(state.slug)"
custom-input-class="!bg-transparent dark:!bg-transparent"
@input="v$.slug.$touch()"
@blur="v$.slug.$touch()"
/>
</div>
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.LABEL') }}
</label>
<ComboBox
v-model="state.liveChatWidgetInboxId"
:options="liveChatWidgets"
:placeholder="
t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.PLACEHOLDER')
"
:message="
t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.HELP_TEXT')
"
class="[&>div>button:not(.focused)]:!outline-n-weak"
/>
</div>
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.BRAND_COLOR.LABEL') }}
</label>
<div class="w-[432px] justify-start">
<ColorPicker v-model="state.widgetColor" />
</div>
</div>
<div class="flex justify-end w-full gap-2">
<Button
:label="t('HELP_CENTER.PORTAL_SETTINGS.FORM.SAVE_CHANGES')"
:disabled="!hasChanges || isUpdatingPortal || v$.$invalid"
:is-loading="isUpdatingPortal"
@click="handleUpdatePortal"
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,254 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import AddCustomDomainDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/AddCustomDomainDialog.vue';
import DNSConfigurationDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/DNSConfigurationDialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
activePortal: {
type: Object,
required: true,
},
isFetchingStatus: {
type: Boolean,
required: true,
},
});
const emit = defineEmits([
'updatePortalConfiguration',
'refreshStatus',
'sendCnameInstructions',
]);
const SSL_STATUS = {
LIVE: ['active', 'staging_active'],
PENDING: [
'provisioned',
'pending',
'initializing',
'pending_validation',
'pending_deployment',
'pending_issuance',
'holding_deployment',
'holding_validation',
'pending_expiration',
'pending_cleanup',
'pending_deletion',
'staging_deployment',
'backup_issued',
],
ERROR: [
'blocked',
'inactive',
'moved',
'expired',
'deleted',
'timed_out_initializing',
'timed_out_validation',
'timed_out_issuance',
'timed_out_deployment',
'timed_out_deletion',
'deactivating',
],
};
const { t } = useI18n();
const { isOnChatwootCloud } = useAccount();
const addCustomDomainDialogRef = ref(null);
const dnsConfigurationDialogRef = ref(null);
const updatedDomainAddress = ref('');
const customDomainAddress = computed(
() => props.activePortal?.custom_domain || ''
);
const sslSettings = computed(() => props.activePortal?.ssl_settings || {});
const verificationErrors = computed(
() => sslSettings.value.verification_errors || ''
);
const isLive = computed(() =>
SSL_STATUS.LIVE.includes(sslSettings.value.status)
);
const isPending = computed(() =>
SSL_STATUS.PENDING.includes(sslSettings.value.status)
);
const isError = computed(() =>
SSL_STATUS.ERROR.includes(sslSettings.value.status)
);
const statusText = computed(() => {
if (isLive.value)
return t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS.LIVE'
);
if (isPending.value)
return t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS.PENDING'
);
if (isError.value)
return t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS.ERROR'
);
return '';
});
const statusColors = computed(() => {
if (isLive.value)
return { text: 'text-n-teal-11', bubble: 'outline-n-teal-6 bg-n-teal-9' };
if (isError.value)
return { text: 'text-n-ruby-11', bubble: 'outline-n-ruby-6 bg-n-ruby-9' };
return { text: 'text-n-amber-11', bubble: 'outline-n-amber-6 bg-n-amber-9' };
});
const updatePortalConfiguration = customDomain => {
const portal = {
id: props.activePortal?.id,
custom_domain: customDomain,
};
emit('updatePortalConfiguration', portal);
addCustomDomainDialogRef.value.dialogRef.close();
if (customDomain) {
updatedDomainAddress.value = customDomain;
dnsConfigurationDialogRef.value.dialogRef.open();
}
};
const closeDNSConfigurationDialog = () => {
updatedDomainAddress.value = '';
dnsConfigurationDialogRef.value.dialogRef.close();
};
const onClickRefreshSSLStatus = () => {
emit('refreshStatus');
};
const onClickSend = email => {
emit('sendCnameInstructions', {
portalSlug: props.activePortal?.slug,
email,
});
};
</script>
<template>
<div class="flex flex-col w-full gap-6">
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.HEADER'
)
}}
</h6>
<span class="text-sm text-n-slate-11">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DESCRIPTION'
)
}}
</span>
</div>
<div class="flex flex-col w-full gap-4">
<div class="flex items-center justify-between w-full gap-2">
<div v-if="customDomainAddress" class="flex flex-col gap-1">
<div class="flex items-center w-full h-8 gap-4">
<label class="text-sm font-medium text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.LABEL'
)
}}
</label>
<span class="text-sm text-n-slate-12">
{{ customDomainAddress }}
</span>
</div>
<span
v-if="!isLive && isOnChatwootCloud"
class="text-sm text-n-slate-11"
>
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS_DESCRIPTION'
)
}}
</span>
</div>
<div class="flex items-center">
<div v-if="customDomainAddress" class="flex items-center gap-3">
<div
v-if="statusText && isOnChatwootCloud"
v-tooltip="verificationErrors"
class="flex items-center gap-3 flex-shrink-0"
>
<span
class="size-1.5 rounded-full outline outline-2 block flex-shrink-0"
:class="statusColors.bubble"
/>
<span
:class="statusColors.text"
class="text-sm leading-[16px] font-medium"
>
{{ statusText }}
</span>
</div>
<div
v-if="statusText && isOnChatwootCloud"
class="w-px h-3 bg-n-weak"
/>
<Button
slate
sm
link
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.EDIT_BUTTON'
)
"
class="hover:!no-underline flex-shrink-0"
@click="addCustomDomainDialogRef.dialogRef.open()"
/>
<div v-if="isOnChatwootCloud" class="w-px h-3 bg-n-weak" />
<Button
v-if="isOnChatwootCloud"
slate
sm
link
icon="i-lucide-refresh-ccw"
:class="isFetchingStatus && 'animate-spin'"
@click="onClickRefreshSSLStatus"
/>
</div>
<Button
v-else
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.ADD_BUTTON'
)
"
color="slate"
@click="addCustomDomainDialogRef.dialogRef.open()"
/>
</div>
</div>
</div>
<AddCustomDomainDialog
ref="addCustomDomainDialogRef"
:mode="customDomainAddress ? 'edit' : 'add'"
:custom-domain="customDomainAddress"
@add-custom-domain="updatePortalConfiguration"
/>
<DNSConfigurationDialog
ref="dnsConfigurationDialogRef"
:custom-domain="updatedDomainAddress || customDomainAddress"
@close="closeDNSConfigurationDialog"
@send="onClickSend"
/>
</div>
</template>

View File

@@ -0,0 +1,13 @@
<script setup>
import PortalSettings from './PortalSettings.vue';
</script>
<template>
<Story title="Pages/HelpCenter/PortalSettings" :layout="{ type: 'single' }">
<Variant title="Default">
<div class="w-[1000px] min-h-screen bg-n-background">
<PortalSettings />
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,144 @@
<script setup>
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import PortalBaseSettings from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue';
import PortalConfigurationSettings from './PortalConfigurationSettings.vue';
import ConfirmDeletePortalDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/ConfirmDeletePortalDialog.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
portals: {
type: Array,
required: true,
},
isFetching: {
type: Boolean,
required: true,
},
});
const emit = defineEmits([
'updatePortal',
'updatePortalConfiguration',
'deletePortal',
'refreshStatus',
'sendCnameInstructions',
]);
const { t } = useI18n();
const route = useRoute();
const confirmDeletePortalDialogRef = ref(null);
const currentPortalSlug = computed(() => route.params.portalSlug);
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
const isFetchingSSLStatus = useMapGetter('portals/isFetchingSSLStatus');
const activePortal = computed(() => {
return props.portals?.find(portal => portal.slug === currentPortalSlug.value);
});
const activePortalName = computed(() => activePortal.value?.name || '');
const isLoading = computed(() => props.isFetching || isSwitchingPortal.value);
const handleUpdatePortal = portal => {
emit('updatePortal', portal);
};
const handleUpdatePortalConfiguration = portal => {
emit('updatePortalConfiguration', portal);
};
const fetchSSLStatus = () => {
emit('refreshStatus');
};
const handleSendCnameInstructions = payload => {
emit('sendCnameInstructions', payload);
};
const openConfirmDeletePortalDialog = () => {
confirmDeletePortalDialogRef.value.dialogRef.open();
};
const handleDeletePortal = () => {
emit('deletePortal', activePortal.value);
confirmDeletePortalDialogRef.value.dialogRef.close();
};
</script>
<template>
<HelpCenterLayout :show-pagination-footer="false">
<template #content>
<div
v-if="isLoading"
class="flex items-center justify-center py-10 pt-2 pb-8 text-n-slate-11"
>
<Spinner />
</div>
<div
v-else-if="activePortal"
class="flex flex-col w-full gap-4 max-w-[40rem] pb-8"
>
<PortalBaseSettings
:active-portal="activePortal"
:is-fetching="isFetching"
@update-portal="handleUpdatePortal"
/>
<div class="w-full h-px bg-n-weak" />
<PortalConfigurationSettings
:active-portal="activePortal"
:is-fetching="isFetching"
:is-fetching-status="isFetchingSSLStatus"
@update-portal-configuration="handleUpdatePortalConfiguration"
@refresh-status="fetchSSLStatus"
@send-cname-instructions="handleSendCnameInstructions"
/>
<div class="w-full h-px bg-n-weak" />
<div class="flex items-end justify-between w-full gap-4">
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.HEADER'
)
}}
</h6>
<span class="text-sm text-n-slate-11">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.DESCRIPTION'
)
}}
</span>
</div>
<Button
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.BUTTON',
{
portalName: activePortalName,
}
)
"
color="ruby"
class="max-w-56 !w-fit"
@click="openConfirmDeletePortalDialog"
/>
</div>
</div>
</template>
<ConfirmDeletePortalDialog
ref="confirmDeletePortalDialogRef"
:active-portal-name="activePortalName"
@delete-portal="handleDeletePortal"
/>
</HelpCenterLayout>
</template>