Make catalog settings editable
This commit is contained in:
@@ -94,11 +94,17 @@ export type CatalogProductTypeSetting = {
|
||||
allowCustomLabel: Scalars['Boolean']['output'];
|
||||
allowCustomLength: Scalars['Boolean']['output'];
|
||||
allowCustomSleeveBrand: Scalars['Boolean']['output'];
|
||||
colorOptions: Array<Scalars['String']['output']>;
|
||||
customLengthMaxM?: Maybe<Scalars['Int']['output']>;
|
||||
customLengthMinM?: Maybe<Scalars['Int']['output']>;
|
||||
customLengthStepM?: Maybe<Scalars['Int']['output']>;
|
||||
labelOptions: Array<Scalars['String']['output']>;
|
||||
lengthOptionsM: Array<Scalars['Int']['output']>;
|
||||
productType: Scalars['String']['output'];
|
||||
showQuantityPerBox: Scalars['Boolean']['output'];
|
||||
sleeveOptions: Array<Scalars['String']['output']>;
|
||||
thicknessOptionsMicron: Array<Scalars['Int']['output']>;
|
||||
widthOptionsMm: Array<Scalars['Int']['output']>;
|
||||
};
|
||||
|
||||
export type Company = {
|
||||
@@ -786,11 +792,17 @@ export type UpsertCatalogProductTypeSettingInput = {
|
||||
allowCustomLabel: Scalars['Boolean']['input'];
|
||||
allowCustomLength: Scalars['Boolean']['input'];
|
||||
allowCustomSleeveBrand: Scalars['Boolean']['input'];
|
||||
colorOptions: Array<Scalars['String']['input']>;
|
||||
customLengthMaxM?: InputMaybe<Scalars['Int']['input']>;
|
||||
customLengthMinM?: InputMaybe<Scalars['Int']['input']>;
|
||||
customLengthStepM?: InputMaybe<Scalars['Int']['input']>;
|
||||
labelOptions: Array<Scalars['String']['input']>;
|
||||
lengthOptionsM: Array<Scalars['Int']['input']>;
|
||||
productType: Scalars['String']['input'];
|
||||
showQuantityPerBox: Scalars['Boolean']['input'];
|
||||
sleeveOptions: Array<Scalars['String']['input']>;
|
||||
thicknessOptionsMicron: Array<Scalars['Int']['input']>;
|
||||
widthOptionsMm: Array<Scalars['Int']['input']>;
|
||||
};
|
||||
|
||||
export type UpsertMyCounterpartyProfileInput = {
|
||||
@@ -1158,7 +1170,7 @@ export type UpsertMyCounterpartyProfileMutation = { __typename?: 'Mutation', ups
|
||||
export type CatalogProductTypeSettingsQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type CatalogProductTypeSettingsQuery = { __typename?: 'Query', catalogProductTypeSettings: Array<{ __typename?: 'CatalogProductTypeSetting', productType: string, showQuantityPerBox: boolean, allowCustomLength: boolean, customLengthMinM?: number | null, customLengthMaxM?: number | null, customLengthStepM?: number | null, allowCustomSleeveBrand: boolean, allowCustomLabel: boolean }> };
|
||||
export type CatalogProductTypeSettingsQuery = { __typename?: 'Query', catalogProductTypeSettings: Array<{ __typename?: 'CatalogProductTypeSetting', productType: string, showQuantityPerBox: boolean, allowCustomLength: boolean, customLengthMinM?: number | null, customLengthMaxM?: number | null, customLengthStepM?: number | null, allowCustomSleeveBrand: boolean, allowCustomLabel: boolean, widthOptionsMm: Array<number>, lengthOptionsM: Array<number>, thicknessOptionsMicron: Array<number>, sleeveOptions: Array<string>, colorOptions: Array<string>, labelOptions: Array<string> }> };
|
||||
|
||||
export type IntegrationSyncDashboardQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
@@ -1170,7 +1182,7 @@ export type UpsertCatalogProductTypeSettingMutationVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type UpsertCatalogProductTypeSettingMutation = { __typename?: 'Mutation', upsertCatalogProductTypeSetting: { __typename?: 'CatalogProductTypeSetting', productType: string, showQuantityPerBox: boolean, allowCustomLength: boolean, customLengthMinM?: number | null, customLengthMaxM?: number | null, customLengthStepM?: number | null, allowCustomSleeveBrand: boolean, allowCustomLabel: boolean } };
|
||||
export type UpsertCatalogProductTypeSettingMutation = { __typename?: 'Mutation', upsertCatalogProductTypeSetting: { __typename?: 'CatalogProductTypeSetting', productType: string, showQuantityPerBox: boolean, allowCustomLength: boolean, customLengthMinM?: number | null, customLengthMaxM?: number | null, customLengthStepM?: number | null, allowCustomSleeveBrand: boolean, allowCustomLabel: boolean, widthOptionsMm: Array<number>, lengthOptionsM: Array<number>, thicknessOptionsMicron: Array<number>, sleeveOptions: Array<string>, colorOptions: Array<string>, labelOptions: Array<string> } };
|
||||
|
||||
|
||||
export const ConsumeLoginTokenDocument = gql`
|
||||
@@ -2980,6 +2992,12 @@ export const CatalogProductTypeSettingsDocument = gql`
|
||||
customLengthStepM
|
||||
allowCustomSleeveBrand
|
||||
allowCustomLabel
|
||||
widthOptionsMm
|
||||
lengthOptionsM
|
||||
thicknessOptionsMicron
|
||||
sleeveOptions
|
||||
colorOptions
|
||||
labelOptions
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -3055,6 +3073,12 @@ export const UpsertCatalogProductTypeSettingDocument = gql`
|
||||
customLengthStepM
|
||||
allowCustomSleeveBrand
|
||||
allowCustomLabel
|
||||
widthOptionsMm
|
||||
lengthOptionsM
|
||||
thicknessOptionsMicron
|
||||
sleeveOptions
|
||||
colorOptions
|
||||
labelOptions
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||
import {
|
||||
CatalogProductTypeSettingsDocument,
|
||||
ClientProductsDocument,
|
||||
UpsertCatalogProductTypeSettingDocument,
|
||||
type CatalogProductTypeSettingsQuery,
|
||||
type ClientProductsQuery,
|
||||
} from '~/composables/graphql/generated';
|
||||
|
||||
definePageMeta({
|
||||
@@ -14,7 +12,14 @@ definePageMeta({
|
||||
});
|
||||
|
||||
type CatalogSettingItem = CatalogProductTypeSettingsQuery['catalogProductTypeSettings'][number];
|
||||
type ProductNode = ClientProductsQuery['clientProducts'][number];
|
||||
type OptionKey =
|
||||
| 'widthOptionsMm'
|
||||
| 'lengthOptionsM'
|
||||
| 'thicknessOptionsMicron'
|
||||
| 'sleeveOptions'
|
||||
| 'colorOptions'
|
||||
| 'labelOptions';
|
||||
type OptionKind = 'number' | 'text';
|
||||
type CatalogSettingForm = {
|
||||
productType: string;
|
||||
allowCustomLength: boolean;
|
||||
@@ -23,17 +28,32 @@ type CatalogSettingForm = {
|
||||
customLengthStepM: string;
|
||||
allowCustomSleeveBrand: boolean;
|
||||
allowCustomLabel: boolean;
|
||||
widthOptionsMm: string[];
|
||||
lengthOptionsM: string[];
|
||||
thicknessOptionsMicron: string[];
|
||||
sleeveOptions: string[];
|
||||
colorOptions: string[];
|
||||
labelOptions: string[];
|
||||
drafts: Record<OptionKey, string>;
|
||||
};
|
||||
type StandardOptionGroup = {
|
||||
type OptionGroupDefinition = {
|
||||
key: OptionKey;
|
||||
label: string;
|
||||
values: string[];
|
||||
placeholder: string;
|
||||
kind: OptionKind;
|
||||
suffix?: string;
|
||||
};
|
||||
|
||||
const COLOR_TAGS = ['прозрачный', 'коричневый', 'белый', 'черный', 'желтый', 'зеленый', 'красный', 'синий', 'оранжевый', 'красно-белый'];
|
||||
const LABEL_TAGS = ['хрупкое', 'подарок', 'акция'];
|
||||
const OPTION_GROUPS: OptionGroupDefinition[] = [
|
||||
{ key: 'widthOptionsMm', label: 'Ширина', placeholder: 'Добавить ширину', kind: 'number', suffix: 'мм' },
|
||||
{ key: 'lengthOptionsM', label: 'Длина', placeholder: 'Добавить длину', kind: 'number', suffix: 'м' },
|
||||
{ key: 'thicknessOptionsMicron', label: 'Толщина', placeholder: 'Добавить толщину', kind: 'number', suffix: 'мкм' },
|
||||
{ key: 'sleeveOptions', label: 'Втулка', placeholder: 'Добавить втулку', kind: 'text' },
|
||||
{ key: 'colorOptions', label: 'Цвет', placeholder: 'Добавить цвет', kind: 'text' },
|
||||
{ key: 'labelOptions', label: 'Надпись', placeholder: 'Добавить надпись', kind: 'text' },
|
||||
];
|
||||
|
||||
const settingsQuery = useQuery(CatalogProductTypeSettingsDocument);
|
||||
const catalogProductsQuery = useQuery(ClientProductsDocument);
|
||||
const saveSettingMutation = useMutation(UpsertCatalogProductTypeSettingDocument, { throws: 'never' });
|
||||
|
||||
const forms = reactive<Record<string, CatalogSettingForm>>({});
|
||||
@@ -42,64 +62,64 @@ const saveSuccess = ref('');
|
||||
const saveError = ref('');
|
||||
|
||||
const settings = computed<CatalogSettingItem[]>(() => settingsQuery.result.value?.catalogProductTypeSettings ?? []);
|
||||
const isLoading = computed(() => settingsQuery.loading.value || catalogProductsQuery.loading.value);
|
||||
const isLoading = computed(() => settingsQuery.loading.value);
|
||||
|
||||
function normalizeText(value: string | null | undefined) {
|
||||
return String(value ?? '').replaceAll(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function formatNumberOptions(values: Array<number | null | undefined>, suffix: string) {
|
||||
return [...new Set(values.filter((value): value is number => typeof value === 'number'))]
|
||||
.sort((a, b) => a - b)
|
||||
.map((value) => `${value} ${suffix}`);
|
||||
}
|
||||
|
||||
function formatTextOptions(values: Array<string | null | undefined>) {
|
||||
return [...new Set(values.map((value) => normalizeText(value)).filter(Boolean))]
|
||||
.sort((a, b) => a.localeCompare(b, 'ru'));
|
||||
}
|
||||
|
||||
const standardOptionsByType = computed<Record<string, StandardOptionGroup[]>>(() => {
|
||||
const products = catalogProductsQuery.result.value?.clientProducts ?? [];
|
||||
const grouped = new Map<string, ProductNode[]>();
|
||||
|
||||
for (const product of products) {
|
||||
const typeLabel = normalizeText(product.productType) || 'Без типа';
|
||||
const existing = grouped.get(typeLabel);
|
||||
if (existing) {
|
||||
existing.push(product);
|
||||
} else {
|
||||
grouped.set(typeLabel, [product]);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
[...grouped.entries()].map(([typeLabel, items]) => {
|
||||
const colorValues = formatTextOptions(
|
||||
items.flatMap((product) => product.tags.filter((tag) => COLOR_TAGS.includes(normalizeText(tag)))),
|
||||
);
|
||||
const labelValues = formatTextOptions(
|
||||
items.flatMap((product) => product.tags.filter((tag) => LABEL_TAGS.includes(normalizeText(tag)))),
|
||||
);
|
||||
|
||||
const optionGroups: StandardOptionGroup[] = [
|
||||
{ label: 'Ширина', values: formatNumberOptions(items.map((product) => product.widthMm), 'мм') },
|
||||
{ label: 'Длина', values: formatNumberOptions(items.map((product) => product.lengthM), 'м') },
|
||||
{ label: 'Толщина', values: formatNumberOptions(items.map((product) => product.thicknessMicron), 'мкм') },
|
||||
{ label: 'Втулка', values: formatTextOptions(items.map((product) => product.sleeveBrand)) },
|
||||
{ label: 'Цвет', values: colorValues },
|
||||
{ label: 'Надпись', values: labelValues },
|
||||
].filter((group) => group.values.length > 0);
|
||||
|
||||
return [typeLabel, optionGroups];
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
function toInputValue(value: number | null | undefined) {
|
||||
return value == null ? '' : String(value);
|
||||
}
|
||||
|
||||
function createDrafts(): Record<OptionKey, string> {
|
||||
return {
|
||||
widthOptionsMm: '',
|
||||
lengthOptionsM: '',
|
||||
thicknessOptionsMicron: '',
|
||||
sleeveOptions: '',
|
||||
colorOptions: '',
|
||||
labelOptions: '',
|
||||
};
|
||||
}
|
||||
|
||||
function parseOptionalInteger(value: string) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number(normalized);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizeOptionEntry(value: string, kind: OptionKind) {
|
||||
if (kind === 'number') {
|
||||
const parsed = parseOptionalInteger(value);
|
||||
return parsed == null ? '' : String(parsed);
|
||||
}
|
||||
|
||||
return normalizeText(value);
|
||||
}
|
||||
|
||||
function normalizeOptionList(values: Array<string | number | null | undefined>, kind: OptionKind) {
|
||||
const normalizedValues = values
|
||||
.map((value) => normalizeOptionEntry(String(value ?? ''), kind))
|
||||
.filter(Boolean);
|
||||
|
||||
return [...new Set(normalizedValues)].sort((left, right) => {
|
||||
if (kind === 'number') {
|
||||
return Number(left) - Number(right);
|
||||
}
|
||||
|
||||
return left.localeCompare(right, 'ru');
|
||||
});
|
||||
}
|
||||
|
||||
function createForm(item: CatalogSettingItem): CatalogSettingForm {
|
||||
return {
|
||||
productType: item.productType,
|
||||
@@ -109,25 +129,45 @@ function createForm(item: CatalogSettingItem): CatalogSettingForm {
|
||||
customLengthStepM: toInputValue(item.customLengthStepM),
|
||||
allowCustomSleeveBrand: item.allowCustomSleeveBrand,
|
||||
allowCustomLabel: item.allowCustomLabel,
|
||||
widthOptionsMm: normalizeOptionList(item.widthOptionsMm, 'number'),
|
||||
lengthOptionsM: normalizeOptionList(item.lengthOptionsM, 'number'),
|
||||
thicknessOptionsMicron: normalizeOptionList(item.thicknessOptionsMicron, 'number'),
|
||||
sleeveOptions: normalizeOptionList(item.sleeveOptions, 'text'),
|
||||
colorOptions: normalizeOptionList(item.colorOptions, 'text'),
|
||||
labelOptions: normalizeOptionList(item.labelOptions, 'text'),
|
||||
drafts: createDrafts(),
|
||||
};
|
||||
}
|
||||
|
||||
function parseOptionalInteger(value: string) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Number(normalized);
|
||||
}
|
||||
|
||||
function formFor(item: CatalogSettingItem) {
|
||||
forms[item.productType] ??= createForm(item);
|
||||
return forms[item.productType];
|
||||
}
|
||||
|
||||
function standardOptionsFor(item: CatalogSettingItem) {
|
||||
return standardOptionsByType.value[item.productType] ?? [];
|
||||
function addOption(form: CatalogSettingForm, group: OptionGroupDefinition) {
|
||||
const value = normalizeOptionEntry(form.drafts[group.key], group.kind);
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
form[group.key] = normalizeOptionList([...form[group.key], value], group.kind);
|
||||
form.drafts[group.key] = '';
|
||||
}
|
||||
|
||||
function removeOption(form: CatalogSettingForm, groupKey: OptionKey, value: string) {
|
||||
form[groupKey] = form[groupKey].filter((item) => item !== value);
|
||||
}
|
||||
|
||||
function optionChipLabel(value: string, group: OptionGroupDefinition) {
|
||||
return group.suffix ? `${value} ${group.suffix}` : value;
|
||||
}
|
||||
|
||||
function parseIntegerOptionList(values: string[]) {
|
||||
return normalizeOptionList(values, 'number').map((value) => Number(value));
|
||||
}
|
||||
|
||||
function parseTextOptionList(values: string[]) {
|
||||
return normalizeOptionList(values, 'text');
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -165,6 +205,12 @@ async function saveAllSettings() {
|
||||
customLengthStepM: form.allowCustomLength ? parseOptionalInteger(form.customLengthStepM) : null,
|
||||
allowCustomSleeveBrand: form.allowCustomSleeveBrand,
|
||||
allowCustomLabel: form.allowCustomLabel,
|
||||
widthOptionsMm: parseIntegerOptionList(form.widthOptionsMm),
|
||||
lengthOptionsM: parseIntegerOptionList(form.lengthOptionsM),
|
||||
thicknessOptionsMicron: parseIntegerOptionList(form.thicknessOptionsMicron),
|
||||
sleeveOptions: parseTextOptionList(form.sleeveOptions),
|
||||
colorOptions: parseTextOptionList(form.colorOptions),
|
||||
labelOptions: parseTextOptionList(form.labelOptions),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -194,13 +240,13 @@ async function saveAllSettings() {
|
||||
Типы товаров пока не появились в каталоге.
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-4 xl:grid-cols-2">
|
||||
<div v-else class="space-y-4">
|
||||
<article
|
||||
v-for="item in settings"
|
||||
:key="item.productType"
|
||||
class="rounded-[28px] bg-white p-5 shadow-[0_18px_38px_rgba(18,56,36,0.08)]"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="space-y-5">
|
||||
<h2 class="text-xl font-bold text-[#123824]">{{ item.productType }}</h2>
|
||||
|
||||
<div class="space-y-3">
|
||||
@@ -258,23 +304,56 @@ async function saveAllSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="standardOptionsFor(item).length" class="rounded-[24px] bg-[#f7fbf8] p-4">
|
||||
<div class="rounded-[24px] bg-[#f7fbf8] p-4">
|
||||
<div class="mb-4 text-sm font-bold uppercase tracking-[0.12em] text-[#355947]">Стандартные параметры</div>
|
||||
<div class="space-y-3">
|
||||
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="group in standardOptionsFor(item)"
|
||||
:key="`${item.productType}-${group.label}`"
|
||||
class="space-y-2"
|
||||
v-for="group in OPTION_GROUPS"
|
||||
:key="`${item.productType}-${group.key}`"
|
||||
class="rounded-[20px] bg-white p-4"
|
||||
>
|
||||
<div class="text-sm font-semibold text-[#123824]">{{ group.label }}</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="value in group.values"
|
||||
:key="`${item.productType}-${group.label}-${value}`"
|
||||
class="rounded-full bg-white px-3 py-1 text-xs font-semibold text-[#355947]"
|
||||
>
|
||||
{{ value }}
|
||||
</span>
|
||||
<div class="space-y-3">
|
||||
<div class="text-sm font-semibold text-[#123824]">{{ group.label }}</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="value in formFor(item)[group.key]"
|
||||
:key="`${item.productType}-${group.key}-${value}`"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-full bg-[#eef7f1] px-3 py-1 text-xs font-semibold text-[#1d5a3c]"
|
||||
@click="removeOption(formFor(item), group.key, value)"
|
||||
>
|
||||
<span>{{ optionChipLabel(value, group) }}</span>
|
||||
<span class="text-[11px] leading-none text-[#6a8a78]">×</span>
|
||||
</button>
|
||||
|
||||
<span
|
||||
v-if="formFor(item)[group.key].length === 0"
|
||||
class="rounded-full border border-dashed border-[#d6e7dc] px-3 py-1 text-xs font-medium text-[#7b8f84]"
|
||||
>
|
||||
Пока пусто
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 md:flex-row">
|
||||
<input
|
||||
v-model="formFor(item).drafts[group.key]"
|
||||
:type="group.kind === 'number' ? 'number' : 'text'"
|
||||
:min="group.kind === 'number' ? '1' : undefined"
|
||||
:step="group.kind === 'number' ? '1' : undefined"
|
||||
class="input manager-field w-full"
|
||||
:placeholder="group.placeholder"
|
||||
@keydown.enter.prevent="addOption(formFor(item), group)"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn h-11 rounded-full border-0 bg-[#dff2e7] px-5 text-sm font-semibold text-[#155c3a] hover:bg-[#caead8]"
|
||||
@click="addOption(formFor(item), group)"
|
||||
>
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,5 +8,11 @@ query CatalogProductTypeSettings {
|
||||
customLengthStepM
|
||||
allowCustomSleeveBrand
|
||||
allowCustomLabel
|
||||
widthOptionsMm
|
||||
lengthOptionsM
|
||||
thicknessOptionsMicron
|
||||
sleeveOptions
|
||||
colorOptions
|
||||
labelOptions
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,11 @@ mutation UpsertCatalogProductTypeSetting($input: UpsertCatalogProductTypeSetting
|
||||
customLengthStepM
|
||||
allowCustomSleeveBrand
|
||||
allowCustomLabel
|
||||
widthOptionsMm
|
||||
lengthOptionsM
|
||||
thicknessOptionsMicron
|
||||
sleeveOptions
|
||||
colorOptions
|
||||
labelOptions
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,6 +257,12 @@ type CatalogProductTypeSetting {
|
||||
customLengthStepM: Int
|
||||
allowCustomSleeveBrand: Boolean!
|
||||
allowCustomLabel: Boolean!
|
||||
widthOptionsMm: [Int!]!
|
||||
lengthOptionsM: [Int!]!
|
||||
thicknessOptionsMicron: [Int!]!
|
||||
sleeveOptions: [String!]!
|
||||
colorOptions: [String!]!
|
||||
labelOptions: [String!]!
|
||||
}
|
||||
|
||||
type CartItem {
|
||||
@@ -512,6 +518,12 @@ input UpsertCatalogProductTypeSettingInput {
|
||||
customLengthStepM: Int
|
||||
allowCustomSleeveBrand: Boolean!
|
||||
allowCustomLabel: Boolean!
|
||||
widthOptionsMm: [Int!]!
|
||||
lengthOptionsM: [Int!]!
|
||||
thicknessOptionsMicron: [Int!]!
|
||||
sleeveOptions: [String!]!
|
||||
colorOptions: [String!]!
|
||||
labelOptions: [String!]!
|
||||
}
|
||||
|
||||
input ReadyOrderItemInput {
|
||||
|
||||
Reference in New Issue
Block a user