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:
70
.cursor/rules/form-builder.md
Normal file
70
.cursor/rules/form-builder.md
Normal 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
29
.cursorignore
Normal 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
|
||||
1175
frontend/admin/package-lock.json
generated
1175
frontend/admin/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
70
frontend/admin/src/components/FormBuilder/CodeEditor.vue
Normal file
70
frontend/admin/src/components/FormBuilder/CodeEditor.vue
Normal 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>
|
||||
376
frontend/admin/src/components/FormBuilder/FieldSettings.vue
Normal file
376
frontend/admin/src/components/FormBuilder/FieldSettings.vue
Normal 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>
|
||||
54
frontend/admin/src/components/FormBuilder/FieldsPanel.vue
Normal file
54
frontend/admin/src/components/FormBuilder/FieldsPanel.vue
Normal 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>
|
||||
320
frontend/admin/src/components/FormBuilder/FormBuilder.vue
Normal file
320
frontend/admin/src/components/FormBuilder/FormBuilder.vue
Normal 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>
|
||||
139
frontend/admin/src/components/FormBuilder/FormCanvas.vue
Normal file
139
frontend/admin/src/components/FormBuilder/FormCanvas.vue
Normal 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>
|
||||
52
frontend/admin/src/components/FormBuilder/FormRenderer.vue
Normal file
52
frontend/admin/src/components/FormBuilder/FormRenderer.vue
Normal 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>
|
||||
124
frontend/admin/src/components/FormBuilder/IconPicker.vue
Normal file
124
frontend/admin/src/components/FormBuilder/IconPicker.vue
Normal 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>
|
||||
|
||||
@@ -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, // Экспортируем метод установки ошибки
|
||||
};
|
||||
}
|
||||
@@ -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: '',
|
||||
}
|
||||
},
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
24
frontend/admin/src/formkit.config.js
Normal file
24
frontend/admin/src/formkit.config.js
Normal 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;
|
||||
3398
frontend/admin/src/formkit.theme.mjs
Normal file
3398
frontend/admin/src/formkit.theme.mjs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
|
||||
@@ -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},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
14
frontend/admin/src/stores/forms.js
Normal file
14
frontend/admin/src/stores/forms.js
Normal 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,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -63,6 +63,15 @@ export const useSettingsStore = defineStore('settings', {
|
||||
},
|
||||
|
||||
mainpage_blocks: [],
|
||||
|
||||
forms: {
|
||||
checkout: {
|
||||
alias: '',
|
||||
friendly_name: '',
|
||||
is_custom: false,
|
||||
schema: [],
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
|
||||
23
frontend/admin/src/views/FormBuilderView.vue
Normal file
23
frontend/admin/src/views/FormBuilderView.vue
Normal 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>
|
||||
9
frontend/admin/tailwind.config.js
Normal file
9
frontend/admin/tailwind.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
prefix: 'tw:',
|
||||
content: [
|
||||
'./index.html',
|
||||
'./src/**/*.{vue,js,ts,jsx,tsx}',
|
||||
'./templates/**/*.twig',
|
||||
'./views/**/*.php',
|
||||
],
|
||||
};
|
||||
1039
frontend/spa/package-lock.json
generated
1039
frontend/spa/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
7
frontend/spa/src/components/Icons/IconWarning.vue
Normal file
7
frontend/spa/src/components/Icons/IconWarning.vue
Normal 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>
|
||||
16
frontend/spa/src/formkit.config.js
Normal file
16
frontend/spa/src/formkit.config.js
Normal 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;
|
||||
3398
frontend/spa/src/formkit.theme.mjs
Normal file
3398
frontend/spa/src/formkit.theme.mjs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
16
frontend/spa/src/stores/FormsStore.js
Normal file
16
frontend/spa/src/stores/FormsStore.js
Normal 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,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
|
||||
|
||||
@plugin "daisyui" {
|
||||
themes: all;
|
||||
}
|
||||
|
||||
@@ -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: 'Оформление заказа',
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ module.exports = {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||
"./formkit.theme.mjs",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
|
||||
@@ -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'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
2
module/oc_telegram_shop/upload/oc_telegram_shop/bastion/Tasks/CachePruneTask.php
Normal file → Executable file
2
module/oc_telegram_shop/upload/oc_telegram_shop/bastion/Tasks/CachePruneTask.php
Normal file → Executable file
@@ -27,4 +27,4 @@ class CachePruneTask extends BaseMaintenanceTask
|
||||
{
|
||||
return new DateInterval('P1D');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
],
|
||||
];
|
||||
}
|
||||
};
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -109,6 +109,7 @@ SQL;
|
||||
} catch (Exception $e) {
|
||||
$this->connection->rollbackTransaction();
|
||||
$this->logger->error("An error occurred while applying migration.", ['exception' => $e]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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, 'Произошла ошибка');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ class HelpersTest extends TestCase
|
||||
{
|
||||
public function testDbTable(): void
|
||||
{
|
||||
|
||||
$this->assertEquals('oc_some_table', db_table('some_table'));
|
||||
}
|
||||
|
||||
|
||||
@@ -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'])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user