@codecavepro/brand 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +85 -7
  2. package/dist/src/assets/icons/asterisk-icon.vue +12 -0
  3. package/dist/src/assets/icons/back-icon.vue +13 -0
  4. package/dist/src/assets/icons/cart-icon.vue +16 -0
  5. package/dist/src/assets/icons/close-icon.vue +10 -0
  6. package/dist/src/assets/icons/cloud-icon.vue +13 -0
  7. package/dist/src/assets/icons/database-icon.vue +17 -0
  8. package/dist/src/assets/icons/lightning-icon.vue +13 -0
  9. package/dist/src/assets/icons/linkedin-icon.vue +17 -0
  10. package/dist/src/assets/icons/panorama-icon.vue +16 -0
  11. package/dist/src/assets/icons/shevron.vue +8 -0
  12. package/dist/src/assets/icons/success-icon.vue +43 -0
  13. package/dist/src/assets/icons/verified-icon.vue +10 -0
  14. package/dist/src/assets/icons/widget-icon.vue +18 -0
  15. package/dist/src/assets/images/logo.svg +13 -0
  16. package/dist/src/components/common/Button.vue +46 -0
  17. package/dist/src/components/common/Checkbox.vue +118 -0
  18. package/dist/src/components/common/GlowButton.vue +122 -0
  19. package/dist/src/components/common/InputText.vue +58 -0
  20. package/dist/src/components/common/Radio.vue +68 -0
  21. package/dist/src/components/common/TextField.vue +55 -0
  22. package/dist/src/components/common/effects/TypingEffect.vue +38 -0
  23. package/dist/src/components/common/forms/ContactUsForm.vue +243 -0
  24. package/dist/src/components/common/forms/ResearchForm.vue +56 -0
  25. package/dist/src/components/common/images/LazyImage.vue +72 -0
  26. package/dist/src/components/footer/link-group.vue +27 -0
  27. package/dist/src/components/footer/links.ts +52 -0
  28. package/dist/src/components/header/desktop-menu.vue +95 -0
  29. package/dist/src/components/header/menu.ts +41 -0
  30. package/dist/src/components/header/mobile-menu.vue +117 -0
  31. package/dist/src/components/header/services-list.vue +26 -0
  32. package/dist/src/components/homepage/technology-card.vue +95 -0
  33. package/dist/src/helpers/form-validator.ts +9 -0
  34. package/dist/src/helpers/paths.ts +25 -0
  35. package/dist/src/lib/crm/types.ts +62 -0
  36. package/dist/tokens.css +229 -0
  37. package/package.json +15 -1
@@ -0,0 +1,122 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, ref } from "vue";
3
+ import { gsap } from "gsap";
4
+
5
+ const props = defineProps<{
6
+ title: string
7
+ class?: string
8
+ href?: string
9
+ }>()
10
+ const isTouchDevice = ref(false)
11
+ const link = ref(null)
12
+ const glow = ref(null)
13
+ const edgeLeft = ref(null)
14
+ const edgeRight = ref(null)
15
+ let leaveTween = null
16
+ const handleMouseMove = (e: MouseEvent) => {
17
+ if (isTouchDevice.value) return
18
+
19
+ const rect = link.value.getBoundingClientRect()
20
+ const x = e.clientX - rect.left
21
+ const y = e.clientY - rect.top
22
+ const percentX = x / rect.width
23
+ gsap.to(glow.value, {
24
+ left: x,
25
+ top: y,
26
+ duration: 0.5,
27
+ ease: 'power2.out',
28
+ })
29
+ const edgeZone = 0.1
30
+ let leftOpacity = 0
31
+ let rightOpacity = 0
32
+ if (percentX < edgeZone) {
33
+ leftOpacity = 1 - percentX / edgeZone
34
+ } else if (percentX > 1 - edgeZone) {
35
+ rightOpacity = (percentX - (1 - edgeZone)) / edgeZone
36
+ }
37
+ edgeLeft.value.style.opacity = leftOpacity
38
+ edgeRight.value.style.opacity = rightOpacity
39
+ }
40
+ const handleMouseEnter = () => {
41
+ if (isTouchDevice.value) return
42
+ if (leaveTween) {
43
+ leaveTween.kill()
44
+ leaveTween = null
45
+ }
46
+ }
47
+ const handleMouseLeave = () => {
48
+ edgeLeft.value.style.opacity = 1
49
+ edgeRight.value.style.opacity = 0
50
+ if (leaveTween) leaveTween.kill()
51
+ leaveTween = gsap.to(glow.value, {
52
+ left: '20%',
53
+ top: '50%',
54
+ duration: 1,
55
+ delay: 1,
56
+ ease: 'power2.out',
57
+ })
58
+ }
59
+ onMounted(() => {
60
+ isTouchDevice.value = window.matchMedia("(pointer: coarse)").matches
61
+ || "ontouchstart" in window
62
+ || navigator.maxTouchPoints > 0
63
+ })
64
+ </script>
65
+
66
+ <template>
67
+ <div :class="`relative inline-block ${props.class ?? ''}`">
68
+ <div ref="edgeLeft" class="edge-glow -left-2"></div>
69
+ <a ref="link" :href="href" @mousemove="handleMouseMove" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave" :class="`cursor-pointer flex items-center justify-center relative z-[1] overflow-hidden w-full h-11 px-6 py-1 rounded-full bg-glow-25 ${props.class ?? ''}`">
70
+ <span class="relative z-[2] text-primary-800 font-bold select-none">
71
+ {{ title }}
72
+ </span>
73
+ <div ref="glow" class="glow"></div>
74
+ </a>
75
+ <div ref="edgeRight" class="edge-glow -right-2 opacity-0"></div>
76
+ </div>
77
+ </template>
78
+
79
+ <style scoped>
80
+ a {
81
+ box-shadow: 0 0 64px 0 #7A58FFA8,
82
+ 0 0 16px 0 #4F22FFA6,
83
+ 0 0 4px 2px #5B34FA;
84
+ transition: transform 0.5s ease;
85
+ }
86
+
87
+ a:active {
88
+ transform: scale(0.98);
89
+ }
90
+
91
+ .edge-glow {
92
+ position: absolute;
93
+ z-index: 0;
94
+ pointer-events: none;
95
+ top: 50%;
96
+ width: 100%;
97
+ height: 80%;
98
+ transform: translateY(-50%);
99
+ transition: opacity 0.2s ease;
100
+ background: radial-gradient(55.23% 55.23% at 50% 50%,
101
+ #FFFFFF 27.88%,
102
+ rgba(223, 212, 249, 0.762963) 51.92%,
103
+ rgba(153, 128, 255, 0) 100%);
104
+ filter: blur(12px);
105
+ }
106
+
107
+ .glow {
108
+ position: absolute;
109
+ z-index: 1;
110
+ pointer-events: none;
111
+ top: 50%;
112
+ left: 20%;
113
+ transform: translateY(-50%) translateX(-50%);
114
+ width: 50%;
115
+ height: 200%;
116
+ background: radial-gradient(ellipse at left center,
117
+ #FFFFFF 27.88%,
118
+ rgba(223, 212, 249, 0.76) 51.92%,
119
+ rgba(153, 128, 255, 0) 100%);
120
+ filter: blur(12px);
121
+ }
122
+ </style>
@@ -0,0 +1,58 @@
1
+ <script setup lang="ts">
2
+ import AsteriskIcon from "../../assets/icons/asterisk-icon.vue";
3
+
4
+ defineProps<{
5
+ id: string
6
+ label: string
7
+ type: 'text' | 'email'
8
+ autocomplete?: string
9
+ placeholder: string
10
+ isRequired?: boolean
11
+ isError?: boolean
12
+ errorMessage?: string,
13
+ modelValue?: string
14
+ }>()
15
+
16
+ defineEmits(['update:modelValue'])
17
+ </script>
18
+
19
+ <template>
20
+ <div class="w-full relative">
21
+ <label :for="id" class="absolute flex items-center gap-0.5 pl-3 pt-3 font-bold text-heading text-sm">
22
+ <span>{{ label }}</span>
23
+ <AsteriskIcon v-if="isRequired" />
24
+ </label>
25
+ <input :id="id" :type="type" :autocomplete="autocomplete" :placeholder="placeholder" :required="isRequired" :value="modelValue" @input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
26
+ :class="`
27
+ w-full p-3 pt-7 bg-surface-secondary rounded-lg placeholder:text-xs placeholder:text-body-secondary
28
+ focus:outline-none transition-colors
29
+ hover:bg-surface-tertiary
30
+ ${isError ? 'text-error input-error focus:text-error' : 'text-hovered focus:text-hovered'}
31
+ `" />
32
+ <span v-if="isError" class="text-error text-xs">
33
+ {{ errorMessage }}
34
+ </span>
35
+ </div>
36
+ </template>
37
+
38
+ <style scoped>
39
+ input:-webkit-autofill,
40
+ input:-webkit-autofill:hover,
41
+ input:-webkit-autofill:focus,
42
+ input:-webkit-autofill:active {
43
+ -webkit-box-shadow: 0 0 0 1000px var(--color-surface-tertiary) inset !important;
44
+ -webkit-text-fill-color: var(--color-heading) !important;
45
+ -webkit-background-clip: text !important;
46
+ }
47
+
48
+ input:focus {
49
+ box-shadow: 0 0 16px 0 hsl(from var(--color-brand-500) h s l / 0.5),
50
+ 0 0 4px 0 hsl(from var(--color-brand-500) h s l / 0.6);
51
+ }
52
+
53
+ input.input-error:focus,
54
+ .input-error {
55
+ box-shadow: 0 0 16px 0 hsl(from var(--color-error-200) h s l / 0.5),
56
+ 0 0 4px 0 hsl(from var(--color-error-100) h s l / 0.6);
57
+ }
58
+ </style>
@@ -0,0 +1,68 @@
1
+ <script setup lang="ts">
2
+ import { computed } from "vue";
3
+
4
+ const props = defineProps<{
5
+ id: string
6
+ label: string
7
+ name: string
8
+ isChecked?: boolean
9
+ modelValue?: string
10
+ variant?: 'primary' | 'secondary'
11
+ }>()
12
+ const baseLabelClass = 'w-fit flex items-center cursor-pointer text-body-primary transition-colors'
13
+ const labelClass = computed(() => {
14
+ switch (props.variant) {
15
+ case 'secondary':
16
+ return `${baseLabelClass} gap-3 p-3 pr-4 bg-surface-primary-transparent rounded-custom border-2 border-surface-quaternary group-hover:bg-surface-tertiary`
17
+ default:
18
+ return `${baseLabelClass} gap-2 py-2 px-3 bg-surface-secondary rounded-lg`
19
+ }
20
+ })
21
+
22
+ defineEmits(['update:modelValue'])
23
+ </script>
24
+
25
+ <template>
26
+ <label
27
+ :for="id"
28
+ :class="[labelClass]"
29
+ >
30
+ <input
31
+ type="radio"
32
+ :id="id"
33
+ :name="name"
34
+ :value="id"
35
+ class="bg-transparent border-[2px] border-surface-quaternary checked:border-action rounded-full hover:border-action"
36
+ :checked="isChecked"
37
+ @change="$emit('update:modelValue', id)"
38
+ />
39
+ <span class="text-xs whitespace-nowrap">
40
+ {{ label }}
41
+ </span>
42
+ </label>
43
+ </template>
44
+
45
+ <style scoped>
46
+ input {
47
+ width: 17px;
48
+ height: 17px;
49
+ appearance: none;
50
+ -webkit-appearance: none;
51
+ display: grid;
52
+ place-content: center;
53
+ }
54
+
55
+ input::before {
56
+ content: '';
57
+ width: 7px;
58
+ height: 7px;
59
+ border-radius: 50%;
60
+ transform: scale(0);
61
+ transition: var(--duration-control) transform ease-in-out;
62
+ background: var(--color-action);
63
+ }
64
+
65
+ input:checked::before {
66
+ transform: scale(1);
67
+ }
68
+ </style>
@@ -0,0 +1,55 @@
1
+ <script setup lang="ts">
2
+ import { ref } from "vue";
3
+
4
+ defineProps<{
5
+ id: string
6
+ label: string
7
+ placeholder: string
8
+ isRequired?: boolean
9
+ isError?: boolean
10
+ errorMessage?: string,
11
+ modelValue?: string
12
+ }>()
13
+
14
+ const textareaRef = ref(null)
15
+ const autoResize = () => {
16
+ if (textareaRef.value) {
17
+ textareaRef.value.style.height = 'auto'
18
+ textareaRef.value.style.height = textareaRef.value.scrollHeight + 'px'
19
+ }
20
+ }
21
+
22
+ defineEmits(['update:modelValue'])
23
+ </script>
24
+
25
+ <template>
26
+ <div class="w-full relative">
27
+ <div :class="`focus-area flex flex-col min-h-[7.5rem] p-3 rounded-lg bg-surface-secondary hover:bg-surface-tertiary transition-colors text-heading
28
+ ${isError ? 'input-error' : ''}`">
29
+ <label :for="id" class="font-bold text-sm select-none">
30
+ {{ label }}
31
+ </label>
32
+ <textarea
33
+ @input="autoResize"
34
+ ref="textareaRef"
35
+ :id="id" :placeholder="placeholder" :value="modelValue" @change="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
36
+ :class="`w-full flex-grow resize-none overflow-hidden placeholder:pt-1.5 placeholder:text-xs placeholder:text-body-secondary outline-none
37
+ ${isError ? 'text-error focus:text-error' : 'text-hovered focus:text-hovered'}`" />
38
+ </div>
39
+ <span v-if="isError" class="text-error text-xs">
40
+ {{ errorMessage }}
41
+ </span>
42
+ </div>
43
+ </template>
44
+
45
+ <style scoped>
46
+ .focus-area:focus-within {
47
+ box-shadow: 0 0 16px 0 hsl(from var(--color-brand-500) h s l / 0.5),
48
+ 0 0 4px 0 hsl(from var(--color-brand-500) h s l / 0.8);
49
+ }
50
+
51
+ .input-error {
52
+ box-shadow: 0 0 16px 0 hsl(from var(--color-error-200) h s l / 0.5),
53
+ 0 0 4px 0 hsl(from var(--color-error-100) h s l / 0.6);
54
+ }
55
+ </style>
@@ -0,0 +1,38 @@
1
+ <script setup lang="ts">
2
+ import { gsap } from "gsap";
3
+ import { SplitText } from "gsap/SplitText";
4
+ import { nextTick, onMounted, watch } from "vue";
5
+
6
+ const props = defineProps<{
7
+ text1: string
8
+ text2: string
9
+ }>()
10
+
11
+ const splitAnimation = () => {
12
+ gsap.registerPlugin(SplitText)
13
+ const split = SplitText.create('.split-text span', {
14
+ type: "words, chars"
15
+ })
16
+ gsap.from(split.chars, {
17
+ opacity: 0.5,
18
+ duration: 0.1,
19
+ stagger: 0.1,
20
+ })
21
+ }
22
+ onMounted(() => {
23
+ document.fonts.ready.then(() => {
24
+ splitAnimation()
25
+ })
26
+ })
27
+ watch(() => [props.text1, props.text2],
28
+ () => nextTick(splitAnimation))
29
+ </script>
30
+
31
+ <template>
32
+ <div class="flex flex-col items-center leading-[130%] text-heading-sm lg:text-heading-lg font-bold">
33
+ <h2 :key="text1" class="split-text">
34
+ <span class="block text-heading">{{ text1 }}</span>
35
+ <span class="block text-action">{{ text2 }}</span>
36
+ </h2>
37
+ </div>
38
+ </template>
@@ -0,0 +1,243 @@
1
+ <script setup lang="ts">
2
+ import GlowButton from "../GlowButton.vue";
3
+ import InputText from "../InputText.vue";
4
+ import Checkbox from "../Checkbox.vue";
5
+ import Radio from "../Radio.vue";
6
+ import TextField from "../TextField.vue";
7
+ import { computed, ref, watch } from "vue";
8
+ import SuccessIcon from "../../../assets/icons/success-icon.vue";
9
+ import CloseIcon from "../../../assets/icons/close-icon.vue";
10
+ import gsap from "gsap";
11
+ import type {
12
+ ContactFormDefinition,
13
+ ContactFormValues,
14
+ CrmSubmitResult,
15
+ ICrmFormClient,
16
+ } from "../../../lib/crm/types";
17
+ import { isCorrectEmailFormat, isCorrectLinkedInFormat } from "../../../helpers/form-validator.ts";
18
+
19
+ /* This component knows nothing about HubSpot, or about any other CRM. It is
20
+ * handed the labels to render and something that can accept a submission; who
21
+ * that something talks to is the caller's business. See lib/crm/types.ts. */
22
+ const props = defineProps<{
23
+ definition: ContactFormDefinition
24
+ client: ICrmFormClient
25
+ }>()
26
+
27
+ const emit = defineEmits<{
28
+ (e: 'submit', values: ContactFormValues): void
29
+ (e: 'submitted', result: CrmSubmitResult): void
30
+ }>()
31
+
32
+ interface FormField<T = string> {
33
+ value: T
34
+ error: string
35
+ label: string
36
+ placeholder?: string
37
+ required: boolean
38
+ maxLength?: number
39
+ }
40
+
41
+ const createFormField = <T,>(name: keyof ContactFormDefinition, value: T): FormField<T> => {
42
+ const spec = props.definition[name]
43
+
44
+ return {
45
+ value,
46
+ error: '',
47
+ label: spec.label,
48
+ placeholder: spec.placeholder,
49
+ required: spec.required,
50
+ ...(spec.maxLength === undefined ? {} : { maxLength: spec.maxLength }),
51
+ }
52
+ }
53
+
54
+ const formData = ref({
55
+ email: createFormField('email', ''),
56
+ firstName: createFormField('firstName', ''),
57
+ lastName: createFormField('lastName', ''),
58
+ companyName: createFormField('companyName', ''),
59
+ linkedinCompanyPage: createFormField('linkedinCompanyPage', ''),
60
+ services: createFormField('services', ''),
61
+ description: createFormField('description', ''),
62
+ privacyPolicy: {
63
+ value: false,
64
+ error: '',
65
+ label: 'I agree with the privacy policy',
66
+ required: true
67
+ }
68
+ })
69
+
70
+ const services = computed(() => props.definition.services.options ?? [])
71
+
72
+ const validateForm = () => {
73
+ resetErrorMessages();
74
+ let isValid = validateRequiredFields();
75
+
76
+ let description = formData.value.description;
77
+ let email = formData.value.email;
78
+ let linkedInPage = formData.value.linkedinCompanyPage;
79
+
80
+ if (email.value !== '' && !isCorrectEmailFormat(email.value)) {
81
+ email.error = 'Please enter valid email'
82
+ isValid = false
83
+ }
84
+ if (linkedInPage.value !== '' && !isCorrectLinkedInFormat(linkedInPage.value)) {
85
+ linkedInPage.error = 'Please enter valid LinkedIn page link'
86
+ isValid = false
87
+ }
88
+ if (description.maxLength !== undefined && description.value.length > description.maxLength) {
89
+ description.error = `You faced characters limits. Max length is ${description.maxLength}`
90
+ isValid = false
91
+ }
92
+
93
+ return isValid
94
+ }
95
+
96
+ const resetErrorMessages = () => Object.values(formData.value).forEach(field => field.error = '');
97
+
98
+ const validateRequiredFields = () => {
99
+ let isValid = true
100
+
101
+ Object.values(formData.value).forEach(field => {
102
+ const isEmpty = field.value === '' || field.value === false
103
+
104
+ if (field.required && isEmpty) {
105
+ field.error = 'This field is required'
106
+ isValid = false
107
+ } else {
108
+ field.error = ''
109
+ }
110
+ })
111
+
112
+ return isValid
113
+ }
114
+
115
+ const resetForm = () => {
116
+ formData.value.email.value = '';
117
+ formData.value.firstName.value = '';
118
+ formData.value.lastName.value = '';
119
+ formData.value.companyName.value = '';
120
+ formData.value.linkedinCompanyPage.value = '';
121
+ formData.value.description.value = '';
122
+ formData.value.services.value = '';
123
+ formData.value.privacyPolicy.value = false;
124
+ }
125
+
126
+ const currentValues = (): ContactFormValues => ({
127
+ email: formData.value.email.value,
128
+ firstName: formData.value.firstName.value,
129
+ lastName: formData.value.lastName.value,
130
+ companyName: formData.value.companyName.value,
131
+ linkedinCompanyPage: formData.value.linkedinCompanyPage.value,
132
+ services: formData.value.services.value,
133
+ description: formData.value.description.value,
134
+ privacyPolicyAccepted: formData.value.privacyPolicy.value,
135
+ })
136
+
137
+ const isSubmitting = ref(false)
138
+
139
+ const submitContactsForm = async () => {
140
+ if (isSubmitting.value || !validateForm()) {
141
+ return
142
+ }
143
+
144
+ const values = currentValues()
145
+ emit('submit', values)
146
+
147
+ isSubmitting.value = true
148
+ try {
149
+ const result = await props.client.submit(values)
150
+
151
+ if (result.ok) {
152
+ resetForm()
153
+ showAlert('success')
154
+ } else {
155
+ /* The previous code logged the failure and showed nothing, so a visitor
156
+ * whose enquiry never sent had no way to know. The wording below is a
157
+ * placeholder for a writer -- see CCWEB2-325. */
158
+ showAlert('error')
159
+ }
160
+
161
+ emit('submitted', result)
162
+ } finally {
163
+ isSubmitting.value = false
164
+ }
165
+ }
166
+
167
+ const alertKind = ref<'success' | 'error' | null>(null)
168
+ const isAlertVisible = computed(() => alertKind.value !== null)
169
+
170
+ const ALERT_TEXT = {
171
+ success: 'Sent! We will contact you within next 1-3 business days.',
172
+ error: 'We could not send your request. Please try again, or email us at hello@codecave.pro.',
173
+ } as const
174
+
175
+ const showAlert = (kind: 'success' | 'error') => {
176
+ if (alertKind.value !== null) return
177
+
178
+ alertKind.value = kind
179
+ setTimeout(() => {
180
+ alertKind.value = null
181
+ }, 10000)
182
+ }
183
+
184
+ watch(isAlertVisible, () => {
185
+ gsap.timeline()
186
+ .from('.form-alert', {
187
+ x: 120,
188
+ duration: 0.5,
189
+ })
190
+ })
191
+
192
+ </script>
193
+
194
+ <template>
195
+ <form class="flex flex-col gap-14 relative" novalidate>
196
+ <div class="space-y-5">
197
+ <fieldset class="space-y-2">
198
+ <InputText id="email" v-model="formData.email.value" :isRequired=formData.email.required :label=formData.email.label type="email" :isError="!!formData.email.error" :errorMessage="formData.email.error" :placeholder="formData.email.placeholder ?? ''" autocomplete="email" />
199
+ <div class="flex flex-col xl:flex-row gap-2">
200
+ <InputText id="firstname" v-model="formData.firstName.value" :isRequired=formData.firstName.required :label=formData.firstName.label type="text" :isError="!!formData.firstName.error" :errorMessage="formData.firstName.error" :placeholder="formData.firstName.placeholder ?? ''" autocomplete="given-name" />
201
+ <InputText id="lastname" v-model="formData.lastName.value" :isRequired=formData.lastName.required :label=formData.lastName.label type="text" :isError="!!formData.lastName.error" :errorMessage="formData.lastName.error" :placeholder="formData.lastName.placeholder ?? ''" autocomplete="family-name" />
202
+ </div>
203
+ <div class="flex flex-col xl:flex-row gap-2">
204
+ <InputText id="companyName" v-model="formData.companyName.value" :isRequired=formData.companyName.required :label=formData.companyName.label type="text" :isError="!!formData.companyName.error" :errorMessage="formData.companyName.error" :placeholder="formData.companyName.placeholder ?? ''" />
205
+ <InputText id="linkedinCompanyPage" v-model="formData.linkedinCompanyPage.value" :isRequired=formData.linkedinCompanyPage.required :label=formData.linkedinCompanyPage.label type="text" :isError="!!formData.linkedinCompanyPage.error" :errorMessage="formData.linkedinCompanyPage.error" :placeholder="formData.linkedinCompanyPage.placeholder ?? ''" />
206
+ </div>
207
+ </fieldset>
208
+ <fieldset class="space-y-3">
209
+ <legend class="pl-3 text-sm text-heading font-bold">{{ formData.services.label }}</legend>
210
+ <div class="flex flex-wrap gap-2">
211
+ <Radio v-for="item in services" :key="item.id" :id="item.id" :label="item.label" v-model="formData.services.value" name="services" />
212
+ </div>
213
+ </fieldset>
214
+ <fieldset class="space-y-3">
215
+ <TextField id="project" v-model="formData.description.value" :isRequired=formData.description.required :label=formData.description.label :isError="!!formData.description.error" :errorMessage="formData.description.error" :placeholder="formData.description.placeholder ?? ''" />
216
+ </fieldset>
217
+ <div class="space-y-2">
218
+ <Checkbox id="privacy" v-model="formData.privacyPolicy.value" :label="formData.privacyPolicy.label" :isRequired="formData.privacyPolicy.required" :isError="!!formData.privacyPolicy.error" />
219
+ <Checkbox id="promotions" label="I agree to receive promotional materials" />
220
+ </div>
221
+ </div>
222
+ <GlowButton @click="submitContactsForm" title="Leave consultation request" class="self-center lg:self-start" />
223
+ <div v-show="isAlertVisible" class="border border-action group absolute bottom-19.25 md:fixed z-50 left-1/2 -translate-x-1/2 md:top-24 form-alert flex gap-2 md:gap-8 items-center justify-between w-full h-fit max-w-139 bg-surface-secondary p-4 md:px-6 md:py-2.5 rounded-3xl">
224
+ <div v-if="alertKind === 'success'" class="w-8 h-8">
225
+ <component :is="SuccessIcon" class="w-8 h-8" />
226
+ </div>
227
+ <p class="text-sm md:text-lg font-bold text-heading md:max-w-sm">
228
+ {{ alertKind ? ALERT_TEXT[alertKind] : '' }}
229
+ </p>
230
+ <button @click="alertKind = null" class="group-hover:text-action text-heading transition-colors cursor-pointer">
231
+ <component :is="CloseIcon" />
232
+ </button>
233
+ </div>
234
+ </form>
235
+ </template>
236
+
237
+ <style scoped>
238
+ .form-alert {
239
+ box-shadow: 0 -34px 40px 0 #2814701A,
240
+ 0 -10px 24px 0 #2814701A,
241
+ 0 -6px 6px 0 #28147014;
242
+ }
243
+ </style>
@@ -0,0 +1,56 @@
1
+ <script setup lang="ts">
2
+ import { ref } from 'vue'
3
+ import InputText from '../InputText.vue'
4
+ import Button from '../Button.vue'
5
+ import { isCorrectEmailFormat } from "../../../helpers/form-validator.ts";
6
+
7
+ const email = ref('')
8
+
9
+ const hasError = ref(false)
10
+ const emailErrorMessage = ref('')
11
+
12
+ const submitForm = () => {
13
+ hasError.value = false
14
+ emailErrorMessage.value = ''
15
+
16
+ if (!email.value.trim()) {
17
+ hasError.value = true
18
+ emailErrorMessage.value = 'This field is required'
19
+ return
20
+ }
21
+
22
+ if (!isCorrectEmailFormat(email.value)) {
23
+ hasError.value = true
24
+ emailErrorMessage.value = 'Please enter valid email'
25
+ return
26
+ }
27
+ }
28
+ </script>
29
+
30
+ <template>
31
+ <form
32
+ novalidate
33
+ class="flex flex-col gap-4 md:flex-row md:items-center"
34
+ @submit.prevent="submitForm"
35
+ >
36
+ <div class="md:w-64 xl:w-80">
37
+ <InputText
38
+ id="file-email"
39
+ v-model="email"
40
+ is-required
41
+ :isError="hasError"
42
+ :errorMessage="emailErrorMessage"
43
+ label="E-mail"
44
+ type="email"
45
+ placeholder="Where should we send it?"
46
+ autocomplete="email"
47
+ />
48
+ </div>
49
+
50
+ <Button
51
+ title="Grab research"
52
+ variant="tertiary"
53
+ class="w-40 flex-shrink-0"
54
+ />
55
+ </form>
56
+ </template>