@nukipa/post-renderer-react 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/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # @nukipa/post-renderer-react
2
+
3
+ React adapter for `@nukipa/post-content`. Renders a CMS post body, then
4
+ hydrates interactive islands (lead-gen forms, contact forms, CTA click
5
+ tracking, basic carousels).
6
+
7
+ ```tsx
8
+ import { PostBody } from '@nukipa/post-renderer-react';
9
+ import { createNukipaClient } from '@nukipa/site-sdk';
10
+
11
+ const client = createNukipaClient({ /* ... */ });
12
+
13
+ <PostBody
14
+ body={post.body}
15
+ components={post.components}
16
+ sources={post.sources}
17
+ postId={post.id}
18
+ lang={post.language}
19
+ client={client}
20
+ />
21
+ ```
22
+
23
+ ## What it hydrates
24
+
25
+ | Island | Behaviour |
26
+ | ------------------ | -------------------------------------------------------------------- |
27
+ | `cta` | Click-track via `client.recordCtaClick`. Anchor stays SSR-rendered. |
28
+ | `form` | Mounts `<LeadForm>` against the form slug. |
29
+ | `contact-form` | Mounts `<ContactForm>` reading the schema from the DOM. |
30
+ | `carousel` | Prev/next handlers, no autoplay. |
31
+ | `chart`, `widget` | Not hydrated — placeholder HTML only. Wire per-tenant if needed. |
32
+
33
+ The CTA anchor is hybrid by design: search engines and no-JS clients
34
+ follow the real `href`; the click-tracker is purely additive on hydration.
35
+
36
+ ## Pass `client`
37
+
38
+ If `client` is omitted, interactive islands silently no-op — the static
39
+ HTML still renders correctly. Pass `client` when you want forms to submit
40
+ and CTA pings to fire.
41
+
42
+ ## Styling
43
+
44
+ All static blocks use `bp-*` classes from `@nukipa/post-content`. Forms
45
+ use BEM-ish `lead-form__*` / `contact-form__*` classes. Lift the relevant
46
+ CSS from `apps/public/app/components/BlogArticle.vue` (scoped style block)
47
+ into your tenant site.
@@ -0,0 +1,17 @@
1
+ export interface ContactFormProps {
2
+ /**
3
+ * Same-origin endpoint that relays to
4
+ * `POST /public/v1/posts/:postId/contact-form-submissions`. The host's
5
+ * route handler is responsible for resolving the postId from the URL.
6
+ * Defaults to `/api/contact-form-submissions/:postId` (caller substitutes
7
+ * the postId before passing).
8
+ */
9
+ endpoint: string;
10
+ componentId: string;
11
+ title?: string | null;
12
+ description?: string | null;
13
+ submitLabel?: string | null;
14
+ successMessage?: string | null;
15
+ fields: Array<Record<string, unknown>>;
16
+ }
17
+ export declare function ContactForm({ endpoint, componentId, title, description, submitLabel, successMessage, fields }: ContactFormProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,49 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useState } from 'react';
4
+ export function ContactForm({ endpoint, componentId, title, description, submitLabel, successMessage, fields }) {
5
+ const [values, setValues] = useState({});
6
+ const [busy, setBusy] = useState(false);
7
+ const [error, setError] = useState(null);
8
+ const [success, setSuccess] = useState(null);
9
+ if (success)
10
+ return _jsx("div", { className: "contact-form contact-form--done", children: _jsx("p", { children: success }) });
11
+ async function onSubmit(e) {
12
+ e.preventDefault();
13
+ if (busy)
14
+ return;
15
+ setBusy(true);
16
+ setError(null);
17
+ try {
18
+ const res = await fetch(endpoint, {
19
+ method: 'POST',
20
+ headers: { 'Content-Type': 'application/json' },
21
+ body: JSON.stringify({ component_id: componentId, values })
22
+ });
23
+ if (!res.ok) {
24
+ const data = await res.json().catch(() => ({}));
25
+ throw new Error(data?.error?.message || 'Submission failed');
26
+ }
27
+ setSuccess(successMessage || 'Thanks — we got your message.');
28
+ }
29
+ catch (err) {
30
+ setError(err instanceof Error ? err.message : 'Submission failed');
31
+ }
32
+ finally {
33
+ setBusy(false);
34
+ }
35
+ }
36
+ return (_jsxs("form", { className: "contact-form", onSubmit: onSubmit, noValidate: true, children: [title && _jsx("h3", { className: "contact-form__title", children: title }), description && _jsx("p", { className: "contact-form__desc", children: description }), fields.map((raw) => {
37
+ const f = raw;
38
+ const id = `contact-${componentId}-${f.key}`;
39
+ const onChange = (e) => {
40
+ const v = e.target.type === 'checkbox'
41
+ ? e.target.checked
42
+ : e.target.value;
43
+ setValues((s) => ({ ...s, [f.key]: v }));
44
+ };
45
+ return (_jsxs("div", { className: "contact-form__field", children: [f.label && _jsx("label", { htmlFor: id, children: f.label }), f.type === 'textarea'
46
+ ? _jsx("textarea", { id: id, name: f.key, required: f.required, value: String(values[f.key] ?? ''), onChange: onChange })
47
+ : _jsx("input", { id: id, name: f.key, type: f.type === 'email' ? 'email' : f.type === 'phone' ? 'tel' : f.type === 'number' ? 'number' : 'text', required: f.required, value: String(values[f.key] ?? ''), onChange: onChange })] }, f.key));
48
+ }), error && _jsx("p", { className: "contact-form__error", role: "alert", children: error }), _jsx("button", { type: "submit", disabled: busy, className: "contact-form__submit", children: busy ? '…' : (submitLabel || 'Send') })] }));
49
+ }
@@ -0,0 +1,16 @@
1
+ export interface LeadFormProps {
2
+ /** Form slug — must match a `cms.forms` row for the resolved tenant. */
3
+ slug: string;
4
+ /**
5
+ * Same-origin endpoint the host owns. Defaults to `/api/forms`. The
6
+ * handler:
7
+ * - GET `${endpoint}/:slug` → relays to /public/v1/forms/:slug
8
+ * - POST `${endpoint}` → relays to /public/v1/forms/:slug/submit
9
+ * with body `{ slug, ...values }`.
10
+ */
11
+ endpoint?: string;
12
+ title?: string | null;
13
+ description?: string | null;
14
+ submitLabel?: string | null;
15
+ }
16
+ export declare function LeadForm({ slug, endpoint, title, description, submitLabel }: LeadFormProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,89 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useState } from 'react';
4
+ export function LeadForm({ slug, endpoint = '/api/forms', title, description, submitLabel }) {
5
+ const [form, setForm] = useState(null);
6
+ const [values, setValues] = useState({});
7
+ const [busy, setBusy] = useState(false);
8
+ const [error, setError] = useState(null);
9
+ const [success, setSuccess] = useState(null);
10
+ useEffect(() => {
11
+ let cancelled = false;
12
+ fetch(`${endpoint}/${encodeURIComponent(slug)}`)
13
+ .then((r) => (r.ok ? r.json() : null))
14
+ .then((j) => {
15
+ if (cancelled || !j)
16
+ return;
17
+ const f = (j.data ?? j);
18
+ setForm(f);
19
+ // Seed values so checkbox/text aren't undefined.
20
+ const seed = {};
21
+ for (const field of f.fields || []) {
22
+ seed[field.key] = field.type === 'checkbox' ? false : '';
23
+ }
24
+ setValues(seed);
25
+ })
26
+ .catch(() => { });
27
+ return () => { cancelled = true; };
28
+ }, [slug, endpoint]);
29
+ if (!form)
30
+ return _jsx("div", { className: "lead-form lead-form--loading" });
31
+ if (success)
32
+ return _jsx("div", { className: "lead-form lead-form--done", children: _jsx("p", { children: success }) });
33
+ async function onSubmit(e) {
34
+ e.preventDefault();
35
+ if (busy)
36
+ return;
37
+ setBusy(true);
38
+ setError(null);
39
+ try {
40
+ const res = await fetch(endpoint, {
41
+ method: 'POST',
42
+ headers: { 'Content-Type': 'application/json' },
43
+ body: JSON.stringify({ slug, ...values })
44
+ });
45
+ const data = await res.json().catch(() => ({}));
46
+ if (!res.ok) {
47
+ throw new Error(data?.error?.message || 'Submission failed');
48
+ }
49
+ const onSubmitCfg = form?.on_submit;
50
+ if (data?.redirect_url) {
51
+ window.location.href = data.redirect_url;
52
+ return;
53
+ }
54
+ if (onSubmitCfg?.redirect_url) {
55
+ window.location.href = onSubmitCfg.redirect_url;
56
+ return;
57
+ }
58
+ setSuccess(data?.success_message || onSubmitCfg?.success_message || 'Thanks — we got your message.');
59
+ }
60
+ catch (err) {
61
+ setError(err instanceof Error ? err.message : 'Submission failed');
62
+ }
63
+ finally {
64
+ setBusy(false);
65
+ }
66
+ }
67
+ return (_jsxs("form", { className: "lead-form", onSubmit: onSubmit, noValidate: true, children: [(title || form.name) && _jsx("h3", { className: "lead-form-title", children: title || form.name }), description && _jsx("p", { className: "lead-form-description", children: description }), form.fields.map((field) => {
68
+ const id = `lead-${slug}-${field.key}`;
69
+ const onChange = (e) => {
70
+ const v = e.target.type === 'checkbox'
71
+ ? e.target.checked
72
+ : e.target.value;
73
+ setValues((s) => ({ ...s, [field.key]: v }));
74
+ };
75
+ return (_jsxs("label", { className: `lead-form-field${field.type === 'checkbox' ? ' lead-form-field--checkbox' : ''}`, children: [_jsxs("span", { className: "lead-form-label", children: [field.label, field.required && _jsx("span", { className: "lead-form-required", children: "*" })] }), field.type === 'textarea' ? (_jsx("textarea", { id: id, name: field.key, required: field.required, rows: 4, className: "lead-form-textarea", value: String(values[field.key] ?? ''), onChange: onChange })) : field.type === 'select' ? (_jsxs("select", { id: id, name: field.key, required: field.required, className: "lead-form-input", value: String(values[field.key] ?? ''), onChange: onChange, children: [_jsx("option", { value: "", disabled: true, children: "\u2014" }), (field.options || []).map((opt) => {
76
+ const v = typeof opt === 'string' ? opt : opt.value;
77
+ const l = typeof opt === 'string' ? opt : opt.label;
78
+ return _jsx("option", { value: v, children: l }, v);
79
+ })] })) : field.type === 'checkbox' ? (_jsx("input", { id: id, name: field.key, type: "checkbox", className: "lead-form-checkbox", checked: Boolean(values[field.key]), onChange: onChange })) : (_jsx("input", { id: id, name: field.key, type: htmlInputType(field.type), required: field.required, className: "lead-form-input", value: String(values[field.key] ?? ''), onChange: onChange }))] }, field.key));
80
+ }), error && _jsx("p", { className: "lead-form-error", role: "alert", children: error }), _jsx("button", { type: "submit", className: "lead-form-submit", disabled: busy, children: busy ? '…' : (submitLabel || 'Send') })] }));
81
+ }
82
+ function htmlInputType(t) {
83
+ switch (t) {
84
+ case 'email': return 'email';
85
+ case 'phone': return 'tel';
86
+ case 'number': return 'number';
87
+ default: return 'text';
88
+ }
89
+ }
@@ -0,0 +1,28 @@
1
+ import { type PostComponent, type PostSource } from '@nukipa/post-content';
2
+ import { type IslandEndpoints } from './islands';
3
+ export interface PostBodyProps {
4
+ body: string;
5
+ components?: PostComponent[];
6
+ sources?: PostSource[];
7
+ postId?: string | null;
8
+ lang?: string;
9
+ /**
10
+ * Same-origin relay endpoints the host app exposes. Defaults match the
11
+ * Next.js template's route handlers under `app/api/*`. The host's
12
+ * server-side handler forwards to the gateway with `X-Forwarded-Host`
13
+ * attached — the browser can't set that header itself.
14
+ */
15
+ endpoints?: Partial<IslandEndpoints>;
16
+ className?: string;
17
+ }
18
+ /**
19
+ * Render a CMS post body. Server-renders the HTML directly via
20
+ * `dangerouslySetInnerHTML`; client-hydrates interactive islands
21
+ * (lead-gen forms, contact forms, CTA click tracking, basic carousels)
22
+ * after mount.
23
+ *
24
+ * Forms and CTA clicks POST to same-origin relay routes the host app
25
+ * owns — see `endpoints` prop. The defaults match the relays in
26
+ * `templates/site-nextjs/app/api/`.
27
+ */
28
+ export declare function PostBody({ body, components, sources, postId, lang, endpoints, className }: PostBodyProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,37 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useEffect, useRef } from 'react';
4
+ import { renderPostBody } from '@nukipa/post-content';
5
+ import { hydrateIslands } from './islands';
6
+ const DEFAULT_ENDPOINTS = {
7
+ forms: '/api/forms',
8
+ ctaClicks: '/api/cta-clicks',
9
+ contactForms: '/api/contact-form-submissions'
10
+ };
11
+ /**
12
+ * Render a CMS post body. Server-renders the HTML directly via
13
+ * `dangerouslySetInnerHTML`; client-hydrates interactive islands
14
+ * (lead-gen forms, contact forms, CTA click tracking, basic carousels)
15
+ * after mount.
16
+ *
17
+ * Forms and CTA clicks POST to same-origin relay routes the host app
18
+ * owns — see `endpoints` prop. The defaults match the relays in
19
+ * `templates/site-nextjs/app/api/`.
20
+ */
21
+ export function PostBody({ body, components = [], sources = [], postId = null, lang, endpoints, className }) {
22
+ const ref = useRef(null);
23
+ const { html, mounts } = renderPostBody({
24
+ body,
25
+ components,
26
+ sources,
27
+ options: { lang, postId }
28
+ });
29
+ const merged = { ...DEFAULT_ENDPOINTS, ...(endpoints || {}) };
30
+ useEffect(() => {
31
+ if (!ref.current)
32
+ return undefined;
33
+ return hydrateIslands(ref.current, mounts, { endpoints: merged, postId });
34
+ // eslint-disable-next-line react-hooks/exhaustive-deps
35
+ }, [body, postId, merged.forms, merged.ctaClicks, merged.contactForms]);
36
+ return (_jsx("div", { ref: ref, className: className ?? 'prose-body', dangerouslySetInnerHTML: { __html: html } }));
37
+ }
@@ -0,0 +1,6 @@
1
+ export { PostBody } from './PostBody';
2
+ export { LeadForm } from './LeadForm';
3
+ export { ContactForm } from './ContactForm';
4
+ export { renderSourcesList } from '@nukipa/post-content';
5
+ export type { PostBodyProps } from './PostBody';
6
+ export type { IslandEndpoints } from './islands';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { PostBody } from './PostBody';
2
+ export { LeadForm } from './LeadForm';
3
+ export { ContactForm } from './ContactForm';
4
+ export { renderSourcesList } from '@nukipa/post-content';
@@ -0,0 +1,15 @@
1
+ import type { Mount } from '@nukipa/post-content';
2
+ export interface IslandEndpoints {
3
+ /** POST { slug, ...values } — relay to /public/v1/forms/:slug/submit. */
4
+ forms: string;
5
+ /** POST { cta_id, cta_label, cta_url, post_id, page_path, ... }. */
6
+ ctaClicks: string;
7
+ /** POST { component_id, values } scoped to a postId in the URL: `${contactForms}/${postId}`. */
8
+ contactForms: string;
9
+ }
10
+ interface HydrationContext {
11
+ endpoints: IslandEndpoints;
12
+ postId: string | null;
13
+ }
14
+ export declare function hydrateIslands(root: HTMLElement, mounts: Mount[], ctx: HydrationContext): () => void;
15
+ export {};
@@ -0,0 +1,106 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ // Island hydration for the post body.
4
+ //
5
+ // `hydrateIslands` walks the rendered HTML for `data-island="..."` markers
6
+ // and attaches behaviour. Returns a cleanup function that unbinds every
7
+ // listener and unmounts every React root — call from a useEffect cleanup.
8
+ //
9
+ // Forms and CTA clicks POST to same-origin relay routes (the host app's
10
+ // server-side handler attaches `X-Forwarded-Host` and forwards to the
11
+ // gateway). Browser-direct calls to the gateway aren't viable: gateway
12
+ // CORS uses an allowlist, and `X-Forwarded-Host` is not in `allowedHeaders`
13
+ // so a browser couldn't spoof its own host anyway.
14
+ import { createRoot } from 'react-dom/client';
15
+ import { LeadForm } from './LeadForm';
16
+ import { ContactForm } from './ContactForm';
17
+ export function hydrateIslands(root, mounts, ctx) {
18
+ const cleanups = [];
19
+ // CTAs: enhance the existing <a> with a click tracker. We don't replace
20
+ // the link — server-rendered HTML stays the source of truth so SEO + no-JS
21
+ // clients still navigate. Use sendBeacon when available (survives unload).
22
+ const anchors = root.querySelectorAll('[data-island="cta"] a[data-cta-id]');
23
+ anchors.forEach((a) => {
24
+ const onClick = () => {
25
+ try {
26
+ const payload = JSON.stringify({
27
+ cta_id: a.getAttribute('data-cta-id'),
28
+ cta_label: a.getAttribute('data-cta-label'),
29
+ cta_url: a.getAttribute('href'),
30
+ post_id: ctx.postId,
31
+ page_path: typeof window !== 'undefined' ? window.location.pathname : null,
32
+ referrer: typeof document !== 'undefined' ? document.referrer || null : null,
33
+ user_agent: typeof navigator !== 'undefined' ? navigator.userAgent : null
34
+ });
35
+ if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
36
+ navigator.sendBeacon(ctx.endpoints.ctaClicks, new Blob([payload], { type: 'application/json' }));
37
+ }
38
+ else {
39
+ fetch(ctx.endpoints.ctaClicks, {
40
+ method: 'POST',
41
+ body: payload,
42
+ headers: { 'Content-Type': 'application/json' },
43
+ keepalive: true
44
+ }).catch(() => { });
45
+ }
46
+ }
47
+ catch { /* never block navigation */ }
48
+ };
49
+ a.addEventListener('click', onClick, { passive: true });
50
+ cleanups.push(() => a.removeEventListener('click', onClick));
51
+ });
52
+ // Lead-gen forms.
53
+ const leadEls = root.querySelectorAll('[data-island="form"]');
54
+ leadEls.forEach((el) => {
55
+ const slug = el.getAttribute('data-form-slug');
56
+ if (!slug)
57
+ return;
58
+ const r = createRoot(el);
59
+ r.render(_jsx(LeadForm, { slug: slug, endpoint: ctx.endpoints.forms, title: el.getAttribute('data-form-title'), description: el.getAttribute('data-form-description'), submitLabel: el.getAttribute('data-form-submit-label') }));
60
+ cleanups.push(() => r.unmount());
61
+ });
62
+ // Contact forms (inline schema on the post component).
63
+ const contactEls = root.querySelectorAll('[data-island="contact-form"]');
64
+ contactEls.forEach((el) => {
65
+ const componentId = el.getAttribute('data-component-id') || '';
66
+ const raw = el.getAttribute('data-content') || '{}';
67
+ let content = {};
68
+ try {
69
+ content = JSON.parse(raw);
70
+ }
71
+ catch { /* ignore */ }
72
+ if (!ctx.postId)
73
+ return;
74
+ const r = createRoot(el);
75
+ r.render(_jsx(ContactForm, { endpoint: `${ctx.endpoints.contactForms}/${ctx.postId}`, componentId: componentId, title: content.title ?? null, description: content.description ?? null, submitLabel: content.submit_label ?? null, successMessage: content.success_message ?? null, fields: content.fields ?? [] }));
76
+ cleanups.push(() => r.unmount());
77
+ });
78
+ // Carousel: minimal prev/next behaviour, no autoplay.
79
+ const carousels = root.querySelectorAll('[data-island="carousel"]');
80
+ carousels.forEach((el) => {
81
+ const slides = Array.from(el.querySelectorAll('.bp-carousel__slide'));
82
+ if (slides.length === 0)
83
+ return;
84
+ let active = slides.findIndex((s) => s.classList.contains('active'));
85
+ if (active < 0)
86
+ active = 0;
87
+ const setActive = (i) => {
88
+ const next = (i + slides.length) % slides.length;
89
+ slides[active].classList.remove('active');
90
+ slides[next].classList.add('active');
91
+ active = next;
92
+ };
93
+ const prev = el.querySelector('[data-carousel-prev]');
94
+ const next = el.querySelector('[data-carousel-next]');
95
+ const onPrev = () => setActive(active - 1);
96
+ const onNext = () => setActive(active + 1);
97
+ prev?.addEventListener('click', onPrev);
98
+ next?.addEventListener('click', onNext);
99
+ cleanups.push(() => {
100
+ prev?.removeEventListener('click', onPrev);
101
+ next?.removeEventListener('click', onNext);
102
+ });
103
+ });
104
+ return () => { for (const fn of cleanups)
105
+ fn(); };
106
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@nukipa/post-renderer-react",
3
+ "version": "0.1.0",
4
+ "description": "React adapter for @nukipa/post-content — renders Nukipa CMS post bodies and hydrates interactive islands (forms, CTAs, carousels).",
5
+ "license": "MIT",
6
+ "author": "Nukipa Labs",
7
+ "homepage": "https://github.com/nukipa-labs/nukipa/tree/main/packages/post-renderer-react",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/nukipa-labs/nukipa.git",
11
+ "directory": "packages/post-renderer-react"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/nukipa-labs/nukipa/issues"
15
+ },
16
+ "keywords": ["nukipa", "cms", "react", "renderer", "post"],
17
+ "type": "module",
18
+ "main": "./dist/index.js",
19
+ "types": "./dist/index.d.ts",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "import": "./dist/index.js",
24
+ "default":"./dist/index.js"
25
+ }
26
+ },
27
+ "files": ["dist", "README.md"],
28
+ "scripts": {
29
+ "build": "tsc -p tsconfig.build.json",
30
+ "clean": "rm -rf dist",
31
+ "prepublishOnly": "npm run clean && npm run build"
32
+ },
33
+ "dependencies": {
34
+ "@nukipa/post-content": "^0.1.0"
35
+ },
36
+ "peerDependencies": {
37
+ "marked": "^17.0.0",
38
+ "react": "^19.0.0",
39
+ "react-dom": "^19.0.0"
40
+ },
41
+ "devDependencies": {
42
+ "@types/react": "^19.0.0",
43
+ "@types/react-dom": "^19.0.0",
44
+ "typescript": "^5.7.0"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public"
48
+ }
49
+ }