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:
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: {
|
||||
|
||||
Reference in New Issue
Block a user