@oneie/plugin-pages 0.1.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/LICENSE ADDED
@@ -0,0 +1,86 @@
1
+ # ONE License (Version 1.0)
2
+
3
+ Copyright (c) 2024-2026 one.ie
4
+
5
+ ## Maximum Freedom, One Obligation
6
+
7
+ This license empowers you with complete commercial freedom to use, reuse, modify, sell and resell the software, AI and data, at any price you wish.
8
+
9
+ No usage limits. No royalty fees. Just pure, unrestricted ability to innovate and profit from the software.
10
+
11
+ You are free to run ONE locally, on your own servers, in the cloud and at the edge.
12
+
13
+ ## You Receive
14
+
15
+ ### Unlimited Rights
16
+
17
+ You have unrestricted rights to use, modify, license, sublicense, distribute, sell, resell and monetize the Software without restrictions, subject only to the Brand Requirement below.
18
+
19
+ ### Permitted Actions
20
+
21
+ Including, but not limited to:
22
+
23
+ - Commercial use and integration into any systems
24
+ - Creation and sale of derivative works
25
+ - Providing software as a service
26
+ - AI training and content generation
27
+ - Patenting innovations based on the Software
28
+ - Open source applications and integrations
29
+
30
+ ### License Compatibility
31
+
32
+ This license is compatible with all major open-source licenses, including:
33
+
34
+ - MIT License
35
+ - Apache License
36
+ - GNU General Public License (GPL)
37
+ - BSD Licenses
38
+ - Mozilla Public License
39
+
40
+ ### Perpetual Rights
41
+
42
+ These rights are granted in perpetuity and are irrevocable, provided the Brand Requirement is maintained.
43
+
44
+ ## Intellectual Property
45
+
46
+ - We retain ownership of the original Software and data.
47
+ - You own any modifications you make.
48
+ - You never have to share your code or data.
49
+
50
+ ## Brand Requirement
51
+
52
+ The only obligation is:
53
+
54
+ - Don't remove the ONE brand, logo, and link to https://one.ie/ from the deployed product.
55
+
56
+ To remove the Brand Requirement — white-label, no visible ONE branding — obtain the
57
+ **ONE Enterprise License** (see `LICENSE-ENTERPRISE.md` or contact agent@one.ie).
58
+
59
+ ## Liability and Warranty
60
+
61
+ The Software and data is provided "AS IS". We bear no liability for its use.
62
+
63
+ ## Termination
64
+
65
+ This license terminates if you remove or hide the ONE brand, logo, or link without
66
+ holding a current ONE Enterprise License.
67
+
68
+ ## Governing Law and Disputes
69
+
70
+ This license is governed by the laws of Ireland. The parties will attempt to resolve disputes through good-faith negotiation. If necessary, disputes will proceed to mediation under the Mediators' Institute of Ireland rules, and then to binding arbitration under the Arbitration Act 2010, seated in Dublin, conducted in English.
71
+
72
+ ---
73
+
74
+ This license is designed to maximize freedom to innovate and profit. There is no copyleft requirement to share any code, making it suitable for enterprise use.
75
+
76
+ ## Enterprise Solutions
77
+
78
+ Building something big? We're here to help:
79
+
80
+ - **Free** — use every feature with the ONE brand link in the footer
81
+ - **White-label** — remove the brand requirement (ONE Enterprise License)
82
+ - **Custom** — white-label solutions tailored to your needs
83
+ - **Enterprise** — full support and deployment assistance
84
+ - **Training** — help getting your team started
85
+
86
+ Contact agent@one.ie to share your needs · Learn at https://one.ie/learn · Agents: https://one.ie/llms.txt
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@oneie/plugin-pages",
3
+ "version": "0.1.0",
4
+ "description": "ONE pages — renders a workspace's published Puck pages on any Astro site via the public pages:view receiver",
5
+ "license": "SEE LICENSE IN LICENSE",
6
+ "type": "module",
7
+ "main": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./PageRenderer.tsx": "./src/PageRenderer.tsx",
11
+ "./fetchPage.ts": "./src/fetchPage.ts",
12
+ "./PageRoute.astro": "./src/PageRoute.astro"
13
+ },
14
+ "dependencies": {
15
+ "clsx": "^2.1.1",
16
+ "lucide-react": "^1.14.0",
17
+ "radix-ui": "^1.6.2",
18
+ "tailwind-merge": "^3.5.0"
19
+ },
20
+ "peerDependencies": {
21
+ "astro": ">=6.0.0",
22
+ "@oneie/frontend": ">=0.1.0",
23
+ "@puckeditor/core": ">=0.21.0",
24
+ "react": ">=19.0.0"
25
+ }
26
+ }
@@ -0,0 +1,17 @@
1
+ // SSR-safe render of a published Puck Data tree. Ported from
2
+ // one.ie/web/src/components/puck/PuckRenderer.tsx — same guard, same <Render>, but
3
+ // bound to the package's self-contained pagesPuckConfig instead of the workspace
4
+ // config. No puck.css / editor-chrome import (keeps it out of any SSR bundle).
5
+ import { Render, type Data } from '@puckeditor/core'
6
+ import { pagesPuckConfig } from './config'
7
+
8
+ export type PuckData = Data
9
+
10
+ export interface PageRendererProps {
11
+ data: PuckData
12
+ }
13
+
14
+ export function PageRenderer({ data }: PageRendererProps) {
15
+ if (!data || !data.content || data.content.length === 0) return null
16
+ return <Render config={pagesPuckConfig} data={data} />
17
+ }
@@ -0,0 +1,17 @@
1
+ ---
2
+ // Default injected entrypoint for the /p/[slug] route. Self-resolves the page slug
3
+ // from Astro.params and the workspace from env; renders the published Puck tree.
4
+ // apps/one/site (C2) sets injectRoutes:false and wraps <PageRenderer> in its own
5
+ // <Layout> instead of using this file.
6
+ import { PageRenderer } from './PageRenderer'
7
+ import { fetchPage } from './fetchPage'
8
+
9
+ const { slug } = Astro.params as { slug: string }
10
+ const ws = import.meta.env.ONE_WS ?? import.meta.env.PUBLIC_ONE_WS ?? ''
11
+ const baseUrl = import.meta.env.ONE_BASE_URL as string | undefined
12
+ const apiKey = import.meta.env.ONE_API_KEY as string | undefined
13
+
14
+ const data = ws ? await fetchPage(ws, slug, { baseUrl, apiKey }) : null
15
+ if (!data) return new Response(null, { status: 404 })
16
+ ---
17
+ <PageRenderer data={data} client:only="react" />
@@ -0,0 +1,64 @@
1
+ // Ported verbatim from one.ie/web/src/components/cro/FAQSection.tsx
2
+ import { useState } from 'react'
3
+ import { ChevronDown } from 'lucide-react'
4
+ import { emitClick } from '../lib/ui-signal'
5
+
6
+ const BRAND_TOKENS = new Set(['primary', 'secondary', 'tertiary'])
7
+
8
+ interface FAQ {
9
+ q: string
10
+ a: string
11
+ }
12
+
13
+ interface FAQSectionProps {
14
+ title?: string
15
+ faqs: FAQ[]
16
+ bgToken?: string
17
+ }
18
+
19
+ export function FAQSection({ title = 'Common questions', faqs, bgToken }: FAQSectionProps) {
20
+ const [open, setOpen] = useState<number | null>(null)
21
+
22
+ const hasBgToken = !!bgToken && bgToken !== 'none'
23
+ const isBrandToken = hasBgToken && BRAND_TOKENS.has(bgToken)
24
+
25
+ return (
26
+ <section
27
+ className={`px-6 py-24 ${hasBgToken ? '' : 'bg-foreground/30'}${isBrandToken ? ' text-on-primary' : ''}`}
28
+ style={hasBgToken ? { backgroundColor: `var(--color-${bgToken})` } : undefined}
29
+ >
30
+ <div className="max-w-3xl mx-auto">
31
+ <h2 className="text-3xl font-bold tracking-tight text-center mb-14">{title}</h2>
32
+ <div className="flex flex-col gap-2">
33
+ {faqs.map((faq, i) => (
34
+ <article
35
+ key={faq.q}
36
+ className="bg-background rounded-xl border overflow-hidden"
37
+ style={{ borderColor: 'var(--color-border)' }}
38
+ >
39
+ <button
40
+ className="w-full flex items-center justify-between gap-4 px-6 py-5 text-left hover:bg-foreground/30 transition-colors"
41
+ onClick={() => {
42
+ const next = open === i ? null : i
43
+ setOpen(next)
44
+ if (next !== null) emitClick('ui:faq:open', { question: faq.q })
45
+ }}
46
+ >
47
+ <span className="font-medium text-sm text-font">{faq.q}</span>
48
+ <ChevronDown
49
+ size={18}
50
+ className={`text-font/40 flex-shrink-0 transition-transform ${open === i ? 'rotate-180' : ''}`}
51
+ />
52
+ </button>
53
+ {open === i && (
54
+ <div className="px-6 pb-5 border-t" style={{ borderColor: 'var(--color-border)' }}>
55
+ <p className="text-sm text-font/60 leading-relaxed pt-4">{faq.a}</p>
56
+ </div>
57
+ )}
58
+ </article>
59
+ ))}
60
+ </div>
61
+ </div>
62
+ </section>
63
+ )
64
+ }
@@ -0,0 +1,72 @@
1
+ // Ported from one.ie/web/src/components/cro/FooterSection.tsx — hrefs additionally
2
+ // wrapped in safeHref() (see ../lib/safe-url.ts): public, no-login render surface.
3
+ import { AtSign, Globe, MessageCircle, Share2 } from 'lucide-react'
4
+ import { Separator } from './Separator'
5
+ import { safeHref } from '../lib/safe-url'
6
+
7
+ export interface FooterLink {
8
+ label: string
9
+ href: string
10
+ }
11
+
12
+ export interface FooterSocial {
13
+ platform: 'facebook' | 'instagram' | 'twitter' | 'youtube'
14
+ href: string
15
+ }
16
+
17
+ export interface FooterSectionProps {
18
+ brand?: string
19
+ links?: FooterLink[]
20
+ socials?: FooterSocial[]
21
+ copyright?: string
22
+ }
23
+
24
+ // lucide dropped brand glyphs (Facebook/Twitter/…); map platforms to generic icons.
25
+ const ICONS = {
26
+ facebook: Globe,
27
+ instagram: AtSign,
28
+ twitter: MessageCircle,
29
+ youtube: Share2,
30
+ } as const
31
+
32
+ export function FooterSection({
33
+ brand = 'ONE',
34
+ links = [],
35
+ socials = [],
36
+ copyright = `©${new Date().getFullYear()} ONE, Made for better web.`,
37
+ }: FooterSectionProps) {
38
+ return (
39
+ <footer>
40
+ <div className="mx-auto flex max-w-7xl items-center justify-between gap-3 px-4 py-4 max-md:flex-col sm:px-6 sm:py-6 md:gap-6 md:py-8">
41
+ <a href="#" className="flex items-center gap-3 font-semibold text-lg">
42
+ {brand}
43
+ </a>
44
+
45
+ <nav className="flex items-center gap-5 whitespace-nowrap">
46
+ {links.map((link, i) => (
47
+ <a key={i} href={safeHref(link.href) ?? '#'} className="hover:underline">
48
+ {link.label}
49
+ </a>
50
+ ))}
51
+ </nav>
52
+
53
+ <div className="flex items-center gap-4">
54
+ {socials.map((social, i) => {
55
+ const IconCmp = ICONS[social.platform] ?? Globe
56
+ return (
57
+ <a key={i} href={safeHref(social.href) ?? '#'} aria-label={social.platform}>
58
+ <IconCmp className="size-5" />
59
+ </a>
60
+ )
61
+ })}
62
+ </div>
63
+ </div>
64
+
65
+ <Separator />
66
+
67
+ <div className="mx-auto flex max-w-7xl justify-center px-4 py-8 sm:px-6">
68
+ <p className="text-center font-medium text-balance">{copyright}</p>
69
+ </div>
70
+ </footer>
71
+ )
72
+ }
@@ -0,0 +1,56 @@
1
+ // Ported verbatim from one.ie/web/src/components/cro/HowItWorks.tsx
2
+ const BRAND_TOKENS = new Set(['primary', 'secondary', 'tertiary'])
3
+
4
+ interface Step {
5
+ label: string
6
+ description: string
7
+ detail?: string
8
+ }
9
+
10
+ interface HowItWorksProps {
11
+ title?: string
12
+ subtitle?: string
13
+ steps: Step[]
14
+ bgToken?: string
15
+ }
16
+
17
+ export function HowItWorks({ title = 'How it works', subtitle, steps, bgToken }: HowItWorksProps) {
18
+ const hasBgToken = !!bgToken && bgToken !== 'none'
19
+ const isBrandToken = hasBgToken && BRAND_TOKENS.has(bgToken)
20
+
21
+ return (
22
+ <section
23
+ className={`px-6 py-24${isBrandToken ? ' text-on-primary' : ''}`}
24
+ style={hasBgToken ? { backgroundColor: `var(--color-${bgToken})` } : undefined}
25
+ >
26
+ <div className="max-w-4xl mx-auto">
27
+ <h2 className="text-3xl font-bold tracking-tight text-center mb-3">{title}</h2>
28
+ {subtitle && <p className="text-font/60 text-center mb-16 max-w-xl mx-auto">{subtitle}</p>}
29
+ {!subtitle && <div className="mb-16" />}
30
+
31
+ <div className="relative">
32
+ {steps.length > 1 && (
33
+ <div
34
+ className="absolute left-6 top-8 bottom-8 w-px hidden md:block"
35
+ style={{ background: 'var(--color-border)' }}
36
+ />
37
+ )}
38
+ <div className="flex flex-col gap-10">
39
+ {steps.map((step, i) => (
40
+ <div key={step.label} className="flex gap-6 md:gap-10 items-start">
41
+ <div className="flex-shrink-0 w-12 h-12 rounded-full bg-primary text-on-primary flex items-center justify-center font-bold text-lg relative z-10">
42
+ {i + 1}
43
+ </div>
44
+ <div className="pt-2">
45
+ <h3 className="font-semibold text-base mb-1">{step.label}</h3>
46
+ <p className="text-font/60 text-sm leading-relaxed">{step.description}</p>
47
+ {step.detail && <p className="text-font/40 text-xs mt-2 font-mono">{step.detail}</p>}
48
+ </div>
49
+ </div>
50
+ ))}
51
+ </div>
52
+ </div>
53
+ </div>
54
+ </section>
55
+ )
56
+ }
@@ -0,0 +1,25 @@
1
+ // Ported verbatim from one.ie/web/src/components/ui/Icon.tsx
2
+ import type { LucideIcon } from 'lucide-react'
3
+
4
+ export type IconSize = 'sm' | 'md' | 'lg' | 'xl'
5
+
6
+ const SIZE: Record<IconSize, number> = { sm: 14, md: 16, lg: 20, xl: 24 }
7
+
8
+ interface IconProps {
9
+ icon: LucideIcon
10
+ size?: IconSize
11
+ className?: string
12
+ 'aria-label'?: string
13
+ }
14
+
15
+ export function Icon({ icon: I, size = 'md', className, 'aria-label': label }: IconProps) {
16
+ return (
17
+ <I
18
+ size={SIZE[size]}
19
+ strokeWidth={1.5}
20
+ className={className}
21
+ aria-hidden={label ? undefined : true}
22
+ aria-label={label}
23
+ />
24
+ )
25
+ }
@@ -0,0 +1,62 @@
1
+ // Ported verbatim from one.ie/web/src/components/ui/IconBadge.tsx
2
+ import type { LucideIcon } from 'lucide-react'
3
+
4
+ export type IconBadgeTone = 'primary' | 'secondary' | 'tertiary' | 'neutral'
5
+ export type IconBadgeSize = 'sm' | 'md' | 'lg'
6
+
7
+ const BOX: Record<IconBadgeSize, string> = {
8
+ sm: 'w-9 h-9',
9
+ md: 'w-11 h-11',
10
+ lg: 'w-14 h-14',
11
+ }
12
+ const PX: Record<IconBadgeSize, number> = { sm: 16, md: 20, lg: 24 }
13
+
14
+ interface ToneStyle {
15
+ background: string
16
+ borderColor: string
17
+ color: string
18
+ }
19
+
20
+ const tone = (name: 'primary' | 'secondary' | 'tertiary'): ToneStyle => ({
21
+ background: `color-mix(in oklab, var(--color-${name}) 14%, var(--color-foreground))`,
22
+ borderColor: `color-mix(in oklab, var(--color-${name}) 28%, var(--color-border))`,
23
+ color: `var(--color-${name})`,
24
+ })
25
+
26
+ const TONE: Record<IconBadgeTone, ToneStyle> = {
27
+ primary: tone('primary'),
28
+ secondary: tone('secondary'),
29
+ tertiary: tone('tertiary'),
30
+ neutral: {
31
+ background: 'var(--color-foreground)',
32
+ borderColor: 'var(--color-border)',
33
+ color: 'var(--color-font)',
34
+ },
35
+ }
36
+
37
+ interface Props {
38
+ icon: LucideIcon
39
+ tone?: IconBadgeTone
40
+ size?: IconBadgeSize
41
+ className?: string
42
+ 'aria-label'?: string
43
+ }
44
+
45
+ export function IconBadge({
46
+ icon: I,
47
+ tone: t = 'tertiary',
48
+ size = 'md',
49
+ className = '',
50
+ 'aria-label': label,
51
+ }: Props) {
52
+ return (
53
+ <div
54
+ className={`${BOX[size]} flex-shrink-0 inline-flex items-center justify-center rounded-xl border ${className}`}
55
+ style={TONE[t]}
56
+ aria-hidden={label ? undefined : true}
57
+ aria-label={label}
58
+ >
59
+ <I size={PX[size]} strokeWidth={1.75} />
60
+ </div>
61
+ )
62
+ }
@@ -0,0 +1,61 @@
1
+ // Ported verbatim from one.ie/web/src/components/cro/LandingFeatures.tsx
2
+ import type { LucideIcon } from 'lucide-react'
3
+ import { IconBadge, type IconBadgeTone } from './IconBadge'
4
+
5
+ const BRAND_TOKENS = new Set(['primary', 'secondary', 'tertiary'])
6
+
7
+ export interface LandingFeature {
8
+ headline: string
9
+ body: string
10
+ icon?: LucideIcon
11
+ tone?: IconBadgeTone
12
+ }
13
+
14
+ interface LandingFeaturesProps {
15
+ title?: string
16
+ subtitle?: string
17
+ features: LandingFeature[]
18
+ columns?: 2 | 3
19
+ bgToken?: string
20
+ }
21
+
22
+ export function LandingFeatures({
23
+ title = 'What you get',
24
+ subtitle,
25
+ features,
26
+ columns = 3,
27
+ bgToken,
28
+ }: LandingFeaturesProps) {
29
+ const hasBgToken = !!bgToken && bgToken !== 'none'
30
+ const isBrandToken = hasBgToken && BRAND_TOKENS.has(bgToken)
31
+
32
+ const gridClass = columns === 2 ? 'grid sm:grid-cols-2 gap-8' : 'grid sm:grid-cols-2 lg:grid-cols-3 gap-8'
33
+
34
+ return (
35
+ <section
36
+ className={`px-6 py-24 ${hasBgToken ? '' : 'bg-foreground/30'}${isBrandToken ? ' text-on-primary' : ''}`}
37
+ style={hasBgToken ? { backgroundColor: `var(--color-${bgToken})` } : undefined}
38
+ >
39
+ <div className="max-w-5xl mx-auto">
40
+ <h2 className="text-3xl font-bold tracking-tight text-center mb-3">{title}</h2>
41
+ {subtitle && <p className="text-font/60 text-center mb-14 max-w-xl mx-auto">{subtitle}</p>}
42
+ {!subtitle && <div className="mb-14" />}
43
+ <div className={gridClass}>
44
+ {features.map((f) => (
45
+ <article
46
+ key={f.headline}
47
+ className="bg-background rounded-2xl p-6 border flex flex-col gap-4"
48
+ style={{ borderColor: 'var(--color-border)', boxShadow: 'var(--shadow-card)' }}
49
+ >
50
+ {f.icon && <IconBadge icon={f.icon} tone={f.tone ?? 'primary'} size="md" />}
51
+ <div>
52
+ <h3 className="font-semibold text-base mb-2">{f.headline}</h3>
53
+ <p className="text-sm text-font/60 leading-relaxed">{f.body}</p>
54
+ </div>
55
+ </article>
56
+ ))}
57
+ </div>
58
+ </div>
59
+ </section>
60
+ )
61
+ }
@@ -0,0 +1,113 @@
1
+ // Ported from one.ie/web/src/components/cro/LandingHero.tsx — hrefs additionally
2
+ // wrapped in safeHref() (see ../lib/safe-url.ts): this package renders to public,
3
+ // no-login visitors on any external site, so an unsanitized javascript:/data:
4
+ // URL in stored page content would execute in every visitor's browser.
5
+ import { ArrowRight } from 'lucide-react'
6
+ import { Icon } from './Icon'
7
+ import { emitClick } from '../lib/ui-signal'
8
+ import { safeHref } from '../lib/safe-url'
9
+
10
+ const BRAND_TOKENS = new Set(['primary', 'secondary', 'tertiary'])
11
+
12
+ interface CTA {
13
+ label: string
14
+ href: string
15
+ }
16
+
17
+ interface LandingHeroProps {
18
+ eyebrow?: string
19
+ headline: string
20
+ highlight?: string
21
+ subhead: string
22
+ primaryCta: CTA
23
+ secondaryCta?: CTA
24
+ frictionText?: string
25
+ visual?: string
26
+ bgToken?: string
27
+ }
28
+
29
+ export function LandingHero({
30
+ eyebrow,
31
+ headline,
32
+ highlight,
33
+ subhead,
34
+ primaryCta,
35
+ secondaryCta,
36
+ frictionText = 'No credit card · 2-minute setup',
37
+ visual,
38
+ bgToken,
39
+ }: LandingHeroProps) {
40
+ const hasBgToken = !!bgToken && bgToken !== 'none'
41
+ const isBrandToken = hasBgToken && BRAND_TOKENS.has(bgToken)
42
+
43
+ return (
44
+ <section
45
+ className={`px-6 pt-24 pb-20 md:pt-32 md:pb-28${isBrandToken ? ' text-on-primary' : ''}`}
46
+ style={hasBgToken ? { backgroundColor: `var(--color-${bgToken})` } : undefined}
47
+ >
48
+ <div className="max-w-4xl mx-auto text-center">
49
+ {eyebrow && (
50
+ <p
51
+ className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-foreground border text-xs font-medium text-font/60 mb-8 tracking-wide uppercase"
52
+ style={{ borderColor: 'var(--color-border)' }}
53
+ >
54
+ {eyebrow}
55
+ </p>
56
+ )}
57
+
58
+ <h1 className="text-5xl sm:text-6xl lg:text-7xl font-bold tracking-tight mb-6 leading-[0.96]">
59
+ {headline}
60
+ {highlight && (
61
+ <>
62
+ {' '}
63
+ <span className="text-tertiary">{highlight}</span>
64
+ </>
65
+ )}
66
+ </h1>
67
+
68
+ <p className="text-xl sm:text-2xl text-font/60 mb-10 max-w-2xl mx-auto leading-snug">
69
+ {subhead}
70
+ </p>
71
+
72
+ <div className="flex flex-col sm:flex-row gap-3 justify-center mb-4">
73
+ <a
74
+ href={safeHref(primaryCta.href) ?? '#'}
75
+ onClick={() => emitClick('ui:landing:primary-cta', { page: headline })}
76
+ className="inline-flex items-center justify-center gap-2 px-8 py-4 bg-primary text-on-primary rounded-full font-medium text-base hover:brightness-110 transition"
77
+ >
78
+ {primaryCta.label}
79
+ <Icon icon={ArrowRight} size="md" />
80
+ </a>
81
+ {secondaryCta && (
82
+ <a
83
+ href={safeHref(secondaryCta.href) ?? '#'}
84
+ onClick={() => emitClick('ui:landing:secondary-cta', { page: headline })}
85
+ className="inline-flex items-center justify-center gap-2 px-8 py-4 text-font rounded-full font-medium text-base hover:text-font/70 transition border"
86
+ style={{ borderColor: 'var(--color-border)' }}
87
+ >
88
+ {secondaryCta.label}
89
+ </a>
90
+ )}
91
+ </div>
92
+
93
+ {frictionText && <p className="text-sm text-font/40">{frictionText}</p>}
94
+
95
+ {visual && (
96
+ <div className="mt-14 mx-auto max-w-3xl">
97
+ <div
98
+ className="bg-foreground rounded-2xl border overflow-hidden"
99
+ style={{ borderColor: 'var(--color-border)', boxShadow: 'var(--shadow-pop)' }}
100
+ >
101
+ <div className="flex items-center gap-1.5 px-4 py-3 border-b" style={{ borderColor: 'var(--color-border)' }}>
102
+ <span className="w-3 h-3 rounded-full bg-destructive/40" />
103
+ <span className="w-3 h-3 rounded-full bg-tertiary/40" />
104
+ <span className="w-3 h-3 rounded-full bg-primary/40" />
105
+ </div>
106
+ <div className="px-6 py-8 text-sm text-font/50 italic text-left">{visual}</div>
107
+ </div>
108
+ </div>
109
+ )}
110
+ </div>
111
+ </section>
112
+ )
113
+ }
@@ -0,0 +1,120 @@
1
+ // Ported from one.ie/web/src/components/cro/PricingSection.tsx — hrefs additionally
2
+ // wrapped in safeHref() (see ../lib/safe-url.ts): public, no-login render surface.
3
+ import { Check } from 'lucide-react'
4
+ import { useState } from 'react'
5
+ import { emitClick } from '../lib/ui-signal'
6
+ import { safeHref } from '../lib/safe-url'
7
+
8
+ export interface PricingTier {
9
+ name: string
10
+ monthlyPrice: string
11
+ annualPrice: string
12
+ description: string
13
+ features: string[]
14
+ cta: string
15
+ ctaHref: string
16
+ popular?: boolean
17
+ enterprise?: boolean
18
+ }
19
+
20
+ interface PricingSectionProps {
21
+ title?: string
22
+ subtitle?: string
23
+ tiers: PricingTier[]
24
+ }
25
+
26
+ export function PricingSection({
27
+ title = 'Simple pricing',
28
+ subtitle = 'Annual billing pre-selected saves you 20%.',
29
+ tiers,
30
+ }: PricingSectionProps) {
31
+ const [annual, setAnnual] = useState(true)
32
+
33
+ return (
34
+ <section className="px-6 py-24 bg-foreground/30">
35
+ <div className="max-w-5xl mx-auto">
36
+ <h2 className="text-3xl font-bold tracking-tight text-center mb-3">{title}</h2>
37
+ <p className="text-font/60 text-center mb-10 max-w-xl mx-auto">{subtitle}</p>
38
+
39
+ <div className="flex justify-center mb-12">
40
+ <div
41
+ className="inline-flex items-center gap-1 bg-background border rounded-full p-1"
42
+ style={{ borderColor: 'var(--color-border)' }}
43
+ >
44
+ <button
45
+ onClick={() => {
46
+ setAnnual(true)
47
+ emitClick('ui:pricing:annual')
48
+ }}
49
+ className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${annual ? 'bg-primary text-on-primary' : 'text-font/60 hover:text-font'}`}
50
+ >
51
+ Annual <span className="text-xs opacity-70 ml-1">save 20%</span>
52
+ </button>
53
+ <button
54
+ onClick={() => {
55
+ setAnnual(false)
56
+ emitClick('ui:pricing:monthly')
57
+ }}
58
+ className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${!annual ? 'bg-primary text-on-primary' : 'text-font/60 hover:text-font'}`}
59
+ >
60
+ Monthly
61
+ </button>
62
+ </div>
63
+ </div>
64
+
65
+ <div className="grid sm:grid-cols-3 gap-6 items-start">
66
+ {tiers.map((tier) => (
67
+ <article
68
+ key={tier.name}
69
+ className={`bg-background rounded-2xl border flex flex-col relative overflow-hidden ${tier.popular ? 'ring-2 ring-primary' : ''}`}
70
+ style={{
71
+ borderColor: tier.popular ? 'var(--color-primary)' : 'var(--color-border)',
72
+ boxShadow: 'var(--shadow-card)',
73
+ }}
74
+ >
75
+ {tier.popular && (
76
+ <div className="absolute top-0 inset-x-0 bg-primary text-on-primary text-xs font-semibold text-center py-1.5 tracking-wide uppercase">
77
+ Most popular
78
+ </div>
79
+ )}
80
+
81
+ <header
82
+ className={`px-6 ${tier.popular ? 'pt-10' : 'pt-6'} pb-6 border-b`}
83
+ style={{ borderColor: 'var(--color-border)' }}
84
+ >
85
+ <h3 className="font-bold text-lg mb-1">{tier.name}</h3>
86
+ <p className="text-font/50 text-sm mb-4">{tier.description}</p>
87
+ <div className="flex items-baseline gap-1">
88
+ <span className="text-4xl font-bold">{annual ? tier.annualPrice : tier.monthlyPrice}</span>
89
+ {!tier.enterprise && <span className="text-font/40 text-sm">/mo</span>}
90
+ </div>
91
+ </header>
92
+
93
+ <div className="px-6 py-6 flex flex-col gap-3 flex-1">
94
+ {(tier.features ?? []).map((f) => (
95
+ <div key={f} className="flex gap-2.5 items-start">
96
+ <Check size={15} className="text-tertiary flex-shrink-0 mt-0.5" />
97
+ <span className="text-sm text-font/70">{f}</span>
98
+ </div>
99
+ ))}
100
+ </div>
101
+
102
+ <div className="px-6 pb-6">
103
+ <a
104
+ href={safeHref(tier.ctaHref) ?? '#'}
105
+ onClick={() => emitClick('ui:pricing:tier-cta', { tier: tier.name })}
106
+ className={`w-full inline-flex items-center justify-center py-3 px-6 rounded-xl font-medium text-sm transition ${
107
+ tier.popular ? 'bg-primary text-on-primary hover:brightness-110' : 'bg-foreground text-font hover:bg-foreground/80 border'
108
+ }`}
109
+ style={!tier.popular ? { borderColor: 'var(--color-border)' } : {}}
110
+ >
111
+ {tier.cta}
112
+ </a>
113
+ </div>
114
+ </article>
115
+ ))}
116
+ </div>
117
+ </div>
118
+ </section>
119
+ )
120
+ }
@@ -0,0 +1,56 @@
1
+ // Ported verbatim from one.ie/web/src/components/cro/ProofBar.tsx
2
+ const BRAND_TOKENS = new Set(['primary', 'secondary', 'tertiary'])
3
+
4
+ interface Stat {
5
+ label: string
6
+ value: string
7
+ source?: string
8
+ }
9
+
10
+ interface NamedQuote {
11
+ quote: string
12
+ name: string
13
+ role: string
14
+ company: string
15
+ }
16
+
17
+ interface ProofBarProps {
18
+ stats?: Stat[]
19
+ leadQuote?: NamedQuote
20
+ bgToken?: string
21
+ }
22
+
23
+ export function ProofBar({ stats, leadQuote, bgToken }: ProofBarProps) {
24
+ const hasBgToken = !!bgToken && bgToken !== 'none'
25
+ const isBrandToken = hasBgToken && BRAND_TOKENS.has(bgToken)
26
+
27
+ return (
28
+ <section
29
+ className={`px-6 py-12 border-y${isBrandToken ? ' text-on-primary' : ''}`}
30
+ style={{ borderColor: 'var(--color-border)', ...(hasBgToken ? { backgroundColor: `var(--color-${bgToken})` } : {}) }}
31
+ >
32
+ <div className="max-w-5xl mx-auto">
33
+ {stats && stats.length > 0 && (
34
+ <div className="flex flex-wrap justify-center gap-10 mb-8">
35
+ {stats.map((s) => (
36
+ <div key={s.label} className="text-center">
37
+ <p className="text-3xl font-bold text-font">{s.value}</p>
38
+ <p className="text-xs text-font/50 mt-1">{s.label}</p>
39
+ {s.source && <p className="text-xs text-font/30">{s.source}</p>}
40
+ </div>
41
+ ))}
42
+ </div>
43
+ )}
44
+ {leadQuote && (
45
+ <blockquote className="max-w-2xl mx-auto text-center">
46
+ <p className="text-font/70 text-base italic leading-relaxed">&quot;{leadQuote.quote}&quot;</p>
47
+ <footer className="mt-3">
48
+ <span className="text-sm font-semibold text-font">{leadQuote.name}</span>
49
+ <span className="text-sm text-font/50"> · {leadQuote.role} · {leadQuote.company}</span>
50
+ </footer>
51
+ </blockquote>
52
+ )}
53
+ </div>
54
+ </section>
55
+ )
56
+ }
@@ -0,0 +1,62 @@
1
+ // Ported from one.ie/web/src/components/cro/SecondaryCTA.tsx — hrefs additionally
2
+ // wrapped in safeHref() (see ../lib/safe-url.ts): public, no-login render surface.
3
+ import { ArrowRight } from 'lucide-react'
4
+ import { Icon } from './Icon'
5
+ import { emitClick } from '../lib/ui-signal'
6
+ import { safeHref } from '../lib/safe-url'
7
+
8
+ const BRAND_TOKENS = new Set(['primary', 'secondary', 'tertiary'])
9
+
10
+ interface SecondaryCTAProps {
11
+ headline: string
12
+ subhead?: string
13
+ primaryCta: { label: string; href: string }
14
+ secondaryCta?: { label: string; href: string }
15
+ frictionText?: string
16
+ bgToken?: string
17
+ }
18
+
19
+ export function SecondaryCTA({
20
+ headline,
21
+ subhead,
22
+ primaryCta,
23
+ secondaryCta,
24
+ frictionText = 'No credit card · 2-minute setup',
25
+ bgToken,
26
+ }: SecondaryCTAProps) {
27
+ const hasBgToken = !!bgToken && bgToken !== 'none'
28
+ const isBrandToken = hasBgToken && BRAND_TOKENS.has(bgToken)
29
+
30
+ return (
31
+ <section
32
+ className={`px-6 py-24${isBrandToken ? ' text-on-primary' : ''}`}
33
+ style={hasBgToken ? { backgroundColor: `var(--color-${bgToken})` } : undefined}
34
+ >
35
+ <div className="max-w-2xl mx-auto text-center">
36
+ <h2 className="text-3xl font-bold tracking-tight mb-4">{headline}</h2>
37
+ {subhead && <p className="text-font/60 mb-10 text-lg leading-snug">{subhead}</p>}
38
+ {!subhead && <div className="mb-10" />}
39
+ <div className="flex flex-col sm:flex-row gap-3 justify-center mb-4">
40
+ <a
41
+ href={safeHref(primaryCta.href) ?? '#'}
42
+ onClick={() => emitClick('ui:landing:secondary-cta', { section: 'bottom-cta' })}
43
+ className="inline-flex items-center justify-center gap-2 px-8 py-4 bg-primary text-on-primary rounded-full font-medium text-base hover:brightness-110 transition"
44
+ >
45
+ {primaryCta.label}
46
+ <Icon icon={ArrowRight} size="md" />
47
+ </a>
48
+ {secondaryCta && (
49
+ <a
50
+ href={safeHref(secondaryCta.href) ?? '#'}
51
+ className="inline-flex items-center justify-center gap-2 px-8 py-4 text-font rounded-full font-medium text-base hover:text-font/70 transition border"
52
+ style={{ borderColor: 'var(--color-border)' }}
53
+ >
54
+ {secondaryCta.label}
55
+ </a>
56
+ )}
57
+ </div>
58
+ {frictionText && <p className="text-sm text-font/40">{frictionText}</p>}
59
+ </div>
60
+ </section>
61
+ )
62
+ }
@@ -0,0 +1,27 @@
1
+ // Ported verbatim from one.ie/web/src/components/ui/separator.tsx
2
+ import * as React from 'react'
3
+ import { Separator as SeparatorPrimitive } from 'radix-ui'
4
+
5
+ import { cn } from '../lib/utils'
6
+
7
+ function Separator({
8
+ className,
9
+ orientation = 'horizontal',
10
+ decorative = true,
11
+ ...props
12
+ }: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
13
+ return (
14
+ <SeparatorPrimitive.Root
15
+ data-slot="separator"
16
+ decorative={decorative}
17
+ orientation={orientation}
18
+ className={cn(
19
+ 'shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch',
20
+ className,
21
+ )}
22
+ {...props}
23
+ />
24
+ )
25
+ }
26
+
27
+ export { Separator }
@@ -0,0 +1,48 @@
1
+ // Ported verbatim from one.ie/web/src/components/cro/Testimonials.tsx
2
+ const BRAND_TOKENS = new Set(['primary', 'secondary', 'tertiary'])
3
+
4
+ interface Testimonial {
5
+ quote: string
6
+ name: string
7
+ role: string
8
+ company: string
9
+ }
10
+
11
+ interface TestimonialsProps {
12
+ title?: string
13
+ testimonials: Testimonial[]
14
+ bgToken?: string
15
+ }
16
+
17
+ export function Testimonials({ title = 'What agencies say', testimonials, bgToken }: TestimonialsProps) {
18
+ const hasBgToken = !!bgToken && bgToken !== 'none'
19
+ const isBrandToken = hasBgToken && BRAND_TOKENS.has(bgToken)
20
+
21
+ return (
22
+ <section
23
+ className={`px-6 py-24${isBrandToken ? ' text-on-primary' : ''}`}
24
+ style={hasBgToken ? { backgroundColor: `var(--color-${bgToken})` } : undefined}
25
+ >
26
+ <div className="max-w-5xl mx-auto">
27
+ <h2 className="text-3xl font-bold tracking-tight text-center mb-14">{title}</h2>
28
+ <div className="grid sm:grid-cols-3 gap-6">
29
+ {testimonials.map((t) => (
30
+ <blockquote
31
+ key={t.name}
32
+ className="bg-foreground rounded-2xl p-6 border flex flex-col gap-4"
33
+ style={{ borderColor: 'var(--color-border)', boxShadow: 'var(--shadow-card)' }}
34
+ >
35
+ <p className="text-sm leading-relaxed text-font/80 flex-1">&quot;{t.quote}&quot;</p>
36
+ <footer className="pt-4 border-t" style={{ borderColor: 'var(--color-border)' }}>
37
+ <p className="font-semibold text-sm text-font">{t.name}</p>
38
+ <p className="text-xs text-font/50">
39
+ {t.role} · {t.company}
40
+ </p>
41
+ </footer>
42
+ </blockquote>
43
+ ))}
44
+ </div>
45
+ </div>
46
+ </section>
47
+ )
48
+ }
package/src/config.tsx ADDED
@@ -0,0 +1,181 @@
1
+ // Portable Puck block config for external sites. Unlike one.ie/web's
2
+ // lib/puck/config.tsx (176 blocks, most delegating to @/components/* imports and
3
+ // several to live D1/resolver reads), this config is SELF-CONTAINED: every block
4
+ // is copied verbatim into ./components with imports rewritten to local paths, so
5
+ // it builds and ships as an npm package with no workspace-internal dependency.
6
+ //
7
+ // Block KEYS match the real registry exactly (LandingHero, PricingSection, …) —
8
+ // C1 shipped 4 generic stub keys (Hero/Features/Pricing/Text) that never matched
9
+ // any real published page, so every real page rendered blank. C3 (2026-07-11)
10
+ // replaced that stub with this verified-portable subset: the 9 blocks confirmed,
11
+ // via a local D1 query, to be what real seeded pages (`beer`, `demo`, `oo-demo-*`)
12
+ // actually use.
13
+ //
14
+ // KNOWN LIMITATION (tracked in text/improvements.md): 176 blocks exist in the real
15
+ // registry; only these 9 ship here. The remaining ~167 either need substrate-live
16
+ // data (excluded by the promise's own "Out of scope" clause — chat/cart/storefront
17
+ // widgets, ProductBlock/RecordBlock/ViewBlock) or simply haven't been ported yet.
18
+ // A published page using an unregistered block type renders that block as nothing
19
+ // (Puck drops unknown types) — same distribution-lag limitation plugin-blog/
20
+ // plugin-docs already carry for their own registries.
21
+ //
22
+ // `fields` below are intentionally minimal (`{}`) — this package only ever calls
23
+ // Puck's read-only <Render>, which never consults `fields` (editor-only metadata).
24
+ // Field/defaultProps shapes are otherwise ported verbatim from the real registry
25
+ // for documentation parity.
26
+ import type { Config } from '@puckeditor/core'
27
+ import { LandingHero } from './components/LandingHero'
28
+ import { HowItWorks } from './components/HowItWorks'
29
+ import { LandingFeatures } from './components/LandingFeatures'
30
+ import { ProofBar } from './components/ProofBar'
31
+ import { SecondaryCTA } from './components/SecondaryCTA'
32
+ import { FAQSection } from './components/FAQSection'
33
+ import { Testimonials } from './components/Testimonials'
34
+ import { PricingSection } from './components/PricingSection'
35
+ import { FooterSection } from './components/FooterSection'
36
+
37
+ export const pagesPuckConfig: Config = {
38
+ components: {
39
+ LandingHero: {
40
+ fields: {},
41
+ defaultProps: {
42
+ bgToken: 'none',
43
+ headline: 'Build and sell smarter with AI agents',
44
+ subhead: 'ONE connects your website, your audience, and your AI — so you can launch, grow, and earn without the usual overhead.',
45
+ primaryCta: { label: 'Get started free', href: '#' },
46
+ secondaryCta: { label: 'See how it works', href: '#how' },
47
+ },
48
+ render: (props: Record<string, unknown>) => (
49
+ <LandingHero
50
+ {...(props as any)}
51
+ primaryCta={(props.primaryCta as any) ?? { label: 'Get started', href: '#' }}
52
+ secondaryCta={(props.secondaryCta as any)?.label ? (props.secondaryCta as any) : undefined}
53
+ />
54
+ ),
55
+ },
56
+
57
+ ProofBar: {
58
+ fields: {},
59
+ defaultProps: {
60
+ bgToken: 'none',
61
+ stats: [
62
+ { value: '12,000+', label: 'Businesses powered', source: '' },
63
+ { value: '99.9%', label: 'Uptime SLA', source: '' },
64
+ { value: '< 60s', label: 'Time to first agent', source: '' },
65
+ ],
66
+ },
67
+ render: (props: Record<string, unknown>) => <ProofBar {...(props as any)} stats={(props.stats as any) ?? []} />,
68
+ },
69
+
70
+ HowItWorks: {
71
+ fields: {},
72
+ defaultProps: {
73
+ bgToken: 'none',
74
+ title: 'How it works',
75
+ steps: [
76
+ { label: 'Connect your site', description: 'Point ONE at your domain — takes 60 seconds.', detail: 'No code required' },
77
+ { label: 'Add your agents', description: 'Pick from the library or describe one in plain English.', detail: 'Works with any model' },
78
+ { label: 'Go live', description: 'Publish your page. Agents handle the rest.', detail: 'Scales automatically' },
79
+ ],
80
+ },
81
+ render: (props: Record<string, unknown>) => <HowItWorks {...(props as any)} steps={(props.steps as any) ?? []} />,
82
+ },
83
+
84
+ LandingFeatures: {
85
+ fields: {},
86
+ defaultProps: {
87
+ bgToken: 'none',
88
+ title: 'Everything you need',
89
+ subtitle: 'One platform, every capability.',
90
+ columns: 3,
91
+ features: [
92
+ { headline: 'AI agents built in', body: 'Deploy agents for sales, support, and content — no integrations required.' },
93
+ { headline: 'Pages that convert', body: 'Drag-and-drop blocks built for every funnel stage.' },
94
+ { headline: 'Payments included', body: 'Accept crypto and card in minutes, globally.' },
95
+ ],
96
+ },
97
+ render: (props: Record<string, unknown>) => <LandingFeatures {...(props as any)} features={(props.features as any) ?? []} />,
98
+ },
99
+
100
+ Testimonials: {
101
+ fields: {},
102
+ defaultProps: {
103
+ bgToken: 'none',
104
+ title: 'What our customers say',
105
+ testimonials: [
106
+ { quote: 'ONE cut our onboarding time in half. The agents handle everything we used to do manually.', name: 'Sarah O.', role: 'Head of Growth', company: 'Elevate Labs' },
107
+ { quote: 'We launched our course site in a weekend. The drag-and-drop editor is genuinely impressive.', name: 'Marco D.', role: 'Founder', company: 'Skill Stack' },
108
+ ],
109
+ },
110
+ render: (props: Record<string, unknown>) => <Testimonials {...(props as any)} testimonials={(props.testimonials as any) ?? []} />,
111
+ },
112
+
113
+ PricingSection: {
114
+ fields: {},
115
+ defaultProps: {
116
+ title: 'Simple, transparent pricing',
117
+ subtitle: 'Start free. Scale as you grow.',
118
+ tiers: [
119
+ { name: 'Free', monthlyPrice: '$0', annualPrice: '$0', description: 'Everything you need to get started.', cta: 'Get started', ctaHref: '#' },
120
+ { name: 'Pro', monthlyPrice: '$49', annualPrice: '$39', description: 'For teams that want more power and scale.', cta: 'Start free trial', ctaHref: '#' },
121
+ ],
122
+ },
123
+ render: (props: Record<string, unknown>) => <PricingSection {...(props as any)} tiers={(props.tiers as any) ?? []} />,
124
+ },
125
+
126
+ FAQSection: {
127
+ fields: {},
128
+ defaultProps: {
129
+ bgToken: 'none',
130
+ title: 'Frequently asked questions',
131
+ faqs: [
132
+ { q: 'Do I need to know how to code?', a: 'No. ONE is designed for non-technical founders and marketers. Everything is drag-and-drop or describe-in-plain-English.' },
133
+ { q: 'Can I use my own domain?', a: 'Yes. Connect any domain in under a minute from your settings panel.' },
134
+ ],
135
+ },
136
+ render: (props: Record<string, unknown>) => <FAQSection {...(props as any)} faqs={(props.faqs as any) ?? []} />,
137
+ },
138
+
139
+ SecondaryCTA: {
140
+ fields: {},
141
+ defaultProps: {
142
+ bgToken: 'none',
143
+ headline: 'Ready to start?',
144
+ primaryCta: { label: 'Get started', href: '#' },
145
+ },
146
+ render: (props: Record<string, unknown>) => (
147
+ <SecondaryCTA
148
+ {...(props as any)}
149
+ primaryCta={(props.primaryCta as any) ?? { label: 'Get started', href: '#' }}
150
+ secondaryCta={(props.secondaryCta as any)?.label ? (props.secondaryCta as any) : undefined}
151
+ />
152
+ ),
153
+ },
154
+
155
+ FooterSection: {
156
+ fields: {},
157
+ defaultProps: {
158
+ brand: 'ONE',
159
+ links: [
160
+ { label: 'About', href: '#' },
161
+ { label: 'Features', href: '#' },
162
+ { label: 'Pricing', href: '#' },
163
+ { label: 'Contact', href: '#' },
164
+ ],
165
+ socials: [
166
+ { platform: 'twitter', href: '#' },
167
+ { platform: 'instagram', href: '#' },
168
+ ],
169
+ copyright: '© ONE, made for better web.',
170
+ },
171
+ render: (props: Record<string, unknown>) => (
172
+ <FooterSection
173
+ brand={props.brand as string}
174
+ links={(props.links as any) ?? []}
175
+ socials={(props.socials as any) ?? []}
176
+ copyright={props.copyright as string}
177
+ />
178
+ ),
179
+ },
180
+ },
181
+ }
@@ -0,0 +1,58 @@
1
+ // Reads one published page from a ONE workspace via the public `pages:view`
2
+ // receiver, over the existing /api/ask/:receiver dispatch. Draft/unpublished pages
3
+ // are filtered SERVER-SIDE (status='published' in the resolver SQL) — this helper
4
+ // never sees them; it returns null for any not-ok / error / non-result outcome.
5
+ //
6
+ // The /api/ask edge is gateway-guarded (isGatewayRequest): a foreign-origin,
7
+ // keyless request is 403'd unless GATEWAY_SERVICE_SECRET is unset on the target OR
8
+ // a Bearer token is present. `opts.apiKey` sends a Bearer so an external site can
9
+ // reach the receiver when the guard is armed. See text/page-editor-external-plan.md.
10
+ import type { PuckData } from './PageRenderer'
11
+
12
+ export type { PuckData }
13
+
14
+ export interface FetchPageOptions {
15
+ /** Origin of the ONE deploy serving /api/ask. Default: https://one.ie */
16
+ baseUrl?: string
17
+ /** Optional workspace world-key sent as `Authorization: Bearer` when the ask edge guard is armed. */
18
+ apiKey?: string
19
+ /** Injectable fetch (tests). Default: the global fetch. */
20
+ fetchImpl?: typeof fetch
21
+ }
22
+
23
+ interface AskEnvelope {
24
+ outcome?: string
25
+ result?: { ok?: boolean; data?: unknown; error?: string }
26
+ }
27
+
28
+ export async function fetchPage(
29
+ ws: string,
30
+ slug: string,
31
+ opts: FetchPageOptions = {},
32
+ ): Promise<PuckData | null> {
33
+ const baseUrl = (opts.baseUrl ?? 'https://one.ie').replace(/\/$/, '')
34
+ const doFetch = opts.fetchImpl ?? fetch
35
+ const headers: Record<string, string> = { 'content-type': 'application/json' }
36
+ if (opts.apiKey) headers['authorization'] = `Bearer ${opts.apiKey}`
37
+
38
+ let res: Response
39
+ try {
40
+ res = await doFetch(`${baseUrl}/api/ask/pages:view`, {
41
+ method: 'POST',
42
+ headers,
43
+ body: JSON.stringify({ data: { slug: ws, page: slug } }),
44
+ })
45
+ } catch {
46
+ return null
47
+ }
48
+ if (!res.ok) return null
49
+
50
+ let env: AskEnvelope
51
+ try {
52
+ env = (await res.json()) as AskEnvelope
53
+ } catch {
54
+ return null
55
+ }
56
+ if (env.outcome !== 'result' || !env.result?.ok) return null
57
+ return (env.result.data ?? null) as PuckData | null
58
+ }
package/src/index.ts ADDED
@@ -0,0 +1,48 @@
1
+ import type { AstroIntegration } from 'astro'
2
+ import type { OnePluginFactory } from '@oneie/frontend'
3
+
4
+ export interface OnePagesConfig {
5
+ /** Workspace slug whose published pages this site renders. */
6
+ ws?: string
7
+ /** Inject the /p/[slug] route automatically. Default: true. */
8
+ injectRoutes?: boolean
9
+ }
10
+
11
+ const defaults: Required<OnePagesConfig> = {
12
+ ws: '',
13
+ injectRoutes: true,
14
+ }
15
+
16
+ export const pages: OnePluginFactory<OnePagesConfig> = (config = {}) => {
17
+ const resolved: Required<OnePagesConfig> = { ...defaults, ...config }
18
+
19
+ const integration = (_cfg: OnePagesConfig): AstroIntegration => ({
20
+ name: '@oneie/plugin-pages',
21
+ hooks: {
22
+ 'astro:config:setup': ({ injectRoute }) => {
23
+ if (resolved.injectRoutes) {
24
+ injectRoute({
25
+ pattern: '/p/[slug]',
26
+ entrypoint: '@oneie/plugin-pages/PageRoute.astro',
27
+ })
28
+ }
29
+ },
30
+ },
31
+ })
32
+
33
+ return {
34
+ name: 'plugin-pages',
35
+ tier: 'free',
36
+ config: undefined,
37
+ integration,
38
+ entitlement: undefined,
39
+ serves: undefined,
40
+ }
41
+ }
42
+
43
+ export { PageRenderer } from './PageRenderer'
44
+ export type { PageRendererProps, PuckData } from './PageRenderer'
45
+ export { fetchPage } from './fetchPage'
46
+ export type { FetchPageOptions } from './fetchPage'
47
+ export { pagesPuckConfig } from './config'
48
+ export type { OnePagesConfig as PagesConfig }
@@ -0,0 +1,20 @@
1
+ // Ported verbatim from one.ie/web/src/lib/safe-url.ts
2
+ //
3
+ // Allowlist a URL's scheme before it is used as an href. Puck page content is
4
+ // workspace-owner-authored but rendered to the public with no login — a
5
+ // javascript:/data:/vbscript: URL stored in an href would execute script in
6
+ // every visitor's browser on every site that installs this package. Returns
7
+ // the URL only when it parses to a safe scheme, otherwise undefined so the
8
+ // caller drops the link.
9
+ const SAFE_SCHEMES = new Set(['http:', 'https:', 'mailto:', 'tel:'])
10
+
11
+ export function safeHref(url?: string | null): string | undefined {
12
+ if (!url) return undefined
13
+ try {
14
+ const base = typeof window !== 'undefined' ? window.location.origin : 'https://one.ie'
15
+ const parsed = new URL(url, base)
16
+ return SAFE_SCHEMES.has(parsed.protocol) ? url : undefined
17
+ } catch {
18
+ return undefined
19
+ }
20
+ }
@@ -0,0 +1,6 @@
1
+ // Ported verbatim from one.ie/web/src/lib/ui-signal.ts — window-only CustomEvent
2
+ // dispatch, zero substrate coupling, safe on any external site.
3
+ export function emitClick(receiver: string, payload?: unknown): void {
4
+ if (typeof window === 'undefined') return
5
+ window.dispatchEvent(new CustomEvent('ui:click', { detail: { receiver, payload } }))
6
+ }
@@ -0,0 +1,7 @@
1
+ // Ported verbatim from one.ie/web/src/lib/utils.ts
2
+ import { clsx, type ClassValue } from 'clsx'
3
+ import { twMerge } from 'tailwind-merge'
4
+
5
+ export function cn(...inputs: ClassValue[]) {
6
+ return twMerge(clsx(inputs))
7
+ }