Skip to content

Commit e182ba6

Browse files
authored
feat: wire per-field CSS class hook + refresh README (captcha, hidden field, styling) (#71)
1 parent 8e658a6 commit e182ba6

3 files changed

Lines changed: 88 additions & 6 deletions

File tree

README.md

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ A visual drag-and-drop form builder plugin for [Strapi 5](https://strapi.io). Cr
1313

1414
- **Drag-and-drop builder** — reorder fields visually inside the Strapi admin.
1515
- **Starter templates** — begin from Contact, Newsletter, Feedback, Job application, Event RSVP, or a blank form.
16-
- **Rich field types** — text, email, number, phone, URL, password, date, time, textarea, select, radio, checkbox, checkbox-group, heading, paragraph, divider.
16+
- **Rich field types** — text, email, number, phone, URL, password, date, time, textarea, select, radio, checkbox, checkbox-group, hidden, heading, paragraph, divider.
1717
- **Field validation** — required, minLength, maxLength, min value, max value, email, URL, regex pattern — all configurable per field with custom error messages.
1818
- **Draft / Publish** — draft edits stay private; the public page and embed always serve the last **published** version, so saving a draft never affects the live form.
1919
- **Public page** — one toggle generates a hosted page at `/api/strapi-plugin-form-builder-cms/page/:slug` (available once the form is published).
2020
- **Embed script** — a single `<script>` tag renders the form on any external site with zero dependencies.
2121
- **Spam & limits** — honeypot field plus optional per-IP hourly rate limiting.
22+
- **CAPTCHA** — optional Cloudflare Turnstile or Google reCAPTCHA v2 per form, verified server-side (fail-closed). The secret key never reaches the browser.
23+
- **Hidden tracking fields** — capture UTM parameters, referrers or campaign IDs from the page URL into every submission.
2224
- **Submission inbox** — search, filter by status (new / read / archived), bulk mark-as-read / archive / delete, per-form column picker, a detail drawer with next/previous navigation, and CSV export.
2325
- **Form preview** — preview the rendered form inside the admin before publishing.
2426

@@ -152,6 +154,12 @@ These routes require no authentication and are safe to call from the browser.
152154
}
153155
```
154156

157+
### Submit response — CAPTCHA failure (`400`) or rate limit (`429`)
158+
159+
```json
160+
{ "success": false, "message": "Captcha verification failed. Please try again." }
161+
```
162+
155163
---
156164

157165
## Field types
@@ -171,6 +179,7 @@ These routes require no authentication and are safe to call from the browser.
171179
| `radio` | Radio group — configure options in the settings panel |
172180
| `checkbox` | Single checkbox (agree / consent) |
173181
| `checkbox-group` | Multiple checkboxes — configure options in the settings panel |
182+
| `hidden` | Invisible tracking field — prefilled from a URL query parameter (e.g. `utm_source`) or a default value, then stored with the submission |
174183
| `heading` | Non-input heading label |
175184
| `paragraph` | Non-input descriptive text |
176185
| `divider` | Horizontal rule to separate sections |
@@ -204,10 +213,69 @@ Each rule accepts an optional **custom error message**. The same rule type canno
204213
| Public page | Generates a hosted page URL for the form (served once published) |
205214
| Honeypot protection | Adds a hidden field to silently discard bot submissions |
206215
| Rate limiting | Caps submissions per IP each hour (configurable max) |
216+
| CAPTCHA | None, Cloudflare Turnstile or Google reCAPTCHA v2 — with a **Test secret key** button to validate credentials before publishing |
207217
| Redirect URL | Where to send the visitor after a successful submit |
208218

209219
---
210220

221+
## CAPTCHA
222+
223+
Protect a form with a human-verification challenge. In the form settings drawer, under **Spam & limits → CAPTCHA**, pick a provider and paste your keys:
224+
225+
| Provider | Where to get keys |
226+
|---|---|
227+
| **Cloudflare Turnstile** | [Turnstile dashboard](https://dash.cloudflare.com/?to=/:account/turnstile) |
228+
| **Google reCAPTCHA v2** | [reCAPTCHA admin console](https://www.google.com/recaptcha/admin) |
229+
230+
- The **site key** is public — it is sent to the browser to render the widget.
231+
- The **secret key** is private — it is stored server-side and **never** exposed in the public schema. Use the **Test secret key** button to validate it against the provider before publishing.
232+
- Submissions are verified server-side and **fail closed**: an invalid or missing token is rejected with `400`, even if the client is bypassed.
233+
- If the widget can't load (e.g. an invalid site key), the visitor sees a clear message instead of a silent failure.
234+
235+
> The public page relaxes its Content-Security-Policy just enough to load the chosen provider's script and iframe — only when a CAPTCHA is configured.
236+
237+
## Hidden tracking fields
238+
239+
Add a **hidden** field to capture campaign data into every submission without showing anything to the visitor:
240+
241+
- **Name** — the key stored with the submission (e.g. `utm_source`).
242+
- **Prefill from URL parameter** — a query-string parameter to read from the page URL. If the form is opened at `?utm_source=google`, that value is captured automatically.
243+
- **Default value** — used when the URL parameter is absent.
244+
245+
The captured value is stored with the submission and included in the CSV export.
246+
247+
---
248+
249+
## Styling the embedded form
250+
251+
The embedded form renders in the host page's DOM (no shadow DOM), so you can restyle it from your own site.
252+
253+
**CSS variables** (the recommended, stable API):
254+
255+
```css
256+
#sfb-form-1 { /* the container div, or any ancestor */
257+
--sfb-accent: #e11d48;
258+
--sfb-radius: 12px;
259+
}
260+
```
261+
262+
**Or target the classes directly**`.sfb-form`, `.sfb-field`, `.sfb-label`, `.sfb-input`, `.sfb-btn`, `.sfb-help`, `.sfb-error`, `.sfb-success`. The plugin's rules use single-class specificity; to reliably win, scope your selector to the form container:
263+
264+
```css
265+
#sfb-form-1 .sfb-input { border-width: 2px; }
266+
```
267+
268+
**Per-field class** — give any field a **CSS class** in its settings panel (e.g. `newsletter-email`). It's added to that field's wrapper, so you can style one field from your own stylesheet:
269+
270+
```css
271+
.newsletter-email input { border-color: #e11d48; }
272+
.newsletter-email .sfb-label { font-weight: 700; }
273+
```
274+
275+
The class sits on the field's wrapper (not the `<input>`), so a descendant selector like `.newsletter-email input` naturally out-specifies the plugin's own rules — no `!important` needed.
276+
277+
---
278+
211279
## Development
212280

213281
Clone the repo and link the plugin to a local Strapi project:
@@ -227,6 +295,14 @@ In your local Strapi project:
227295
npm run develop
228296
```
229297

298+
Run the test suite (Vitest) and type-checks:
299+
300+
```bash
301+
npm test # unit tests
302+
npm run test:ts:back # type-check the server
303+
npm run test:ts:front # type-check the admin
304+
```
305+
230306
---
231307

232308
## Contributing
@@ -243,7 +319,9 @@ Pull requests are welcome. For major changes please open an issue first to discu
243319
## Roadmap
244320

245321
- [x] Export submissions as CSV
246-
- [ ] CAPTCHA / spam protection (Cloudflare Turnstile · reCAPTCHA)
322+
- [x] CAPTCHA / spam protection (Cloudflare Turnstile · reCAPTCHA v2)
323+
- [x] Hidden tracking fields (UTM / referrer capture)
324+
- [ ] Theming / style presets
247325
- [ ] Email notifications on new submission
248326
- [ ] File upload field type
249327
- [ ] Multi-step / wizard forms

admin/src/components/FormPreview.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -237,23 +237,23 @@ function PreviewField({ field }: { field: FormField }) {
237237

238238
if (field.type === 'divider') {
239239
return (
240-
<div style={{ gridColumn: '1 / -1' }}>
240+
<div className={field.cssClass || undefined} style={{ gridColumn: '1 / -1' }}>
241241
<hr style={{ border: 'none', borderTop: TOKEN.border, margin: '4px 0' }} />
242242
</div>
243243
);
244244
}
245245

246246
if (field.type === 'heading') {
247247
return (
248-
<div style={{ gridColumn: '1 / -1' }}>
248+
<div className={field.cssClass || undefined} style={{ gridColumn: '1 / -1' }}>
249249
<p style={{ fontSize: 18, fontWeight: 700, color: TOKEN.text, margin: 0 }}>{field.label || 'Heading'}</p>
250250
</div>
251251
);
252252
}
253253

254254
if (field.type === 'paragraph') {
255255
return (
256-
<div style={{ gridColumn: '1 / -1' }}>
256+
<div className={field.cssClass || undefined} style={{ gridColumn: '1 / -1' }}>
257257
<p style={{ fontSize: 14, color: TOKEN.sub, margin: 0, lineHeight: 1.6 }}>{field.label || 'Paragraph text'}</p>
258258
</div>
259259
);
@@ -269,7 +269,7 @@ function PreviewField({ field }: { field: FormField }) {
269269
default: content = <TextField field={field} />; break;
270270
}
271271

272-
return <div style={{ gridColumn: `span ${colSpan}` }}>{content}</div>;
272+
return <div className={field.cssClass || undefined} style={{ gridColumn: `span ${colSpan}` }}>{content}</div>;
273273
}
274274

275275
/* ── modal ──────────────────────────────────────────────────────────── */

server/src/controllers/form.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,13 +239,15 @@ function embedScript(): string {
239239
if (field.type === 'divider') {
240240
var hr = document.createElement('hr');
241241
hr.className = 'sfb-divider';
242+
if (field.cssClass) hr.className += ' ' + field.cssClass;
242243
hr.style.gridColumn = '1 / -1';
243244
return hr;
244245
}
245246
246247
if (field.type === 'heading') {
247248
var h = document.createElement('p');
248249
h.className = 'sfb-heading';
250+
if (field.cssClass) h.className += ' ' + field.cssClass;
249251
h.style.gridColumn = '1 / -1';
250252
h.textContent = field.label || '';
251253
return h;
@@ -254,13 +256,15 @@ function embedScript(): string {
254256
if (field.type === 'paragraph') {
255257
var p = document.createElement('p');
256258
p.className = 'sfb-paragraph';
259+
if (field.cssClass) p.className += ' ' + field.cssClass;
257260
p.style.gridColumn = '1 / -1';
258261
p.textContent = field.label || '';
259262
return p;
260263
}
261264
262265
var wrapper = document.createElement('div');
263266
wrapper.className = 'sfb-field';
267+
if (field.cssClass) wrapper.className += ' ' + field.cssClass;
264268
wrapper.setAttribute('data-field-name', field.name);
265269
if (field.width !== 'half') wrapper.style.gridColumn = '1 / -1';
266270

0 commit comments

Comments
 (0)