feat(apps): Shopify Integration (#11101)

This PR adds native integration with Shopify. No more dashboard apps.
The support agents can view the orders, their status and the link to the
order page on the conversation sidebar.

This PR does the following: 
- Create an integration with Shopify (a new app is added in the
integrations tab)
- Option to configure it in SuperAdmin
- OAuth endpoint and the callbacks.
- Frontend component to render the orders. (We might need to cache it in
the future)
---------

Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
Pranav
2025-03-19 15:37:55 -07:00
committed by GitHub
parent a60dcda301
commit b34c526c51
35 changed files with 1211 additions and 37 deletions

View File

@@ -32,6 +32,12 @@ class IntegrationsAPI extends ApiClient {
deleteHook(hookId) {
return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`);
}
connectShopify({ shopDomain }) {
return axios.post(`${this.baseUrl()}/integrations/shopify/auth`, {
shop_domain: shopDomain,
});
}
}
export default new IntegrationsAPI();

View File

@@ -0,0 +1,17 @@
/* global axios */
import ApiClient from '../ApiClient';
class ShopifyAPI extends ApiClient {
constructor() {
super('integrations/shopify', { accountScoped: true });
}
getOrders(contactId) {
return axios.get(`${this.url}/orders`, {
params: { contact_id: contactId },
});
}
}
export default new ShopifyAPI();

View File

@@ -80,10 +80,12 @@ const maxWidthClass = computed(() => {
const open = () => {
dialogRef.value?.showModal();
};
const close = () => {
emit('close');
dialogRef.value?.close();
};
const confirm = () => {
emit('confirm');
};
@@ -104,9 +106,10 @@ defineExpose({ open, close });
@close="close"
>
<OnClickOutside @trigger="close">
<div
<form
ref="dialogContentRef"
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-left align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
@submit.prevent="confirm"
@click.stop
>
<div v-if="title || description" class="flex flex-col gap-2">
@@ -129,6 +132,7 @@ defineExpose({ open, close });
color="slate"
:label="cancelButtonLabel || t('DIALOG.BUTTONS.CANCEL')"
class="w-full"
type="button"
@click="close"
/>
<Button
@@ -138,11 +142,11 @@ defineExpose({ open, close });
class="w-full"
:is-loading="isLoading"
:disabled="disableConfirmButton || isLoading"
@click="confirm"
type="submit"
/>
</div>
</slot>
</div>
</form>
</OnClickOutside>
</dialog>
</Teleport>

View File

@@ -0,0 +1,105 @@
<script setup>
import { computed } from 'vue';
import { format } from 'date-fns';
import { useI18n } from 'vue-i18n';
const props = defineProps({
order: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const formatDate = dateString => {
return format(new Date(dateString), 'MMM d, yyyy');
};
const formatCurrency = (amount, currency) => {
return new Intl.NumberFormat('en', {
style: 'currency',
currency: currency || 'USD',
}).format(amount);
};
const getStatusClass = status => {
const classes = {
paid: 'bg-n-teal-5 text-n-teal-12',
};
return classes[status] || 'bg-slate-50 text-slate-700';
};
const getStatusI18nKey = (type, status = '') => {
return `CONVERSATION_SIDEBAR.SHOPIFY.${type.toUpperCase()}_STATUS.${status.toUpperCase()}`;
};
const fulfillmentStatus = computed(() => {
const { fulfillment_status: status } = props.order;
if (!status) {
return '';
}
return t(getStatusI18nKey('FULFILLMENT', status));
});
const financialStatus = computed(() => {
const { financial_status: status } = props.order;
if (!status) {
return '';
}
return t(getStatusI18nKey('FINANCIAL', status));
});
const getFulfillmentClass = status => {
const classes = {
fulfilled: 'text-green-600',
partial: 'text-yellow-600',
unfulfilled: 'text-red-600',
};
return classes[status] || 'text-slate-600';
};
</script>
<template>
<div
class="py-3 border-b border-n-weak last:border-b-0 flex flex-col gap-1.5"
>
<div class="flex justify-between items-center">
<div class="font-medium flex">
<a
:href="order.admin_url"
target="_blank"
rel="noopener noreferrer"
class="hover:underline text-n-slate-12 cursor-pointer truncate"
>
{{ $t('CONVERSATION_SIDEBAR.SHOPIFY.ORDER_ID', { id: order.id }) }}
<i class="i-lucide-external-link pl-5" />
</a>
</div>
<div
:class="getStatusClass(order.financial_status)"
class="text-xs px-2 py-1 rounded capitalize truncate"
:title="financialStatus"
>
{{ financialStatus }}
</div>
</div>
<div class="text-sm text-n-slate-12">
<span class="text-n-slate-11 border-r border-n-weak pr-2">
{{ formatDate(order.created_at) }}
</span>
<span class="text-n-slate-11 pl-2">
{{ formatCurrency(order.total_price, order.currency) }}
</span>
</div>
<div v-if="fulfillmentStatus">
<span
:class="getFulfillmentClass(order.fulfillment_status)"
class="capitalize font-medium"
:title="fulfillmentStatus"
>
{{ fulfillmentStatus }}
</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,71 @@
<script setup>
import { ref, watch, computed } from 'vue';
import { useFunctionGetter } from 'dashboard/composables/store';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ShopifyAPI from '../../../api/integrations/shopify';
import ShopifyOrderItem from './ShopifyOrderItem.vue';
const props = defineProps({
contactId: {
type: [Number, String],
required: true,
},
});
const contact = useFunctionGetter('contacts/getContact', props.contactId);
const hasSearchableInfo = computed(
() => !!contact.value?.email || !!contact.value?.phone_number
);
const orders = ref([]);
const loading = ref(true);
const error = ref('');
const fetchOrders = async () => {
try {
loading.value = true;
const response = await ShopifyAPI.getOrders(props.contactId);
orders.value = response.data.orders;
} catch (e) {
error.value =
e.response?.data?.error || 'CONVERSATION_SIDEBAR.SHOPIFY.ERROR';
} finally {
loading.value = false;
}
};
watch(
() => props.contactId,
() => {
if (hasSearchableInfo.value) {
fetchOrders();
}
},
{ immediate: true }
);
</script>
<template>
<div class="px-4 py-2 text-n-slate-12">
<div v-if="!hasSearchableInfo" class="text-center text-n-slate-12">
{{ $t('CONVERSATION_SIDEBAR.SHOPIFY.NO_SHOPIFY_ORDERS') }}
</div>
<div v-else-if="loading" class="flex justify-center items-center p-4">
<Spinner size="32" class="text-n-brand" />
</div>
<div v-else-if="error" class="text-center text-n-ruby-12">
{{ error }}
</div>
<div v-else-if="!orders.length" class="text-center text-n-slate-12">
{{ $t('CONVERSATION_SIDEBAR.SHOPIFY.NO_SHOPIFY_ORDERS') }}
</div>
<div v-else>
<ShopifyOrderItem
v-for="order in orders"
:key="order.id"
:order="order"
/>
</div>
</div>
</template>

View File

@@ -8,6 +8,7 @@ export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([
{ name: 'contact_attributes' },
{ name: 'previous_conversation' },
{ name: 'conversation_participants' },
{ name: 'shopify_orders' },
]);
export const DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER = Object.freeze([

View File

@@ -295,7 +295,27 @@
"CONVERSATION_INFO": "Conversation Information",
"CONTACT_ATTRIBUTES": "Contact Attributes",
"PREVIOUS_CONVERSATION": "Previous Conversations",
"MACROS": "Macros"
"MACROS": "Macros",
"SHOPIFY_ORDERS": "Shopify Orders"
},
"SHOPIFY": {
"ORDER_ID": "Order #{id}",
"ERROR": "Error loading orders",
"NO_SHOPIFY_ORDERS": "No orders found",
"FINANCIAL_STATUS": {
"PENDING": "Pending",
"AUTHORIZED": "Authorized",
"PARTIALLY_PAID": "Partially Paid",
"PAID": "Paid",
"PARTIALLY_REFUNDED": "Partially Refunded",
"REFUNDED": "Refunded",
"VOIDED": "Voided"
},
"FULFILLMENT_STATUS": {
"FULFILLED": "Fulfilled",
"PARTIALLY_FULFILLED": "Partially Fulfilled",
"UNFULFILLED": "Unfulfilled"
}
}
},
"CONVERSATION_CUSTOM_ATTRIBUTES": {

View File

@@ -1,5 +1,20 @@
{
"INTEGRATION_SETTINGS": {
"SHOPIFY": {
"DELETE": {
"TITLE": "Delete Shopify Integration",
"MESSAGE": "Are you sure you want to delete the Shopify integration?"
},
"STORE_URL": {
"TITLE": "Connect Shopify Store",
"LABEL": "Store URL",
"PLACEHOLDER": "your-store.myshopify.com",
"HELP": "Enter your Shopify store's myshopify.com URL",
"CANCEL": "Cancel",
"SUBMIT": "Connect Store"
},
"ERROR": "There was an error connecting to Shopify. Please try again or contact support if the issue persists."
},
"HEADER": "Integrations",
"DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.",
"LEARN_MORE": "Learn more about integrations",

View File

@@ -50,7 +50,7 @@ onUnmounted(() => {
<template>
<div
class="input-container rounded-xl transition-[border-bottom] duration-[0.2s] ease-[ease-in-out] relative flex items-center py-2 px-4 h-14 gap-2 border border-solid"
class="input-container rounded-xl transition-[border-bottom] duration-[0.2s] ease-[ease-in-out] relative flex items-center py-2 px-4 h-14 gap-2 border border-solid bg-n-alpha-black2"
:class="{
'border-n-brand': isInputFocused,
'border-n-weak': !isInputFocused,

View File

@@ -1,6 +1,10 @@
<script setup>
import { computed, watch, onMounted, ref } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import {
useMapGetter,
useFunctionGetter,
useStore,
} from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import AccordionItem from 'dashboard/components/Accordion/AccordionItem.vue';
@@ -13,6 +17,7 @@ import ConversationInfo from './ConversationInfo.vue';
import CustomAttributes from './customAttributes/CustomAttributes.vue';
import Draggable from 'vuedraggable';
import MacrosList from './Macros/List.vue';
import ShopifyOrdersList from '../../../components/widgets/conversation/ShopifyOrdersList.vue';
const props = defineProps({
conversationId: {
@@ -38,6 +43,14 @@ const {
const dragging = ref(false);
const conversationSidebarItems = ref([]);
const shopifyIntegration = useFunctionGetter(
'integrations/getIntegration',
'shopify'
);
const isShopifyFeatureEnabled = computed(
() => shopifyIntegration.value.enabled
);
const store = useStore();
const currentChat = useMapGetter('getSelectedChat');
@@ -216,6 +229,22 @@ onMounted(() => {
<MacrosList :conversation-id="conversationId" />
</AccordionItem>
</woot-feature-toggle>
<div
v-else-if="
element.name === 'shopify_orders' && isShopifyFeatureEnabled
"
>
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.SHOPIFY_ORDERS')"
:is-open="isContactSidebarItemOpen('is_shopify_orders_open')"
compact
@toggle="
value => toggleSidebarUIState('is_shopify_orders_open', value)
"
>
<ShopifyOrdersList :contact-id="contactId" />
</AccordionItem>
</div>
</div>
</template>
</Draggable>

View File

@@ -6,6 +6,8 @@ import { useI18n } from 'vue-i18n';
import { frontendURL } from '../../../../helper/URLHelper';
import { useAlert } from 'dashboard/composables';
import { useInstallationName } from 'shared/mixins/globalConfigMixin';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
@@ -25,17 +27,21 @@ const { t } = useI18n();
const store = useStore();
const router = useRouter();
const showDeleteConfirmationPopup = ref(false);
const dialogRef = ref(null);
const accountId = computed(() => store.getters.getCurrentAccountId);
const globalConfig = computed(() => store.getters['globalConfig/get']);
const openDeletePopup = () => {
showDeleteConfirmationPopup.value = true;
if (dialogRef.value) {
dialogRef.value.open();
}
};
const closeDeletePopup = () => {
showDeleteConfirmationPopup.value = false;
if (dialogRef.value) {
dialogRef.value.close();
}
};
const deleteIntegration = async () => {
@@ -50,16 +56,18 @@ const deleteIntegration = async () => {
const confirmDeletion = () => {
closeDeletePopup();
deleteIntegration();
router.push({ name: 'settings_integrations' });
router.push({ name: 'settings_applications' });
};
</script>
<template>
<div
class="flex flex-col items-start justify-between md:flex-row md:items-center p-4 outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow"
class="flex flex-col items-start justify-between lg:flex-row lg:items-center p-6 outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow gap-6"
>
<div class="flex items-center justify-start flex-1 m-0 mx-4 gap-6">
<div class="flex h-16 w-16 items-center justify-center">
<div
class="flex items-start lg:items-center justify-start flex-1 m-0 gap-6 flex-col lg:flex-row"
>
<div class="flex h-16 w-16 items-center justify-center flex-shrink-0">
<img
:src="`/dashboard/images/integrations/${integrationId}.png`"
class="max-w-full rounded-md border border-n-weak shadow-sm block dark:hidden bg-n-alpha-3 dark:bg-n-alpha-2"
@@ -83,7 +91,7 @@ const confirmDeletion = () => {
</p>
</div>
</div>
<div class="flex justify-center items-center mb-0 w-[15%]">
<div class="flex justify-center items-center mb-0">
<router-link
:to="
frontendURL(
@@ -105,33 +113,37 @@ const confirmDeletion = () => {
</div>
<div v-else>
<NextButton faded blue>
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.CONFIGURE') }}
{{ t('INTEGRATION_SETTINGS.WEBHOOK.CONFIGURE') }}
</NextButton>
</div>
</div>
</router-link>
<div v-if="!integrationEnabled">
<a :href="integrationAction">
<NextButton faded blue>
{{ $t('INTEGRATION_SETTINGS.CONNECT.BUTTON_TEXT') }}
</NextButton>
</a>
<slot name="action">
<a :href="integrationAction">
<NextButton faded blue>
{{ t('INTEGRATION_SETTINGS.CONNECT.BUTTON_TEXT') }}
</NextButton>
</a>
</slot>
</div>
</div>
<woot-delete-modal
v-model:show="showDeleteConfirmationPopup"
:on-close="closeDeletePopup"
:on-confirm="confirmDeletion"
<Dialog
ref="dialogRef"
type="alert"
:title="
deleteConfirmationText.title ||
$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.TITLE')
t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.TITLE')
"
:message="
:description="
deleteConfirmationText.message ||
$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.MESSAGE')
t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.MESSAGE')
"
:confirm-text="$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.YES')"
:reject-text="$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.NO')"
:confirm-button-label="
t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.YES')
"
:cancel-button-label="t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.NO')"
@confirm="confirmDeletion"
/>
</div>
</template>

View File

@@ -0,0 +1,151 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import {
useFunctionGetter,
useMapGetter,
useStore,
} from 'dashboard/composables/store';
import Integration from './Integration.vue';
import Spinner from 'shared/components/Spinner.vue';
import integrationAPI from 'dashboard/api/integrations';
import Input from 'dashboard/components-next/input/Input.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
defineProps({
error: {
type: String,
default: '',
},
});
const store = useStore();
const dialogRef = ref(null);
const integrationLoaded = ref(false);
const storeUrl = ref('');
const isSubmitting = ref(false);
const storeUrlError = ref('');
const integration = useFunctionGetter('integrations/getIntegration', 'shopify');
const uiFlags = useMapGetter('integrations/getUIFlags');
const integrationAction = computed(() => {
if (integration.value.enabled) {
return 'disconnect';
}
return 'connect';
});
const hideStoreUrlModal = () => {
storeUrl.value = '';
storeUrlError.value = '';
isSubmitting.value = false;
};
const validateStoreUrl = url => {
const pattern = /^[a-zA-Z0-9][a-zA-Z0-9-]*\.myshopify\.com$/;
return pattern.test(url);
};
const openStoreUrlDialog = () => {
if (dialogRef.value) {
dialogRef.value.open();
}
};
const handleStoreUrlSubmit = async () => {
try {
storeUrlError.value = '';
if (!validateStoreUrl(storeUrl.value)) {
storeUrlError.value =
'Please enter a valid Shopify store URL (e.g., your-store.myshopify.com)';
return;
}
isSubmitting.value = true;
const { data } = await integrationAPI.connectShopify({
shopDomain: storeUrl.value,
});
if (data.redirect_url) {
window.location.href = data.redirect_url;
}
} catch (error) {
storeUrlError.value = error.message;
} finally {
isSubmitting.value = false;
}
};
const initializeShopifyIntegration = async () => {
await store.dispatch('integrations/get', 'shopify');
integrationLoaded.value = true;
};
onMounted(() => {
initializeShopifyIntegration();
});
</script>
<template>
<div class="flex-grow flex-shrink p-4 overflow-auto max-w-6xl mx-auto">
<div
v-if="integrationLoaded && !uiFlags.isCreatingShopify"
class="flex flex-col gap-6"
>
<Integration
:integration-id="integration.id"
:integration-logo="integration.logo"
:integration-name="integration.name"
:integration-description="integration.description"
:integration-enabled="integration.enabled"
:integration-action="integrationAction"
:delete-confirmation-text="{
title: $t('INTEGRATION_SETTINGS.SHOPIFY.DELETE.TITLE'),
message: $t('INTEGRATION_SETTINGS.SHOPIFY.DELETE.MESSAGE'),
}"
>
<template #action>
<button
class="rounded button success nice"
@click="openStoreUrlDialog"
>
{{ $t('INTEGRATION_SETTINGS.CONNECT.BUTTON_TEXT') }}
</button>
</template>
</Integration>
<div
v-if="error"
class="flex items-center justify-center flex-1 outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow p-6"
>
<p class="text-red-500">
{{ $t('INTEGRATION_SETTINGS.SHOPIFY.ERROR') }}
</p>
</div>
<Dialog
ref="dialogRef"
:title="$t('INTEGRATION_SETTINGS.SHOPIFY.STORE_URL.TITLE')"
:is-loading="isSubmitting"
@confirm="handleStoreUrlSubmit"
@close="hideStoreUrlModal"
>
<Input
v-model="storeUrl"
:label="$t('INTEGRATION_SETTINGS.SHOPIFY.STORE_URL.LABEL')"
:placeholder="
$t('INTEGRATION_SETTINGS.SHOPIFY.STORE_URL.PLACEHOLDER')
"
:message="
!storeUrlError
? $t('INTEGRATION_SETTINGS.SHOPIFY.STORE_URL.HELP')
: storeUrlError
"
:message-type="storeUrlError ? 'error' : 'info'"
/>
</Dialog>
</div>
<div v-else class="flex items-center justify-center flex-1">
<Spinner size="" color-scheme="primary" />
</div>
</div>
</template>

View File

@@ -8,6 +8,8 @@ import DashboardApps from './DashboardApps/Index.vue';
import Slack from './Slack.vue';
import SettingsContent from '../Wrapper.vue';
import Linear from './Linear.vue';
import Shopify from './Shopify.vue';
export default {
routes: [
{
@@ -88,6 +90,16 @@ export default {
},
props: route => ({ code: route.query.code }),
},
{
path: 'shopify',
name: 'settings_integrations_shopify',
component: Shopify,
meta: {
featureFlag: FEATURE_FLAGS.INTEGRATIONS,
permissions: ['administrator'],
},
props: route => ({ error: route.query.error }),
},
{
path: ':integration_id',
name: 'settings_applications_integration',