@amulet-laboratories/rig-nuxt 0.3.0 → 0.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/package.json +2 -2
- package/src/module.ts +37 -1
- package/src/runtime/components/content/ArticleHeader.vue +16 -0
- package/src/runtime/components/content/CategoryIcon.vue +453 -0
- package/src/runtime/components/content/ContentBestForIndexPage.vue +80 -0
- package/src/runtime/components/content/ContentBestForPersonaPage.vue +120 -0
- package/src/runtime/components/content/ContentCompareIndexPage.vue +96 -0
- package/src/runtime/components/content/ContentComparePage.vue +207 -0
- package/src/runtime/components/content/ContentHomePage.vue +221 -0
- package/src/runtime/components/content/EditorialSidebar.vue +27 -0
- package/src/runtime/components/content/NetworkArticles.vue +1 -1
- package/src/runtime/components/content/NetworkLinks.vue +1 -1
- package/src/runtime/components/content/ProductCardWrapper.vue +58 -2
- package/src/runtime/components/layout/NetworkFooter.vue +6 -1
- package/src/runtime/composables/useArticleSeo.ts +5 -1
- package/src/runtime/composables/useStructuredData.ts +92 -17
- package/src/runtime/plugins/fathom.client.ts +2 -0
- package/src/runtime/plugins/sentry.client.ts +1 -0
- package/src/runtime/server/api/newsletter/subscribe.post.ts +1 -0
- package/src/runtime/server/routes/feed.xml.ts +2 -0
- package/src/runtime/server/routes/sitemap.xml.ts +3 -0
- package/src/runtime/server/utils/injectAffiliateTag.ts +35 -8
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import {
|
|
4
|
+
useRoute,
|
|
5
|
+
useAsyncData,
|
|
6
|
+
createError,
|
|
7
|
+
useSchemaOrg,
|
|
8
|
+
defineWebPage,
|
|
9
|
+
defineBreadcrumb,
|
|
10
|
+
} from '#imports'
|
|
11
|
+
import { useProducts } from '../../composables/useProducts'
|
|
12
|
+
import { useArticleSeo } from '../../composables/useArticleSeo'
|
|
13
|
+
|
|
14
|
+
interface PersonaConfig {
|
|
15
|
+
name: string
|
|
16
|
+
quiz: string
|
|
17
|
+
description: string
|
|
18
|
+
productSlugs: string[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const props = defineProps<{
|
|
22
|
+
personas: Record<string, PersonaConfig>
|
|
23
|
+
retakeDescription?: string
|
|
24
|
+
}>()
|
|
25
|
+
|
|
26
|
+
const route = useRoute()
|
|
27
|
+
const personaSlug = route.params.persona as string
|
|
28
|
+
|
|
29
|
+
const { getAllProducts } = useProducts()
|
|
30
|
+
const { data: allProducts } = await useAsyncData('persona-products', () => getAllProducts())
|
|
31
|
+
|
|
32
|
+
const persona = props.personas[personaSlug]
|
|
33
|
+
if (!persona) {
|
|
34
|
+
throw createError({ statusCode: 404, statusMessage: 'Persona not found' })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const products = computed(() => {
|
|
38
|
+
if (!allProducts.value) return []
|
|
39
|
+
return persona.productSlugs
|
|
40
|
+
.map((slug) => allProducts.value!.find((product: { slug: string }) => product.slug === slug))
|
|
41
|
+
.filter((product): product is NonNullable<typeof product> => !!product)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const title = `Best for ${persona.name}`
|
|
45
|
+
const description = persona.description
|
|
46
|
+
|
|
47
|
+
useArticleSeo({ title, description })
|
|
48
|
+
|
|
49
|
+
const breadcrumbs = [
|
|
50
|
+
{ label: 'Home', path: '/' },
|
|
51
|
+
{ label: 'Best For', path: '/best-for' },
|
|
52
|
+
{ label: persona.name, path: route.path },
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
useSchemaOrg([
|
|
56
|
+
defineWebPage({ name: title }),
|
|
57
|
+
defineBreadcrumb({
|
|
58
|
+
itemListElement: breadcrumbs.map((item, i) => ({
|
|
59
|
+
name: item.label,
|
|
60
|
+
item: item.path,
|
|
61
|
+
position: i + 1,
|
|
62
|
+
})),
|
|
63
|
+
}),
|
|
64
|
+
])
|
|
65
|
+
</script>
|
|
66
|
+
|
|
67
|
+
<template>
|
|
68
|
+
<div>
|
|
69
|
+
<ContentBreadcrumbs :items="breadcrumbs" />
|
|
70
|
+
<div data-rig-persona-header>
|
|
71
|
+
<span data-rig-persona-badge>Your Result</span>
|
|
72
|
+
<h1 data-rig-persona-title>Best for {{ persona.name }}</h1>
|
|
73
|
+
<p data-rig-persona-description>{{ persona.description }}</p>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<Section title="Our Picks for You" subtitle="Products matched to your style">
|
|
77
|
+
<div data-rig-article-grid>
|
|
78
|
+
<Card v-for="product in products" :key="product.slug" data-rig-product-card>
|
|
79
|
+
<template #header>
|
|
80
|
+
<div data-rig-product-card-header>
|
|
81
|
+
<div data-rig-product-card-info>
|
|
82
|
+
<span data-rig-product-card-name>{{ product.name }}</span>
|
|
83
|
+
<span data-rig-product-card-brand
|
|
84
|
+
>{{ product.brand }} · {{ product.price_range }}</span
|
|
85
|
+
>
|
|
86
|
+
</div>
|
|
87
|
+
<span data-rig-product-card-rating>{{ product.rating }}/5</span>
|
|
88
|
+
</div>
|
|
89
|
+
</template>
|
|
90
|
+
<p data-rig-product-card-oneliner>{{ product.one_liner }}</p>
|
|
91
|
+
<template v-if="product.amazon?.url" #footer>
|
|
92
|
+
<div data-rig-product-card-footer>
|
|
93
|
+
<a
|
|
94
|
+
:href="product.amazon.url"
|
|
95
|
+
target="_blank"
|
|
96
|
+
rel="nofollow noopener sponsored"
|
|
97
|
+
data-rig-product-card-link
|
|
98
|
+
:aria-label="`Check price on Amazon for ${product.name}`"
|
|
99
|
+
>
|
|
100
|
+
Check price on Amazon
|
|
101
|
+
</a>
|
|
102
|
+
</div>
|
|
103
|
+
</template>
|
|
104
|
+
</Card>
|
|
105
|
+
</div>
|
|
106
|
+
</Section>
|
|
107
|
+
|
|
108
|
+
<Section variant="alternate">
|
|
109
|
+
<QuizPromo
|
|
110
|
+
:quiz-slug="persona.quiz"
|
|
111
|
+
quiz-title="Not your result?"
|
|
112
|
+
:description="props.retakeDescription || 'Retake the quiz to find your personalized picks.'"
|
|
113
|
+
/>
|
|
114
|
+
</Section>
|
|
115
|
+
|
|
116
|
+
<CTABanner layout="centered" variant="card">
|
|
117
|
+
<ContentNewsletterSignup />
|
|
118
|
+
</CTABanner>
|
|
119
|
+
</div>
|
|
120
|
+
</template>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import {
|
|
4
|
+
useAsyncData,
|
|
5
|
+
useSeoMeta,
|
|
6
|
+
useSchemaOrg,
|
|
7
|
+
defineWebPage,
|
|
8
|
+
defineBreadcrumb,
|
|
9
|
+
navigateTo,
|
|
10
|
+
} from '#imports'
|
|
11
|
+
import { useProducts } from '../../composables/useProducts'
|
|
12
|
+
|
|
13
|
+
const { getAllProducts } = useProducts()
|
|
14
|
+
const { data: products } = await useAsyncData('all-products', () => getAllProducts())
|
|
15
|
+
|
|
16
|
+
const comparisons = computed(() => {
|
|
17
|
+
if (!products.value?.length) return []
|
|
18
|
+
const byCategory = new Map<string, typeof products.value>()
|
|
19
|
+
for (const product of products.value) {
|
|
20
|
+
const items = byCategory.get(product.category) ?? []
|
|
21
|
+
items.push(product)
|
|
22
|
+
byCategory.set(product.category, items)
|
|
23
|
+
}
|
|
24
|
+
const pairs: Array<{
|
|
25
|
+
slugA: string
|
|
26
|
+
slugB: string
|
|
27
|
+
nameA: string
|
|
28
|
+
nameB: string
|
|
29
|
+
category: string
|
|
30
|
+
}> = []
|
|
31
|
+
for (const [category, items] of byCategory) {
|
|
32
|
+
for (let first = 0; first < items.length; first++) {
|
|
33
|
+
for (let second = first + 1; second < items.length; second++) {
|
|
34
|
+
pairs.push({
|
|
35
|
+
slugA: items[first].slug,
|
|
36
|
+
slugB: items[second].slug,
|
|
37
|
+
nameA: items[first].name,
|
|
38
|
+
nameB: items[second].name,
|
|
39
|
+
category,
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return pairs.slice(0, 30)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
useSeoMeta({
|
|
48
|
+
title: 'Product Comparisons',
|
|
49
|
+
description: 'Side-by-side product comparisons with specs, pros, cons, and our recommendations.',
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
useSchemaOrg([
|
|
53
|
+
defineWebPage({ name: 'Product Comparisons' }),
|
|
54
|
+
defineBreadcrumb({
|
|
55
|
+
itemListElement: [
|
|
56
|
+
{ name: 'Home', item: '/', position: 1 },
|
|
57
|
+
{ name: 'Compare', item: '/compare', position: 2 },
|
|
58
|
+
],
|
|
59
|
+
}),
|
|
60
|
+
])
|
|
61
|
+
</script>
|
|
62
|
+
|
|
63
|
+
<template>
|
|
64
|
+
<div>
|
|
65
|
+
<ContentBreadcrumbs
|
|
66
|
+
:items="[
|
|
67
|
+
{ label: 'Home', path: '/' },
|
|
68
|
+
{ label: 'Compare', path: '/compare' },
|
|
69
|
+
]"
|
|
70
|
+
/>
|
|
71
|
+
<Section title="Product Comparisons" subtitle="Side-by-side buying decisions">
|
|
72
|
+
<div v-if="comparisons.length" data-rig-article-grid>
|
|
73
|
+
<Card
|
|
74
|
+
v-for="comparison in comparisons"
|
|
75
|
+
:key="`${comparison.slugA}-${comparison.slugB}`"
|
|
76
|
+
interactive
|
|
77
|
+
role="link"
|
|
78
|
+
:tabindex="0"
|
|
79
|
+
@click="navigateTo(`/compare/${comparison.slugA}-vs-${comparison.slugB}`)"
|
|
80
|
+
@keydown.enter="navigateTo(`/compare/${comparison.slugA}-vs-${comparison.slugB}`)"
|
|
81
|
+
>
|
|
82
|
+
<NuxtLink
|
|
83
|
+
:to="`/compare/${comparison.slugA}-vs-${comparison.slugB}`"
|
|
84
|
+
data-rig-article-card-link
|
|
85
|
+
>
|
|
86
|
+
<span data-rig-article-card-category>{{ comparison.category }}</span>
|
|
87
|
+
<span data-rig-article-card-title
|
|
88
|
+
>{{ comparison.nameA }} vs {{ comparison.nameB }}</span
|
|
89
|
+
>
|
|
90
|
+
</NuxtLink>
|
|
91
|
+
</Card>
|
|
92
|
+
</div>
|
|
93
|
+
<p v-else data-rig-empty-state>No comparisons available yet.</p>
|
|
94
|
+
</Section>
|
|
95
|
+
</div>
|
|
96
|
+
</template>
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import {
|
|
4
|
+
useRoute,
|
|
5
|
+
createError,
|
|
6
|
+
useAppConfig,
|
|
7
|
+
useAsyncData,
|
|
8
|
+
onMounted,
|
|
9
|
+
useSchemaOrg,
|
|
10
|
+
defineWebPage,
|
|
11
|
+
defineBreadcrumb,
|
|
12
|
+
} from '#imports'
|
|
13
|
+
import { useFathom } from '@amulet-laboratories/rig'
|
|
14
|
+
import { useProducts } from '../../composables/useProducts'
|
|
15
|
+
import { useArticleSeo } from '../../composables/useArticleSeo'
|
|
16
|
+
import { useStructuredData } from '../../composables/useStructuredData'
|
|
17
|
+
|
|
18
|
+
const props = defineProps<{
|
|
19
|
+
quizSlug: string
|
|
20
|
+
quizDescription: string
|
|
21
|
+
}>()
|
|
22
|
+
|
|
23
|
+
const route = useRoute()
|
|
24
|
+
const slugParam = Array.isArray(route.params.slugs)
|
|
25
|
+
? route.params.slugs.join('/')
|
|
26
|
+
: String(route.params.slugs || '')
|
|
27
|
+
const [slugA, slugB] = slugParam.split('-vs-')
|
|
28
|
+
|
|
29
|
+
if (!slugA || !slugB) {
|
|
30
|
+
throw createError({
|
|
31
|
+
statusCode: 404,
|
|
32
|
+
statusMessage: 'Comparison requires two products: /compare/product-a-vs-product-b',
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const appConfig = useAppConfig()
|
|
37
|
+
const networkArticles = appConfig.networkArticles as {
|
|
38
|
+
site: string
|
|
39
|
+
url: string
|
|
40
|
+
title: string
|
|
41
|
+
path: string
|
|
42
|
+
tag: string
|
|
43
|
+
}[]
|
|
44
|
+
|
|
45
|
+
const { getProduct } = useProducts()
|
|
46
|
+
const { data: productA } = await useAsyncData(`compare-a-${slugA}`, () => getProduct(slugA))
|
|
47
|
+
const { data: productB } = await useAsyncData(`compare-b-${slugB}`, () => getProduct(slugB))
|
|
48
|
+
|
|
49
|
+
if (!productA.value || !productB.value) {
|
|
50
|
+
throw createError({ statusCode: 404, statusMessage: 'One or both products not found' })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const title = `${productA.value.name} vs ${productB.value.name}`
|
|
54
|
+
const description = `Side-by-side comparison of ${productA.value.name} and ${productB.value.name}. Specs, pros, cons, and our recommendation.`
|
|
55
|
+
|
|
56
|
+
useArticleSeo({ title, description })
|
|
57
|
+
useStructuredData({ title, description, schema: 'Article' })
|
|
58
|
+
|
|
59
|
+
const { trackAffiliateClick, trackComparisonView } = useFathom()
|
|
60
|
+
onMounted(() => trackComparisonView(slugA, slugB))
|
|
61
|
+
|
|
62
|
+
const winner = computed(() => {
|
|
63
|
+
if (!productA.value || !productB.value) return null
|
|
64
|
+
return productA.value.rating >= productB.value.rating ? productA.value : productB.value
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const editorial = computed(() => {
|
|
68
|
+
if (!productA.value || !productB.value) return null
|
|
69
|
+
const a = productA.value
|
|
70
|
+
const b = productB.value
|
|
71
|
+
|
|
72
|
+
const extractPrice = (range: string): number => {
|
|
73
|
+
const match = range?.match(/\d+/)
|
|
74
|
+
return match ? parseInt(match[0], 10) : 0
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const priceA = extractPrice(a.price_range)
|
|
78
|
+
const priceB = extractPrice(b.price_range)
|
|
79
|
+
|
|
80
|
+
let priceNote = ''
|
|
81
|
+
if (Math.abs(priceA - priceB) < 10) {
|
|
82
|
+
priceNote = `Both are competitively priced (${a.price_range} vs ${b.price_range})`
|
|
83
|
+
} else {
|
|
84
|
+
const cheaper = priceA < priceB ? a : b
|
|
85
|
+
priceNote = `The ${cheaper.name} is the more budget-friendly option at ${cheaper.price_range}`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const commonality =
|
|
89
|
+
a.category === b.category
|
|
90
|
+
? `The ${a.name} and ${b.name} are both ${a.category} products from ${a.brand} and ${b.brand} respectively. ${priceNote}, making this a straightforward decision based on your priorities.`
|
|
91
|
+
: `The ${a.name} (${a.category}) and ${b.name} (${b.category}) take different approaches. ${priceNote}.`
|
|
92
|
+
|
|
93
|
+
const strengthA = a.pros?.[0]?.toLowerCase() || 'solid performance'
|
|
94
|
+
const strengthB = b.pros?.[0]?.toLowerCase() || 'solid performance'
|
|
95
|
+
const weaknessA = a.cons?.[0]?.toLowerCase() || 'minor limitations'
|
|
96
|
+
const weaknessB = b.cons?.[0]?.toLowerCase() || 'minor limitations'
|
|
97
|
+
const ratingDiff = Math.abs(a.rating - b.rating)
|
|
98
|
+
const ratingNote =
|
|
99
|
+
ratingDiff < 0.3
|
|
100
|
+
? `Both are well-regarded with ratings of ${a.rating}/5 and ${b.rating}/5.`
|
|
101
|
+
: `The ${a.rating > b.rating ? a.name : b.name} has a higher overall rating (${Math.max(a.rating, b.rating)}/5 vs ${Math.min(a.rating, b.rating)}/5).`
|
|
102
|
+
|
|
103
|
+
const differentiators = `Where they differ: the ${a.name} stands out for ${strengthA}, while the ${b.name} excels at ${strengthB}. On the downside, the ${a.name}'s main concern is ${weaknessA}, and the ${b.name}'s is ${weaknessB}. ${ratingNote}`
|
|
104
|
+
|
|
105
|
+
const pickA = `Choose the ${a.name} if you prioritize ${strengthA}.`
|
|
106
|
+
const pickB = `Go with the ${b.name} if ${strengthB} matters most.`
|
|
107
|
+
const recommendation = `${pickA} ${pickB}`
|
|
108
|
+
|
|
109
|
+
return { commonality, differentiators, recommendation }
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const breadcrumbs = [
|
|
113
|
+
{ label: 'Home', path: '/' },
|
|
114
|
+
{ label: 'Compare', path: '/compare' },
|
|
115
|
+
{ label: title, path: route.path },
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
useSchemaOrg([
|
|
119
|
+
defineWebPage({ name: title }),
|
|
120
|
+
defineBreadcrumb({
|
|
121
|
+
itemListElement: breadcrumbs.map((item, i) => ({
|
|
122
|
+
name: item.label,
|
|
123
|
+
item: item.path,
|
|
124
|
+
position: i + 1,
|
|
125
|
+
})),
|
|
126
|
+
}),
|
|
127
|
+
])
|
|
128
|
+
</script>
|
|
129
|
+
|
|
130
|
+
<template>
|
|
131
|
+
<div v-if="productA && productB">
|
|
132
|
+
<ContentBreadcrumbs :items="breadcrumbs" />
|
|
133
|
+
<div data-rig-article-layout>
|
|
134
|
+
<article data-rig-article-layout-content>
|
|
135
|
+
<header data-rig-article-header>
|
|
136
|
+
<h1 data-rig-article-header-title>{{ title }}</h1>
|
|
137
|
+
<p data-rig-article-header-description>{{ description }}</p>
|
|
138
|
+
</header>
|
|
139
|
+
|
|
140
|
+
<section v-if="editorial" data-rig-prose>
|
|
141
|
+
<h2>What's the Difference?</h2>
|
|
142
|
+
<p>{{ editorial.commonality }}</p>
|
|
143
|
+
<p>{{ editorial.differentiators }}</p>
|
|
144
|
+
<p>{{ editorial.recommendation }}</p>
|
|
145
|
+
</section>
|
|
146
|
+
|
|
147
|
+
<div data-rig-compare-grid>
|
|
148
|
+
<div v-for="product in [productA, productB]" :key="product.slug" data-rig-compare-card>
|
|
149
|
+
<div data-rig-compare-card-header>
|
|
150
|
+
<h2 data-rig-compare-card-name>{{ product.name }}</h2>
|
|
151
|
+
<span data-rig-compare-card-brand>{{ product.brand }}</span>
|
|
152
|
+
<span data-rig-compare-card-price>{{ product.price_range }}</span>
|
|
153
|
+
<span data-rig-product-card-rating>{{ product.rating }}/5</span>
|
|
154
|
+
</div>
|
|
155
|
+
<p data-rig-compare-card-oneliner>{{ product.one_liner }}</p>
|
|
156
|
+
<div data-rig-product-card-details>
|
|
157
|
+
<div data-rig-product-card-pros>
|
|
158
|
+
<strong>Pros</strong>
|
|
159
|
+
<ul>
|
|
160
|
+
<li v-for="pro in product.pros" :key="pro">{{ pro }}</li>
|
|
161
|
+
</ul>
|
|
162
|
+
</div>
|
|
163
|
+
<div data-rig-product-card-cons>
|
|
164
|
+
<strong>Cons</strong>
|
|
165
|
+
<ul>
|
|
166
|
+
<li v-for="con in product.cons" :key="con">{{ con }}</li>
|
|
167
|
+
</ul>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
<div v-if="product.amazon?.url" data-rig-compare-card-cta>
|
|
171
|
+
<a
|
|
172
|
+
:href="product.amazon.url"
|
|
173
|
+
target="_blank"
|
|
174
|
+
rel="nofollow noopener sponsored"
|
|
175
|
+
data-rig-product-card-link
|
|
176
|
+
:aria-label="`Check price on Amazon for ${product.name}`"
|
|
177
|
+
@click="trackAffiliateClick(product.slug)"
|
|
178
|
+
>
|
|
179
|
+
Check price on Amazon
|
|
180
|
+
</a>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<div v-if="winner" data-rig-compare-verdict>
|
|
186
|
+
<h2>Our Pick: {{ winner.name }}</h2>
|
|
187
|
+
<p>
|
|
188
|
+
With a {{ winner.rating }}/5 rating, the {{ winner.name }} edges ahead.
|
|
189
|
+
{{ winner.one_liner }}
|
|
190
|
+
</p>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<AffiliateDisclosure />
|
|
194
|
+
<NetworkArticles :articles="networkArticles" />
|
|
195
|
+
</article>
|
|
196
|
+
<aside data-rig-article-layout-sidebar>
|
|
197
|
+
<div data-rig-article-layout-sidebar-sticky>
|
|
198
|
+
<QuizPromo
|
|
199
|
+
:quiz-slug="props.quizSlug"
|
|
200
|
+
quiz-title="Not sure which to pick?"
|
|
201
|
+
:description="props.quizDescription"
|
|
202
|
+
/>
|
|
203
|
+
</div>
|
|
204
|
+
</aside>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
</template>
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import {
|
|
4
|
+
defineWebPage,
|
|
5
|
+
defineWebSite,
|
|
6
|
+
navigateTo,
|
|
7
|
+
queryCollection,
|
|
8
|
+
useAppConfig,
|
|
9
|
+
useAsyncData,
|
|
10
|
+
useFormatDate,
|
|
11
|
+
useSchemaOrg,
|
|
12
|
+
useSeoMeta,
|
|
13
|
+
useSiteConfig,
|
|
14
|
+
} from '#imports'
|
|
15
|
+
|
|
16
|
+
const { data: featuredArticles } = await useAsyncData('featured', () =>
|
|
17
|
+
queryCollection('articles')
|
|
18
|
+
.where('pillar', '=', true)
|
|
19
|
+
.order('publishedAt', 'DESC')
|
|
20
|
+
.limit(3)
|
|
21
|
+
.all(),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
const { data: latestArticles } = await useAsyncData('latest', () =>
|
|
25
|
+
queryCollection('articles').order('publishedAt', 'DESC').limit(6).all(),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
const { data: moreArticles } = await useAsyncData('more', () =>
|
|
29
|
+
queryCollection('articles').order('publishedAt', 'DESC').limit(9).all(),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
const appConfig = useAppConfig()
|
|
33
|
+
const categories = appConfig.categories as Record<string, { label: string; description: string }>
|
|
34
|
+
const homepage = appConfig.homepage as {
|
|
35
|
+
eyebrow: string
|
|
36
|
+
headline: string
|
|
37
|
+
description: string
|
|
38
|
+
cta: string
|
|
39
|
+
seo: { title: string; description: string }
|
|
40
|
+
quiz: { slug: string; title: string; description: string }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const categoryImages = computed(() => {
|
|
44
|
+
const images: Record<string, string> = {}
|
|
45
|
+
for (const slug of Object.keys(categories)) {
|
|
46
|
+
images[slug] = `/images/categories/${slug}.svg`
|
|
47
|
+
}
|
|
48
|
+
return images
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const { formatDate } = useFormatDate()
|
|
52
|
+
const siteConfig = useSiteConfig()
|
|
53
|
+
|
|
54
|
+
useSeoMeta({
|
|
55
|
+
title: homepage.seo.title,
|
|
56
|
+
ogTitle: homepage.seo.title,
|
|
57
|
+
description: homepage.seo.description,
|
|
58
|
+
ogDescription: homepage.seo.description,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
useSchemaOrg([
|
|
62
|
+
defineWebSite({ name: siteConfig.name }),
|
|
63
|
+
defineWebPage({ name: homepage.seo.title }),
|
|
64
|
+
])
|
|
65
|
+
</script>
|
|
66
|
+
|
|
67
|
+
<template>
|
|
68
|
+
<div>
|
|
69
|
+
<Hero layout="centered">
|
|
70
|
+
<template #eyebrow>{{ homepage.eyebrow }}</template>
|
|
71
|
+
<template #title>
|
|
72
|
+
<h1>{{ homepage.headline }}</h1>
|
|
73
|
+
</template>
|
|
74
|
+
<template #description>
|
|
75
|
+
<p>{{ homepage.description }}</p>
|
|
76
|
+
</template>
|
|
77
|
+
<template #actions>
|
|
78
|
+
<Button variant="primary" @click="navigateTo('/category/' + Object.keys(categories)[0])">
|
|
79
|
+
{{ homepage.cta }}
|
|
80
|
+
</Button>
|
|
81
|
+
</template>
|
|
82
|
+
</Hero>
|
|
83
|
+
|
|
84
|
+
<Section v-if="featuredArticles?.length" title="Featured" subtitle="Editor's picks">
|
|
85
|
+
<div data-rig-magazine-grid>
|
|
86
|
+
<Card
|
|
87
|
+
v-for="article in featuredArticles"
|
|
88
|
+
:key="article.path"
|
|
89
|
+
interactive
|
|
90
|
+
role="link"
|
|
91
|
+
:tabindex="0"
|
|
92
|
+
:data-category="article.category"
|
|
93
|
+
@click="navigateTo(article.path)"
|
|
94
|
+
@keydown.enter="navigateTo(article.path)"
|
|
95
|
+
>
|
|
96
|
+
<div data-rig-card-visual>
|
|
97
|
+
<CategoryIcon :category="article.category ?? ''" :size="40" />
|
|
98
|
+
</div>
|
|
99
|
+
<NuxtLink :to="article.path" data-rig-featured-card-link>
|
|
100
|
+
<span data-rig-featured-card-category>
|
|
101
|
+
<CategoryIcon :category="article.category ?? ''" :size="14" />
|
|
102
|
+
{{ article.category }}
|
|
103
|
+
</span>
|
|
104
|
+
<span data-rig-featured-card-title>{{ article.title }}</span>
|
|
105
|
+
<span data-rig-featured-card-description>{{ article.description }}</span>
|
|
106
|
+
<span v-if="article.publishedAt || article.timeToRead" data-rig-featured-card-meta>
|
|
107
|
+
<time v-if="article.publishedAt" :datetime="article.publishedAt">
|
|
108
|
+
{{ formatDate(article.publishedAt) }}
|
|
109
|
+
</time>
|
|
110
|
+
<span v-if="article.timeToRead">· {{ article.timeToRead }} min read</span>
|
|
111
|
+
</span>
|
|
112
|
+
</NuxtLink>
|
|
113
|
+
</Card>
|
|
114
|
+
</div>
|
|
115
|
+
</Section>
|
|
116
|
+
|
|
117
|
+
<Section title="Explore by Category" subtitle="Find what you need" variant="alternate">
|
|
118
|
+
<div data-rig-category-grid>
|
|
119
|
+
<Card
|
|
120
|
+
v-for="(category, slug) in categories"
|
|
121
|
+
:key="slug"
|
|
122
|
+
interactive
|
|
123
|
+
role="link"
|
|
124
|
+
:tabindex="0"
|
|
125
|
+
:data-category="slug"
|
|
126
|
+
@click="navigateTo(`/category/${slug}`)"
|
|
127
|
+
@keydown.enter="navigateTo(`/category/${slug}`)"
|
|
128
|
+
>
|
|
129
|
+
<div
|
|
130
|
+
data-rig-card-photo
|
|
131
|
+
:style="{ backgroundImage: `url(${categoryImages[String(slug)]})` }"
|
|
132
|
+
>
|
|
133
|
+
<span data-rig-card-photo-overlay>
|
|
134
|
+
<CategoryIcon :category="String(slug)" :size="28" />
|
|
135
|
+
</span>
|
|
136
|
+
</div>
|
|
137
|
+
<NuxtLink :to="`/category/${slug}`" data-rig-category-card-link>
|
|
138
|
+
<span data-rig-category-card-title>{{ category.label }}</span>
|
|
139
|
+
<span data-rig-category-card-description>{{ category.description }}</span>
|
|
140
|
+
</NuxtLink>
|
|
141
|
+
</Card>
|
|
142
|
+
</div>
|
|
143
|
+
</Section>
|
|
144
|
+
|
|
145
|
+
<Section variant="alternate">
|
|
146
|
+
<QuizPromo
|
|
147
|
+
:quiz-slug="homepage.quiz.slug"
|
|
148
|
+
:quiz-title="homepage.quiz.title"
|
|
149
|
+
:description="homepage.quiz.description"
|
|
150
|
+
/>
|
|
151
|
+
</Section>
|
|
152
|
+
|
|
153
|
+
<CTABanner layout="centered" variant="card">
|
|
154
|
+
<ContentNewsletterSignup />
|
|
155
|
+
</CTABanner>
|
|
156
|
+
|
|
157
|
+
<Section v-if="latestArticles?.length" title="Latest Articles" subtitle="Recently published">
|
|
158
|
+
<div data-rig-article-grid>
|
|
159
|
+
<Card
|
|
160
|
+
v-for="article in latestArticles"
|
|
161
|
+
:key="article.path"
|
|
162
|
+
interactive
|
|
163
|
+
role="link"
|
|
164
|
+
:tabindex="0"
|
|
165
|
+
:data-category="article.category"
|
|
166
|
+
@click="navigateTo(article.path)"
|
|
167
|
+
@keydown.enter="navigateTo(article.path)"
|
|
168
|
+
>
|
|
169
|
+
<div data-rig-card-visual>
|
|
170
|
+
<CategoryIcon :category="article.category ?? ''" :size="32" />
|
|
171
|
+
</div>
|
|
172
|
+
<NuxtLink :to="article.path" data-rig-article-card-link>
|
|
173
|
+
<span data-rig-article-card-category>{{ article.category }}</span>
|
|
174
|
+
<span data-rig-article-card-title>{{ article.title }}</span>
|
|
175
|
+
<span data-rig-article-card-description>{{ article.description }}</span>
|
|
176
|
+
<span data-rig-article-card-meta>
|
|
177
|
+
<time v-if="article.publishedAt" :datetime="article.publishedAt">{{
|
|
178
|
+
formatDate(article.publishedAt)
|
|
179
|
+
}}</time>
|
|
180
|
+
<span v-if="article.timeToRead">· {{ article.timeToRead }} min</span>
|
|
181
|
+
</span>
|
|
182
|
+
</NuxtLink>
|
|
183
|
+
</Card>
|
|
184
|
+
</div>
|
|
185
|
+
</Section>
|
|
186
|
+
|
|
187
|
+
<Section
|
|
188
|
+
v-if="moreArticles?.length && moreArticles.length > 6"
|
|
189
|
+
title="More to Read"
|
|
190
|
+
variant="alternate"
|
|
191
|
+
>
|
|
192
|
+
<div data-rig-article-grid>
|
|
193
|
+
<Card
|
|
194
|
+
v-for="article in moreArticles.slice(6)"
|
|
195
|
+
:key="article.path"
|
|
196
|
+
interactive
|
|
197
|
+
role="link"
|
|
198
|
+
:tabindex="0"
|
|
199
|
+
:data-category="article.category"
|
|
200
|
+
@click="navigateTo(article.path)"
|
|
201
|
+
@keydown.enter="navigateTo(article.path)"
|
|
202
|
+
>
|
|
203
|
+
<div data-rig-card-visual>
|
|
204
|
+
<CategoryIcon :category="article.category ?? ''" :size="32" />
|
|
205
|
+
</div>
|
|
206
|
+
<NuxtLink :to="article.path" data-rig-article-card-link>
|
|
207
|
+
<span data-rig-article-card-category>{{ article.category }}</span>
|
|
208
|
+
<span data-rig-article-card-title>{{ article.title }}</span>
|
|
209
|
+
<span data-rig-article-card-description>{{ article.description }}</span>
|
|
210
|
+
<span data-rig-article-card-meta>
|
|
211
|
+
<time v-if="article.publishedAt" :datetime="article.publishedAt">{{
|
|
212
|
+
formatDate(article.publishedAt)
|
|
213
|
+
}}</time>
|
|
214
|
+
<span v-if="article.timeToRead">· {{ article.timeToRead }} min</span>
|
|
215
|
+
</span>
|
|
216
|
+
</NuxtLink>
|
|
217
|
+
</Card>
|
|
218
|
+
</div>
|
|
219
|
+
</Section>
|
|
220
|
+
</div>
|
|
221
|
+
</template>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
interface Props {
|
|
3
|
+
author: string
|
|
4
|
+
role?: string
|
|
5
|
+
blurb?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
defineProps<Props>()
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<template>
|
|
12
|
+
<div data-rig-editorial-sidebar>
|
|
13
|
+
<div data-rig-editorial-sidebar-header>
|
|
14
|
+
<slot name="avatar">
|
|
15
|
+
<div data-rig-editorial-sidebar-avatar aria-hidden="true">
|
|
16
|
+
{{ author.charAt(0) }}
|
|
17
|
+
</div>
|
|
18
|
+
</slot>
|
|
19
|
+
<div data-rig-editorial-sidebar-identity>
|
|
20
|
+
<span data-rig-editorial-sidebar-name>{{ author }}</span>
|
|
21
|
+
<span v-if="role" data-rig-editorial-sidebar-role>{{ role }}</span>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
<p v-if="blurb" data-rig-editorial-sidebar-blurb>{{ blurb }}</p>
|
|
25
|
+
<slot />
|
|
26
|
+
</div>
|
|
27
|
+
</template>
|