feat: add FormKit framework support and update dependencies

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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