@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 +86 -0
- package/package.json +26 -0
- package/src/PageRenderer.tsx +17 -0
- package/src/PageRoute.astro +17 -0
- package/src/components/FAQSection.tsx +64 -0
- package/src/components/FooterSection.tsx +72 -0
- package/src/components/HowItWorks.tsx +56 -0
- package/src/components/Icon.tsx +25 -0
- package/src/components/IconBadge.tsx +62 -0
- package/src/components/LandingFeatures.tsx +61 -0
- package/src/components/LandingHero.tsx +113 -0
- package/src/components/PricingSection.tsx +120 -0
- package/src/components/ProofBar.tsx +56 -0
- package/src/components/SecondaryCTA.tsx +62 -0
- package/src/components/Separator.tsx +27 -0
- package/src/components/Testimonials.tsx +48 -0
- package/src/config.tsx +181 -0
- package/src/fetchPage.ts +58 -0
- package/src/index.ts +48 -0
- package/src/lib/safe-url.ts +20 -0
- package/src/lib/ui-signal.ts +6 -0
- package/src/lib/utils.ts +7 -0
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">"{leadQuote.quote}"</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">"{t.quote}"</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
|
+
}
|
package/src/fetchPage.ts
ADDED
|
@@ -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
|
+
}
|