web-form.blade.php 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. {{--
  2. Компонент: <x-web-form slug="..." />
  3. Создан: 2026-05-07
  4. Переменные: $slug — slug формы из таблицы web_forms
  5. Логика: загружает форму по slug, рендерит поля согласно конфигурации,
  6. отправляет через fetch POST /forms/{slug}/submit (JSON),
  7. показывает сообщение об успехе или ошибки валидации.
  8. Если форма не найдена или неактивна — ничего не рендерит.
  9. --}}
  10. @php
  11. $webForm = \App\Models\WebForm::where('slug', $slug)->where('is_active', true)->first();
  12. @endphp
  13. @if($webForm && count($webForm->fields ?? []) > 0)
  14. @php $fid = 'wf_' . $webForm->slug; @endphp
  15. <form id="{{ $fid }}" class="wf-form" novalidate>
  16. @csrf
  17. <div class="wf-fields">
  18. @php
  19. // Группируем поля попарно для half-width
  20. $fields = $webForm->fields;
  21. $i = 0;
  22. $total = count($fields);
  23. @endphp
  24. @while($i < $total)
  25. @php $f = $fields[$i]; $next = $fields[$i + 1] ?? null; @endphp
  26. @if($f['width'] === 'half' && $next && $next['width'] === 'half')
  27. {{-- Пара half-width полей --}}
  28. <div class="form-row">
  29. <div class="form-group col-md-6">
  30. @include('components._wf_field', ['field' => $f])
  31. </div>
  32. <div class="form-group col-md-6">
  33. @include('components._wf_field', ['field' => $next])
  34. </div>
  35. </div>
  36. @php $i += 2; @endphp
  37. @else
  38. <div class="form-group">
  39. @include('components._wf_field', ['field' => $f])
  40. </div>
  41. @php $i++; @endphp
  42. @endif
  43. @endwhile
  44. </div>
  45. <div class="wf-errors alert alert-danger" style="display:none"></div>
  46. <button type="submit" class="btn btn-red" style="width:100%;justify-content:center;font-size:15px;padding:15px">
  47. Отправить заявку →
  48. </button>
  49. <p style="font-size:12px;color:var(--light);margin-top:12px;text-align:center">
  50. Нажимая кнопку, вы соглашаетесь с <a href="{{ route('privacy') }}" style="color:var(--red)">политикой конфиденциальности</a>
  51. </p>
  52. </form>
  53. <div id="{{ $fid }}_ok" style="display:none;background:var(--w);border:1px solid var(--line);border-radius:var(--r);padding:40px;text-align:center">
  54. <div style="font-size:52px;margin-bottom:16px">✅</div>
  55. <h3 style="font-family:var(--fh);font-size:24px;font-weight:800;margin-bottom:8px">Заявка отправлена!</h3>
  56. <p style="color:var(--muted);font-size:14px">Свяжемся в ближайшее время.</p>
  57. </div>
  58. <script>
  59. (function () {
  60. // Форматирует 10 пользовательских цифр в +7 (XXX) XXX-XX-XX.
  61. // Разделители добавляются только при наличии цифр после них — без "висячих" дефисов.
  62. function phoneFmt(d) {
  63. var out = '+7';
  64. if (d.length > 0) out += ' (' + d.slice(0, 3);
  65. if (d.length >= 3) out += ')';
  66. if (d.length > 3) out += ' ' + d.slice(3, 6);
  67. if (d.length > 6) out += '-' + d.slice(6, 8);
  68. if (d.length > 8) out += '-' + d.slice(8, 10);
  69. return out;
  70. }
  71. // Маска телефона: полностью keydown-driven, input-событие не используется
  72. function initPhoneMask(el) {
  73. el.setAttribute('placeholder', '+7 (___) ___-__-__');
  74. el.setAttribute('maxlength', '18');
  75. // Возвращает пользовательские цифры (без ведущей 7/8)
  76. function digits() {
  77. return el.value.replace(/\D/g, '').replace(/^[78]/, '');
  78. }
  79. el.addEventListener('focus', function () {
  80. if (!this.value) this.value = '+7';
  81. });
  82. el.addEventListener('blur', function () {
  83. if (digits().length === 0) this.value = '';
  84. });
  85. el.addEventListener('keydown', function (e) {
  86. if (e.ctrlKey || e.metaKey) return;
  87. if (['Tab','Enter','ArrowLeft','ArrowRight','Home','End'].includes(e.key)) return;
  88. e.preventDefault();
  89. var d = digits();
  90. if (e.key === 'Backspace') {
  91. d = d.slice(0, -1);
  92. } else if (/^\d$/.test(e.key) && d.length < 10) {
  93. d += e.key;
  94. } else {
  95. return;
  96. }
  97. this.value = phoneFmt(d);
  98. });
  99. // Вставка из буфера — нормализуем цифры
  100. el.addEventListener('paste', function (e) {
  101. e.preventDefault();
  102. var text = (e.clipboardData || window.clipboardData).getData('text');
  103. var d = (digits() + text.replace(/\D/g, '').replace(/^[78]/, '')).slice(0, 10);
  104. this.value = phoneFmt(d);
  105. });
  106. }
  107. // Валидация email: красная рамка при некорректном адресе
  108. function initEmailValidation(el) {
  109. el.addEventListener('blur', function () {
  110. if (!this.value) { this.style.borderColor = ''; return; }
  111. var ok = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(this.value);
  112. this.style.borderColor = ok ? '' : 'var(--red, #d12626)';
  113. });
  114. el.addEventListener('input', function () { this.style.borderColor = ''; });
  115. }
  116. var form = document.getElementById('{{ $fid }}');
  117. var ok = document.getElementById('{{ $fid }}_ok');
  118. var errs = form.querySelector('.wf-errors');
  119. // Применяем маски ко всем полям формы
  120. form.querySelectorAll('input[type=tel]').forEach(initPhoneMask);
  121. form.querySelectorAll('input[type=email]').forEach(initEmailValidation);
  122. form.addEventListener('submit', function (e) {
  123. e.preventDefault();
  124. errs.style.display = 'none';
  125. var data = {};
  126. var inputs = form.querySelectorAll('[data-wf-name]');
  127. inputs.forEach(function (el) {
  128. var name = el.dataset.wfName;
  129. var type = el.type || el.tagName.toLowerCase();
  130. if (type === 'checkbox') {
  131. data[name] = el.checked ? '1' : '';
  132. } else {
  133. data[name] = el.value;
  134. }
  135. });
  136. var btn = form.querySelector('[type=submit]');
  137. btn.disabled = true;
  138. btn.textContent = 'Отправляем...';
  139. fetch('{{ route('forms.submit', $webForm->slug) }}', {
  140. method: 'POST',
  141. headers: {
  142. 'Content-Type': 'application/json',
  143. 'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]')?.content
  144. || '{{ csrf_token() }}'
  145. },
  146. body: JSON.stringify(data),
  147. })
  148. .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, data: j }; }); })
  149. .then(function (res) {
  150. if (res.ok && res.data.ok) {
  151. form.style.display = 'none';
  152. ok.style.display = 'block';
  153. } else {
  154. var msgs = Object.values(res.data.errors || {}).flat();
  155. errs.innerHTML = msgs.map(function (m) { return '<p class="mb-1">'+m+'</p>'; }).join('');
  156. errs.style.display = 'block';
  157. btn.disabled = false;
  158. btn.textContent = 'Отправить заявку →';
  159. }
  160. })
  161. .catch(function () {
  162. errs.innerHTML = '<p>Ошибка соединения. Попробуйте ещё раз.</p>';
  163. errs.style.display = 'block';
  164. btn.disabled = false;
  165. btn.textContent = 'Отправить заявку →';
  166. });
  167. });
  168. })();
  169. </script>
  170. @endif