Frontend Specs
RankFlow AI — Frontend Specification Part 2
- Section 5 — Dashboard Overview
docs/specs/frontend/part-2.mdOn this page
- Design Token Compliance
- Section 5 — Dashboard Overview
- 5.1 KPI Overview (/dashboard)
- 5.2 Activity Feed
- 5.3 Quick Actions Panel
- Section 6 — GBP Management
- 6.1 GBP Dashboard (/dashboard/gbp)
- 6.2 Post Manager
- 6.3 Review Inbox
- 6.4 Insights Chart
- 6.5 Q&A Panel
- Section 7 — Social Media
- 7.1 Social Dashboard (/dashboard/social)
- 7.2 Account Manager
- 7.3 Post Composer & Scheduler
- 7.4 Content Calendar
- Section 8 — Citation Network
- 8.1 Citation Dashboard (/dashboard/citations)
- 8.2 Directory Grid
- 8.3 NAP Health Monitor
- 8.4 Submission Tracker
- Section 9 — Site Monitor
- 9.1 Site Dashboard (/dashboard/site)
- 9.2 Section Editor
- 9.3 Template Gallery
- 9.4 Publish Control
- Data Fetching Patterns (Part 2 Specific)
- React Query Configuration
- Mutation Invalidation Patterns
- Route Guards & Navigation
- End of Part 2
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 Cardfrom 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:>=80→forest-teal(healthy),>=50→amber(warning),<50→error-red(critical). - GBP Activity Status:
postsThisMonthcompared to plan limit (e.g., 8 for Basic plan).postsThisMonth / planLimit * 100for progress bar. - Social Reach:
totalReachis platform-aggregated. IftopPlatform === "NONE", show "No active posts" placeholder. - Site Traffic:
avgSessionDurationformatted asMM:SS(e.g., "2:34").bounceRateformatted asX.X%. - Recent Activity: Sorted by
createdAtdescending. Truncated to 5 items.statusmapped 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:
Cardcomponent, full-width within the dashboard. - Header: "Recent Activity" + "View All" link (navigates to full activity log).
- Table: 5 rows max, 4 columns:
- Action: Icon + description (e.g., "GBP post published: 'New dental services'")
- Module: Badge color-coded by module (Citation =
sky-blue, GBP =forest-teal, Social =lavender, Site =ember-orange, Content =mint) - Status: Badge —
SUCCESS(green dot),PENDING(amber dot),FAILED(red dot),WARNING(amber dot) - 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-fnsformatDistanceToNow(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:
SUCCESS→forest-tealbadge,PENDING→amberbadge,FAILED→error-redbadge,WARNING→amberbadge. - 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:
Buttoncomponent,variant: secondary,size: md. - Each button: Icon (20px) + label, stacked vertically (icon above, text below). Mobile: horizontal (icon left, text right).
- Buttons:
- "New GBP Post" —
MapPinicon,color: forest-teal - "New Social Post" —
Share2icon,color: lavender - "Run Citation Check" —
Linkicon,color: sky-blue - "View Report" —
BarChart3icon,color: deep-indigo
- "New GBP Post" —
- Hover: Background shifts to
pale-cyan(#C1E8EF), icon color intensifies. - Disabled: If module disconnected, button shows
fogcolor, 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.overviewquery.
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:
isConnectedderived fromgbp.listAccountsreturning non-empty array. - Location status: Color mapping —
PUBLISHED→forest-teal,SUSPENDED→error-red,VERIFICATION_REQUIRED→amber,CLOSED→slate. - 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:
PUBLISHED→forest-teal,SCHEDULED→amber,DRAFT→slate,FAILED→error-red,PENDING_APPROVAL→sky-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: amberfor filled,color: mistfor 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:
POSITIVE→forest-teal,NEUTRAL→sky-blue,NEGATIVE→amber,CRITICAL→error-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
aiReplySuggestedpresent, pre-fill textarea with suggestion. User can edit before sending. - Flagging: If
rating <= 2ANDreplyStatus === "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:
upvoteswith thumb-up icon. Ifupvotes > 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 →
Facebookicon, Instagram →Instagramicon, LinkedIn →Linkedinicon, Twitter →Twittericon. - 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.NumberFormatwith compact notation.1234→ "1.2K",1234567→ "1.2M". - Token expiry warning: If
tokenExpiresAtis 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) orsocial.schedulePosts(batch) - Input:
CreateSocialPostInputorSchedulePostsInput - Output:
SocialPostor{ 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
#wordfrom content. Shows as chips below textarea. Click to remove. - Hashtag suggestions: Based on content keywords + medical niche.
content.generatewithtaskType: "HASHTAG_SUGGESTIONS". - Media preview: Thumbnails in grid. Click to remove. Drag to reorder.
- Link preview: If
linkUrlpresent, fetch OpenGraph data (title, image, description). Show preview card. - Schedule validation:
scheduledFormust 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-cyanbackground. - Drag to reschedule: Drag post from one date to another. Confirmation modal on drop.
3. Data Source:
- tRPC endpoint:
social.listPostswith 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
scheduledFordate. 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.createPostwith updatedscheduledFor(or dedicatedsocial.rescheduleendpoint 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 / totalDirectoriespercentage - Progress bar: Green (verified), amber (pending), red (failed)
- "Last verified: 2 days ago"
- Big number:
- 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
- Green width:
- Directory grouping: Grouped by category: "Medical" (priority for medical clients), "Local", "General", "Industry", "Social".
- NAP snapshot comparison: If
napSnapshotexists, 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_SUBMITTED→slate,PENDING→amber,SUBMITTED→sky-blue,VERIFIED→forest-teal,FAILED→error-red,REJECTED→error-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 >= 50→forest-tealbadge,da >= 30→amberbadge,da < 30→slatebadge. - 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:
- Directory — Name + domain
- Name — NAP snapshot name. Mismatch = red text + warning icon.
- Address — NAP snapshot address. Mismatch = red text + warning icon.
- Phone — NAP snapshot phone. Mismatch = red text + warning icon.
- Website — NAP snapshot website. Mismatch = red text + warning icon.
- Match Score — Percentage (4 fields, each 25%). Green >= 100%, amber >= 75%, red < 75%.
- Last Checked — Relative time.
- 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(withnapSnapshot) +practice.get(for canonical NAP) citation.verifyNapfor 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 haserror-redtext +AlertTriangleicon tooltip: "Does not match canonical name." - Summary banner:
consistentCount / totalChecked * 100for overall percentage. IfmismatchCount > 0, banner is amber. IfmismatchCount === 0, banner is green. - Sort: Default sort by
matchScoreascending (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:
- Directory — Name
- Status — Badge + spinner if pending
- Submitted — Date/time
- Verified — Date/time or "—"
- Failed Reason — Tooltip if failed
- Job ID — Collapsible, links to job monitor
- 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_SUBMITTED→PENDING→SUBMITTED→VERIFIEDorFAILED. - Progress bar: For
PENDINGstatus, show indeterminate progress bar. ForSUBMITTED, show "Waiting for verification". - Failed reason tooltip: If
failedReasonpresent, 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
jobIdpresent, 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:
siteUrlishttps://{subdomain}.rankflow.aiorhttps://{customDomain}if set. - Status badge:
PUBLISHED→forest-teal,UNPUBLISHED→amber,PENDING→sky-blue. - Section order: Sorted by
sortOrder. Drag-and-drop reordering updatessortOrdervalues. - 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.
mediaUrlspopulated 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.generatewithtaskType: "LANDING_PAGE_SECTION"andsectionKey. 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 |
✅ | ✅ | ✅ | ✅ | ✅ |
9.3 Template Gallery#
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) orFAILED(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).