@amulet-laboratories/rig-nuxt 0.2.0 → 0.3.1
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/LICENSE +21 -0
- package/package.json +12 -4
- package/src/module.ts +148 -2
- package/src/runtime/components/content/AdUnit.vue +73 -0
- package/src/runtime/components/content/AffiliateDisclosure.vue +11 -0
- package/src/runtime/components/content/ArticleHeader.vue +80 -0
- package/src/runtime/components/content/ContentBreadcrumbs.vue +37 -0
- package/src/runtime/components/content/EditorialSidebar.vue +27 -0
- package/src/runtime/components/content/NetworkArticles.vue +44 -0
- package/src/runtime/components/content/NetworkLinks.vue +71 -0
- package/src/runtime/components/content/NewsletterSignup.vue +46 -0
- package/src/runtime/components/content/PersonalizedHero.vue +33 -0
- package/src/runtime/components/content/ProductCardWrapper.vue +79 -0
- package/src/runtime/components/content/QuizEmbedWrapper.vue +69 -0
- package/src/runtime/components/content/QuizGatedGuide.vue +68 -0
- package/src/runtime/components/content/QuizPromo.vue +59 -0
- package/src/runtime/components/content/RelatedArticles.vue +26 -0
- package/src/runtime/components/content/RelatedOnSite.vue +37 -0
- package/src/runtime/components/content/TableOfContents.vue +66 -0
- package/src/runtime/components/layout/AdSlot.vue +78 -0
- package/src/runtime/components/layout/ContentAppFooter.vue +63 -0
- package/src/runtime/components/layout/CookieConsent.vue +25 -0
- package/src/runtime/components/layout/NetworkFooter.vue +34 -0
- package/src/runtime/composables/useArticleSeo.ts +33 -0
- package/src/runtime/composables/useProducts.ts +55 -0
- package/src/runtime/composables/useStructuredData.ts +110 -0
- package/src/runtime/plugins/fathom.client.ts +17 -0
- package/src/runtime/plugins/sentry.client.ts +17 -0
- package/src/runtime/server/api/newsletter/subscribe.post.ts +35 -0
- package/src/runtime/server/api/products/[slug].get.ts +38 -0
- package/src/runtime/server/api/products/index.get.ts +36 -0
- package/src/runtime/server/routes/feed.xml.ts +45 -0
- package/src/runtime/server/routes/sitemap.xml.ts +121 -0
- package/src/runtime/server/utils/injectAffiliateTag.ts +17 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useAsyncData } from '#imports'
|
|
3
|
+
import { useFathom } from '@amulet-laboratories/rig'
|
|
4
|
+
import { useProducts } from '../../composables/useProducts'
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
slug: string
|
|
8
|
+
}>()
|
|
9
|
+
|
|
10
|
+
const { getProduct } = useProducts()
|
|
11
|
+
const { data: product, status } = await useAsyncData(`product-${props.slug}`, () =>
|
|
12
|
+
getProduct(props.slug),
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
const { trackAffiliateClick } = useFathom()
|
|
16
|
+
|
|
17
|
+
function onAffiliateClick() {
|
|
18
|
+
trackAffiliateClick(props.slug)
|
|
19
|
+
}
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<template>
|
|
23
|
+
<div v-if="status === 'pending'" data-rig-product-card>
|
|
24
|
+
<div data-rig-product-card-header>
|
|
25
|
+
<div data-rig-product-card-info>
|
|
26
|
+
<span data-rig-product-card-name data-rig-skeleton> </span>
|
|
27
|
+
<span data-rig-product-card-brand data-rig-skeleton> </span>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
<p data-rig-product-card-oneliner data-rig-skeleton> </p>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<div v-else-if="product" data-rig-product-card>
|
|
34
|
+
<div v-if="product.image_url" data-rig-product-card-image>
|
|
35
|
+
<NuxtImg
|
|
36
|
+
:src="product.image_url"
|
|
37
|
+
:alt="product.image_alt || product.name"
|
|
38
|
+
width="280"
|
|
39
|
+
height="280"
|
|
40
|
+
loading="lazy"
|
|
41
|
+
/>
|
|
42
|
+
</div>
|
|
43
|
+
<div data-rig-product-card-header>
|
|
44
|
+
<div data-rig-product-card-info>
|
|
45
|
+
<span data-rig-product-card-name>{{ product.name }}</span>
|
|
46
|
+
<span data-rig-product-card-brand
|
|
47
|
+
>{{ product.brand }} · {{ product.price_range }}</span
|
|
48
|
+
>
|
|
49
|
+
</div>
|
|
50
|
+
<span data-rig-product-card-rating>{{ product.rating }}/5</span>
|
|
51
|
+
</div>
|
|
52
|
+
<p data-rig-product-card-oneliner>{{ product.one_liner }}</p>
|
|
53
|
+
<div data-rig-product-card-details>
|
|
54
|
+
<div data-rig-product-card-pros>
|
|
55
|
+
<strong>Pros</strong>
|
|
56
|
+
<ul>
|
|
57
|
+
<li v-for="pro in product.pros" :key="pro">{{ pro }}</li>
|
|
58
|
+
</ul>
|
|
59
|
+
</div>
|
|
60
|
+
<div data-rig-product-card-cons>
|
|
61
|
+
<strong>Cons</strong>
|
|
62
|
+
<ul>
|
|
63
|
+
<li v-for="con in product.cons" :key="con">{{ con }}</li>
|
|
64
|
+
</ul>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
<div data-rig-product-card-footer>
|
|
68
|
+
<a
|
|
69
|
+
:href="product.amazon?.url"
|
|
70
|
+
target="_blank"
|
|
71
|
+
rel="nofollow noopener sponsored"
|
|
72
|
+
data-rig-product-card-link
|
|
73
|
+
@click="onAffiliateClick"
|
|
74
|
+
>
|
|
75
|
+
Check price on Amazon
|
|
76
|
+
</a>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
</template>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, onMounted, onUnmounted } from 'vue'
|
|
3
|
+
import { useRuntimeConfig } from '#imports'
|
|
4
|
+
|
|
5
|
+
const props = defineProps<{
|
|
6
|
+
quizSlug: string
|
|
7
|
+
heading?: string
|
|
8
|
+
cta?: string
|
|
9
|
+
}>()
|
|
10
|
+
|
|
11
|
+
const config = useRuntimeConfig()
|
|
12
|
+
const quizOrigin = (config.public.quizEmbedOrigin as string) || 'https://quizsort.com'
|
|
13
|
+
|
|
14
|
+
const containerRef = ref<HTMLElement>()
|
|
15
|
+
const iframeHeight = ref(400)
|
|
16
|
+
const isVisible = ref(false)
|
|
17
|
+
|
|
18
|
+
const embedUrl = `${quizOrigin}/embed/${props.quizSlug}`
|
|
19
|
+
|
|
20
|
+
const handleMessage = (event: MessageEvent) => {
|
|
21
|
+
if (event.origin !== quizOrigin) return
|
|
22
|
+
if (event.data?.type === 'quizsort:resize' && event.data?.slug === props.quizSlug) {
|
|
23
|
+
iframeHeight.value = event.data.height
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let observer: IntersectionObserver | null = null
|
|
28
|
+
|
|
29
|
+
onMounted(() => {
|
|
30
|
+
window.addEventListener('message', handleMessage)
|
|
31
|
+
|
|
32
|
+
observer = new IntersectionObserver(
|
|
33
|
+
([entry]) => {
|
|
34
|
+
if (entry.isIntersecting) {
|
|
35
|
+
isVisible.value = true
|
|
36
|
+
observer?.disconnect()
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
{ rootMargin: '200px' },
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if (containerRef.value) {
|
|
43
|
+
observer.observe(containerRef.value)
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
onUnmounted(() => {
|
|
48
|
+
window.removeEventListener('message', handleMessage)
|
|
49
|
+
observer?.disconnect()
|
|
50
|
+
})
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<template>
|
|
54
|
+
<section ref="containerRef" data-rig-quiz-embed>
|
|
55
|
+
<h3 v-if="heading" data-rig-quiz-embed-heading>{{ heading }}</h3>
|
|
56
|
+
<p v-if="cta" data-rig-quiz-embed-cta>{{ cta }}</p>
|
|
57
|
+
<iframe
|
|
58
|
+
v-if="isVisible"
|
|
59
|
+
:src="embedUrl"
|
|
60
|
+
:title="`Quiz: ${heading || quizSlug}`"
|
|
61
|
+
:style="{ height: `${iframeHeight}px` }"
|
|
62
|
+
width="100%"
|
|
63
|
+
frameborder="0"
|
|
64
|
+
loading="lazy"
|
|
65
|
+
allow="clipboard-write"
|
|
66
|
+
data-rig-quiz-embed-frame
|
|
67
|
+
/>
|
|
68
|
+
</section>
|
|
69
|
+
</template>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed, onMounted } from 'vue'
|
|
3
|
+
import { useRuntimeConfig } from '#imports'
|
|
4
|
+
import { Button } from '@amulet-laboratories/rig'
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
quizSlug: string
|
|
8
|
+
heading?: string
|
|
9
|
+
}>()
|
|
10
|
+
|
|
11
|
+
const config = useRuntimeConfig()
|
|
12
|
+
const quizOrigin = (config.public.quizEmbedOrigin as string) || 'https://quizsort.com'
|
|
13
|
+
|
|
14
|
+
const resultKey = `quizsort-result-${props.quizSlug}`
|
|
15
|
+
const result = ref<string | null>(null)
|
|
16
|
+
const showQuiz = ref(false)
|
|
17
|
+
|
|
18
|
+
onMounted(() => {
|
|
19
|
+
result.value = localStorage.getItem(resultKey)
|
|
20
|
+
|
|
21
|
+
window.addEventListener('message', (event) => {
|
|
22
|
+
if (event.origin !== quizOrigin) return
|
|
23
|
+
if (event.data?.type === 'quizsort:result' && event.data?.slug === props.quizSlug) {
|
|
24
|
+
result.value = event.data.character
|
|
25
|
+
localStorage.setItem(resultKey, event.data.character)
|
|
26
|
+
showQuiz.value = false
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const resultLabel = computed(
|
|
32
|
+
() =>
|
|
33
|
+
result.value
|
|
34
|
+
?.replace(/-/g, ' ')
|
|
35
|
+
.replace(/\bthe\b/gi, '')
|
|
36
|
+
.trim()
|
|
37
|
+
.replace(/\b\w/g, (c) => c.toUpperCase()) ?? '',
|
|
38
|
+
)
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<template>
|
|
42
|
+
<section data-rig-quiz-gated>
|
|
43
|
+
<template v-if="result">
|
|
44
|
+
<div data-rig-quiz-gated-result>
|
|
45
|
+
<span data-rig-quiz-gated-badge>Your result: {{ resultLabel }}</span>
|
|
46
|
+
<slot :result="result" />
|
|
47
|
+
</div>
|
|
48
|
+
</template>
|
|
49
|
+
<template v-else-if="showQuiz">
|
|
50
|
+
<iframe
|
|
51
|
+
:src="`${quizOrigin}/embed/${quizSlug}`"
|
|
52
|
+
width="100%"
|
|
53
|
+
height="600"
|
|
54
|
+
frameborder="0"
|
|
55
|
+
loading="lazy"
|
|
56
|
+
allow="clipboard-write"
|
|
57
|
+
data-rig-quiz-embed-frame
|
|
58
|
+
/>
|
|
59
|
+
</template>
|
|
60
|
+
<template v-else>
|
|
61
|
+
<div data-rig-quiz-gated-prompt>
|
|
62
|
+
<h3 v-if="heading">{{ heading }}</h3>
|
|
63
|
+
<p>Take a quick quiz to get recommendations tailored to your style.</p>
|
|
64
|
+
<Button variant="primary" @click="showQuiz = true"> Take the Quiz </Button>
|
|
65
|
+
</div>
|
|
66
|
+
</template>
|
|
67
|
+
</section>
|
|
68
|
+
</template>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useRuntimeConfig } from '#imports'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{
|
|
5
|
+
quizSlug: string
|
|
6
|
+
quizTitle: string
|
|
7
|
+
description: string
|
|
8
|
+
resultCount?: number
|
|
9
|
+
}>()
|
|
10
|
+
|
|
11
|
+
const config = useRuntimeConfig()
|
|
12
|
+
const quizOrigin = (config.public.quizEmbedOrigin as string) || 'https://quizsort.com'
|
|
13
|
+
const quizUrl = `${quizOrigin}/quiz/${props.quizSlug}`
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<template>
|
|
17
|
+
<a :href="quizUrl" target="_blank" rel="noopener" data-rig-quiz-promo>
|
|
18
|
+
<div data-rig-quiz-promo-icon>
|
|
19
|
+
<svg
|
|
20
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
21
|
+
width="24"
|
|
22
|
+
height="24"
|
|
23
|
+
viewBox="0 0 24 24"
|
|
24
|
+
fill="none"
|
|
25
|
+
stroke="currentColor"
|
|
26
|
+
stroke-width="1.5"
|
|
27
|
+
stroke-linecap="round"
|
|
28
|
+
stroke-linejoin="round"
|
|
29
|
+
aria-hidden="true"
|
|
30
|
+
>
|
|
31
|
+
<circle cx="12" cy="12" r="10" />
|
|
32
|
+
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
|
|
33
|
+
<line x1="12" y1="17" x2="12.01" y2="17" />
|
|
34
|
+
</svg>
|
|
35
|
+
</div>
|
|
36
|
+
<div data-rig-quiz-promo-content>
|
|
37
|
+
<span data-rig-quiz-promo-badge>Quiz</span>
|
|
38
|
+
<span data-rig-quiz-promo-title>{{ quizTitle }}</span>
|
|
39
|
+
<span data-rig-quiz-promo-description>{{ description }}</span>
|
|
40
|
+
</div>
|
|
41
|
+
<div data-rig-quiz-promo-arrow>
|
|
42
|
+
<svg
|
|
43
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
44
|
+
width="20"
|
|
45
|
+
height="20"
|
|
46
|
+
viewBox="0 0 24 24"
|
|
47
|
+
fill="none"
|
|
48
|
+
stroke="currentColor"
|
|
49
|
+
stroke-width="2"
|
|
50
|
+
stroke-linecap="round"
|
|
51
|
+
stroke-linejoin="round"
|
|
52
|
+
aria-hidden="true"
|
|
53
|
+
>
|
|
54
|
+
<line x1="5" y1="12" x2="19" y2="12" />
|
|
55
|
+
<polyline points="12 5 19 12 12 19" />
|
|
56
|
+
</svg>
|
|
57
|
+
</div>
|
|
58
|
+
</a>
|
|
59
|
+
</template>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useAsyncData, queryCollection } from '#imports'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{
|
|
5
|
+
slugs: string[]
|
|
6
|
+
}>()
|
|
7
|
+
|
|
8
|
+
const { data: articles } = await useAsyncData(`related-${props.slugs.join('-')}`, () =>
|
|
9
|
+
queryCollection('articles').where('slug', 'in', props.slugs).all(),
|
|
10
|
+
)
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<template>
|
|
14
|
+
<section v-if="articles?.length" data-rig-related-articles>
|
|
15
|
+
<h2 data-rig-related-articles-heading>Related Articles</h2>
|
|
16
|
+
<div data-rig-related-grid>
|
|
17
|
+
<div v-for="article in articles" :key="article.path" data-rig-related-card>
|
|
18
|
+
<NuxtLink :to="article.path" data-rig-related-link>
|
|
19
|
+
<span data-rig-related-category>{{ article.category }}</span>
|
|
20
|
+
<span data-rig-related-title>{{ article.title }}</span>
|
|
21
|
+
<span data-rig-related-description>{{ article.description }}</span>
|
|
22
|
+
</NuxtLink>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
</section>
|
|
26
|
+
</template>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useAsyncData, queryCollection } from '#imports'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{
|
|
5
|
+
currentPath: string
|
|
6
|
+
category?: string
|
|
7
|
+
}>()
|
|
8
|
+
|
|
9
|
+
const { data: related } = await useAsyncData(
|
|
10
|
+
`related-onsite-${props.currentPath}`,
|
|
11
|
+
async () => {
|
|
12
|
+
if (!props.category) return []
|
|
13
|
+
const articles = await queryCollection('articles')
|
|
14
|
+
.where('category', '=', props.category)
|
|
15
|
+
.order('publishedAt', 'DESC')
|
|
16
|
+
.limit(4)
|
|
17
|
+
.all()
|
|
18
|
+
return articles.filter((a) => a.path !== props.currentPath).slice(0, 3)
|
|
19
|
+
},
|
|
20
|
+
{ default: () => [] },
|
|
21
|
+
)
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<template>
|
|
25
|
+
<section v-if="related.length" data-rig-related-onsite>
|
|
26
|
+
<h2 data-rig-related-onsite-heading>More in this category</h2>
|
|
27
|
+
<div data-rig-related-grid>
|
|
28
|
+
<div v-for="article in related" :key="article.path" data-rig-related-card>
|
|
29
|
+
<NuxtLink :to="article.path" data-rig-related-link>
|
|
30
|
+
<span data-rig-related-category>{{ article.category }}</span>
|
|
31
|
+
<span data-rig-related-title>{{ article.title }}</span>
|
|
32
|
+
<span data-rig-related-description>{{ article.description }}</span>
|
|
33
|
+
</NuxtLink>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
</section>
|
|
37
|
+
</template>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
|
3
|
+
import { useRoute, useAsyncData, queryCollection } from '#imports'
|
|
4
|
+
|
|
5
|
+
interface TocLink {
|
|
6
|
+
id: string
|
|
7
|
+
text: string
|
|
8
|
+
depth: number
|
|
9
|
+
children?: TocLink[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const route = useRoute()
|
|
13
|
+
const { data: page } = await useAsyncData(`toc-${route.path}`, () =>
|
|
14
|
+
queryCollection('articles').path(route.path).first(),
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
const toc = computed<TocLink[]>(() => {
|
|
18
|
+
const body = page.value?.body as Record<string, unknown> | undefined
|
|
19
|
+
const tocData = body?.toc as { links?: TocLink[] } | undefined
|
|
20
|
+
return tocData?.links ?? []
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const activeId = ref('')
|
|
24
|
+
|
|
25
|
+
onMounted(() => {
|
|
26
|
+
const observer = new IntersectionObserver(
|
|
27
|
+
(entries) => {
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
if (entry.isIntersecting) {
|
|
30
|
+
activeId.value = entry.target.id
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
{ rootMargin: '-80px 0px -80% 0px' },
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
const headings = document.querySelectorAll('h2[id], h3[id]')
|
|
38
|
+
headings.forEach((heading) => observer.observe(heading))
|
|
39
|
+
|
|
40
|
+
onUnmounted(() => observer.disconnect())
|
|
41
|
+
})
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<template>
|
|
45
|
+
<nav v-if="toc.length" aria-label="Table of contents" data-rig-toc>
|
|
46
|
+
<h2 data-rig-toc-heading>On this page</h2>
|
|
47
|
+
<ul data-rig-toc-list>
|
|
48
|
+
<li v-for="link in toc" :key="link.id" data-rig-toc-item>
|
|
49
|
+
<a :href="`#${link.id}`" data-rig-toc-link :data-active="activeId === link.id || undefined">
|
|
50
|
+
{{ link.text }}
|
|
51
|
+
</a>
|
|
52
|
+
<ul v-if="link.children?.length" data-rig-toc-children>
|
|
53
|
+
<li v-for="child in link.children" :key="child.id" data-rig-toc-item>
|
|
54
|
+
<a
|
|
55
|
+
:href="`#${child.id}`"
|
|
56
|
+
data-rig-toc-link
|
|
57
|
+
:data-active="activeId === child.id || undefined"
|
|
58
|
+
>
|
|
59
|
+
{{ child.text }}
|
|
60
|
+
</a>
|
|
61
|
+
</li>
|
|
62
|
+
</ul>
|
|
63
|
+
</li>
|
|
64
|
+
</ul>
|
|
65
|
+
</nav>
|
|
66
|
+
</template>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, onMounted, computed } from 'vue'
|
|
3
|
+
import { useRuntimeConfig, useHead } from '#imports'
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
/** Ad unit slot ID from AdSense */
|
|
7
|
+
slot: string
|
|
8
|
+
/** Ad format — 'auto' adapts to container, 'horizontal' and 'vertical' are fixed axis */
|
|
9
|
+
format?: 'auto' | 'horizontal' | 'vertical'
|
|
10
|
+
/** Enable responsive sizing (default: true) */
|
|
11
|
+
responsive?: boolean
|
|
12
|
+
/** Layout hint for native ads */
|
|
13
|
+
layout?: string
|
|
14
|
+
/** Location label for analytics/debugging */
|
|
15
|
+
location?: 'sidebar-top' | 'sidebar-bottom' | 'in-content' | 'header' | 'footer'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
19
|
+
format: 'auto',
|
|
20
|
+
responsive: true,
|
|
21
|
+
location: 'in-content',
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const config = useRuntimeConfig()
|
|
25
|
+
const adClientId = computed(() => config.public.adsenseClientId as string | undefined)
|
|
26
|
+
const adReady = ref(false)
|
|
27
|
+
|
|
28
|
+
// Conditionally add the AdSense script to <head> when a client ID is configured
|
|
29
|
+
if (adClientId.value) {
|
|
30
|
+
useHead({
|
|
31
|
+
script: [
|
|
32
|
+
{
|
|
33
|
+
src: `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${adClientId.value}`,
|
|
34
|
+
async: true,
|
|
35
|
+
crossorigin: 'anonymous',
|
|
36
|
+
key: 'adsense',
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
declare global {
|
|
43
|
+
interface Window {
|
|
44
|
+
adsbygoogle: unknown[]
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
onMounted(() => {
|
|
49
|
+
if (!adClientId.value) return
|
|
50
|
+
try {
|
|
51
|
+
;(window.adsbygoogle = window.adsbygoogle || []).push({})
|
|
52
|
+
adReady.value = true
|
|
53
|
+
} catch {
|
|
54
|
+
// AdSense blocked by ad blocker or not loaded — fail silently
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<template>
|
|
60
|
+
<div
|
|
61
|
+
v-if="adClientId"
|
|
62
|
+
data-rig-ad-slot
|
|
63
|
+
:data-ad-location="location"
|
|
64
|
+
:style="{
|
|
65
|
+
minHeight: format === 'horizontal' ? '90px' : format === 'vertical' ? '250px' : '100px',
|
|
66
|
+
}"
|
|
67
|
+
>
|
|
68
|
+
<ins
|
|
69
|
+
class="adsbygoogle"
|
|
70
|
+
:style="{ display: 'block', minHeight: 'inherit' }"
|
|
71
|
+
:data-ad-client="adClientId"
|
|
72
|
+
:data-ad-slot="slot"
|
|
73
|
+
:data-ad-format="format"
|
|
74
|
+
:data-ad-layout="layout || undefined"
|
|
75
|
+
:data-full-width-responsive="responsive ? 'true' : undefined"
|
|
76
|
+
/>
|
|
77
|
+
</div>
|
|
78
|
+
</template>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useSiteConfig, useAppConfig } from '#imports'
|
|
3
|
+
|
|
4
|
+
defineProps<{
|
|
5
|
+
tagline?: string
|
|
6
|
+
}>()
|
|
7
|
+
|
|
8
|
+
const { name: siteName } = useSiteConfig()
|
|
9
|
+
const appConfig = useAppConfig()
|
|
10
|
+
const categories = appConfig.categories as Record<string, { label: string }>
|
|
11
|
+
const currentYear = new Date().getFullYear()
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<template>
|
|
15
|
+
<SiteFooter :columns="3">
|
|
16
|
+
<div data-rig-site-footer-column>
|
|
17
|
+
<NuxtLink to="/" data-rig-site-footer-brand>
|
|
18
|
+
{{ siteName }}
|
|
19
|
+
</NuxtLink>
|
|
20
|
+
<p v-if="tagline" data-rig-site-footer-tagline>
|
|
21
|
+
{{ tagline }}
|
|
22
|
+
</p>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<div data-rig-site-footer-column>
|
|
26
|
+
<h3 data-rig-site-footer-heading>Categories</h3>
|
|
27
|
+
<ul data-rig-site-footer-links>
|
|
28
|
+
<li v-for="(category, slug) in categories" :key="slug">
|
|
29
|
+
<NuxtLink :to="`/category/${slug}`">
|
|
30
|
+
{{ category.label }}
|
|
31
|
+
</NuxtLink>
|
|
32
|
+
</li>
|
|
33
|
+
</ul>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<div data-rig-site-footer-column>
|
|
37
|
+
<h3 data-rig-site-footer-heading>Site</h3>
|
|
38
|
+
<ul data-rig-site-footer-links>
|
|
39
|
+
<li>
|
|
40
|
+
<NuxtLink to="/about">About</NuxtLink>
|
|
41
|
+
</li>
|
|
42
|
+
<li>
|
|
43
|
+
<NuxtLink to="/privacy">Privacy Policy</NuxtLink>
|
|
44
|
+
</li>
|
|
45
|
+
<li>
|
|
46
|
+
<NuxtLink to="/terms-of-service">Terms of Service</NuxtLink>
|
|
47
|
+
</li>
|
|
48
|
+
<li>
|
|
49
|
+
<NuxtLink to="/affiliate-disclosure">Affiliate Disclosure</NuxtLink>
|
|
50
|
+
</li>
|
|
51
|
+
<li>
|
|
52
|
+
<a href="/feed.xml" target="_blank" rel="noopener">RSS Feed</a>
|
|
53
|
+
</li>
|
|
54
|
+
</ul>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<template #post-footer>
|
|
58
|
+
<p data-rig-site-footer-copyright>
|
|
59
|
+
© {{ currentYear }} {{ siteName }}. All rights reserved.
|
|
60
|
+
</p>
|
|
61
|
+
</template>
|
|
62
|
+
</SiteFooter>
|
|
63
|
+
</template>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, onMounted } from 'vue'
|
|
3
|
+
import { Button } from '@amulet-laboratories/rig'
|
|
4
|
+
|
|
5
|
+
const accepted = ref(true)
|
|
6
|
+
|
|
7
|
+
onMounted(() => {
|
|
8
|
+
accepted.value = localStorage.getItem('cookie-consent') === 'accepted'
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
function accept() {
|
|
12
|
+
localStorage.setItem('cookie-consent', 'accepted')
|
|
13
|
+
accepted.value = true
|
|
14
|
+
}
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<template>
|
|
18
|
+
<div v-if="!accepted" data-rig-cookie-consent role="alert">
|
|
19
|
+
<p data-rig-cookie-consent-text>
|
|
20
|
+
We use cookies for analytics and to improve your experience.
|
|
21
|
+
<NuxtLink to="/privacy">Privacy Policy</NuxtLink>
|
|
22
|
+
</p>
|
|
23
|
+
<Button variant="primary" @click="accept"> Accept </Button>
|
|
24
|
+
</div>
|
|
25
|
+
</template>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { useSiteConfig } from '#imports'
|
|
4
|
+
|
|
5
|
+
interface NetworkSite {
|
|
6
|
+
name: string
|
|
7
|
+
url: string
|
|
8
|
+
tagline: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const props = defineProps<{
|
|
12
|
+
sites: NetworkSite[]
|
|
13
|
+
}>()
|
|
14
|
+
|
|
15
|
+
const { name: currentSite } = useSiteConfig()
|
|
16
|
+
|
|
17
|
+
const otherSites = computed(() => props.sites.filter((s) => s.name !== currentSite))
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<nav data-rig-network-footer aria-label="Partner sites">
|
|
22
|
+
<div data-rig-network-footer-inner>
|
|
23
|
+
<p data-rig-network-footer-label>From our network</p>
|
|
24
|
+
<ul data-rig-network-footer-list>
|
|
25
|
+
<li v-for="site in otherSites" :key="site.name">
|
|
26
|
+
<a :href="site.url" target="_blank" rel="noopener" data-rig-network-footer-link>
|
|
27
|
+
<span data-rig-network-footer-name>{{ site.name }}</span>
|
|
28
|
+
<span data-rig-network-footer-tagline>{{ site.tagline }}</span>
|
|
29
|
+
</a>
|
|
30
|
+
</li>
|
|
31
|
+
</ul>
|
|
32
|
+
</div>
|
|
33
|
+
</nav>
|
|
34
|
+
</template>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface SeoMetaOptions {
|
|
2
|
+
title: string
|
|
3
|
+
description?: string
|
|
4
|
+
ogImage?: string
|
|
5
|
+
ogType?: string
|
|
6
|
+
twitterCard?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const useArticleSeo = (options: SeoMetaOptions) => {
|
|
10
|
+
const { name: siteName, url: siteUrl } = useSiteConfig()
|
|
11
|
+
const route = useRoute()
|
|
12
|
+
const canonicalUrl = `${siteUrl}${route.path}`
|
|
13
|
+
const ogImage = options.ogImage || `${siteUrl}/og-default.svg`
|
|
14
|
+
|
|
15
|
+
useSeoMeta({
|
|
16
|
+
title: options.title,
|
|
17
|
+
description: options.description,
|
|
18
|
+
ogTitle: options.title,
|
|
19
|
+
ogDescription: options.description,
|
|
20
|
+
ogType: (options.ogType || 'article') as 'article',
|
|
21
|
+
ogSiteName: siteName,
|
|
22
|
+
ogUrl: canonicalUrl,
|
|
23
|
+
ogImage,
|
|
24
|
+
twitterCard: (options.twitterCard || 'summary_large_image') as 'summary_large_image',
|
|
25
|
+
twitterTitle: options.title,
|
|
26
|
+
twitterDescription: options.description,
|
|
27
|
+
twitterImage: options.ogImage,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
useHead({
|
|
31
|
+
link: [{ rel: 'canonical', href: canonicalUrl }],
|
|
32
|
+
})
|
|
33
|
+
}
|