🎯 Exemplos recomendados
Balanced sample collections from various categories for you to explore
Mapas de Calor e Análise Hotjar
Exemplos Hotjar para mapas de calor, gravações de sessão, funis de conversão, widgets de feedback e análise de comportamento do usuário
💻 Hotjar Hello World javascript
🟢 simple
⭐
Configuração básica do Hotjar com mapas de calor e gravações de sessão habilitados
⏱️ 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 Personalizados typescript
🟡 intermediate
⭐⭐⭐⭐
Implementar widgets de feedback personalizados com integração Hotjar para coleta de feedback do usuário
⏱️ 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 }
💻 Integração React Hooks typescript
🟡 intermediate
⭐⭐⭐
Hooks React modernos para integração Hotjar com rastreamento automático e análise em nível de componente
⏱️ 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
}
💻 Integração de Dashboard Analítico typescript
🔴 complex
⭐⭐⭐⭐⭐
Construir um dashboard analítico abrangente usando dados Hotjar e métricas personalizadas
⏱️ 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