bsmnt 0.7.1 → 0.10.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 (121) hide show
  1. package/dist/application/add-hooks/index.d.ts.map +1 -1
  2. package/dist/application/add-hooks/index.js +1 -0
  3. package/dist/application/add-hooks/index.js.map +1 -1
  4. package/dist/application/add-integration/index.d.ts.map +1 -1
  5. package/dist/application/add-integration/index.js +20 -2
  6. package/dist/application/add-integration/index.js.map +1 -1
  7. package/dist/core/create/execute-plan.d.ts +2 -0
  8. package/dist/core/create/execute-plan.d.ts.map +1 -1
  9. package/dist/core/create/execute-plan.js +3 -0
  10. package/dist/core/create/execute-plan.js.map +1 -1
  11. package/dist/domain/version.d.ts +14 -0
  12. package/dist/domain/version.d.ts.map +1 -0
  13. package/dist/domain/version.js +81 -0
  14. package/dist/domain/version.js.map +1 -0
  15. package/dist/index.js +3 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/infrastructure/create/executor.d.ts.map +1 -1
  18. package/dist/infrastructure/create/executor.js +2 -0
  19. package/dist/infrastructure/create/executor.js.map +1 -1
  20. package/dist/infrastructure/create/render-generated-docs.d.ts +18 -0
  21. package/dist/infrastructure/create/render-generated-docs.d.ts.map +1 -0
  22. package/dist/infrastructure/create/render-generated-docs.js +72 -0
  23. package/dist/infrastructure/create/render-generated-docs.js.map +1 -0
  24. package/dist/infrastructure/create/setup-dotenvx.d.ts.map +1 -1
  25. package/dist/infrastructure/create/setup-dotenvx.js +2 -1
  26. package/dist/infrastructure/create/setup-dotenvx.js.map +1 -1
  27. package/dist/infrastructure/create/setup-remote.d.ts +6 -1
  28. package/dist/infrastructure/create/setup-remote.d.ts.map +1 -1
  29. package/dist/infrastructure/create/setup-remote.js +7 -2
  30. package/dist/infrastructure/create/setup-remote.js.map +1 -1
  31. package/dist/infrastructure/integrations/ensure-studio-headers.d.ts +6 -0
  32. package/dist/infrastructure/integrations/ensure-studio-headers.d.ts.map +1 -0
  33. package/dist/infrastructure/integrations/ensure-studio-headers.js +153 -0
  34. package/dist/infrastructure/integrations/ensure-studio-headers.js.map +1 -0
  35. package/dist/infrastructure/update/cache.d.ts +14 -0
  36. package/dist/infrastructure/update/cache.d.ts.map +1 -0
  37. package/dist/infrastructure/update/cache.js +41 -0
  38. package/dist/infrastructure/update/cache.js.map +1 -0
  39. package/dist/infrastructure/update/check-version.d.ts +2 -0
  40. package/dist/infrastructure/update/check-version.d.ts.map +1 -0
  41. package/dist/infrastructure/update/check-version.js +33 -0
  42. package/dist/infrastructure/update/check-version.js.map +1 -0
  43. package/dist/infrastructure/update/notifier.d.ts +8 -0
  44. package/dist/infrastructure/update/notifier.d.ts.map +1 -0
  45. package/dist/infrastructure/update/notifier.js +141 -0
  46. package/dist/infrastructure/update/notifier.js.map +1 -0
  47. package/dist/modules/features/cms/sanity/config.d.ts.map +1 -1
  48. package/dist/modules/features/cms/sanity/config.js +2 -1
  49. package/dist/modules/features/cms/sanity/config.js.map +1 -1
  50. package/dist/modules/features/cms/sanity-pagebuilder/config.d.ts.map +1 -1
  51. package/dist/modules/features/cms/sanity-pagebuilder/config.js +4 -1
  52. package/dist/modules/features/cms/sanity-pagebuilder/config.js.map +1 -1
  53. package/package.json +2 -2
  54. package/src/modules/features/cms/sanity/files/app/api/blog/[slug]/route.ts +4 -1
  55. package/src/modules/features/cms/sanity/files/app/sitemap.md/route.ts +2 -1
  56. package/src/modules/features/cms/sanity/files/components/ui/sanity-image/index.tsx +19 -5
  57. package/src/modules/features/cms/sanity/files/lib/integrations/sanity/README.md +31 -0
  58. package/src/modules/features/cms/sanity/files/proxy.ts +9 -0
  59. package/src/modules/features/cms/sanity-pagebuilder/files/app/api/[[...slug]]/route.ts +8 -3
  60. package/src/modules/features/cms/sanity-pagebuilder/files/app/sitemap.md/route.ts +7 -1
  61. package/src/modules/features/cms/sanity-pagebuilder/files/components/ui/sanity-image/index.tsx +15 -5
  62. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/block-actions-item.tsx +81 -0
  63. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/slug-field.tsx +13 -7
  64. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/slug-notices.tsx +135 -0
  65. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/icons.ts +2 -0
  66. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/live/index.tsx +1 -1
  67. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/markdown-proxy.config.ts +1 -1
  68. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/page-builder-config.ts +7 -0
  69. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/queries.ts +72 -21
  70. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/index.ts +1 -0
  71. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/reusable/gated-content.ts +178 -0
  72. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/index.ts +9 -1
  73. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/layout/redirect.ts +35 -4
  74. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/metadata.ts +54 -1
  75. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/page-builder.ts +4 -0
  76. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/page.ts +11 -0
  77. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/structure.ts +4 -0
  78. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/utils/page-builder-markdown.ts +20 -0
  79. package/src/modules/features/cms/sanity-pagebuilder/files/lib/utils/metadata.ts +27 -5
  80. package/src/modules/features/cms/sanity-pagebuilder/files/proxy.ts +9 -0
  81. package/src/modules/features/env/dotenvx/files/setup-remote.ts +3 -1
  82. package/src/templates/next-default/.env.example +10 -0
  83. package/src/templates/next-default/.github/workflows/ci.yml +7 -0
  84. package/src/templates/next-default/README.md +14 -0
  85. package/src/templates/next-default/app/robots.ts +2 -3
  86. package/src/templates/next-default/knip.json +8 -6
  87. package/src/templates/next-default/lefthook.yml +0 -5
  88. package/src/templates/next-default/next.config.ts +36 -2
  89. package/src/templates/next-default/package.json +9 -9
  90. package/src/templates/next-experiments/.env.example +10 -0
  91. package/src/templates/next-experiments/.github/workflows/ci.yml +7 -0
  92. package/src/templates/next-experiments/README.md +14 -0
  93. package/src/templates/next-experiments/app/robots.ts +2 -3
  94. package/src/templates/next-experiments/knip.json +8 -6
  95. package/src/templates/next-experiments/lefthook.yml +0 -5
  96. package/src/templates/next-experiments/next.config.ts +36 -2
  97. package/src/templates/next-experiments/package.json +9 -9
  98. package/src/templates/next-pagebuilder/.env.example +10 -0
  99. package/src/templates/next-pagebuilder/.github/PULL_REQUEST_TEMPLATE.md +13 -0
  100. package/src/templates/next-pagebuilder/.github/workflows/ci.yml +15 -0
  101. package/src/templates/next-pagebuilder/README.md +45 -0
  102. package/src/templates/next-pagebuilder/app/(content)/[[...slug]]/page.tsx +26 -11
  103. package/src/templates/next-pagebuilder/app/actions/unlock-gated-content.ts +39 -0
  104. package/src/templates/next-pagebuilder/app/robots.ts +2 -3
  105. package/src/templates/next-pagebuilder/app/sitemap.xml/route.ts +6 -3
  106. package/src/templates/next-pagebuilder/components/page-builder/components/content-collection/skeleton.tsx +53 -0
  107. package/src/templates/next-pagebuilder/components/page-builder/components/gated-content/index.tsx +169 -0
  108. package/src/templates/next-pagebuilder/components/page-builder/renderer.tsx +10 -1
  109. package/src/templates/next-pagebuilder/knip.json +8 -6
  110. package/src/templates/next-pagebuilder/lefthook.yml +0 -5
  111. package/src/templates/next-pagebuilder/lib/integrations/sanity/sanity.types.ts +81 -0
  112. package/src/templates/next-pagebuilder/next.config.ts +33 -2
  113. package/src/templates/next-pagebuilder/package.json +4 -4
  114. package/src/templates/next-webgl/.env.example +10 -0
  115. package/src/templates/next-webgl/.github/workflows/ci.yml +7 -0
  116. package/src/templates/next-webgl/README.md +14 -0
  117. package/src/templates/next-webgl/app/robots.ts +2 -3
  118. package/src/templates/next-webgl/knip.json +8 -6
  119. package/src/templates/next-webgl/lefthook.yml +0 -5
  120. package/src/templates/next-webgl/next.config.ts +36 -2
  121. package/src/templates/next-webgl/package.json +9 -9
@@ -14,7 +14,7 @@ import { checkRedirect } from "@/lib/utils/check-redirect"
14
14
  import { generateSanityMetadata } from "@/lib/utils/metadata"
15
15
  import { getSlugTag } from "@/lib/utils/slug-tag"
16
16
 
17
- const getPageDocument = async (slug: string | null, stega = true) =>
17
+ const getPageDocument = async (slug: string | null, stega?: boolean) =>
18
18
  fetchSanity<PAGE_QUERY_RESULT>({
19
19
  query: PAGE_QUERY,
20
20
  params: { slug },
@@ -22,12 +22,18 @@ const getPageDocument = async (slug: string | null, stega = true) =>
22
22
  stega,
23
23
  })
24
24
 
25
- const toPath = (segments?: string[]) =>
26
- segments?.filter(Boolean).join("/") || null
25
+ const isValidSegment = (segment: string) =>
26
+ segment.length > 0 && segment !== "." && segment !== ".."
27
+
28
+ const toPath = (segments?: string[]) => {
29
+ if (!segments?.length) return null
30
+ if (!segments.every(isValidSegment)) return null
31
+ return segments.join("/")
32
+ }
27
33
 
28
34
  const getResolvedPage = async (
29
35
  path: string | null,
30
- stega = true
36
+ stega?: boolean
31
37
  ): Promise<NonNullable<PAGE_QUERY_RESULT> | null> => {
32
38
  const { data: page } = await getPageDocument(path, stega)
33
39
  return (page as NonNullable<PAGE_QUERY_RESULT> | null) ?? null
@@ -35,18 +41,21 @@ const getResolvedPage = async (
35
41
 
36
42
  type SearchParams = Promise<{ [key: string]: string | string[] | undefined }>
37
43
 
44
+ const HOMEPAGE_PARAM: { slug: string[] } = { slug: [] }
45
+
38
46
  export const generateStaticParams = async () => {
39
- if (!client) return [{ slug: [] }]
47
+ if (!client) return [HOMEPAGE_PARAM]
40
48
 
41
49
  const slugs =
42
50
  await client.fetch<ALL_PAGE_SLUGS_QUERY_RESULT>(ALL_PAGE_SLUGS_QUERY)
43
51
 
44
- if (!slugs?.length) return [{ slug: [] }]
45
-
46
- return (slugs ?? []).flatMap((page) => {
47
- if (!page.slug) return []
48
- return page.slug === "/" ? { slug: [] } : { slug: page.slug.split("/") }
49
- })
52
+ return [
53
+ HOMEPAGE_PARAM,
54
+ ...(slugs ?? []).flatMap((page) => {
55
+ if (!page.slug || page.slug === "/") return []
56
+ return { slug: page.slug.split("/") }
57
+ }),
58
+ ]
50
59
  }
51
60
 
52
61
  type Props = { params: Promise<{ slug?: string[] }> }
@@ -54,6 +63,9 @@ type Props = { params: Promise<{ slug?: string[] }> }
54
63
  export const generateMetadata = async ({ params }: Props) => {
55
64
  const { slug } = await params
56
65
  const path = toPath(slug)
66
+
67
+ if (slug?.length && path === null) return {}
68
+
57
69
  const page = await getResolvedPage(path, false)
58
70
 
59
71
  return page
@@ -70,6 +82,9 @@ export default async function CatchAllPage({
70
82
  const { slug } = await params
71
83
 
72
84
  const path = toPath(slug)
85
+
86
+ if (slug?.length && path === null) notFound()
87
+
73
88
  const page = await getResolvedPage(path)
74
89
 
75
90
  if (!page) {
@@ -0,0 +1,39 @@
1
+ "use server"
2
+
3
+ import { fetchSanity } from "@/lib/integrations/sanity/live"
4
+ import { GATED_CONTENT_QUERY } from "@/lib/integrations/sanity/queries"
5
+ import type { GATED_CONTENT_QUERY_RESULT } from "@/lib/integrations/sanity/sanity.types"
6
+
7
+ export type GatedContentPayload = NonNullable<GATED_CONTENT_QUERY_RESULT>
8
+
9
+ export type UnlockResult =
10
+ | { ok: true; payload: GatedContentPayload }
11
+ | { ok: false; error: string }
12
+
13
+ type UnlockInput = { documentId: string; email: string }
14
+
15
+ export async function unlockGatedContent({
16
+ documentId,
17
+ email,
18
+ }: UnlockInput): Promise<UnlockResult> {
19
+ if (!documentId) return { ok: false, error: "Missing content id." }
20
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
21
+ return { ok: false, error: "Enter a valid email address." }
22
+ }
23
+
24
+ // Lead-capture seam: forward `email` to your CRM / email provider here.
25
+ // This is not access control — the payload is withheld from the page until
26
+ // this action runs, but anyone who submits the form receives it.
27
+
28
+ const { data } = await fetchSanity<GATED_CONTENT_QUERY_RESULT>({
29
+ query: GATED_CONTENT_QUERY,
30
+ params: { id: documentId },
31
+ // stega must stay off: it injects invisible characters into strings, which
32
+ // would corrupt the asset URLs returned for download/playback.
33
+ stega: false,
34
+ })
35
+
36
+ if (!data) return { ok: false, error: "Content not found." }
37
+
38
+ return { ok: true, payload: data }
39
+ }
@@ -1,6 +1,5 @@
1
1
  import type { MetadataRoute } from "next"
2
-
3
- const APP_BASE_URL = process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"
2
+ import { getBaseUrl } from "@/lib/utils/base-url"
4
3
 
5
4
  export default function robots(): MetadataRoute.Robots {
6
5
  return {
@@ -9,6 +8,6 @@ export default function robots(): MetadataRoute.Robots {
9
8
  allow: "/",
10
9
  disallow: [],
11
10
  },
12
- sitemap: `${APP_BASE_URL}/sitemap.xml`,
11
+ sitemap: `${getBaseUrl()}/sitemap.xml`,
13
12
  }
14
13
  }
@@ -51,8 +51,11 @@ const renderSitemap = (entries: SitemapEntry[]) =>
51
51
  "</urlset>",
52
52
  ].join("\n")
53
53
 
54
- const getSitemapResponse = (entries: SitemapEntry[]) =>
55
- new NextResponse(renderSitemap(entries))
54
+ const getSitemapResponse = (entries: SitemapEntry[], status = 200) =>
55
+ new NextResponse(renderSitemap(entries), {
56
+ status,
57
+ headers: { "Content-Type": "application/xml; charset=utf-8" },
58
+ })
56
59
 
57
60
  export const GET = async () => {
58
61
  const baseUrl = getSitemapBaseUrl()
@@ -75,6 +78,6 @@ export const GET = async () => {
75
78
  )
76
79
  } catch (error) {
77
80
  console.error("Error generating sitemap.xml:", error)
78
- return getSitemapResponse([homepageEntry])
81
+ return getSitemapResponse([homepageEntry], 503)
79
82
  }
80
83
  }
@@ -0,0 +1,53 @@
1
+ const FEATURED_PLACEHOLDERS = ["featured-1", "featured-2"]
2
+ const CARD_PLACEHOLDERS = [
3
+ "card-1",
4
+ "card-2",
5
+ "card-3",
6
+ "card-4",
7
+ "card-5",
8
+ "card-6",
9
+ ]
10
+ const CHIP_PLACEHOLDERS = ["chip-1", "chip-2", "chip-3", "chip-4"]
11
+
12
+ function CardPlaceholder() {
13
+ return (
14
+ <li className="flex flex-col gap-3">
15
+ <div className="aspect-16/9 w-full rounded-lg bg-black/10" />
16
+ <div className="flex flex-col gap-2">
17
+ <div className="h-3 w-16 rounded bg-black/10" />
18
+ <div className="h-5 w-4/5 rounded bg-black/10" />
19
+ <div className="h-4 w-1/2 rounded bg-black/10" />
20
+ </div>
21
+ </li>
22
+ )
23
+ }
24
+
25
+ export function ContentCollectionSkeleton() {
26
+ return (
27
+ <section
28
+ className="flex animate-pulse flex-col gap-8 py-8"
29
+ aria-hidden="true"
30
+ >
31
+ <div className="flex flex-col gap-4 rounded-[1.5rem] border border-black/10 bg-black/[0.02] p-4 sm:p-5">
32
+ <div className="flex flex-wrap gap-2">
33
+ {CHIP_PLACEHOLDERS.map((key) => (
34
+ <div className="h-9 w-28 rounded-full bg-black/10" key={key} />
35
+ ))}
36
+ </div>
37
+ <div className="h-4 w-40 rounded bg-black/10" />
38
+ </div>
39
+
40
+ <ul className="grid grid-cols-1 gap-8 md:grid-cols-2">
41
+ {FEATURED_PLACEHOLDERS.map((key) => (
42
+ <CardPlaceholder key={key} />
43
+ ))}
44
+ </ul>
45
+
46
+ <ul className="grid grid-cols-2 gap-8 md:grid-cols-2 lg:grid-cols-3">
47
+ {CARD_PLACEHOLDERS.map((key) => (
48
+ <CardPlaceholder key={key} />
49
+ ))}
50
+ </ul>
51
+ </section>
52
+ )
53
+ }
@@ -0,0 +1,169 @@
1
+ "use client"
2
+
3
+ import { useEffect, useState } from "react"
4
+ import {
5
+ type GatedContentPayload,
6
+ unlockGatedContent,
7
+ } from "@/app/actions/unlock-gated-content"
8
+ import type { PageBuilderBlock } from "@/components/page-builder/types"
9
+ import { RichText } from "@/components/sanity/rich-text"
10
+ import { Link } from "@/components/ui/link"
11
+
12
+ type GatedContentBlock = Extract<PageBuilderBlock, { _type: "gatedContent" }>
13
+
14
+ type GatedContentProps = {
15
+ block: GatedContentBlock
16
+ }
17
+
18
+ type GateState = "locked" | "loading" | "unlocked"
19
+
20
+ const STORAGE_PREFIX = "gated-content:"
21
+
22
+ export function GatedContent({ block }: GatedContentProps) {
23
+ const storageKey = `${STORAGE_PREFIX}${block._id}`
24
+ const [state, setState] = useState<GateState>("locked")
25
+ const [payload, setPayload] = useState<GatedContentPayload | null>(null)
26
+ const [error, setError] = useState<string | null>(null)
27
+ const [justUnlocked, setJustUnlocked] = useState(false)
28
+
29
+ // The payload is withheld from the page query, so it never reaches the client
30
+ // until this action runs. We remember the submitted email so a returning
31
+ // visitor re-fetches without seeing the form again.
32
+ const unlock = async (email: string, persist: boolean) => {
33
+ setState("loading")
34
+ setError(null)
35
+
36
+ const result = await unlockGatedContent({ documentId: block._id, email })
37
+
38
+ if (!result.ok) {
39
+ setError(result.error)
40
+ setState("locked")
41
+ if (!persist) window.localStorage.removeItem(storageKey)
42
+ return
43
+ }
44
+
45
+ setPayload(result.payload)
46
+ setState("unlocked")
47
+ if (persist) {
48
+ window.localStorage.setItem(storageKey, email)
49
+ setJustUnlocked(true)
50
+ }
51
+ }
52
+
53
+ // biome-ignore lint/correctness/useExhaustiveDependencies: re-fetch only when the block changes; unlock closes over stable values
54
+ useEffect(() => {
55
+ const savedEmail = window.localStorage.getItem(storageKey)
56
+ if (savedEmail) void unlock(savedEmail, false)
57
+ }, [storageKey])
58
+
59
+ const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
60
+ event.preventDefault()
61
+ const email =
62
+ new FormData(event.currentTarget).get("email")?.toString() ?? ""
63
+ void unlock(email, true)
64
+ }
65
+
66
+ return (
67
+ <section className="py-8 md:py-12">
68
+ {block.teaserHeading ? (
69
+ <h2 className="mb-4 font-semibold text-2xl tracking-tight md:text-4xl">
70
+ {block.teaserHeading}
71
+ </h2>
72
+ ) : null}
73
+
74
+ {block.teaserBody ? (
75
+ <div className="max-w-[62ch] space-y-4 text-base/7 text-current/80 md:text-lg/8">
76
+ <RichText content={block.teaserBody} />
77
+ </div>
78
+ ) : null}
79
+
80
+ {state === "unlocked" && payload ? (
81
+ <div className="mt-8">
82
+ {justUnlocked && block.successMessage ? (
83
+ <p className="mb-6 rounded-lg bg-black/[0.04] px-4 py-3 text-current/72 text-sm">
84
+ {block.successMessage}
85
+ </p>
86
+ ) : null}
87
+ <GatedReveal payload={payload} />
88
+ </div>
89
+ ) : (
90
+ <form
91
+ onSubmit={handleSubmit}
92
+ className="mt-8 flex max-w-md flex-col gap-4 rounded-[1.5rem] border border-black/10 bg-black/[0.02] p-6"
93
+ >
94
+ {block.gateHeadline ? (
95
+ <h3 className="font-semibold text-lg">{block.gateHeadline}</h3>
96
+ ) : null}
97
+ {block.gateDescription ? (
98
+ <p className="text-current/72 text-sm">{block.gateDescription}</p>
99
+ ) : null}
100
+ <input
101
+ type="email"
102
+ name="email"
103
+ required
104
+ placeholder="you@example.com"
105
+ className="rounded-lg border border-black/10 bg-white px-4 py-2.5 text-sm outline-none focus:border-black/40"
106
+ />
107
+ {error ? <p className="text-red-600 text-sm">{error}</p> : null}
108
+ <button
109
+ type="submit"
110
+ disabled={state === "loading"}
111
+ className="rounded-lg bg-black px-4 py-2.5 font-medium text-sm text-white transition-colors hover:bg-black/80 disabled:opacity-60"
112
+ >
113
+ {state === "loading" ? "Unlocking…" : block.submitLabel || "Unlock"}
114
+ </button>
115
+ </form>
116
+ )}
117
+ </section>
118
+ )
119
+ }
120
+
121
+ function GatedReveal({ payload }: { payload: GatedContentPayload }) {
122
+ if (payload.kind === "richText" && payload.content) {
123
+ return (
124
+ <div className="max-w-[62ch] space-y-4 text-base/7 text-current/80 md:text-lg/8">
125
+ <RichText content={payload.content} />
126
+ </div>
127
+ )
128
+ }
129
+
130
+ if (payload.kind === "file" && payload.file?.url) {
131
+ return (
132
+ <Link
133
+ href={payload.file.url}
134
+ download
135
+ className="inline-flex items-center gap-2 rounded-lg bg-black px-4 py-2.5 font-medium text-sm text-white transition-colors hover:bg-black/80"
136
+ >
137
+ Download {payload.file.originalFilename ?? "file"}
138
+ </Link>
139
+ )
140
+ }
141
+
142
+ if (payload.kind === "video") {
143
+ if (payload.video.url) {
144
+ return (
145
+ <div className="aspect-video w-full max-w-3xl overflow-hidden rounded-lg">
146
+ <iframe
147
+ src={payload.video.url}
148
+ title="Gated video"
149
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
150
+ allowFullScreen
151
+ className="h-full w-full border-0"
152
+ />
153
+ </div>
154
+ )
155
+ }
156
+ if (payload.video.file?.url) {
157
+ return (
158
+ // biome-ignore lint/a11y/useMediaCaption: editor-supplied video without a caption track
159
+ <video
160
+ controls
161
+ src={payload.video.file.url}
162
+ className="w-full max-w-3xl rounded-lg"
163
+ />
164
+ )
165
+ }
166
+ }
167
+
168
+ return null
169
+ }
@@ -1,7 +1,10 @@
1
+ import { Suspense } from "react"
1
2
  import { ArticleContent } from "./components/article-content"
2
3
  import { ContentCollection } from "./components/content-collection"
4
+ import { ContentCollectionSkeleton } from "./components/content-collection/skeleton"
3
5
  import type { ContentCollectionSearchParams } from "./components/content-collection/types"
4
6
  import { Description } from "./components/description"
7
+ import { GatedContent } from "./components/gated-content"
5
8
  import { Hero } from "./components/hero"
6
9
  import type { PageBuilderBlock, PageBuilderBlocks } from "./types"
7
10
 
@@ -35,13 +38,19 @@ function PageBuilderNode({
35
38
  }) {
36
39
  switch (block._type) {
37
40
  case "blogCollection":
38
- return <ContentCollection block={block} searchParams={searchParams} />
41
+ return (
42
+ <Suspense fallback={<ContentCollectionSkeleton />}>
43
+ <ContentCollection block={block} searchParams={searchParams} />
44
+ </Suspense>
45
+ )
39
46
  case "blogContent":
40
47
  return <ArticleContent block={block} />
41
48
  case "hero":
42
49
  return <Hero block={block} />
43
50
  case "description":
44
51
  return <Description block={block} />
52
+ case "gatedContent":
53
+ return <GatedContent block={block} />
45
54
  default:
46
55
  return null
47
56
  }
@@ -22,11 +22,13 @@
22
22
  "rxjs",
23
23
  "gsap",
24
24
  "@gsap/react",
25
- "motion"
25
+ "motion",
26
+ "detect-gpu",
27
+ "lodash-es",
28
+ "@types/lodash-es",
29
+ "react-device-detect",
30
+ "zustand"
26
31
  ],
27
- "ignoreBinaries": [
28
- "gh",
29
- "op",
30
- "vercel"
31
- ]
32
+ "ignoreBinaries": ["gh", "op", "vercel"],
33
+ "ignoreFiles": [".agents/**", ".claude/**", "hooks/**"]
32
34
  }
@@ -14,11 +14,6 @@ pre-commit:
14
14
  run: bunx tsgo --noEmit --incremental false
15
15
  stage_fixed: false
16
16
 
17
- pre-push:
18
- commands:
19
- knip:
20
- run: bun run knip
21
-
22
17
  post-merge:
23
18
  commands:
24
19
  install-deps:
@@ -50,6 +50,13 @@ export type SanityImageAssetReference = {
50
50
  [internalGroqTypeReferenceTo]?: "sanity.imageAsset"
51
51
  }
52
52
 
53
+ export type SanityFileAssetReference = {
54
+ _ref: string
55
+ _type: "reference"
56
+ _weak?: boolean
57
+ [internalGroqTypeReferenceTo]?: "sanity.fileAsset"
58
+ }
59
+
53
60
  export type StructuredData = {
54
61
  _id: string
55
62
  _type: "structuredData"
@@ -307,6 +314,33 @@ export type BlogCollection = {
307
314
  subtitle?: string
308
315
  }
309
316
 
317
+ export type GatedContent = {
318
+ _id: string
319
+ _type: "gatedContent"
320
+ _createdAt: string
321
+ _updatedAt: string
322
+ _rev: string
323
+ internalTitle?: string
324
+ teaserHeading?: string
325
+ teaserBody?: RichText
326
+ gateType?: "form"
327
+ gateHeadline?: string
328
+ gateDescription?: string
329
+ submitLabel?: string
330
+ successMessage?: string
331
+ kind?: "richText" | "file" | "video"
332
+ content?: RichText
333
+ file?: {
334
+ asset?: SanityFileAssetReference
335
+ _type: "file"
336
+ }
337
+ videoUrl?: string
338
+ videoFile?: {
339
+ asset?: SanityFileAssetReference
340
+ _type: "file"
341
+ }
342
+ }
343
+
310
344
  export type NavItem = {
311
345
  _type: "navItem"
312
346
  title?: string
@@ -475,6 +509,13 @@ export type BlogCollectionReference = {
475
509
  [internalGroqTypeReferenceTo]?: "blogCollection"
476
510
  }
477
511
 
512
+ export type GatedContentReference = {
513
+ _ref: string
514
+ _type: "reference"
515
+ _weak?: boolean
516
+ [internalGroqTypeReferenceTo]?: "gatedContent"
517
+ }
518
+
478
519
  export type PageBuilder = Array<
479
520
  | ({
480
521
  _key: string
@@ -488,6 +529,9 @@ export type PageBuilder = Array<
488
529
  | ({
489
530
  _key: string
490
531
  } & BlogCollectionReference)
532
+ | ({
533
+ _key: string
534
+ } & GatedContentReference)
491
535
  >
492
536
 
493
537
  export type Metadata = {
@@ -699,6 +743,7 @@ export type AllSanitySchemaTypes =
699
743
  | Slug
700
744
  | Redirect
701
745
  | SanityImageAssetReference
746
+ | SanityFileAssetReference
702
747
  | StructuredData
703
748
  | SanityImageCrop
704
749
  | SanityImageHotspot
@@ -715,6 +760,7 @@ export type AllSanitySchemaTypes =
715
760
  | RichText
716
761
  | Author
717
762
  | BlogCollection
763
+ | GatedContent
718
764
  | NavItem
719
765
  | NavMegaMenu
720
766
  | NavColumn
@@ -730,6 +776,7 @@ export type AllSanitySchemaTypes =
730
776
  | DescriptionReference
731
777
  | BlogContentReference
732
778
  | BlogCollectionReference
779
+ | GatedContentReference
733
780
  | PageBuilder
734
781
  | Metadata
735
782
  | ExternalLink
@@ -768,6 +815,20 @@ export type PAGE_QUERY_RESULT = {
768
815
  _rev: string
769
816
  subtitle?: string
770
817
  }
818
+ | {
819
+ _key: string
820
+ _id: string
821
+ _type: "gatedContent"
822
+ internalTitle: string | null
823
+ teaserHeading: string | null
824
+ teaserBody: RichText | null
825
+ gateType: string | null
826
+ gateHeadline: string | null
827
+ gateDescription: string | null
828
+ submitLabel: string | null
829
+ successMessage: string | null
830
+ kind: "richText" | "file" | "video" | null
831
+ }
771
832
  | {
772
833
  _key: string
773
834
  _id: string
@@ -897,6 +958,26 @@ export type PAGE_QUERY_RESULT = {
897
958
  _updatedAt: string
898
959
  } | null
899
960
 
961
+ // Source: queries.ts
962
+ // Variable: GATED_CONTENT_QUERY
963
+ export type GATED_CONTENT_QUERY_RESULT = {
964
+ kind: "richText" | "file" | "video" | null
965
+ content: RichText | null
966
+ file: {
967
+ url: string | null
968
+ originalFilename: string | null
969
+ mimeType: string | null
970
+ size: number | null
971
+ } | null
972
+ video: {
973
+ url: string | null
974
+ file: {
975
+ url: string | null
976
+ mimeType: string | null
977
+ } | null
978
+ }
979
+ } | null
980
+
900
981
  // Source: queries.ts
901
982
  // Variable: PAGE_METADATA_QUERY
902
983
  // Query: *[ _type == "page" && ( ($slug == null && !defined(pageFolder._ref) && (!defined(slug.current) || slug.current == "" || slug.current == "/")) || select( defined(pageFolder->parentFolder->parentFolder->parentFolder->parentFolder->parentFolder->slug.current) && defined(pageFolder->parentFolder->parentFolder->parentFolder->parentFolder->slug.current) && defined(pageFolder->parentFolder->parentFolder->parentFolder->slug.current) && defined(pageFolder->parentFolder->parentFolder->slug.current) && defined(pageFolder->parentFolder->slug.current) && defined(pageFolder->slug.current) && (!defined(slug.current) || slug.current == "" || slug.current == "/") => pageFolder->parentFolder->parentFolder->parentFolder->parentFolder->parentFolder->slug.current + "/" + pageFolder->parentFolder->parentFolder->parentFolder->parentFolder->slug.current + "/" + pageFolder->parentFolder->parentFolder->parentFolder->slug.current + "/" + pageFolder->parentFolder->parentFolder->slug.current + "/" + pageFolder->parentFolder->slug.current + "/" + pageFolder->slug.current, defined(pageFolder->parentFolder->parentFolder->parentFolder->parentFolder->slug.current) && defined(pageFolder->parentFolder->parentFolder->parentFolder->slug.current) && defined(pageFolder->parentFolder->parentFolder->slug.current) && defined(pageFolder->parentFolder->slug.current) && defined(pageFolder->slug.current) && (!defined(slug.current) || slug.current == "" || slug.current == "/") => pageFolder->parentFolder->parentFolder->parentFolder->parentFolder->slug.current + "/" + pageFolder->parentFolder->parentFolder->parentFolder->slug.current + "/" + pageFolder->parentFolder->parentFolder->slug.current + "/" + pageFolder->parentFolder->slug.current + "/" + pageFolder->slug.current, defined(pageFolder->parentFolder->parentFolder->parentFolder->slug.current) && defined(pageFolder->parentFolder->parentFolder->slug.current) && defined(pageFolder->parentFolder->slug.current) && defined(pageFolder->slug.current) && (!defined(slug.current) || slug.current == "" || slug.current == "/") => pageFolder->parentFolder->parentFolder->parentFolder->slug.current + "/" + pageFolder->parentFolder->parentFolder->slug.current + "/" + pageFolder->parentFolder->slug.current + "/" + pageFolder->slug.current, defined(pageFolder->parentFolder->parentFolder->slug.current) && defined(pageFolder->parentFolder->slug.current) && defined(pageFolder->slug.current) && (!defined(slug.current) || slug.current == "" || slug.current == "/") => pageFolder->parentFolder->parentFolder->slug.current + "/" + pageFolder->parentFolder->slug.current + "/" + pageFolder->slug.current, defined(pageFolder->parentFolder->slug.current) && defined(pageFolder->slug.current) && (!defined(slug.current) || slug.current == "" || slug.current == "/") => pageFolder->parentFolder->slug.current + "/" + pageFolder->slug.current, defined(pageFolder->slug.current) && (!defined(slug.current) || slug.current == "" || slug.current == "/") => pageFolder->slug.current, defined(pageFolder->parentFolder->parentFolder->parentFolder->parentFolder->parentFolder->slug.current) && defined(pageFolder->parentFolder->parentFolder->parentFolder->parentFolder->slug.current) && defined(pageFolder->parentFolder->parentFolder->parentFolder->slug.current) && defined(pageFolder->parentFolder->parentFolder->slug.current) && defined(pageFolder->parentFolder->slug.current) && defined(pageFolder->slug.current) && defined(slug.current) && slug.current != "" && slug.current != "/" => pageFolder->parentFolder->parentFolder->parentFolder->parentFolder->parentFolder->slug.current + "/" + pageFolder->parentFolder->parentFolder->parentFolder->parentFolder->slug.current + "/" + pageFolder->parentFolder->parentFolder->parentFolder->slug.current + "/" + pageFolder->parentFolder->parentFolder->slug.current + "/" + pageFolder->parentFolder->slug.current + "/" + pageFolder->slug.current + "/" + slug.current, defined(pageFolder->parentFolder->parentFolder->parentFolder->parentFolder->slug.current) && defined(pageFolder->parentFolder->parentFolder->parentFolder->slug.current) && defined(pageFolder->parentFolder->parentFolder->slug.current) && defined(pageFolder->parentFolder->slug.current) && defined(pageFolder->slug.current) && defined(slug.current) && slug.current != "" && slug.current != "/" => pageFolder->parentFolder->parentFolder->parentFolder->parentFolder->slug.current + "/" + pageFolder->parentFolder->parentFolder->parentFolder->slug.current + "/" + pageFolder->parentFolder->parentFolder->slug.current + "/" + pageFolder->parentFolder->slug.current + "/" + pageFolder->slug.current + "/" + slug.current, defined(pageFolder->parentFolder->parentFolder->parentFolder->slug.current) && defined(pageFolder->parentFolder->parentFolder->slug.current) && defined(pageFolder->parentFolder->slug.current) && defined(pageFolder->slug.current) && defined(slug.current) && slug.current != "" && slug.current != "/" => pageFolder->parentFolder->parentFolder->parentFolder->slug.current + "/" + pageFolder->parentFolder->parentFolder->slug.current + "/" + pageFolder->parentFolder->slug.current + "/" + pageFolder->slug.current + "/" + slug.current, defined(pageFolder->parentFolder->parentFolder->slug.current) && defined(pageFolder->parentFolder->slug.current) && defined(pageFolder->slug.current) && defined(slug.current) && slug.current != "" && slug.current != "/" => pageFolder->parentFolder->parentFolder->slug.current + "/" + pageFolder->parentFolder->slug.current + "/" + pageFolder->slug.current + "/" + slug.current, defined(pageFolder->parentFolder->slug.current) && defined(pageFolder->slug.current) && defined(slug.current) && slug.current != "" && slug.current != "/" => pageFolder->parentFolder->slug.current + "/" + pageFolder->slug.current + "/" + slug.current, defined(pageFolder->slug.current) && defined(slug.current) && slug.current != "" && slug.current != "/" => pageFolder->slug.current + "/" + slug.current, slug.current ) == $slug ) ][0] { _id, title, metadata, _updatedAt }
@@ -68,7 +68,6 @@ const nextConfig: NextConfig = {
68
68
  browserToTerminal: true,
69
69
  },
70
70
  experimental: {
71
- prefetchInlining: true,
72
71
  optimizePackageImports: [
73
72
  "@react-three/drei",
74
73
  "@react-three/fiber",
@@ -94,7 +93,8 @@ const nextConfig: NextConfig = {
94
93
  },
95
94
  headers: async () => [
96
95
  {
97
- source: "/(.*)",
96
+ // Excludes /studio* — must stay mutually exclusive with the rule below.
97
+ source: "/((?!studio(?:/|$)).*)",
98
98
  headers: [
99
99
  {
100
100
  key: "X-Content-Type-Options",
@@ -126,6 +126,37 @@ const nextConfig: NextConfig = {
126
126
  },
127
127
  ],
128
128
  },
129
+ {
130
+ // X-Frame-Options can't allow a third-party origin, and Next can't unset
131
+ // it from another rule — the iframe needs it absent, not overridden.
132
+ source: "/studio/:path*",
133
+ headers: [
134
+ {
135
+ key: "X-Content-Type-Options",
136
+ value: "nosniff",
137
+ },
138
+ {
139
+ key: "Content-Security-Policy",
140
+ value: "frame-ancestors 'self' https://*.sanity.io;",
141
+ },
142
+ {
143
+ key: "X-XSS-Protection",
144
+ value: "1; mode=block",
145
+ },
146
+ {
147
+ key: "X-DNS-Prefetch-Control",
148
+ value: "on",
149
+ },
150
+ {
151
+ key: "Strict-Transport-Security",
152
+ value: "max-age=63072000; includeSubDomains; preload",
153
+ },
154
+ {
155
+ key: "Permissions-Policy",
156
+ value: "camera=(), microphone=(), geolocation=()",
157
+ },
158
+ ],
159
+ },
129
160
  ],
130
161
  }
131
162
 
@@ -28,9 +28,9 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "class-variance-authority": "^0.7.1",
31
- "next": "^16",
32
- "react": "^19",
33
- "react-dom": "^19",
31
+ "next": "^16.3.0",
32
+ "react": "^19.2.8",
33
+ "react-dom": "^19.2.8",
34
34
  "react-use": "^17.6.0",
35
35
  "tailwind-merge": "^3.5.0",
36
36
  "zod": "^4.3.6"
@@ -38,7 +38,7 @@
38
38
  "devDependencies": {
39
39
  "@biomejs/biome": "^2.4.14",
40
40
  "@csstools/postcss-global-data": "^4.0.0",
41
- "@next/bundle-analyzer": "^16.2.1",
41
+ "@next/bundle-analyzer": "^16.3.0",
42
42
  "@svgr/webpack": "^8.1.0",
43
43
  "@tailwindcss/postcss": "^4.2.2",
44
44
  "@types/bun": "^1.3.11",