Files
nullcart/cms/src/components/DiscountScopePicker.vue
T
2026-08-28 17:31:02 +02:00

244 lines
7.8 KiB
Vue

<template>
<div class="form-item-stack">
<el-switch
:model-value="modelValue.applyToAll"
:validate-event="false"
active-text="All products"
@update:model-value="value => updateScope({ applyToAll: Boolean(value) })"
/>
<template v-if="!modelValue.applyToAll">
<div class="scope-field">
<span class="secondary-text m-0">Applies to all products in category</span>
<el-select
:model-value="modelValue.categoryIds"
:validate-event="false"
multiple
collapse-tags
:max-collapse-tags="3"
collapse-tags-tooltip
class="cms-scope-select"
placeholder="Select categories"
@update:model-value="value => updateScope({ categoryIds: value })"
>
<el-option
v-for="category in categories"
:key="category.id"
:label="category.name"
:value="category.id"
/>
</el-select>
</div>
<div class="scope-field">
<span class="secondary-text m-0">Applies to all variants of the product</span>
<el-select
:model-value="modelValue.productIds"
:validate-event="false"
multiple
filterable
remote
reserve-keyword
collapse-tags
:max-collapse-tags="3"
collapse-tags-tooltip
class="cms-scope-select"
:remote-method="loadProducts"
:loading="productSearchLoading"
placeholder="Search products"
@update:model-value="value => updateScope({ productIds: value })"
>
<el-option
v-for="product in productOptions"
:key="product.id"
:label="product.title"
:value="product.id"
/>
</el-select>
</div>
<div class="scope-field">
<span class="secondary-text m-0">Applies to selected variants of the product</span>
<el-select
:model-value="modelValue.variantIds"
:validate-event="false"
multiple
filterable
remote
reserve-keyword
collapse-tags
:max-collapse-tags="3"
collapse-tags-tooltip
class="cms-scope-select"
:remote-method="loadVariants"
:loading="variantSearchLoading"
placeholder="Search variants"
@update:model-value="value => updateScope({ variantIds: value })"
>
<el-option
v-for="variant in variantOptions"
:key="variant.id"
:label="variant.label"
:value="variant.id"
/>
</el-select>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { getProductTitle } from '@/utils/product/getProductTitle';
import { getVariantLabel } from '@/utils/product/getVariantLabel';
import { useCategoriesStore } from '@/stores/categories';
import { useProductsStore } from '@/stores/products';
import type { DiscountCode } from '@/types/discountCode/DiscountCode';
import type { DiscountScope } from '@/types/discountCode/DiscountScope';
import type { Product } from '@/types/product/Product';
import type { ProductOption } from '@/types/product/ProductOption';
import type { ProductVariant } from '@/types/product/ProductVariant';
import type { VariantOption } from '@/types/product/VariantOption';
import { ElMessage } from 'element-plus';
import { onMounted, ref, type PropType } from 'vue';
import { storeToRefs } from 'pinia';
const props = defineProps({
modelValue: {
type: Object as PropType<DiscountScope>,
required: true
},
discountCode: {
type: Object as PropType<DiscountCode | null>,
default: null
}
});
const emit = defineEmits<{
'update:modelValue': [value: DiscountScope];
change: [];
}>();
const { fetchProducts, fetchProductVariants } = useProductsStore();
const { categories } = storeToRefs(useCategoriesStore());
const productOptions = ref<ProductOption[]>([]);
const variantOptions = ref<VariantOption[]>([]);
const productSearchLoading = ref(false);
const variantSearchLoading = ref(false);
onMounted(() => {
if (props.discountCode) {
mergeProductOptions(props.discountCode.products?.map(productOptionFromEntity) ?? []);
mergeVariantOptions(props.discountCode.variants?.map(variantOptionFromEntity) ?? []);
}
});
const mergeProductOptions = (products: ProductOption[]): void => {
const selectedIds = new Set(props.modelValue.productIds);
const byId = new Map(
productOptions.value.filter(product => selectedIds.has(product.id)).map(product => [product.id, product])
);
for (const product of products) {
byId.set(product.id, product);
}
productOptions.value = [...byId.values()].sort((a, b) => a.title.localeCompare(b.title));
};
const productOptionFromEntity = (product: Pick<Product, 'id' | 'title'>): ProductOption => ({
id: product.id,
title: getProductTitle(product.title)
});
const mergeVariantOptions = (variants: VariantOption[]): void => {
const selectedIds = new Set(props.modelValue.variantIds);
const byId = new Map(
variantOptions.value.filter(variant => selectedIds.has(variant.id)).map(variant => [variant.id, variant])
);
for (const variant of variants) {
byId.set(variant.id, variant);
}
variantOptions.value = [...byId.values()].sort((a, b) => a.label.localeCompare(b.label));
};
const variantOptionFromEntity = (variant: ProductVariant): VariantOption => ({
id: variant.id,
label: getVariantLabel(variant.title, variant.product?.title)
});
const loadProducts = async (search: string) => {
productSearchLoading.value = true;
try {
const { items } = await fetchProducts({ search, page: 1, limit: 20 });
mergeProductOptions(items.map(product => productOptionFromEntity(product)));
} catch {
ElMessage.error('Failed to load products');
} finally {
productSearchLoading.value = false;
}
};
const loadVariants = async (search: string) => {
variantSearchLoading.value = true;
try {
const { items } = await fetchProductVariants({ search, page: 1, limit: 20 });
mergeVariantOptions(items.map(variantOptionFromEntity));
} catch {
ElMessage.error('Failed to load variants');
} finally {
variantSearchLoading.value = false;
}
};
const updateScope = (patch: Partial<DiscountScope>) => {
emit('update:modelValue', { ...props.modelValue, ...patch });
emit('change');
};
</script>
<style scoped>
.form-item-stack {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.scope-field {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
width: 100%;
margin-bottom: 8px;
}
.scope-field .cms-scope-select {
width: 100%;
}
.scope-field :deep(.el-select__wrapper) {
min-width: 0;
}
.scope-field :deep(.el-select__selected-item) {
max-width: 100%;
}
.scope-field :deep(.el-select__tags-text) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: min(160px, 45vw);
}
</style>