Headless SDK: engagement components
Quizzes, spins, scratches, and predictions all share one hook: useEngagement. The hook returns the configured experience, the participant's state, and a submit handler that fires the right event with the right idempotency key.
Key takeaways
Quick read- One hook (useEngagement) handles every engagement type. Branch on engagement.type for type-specific UI.
- The hook polls config from the server. Builder edits propagate without a redeploy.
- submit fires the underlying event with the right idempotency key. Always pass an attemptId for retries.
- Result data (the spin segment, the quiz score) comes back in the submit response on the sync path.
- Always render an empty state. Engagements can be paused or deleted server-side.
Quickstart
A working spin in 35 lines
"use client";
import { useEngagement } from "@bricqs/headless-react";
export function SpinWheel({ engagementId }: { engagementId: string }) {
const { engagement, state, submit, isLoading } = useEngagement({
engagementId,
sync: true, // resolve the prize in the response
});
if (isLoading || !engagement) return null;
if (state.completed) {
return <p>You already won {state.lastReward?.label}.</p>;
}
async function spin() {
const result = await submit({
attributes: {},
attemptId: `spin:${engagementId}:${Date.now()}`,
});
showRevealAnimation(result.outcome.label);
}
return (
<button onClick={spin} className="px-6 py-3 rounded-xl bg-orange-500 text-white">
Spin to win
</button>
);
}Quiz
A 6-question quiz with branching
"use client";
import { useEngagement } from "@bricqs/headless-react";
export function Quiz({ engagementId }: { engagementId: string }) {
const { engagement, state, submit, isLoading } = useEngagement({
engagementId,
sync: true,
});
if (isLoading || !engagement) return null;
if (engagement.type !== "quiz") return null;
const q = engagement.questions[state.cursor ?? 0];
async function answer(choiceId: string) {
const result = await submit({
attributes: { question_id: q.id, choice_id: choiceId },
attemptId: `quiz:${engagementId}:q${q.id}`,
});
if (result.completed) {
window.location.href = `/quiz/result/${result.resultId}`;
}
}
return (
<article>
<p className="text-sm text-slate-500">Question {(state.cursor ?? 0) + 1} of {engagement.questions.length}</p>
<h2 className="text-xl font-bold mb-4">{q.text}</h2>
<ul className="grid gap-3">
{q.choices.map((c) => (
<li key={c.id}>
<button
onClick={() => answer(c.id)}
className="w-full text-left rounded-xl border p-3 hover:border-orange-500"
>
{c.label}
</button>
</li>
))}
</ul>
</article>
);
}Scratch
Server-determined reveal
const { state, submit } = useEngagement({ engagementId, sync: true });
async function reveal() {
// Submit on first scratch. The reveal you animate is the prize the server returned.
const result = await submit({
attributes: {},
attemptId: `scratch:${engagementId}:${userId}`,
});
setPrize(result.outcome);
}The prize is determined server-side at submit time. Animate the reveal to that outcome; never let the client decide what was won.
Common mistakes
What goes wrong
Determining the prize on the client and sending it as an event. Trivially gamed.
Always sync=true. Server returns the outcome; client animates to that outcome.
Reusing attemptId. Retries silently get the original result, which the user has already seen.
Generate a fresh attemptId per actual submission. Use Date.now() or a UUID.
Hardcoding the question count. Builder updates break the UI.
Render from engagement.questions.length. The hook returns live config; builder edits show up without a redeploy.
Submitting on every render. The submit handler is fired in useEffect by mistake.
submit must be called from a user gesture (click, swipe, scratch). Never from useEffect.
Developer FAQ
Common questions when integrating gamification with Bricqs.
Ready to ship?
Wire it up with the Bricqs SDK or API
Headless SDK for React UIs, REST API for any backend. Same engine behind both.
