@codecavepro/brand 1.3.0 → 1.4.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 +24 -12
- package/dist/src/components/common/ArticlePreview.vue +58 -0
- package/dist/src/components/common/Review.vue +64 -0
- package/dist/src/components/homepage/technologies.vue +122 -0
- package/dist/src/components/project/pain-points-item.vue +50 -0
- package/dist/src/helpers/breakpoints.ts +5 -0
- package/dist/src/helpers/date-formatter.ts +10 -0
- package/package.json +14 -2
package/README.md
CHANGED
|
@@ -142,8 +142,8 @@ Two things to know about it:
|
|
|
142
142
|
|
|
143
143
|
## The components
|
|
144
144
|
|
|
145
|
-
|
|
146
|
-
codecave.pro renders — ship as **source**, byte-for-byte the files the site builds
|
|
145
|
+
19 components and 13 icons — the buttons, form controls, menu, footer, and the
|
|
146
|
+
article, testimonial and technology cards codecave.pro renders — ship as **source**, byte-for-byte the files the site builds
|
|
147
147
|
from. Not a reimplementation of them, and not a bundle compiled from a snapshot:
|
|
148
148
|
the same files, kept identical by a check that runs on every push.
|
|
149
149
|
|
|
@@ -166,8 +166,11 @@ plugin — `@vitejs/plugin-vue`, `@astrojs/vue`, `vue-loader`. In exchange their
|
|
|
166
166
|
styles go through your own pipeline and you can read the source of anything that
|
|
167
167
|
surprises you.
|
|
168
168
|
|
|
169
|
-
`vue` is
|
|
170
|
-
|
|
169
|
+
`vue` is the only required peer. Four more are optional, each wanted by one or two
|
|
170
|
+
components; leave one out and only those are affected — `gsap` by `GlowButton.vue`,
|
|
171
|
+
`effects/TypingEffect.vue` and `forms/ContactUsForm.vue`; `vue3-carousel` by
|
|
172
|
+
`homepage/technologies.vue`; `marked` and `isomorphic-dompurify` by
|
|
173
|
+
`project/pain-points-item.vue`.
|
|
171
174
|
|
|
172
175
|
### Import the theme, not just the tokens
|
|
173
176
|
|
|
@@ -192,15 +195,24 @@ cascade layer — or leave it out — and those names resolve to nothing.
|
|
|
192
195
|
Import only `tokens.css` and the components mount and behave correctly and render
|
|
193
196
|
nearly unstyled.
|
|
194
197
|
|
|
195
|
-
###
|
|
198
|
+
### The content-shaped four resolve their own image URLs
|
|
196
199
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
200
|
+
`ArticlePreview`, `Review`, `pain-points-item` and `technologies` render CMS content, so
|
|
201
|
+
they used to reach a helper that knew codecave.pro's CMS host. They no longer do. The
|
|
202
|
+
three that show images take an optional resolver and default to leaving the URL alone:
|
|
203
|
+
|
|
204
|
+
```vue
|
|
205
|
+
<!-- URLs already absolute — nothing to pass -->
|
|
206
|
+
<Review :item="testimonial" />
|
|
207
|
+
|
|
208
|
+
<!-- URLs relative to your own media host -->
|
|
209
|
+
<Review :item="testimonial" :resolve-image="(u) => `https://cdn.example.com/${u}`" />
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Their props are declared as the fields each one reads — `item.photo.url`,
|
|
213
|
+
`article.cover.url` — not as a generated CMS schema. Anything with those fields
|
|
214
|
+
satisfies them, so a Strapi entity, a Contentful entry or a plain object all work
|
|
215
|
+
unchanged.
|
|
204
216
|
|
|
205
217
|
## Controls are 48px, and it is a floor
|
|
206
218
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { formattedDate } from "../../helpers/date-formatter.ts";
|
|
3
|
+
import { paths } from "../../helpers/paths.ts";
|
|
4
|
+
|
|
5
|
+
/* The fields this component actually reads, instead of the generated Strapi
|
|
6
|
+
* `Article`. TypeScript is structural, so a real Article still satisfies this
|
|
7
|
+
* -- callers pass exactly what they passed before -- but the component no
|
|
8
|
+
* longer carries the CMS schema with it. */
|
|
9
|
+
type ArticleSummary = {
|
|
10
|
+
slug: string | null
|
|
11
|
+
title: string
|
|
12
|
+
excerpt: string
|
|
13
|
+
date: Date | null
|
|
14
|
+
locale: string | null
|
|
15
|
+
readingtime: unknown
|
|
16
|
+
cover: { url: string; name: string }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const props = defineProps<{
|
|
20
|
+
article: ArticleSummary
|
|
21
|
+
className?: string
|
|
22
|
+
/* CMS media URLs arrive relative ("uploads/x.png"). The site injects its own
|
|
23
|
+
* resolver; a caller whose URLs are already absolute passes nothing. */
|
|
24
|
+
resolveImage?: (url: string) => string
|
|
25
|
+
}>()
|
|
26
|
+
|
|
27
|
+
const imageUrl = (url: string) => props.resolveImage?.(url) ?? url
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<template>
|
|
31
|
+
<a :href="`${paths.insights}/${article.slug}`" :class="`mx-1 lg:mx-2 w-full h-full self-start sm:self-auto p-6 flex flex-col gap-5 sm:gap-8
|
|
32
|
+
rounded-[2.25rem] bg-surface-secondary hover:bg-surface-secondary transition-colors cursor-pointer border-surface-tertiary border
|
|
33
|
+
${className ?? ''}`">
|
|
34
|
+
<div class="flex flex-col sm:flex-row gap-5 sm:gap-8 h-fit">
|
|
35
|
+
<img loading="lazy" class="sm:w-[132px] sm:h-[132px] rounded-xl object-cover"
|
|
36
|
+
:src="imageUrl(article.cover.url)"
|
|
37
|
+
:alt="article.cover.name"
|
|
38
|
+
:width="100"
|
|
39
|
+
:height="100"/>
|
|
40
|
+
<div class="space-y-2 sm:space-y-3">
|
|
41
|
+
<time class="text-body-secondary text-xs sm:text-sm" :datetime="article.date?.toString()">
|
|
42
|
+
{{ formattedDate(article.locale, article.date) }}
|
|
43
|
+
</time>
|
|
44
|
+
<h3 class="font-bold text-lg md:text-xl text-heading">
|
|
45
|
+
{{ article.title }}
|
|
46
|
+
</h3>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
<div class="text-sm flex flex-col justify-between h-full">
|
|
50
|
+
<p class="text-body-secondary-lighter">
|
|
51
|
+
{{ article.excerpt }}
|
|
52
|
+
</p>
|
|
53
|
+
<p class="text-xs sm:text-sm text-body-secondary mt-6 sm:mt-7">
|
|
54
|
+
Reading time: {{ article.readingtime }} m.
|
|
55
|
+
</p>
|
|
56
|
+
</div>
|
|
57
|
+
</a>
|
|
58
|
+
</template>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import LinkedinIcon from "../../assets/icons/linkedin-icon.vue";
|
|
3
|
+
import VerifiedIcon from "../../assets/icons/verified-icon.vue";
|
|
4
|
+
import LazyImage from "./images/LazyImage.vue"
|
|
5
|
+
|
|
6
|
+
/* See ArticlePreview.vue: the fields read here, not the generated Strapi
|
|
7
|
+
* `Testimonial`, which a caller may still pass unchanged. */
|
|
8
|
+
type TestimonialCard = {
|
|
9
|
+
name: string
|
|
10
|
+
position: string
|
|
11
|
+
review: string
|
|
12
|
+
verification: string | null
|
|
13
|
+
linkedinurl: string
|
|
14
|
+
photo: { url: string; name: string }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const props = defineProps<{
|
|
18
|
+
item: TestimonialCard
|
|
19
|
+
className?: string
|
|
20
|
+
/* Injected by the site; omit it when the URLs are already absolute. */
|
|
21
|
+
resolveImage?: (url: string) => string
|
|
22
|
+
}>()
|
|
23
|
+
|
|
24
|
+
const imageUrl = (url: string) => props.resolveImage?.(url) ?? url
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<template>
|
|
28
|
+
<div :class="`mx-1 lg:mx-2 testimonial rounded-custom space-y-2 lg:space-y-3 py-10 px-6 lg:px-11 ${className}`">
|
|
29
|
+
<div class="flex flex-col lg:flex-row gap-5 lg:items-center">
|
|
30
|
+
<LazyImage v-if="item.photo.name !== 'no-image.svg'"
|
|
31
|
+
class="w-12 lg:w-16 h-12 lg:h-16" :src="imageUrl(item.photo.url)"
|
|
32
|
+
:alt=item.photo.name />
|
|
33
|
+
<div>
|
|
34
|
+
<div class="flex items-center gap-2">
|
|
35
|
+
<p class="text-heading text-lg lg:text-xl">{{ item.name }}</p>
|
|
36
|
+
<a
|
|
37
|
+
v-if="item.linkedinurl.length > 1"
|
|
38
|
+
target="_blank"
|
|
39
|
+
rel='noopener noreferrer'
|
|
40
|
+
class="text-default-transparent hover:text-action transition-colors"
|
|
41
|
+
:href="item.linkedinurl">
|
|
42
|
+
<component
|
|
43
|
+
:is="LinkedinIcon"
|
|
44
|
+
/>
|
|
45
|
+
</a>
|
|
46
|
+
</div>
|
|
47
|
+
<p class="text-sm text-body-secondary">{{ item.position }}</p>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
<p class="text-sm text-body-secondary-lighter whitespace-pre-wrap">
|
|
51
|
+
{{ item.review }}
|
|
52
|
+
</p>
|
|
53
|
+
<div v-if="item.verification" class="flex items-center gap-1 text-action">
|
|
54
|
+
<VerifiedIcon />
|
|
55
|
+
<span>{{ item.verification }}</span>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
</template>
|
|
59
|
+
|
|
60
|
+
<style scoped>
|
|
61
|
+
.testimonial {
|
|
62
|
+
backdrop-filter: blur(32px);
|
|
63
|
+
}
|
|
64
|
+
</style>
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref } from "vue";
|
|
3
|
+
import { Carousel, type CarouselConfig, Slide } from "vue3-carousel";
|
|
4
|
+
import "vue3-carousel/carousel.css";
|
|
5
|
+
import { BREAKPOINTS } from "../../helpers/breakpoints.ts";
|
|
6
|
+
import GlowButton from "../common/GlowButton.vue";
|
|
7
|
+
import TypingEffect from "../common/effects/TypingEffect.vue";
|
|
8
|
+
import TechnologyCard from "./technology-card.vue";
|
|
9
|
+
import { paths } from "../../helpers/paths.ts";
|
|
10
|
+
|
|
11
|
+
/* See ArticlePreview.vue: the fields read here, not the generated Strapi
|
|
12
|
+
* `Technology`, which a caller may still pass unchanged. This component
|
|
13
|
+
* renders no images, so it needs no resolver. */
|
|
14
|
+
type TechnologySummary = {
|
|
15
|
+
name: string
|
|
16
|
+
title1: string
|
|
17
|
+
title2: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const props = defineProps<{
|
|
21
|
+
technologies: TechnologySummary[]
|
|
22
|
+
hideSelected?: boolean
|
|
23
|
+
}>()
|
|
24
|
+
|
|
25
|
+
const activeIndex = ref(0)
|
|
26
|
+
const defaultAutoplayValue = 12000
|
|
27
|
+
const autoplay = ref(defaultAutoplayValue)
|
|
28
|
+
const handleMouseOver = (index: number) => {
|
|
29
|
+
activeIndex.value = index
|
|
30
|
+
autoplay.value = 0
|
|
31
|
+
}
|
|
32
|
+
const selectedTechnology = computed(() => {
|
|
33
|
+
return props.technologies[activeIndex.value]
|
|
34
|
+
})
|
|
35
|
+
const handleMouseLeave = () => {
|
|
36
|
+
activeIndex.value = 0
|
|
37
|
+
autoplay.value = defaultAutoplayValue
|
|
38
|
+
}
|
|
39
|
+
const config = computed<Partial<CarouselConfig>>(() => ({
|
|
40
|
+
gap: 8,
|
|
41
|
+
itemsToShow: 'auto',
|
|
42
|
+
height: 220,
|
|
43
|
+
autoplay: autoplay.value,
|
|
44
|
+
snapAlign: 'center',
|
|
45
|
+
pauseAutoplayOnHover: true,
|
|
46
|
+
wrapAround: true,
|
|
47
|
+
transition: 500,
|
|
48
|
+
breakpoints: {
|
|
49
|
+
[BREAKPOINTS.md]: {
|
|
50
|
+
gap: 16,
|
|
51
|
+
},
|
|
52
|
+
[BREAKPOINTS.xl]: {
|
|
53
|
+
enabled: false,
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
}))
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<template>
|
|
60
|
+
<section :class="`page-container
|
|
61
|
+
${hideSelected ? 'pt-8' : 'flex flex-col pt-4 md:pt-14 pb-20 xl:pb-0 swipe-over'}`">
|
|
62
|
+
<div v-if="!hideSelected" class="flex flex-col items-center text-center">
|
|
63
|
+
<div class="space-y-3">
|
|
64
|
+
<div class="flex justify-center gap-6 font-bold text-xl">
|
|
65
|
+
<div class="flex flex-col xl:flex-row xl:gap-2">
|
|
66
|
+
<span class="text-body-primary">15+</span>
|
|
67
|
+
<span class="text-body-secondary-lighter">Years of experience</span>
|
|
68
|
+
</div>
|
|
69
|
+
<div class="flex flex-col xl:flex-row xl:gap-2">
|
|
70
|
+
<span class="text-body-primary">4.8</span>
|
|
71
|
+
<span class="text-body-secondary-lighter">Average rating</span>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
<TypingEffect
|
|
75
|
+
:text1="selectedTechnology.title1"
|
|
76
|
+
:text2="selectedTechnology.title2"
|
|
77
|
+
/>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
<div v-if="!hideSelected" class="self-center pt-10 pb-[4.5rem] md:pt-14 md:pb-16">
|
|
81
|
+
<GlowButton title="Get a free consultation" :href="paths.contactUs" />
|
|
82
|
+
</div>
|
|
83
|
+
<div v-else class="pt-[7.5rem] pb-20 space-y-7 text-center">
|
|
84
|
+
<h2 class="font-bold text-heading-md text-heading">
|
|
85
|
+
Looking for something specific?
|
|
86
|
+
</h2>
|
|
87
|
+
<p class="text-xl mx-auto max-w-lg text-body-secondary-lighter">
|
|
88
|
+
We offer a variety of services that might be a game-changer for your business.
|
|
89
|
+
</p>
|
|
90
|
+
</div>
|
|
91
|
+
<div class="hidden xl:block relative h-80">
|
|
92
|
+
<div
|
|
93
|
+
v-for="(item, index) in technologies"
|
|
94
|
+
:key="index"
|
|
95
|
+
@mouseover="handleMouseOver(index)"
|
|
96
|
+
@mouseleave="handleMouseLeave"
|
|
97
|
+
>
|
|
98
|
+
<TechnologyCard
|
|
99
|
+
:name="item.name"
|
|
100
|
+
:active="activeIndex === index"
|
|
101
|
+
className="w-72 h-72"
|
|
102
|
+
:index="index"
|
|
103
|
+
/>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
<Carousel
|
|
107
|
+
v-model="activeIndex"
|
|
108
|
+
v-bind="config"
|
|
109
|
+
class="-mx-4 xl:mx-0 xl:hidden"
|
|
110
|
+
>
|
|
111
|
+
<Slide v-for="(item, index) in technologies" :key="index">
|
|
112
|
+
<div class="flex items-center w-[190px] h-[220px]">
|
|
113
|
+
<TechnologyCard
|
|
114
|
+
:name="item.name"
|
|
115
|
+
:active="activeIndex === index"
|
|
116
|
+
:className="`w-full h-full ${activeIndex === index ? 'max-h-full' : 'max-h-[190px]'}`"
|
|
117
|
+
/>
|
|
118
|
+
</div>
|
|
119
|
+
</Slide>
|
|
120
|
+
</Carousel>
|
|
121
|
+
</section>
|
|
122
|
+
</template>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { Marked, type RendererObject } from "marked";
|
|
3
|
+
import LazyImage from "../common/images/LazyImage.vue"
|
|
4
|
+
import { sanitize } from "isomorphic-dompurify";
|
|
5
|
+
|
|
6
|
+
/* See ArticlePreview.vue: the fields read here, not the generated Strapi
|
|
7
|
+
* `Feature`, which a caller may still pass unchanged. */
|
|
8
|
+
type PainPoint = {
|
|
9
|
+
content: unknown
|
|
10
|
+
image: { url: string; name: string }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const props = defineProps<{
|
|
14
|
+
item: PainPoint
|
|
15
|
+
/* Injected by the site; omit it when the URLs are already absolute. */
|
|
16
|
+
resolveImage?: (url: string) => string
|
|
17
|
+
}>()
|
|
18
|
+
|
|
19
|
+
const imageUrl = (url: string) => props.resolveImage?.(url) ?? url
|
|
20
|
+
|
|
21
|
+
const marked = new Marked()
|
|
22
|
+
const renderer: RendererObject = {
|
|
23
|
+
paragraph({ tokens }) {
|
|
24
|
+
return `<p class="text-lg">${this.parser.parseInline(tokens)}</p>`;
|
|
25
|
+
},
|
|
26
|
+
list(token) {
|
|
27
|
+
const body = token.items.map(item => `<li>${this.parser.parse(item.tokens)}</li>`).join('');
|
|
28
|
+
|
|
29
|
+
const type = token.ordered ? 'ol' : 'ul';
|
|
30
|
+
return `<${type} class="pt-2 pl-5 list-disc">${body}</${type}>`;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
marked.use({ renderer })
|
|
34
|
+
const contentString = (typeof props.item.content === 'string' ? props.item.content : '') || ''
|
|
35
|
+
const htmlContent = sanitize(marked.parse(contentString, { async: false }))
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<template>
|
|
39
|
+
<div class="mx-1 md:mx-0 p-6 w-56 h-full md:h-auto rounded-3xl bg-surface-secondary space-y-6">
|
|
40
|
+
<LazyImage
|
|
41
|
+
:src="imageUrl(item.image.url)"
|
|
42
|
+
:alt="item.image.name"
|
|
43
|
+
class="w-8 h-8"
|
|
44
|
+
/>
|
|
45
|
+
<div
|
|
46
|
+
v-html="htmlContent"
|
|
47
|
+
class="text-body-secondary-lighter"
|
|
48
|
+
/>
|
|
49
|
+
</div>
|
|
50
|
+
</template>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const formattedDate = (locale: string | null, date: Date | null) => {
|
|
2
|
+
if (!locale || !date) return '';
|
|
3
|
+
|
|
4
|
+
const newDate = new Date(date)
|
|
5
|
+
return new Intl.DateTimeFormat(locale, {
|
|
6
|
+
year: 'numeric',
|
|
7
|
+
month: 'long',
|
|
8
|
+
day: 'numeric'
|
|
9
|
+
}).format(newDate)
|
|
10
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@codecavepro/brand",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "CODECAVE design system — colour, typography and layout tokens as CSS custom properties and as a typed module.",
|
|
5
5
|
"license": "Unlicense",
|
|
6
6
|
"type": "module",
|
|
@@ -39,11 +39,23 @@
|
|
|
39
39
|
],
|
|
40
40
|
"peerDependencies": {
|
|
41
41
|
"gsap": "^3.13.0",
|
|
42
|
-
"
|
|
42
|
+
"isomorphic-dompurify": "^3.22.0",
|
|
43
|
+
"marked": "^18.0.5",
|
|
44
|
+
"vue": "^3.5.22",
|
|
45
|
+
"vue3-carousel": "^0.17.0"
|
|
43
46
|
},
|
|
44
47
|
"peerDependenciesMeta": {
|
|
45
48
|
"gsap": {
|
|
46
49
|
"optional": true
|
|
50
|
+
},
|
|
51
|
+
"isomorphic-dompurify": {
|
|
52
|
+
"optional": true
|
|
53
|
+
},
|
|
54
|
+
"marked": {
|
|
55
|
+
"optional": true
|
|
56
|
+
},
|
|
57
|
+
"vue3-carousel": {
|
|
58
|
+
"optional": true
|
|
47
59
|
}
|
|
48
60
|
},
|
|
49
61
|
"scripts": {
|