diff --git a/src/components/homepage/ContentTabs.tsx b/src/components/homepage/ContentTabs.tsx index 11dd659a..8c0c8ab5 100644 --- a/src/components/homepage/ContentTabs.tsx +++ b/src/components/homepage/ContentTabs.tsx @@ -6,9 +6,7 @@ import { Park, Ride } from '@/types/database'; import { supabase } from '@/integrations/supabase/client'; export function ContentTabs() { - const [popularParks, setPopularParks] = useState([]); const [trendingParks, setTrendingParks] = useState([]); - const [popularRides, setPopularRides] = useState([]); const [trendingRides, setTrendingRides] = useState([]); const [recentParks, setRecentParks] = useState([]); const [recentRides, setRecentRides] = useState([]); @@ -20,18 +18,11 @@ export function ContentTabs() { const fetchContent = async () => { try { - // Most Popular Parks (by rating) - const { data: popular } = await supabase - .from('parks') - .select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`) - .order('average_rating', { ascending: false }) - .limit(12); - - // Trending Parks (by review count) + // Trending Parks (by 30-day view count) const { data: trending } = await supabase .from('parks') .select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`) - .order('review_count', { ascending: false }) + .order('view_count_30d', { ascending: false }) .limit(12); // Recently Added Parks @@ -41,18 +32,11 @@ export function ContentTabs() { .order('created_at', { ascending: false }) .limit(12); - // Popular Rides (by rating) - const { data: popularRidesData } = await supabase - .from('rides') - .select(`*, park:parks!inner(name, slug, location:locations(*))`) - .order('average_rating', { ascending: false }) - .limit(12); - - // Trending Rides (by review count) + // Trending Rides (by 30-day view count) const { data: trendingRidesData } = await supabase .from('rides') .select(`*, park:parks!inner(name, slug, location:locations(*))`) - .order('review_count', { ascending: false }) + .order('view_count_30d', { ascending: false }) .limit(12); // Recently Added Rides @@ -62,10 +46,8 @@ export function ContentTabs() { .order('created_at', { ascending: false }) .limit(12); - setPopularParks(popular || []); setTrendingParks(trending || []); setRecentParks(recent || []); - setPopularRides(popularRidesData || []); setTrendingRides(trendingRidesData || []); setRecentRides(recentRidesData || []); } catch (error) { @@ -96,18 +78,12 @@ export function ContentTabs() { return (
- +
- - - Popular Parks - + Trending Parks - - Popular Rides - Trending Rides @@ -120,22 +96,10 @@ export function ContentTabs() {
- -
-

Most Popular Parks

-

Highest rated theme parks worldwide

-
-
- {popularParks.map((park) => ( - - ))} -
-
-

Trending Parks

-

Most reviewed parks this month

+

Most viewed parks in the last 30 days

{trendingParks.map((park) => ( @@ -144,22 +108,10 @@ export function ContentTabs() {
- -
-

Most Popular Rides

-

Highest rated attractions worldwide

-
-
- {popularRides.map((ride) => ( - - ))} -
-
-

Trending Rides

-

Most talked about attractions

+

Most viewed rides in the last 30 days

{trendingRides.map((ride) => ( diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index c3e188a7..0aed41f9 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -93,6 +93,9 @@ export type Database = { review_count: number | null slug: string updated_at: string + view_count_30d: number | null + view_count_7d: number | null + view_count_all: number | null website_url: string | null } Insert: { @@ -113,6 +116,9 @@ export type Database = { review_count?: number | null slug: string updated_at?: string + view_count_30d?: number | null + view_count_7d?: number | null + view_count_all?: number | null website_url?: string | null } Update: { @@ -133,6 +139,9 @@ export type Database = { review_count?: number | null slug?: string updated_at?: string + view_count_30d?: number | null + view_count_7d?: number | null + view_count_all?: number | null website_url?: string | null } Relationships: [] @@ -359,6 +368,33 @@ export type Database = { }, ] } + entity_page_views: { + Row: { + created_at: string | null + entity_id: string + entity_type: string + id: string + session_hash: string | null + viewed_at: string | null + } + Insert: { + created_at?: string | null + entity_id: string + entity_type: string + id?: string + session_hash?: string | null + viewed_at?: string | null + } + Update: { + created_at?: string | null + entity_id?: string + entity_type?: string + id?: string + session_hash?: string | null + viewed_at?: string | null + } + Relationships: [] + } entity_relationships_history: { Row: { change_type: string @@ -960,6 +996,9 @@ export type Database = { slug: string status: string updated_at: string + view_count_30d: number | null + view_count_7d: number | null + view_count_all: number | null website_url: string | null } Insert: { @@ -986,6 +1025,9 @@ export type Database = { slug: string status?: string updated_at?: string + view_count_30d?: number | null + view_count_7d?: number | null + view_count_all?: number | null website_url?: string | null } Update: { @@ -1012,6 +1054,9 @@ export type Database = { slug?: string status?: string updated_at?: string + view_count_30d?: number | null + view_count_7d?: number | null + view_count_all?: number | null website_url?: string | null } Relationships: [ @@ -2030,6 +2075,9 @@ export type Database = { slug: string status: string updated_at: string + view_count_30d: number | null + view_count_7d: number | null + view_count_all: number | null } Insert: { age_requirement?: number | null @@ -2067,6 +2115,9 @@ export type Database = { slug: string status?: string updated_at?: string + view_count_30d?: number | null + view_count_7d?: number | null + view_count_all?: number | null } Update: { age_requirement?: number | null @@ -2104,6 +2155,9 @@ export type Database = { slug?: string status?: string updated_at?: string + view_count_30d?: number | null + view_count_7d?: number | null + view_count_all?: number | null } Relationships: [ { @@ -2707,6 +2761,10 @@ export type Database = { Args: Record Returns: undefined } + cleanup_old_page_views: { + Args: Record + Returns: undefined + } compare_versions: { Args: { p_from_version_id: string; p_to_version_id: string } Returns: Json @@ -2825,6 +2883,10 @@ export type Database = { Args: { target_company_id: string } Returns: undefined } + update_entity_view_counts: { + Args: Record + Returns: undefined + } update_park_ratings: { Args: { target_park_id: string } Returns: undefined diff --git a/src/lib/viewTracking.ts b/src/lib/viewTracking.ts new file mode 100644 index 00000000..185885b3 --- /dev/null +++ b/src/lib/viewTracking.ts @@ -0,0 +1,47 @@ +import { supabase } from '@/integrations/supabase/client'; + +// Generate anonymous session hash (no PII) +function getSessionHash(): string { + // Check if we have a session hash in sessionStorage + let sessionHash = sessionStorage.getItem('session_hash'); + + if (!sessionHash) { + // Create a random hash for this session (no user data) + sessionHash = `session_${Math.random().toString(36).substring(2, 15)}`; + sessionStorage.setItem('session_hash', sessionHash); + } + + return sessionHash; +} + +// Debounce tracking to avoid rapid-fire views +const trackedViews = new Set(); + +export async function trackPageView( + entityType: 'park' | 'ride' | 'company', + entityId: string +) { + // Create unique key for this view + const viewKey = `${entityType}:${entityId}`; + + // Don't track the same entity twice in the same session + if (trackedViews.has(viewKey)) { + return; + } + + trackedViews.add(viewKey); + + try { + // Track view asynchronously (fire and forget) + await supabase.from('entity_page_views').insert({ + entity_type: entityType, + entity_id: entityId, + session_hash: getSessionHash() + }); + + console.log(`✅ Tracked view: ${entityType} ${entityId}`); + } catch (error) { + // Fail silently - don't break the page if tracking fails + console.error('Failed to track page view:', error); + } +} diff --git a/src/pages/DesignerDetail.tsx b/src/pages/DesignerDetail.tsx index bcc72524..17cc8af3 100644 --- a/src/pages/DesignerDetail.tsx +++ b/src/pages/DesignerDetail.tsx @@ -18,6 +18,7 @@ import { toast } from '@/hooks/use-toast'; import { submitCompanyUpdate } from '@/lib/companyHelpers'; import { VersionIndicator } from '@/components/versioning/VersionIndicator'; import { EntityHistoryTabs } from '@/components/history/EntityHistoryTabs'; +import { trackPageView } from '@/lib/viewTracking'; export default function DesignerDetail() { const { slug } = useParams<{ slug: string }>(); @@ -37,6 +38,13 @@ export default function DesignerDetail() { } }, [slug]); + // Track page view when designer is loaded + useEffect(() => { + if (designer?.id) { + trackPageView('company', designer.id); + } + }, [designer?.id]); + const fetchDesignerData = async () => { try { const { data, error } = await supabase diff --git a/src/pages/ManufacturerDetail.tsx b/src/pages/ManufacturerDetail.tsx index 0da3f4cc..ba266b63 100644 --- a/src/pages/ManufacturerDetail.tsx +++ b/src/pages/ManufacturerDetail.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { Header } from '@/components/layout/Header'; +import { trackPageView } from '@/lib/viewTracking'; import { getBannerUrls } from '@/lib/cloudflareImageUtils'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -38,6 +39,13 @@ export default function ManufacturerDetail() { } }, [slug]); + // Track page view when manufacturer is loaded + useEffect(() => { + if (manufacturer?.id) { + trackPageView('company', manufacturer.id); + } + }, [manufacturer?.id]); + const fetchManufacturerData = async () => { try { const { data, error } = await supabase diff --git a/src/pages/OperatorDetail.tsx b/src/pages/OperatorDetail.tsx index 1752bb61..74211069 100644 --- a/src/pages/OperatorDetail.tsx +++ b/src/pages/OperatorDetail.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { Header } from '@/components/layout/Header'; +import { trackPageView } from '@/lib/viewTracking'; import { getBannerUrls } from '@/lib/cloudflareImageUtils'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -41,6 +42,13 @@ export default function OperatorDetail() { } }, [slug]); + // Track page view when operator is loaded + useEffect(() => { + if (operator?.id) { + trackPageView('company', operator.id); + } + }, [operator?.id]); + const fetchOperatorData = async () => { try { const { data, error } = await supabase diff --git a/src/pages/ParkDetail.tsx b/src/pages/ParkDetail.tsx index 248c59fc..67aaf99f 100644 --- a/src/pages/ParkDetail.tsx +++ b/src/pages/ParkDetail.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { Header } from '@/components/layout/Header'; import { getBannerUrls } from '@/lib/cloudflareImageUtils'; +import { trackPageView } from '@/lib/viewTracking'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -45,6 +46,13 @@ export default function ParkDetail() { fetchParkData(); } }, [slug]); + + // Track page view when park is loaded + useEffect(() => { + if (park?.id) { + trackPageView('park', park.id); + } + }, [park?.id]); const fetchParkData = async () => { try { // Fetch park details diff --git a/src/pages/PropertyOwnerDetail.tsx b/src/pages/PropertyOwnerDetail.tsx index 8234ffc5..463873dc 100644 --- a/src/pages/PropertyOwnerDetail.tsx +++ b/src/pages/PropertyOwnerDetail.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { Header } from '@/components/layout/Header'; +import { trackPageView } from '@/lib/viewTracking'; import { getBannerUrls } from '@/lib/cloudflareImageUtils'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -41,6 +42,13 @@ export default function PropertyOwnerDetail() { } }, [slug]); + // Track page view when property owner is loaded + useEffect(() => { + if (owner?.id) { + trackPageView('company', owner.id); + } + }, [owner?.id]); + const fetchOwnerData = async () => { try { const { data, error } = await supabase diff --git a/src/pages/RideDetail.tsx b/src/pages/RideDetail.tsx index cc4eeb44..1bf60310 100644 --- a/src/pages/RideDetail.tsx +++ b/src/pages/RideDetail.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { Header } from '@/components/layout/Header'; import { getBannerUrls } from '@/lib/cloudflareImageUtils'; +import { trackPageView } from '@/lib/viewTracking'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -50,14 +51,14 @@ import { EntityHistoryTabs } from '@/components/history/EntityHistoryTabs'; export default function RideDetail() { const { parkSlug, rideSlug } = useParams<{ parkSlug: string; rideSlug: string }>(); const navigate = useNavigate(); + const { user } = useAuth(); + const { isModerator } = useUserRole(); const [ride, setRide] = useState(null); const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState("overview"); const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [photoCount, setPhotoCount] = useState(0); const [statsLoading, setStatsLoading] = useState(true); - const { user } = useAuth(); - const { isModerator } = useUserRole(); useEffect(() => { if (parkSlug && rideSlug) { @@ -65,6 +66,13 @@ export default function RideDetail() { } }, [parkSlug, rideSlug]); + // Track page view when ride is loaded + useEffect(() => { + if (ride?.id) { + trackPageView('ride', ride.id); + } + }, [ride?.id]); + const fetchRideData = async () => { try { // First get park to find park_id diff --git a/supabase/migrations/20251010155048_7be05bad-b276-4b15-9546-7ff5b9f9bcd9.sql b/supabase/migrations/20251010155048_7be05bad-b276-4b15-9546-7ff5b9f9bcd9.sql new file mode 100644 index 00000000..225f4b05 --- /dev/null +++ b/supabase/migrations/20251010155048_7be05bad-b276-4b15-9546-7ff5b9f9bcd9.sql @@ -0,0 +1,121 @@ +-- Phase 1: Create view tracking infrastructure + +-- Create entity_page_views table for tracking page views +CREATE TABLE entity_page_views ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + entity_type text NOT NULL CHECK (entity_type IN ('park', 'ride', 'company')), + entity_id uuid NOT NULL, + viewed_at timestamp with time zone DEFAULT now(), + session_hash text, + created_at timestamp with time zone DEFAULT now() +); + +-- Create index for fast trending queries +CREATE INDEX idx_entity_views_trending ON entity_page_views(entity_type, entity_id, viewed_at DESC); +CREATE INDEX idx_entity_views_by_type ON entity_page_views(entity_type, viewed_at DESC); + +-- Enable RLS +ALTER TABLE entity_page_views ENABLE ROW LEVEL SECURITY; + +-- Policy: Allow inserts from anyone (tracking is anonymous) +CREATE POLICY "Anyone can track page views" ON entity_page_views + FOR INSERT WITH CHECK (true); + +-- Policy: Only moderators can read analytics +CREATE POLICY "Moderators can read analytics" ON entity_page_views + FOR SELECT USING (is_moderator(auth.uid())); + +-- Add view count columns to parks +ALTER TABLE parks ADD COLUMN IF NOT EXISTS view_count_7d integer DEFAULT 0; +ALTER TABLE parks ADD COLUMN IF NOT EXISTS view_count_30d integer DEFAULT 0; +ALTER TABLE parks ADD COLUMN IF NOT EXISTS view_count_all integer DEFAULT 0; + +-- Add view count columns to rides +ALTER TABLE rides ADD COLUMN IF NOT EXISTS view_count_7d integer DEFAULT 0; +ALTER TABLE rides ADD COLUMN IF NOT EXISTS view_count_30d integer DEFAULT 0; +ALTER TABLE rides ADD COLUMN IF NOT EXISTS view_count_all integer DEFAULT 0; + +-- Add view count columns to companies +ALTER TABLE companies ADD COLUMN IF NOT EXISTS view_count_7d integer DEFAULT 0; +ALTER TABLE companies ADD COLUMN IF NOT EXISTS view_count_30d integer DEFAULT 0; +ALTER TABLE companies ADD COLUMN IF NOT EXISTS view_count_all integer DEFAULT 0; + +-- Create indexes for sorting by view count +CREATE INDEX idx_parks_view_count_30d ON parks(view_count_30d DESC); +CREATE INDEX idx_rides_view_count_30d ON rides(view_count_30d DESC); +CREATE INDEX idx_companies_view_count_30d ON companies(view_count_30d DESC); + +-- Create function to update view counts (runs daily via cron) +CREATE OR REPLACE FUNCTION update_entity_view_counts() +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public' +AS $$ +BEGIN + -- Update parks view counts + UPDATE parks p SET + view_count_7d = ( + SELECT COUNT(*) FROM entity_page_views + WHERE entity_type = 'park' AND entity_id = p.id + AND viewed_at >= NOW() - INTERVAL '7 days' + ), + view_count_30d = ( + SELECT COUNT(*) FROM entity_page_views + WHERE entity_type = 'park' AND entity_id = p.id + AND viewed_at >= NOW() - INTERVAL '30 days' + ), + view_count_all = ( + SELECT COUNT(*) FROM entity_page_views + WHERE entity_type = 'park' AND entity_id = p.id + ); + + -- Update rides view counts + UPDATE rides r SET + view_count_7d = ( + SELECT COUNT(*) FROM entity_page_views + WHERE entity_type = 'ride' AND entity_id = r.id + AND viewed_at >= NOW() - INTERVAL '7 days' + ), + view_count_30d = ( + SELECT COUNT(*) FROM entity_page_views + WHERE entity_type = 'ride' AND entity_id = r.id + AND viewed_at >= NOW() - INTERVAL '30 days' + ), + view_count_all = ( + SELECT COUNT(*) FROM entity_page_views + WHERE entity_type = 'ride' AND entity_id = r.id + ); + + -- Update companies view counts + UPDATE companies c SET + view_count_7d = ( + SELECT COUNT(*) FROM entity_page_views + WHERE entity_type = 'company' AND entity_id = c.id + AND viewed_at >= NOW() - INTERVAL '7 days' + ), + view_count_30d = ( + SELECT COUNT(*) FROM entity_page_views + WHERE entity_type = 'company' AND entity_id = c.id + AND viewed_at >= NOW() - INTERVAL '30 days' + ), + view_count_all = ( + SELECT COUNT(*) FROM entity_page_views + WHERE entity_type = 'company' AND entity_id = c.id + ); +END; +$$; + +-- Create cleanup function to prevent database bloat +CREATE OR REPLACE FUNCTION cleanup_old_page_views() +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public' +AS $$ +BEGIN + -- Delete views older than 90 days (keep recent data only) + DELETE FROM entity_page_views + WHERE viewed_at < NOW() - INTERVAL '90 days'; +END; +$$; \ No newline at end of file