Quiz Funnel Development on 1C-Bitrix
A quiz funnel is the next level beyond a regular quiz. Questions are not shown linearly: the answer to one question determines which question to ask next. Different paths lead to different results and different offers at the end. This allows precise lead qualification: some users will receive an entry-level product offer, others a premium one, and some will be redirected to a different page. The main technical challenge is implementing the branching logic.
Branching Logic: Data Structure
Each question has transitions: when option X is selected — the next question is Y. If there is no transition — go by default to the next in order or to the final step.
HL-block of questions with branching:
class FunnelQuestionTable extends \Bitrix\Main\ORM\Data\DataManager
{
public static function getTableName(): string { return 'b_hl_quiz_funnel_questions'; }
public static function getMap(): array
{
return [
new IntegerField('ID', ['primary' => true, 'autocomplete' => true]),
new IntegerField('FUNNEL_ID'),
new StringField('SLUG'), // Unique question identifier within the funnel
new IntegerField('SORT'),
new StringField('TEXT'),
new StringField('TYPE'), // single | multiple | scale | input
new TextField('OPTIONS_JSON'), // [{id, text, next_slug?, result_id?}]
new StringField('DEFAULT_NEXT_SLUG'), // Default next question
new BooleanField('IS_FINAL', ['values' => [false, true]]),
];
}
}
Structure of OPTIONS_JSON for a question with branching:
[
{"id": "opt_a", "text": "Individual", "next_slug": "q_budget_personal"},
{"id": "opt_b", "text": "Company (up to 50 people)", "next_slug": "q_budget_smb"},
{"id": "opt_c", "text": "Large enterprise", "next_slug": "q_budget_enterprise"}
]
Transition Graph
The funnel is a directed graph. On the backend, the graph is built when the funnel is loaded:
class FunnelGraph
{
private array $questions = []; // slug => question
private string $startSlug;
public function __construct(int $funnelId)
{
$rows = FunnelQuestionTable::getList([
'filter' => ['FUNNEL_ID' => $funnelId],
'order' => ['SORT' => 'ASC'],
])->fetchAll();
foreach ($rows as $row) {
$row['OPTIONS'] = json_decode($row['OPTIONS_JSON'], true) ?? [];
$this->questions[$row['SLUG']] = $row;
}
// Start question — first by SORT
$this->startSlug = array_key_first($this->questions);
}
public function getNextSlug(string $currentSlug, string $selectedOptionId): ?string
{
$question = $this->questions[$currentSlug] ?? null;
if (!$question) {
return null;
}
// Look for a transition by the selected option
foreach ($question['OPTIONS'] as $option) {
if ($option['id'] === $selectedOptionId && !empty($option['next_slug'])) {
return $option['next_slug'];
}
}
// Default transition
return $question['DEFAULT_NEXT_SLUG'] ?: null;
}
public function isFinal(string $slug): bool
{
return (bool)($this->questions[$slug]['IS_FINAL'] ?? false);
}
public function toClientJson(): array
{
// Return only what the client needs — without server-side logic
$result = [];
foreach ($this->questions as $slug => $q) {
$result[$slug] = [
'text' => $q['TEXT'],
'type' => $q['TYPE'],
'options' => $q['OPTIONS'],
'is_final' => $q['IS_FINAL'],
];
}
return ['start' => $this->startSlug, 'questions' => $result];
}
}
Client-Side Navigation Logic
class QuizFunnel {
constructor(graphData) {
this.questions = graphData.questions;
this.currentSlug = graphData.start;
this.history = []; // Stack for the "Back" button
this.answers = {}; // slug => [optionIds]
}
selectOption(optionId) {
const question = this.questions[this.currentSlug];
this.answers[this.currentSlug] = [optionId];
// Determine next step
let nextSlug = null;
for (const opt of question.options) {
if (opt.id === optionId && opt.next_slug) {
nextSlug = opt.next_slug;
break;
}
}
if (!nextSlug && question.is_final) {
this.showContactForm();
return;
}
if (nextSlug && this.questions[nextSlug]) {
this.history.push(this.currentSlug);
this.currentSlug = nextSlug;
this.renderQuestion(nextSlug);
} else {
this.showContactForm();
}
}
goBack() {
if (this.history.length === 0) return;
this.currentSlug = this.history.pop();
delete this.answers[this.currentSlug];
this.renderQuestion(this.currentSlug);
}
renderQuestion(slug) {
const q = this.questions[slug];
const el = document.getElementById('quiz-question');
el.querySelector('.quiz-text').textContent = q.text;
const optionsEl = el.querySelector('.quiz-options');
optionsEl.innerHTML = q.options.map(opt =>
`<button class="quiz-option" data-option-id="${opt.id}">${opt.text}</button>`
).join('');
optionsEl.querySelectorAll('.quiz-option').forEach(btn => {
btn.addEventListener('click', () => this.selectOption(btn.dataset.optionId));
});
}
showContactForm() {
document.getElementById('quiz-questions').style.display = 'none';
document.getElementById('quiz-contact-form').style.display = 'block';
}
}
// Initialization
const funnel = new QuizFunnel(window.FUNNEL_DATA); // Data from PHP
funnel.renderQuestion(funnel.currentSlug);
Results and Offers
At the end of the funnel, the user receives not just a "thank you" but a personalized result. The result is determined by the path: which answers the user gave.
HL-block of funnel results:
class FunnelResultTable extends \Bitrix\Main\ORM\Data\DataManager
{
public static function getTableName(): string { return 'b_hl_quiz_funnel_results'; }
public static function getMap(): array
{
return [
new IntegerField('ID', ['primary' => true, 'autocomplete' => true]),
new IntegerField('FUNNEL_ID'),
new StringField('TITLE'),
new TextField('DESCRIPTION'),
new StringField('CTA_TEXT'),
new StringField('CTA_URL'),
new TextField('CONDITIONS_JSON'), // Rules: [{question_slug, option_ids}]
];
}
}
Result matching service based on answers:
class ResultMatcher
{
public function match(int $funnelId, array $answers): ?array
{
$results = FunnelResultTable::getList([
'filter' => ['FUNNEL_ID' => $funnelId],
])->fetchAll();
foreach ($results as $result) {
$conditions = json_decode($result['CONDITIONS_JSON'], true) ?? [];
if ($this->checkConditions($conditions, $answers)) {
return $result;
}
}
return null; // No match — show default result
}
private function checkConditions(array $conditions, array $answers): bool
{
foreach ($conditions as $condition) {
$slug = $condition['question_slug'];
$required = $condition['option_ids'];
$given = $answers[$slug] ?? [];
if (empty(array_intersect($required, $given))) {
return false;
}
}
return true;
}
}
Lead with Funnel Context
In Bitrix24, a lead is created with the user's full path attached:
$matchedResult = (new ResultMatcher())->match($funnelId, $answers);
$resultTitle = $matchedResult['TITLE'] ?? 'Undefined';
$lead = new \CCrmLead(false);
$lead->Add([
'TITLE' => 'Funnel: ' . $funnelName . ' → ' . $resultTitle . ' — ' . $name,
'NAME' => $name,
'PHONE' => [['VALUE' => $phone, 'VALUE_TYPE' => 'WORK']],
'SOURCE_ID' => 'WEB',
'COMMENTS' => "Result: {$resultTitle}\nPath: " . implode(' → ', array_keys($answers)),
'UF_CRM_QUIZ_PATH' => json_encode($answers, JSON_UNESCAPED_UNICODE),
]);
Development Timeline
| Option | Scope | Timeline |
|---|---|---|
| Linear funnel | No branching, final result | 4–6 days |
| Funnel with branching | Transition graph, multiple results | 8–12 days |
| With constructor | Funnel management via UI without a developer | 15–22 days |







