@codecavepro/brand 1.3.0 → 1.5.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 CHANGED
@@ -142,8 +142,8 @@ Two things to know about it:
142
142
 
143
143
  ## The components
144
144
 
145
- 15 components and 13 icons — the buttons, form controls, menu, footer and cards
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 a peer dependency. `gsap` is an optional one, wanted only by `GlowButton`,
170
- `TypingEffect` and `ContactUsForm`; leave it out and the rest are unaffected.
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
- ### What is not here, and why
198
+ ### The content-shaped four resolve their own image URLs
196
199
 
197
- Four components stay out: `ArticlePreview`, `Review`, `pain-points-item` and
198
- `technologies`. They are CMS-shaped rather than brand-shaped their props are types
199
- generated from the Strapi schema, and they build image URLs through a helper that reads
200
- a hardcoded CMS host and token. Shipping them would put the site's content layer inside
201
- a brand package. Inverting that dependency site-side is
202
- [CCWEB2-332](https://codecave.atlassian.net/browse/CCWEB2-332); they arrive when it
203
- lands.
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>
@@ -47,6 +47,6 @@ export const reviews: Link[] = [
47
47
  ]
48
48
 
49
49
  export const legal: Link[] = [
50
- {name: 'Cookie policy', href: '/cookie-policy'},
51
- {name: 'Privacy policy', href: '/privacy-policy'},
50
+ {name: 'Cookie policy', href: paths.cookiePolicy},
51
+ {name: 'Privacy policy', href: paths.privacyPolicy},
52
52
  ]
@@ -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,5 @@
1
+ export const BREAKPOINTS = {
2
+ md: 768,
3
+ lg: 1024,
4
+ xl: 1280,
5
+ }
@@ -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
+ }
@@ -1,21 +1,33 @@
1
+ /* Every value here is a COMPLETE url, trailing slash included, because
2
+ * build.format is Astro's default 'directory' — /workflow is emitted as
3
+ * workflow/index.html and only /workflow/ is the address of that file.
4
+ * Written without the slash, all 716 internal links on the site were a 308
5
+ * to the same page with one added; nothing complained because Astro's
6
+ * trailingSlash default is 'ignore'. astro.config.mjs now sets 'always', so
7
+ * dev 404s a slash-less route instead of quietly serving it.
8
+ *
9
+ * Consequence for callers: a path is a PREFIX you append to, never a
10
+ * segment you join with a slash. Write `${paths.insights}${slug}/`, not
11
+ * `${paths.insights}/${slug}` — the latter now yields //. scripts/check-links.mjs
12
+ * fails the build on either mistake. */
1
13
  const services = '/services'
2
14
 
3
15
  export const paths = {
4
16
  home: '/',
5
17
 
6
- devops: `${services}/devops`,
7
- autodesk: `${services}/autodesk`,
8
- hubspot: `${services}/hubspot`,
9
- ecommerce: `${services}/ecommerce`,
10
- automation: `${services}/automation`,
11
- arVr: `${services}/ar-vr`,
18
+ devops: `${services}/devops/`,
19
+ autodesk: `${services}/autodesk/`,
20
+ hubspot: `${services}/hubspot/`,
21
+ ecommerce: `${services}/ecommerce/`,
22
+ automation: `${services}/automation/`,
23
+ arVr: `${services}/ar-vr/`,
12
24
 
13
- workflow: '/workflow',
14
- projects: '/projects',
15
- insights: '/insights',
25
+ workflow: '/workflow/',
26
+ projects: '/projects/',
27
+ insights: '/insights/',
16
28
  contactUs: '/#contact-us',
17
- cookiePolicy: '/cookie-policy',
18
- privacyPolicy: '/privacy-policy',
29
+ cookiePolicy: '/cookie-policy/',
30
+ privacyPolicy: '/privacy-policy/',
19
31
 
20
32
  testimonials: '/#testimonials',
21
33
  strongPoints: '/workflow/#our-strong-point',
@@ -23,3 +35,30 @@ export const paths = {
23
35
  }
24
36
 
25
37
  export const websiteUrl = 'https://www.codecave.it'
38
+
39
+ /* Give an internal href the trailing slash the route table already carries.
40
+ *
41
+ * Links in CMS prose do not come from the table above — an editor writes
42
+ * them, and the privacy policy links the cookie policy as a bare
43
+ * https://www.codecave.it/cookie-policy, one 308 on a page nobody rebuilds.
44
+ * Normalising at render time is the only fix that survives the next edit.
45
+ *
46
+ * Deliberately narrow: internal only, and only where the last segment has no
47
+ * dot, so a linked .pdf keeps its own address. A #fragment or ?query is put
48
+ * back after the slash, since /workflow#x and /workflow/#x are different
49
+ * requests for the same reason. */
50
+ export const internalHref = (href: string): string => {
51
+ if (!href) return href
52
+
53
+ const rest = href.startsWith(websiteUrl)
54
+ ? href.slice(websiteUrl.length)
55
+ : href.startsWith('/') && !href.startsWith('//')
56
+ ? href
57
+ : null
58
+ if (rest === null) return href
59
+
60
+ const [path, tail = ''] = [rest.split(/[#?]/)[0], rest.slice(rest.split(/[#?]/)[0].length)]
61
+ if (!path || path.endsWith('/') || path.split('/').pop()!.includes('.')) return href
62
+
63
+ return href.slice(0, href.length - rest.length) + path + '/' + tail
64
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codecavepro/brand",
3
- "version": "1.3.0",
3
+ "version": "1.5.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
- "vue": "^3.5.22"
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": {