Add Matomo analytics tracking with Suspense fix
- Created MatomoAnalytics component with page view and event tracking - Fixed Next.js build issue by wrapping useSearchParams in Suspense - Added tracking for user interactions: - Search functionality - Table sorting - Filter changes (material, finish, color) - Dark mode toggles - Admin login success/failure - Admin filament create/update actions - Updated Amplify environment variables via AWS CLI - Analytics URL: https://analytics.demirix.dev (Site ID: 7) - Only loads when environment variables are set 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -4,3 +4,7 @@ NODE_ENV=production
|
|||||||
# API Configuration
|
# API Configuration
|
||||||
NEXT_PUBLIC_API_URL=https://api.filamenteka.rs/api
|
NEXT_PUBLIC_API_URL=https://api.filamenteka.rs/api
|
||||||
|
|
||||||
|
# Matomo Analytics Configuration
|
||||||
|
NEXT_PUBLIC_MATOMO_URL=https://analytics.demirix.dev
|
||||||
|
NEXT_PUBLIC_MATOMO_SITE_ID=7
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
import '../src/styles/index.css'
|
import '../src/styles/index.css'
|
||||||
import { BackToTop } from '../src/components/BackToTop'
|
import { BackToTop } from '../src/components/BackToTop'
|
||||||
|
import { MatomoAnalytics } from '../src/components/MatomoAnalytics'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Filamenteka',
|
title: 'Filamenteka',
|
||||||
@@ -46,6 +47,12 @@ export default function RootLayout({
|
|||||||
<body suppressHydrationWarning>
|
<body suppressHydrationWarning>
|
||||||
{children}
|
{children}
|
||||||
<BackToTop />
|
<BackToTop />
|
||||||
|
{process.env.NEXT_PUBLIC_MATOMO_URL && process.env.NEXT_PUBLIC_MATOMO_SITE_ID && (
|
||||||
|
<MatomoAnalytics
|
||||||
|
matomoUrl={process.env.NEXT_PUBLIC_MATOMO_URL}
|
||||||
|
siteId={process.env.NEXT_PUBLIC_MATOMO_SITE_ID}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { FilamentTableV2 } from '../src/components/FilamentTableV2';
|
import { FilamentTableV2 } from '../src/components/FilamentTableV2';
|
||||||
import { Filament } from '../src/types/filament';
|
import { Filament } from '../src/types/filament';
|
||||||
import { filamentService } from '../src/services/api';
|
import { filamentService } from '../src/services/api';
|
||||||
|
import { trackEvent } from '../src/components/MatomoAnalytics';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [filaments, setFilaments] = useState<Filament[]>([]);
|
const [filaments, setFilaments] = useState<Filament[]>([]);
|
||||||
@@ -103,7 +104,10 @@ export default function Home() {
|
|||||||
<div className="flex-shrink-0 ml-4">
|
<div className="flex-shrink-0 ml-4">
|
||||||
{mounted ? (
|
{mounted ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => setDarkMode(!darkMode)}
|
onClick={() => {
|
||||||
|
setDarkMode(!darkMode);
|
||||||
|
trackEvent('UI', 'Dark Mode Toggle', darkMode ? 'Light' : 'Dark');
|
||||||
|
}}
|
||||||
className="p-2 bg-white/50 dark:bg-gray-700/50 backdrop-blur text-gray-800 dark:text-gray-200 rounded-full hover:bg-white/80 dark:hover:bg-gray-600/80 transition-all duration-200 shadow-md"
|
className="p-2 bg-white/50 dark:bg-gray-700/50 backdrop-blur text-gray-800 dark:text-gray-200 rounded-full hover:bg-white/80 dark:hover:bg-gray-600/80 transition-all duration-200 shadow-md"
|
||||||
title={darkMode ? 'Svetla tema' : 'Tamna tema'}
|
title={darkMode ? 'Svetla tema' : 'Tamna tema'}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useMemo } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { filamentService, colorService } from '@/src/services/api';
|
import { filamentService, colorService } from '@/src/services/api';
|
||||||
import { Filament } from '@/src/types/filament';
|
import { Filament } from '@/src/types/filament';
|
||||||
|
import { trackEvent } from '@/src/components/MatomoAnalytics';
|
||||||
// Removed unused imports for Bambu Lab color categorization
|
// Removed unused imports for Bambu Lab color categorization
|
||||||
import '@/src/styles/select.css';
|
import '@/src/styles/select.css';
|
||||||
|
|
||||||
@@ -191,8 +192,10 @@ export default function AdminDashboard() {
|
|||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
await filamentService.update(id, cleanData);
|
await filamentService.update(id, cleanData);
|
||||||
|
trackEvent('Admin', 'Update Filament', `${cleanData.tip} ${cleanData.finish} ${cleanData.boja}`);
|
||||||
} else {
|
} else {
|
||||||
await filamentService.create(cleanData);
|
await filamentService.create(cleanData);
|
||||||
|
trackEvent('Admin', 'Create Filament', `${cleanData.tip} ${cleanData.finish} ${cleanData.boja}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
setEditingFilament(null);
|
setEditingFilament(null);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { authService } from '@/src/services/api';
|
import { authService } from '@/src/services/api';
|
||||||
|
import { trackEvent } from '@/src/components/MatomoAnalytics';
|
||||||
|
|
||||||
export default function AdminLogin() {
|
export default function AdminLogin() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -28,11 +29,15 @@ export default function AdminLogin() {
|
|||||||
localStorage.setItem('authToken', response.token);
|
localStorage.setItem('authToken', response.token);
|
||||||
localStorage.setItem('tokenExpiry', String(Date.now() + 24 * 60 * 60 * 1000)); // 24 hours
|
localStorage.setItem('tokenExpiry', String(Date.now() + 24 * 60 * 60 * 1000)); // 24 hours
|
||||||
|
|
||||||
|
// Track successful login
|
||||||
|
trackEvent('Admin', 'Login', 'Success');
|
||||||
|
|
||||||
// Redirect to admin dashboard
|
// Redirect to admin dashboard
|
||||||
router.push('/upadaj/dashboard');
|
router.push('/upadaj/dashboard');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError('Neispravno korisničko ime ili lozinka');
|
setError('Neispravno korisničko ime ili lozinka');
|
||||||
console.error('Login error:', err);
|
console.error('Login error:', err);
|
||||||
|
trackEvent('Admin', 'Login', 'Failed');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import '@/src/styles/select.css';
|
import '@/src/styles/select.css';
|
||||||
|
import { trackEvent } from './MatomoAnalytics';
|
||||||
|
|
||||||
interface EnhancedFiltersProps {
|
interface EnhancedFiltersProps {
|
||||||
filters: {
|
filters: {
|
||||||
@@ -34,7 +35,10 @@ export const EnhancedFilters: React.FC<EnhancedFiltersProps> = ({
|
|||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={filters.material}
|
value={filters.material}
|
||||||
onChange={(e) => onFilterChange({ ...filters, material: e.target.value })}
|
onChange={(e) => {
|
||||||
|
onFilterChange({ ...filters, material: e.target.value });
|
||||||
|
trackEvent('Filter', 'Material', e.target.value || 'All');
|
||||||
|
}}
|
||||||
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
||||||
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
||||||
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
@@ -61,7 +65,10 @@ export const EnhancedFilters: React.FC<EnhancedFiltersProps> = ({
|
|||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={filters.finish}
|
value={filters.finish}
|
||||||
onChange={(e) => onFilterChange({ ...filters, finish: e.target.value })}
|
onChange={(e) => {
|
||||||
|
onFilterChange({ ...filters, finish: e.target.value });
|
||||||
|
trackEvent('Filter', 'Finish', e.target.value || 'All');
|
||||||
|
}}
|
||||||
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
||||||
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
||||||
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
@@ -97,7 +104,10 @@ export const EnhancedFilters: React.FC<EnhancedFiltersProps> = ({
|
|||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={filters.color}
|
value={filters.color}
|
||||||
onChange={(e) => onFilterChange({ ...filters, color: e.target.value })}
|
onChange={(e) => {
|
||||||
|
onFilterChange({ ...filters, color: e.target.value });
|
||||||
|
trackEvent('Filter', 'Color', e.target.value || 'All');
|
||||||
|
}}
|
||||||
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
||||||
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
||||||
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
@@ -115,11 +125,14 @@ export const EnhancedFilters: React.FC<EnhancedFiltersProps> = ({
|
|||||||
{hasActiveFilters && (
|
{hasActiveFilters && (
|
||||||
<div className="mt-4 text-center">
|
<div className="mt-4 text-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => onFilterChange({
|
onClick={() => {
|
||||||
material: '',
|
onFilterChange({
|
||||||
finish: '',
|
material: '',
|
||||||
color: ''
|
finish: '',
|
||||||
})}
|
color: ''
|
||||||
|
});
|
||||||
|
trackEvent('Filter', 'Reset', 'All Filters');
|
||||||
|
}}
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-red-500 dark:bg-red-600 hover:bg-red-600 dark:hover:bg-red-700 rounded-md transition-colors"
|
className="px-4 py-2 text-sm font-medium text-white bg-red-500 dark:bg-red-600 hover:bg-red-600 dark:hover:bg-red-700 rounded-md transition-colors"
|
||||||
>
|
>
|
||||||
Reset filtere
|
Reset filtere
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState, useMemo, useEffect } from 'react';
|
|||||||
import { Filament } from '@/src/types/filament';
|
import { Filament } from '@/src/types/filament';
|
||||||
import { EnhancedFilters } from './EnhancedFilters';
|
import { EnhancedFilters } from './EnhancedFilters';
|
||||||
import { colorService } from '@/src/services/api';
|
import { colorService } from '@/src/services/api';
|
||||||
|
import { trackEvent } from './MatomoAnalytics';
|
||||||
|
|
||||||
interface FilamentTableV2Props {
|
interface FilamentTableV2Props {
|
||||||
filaments: Filament[];
|
filaments: Filament[];
|
||||||
@@ -114,6 +115,7 @@ const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments }) => {
|
|||||||
setSortField(field);
|
setSortField(field);
|
||||||
setSortOrder('asc');
|
setSortOrder('asc');
|
||||||
}
|
}
|
||||||
|
trackEvent('Table', 'Sort', field);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -125,7 +127,12 @@ const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments }) => {
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Pretraži po materijalu, boji..."
|
placeholder="Pretraži po materijalu, boji..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setSearchTerm(e.target.value);
|
||||||
|
if (e.target.value) {
|
||||||
|
trackEvent('Search', 'Filament Search', e.target.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="w-full px-4 py-2 pl-10 pr-4 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-blue-500"
|
className="w-full px-4 py-2 pl-10 pr-4 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-blue-500"
|
||||||
/>
|
/>
|
||||||
<svg className="absolute left-3 top-2.5 h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="absolute left-3 top-2.5 h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|||||||
76
src/components/MatomoAnalytics.tsx
Normal file
76
src/components/MatomoAnalytics.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, Suspense } from 'react';
|
||||||
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
interface MatomoAnalyticsProps {
|
||||||
|
siteId: string;
|
||||||
|
matomoUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MatomoTracker({ siteId, matomoUrl }: MatomoAnalyticsProps) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Initialize Matomo
|
||||||
|
const _paq = (window as any)._paq = (window as any)._paq || [];
|
||||||
|
|
||||||
|
// Track page view
|
||||||
|
_paq.push(['setCustomUrl', window.location.href]);
|
||||||
|
_paq.push(['setDocumentTitle', document.title]);
|
||||||
|
_paq.push(['trackPageView']);
|
||||||
|
|
||||||
|
// Enable link tracking
|
||||||
|
_paq.push(['enableLinkTracking']);
|
||||||
|
|
||||||
|
// Set up Matomo tracker
|
||||||
|
if (!(window as any).MatomoInitialized) {
|
||||||
|
_paq.push(['setTrackerUrl', `${matomoUrl}/matomo.php`]);
|
||||||
|
_paq.push(['setSiteId', siteId]);
|
||||||
|
|
||||||
|
// Create script element
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.type = 'text/javascript';
|
||||||
|
script.async = true;
|
||||||
|
script.src = `${matomoUrl}/matomo.js`;
|
||||||
|
|
||||||
|
const firstScript = document.getElementsByTagName('script')[0];
|
||||||
|
firstScript.parentNode?.insertBefore(script, firstScript);
|
||||||
|
|
||||||
|
(window as any).MatomoInitialized = true;
|
||||||
|
}
|
||||||
|
}, [pathname, searchParams, siteId, matomoUrl]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MatomoAnalytics({ siteId, matomoUrl }: MatomoAnalyticsProps) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<MatomoTracker siteId={siteId} matomoUrl={matomoUrl} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to track events
|
||||||
|
export function trackEvent(category: string, action: string, name?: string, value?: number) {
|
||||||
|
const _paq = (window as any)._paq = (window as any)._paq || [];
|
||||||
|
_paq.push(['trackEvent', category, action, name, value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to track e-commerce
|
||||||
|
export function trackEcommerce(productName: string, price: number, quantity: number = 1) {
|
||||||
|
const _paq = (window as any)._paq = (window as any)._paq || [];
|
||||||
|
_paq.push(['addEcommerceItem',
|
||||||
|
productName, // Product name
|
||||||
|
productName, // Product SKU
|
||||||
|
'Filament', // Product category
|
||||||
|
price,
|
||||||
|
quantity
|
||||||
|
]);
|
||||||
|
_paq.push(['trackEcommerceOrder',
|
||||||
|
Date.now().toString(), // Order ID
|
||||||
|
price * quantity // Total value
|
||||||
|
]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user