Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,14 @@ fragment shortLineItem on LineItemType {
extendedPrice {
...money
}
configurationItems {
id
sectionId
type
productId
customText
files {
url
}
}
}
124 changes: 68 additions & 56 deletions client-app/core/api/graphql/types.ts

Large diffs are not rendered by default.

105 changes: 74 additions & 31 deletions client-app/shared/cart/components/add-to-cart.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>
<QuantityControl
:mode="$cfg.product_quantity_control"
:mode
:model-value="enteredQuantity"
:name="product.id"
:count-in-cart="countInCart"
Expand All @@ -18,12 +18,16 @@
:show-empty-details="reservedSpace"
validate-on-mount
:timeout="DEFAULT_DEBOUNCE_IN_MS"
:allow-zero="$cfg.product_quantity_control === 'stepper'"
:allow-zero="mode === 'stepper'"
@update:model-value="onInput"
@update:cart-item-quantity="onChange"
@update:validation="onValidationUpdate"
>
<slot />

<template #append>
<slot name="append" :on-change-handler="onChange" />
</template>
</QuantityControl>
</template>

Expand All @@ -32,12 +36,13 @@ import { isDefined } from "@vueuse/core";
import { clone } from "lodash";
import { computed, ref, toRef } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
import { useErrorsTranslator, useHistoricalEvents } from "@/core/composables";
import { useAnalyticsUtils } from "@/core/composables/useAnalyticsUtils";
import { useThemeContext } from "@/core/composables/useThemeContext";
import { LINE_ITEM_ID_URL_SEARCH_PARAM, LINE_ITEM_QUANTITY_LIMIT } from "@/core/constants";
import { ValidationErrorObjectType } from "@/core/enums";
import { getUrlSearchParam, Logger } from "@/core/utilities";
import { Logger } from "@/core/utilities";
import { useShortCart } from "@/shared/cart/composables";
import { useConfigurableProduct } from "@/shared/catalog/composables";
import { useNotifications } from "@/shared/notification";
Expand All @@ -57,6 +62,7 @@ interface IEmits {
}

interface IProps {
mode?: "button" | "stepper";
product: Product | VariationType;
reservedSpace?: boolean;
}
Expand All @@ -65,10 +71,13 @@ const product = toRef(props, "product");
const { cart, addToCart, changeItemQuantity } = useShortCart();
const { t } = useI18n();
const { translate } = useErrorsTranslator<ValidationErrorType>("validation_error");
const configurableLineItemId = getUrlSearchParam(LINE_ITEM_ID_URL_SEARCH_PARAM);
const route = useRoute();
const router = useRouter();
const {
selectedConfiguration,
selectedConfigurationInput,
changeCartConfiguredItem,
compareInputAndConfigurationItem,
validateSections: validateConfigurableInput,
} = useConfigurableProduct(product.value.id);
const { trackAddItemToCart } = useAnalyticsUtils();
Expand All @@ -85,25 +94,22 @@ const notAvailableMessage = computed<string | undefined>(() => {
return undefined;
});

const defaultMinQuantity = computed<number>(() =>
themeContext.value.settings.product_quantity_control === "button" ? 1 : 0,
);
const isConfigurable = computed<boolean>(() => "isConfigurable" in product.value && product.value.isConfigurable);
const disabled = computed<boolean>(() => loading.value || !product.value.availabilityData?.isAvailable);
const configurableLineItemIdParam = computed(() => route.query[LINE_ITEM_ID_URL_SEARCH_PARAM]);
const mode = computed(() => props.mode ?? themeContext.value.settings.product_quantity_control ?? "button");
const defaultMinQuantity = computed(() => (mode.value === "button" ? 1 : 0));
const isConfigurable = computed(() => "isConfigurable" in product.value && product.value.isConfigurable);
const disabled = computed(() => loading.value || !product.value.availabilityData?.isAvailable);
const countInCart = computed<number>(() => getLineItem(cart.value?.items)?.quantity || 0);
const minQty = computed<number>(() => product.value.minQuantity || defaultMinQuantity.value);
const maxQty = computed<number>(() =>
const minQty = computed(() => product.value.minQuantity || defaultMinQuantity.value);
const maxQty = computed(() =>
Math.min(
product.value.availabilityData?.availableQuantity || LINE_ITEM_QUANTITY_LIMIT,
isDefined(product.value.maxQuantity) && product.value.maxQuantity !== 0
? product.value.maxQuantity
: LINE_ITEM_QUANTITY_LIMIT,
),
);
const defaultQuantity =
themeContext.value.settings.product_quantity_control === "button"
? countInCart.value || minQty.value
: countInCart.value;
const defaultQuantity = mode.value === "button" ? countInCart.value || minQty.value : countInCart.value;
const enteredQuantity = ref(!disabled.value ? defaultQuantity : undefined);

function onInput(value: number): void {
Expand All @@ -121,35 +127,40 @@ function onInput(value: number): void {
*/
async function onChange() {
const lineItem = getLineItem(cart.value?.items);
const mode = lineItem ? AddToCartModeType.Update : AddToCartModeType.Add;
const _mode = lineItem ? AddToCartModeType.Update : AddToCartModeType.Add;

if (isConfigurable.value && !validateConfigurableInput()) {
displayErrorMessage(mode, t("shared.catalog.product_details.product_configuration.check_your_configuration"));
displayErrorMessage(_mode, t("shared.catalog.product_details.product_configuration.check_your_configuration"));
return;
}

loading.value = true;

try {
const updatedCart = await updateOrAddToCart(lineItem, mode);
const updatedCart = await updateOrAddToCart(lineItem, _mode);

if ((isConfigurable.value && mode === AddToCartModeType.Add) || enteredQuantity.value === 0) {
if (isConfigurable.value && _mode === AddToCartModeType.Add) {
const lineItemByMatchingConfiguration = getLineItemByMatchingConfiguration(updatedCart?.items);
void router.push({ query: { [LINE_ITEM_ID_URL_SEARCH_PARAM]: lineItemByMatchingConfiguration?.id } });
}

if ((isConfigurable.value && _mode === AddToCartModeType.Add) || enteredQuantity.value === 0) {
loading.value = false;
return;
}

const updatedLineItem = getLineItem(updatedCart?.items);
handleUpdateResult(updatedLineItem, mode);
handleUpdateResult(updatedLineItem, _mode);
} finally {
loading.value = false;
}
}

async function updateOrAddToCart(lineItem: ShortLineItemFragment | undefined, mode: AddToCartModeType) {
if (mode === AddToCartModeType.Update && enteredQuantity.value === undefined) {
async function updateOrAddToCart(lineItem: ShortLineItemFragment | undefined, _mode: AddToCartModeType) {
if (_mode === AddToCartModeType.Update && enteredQuantity.value === undefined) {
return cart.value;
}
if (mode === AddToCartModeType.Update && !!lineItem && enteredQuantity.value !== undefined) {
if (_mode === AddToCartModeType.Update && !!lineItem && enteredQuantity.value !== undefined) {
return isConfigurable.value
? await changeCartConfiguredItem(lineItem.id, enteredQuantity.value, selectedConfigurationInput.value)
: await changeItemQuantity(lineItem.id, enteredQuantity.value);
Expand All @@ -165,20 +176,20 @@ async function updateOrAddToCart(lineItem: ShortLineItemFragment | undefined, mo
return updatedCart;
}

function handleUpdateResult(lineItem: ShortLineItemFragment | undefined, mode: AddToCartModeType) {
function handleUpdateResult(lineItem: ShortLineItemFragment | undefined, _mode: AddToCartModeType) {
if (!lineItem) {
Logger.error(onChange.name, 'The variable "lineItem" must be defined');
displayErrorMessage(mode, getValidationErrors());
displayErrorMessage(_mode, getValidationErrors());
return;
}

emit("update:lineItem", clone(lineItem));
}

function displayErrorMessage(mode: AddToCartModeType, message: string) {
function displayErrorMessage(_mode: AddToCartModeType, message: string) {
notifications.error({
text: t(
mode === AddToCartModeType.Update
_mode === AddToCartModeType.Update
? "common.messages.fail_to_change_quantity_in_cart"
: "common.messages.fail_add_product_to_cart",
{ reason: message },
Expand All @@ -199,12 +210,44 @@ function getValidationErrors(): string {
);
}

function getLineItem(items?: ShortLineItemFragment[]): ShortLineItemFragment | undefined {
if (isConfigurable.value) {
return configurableLineItemId ? items?.find((item) => item.id === configurableLineItemId) : undefined;
} else {
function getLineItem(items?: ShortLineItemFragment[]) {
if (!isConfigurable.value) {
return items?.find((item) => item.productId === product.value.id);
}

if (configurableLineItemIdParam.value) {
return items?.find((item) => item.id === configurableLineItemIdParam.value);
}
}

function getLineItemByMatchingConfiguration(items?: ShortLineItemFragment[]) {
const productLineItems = items?.filter((item) => item.productId === product.value.id);

return productLineItems?.find((item) => {
if (item.configurationItems?.length !== selectedConfigurationInput.value.length) {
return false;
}

if (item.configurationItems?.length) {
return true;
}

return item.configurationItems?.every((itemConfiguration) => {
const selectedConfigurationInputItem = selectedConfigurationInput.value.find(
(input) => input.sectionId === itemConfiguration.sectionId,
);

const selectedConfigurationItem = selectedConfiguration.value?.[item.id];

if (enteredQuantity.value !== selectedConfigurationItem?.quantity) {
return false;
}

return selectedConfigurationInputItem
? compareInputAndConfigurationItem(selectedConfigurationInputItem, itemConfiguration)
: false;
});
});
}

function onValidationUpdate(validation: { isValid: true } | { isValid: false; errorMessage: string }) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,11 +135,10 @@
</template>

<script setup lang="ts">
import { toRef, watch } from "vue";
import { computed, toRef, watch } from "vue";
import { useI18n } from "vue-i18n";
import { onBeforeRouteLeave, onBeforeRouteUpdate } from "vue-router";
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute } from "vue-router";
import { LINE_ITEM_ID_URL_SEARCH_PARAM } from "@/core/constants";
import { getUrlSearchParam } from "@/core/utilities";
import { useConfigurableProduct } from "@/shared/catalog/composables";
import { CONFIGURABLE_SECTION_TYPES } from "@/shared/catalog/constants/configurableProducts";
import { SaveChangesModal } from "@/shared/common";
Expand All @@ -152,10 +151,10 @@ import OptionProduct from "./option-product.vue";
import SectionTextFieldset from "./section-text-fieldset.vue";
import type { ConfigurationSectionType } from "@/core/api/graphql/types";
import type { DeepReadonly } from "vue";
import type { RouteLocationNormalized } from "vue-router";

const props = defineProps<IProps>();

const configurableLineItemId = getUrlSearchParam(LINE_ITEM_ID_URL_SEARCH_PARAM);
const NOTIFICATIONS_GROUP = "product-configuration";

interface IProps {
Expand All @@ -178,11 +177,14 @@ const {

const { openModal } = useModal();
const notifications = useNotifications();
const route = useRoute();

const configurableLineItemId = computed(() => route.query[LINE_ITEM_ID_URL_SEARCH_PARAM]);

watch(
() => [isConfigurationChanged.value, validationErrors.value.size === 0, isDataUpdating.value],
([isChanged, isValid, isUpdating]) => {
if (isChanged && configurableLineItemId && isValid && !isUpdating) {
if (isChanged && configurableLineItemId.value && isValid && !isUpdating) {
notifications.info({
text: t("shared.catalog.product_details.product_configuration.changed_notification"),
singleInGroup: true,
Expand All @@ -191,7 +193,11 @@ watch(
text: t("common.buttons.save"),
color: "accent",
clickHandler() {
void changeCartConfiguredItem(configurableLineItemId, undefined, selectedConfigurationInput.value);
void changeCartConfiguredItem(
configurableLineItemId.value as string,
undefined,
selectedConfigurationInput.value,
);
},
},
});
Expand All @@ -214,11 +220,8 @@ function getSectionSubtitle(section: DeepReadonly<ConfigurationSectionType>) {
: t("shared.catalog.product_details.product_configuration.optional_no_selected");
}

async function canChangeRoute(): Promise<boolean> {
if (!configurableLineItemId) {
return true;
}
if (!isConfigurationChanged.value) {
async function canChangeRoute(to: RouteLocationNormalized, from: RouteLocationNormalized) {
if (!configurableLineItemId.value || !isConfigurationChanged.value || to.path === from.path) {
return true;
}
return await openSaveChangesModal();
Expand All @@ -245,8 +248,12 @@ async function openSaveChangesModal(): Promise<boolean> {
});
return;
}
if (configurableLineItemId) {
await changeCartConfiguredItem(configurableLineItemId, undefined, selectedConfigurationInput.value);
if (configurableLineItemId.value) {
await changeCartConfiguredItem(
configurableLineItemId.value as string,
undefined,
selectedConfigurationInput.value,
);
}
resolve(true);
},
Expand Down
37 changes: 36 additions & 1 deletion client-app/shared/catalog/components/product-sidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,29 @@
:product="product"
v-bind="getComponentProps(CUSTOM_PRODUCT_COMPONENT_IDS.PAGE_SIDEBAR_BUTTON)"
/>
<AddToCart v-else :product="product">
<AddToCart v-else :product="product" mode="button" :hide-button="isExactConfigurationLineItemOpened">
<template v-if="isExactConfigurationLineItemOpened" #append="{ onChangeHandler }">
<div class="flex gap-0.5">
<VcButton
variant="solid-light"
color="primary"
:title="$t('common.buttons.update')"
@click="onChangeHandler"
>
<VcIcon name="reload-circle" size="sm" />
</VcButton>

<VcButton
variant="solid-light"
color="secondary"
:title="$t('common.buttons.add_new')"
@click="addNewConfiguration(onChangeHandler)"
>
<VcIcon name="plus-circle" size="sm" />
</VcButton>
</div>
</template>

<InStock
:is-in-stock="product.availabilityData?.isInStock"
:is-digital="isDigital"
Expand All @@ -111,7 +133,9 @@

<script setup lang="ts">
import { computed, toRef } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useCurrency, useThemeContext } from "@/core/composables";
import { LINE_ITEM_ID_URL_SEARCH_PARAM } from "@/core/constants";
import { ProductType } from "@/core/enums";
import { AddToCart, useShortCart } from "@/shared/cart";
import { useConfigurableProduct } from "@/shared/catalog/composables";
Expand All @@ -135,6 +159,8 @@ const props = defineProps<IProps>();
const product = toRef(props, "product");
const variations = toRef(props, "variations");

const router = useRouter();
const route = useRoute();
const { currentCurrency } = useCurrency();
const { getItemsTotal } = useShortCart();
const { configuredLineItem, loading: configuredLineItemLoading } = useConfigurableProduct(product.value.id);
Expand Down Expand Up @@ -167,4 +193,13 @@ const price = computed<PriceType | { actual: MoneyType; list: MoneyType } | unde
}
return props.product.price;
});

const isExactConfigurationLineItemOpened = computed(
() => product.value.isConfigurable && route.query[LINE_ITEM_ID_URL_SEARCH_PARAM],
);

async function addNewConfiguration(onChangeHandler: () => void) {
await router.push({ query: { [LINE_ITEM_ID_URL_SEARCH_PARAM]: undefined } });
onChangeHandler();
}
</script>
Loading
Loading