@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 +47 -0
- package/dist/ContactForm.d.ts +17 -0
- package/dist/ContactForm.js +49 -0
- package/dist/LeadForm.d.ts +16 -0
- package/dist/LeadForm.js +89 -0
- package/dist/PostBody.d.ts +28 -0
- package/dist/PostBody.js +37 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/islands.d.ts +15 -0
- package/dist/islands.js +106 -0
- package/package.json +49 -0
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;
|
package/dist/LeadForm.js
ADDED
|
@@ -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;
|
package/dist/PostBody.js
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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,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 {};
|
package/dist/islands.js
ADDED
|
@@ -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
|
+
}
|