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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user