Developer Prompt: SAM AI Funnels - Phase 7 (Quiz Logic)
Developer Prompt: SAM AI Funnels - Phase 7 (Quiz Logic)
Date: 2025-12-31
Phase: 7 of 7 (FINAL)
Scope: Interactive quiz functionality with branching, scoring, and personalization
Prerequisite: Phase 6 complete
CONTEXT
This final phase implements the interactive quiz system. Quiz funnels are powerful for lead qualification and segmentation. We need JavaScript state management, branching logic, scoring, and personalized results.
GOAL
- Quiz state management (track answers across questions)
- Progress indicator updates
- Branching logic (skip questions based on answers)
- Score calculation
- Email gate before showing results
- Personalized results based on answers
- Quiz data stored in conversion events
DELIVERABLES
1. Quiz Controller
File: controllers/quiz_controller.py
import json
from odoo import http
from odoo.http import request
class QuizController(http.Controller):
"""Handle quiz submissions and result calculations."""
@http.route('/funnel/quiz/submit', type='json', auth='public', methods=['POST'])
def submit_quiz(self, funnel_id, page_id, answers, email=None, name=None, **kwargs):
"""
Submit quiz answers and get result.
Args:
funnel_id: The funnel ID
page_id: The quiz gate page ID
answers: List of {question_id, answer_value}
email: User's email (captured at gate)
name: User's name (optional)
Returns:
{
'success': True,
'result_type': 'achiever',
'result_headline': 'You are an Achiever!',
'result_description': '...',
'redirect_url': '/your-results?type=achiever'
}
"""
try:
# Calculate quiz result
result = self._calculate_result(answers)
# Store quiz data
partner = None
lead = None
if email:
# Get or create partner
Partner = request.env['res.partner'].sudo()
partner = Partner.search([('email', '=ilike', email)], limit=1)
if not partner:
partner = Partner.create({
'name': name or email.split('@')[0],
'email': email,
})
# Create lead with quiz results
funnel = request.env['funnel.definition'].sudo().browse(int(funnel_id))
lead = request.env['crm.lead'].sudo().create({
'name': f"Quiz Lead: {name or email}",
'email_from': email,
'partner_id': partner.id,
'type': 'lead',
'description': self._format_quiz_results(answers, result),
'team_id': funnel.crm_team_id.id if funnel.crm_team_id else False,
})
# Add to mailing list if configured
if funnel.mailing_list_id:
self._add_to_mailing(email, name, funnel.mailing_list_id.id)
# Track conversion
request.env['funnel.conversion'].sudo().create({
'funnel_id': int(funnel_id),
'page_id': int(page_id),
'event_type': 'quiz_complete',
'partner_id': partner.id if partner else False,
'lead_id': lead.id if lead else False,
'session_data': json.dumps({
'answers': answers,
'result': result
}),
})
# Build redirect URL with result
redirect_url = f"/your-results?type={result['type']}"
if name:
redirect_url += f"&name={name}"
return {
'success': True,
'result_type': result['type'],
'result_headline': result['headline'],
'result_description': result['description'],
'redirect_url': redirect_url
}
except Exception as e:
return {'success': False, 'error': str(e)}
def _calculate_result(self, answers):
"""Calculate quiz result based on answer scoring."""
# Simple scoring: count answer types
scores = {}
for answer in answers:
value = answer.get('answer_value', 'a')
scores[value] = scores.get(value, 0) + 1
# Determine result type by highest score
if not scores:
result_type = 'general'
else:
result_type = max(scores, key=scores.get)
# Map result types to outcomes
result_map = {
'a': {
'type': 'achiever',
'headline': 'You are an Achiever!',
'description': 'You thrive on goals and results. Your ideal solution focuses on measurable outcomes.'
},
'b': {
'type': 'explorer',
'headline': 'You are an Explorer!',
'description': 'You love discovering new approaches. Flexibility and variety are key for you.'
},
'c': {
'type': 'connector',
'headline': 'You are a Connector!',
'description': 'Relationships matter most to you. Community and support drive your success.'
},
'd': {
'type': 'strategist',
'headline': 'You are a Strategist!',
'description': 'You think long-term and plan carefully. Systems and frameworks work best for you.'
},
'general': {
'type': 'general',
'headline': 'Your Results Are Ready!',
'description': 'Based on your answers, here is your personalized recommendation.'
}
}
return result_map.get(result_type, result_map['general'])
def _format_quiz_results(self, answers, result):
"""Format quiz results for lead description."""
lines = [
f"Quiz Result: {result['headline']}",
f"Result Type: {result['type']}",
"",
"Answers:"
]
for i, answer in enumerate(answers, 1):
lines.append(f" Q{answer.get('question_id', i)}: {answer.get('answer_value', 'N/A')}")
return '\n'.join(lines)
def _add_to_mailing(self, email, name, mailing_list_id):
"""Add contact to mailing list."""
MailingContact = request.env['mailing.contact'].sudo()
existing = MailingContact.search([
('email', '=ilike', email),
('list_ids', 'in', [mailing_list_id])
], limit=1)
if not existing:
contact = MailingContact.search([('email', '=ilike', email)], limit=1)
if contact:
contact.write({'list_ids': [(4, mailing_list_id)]})
else:
MailingContact.create({
'name': name or email.split('@')[0],
'email': email,
'list_ids': [(4, mailing_list_id)],
})
@http.route('/funnel/quiz/track_start', type='json', auth='public', methods=['POST'])
def track_quiz_start(self, funnel_id, page_id, **kwargs):
"""Track when user starts a quiz."""
request.env['funnel.conversion'].sudo().create({
'funnel_id': int(funnel_id),
'page_id': int(page_id),
'event_type': 'quiz_start',
'visitor_id': request.httprequest.cookies.get('funnel_visitor_id', ''),
})
return {'success': True}
2. Quiz JavaScript
File: static/src/js/quiz_manager.js
/** @odoo-module **/
import publicWidget from "@web/legacy/js/public/public_widget";
import { jsonrpc } from "@web/core/network/rpc_service";
/**
* Quiz Manager - Handles complete quiz flow
*/
publicWidget.registry.FunnelQuizManager = publicWidget.Widget.extend({
selector: '.s_quiz_intro, .s_quiz_question, .s_quiz_progress, .s_quiz_gate, .s_quiz_results',
init: function () {
this._super.apply(this, arguments);
this.quizState = this._loadState();
},
start: function () {
this._super.apply(this, arguments);
this._initQuizElement();
return Promise.resolve();
},
_loadState: function () {
const stored = sessionStorage.getItem('funnel_quiz_state');
return stored ? JSON.parse(stored) : {
started: false,
currentQuestion: 0,
answers: [],
totalQuestions: 5,
};
},
_saveState: function () {
sessionStorage.setItem('funnel_quiz_state', JSON.stringify(this.quizState));
},
_initQuizElement: function () {
const $el = this.$el;
if ($el.hasClass('s_quiz_intro')) {
this._initQuizIntro();
} else if ($el.hasClass('s_quiz_question')) {
this._initQuizQuestion();
} else if ($el.hasClass('s_quiz_progress')) {
this._updateProgress();
} else if ($el.hasClass('s_quiz_gate')) {
this._initQuizGate();
} else if ($el.hasClass('s_quiz_results')) {
this._initQuizResults();
}
},
_initQuizIntro: function () {
this.$('.s_quiz_start').on('click', (ev) => {
ev.preventDefault();
// Track quiz start
const funnelId = this.$el.data('funnel-id');
const pageId = this.$el.data('page-id');
if (funnelId && pageId) {
jsonrpc('/funnel/quiz/track_start', { funnel_id: funnelId, page_id: pageId });
}
// Initialize state
this.quizState = {
started: true,
currentQuestion: 0,
answers: [],
totalQuestions: parseInt(this.$el.data('total-questions')) || 5,
};
this._saveState();
// Navigate to first question
const nextUrl = this.$el.data('next-url') || '/quiz-questions';
window.location.href = nextUrl;
});
},
_initQuizQuestion: function () {
const questionId = this.$el.data('question-id') || this.quizState.currentQuestion + 1;
// Show correct question based on state
this._showQuestion(questionId);
// Handle answer clicks
this.$('.s_answer').on('click', (ev) => {
ev.preventDefault();
const $answer = $(ev.currentTarget);
const answerValue = $answer.data('value');
// Visual feedback
this.$('.s_answer').removeClass('active btn-primary').addClass('btn-outline-primary');
$answer.removeClass('btn-outline-primary').addClass('active btn-primary');
// Store answer
this.quizState.answers.push({
question_id: questionId,
answer_value: answerValue,
});
// Check for branching
const skipTo = $answer.data('skip-to');
const nextQuestion = skipTo || (this.quizState.currentQuestion + 2);
this.quizState.currentQuestion++;
this._saveState();
// Delay then navigate
setTimeout(() => {
if (this.quizState.currentQuestion >= this.quizState.totalQuestions) {
// Go to gate page
const gateUrl = this.$el.data('gate-url') || '/quiz-results-gate';
window.location.href = gateUrl;
} else {
// Next question (or reload for SPA behavior)
this._showQuestion(nextQuestion);
this._updateProgress();
}
}, 300);
});
},
_showQuestion: function (questionId) {
// Update question display
const current = this.quizState.currentQuestion + 1;
const total = this.quizState.totalQuestions;
this.$('.s_question_counter').text(`Question ${current} of ${total}`);
},
_updateProgress: function () {
const current = this.quizState.currentQuestion;
const total = this.quizState.totalQuestions;
const percent = (current / total) * 100;
this.$('.s_progress_bar').css('width', percent + '%');
this.$('.s_progress_text').text(`Step ${current + 1} of ${total}`);
},
_initQuizGate: function () {
this.$('.s_quiz_gate_form').on('submit', (ev) => {
ev.preventDefault();
const $form = $(ev.currentTarget);
const $button = $form.find('button[type="submit"]');
const originalText = $button.text();
// Disable button
$button.prop('disabled', true).text('Processing...');
const name = $form.find('input[name="name"]').val();
const email = $form.find('input[name="email"]').val();
const funnelId = this.$el.data('funnel-id');
const pageId = this.$el.data('page-id');
// Submit quiz
jsonrpc('/funnel/quiz/submit', {
funnel_id: funnelId,
page_id: pageId,
answers: this.quizState.answers,
email: email,
name: name,
}).then(response => {
if (response.success) {
// Store result for results page
sessionStorage.setItem('quiz_result', JSON.stringify(response));
// Clear quiz state
sessionStorage.removeItem('funnel_quiz_state');
// Redirect to results
window.location.href = response.redirect_url;
} else {
$button.prop('disabled', false).text(originalText);
alert(response.error || 'Something went wrong. Please try again.');
}
}).catch(error => {
$button.prop('disabled', false).text(originalText);
alert('An error occurred. Please try again.');
});
});
},
_initQuizResults: function () {
// Load result from session storage
const resultData = sessionStorage.getItem('quiz_result');
if (resultData) {
const result = JSON.parse(resultData);
this._displayResult(result);
sessionStorage.removeItem('quiz_result');
} else {
// Try to get from URL params
const params = new URLSearchParams(window.location.search);
const resultType = params.get('type');
const name = params.get('name');
if (resultType) {
this._displayResultByType(resultType, name);
}
}
},
_displayResult: function (result) {
this.$('.s_result_type').text(result.result_type);
this.$('.s_result_headline').text(result.result_headline);
this.$('.s_result_description').text(result.result_description);
},
_displayResultByType: function (resultType, name) {
// Personalize with name if available
if (name) {
const $headline = this.$('.s_result_headline');
const currentText = $headline.text();
$headline.text(currentText.replace('You are', `${name}, you are`));
}
// Show result-specific content
this.$(`[data-result-type]`).hide();
this.$(`[data-result-type="${resultType}"]`).show();
},
});
export default publicWidget.registry.FunnelQuizManager;
3. Enhanced Quiz Snippets
Update quiz snippet XMLs to support the JavaScript:
views/snippets/s_quiz_question.xml (updated):
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="s_quiz_question" name="Quiz Question">
<section class="s_quiz_question py-5"
data-snippet="sam_ai_funnels.s_quiz_question"
data-name="Quiz Question"
data-question-id="1"
data-gate-url="/quiz-results-gate">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-8">
<p class="text-center text-muted s_question_counter">Question 1 of 5</p>
<h2 class="h3 mb-4 text-center" data-oe-field="question">
What is your biggest challenge right now?
</h2>
<div class="s_answers d-grid gap-3" data-oe-field="answers">
<button class="btn btn-outline-primary btn-lg text-start s_answer"
data-value="a">
<span class="me-2">A.</span> I struggle with time management
</button>
<button class="btn btn-outline-primary btn-lg text-start s_answer"
data-value="b">
<span class="me-2">B.</span> I need more clients
</button>
<button class="btn btn-outline-primary btn-lg text-start s_answer"
data-value="c">
<span class="me-2">C.</span> I'm overwhelmed with choices
</button>
<button class="btn btn-outline-primary btn-lg text-start s_answer"
data-value="d">
<span class="me-2">D.</span> I lack clarity on my goals
</button>
</div>
</div>
</div>
</div>
</section>
</template>
</odoo>
4. Quiz Results with Personalization
views/snippets/s_quiz_results.xml (updated):
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="s_quiz_results" name="Quiz Results">
<section class="s_quiz_results py-5"
data-snippet="sam_ai_funnels.s_quiz_results"
data-name="Quiz Results">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-8 text-center">
<p class="text-muted mb-2">Based on your answers...</p>
<h1 class="display-5 fw-bold mb-4 s_result_headline">
You are a <span class="text-primary s_result_type">[Result Type]</span>
</h1>
<!-- Result-specific content blocks -->
<div class="s_result_content" data-result-type="achiever">
<div class="card mb-4 border-primary">
<div class="card-body">
<p class="lead">As an Achiever, you thrive on goals and measurable results.</p>
<p>Your ideal solution focuses on clear outcomes, tracking progress, and celebrating wins.</p>
</div>
</div>
</div>
<div class="s_result_content" data-result-type="explorer" style="display:none;">
<div class="card mb-4 border-success">
<div class="card-body">
<p class="lead">As an Explorer, you love discovering new approaches.</p>
<p>Flexibility and variety are key for you. You need room to experiment.</p>
</div>
</div>
</div>
<div class="s_result_content" data-result-type="connector" style="display:none;">
<div class="card mb-4 border-warning">
<div class="card-body">
<p class="lead">As a Connector, relationships matter most to you.</p>
<p>Community and support drive your success. You thrive with others.</p>
</div>
</div>
</div>
<div class="s_result_content" data-result-type="strategist" style="display:none;">
<div class="card mb-4 border-info">
<div class="card-body">
<p class="lead">As a Strategist, you think long-term and plan carefully.</p>
<p>Systems and frameworks work best for you. You see the big picture.</p>
</div>
</div>
</div>
<h3 class="h5 mb-3">Your Personalized Recommendation:</h3>
<a href="#" class="btn btn-primary btn-lg">See Your Solution</a>
</div>
</div>
</div>
</section>
</template>
</odoo>
5. Quiz Customize Options
Add to views/snippets/options.xml:
<!-- Quiz Question Options -->
<div data-selector=".s_quiz_question">
<we-input string="Question" data-attribute-name="data-question"/>
<we-input string="Question ID" data-attribute-name="data-question-id"/>
<we-input string="Gate URL" data-attribute-name="data-gate-url"/>
<we-select string="Answer Layout">
<we-button data-select-class="s_answers_list">List</we-button>
<we-button data-select-class="s_answers_grid">Grid (2 col)</we-button>
</we-select>
<we-colorpicker string="Background" data-css-property="background-color"/>
</div>
<!-- Quiz Results Options -->
<div data-selector=".s_quiz_results">
<we-select string="Result Display">
<we-button data-select-class="s_result_single">Single Result</we-button>
<we-button data-select-class="s_result_multi">Multiple Outcomes</we-button>
</we-select>
<we-checkbox string="Show Score" data-attribute-name="data-show-score"/>
<we-colorpicker string="Background" data-css-property="background-color"/>
</div>
6. Update Manifest
'assets': {
'web.assets_frontend': [
# ... existing ...
'sam_ai_funnels/static/src/js/quiz_manager.js',
],
},
Update controllers/init.py:
from . import quiz_controller
VALIDATION CHECKLIST
- [ ] Quiz intro "Start Quiz" button works
- [ ] Quiz tracks start event
- [ ] Answer selection stores in session
- [ ] Progress bar updates correctly
- [ ] Branching/skip logic works
- [ ] Email gate captures before results
- [ ] Quiz submission creates lead with answers
- [ ] Lead has quiz results in description
- [ ] Results page shows personalized content
- [ ] Result type from URL works
- [ ] Name personalization works
- [ ] Mailing list integration works
COMPLETE MODULE STRUCTURE (Final)
sam_ai_funnels/
├── __manifest__.py
├── __init__.py
├── models/
│ ├── __init__.py
│ ├── funnel_definition.py
│ ├── funnel_page.py
│ ├── funnel_template.py
│ ├── funnel_snippet.py
│ ├── funnel_conversion.py
│ └── sam_funnel_context.py
├── controllers/
│ ├── __init__.py
│ ├── funnel_controller.py
│ ├── funnel_form_controller.py
│ ├── sam_funnel_api.py
│ └── quiz_controller.py
├── wizards/
│ ├── __init__.py
│ └── funnel_generator_wizard.py
├── views/
│ ├── funnel_definition_views.xml
│ ├── funnel_page_views.xml
│ ├── funnel_template_views.xml
│ ├── funnel_snippet_views.xml
│ ├── funnel_conversion_views.xml
│ ├── funnel_dashboard.xml
│ ├── funnel_menus.xml
│ ├── funnel_form_error.xml
│ └── snippets/
│ ├── options.xml
│ └── s_*.xml (46 files)
├── wizards/
│ └── funnel_generator_wizard_views.xml
├── data/
│ ├── funnel_snippet_data.xml
│ ├── funnel_templates.xml
│ └── sam_funnel_knowledge.xml
├── static/
│ └── src/
│ ├── snippets/
│ │ └── s_*/
│ │ ├── 000.js
│ │ └── 000.scss
│ ├── js/
│ │ ├── funnel_form.js
│ │ ├── funnel_tracking.js
│ │ ├── funnel_snippet_options.js
│ │ └── quiz_manager.js
│ └── img/
│ └── thumbnails/
└── security/
└── ir.model.access.csv
MODULE COMPLETE
With Phase 7 complete, the SAM AI Funnels module is fully implemented with:
- FUNNELS tab in website builder (46 snippets)
- 6 complete funnel templates
- CRM and mailing list integration
- Conversion analytics
- SAM AI integration for automated funnel building
- Interactive quiz functionality
END OF PHASE 7 DEVELOPER PROMPT
END OF IMPLEMENTATION PHASES