@amulet-laboratories/rig-nuxt 0.2.0 → 0.3.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.
Files changed (32) hide show
  1. package/package.json +11 -3
  2. package/src/module.ts +144 -2
  3. package/src/runtime/components/content/AdUnit.vue +73 -0
  4. package/src/runtime/components/content/AffiliateDisclosure.vue +11 -0
  5. package/src/runtime/components/content/ArticleHeader.vue +80 -0
  6. package/src/runtime/components/content/ContentBreadcrumbs.vue +37 -0
  7. package/src/runtime/components/content/NetworkArticles.vue +44 -0
  8. package/src/runtime/components/content/NetworkLinks.vue +71 -0
  9. package/src/runtime/components/content/NewsletterSignup.vue +46 -0
  10. package/src/runtime/components/content/PersonalizedHero.vue +33 -0
  11. package/src/runtime/components/content/ProductCardWrapper.vue +79 -0
  12. package/src/runtime/components/content/QuizEmbedWrapper.vue +69 -0
  13. package/src/runtime/components/content/QuizGatedGuide.vue +68 -0
  14. package/src/runtime/components/content/QuizPromo.vue +59 -0
  15. package/src/runtime/components/content/RelatedArticles.vue +26 -0
  16. package/src/runtime/components/content/RelatedOnSite.vue +37 -0
  17. package/src/runtime/components/content/TableOfContents.vue +66 -0
  18. package/src/runtime/components/layout/AdSlot.vue +78 -0
  19. package/src/runtime/components/layout/ContentAppFooter.vue +63 -0
  20. package/src/runtime/components/layout/CookieConsent.vue +25 -0
  21. package/src/runtime/components/layout/NetworkFooter.vue +34 -0
  22. package/src/runtime/composables/useArticleSeo.ts +33 -0
  23. package/src/runtime/composables/useProducts.ts +55 -0
  24. package/src/runtime/composables/useStructuredData.ts +110 -0
  25. package/src/runtime/plugins/fathom.client.ts +17 -0
  26. package/src/runtime/plugins/sentry.client.ts +17 -0
  27. package/src/runtime/server/api/newsletter/subscribe.post.ts +35 -0
  28. package/src/runtime/server/api/products/[slug].get.ts +38 -0
  29. package/src/runtime/server/api/products/index.get.ts +36 -0
  30. package/src/runtime/server/routes/feed.xml.ts +45 -0
  31. package/src/runtime/server/routes/sitemap.xml.ts +121 -0
  32. package/src/runtime/server/utils/injectAffiliateTag.ts +17 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amulet-laboratories/rig-nuxt",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Nuxt module for @amulet-laboratories/rig — auto-imports, tree-shaking, SSR-safe",
6
6
  "license": "MIT",
@@ -15,12 +15,20 @@
15
15
  "src"
16
16
  ],
17
17
  "peerDependencies": {
18
- "@amulet-laboratories/rig": ">=0.2.0",
19
- "nuxt": "^3.0.0"
18
+ "@amulet-laboratories/rig": ">=0.4.0",
19
+ "nuxt": "^3.0.0",
20
+ "@nuxt/content": "^3.0.0",
21
+ "@sentry/vue": "^9.0.0"
20
22
  },
21
23
  "peerDependenciesMeta": {
22
24
  "@amulet-laboratories/hex": {
23
25
  "optional": true
26
+ },
27
+ "@nuxt/content": {
28
+ "optional": true
29
+ },
30
+ "@sentry/vue": {
31
+ "optional": true
24
32
  }
25
33
  },
26
34
  "devDependencies": {
package/src/module.ts CHANGED
@@ -1,4 +1,11 @@
1
- import { defineNuxtModule, addImports, addComponent } from '@nuxt/kit'
1
+ import {
2
+ defineNuxtModule,
3
+ addImports,
4
+ addComponent,
5
+ addPlugin,
6
+ addServerScanDir,
7
+ createResolver,
8
+ } from '@nuxt/kit'
2
9
 
3
10
  /**
4
11
  * @amulet-laboratories/rig-nuxt
@@ -12,13 +19,31 @@ import { defineNuxtModule, addImports, addComponent } from '@nuxt/kit'
12
19
  * Options:
13
20
  * prefix: string — component prefix (default: '' — no prefix)
14
21
  * composables: boolean — auto-import composables (default: true)
22
+ * content: boolean | ContentOptions — enable authority-site content features
15
23
  */
16
24
 
25
+ export interface ContentOptions {
26
+ /** Enable Fathom analytics plugin (default: true) */
27
+ fathom?: boolean
28
+ /** Enable Sentry error tracking plugin (default: true) */
29
+ sentry?: boolean
30
+ /** Enable product API routes (default: true) */
31
+ products?: boolean
32
+ /** Enable newsletter API route (default: true) */
33
+ newsletter?: boolean
34
+ /** Enable RSS feed route (default: true) */
35
+ feed?: boolean
36
+ /** Enable sitemap route (default: true) */
37
+ sitemap?: boolean
38
+ }
39
+
17
40
  export interface NuxtRigOptions {
18
41
  /** Prefix for component names (e.g. 'Rig' → RigButton, RigModal) */
19
42
  prefix?: string
20
43
  /** Whether to auto-import composables (default: true) */
21
44
  composables?: boolean
45
+ /** Enable authority-site content features (components, composables, plugins, server routes) */
46
+ content?: boolean | ContentOptions
22
47
  }
23
48
 
24
49
  const COMPONENTS = [
@@ -145,6 +170,7 @@ const COMPONENTS = [
145
170
  'TemporalHeatmap',
146
171
  'ParticleField',
147
172
  // Web
173
+ 'ReadingProgress',
148
174
  'SiteShell',
149
175
  'SiteNav',
150
176
  'Hero',
@@ -154,6 +180,17 @@ const COMPONENTS = [
154
180
  'Testimonial',
155
181
  'SiteFooter',
156
182
  'PricingCard',
183
+ 'SectionDivider',
184
+ 'FlankedHeading',
185
+ 'Ornament',
186
+ 'ContactForm',
187
+ 'NewsletterForm',
188
+ 'ServiceGrid',
189
+ 'TeamGrid',
190
+ 'MenuList',
191
+ 'FeatureList',
192
+ 'Gallery',
193
+ 'MapPlaceholder',
157
194
  ] as const
158
195
 
159
196
  const COMPOSABLES = [
@@ -188,11 +225,13 @@ export default defineNuxtModule<NuxtRigOptions>({
188
225
  defaults: {
189
226
  prefix: '',
190
227
  composables: true,
228
+ content: false,
191
229
  },
192
230
  setup(options) {
193
231
  const prefix = options.prefix ?? ''
232
+ const { resolve } = createResolver(import.meta.url)
194
233
 
195
- // Auto-import all components
234
+ // Auto-import all Rig components
196
235
  for (const name of COMPONENTS) {
197
236
  addComponent({
198
237
  name: `${prefix}${name}`,
@@ -217,7 +256,110 @@ export default defineNuxtModule<NuxtRigOptions>({
217
256
  // Web composables
218
257
  addImports({ name: 'useHashRouter', from: '@amulet-laboratories/rig' })
219
258
  addImports({ name: 'useScrollNav', from: '@amulet-laboratories/rig' })
259
+ addImports({ name: 'useDetailView', from: '@amulet-laboratories/rig' })
260
+ addImports({ name: 'useFormatDate', from: '@amulet-laboratories/rig' })
261
+ addImports({ name: 'useBreadcrumbs', from: '@amulet-laboratories/rig' })
262
+ addImports({ name: 'useFathom', from: '@amulet-laboratories/rig' })
263
+ }
264
+
265
+ // ── Content features (authority-site runtime) ──
266
+ if (!options.content) return
267
+
268
+ const contentOpts: ContentOptions = options.content === true ? {} : options.content
269
+
270
+ // Content components
271
+ const contentComponents: Array<{ name: string; filePath: string }> = [
272
+ {
273
+ name: 'ArticleHeader',
274
+ filePath: resolve('./runtime/components/content/ArticleHeader.vue'),
275
+ },
276
+ {
277
+ name: 'ContentBreadcrumbs',
278
+ filePath: resolve('./runtime/components/content/ContentBreadcrumbs.vue'),
279
+ },
280
+ {
281
+ name: 'TableOfContents',
282
+ filePath: resolve('./runtime/components/content/TableOfContents.vue'),
283
+ },
284
+ {
285
+ name: 'RelatedArticles',
286
+ filePath: resolve('./runtime/components/content/RelatedArticles.vue'),
287
+ },
288
+ {
289
+ name: 'RelatedOnSite',
290
+ filePath: resolve('./runtime/components/content/RelatedOnSite.vue'),
291
+ },
292
+ {
293
+ name: 'NetworkArticles',
294
+ filePath: resolve('./runtime/components/content/NetworkArticles.vue'),
295
+ },
296
+ {
297
+ name: 'NetworkLinks',
298
+ filePath: resolve('./runtime/components/content/NetworkLinks.vue'),
299
+ },
300
+ {
301
+ name: 'ProductCardWrapper',
302
+ filePath: resolve('./runtime/components/content/ProductCardWrapper.vue'),
303
+ },
304
+ {
305
+ name: 'AffiliateDisclosure',
306
+ filePath: resolve('./runtime/components/content/AffiliateDisclosure.vue'),
307
+ },
308
+ {
309
+ name: 'ContentNewsletterSignup',
310
+ filePath: resolve('./runtime/components/content/NewsletterSignup.vue'),
311
+ },
312
+ { name: 'QuizPromo', filePath: resolve('./runtime/components/content/QuizPromo.vue') },
313
+ {
314
+ name: 'QuizEmbedWrapper',
315
+ filePath: resolve('./runtime/components/content/QuizEmbedWrapper.vue'),
316
+ },
317
+ {
318
+ name: 'QuizGatedGuide',
319
+ filePath: resolve('./runtime/components/content/QuizGatedGuide.vue'),
320
+ },
321
+ {
322
+ name: 'PersonalizedHero',
323
+ filePath: resolve('./runtime/components/content/PersonalizedHero.vue'),
324
+ },
325
+ {
326
+ name: 'AdUnit',
327
+ filePath: resolve('./runtime/components/content/AdUnit.vue'),
328
+ },
329
+ {
330
+ name: 'ContentAppFooter',
331
+ filePath: resolve('./runtime/components/layout/ContentAppFooter.vue'),
332
+ },
333
+ { name: 'NetworkFooter', filePath: resolve('./runtime/components/layout/NetworkFooter.vue') },
334
+ { name: 'CookieConsent', filePath: resolve('./runtime/components/layout/CookieConsent.vue') },
335
+ { name: 'AdSlot', filePath: resolve('./runtime/components/layout/AdSlot.vue') },
336
+ ]
337
+
338
+ for (const comp of contentComponents) {
339
+ addComponent({
340
+ name: `${prefix}${comp.name}`,
341
+ filePath: comp.filePath,
342
+ })
220
343
  }
344
+
345
+ // Content composables
346
+ addImports({ name: 'useArticleSeo', from: resolve('./runtime/composables/useArticleSeo') })
347
+ addImports({
348
+ name: 'useStructuredData',
349
+ from: resolve('./runtime/composables/useStructuredData'),
350
+ })
351
+ addImports({ name: 'useProducts', from: resolve('./runtime/composables/useProducts') })
352
+
353
+ // Plugins
354
+ if (contentOpts.fathom !== false) {
355
+ addPlugin(resolve('./runtime/plugins/fathom.client'))
356
+ }
357
+ if (contentOpts.sentry !== false) {
358
+ addPlugin(resolve('./runtime/plugins/sentry.client'))
359
+ }
360
+
361
+ // Server routes — use addServerScanDir so Nitro transpiles TypeScript
362
+ addServerScanDir(resolve('./runtime/server'))
221
363
  },
222
364
  })
223
365
 
@@ -0,0 +1,73 @@
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 (default) */
9
+ format?: 'auto' | 'horizontal' | 'vertical'
10
+ /** Enable responsive sizing (default: true) */
11
+ responsive?: boolean
12
+ /** Layout hint for native ads */
13
+ layout?: string
14
+ }
15
+
16
+ const props = withDefaults(defineProps<Props>(), {
17
+ format: 'auto',
18
+ responsive: true,
19
+ })
20
+
21
+ const config = useRuntimeConfig()
22
+ const adClientId = computed(() => config.public.adsenseClientId as string | undefined)
23
+ const adReady = ref(false)
24
+
25
+ // Conditionally add the AdSense script to <head> when a client ID is configured
26
+ if (adClientId.value) {
27
+ useHead({
28
+ script: [
29
+ {
30
+ src: `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${adClientId.value}`,
31
+ async: true,
32
+ crossorigin: 'anonymous',
33
+ key: 'adsense',
34
+ },
35
+ ],
36
+ })
37
+ }
38
+
39
+ declare global {
40
+ interface Window {
41
+ adsbygoogle: unknown[]
42
+ }
43
+ }
44
+
45
+ onMounted(() => {
46
+ if (!adClientId.value) return
47
+ try {
48
+ ;(window.adsbygoogle = window.adsbygoogle || []).push({})
49
+ adReady.value = true
50
+ } catch {
51
+ // AdSense blocked by ad blocker or not loaded — fail silently
52
+ }
53
+ })
54
+ </script>
55
+
56
+ <template>
57
+ <aside
58
+ v-if="adClientId"
59
+ data-rig-ad-unit
60
+ aria-label="Advertisement"
61
+ :style="{ minHeight: '100px' }"
62
+ >
63
+ <ins
64
+ class="adsbygoogle"
65
+ :style="{ display: 'block', minHeight: 'inherit' }"
66
+ :data-ad-client="adClientId"
67
+ :data-ad-slot="slot"
68
+ :data-ad-format="format"
69
+ :data-ad-layout="layout || undefined"
70
+ :data-full-width-responsive="responsive ? 'true' : undefined"
71
+ />
72
+ </aside>
73
+ </template>
@@ -0,0 +1,11 @@
1
+ <script setup lang="ts"></script>
2
+
3
+ <template>
4
+ <div data-rig-affiliate-disclosure>
5
+ <p>
6
+ This article may contain affiliate links. If you purchase through these links, we may earn a
7
+ small commission at no extra cost to you.
8
+ <NuxtLink to="/affiliate-disclosure">Learn more</NuxtLink>.
9
+ </p>
10
+ </div>
11
+ </template>
@@ -0,0 +1,80 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { useAppConfig } from '#imports'
4
+ import { useFormatDate } from '@amulet-laboratories/rig'
5
+
6
+ interface Props {
7
+ title: string
8
+ description?: string
9
+ publishedAt?: string
10
+ updatedAt?: string
11
+ category?: string
12
+ timeToRead?: number
13
+ }
14
+
15
+ const props = defineProps<Props>()
16
+
17
+ const appConfig = useAppConfig()
18
+ const categories = appConfig.categories as Record<string, { label: string }> | undefined
19
+
20
+ const categoryLabel = computed(() => {
21
+ if (!props.category || !categories) return props.category
22
+ return categories[props.category]?.label ?? props.category
23
+ })
24
+
25
+ const { formatDate } = useFormatDate()
26
+
27
+ const formattedDate = computed(() => {
28
+ if (!props.publishedAt) return ''
29
+ return formatDate(props.publishedAt, 'long')
30
+ })
31
+
32
+ const formattedUpdatedDate = computed(() => {
33
+ if (!props.updatedAt) return ''
34
+ return formatDate(props.updatedAt, 'long')
35
+ })
36
+ </script>
37
+
38
+ <template>
39
+ <header data-rig-article-header>
40
+ <div data-rig-article-header-meta>
41
+ <NuxtLink v-if="category" :to="`/category/${category}`" data-rig-article-header-category>
42
+ <slot name="category-icon" :category="category" />
43
+ {{ categoryLabel }}
44
+ </NuxtLink>
45
+ <span v-if="timeToRead" data-rig-article-header-reading-time>{{ timeToRead }} min read</span>
46
+ </div>
47
+ <h1 data-rig-article-header-title>{{ title }}</h1>
48
+ <p v-if="description" data-rig-article-header-description>{{ description }}</p>
49
+ <div v-if="publishedAt || updatedAt" data-rig-article-header-byline>
50
+ <time v-if="publishedAt" :datetime="publishedAt">
51
+ {{ formattedDate }}
52
+ </time>
53
+ <span v-if="updatedAt" data-rig-article-header-updated>
54
+ Updated {{ formattedUpdatedDate }}
55
+ </span>
56
+ </div>
57
+ <div data-rig-affiliate-notice>
58
+ <svg
59
+ width="14"
60
+ height="14"
61
+ viewBox="0 0 24 24"
62
+ fill="none"
63
+ stroke="currentColor"
64
+ stroke-width="2"
65
+ stroke-linecap="round"
66
+ stroke-linejoin="round"
67
+ aria-hidden="true"
68
+ >
69
+ <circle cx="12" cy="12" r="10" />
70
+ <line x1="12" y1="16" x2="12" y2="12" />
71
+ <line x1="12" y1="8" x2="12.01" y2="8" />
72
+ </svg>
73
+ <span
74
+ >This article may contain affiliate links. If you purchase through these links, we may earn
75
+ a small commission at no extra cost to you.
76
+ <NuxtLink to="/affiliate-disclosure">Learn more</NuxtLink>.</span
77
+ >
78
+ </div>
79
+ </header>
80
+ </template>
@@ -0,0 +1,37 @@
1
+ <script setup lang="ts">
2
+ import type { ContentBreadcrumbItem } from '@amulet-laboratories/rig'
3
+
4
+ defineProps<{
5
+ items: ContentBreadcrumbItem[]
6
+ }>()
7
+ </script>
8
+
9
+ <template>
10
+ <nav aria-label="Breadcrumb" data-rig-breadcrumbs>
11
+ <ol data-rig-breadcrumbs-list>
12
+ <li v-for="(item, index) in items" :key="item.path" data-rig-breadcrumbs-item>
13
+ <NuxtLink v-if="index < items.length - 1" :to="item.path" data-rig-breadcrumbs-link>
14
+ {{ item.label }}
15
+ </NuxtLink>
16
+ <span v-else data-rig-breadcrumbs-current aria-current="page">
17
+ {{ item.label }}
18
+ </span>
19
+ <svg
20
+ v-if="index < items.length - 1"
21
+ width="12"
22
+ height="12"
23
+ viewBox="0 0 24 24"
24
+ fill="none"
25
+ stroke="currentColor"
26
+ stroke-width="2"
27
+ stroke-linecap="round"
28
+ stroke-linejoin="round"
29
+ aria-hidden="true"
30
+ data-rig-breadcrumbs-separator
31
+ >
32
+ <polyline points="9 18 15 12 9 6" />
33
+ </svg>
34
+ </li>
35
+ </ol>
36
+ </nav>
37
+ </template>
@@ -0,0 +1,44 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { useSiteConfig } from '#imports'
4
+ import { useFathom } from '@amulet-laboratories/rig'
5
+
6
+ interface NetworkArticle {
7
+ site: string
8
+ url: string
9
+ title: string
10
+ path: string
11
+ tag: string
12
+ }
13
+
14
+ const props = defineProps<{
15
+ articles: NetworkArticle[]
16
+ }>()
17
+
18
+ const { name: currentSite } = useSiteConfig()
19
+ const { trackCrossSiteNav } = useFathom()
20
+
21
+ const recommendations = computed(() =>
22
+ props.articles.filter((a) => a.site !== currentSite).slice(0, 3),
23
+ )
24
+ </script>
25
+
26
+ <template>
27
+ <section v-if="recommendations.length" data-rig-network-articles>
28
+ <h2 data-rig-network-articles-heading>More from our network</h2>
29
+ <div data-rig-network-articles-grid>
30
+ <a
31
+ v-for="article in recommendations"
32
+ :key="article.path"
33
+ :href="`${article.url}${article.path}`"
34
+ target="_blank"
35
+ rel="noopener"
36
+ data-rig-network-articles-link
37
+ @click="trackCrossSiteNav(article.site)"
38
+ >
39
+ <span data-rig-network-articles-site>{{ article.site }}</span>
40
+ <span data-rig-network-articles-title>{{ article.title }}</span>
41
+ </a>
42
+ </div>
43
+ </section>
44
+ </template>
@@ -0,0 +1,71 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { useSiteConfig } from '#imports'
4
+ import { useFathom } from '@amulet-laboratories/rig'
5
+
6
+ interface CrossSiteLink {
7
+ site: string
8
+ slug: string
9
+ title: string
10
+ }
11
+
12
+ const runtimeConfig = useRuntimeConfig()
13
+ const SITE_URLS: Record<string, string> = ((runtimeConfig.public as Record<string, unknown>)
14
+ .networkSiteUrls as Record<string, string>) ?? {
15
+ 'beanwoven.com': 'https://beanwoven.com',
16
+ 'burrowtail.com': 'https://burrowtail.com',
17
+ 'dewthread.com': 'https://dewthread.com',
18
+ 'meeplehaus.com': 'https://meeplehaus.com',
19
+ 'shelfglow.com': 'https://shelfglow.com',
20
+ 'wainloft.com': 'https://wainloft.com',
21
+ }
22
+
23
+ const SITE_LABELS: Record<string, string> = {
24
+ 'beanwoven.com': 'Beanwoven',
25
+ 'burrowtail.com': 'Burrowtail',
26
+ 'dewthread.com': 'Dewthread',
27
+ 'meeplehaus.com': 'Meeplehaus',
28
+ 'shelfglow.com': 'Shelfglow',
29
+ 'wainloft.com': 'Wainloft',
30
+ }
31
+
32
+ const props = defineProps<{
33
+ links?: CrossSiteLink[]
34
+ }>()
35
+
36
+ const { trackCrossSiteNav } = useFathom()
37
+ const { name: currentSite } = useSiteConfig()
38
+
39
+ const resolvedLinks = computed(() => {
40
+ if (!props.links?.length) return []
41
+ return props.links
42
+ .filter((link) => link.site !== currentSite)
43
+ .slice(0, 3)
44
+ .map((link) => ({
45
+ site: link.site,
46
+ label: SITE_LABELS[link.site] ?? link.site.replace('.com', ''),
47
+ url: `${SITE_URLS[link.site] ?? `https://${link.site}`}/articles/${link.slug}`,
48
+ title: link.title,
49
+ }))
50
+ })
51
+ </script>
52
+
53
+ <template>
54
+ <section v-if="resolvedLinks.length" data-rig-network-links>
55
+ <h2 data-rig-network-links-heading>From across the network</h2>
56
+ <div data-rig-network-links-grid>
57
+ <a
58
+ v-for="link in resolvedLinks"
59
+ :key="link.url"
60
+ :href="link.url"
61
+ target="_blank"
62
+ rel="noopener"
63
+ data-rig-network-links-card
64
+ @click="trackCrossSiteNav(link.site)"
65
+ >
66
+ <span data-rig-network-links-site>{{ link.label }}</span>
67
+ <span data-rig-network-links-title>{{ link.title }}</span>
68
+ </a>
69
+ </div>
70
+ </section>
71
+ </template>
@@ -0,0 +1,46 @@
1
+ <script setup lang="ts">
2
+ import { ref } from 'vue'
3
+ import { useSiteConfig } from '#imports'
4
+ import { useFathom } from '@amulet-laboratories/rig'
5
+
6
+ const props = defineProps<{
7
+ heading?: string
8
+ description?: string
9
+ }>()
10
+
11
+ const { name: siteName } = useSiteConfig()
12
+
13
+ const formRef = ref<{ onSuccess: () => void; onError: () => void } | null>(null)
14
+ const { trackNewsletterSignup } = useFathom()
15
+
16
+ async function handleSubscribe(email: string) {
17
+ try {
18
+ await $fetch('/api/newsletter/subscribe', {
19
+ method: 'POST',
20
+ body: { email },
21
+ })
22
+ trackNewsletterSignup('article')
23
+ formRef.value?.onSuccess()
24
+ } catch {
25
+ formRef.value?.onError()
26
+ }
27
+ }
28
+ </script>
29
+
30
+ <template>
31
+ <NewsletterForm
32
+ ref="formRef"
33
+ placeholder="you@example.com"
34
+ button-text="Subscribe"
35
+ success-message="Thanks for subscribing. Check your inbox to confirm."
36
+ @subscribe="handleSubscribe"
37
+ >
38
+ <h2 data-rig-newsletter-heading>{{ heading || 'Never miss a great read' }}</h2>
39
+ <p data-rig-newsletter-description>
40
+ {{
41
+ description ||
42
+ `Curated picks, honest reviews, and expert tips delivered weekly. Join readers who trust ${siteName}.`
43
+ }}
44
+ </p>
45
+ </NewsletterForm>
46
+ </template>
@@ -0,0 +1,33 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, onMounted } from 'vue'
3
+
4
+ const props = defineProps<{
5
+ quizSlug: string
6
+ siteSlug: string
7
+ }>()
8
+
9
+ const result = ref<string | null>(null)
10
+ const resultLabel = computed(
11
+ () =>
12
+ result.value
13
+ ?.replace(/^the-/, '')
14
+ .replace(/-/g, ' ')
15
+ .replace(/\b\w/g, (c) => c.toUpperCase()) ?? '',
16
+ )
17
+
18
+ onMounted(() => {
19
+ result.value = localStorage.getItem(`quizsort-result-${props.quizSlug}`)
20
+ })
21
+ </script>
22
+
23
+ <template>
24
+ <div v-if="result" data-rig-personalized-hero>
25
+ <span data-rig-persona-badge>Welcome back</span>
26
+ <p data-rig-personalized-hero-text>
27
+ Picks for <strong>{{ resultLabel }}</strong> readers
28
+ </p>
29
+ <NuxtLink :to="`/best-for/${result}`" data-rig-personalized-hero-link>
30
+ See your personalized picks &rarr;
31
+ </NuxtLink>
32
+ </div>
33
+ </template>