feat: add FormKit framework support and update dependencies

- Add `telecart_forms` table migration and default checkout form seeder
- Implement `FormsHandler` to fetch form schemas
- Update `OrderCreateService` to handle custom fields in order comments
- Add `update` method to QueryBuilder and Grammar
- Add `Arr::except` helper
- Update composer dependencies (Carbon, Symfony, PHPUnit, etc.)
- Improve `MigratorService` error handling
- Add unit tests for new functionality
This commit is contained in:
2025-11-15 01:23:17 +03:00
committed by Nikita Kiselev
parent ae9771dec4
commit 6a59dcc0c9
69 changed files with 12529 additions and 416 deletions

View File

@@ -0,0 +1,70 @@
# FormBuilder System Context
## Architectural Overview
The FormBuilder ecosystem is a strictly typed Vue 3 application module designed to generate standard FormKit Schema JSON. It eschews internal DTOs in favor of direct schema manipulation.
### Core Components
1. **FormBuilderView (`views/FormBuilderView.vue`)**:
* **Role**: Smart container / Data fetcher.
* **Responsibility**: Fetches form data from API (`GET /api/admin/forms/{alias}`), handles loading states, and passes data to `FormBuilder`.
* **Contract**: Expects API response `{ data: { schema: Array, is_custom: Boolean, ... } }`.
2. **FormBuilder (`components/FormBuilder/FormBuilder.vue`)**:
* **Role**: Main Orchestrator / State Owner.
* **Responsibility**: Manages `v-model` (schema), mode switching (Visual/Code/Preview), and provides state to children.
* **State Management**: Uses `defineModel` for `formFields` (schema) and `isCustom` (mode flag). Uses `provide('formFields')` and `provide('selectedFieldId')` for deep dependency injection.
* **Modes**:
* **Visual**: Drag-and-drop interface using `vuedraggable`.
* **Code**: Direct JSON editing of the FormKit schema. Sets `isCustom = true`.
* **Preview**: Renders the current schema using `FormKit`.
3. **FormCanvas (`components/FormBuilder/FormCanvas.vue`)**:
* **Role**: Visual Editor Surface.
* **Responsibility**: Renders the draggable list of fields.
* **Implementation**: Uses `vuedraggable` bound to `formFields`.
* **UX**: Implements "Ghost" and "Drag" classes for visual feedback. Handles selection logic.
4. **FieldsPanel (`components/FormBuilder/FieldsPanel.vue`)**:
* **Role**: Component Palette.
* **Responsibility**: Source of truth for available field types.
* **Implementation**: Uses `vuedraggable` with `pull: 'clone', put: false` to spawn new fields.
5. **FieldSettings (`components/FormBuilder/FieldSettings.vue`)**:
* **Role**: Property Editor.
* **Responsibility**: Edits properties of the `selectedFieldId`.
* **Constraint**: Must use PrimeVue components for all inputs.
## Data Flow & Invariants
1. **Schema Authority**: The FormKit Schema JSON is the single source of truth. There is no "internal model" separate from the schema.
2. **Reactivity**:
* `formFields` is an Array of Objects.
* Mutations must preserve reactivity. When using `v-model` or `provide/inject`, ensure array methods (splice, push, filter) are used correctly or replace the entire array reference if needed to trigger watchers.
* **Immutability**: `useFormFields` composable uses immutable patterns (returning new array references) to ensure `defineModel` in parent detects changes.
3. **Mode Logic**:
* Switching to **Code** mode sets `isCustom = true`.
* Switching to **Visual** mode sets `isCustom = false`.
* **Safety**: Switching modes triggers JSON validation. Invalid JSON prevents mode switch.
4. **Drag and Drop**:
* Powered by `vuedraggable` (Sortable.js).
* **Clone Logic**: `FieldsPanel` clones from `availableFields`. `FormCanvas` receives the clone.
* **ID Generation**: Unique IDs are generated upon cloning/addition to ensure key stability.
## Naming & Conventions
* **Tailwind**: Use `tw:` prefix for all utility classes (e.g., `tw:flex`, `tw:p-4`).
* **Components**: PrimeVue components are the standard UI kit (Button, Panel, InputText, etc.).
* **Icons**: FontAwesome (`fa fa-*`).
* **Files**: PascalCase for components (`FormBuilder.vue`), camelCase for logic (`useFormFields.js`).
## Integration Rules
* **Backend**: The backend stores the JSON blob directly. `FormBuilder` does not transform data before save; it emits the raw schema.
* **API**: `useFormsStore` handles API communication.
## Pitfalls & Warnings
* **vuedraggable vs @formkit/drag-and-drop**: We strictly use `vuedraggable`. Do not re-introduce `@formkit/drag-and-drop`.
* **Watchers**: Avoid `watch` where `computed` or event handlers suffice, to prevent infinite loops in bidirectional data flow.
* **Tailwind Config**: Do not use `@apply` with `tw:` prefixed classes in `<style>` blocks; standard CSS properties should be used if custom classes are needed.
## Future Modifications
* **Adding Fields**: Update `constants/availableFields.js` and ensure `utils/fieldHelpers.js` supports the new type.
* **Validation**: FormKit validation rules string (e.g., "required|email") is edited as a raw string in `FieldSettings`. Complex validation builders would require a new UI component.

29
.cursorignore Normal file
View File

@@ -0,0 +1,29 @@
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
src/*
frontend/spa/node_modules
module/oc_telegram_shop/upload/oc_telegram_shop/vendor
module/oc_telegram_shop/upload/image
module/oc_telegram_shop/upload/oc_telegram_shop/.phpunit.cache

File diff suppressed because it is too large Load Diff

View File

@@ -16,10 +16,16 @@
"format": "prettier --write src/"
},
"dependencies": {
"@codemirror/lang-json": "^6.0.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@formkit/drag-and-drop": "^0.5.3",
"@formkit/i18n": "^1.6.9",
"@formkit/vue": "^1.6.9",
"@primeuix/themes": "^1.2.5",
"@tailwindcss/vite": "^4.1.16",
"@vueuse/core": "^14.0.0",
"axios": "^1.13.1",
"codemirror": "^6.0.2",
"daisyui": "^5.4.2",
"js-md5": "^0.8.3",
"mitt": "^3.0.1",
@@ -27,11 +33,13 @@
"primevue": "^4.4.1",
"tailwindcss": "^4.1.16",
"vue": "^3.5.22",
"vue-codemirror": "^6.1.1",
"vue-router": "^4.6.3",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@eslint/js": "^9.37.0",
"@formkit/icons": "^1.6.9",
"@prettier/plugin-oxc": "^0.0.4",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/eslint-config-prettier": "^10.2.0",

View File

@@ -30,6 +30,10 @@
<RouterLink :to="{name: 'mainpage'}">Главная страница</RouterLink>
</li>
<li :class="{active: route.name === 'formbuilder'}">
<RouterLink :to="{name: 'formbuilder'}">Форма заказа</RouterLink>
</li>
<li :class="{active: route.name === 'logs'}">
<RouterLink :to="{name: 'logs'}">Журнал событий</RouterLink>
</li>

View File

@@ -46,3 +46,23 @@ legend.p-fieldset-legend {
.telecart-admin-app {
color: var(--color-slate-700);
}
.blueprint-bg {
background-color: #efefef;
opacity: 0.7;
background-image: radial-gradient(#989898 0.65px, #efefef 0.65px);
background-size: 13px 13px;
}
ul.formkit-options {
padding: 0;
margin-bottom: 0;
list-style: none;
}
ul.formkit-options label {
display: inline-flex;
}
ul.formkit-options input[type="radio"] {
position: absolute;
}

View File

@@ -0,0 +1,70 @@
<template>
<div class="tw:flex-1 tw:flex tw:flex-col tw:gap-2 tw:h-full">
<Message v-if="isCustom" severity="info" class="tw:mb-2">
Вы находитесь в режиме ручного редактирования схемы.
<a
href="https://formkit.com/essentials/schema"
target="_blank"
title="Документация FormKit Schema"
class="tw:ml-1 tw:text-blue-600 hover:tw:underline"
>
Документация по схеме <i class="fa fa-external-link"></i>
</a>
</Message>
<Panel class="tw:flex-1 tw:flex tw:flex-col tw:overflow-hidden">
<template #header>
<div class="tw:flex tw:justify-between tw:items-center tw:w-full">
<div class="tw:flex tw:items-center tw:gap-2">
<span class="tw:font-medium">Редактор FormKit Schema</span>
</div>
<div v-if="error" class="tw:text-red-500 tw:text-sm">
<i class="fa fa-exclamation-circle"></i> {{ error }}
</div>
</div>
</template>
<div class="tw:flex-1 tw:h-full tw:overflow-hidden">
<Codemirror
:modelValue="modelValue"
@update:modelValue="onCodeChange"
placeholder="Code goes here..."
:style="{ height: '600px' }"
:autofocus="true"
:indent-with-tab="true"
:tab-size="2"
:extensions="extensions"
/>
</div>
</Panel>
</div>
</template>
<script setup>
import { Message, Panel } from 'primevue';
import { Codemirror } from 'vue-codemirror';
import { json } from '@codemirror/lang-json';
import { oneDark } from '@codemirror/theme-one-dark';
const props = defineProps({
modelValue: {
type: String,
default: ''
},
error: {
type: String,
default: null
},
isCustom: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['update:modelValue', 'change']);
const extensions = [json(), oneDark];
function onCodeChange(newVal) {
emit('update:modelValue', newVal);
emit('change', newVal);
}
</script>

View File

@@ -0,0 +1,376 @@
<template>
<div>
<div v-if="!selectedField" class="tw:text-gray-400 tw:text-center tw:py-8">
<i class="fa fa-mouse-pointer tw:text-2xl tw:mb-2"></i>
<p>Выберите поле для настройки</p>
</div>
<div v-else class="tw:space-y-4">
<!-- Тип поля (только для чтения) -->
<div>
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Тип поля</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs tw:mt-0.5"
v-tooltip.top="'Тип элемента формы (например, текст, число, выбор). Нельзя изменить после создания.'"
></i>
</div>
<InputText
:value="selectedField.$formkit"
disabled
class="tw:w-full tw:bg-gray-100"
/>
</div>
<!-- Название поля -->
<div>
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Название (name)</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs tw:mt-0.5"
v-tooltip.top="'Уникальный ключ поля в JSON-объекте данных. Используется при отправке формы. Должен быть на английском.'"
></i>
</div>
<InputText
:modelValue="selectedField.name"
@update:modelValue="onNameChange"
class="tw:w-full"
:class="{ 'p-invalid': nameError }"
placeholder="field_name"
:disabled="selectedField.locked"
/>
<small v-if="nameError" class="p-error tw:text-red-500 tw:text-xs tw:mt-1 tw:block">{{ nameError }}</small>
</div>
<!-- Метка -->
<div>
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Метка (label)</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs tw:mt-0.5"
v-tooltip.top="'Подпись, которая отображается над полем ввода для пользователя.'"
></i>
</div>
<InputText
:modelValue="selectedField.label"
@update:modelValue="updateField(selectedField.id, { label: $event })"
class="tw:w-full"
placeholder="Название поля"
/>
</div>
<!-- Help Text -->
<div>
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Подсказка (help)</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs tw:mt-0.5"
v-tooltip.top="'Дополнительный поясняющий текст, который отображается мелким шрифтом под полем.'"
></i>
</div>
<InputText
:modelValue="selectedField.help"
@update:modelValue="updateField(selectedField.id, { help: $event })"
class="tw:w-full"
placeholder="Текст подсказки"
/>
</div>
<!-- Иконки -->
<div class="tw:grid tw:grid-cols-2 tw:gap-2">
<div>
<div class="tw:flex tw:items-baseline tw:gap-2 tw:mb-1">
<label class="tw:block tw:text-sm tw:font-medium">Иконка слева</label>
</div>
<IconPicker
:modelValue="selectedField.prefixIcon"
@update:modelValue="updateField(selectedField.id, { prefixIcon: $event })"
/>
</div>
<div>
<div class="tw:flex tw:items-baseline tw:gap-2 tw:mb-1">
<label class="tw:block tw:text-sm tw:font-medium">Иконка справа</label>
</div>
<IconPicker
:modelValue="selectedField.suffixIcon"
@update:modelValue="updateField(selectedField.id, { suffixIcon: $event })"
/>
</div>
</div>
<!-- Placeholder (для текстовых полей) -->
<div v-if="hasPlaceholder">
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Текст-заполнитель (placeholder)</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs tw:mt-0.5"
v-tooltip.top="'Текст-подсказка внутри поля, который исчезает при начале ввода.'"
></i>
</div>
<InputText
:modelValue="selectedField.placeholder"
@update:modelValue="updateField(selectedField.id, { placeholder: $event })"
class="tw:w-full"
placeholder="Например: Введите ваше имя"
/>
</div>
<!-- Настройки для Range/Number -->
<div v-if="isRangeOrNumber">
<div class="tw:grid tw:grid-cols-2 tw:gap-2">
<div>
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Минимум</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs tw:mt-0.5"
v-tooltip.top="'Минимально допустимое значение.'"
></i>
</div>
<InputNumber
:modelValue="Number(selectedField.min)"
@update:modelValue="updateField(selectedField.id, { min: $event })"
class="tw:w-full"
inputClass="tw:w-full"
/>
</div>
<div>
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Максимум</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs tw:mt-0.5"
v-tooltip.top="'Максимально допустимое значение.'"
></i>
</div>
<InputNumber
:modelValue="Number(selectedField.max)"
@update:modelValue="updateField(selectedField.id, { max: $event })"
class="tw:w-full"
inputClass="tw:w-full"
/>
</div>
<div class="tw:col-span-2">
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Шаг (step)</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs tw:mt-0.5"
v-tooltip.top="'Шаг изменения значения (например, 1 или 0.5).'"
></i>
</div>
<InputNumber
:modelValue="Number(selectedField.step)"
@update:modelValue="updateField(selectedField.id, { step: $event })"
class="tw:w-full"
inputClass="tw:w-full"
/>
</div>
</div>
</div>
<!-- Настройки для Color -->
<div v-if="selectedField.$formkit === 'color'">
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Значение по умолчанию</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs"
v-tooltip.top="'Цвет, выбранный по умолчанию.'"
></i>
</div>
<div class="tw:flex tw:gap-2 tw:items-baseline">
<div class="tw:relative tw:w-10 tw:h-10 tw:rounded tw:overflow-hidden tw:border tw:border-gray-300">
<input
type="color"
:value="selectedField.value || '#000000'"
@input="updateField(selectedField.id, { value: $event.target.value })"
class="tw:absolute tw:-top-2 tw:-left-2 tw:w-16 tw:h-16 tw:cursor-pointer tw:p-0 tw:border-0"
/>
</div>
<InputText
:modelValue="selectedField.value"
@update:modelValue="updateField(selectedField.id, { value: $event })"
class="tw:flex-1"
placeholder="#000000"
/>
</div>
</div>
<!-- Опции (для select и radio) -->
<div v-if="hasOptions">
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Опции</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs tw:mt-0.5"
v-tooltip.top="'Список вариантов для выбора. Текст - то, что видит пользователь. Значение - то, что отправляется на сервер.'"
></i>
</div>
<div class="tw:space-y-2">
<div
v-for="(option, index) in selectedField.options"
:key="index"
class="tw:flex tw:gap-2 tw:items-center"
>
<InputText
:modelValue="option.label"
@update:modelValue="updateFieldOption(selectedField.id, index, 'label', $event)"
placeholder="Текст"
class="tw:flex-1 tw:w-full"
/>
<InputText
:modelValue="option.value"
@update:modelValue="updateFieldOption(selectedField.id, index, 'value', $event)"
placeholder="Значение"
class="tw:flex-1 tw:w-full"
/>
<Button
icon="fa fa-trash"
severity="danger"
size="small"
text
rounded
@click="removeFieldOption(selectedField.id, index)"
/>
</div>
<Button
label="Добавить опцию"
icon="fa fa-plus"
size="small"
class="tw:w-full"
@click="addFieldOption(selectedField.id)"
/>
</div>
</div>
<!-- Валидация -->
<div>
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Валидация</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs"
v-tooltip.top="'Правила проверки данных (FormKit Validation). Разделяются вертикальной чертой |. Например: required|email|length:5,10'"
></i>
</div>
<InputText
:modelValue="selectedField.validation"
@update:modelValue="updateField(selectedField.id, { validation: $event })"
class="tw:w-full"
placeholder="required|email|length:5,10"
/>
<p class="tw:text-xs tw:text-gray-500 tw:mt-1">
Примеры: required, email, length:5,10, number, url. <a href="https://formkit.com/essentials/validation" target="_blank">Документация <i class="fa fa-external-link"></i></a>
</p>
</div>
<!-- Label валидации -->
<div>
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Имя поля для ошибок</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs"
v-tooltip.top="'Название поля, которое будет подставляться в текст ошибки валидации вместо системного имени.'"
></i>
</div>
<InputText
:modelValue="selectedField.validationLabel"
@update:modelValue="updateField(selectedField.id, { validationLabel: $event })"
class="tw:w-full"
placeholder="Например: Пароль"
/>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue';
import { Button, InputText, InputNumber, useConfirm } from 'primevue';
import { useFormFields } from './composables/useFormFields.js';
import { supportsPlaceholder, supportsOptions } from './utils/fieldHelpers.js';
import IconPicker from '@/components/FormBuilder/IconPicker.vue';
const {
formFields,
selectedFieldId,
removeField,
updateField,
addFieldOption,
removeFieldOption,
updateFieldOption,
isFieldNameUnique,
setFieldError // Импортируем метод для установки ошибок
} = useFormFields();
const confirm = useConfirm();
const nameError = ref(null);
const selectedField = computed(() => {
if (!selectedFieldId || !selectedFieldId.value || !formFields || !formFields.value) {
return null;
}
return formFields.value.find(f => f.id === selectedFieldId.value);
});
const hasPlaceholder = computed(() => {
if (!selectedField.value) return false;
return supportsPlaceholder(selectedField.value.$formkit);
});
const hasOptions = computed(() => {
if (!selectedField.value) return false;
return supportsOptions(selectedField.value.$formkit);
});
const isRangeOrNumber = computed(() => {
if (!selectedField.value) return false;
return ['range', 'number'].includes(selectedField.value.$formkit);
});
// Сбрасываем ошибку при смене поля
watch(selectedFieldId, () => {
nameError.value = null;
// Ошибки в глобальном состоянии сбрасываются только при исправлении
});
function onNameChange(newName) {
if (!selectedField.value) return;
// Убираем пробелы и спецсимволы, кроме _ и букв/цифр
// Хотя FormKit позволяет многое, лучше придерживаться стандартных правил переменных
const sanitized = newName.trim(); // .replace(/[^a-zA-Z0-9_]/g, '');
// Не будем жестко фильтровать, но проверим уникальность
if (!sanitized) {
nameError.value = 'Имя поля не может быть пустым';
setFieldError(selectedField.value.id, nameError.value);
return;
}
if (sanitized !== selectedField.value.name && !isFieldNameUnique(sanitized, selectedField.value.id)) {
nameError.value = 'Поле с таким именем уже существует';
setFieldError(selectedField.value.id, nameError.value);
return;
}
nameError.value = null;
setFieldError(selectedField.value.id, null);
updateField(selectedField.value.id, { name: sanitized });
}
function removeSelectedField(event) {
if (!selectedField.value) return;
confirm.require({
target: event.currentTarget,
message: `Вы уверены, что хотите удалить поле "${selectedField.value.label || selectedField.value.name}"?`,
icon: 'fa fa-exclamation-triangle',
acceptLabel: 'Да, удалить',
rejectLabel: 'Нет',
acceptClass: 'p-button-danger p-button-sm',
rejectClass: 'p-button-secondary p-button-sm p-button-text',
accept: () => {
removeField(selectedField.value.id);
}
});
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,54 @@
<template>
<div class="tw:h-full tw:min-h-0 tw:flex tw:flex-col tw:bg-white tw:border tw:border-gray-200 tw:rounded-lg tw:overflow-hidden">
<!-- Заголовок -->
<div class="tw:p-4 tw:bg-[#f8f9fa] tw:border-b tw:border-gray-200 tw:font-bold tw:text-[#374151] tw:flex-shrink-0">
Доступные поля
</div>
<!-- Контент со скроллом -->
<div class="tw:flex-1 tw:min-h-0 tw:overflow-y-auto tw:p-4">
<draggable
:list="availableFields"
:group="{ name: 'fields', pull: 'clone', put: false }"
:sort="false"
:clone="cloneField"
item-key="type"
class="tw:space-y-2"
>
<template #item="{ element: field }">
<div
class="tw:p-3 tw:bg-gray-50 tw:border tw:border-gray-200 tw:rounded tw:cursor-move tw:hover:bg-gray-100 tw:transition-colors"
>
<div class="tw:flex tw:items-center tw:gap-2">
<i :class="field.icon" class="tw:text-gray-600"></i>
<span class="tw:text-sm tw:font-medium">{{ field.label }}</span>
</div>
</div>
</template>
</draggable>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import draggable from 'vuedraggable';
import { AVAILABLE_FIELDS } from './constants/availableFields.js';
import { useFormFields } from './composables/useFormFields.js';
const availableFields = ref(AVAILABLE_FIELDS);
const { generateFieldId } = useFormFields();
// Функция клонирования элемента при перетаскивании в канвас
function cloneField(field) {
const id = generateFieldId();
return {
id: id,
...field.defaultConfig,
name: field.defaultConfig.name || `field_${id.split('_')[1]}`,
};
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,320 @@
<template>
<div class="tw:flex tw:flex-col tw:h-[calc(100vh-200px)] tw:gap-4">
<!-- Popup подтверждения очистки -->
<ConfirmPopup group="clearForm" />
<!-- Диалог предупреждения при смене режима -->
<ConfirmDialog group="modeSwitch">
<template #message="{ message }">
<div class="tw:whitespace-pre-wrap tw:max-w-lg">{{ message.message }}</div>
</template>
</ConfirmDialog>
<!-- Панель инструментов -->
<div class="tw:flex tw:justify-start tw:items-center tw:pb-2 tw:border-b tw:border-gray-200">
<!-- Переключатель режимов -->
<SelectButton
:key="selectButtonKey"
:modelValue="activeMode"
:options="modes"
optionLabel="label"
optionValue="value"
:allowEmpty="false"
@update:modelValue="handleModeChange"
>
<template #option="slotProps">
<i :class="slotProps.option.icon" class="tw:mr-2"></i>
<span>{{ slotProps.option.label }}</span>
</template>
</SelectButton>
</div>
<div class="tw:flex tw:flex-1 tw:gap-4 tw:overflow-hidden tw:min-h-0">
<!-- Режим визуального конструктора -->
<template v-if="activeMode === 'visual'">
<!-- Панель доступных полей -->
<div class="tw:w-64 tw:flex-shrink-0 tw:h-full tw:overflow-hidden">
<FieldsPanel class="tw:h-full" />
</div>
<!-- Основная зона конструктора -->
<div class="tw:flex-1 tw:flex tw:gap-4 tw:overflow-hidden">
<!-- Зона формы -->
<div class="tw:flex-1 tw:flex tw:flex-col tw:border tw:border-gray-200 tw:rounded-lg tw:overflow-hidden tw:bg-white tw:relative">
<!-- Кнопка очистки (абсолютно позиционирована) -->
<div class="tw:absolute tw:top-4 tw:right-4 tw:z-20">
<Button
label="Очистить"
icon="fa fa-trash"
severity="danger"
size="small"
text
v-tooltip.left="'Удалить все поля и очистить форму'"
@click="clearForm"
/>
</div>
<!-- Контент (FormCanvas) занимает все оставшееся пространство -->
<div class="tw:flex-1 tw:overflow-y-auto tw:relative">
<FormCanvas class="tw:min-h-full" />
</div>
</div>
<!-- Панель настроек (справа) -->
<div class="tw:w-80 tw:flex-shrink-0 tw:h-full tw:overflow-y-auto">
<Panel class="tw:min-h-full">
<template #header>
<span>Настройки поля</span>
</template>
<FieldSettings />
</Panel>
</div>
</div>
</template>
<!-- Режим редактирования кода -->
<template v-if="activeMode === 'code'">
<CodeEditor
v-model="jsonCode"
:error="jsonError"
:is-custom="isCustom"
@change="handleJsonInput"
/>
</template>
<!-- Режим предпросмотра -->
<template v-if="activeMode === 'preview'">
<div class="tw:flex-1 tw:flex tw:justify-center tw:overflow-auto">
<div class="tw:w-full">
<FormRenderer
:schema="formSchema"
submit-label="Отправить форму"
@submit="handleFormSubmit"
/>
</div>
</div>
</template>
</div>
</div>
</template>
<script setup>
import { ref, provide, computed, onMounted } from 'vue';
import { Button, Panel, SelectButton, useConfirm, ConfirmPopup, ConfirmDialog } from 'primevue';
import FieldsPanel from '@/components/FormBuilder/FieldsPanel.vue';
import FormCanvas from '@/components/FormBuilder/FormCanvas.vue';
import FieldSettings from '@/components/FormBuilder/FieldSettings.vue';
import FormRenderer from '@/components/FormBuilder/FormRenderer.vue';
import CodeEditor from '@/components/FormBuilder/CodeEditor.vue';
import { toastBus } from '@/utils/toastHelper';
const formFields = defineModel({
type: Array,
default: () => []
});
const isCustom = defineModel('isCustom', {
type: Boolean,
default: false
});
const activeMode = ref('visual');
const jsonCode = ref('');
const originalJsonCode = ref(''); // Для отслеживания изменений в режиме кода
const jsonError = ref(null);
const confirm = useConfirm();
const selectButtonKey = ref(0);
const modes = [
{ label: 'Визуальный', value: 'visual', icon: 'fa fa-th-large' },
{ label: 'Код', value: 'code', icon: 'fa fa-code' },
{ label: 'Предпросмотр', value: 'preview', icon: 'fa fa-eye' },
];
// Состояние выбранного поля
const selectedFieldId = ref(null);
// Состояние ошибок полей
const fieldErrors = ref({});
// Предоставляем состояние дочерним компонентам
provide('formFields', formFields);
provide('selectedFieldId', selectedFieldId);
provide('fieldErrors', fieldErrors);
// Схема формы для предпросмотра
const formSchema = computed(() => {
return formFields.value || [];
});
// Инициализация режима при монтировании
onMounted(() => {
if (isCustom.value) {
activeMode.value = 'code';
jsonCode.value = JSON.stringify(formFields.value, null, 2);
originalJsonCode.value = jsonCode.value;
} else {
activeMode.value = 'visual';
}
});
function hasDuplicateNames(fields) {
if (!Array.isArray(fields)) return false;
const names = new Set();
for (const field of fields) {
if (field.name) {
if (names.has(field.name)) {
return true;
}
names.add(field.name);
}
}
return false;
}
function cancelModeSwitch() {
// Инкементируем ключ, чтобы принудительно перерисовать SelectButton с текущим activeMode
selectButtonKey.value++;
}
function handleModeChange(newMode) {
// Если пытаемся переключиться на тот же режим
if (newMode === activeMode.value) return;
// Если переключаемся ИЗ режима кода
if (activeMode.value === 'code') {
// Пытаемся распарсить JSON перед уходом
if (jsonError.value) {
toastBus.emit('show', { severity: 'error', summary: 'Ошибка JSON', detail: 'Исправьте ошибки в JSON перед переключением режима' });
// Не обновляем activeMode, остаемся в code
cancelModeSwitch();
return;
}
try {
const parsed = JSON.parse(jsonCode.value);
if (hasDuplicateNames(parsed)) {
toastBus.emit('show', { severity: 'error', summary: 'Ошибка валидации', detail: 'В схеме есть поля с одинаковыми именами (name). Исправьте их перед переключением.' });
cancelModeSwitch();
return;
}
// Проверяем, были ли изменения в коде
let hasChanges = false;
try {
// Сравниваем распарсенные объекты, чтобы избежать ложных срабатываний из-за форматирования
const originalObj = JSON.parse(originalJsonCode.value);
hasChanges = JSON.stringify(parsed) !== JSON.stringify(originalObj);
} catch (e) {
hasChanges = true; // Если не смогли распарсить оригинал, считаем что изменения были (или ошибка)
}
// Если переключаемся в визуальный режим
if (newMode === 'visual') {
if (hasChanges) {
// Если были изменения, показываем предупреждение
confirm.require({
group: 'modeSwitch',
header: 'Предупреждение',
message: 'Вы внесли изменения в код вручную.\n\nПосле ручного изменения визуальный редактор может работать некорректно или не отображать все настройки.\n\nВы можете остаться в режиме кода или очистить форму и начать заново в визуальном редакторе.',
icon: 'fa fa-exclamation-triangle',
acceptLabel: 'Очистить и перейти',
rejectLabel: 'Остаться в коде',
acceptClass: 'p-button-danger',
accept: () => {
formFields.value = [];
selectedFieldId.value = null;
isCustom.value = false;
jsonCode.value = '[]';
// Успешный переход
activeMode.value = 'visual';
},
reject: () => {
// Остаемся в коде
// activeMode.value НЕ обновляем, он остался 'code'
cancelModeSwitch();
}
});
// Не обновляем activeMode сразу, ждем решения пользователя
return;
} else {
// Если изменений не было, просто переключаемся и сбрасываем флаг кастомного режима
isCustom.value = false;
formFields.value = parsed;
}
} else {
// Переход в Preview - разрешаем обновление модели из кода
console.log('Updating formFields for Preview:', parsed);
formFields.value = parsed;
}
} catch (e) {
console.error('Ошибка парсинга при переключении:', e);
toastBus.emit('show', { severity: 'error', summary: 'Ошибка', detail: 'Некорректный JSON' });
cancelModeSwitch();
return;
}
}
// Если переключаемся В режим кода
if (newMode === 'code') {
isCustom.value = true;
jsonCode.value = JSON.stringify(formFields.value, null, 2);
originalJsonCode.value = jsonCode.value; // Сохраняем исходное состояние
}
// Обновляем режим для успешных переходов
activeMode.value = newMode;
}
function handleJsonInput() {
try {
const parsed = JSON.parse(jsonCode.value);
if (hasDuplicateNames(parsed)) {
jsonError.value = 'Ошибка: найдены поля с одинаковыми именами (name)';
} else {
jsonError.value = null;
}
// Обновляем модель сразу, чтобы изменения не терялись
console.log('handleJsonInput: Updating formFields', parsed);
formFields.value = parsed;
} catch (e) {
jsonError.value = e.message;
}
}
function clearForm(event) {
confirm.require({
target: event.currentTarget,
group: 'clearForm',
message: 'Вы уверены, что хотите очистить форму? Все поля будут удалены.',
icon: 'fa fa-exclamation-triangle',
acceptLabel: 'Да, очистить',
rejectLabel: 'Нет',
acceptClass: 'p-button-danger p-button-sm',
rejectClass: 'p-button-secondary p-button-sm p-button-text',
accept: () => {
formFields.value = [];
selectedFieldId.value = null;
jsonCode.value = '[]';
toastBus.emit('show', { severity: 'success', summary: 'Успешно', detail: 'Форма очищена' });
}
});
}
function handleFormSubmit(data) {
console.log('Данные формы:', data);
toastBus.emit('show', { severity: 'success', summary: 'Форма отправлена', detail: 'Данные: ' + JSON.stringify(data, null, 2) });
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,139 @@
<template>
<div
class="tw:min-h-full tw:w-full tw:flex tw:flex-col tw:items-center tw:p-8 blueprint-bg"
@click.self="handleBackgroundClick"
>
<div v-if="formFields.length === 0" class="tw:absolute tw:top-1/2 tw:left-1/2 tw:-translate-x-1/2 tw:-translate-y-1/2 tw:z-0 tw:text-center tw:text-gray-500 tw:py-12 tw:bg-white/80 tw:backdrop-blur-sm tw:rounded-xl tw:p-8 tw:shadow-sm tw:max-w-md tw:pointer-events-none">
<i class="fa fa-mouse-pointer tw:text-4xl tw:mb-4 tw:text-blue-500/50"></i>
<p class="tw:font-medium">Перетащите поля сюда, чтобы начать создавать форму</p>
</div>
<draggable
v-model="formFields"
group="fields"
item-key="id"
class="tw:w-full tw:max-w-2xl tw:space-y-4 tw:flex-1 tw:min-h-[300px] tw:relative tw:z-10 tw:pb-24"
ghost-class="ghost-field"
drag-class="drag-field"
@change="handleDragChange"
@click.self="handleBackgroundClick"
>
<template #item="{ element: field, index }">
<div
class="tw:relative tw:group tw:border-2 tw:rounded-lg tw:p-4 tw:bg-white tw:shadow-sm tw:cursor-pointer tw:transition-all"
:class="[
fieldErrors[field.id] ? 'tw:border-red-500 tw:ring-2 tw:ring-red-200' :
selectedFieldId === field.id ? 'tw:border-blue-500 tw:ring-2 tw:ring-blue-200' : 'tw:border-transparent hover:tw:border-blue-400'
]"
@click.stop="selectField(field.id)"
>
<!-- Иконка ошибки -->
<div
v-if="fieldErrors[field.id]"
class="tw:absolute tw:-left-3 tw:-top-3 tw:z-20 tw:bg-red-500 tw:text-white tw:rounded-full tw:w-6 tw:h-6 tw:flex tw:items-center tw:justify-center tw:shadow-sm"
v-tooltip.top="fieldErrors[field.id]"
>
<i class="fa fa-exclamation tw:text-xs"></i>
</div>
<!-- Кнопка удаления (справа за пределами блока, видна при выборе) -->
<div
class="tw:absolute tw:-right-12 tw:top-1/2 tw:-translate-y-1/2 tw:z-10 tw:transition-opacity tw:duration-200"
:class="selectedFieldId === field.id ? 'tw:opacity-100' : 'tw:opacity-0 tw:pointer-events-none'"
>
<Button
@click.stop="removeField(field.id)"
icon="fa fa-trash"
severity="danger"
rounded
size="small"
v-tooltip.right="'Удалить поле'"
class="!tw:shadow-md !tw:w-9 !tw:h-9 !tw:p-0 tw:flex tw:items-center tw:justify-center hover:!tw:bg-red-600"
/>
</div>
<!-- Оверлей для перехвата кликов поверх disabled инпутов -->
<div class="tw:absolute tw:inset-0 tw:z-[1] tw:bg-transparent"></div>
<!-- Предпросмотр поля -->
<div class="tw:relative tw:z-0">
<FormKit
:key="`${field.id}-${field.prefixIcon}-${field.suffixIcon}`"
:type="field.$formkit"
v-bind="getFieldProps(field)"
:name="field.name || `field_${field.id}`"
/>
</div>
</div>
</template>
</draggable>
</div>
</template>
<script setup>
import { FormKit } from '@formkit/vue';
import { Button } from 'primevue';
import draggable from 'vuedraggable';
import { useFormFields } from './composables/useFormFields.js';
import { getFieldProps } from './utils/fieldHelpers.js';
import { toastBus } from '@/utils/toastHelper';
// Используем composable для работы с полями
const {
formFields,
selectedFieldId,
fieldErrors,
selectField,
removeField,
isFieldNameUnique
} = useFormFields();
function handleDragChange(evt) {
if (evt.added) {
const addedField = evt.added.element;
// Проверяем уникальность имени
if (!isFieldNameUnique(addedField.name, addedField.id)) {
// Удаляем дубликат
removeField(addedField.id);
toastBus.emit('show', {
severity: 'error',
summary: 'Ошибка добавления',
detail: `Поле с именем "${addedField.name}" уже добавлено в форму.`
});
return;
}
selectField(addedField.id);
}
}
function handleBackgroundClick() {
// Сбрасываем выбор, если есть выбранный элемент
if (selectedFieldId.value) {
selectedFieldId.value = null;
}
}
</script>
<style scoped>
.blueprint-bg {
background-color: #e2e8f0;
background-image: radial-gradient(#cbd5e1 1px, transparent 1px);
background-size: 10px 10px;
}
.ghost-field {
opacity: 0.5;
background-color: #eff6ff;
border-color: #93c5fd;
border-style: dashed;
}
.drag-field {
opacity: 1;
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
transform: scale(1.05);
}
</style>

View File

@@ -0,0 +1,52 @@
<template>
<div class="tw:flex tw:justify-center tw:items-start tw:p-8">
<!-- Phone Mockup -->
<div class="tw:relative tw:inline-grid tw:justify-items-center tw:bg-black tw:border-[2.5px] tw:border-gray-600 tw:rounded-[32.5px] tw:p-[3px] tw:overflow-hidden tw:w-full tw:max-w-[280px]" style="aspect-ratio: 462/978;">
<!-- Camera -->
<div class="tw:absolute tw:top-[3%] tw:left-1/2 tw:-translate-x-1/2 tw:z-10 tw:bg-black tw:rounded-[8.5px] tw:w-[28%] tw:h-[3.7%]"></div>
<!-- Display -->
<div class="tw:relative tw:rounded-[27px] tw:w-full tw:h-full tw:overflow-hidden tw:bg-gray-100">
<div class="tw:pt-15 tw:px-2 tw:pb-2 tw:overflow-y-auto tw:max-h-full tw:h-full">
<FormKit
type="form"
@submit="handleSubmit"
:submit-label="submitLabel"
outer-class="tw:space-y-4"
>
<FormKitSchema :schema="schema" />
</FormKit>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { FormKit, FormKitSchema } from '@formkit/vue';
const props = defineProps({
schema: {
type: Array,
required: true,
default: () => [],
},
submitLabel: {
type: String,
default: 'Отправить',
},
});
const emit = defineEmits(['submit']);
function handleSubmit(data) {
emit('submit', data);
}
</script>
<style scoped>
::v-deep(ul.formkit-messages) {
margin-bottom: 0;
padding-left: 0;
}
</style>

View File

@@ -0,0 +1,124 @@
<template>
<div>
<div class="tw:flex tw:gap-2">
<div
class="tw:flex-1 tw:border tw:border-gray-300 tw:rounded-md tw:p-2 tw:flex tw:items-center tw:gap-2 tw:cursor-pointer hover:tw:bg-gray-50 tw:min-h-[42px]"
@click="visible = true"
>
<div v-if="modelValue" class="tw:w-5 tw:h-5 tw:text-gray-600 tw:flex tw:items-center tw:justify-center" v-html="getIconSvg(modelValue)"></div>
<span v-if="modelValue" class="tw:text-sm tw:text-gray-700">{{ modelValue }}</span>
<span v-else class="tw:text-sm tw:text-gray-400">Выберите иконку...</span>
</div>
<Button
v-if="modelValue"
icon="fa fa-times"
text
rounded
severity="secondary"
@click="emit('update:modelValue', null)"
v-tooltip="'Очистить'"
/>
</div>
<Dialog
v-model:visible="visible"
modal
header="Выберите иконку"
:style="{ width: '50vw', maxWidth: '600px' }"
:breakpoints="{ '960px': '75vw', '640px': '90vw' }"
>
<div class="tw:flex tw:flex-col tw:gap-4">
<IconField>
<InputIcon class="fa fa-search" />
<InputText v-model="searchQuery" placeholder="Поиск иконки..." class="tw:w-full" />
</IconField>
<div class="tw:grid tw:grid-cols-6 sm:tw:grid-cols-8 md:tw:grid-cols-12 tw:gap-1 tw:max-h-[400px] tw:overflow-y-auto tw:p-1">
<div
v-for="iconName in filteredIcons"
:key="iconName"
class="tw:flex tw:flex-col tw:items-center tw:justify-between tw:p-1 tw:border tw:rounded tw:cursor-pointer hover:tw:bg-blue-50 hover:tw:border-blue-200 tw:transition-colors tw:aspect-square"
:class="{ 'tw:bg-blue-100 tw:border-blue-400': modelValue === iconName }"
@click="selectIcon(iconName)"
>
<div class="tw:flex-1 tw:flex tw:items-center tw:justify-center tw:w-full tw:min-h-0 tw:text-gray-700 tw:[&>svg]:w-15 tw:[&>svg]:h-15" v-html="getIconSvg(iconName)"></div>
<span class="tw:text-[9px] tw:text-gray-500 tw:truncate tw:w-full tw:text-center tw:mt-0.5 tw:flex-shrink-0" :title="iconName">{{ iconName }}</span>
</div>
<div v-if="filteredIcons.length === 0" class="tw:col-span-full tw:text-center tw:text-gray-500 tw:py-8">
Ничего не найдено
</div>
</div>
</div>
</Dialog>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import * as icons from '@formkit/icons';
import {Button, IconField, InputIcon, InputText, Dialog} from 'primevue';
const props = defineProps({
modelValue: {
type: String,
default: null
}
});
const emit = defineEmits(['update:modelValue']);
const visible = ref(false);
const searchQuery = ref('');
// Собираем все иконки из экспорта @formkit/icons
// genesisIcons входит сюда как подмножество, но также там есть и другие наборы (например, feather, fontawesome и т.д. если они были бы установлены,
// но в стандартном пакете @formkit/icons есть только genesis, application, brand, currency, direction, file, input, payment, social, etc.)
// Пройдемся по всему объекту icons и соберем все строки-SVG.
// Но структура экспорта @formkit/icons может быть такой:
// export { genesisIcons } ...
// export { ... }
// Реально пакет содержит много наборов.
// Давайте соберем их все в один плоский список.
const allIconsMap = {};
// Функция для рекурсивного/плоского сбора иконок, если они сгруппированы
Object.entries(icons).forEach(([key, value]) => {
if (typeof value === 'string' && value.startsWith('<svg')) {
// Это прямая иконка (если вдруг)
allIconsMap[key] = value;
} else if (typeof value === 'object' && value !== null) {
// Это группа иконок (например genesisIcons)
Object.entries(value).forEach(([iconName, svgContent]) => {
if (typeof svgContent === 'string' && svgContent.startsWith('<svg')) {
// Если имя уже есть, не перезаписываем или перезаписываем - не важно, главное чтобы был доступ.
// Лучше сохранить оригинальное имя.
allIconsMap[iconName] = svgContent;
}
});
}
});
const allIconNames = Object.keys(allIconsMap).sort();
const filteredIcons = computed(() => {
if (!searchQuery.value) return allIconNames;
const lower = searchQuery.value.toLowerCase();
return allIconNames.filter(name => name.toLowerCase().includes(lower));
});
function getIconSvg(iconName) {
return allIconsMap[iconName];
}
function selectIcon(iconName) {
emit('update:modelValue', iconName);
visible.value = false;
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,235 @@
import { inject, ref } from 'vue';
/**
* Composable для работы с полями формы
*/
export function useFormFields() {
const formFields = inject('formFields');
const selectedFieldId = inject('selectedFieldId');
// Глобальное состояние ошибок полей { [fieldId]: 'Текст ошибки' }
// Используем provide/inject если нужно шарить между компонентами, но пока можно и локально,
// если этот composable используется в provide в родителе.
// В данном случае мы просто добавим ref, но так как composable вызывается в разных местах,
// состояние не будет общим. Нужно вынести состояние выше или использовать provide/inject для ошибок.
// Но для простоты, раз у нас FormBuilder провайдит formFields, добавим и errors туда.
const fieldErrors = inject('fieldErrors', ref({}));
/**
* Выбирает поле по ID
*/
function selectField(fieldId) {
if (selectedFieldId) {
selectedFieldId.value = fieldId;
}
}
/**
* Устанавливает ошибку для поля
*/
function setFieldError(fieldId, error) {
if (!fieldErrors.value) return;
if (error) {
fieldErrors.value[fieldId] = error;
} else {
delete fieldErrors.value[fieldId];
}
}
/**
* Удаляет поле по ID
*/
function removeField(fieldId) {
if (!formFields || !formFields.value) return;
formFields.value = formFields.value.filter(f => f.id !== fieldId);
// Очищаем ошибку при удалении
if (fieldErrors.value[fieldId]) {
delete fieldErrors.value[fieldId];
}
if (selectedFieldId && selectedFieldId.value === fieldId) {
selectedFieldId.value = null;
}
}
/**
* Перемещает поле вверх или вниз (больше не нужно с vuedraggable, но оставим для совместимости/ручного управления)
*/
function moveField(index, direction) {
if (!formFields || !formFields.value) return;
const newFields = [...formFields.value];
if (direction === 'up' && index > 0) {
[newFields[index], newFields[index - 1]] =
[newFields[index - 1], newFields[index]];
formFields.value = newFields;
} else if (direction === 'down' && index < newFields.length - 1) {
[newFields[index], newFields[index + 1]] =
[newFields[index + 1], newFields[index]];
formFields.value = newFields;
}
}
/**
* Генерирует уникальный ID для поля
*/
function generateFieldId() {
let maxId = 0;
if (formFields && formFields.value) {
formFields.value.forEach(field => {
const match = field.id?.match(/field_(\d+)/);
if (match) {
const idNum = parseInt(match[1]);
if (idNum > maxId) maxId = idNum;
}
});
}
return `field_${maxId + 1}_${Date.now()}`;
}
/**
* Проверяет, уникально ли имя поля
*/
function isFieldNameUnique(name, excludeId = null) {
if (!formFields || !formFields.value) return true;
return !formFields.value.some(field =>
field.name === name && field.id !== excludeId
);
}
/**
* Генерирует уникальное имя для поля
*/
function generateUniqueName(baseName) {
let name = baseName;
let counter = 1;
while (!isFieldNameUnique(name)) {
name = `${baseName}_${counter}`;
counter++;
}
return name;
}
/**
* Добавляет новое поле в форму
*/
function addField(fieldConfig, targetIndex = null) {
if (!formFields || !formFields.value) return null;
const id = generateFieldId();
// Генерируем уникальное имя на основе конфига или ID, если имя не задано
let initialName = fieldConfig.name || `field_${id.split('_')[1]}`;
const uniqueName = generateUniqueName(initialName);
const newField = {
id,
...fieldConfig,
name: uniqueName,
};
const newFields = [...formFields.value];
if (targetIndex !== null && targetIndex >= 0) {
newFields.splice(targetIndex + 1, 0, newField);
} else {
newFields.push(newField);
}
formFields.value = newFields;
selectField(newField.id);
return newField;
}
/**
* Обновляет свойства поля
*/
function updateField(fieldId, updates) {
if (!formFields || !formFields.value) return;
const index = formFields.value.findIndex(f => f.id === fieldId);
if (index !== -1) {
const newFields = [...formFields.value];
newFields[index] = { ...newFields[index], ...updates };
formFields.value = newFields;
}
}
/**
* Добавляет опцию к полю
*/
function addFieldOption(fieldId) {
if (!formFields || !formFields.value) return;
const index = formFields.value.findIndex(f => f.id === fieldId);
if (index !== -1) {
const field = formFields.value[index];
const options = field.options ? [...field.options] : [];
options.push({
label: 'Новая опция',
value: `option_${options.length + 1}`,
});
updateField(fieldId, { options });
}
}
/**
* Удаляет опцию у поля
*/
function removeFieldOption(fieldId, optionIndex) {
if (!formFields || !formFields.value) return;
const index = formFields.value.findIndex(f => f.id === fieldId);
if (index !== -1) {
const field = formFields.value[index];
if (!field.options) return;
const options = [...field.options];
options.splice(optionIndex, 1);
updateField(fieldId, { options });
}
}
/**
* Обновляет опцию поля
*/
function updateFieldOption(fieldId, optionIndex, key, value) {
if (!formFields || !formFields.value) return;
const index = formFields.value.findIndex(f => f.id === fieldId);
if (index !== -1) {
const field = formFields.value[index];
if (!field.options) return;
const options = [...field.options];
options[optionIndex] = { ...options[optionIndex], [key]: value };
updateField(fieldId, { options });
}
}
return {
formFields,
selectedFieldId,
fieldErrors, // Экспортируем ошибки
selectField,
removeField,
moveField,
addField,
updateField,
generateFieldId,
isFieldNameUnique,
generateUniqueName,
addFieldOption,
removeFieldOption,
updateFieldOption,
setFieldError, // Экспортируем метод установки ошибки
};
}

View File

@@ -0,0 +1,301 @@
// Доступные типы полей для конструктора
export const AVAILABLE_FIELDS = [
// Поля заказа
{
type: 'firstname_order',
label: 'Имя (Заказ)',
icon: 'fa fa-user',
group: 'order',
defaultConfig: {
$formkit: 'text',
name: 'firstname',
label: 'Имя',
placeholder: 'Например: Иван',
help: 'Введите ваше имя',
validation: 'required|length:0,32',
prefixIcon: "avatarMan",
locked: true,
}
},
{
type: 'lastname_order',
label: 'Фамилия (Заказ)',
icon: 'fa fa-user',
group: 'order',
defaultConfig: {
$formkit: 'text',
name: 'lastname',
label: 'Фамилия',
placeholder: 'Например: Иванов',
help: 'Введите вашу фамилию',
validation: 'required|length:0,32',
prefixIcon: "avatarMan",
locked: true,
}
},
{
type: 'email_order',
label: 'Email (Заказ)',
icon: 'fa fa-envelope',
group: 'order',
defaultConfig: {
$formkit: 'email',
name: 'email',
label: 'E-mail',
placeholder: 'Например: example@mail.com',
help: 'Введите ваш электронный адрес.',
validation: 'required|email|length:0,96',
prefixIcon: "email",
locked: true,
}
},
{
type: 'telephone_order',
label: 'Телефон (Заказ)',
icon: 'fa fa-phone',
group: 'order',
defaultConfig: {
$formkit: 'tel',
name: 'telephone',
label: 'Телефон',
placeholder: 'Например: +7 (999) 000-00-00',
validation: 'required|length:0,32',
help: 'Введите ваш номер телефона.',
prefixIcon: "telephone",
locked: true,
}
},
{
type: 'comment_order',
label: 'Комментарий (Заказ)',
icon: 'fa fa-comment',
group: 'order',
defaultConfig: {
$formkit: 'textarea',
name: 'comment',
label: 'Комментарий к заказу',
placeholder: 'Например: Домофон не работает',
help: 'Дополнительная информация к заказу',
validation: 'length:0,5000',
locked: true,
}
},
{
type: 'shipping_address_1_order',
label: 'Адрес доставки (Заказ)',
icon: 'fa fa-map-marker',
group: 'order',
defaultConfig: {
$formkit: 'textarea',
name: 'shipping_address_1',
label: 'Адрес доставки',
placeholder: 'Например: ул. Ленина, д. 1, кв. 10',
help: 'Укажите улицу, дом и квартиру',
validation: 'required|length:0,128',
locked: true,
}
},
{
type: 'shipping_city_order',
label: 'Город доставки (Заказ)',
icon: 'fa fa-building',
group: 'order',
defaultConfig: {
$formkit: 'text',
name: 'shipping_city',
label: 'Город',
placeholder: 'Например: Москва',
help: 'Город доставки',
validation: 'required|length:0,128',
locked: true,
}
},
{
type: 'shipping_postcode_order',
label: 'Индекс доставки (Заказ)',
icon: 'fa fa-map-pin',
group: 'order',
defaultConfig: {
$formkit: 'text',
name: 'shipping_postcode',
label: 'Почтовый индекс',
placeholder: 'Например: 101000',
help: 'Почтовый индекс',
validation: 'length:0,10',
locked: true,
}
},
{
type: 'shipping_zone_order',
label: 'Регион доставки (Заказ)',
icon: 'fa fa-map',
group: 'order',
defaultConfig: {
$formkit: 'text',
name: 'shipping_zone',
label: 'Регион / Область',
placeholder: 'Например: Московская область',
help: 'Регион или область',
validation: 'length:0,128',
locked: true,
}
},
{
type: 'payment_method_order',
label: 'Способ оплаты (Заказ)',
icon: 'fa fa-money',
group: 'order',
defaultConfig: {
$formkit: 'select',
label: "Способ оплаты заказа",
options: [
{
"label": "Наличными в пункте выдачи",
"value": "Наличными в пункте выдачи"
},
{
"label": "Наличными курьеру",
"value": "Наличными курьеру"
},
{
"label": "Картой курьеру",
"value": "Картой курьеру"
},
{
"label": "В кредит",
"value": "В кредит"
}
],
validation: "required",
name: "payment_method",
prefixIcon: "mastercard",
validationLabel: "Способ оплаты",
help: "Выберите способ оплаты заказа",
locked: true,
}
},
{
type: 'text',
label: 'Текстовое поле',
icon: 'fa fa-font',
defaultConfig: {
$formkit: 'text',
label: 'Текстовое поле',
placeholder: 'Введите текст',
validation: 'required',
}
},
{
type: 'textarea',
label: 'Многострочный текст',
icon: 'fa fa-align-left',
defaultConfig: {
$formkit: 'textarea',
label: 'Многострочный текст',
placeholder: 'Введите текст',
validation: '',
}
},
{
type: 'number',
label: 'Число',
icon: 'fa fa-hashtag',
group: 'general',
defaultConfig: {
$formkit: 'number',
label: 'Число',
placeholder: '0',
validation: '',
}
},
{
type: 'url',
label: 'URL',
icon: 'fa fa-link',
group: 'general',
defaultConfig: {
$formkit: 'url',
label: 'Ссылка',
placeholder: 'https://example.com',
validation: 'url',
}
},
{
type: 'select',
label: 'Выпадающий список',
icon: 'fa fa-list',
group: 'general',
defaultConfig: {
$formkit: 'select',
label: 'Выпадающий список',
options: [
{label: 'Вариант 1', value: 'option1'},
{label: 'Вариант 2', value: 'option2'},
],
validation: 'required',
}
},
{
type: 'checkbox',
label: 'Чекбокс',
icon: 'fa fa-check-square',
group: 'general',
defaultConfig: {
$formkit: 'checkbox',
label: 'Чекбокс',
validation: '',
}
},
{
type: 'radio',
label: 'Радио кнопки',
icon: 'fa fa-dot-circle',
group: 'general',
defaultConfig: {
$formkit: 'radio',
label: 'Радио кнопки',
options: [
{label: 'Вариант 1', value: 'option1'},
{label: 'Вариант 2', value: 'option2'},
],
validation: 'required',
}
},
{
type: 'date',
label: 'Дата',
icon: 'fa fa-calendar',
group: 'general',
defaultConfig: {
$formkit: 'date',
label: 'Дата',
validation: 'required',
}
},
{
type: 'color',
label: 'Цвет',
icon: 'fa fa-palette',
group: 'general',
defaultConfig: {
$formkit: 'color',
label: 'Выберите цвет',
value: '#000000',
validation: '',
}
},
{
type: 'range',
label: 'Диапазон',
icon: 'fa fa-sliders-h',
group: 'general',
defaultConfig: {
$formkit: 'range',
label: 'Диапазон',
min: 0,
max: 100,
step: 1,
validation: '',
}
},
];

View File

@@ -0,0 +1,53 @@
import { PLACEHOLDER_FIELD_TYPES, OPTIONS_FIELD_TYPES } from './fieldTypes.js';
/**
* Получает placeholder для поля (только для поддерживаемых типов)
* @param {Object} field - Объект поля
* @returns {string|undefined} - Placeholder или undefined
*/
export function getPlaceholder(field) {
const type = field.$formkit;
if (!PLACEHOLDER_FIELD_TYPES.includes(type)) {
return undefined;
}
if (field.placeholder && field.placeholder.trim()) {
return field.placeholder.trim();
}
return undefined;
}
/**
* Получает props для поля FormKit для отображения в редакторе
* @param {Object} field - Объект поля
* @returns {Object} - Объект с props для FormKit
*/
export function getFieldProps(field) {
// Создаем копию, исключая служебные поля, которые мы передаем отдельно или не хотим передавать
const { $formkit, id, ...rest } = field;
const props = { ...rest };
// Опции для select и radio
// FormKit принимает массив объектов { label, value }, так что преобразование может не понадобиться
// если формат совпадает. В availableFields мы используем { label, value }.
return props;
}
/**
* Проверяет, поддерживает ли тип поля placeholder
* @param {string} fieldType - Тип поля ($formkit)
* @returns {boolean}
*/
export function supportsPlaceholder(fieldType) {
return PLACEHOLDER_FIELD_TYPES.includes(fieldType);
}
/**
* Проверяет, поддерживает ли тип поля опции
* @param {string} fieldType - Тип поля ($formkit)
* @returns {boolean}
*/
export function supportsOptions(fieldType) {
return OPTIONS_FIELD_TYPES.includes(fieldType);
}

View File

@@ -0,0 +1,30 @@
// Типы полей, которые поддерживают placeholder
export const PLACEHOLDER_FIELD_TYPES = [
'text',
'email',
'textarea',
'number',
'tel',
'url',
'password',
'search'
];
// Типы полей, которые поддерживают опции
export const OPTIONS_FIELD_TYPES = ['select', 'radio'];
// Все поддерживаемые типы полей
export const FIELD_TYPES = {
TEXT: 'text',
EMAIL: 'email',
TEXTAREA: 'textarea',
SELECT: 'select',
CHECKBOX: 'checkbox',
RADIO: 'radio',
DATE: 'date',
NUMBER: 'number',
TEL: 'tel',
URL: 'url',
COLOR: 'color',
RANGE: 'range',
};

View File

@@ -0,0 +1,24 @@
import {ru} from '@formkit/i18n';
import * as allIcons from '@formkit/icons';
import {rootClasses} from './formkit.theme.mjs';
// Собираем все иконки в плоский объект
const icons = {};
Object.values(allIcons).forEach(group => {
if (typeof group === 'object') {
Object.assign(icons, group);
}
});
const config = {
locales: {ru},
locale: 'ru',
icons: {
...icons,
},
config: {
rootClasses,
},
};
export default config;

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,9 @@ import ToastService from 'primevue/toastservice';
import {definePreset} from "@primeuix/themes";
import Tooltip from 'primevue/tooltip';
import ConfirmationService from 'primevue/confirmationservice';
import { plugin, defaultConfig } from '@formkit/vue';
import { ru } from '@formkit/i18n';
import config from './formkit.config.js'
const MyPreset = definePreset(Aura, {
@@ -38,6 +41,7 @@ onReady(async () => {
app.use(ToastService);
app.directive('tooltip', Tooltip);
app.use(ConfirmationService);
app.use(plugin, defaultConfig(config));
app.mount('#app');
await useSettingsStore().fetchSettings();

View File

@@ -7,6 +7,7 @@ import MetricsView from "@/views/MetricsView.vue";
import StoreView from "@/views/StoreView.vue";
import MainPageView from "@/views/MainPageView.vue";
import LogsView from "@/views/LogsView.vue";
import FormBuilderView from "@/views/FormBuilderView.vue";
const router = createRouter({
history: createMemoryHistory(),
@@ -19,6 +20,7 @@ const router = createRouter({
{path: '/store', name: 'store', component: StoreView},
{path: '/mainpage', name: 'mainpage', component: MainPageView},
{path: '/logs', name: 'logs', component: LogsView},
{path: '/formbuilder', name: 'formbuilder', component: FormBuilderView},
],
});

View File

@@ -0,0 +1,14 @@
import {defineStore} from "pinia";
import {apiPost} from "@/utils/http.js";
export const useFormsStore = defineStore('forms', {
state: () => ({}),
actions: {
async getFormByAlias(alias) {
return await apiPost('getFormByAlias', {
alias,
});
}
},
});

View File

@@ -63,6 +63,15 @@ export const useSettingsStore = defineStore('settings', {
},
mainpage_blocks: [],
forms: {
checkout: {
alias: '',
friendly_name: '',
is_custom: false,
schema: [],
}
},
},
}),

View File

@@ -0,0 +1,23 @@
<template>
<div v-if="isLoading" class="tw:flex tw:justify-center tw:items-center tw:h-full">
<i class="fa fa-spinner fa-spin tw:text-4xl tw:text-blue-500"></i>
</div>
<div v-else class="tw:h-full">
<FormBuilder
v-model="settings.items.forms.checkout.schema"
v-model:isCustom="settings.items.forms.checkout.is_custom"
/>
</div>
</template>
<script setup>
import {ref} from 'vue';
import FormBuilder from '@/components/FormBuilder/FormBuilder.vue';
import {useSettingsStore} from "@/stores/settings.js";
const formSchema = ref([]);
const isCustom = ref(false);
const isLoading = ref(false);
const settings = useSettingsStore();
</script>

View File

@@ -0,0 +1,9 @@
module.exports = {
prefix: 'tw:',
content: [
'./index.html',
'./src/**/*.{vue,js,ts,jsx,tsx}',
'./templates/**/*.twig',
'./views/**/*.php',
],
};

File diff suppressed because it is too large Load Diff

View File

@@ -13,9 +13,13 @@
"test:run": "vitest run"
},
"dependencies": {
"@formkit/core": "^1.6.9",
"@formkit/icons": "^1.6.9",
"@formkit/vue": "^1.6.9",
"@heroicons/vue": "^2.2.0",
"@tailwindcss/vite": "^4.1.16",
"@vueuse/core": "^13.9.0",
"cleave.js": "^1.6.0",
"crypto-js": "^4.2.0",
"js-md5": "^0.8.3",
"ofetch": "^1.4.1",

View File

@@ -1,52 +0,0 @@
<template>
<fieldset class="fieldset mb-0">
<input
:type="type"
:inputmode="inputMode"
class="input input-lg w-full"
:class="error ? 'input-error' : ''"
:placeholder="placeholder"
v-model="model"
@input="$emit('clearError')"
:maxlength="maxlength"
/>
<p v-if="error" class="label text-error">{{ error }}</p>
</fieldset>
</template>
<script setup lang="ts">
import {computed} from "vue";
const model = defineModel();
const props = defineProps({
error: {
type: String,
default: null,
},
placeholder: {
type: String,
default: null,
},
type: {
type: String,
default: 'text',
},
maxlength: {
type: Number,
default: 1000,
}
});
const emits = defineEmits(['clearError']);
const inputMode = computed(() => {
switch (props.type) {
case 'email': return 'email';
case 'tel': return 'tel';
case 'number': return 'numeric';
default: return 'text';
}
});
</script>

View File

@@ -1,36 +0,0 @@
<template>
<fieldset class="fieldset mb-0">
<textarea
class="input input-lg w-full h-50"
:class="error ? 'input-error' : ''"
:placeholder="placeholder"
v-model="model"
@input="$emit('clearError')"
rows="8"
:maxlength="maxlength"
/>
<p v-if="error" class="label">{{ error }}</p>
</fieldset>
</template>
<script setup lang="ts">
const model = defineModel();
const props = defineProps({
error: {
type: String,
default: null,
},
placeholder: {
type: String,
default: null,
},
maxlength: {
type: Number,
default: 1000,
}
});
const emits = defineEmits(['clearError']);
</script>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</template>

View File

@@ -0,0 +1,16 @@
import {ru} from '@formkit/i18n';
import {genesisIcons} from '@formkit/icons';
import {rootClasses} from './formkit.theme';
const config = {
locales: {ru},
locale: 'ru',
icons: {
...genesisIcons,
},
config: {
rootClasses,
},
};
export default config;

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,9 @@ import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
import {useBlocksStore} from "@/stores/BlocksStore.js";
import {getCssVarOklchRgb} from "@/helpers.js";
import {defaultConfig, plugin} from '@formkit/vue';
import config from './formkit.config.js';
register();
const pinia = createPinia();
@@ -24,7 +27,9 @@ const app = createApp(App);
app
.use(pinia)
.use(router)
.use(VueTelegramPlugin);
.use(VueTelegramPlugin)
.use(plugin, defaultConfig(config))
;
const settings = useSettingsStore();
const blocks = useBlocksStore();
@@ -52,6 +57,7 @@ settings.load()
window.Telegram.WebApp.onEvent('themeChanged', function () {
document.documentElement.setAttribute('data-theme', settings.theme[this.colorScheme]);
});
}
for (const key in settings.theme.variables) {

View File

@@ -8,20 +8,11 @@ import {useSettingsStore} from "@/stores/SettingsStore.js";
export const useCheckoutStore = defineStore('checkout', {
state: () => ({
customer: {
firstName: "",
lastName: "",
email: "",
phone: "",
address: "",
comment: "",
tgData: null,
},
form: {},
order: null,
isLoading: false,
validationErrors: {},
errorMessage: '',
}),
getters: {
@@ -33,6 +24,7 @@ export const useCheckoutStore = defineStore('checkout', {
actions: {
async makeOrder() {
try {
this.errorMessage = '';
this.isLoading = true;
const data = window.Telegram.WebApp.initDataUnsafe;
@@ -54,9 +46,10 @@ export const useCheckoutStore = defineStore('checkout', {
}
}
this.customer.tgData = data;
const response = await storeOrder(this.customer);
const response = await storeOrder({
...this.form,
tgData: data,
});
this.order = response.data;
if (! this.order.id) {
@@ -101,6 +94,8 @@ export const useCheckoutStore = defineStore('checkout', {
window.Telegram.WebApp.HapticFeedback.notificationOccurred('error');
this.errorMessage = 'Возникла ошибка при создании заказа.';
throw error;
} finally {
this.isLoading = false;

View File

@@ -0,0 +1,16 @@
import {defineStore} from "pinia";
import ftch from "@/utils/ftch.js";
export const useFormsStore = defineStore('forms', {
state: () => ({}),
actions: {
async getFormByAlias(alias) {
return await ftch('getForm', null, {
alias,
});
}
},
});

View File

@@ -1,4 +1,6 @@
@import "tailwindcss";
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
@plugin "daisyui" {
themes: all;
}

View File

@@ -1,92 +1,93 @@
<template>
<div class="max-w-3xl mx-auto p-4 space-y-6 pb-20">
<h2 class="text-2xl mb-5 text-center">
Оформление заказа
</h2>
<BaseViewWrapper title="Оформление заказа" class="pb-10">
<div
v-if="isLoading"
class="flex items-center justify-center h-20">
<span class="loading loading-spinner loading-xl"></span>
</div>
<div class="w-full">
<TgInput
v-model="checkout.customer.firstName"
placeholder="Введите имя"
:error="checkout.validationErrors.firstName"
:maxlength="32"
@clearError="checkout.clearError('firstName')"
/>
<div v-else-if="checkoutFormSchema" class="w-full">
<FormKit
type="form"
id="form-checkout"
ref="checkoutFormRef"
v-model="checkout.form"
:actions="false"
@submit="onFormSubmit"
>
<FormKitSchema :schema="checkoutFormSchema"/>
</FormKit>
</div>
<TgInput
v-model="checkout.customer.lastName"
placeholder="Введите фамилию"
:maxlength="32"
:error="checkout.validationErrors.lastName"
@clearError="checkout.clearError('lastName')"
/>
<fieldset class="fieldset">
<IMaskComponent
v-model="checkout.customer.phone"
type="tel"
class="input input-lg w-full"
mask="+{7} (000) 000-00-00"
placeholder="Введите телефон"
:unmask="true"
/>
<p v-if="error" class="label text-error">{{ checkout.validationErrors.phone }}</p>
</fieldset>
<TgInput
v-model="checkout.customer.email"
type="email"
placeholder="Введите email (опционально)"
:maxlength="96"
:error="checkout.validationErrors.email"
@clearError="checkout.clearError('email')"
/>
<TgTextarea
v-model="checkout.customer.comment"
placeholder="Комментарий (опционально)"
:error="checkout.validationErrors.comment"
@clearError="checkout.clearError('comment')"
/>
<div v-else>
<div role="alert" class="alert alert-warning alert-outline">
<IconWarning/>
<span>
Форма заказа не сконфигурирована. <br>
Пожалуйста, укажите параметры формы в настройках модуля, чтобы эта секция работала корректно.
</span>
</div>
</div>
<div
class="fixed px-4 pb-10 pt-4 bottom-0 left-0 w-full bg-base-200 z-50 flex flex-col justify-between items-center gap-2 border-t-1 border-t-base-300">
<div v-if="error" class="text-error text-sm">{{ error }}</div>
class="fixed px-4 pb-4 pt-4 bottom-0 left-0 w-full bg-base-200 z-50 flex flex-col justify-between items-center gap-2 border-t-1 border-t-base-300">
<div class="text-error">{{ checkout.errorMessage }}</div>
<div>
<FormKitMessages :node="checkoutFormRef?.node"/>
</div>
<button
:disabled="checkout.isLoading"
:disabled="isLoading || ! checkoutFormSchema || checkout.isLoading"
class="btn btn-primary w-full"
@click="onCreateBtnClick"
>
<span v-if="checkout.isLoading" class="loading loading-spinner loading-sm"></span>
{{ btnText }}
</button>
</div>
</div>
</BaseViewWrapper>
</template>
<script setup>
import {useCheckoutStore} from "@/stores/CheckoutStore.js";
import TgInput from "@/components/Form/TgInput.vue";
import TgTextarea from "@/components/Form/TgTextarea.vue";
import {useRoute, useRouter} from "vue-router";
import {computed, onMounted, ref} from "vue";
import {IMaskComponent} from "vue-imask";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
import {useSettingsStore} from "@/stores/SettingsStore.js";
import {FormKit, FormKitMessages, FormKitSchema} from '@formkit/vue';
import {submitForm} from '@formkit/core';
import IconWarning from "@/components/Icons/IconWarning.vue";
import {useFormsStore} from "@/stores/FormsStore.js";
import BaseViewWrapper from "@/views/BaseViewWrapper.vue";
const checkout = useCheckoutStore();
const yaMetrika = useYaMetrikaStore();
const forms = useFormsStore();
const route = useRoute();
const router = useRouter();
const error = ref(null);
const isLoading = ref(false);
const checkoutFormSchema = ref(null);
const checkoutFormRef = ref(null);
const btnText = computed(() => {
return checkout.isLoading ? 'Подождите...' : 'Создать заказ';
});
async function onCreateBtnClick() {
function onCreateBtnClick() {
try {
submitForm('form-checkout');
} catch (error) {
console.error(error);
error.value = 'Невозможно создать заказ.';
}
}
async function onFormSubmit() {
console.log('[Checkout]: submit form');
try {
error.value = null;
yaMetrika.reachGoal(YA_METRIKA_GOAL.CREATE_ORDER, {
@@ -97,13 +98,32 @@ async function onCreateBtnClick() {
await checkout.makeOrder();
router.push({name: 'order_created'});
} catch {
} catch (error) {
console.error(error);
error.value = 'Невозможно создать заказ.';
}
}
async function loadCheckoutFormSchema() {
try {
isLoading.value = true;
const response = await forms.getFormByAlias('checkout');
if (response?.data?.schema && response.data.schema.length > 0) {
checkoutFormSchema.value = response.data.schema;
}
} catch (error) {
console.error('Failed to load checkout form: ', error);
checkoutFormSchema.value = false;
} finally {
isLoading.value = false;
}
}
onMounted(async () => {
window.document.title = 'Оформление заказа';
await loadCheckoutFormSchema();
yaMetrika.pushHit(route.path, {
title: 'Оформление заказа',
});

View File

@@ -2,6 +2,7 @@ module.exports = {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
"./formkit.theme.mjs",
],
theme: {
extend: {

View File

@@ -0,0 +1,57 @@
<?php
namespace Bastion\Handlers;
use JsonException;
use Openguru\OpenCartFramework\Exceptions\EntityNotFoundException;
use Openguru\OpenCartFramework\Http\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\Http\Response;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
class FormsHandler
{
private Builder $builder;
public function __construct(Builder $builder)
{
$this->builder = $builder;
}
/**
* @throws EntityNotFoundException
* @throws JsonException
*/
public function getFormByAlias(Request $request): JsonResponse
{
$alias = 'checkout';
//$request->json('alias');
if (! $alias) {
return new JsonResponse([
'error' => 'Form alias is required',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$form = $this->builder->newQuery()
->from('telecart_forms')
->where('alias', '=', $alias)
->firstOrNull();
if (! $form) {
throw new EntityNotFoundException("Form with alias `{$alias}` not found");
}
$schema = json_decode($form['schema'], true, 512, JSON_THROW_ON_ERROR);
return new JsonResponse([
'data' => [
'alias' => $alias,
'friendly_name' => $form['friendly_name'],
'is_custom' => filter_var($form['is_custom'], FILTER_VALIDATE_BOOLEAN),
'schema' => $schema,
'created_at' => $form['created_at'],
'updated_at' => $form['updated_at'],
],
]);
}
}

View File

@@ -11,6 +11,8 @@ use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\Http\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\Http\Response;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
use Openguru\OpenCartFramework\Support\Arr;
use Psr\Log\LoggerInterface;
@@ -21,19 +23,25 @@ class SettingsHandler
private SettingsService $settingsUpdateService;
private CacheInterface $cache;
private LoggerInterface $logger;
private Builder $builder;
private ConnectionInterface $connection;
public function __construct(
BotTokenConfigurator $botTokenConfigurator,
Settings $settings,
SettingsService $settingsUpdateService,
CacheInterface $cache,
LoggerInterface $logger
LoggerInterface $logger,
Builder $builder,
ConnectionInterface $connection
) {
$this->botTokenConfigurator = $botTokenConfigurator;
$this->settings = $settings;
$this->settingsUpdateService = $settingsUpdateService;
$this->cache = $cache;
$this->logger = $logger;
$this->builder = $builder;
$this->connection = $connection;
}
public function configureBotToken(Request $request): JsonResponse
@@ -62,17 +70,59 @@ class SettingsHandler
'mainpage_blocks',
]);
$data['forms'] = [];
$forms = $this->builder->newQuery()
->from('telecart_forms')
->get();
if ($forms) {
foreach ($forms as $form) {
$schema = json_decode($form['schema'], true, 512, JSON_THROW_ON_ERROR);
$data['forms'][$form['alias']] = [
'alias' => $form['alias'],
'friendly_name' => $form['friendly_name'],
'is_custom' => filter_var($form['is_custom'], FILTER_VALIDATE_BOOLEAN),
'schema' => $schema,
];
}
}
return new JsonResponse(compact('data'));
}
public function saveSettingsForm(Request $request): JsonResponse
{
$this->validate($request->json());
$input = $request->json();
$this->validate($input);
$this->settingsUpdateService->update(
$request->json(),
Arr::getWithKeys($input, [
'app',
'telegram',
'metrics',
'store',
'orders',
'texts',
'sliders',
'mainpage_blocks',
]),
);
// Update forms
$forms = Arr::get($input, 'forms', []);
foreach ($forms as $form) {
$schema = json_encode($form['schema'], JSON_THROW_ON_ERROR);
$this->builder->newQuery()
->where('alias', '=', $form['alias'])
->update('telecart_forms', [
'friendly_name' => $form['friendly_name'],
'is_custom' => $form['is_custom'],
'schema' => $schema,
]);
}
return new JsonResponse([], Response::HTTP_ACCEPTED);
}

View File

@@ -58,7 +58,7 @@ class BotTokenConfigurator
'webhook_url' => $webhookUrl,
];
} catch (TelegramClientException $exception) {
$this->logger->logException($exception);
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
if ($exception->getCode() === 404 || $exception->getCode() === 401) {
throw new BotTokenConfiguratorException(
'Telegram сообщает, что BotToken не верный. Проверьте корректность.'
@@ -67,7 +67,7 @@ class BotTokenConfigurator
throw new BotTokenConfiguratorException($exception->getMessage());
} catch (Exception | GuzzleException $exception) {
$this->logger->logException($exception);
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
throw new BotTokenConfiguratorException($exception->getMessage());
}
}

View File

@@ -27,4 +27,4 @@ class CachePruneTask extends BaseMaintenanceTask
{
return new DateInterval('P1D');
}
}
}

View File

@@ -2,6 +2,7 @@
use Bastion\Handlers\AutocompleteHandler;
use Bastion\Handlers\DictionariesHandler;
use Bastion\Handlers\FormsHandler;
use Bastion\Handlers\LogsHandler;
use Bastion\Handlers\SettingsHandler;
use Bastion\Handlers\StatsHandler;
@@ -24,4 +25,6 @@ return [
'getAutocompleteCategoriesFlat' => [AutocompleteHandler::class, 'getCategoriesFlat'],
'resetCache' => [SettingsHandler::class, 'resetCache'],
'getLogs' => [LogsHandler::class, 'getLogs'],
'getFormByAlias' => [FormsHandler::class, 'getFormByAlias'],
];

View File

@@ -19,24 +19,25 @@
}
],
"require": {
"php": "^7.4",
"ext-pdo": "*",
"psr/container": "^2.0",
"ext-json": "*",
"intervention/image": "^2.7",
"vlucas/phpdotenv": "^5.6",
"guzzlehttp/guzzle": "^7.9",
"symfony/cache": "^5.4",
"doctrine/dbal": "^3.10",
"ext-json": "*",
"ext-pdo": "*",
"guzzlehttp/guzzle": "^7.9",
"intervention/image": "^2.7",
"monolog/monolog": "^2.10",
"psr/log": "^1.1"
"nesbot/carbon": "^2.73",
"php": "^7.4",
"psr/container": "^2.0",
"psr/log": "^1.1",
"symfony/cache": "^5.4",
"vlucas/phpdotenv": "^5.6"
},
"require-dev": {
"roave/security-advisories": "dev-latest",
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^9.6",
"doctrine/sql-formatter": "^1.3",
"mockery/mockery": "^1.6",
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^9.6",
"roave/security-advisories": "dev-latest",
"squizlabs/php_codesniffer": "*"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
<?php
use Openguru\OpenCartFramework\Migrations\Migration;
return new class extends Migration {
public function up(): void
{
$sql = <<<SQL
CREATE TABLE IF NOT EXISTS `telecart_forms` (
`id` bigint(11) AUTO_INCREMENT PRIMARY KEY,
`alias` varchar(100) NOT NULL,
`friendly_name` varchar(100) NOT NULL,
`is_custom` tinyint(1) NOT NULL DEFAULT 0,
`schema` longtext NOT NULL,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
) collate = utf8_unicode_ci
SQL;
$this->database->statement($sql);
}
};

View File

@@ -0,0 +1,68 @@
<?php
use Carbon\Carbon;
use Openguru\OpenCartFramework\Migrations\Migration;
return new class extends Migration {
public function up(): void
{
$checkoutForm = json_encode(self::getCheckoutFormSchema(), JSON_THROW_ON_ERROR);
$this->database->insert('telecart_forms', [
'alias' => 'checkout',
'friendly_name' => 'Оформление заказа',
'schema' => $checkoutForm,
'created_at' => Carbon::now(),
'updated_at' => Carbon::now(),
]);
}
private static function getCheckoutFormSchema(): array
{
return [
[
'id' => 'field_1_1763897608480',
'$formkit' => 'text',
'name' => 'firstname',
'label' => 'Имя',
'placeholder' => 'Например: Иван',
'help' => 'Введите ваше имя',
'validation' => 'required|length:0,32',
'prefixIcon' => 'avatarMan',
'locked' => true,
],
[
'id' => 'field_2_1763897611020',
'$formkit' => 'text',
'name' => 'lastname',
'label' => 'Фамилия',
'placeholder' => 'Например: Иванов',
'help' => 'Введите вашу фамилию',
'validation' => 'required|length:0,32',
'prefixIcon' => 'avatarMan',
'locked' => true,
],
[
'id' => 'field_5_1763897626036',
'$formkit' => 'tel',
'name' => 'telephone',
'label' => 'Телефон',
'placeholder' => 'Например: +7 (999) 000-00-00',
'validation' => 'required|length:0,32',
'help' => 'Введите ваш номер телефона.',
'prefixIcon' => 'telephone',
'locked' => true,
],
[
'id' => 'field_4_1763897617570',
'$formkit' => 'textarea',
'name' => 'comment',
'label' => 'Комментарий к заказу',
'placeholder' => 'Например: Домофон не работает',
'help' => 'Дополнительная информация к заказу',
'validation' => 'length:0,5000',
'locked' => true,
],
];
}
};

View File

@@ -66,6 +66,7 @@ class Container implements ContainerInterface
* @return T
* @psalm-param class-string<T>|string $id
* @psalm-suppress MoreSpecificImplementedParamType
*
*/
public function get(string $id)
{

View File

@@ -6,7 +6,6 @@ use Openguru\OpenCartFramework\Container\Container;
use Openguru\OpenCartFramework\Container\ServiceProvider;
use Psr\Log\LoggerInterface;
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
use Openguru\OpenCartFramework\Support\WorkLogsBag;
class MigrationsServiceProvider extends ServiceProvider
{

View File

@@ -109,6 +109,7 @@ SQL;
} catch (Exception $e) {
$this->connection->rollbackTransaction();
$this->logger->error("An error occurred while applying migration.", ['exception' => $e]);
break;
}
}

View File

@@ -455,4 +455,17 @@ class Builder
return $this;
}
public function update(string $table, array $values): bool
{
$sql = $this->grammar->compileUpdate($this, $table, $values);
$bindings = array_merge(
Utils::arrayFlatten($this->getBindings('join')),
array_values($values),
Utils::arrayFlatten($this->getBindings('where'))
);
return $this->connection->statement($sql, $bindings);
}
}

View File

@@ -201,4 +201,25 @@ abstract class Grammar
{
return 'GROUP BY ' . implode(', ', $groupBy);
}
public function compileUpdate(Builder $builder, string $table, array $values): string
{
$columns = [];
foreach ($values as $key => $value) {
$columns[] = "`{$key}` = ?";
}
$columns = implode(', ', $columns);
$joins = $this->compileJoins($builder, $builder->joins);
$joins = $joins ? ' ' . $joins : '';
$wheres = $this->compileWheres($builder, $builder->wheres);
$wheres = $wheres ? ' ' . $wheres : '';
return "UPDATE `{$table}`{$joins} SET {$columns}{$wheres}";
}
}

View File

@@ -206,4 +206,20 @@ class Arr
return $filtered;
}
/**
* Возвращает массив без указанных ключей.
*
* @param array $array Исходный массив
* @param array $keys Массив ключей, которые нужно исключить
* @return array Массив без исключенных ключей
*/
public static function except(array $array, array $keys): array
{
if (empty($keys)) {
return $array;
}
return array_diff_key($array, array_flip($keys));
}
}

View File

@@ -32,6 +32,7 @@
<env name="DB_DATABASE" value="ocstore3"/>
<env name="DB_USERNAME" value="root"/>
<env name="DB_PASSWORD" value="secret"/>
<env name="DB_PORT" value="3306"/>
<env name="DB_PREFIX" value="oc_"/>
</php>
</phpunit>

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Handlers;
use JsonException;
use Openguru\OpenCartFramework\Exceptions\EntityNotFoundException;
use Openguru\OpenCartFramework\Http\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\Http\Response;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
class FormsHandler
{
private Builder $builder;
public function __construct(Builder $builder)
{
$this->builder = $builder;
}
/**
* @throws EntityNotFoundException
* @throws JsonException
*/
public function getForm(Request $request): JsonResponse
{
$alias = $request->json('alias');
if (! $alias) {
return new JsonResponse([
'error' => 'Form alias is required',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$form = $this->builder->newQuery()
->from('telecart_forms')
->where('alias', '=', $alias)
->firstOrNull();
if (! $form) {
throw new EntityNotFoundException("Form with alias `{$alias}` not found");
}
$schema = json_decode($form['schema'], true, 512, JSON_THROW_ON_ERROR);
return new JsonResponse([
'data' => [
'schema' => $schema,
],
]);
}
}

View File

@@ -55,7 +55,7 @@ class ProductsHandler
'message' => 'Product with id ' . $productId . ' not found',
], Response::HTTP_NOT_FOUND);
} catch (Exception $exception) {
$this->logger->logException($exception);
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
throw new RuntimeException('Error get product with id ' . $productId, 500);
}

View File

@@ -91,7 +91,7 @@ class TelegramHandler
} catch (TelegramCommandNotFoundException $exception) {
$this->telegramService->sendMessage($chatId, 'Неверная команда');
} catch (Exception $exception) {
$this->logger->logException($exception);
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
}
return new JsonResponse([]);

View File

@@ -4,16 +4,14 @@ declare(strict_types=1);
namespace App\Services;
use App\Exceptions\OrderValidationFailedException;
use DateTime;
use Carbon\Carbon;
use Exception;
use Psr\Log\LoggerInterface;
use Openguru\OpenCartFramework\OpenCart\Decorators\OcRegistryDecorator;
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
use Openguru\OpenCartFramework\Support\Arr;
use Openguru\OpenCartFramework\Telegram\TelegramService;
use Openguru\OpenCartFramework\Validator\ValidationRuleNotFoundException;
use Openguru\OpenCartFramework\Validator\ValidatorInterface;
use Psr\Log\LoggerInterface;
use RuntimeException;
class OrderCreateService
@@ -24,7 +22,6 @@ class OrderCreateService
private SettingsService $settings;
private TelegramService $telegramService;
private LoggerInterface $logger;
private ValidatorInterface $validator;
public function __construct(
ConnectionInterface $database,
@@ -32,8 +29,7 @@ class OrderCreateService
OcRegistryDecorator $registry,
SettingsService $settings,
TelegramService $telegramService,
LoggerInterface $logger,
ValidatorInterface $validator
LoggerInterface $logger
) {
$this->database = $database;
$this->cartService = $cartService;
@@ -41,18 +37,11 @@ class OrderCreateService
$this->settings = $settings;
$this->telegramService = $telegramService;
$this->logger = $logger;
$this->validator = $validator;
}
public function create(array $data, array $meta = []): array
{
try {
$this->validate($data);
} catch (ValidationRuleNotFoundException $e) {
throw new RuntimeException($e->getMessage());
}
$now = date('Y-m-d H:i:s');
$now = Carbon::now();
$storeId = $this->settings->get('store.oc_store_id');
$storeName = $this->settings->config()->getApp()->getAppName();
$orderStatusId = $this->settings->config()->getOrders()->getOrderDefaultStatusId();
@@ -70,12 +59,16 @@ class OrderCreateService
$orderData = [
'store_id' => $storeId,
'store_name' => $storeName,
'firstname' => $data['firstName'],
'lastname' => $data['lastName'],
'email' => $data['email'],
'telephone' => $data['phone'],
'comment' => $data['comment'],
'shipping_address_1' => $data['address'],
'firstname' => $data['firstname'] ?? '',
'lastname' => $data['lastname'] ?? '',
'email' => $data['email'] ?? '',
'telephone' => $data['telephone'] ?? '',
'comment' => $data['comment'] ?? '',
'payment_method' => $data['payment_method'] ?? '',
'shipping_address_1' => $data['shipping_address_1'] ?? '',
'shipping_city' => $data['shipping_city'] ?? '',
'shipping_zone' => $data['shipping_zone'] ?? '',
'shipping_postcode' => $data['shipping_postcode'] ?? '',
'total' => $total,
'order_status_id' => $orderStatusId,
'ip' => $meta['ip'] ?? '',
@@ -93,7 +86,7 @@ class OrderCreateService
$orderId = null;
$this->database->transaction(
function () use (&$orderData, $products, $totals, $orderStatusId, $now, &$orderId) {
function () use (&$orderData, $products, $totals, $orderStatusId, $now, &$orderId, $data) {
$success = $this->database->insert(db_table('order'), $orderData);
if (! $success) {
@@ -157,13 +150,14 @@ class OrderCreateService
}
// Insert history
$success = $this->database->insert(db_table('order_history'), [
$history = [
'order_id' => $orderId,
'order_status_id' => $orderStatusId,
'notify' => 0,
'comment' => 'Заказ оформлен через Telegram Mini App',
'comment' => $this->formatHistoryComment($data),
'date_added' => $now,
]);
];
$success = $this->database->insert(db_table('order_history'), $history);
if (! $success) {
[, $error] = $this->database->getLastError();
@@ -182,9 +176,9 @@ class OrderCreateService
$dateTimeFormatted = '';
try {
$dateTimeFormatted = (new DateTime($orderData['date_added']))->format('d.m.Y H:i');
$dateTimeFormatted = $now->format('d.m.Y H:i');
} catch (Exception $exception) {
$this->logger->logException($exception);
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
}
return [
@@ -197,25 +191,6 @@ class OrderCreateService
];
}
/**
* @throws ValidationRuleNotFoundException
*/
private function validate(array $data): void
{
$v = $this->validator->make($data, $this->makeValidationRulesFromSettings(), [
'firstName' => 'Имя',
'lastName' => 'Фамилия',
'email' => 'E-mail',
'phone' => 'Номер телефона',
'address' => 'Адрес доставки',
'comment' => 'Комментарий',
]);
if ($v->fails()) {
throw new OrderValidationFailedException($v->getErrors());
}
}
private function sendNotifications(array $orderData, array $tgInitData): void
{
$variables = [
@@ -239,8 +214,10 @@ class OrderCreateService
try {
$this->telegramService->sendMessage((int) $chatId, $message);
} catch (Exception $exception) {
$this->logger->error("Telegram sendMessage to owner error. ChatID: $chatId, Message: $message");
$this->logger->logException($exception);
$this->logger->error(
"Telegram sendMessage to owner error. ChatID: $chatId, Message: $message",
['exception' => $exception],
);
}
}
@@ -253,19 +230,38 @@ class OrderCreateService
try {
$this->telegramService->sendMessage($customerChatId, $message);
} catch (Exception $exception) {
$this->logger->error("Telegram sendMessage to customer error. ChatID: $chatId, Message: $message");
$this->logger->logException($exception);
$this->logger->error(
"Telegram sendMessage to customer error. ChatID: $chatId, Message: $message",
['exception' => $exception]
);
}
}
}
private function makeValidationRulesFromSettings(): array
private function formatHistoryComment(array $data): string
{
return [
'firstName' => 'required',
'lastName' => 'required',
'phone' => 'required',
'email' => 'email',
];
$customFields = Arr::except($data, [
'firstname',
'lastname',
'email',
'telephone',
'comment',
'shipping_address_1',
'shipping_city',
'shipping_zone',
'shipping_postcode',
'payment_method',
'tgData',
]);
$additionalString = '';
if ($customFields) {
$additionalString = "\n\nДополнительная информация по заказу:\n";
foreach ($customFields as $field => $value) {
$additionalString .= $field . ': ' . $value . "\n";
}
}
return "Заказ оформлен через Telegram Mini App.{$additionalString}";
}
}

View File

@@ -298,7 +298,7 @@ class ProductsService
'alt' => Utils::htmlEntityEncode($product_info['name']),
];
} catch (Exception $e) {
$this->logger->logException($e);
$this->logger->error($e->getMessage(), ['exception' => $e]);
}
}

View File

@@ -391,6 +391,10 @@ class SettingsSerializerService
}
if (isset($data['port'])) {
if (is_string($data['port']) && ctype_digit($data['port'])) {
$data['port'] = (int) $data['port'];
}
if (! is_int($data['port'])) {
throw new InvalidArgumentException('database.port must be an integer');
}

View File

@@ -142,7 +142,7 @@ MARKDOWN;
} catch (ClientException $exception) {
$this->telegram->sendMessage($chatId, 'Ошибка: ' . $exception->getResponse()->getBody()->getContents());
} catch (Throwable $exception) {
$this->logger->logException($exception);
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
$this->telegram->sendMessage($chatId, 'Произошла ошибка');
}
}

View File

@@ -1,10 +1,10 @@
<?php
use App\Handlers\BannerHandler;
use App\Handlers\BlocksHandler;
use App\Handlers\CartHandler;
use App\Handlers\CategoriesHandler;
use App\Handlers\FiltersHandler;
use App\Handlers\FormsHandler;
use App\Handlers\HealthCheckHandler;
use App\Handlers\OrderHandler;
use App\Handlers\ProductsHandler;
@@ -30,4 +30,6 @@ return [
'webhook' => [TelegramHandler::class, 'webhook'],
'processBlock' => [BlocksHandler::class, 'processBlock'],
'getForm' => [FormsHandler::class, 'getForm'],
];

View File

@@ -44,25 +44,82 @@ class TestCase extends BaseTestCase
private function bootstrapApplication(): Application
{
$app = ApplicationFactory::create([
'database' => [
'host' => getenv('DB_HOSTNAME') ?: 'mysql',
'database' => getenv('DB_DATABASE') ?: 'ocstore3',
'username' => getenv('DB_USERNAME') ?: 'root',
'password' => getenv('DB_PASSWORD') ?: 'secret',
'prefix' => getenv('DB_PREFIX') ?: 'oc_',
'port' => getenv('DB_PORT') ?: 3306,
'app' => [
'app_enabled' => true,
'app_name' => 'Telecart',
'app_icon' => null,
"theme_light" => "light",
"theme_dark" => "dark",
"app_debug" => false,
'shop_base_url' => 'http://localhost', // for catalog: HTTPS_SERVER, for admin: HTTPS_CATALOG
'language_id' => 10,
],
'logs' => [
'path' => sys_get_temp_dir(),
],
'base_url' => 'http://localhost',
'public_url' => 'http://localhost',
'telegram' => [
'bot_token' => 'test_token',
'chat_id' => '123',
'owner_notification_template' => 'Test',
'customer_notification_template' => 'Test',
'mini_app_url' => 'https://example.com',
"bot_token" => "",
"chat_id" => null,
"owner_notification_template" => 'owner_notification_template',
"customer_notification_template" => 'customer_notification_template',
"mini_app_url" => "",
],
"metrics" => [
"yandex_metrika_enabled" => false,
"yandex_metrika_counter" => "",
],
'store' => [
'enable_store' => true,
'feature_coupons' => true,
'feature_vouchers' => true,
'oc_store_id' => 777,
'oc_default_currency' => 'RRR',
'oc_config_tax' => true,
],
'texts' => [
'text_no_more_products' => 'Это всё по текущему запросу. Попробуйте уточнить фильтры или поиск.',
'text_empty_cart' => 'Ваша корзина пуста.',
'text_order_created_success' => 'Ваш заказ успешно оформлен и будет обработан в ближайшее время.'
],
'orders' => [
'order_default_status_id' => 11,
'oc_customer_group_id' => 99,
],
'mainpage_blocks' => [
[
'type' => 'products_feed',
'title' => '',
'description' => '',
'is_enabled' => true,
'goal_name' => '',
'data' => [
'max_page_count' => 10,
],
],
],
'cache' => [
'namespace' => 'telecart',
'default_lifetime' => 60 * 60 * 24,
'options' => [
'db_table' => 'telecart_cache_items',
],
],
'logs' => [
'path' => '/tmp'
],
'database' => [
'host' => env('DB_HOSTNAME'),
'database' => env('DB_DATABASE'),
'username' => env('DB_USERNAME'),
'password' => env('DB_PASSWORD'),
'prefix' => env('DB_PREFIX') ?: 'oc_',
'port' => env('DB_PORT'),
],
]);

View File

@@ -663,4 +663,34 @@ class ArrTest extends TestCase
$this->assertSame([], $result);
}
public function testExceptRemovesSpecifiedKeys(): void
{
$array = [
'app' => 'telecart',
'debug' => true,
'version' => '1.0.0',
];
$result = Arr::except($array, ['debug', 'nonexistent']);
$expected = [
'app' => 'telecart',
'version' => '1.0.0',
];
$this->assertSame($expected, $result);
}
public function testExceptReturnsOriginalArrayWhenNoKeysProvided(): void
{
$array = [
'app' => 'telecart',
'debug' => true,
];
$result = Arr::except($array, []);
$this->assertSame($array, $result);
}
}

View File

@@ -512,4 +512,92 @@ class BuilderTest extends TestCase
);
}
public function testUpdate(): void
{
$connection = $this->createMock(MySqlConnection::class);
$connection->expects($this->once())
->method('statement')
->with(
'UPDATE `telecart_settings` SET `alias` = ?, `foo` = ? WHERE alias = ?',
['foobar2', 'bar2', 'foobar']
)
->willReturn(true);
$builder = new Builder($connection, new MySqlGrammar());
$builder->newQuery()
->where('alias', '=', 'foobar')
->update('telecart_settings', [
'alias' => 'foobar2',
'foo' => 'bar2',
]);
}
public function testUpdateJsonField(): void
{
$json = json_encode(['xyz' => 'bazz'], JSON_THROW_ON_ERROR);
$connection = $this->createMock(MySqlConnection::class);
$connection->expects($this->once())
->method('statement')
->with(
'UPDATE `telecart_settings` SET `json` = ? WHERE alias = ?',
[$json, 'foobar']
)
->willReturn(true);
$builder = new Builder($connection, new MySqlGrammar());
$builder->newQuery()
->where('alias', '=', 'foobar')
->update('telecart_settings', [
'json' => $json,
]);
}
public function testUpdateJsonFieldWithCyrillic(): void
{
$json = json_encode(['xyz' => 'привет'], JSON_THROW_ON_ERROR);
$connection = $this->createMock(MySqlConnection::class);
$connection->expects($this->once())
->method('statement')
->with(
'UPDATE `telecart_settings` SET `json` = ? WHERE alias = ?',
[$json, 'foobar']
)
->willReturn(true);
$builder = new Builder($connection, new MySqlGrammar());
$builder->newQuery()
->where('alias', '=', 'foobar')
->update('telecart_settings', [
'json' => $json,
]);
}
public function testUpdateWithJoin(): void
{
$connection = $this->createMock(MySqlConnection::class);
$connection->expects($this->once())
->method('statement')
->with(
'UPDATE `t1` INNER JOIN t2 ON t1.id = t2.t1_id SET `t1.foo` = ? WHERE t2.bar = ?',
['new_value', 'condition']
)
->willReturn(true);
$builder = new Builder($connection, new MySqlGrammar());
$builder->newQuery()
->join('t2', function (JoinClause $join) {
$join->on('t1.id', '=', 't2.t1_id');
})
->where('t2.bar', '=', 'condition')
->update('t1', [
't1.foo' => 'new_value',
]);
}
}

View File

@@ -9,6 +9,7 @@ class HelpersTest extends TestCase
{
public function testDbTable(): void
{
$this->assertEquals('oc_some_table', db_table('some_table'));
}

View File

@@ -230,4 +230,52 @@ class MySqlGrammarTest extends TestCase
$this->grammar->compileGroupBy($mock, ['foo', 'bar'])
);
}
public function testCompileUpdate(): void
{
$builder = m::mock(Builder::class);
$builder->joins = [];
$builder->wheres = [
[
'type' => 'Basic',
'column' => 'id',
'operator' => '=',
'value' => 1,
'boolean' => 'and',
]
];
$this->assertEquals(
"UPDATE `table` SET `foo` = ?, `bar` = ? WHERE id = ?",
$this->grammar->compileUpdate($builder, 'table', ['foo' => 'bar', 'bar' => 'baz'])
);
}
public function testCompileUpdateWithJoins(): void
{
$joinClause = m::mock(JoinClause::class);
$joinClause->table = 'other_table';
$joinClause->type = 'inner';
$joinClause->first = 'table.id';
$joinClause->operator = '=';
$joinClause->second = 'other_table.id';
$joinClause->wheres = [];
$builder = m::mock(Builder::class);
$builder->joins = [$joinClause];
$builder->wheres = [
[
'type' => 'Basic',
'column' => 'table.id',
'operator' => '=',
'value' => 1,
'boolean' => 'and',
]
];
$this->assertEquals(
"UPDATE `table` INNER JOIN other_table ON table.id = other_table.id SET `foo` = ?, `bar` = ? WHERE table.id = ?",
$this->grammar->compileUpdate($builder, 'table', ['foo' => 'bar', 'bar' => 'baz'])
);
}
}

View File

@@ -0,0 +1,180 @@
<?php
namespace Tests\Unit\Services;
use App\Services\CartService;
use App\Services\OrderCreateService;
use App\Services\SettingsService;
use Carbon\Carbon;
use Mockery as m;
use Openguru\OpenCartFramework\OpenCart\Decorators\OcRegistryDecorator;
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
use Openguru\OpenCartFramework\Telegram\TelegramService;
use Openguru\OpenCartFramework\Validator\ValidatorInterface;
use Psr\Log\LoggerInterface;
use Tests\TestCase;
class OrderCreateServiceTest extends TestCase
{
public function testCreateNewOrder(): void
{
$data = [
'firstname' => 'John',
'lastname' => 'Doe',
'email' => 'test@mail.com',
'telephone' => '+79999999999',
'comment' => 'Comment',
'shipping_address_1' => 'Russia, Moscow',
'shipping_city' => 'Moscow',
'shipping_zone' => 'Rostov',
'shipping_postcode' => 'Rostov',
'payment_method' => 'Cash',
'field_1' => 'кирилица',
'field_2' => 'hello',
'tgData' => [],
];
$meta = [
'ip' => '127.0.0.1',
'user_agent' => 'UnitTests',
];
$dateAdded = '2026-01-01 00:00:00';
$dateAddedFormatted = '01.01.2026 00:00';
Carbon::setTestNow($dateAdded);
$totalText = '100.5р.';
$totalNumeric = 100.5;
$totals = [];
$currencyId = 100;
$currencyCode = $this->app->getConfigValue('store.oc_default_currency');
$currencyValue = 222;
$orderId = 1111;
$orderProductId = 223;
$product = [
'product_id' => 93,
'name' => 'Product Name',
'model' => 'Product Model',
'quantity' => 1,
'price_numeric' => 100,
'total_numeric' => 100,
'reward_numeric' => 88,
];
$products = [$product];
$connection = m::mock(ConnectionInterface::class);
$connection->shouldReceive('transaction')->once()->andReturnUsing(fn($c) => $c());
$connection->shouldReceive('lastInsertId')->once()->andReturn($orderId)->ordered();
$connection->shouldReceive('lastInsertId')->once()->andReturn($orderProductId)->ordered();
$connection->shouldReceive('insert')->once()->with(
db_table('order'),
[
'store_id' => $this->app->getConfigValue('store.oc_store_id'),
'store_name' => $this->app->getConfigValue('app.app_name'),
'firstname' => $data['firstname'],
'lastname' => $data['lastname'],
'email' => $data['email'],
'telephone' => $data['telephone'],
'payment_method' => $data['payment_method'],
'comment' => $data['comment'],
'shipping_address_1' => $data['shipping_address_1'],
'shipping_city' => $data['shipping_city'],
'shipping_zone' => $data['shipping_zone'],
'shipping_postcode' => $data['shipping_postcode'],
'total' => $totalNumeric,
'order_status_id' => $this->app->getConfigValue('orders.order_default_status_id'),
'ip' => $meta['ip'],
'forwarded_ip' => $meta['ip'],
'user_agent' => $meta['user_agent'],
'date_added' => $dateAdded,
'date_modified' => $dateAdded,
'language_id' => $this->app->getConfigValue('app.language_id'),
'currency_id' => $currencyId,
'currency_code' => $currencyCode,
'currency_value' => $currencyValue,
'customer_group_id' => $this->app->getConfigValue('orders.oc_customer_group_id'),
],
)
->andReturn(true);
$connection->shouldReceive('insert')->once()->with(
db_table('order_product'),
[
'order_id' => $orderId,
'product_id' => $product['product_id'],
'name' => $product['name'],
'model' => $product['model'],
'quantity' => $product['quantity'],
'price' => $product['price_numeric'],
'total' => $product['total_numeric'],
'reward' => $product['reward_numeric'],
]
)->andReturn(true);
$connection->shouldReceive('insert')->once()->with(
db_table('order_history'),
[
'order_id' => $orderId,
'order_status_id' => $this->app->getConfigValue('orders.order_default_status_id'),
'notify' => 0,
'comment' => 'Заказ оформлен через Telegram Mini App.'
. "\n\nДополнительная информация по заказу:"
. "\nfield_1: кирилица"
. "\nfield_2: hello\n",
'date_added' => $dateAdded,
],
)->andReturnTrue();
$cartService = m::mock(CartService::class);
$cartService
->shouldReceive('getCart')
->once()
->andReturn([
'total' => $totalNumeric,
'totals' => $totals,
'products' => $products,
'total_text' => $totalText,
]);
$cartService->shouldReceive('flush')->once()->andReturnNull();
$ocCurrencyMock = m::mock();
$ocCurrencyMock->shouldReceive('getId')->once()->andReturn($currencyId);
$ocCurrencyMock->shouldReceive('getValue')->once()->andReturn($currencyValue);
$ocSessionMock = m::mock();
$ocSessionMock->data = [
'currency' => $currencyCode,
];
$registryMock = m::mock('Registry');
$registryMock->shouldReceive('get')->with('currency')->andReturn($ocCurrencyMock);
$registryMock->shouldReceive('get')->with('session')->andReturn($ocSessionMock);
$ocRegistryDecorator = new OcRegistryDecorator($registryMock);
$telegramServiceMock = m::mock(TelegramService::class);
$loggerMock = m::mock(LoggerInterface::class);
$validatorMock = m::mock(ValidatorInterface::class);
$service = new OrderCreateService(
$connection,
$cartService,
$ocRegistryDecorator,
$this->app->get(SettingsService::class),
$telegramServiceMock,
$loggerMock,
$validatorMock,
);
$order = $service->create($data, $meta);
$this->assertEquals([
'id' => $orderId,
'created_at' => $dateAddedFormatted,
'total' => $totalText,
'final_total_numeric' => $totalNumeric,
'currency' => $currencyCode,
'products' => $products,
], $order);
}
}

View File