@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.
- package/README.md +85 -7
- package/dist/src/assets/icons/asterisk-icon.vue +12 -0
- package/dist/src/assets/icons/back-icon.vue +13 -0
- package/dist/src/assets/icons/cart-icon.vue +16 -0
- package/dist/src/assets/icons/close-icon.vue +10 -0
- package/dist/src/assets/icons/cloud-icon.vue +13 -0
- package/dist/src/assets/icons/database-icon.vue +17 -0
- package/dist/src/assets/icons/lightning-icon.vue +13 -0
- package/dist/src/assets/icons/linkedin-icon.vue +17 -0
- package/dist/src/assets/icons/panorama-icon.vue +16 -0
- package/dist/src/assets/icons/shevron.vue +8 -0
- package/dist/src/assets/icons/success-icon.vue +43 -0
- package/dist/src/assets/icons/verified-icon.vue +10 -0
- package/dist/src/assets/icons/widget-icon.vue +18 -0
- package/dist/src/assets/images/logo.svg +13 -0
- package/dist/src/components/common/Button.vue +46 -0
- package/dist/src/components/common/Checkbox.vue +118 -0
- package/dist/src/components/common/GlowButton.vue +122 -0
- package/dist/src/components/common/InputText.vue +58 -0
- package/dist/src/components/common/Radio.vue +68 -0
- package/dist/src/components/common/TextField.vue +55 -0
- package/dist/src/components/common/effects/TypingEffect.vue +38 -0
- package/dist/src/components/common/forms/ContactUsForm.vue +243 -0
- package/dist/src/components/common/forms/ResearchForm.vue +56 -0
- package/dist/src/components/common/images/LazyImage.vue +72 -0
- package/dist/src/components/footer/link-group.vue +27 -0
- package/dist/src/components/footer/links.ts +52 -0
- package/dist/src/components/header/desktop-menu.vue +95 -0
- package/dist/src/components/header/menu.ts +41 -0
- package/dist/src/components/header/mobile-menu.vue +117 -0
- package/dist/src/components/header/services-list.vue +26 -0
- package/dist/src/components/homepage/technology-card.vue +95 -0
- package/dist/src/helpers/form-validator.ts +9 -0
- package/dist/src/helpers/paths.ts +25 -0
- package/dist/src/lib/crm/types.ts +62 -0
- package/dist/tokens.css +229 -0
- 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>
|