🎯 Exemples recommandés
Balanced sample collections from various categories for you to explore
Cartes Thermiques et Analyse Hotjar
Exemples Hotjar pour cartes thermiques, enregistrements de session, entonnoirs de conversion, widgets de feedback et analyse du comportement utilisateur
💻 Hotjar Hello World javascript
🟢 simple
⭐
Configuration Hotjar de base avec cartes thermiques et enregistrements de session activés
⏱️ 5 min
🏷️ frontend, analytics, user-experience, heatmaps
Prerequisites:
JavaScript basics, HTML/CSS
// Hotjar Hello World - Basic Heatmaps and Session Recordings
// Simple setup for user behavior tracking and analysis
(function(h,o,t,j,a,r){
h.hj=h.hj||function(){(h.hj.q=h.hj.q||[]).push(arguments)};
h._hjSettings={hjid:1234567,hjsv:6};
a=o.getElementsByTagName('head')[0];
r=o.createElement('script');r.async=1;
r.src=t+h._hjSettings.hjid+j+h._hjSettings.hjsv;
a.appendChild(r);
})(window,document,'https://static.hotjar.com/c/hotjar-','.js?sv=');
// Initialize Hotjar with your site ID
window.hj('trigger', 'identification_changed', {
user_id: 'user_123',
email: '[email protected]',
name: 'John Doe'
});
// Track custom events
window.hj('event', 'user_signed_up');
// Track page changes (for single page applications)
window.hj('stateChange', '/new-page');
console.log('Hotjar initialized and ready for tracking!');
// Track form submissions
document.addEventListener('submit', function(event) {
const form = event.target;
if (form.tagName === 'FORM') {
window.hj('event', 'form_submitted', {
form_id: form.id,
form_name: form.name
});
}
});
// Track clicks on important elements
document.addEventListener('click', function(event) {
const element = event.target;
// Track button clicks
if (element.matches('button, .btn, input[type="button"], input[type="submit"]')) {
window.hj('event', 'button_clicked', {
text: element.textContent || element.value,
id: element.id,
class: element.className
});
}
// Track link clicks
if (element.matches('a') && element.href) {
window.hj('event', 'link_clicked', {
href: element.href,
text: element.textContent
});
}
});
// Track scroll depth
let maxScroll = 0;
window.addEventListener('scroll', function() {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const winHeight = window.innerHeight;
const docHeight = document.documentElement.scrollHeight;
const scrollPercentage = Math.round((scrollTop + winHeight) / docHeight * 100);
if (scrollPercentage > maxScroll) {
maxScroll = scrollPercentage;
// Track important scroll milestones
if (scrollPercentage === 25) {
window.hj('event', 'scrolled_25_percent');
} else if (scrollPercentage === 50) {
window.hj('event', 'scrolled_50_percent');
} else if (scrollPercentage === 75) {
window.hj('event', 'scrolled_75_percent');
} else if (scrollPercentage === 90) {
window.hj('event', 'scrolled_90_percent');
}
}
});
💻 Widgets de Feedback Personnalisés typescript
🟡 intermediate
⭐⭐⭐⭐
Implémenter des widgets de feedback personnalisés avec intégration Hotjar pour la collecte de feedback utilisateur
⏱️ 40 min
🏷️ frontend, feedback, user-experience, widgets
Prerequisites:
TypeScript, DOM manipulation, Event handling, CSS styling
// Hotjar Custom Feedback Widgets
// Custom feedback implementation with Hotjar integration
interface FeedbackWidgetConfig {
position: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left'
trigger: 'click' | 'hover' | 'scroll' | 'exit-intent'
theme: 'light' | 'dark'
questions: FeedbackQuestion[]
showOnPages?: string[]
delay?: number
}
interface FeedbackQuestion {
id: string
type: 'rating' | 'text' | 'nps' | 'multiple-choice'
question: string
required: boolean
options?: string[]
placeholder?: string
}
interface FeedbackResponse {
userId?: string
sessionId?: string
page: string
responses: Record<string, any>
timestamp: number
userAgent: string
viewport: {
width: number
height: number
}
}
class HotjarFeedbackWidget {
private config: FeedbackWidgetConfig
private widgetElement: HTMLElement | null = null
private isOpen: boolean = false
private currentStep: number = 0
private responses: Record<string, any> = {}
constructor(config: FeedbackWidgetConfig) {
this.config = config
this.init()
}
private init() {
this.createWidget()
this.setupEventListeners()
this.setupTriggerConditions()
}
private createWidget() {
// Create main widget container
this.widgetElement = document.createElement('div')
this.widgetElement.className = `hj-feedback-widget hj-theme-${this.config.theme}`
this.widgetElement.innerHTML = `
<div class="hj-widget-trigger">
<button class="hj-trigger-button">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z" fill="currentColor"/>
</svg>
<span>Feedback</span>
</button>
</div>
<div class="hj-widget-container" style="display: none;">
<div class="hj-widget-header">
<h3>Share your feedback</h3>
<button class="hj-close-button">×</button>
</div>
<div class="hj-widget-content">
<!-- Dynamic content will be inserted here -->
</div>
<div class="hj-widget-footer">
<button class="hj-button hj-button-secondary" id="hj-prev-button" style="display: none;">Previous</button>
<button class="hj-button hj-button-primary" id="hj-next-button">Next</button>
<button class="hj-button hj-button-primary" id="hj-submit-button" style="display: none;">Submit</button>
</div>
</div>
`
// Add styles
this.addStyles()
// Add to page
document.body.appendChild(this.widgetElement)
}
private addStyles() {
const styles = `
.hj-feedback-widget {
position: fixed;
z-index: 999999;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.hj-feedback-widget.hj-position-bottom-right {
bottom: 20px;
right: 20px;
}
.hj-feedback-widget.hj-position-bottom-left {
bottom: 20px;
left: 20px;
}
.hj-feedback-widget.hj-position-top-right {
top: 20px;
right: 20px;
}
.hj-feedback-widget.hj-position-top-left {
top: 20px;
left: 20px;
}
.hj-widget-trigger {
margin-bottom: 10px;
}
.hj-trigger-button {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: #1f77b4;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
transition: all 0.2s;
}
.hj-trigger-button:hover {
background: #165a8a;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
}
.hj-widget-container {
width: 380px;
max-width: 90vw;
background: white;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.1);
overflow: hidden;
}
.hj-feedback-widget.hj-theme-dark .hj-widget-container {
background: #2c3e50;
color: white;
}
.hj-widget-header {
padding: 20px;
border-bottom: 1px solid #e1e4e8;
display: flex;
justify-content: space-between;
align-items: center;
}
.hj-feedback-widget.hj-theme-dark .hj-widget-header {
border-bottom-color: #34495e;
}
.hj-widget-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.hj-close-button {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #6c757d;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.hj-close-button:hover {
background: rgba(0,0,0,0.1);
}
.hj-widget-content {
padding: 20px;
min-height: 200px;
}
.hj-question-container {
margin-bottom: 20px;
}
.hj-question-label {
display: block;
margin-bottom: 8px;
font-weight: 500;
font-size: 16px;
}
.hj-rating-stars {
display: flex;
gap: 8px;
margin-top: 8px;
}
.hj-star {
font-size: 24px;
color: #ddd;
cursor: pointer;
transition: color 0.2s;
}
.hj-star:hover,
.hj-star.active {
color: #ffc107;
}
.hj-text-input,
.hj-textarea {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
font-family: inherit;
margin-top: 8px;
}
.hj-textarea {
min-height: 100px;
resize: vertical;
}
.hj-nps-scale {
display: flex;
gap: 4px;
margin-top: 8px;
}
.hj-nps-number {
width: 32px;
height: 32px;
border: 1px solid #ddd;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.hj-nps-number:hover {
background: #f8f9fa;
}
.hj-nps-number.selected {
background: #1f77b4;
color: white;
border-color: #1f77b4;
}
.hj-multiple-choice {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
.hj-choice-option {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.hj-choice-option input[type="radio"] {
margin: 0;
}
.hj-widget-footer {
padding: 20px;
border-top: 1px solid #e1e4e8;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.hj-feedback-widget.hj-theme-dark .hj-widget-footer {
border-top-color: #34495e;
}
.hj-button {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.hj-button-primary {
background: #1f77b4;
color: white;
}
.hj-button-primary:hover {
background: #165a8a;
}
.hj-button-secondary {
background: #6c757d;
color: white;
}
.hj-button-secondary:hover {
background: #545b62;
}
.hj-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.hj-progress-indicator {
display: flex;
justify-content: center;
gap: 6px;
margin-bottom: 20px;
}
.hj-progress-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #ddd;
transition: background 0.2s;
}
.hj-progress-dot.active {
background: #1f77b4;
}
`
const styleSheet = document.createElement('style')
styleSheet.textContent = styles
document.head.appendChild(styleSheet)
// Add position class
this.widgetElement!.classList.add(`hj-position-${this.config.position}`)
}
private setupEventListeners() {
if (!this.widgetElement) return
// Trigger button click
const triggerButton = this.widgetElement.querySelector('.hj-trigger-button')
triggerButton?.addEventListener('click', () => {
this.toggleWidget()
})
// Close button click
const closeButton = this.widgetElement.querySelector('.hj-close-button')
closeButton?.addEventListener('click', () => {
this.closeWidget()
})
// Navigation buttons
const prevButton = this.widgetElement.querySelector('#hj-prev-button')
const nextButton = this.widgetElement.querySelector('#hj-next-button')
const submitButton = this.widgetElement.querySelector('#hj-submit-button')
prevButton?.addEventListener('click', () => {
this.previousStep()
})
nextButton?.addEventListener('click', () => {
this.nextStep()
})
submitButton?.addEventListener('click', () => {
this.submitFeedback()
})
}
private setupTriggerConditions() {
// Check if current page should show widget
if (this.config.showOnPages && this.config.showOnPages.length > 0) {
const currentPage = window.location.pathname
const shouldShow = this.config.showOnPages.some(page =>
currentPage === page || currentPage.startsWith(page)
)
if (!shouldShow && this.widgetElement) {
this.widgetElement.style.display = 'none'
return
}
}
// Apply delay if specified
if (this.config.delay && this.config.delay > 0) {
setTimeout(() => {
this.showTrigger()
}, this.config.delay)
} else {
this.showTrigger()
}
// Setup specific trigger types
switch (this.config.trigger) {
case 'scroll':
this.setupScrollTrigger()
break
case 'exit-intent':
this.setupExitIntentTrigger()
break
}
}
private showTrigger() {
if (this.widgetElement) {
const trigger = this.widgetElement.querySelector('.hj-widget-trigger')
if (trigger) {
(trigger as HTMLElement).style.display = 'block'
}
}
}
private setupScrollTrigger() {
const scrollPercentage = 0.5 // Show after 50% scroll
window.addEventListener('scroll', () => {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
const winHeight = window.innerHeight
const docHeight = document.documentElement.scrollHeight
const currentScroll = (scrollTop + winHeight) / docHeight
if (currentScroll >= scrollPercentage && !this.isOpen) {
this.openWidget()
}
}, { once: true })
}
private setupExitIntentTrigger() {
let mouseTimer: number
document.addEventListener('mouseout', (event) => {
if (event.clientY <= 0) {
mouseTimer = window.setTimeout(() => {
if (!this.isOpen) {
this.openWidget()
}
}, 500)
}
})
document.addEventListener('mouseover', () => {
clearTimeout(mouseTimer)
})
}
private toggleWidget() {
if (this.isOpen) {
this.closeWidget()
} else {
this.openWidget()
}
}
private openWidget() {
if (!this.widgetElement || this.isOpen) return
this.isOpen = true
const container = this.widgetElement.querySelector('.hj-widget-container')
if (container) {
(container as HTMLElement).style.display = 'block'
}
this.currentStep = 0
this.responses = {}
this.renderCurrentStep()
// Track widget opened in Hotjar
window.hj('event', 'feedback_widget_opened', {
trigger_type: this.config.trigger,
page: window.location.pathname
})
}
private closeWidget() {
if (!this.widgetElement || !this.isOpen) return
this.isOpen = false
const container = this.widgetElement.querySelector('.hj-widget-container')
if (container) {
(container as HTMLElement).style.display = 'none'
}
// Track widget closed in Hotjar
window.hj('event', 'feedback_widget_closed', {
current_step: this.currentStep,
completion_percentage: (this.currentStep / this.config.questions.length) * 100
})
}
private renderCurrentStep() {
if (!this.widgetElement || this.currentStep >= this.config.questions.length) return
const content = this.widgetElement.querySelector('.hj-widget-content')
if (!content) return
const question = this.config.questions[this.currentStep]
// Update progress indicator
this.updateProgressIndicator()
// Render question
let questionHtml = `
<div class="hj-question-container">
<label class="hj-question-label">
${question.required ? '*' : ''}${question.question}
</label>
`
switch (question.type) {
case 'rating':
questionHtml += this.renderRatingQuestion(question)
break
case 'text':
questionHtml += this.renderTextQuestion(question)
break
case 'nps':
questionHtml += this.renderNpsQuestion(question)
break
case 'multiple-choice':
questionHtml += this.renderMultipleChoiceQuestion(question)
break
}
questionHtml += '</div>'
content.innerHTML = questionHtml
// Setup question-specific event listeners
this.setupQuestionListeners(question)
// Update navigation buttons
this.updateNavigationButtons()
// Track question viewed in Hotjar
window.hj('event', 'feedback_question_viewed', {
question_id: question.id,
question_type: question.type,
step_number: this.currentStep + 1,
total_steps: this.config.questions.length
})
}
private renderRatingQuestion(question: FeedbackQuestion): string {
return `
<div class="hj-rating-stars" data-question-id="${question.id}">
${[1,2,3,4,5].map(star =>
`<span class="hj-star" data-rating="${star}">★</span>`
).join('')}
</div>
`
}
private renderTextQuestion(question: FeedbackQuestion): string {
const inputType = question.placeholder?.includes('email') ? 'email' : 'text'
const isTextArea = question.placeholder?.toLowerCase().includes('message') ||
question.placeholder?.toLowerCase().includes('comment')
if (isTextArea) {
return `
<textarea
class="hj-textarea"
data-question-id="${question.id}"
placeholder="${question.placeholder || 'Enter your response...'}"
${question.required ? 'required' : ''}
></textarea>
`
} else {
return `
<input
type="${inputType}"
class="hj-text-input"
data-question-id="${question.id}"
placeholder="${question.placeholder || 'Enter your response...'}"
${question.required ? 'required' : ''}
/>
`
}
}
private renderNpsQuestion(question: FeedbackQuestion): string {
return `
<div class="hj-nps-scale" data-question-id="${question.id}">
<div style="display: flex; justify-content: space-between; margin-bottom: 8px; font-size: 12px; color: #666;">
<span>Not likely</span>
<span>Very likely</span>
</div>
${[0,1,2,3,4,5,6,7,8,9,10].map(num =>
`<span class="hj-nps-number" data-rating="${num}">${num}</span>`
).join('')}
</div>
`
}
private renderMultipleChoiceQuestion(question: FeedbackQuestion): string {
return `
<div class="hj-multiple-choice" data-question-id="${question.id}">
${(question.options || []).map((option, index) =>
`
<label class="hj-choice-option">
<input type="radio" name="${question.id}" value="${option}" />
<span>${option}</span>
</label>
`
).join('')}
</div>
`
}
private setupQuestionListeners(question: FeedbackQuestion) {
if (!this.widgetElement) return
const questionElement = this.widgetElement.querySelector(`[data-question-id="${question.id}"]`)
if (!questionElement) return
switch (question.type) {
case 'rating':
this.setupRatingListeners(questionElement as HTMLElement, question.id)
break
case 'nps':
this.setupNpsListeners(questionElement as HTMLElement, question.id)
break
case 'text':
this.setupTextListeners(questionElement as HTMLElement, question.id)
break
case 'multiple-choice':
this.setupMultipleChoiceListeners(questionElement as HTMLElement, question.id)
break
}
}
private setupRatingListeners(element: HTMLElement, questionId: string) {
const stars = element.querySelectorAll('.hj-star')
stars.forEach(star => {
star.addEventListener('click', () => {
const rating = parseInt((star as HTMLElement).dataset.rating || '0')
this.responses[questionId] = rating
// Update visual state
stars.forEach((s, index) => {
if (index < rating) {
s.classList.add('active')
} else {
s.classList.remove('active')
}
})
// Track rating in Hotjar
window.hj('event', 'feedback_rating_given', {
question_id: questionId,
rating: rating
})
})
})
}
private setupNpsListeners(element: HTMLElement, questionId: string) {
const numbers = element.querySelectorAll('.hj-nps-number')
numbers.forEach(number => {
number.addEventListener('click', () => {
const rating = parseInt((number as HTMLElement).dataset.rating || '0')
this.responses[questionId] = rating
// Update visual state
numbers.forEach(n => n.classList.remove('selected'))
number.classList.add('selected')
// Track NPS in Hotjar
window.hj('event', 'feedback_nps_given', {
question_id: questionId,
rating: rating
})
})
})
}
private setupTextListeners(element: HTMLElement, questionId: string) {
const input = element.querySelector('.hj-text-input, .hj-textarea') as HTMLInputElement
if (input) {
input.addEventListener('input', () => {
this.responses[questionId] = input.value
})
input.addEventListener('blur', () => {
// Track text response in Hotjar
if (input.value.trim()) {
window.hj('event', 'feedback_text_entered', {
question_id: questionId,
has_content: true
})
}
})
}
}
private setupMultipleChoiceListeners(element: HTMLElement, questionId: string) {
const radios = element.querySelectorAll('input[type="radio"]')
radios.forEach(radio => {
radio.addEventListener('change', () => {
this.responses[questionId] = (radio as HTMLInputElement).value
// Track multiple choice in Hotjar
window.hj('event', 'feedback_choice_selected', {
question_id: questionId,
choice: (radio as HTMLInputElement).value
})
})
})
}
private updateProgressIndicator() {
if (!this.widgetElement) return
// Add progress indicator if not exists
let progressIndicator = this.widgetElement.querySelector('.hj-progress-indicator')
if (!progressIndicator) {
const content = this.widgetElement.querySelector('.hj-widget-content')
progressIndicator = document.createElement('div')
progressIndicator.className = 'hj-progress-indicator'
if (content) {
content.insertBefore(progressIndicator, content.firstChild)
}
}
// Update progress dots
const dots = this.config.questions.map((_, index) => {
const isActive = index === this.currentStep
return `<span class="hj-progress-dot ${isActive ? 'active' : ''}"></span>`
}).join('')
progressIndicator.innerHTML = dots
}
private updateNavigationButtons() {
if (!this.widgetElement) return
const prevButton = this.widgetElement.querySelector('#hj-prev-button') as HTMLButtonElement
const nextButton = this.widgetElement.querySelector('#hj-next-button') as HTMLButtonElement
const submitButton = this.widgetElement.querySelector('#hj-submit-button') as HTMLButtonElement
const isLastStep = this.currentStep === this.config.questions.length - 1
const isFirstStep = this.currentStep === 0
// Show/hide and disable buttons
prevButton.style.display = isFirstStep ? 'none' : 'block'
nextButton.style.display = isLastStep ? 'none' : 'block'
submitButton.style.display = isLastStep ? 'block' : 'none'
// Validate current step
const currentQuestion = this.config.questions[this.currentStep]
const hasResponse = this.responses[currentQuestion.id] !== undefined
const isValidResponse = this.validateResponse(currentQuestion, this.responses[currentQuestion.id])
nextButton.disabled = !hasResponse || !isValidResponse
submitButton.disabled = !hasResponse || !isValidResponse
}
private validateResponse(question: FeedbackQuestion, response: any): boolean {
if (question.required && !response) return false
if (question.type === 'text' && typeof response === 'string') {
return response.trim().length > 0
}
if (question.type === 'multiple-choice' && !response) {
return false
}
return true
}
private previousStep() {
if (this.currentStep > 0) {
this.currentStep--
this.renderCurrentStep()
}
}
private nextStep() {
if (this.currentStep < this.config.questions.length - 1) {
this.currentStep++
this.renderCurrentStep()
}
}
private submitFeedback() {
const feedbackResponse: FeedbackResponse = {
userId: this.getCurrentUserId(),
sessionId: this.getHotjarSessionId(),
page: window.location.pathname,
responses: this.responses,
timestamp: Date.now(),
userAgent: navigator.userAgent,
viewport: {
width: window.innerWidth,
height: window.innerHeight
}
}
// Send data to your backend
this.sendFeedbackToServer(feedbackResponse)
// Track feedback submission in Hotjar
window.hj('event', 'feedback_submitted', {
total_questions: this.config.questions.length,
responses_count: Object.keys(this.responses).length,
completion_time: Date.now() - (this.widgetElement?.dataset.openTime || Date.now())
})
// Close widget and show thank you message
this.closeWidget()
this.showThankYouMessage()
console.log('Feedback submitted:', feedbackResponse)
}
private sendFeedbackToServer(response: FeedbackResponse) {
// Send feedback to your backend API
fetch('/api/feedback', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(response)
}).catch(error => {
console.error('Failed to send feedback:', error)
})
}
private showThankYouMessage() {
if (!this.widgetElement) return
const container = this.widgetElement.querySelector('.hj-widget-container')
if (container) {
container.innerHTML = `
<div style="padding: 40px 20px; text-align: center;">
<div style="font-size: 48px; margin-bottom: 16px;">✅</div>
<h3 style="margin: 0 0 8px 0;">Thank you for your feedback!</h3>
<p style="margin: 0; color: #666;">Your response helps us improve our product.</p>
</div>
`
(container as HTMLElement).style.display = 'block'
this.widgetElement.dataset.openTime = Date.now().toString()
// Auto-hide after 3 seconds
setTimeout(() => {
(container as HTMLElement).style.display = 'none'
}, 3000)
}
}
private getCurrentUserId(): string | undefined {
// Get current user ID from your authentication system
return (window as any).currentUser?.id
}
private getHotjarSessionId(): string | undefined {
// Get Hotjar session ID if available
return (window as any).hj?.session?.get?.()?.id
}
}
// Example usage
// Basic feedback widget
const basicFeedback = new HotjarFeedbackWidget({
position: 'bottom-right',
trigger: 'click',
theme: 'light',
questions: [
{
id: 'satisfaction',
type: 'rating',
question: 'How satisfied are you with our product?',
required: true
},
{
id: 'improvement',
type: 'text',
question: 'What could we improve?',
placeholder: 'Share your thoughts...',
required: false
}
]
})
// NPS survey widget
const npsSurvey = new HotjarFeedbackWidget({
position: 'bottom-left',
trigger: 'exit-intent',
theme: 'dark',
questions: [
{
id: 'nps',
type: 'nps',
question: 'How likely are you to recommend our product to a friend?',
required: true
},
{
id: 'reason',
type: 'text',
question: 'What is the main reason for your score?',
placeholder: 'Please explain...',
required: true
}
]
})
export { HotjarFeedbackWidget, FeedbackWidgetConfig, FeedbackQuestion }
💻 Intégration React Hooks typescript
🟡 intermediate
⭐⭐⭐
Hooks React modernes pour intégration Hotjar avec suivi automatique et analyse au niveau des composants
⏱️ 35 min
🏷️ react, frontend, hooks, analytics
Prerequisites:
React hooks, TypeScript, Hotjar API, Event tracking
// Hotjar React Hooks Integration
// Modern React hooks for comprehensive Hotjar tracking and analytics
import React, { useEffect, useCallback, useRef, useState, useLayoutEffect } from 'react'
// Type definitions
interface HotjarEventProperties {
[key: string]: any
}
interface HeatmapConfig {
enabled: boolean
clickTracking?: boolean
scrollTracking?: boolean
movementTracking?: boolean
}
interface RecordingConfig {
enabled: boolean
recordConsoleLogs?: boolean
recordNetworkRequests?: boolean
maskSensitiveInputs?: boolean
}
// Custom hook for Hotjar initialization
export const useHotjarInit = (siteId: number, hotjarVersion: number = 6) => {
const [isInitialized, setIsInitialized] = useState(false)
useEffect(() => {
if (typeof window === 'undefined') return
if (window.hj) {
setIsInitialized(true)
return
}
const script = document.createElement('script')
script.innerHTML = `
(function(h,o,t,j,a,r){
h.hj=h.hj||function(){(h.hj.q=h.hj.q||[]).push(arguments)};
h._hjSettings={hjid:${siteId},hjsv:${hotjarVersion}};
a=o.getElementsByTagName('head')[0];
r=o.createElement('script');r.async=1;
r.src=t+h._hjSettings.hjid+j+h._hjSettings.hjsv;
a.appendChild(r);
})(window,document,'https://static.hotjar.com/c/hotjar-','.js?sv=');
`
document.head.appendChild(script)
// Check if Hotjar is loaded
const checkInterval = setInterval(() => {
if (window.hj) {
setIsInitialized(true)
clearInterval(checkInterval)
}
}, 100)
// Cleanup
return () => {
clearInterval(checkInterval)
}
}, [siteId, hotjarVersion])
return { isInitialized }
}
// Custom hook for user identification
export const useHotjarIdentify = (userId?: string, traits?: Record<string, any>) => {
const identifyUser = useCallback((uid: string, userTraits?: Record<string, any>) => {
if (window.hj) {
window.hj('identify', uid, userTraits)
}
}, [])
useEffect(() => {
if (userId) {
identifyUser(userId, traits)
}
}, [userId, traits, identifyUser])
return { identifyUser }
}
// Custom hook for event tracking
export const useHotjarEvent = () => {
const trackEvent = useCallback((eventName: string, properties?: HotjarEventProperties) => {
if (window.hj) {
window.hj('event', eventName, properties)
}
}, [])
return { trackEvent }
}
// Custom hook for page view tracking
export const useHotjarPageView = (pageTitle?: string) => {
const { trackEvent } = useHotjarEvent()
useEffect(() => {
trackEvent('page_view', {
page: window.location.pathname,
title: pageTitle || document.title,
url: window.location.href,
referrer: document.referrer,
timestamp: Date.now()
})
}, [trackEvent, pageTitle])
}
// Custom hook for form tracking
export const useHotjarFormTracking = (formName: string, options: {
trackFieldFocus?: boolean
trackFieldBlur?: boolean
trackValidation?: boolean
} = {}) => {
const { trackEvent } = useHotjarEvent()
const formRef = useRef<HTMLFormElement>(null)
const trackFieldEvent = useCallback((eventType: string, fieldName: string, value?: any) => {
trackEvent('form_interaction', {
form_name: formName,
event_type: eventType,
field_name: fieldName,
value: value ? 'has_value' : 'empty',
timestamp: Date.now()
})
}, [formName, trackEvent])
const trackFormEvent = useCallback((eventType: string, data?: Record<string, any>) => {
trackEvent('form_event', {
form_name: formName,
event_type: eventType,
...data,
timestamp: Date.now()
})
}, [formName, trackEvent])
useEffect(() => {
if (!formRef.current || !options.trackFieldFocus) return
const form = formRef.current
const handleFocus = (event: FocusEvent) => {
const target = event.target as HTMLElement
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') {
const fieldName = target.getAttribute('name') || target.getAttribute('id')
if (fieldName) {
trackFieldEvent('field_focus', fieldName)
}
}
}
form.addEventListener('focus', handleFocus, true)
return () => {
form.removeEventListener('focus', handleFocus, true)
}
}, [options.trackFieldFocus, trackFieldEvent])
useEffect(() => {
if (!formRef.current || !options.trackFieldBlur) return
const form = formRef.current
const handleBlur = (event: FocusEvent) => {
const target = event.target as HTMLInputElement | HTMLTextAreaElement
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') {
const fieldName = target.getAttribute('name') || target.getAttribute('id')
if (fieldName) {
trackFieldEvent('field_blur', fieldName, target.value)
}
}
}
form.addEventListener('blur', handleBlur, true)
return () => {
form.removeEventListener('blur', handleBlur, true)
}
}, [options.trackFieldBlur, trackFieldEvent])
const handleSubmit = useCallback((event: React.FormEvent) => {
const form = event.target as HTMLFormElement
const formData = new FormData(form)
const fieldCount = Array.from(formData.keys()).length
trackFormEvent('form_submit', {
field_count: fieldCount,
action: form.action,
method: form.method
})
}, [trackFormEvent])
const trackValidationError = useCallback((fieldName: string, errorMessage: string) => {
if (options.trackValidation) {
trackEvent('form_validation_error', {
form_name: formName,
field_name: fieldName,
error_message: errorMessage,
timestamp: Date.now()
})
}
}, [formName, options.trackValidation, trackEvent])
return {
formRef,
handleSubmit,
trackValidationError,
trackFormEvent
}
}
// Custom hook for click tracking
export const useHotjarClickTracking = (options: {
trackOutboundLinks?: boolean
trackDownloads?: boolean
trackButtonActions?: boolean
} = {}) => {
const { trackEvent } = useHotjarEvent()
const elementRef = useRef<HTMLElement>(null)
const handleClick = useCallback((event: React.MouseEvent) => {
const target = event.target as HTMLElement
// Track button clicks
if (options.trackButtonActions && (target.tagName === 'BUTTON' || target.closest('button'))) {
const button = target.tagName === 'BUTTON' ? target : target.closest('button')
trackEvent('button_click', {
text: button?.textContent?.trim() || '',
id: button?.id || '',
class: button?.className || ''
})
}
// Track link clicks
if (target.tagName === 'A' || target.closest('a')) {
const link = target.tagName === 'A' ? target as HTMLAnchorElement : target.closest('a')
// Outbound links
if (options.trackOutboundLinks && link?.hostname !== window.location.hostname) {
trackEvent('outbound_link_click', {
url: link?.href || '',
text: link?.textContent?.trim() || ''
})
}
// Download links
if (options.trackDownloads && link?.href && link.href.match(/\.(pdf|doc|docx|xls|xlsx|zip|rar)$/i)) {
trackEvent('download_link_click', {
url: link?.href || '',
file_type: link?.href?.split('.').pop() || '',
file_name: link?.href?.split('/').pop() || ''
})
}
}
// Track custom clicks on tracked elements
if (elementRef.current && (elementRef.current === target || elementRef.current.contains(target))) {
trackEvent('element_click', {
element_tag: target.tagName.toLowerCase(),
element_id: target.id || '',
element_class: target.className || '',
element_text: target.textContent?.trim().substring(0, 50) || '',
coordinates: {
x: event.clientX,
y: event.clientY
}
})
}
}, [trackEvent, options])
useEffect(() => {
document.addEventListener('click', handleClick, true)
return () => {
document.removeEventListener('click', handleClick, true)
}
}, [handleClick])
return { elementRef }
}
// Custom hook for scroll tracking
export const useHotjarScrollTracking = (thresholds: number[] = [25, 50, 75, 90]) => {
const { trackEvent } = useHotjarEvent()
const maxScrollDepth = useRef(0)
const scrolledThresholds = useRef<Set<number>>(new Set())
useEffect(() => {
const handleScroll = () => {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
const winHeight = window.innerHeight
const docHeight = document.documentElement.scrollHeight
const scrollPercentage = Math.round(((scrollTop + winHeight) / docHeight) * 100)
// Update max scroll depth
if (scrollPercentage > maxScrollDepth.current) {
maxScrollDepth.current = scrollPercentage
}
// Check thresholds
thresholds.forEach(threshold => {
if (scrollPercentage >= threshold && !scrolledThresholds.current.has(threshold)) {
scrolledThresholds.current.add(threshold)
trackEvent('scroll_threshold_reached', {
threshold: threshold,
scroll_percentage: scrollPercentage,
scroll_position: scrollTop,
page_height: docHeight
})
}
})
}
window.addEventListener('scroll', handleScroll, { passive: true })
return () => {
window.removeEventListener('scroll', handleScroll)
}
}, [trackEvent, thresholds])
const getMaxScrollDepth = useCallback(() => {
return maxScrollDepth.current
}, [])
return { getMaxScrollDepth }
}
// Custom hook for session engagement tracking
export const useHotjarSessionTracking = () => {
const { trackEvent } = useHotjarEvent()
const [sessionStartTime] = useState(Date.now())
const [lastActivityTime, setLastActivityTime] = useState(Date.now())
const [totalActiveTime, setTotalActiveTime] = useState(0)
const activityTimeoutRef = useRef<number>()
const updateActivity = useCallback(() => {
const now = Date.now()
const inactiveTime = now - lastActivityTime
// Consider active if less than 30 seconds inactive
if (inactiveTime < 30000) {
setTotalActiveTime(prev => prev + Math.min(1000, inactiveTime))
}
setLastActivityTime(now)
}, [lastActivityTime])
const trackEngagement = useCallback((eventType: string, data?: any) => {
updateActivity()
trackEvent(eventType, {
...data,
session_duration: Date.now() - sessionStartTime,
active_time: totalActiveTime,
timestamp: Date.now()
})
}, [updateActivity, trackEvent, sessionStartTime, totalActiveTime])
useEffect(() => {
const events = ['mousedown', 'keydown', 'scroll', 'touchstart', 'click']
const handleActivity = () => {
updateActivity()
// Clear existing timeout
if (activityTimeoutRef.current) {
clearTimeout(activityTimeoutRef.current)
}
// Set new timeout for session end detection
activityTimeoutRef.current = window.setTimeout(() => {
trackEvent('session_ended', {
total_duration: Date.now() - sessionStartTime,
total_active_time: totalActiveTime,
reason: 'timeout'
})
}, 30000) // 30 seconds of inactivity
}
events.forEach(event => {
document.addEventListener(event, handleActivity, { passive: true })
})
// Track session start
trackEvent('session_started', {
timestamp: sessionStartTime,
user_agent: navigator.userAgent,
viewport: {
width: window.innerWidth,
height: window.innerHeight
}
})
// Cleanup
return () => {
events.forEach(event => {
document.removeEventListener(event, handleActivity)
})
if (activityTimeoutRef.current) {
clearTimeout(activityTimeoutRef.current)
}
// Track session end
trackEvent('session_ended', {
total_duration: Date.now() - sessionStartTime,
total_active_time: totalActiveTime,
reason: 'page_unload'
})
}
}, [sessionStartTime, totalActiveTime, updateActivity, trackEvent])
return { trackEngagement, sessionStartTime }
}
// Custom hook for A/B testing with Hotjar
export const useHotjarABTest = (experimentName: string, variant?: string) => {
const { trackEvent } = useHotjarEvent()
useEffect(() => {
if (variant) {
// Set user properties for segmentation
if (window.hj) {
window.hj('identify', null, {
[`ab_${experimentName}`]: variant
})
}
trackEvent('ab_test_started', {
experiment_name: experimentName,
variant: variant
})
}
}, [experimentName, variant, trackEvent])
const trackConversion = useCallback((conversionType: string, value?: number) => {
trackEvent('ab_test_conversion', {
experiment_name: experimentName,
variant: variant,
conversion_type: conversionType,
value: value
})
}, [experimentName, variant, trackEvent])
return { trackConversion }
}
// Higher-order component for automatic tracking
export const withHotjarTracking = <P extends object>(
WrappedComponent: React.ComponentType<P>,
options: {
trackPageView?: boolean
trackClicks?: boolean
trackScroll?: boolean
componentName?: string
} = {}
) => {
const HOC = (props: P) => {
const { elementRef } = useHotjarClickTracking({ trackButtonActions: true })
const { trackEvent } = useHotjarEvent()
// Track page view
useHotjarPageView(options.componentName)
// Track scroll depth
if (options.trackScroll) {
useHotjarScrollTracking()
}
// Track component mount
useEffect(() => {
if (options.componentName) {
trackEvent('component_mounted', {
component_name: options.componentName
})
}
}, [trackEvent, options.componentName])
return (
<div ref={elementRef}>
<WrappedComponent {...props} />
</div>
)
}
HOC.displayName = `withHotjarTracking(${WrappedComponent.displayName || WrappedComponent.name})`
return HOC
}
// Example usage components
// Contact form with tracking
const TrackedContactForm: React.FC = () => {
const { formRef, handleSubmit, trackValidationError } = useHotjarFormTracking('contact_form', {
trackFieldFocus: true,
trackFieldBlur: true,
trackValidation: true
})
const [formData, setFormData] = useState({
name: '',
email: '',
message: ''
})
const onSubmit = (e: React.FormEvent) => {
e.preventDefault()
handleSubmit(e)
// Validate form
if (!formData.name) {
trackValidationError('name', 'Name is required')
return
}
if (!formData.email) {
trackValidationError('email', 'Email is required')
return
}
console.log('Form submitted:', formData)
}
return (
<form ref={formRef} onSubmit={onSubmit}>
<input
name="name"
type="text"
placeholder="Your Name"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
/>
<input
name="email"
type="email"
placeholder="Your Email"
value={formData.email}
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
/>
<textarea
name="message"
placeholder="Your Message"
value={formData.message}
onChange={(e) => setFormData(prev => ({ ...prev, message: e.target.value }))}
/>
<button type="submit">Send Message</button>
</form>
)
}
// A/B testing example
const CheckoutButton: React.FC<{ variant: 'A' | 'B' }> = ({ variant }) => {
const { trackConversion } = useHotjarABTest('checkout_button_test', variant)
const handleClick = () => {
trackConversion('checkout_started', 99.99)
console.log('Checkout started for variant:', variant)
}
return (
<button
onClick={handleClick}
style={{
backgroundColor: variant === 'A' ? '#007bff' : '#28a745',
color: 'white',
padding: '12px 24px',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
{variant === 'A' ? 'Buy Now' : 'Get Started'}
</button>
)
}
// Tracked component example
const TrackedProductCard = withHotjarTracking(
({ name, price }: { name: string; price: number }) => (
<div className="product-card">
<h3>{name}</h3>
<p>${price}</p>
<button>Add to Cart</button>
</div>
),
{
trackPageView: true,
trackClicks: true,
componentName: 'ProductCard'
}
)
export {
TrackedContactForm,
CheckoutButton,
TrackedProductCard
}
💻 Intégration de Tableau de Bord Analytique typescript
🔴 complex
⭐⭐⭐⭐⭐
Construire un tableau de bord analytique complet en utilisant les données Hotjar et métriques personnalisées
⏱️ 60 min
🏷️ frontend, analytics, dashboard, visualization
Prerequisites:
React, TypeScript, Data visualization, Charts library, Hotjar API
// Hotjar Analytics Dashboard Integration
// Comprehensive dashboard for analyzing Hotjar data and user behavior insights
import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { LineChart, Line, BarChart, Bar, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
// Type definitions
interface HeatmapData {
page: string
clicks: Array<{
x: number
y: number
element: string
count: number
}>
scrolls: Array<{
percentage: number
count: number
}>
date: string
}
interface SessionData {
sessionId: string
userId?: string
duration: number
pageViews: number
clicks: number
rageClicks: number
errors: number
deviceType: string
browser: string
country: string
startDate: string
endDate: string
exitPage: string
conversionEvents: Array<{
type: string
value?: number
timestamp: number
}>
}
interface FunnelStep {
name: string
stepOrder: number
users: number
dropOffRate: number
avgTime: number
conversionRate: number
}
interface DashboardMetrics {
totalSessions: number
totalPageViews: number
averageSessionDuration: number
bounceRate: number
conversionRate: number
topPages: Array<{ page: string; views: number; uniqueVisitors: number }>
deviceBreakdown: Array<{ device: string; sessions: number; percentage: number }>
countryBreakdown: Array<{ country: string; sessions: number; percentage: number }>
hourlyActivity: Array<{ hour: number; sessions: number; activeUsers: number }>
funnelData: FunnelStep[]
}
const HotjarAnalyticsDashboard: React.FC = () => {
const [metrics, setMetrics] = useState<DashboardMetrics | null>(null)
const [loading, setLoading] = useState(true)
const [dateRange, setDateRange] = useState({
start: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), // 30 days ago
end: new Date()
})
const [selectedMetric, setSelectedMetric] = useState<'overview' | 'funnel' | 'behavior' | 'technical'>('overview')
// Initialize Hotjar tracking for the dashboard
useEffect(() => {
if (window.hj) {
window.hj('event', 'analytics_dashboard_viewed', {
metric_type: selectedMetric,
date_range: {
start: dateRange.start.toISOString(),
end: dateRange.end.toISOString()
}
})
}
}, [selectedMetric, dateRange])
// Fetch analytics data
const fetchAnalyticsData = useCallback(async () => {
setLoading(true)
try {
// This would typically call your backend API that queries Hotjar
const mockData = await generateMockAnalyticsData()
setMetrics(mockData)
if (window.hj) {
window.hj('event', 'analytics_data_loaded', {
total_sessions: mockData.totalSessions,
date_range: {
start: dateRange.start.toISOString(),
end: dateRange.end.toISOString()
}
})
}
} catch (error) {
console.error('Failed to fetch analytics data:', error)
if (window.hj) {
window.hj('event', 'analytics_data_load_error', {
error: error instanceof Error ? error.message : 'Unknown error'
})
}
} finally {
setLoading(false)
}
}, [dateRange])
useEffect(() => {
fetchAnalyticsData()
}, [fetchAnalyticsData])
// Generate mock analytics data (replace with real API call)
const generateMockAnalyticsData = async (): Promise<DashboardMetrics> => {
return new Promise(resolve => {
setTimeout(() => {
resolve({
totalSessions: 15420,
totalPageViews: 89450,
averageSessionDuration: 245000, // 4.08 minutes
bounceRate: 34.2,
conversionRate: 12.8,
topPages: [
{ page: '/home', views: 25400, uniqueVisitors: 18200 },
{ page: '/products', views: 19800, uniqueVisitors: 14200 },
{ page: '/about', views: 12300, uniqueVisitors: 8900 },
{ page: '/contact', views: 8700, uniqueVisitors: 6200 },
{ page: '/blog', views: 7200, uniqueVisitors: 5100 }
],
deviceBreakdown: [
{ device: 'Desktop', sessions: 8920, percentage: 57.8 },
{ device: 'Mobile', sessions: 5320, percentage: 34.5 },
{ device: 'Tablet', sessions: 1180, percentage: 7.7 }
],
countryBreakdown: [
{ country: 'United States', sessions: 6200, percentage: 40.2 },
{ country: 'United Kingdom', sessions: 2890, percentage: 18.7 },
{ country: 'Canada', sessions: 1870, percentage: 12.1 },
{ country: 'Australia', sessions: 1230, percentage: 8.0 },
{ country: 'Germany', sessions: 980, percentage: 6.4 }
],
hourlyActivity: Array.from({ length: 24 }, (_, hour) => ({
hour,
sessions: Math.floor(Math.random() * 800) + 200,
activeUsers: Math.floor(Math.random() * 300) + 50
})),
funnelData: [
{ name: 'Homepage Visit', stepOrder: 1, users: 15420, dropOffRate: 0, avgTime: 45000, conversionRate: 100 },
{ name: 'Product View', stepOrder: 2, users: 11200, dropOffRate: 27.3, avgTime: 78000, conversionRate: 72.7 },
{ name: 'Add to Cart', stepOrder: 3, users: 5400, dropOffRate: 51.8, avgTime: 32000, conversionRate: 35.0 },
{ name: 'Checkout Started', stepOrder: 4, users: 3800, dropOffRate: 29.6, avgTime: 120000, conversionRate: 24.6 },
{ name: 'Purchase Completed', stepOrder: 5, users: 1970, dropOffRate: 48.2, avgTime: 180000, conversionRate: 12.8 }
]
})
}, 1000)
})
}
// Calculate additional metrics
const calculatedMetrics = useMemo(() => {
if (!metrics) return null
return {
engagementRate: ((metrics.averageSessionDuration / 300000) * 100).toFixed(1), // 5 min = 100%
pagesPerSession: (metrics.totalPageViews / metrics.totalSessions).toFixed(1),
peakActivityHour: metrics.hourlyActivity.reduce((max, hour) =>
hour.sessions > max.sessions ? hour : max
).hour,
topDevice: metrics.deviceBreakdown.reduce((max, device) =>
device.sessions > max.sessions ? device : max
).device,
conversionValue: metrics.totalSessions * metrics.conversionRate * 89.99 // Assuming avg $89.99 value
}
}, [metrics])
// Export dashboard data
const exportData = () => {
if (!metrics) return
const exportData = {
dateRange,
metrics,
calculatedMetrics,
exportDate: new Date().toISOString()
}
const dataStr = JSON.stringify(exportData, null, 2)
const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr)
const exportFileDefaultName = `hotjar-analytics-${new Date().toISOString().split('T')[0]}.json`
const linkElement = document.createElement('a')
linkElement.setAttribute('href', dataUri)
linkElement.setAttribute('download', exportFileDefaultName)
linkElement.click()
if (window.hj) {
window.hj('event', 'analytics_data_exported', {
date_range: dateRange,
file_name: exportFileDefaultName
})
}
}
// Track metric selection
const handleMetricSelection = (metric: typeof selectedMetric) => {
setSelectedMetric(metric)
if (window.hj) {
window.hj('event', 'dashboard_metric_selected', {
metric_type: metric
})
}
}
if (loading) {
return (
<div className="dashboard-loading">
<div className="loading-spinner"></div>
<p>Loading analytics data...</p>
</div>
)
}
if (!metrics || !calculatedMetrics) {
return <div>No analytics data available</div>
}
const COLORS = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd']
return (
<div className="hotjar-analytics-dashboard">
<div className="dashboard-header">
<h1>Hotjar Analytics Dashboard</h1>
<div className="dashboard-controls">
<div className="date-range-selector">
<label>Date Range:</label>
<input
type="date"
value={dateRange.start.toISOString().split('T')[0]}
onChange={(e) => setDateRange(prev => ({ ...prev, start: new Date(e.target.value) }))}
/>
<span>to</span>
<input
type="date"
value={dateRange.end.toISOString().split('T')[0]}
onChange={(e) => setDateRange(prev => ({ ...prev, end: new Date(e.target.value) }))}
/>
<button onClick={fetchAnalyticsData}>Update</button>
</div>
<button onClick={exportData} className="export-button">Export Data</button>
</div>
</div>
{/* Metric Navigation */}
<div className="metric-navigation">
{(['overview', 'funnel', 'behavior', 'technical'] as const).map(metric => (
<button
key={metric}
className={`metric-tab ${selectedMetric === metric ? 'active' : ''}`}
onClick={() => handleMetricSelection(metric)}
>
{metric.charAt(0).toUpperCase() + metric.slice(1)}
</button>
))}
</div>
{/* Overview Section */}
{selectedMetric === 'overview' && (
<div className="overview-section">
<div className="metrics-grid">
<div className="metric-card">
<h3>Total Sessions</h3>
<p className="metric-value">{metrics.totalSessions.toLocaleString()}</p>
<span className="metric-change positive">+12.5%</span>
</div>
<div className="metric-card">
<h3>Avg Session Duration</h3>
<p className="metric-value">{Math.round(metrics.averageSessionDuration / 1000 / 60)}m</p>
<span className="metric-change positive">+8.3%</span>
</div>
<div className="metric-card">
<h3>Bounce Rate</h3>
<p className="metric-value">{metrics.bounceRate}%</p>
<span className="metric-change negative">+2.1%</span>
</div>
<div className="metric-card">
<h3>Conversion Rate</h3>
<p className="metric-value">{metrics.conversionRate}%</p>
<span className="metric-change positive">+15.7%</span>
</div>
</div>
<div className="charts-grid">
<div className="chart-container">
<h3>Hourly Activity</h3>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={metrics.hourlyActivity}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="hour" />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="sessions" stroke="#1f77b4" name="Sessions" />
<Line type="monotone" dataKey="activeUsers" stroke="#ff7f0e" name="Active Users" />
</LineChart>
</ResponsiveContainer>
</div>
<div className="chart-container">
<h3>Device Breakdown</h3>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={metrics.deviceBreakdown}
cx="50%"
cy="50%"
labelLine={false}
label={({ device, percentage }) => `${device}: ${percentage}%`}
outerRadius={80}
fill="#8884d8"
dataKey="sessions"
>
{metrics.deviceBreakdown.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
</div>
<div className="top-pages-container">
<h3>Top Pages</h3>
<table className="data-table">
<thead>
<tr>
<th>Page</th>
<th>Page Views</th>
<th>Unique Visitors</th>
<th>Views per Visitor</th>
</tr>
</thead>
<tbody>
{metrics.topPages.map((page, index) => (
<tr key={index}>
<td>{page.page}</td>
<td>{page.views.toLocaleString()}</td>
<td>{page.uniqueVisitors.toLocaleString()}</td>
<td>{(page.views / page.uniqueVisitors).toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Funnel Section */}
{selectedMetric === 'funnel' && (
<div className="funnel-section">
<h2>Conversion Funnel Analysis</h2>
<div className="funnel-chart-container">
<ResponsiveContainer width="100%" height={400}>
<BarChart
data={metrics.funnelData}
layout="horizontal"
margin={{ top: 20, right: 30, left: 80, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" />
<YAxis dataKey="name" type="category" width={100} />
<Tooltip />
<Legend />
<Bar dataKey="users" fill="#1f77b4" name="Users" />
</BarChart>
</ResponsiveContainer>
</div>
<div className="funnel-details">
<h3>Funnel Step Details</h3>
<table className="data-table">
<thead>
<tr>
<th>Step</th>
<th>Users</th>
<th>Conversion Rate</th>
<th>Drop-off Rate</th>
<th>Avg Time</th>
</tr>
</thead>
<tbody>
{metrics.funnelData.map((step, index) => (
<tr key={index}>
<td>{step.name}</td>
<td>{step.users.toLocaleString()}</td>
<td>{step.conversionRate.toFixed(1)}%</td>
<td className={step.dropOffRate > 30 ? 'negative' : ''}>
{step.dropOffRate.toFixed(1)}%
</td>
<td>{Math.round(step.avgTime / 1000)}s</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Behavior Section */}
{selectedMetric === 'behavior' && (
<div className="behavior-section">
<h2>User Behavior Analysis</h2>
<div className="behavior-metrics">
<div className="metric-card">
<h3>Engagement Rate</h3>
<p className="metric-value">{calculatedMetrics.engagementRate}%</p>
<small>Based on session duration</small>
</div>
<div className="metric-card">
<h3>Pages per Session</h3>
<p className="metric-value">{calculatedMetrics.pagesPerSession}</p>
<small>Average pages viewed</small>
</div>
<div className="metric-card">
<h3>Peak Activity Hour</h3>
<p className="metric-value">{calculatedMetrics.peakActivityHour}:00</p>
<small>Most active time</small>
</div>
<div className="metric-card">
<h3>Top Device</h3>
<p className="metric-value">{calculatedMetrics.topDevice}</p>
<small>Most used device type</small>
</div>
</div>
<div className="charts-grid">
<div className="chart-container">
<h3>Country Distribution</h3>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={metrics.countryBreakdown}
cx="50%"
cy="50%"
labelLine={false}
label={({ country, percentage }) =>
percentage > 5 ? `${country}: ${percentage}%` : ''
}
outerRadius={80}
fill="#8884d8"
dataKey="sessions"
>
{metrics.countryBreakdown.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
<div className="chart-container">
<h3>Session Duration Distribution</h3>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={[
{ range: '0-30s', count: 3200 },
{ range: '30s-2m', count: 4800 },
{ range: '2-5m', count: 5100 },
{ range: '5-10m', count: 1890 },
{ range: '10m+', count: 430 }
]}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="range" />
<YAxis />
<Tooltip />
<Bar dataKey="count" fill="#2ca02c" name="Sessions" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
)}
{/* Technical Section */}
{selectedMetric === 'technical' && (
<div className="technical-section">
<h2>Technical Performance</h2>
<div className="technical-metrics">
<div className="metric-card">
<h3>Conversion Value</h3>
<p className="metric-value">${calculatedMetrics.conversionValue.toLocaleString()}</p>
<small>Total estimated value</small>
</div>
<div className="metric-card">
<h3>Mobile Performance</h3>
<p className="metric-value">
{metrics.deviceBreakdown.find(d => d.device === 'Mobile')?.percentage}%
</p>
<small>Mobile session share</small>
</div>
</div>
<div className="performance-insights">
<h3>Performance Insights</h3>
<div className="insights-grid">
<div className="insight-card positive">
<h4>✅ Strong Mobile Usage</h4>
<p>{metrics.deviceBreakdown.find(d => d.device === 'Mobile')?.percentage}% of users access via mobile</p>
</div>
<div className="insight-card warning">
<h4>⚠️ High Bounce Rate</h4>
<p>Bounce rate of {metrics.bounceRate}% suggests landing page optimization needed</p>
</div>
<div className="insight-card positive">
<h4>✅ Good Conversion Funnel</h4>
<p>{metrics.conversionRate}% conversion rate indicates effective user journey</p>
</div>
<div className="insight-card warning">
<h4>⚠️ Drop-off at Checkout</h4>
<p>48.2% drop-off at final checkout step suggests payment friction</p>
</div>
</div>
</div>
</div>
)}
</div>
)
}
// CSS for the dashboard
const dashboardStyles = `
.hotjar-analytics-dashboard {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
padding: 20px;
background: #f8f9fa;
min-height: 100vh;
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
}
.dashboard-header h1 {
margin: 0;
color: #2c3e50;
}
.dashboard-controls {
display: flex;
gap: 20px;
align-items: center;
}
.date-range-selector {
display: flex;
gap: 10px;
align-items: center;
}
.date-range-selector input {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.date-range-selector button {
padding: 8px 16px;
background: #1f77b4;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.export-button {
padding: 8px 16px;
background: #28a745;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.metric-navigation {
display: flex;
gap: 10px;
margin-bottom: 30px;
border-bottom: 2px solid #e1e4e8;
}
.metric-tab {
padding: 12px 24px;
background: none;
border: none;
border-bottom: 3px solid transparent;
cursor: pointer;
font-size: 16px;
color: #6c757d;
transition: all 0.2s;
}
.metric-tab:hover {
color: #1f77b4;
}
.metric-tab.active {
color: #1f77b4;
border-bottom-color: #1f77b4;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.metric-card {
background: white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.metric-card h3 {
margin: 0 0 12px 0;
color: #6c757d;
font-size: 14px;
font-weight: 500;
}
.metric-value {
font-size: 32px;
font-weight: 700;
color: #2c3e50;
margin: 0 0 8px 0;
}
.metric-change {
font-size: 14px;
font-weight: 500;
}
.metric-change.positive {
color: #28a745;
}
.metric-change.negative {
color: #dc3545;
}
.charts-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 30px;
}
.chart-container {
background: white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.chart-container h3 {
margin: 0 0 20px 0;
color: #2c3e50;
}
.top-pages-container {
background: white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.top-pages-container h3 {
margin: 0 0 20px 0;
color: #2c3e50;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #e1e4e8;
}
.data-table th {
background: #f8f9fa;
font-weight: 600;
color: #2c3e50;
}
.negative {
color: #dc3545;
font-weight: 600;
}
.funnel-section,
.behavior-section,
.technical-section {
h2 {
margin: 0 0 30px 0;
color: #2c3e50;
}
}
.funnel-chart-container {
background: white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-bottom: 30px;
}
.funnel-details {
background: white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.funnel-details h3 {
margin: 0 0 20px 0;
color: #2c3e50;
}
.behavior-metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.behavior-metrics .metric-card small {
display: block;
color: #6c757d;
font-size: 12px;
}
.technical-metrics {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 30px;
}
.performance-insights {
background: white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.performance-insights h3 {
margin: 0 0 20px 0;
color: #2c3e50;
}
.insights-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.insight-card {
padding: 20px;
border-radius: 8px;
border-left: 4px solid;
}
.insight-card.positive {
background: #d4edda;
border-left-color: #28a745;
}
.insight-card.warning {
background: #fff3cd;
border-left-color: #ffc107;
}
.insight-card h4 {
margin: 0 0 8px 0;
color: #2c3e50;
}
.insight-card p {
margin: 0;
color: #6c757d;
}
.dashboard-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 400px;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #1f77b4;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 20px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`
// Inject styles
if (typeof document !== 'undefined') {
const styleSheet = document.createElement('style')
styleSheet.textContent = dashboardStyles
document.head.appendChild(styleSheet)
}
export default HotjarAnalyticsDashboard