diff --git a/.env.example b/.env.example index dad5681..b7957ba 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,13 @@ VITE_ANVIL_RPC_URL=http://127.0.0.1:8545 VITE_BASE_SEPOLIA_RPC_URL=https://sepolia.base.org VITE_SEPOLIA_RPC_URL= VITE_MAINNET_RPC_URL= + +# UTM settings for shared profile links (optional) +# Set any of these to enable UTM parameters on shared profile URLs. +# Remove or comment out to disable UTM tracking. +# Example configuration: +VITE_UTM_SOURCE=accesslayer +VITE_UTM_MEDIUM=share +VITE_UTM_CAMPAIGN=profile-sharing +# VITE_UTM_TERM= +# VITE_UTM_CONTENT= diff --git a/src/components/common/StickyFilterBar.tsx b/src/components/common/StickyFilterBar.tsx index c0ae83f..4c05c62 100644 --- a/src/components/common/StickyFilterBar.tsx +++ b/src/components/common/StickyFilterBar.tsx @@ -1,7 +1,14 @@ -import { useEffect, useState, type ReactNode } from 'react'; +import { useEffect, useRef, useState, type ReactNode } from 'react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; -import { X } from 'lucide-react'; +import { Filter, X } from 'lucide-react'; +import { + BottomSheet, + BottomSheetContent, + BottomSheetHandle, + BottomSheetTitle, + BottomSheetTrigger, +} from '@/components/ui/bottom-sheet'; interface StickyFilterBarProps { eyebrow?: string; @@ -28,6 +35,23 @@ const StickyFilterBar: React.FC = ({ resultCount ); + // Track whether the viewport is mobile-sized (≤767 px). + // Using a state-driven matchMedia listener rather than a CSS class so + // the filter controls are only rendered in ONE place in the DOM at a + // time, which prevents duplicate element IDs. + const [isMobile, setIsMobile] = useState(() => { + if (typeof window === 'undefined') return false; + return window.matchMedia('(max-width: 767px)').matches; + }); + + // Open state for the mobile filter panel. + const [isFilterPanelOpen, setIsFilterPanelOpen] = useState(false); + + // Ref kept on the trigger so Radix can restore focus on close. + // (Radix Dialog does this automatically via the Trigger element, but + // keeping the ref lets us imperatively focus it in edge-cases.) + const triggerRef = useRef(null); + // Debounce result count announcements so screen readers don't stutter // on every individual keystroke during a search. useEffect(() => { @@ -37,11 +61,36 @@ const StickyFilterBar: React.FC = ({ return () => clearTimeout(timer); }, [resultCount]); + // Subscribe to viewport changes. + useEffect(() => { + const mq = window.matchMedia('(max-width: 767px)'); + const handler = (e: MediaQueryListEvent) => { + setIsMobile(e.matches); + // Close the sheet when the user resizes to desktop so it doesn't + // linger in a half-open state. + if (!e.matches) setIsFilterPanelOpen(false); + }; + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); + }, []); + const announcementText = typeof announcedCount === 'number' ? `${announcedCount} ${announcedCount === 1 ? 'result' : 'results'} found.` : ''; + const resetButton = showReset && onReset && ( + + ); + return (
@@ -71,17 +120,7 @@ const StickyFilterBar: React.FC = ({ {resultCount === 1 ? 'result' : 'results'} )} - {showReset && onReset && ( - - )} + {resetButton}
{description && (

@@ -91,12 +130,65 @@ const StickyFilterBar: React.FC = ({

-
-
{children}
- - Filters stay pinned while you browse - -
+ {/* ── MOBILE: BottomSheet with built-in focus trap ── */} + {isMobile ? ( + + + + + + + + {/* BottomSheetTitle satisfies the Radix Dialog title requirement + and is visually hidden so it doesn't crowd the UI. */} + + {title} Filters + + +
+
+

+ {eyebrow} +

+ {resetButton} +
+ {/* Filter controls — focus is trapped here by Radix Dialog */} +
{children}
+
+
+
+ ) : ( + /* ── DESKTOP: inline controls, no overlay ── */ +
+
{children}
+ + Filters stay pinned while you browse + +
+ )} diff --git a/src/components/common/__tests__/StickyFilterBar.test.tsx b/src/components/common/__tests__/StickyFilterBar.test.tsx index 3780fad..71d6218 100644 --- a/src/components/common/__tests__/StickyFilterBar.test.tsx +++ b/src/components/common/__tests__/StickyFilterBar.test.tsx @@ -1,8 +1,53 @@ -import { describe, expect, it, vi } from 'vitest'; -import { render, screen, act } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, act, waitFor, fireEvent } from '@testing-library/react'; import StickyFilterBar from '@/components/common/StickyFilterBar'; +// --------------------------------------------------------------------------- +// matchMedia mock helpers +// --------------------------------------------------------------------------- + +type MQCallback = (e: Pick) => void; + +interface MockMQL { + matches: boolean; + addEventListener: (event: string, cb: MQCallback) => void; + removeEventListener: (event: string, cb: MQCallback) => void; + _fire: (newMatches: boolean) => void; +} + +function mockMatchMedia(matches: boolean): MockMQL { + const listeners: MQCallback[] = []; + const mql: MockMQL = { + matches, + addEventListener: (_event: string, cb: MQCallback) => + listeners.push(cb), + removeEventListener: (_event: string, cb: MQCallback) => { + const idx = listeners.indexOf(cb); + if (idx !== -1) listeners.splice(idx, 1); + }, + _fire: (newMatches: boolean) => + listeners.forEach((cb) => cb({ matches: newMatches })), + }; + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockReturnValue(mql), + }); + return mql; +} + +// --------------------------------------------------------------------------- +// Existing accessibility tests (unchanged behaviour) +// --------------------------------------------------------------------------- + describe('StickyFilterBar accessibility', () => { + beforeEach(() => { + mockMatchMedia(false); // desktop by default + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + it('renders a visually hidden aria-live region for search results', () => { render( @@ -23,11 +68,8 @@ describe('StickyFilterBar accessibility', () => { ); - // Initial render should have the count (or undefined if it's the very first render before effect) - // Actually in my implementation, announcedCount is initialized with resultCount expect(screen.getByRole('status')).toHaveTextContent('5 results found.'); - // Change count rerender(
Children
@@ -37,12 +79,10 @@ describe('StickyFilterBar accessibility', () => { // Should still show old count immediately expect(screen.getByRole('status')).toHaveTextContent('5 results found.'); - // Fast forward time act(() => { vi.advanceTimersByTime(500); }); - // Should show new count expect(screen.getByRole('status')).toHaveTextContent('10 results found.'); vi.useRealTimers(); @@ -55,8 +95,178 @@ describe('StickyFilterBar accessibility', () => {
); - // The visual count span should have aria-hidden="true" const visualCount = screen.getByText('5 results'); expect(visualCount).toHaveAttribute('aria-hidden', 'true'); }); }); + +// --------------------------------------------------------------------------- +// Focus-trap / BottomSheet tests — mobile viewport +// --------------------------------------------------------------------------- + +describe('StickyFilterBar – mobile focus trap', () => { + beforeEach(() => { + mockMatchMedia(true); // simulate mobile (max-width: 767px) + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders a trigger button instead of inline controls on mobile', () => { + render( + + + + ); + + // Trigger must exist + expect(screen.getByTestId('filter-panel-trigger')).toBeInTheDocument(); + + // Inline filter input must NOT be visible — it lives inside the sheet + // which is closed, so Radix won't mount it yet. + expect(screen.queryByTestId('filter-input')).not.toBeInTheDocument(); + }); + + it('trigger button has aria-expanded="false" when panel is closed', () => { + render( + +
Controls
+
+ ); + + const trigger = screen.getByTestId('filter-panel-trigger'); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + }); + + it('opens the filter panel when the trigger is clicked', async () => { + render( + + + + ); + + act(() => { + fireEvent.click(screen.getByTestId('filter-panel-trigger')); + }); + + await waitFor(() => + expect(screen.getByTestId('filter-panel-content')).toBeInTheDocument() + ); + + // filter controls are now rendered inside the sheet + expect(screen.getByTestId('filter-input')).toBeInTheDocument(); + }); + + it('panel has role="dialog" when open (Radix Dialog semantics)', async () => { + render( + +
Controls
+
+ ); + + act(() => { + fireEvent.click(screen.getByTestId('filter-panel-trigger')); + }); + + await waitFor(() => + expect(screen.getByRole('dialog')).toBeInTheDocument() + ); + }); + + it('panel includes a visually-hidden title for screen readers', async () => { + render( + +
Controls
+
+ ); + + act(() => { + fireEvent.click(screen.getByTestId('filter-panel-trigger')); + }); + + await waitFor(() => + expect( + screen.getByText('Marketplace Filters') + ).toBeInTheDocument() + ); + }); + + it('closes the panel when Escape is pressed', async () => { + render( + +
Controls
+
+ ); + + act(() => { + fireEvent.click(screen.getByTestId('filter-panel-trigger')); + }); + + await waitFor(() => + expect(screen.getByRole('dialog')).toBeInTheDocument() + ); + + act(() => { + fireEvent.keyDown(screen.getByRole('dialog'), { + key: 'Escape', + code: 'Escape', + bubbles: true, + }); + }); + + await waitFor(() => + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + ); + }); + + it('inline controls are not rendered at all on mobile (no duplicate IDs)', () => { + render( + + + + ); + + // With the sheet closed, the input must not be in the DOM at all. + expect(document.querySelectorAll('#unique-filter-id')).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Desktop – no overlay, inline controls always visible +// --------------------------------------------------------------------------- + +describe('StickyFilterBar – desktop layout', () => { + beforeEach(() => { + mockMatchMedia(false); // desktop + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders children inline without a trigger button', () => { + render( + + + + ); + + expect(screen.getByTestId('filter-input')).toBeInTheDocument(); + expect( + screen.queryByTestId('filter-panel-trigger') + ).not.toBeInTheDocument(); + }); + + it('shows the "Filters stay pinned" label on desktop', () => { + render( + +
Controls
+
+ ); + + expect( + screen.getByText(/filters stay pinned while you browse/i) + ).toBeInTheDocument(); + }); +}); diff --git a/src/utils/__tests__/utm.utils.test.ts b/src/utils/__tests__/utm.utils.test.ts new file mode 100644 index 0000000..1cd37e8 --- /dev/null +++ b/src/utils/__tests__/utm.utils.test.ts @@ -0,0 +1,289 @@ +import { describe, it, expect, vi } from 'vitest'; +import { appendUtmParams, getConfiguredUtmParams, type UtmParams } from '../utm.utils'; + +// Mock the env module +vi.mock('@/utils/env.utils', () => ({ + env: { + VITE_BACKEND_URL: '/api', + VITE_DEFAULT_CHAIN_ID: 84532, + VITE_ANVIL_RPC_URL: 'http://127.0.0.1:8545', + VITE_BASE_SEPOLIA_RPC_URL: 'https://sepolia.base.org', + VITE_SEPOLIA_RPC_URL: undefined, + VITE_MAINNET_RPC_URL: undefined, + VITE_UTM_SOURCE: undefined, + VITE_UTM_MEDIUM: undefined, + VITE_UTM_CAMPAIGN: undefined, + VITE_UTM_TERM: undefined, + VITE_UTM_CONTENT: undefined, + }, +})); + +describe('UTM Helper: appendUtmParams', () => { + describe('Acceptance Criteria 1: Shared URL includes expected UTM parameters', () => { + it('appends configured UTM params to URL', () => { + const baseUrl = 'https://example.com/creator/alice'; + const params: UtmParams = { + utm_source: 'twitter', + utm_medium: 'social', + utm_campaign: 'share', + }; + + const result = appendUtmParams(baseUrl, params); + + expect(result).toContain('utm_source=twitter'); + expect(result).toContain('utm_medium=social'); + expect(result).toContain('utm_campaign=share'); + }); + + it('includes all five UTM parameters when provided', () => { + const baseUrl = 'https://example.com/creator/bob'; + const params: UtmParams = { + utm_source: 'facebook', + utm_medium: 'post', + utm_campaign: 'creator_discovery', + utm_term: 'nft_art', + utm_content: 'profile_card', + }; + + const result = appendUtmParams(baseUrl, params); + + expect(result).toContain('utm_source=facebook'); + expect(result).toContain('utm_medium=post'); + expect(result).toContain('utm_campaign=creator_discovery'); + expect(result).toContain('utm_term=nft_art'); + expect(result).toContain('utm_content=profile_card'); + }); + + it('appends only provided parameters', () => { + const baseUrl = 'https://example.com/creator/charlie'; + const params: UtmParams = { + utm_source: 'email', + utm_medium: 'newsletter', + }; + + const result = appendUtmParams(baseUrl, params); + + expect(result).toContain('utm_source=email'); + expect(result).toContain('utm_medium=newsletter'); + expect(result).not.toContain('utm_campaign'); + expect(result).not.toContain('utm_term'); + expect(result).not.toContain('utm_content'); + }); + }); + + describe('Acceptance Criteria 2: Helper does not modify URL when UTM params not configured', () => { + it('returns original URL unchanged when no params provided', () => { + const baseUrl = 'https://example.com/creator/diane'; + + const result = appendUtmParams(baseUrl); + + expect(result).toBe(baseUrl); + }); + + it('returns original URL when empty params object', () => { + const baseUrl = 'https://example.com/creator/elena'; + + const result = appendUtmParams(baseUrl, {}); + + expect(result).toBe(baseUrl); + }); + + it('returns original URL when all params are undefined', () => { + const baseUrl = 'https://example.com/creator/frank'; + const params: UtmParams = { + utm_source: undefined, + utm_medium: undefined, + }; + + const result = appendUtmParams(baseUrl, params); + + expect(result).toBe(baseUrl); + }); + }); + + describe('Acceptance Criteria 3: Base URL unchanged; only query params appended', () => { + it('preserves base URL structure', () => { + const baseUrl = 'https://example.com/creator/grace'; + const params: UtmParams = { utm_source: 'linkedin' }; + + const result = appendUtmParams(baseUrl, params); + + expect(result).toStartWith('https://example.com/creator/grace'); + }); + + it('preserves path when appending params', () => { + const baseUrl = 'https://accesslayer.com/#creations'; + const params: UtmParams = { utm_source: 'twitter', utm_medium: 'share' }; + + const result = appendUtmParams(baseUrl, params); + + expect(result).toContain('/creator/grace'); + expect(result).toContain('utm_source=twitter'); + expect(result).toContain('utm_medium=share'); + }); + + it('preserves URL hash fragment', () => { + const baseUrl = 'https://example.com/creator/henry#creations'; + const params: UtmParams = { utm_source: 'whatsapp' }; + + const result = appendUtmParams(baseUrl, params); + + expect(result).toContain('#creations'); + expect(result).toContain('utm_source=whatsapp'); + }); + + it('preserves existing query parameters', () => { + const baseUrl = 'https://example.com/creator/iris?tab=overview'; + const params: UtmParams = { utm_source: 'telegram' }; + + const result = appendUtmParams(baseUrl, params); + + expect(result).toContain('tab=overview'); + expect(result).toContain('utm_source=telegram'); + }); + + it('does not duplicate base URL', () => { + const baseUrl = 'https://example.com/creator/jack'; + const params: UtmParams = { utm_source: 'reddit' }; + + const result = appendUtmParams(baseUrl, params); + + // Should have exactly one protocol + const protocolCount = (result.match(/https:\/\//g) || []).length; + expect(protocolCount).toBe(1); + }); + }); + + describe('Edge cases', () => { + it('handles relative URLs', () => { + const baseUrl = '/creator/karen'; + const params: UtmParams = { utm_source: 'slack' }; + + const result = appendUtmParams(baseUrl, params); + + // Should fall back to original if URL parsing fails + expect(result).toContain('utm_source=slack'); + }); + + it('handles URLs with multiple existing query parameters', () => { + const baseUrl = 'https://example.com/creator/leo?a=1&b=2&c=3'; + const params: UtmParams = { utm_source: 'github', utm_campaign: 'open_source' }; + + const result = appendUtmParams(baseUrl, params); + + expect(result).toContain('a=1'); + expect(result).toContain('b=2'); + expect(result).toContain('c=3'); + expect(result).toContain('utm_source=github'); + expect(result).toContain('utm_campaign=open_source'); + }); + + it('encodes special characters in UTM param values', () => { + const baseUrl = 'https://example.com/creator/maya'; + const params: UtmParams = { + utm_content: 'Q&A Session', + utm_term: 'digital art & design', + }; + + const result = appendUtmParams(baseUrl, params); + + // URL should have properly encoded special characters + expect(result).toContain('utm_content='); + expect(result).toContain('utm_term='); + // The & should be encoded as %26 in the query string + }); + + it('returns original URL if parsing fails', () => { + const invalidUrl = 'not a valid url at all!!!'; + const params: UtmParams = { utm_source: 'source' }; + + const result = appendUtmParams(invalidUrl, params); + + expect(result).toBe(invalidUrl); + }); + + it('handles empty string params gracefully', () => { + const baseUrl = 'https://example.com/creator/noah'; + const params: UtmParams = { + utm_source: '', + utm_medium: 'social', + }; + + const result = appendUtmParams(baseUrl, params); + + // Empty utm_source should not be appended + expect(result).not.toContain('utm_source'); + expect(result).toContain('utm_medium=social'); + }); + + it('handles very long URLs', () => { + const baseUrl = + 'https://example.com/creator/' + + 'a'.repeat(100) + + '?very=long&query=with&many=params'; + const params: UtmParams = { utm_source: 'test' }; + + const result = appendUtmParams(baseUrl, params); + + expect(result).toContain('utm_source=test'); + }); + }); + + describe('getConfiguredUtmParams', () => { + it('returns configured params from environment', () => { + // This test depends on the mocked env module above + const params = getConfiguredUtmParams(); + + expect(params).toEqual({}); + }); + + it('only includes params that are configured', () => { + const params = getConfiguredUtmParams(); + + // Should not include undefined values + Object.values(params).forEach((value) => { + expect(value).toBeDefined(); + expect(value).not.toBe(undefined); + }); + }); + }); + + describe('Integration: Creator profile URL sharing', () => { + it('appends UTM params to creator profile URL', () => { + const profileUrl = window.location.href || 'https://accesslayer.com/#alice'; + const params: UtmParams = { + utm_source: 'twitter', + utm_medium: 'share_button', + utm_campaign: 'creator_profiles', + }; + + const result = appendUtmParams(profileUrl, params); + + // Base URL should be preserved + expect(result).toContain('accesslayer.com'); + // UTM params should be appended + expect(result).toContain('utm_source=twitter'); + expect(result).toContain('utm_medium=share_button'); + expect(result).toContain('utm_campaign=creator_profiles'); + }); + + it('enables tracking without separate landing pages', () => { + const creatorUrl = 'https://accesslayer.com/creator/artist_001'; + const trackingParams: UtmParams = { + utm_source: 'instagram', + utm_medium: 'post', + utm_campaign: 'featured_creator_week', + utm_content: 'bio_link', + }; + + const trackedUrl = appendUtmParams(creatorUrl, trackingParams); + + // Same creator profile URL, but with tracking + expect(trackedUrl).toContain('creator/artist_001'); + expect(trackedUrl).toContain('utm_source=instagram'); + expect(trackedUrl).toContain('utm_medium=post'); + expect(trackedUrl).toContain('utm_campaign=featured_creator_week'); + expect(trackedUrl).toContain('utm_content=bio_link'); + }); + }); +}); diff --git a/src/utils/utm.utils.ts b/src/utils/utm.utils.ts index 32d319e..ab8a08d 100644 --- a/src/utils/utm.utils.ts +++ b/src/utils/utm.utils.ts @@ -1,50 +1,76 @@ import { env } from '@/utils/env.utils'; export interface UtmParams { - utm_source?: string; - utm_medium?: string; - utm_campaign?: string; - utm_term?: string; - utm_content?: string; + utm_source?: string; + utm_medium?: string; + utm_campaign?: string; + utm_term?: string; + utm_content?: string; } +/** + * Get configured UTM parameters from environment variables + * @returns Object with configured UTM params; empty if none configured + */ export const getConfiguredUtmParams = (): UtmParams => { - const params: UtmParams = {}; + const params: UtmParams = {}; - if (env.VITE_UTM_SOURCE) params.utm_source = env.VITE_UTM_SOURCE; - if (env.VITE_UTM_MEDIUM) params.utm_medium = env.VITE_UTM_MEDIUM; - if (env.VITE_UTM_CAMPAIGN) params.utm_campaign = env.VITE_UTM_CAMPAIGN; - if (env.VITE_UTM_TERM) params.utm_term = env.VITE_UTM_TERM; - if (env.VITE_UTM_CONTENT) params.utm_content = env.VITE_UTM_CONTENT; + if (env.VITE_UTM_SOURCE) params.utm_source = env.VITE_UTM_SOURCE; + if (env.VITE_UTM_MEDIUM) params.utm_medium = env.VITE_UTM_MEDIUM; + if (env.VITE_UTM_CAMPAIGN) params.utm_campaign = env.VITE_UTM_CAMPAIGN; + if (env.VITE_UTM_TERM) params.utm_term = env.VITE_UTM_TERM; + if (env.VITE_UTM_CONTENT) params.utm_content = env.VITE_UTM_CONTENT; - return params; + return params; }; -export const appendUtmParams = (inputUrl: string, overrideParams?: UtmParams): string => { - const configured = overrideParams ?? getConfiguredUtmParams(); +/** + * Append UTM parameters to a URL. + * If no UTM params are configured, returns the original URL unchanged. + * + * @param inputUrl - The base URL to append params to + * @param overrideParams - Optional params to use instead of configured ones + * @returns URL with appended UTM params, or original URL if no params configured + * + * @example + * // With env vars: VITE_UTM_SOURCE=twitter, VITE_UTM_MEDIUM=social + * appendUtmParams('https://example.com/creator/alice') + * // returns 'https://example.com/creator/alice?utm_source=twitter&utm_medium=social' + * + * @example + * // Without env vars configured + * appendUtmParams('https://example.com/creator/alice') + * // returns 'https://example.com/creator/alice' (unchanged) + */ +export const appendUtmParams = ( + inputUrl: string, + overrideParams?: UtmParams +): string => { + const configured = overrideParams ?? getConfiguredUtmParams(); - // If no UTM params configured, return original URL unchanged - const keys = Object.keys(configured) as Array; - const hasAny = keys.some((k) => !!configured[k]); - if (!hasAny) return inputUrl; + // If no UTM params configured, return original URL unchanged + const keys = Object.keys(configured) as Array; + const hasAny = keys.some((k) => !!configured[k]); + if (!hasAny) return inputUrl; - try { - const url = new URL(inputUrl); + try { + const url = new URL(inputUrl); + const search = url.searchParams; - const search = url.searchParams; + if (configured.utm_source) search.set('utm_source', configured.utm_source); + if (configured.utm_medium) search.set('utm_medium', configured.utm_medium); + if (configured.utm_campaign) + search.set('utm_campaign', configured.utm_campaign); + if (configured.utm_term) search.set('utm_term', configured.utm_term); + if (configured.utm_content) + search.set('utm_content', configured.utm_content); - if (configured.utm_source) search.set('utm_source', configured.utm_source); - if (configured.utm_medium) search.set('utm_medium', configured.utm_medium); - if (configured.utm_campaign) search.set('utm_campaign', configured.utm_campaign); - if (configured.utm_term) search.set('utm_term', configured.utm_term); - if (configured.utm_content) search.set('utm_content', configured.utm_content); - - // Preserve hash and other parts — URL.toString() keeps them - return url.toString(); - } catch { - // If URL parsing fails, fall back to original input - return inputUrl; - } + // Preserve hash and other parts — URL.toString() keeps them + return url.toString(); + } catch { + // If URL parsing fails, fall back to original input + return inputUrl; + } }; export default appendUtmParams;