Developer Guide - Deckly
Welcome to the Deckly codebase. This document is the source of truth for the technical architecture, core service layers, and development workflows.
Tech Stack
- Frontend: React 19 + Vite + TypeScript (SPA)
- Styling: Tailwind CSS (utility classes) + shadcn/ui (component primitives: Tabs, Button, Badge, Table, etc.) + Custom CSS tokens in
App.css/index.css - Backend-as-a-Service: Supabase (PostgreSQL, Storage, Auth)
- PDF Engine:
pdfjs-dist(Browser-side Canvas/WebP rendering) - Data Fetching: TanStack Query (v5) - Core async state & caching
- Edge Functions: Deno + Supabase Functions (
sign-deck-url,document-processor,delete-account) authenticated withPROJECT_SECRET_KEYfor owner-mode operations (e.g. signing private assets). - Analytics: PostHog (Event Analysis) + Supabase RPCs (
record_deck_visit,get_deck_locations,count_unique_visitors) - Geo-Location: Vercel Edge Functions (
/api/geo) capturing header-based ingress metadata - Security: Hardened via
VITE_SUPABASE_PUBLISHABLE_KEY, Asymmetric JWT Signing, and Transactional Advisory Locks for race-condition prevention. - Icons: Lucide React
Project Structure
src/
├── components/ # UI Components (Modals, Panels, Gates)
│ ├── common/ # Atomic UI atoms (Buttons, Toggles, Inputs, ConfirmModal)
│ │ ├── ConfirmModal.tsx # High-end, animated confirmation dialog
│ ├── dashboard/ # Dashboard-specific cards and charts
│ │ ├── AnalyticsChart.tsx # Responsive bar chart (flex-1 max-w-12 bars)
│ │ ├── AnalyticsDashboard.tsx # Tabbed analytics overview card
│ │ ├── AnalyticsStatsSection.tsx
│ │ ├── ContentStatsCard.tsx # Inline stat row for Content page
│ │ └── DecksTable.tsx # Desktop table / Mobile card-list
│ ├── layout/ # Global layout shell
│ │ ├── DashboardLayout.tsx # Sidebar + Header + FAB + BottomNav
│ │ ├── Sidebar.tsx # Desktop-only navigation (md+)
│ │ └── BottomNav.tsx # Mobile-only bottom nav (< md)
│ └── dashboard/
│ └── MascotSettingsModal.tsx # Branding customization UI
├── constants/ # Tier definitions and theme tokens
│ └── tiers.ts # Tier type, TierConfig (days, maxDataRooms)
├── contexts/ # Global State (Auth, Feature Gating, Branding)
├── hooks/ # Domain-specific logic (useProfile, useDecks)
├── pages/
│ ├── DataRoomsPage.tsx # Room listing with tier enforcement
│ ├── DataRoomDetail.tsx # Per-room detail view
│ ├── ManageDataRoom.tsx # Create / Edit room form
│ ├── DataRoomViewer.tsx # Public room viewer
│ ├── DeckAnalytics.tsx # Per-deck detailed engagement page
│ ├── Viewer.tsx # Deck viewer with access protection
│ ├── LegacyRedirect.tsx # Gracefully handles legacy URL structures
│ └── Home.tsx # Root redirect
├── services/ # Core Business Logic (The "Brain")
│ ├── deckService.ts # Composed facade over deck-focused modules
│ ├── deckStorageService.ts
│ ├── deckLibraryService.ts
│ ├── deckBrandingService.ts
│ ├── authSession.ts
│ ├── analyticsService.ts # Heartbeat tracking & Aggregation (RPC Optimized)
│ ├── dataRoomService.ts # Room CRUD, documents, analytics
│ ├── interestSignalService.ts # Visitor engagement signal computation
│ └── userService.ts # Profile & Tier management
├── utils/ # Shared Utilities
│ └── url.ts # Centralized share-link & routing logic
└── types/ # Global TypeScript interfaces
Core Workflows
1. The PDF processing Pipeline
To keep costs low and privacy high, we avoid server-side PDF processing.
- Location:
ManageDeck.tsx+useManageDeckWorkflow.ts+workflows/deckProcessing.ts. - Logic: User selects PDF → shared workflow renders pages and extracts links → blobs are uploaded through
deckStorageService/deckService.uploadSlideImages().
2. Real-time Analytics Heartbeat
Tracking exact slide-level dwell time without losing data on tab close.
- Location:
Viewer.tsx+analyticsService.syncSlideStats(). - Logic: A timer tracks active viewing. Every 5s (or on slide change), we sync the time to
deck_page_views(per-slide granularity) anddeck_stats(aggregate) in Supabase. - Deduplication: We use a persistent
visitorIdinlocalStorageto ensure unique view counts over a 24-hour sliding window. - Email linking: When
AccessGatecaptures an email, it is stored inviewer_emailondeck_page_viewsand displayed in the Visitor Engagement Signals section.
3. Layered Performance Optimization
We maintain a "one step ahead" user experience by preparing the environment before actions are taken.
- Location:
Home.tsx,Sidebar.tsx, individual Repository components. - Logic:
- Global: Dashboard initializes and pre-loads heavy component chunks while prefetching core index metadata in the background.
- Intent: Navigation items (Sidebar/Cards) use
onMouseEnterto pre-load specific JS bundles and trigger target data queries before the click occurs. - Balanced: Heavy analytics and binary assets are excluded from pre-loading to maintain minimal database and bandwidth overhead.
3. Protection Interceptors & Asset Signing
Intercepting the viewer for password/email gates.
- Location:
AccessGate.tsx. - Logic:
- The
Viewercomponent renders theAccessGatebased on deck metadata. - Access is verified via
get_deck_payloadandcheck_deck_passwordRPCs. - Asset Signing: Once authenticated, the client calls the
sign-deck-urlEdge Function with thestorage_path. - IDOR Protection: The Edge Function re-fetches the canonical path from the database to ensure the user is not attempting to sign an unauthorized file path.
- Refresh Logic:
Viewer.tsximplements asignedUrlMetarefresh loop that re-signs the URL 60s before its 1-hour expiry, ensuring session longevity. - Log Redaction: All Edge Functions implement automatic PII redaction (UUIDs, internal paths) to ensure security compliance in observability logs.
- The
4. Consistent Link Generation
To prevent broken links during routing schema updates, we centralize all public and internal URL generation.
- Location:
src/utils/url.ts. - Logic: All share-links (copy-to-clipboard) and internal navigation items (React Router
Linkcomponents) must use the helpers inurl.ts. This ensures that if the routing pattern changes (e.g., from/:handle/:slugto/v/:handle/:slug), we only need to update the logic in one file to maintain consistency across seven different components.
Key Services
deckService.ts
- Public facade used by the app
- Internally composed over narrower modules for CRUD, storage, branding, and library behavior
- Kept as a stable API while reducing service sprawl
authSession.ts
- Shared
getSessionUserId()andgetRequiredSessionUserId()helpers - Used to reduce repeated ad-hoc
supabase.auth.getSession()calls across services
analyticsService.ts
- Retention: Automatically filters stats based on the user's tier (7 days for Free, 90 days for Pro).
- Server-Side Aggregation: Optimized via
count_unique_visitorsandget_deck_locationsRPCs to avoid client-side memory bloat. - Unique Visitors: Uses
p_deck_idUUID params to count distinctvisitor_ids directly in PostgreSQL.
interestSignalService.ts
- Signal Computation: Queries
deck_page_viewsgrouped byvisitor_id, computes 5 engagement signals (Revisited, Viewed multiple times, Deep read, Quick return, Extended viewing). - Slide Breakdown: Returns per-slide time data (
slideBreakdown) for each visitor, rendered as a vertical bar chart. - Email Resolution: Returns
viewer_emailwhen available, enabling the UI to show actual emails instead of anonymous IDs.
dataRoomService.ts
- CRUD: Full create/read/update/delete for data rooms with Supabase.
- Document Management: Junction table (
data_room_documents) links decks to rooms. Supports add, remove, reorder. - Analytics Aggregation:
getDataRoomAnalytics()aggregates visitor counts across all documents in a room. - Icon Upload:
uploadRoomIcon()handles custom room icon uploads to Supabase Storage.
Mobile Layout System
The dashboard is fully responsive using a Tailwind md breakpoint (768px) as the single split point between mobile and desktop experiences.
Navigation
| Screen | Navigation Component | Location |
|---|---|---|
Mobile (< md) | BottomNav.tsx | Fixed bottom, z-50 |
Desktop (≥ md) | Sidebar.tsx | Fixed left column |
Both components read from the same navItems array but render differently. BottomNav is icon-only with an active dot; Sidebar shows labels and a user profile block.
Layout Rules for Screen Fit
- Content padding: Always use
p-4 md:p-8pattern. Never hard-code large padding without a mobile fallback. - Bottom clearance: Pages inside
DashboardLayoutgetpb-20 md:pb-0on the main scroll area so content is never hidden behind the bottom nav bar. - FAB position: The Floating Action Button sits at
bottom-24on mobile (above the nav bar) andbottom-10on desktop. - Tables on mobile: Use the dual-render pattern — wrap the
<Table>inhidden md:blockand add amd:hiddencard list above it. This avoids horizontal scroll and gives a native app feel. - Custom Scrollbars: Native browser scrollbars on interactive elements like data tables have been replaced with a slim, branded
custom-scrollbarutility fromindex.css. - Font scaling: Stat values use
text-2xl md:text-5xl— never go full-size on mobile; always include a smaller mobile variant. - Grids: Summary card grids use
grid-cols-2 md:grid-cols-4(2×2 on mobile, 1×4 on desktop).
The AnalyticsChart Responsive Pattern
The custom bar chart avoids a fixed-width layout by using:
// Each bar column
<div className="flex-1 max-w-12 h-full ...">
{/* Bar fills 3/4 of column width */}
<div className="w-3/4 mx-auto bg-slate-900 rounded-full ..." />
</div>flex-1— fills available space naturally (no overflow on mobile).max-w-12— caps bar width at 48px on wide desktop screens.- X-axis labels mirror the same
flex-1 max-w-12 truncatepattern.
User Management & Sessions
We use Supabase Auth for identity management. The primary lifecycle is managed in src/contexts/AuthContext.tsx.
The Auth Lifecycle & Watchdog
- The Race: On app initialization, we race the
supabase.auth.getSession()call against a 10s timeout. This prevents the app from hanging forever if the database is "cold" or the network is slow. - Session Persistence: Once a session is retrieved, it's stored in React state.
- Profile & Branding Sync: The context immediately triggers a call to
userService.getProfile(userId)anddeckService.getBrandingSettings(userId)to fetch the user's tier and custom branding (logo/name). - Branding Persistence: Branding state is cached in
localStorageinsideAuthContextto ensure custom logos are available instantly on hard refreshes, preventing the default "penguin" flicker. - Subscription: We listen to
onAuthStateChangeto reactively update the UI when users sign in, sign out, or their profile changes.
Pro Tier Gating
The isPro variable is exposed globally via useAuth(). It evaluates to true if the user_tier is PRO or PRO_PLUS. This gates:
- Password protection settings in
ManageDeck. - Extended analytics retention visibility.
- Data room creation limits (Free: 1, Pro: 5, Pro+: unlimited).
Google/GitHub OAuth Auto-Profile
When a user signs in via OAuth (Google, GitHub) for the first time, AuthContext.fetchProfile() detects that no profiles row exists and auto-creates one:
- Calls
supabase.auth.getUser()to read auth metadata - Creates a profile row with
full_name,avatar_urlfrom metadata, andtier: "FREE" - Re-fetches the profile so the UI immediately reflects the user's name and tier badge
This ensures OAuth users get the same experience as email sign-up users without requiring a database trigger.
The PDF Data Pipeline
Each document is processed entirely in the browser to ensure privacy and eliminate server processing costs.
- Upload: The original source PDF is pushed to
decksstorage. - Rendering:
pdfjs-distis used to load the PDF. It loops through each page and renders it to a Hidden Canvas at 1.5x scale (for High DPI clarity). - Image Conversion: The canvas is converted to a WebP Blob (80% quality).
- Concurrent Push:
deckService.uploadSlideImages()delegates to the storage layer and uses a Concurrency Limit of 3 with retry/backoff logic.
Database Architecture
The schema is optimized for high-speed retrieval of analytics. See supabase/schema.sql for the full blueprint.
Table Schema
decks: The core document record. Includes thepagescolumn (an array of strings) which stores the direct URLs to the processed WebP images.profiles: Extended user data managed byuserService. Includestier(FREE, PRO, PRO_PLUS) for feature gating.deck_stats: An aggregate table used for the dashboard (per-slide totals).deck_page_views: Granular per-visitor per-slide view records. Storesvisitor_id,viewer_email,time_spent, andviewed_at. Used for unique visitor counting and interest signal computation.data_rooms: Room metadata — name, slug, description, icon, access controls.data_room_documents: Junction table for N-M relationship between rooms and decks.
Pro-Tip: Storage RLS & Metadata
When writing INSERT or UPDATE policies for Supabase Storage, always use COALESCE for metadata-based checks.
-- Pattern for resilience against async metadata processing
(metadata->>'property')::type -- ❌ Risky (fails if property is null/missing)
COALESCE((metadata->>'property')::type, default) -- ✅ SafeSupabase Storage often performs an initial database insert before metadata is fully extracted. Without the COALESCE guard, your RLS check will return NULL and fail the request.
Monitoring Edge Functions
The sign-deck-url function provides transparent debugging via structured logs. When an RPC failure occurs, the function captures and logs:
message: The human-readable error.hint/details: Context provided by PostgreSQL (e.g., 'table not found' or 'type mismatch').code: The Supabase/Postgrest error code (e.g.,PGRST116).
Note on Privacy: All Edge Functions redact sensitive user IDs and internal storage paths from logs to prevent PII exposure.
Check the Functions > Logs tab in the Supabase Dashboard to diagnose permission issues or unauthorized access attempts.
data_room_documents: Junction table linking decks to rooms withdisplay_orderfor custom ordering.
High-Performance Analytics Heartbeat
Tracking exact slide-level dwell time:
- When a user opens a deck in
Viewer.tsx, a timer starts. analyticsService.syncSlideStats()is called whenever a user changes slides or closes the browser tab.- The Sync: It uses the
record_deck_visitRPC. This is aSECURITY DEFINERfunction that performs server-side validation of the payload, updatesdeck_stats(atomically), and inserts intodeck_page_views. It correctly parsesx-forwarded-forto identify unique client IPs for accurate rate limiting. - Unique Visitors: "Total Visit" counts are derived from
COUNT(DISTINCT visitor_id)ondeck_page_views. Daily metrics ingetDailyMetrics()use a composite unique key of(visitor_id, deck_id, date)to ensure the dashboard reflects "Daily Deck Views" rather than raw slide turns. Oversized visitor IDs are automatically truncated to prevent database corruption. - Real Engagement Data: The
getDailyMetrics()service aggregates actualtime_spentfrom database records instead of using simulated placeholders. - Owner Exclusion: All viewer components (
ImageDeckViewer,DeckViewer) and theuseDeckAnalyticshook accept anisOwnerflag. If true, tracking calls toanalyticsServiceare bypassed to maintain data integrity. - The Index: We use a composite index
idx_deck_stats_dashboardon(deck_id, user_id, updated_at)to ensure the analytics modal opens instantly even for decks with hundreds of views.
Development Workflows
1. Atomic UI Modification
When adding a new button or input, always add it to src/components/common/. Do NOT write ad-hoc CSS for individual buttons; use the design tokens in App.css or index.css.
2. Data Fetching & State
We use TanStack Query for all asynchronous data fetching. Services should remain as "pure" data-fetching wrappers around Supabase. All caching logic belongs in the Query Client. See TANSTACK_QUERY.md for detailed patterns.
3. Refactor conventions
- Keep heavy orchestration out of pages when possible
- Prefer workflow hooks for screen-specific orchestration
- Prefer focused service modules behind a stable facade
- Prefer shared auth/session helpers over repeated inline session resolution
4. Migrations
Deckly uses a strict CLI-First database strategy. All schema changes must be authored as migrations in supabase/migrations/ and applied via the CLI.
- The Baseline:
00000000000000_initial_schema.sqlis the consolidated source of truth for the entire platform. - Transactional Safety: High-concurrency flows (like signup throttling) utilize
pg_advisory_xact_lock(hashtext(v_ip))within security-definer functions to ensure atomicity. - Rule of Thumb: Never use the Supabase Web SQL Editor for production-bound features.
Local Development & Security
1. RLS Ownership Requirements
When testing Data Room document management locally, be aware of the hardened RLS policy on data_room_documents.
- The Rule: You can only link a Deck to a Data Room if your
auth.uid()owns both entities. - Testing tip: If you manually insert records into
data_room_documentsfor testing, ensure theuser_idon the referenceddecksrow matches theuser_idon thedata_roomsrow.
2. High-Performance Batching
The dashboard uses the get_batch_data_room_analytics RPC to avoid N+1 query bottlenecks.
- Implementation: See
dataRoomService.getBatchDataRoomAnalytics(). - Fallback: The service automatically falls back to client-side aggregation if the RPC is missing, ensuring the UI remains functional during partial database migrations.
3. Verification workflow
Before closing structural work, run:
npm run type-check
npm run lint
npm testThe Vitest pipeline is now healthy and should be treated as a reliable local check.
