Browse documentation

Frontend Specs

RankFlow AI — Frontend Specification Part 2

- Section 5 — Dashboard Overview

docs/specs/frontend/part-2.md
On this page

Version: 1.0.0 Date: 2026-06-13 Scope: Sections 5–8 — Client Dashboard (Overview, GBP, Social, Citations, Site) Stack: Next.js 14 + React 18 + TypeScript + Tailwind CSS + tRPC + React Query + Better Auth Source Documents: backend-api.md, business_flow_map.md, gbp-social-pipeline.md, citation-network.md, landing-page-dns.md, frontend-spec-01-design-system.md


Design Token Compliance#

All UI components in this specification must adhere to the RankFlow design token system (docs/brand/tokens/design-tokens.json). Critical constraints:

Constraint Rule
Typography weight No bold weight (font-weight: 700 or font-bold) anywhere. Maximum allowed: font-weight: 500 (Medium) for headings only. Body text: 400.
Ember Orange #EC652B reserved exclusively for CTAs, primary action buttons, focus rings, booked states, and outcome indicators. Never used for decorative backgrounds or large surfaces.
Gradients No blue-purple gradients. Subtle linear gradients permitted only in chart fills using forest-teal (#167E6C) to transparent.
Primary surface Paper-white #F6F6F8 for canvas. Card-white #FFFFFF for elevated surfaces.
Typography Suisse Intl family, 400/500 weights. Deep-ink #011821 for primary text.
Spacing Token-based: 4px grid (space.1 through space.36). Card padding: space.6 (24px). Element gap: space.3 (12px).
Radius border-radius.lg (8px) for cards, inputs, buttons. border-radius.full (9999px) for badges.
Icons Lucide family, stroke-width: 1.5px, size-md: 20px.

Section 5 — Dashboard Overview#

5.1 KPI Overview (/dashboard)#

1. Purpose: The Dashboard Overview is the primary landing screen after authentication. It presents a high-level health snapshot of the practice's local SEO performance across all modules. The goal is to give the user immediate confidence in the platform's value and surface any issues requiring attention.

2. Visual Layout:

  • Page title: "Dashboard" — text-size-xl, font-weight: 500, color: deep-ink.
  • Subheader: Practice name + last updated timestamp — text-size-sm, color: slate.
  • Layout: 1200px max-width, 24px padding, vertical stack with 24px gap between cards.
  • Card 1 — KPI Grid: 4-column grid (responsive: 2 on tablet, 1 on mobile). Each card is a KPI Card from the design system.
    • Citation Health Score
    • GBP Activity Status
    • Social Reach (30-day)
    • Site Traffic (30-day)
  • Card 2 — Recent Activity: Full-width card, table of 5 most recent automated actions with status badges.
  • Card 3 — Quick Actions: Horizontal row of 4 action buttons ("New GBP Post", "New Social Post", "Run Citation Check", "View Report").
  • Card 4 — Module Health: 4-column status indicators showing green/amber/red dots for each module.

3. Data Source (tRPC Endpoint):

// tRPC endpoint: dashboard.overview
// Router: dashboard.ts (to be created)
// Input: none — practiceId derived from session context (practiceProcedure)
// Output: DashboardOverview
Field Source Endpoint
Citation Health Score citation.list citation.list aggregated
GBP Activity Status gbp.listPosts + gbp.listReviews gbp.listPosts + gbp.listReviews
Social Reach social.listPosts social.listPosts aggregated
Site Traffic site.get + external analytics site.get (traffic field)
Recent Activity Inngest event log dashboard.recentActivity
Module Health Practice status flags practice.get (computed)

4. Zod Schema (API Output):

const dashboardOverviewSchema = z.object({
  practiceId: z.string().uuid(),
  citationHealth: z.object({
    score: z.number().min(0).max(100),
    totalDirectories: z.number().int(),
    verifiedCount: z.number().int(),
    pendingCount: z.number().int(),
    failedCount: z.number().int(),
    lastVerifiedAt: z.string().datetime().nullable(),
  }),
  gbpActivity: z.object({
    status: z.enum(["ACTIVE", "PAUSED", "DISCONNECTED", "SUSPENDED"]),
    postsThisMonth: z.number().int(),
    reviewsPending: z.number().int(),
    avgReviewRating: z.number().min(0).max(5).nullable(),
    lastPostAt: z.string().datetime().nullable(),
  }),
  socialReach: z.object({
    postsThisMonth: z.number().int(),
    scheduledCount: z.number().int(),
    totalReach: z.number().int(), // platform-aggregated
    topPlatform: z.enum(["FACEBOOK", "INSTAGRAM", "LINKEDIN", "TWITTER", "NONE"]),
  }),
  siteTraffic: z.object({
    visitors30d: z.number().int(),
    pageViews30d: z.number().int(),
    avgSessionDuration: z.number(), // seconds
    bounceRate: z.number(), // percentage
    topKeywords: z.array(z.string()).max(5),
  }),
  recentActivity: z.array(z.object({
    id: z.string().uuid(),
    actionType: z.enum([
      "CITATION_SUBMITTED", "GBP_POST_PUBLISHED", "SOCIAL_POST_SCHEDULED",
      "REVIEW_REPLIED", "SITE_PUBLISHED", "REPORT_GENERATED", "CONTENT_APPROVED"
    ]),
    description: z.string(),
    module: z.enum(["CITATION", "GBP", "SOCIAL", "SITE", "CONTENT", "REPORT"]),
    status: z.enum(["SUCCESS", "PENDING", "FAILED", "WARNING"]),
    createdAt: z.string().datetime(),
  })).max(10),
  moduleHealth: z.object({
    citation: z.enum(["healthy", "warning", "critical"]),
    gbp: z.enum(["healthy", "warning", "critical"]),
    social: z.enum(["healthy", "warning", "critical"]),
    site: z.enum(["healthy", "warning", "critical"]),
  }),
  updatedAt: z.string().datetime(),
});

5. Fetch Frequency:

  • Dashboard overview: refetchInterval: 300000 (5 minutes). This is a summary screen; real-time data lives in module-specific screens.
  • Recent activity: refetchInterval: 60000 (1 minute). Users want to see recent automation updates.
  • Module health: refetchInterval: 300000 (5 minutes).

6. Data Manipulations:

  • Citation Health Score: (verifiedCount / totalDirectories * 100).toFixed(0) for percentage. Color mapping: >=80forest-teal (healthy), >=50amber (warning), <50error-red (critical).
  • GBP Activity Status: postsThisMonth compared to plan limit (e.g., 8 for Basic plan). postsThisMonth / planLimit * 100 for progress bar.
  • Social Reach: totalReach is platform-aggregated. If topPlatform === "NONE", show "No active posts" placeholder.
  • Site Traffic: avgSessionDuration formatted as MM:SS (e.g., "2:34"). bounceRate formatted as X.X%.
  • Recent Activity: Sorted by createdAt descending. Truncated to 5 items. status mapped to badge color.
  • Module Health: Each module's health is derived from its own health rules. Displayed as colored dot with label.

7. Why Structured This Way:

  • KPI cards at top: Users see the most important metrics first. Citation health is the primary differentiator (RankFlow's moat), so it leads.
  • 5-minute polling: Dashboard is a summary. Real-time data would be overwhelming and unnecessary. Users drill into module screens for real-time data.
  • Recent activity: Builds trust by showing automation is working. Users see "GBP post published 2 hours ago" and feel confident.
  • Quick actions: Reduces friction for common tasks. Instead of navigating to GBP → New Post, user clicks directly from dashboard.
  • Module health: At-a-glance status. If a module turns red, user knows to investigate immediately.

8. Interaction Flows:

User lands on /dashboard
  → practiceProcedure resolves practiceId from session
  → dashboard.overview query fires (parallel with practice.get)
  → Skeleton cards shown during load
  → KPI cards populate with data
  → Activity feed populates

User clicks "New GBP Post" quick action
  → Router navigates to /dashboard/gbp?action=new-post
  → GBP screen opens with post composer modal pre-opened

User clicks "View Report" quick action
  → Router navigates to /dashboard/reports
  → Reports screen loads

User clicks a module health indicator (e.g., red "citation")
  → Router navigates to /dashboard/citations
  → Citation screen loads, highlighting the issue

9. Error States:

State Trigger UI Treatment Recovery
Loading Initial load 4 KPI skeleton cards (pulse animation) + empty activity table Data arrives
Partial load One module fails Show data for available modules; failed module shows "—" with retry button Retry specific module
All modules error All queries fail Full error card: "Unable to load dashboard. Please refresh." with retry button Retry all
Empty activity No recent activity Activity table shows "No recent activity. Your automation will appear here." N/A
No practice User has no practice Redirect to /onboarding Complete onboarding

10. Role-Based Variations:

Role Dashboard Access Quick Actions Notes
CLIENT Full view All 4 actions visible Default experience
EDITOR Full view "New GBP Post", "New Social Post" only Cannot run citation check or view reports
VIEWER Full view No quick actions Read-only; actions hidden
ADMIN Full view All 4 + "Impersonate" button if viewing as client Admin sees practice selector dropdown

5.2 Activity Feed#

1. Purpose: A chronological feed of all automated and manual actions taken by the platform on behalf of the practice. Builds trust, provides audit trail, and helps users understand what the platform is doing.

2. Visual Layout:

  • Card container: Card component, full-width within the dashboard.
  • Header: "Recent Activity" + "View All" link (navigates to full activity log).
  • Table: 5 rows max, 4 columns:
    1. Action: Icon + description (e.g., "GBP post published: 'New dental services'")
    2. Module: Badge color-coded by module (Citation = sky-blue, GBP = forest-teal, Social = lavender, Site = ember-orange, Content = mint)
    3. Status: Badge — SUCCESS (green dot), PENDING (amber dot), FAILED (red dot), WARNING (amber dot)
    4. Time: Relative time ("2 hours ago", "Yesterday", "3 days ago")
  • Row hover: Background color shifts to mist (#E3E4E8), cursor pointer.
  • Click: Expands row to show full details (raw output, error message if failed, link to related resource).

3. Data Source:

  • tRPC endpoint: dashboard.recentActivity (or derived from Inngest event log)
  • Input: { limit: 5 }
  • Output: ActivityItem[]

4. Zod Schema:

const activityItemSchema = z.object({
  id: z.string().uuid(),
  actionType: z.enum([
    "CITATION_SUBMITTED", "CITATION_VERIFIED", "CITATION_FAILED",
    "GBP_POST_CREATED", "GBP_POST_PUBLISHED", "GBP_POST_FAILED",
    "GBP_REVIEW_RECEIVED", "GBP_REVIEW_REPLIED", "GBP_REVIEW_REPLY_FAILED",
    "SOCIAL_POST_SCHEDULED", "SOCIAL_POST_PUBLISHED", "SOCIAL_POST_FAILED",
    "SITE_SECTION_UPDATED", "SITE_PUBLISHED", "SITE_REGENERATED",
    "CONTENT_GENERATED", "CONTENT_APPROVED", "CONTENT_REJECTED",
    "REPORT_GENERATED", "REPORT_EMAILED", "LEAD_CAPTURED"
  ]),
  module: z.enum(["CITATION", "GBP", "SOCIAL", "SITE", "CONTENT", "REPORT", "LEAD"]),
  description: z.string().max(200),
  status: z.enum(["SUCCESS", "PENDING", "FAILED", "WARNING"]),
  metadata: z.record(z.any()).optional(), // module-specific data
  createdAt: z.string().datetime(),
  actor: z.enum(["SYSTEM", "USER", "ADMIN"]).default("SYSTEM"),
  actorName: z.string().optional(), // if USER or ADMIN
});

const recentActivitySchema = z.array(activityItemSchema).max(50);

5. Fetch Frequency:

  • refetchInterval: 60000 (1 minute). Activity is dynamic; users want to see updates.
  • Full activity log page: refetchInterval: 300000 (5 minutes) or pagination-based.

6. Data Manipulations:

  • Relative time: date-fns formatDistanceToNow(new Date(createdAt), { addSuffix: true }) → "2 hours ago"
  • Description truncation: If > 60 chars, truncate with ellipsis. Full text shown on row expand.
  • Status color mapping: SUCCESSforest-teal badge, PENDINGamber badge, FAILEDerror-red badge, WARNINGamber badge.
  • Module icon mapping: Citation → Link, GBP → MapPin, Social → Share2, Site → Globe, Content → FileText, Report → BarChart3, Lead → UserPlus.

7. Why Structured This Way:

  • Chronological feed: Humans process time-ordered information naturally. Latest action first.
  • 5-row limit: Dashboard is a summary. Full history lives in a dedicated page.
  • Expandable rows: Keeps table compact. Users drill in only when interested.
  • Module color coding: Quick visual association. User sees blue badge and knows it's citation-related.
  • Actor field: Distinguishes system automation from manual user actions. If admin intervened, it's visible.

8. Interaction Flows:

User hovers over activity row
  → Row background changes to mist
  → Cursor changes to pointer

User clicks activity row
  → Row expands with animation (height transition, 200ms ease-out)
  → Expanded area shows:
    - Full description
    - Raw metadata (JSON, collapsible)
    - If FAILED: error message + retry button (if applicable)
    - If GBP post: link to view on Google
    - If citation: link to directory listing
    - If social: link to platform post
  → Click again to collapse

User clicks "View All"
  → Router navigates to /dashboard/activity
  → Full activity log page loads with pagination (20 per page)
  → Filter by module, status, date range

9. Error States:

State Trigger UI Treatment Recovery
Empty feed No activity yet Centered message: "No activity yet. Your automation will appear here." + illustration N/A
Load error Query fails Inline error: "Failed to load activity." with retry button Retry
Partial load Some events missing Show available events; footer note: "Some events may be delayed." Auto-retry

10. Role-Based Variations:

Role Access Notes
CLIENT Full view Sees all system and user actions
EDITOR Full view Sees all actions, but cannot click into admin actions
VIEWER Full view Read-only, no retry buttons
ADMIN Full view + actor filter Can filter by "System only", "User only", "Admin only"

5.3 Quick Actions Panel#

1. Purpose: Provides one-click shortcuts to the most common user actions, reducing navigation friction and increasing platform engagement.

2. Visual Layout:

  • Container: Horizontal flex row, 4 buttons evenly spaced, gap 12px.
  • Button style: Button component, variant: secondary, size: md.
  • Each button: Icon (20px) + label, stacked vertically (icon above, text below). Mobile: horizontal (icon left, text right).
  • Buttons:
    1. "New GBP Post" — MapPin icon, color: forest-teal
    2. "New Social Post" — Share2 icon, color: lavender
    3. "Run Citation Check" — Link icon, color: sky-blue
    4. "View Report" — BarChart3 icon, color: deep-indigo
  • Hover: Background shifts to pale-cyan (#C1E8EF), icon color intensifies.
  • Disabled: If module disconnected, button shows fog color, tooltip explains.

3. Data Source:

  • No API call for the panel itself. Navigation only.
  • Module connectivity status derived from dashboard.overview.moduleHealth.

4. Zod Schema: No schema needed — this is a pure UI/navigation component.

5. Fetch Frequency:

  • Static (no data fetch). Button state depends on parent dashboard.overview query.

6. Data Manipulations:

  • Module connectivity: If moduleHealth.gbp === "critical", "New GBP Post" button is disabled with tooltip: "GBP is disconnected. Please reconnect in Settings."
  • Role filtering: If role === "EDITOR", "Run Citation Check" and "View Report" are hidden.
  • Plan restriction: If plan doesn't include social, "New Social Post" is disabled with tooltip: "Upgrade to Pro to enable social media."

7. Why Structured This Way:

  • Horizontal layout: Scanning left-to-right is natural. 4 actions fits within cognitive load limits (Miller's law: 7±2).
  • Icon + label: Reduces ambiguity. Icons alone are not enough for non-technical users.
  • Disabled with tooltip: Prevents dead-end clicks. User understands why action is unavailable.
  • Module-aware: Button availability reflects actual system state. No "click and error" pattern.

8. Interaction Flows:

User clicks "New GBP Post"
  → Router.push('/dashboard/gbp?action=new-post')
  → GBP screen loads, post composer modal opens automatically

User clicks "New Social Post"
  → Router.push('/dashboard/social?action=new-post')
  → Social screen loads, post composer modal opens

User clicks "Run Citation Check"
  → citation.verifyNap.mutate({ locationId })
  → Button shows spinner: "Running..."
  → On success: toast "Citation check started. Results in ~5 minutes."
  → Activity feed updates with new PENDING event

User clicks "View Report"
  → Router.push('/dashboard/reports')
  → Reports screen loads

9. Error States:

State Trigger UI Treatment Recovery
Module disconnected GBP token expired Button disabled, tooltip: "GBP disconnected. Reconnect in Settings." Navigate to Settings
Plan restricted Social not in plan Button disabled, tooltip: "Upgrade to enable social media." Navigate to Billing
Action in progress Citation check running Button shows spinner, disabled Wait for completion

10. Role-Based Variations:

Role Visible Buttons Disabled Buttons
CLIENT All 4 Based on module status + plan
EDITOR "New GBP Post", "New Social Post" N/A (other 2 hidden)
VIEWER None hidden All buttons visible but disabled with tooltip: "Viewers cannot take actions."
ADMIN All 4 + "Switch Practice" Based on module status

Section 6 — GBP Management#

6.1 GBP Dashboard (/dashboard/gbp)#

1. Purpose: The central hub for Google Business Profile management. Users view GBP health, create posts, reply to reviews, view insights, and answer Q&A — all without leaving RankFlow.

2. Visual Layout:

  • Page header: "Google Business Profile" + practice name + connection status badge.
  • Connection banner: If GBP not connected, full-width banner with "Connect GBP" CTA button (Ember Orange).
  • Tab navigation: 4 tabs — "Posts", "Reviews", "Insights", "Q&A".
  • Default tab: "Posts" (most frequent action).
  • Content area: Tab-specific content below.
  • Sidebar (optional): On desktop, right sidebar shows GBP location summary (name, address, phone, rating, photo count).

3. Data Source:

// Composite data load on mount:
// 1. gbp.listAccounts — check if connected
// 2. gbp.listLocations — get location details
// 3. gbp.listPosts — load posts for default tab
// All fired in parallel via Promise.all or useQueries
Field Endpoint Auth
Account status gbp.listAccounts practiceProcedure
Location details gbp.listLocations practiceProcedure
Posts gbp.listPosts practiceProcedure
Reviews gbp.listReviews practiceProcedure
Insights gbp.getInsights practiceProcedure
Q&A gbp.listQA practiceProcedure

4. Zod Schema (Composite Screen Data):

const gbpDashboardSchema = z.object({
  isConnected: z.boolean(),
  account: z.object({
    id: z.string(),
    name: z.string(),
    email: z.string().email(),
    photoUrl: z.string().url().optional(),
  }).optional(),
  location: z.object({
    id: z.string(),
    name: z.string(),
    address: z.string(),
    phone: z.string(),
    website: z.string().url().optional(),
    category: z.string(),
    status: z.enum(["PUBLISHED", "SUSPENDED", "VERIFICATION_REQUIRED", "CLOSED"]),
    rating: z.number().min(0).max(5).optional(),
    reviewCount: z.number().int().optional(),
    photoCount: z.number().int().optional(),
    latitude: z.number().optional(),
    longitude: z.number().optional(),
  }).optional(),
  pendingActions: z.array(z.object({
    type: z.enum(["POST_APPROVAL", "REVIEW_REPLY", "QA_ANSWER", "PHOTO_UPLOAD"]),
    count: z.number().int(),
  })),
});

5. Fetch Frequency:

  • Connection status: On mount + after OAuth callback. Static once connected.
  • Posts: refetchInterval: 60000 (1 minute). Users create posts frequently.
  • Reviews: refetchInterval: 300000 (5 minutes). Reviews arrive asynchronously.
  • Insights: refetchInterval: 3600000 (1 hour). Google updates insights daily, not real-time.
  • Q&A: refetchInterval: 300000 (5 minutes).

6. Data Manipulations:

  • Connection status: isConnected derived from gbp.listAccounts returning non-empty array.
  • Location status: Color mapping — PUBLISHEDforest-teal, SUSPENDEDerror-red, VERIFICATION_REQUIREDamber, CLOSEDslate.
  • Rating display: rating.toFixed(1) + star icon (e.g., "4.3 ★"). If no rating, show "No reviews yet".
  • Pending actions: Summarized as badge on tab label. "Reviews (3)" if 3 pending replies.

7. Why Structured This Way:

  • Tab navigation: Separates concerns. Posts, reviews, insights, and Q&A are distinct workflows.
  • Connection-first UI: If GBP is not connected, the entire screen focuses on connecting. No distracting empty states.
  • Pending action badges: Users see immediately where attention is needed. "Reviews (3)" drives engagement.
  • 1-minute post polling: Users expect to see their published post immediately after creation.
  • 1-hour insights: Google Business Profile insights update daily; excessive polling wastes quota.

8. Interaction Flows:

User navigates to /dashboard/gbp
  → gbp.listAccounts fires
  → If not connected: show connection banner
  → If connected: load location + posts + reviews + insights + Q&A in parallel
  → Default tab: Posts

User clicks "Connect GBP" banner
  → gbp.getAuthUrl fires
  → Redirects to Google OAuth consent screen
  → On return: OAuth callback handled, token stored
  → Page reloads, now connected

User switches to "Reviews" tab
  → gbp.listReviews query executes (if not already cached)
  → Review table populates
  → Pending reply count shown in badge

User switches to "Insights" tab
  → gbp.getInsights query executes with default date range (last 30 days)
  → Chart renders

9. Error States:

State Trigger UI Treatment Recovery
Not connected gbp.listAccounts returns empty Full-width banner: "Connect your Google Business Profile" + CTA Click CTA, OAuth flow
Token expired gbp.listPosts returns 401 Inline banner: "GBP connection expired. Reconnect." + button Reconnect flow
Location suspended status === "SUSPENDED" Red banner: "Your GBP location is suspended. Contact support." Manual intervention
API quota exceeded Google API rate limit Toast: "Google API quota exceeded. Retrying in 60s." Auto-retry with backoff
Partial load One tab fails Other tabs work. Failed tab shows inline error. Retry failed tab

10. Role-Based Variations:

Role Tab Access Actions
CLIENT All tabs Full CRUD on posts, replies, Q&A
EDITOR All tabs Can create posts, reply to reviews, answer Q&A. Cannot disconnect GBP.
VIEWER All tabs Read-only. No create/edit/reply buttons.
ADMIN All tabs + "Admin Actions" Can force disconnect, clear token cache, impersonate.

6.2 Post Manager#

1. Purpose: Allows users to create, schedule, and manage Google Business Profile posts. Includes AI content generation for post drafts.

2. Visual Layout:

  • Tab: "Posts" within GBP dashboard.
  • Sub-tabs: "All", "Published", "Scheduled", "Drafts", "Failed".
  • Header row: "New Post" button (Ember Orange) + search input + filter dropdown.
  • Content: Table or card grid of posts. Default: card grid (more visual, GBP posts are image-heavy).
  • Post card: Thumbnail (or placeholder), title, status badge, date, actions (edit, delete, view on Google).
  • Status badge: PUBLISHED (green), SCHEDULED (amber), DRAFT (slate), FAILED (red), PENDING_APPROVAL (blue).

3. Data Source:

  • tRPC endpoint: gbp.listPosts
  • Input: { locationId?, status?, search?, page?, limit? }
  • Output: GbpPost[] (paginated)

4. Zod Schema:

const gbpPostSchema = z.object({
  id: z.string().uuid(),
  locationId: z.string(),
  practiceId: z.string(),
  content: z.string().max(1500),
  mediaUrls: z.array(z.string().url()).max(10),
  ctaType: z.enum(["BOOK", "CALL", "LEARN_MORE", "SIGN_UP", "ORDER"]).nullable(),
  topicType: z.enum(["STANDARD", "OFFER", "EVENT"]),
  status: z.enum(["DRAFT", "PENDING_APPROVAL", "SCHEDULED", "PUBLISHED", "FAILED", "DELETED"]),
  scheduledFor: z.string().datetime().nullable(),
  publishedAt: z.string().datetime().nullable(),
  failedReason: z.string().optional(),
  googlePostId: z.string().optional(), // Google's internal ID
  aiGenerated: z.boolean().default(false),
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime(),
});

const listPostsInputSchema = z.object({
  locationId: z.string().optional(),
  status: z.enum(["DRAFT", "PENDING_APPROVAL", "SCHEDULED", "PUBLISHED", "FAILED", "DELETED"]).optional(),
  search: z.string().optional(),
  page: z.number().int().min(1).default(1),
  limit: z.number().int().min(1).max(50).default(20),
});

const listPostsOutputSchema = z.object({
  posts: z.array(gbpPostSchema),
  total: z.number().int(),
  page: z.number().int(),
  limit: z.number().int(),
  hasMore: z.boolean(),
});

5. Fetch Frequency:

  • refetchInterval: 60000 (1 minute). Posts are the most dynamic GBP content.
  • After mutation (create, delete, update): utils.gbp.listPosts.invalidate() immediately.

6. Data Manipulations:

  • Thumbnail: First mediaUrls[0] if available; otherwise placeholder icon (ImageOff).
  • Content preview: First 100 characters + ellipsis. Full content on card click/modal.
  • CTA label: "Book" → "Book Now", "CALL" → "Call Now", etc. Human-readable mapping.
  • Date formatting: publishedAt → "Published: June 12, 2026". scheduledFor → "Scheduled: June 15, 2026 at 9:00 AM".
  • Status badge color: PUBLISHEDforest-teal, SCHEDULEDamber, DRAFTslate, FAILEDerror-red, PENDING_APPROVALsky-blue.
  • Pagination: "Load more" button (infinite scroll) or numbered pagination. Mobile: infinite scroll. Desktop: numbered pages.

7. Why Structured This Way:

  • Card grid over table: GBP posts are visual. Cards show thumbnails and content preview better than rows.
  • Sub-tabs by status: Users quickly filter to what they care about. "Scheduled" shows upcoming posts. "Failed" shows what needs fixing.
  • AI-generated flag: If aiGenerated === true, show small "AI" badge. Builds trust by distinguishing human vs. AI content.
  • Google post ID: If present, "View on Google" link opens the actual GBP post. Bridges platform and Google.

8. Interaction Flows:

User clicks "New Post" button
  → Modal opens: Post Composer
  → Modal has 3 tabs: "Write", "AI Generate", "Preview"

User in "Write" tab
  → Textarea for content (max 1500 chars, char counter)
  → Image upload: drag-drop or click, max 10 images
  → CTA dropdown: "Book Now", "Call Now", "Learn More", "Sign Up", "Order"
  → Topic type: "Standard", "Offer", "Event"
  → Schedule toggle: immediate vs. scheduled
  → If scheduled: datetime picker (min: now + 5 minutes)
  → "Save Draft" (secondary) + "Publish" (primary) buttons

User in "AI Generate" tab
  → Topic input: "What should this post be about?"
  → Tone dropdown: "Professional", "Friendly", "Promotional", "Educational"
  → "Generate Draft" button
  → gbp.createPost mutation with generateAI flag
  → Loading state: "Generating with AI..."
  → Generated content appears in textarea
  → User can edit before publishing

User clicks "Publish"
  → Client-side Zod validation
  → gbp.createPost.mutate(data)
  → Modal closes, listPosts invalidates
  → Toast: "Post published successfully"
  → New post appears at top of grid

User clicks "Delete" on a post
  → Confirmation modal: "Delete this post? This cannot be undone."
  → gbp.deletePost.mutate({ postId })
  → Post removed from grid
  → Toast: "Post deleted"

User clicks "View on Google"
  → Opens `https://www.google.com/maps/...` in new tab

9. Error States:

State Trigger UI Treatment Recovery
Empty state No posts yet Centered: "No posts yet. Create your first GBP post." + "New Post" CTA Create post
Load error listPosts fails Inline error card + retry button Retry
Image upload fail File too large Inline error: "Image must be under 5MB." Re-upload
Content too long > 1500 chars Inline error: "Content must be 1500 characters or less." Trim content
Schedule in past scheduledFor < now Inline error: "Schedule time must be in the future." Pick future time
Publish fail Google API error Toast: "Failed to publish. Google API error: [message]" Retry
AI generation fail LLM timeout Toast: "AI generation failed. Please try again or write manually." Retry or manual

10. Role-Based Variations:

Role New Post Edit Delete Schedule AI Generate
CLIENT
EDITOR
VIEWER
ADMIN ✅ (impersonating)

6.3 Review Inbox#

1. Purpose: Displays all Google reviews for the practice, sorted by recency. Users can reply to reviews, and AI can generate suggested replies for positive reviews. Negative reviews are flagged for manual handling.

2. Visual Layout:

  • Tab: "Reviews" within GBP dashboard.
  • Filter bar: "All Reviews", "Unreplied", "Positive (4-5★)", "Critical (1-3★)", "Flagged".
  • Review cards: Vertical stack of cards, not table. Each card is a self-contained unit.
  • Card layout:
    • Top row: Reviewer name (Google Maps) + star rating (1-5 stars) + date + status badge.
    • Middle: Review text (full text, no truncation — reviews are short).
    • Bottom: Reply area (if unreplied) or existing reply (if replied).
  • Star rating: 5 star icons, filled for rating, empty for remainder. color: amber for filled, color: mist for empty.
  • Status badge: "Unreplied" (red), "Replied" (green), "AI Reply Pending" (blue), "Flagged" (amber).

3. Data Source:

  • tRPC endpoint: gbp.listReviews
  • Input: { locationId?, status?, minRating?, maxRating?, page?, limit? }
  • Output: Review[] (paginated)

4. Zod Schema:

const reviewSchema = z.object({
  id: z.string().uuid(),
  locationId: z.string(),
  googleReviewId: z.string(), // Google's internal review ID
  reviewerName: z.string(),
  reviewerPhotoUrl: z.string().url().optional(),
  rating: z.number().int().min(1).max(5),
  comment: z.string().max(5000),
  replyText: z.string().max(2000).nullable(),
  replyStatus: z.enum(["UNREPLIED", "PENDING_AI", "REPLIED", "FLAGGED"]),
  replySentiment: z.enum(["POSITIVE", "NEUTRAL", "NEGATIVE", "CRITICAL"]).nullable(),
  aiReplySuggested: z.string().max(2000).optional(),
  repliedAt: z.string().datetime().nullable(),
  createdAt: z.string().datetime(),
});

const listReviewsInputSchema = z.object({
  locationId: z.string().optional(),
  status: z.enum(["UNREPLIED", "PENDING_AI", "REPLIED", "FLAGGED"]).optional(),
  minRating: z.number().int().min(1).max(5).optional(),
  maxRating: z.number().int().min(1).max(5).optional(),
  page: z.number().int().min(1).default(1),
  limit: z.number().int().min(1).max(50).default(20),
});

const listReviewsOutputSchema = z.object({
  reviews: z.array(reviewSchema),
  total: z.number().int(),
  unrepliedCount: z.number().int(),
  averageRating: z.number().min(0).max(5),
  page: z.number().int(),
  limit: z.number().int(),
  hasMore: z.boolean(),
});

5. Fetch Frequency:

  • refetchInterval: 300000 (5 minutes). Reviews arrive asynchronously from Google.
  • After reply mutation: utils.gbp.listReviews.invalidate() immediately.

6. Data Manipulations:

  • Rating stars: Array of 5 booleans. i < rating → filled star. i >= rating → empty star.
  • Sentiment color: POSITIVEforest-teal, NEUTRALsky-blue, NEGATIVEamber, CRITICALerror-red.
  • Date: Relative time for recent (< 7 days), absolute date for older. "2 days ago" vs "March 15, 2026".
  • Reply area: If replyStatus === "UNREPLIED", show textarea + "Reply" button + "AI Suggest" button.
  • AI suggestion: If aiReplySuggested present, pre-fill textarea with suggestion. User can edit before sending.
  • Flagging: If rating <= 2 AND replyStatus === "UNREPLIED", auto-flag with "CRITICAL" sentiment. Red border on card.
  • Filter counts: Badge on each filter tab showing count. "Unreplied (3)".

7. Why Structured This Way:

  • Card layout: Reviews are conversational. Cards mimic email/chat UI, which is familiar.
  • Auto-flagging critical: Negative reviews damage reputation. Auto-flagging ensures they're never missed.
  • AI suggestion for positive: Replying to every positive review is tedious but valuable. AI reduces friction while maintaining human oversight.
  • Sentiment on reply: Sentiment analysis of the reply itself ensures the tone matches the review. "Thank you" for positive, empathetic for negative.
  • Filter by status: "Unreplied" is the most important filter. Users check this daily.

8. Interaction Flows:

User navigates to Reviews tab
  → gbp.listReviews fires with default filter (all)
  → Review cards populate, sorted by createdAt desc
  → Unreplied reviews shown first (if "All" filter)
  → Critical reviews have red border

User clicks "AI Suggest" on an unreplied positive review
  → gbp.replyToReview.mutate({ reviewId, generateAI: true })
  → Button shows spinner: "Generating..."
  → AI-generated reply appears in textarea
  → User can edit the suggested reply
  → "Send Reply" button enabled

User clicks "Send Reply"
  → gbp.replyToReview.mutate({ reviewId, replyText })
  → Card updates: status changes to "REPLIED"
  → Reply text appears below review
  → Toast: "Reply sent to Google"
  → Unreplied count decrements

User clicks "Flag" on a review
  → Status changes to "FLAGGED"
  → Card gets amber border
  → Admin notification sent (if medical client, goes to 24h approval queue)
  → Toast: "Review flagged for admin review"

User clicks "View on Google"
  → Opens review on Google Maps in new tab

9. Error States:

State Trigger UI Treatment Recovery
No reviews total === 0 Centered: "No reviews yet. Encourage patients to leave reviews on Google." + link to GBP N/A
Load error listReviews fails Inline error + retry button Retry
Reply too long > 2000 chars Inline error: "Reply must be 2000 characters or less." Trim reply
Reply fail Google API error Toast: "Failed to send reply. Please try again." Retry
AI suggestion fail LLM timeout Toast: "AI suggestion failed. Please write your own reply." Manual reply

10. Role-Based Variations:

Role Reply AI Suggest Flag Delete
CLIENT ❌ (reviews cannot be deleted)
EDITOR
VIEWER
ADMIN ✅ + admin actions Can mark as spam, escalate

6.4 Insights Chart#

1. Purpose: Visualizes Google Business Profile performance metrics over time — views, clicks, calls, direction requests. Helps users understand the ROI of their GBP activity.

2. Visual Layout:

  • Tab: "Insights" within GBP dashboard.
  • Date range selector: "7 days", "30 days", "90 days", "Custom" (date picker). Default: "30 days".
  • Metric selector: Toggle buttons for metrics — "Views", "Clicks", "Calls", "Directions", "Photos".
  • Chart area: Line chart (Recharts) showing selected metric(s) over time. 2 metrics can be overlaid.
  • Summary cards: Below chart, 4 KPI cards showing totals for the period.
  • Comparison badge: Each KPI card shows change vs. previous period (e.g., "+12% vs. last 30 days").

3. Data Source:

  • tRPC endpoint: gbp.getInsights
  • Input: { locationId, dateFrom?, dateTo? }
  • Output: GbpInsight[]

4. Zod Schema:

const gbpInsightSchema = z.object({
  id: z.string().uuid(),
  locationId: z.string(),
  date: z.string().datetime(), // Daily aggregation
  viewsSearch: z.number().int(),
  viewsMaps: z.number().int(),
  clicksWebsite: z.number().int(),
  clicksPhone: z.number().int(),
  directionRequests: z.number().int(),
  photoViews: z.number().int(),
  photoCount: z.number().int(),
  newReviews: z.number().int(),
  averageRating: z.number().min(0).max(5).nullable(),
});

const getInsightsInputSchema = z.object({
  locationId: z.string(),
  dateFrom: z.string().datetime().optional(),
  dateTo: z.string().datetime().optional(),
});

const getInsightsOutputSchema = z.object({
  insights: z.array(gbpInsightSchema),
  summary: z.object({
    totalViews: z.number().int(),
    totalClicks: z.number().int(),
    totalCalls: z.number().int(),
    totalDirections: z.number().int(),
    totalPhotos: z.number().int(),
    newReviews: z.number().int(),
    averageRating: z.number().min(0).max(5).nullable(),
  }),
  previousPeriodSummary: z.object({
    totalViews: z.number().int(),
    totalClicks: z.number().int(),
    totalCalls: z.number().int(),
    totalDirections: z.number().int(),
  }).optional(),
});

5. Fetch Frequency:

  • refetchInterval: 3600000 (1 hour). Google updates insights daily, not real-time.
  • Date range change: On-demand refetch.

6. Data Manipulations:

  • Line chart data: X-axis = date, Y-axis = metric value. Multiple metrics = multiple lines with different colors.
  • Color mapping: Views → forest-teal, Clicks → sky-blue, Calls → ember-orange, Directions → lavender, Photos → mint.
  • Total views: viewsSearch + viewsMaps (search and maps are separate in Google API, but users care about total).
  • Total clicks: clicksWebsite + clicksPhone.
  • Period comparison: ((current - previous) / previous * 100).toFixed(1) for percentage change. If previous is 0, show "New".
  • Trend indicator: Up arrow + green for positive, down arrow + red for negative, neutral for zero change.
  • Chart tooltip: Hover shows exact values for that date. Format: "June 12: 45 views, 12 clicks".

7. Why Structured This Way:

  • Line chart: Time-series data is best understood as a line. Users see trends and patterns.
  • Metric toggle: Users can focus on what matters. A doctor might care about "Calls" more than "Views".
  • Period comparison: Context is critical. "120 views" is meaningless without knowing if it's up or down.
  • 1-hour polling: Google insights update daily. More frequent polling is unnecessary and wastes API quota.
  • Search vs. Maps breakdown: Google separates these, but users want the total. Summing them reduces cognitive load.

8. Interaction Flows:

User navigates to Insights tab
  → gbp.getInsights fires with default date range (last 30 days)
  → Chart renders with "Views" metric selected
  → Summary cards populate

User clicks "Calls" metric toggle
  → Chart line updates to show calls data
  → Y-axis rescales
  → Tooltip updates

User selects "90 days" date range
  → gbp.getInsights refetches with new date range
  → Chart updates with 90 data points
  → Summary cards recalculate

User hovers over chart point
  → Tooltip appears with exact values for that date
  → Vertical reference line highlights the day

User clicks "Download CSV"
  → Data exported as CSV with headers: Date, Views, Clicks, Calls, Directions
  → Browser initiates download

9. Error States:

State Trigger UI Treatment Recovery
No data No insights yet "No insights available yet. Google updates insights within 24-48 hours of activity." Wait
Partial data Some days missing Chart shows gaps. Footer note: "Some days missing due to Google data delays." N/A
Load error getInsights fails Inline error: "Failed to load insights." + retry button Retry
Location not connected isConnected === false Banner: "Connect GBP to see insights." Connect GBP

10. Role-Based Variations:

Role Access Export
CLIENT Full view ✅ CSV export
EDITOR Full view ✅ CSV export
VIEWER Full view ❌ Export disabled
ADMIN Full view + raw data ✅ CSV + JSON export

6.5 Q&A Panel#

1. Purpose: Displays questions asked by Google Maps users about the practice and allows the practice to answer them. Unanswered questions hurt GBP credibility.

2. Visual Layout:

  • Tab: "Q&A" within GBP dashboard.
  • Filter: "All", "Unanswered", "Answered".
  • Q&A cards: Vertical stack. Each card shows:
    • Question text
    • Questioner name ("Google User" or name if available)
    • Question date
    • Answer (if answered) or "Answer" button (if unanswered)
  • Answer area: Textarea + "Post Answer" button + "AI Suggest" button.
  • Upvotes: Vote count displayed next to question. Popular questions shown first.

3. Data Source:

  • tRPC endpoint: gbp.listQA
  • Input: { locationId }
  • Output: GbpQA[]

4. Zod Schema:

const gbpQASchema = z.object({
  id: z.string().uuid(),
  locationId: z.string(),
  googleQuestionId: z.string(),
  questionText: z.string().max(1000),
  questionerName: z.string().default("Google User"),
  questionDate: z.string().datetime(),
  answerText: z.string().max(1000).nullable(),
  answerDate: z.string().datetime().nullable(),
  upvotes: z.number().int().default(0),
  isOwnerAnswer: z.boolean().default(false), // Did the practice owner answer?
  aiGenerated: z.boolean().default(false),
  status: z.enum(["UNANSWERED", "ANSWERED", "PENDING_APPROVAL"]),
});

const listQAInputSchema = z.object({
  locationId: z.string(),
  status: z.enum(["UNANSWERED", "ANSWERED", "PENDING_APPROVAL"]).optional(),
});

const listQAOutputSchema = z.object({
  questions: z.array(gbpQASchema),
  total: z.number().int(),
  unansweredCount: z.number().int(),
});

5. Fetch Frequency:

  • refetchInterval: 300000 (5 minutes). Q&A is low-frequency but important for credibility.

6. Data Manipulations:

  • Sort order: Unanswered first, then by upvotes descending, then by date descending.
  • Upvote display: upvotes with thumb-up icon. If upvotes > 5, show "Popular" badge.
  • Answer status: "Unanswered" = red badge, "Answered" = green badge, "Pending" = amber badge.
  • AI suggestion: Similar to review replies. If unanswered, "AI Suggest" button generates answer based on practice data.
  • Date: Relative time for questions (< 30 days), absolute for older.

7. Why Structured This Way:

  • Q&A visibility: Unanswered questions signal neglect. Making them prominent drives action.
  • Upvote sort: Popular questions are seen by more users. Answering them first maximizes impact.
  • AI suggestion: Many Q&A questions are repetitive ("What are your hours?", "Do you accept walk-ins?"). AI handles routine questions.
  • Owner answer flag: Google's algorithm favors owner-answered questions. Flag ensures proper attribution.

8. Interaction Flows:

User navigates to Q&A tab
  → gbp.listQA fires
  → Questions sorted: unanswered first, then popular
  → Unanswered questions highlighted with red left border

User clicks "Answer" on an unanswered question
  → Answer textarea expands below question
  → "AI Suggest" button visible
  → User types answer or clicks AI Suggest
  → AI generates answer based on practice knowledge base
  → User edits if needed
  → Clicks "Post Answer"
  → gbp.answerQuestion.mutate({ qaId, answerText })
  → Card updates: status "ANSWERED", answer text visible
  → Toast: "Answer posted to Google"
  → Unanswered count decrements

9. Error States:

State Trigger UI Treatment Recovery
No questions total === 0 "No questions yet. This is a good sign — or users haven't found your profile." N/A
Load error listQA fails Inline error + retry Retry
Answer too long > 1000 chars Inline error: "Answer must be 1000 characters or less." Trim
Answer fail Google API error Toast: "Failed to post answer. Please try again." Retry

10. Role-Based Variations:

Role Answer AI Suggest
CLIENT
EDITOR
VIEWER
ADMIN

Section 7 — Social Media#

7.1 Social Dashboard (/dashboard/social)#

1. Purpose: Central hub for managing social media presence across Facebook, Instagram, LinkedIn, and Twitter. Users connect accounts, create posts, view schedules, and monitor engagement.

2. Visual Layout:

  • Page header: "Social Media" + connection status summary ("3/4 connected").
  • Connection banner: If no accounts connected, full-width banner with platform connection buttons.
  • Tab navigation: "Accounts", "Posts", "Calendar", "Analytics".
  • Default tab: "Accounts" (if none connected) or "Posts" (if connected).
  • Content area: Tab-specific content.

3. Data Source:

// Composite load on mount:
// 1. social.listAccounts — check connected platforms
// 2. social.listPosts — load posts for default tab
// All parallel
Field Endpoint Auth
Connected accounts social.listAccounts practiceProcedure
Posts social.listPosts practiceProcedure
Platform analytics social.listPosts (aggregated) practiceProcedure

4. Zod Schema:

const socialDashboardSchema = z.object({
  isConnected: z.boolean(), // At least one account
  accounts: z.array(z.object({
    id: z.string().uuid(),
    platform: z.enum(["FACEBOOK", "INSTAGRAM", "LINKEDIN", "TWITTER"]),
    accountName: z.string(),
    accountHandle: z.string(),
    followerCount: z.number().int().optional(),
    profilePictureUrl: z.string().url().optional(),
    isConnected: z.boolean(),
    lastSyncedAt: z.string().datetime().nullable(),
  })),
  connectionSummary: z.object({
    totalPlatforms: z.number().int(),
    connectedCount: z.number().int(),
    disconnectedPlatforms: z.array(z.enum(["FACEBOOK", "INSTAGRAM", "LINKEDIN", "TWITTER"])),
  }),
  pendingPosts: z.number().int(),
  failedPosts: z.number().int(),
});

5. Fetch Frequency:

  • Accounts: On mount + after OAuth callback. Static once connected.
  • Posts: refetchInterval: 60000 (1 minute).
  • Analytics: refetchInterval: 300000 (5 minutes).

6. Data Manipulations:

  • Platform icon mapping: Facebook → Facebook icon, Instagram → Instagram icon, LinkedIn → Linkedin icon, Twitter → Twitter icon.
  • Connection summary: "3/4 connected" badge. If connectedCount === 0, show connection banner.
  • Follower count: Formatted with K/M suffix. 1500 → "1.5K", 2500000 → "2.5M".
  • Last synced: Relative time. "Synced 2 hours ago".
  • Pending/failed badges: Shown on "Posts" tab label. "Posts (2 pending)".

7. Why Structured This Way:

  • Connection-first: Social media requires platform OAuth. If not connected, the entire UI focuses on connecting.
  • Platform-agnostic: Users may connect 1-4 platforms. UI adapts to what's connected, not forcing all 4.
  • Follower count: Vanity metric but important for users. Displayed prominently on account cards.
  • Sync status: If last sync is > 24 hours, show warning. Token might be expired.

8. Interaction Flows:

User navigates to /dashboard/social
  → social.listAccounts fires
  → If no accounts: show connection banner with 4 platform buttons
  → If connected: show accounts tab with connected platform cards

User clicks "Connect Facebook" (or any platform)
  → social.getAuthUrl({ platform: "FACEBOOK" }) fires
  → Redirects to platform OAuth
  → On return: token stored, account added
  → Page refreshes, account card appears

User clicks "Disconnect" on an account
  → Confirmation: "Disconnect Facebook? Scheduled posts will be cancelled."
  → social.disconnect.mutate({ accountId })
  → Account card removed
  → Scheduled posts for that platform marked FAILED
  → Toast: "Facebook disconnected"

User switches to "Posts" tab
  → social.listPosts fires
  → Post grid/table populates

9. Error States:

State Trigger UI Treatment Recovery
No accounts accounts.length === 0 Full-width banner: "Connect your social media accounts" + 4 platform buttons Connect account
Token expired lastSyncedAt > 24h Account card shows "Token expired. Reconnect." button Reconnect
API rate limit Platform rate limit Toast: "Facebook API rate limit reached. Retrying in 15 minutes." Auto-retry
Partial connection 2 of 4 connected Show connected accounts + prompt for remaining Connect more

10. Role-Based Variations:

Role Connect Disconnect Create Post View Analytics
CLIENT
EDITOR
VIEWER
ADMIN ✅ + admin override

7.2 Account Manager#

1. Purpose: Displays connected social media accounts with status, follower counts, and sync state. Allows connection, disconnection, and reconnection of accounts.

2. Visual Layout:

  • Tab: "Accounts" within Social dashboard.
  • Account cards: 2-column grid (1 on mobile). Each card is a platform-specific card.
  • Card layout:
    • Platform icon (large, 32px) + platform name
    • Account name + handle
    • Profile picture (if available, circular, 48px)
    • Follower count (if available)
    • Sync status badge
    • "Disconnect" button (secondary, danger on hover)
  • Disconnected platforms: Shown as "ghost cards" — faded, with "Connect" button.
  • Add account button: "+ Connect Another Account" (if less than 4 connected).

3. Data Source:

  • tRPC endpoint: social.listAccounts
  • Input: none
  • Output: SocialAccount[]

4. Zod Schema:

const socialAccountSchema = z.object({
  id: z.string().uuid(),
  platform: z.enum(["FACEBOOK", "INSTAGRAM", "LINKEDIN", "TWITTER"]),
  practiceId: z.string(),
  accountName: z.string(),
  accountHandle: z.string(),
  accountId: z.string(), // Platform's internal ID
  accessToken: z.string().optional(), // Never exposed to frontend in full
  tokenExpiresAt: z.string().datetime().optional(),
  followerCount: z.number().int().optional(),
  profilePictureUrl: z.string().url().optional(),
  isConnected: z.boolean(),
  lastSyncedAt: z.string().datetime().nullable(),
  createdAt: z.string().datetime(),
});

5. Fetch Frequency:

  • On mount + after OAuth callback. Static once loaded.
  • Manual refresh: "Refresh" button on each card.

6. Data Manipulations:

  • Follower formatting: Intl.NumberFormat with compact notation. 1234 → "1.2K", 1234567 → "1.2M".
  • Token expiry warning: If tokenExpiresAt is within 7 days, show amber badge: "Token expires in 5 days.".
  • Platform color: Facebook → #1877F2 (blue), Instagram → #E4405F (pink), LinkedIn → #0A66C2 (blue), Twitter → #1DA1F2 (blue). Used as accent on card border/icon.
  • Profile picture: Circular crop with border-radius: full. Fallback to platform icon if no photo.

7. Why Structured This Way:

  • Card per platform: Each platform is a distinct entity. Cards give them equal visual weight.
  • Follower count: The primary metric users care about. Prominently displayed.
  • Token expiry warning: Prevents surprise disconnections. Proactive notification.
  • Ghost cards for disconnected: Encourages completion. User sees "3 of 4 connected" and is motivated to connect the last one.

8. Interaction Flows:

User clicks "Connect" on a disconnected platform
  → social.getAuthUrl({ platform }) fires
  → Redirect to platform OAuth
  → On callback: account added, card populates
  → Toast: "Facebook connected successfully"

User clicks "Disconnect" on connected account
  → Confirmation modal: "Disconnect @handle? Scheduled posts will be cancelled."
  → social.disconnect.mutate({ accountId })
  → Card becomes ghost state
  → Toast: "Account disconnected"

User clicks "Refresh" on account card
  → social.listAccounts refetches
  → Follower count updates
  → Last synced timestamp updates

9. Error States:

State Trigger UI Treatment Recovery
Load error listAccounts fails Inline error + retry Retry
OAuth fail User cancels OAuth Toast: "Connection cancelled. Try again when ready." Retry
Token invalid API returns 401 Card shows "Session expired. Reconnect." Reconnect
Platform API down Platform error Card shows "Platform unavailable. Retrying..." Auto-retry

10. Role-Based Variations:

Role Connect Disconnect Refresh
CLIENT
EDITOR
VIEWER
ADMIN ✅ + admin override

7.3 Post Composer & Scheduler#

1. Purpose: Allows users to create, edit, and schedule social media posts across all connected platforms. Supports AI content generation, media attachments, and cross-platform scheduling.

2. Visual Layout:

  • Modal: Full-screen modal (desktop) or slide-up panel (mobile).
  • Modal tabs: "Compose", "AI Generate", "Preview".
  • Platform selector: Toggle buttons for each connected platform. "Post to all" or select individual.
  • Text area: Content input, max 5000 chars (platform-specific limits handled server-side).
  • Media upload: Drag-drop zone, max 10 files, preview grid.
  • Media type: Auto-detected (image, video, carousel, reel). Manual override available.
  • Hashtags: Auto-suggest based on content. Manual entry with # prefix.
  • Link: URL input with preview card generation.
  • Schedule: Toggle "Post now" vs "Schedule". Date-time picker for schedule.
  • Action buttons: "Save Draft" (secondary), "Schedule" (primary, Ember Orange).

3. Data Source:

  • tRPC endpoint: social.createPost (single) or social.schedulePosts (batch)
  • Input: CreateSocialPostInput or SchedulePostsInput
  • Output: SocialPost or { scheduledIds[] }

4. Zod Schema:

const createSocialPostSchema = z.object({
  socialAccountId: z.string(),
  content: z.string().min(1).max(5000),
  mediaUrls: z.array(z.string().url()).max(10).optional(),
  mediaType: z.enum(["NONE", "IMAGE", "VIDEO", "CAROUSEL", "REEL"]).default("NONE"),
  hashtags: z.array(z.string()).default([]),
  linkUrl: z.string().url().optional(),
  scheduledFor: z.string().datetime().optional(), // If absent, publish immediately
});

const schedulePostsSchema = z.object({
  posts: z.array(z.object({
    platform: z.enum(["FACEBOOK", "INSTAGRAM", "LINKEDIN", "TWITTER"]),
    content: z.string().min(1).max(5000),
    mediaUrls: z.array(z.string().url()).optional(),
    scheduledFor: z.string().datetime(),
  })).min(1).max(50), // Batch scheduling limit
});

const socialPostSchema = z.object({
  id: z.string().uuid(),
  practiceId: z.string(),
  socialAccountId: z.string(),
  platform: z.enum(["FACEBOOK", "INSTAGRAM", "LINKEDIN", "TWITTER"]),
  content: z.string().max(5000),
  mediaUrls: z.array(z.string().url()).max(10),
  mediaType: z.enum(["NONE", "IMAGE", "VIDEO", "CAROUSEL", "REEL"]),
  hashtags: z.array(z.string()),
  linkUrl: z.string().url().nullable(),
  status: z.enum(["DRAFT", "SCHEDULED", "PUBLISHED", "FAILED", "PENDING_APPROVAL"]),
  scheduledFor: z.string().datetime().nullable(),
  publishedAt: z.string().datetime().nullable(),
  platformPostId: z.string().optional(), // Platform's internal post ID
  engagementStats: z.object({
    likes: z.number().int().optional(),
    comments: z.number().int().optional(),
    shares: z.number().int().optional(),
    impressions: z.number().int().optional(),
  }).optional(),
  aiGenerated: z.boolean().default(false),
  failedReason: z.string().optional(),
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime(),
});

5. Fetch Frequency:

  • No polling for the composer itself. On submit, parent list invalidates.

6. Data Manipulations:

  • Character count: Live counter below textarea. Color changes at 80% (amber), 95% (red), 100% (blocked).
  • Platform-specific limits: Frontend shows soft warning, but server enforces hard limits (Twitter: 280, others: 5000).
  • Hashtag extraction: Auto-extracts #word from content. Shows as chips below textarea. Click to remove.
  • Hashtag suggestions: Based on content keywords + medical niche. content.generate with taskType: "HASHTAG_SUGGESTIONS".
  • Media preview: Thumbnails in grid. Click to remove. Drag to reorder.
  • Link preview: If linkUrl present, fetch OpenGraph data (title, image, description). Show preview card.
  • Schedule validation: scheduledFor must be > now + 5 minutes. Max 90 days in future.
  • Cross-platform content: If posting to multiple platforms, content is the same. Platform-specific adaptations handled server-side (e.g., Twitter truncation, Instagram hashtag count).

7. Why Structured This Way:

  • Full-screen modal: Post composition requires focus. Full-screen eliminates distractions.
  • AI Generate tab: Reduces writer's block. Users describe what they want, AI generates options.
  • Platform selector: Users often post to multiple platforms. Single compose, multi-platform publish saves time.
  • Hashtag suggestions: Medical hashtags are specialized. AI suggestions ensure relevance and reach.
  • Schedule validation: Prevents common mistakes (scheduling in past, too far future).
  • Link preview: Users want to see how their link will look. OpenGraph preview ensures confidence.

8. Interaction Flows:

User clicks "New Post" in Social dashboard
  → Post composer modal opens
  → Platform selector shows all connected accounts (all selected by default)
  → User clicks to deselect platforms (e.g., only Facebook + Instagram)

User types content in textarea
  → Character count updates live
  → Hashtags auto-extracted as chips
  → If > 280 chars and Twitter selected, warning: "Twitter limit exceeded. Content will be truncated."

User clicks "AI Generate" tab
  → Topic input: "What should this post be about?"
  → Tone: "Professional", "Friendly", "Promotional", "Educational"
  → "Generate" button
  → content.generate mutation fires
  → Loading: "Generating content..."
  → 3 options appear (variations A, B, C)
  → User selects one, content populates textarea
  → User can edit before publishing

User adds media
  → Drag files onto drop zone or click to browse
  → Thumbnails appear in grid
  → Media type auto-detected (image/video)
  → User can override media type

User sets schedule
  → Toggle from "Post now" to "Schedule"
  → Date-time picker appears
  → Min: now + 5 minutes. Max: now + 90 days
  → If invalid, inline error

User clicks "Schedule"
  → Client-side Zod validation
  → If posting to 1 platform: social.createPost.mutate(data)
  → If posting to multiple: social.schedulePosts.mutate({ posts: [...] })
  → Modal closes
  → Toast: "Post scheduled for June 15, 9:00 AM"
  → social.listPosts invalidates, new post appears

User clicks "Save Draft"
  → social.createPost.mutate({ ...data, status: "DRAFT" })
  → Modal closes
  → Toast: "Draft saved"
  → Post appears in "Drafts" tab

9. Error States:

State Trigger UI Treatment Recovery
No connected accounts accounts.length === 0 Modal shows: "Connect a social account first" + link to Accounts tab Connect account
Content empty content.length === 0 Inline error: "Content is required." Type content
Character limit Platform-specific Inline warning at 80%, error at 100% Trim content
Media too large > 50MB Inline error: "File too large. Max 50MB." Compress or remove
Schedule invalid Past or > 90 days Inline error: "Schedule must be between 5 minutes and 90 days from now." Fix date
Platform API fail Post publish fails Toast: "Failed to post to Facebook. Post saved as draft." Retry or edit
AI generation fail LLM timeout Toast: "AI generation failed. Please write manually." Manual compose

10. Role-Based Variations:

Role Compose Schedule AI Generate Save Draft Delete
CLIENT
EDITOR
VIEWER
ADMIN

7.4 Content Calendar#

1. Purpose: Visual calendar showing all scheduled and published social posts across platforms. Users can drag to reschedule, click to edit, and see the full content pipeline at a glance.

2. Visual Layout:

  • Tab: "Calendar" within Social dashboard.
  • View toggle: "Month", "Week", "Day", "List". Default: "Month".
  • Calendar grid: Standard month grid (7 columns). Each cell shows:
    • Date number
    • Post indicators (colored dots per platform)
    • Post count badge if > 1
  • Post event: Clickable bar on calendar date. Color-coded by platform.
  • Side panel: Clicking a post opens side panel with:
    • Post preview (content + media)
    • Platform badge
    • Schedule time
    • Status badge
    • "Edit" and "Delete" buttons
  • Today highlight: Current date cell has pale-cyan background.
  • Drag to reschedule: Drag post from one date to another. Confirmation modal on drop.

3. Data Source:

  • tRPC endpoint: social.listPosts with date range
  • Input: { accountId?, status?, dateFrom, dateTo }
  • Output: SocialPost[]

4. Zod Schema: Same as socialPostSchema in 7.3. Calendar view adds computed fields:

const calendarEventSchema = z.object({
  id: z.string().uuid(),
  title: z.string().max(100), // Truncated content
  content: z.string(), // Full content
  platform: z.enum(["FACEBOOK", "INSTAGRAM", "LINKEDIN", "TWITTER"]),
  status: z.enum(["DRAFT", "SCHEDULED", "PUBLISHED", "FAILED"]),
  scheduledFor: z.string().datetime(),
  mediaUrls: z.array(z.string().url()),
  color: z.string(), // Platform accent color
});

5. Fetch Frequency:

  • refetchInterval: 300000 (5 minutes). Schedule changes are infrequent.
  • Date range change: On-demand refetch.

6. Data Manipulations:

  • Calendar grouping: Posts grouped by scheduledFor date. Multiple posts on same date = stacked dots.
  • Platform color: Facebook → blue, Instagram → pink, LinkedIn → blue, Twitter → blue. Dots use platform colors.
  • Status dot style: SCHEDULED → solid dot, PUBLISHED → checkmark, FAILED → X mark, DRAFT → dashed outline.
  • Title truncation: First 30 characters of content + ellipsis. Hover shows full content tooltip.
  • Date range: Month view loads ±15 days from month start/end to handle edge cases.
  • Drag reschedule: On drop, social.createPost with updated scheduledFor (or dedicated social.reschedule endpoint if available).

7. Why Structured This Way:

  • Calendar view: Social media is inherently time-based. Calendar is the natural mental model for scheduling.
  • Platform color coding: At a glance, users see which platforms have content on which days. Avoids over-posting to one platform.
  • Drag reschedule: Faster than opening a modal, changing date, saving. Direct manipulation.
  • Month/Week/Day views: Different planning horizons. Month for overview, week for detailed planning, day for exact timing.
  • Today highlight: Orienting marker. Users immediately see where they are in the schedule.

8. Interaction Flows:

User navigates to Calendar tab
  → social.listPosts fires with current month date range
  → Calendar grid populates with post events
  → Today's date highlighted

User clicks a post event on June 15
  → Side panel slides in from right (desktop) or bottom sheet (mobile)
  → Post preview shown
  → "Edit" and "Delete" buttons visible

User clicks "Edit" in side panel
  → Post composer modal opens pre-populated with post data
  → User edits, clicks "Save"
  → Calendar updates, side panel closes

User drags post from June 15 to June 20
  → Visual feedback during drag (ghost image)
  → On drop: confirmation modal: "Reschedule to June 20, 9:00 AM?"
  → User confirms
  → social.createPost.mutate({ id, scheduledFor: newDate })
  → Post moves to new date on calendar
  → Toast: "Rescheduled to June 20"

User clicks "Week" view
  → Calendar switches to 7-column week grid
  → Posts shown with time slots
  → More detail visible per post

User clicks "List" view
  → Switches to chronological list (like Posts tab)
  → No calendar grid, just sorted posts

9. Error States:

State Trigger UI Treatment Recovery
No posts No scheduled/published posts "No posts scheduled. Create your first post." + "New Post" CTA Create post
Load error listPosts fails Inline error + retry Retry
Drag conflict Drop on date with max posts Warning: "Maximum 5 posts per day recommended. Continue?" Confirm or cancel
Reschedule fail API error Toast: "Failed to reschedule. Please try again." Retry

10. Role-Based Variations:

Role View Edit Delete Drag Reschedule
CLIENT All views
EDITOR All views
VIEWER All views
ADMIN All views

Section 8 — Citation Network#

8.1 Citation Dashboard (/dashboard/citations)#

1. Purpose: The central hub for managing business citations across 30+ directories. Users view citation health, submit to new directories, verify NAP consistency, and track submission status.

2. Visual Layout:

  • Page header: "Citation Network" + health score badge (large, color-coded).
  • Health score card: Prominent card at top showing:
    • Big number: verifiedCount / totalDirectories percentage
    • Progress bar: Green (verified), amber (pending), red (failed)
    • "Last verified: 2 days ago"
  • Sub-navigation: "Directories", "Submissions", "NAP Health", "Snapshots".
  • Default tab: "Directories" (the action center).
  • Action bar: "Run NAP Check" button (primary) + "Export Report" (secondary).

3. Data Source:

// Composite load on mount:
// 1. citation.listDirectories — all available directories
// 2. citation.list — current practice citations
// Both parallel
Field Endpoint Auth
All directories citation.listDirectories practiceProcedure
Practice citations citation.list practiceProcedure
NAP health citation.verifyNap practiceProcedure
Submission status citation.list practiceProcedure

4. Zod Schema:

const citationDirectorySchema = z.object({
  id: z.string(),
  name: z.string(),
  domain: z.string(),
  da: z.number().int().min(0).max(100), // Domain authority
  category: z.enum(["GENERAL", "LOCAL", "INDUSTRY", "MEDICAL", "SOCIAL"]),
  submissionUrl: z.string().url(),
  requiresEmail: z.boolean(),
  requiresPhone: z.boolean(),
  description: z.string().optional(),
  isPremium: z.boolean().default(false), // Paid directory
});

const citationSchema = z.object({
  id: z.string().uuid(),
  practiceId: z.string(),
  locationId: z.string(),
  directoryName: z.string(),
  directoryDomain: z.string(),
  status: z.enum(["NOT_SUBMITTED", "PENDING", "SUBMITTED", "VERIFIED", "FAILED", "REJECTED"]),
  submittedAt: z.string().datetime().nullable(),
  verifiedAt: z.string().datetime().nullable(),
  failedAt: z.string().datetime().nullable(),
  failedReason: z.string().optional(),
  listingUrl: z.string().url().nullable(), // URL of the live listing
  napSnapshot: z.object({
    name: z.string(),
    address: z.string(),
    phone: z.string(),
    website: z.string().url().nullable(),
  }).nullable(),
  lastCheckedAt: z.string().datetime().nullable(),
  aiGeneratedDescription: z.boolean().default(false),
  jobId: z.string().optional(), // Reference to background job
});

const citationDashboardSchema = z.object({
  healthScore: z.number().min(0).max(100),
  totalDirectories: z.number().int(),
  verifiedCount: z.number().int(),
  pendingCount: z.number().int(),
  failedCount: z.number().int(),
  notSubmittedCount: z.number().int(),
  lastVerifiedAt: z.string().datetime().nullable(),
  citations: z.array(citationSchema),
  directories: z.array(citationDirectorySchema),
});

5. Fetch Frequency:

  • Dashboard data: refetchInterval: 300000 (5 minutes).
  • NAP verification: On-demand only (manual trigger). NAP check is a background job that takes 5-30 minutes.
  • Submission status: refetchInterval: 300000 (5 minutes). Submissions are background jobs.

6. Data Manipulations:

  • Health score: (verifiedCount / totalDirectories * 100).toFixed(0).
  • Progress bar segments:
    • Green width: verifiedCount / totalDirectories * 100
    • Amber width: pendingCount / totalDirectories * 100
    • Red width: failedCount / totalDirectories * 100
  • Directory grouping: Grouped by category: "Medical" (priority for medical clients), "Local", "General", "Industry", "Social".
  • NAP snapshot comparison: If napSnapshot exists, compare against canonical practice NAP. Highlight mismatches in red.
  • DA display: Domain authority score (0-100). da >= 50 → "High Authority" badge. da >= 30 → "Medium Authority".
  • Submission status color: NOT_SUBMITTEDslate, PENDINGamber, SUBMITTEDsky-blue, VERIFIEDforest-teal, FAILEDerror-red, REJECTEDerror-red.

7. Why Structured This Way:

  • Health score prominence: Citation health is RankFlow's primary differentiator. It gets the most visual weight.
  • Progress bar: Immediate visual understanding of completion status. Users see "80% complete" and know they're close.
  • Directory grouping: Medical clients need medical directories first. Grouping by category surfaces the most relevant ones.
  • NAP verification: NAP consistency is the #1 ranking factor for local SEO. Dedicated tab with snapshot comparison makes it actionable.
  • DA score: Domain authority signals directory quality. Users understand why some directories matter more.
  • 5-minute polling: Submissions are background jobs. Real-time polling would be wasteful.

8. Interaction Flows:

User navigates to /dashboard/citations
  → citation.listDirectories + citation.list fire in parallel
  → Health score card populates
  → Directory grid populates
  → Citation status shown per directory

User clicks "Run NAP Check"
  → citation.verifyNap.mutate({ locationId })
  → Button shows spinner: "Running NAP check..."
  → Toast: "NAP verification started. Results in ~10 minutes."
  → Activity feed updates with PENDING event
  → Background job runs, polling updates status

User clicks "Submit" on an unlisted directory
  → Confirmation modal: "Submit to [Directory Name]?"
  → citation.submit.mutate({ locationId, directoryNames: [name] })
  → Status changes to PENDING
  → Toast: "Submission queued. Check back in 24 hours."
  → Background job runs, status updates to SUBMITTED then VERIFIED

User clicks "View Listing" on a verified citation
  → Opens `listingUrl` in new tab
  → User sees live listing on directory

User clicks "Snapshots" tab
  → citation.getSnapshot fires for each citation
  → NAP comparison shown per directory
  → Mismatches highlighted in red
  → "Fix NAP" button sends to NAP verification queue

9. Error States:

State Trigger UI Treatment Recovery
No directories listDirectories fails Error: "Failed to load directory list." + retry Retry
No citations New practice Health score 0%, all directories show "Not Submitted" Start submissions
All failed failedCount === total Red banner: "All submissions failed. Contact support." Contact support
NAP check fail verifyNap returns error Toast: "NAP check failed. Please try again." Retry
Snapshot load fail getSnapshot fails Show "Snapshot unavailable" with retry Retry

10. Role-Based Variations:

Role Submit Verify NAP View Snapshots Export
CLIENT
EDITOR
VIEWER
ADMIN ✅ + admin override

8.2 Directory Grid#

1. Purpose: Displays all available citation directories with their status, DA score, and submission actions. Users can submit individually or in bulk.

2. Visual Layout:

  • Tab: "Directories" within Citation dashboard.
  • Filter bar: Search input + category filter dropdown + status filter dropdown.
  • Grid: 3-column grid (2 on tablet, 1 on mobile). Each card is a directory card.
  • Directory card:
    • Directory name + domain
    • DA score badge (color-coded: green >= 50, amber >= 30, gray < 30)
    • Category badge
    • Status badge (current submission status)
    • "Submit" button (if not submitted) or "View Listing" link (if verified)
    • "Requires email/phone" indicator (if applicable)
  • Bulk actions: Select multiple directories → "Submit Selected" button appears.
  • Sort: By DA (default), name, status.

3. Data Source:

  • tRPC endpoint: citation.listDirectories + citation.list
  • Combined on frontend to map directory metadata with submission status.

4. Zod Schema: Same as citationDirectorySchema and citationSchema in 8.1.

5. Fetch Frequency:

  • refetchInterval: 300000 (5 minutes). Directory list is static, but submission status changes.

6. Data Manipulations:

  • DA color: da >= 50forest-teal badge, da >= 30amber badge, da < 30slate badge.
  • Status mapping: NOT_SUBMITTED → "Submit" button (primary), PENDING → "Pending" badge + spinner, SUBMITTED → "Submitted" badge, VERIFIED → "View Listing" link, FAILED → "Retry" button (secondary), REJECTED → "Contact Support" link.
  • Category icon: Medical → Stethoscope, Local → MapPin, General → Globe, Industry → Building, Social → Share2.
  • Bulk selection: Checkbox on each card. Selected count shown in sticky action bar. "Submit 5 selected" button.
  • Search filter: Filters by name or domain. Real-time filtering (client-side).

7. Why Structured This Way:

  • Card grid: Directories are independent entities. Cards give each one clear visual boundaries.
  • DA prominence: Domain authority is the quality signal. Badges make it scannable.
  • Bulk submission: Submitting to 30 directories one-by-one is tedious. Bulk selection reduces clicks from 30 to 2.
  • Status-driven CTA: Button changes based on status. No confusion about what action is available.
  • Category filter: Medical clients want medical directories first. Filter reduces noise.

8. Interaction Flows:

User clicks "Submit" on a single directory
  → citation.submit.mutate({ locationId, directoryNames: [directory.name] })
  → Card status changes to PENDING, spinner shown
  → Toast: "Submission to [Name] queued"
  → After background job: status updates to SUBMITTED or VERIFIED

User selects 5 directories via checkboxes
  → Sticky action bar appears at bottom: "Submit 5 selected"
  → User clicks "Submit 5 selected"
  → citation.submit.mutate({ locationId, directoryNames: [name1, name2, ...] })
  → All selected cards change to PENDING
  → Toast: "5 submissions queued"

User clicks "Retry" on a failed directory
  → Same as submit flow
  → Failed reason shown in tooltip on hover

User clicks "View Listing" on verified directory
  → Opens listingUrl in new tab

9. Error States:

State Trigger UI Treatment Recovery
Empty search No matching directories "No directories match your search. Try different keywords." Clear search
Submit fail API error Card shows "Failed" + retry button Retry
Bulk limit > 50 selected Warning: "Maximum 50 directories per batch." Deselect excess

10. Role-Based Variations:

Role Submit Retry Bulk Submit
CLIENT
EDITOR
VIEWER
ADMIN

8.3 NAP Health Monitor#

1. Purpose: Displays NAP (Name, Address, Phone) consistency across all verified citations. Highlights discrepancies that hurt local SEO rankings.

2. Visual Layout:

  • Tab: "NAP Health" within Citation dashboard.
  • Canonical NAP card: At top, shows the "source of truth" NAP from practice settings. "Edit in Settings" link.
  • Comparison table: Full-width table with columns:
    1. Directory — Name + domain
    2. Name — NAP snapshot name. Mismatch = red text + warning icon.
    3. Address — NAP snapshot address. Mismatch = red text + warning icon.
    4. Phone — NAP snapshot phone. Mismatch = red text + warning icon.
    5. Website — NAP snapshot website. Mismatch = red text + warning icon.
    6. Match Score — Percentage (4 fields, each 25%). Green >= 100%, amber >= 75%, red < 75%.
    7. Last Checked — Relative time.
    8. Actions — "Re-check" button.
  • Summary banner: "28/30 directories have consistent NAP" or "2 directories have NAP mismatches." Color-coded.

3. Data Source:

  • tRPC endpoint: citation.list (with napSnapshot) + practice.get (for canonical NAP)
  • citation.verifyNap for re-checks.

4. Zod Schema:

const napHealthSchema = z.object({
  canonicalNap: z.object({
    name: z.string(),
    address: z.string(),
    phone: z.string(),
    website: z.string().url().nullable(),
  }),
  directoryNaps: z.array(z.object({
    citationId: z.string(),
    directoryName: z.string(),
    directoryDomain: z.string(),
    napSnapshot: z.object({
      name: z.string(),
      address: z.string(),
      phone: z.string(),
      website: z.string().url().nullable(),
    }).nullable(),
    matchScore: z.number().min(0).max(100),
    nameMatch: z.boolean(),
    addressMatch: z.boolean(),
    phoneMatch: z.boolean(),
    websiteMatch: z.boolean(),
    lastCheckedAt: z.string().datetime().nullable(),
  })),
  summary: z.object({
    totalChecked: z.number().int(),
    consistentCount: z.number().int(),
    mismatchCount: z.number().int(),
    notCheckedCount: z.number().int(),
  }),
});

5. Fetch Frequency:

  • On mount + on-demand (after "Run NAP Check" or "Re-check"). No polling — NAP doesn't change frequently.

6. Data Manipulations:

  • Match score: (nameMatch + addressMatch + phoneMatch + websiteMatch) / 4 * 100. Each field is 25%.
  • Field comparison: Case-insensitive string comparison. Trims whitespace. Ignores formatting differences in phone (e.g., "+91 98765 43210" vs "9876543210").
  • Mismatch highlighting: If nameMatch === false, the name cell has error-red text + AlertTriangle icon tooltip: "Does not match canonical name."
  • Summary banner: consistentCount / totalChecked * 100 for overall percentage. If mismatchCount > 0, banner is amber. If mismatchCount === 0, banner is green.
  • Sort: Default sort by matchScore ascending (worst first). User can sort by directory name or last checked.

7. Why Structured This Way:

  • Canonical NAP at top: Establishes the source of truth. Users know what the "correct" data is.
  • Table layout: NAP comparison is data-dense. Tables handle dense data better than cards.
  • Match score per field: Users see exactly which field is wrong (name vs. address vs. phone). Not just "mismatch" but "address mismatch."
  • Red highlighting: Visual attention draws to problems. Users don't need to read every cell.
  • Worst-first sort: Problems rise to the top. Users fix the worst mismatches first.
  • No polling: NAP verification is expensive (scrapes 30 directories). Polling would waste resources.

8. Interaction Flows:

User navigates to NAP Health tab
  → citation.list + practice.get fire in parallel
  → Table populates with NAP comparison
  → Mismatches highlighted in red
  → Summary banner shows overall health

User clicks "Re-check" on a directory
  → citation.verifyNap.mutate({ citationId })
  → Row shows spinner in "Last Checked" column
  → After job completes, row updates with new snapshot
  → Match score recalculates
  → Toast: "NAP re-check complete for [Directory]"

User clicks "Edit in Settings" on canonical NAP
  → Router navigates to /dashboard/settings?tab=practice
  → Practice settings open, NAP fields editable
  → User updates NAP
  → On save, all directory match scores recalculate (client-side)

User clicks a mismatch cell
  → Tooltip shows: "Canonical: [canonical value] | Directory: [directory value]"
  → Clear comparison of what differs

9. Error States:

State Trigger UI Treatment Recovery
No snapshots Not checked yet Table shows "Not checked yet" in all cells Run NAP check
Snapshot load fail getSnapshot fails Row shows "Snapshot unavailable" + retry Retry
Canonical NAP missing Practice has no address Banner: "Practice address required. Set up in Settings." Navigate to Settings
All unchecked notCheckedCount === total "No NAP checks run yet. Click 'Run NAP Check' to start." Run NAP check

10. Role-Based Variations:

Role View Re-check Edit Canonical NAP
CLIENT ✅ (navigates to Settings)
EDITOR
VIEWER
ADMIN ✅ + admin override

8.4 Submission Tracker#

1. Purpose: Tracks the status of all citation submissions, including background job progress, failure reasons, and retry history.

2. Visual Layout:

  • Tab: "Submissions" within Citation dashboard.
  • Table: Full-width table with columns:
    1. Directory — Name
    2. Status — Badge + spinner if pending
    3. Submitted — Date/time
    4. Verified — Date/time or "—"
    5. Failed Reason — Tooltip if failed
    6. Job ID — Collapsible, links to job monitor
    7. Actions — Retry, View Listing, Delete
  • Filter: By status, date range, directory category.
  • Sort: By submitted date (default), status, directory name.
  • Bulk retry: Select failed submissions → "Retry Selected" button.

3. Data Source:

  • tRPC endpoint: citation.list (with full status history)
  • Output: Citation[] with job metadata.

4. Zod Schema: Same as citationSchema in 8.1. Additional computed fields for display:

const submissionTrackerRowSchema = z.object({
  id: z.string().uuid(),
  directoryName: z.string(),
  directoryDomain: z.string(),
  status: z.enum(["NOT_SUBMITTED", "PENDING", "SUBMITTED", "VERIFIED", "FAILED", "REJECTED"]),
  submittedAt: z.string().datetime().nullable(),
  verifiedAt: z.string().datetime().nullable(),
  failedAt: z.string().datetime().nullable(),
  failedReason: z.string().optional(),
  listingUrl: z.string().url().nullable(),
  jobId: z.string().optional(),
  retryCount: z.number().int().default(0),
  isRetryable: z.boolean().default(true),
});

5. Fetch Frequency:

  • refetchInterval: 300000 (5 minutes). Submission status changes as background jobs complete.

6. Data Manipulations:

  • Status timeline: Visual timeline indicator. NOT_SUBMITTEDPENDINGSUBMITTEDVERIFIED or FAILED.
  • Progress bar: For PENDING status, show indeterminate progress bar. For SUBMITTED, show "Waiting for verification".
  • Failed reason tooltip: If failedReason present, hover shows full error message.
  • Retry count: Badge showing how many times retried. If retryCount >= 3, disable retry button, show "Contact support" link.
  • Job ID link: If jobId present, clickable link to /admin/workflows (or admin view). For clients, shows as text with copy button.
  • Date formatting: Absolute dates with relative time in tooltip. "June 12, 2026" (hover: "2 days ago").

7. Why Structured This Way:

  • Table layout: Submissions are tracking data, not visual content. Tables handle structured data efficiently.
  • Status timeline: Users understand the submission lifecycle. Visual timeline reduces confusion about what "PENDING" means.
  • Retry count: Prevents infinite retry loops. After 3 retries, manual intervention is needed.
  • Job ID: Links submission to background job. Admin can investigate if submission fails repeatedly.
  • Bulk retry: Failed submissions often fail for the same reason (e.g., directory API down). Bulk retry saves time after the issue is resolved.

8. Interaction Flows:

User navigates to Submissions tab
  → citation.list fires
  → Table populates with all submissions
  → Pending rows show spinner
  → Failed rows show retry button

User clicks "Retry" on a failed submission
  → citation.submit.mutate({ locationId, directoryNames: [name] })
  → Status changes to PENDING
  → Retry count increments
  → Toast: "Retry submitted for [Directory]"

User selects 3 failed submissions
  → "Retry 3 selected" button appears in sticky bar
  → User clicks button
  → All 3 retry simultaneously
  → Toast: "3 retries submitted"

User clicks job ID link
  → If admin: navigates to /admin/workflows?jobId=xxx
  → If client: copies job ID to clipboard, toast: "Job ID copied. Share with support if needed."

User clicks "View Listing" on verified submission
  → Opens listingUrl in new tab

9. Error States:

State Trigger UI Treatment Recovery
No submissions All NOT_SUBMITTED "No submissions yet. Start with the Directories tab." Navigate to Directories
All failed Every submission failed Red banner: "All submissions failed. This may be a system issue. Contact support." Contact support
Retry exhausted retryCount >= 3 Button disabled, tooltip: "Max retries reached. Contact support." Contact support
Job not found Job ID invalid "Job details unavailable. May have been archived." N/A

10. Role-Based Variations:

Role View Retry Bulk Retry Delete
CLIENT
EDITOR
VIEWER
ADMIN ✅ + view job details

Section 9 — Site Monitor#

9.1 Site Dashboard (/dashboard/site)#

1. Purpose: The central hub for managing the AI-generated landing page. Users view site status, edit sections, change templates, and control publish/unpublish state.

2. Visual Layout:

  • Page header: "Landing Page" + site URL + status badge (Published/Unpublished).
  • Status banner: Full-width banner showing:
    • Site URL (clickable, opens in new tab)
    • Status: "Published" (green) or "Unpublished" (amber)
    • Last published date
    • "Preview" button (opens site in new tab)
    • "Publish/Unpublish" toggle button
  • Sub-navigation: "Sections", "Template", "SEO", "Analytics".
  • Default tab: "Sections" (where users spend most time).
  • Preview panel: On desktop, right side shows live preview of the site (iframe or thumbnail). Updates as user edits.

3. Data Source:

  • tRPC endpoint: site.get
  • Input: none (practiceId from context)
  • Output: Practice + SiteSection[]

4. Zod Schema:

const siteSectionSchema = z.object({
  id: z.string().uuid(),
  practiceId: z.string(),
  sectionKey: z.enum([
    "hero", "about", "services", "testimonials", "faq",
    "contact", "cta", "reviews-widget", "stats", "team", "blog", "gallery"
  ]),
  title: z.string().max(200),
  content: z.string().max(50000),
  mediaUrls: z.array(z.string().url()).max(20),
  config: z.record(z.any()).default({}), // Section-specific config (e.g., hero background, stats numbers)
  isVisible: z.boolean().default(true),
  sortOrder: z.number().int(),
  templateId: z.string(),
  aiGenerated: z.boolean().default(false),
  lastEditedAt: z.string().datetime().nullable(),
  lastEditedBy: z.string().optional(),
});

const siteDashboardSchema = z.object({
  practice: z.object({
    id: z.string(),
    name: z.string(),
    subdomain: z.string(),
    customDomain: z.string().nullable(),
    siteUrl: z.string().url(),
    siteStatus: z.enum(["PUBLISHED", "UNPUBLISHED", "PENDING"]),
    templateId: z.string(),
    lastPublishedAt: z.string().datetime().nullable(),
  }),
  sections: z.array(siteSectionSchema),
  seo: z.object({
    metaTitle: z.string().max(60),
    metaDescription: z.string().max(160),
    schemaMarkup: z.string(), // JSON-LD
    robotsTxt: z.string(),
    sitemapUrl: z.string().url(),
  }),
  analytics: z.object({
    visitors30d: z.number().int(),
    pageViews30d: z.number().int(),
    topPages: z.array(z.object({
      path: z.string(),
      views: z.number().int(),
    })).max(5),
  }).optional(),
});

5. Fetch Frequency:

  • On mount + after publish/unpublish/template change. Static during editing.
  • Analytics: refetchInterval: 3600000 (1 hour).

6. Data Manipulations:

  • Site URL: siteUrl is https://{subdomain}.rankflow.ai or https://{customDomain} if set.
  • Status badge: PUBLISHEDforest-teal, UNPUBLISHEDamber, PENDINGsky-blue.
  • Section order: Sorted by sortOrder. Drag-and-drop reordering updates sortOrder values.
  • Visibility toggle: Eye icon (visible) / EyeOff icon (hidden). Click toggles isVisible.
  • AI-generated badge: If aiGenerated === true, small "AI" badge on section card.
  • Last edited: Relative time. "Edited 2 hours ago by Ravi Kumar".
  • Preview URL: siteUrl + ?preview=true (draft preview, not indexed).

7. Why Structured This Way:

  • Status banner at top: Publish state is the most important information. Users need to know if their site is live.
  • Live preview: Users want to see changes immediately. Side-by-side preview reduces back-and-forth between editing and viewing.
  • Section-based editing: Landing pages are modular. Sections (hero, about, services) are independent. Users edit one section without affecting others.
  • Visibility toggle: Users can hide sections without deleting content. Useful for seasonal content (e.g., "Flu Season Special").
  • AI badge: Builds trust. Users know which content was AI-generated vs. manually written.
  • 1-hour analytics: Site traffic doesn't change minute-by-minute. Hourly polling is sufficient.

8. Interaction Flows:

User navigates to /dashboard/site
  → site.get fires
  → Site status banner populates
  → Sections list populates
  → Preview panel loads site in iframe

User clicks "Publish" on unpublished site
  → site.publish.mutate()
  → Status changes to PENDING (spinner)
  → Background job deploys to CDN
  → After completion: status changes to PUBLISHED
  → Toast: "Site published! Live at [URL]"
  → Site URL becomes clickable

User clicks "Unpublish"
  → Confirmation modal: "Unpublish site? It will no longer be accessible."
  → site.unpublish.mutate()
  → Status changes to UNPUBLISHED
  → Toast: "Site unpublished"

User clicks "Preview"
  → Opens siteUrl in new tab
  → Site loads with live content

User clicks "Edit" on a section
  → Section editor modal opens
  → Content, media, and config editable
  → Live preview updates in real-time (debounced, 500ms)

User drags section to reorder
  → Visual drag feedback
  → Drop updates sortOrder
  → site.reorderSections.mutate({ sectionKeys: [newOrder] })
  → Preview updates with new order
  → Toast: "Section order updated"

User toggles visibility on a section
  → Eye icon changes to EyeOff
  → Section card gets faded styling
  → site.setVisibility.mutate({ sectionKey, isVisible: false })
  → Preview updates, section hidden
  → Toast: "Section hidden"

9. Error States:

State Trigger UI Treatment Recovery
Site not created No practice.subdomain Banner: "Site not created yet. Complete onboarding." Complete onboarding
Publish fail Deployment error Status shows "FAILED" + retry button Retry or contact support
Template load fail Template missing "Template unavailable. Please select another." Select different template
Section edit fail API error Toast: "Failed to save section. Please try again." Retry
Preview load fail iframe blocked "Preview unavailable. Open site in new tab." + link Open in new tab

10. Role-Based Variations:

Role Edit Publish Unpublish Reorder Change Template
CLIENT
EDITOR
VIEWER
ADMIN ✅ + admin override

9.2 Section Editor#

1. Purpose: Allows users to edit individual landing page sections (hero, about, services, etc.) with a WYSIWYG-like interface. Supports AI content generation, media upload, and config customization.

2. Visual Layout:

  • Modal: Large modal (80vw x 80vh) or slide-over panel (desktop).
  • Modal header: Section name (e.g., "Hero Section") + "AI Generate" button + "Save" (primary) + "Cancel".
  • Content tab: Rich text editor (TipTap or similar) for HTML content. Toolbar with headings, lists, bold, italic, links. No underline (design system rule).
  • Media tab: Image upload grid, drag-drop, preview, alt text input.
  • Config tab: Section-specific settings (e.g., hero background color, CTA button text, stats numbers).
  • Preview tab: Live preview of the section within the full page context.
  • AI Generate button: Generates content based on section type and practice data. Appears in header and content tab.

3. Data Source:

  • tRPC endpoint: site.updateSection
  • Input: UpdateSectionInput
  • Output: SiteSection

4. Zod Schema:

const updateSectionSchema = z.object({
  sectionKey: z.enum([
    "hero", "about", "services", "testimonials", "faq",
    "contact", "cta", "reviews-widget", "stats", "team", "blog", "gallery"
  ]),
  content: z.string().max(50000),
  mediaUrls: z.array(z.string().url()).max(20).optional(),
  config: z.record(z.any()).optional(),
});

const siteSectionDisplaySchema = z.object({
  id: z.string().uuid(),
  sectionKey: z.string(),
  title: z.string(),
  content: z.string(), // HTML content
  mediaUrls: z.array(z.string().url()),
  config: z.record(z.any()),
  isVisible: z.boolean(),
  aiGenerated: z.boolean(),
  lastEditedAt: z.string().datetime().nullable(),
  lastEditedBy: z.string().optional(),
});

5. Fetch Frequency:

  • No polling. Data loaded on modal open. Saved on "Save" click.

6. Data Manipulations:

  • Content sanitization: HTML content sanitized with DOMPurify on client before submission. Prevents XSS.
  • Media upload: Files uploaded to S3/R2 via presigned URL. mediaUrls populated after upload.
  • Alt text: Each image has alt text input. Required for accessibility. Auto-generated by AI if not provided.
  • Config schema: Each section type has its own config schema. Hero config: { backgroundColor, ctaText, ctaLink, headline }. Stats config: { stat1Value, stat1Label, stat2Value, ... }.
  • AI generation: content.generate with taskType: "LANDING_PAGE_SECTION" and sectionKey. AI generates content based on practice data and section type.
  • Live preview: Debounced at 500ms. User types → preview updates after 500ms of inactivity. Reduces server load and flicker.
  • Character limits: Hero headline: 100 chars. About: 5000 chars. Services: 2000 per service. Enforced client-side and server-side.

7. Why Structured This Way:

  • Modal editing: Sections are independent units. Modal keeps user focused on one section without scrolling through the full page.
  • Tabbed interface: Content, media, and config are distinct concerns. Tabs prevent overwhelming the user with all options at once.
  • Rich text editor: Users need formatting (headings, lists, links). But limited toolbar prevents excessive styling that breaks design consistency.
  • AI generation: Writing landing page content is hard. AI reduces the barrier. One click generates professional content.
  • Live preview: Users see exactly how changes look. No imagination required. Reduces revision cycles.
  • DOMPurify: Critical security measure. Users can paste HTML from anywhere. Sanitization prevents malicious scripts.

8. Interaction Flows:

User clicks "Edit" on Hero section
  → Section editor modal opens
  → Content tab active with current HTML
  → Media tab shows current hero image
  → Config tab shows CTA text, headline, background color

User clicks "AI Generate" in header
  → content.generate.mutate({
      taskType: "LANDING_PAGE_SECTION",
      variables: { sectionKey: "hero", practiceId }
    })
  → Loading state: "Generating hero content..."
  → AI-generated content populates editor
  → User can edit before saving
  → Preview tab updates with new content

User types in content editor
  → Debounced at 500ms
  → Preview tab updates with live changes
  → Character count updates

User uploads new image in Media tab
  → Drag file onto drop zone
  → Presigned URL fetched from server
  → File uploaded to S3/R2
  → mediaUrls updated with new URL
  → Preview tab updates with new image
  → Alt text input appears (required)
  → User types alt text or clicks "AI Generate Alt"

User clicks "Save"
  → Client-side validation (Zod schema)
  → site.updateSection.mutate({ sectionKey, content, mediaUrls, config })
  → Modal closes
  → Toast: "Hero section saved"
  → Site dashboard sections list updates
  → Live site preview invalidates

User clicks "Cancel"
  → Confirmation if unsaved changes: "Discard changes?"
  → If confirmed: modal closes, changes discarded
  → If cancelled: stay in modal

9. Error States:

State Trigger UI Treatment Recovery
Content too long > max chars Inline error: "Content exceeds limit." Trim content
Invalid HTML Malformed HTML Editor auto-corrects or shows warning Fix manually
Upload fail S3 error "Image upload failed. Please try again." Retry upload
AI generation fail LLM timeout "AI generation failed. Please write manually." Manual edit
Save fail API error Toast: "Failed to save. Please try again." Retry save
Missing alt text Alt text empty "Alt text is required for accessibility." Add alt text

10. Role-Based Variations:

Role Edit Content Upload Media AI Generate Save Change Config
CLIENT
EDITOR
VIEWER
ADMIN

1. Purpose: Allows users to change the landing page template (design theme). Shows available templates with live previews and practice-specific rendering.

2. Visual Layout:

  • Tab: "Template" within Site dashboard.
  • Template cards: 2-column grid (1 on mobile). Each card shows:
    • Template thumbnail (screenshot of template)
    • Template name (e.g., "Medical Modern", "Dental Clean")
    • Description (e.g., "Clean, professional design for medical practices")
    • "Preview" button (opens in new tab)
    • "Apply" button (primary, Ember Orange)
    • "Current" badge (if currently selected)
  • Filter: By industry (Medical, Dental, CA, Legal, General).
  • Preview modal: Clicking "Preview" opens modal with full-width iframe of template rendered with practice data.

3. Data Source:

  • tRPC endpoint: site.setTemplate (mutation) + site.get (for current template)
  • Available templates are hardcoded in frontend or fetched from site.get (server sends available templates).

4. Zod Schema:

const templateSchema = z.object({
  id: z.string(),
  name: z.string(),
  description: z.string(),
  industry: z.enum(["MEDICAL", "DENTAL", "CA", "LEGAL", "GENERAL"]),
  thumbnailUrl: z.string().url(),
  previewUrl: z.string().url(),
  features: z.array(z.string()),
  isActive: z.boolean(),
});

const setTemplateSchema = z.object({
  templateId: z.enum([
    "medical-modern", "dental-clean", "clinic-premium",
    "ca-professional", "lawyer-authority"
  ]),
});

5. Fetch Frequency:

  • Static. Templates don't change dynamically. Loaded on mount.

6. Data Manipulations:

  • Template rendering: Preview iframe loads previewUrl?practiceId={id}&templateId={id}. Server renders template with practice data.
  • Current badge: templateId === practice.templateId → "Current" badge on card.
  • Industry filter: If practice.category is "Medical", default filter to "Medical" templates.
  • Feature list: Shown as checkmarks. "Mobile responsive", "Schema markup", "Fast loading", etc.
  • Apply confirmation: Modal: "Apply [Template Name]? Your current design will be replaced. Content will be preserved." Content is preserved, only layout/styling changes.

7. Why Structured This Way:

  • Visual selection: Users choose templates visually. Thumbnails are more informative than text descriptions.
  • Live preview: iframe preview shows the template with actual practice data. Users see exactly what they'll get.
  • Content preservation: Reassuring message. Users fear losing content when changing templates. Explicit confirmation reduces anxiety.
  • Industry filtering: Medical users don't want to see legal templates. Filtering reduces choice paralysis.
  • Feature list: Differentiates templates. A user might choose "Medical Modern" because it has "Appointment booking widget."

8. Interaction Flows:

User navigates to Template tab
  → Template cards load (static data)
  → Current template has "Current" badge
  → Other templates show "Preview" and "Apply"

User clicks "Preview" on a template
  → Modal opens with full-width iframe
  → iframe loads template preview with practice data
  → User can scroll and interact with preview
  → "Close" button returns to gallery

User clicks "Apply" on a template
  → Confirmation modal: "Apply [Name]? Content will be preserved."
  → User confirms
  → site.setTemplate.mutate({ templateId })
  → Button shows spinner: "Applying..."
  → After success: "Current" badge moves to new template
  → Toast: "Template applied. Site will update in 1-2 minutes."
  → Preview panel updates

User switches industry filter
  → Cards filter in real-time (client-side)
  → Smooth animation on card enter/exit

9. Error States:

State Trigger UI Treatment Recovery
Preview fail iframe blocked "Preview unavailable. Template details: [description]" N/A
Apply fail API error Toast: "Failed to apply template. Please try again." Retry
Template not found Invalid templateId "Template unavailable. Please select another." Select another
Content mismatch Template doesn't support all sections Warning: "Some sections may be hidden with this template." Confirm or cancel

10. Role-Based Variations:

Role Preview Apply
CLIENT
EDITOR
VIEWER
ADMIN ✅ + admin override

9.4 Publish Control#

1. Purpose: Controls the publish/unpublish state of the landing page, custom domain configuration, and site regeneration. The most critical action panel — users control their public presence here.

2. Visual Layout:

  • Tab: "Publish" within Site dashboard (or prominent section on main Site tab).
  • Status card: Large card showing:
    • Current status: "Published" or "Unpublished"
    • Site URL (clickable)
    • Last published date
    • SSL status badge (green checkmark if HTTPS)
  • Action buttons:
    • "Publish" (primary, Ember Orange) — if unpublished
    • "Unpublish" (secondary, danger on hover) — if published
    • "Regenerate" (secondary) — regenerates all AI content
    • "Set Custom Domain" (secondary) — opens domain configuration modal
  • Custom domain section: If custom domain set, show domain + DNS status. If not, show "Add custom domain" prompt.
  • DNS records table: If custom domain configured, show required DNS records (A, CNAME) with copy buttons.
  • Danger zone: At bottom, separated by horizontal rule. "Delete Site" button (red, with confirmation).

3. Data Source:

  • tRPC endpoints: site.publish, site.unpublish, site.regenerate, site.setCustomDomain
  • Query: site.get (for current status)

4. Zod Schema:

const publishControlSchema = z.object({
  siteStatus: z.enum(["PUBLISHED", "UNPUBLISHED", "PENDING", "FAILED"]),
  siteUrl: z.string().url(),
  subdomain: z.string(),
  customDomain: z.string().nullable(),
  lastPublishedAt: z.string().datetime().nullable(),
  sslStatus: z.enum(["ACTIVE", "PENDING", "FAILED"]),
  dnsRecords: z.array(z.object({
    type: z.enum(["A", "CNAME", "TXT"]),
    host: z.string(),
    value: z.string(),
    ttl: z.number().int(),
  })).optional(),
});

const setCustomDomainSchema = z.object({
  domain: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\.[a-zA-Z]{2,}$/),
});

const customDomainResponseSchema = z.object({
  dnsRecords: z.array(z.object({
    type: z.enum(["A", "CNAME", "TXT"]),
    host: z.string(),
    value: z.string(),
    ttl: z.number().int(),
  })),
});

5. Fetch Frequency:

  • On mount + after any mutation. Static otherwise.
  • DNS status check: refetchInterval: 60000 (1 minute) if custom domain is pending.

6. Data Manipulations:

  • Site URL display: If custom domain set, show custom domain as primary URL, subdomain as secondary. "https://drsharma.com (also https://drsharma.rankflow.ai)".
  • SSL badge: ACTIVE → green checkmark + "Secure", PENDING → amber spinner + "Provisioning", FAILED → red X + "Contact support".
  • DNS record copy: Click-to-copy button next to each record. Toast: "Copied to clipboard".
  • DNS validation: Frontend validates domain format. Server checks DNS propagation.
  • Publish state machine: UNPUBLISHED → click "Publish" → PENDING (spinner) → PUBLISHED (success) or FAILED (error + retry).
  • Regenerate warning: "Regenerate will rewrite all AI content. Manual edits will be preserved." 2-step confirmation.

7. Why Structured This Way:

  • Status prominence: Publish state is the most critical information. Large card with clear visual status.
  • URL clickable: Users want to see their live site. One-click opens it.
  • Custom domain workflow: Setting up a custom domain requires DNS records. Showing the exact records with copy buttons eliminates support tickets.
  • DNS polling: After adding custom domain, DNS propagation takes 1-48 hours. Polling every minute shows progress.
  • Danger zone separation: "Delete Site" is destructive. Physical separation and red color prevent accidental clicks.
  • SSL status: Users care about security. SSL badge reassures them their site is secure.

8. Interaction Flows:

User clicks "Publish" on unpublished site
  → Confirmation: "Publish your site? It will be live at [URL]."
  → site.publish.mutate()
  → Status changes to PENDING
  → Button shows spinner: "Publishing..."
  → Background job deploys to CDN
  → After completion: status changes to PUBLISHED
  → Toast: "Site is live! 🎉"
  → URL becomes clickable
  → Site opens in new tab on click

User clicks "Unpublish"
  → Confirmation: "Unpublish site? It will no longer be accessible to visitors."
  → site.unpublish.mutate()
  → Status changes to UNPUBLISHED
  → Toast: "Site unpublished"

User clicks "Set Custom Domain"
  → Modal opens: "Add Custom Domain"
  → Input field: "Enter your domain (e.g., drsharma.com)"
  → Validation: regex check + DNS lookup
  → If valid: site.setCustomDomain.mutate({ domain })
  → DNS records returned: A record, CNAME record
  → Table shows exact records with copy buttons
  → Instructions: "Add these records to your DNS provider. Verification may take up to 48 hours."
  → "Check Status" button polls DNS status
  → Once verified: SSL provisioned, custom domain active

User clicks "Regenerate"
  → Step 1: "Regenerate will rewrite AI content. Manual edits preserved."
  → Step 2: "Type 'REGENERATE' to confirm."
  → User types confirmation
  → site.regenerate.mutate()
  → Status changes to PENDING
  → Toast: "Regeneration started. Check back in 5 minutes."
  → After completion: site updates with new AI content

User clicks "Delete Site"
  → Danger zone: red button
  → Confirmation modal: "Delete site? This cannot be undone. All content will be lost."
  → Step 2: "Type 'DELETE' to confirm."
  → On confirm: site deleted, user redirected to dashboard
  → Toast: "Site deleted"

9. Error States:

State Trigger UI Treatment Recovery
Publish fail Deployment error Status: FAILED + "Publish failed. Retry or contact support." Retry
DNS propagation fail > 48 hours "DNS not detected. Check your DNS records or contact support." Re-check DNS
Invalid domain Regex fail Inline error: "Invalid domain format." Fix domain
Domain in use Another practice uses domain "Domain already in use. Contact support if this is your domain." Contact support
SSL fail Certificate provisioning fail "SSL certificate failed. Retrying automatically." Auto-retry
Regenerate fail Content generation error "Regeneration failed. Your site is unchanged." Retry

10. Role-Based Variations:

Role Publish Unpublish Custom Domain Regenerate Delete Site
CLIENT ✅ (with confirmation)
EDITOR
VIEWER
ADMIN ✅ + admin override

Data Fetching Patterns (Part 2 Specific)#

React Query Configuration#

// src/hooks/use-dashboard.ts
export const useDashboardOverview = () => {
  return useQuery({
    queryKey: ["dashboard", "overview"],
    queryFn: () => api.dashboard.overview.query(),
    refetchInterval: 300000, // 5 minutes
    staleTime: 120000, // 2 minutes
  });
};

export const useGbpPosts = (filters: ListPostsInput) => {
  return useQuery({
    queryKey: ["gbp", "posts", filters],
    queryFn: () => api.gbp.listPosts.query(filters),
    refetchInterval: 60000, // 1 minute
  });
};

export const useSocialPosts = (filters: ListSocialPostsInput) => {
  return useQuery({
    queryKey: ["social", "posts", filters],
    queryFn: () => api.social.listPosts.query(filters),
    refetchInterval: 60000, // 1 minute
  });
};

export const useCitations = () => {
  return useQuery({
    queryKey: ["citation", "list"],
    queryFn: () => api.citation.list.query(),
    refetchInterval: 300000, // 5 minutes
  });
};

export const useSiteData = () => {
  return useQuery({
    queryKey: ["site", "get"],
    queryFn: () => api.site.get.query(),
    staleTime: Infinity, // Static during editing, manual invalidation
  });
};

Mutation Invalidation Patterns#

// After creating a GBP post
utils.gbp.listPosts.invalidate();
utils.dashboard.overview.invalidate(); // KPIs may change

// After replying to a review
utils.gbp.listReviews.invalidate();
utils.dashboard.overview.invalidate();

// After scheduling a social post
utils.social.listPosts.invalidate();
utils.dashboard.overview.invalidate();

// After submitting a citation
utils.citation.list.invalidate();
utils.citation.listDirectories.invalidate();
utils.dashboard.overview.invalidate();

// After updating a site section
utils.site.get.invalidate();
utils.dashboard.overview.invalidate();

Route Guards & Navigation#

Route Required Role Redirect If Unauthenticated Redirect If Wrong Role
/dashboard CLIENT, EDITOR, VIEWER, ADMIN /login N/A
/dashboard/gbp CLIENT, EDITOR, VIEWER, ADMIN /login N/A
/dashboard/social CLIENT, EDITOR, VIEWER, ADMIN /login N/A
/dashboard/citations CLIENT, EDITOR, VIEWER, ADMIN /login N/A
/dashboard/site CLIENT, EDITOR, VIEWER, ADMIN /login N/A

Role-based UI adaptations (not route guards) are implemented client-side by checking session.user.role and conditionally rendering/hiding elements. VIEWER sees all screens but no action buttons. EDITOR sees action buttons except destructive/restricted ones.


End of Part 2#

Next: Part 3 covers Content & Leads, Reports & Settings, Admin Dashboards (KPI, Client Management, Infrastructure).