Frontend Specs
RankFlow AI — Frontend Specification Part 3
- Section 9 — Content & Leads Dashboard
docs/specs/frontend/part-3.mdOn this page
Version: 1.0.0
Date: 2026-06-13
Scope: Sections 9–12 — Client Dashboards (Content, Leads, Reports, Settings) + Admin Dashboards (KPI, Client Management, Infrastructure)
Author: Senior Frontend Specification Writer
Stack: Next.js 14 + tRPC + Prisma + Tailwind CSS + shadcn/ui
Design Tokens: docs/brand/tokens/design-tokens.json
Source Documents: admin-dashboard-ui.md, business_flow_map.md, backend-api.md, TEST-admin-observability.md, job-queue-workflows.md, ai-services.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: 600 (semibold) for headings only. Body text: 400 or 500. |
| 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. No radial gradients. Subtle linear gradients are permitted only in chart fills (e.g., area chart underlay) using series-1 (#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. border-radius.sm (2px) for table cells. |
| Shadows | shadow.subtle for cards. shadow.sm for dropdowns. shadow.xl for modals. No custom shadows. |
| Icons | Lucide family, stroke-width: 1.5px, size-md: 20px. |
Section 9 — Content & Leads Dashboard#
9.1 Content Dashboard (/dashboard/content)#
Layout: Client shell (src/app/(dashboard)/layout.tsx) with sidebar navigation. Main content area uses full-width table + split-pane editor.
Role Access: CLIENT (full CRUD), EDITOR (approve/reject/edit, no delete), VIEWER (read-only).
9.1.1 Content List Screen (/dashboard/content)#
1. Screen Name & Route + Role Access
| Route | Component | Role Access |
|---|---|---|
/dashboard/content |
ContentListPage |
CLIENT (full), EDITOR (edit/approve only), VIEWER (read-only) |
2. Data to Show
| Field | Type | Format | Source |
|---|---|---|---|
id |
string |
CUID | content.list |
title |
string |
1–200 chars, truncated at 48 chars in table | content.list |
type |
enum |
GBP_POST / SOCIAL_POST / LANDING_PAGE_ARTICLE / FAQ / BLOG_POST / REVIEW_REPLY |
content.list |
status |
enum |
DRAFT / PENDING_REVIEW / APPROVED / PUBLISHED / REJECTED / FAILED |
content.list |
createdAt |
Date |
DD MMM YYYY (e.g., 13 Jun 2026) |
content.list |
scheduledFor |
Date |
DD MMM YYYY, HH:MM or — if null |
content.list |
author |
string |
AI (with provider icon) or user name |
content.list (aiGenerated, aiProvider) |
aiProvider |
string |
anthropic / openai / null |
content.list |
costUsd |
number |
$0.000 to $9.999, rounded to 3 decimals |
content.list |
seoScore |
number |
0–100, integer |
content.list |
readabilityScore |
number |
0–100, integer |
content.list |
autoPublishAt |
Date |
Countdown target for PENDING_REVIEW |
content.list |
practiceId |
string |
CUID (implicit via practiceProcedure) |
content.list |
Status Badge Color Mapping (Design Token Compliant)
| Status | Badge Color | Token | Text |
|---|---|---|---|
DRAFT |
Gray | color.semantic.text-disabled (#A9ACB6) on color.semantic.surface-canvas |
Draft |
PENDING_REVIEW |
Amber | color.semantic.action-accent (#EC652B) on color.semantic.text-inverse |
Pending Review |
APPROVED |
Green | color.semantic.success (#167E6C) on color.semantic.text-inverse |
Approved |
PUBLISHED |
Blue | color.semantic.info (#023247) on color.semantic.text-inverse |
Published |
REJECTED |
Red | color.semantic.error (#C2442A) on color.semantic.text-inverse |
Rejected |
FAILED |
Red | color.semantic.error (#C2442A) on color.semantic.text-inverse |
Failed |
3. Display Pattern
- Component type:
DataTable(src/components/admin/DataTable.tsx) adapted for client dashboard with reduced density. - Layout: Full-width table (
w-full) withinpage-max-width(1200px) container. Top filter bar (FilterBar) with left-aligned filters and right-aligned search. - Table columns: 8 columns with responsive hiding: on tablet (
<1024px), hidecostUsdandseoScore; on mobile (<768px), render card list view (CardListcomponent) with vertical stacking. - Row height: 56px. Alternating row background:
surface-canvasandsurface-card. - Bulk selection: Checkbox column (first). Shift-click for range select.
indeterminatestate on header checkbox. - Empty state:
EmptyStatecomponent withFileTexticon (Lucide), title "No content yet", description "AI-generated content will appear here once your first posts are created.", primary CTA "View Approval Queue" (if queue items exist) or ghost CTA "Learn More". - Pagination: 25 rows per page default. Page size selector:
[10, 25, 50, 100]. Bottom-aligned pagination bar.
4. Backend Endpoint
// tRPC query
content.list
// Input schema (Zod)
const listContentSchema = z.object({
status: z.enum(["DRAFT", "PENDING_REVIEW", "APPROVED", "PUBLISHED", "REJECTED", "FAILED"]).optional(),
type: z.enum(["GBP_POST", "SOCIAL_POST", "LANDING_PAGE_ARTICLE", "FAQ", "BLOG_POST", "REVIEW_REPLY"]).optional(),
});
// Auth: practiceProcedure (CLIENT, EDITOR, VIEWER roles)
// Returns: ContentPiece[]
5. Fetch Frequency
| Event | Interval | Strategy |
|---|---|---|
| Initial load | Once | useQuery with staleTime: 60_000 |
| Polling | 60 seconds | refetchInterval: 60_000 |
| Window focus | Event-driven | refetchOnWindowFocus: true |
| After mutation | Immediate | utils.content.list.invalidate() |
6. Data Manipulations
| Manipulation | Logic | Implementation |
|---|---|---|
| Client-side search | Filter title by case-insensitive substring match |
useMemo with debounce(300ms) on search input |
| Status filter | Exact match on status enum |
Dropdown filter; multi-select NOT supported (single status only) |
| Type filter | Exact match on type enum |
Dropdown filter; multi-select NOT supported |
| Date range filter | createdAt >= startDate && createdAt <= endDate |
DateRangePicker component; client-side filtering after fetch |
| Sort by date | Descending createdAt default; toggle ascending |
Table header click with sortState |
| Sort by title | Alphanumeric ascending | Table header click |
| Bulk actions | Array of selected ids passed to mutation |
useState<string[]>(selectedIds) |
| AI provider icon | Map anthropic → Brain, openai → Sparkles (Lucide) |
Client-side mapping |
7. Why Structured This Way
- Polling at 60s: Content status changes frequently as AI generation completes and approval timers expire. 60s balances freshness with server load.
staleTimeprevents redundant fetches during active editing. - Client-side search/filtering: The content volume per practice is bounded (< 200 items/month). Client-side filtering is faster than round-trip API calls and avoids tRPC query key complexity.
- Card list on mobile: Tables are unreadable on mobile. Card view shows title, status badge, and date in a vertical stack with swipe actions.
- No bold weight: Title column uses
font-weight: 500(medium) atfont-size: body-sm(14px) for visual hierarchy without bold.
8. Interaction Flows
User lands on /dashboard/content
→ content.list query fires
→ Skeleton table renders (8 rows × 6 columns)
→ Data resolves → table populates
User types in search box
→ debounce 300ms
→ client-side filter applies
→ row count updates in pagination
User clicks status filter "PENDING_REVIEW"
→ filter applied client-side
→ only PENDING_REVIEW rows visible
→ bulk action bar appears if rows > 0
User selects 3 rows via checkboxes
→ bulk action bar slides in (bottom-fixed on mobile, inline on desktop)
→ actions: "Approve All", "Delete All" (CLIENT only)
User clicks "Approve All"
→ ConfirmDialog opens: "Approve 3 items?"
→ On confirm: loop through selectedIds, call content.approve.mutate(id)
→ invalidate content.list on each success
→ Toast: "3 items approved"
User clicks row title
→ navigates to /dashboard/content/[id] (Content Editor)
9. Error States
| State | Trigger | UI Treatment | Recovery |
|---|---|---|---|
| Loading | Initial fetch, refetch | SkeletonTable (8 rows, 6 columns) |
Auto-resolves on data |
| Empty | content.list returns [] |
EmptyState with icon + text + CTA |
N/A — valid state |
| Filter empty | Active filters match 0 rows | EmptyState with "No content matches filters" + "Clear Filters" button |
Click clears all filters |
| tRPC error | Network/server failure | Inline error banner: "Failed to load content." + "Retry" button (ember-orange) | Click retry re-fetches |
| Partial error | Bulk action: 2/3 succeed | Toast: "2 approved, 1 failed" + link to failed item | Manual retry on failed item |
10. Role-Based Variations
| Role | What They See | What They Can Do |
|---|---|---|
CLIENT |
Full table, all action buttons, bulk actions, delete option | Approve, reject, edit, delete, bulk approve, bulk delete, bulk schedule |
EDITOR |
Full table, approve/reject/edit buttons, no delete column | Approve, reject, edit. Cannot delete. Bulk approve only. Cannot bulk delete. |
VIEWER |
Full table, no action buttons, no checkboxes, no bulk bar | Read-only. No inline actions. No edit access. |
9.1.2 Content Editor Screen (/dashboard/content/[id])#
1. Screen Name & Route + Role Access
| Route | Component | Role Access |
|---|---|---|
/dashboard/content/[id] |
ContentEditorPage |
CLIENT (full), EDITOR (edit/approve), VIEWER (read-only preview) |
2. Data to Show
| Field | Type | Format | Source |
|---|---|---|---|
id |
string |
CUID | content.get |
title |
string |
Editable in textarea | content.get |
content |
string |
HTML/Markdown rendered in preview | content.get |
status |
enum |
Current state machine position | content.get |
type |
enum |
Determines preview template | content.get |
seoTitle |
string |
50–60 chars, editable | content.get |
seoDescription |
string |
150–160 chars, editable | content.get |
focusKeywords |
string[] |
Array of tags, add/remove | content.get |
aiGenerated |
boolean |
Determines if AI badge shown | content.get |
aiProvider |
string |
Badge icon + name | content.get |
aiModel |
string |
Sub-label under provider | content.get |
aiTokensUsed |
number |
Integer with comma separator | content.get |
costUsd |
number |
$0.000 format |
content.get |
seoScore |
number |
0–100, color-coded | content.get |
readabilityScore |
number |
0–100, color-coded | content.get |
humanEdited |
boolean |
Timestamp if true | content.get |
createdAt |
Date |
DD MMM YYYY, HH:MM |
content.get |
updatedAt |
Date |
DD MMM YYYY, HH:MM |
content.get |
autoPublishAt |
Date |
Countdown timer display | content.get |
scheduledFor |
Date |
Calendar picker default | content.get |
ctaType |
enum |
BOOK / CALL / LEARN_MORE / SIGN_UP / ORDER |
content.get + gbp.createPost schema |
AI Intelligence Score Badge
| Score Range | Badge Color | Label |
|---|---|---|
| 90–100 | series-1 (#167E6C) |
Excellent |
| 70–89 | series-4 (#44B48B) |
Good |
| 50–69 | series-2 (#7EA7E9) |
Average |
| 0–49 | series-3 (#9F7AEE) |
Needs Work |
The intelligence score is computed client-side as a weighted average:
intelligenceScore = (seoScore * 0.4) + (readabilityScore * 0.4) + (humanEdited ? 10 : 0) + (focusKeywords.length > 0 ? 10 : 0);
3. Display Pattern
- Component type: Split-pane layout (
ResizablePanelGroupfrom shadcn/ui). - Layout: Left pane (60% default, min 40%, max 75%) contains editor; right pane contains preview.
- Editor pane:
Textareafor title (2 rows, max 200 chars).RichTextEditor(TipTap or Plate) for content body. Toolbar: bold (note: backend stores<strong>, but UI button usesfont-weight: 600semibold icon), italic, link, heading, bullet list, numbered list.Inputfor SEO title (max 60 chars, char counter).Textareafor SEO description (max 160 chars, char counter).TagInputfor focus keywords (max 10 tags, comma-separated entry).Selectfor CTA type: options fromgbp.createPostschema enum:BOOK,CALL,LEARN_MORE,SIGN_UP,ORDER.
- Preview pane:
iframeordivwithdangerouslySetInnerHTMLfor rendered preview. CSS scoped to prevent global leakage.- Template varies by
type:GBP_POST→ GBP card preview;SOCIAL_POST→ Instagram/Facebook card preview;LANDING_PAGE_ARTICLE→ article preview. - Mobile preview toggle: desktop / tablet / mobile buttons above iframe.
- Status timeline: Vertical stepper below editor showing state machine:
DRAFT→PENDING_REVIEW(with timer) →APPROVED→PUBLISHED. Current step highlighted withseries-1(#167E6C). - Timer display: For
PENDING_REVIEW, countdown widget showingHH:MM:SSuntilautoPublishAt. Timer color:series-2(#7EA7E9) if > 4h,action-accent(#EC652B) if < 4h,error(#C2442A) if < 1h. - Action bar: Fixed bottom bar with primary actions:
Save(ghost),Approve & Publish(accent, ember-orange),Reject(error),Schedule(info). On mobile: collapsible into floating action button (FAB) menu.
4. Backend Endpoint
// tRPC query
content.get
// Input schema
const getContentSchema = z.object({
id: z.string(),
});
// Auth: practiceProcedure
// Returns: ContentPiece
// tRPC mutation: update
content.update
// Input schema
const updateContentSchema = z.object({
id: z.string(),
title: z.string().optional(),
content: z.string().optional(),
seoTitle: z.string().optional(),
seoDescription: z.string().optional(),
focusKeywords: z.array(z.string()).optional(),
});
// tRPC mutation: approve
content.approve
// Input schema
const approveContentSchema = z.object({
id: z.string(),
});
// tRPC mutation: reject
content.reject
// Input schema
const rejectContentSchema = z.object({
id: z.string(),
reason: z.string().optional(),
});
5. Fetch Frequency
| Event | Interval | Strategy |
|---|---|---|
| Initial load | Once | useQuery with content.get({ id }) |
| Timer refresh | Every 1 second | setInterval for countdown display (client-side only) |
| Content save | On mutation | Invalidate content.get and content.list |
| Background refresh | 60 seconds | refetchInterval: 60_000 for status updates from AI pipeline |
6. Data Manipulations
| Manipulation | Logic | Implementation |
|---|---|---|
| Timer calculation | autoPublishAt - Date.now() |
Client-side setInterval(1000) with useState |
| Timer expiry auto-approve | When countdown reaches 0 | If status === PENDING_REVIEW and timer expires, client auto-calls content.approve mutation. Server also has cron fallback. |
| Medical compliance timer | 24h mandatory for CLINIC/HOSPITAL/DOCTOR |
Client reads practice.type from practice.get. If medical, timer minimum is 24h. Timer display shows "Medical compliance: 24h required". |
| Review star auto-rules | Auto-approve: 4-5 stars; Manual: 1-2 stars; 3-star: 24h timer | Client-side logic: if type === REVIEW_REPLY and reviewRating present, show appropriate badge and timer rules. |
| CTA dropdown | Options from gbp.createPost schema enum |
Hardcoded array mapped to schema: [{ value: "BOOK", label: "Book Appointment" }, ...] |
| Intelligence score | Weighted average (formula above) | Client-side compute on every render |
| Char counter | Real-time length vs max | Input + span showing ${current}/${max} |
| HTML preview sanitization | DOMPurify on content before dangerouslySetInnerHTML |
DOMPurify.sanitize(content, { ALLOWED_TAGS: [...] }) |
7. Why Structured This Way
- Split pane: Content editors need immediate visual feedback. The 60/40 split is the industry standard for CMS interfaces. Resizable panels accommodate different screen sizes and personal preferences.
- Medical compliance 24h timer: Indian medical advertising regulations (MCI guidelines) require human oversight for all patient-facing content. The 24h gate is non-negotiable for
CLINIC/HOSPITAL/DOCTORtypes. The timer is client-displayed but server-enforced (the server cron also auto-approves after 24h). - Auto-approve by star rating: 4-5 star reviews are low-risk; auto-approval reduces friction. 1-2 star reviews are high-risk and require manual oversight. 3-star reviews are ambiguous and get the 24h window.
- Timer resets on edit: Editing content resets the approval timer because the content has changed and requires re-review. This is communicated via a toast: "Timer reset: content modified."
- Rich text editor: Medical content often requires formatting (bullet lists for services, links for bookings). TipTap provides a lightweight, extensible solution.
8. Interaction Flows
User navigates to /dashboard/content/content_123
→ content.get({ id: "content_123" }) fires
→ Split pane renders with editor (left) and preview (right)
→ Status timeline shows current position
→ If PENDING_REVIEW: countdown timer starts (1s interval)
User edits title
→ onChange updates local state
→ Preview pane re-renders with new title
→ "Unsaved changes" indicator appears in action bar
User clicks "Save"
→ content.update.mutate({ id, title, content, seoTitle, seoDescription, focusKeywords })
→ Optimistic UI: no loading state on content itself
→ On success: toast "Saved"; timer resets if status was PENDING_REVIEW
→ On error: toast "Save failed"; content reverts to server state
User clicks "Approve & Publish"
→ If medical client and timer < 24h: block with modal "Medical compliance requires 24h review."
→ If non-medical or timer >= 24h: confirm dialog "Publish now?"
→ On confirm: content.approve.mutate({ id })
→ Status transitions to APPROVED → PUBLISHED (server pushes to platform)
→ Toast: "Published to Google Business Profile"
→ Redirect to /dashboard/content
User clicks "Reject"
→ Modal opens with textarea for rejection reason
→ User enters reason, clicks "Reject"
→ content.reject.mutate({ id, reason })
→ Status transitions to REJECTED
→ Toast: "Content rejected. AI will regenerate."
→ Redirect to /dashboard/content
Timer reaches 0:00:00
→ Client auto-fires content.approve.mutate({ id })
→ If mutation fails, show error banner: "Auto-approve failed. Please approve manually."
→ Server cron will retry within 5 minutes as fallback
9. Error States
| State | Trigger | UI Treatment | Recovery |
|---|---|---|---|
| Loading | content.get pending | Skeleton split pane: left shows 6 text skeletons, right shows 1 large skeleton | Auto-resolves |
| Not found | id doesn't exist |
Full-page error: "Content not found" with back link to /dashboard/content | Navigate back |
| tRPC error | content.get fails | Error banner in editor pane: "Failed to load content." + Retry button | Retry re-fetches |
| Save conflict | Concurrent edit detected | Toast: "Content was modified by another session. Reload?" + Reload button | Click reload refreshes |
| Timer expiry failure | Auto-approve mutation fails | Banner: "Auto-approve failed." + "Approve Manually" button (ember-orange) | Manual approve |
| Medical compliance block | Approve clicked before 24h | Modal: "Medical compliance requires 24h review." + "Understood" button | Wait for timer |
| HTML sanitization error | DOMPurify rejects content | Fallback: render as plain text in <pre> tag |
Edit content to fix |
10. Role-Based Variations
| Role | Editor Access | Actions Available | Timer Visibility |
|---|---|---|---|
CLIENT |
Full read/write on all fields | Save, Approve, Reject, Schedule, Delete | Full countdown + manual override (if non-medical) |
EDITOR |
Edit title/content/SEO fields | Save, Approve, Reject. No delete. No schedule override. | Full countdown. Cannot override medical timer. |
VIEWER |
Read-only. Editor pane disabled. | None. Preview only. | Read-only timer display. No action buttons. |
9.1.3 Content Calendar Screen (/dashboard/content/calendar)#
1. Screen Name & Route + Role Access
| Route | Component | Role Access |
|---|---|---|
/dashboard/content/calendar |
ContentCalendarPage |
CLIENT (full), EDITOR (view + reschedule), VIEWER (read-only) |
2. Data to Show
| Field | Type | Format | Source |
|---|---|---|---|
id |
string |
CUID | Derived from content.list |
title |
string |
Truncated to 30 chars in card | content.list |
type |
enum |
Determines card color | content.list |
status |
enum |
Determines card border | content.list |
scheduledFor |
Date |
Card position on calendar grid | content.list |
platform |
string |
Icon on card (GBP, Instagram, Facebook, LinkedIn, Twitter) | Derived from type + socialAccount.platform |
Platform Color Coding
| Platform | Card Border | Token |
|---|---|---|
GBP |
series-1 (#167E6C) |
Green |
Instagram |
series-3 (#9F7AEE) |
Lavender |
Facebook |
series-2 (#7EA7E9) |
Sky blue |
LinkedIn |
series-5 (#023247) |
Midnight teal |
Twitter/X |
series-4 (#44B48B) |
Mint |
Blog |
slate (#7C7F88) |
Slate |
3. Display Pattern
- Component type: Custom calendar grid (
CalendarGrid) + day cells. - Layout: Full-width. Header row: month/year selector (left), view toggle
Week | Month(right), "Today" button (center-right). - Month view: 7-column grid (Mon–Sun). Day cells are 120px min-height. Each cell shows day number (top-right,
font-size: caption,text-tertiary). - Week view: 7-column grid with 30-minute time slots (08:00–20:00 IST). Vertical scroll.
- Cards: Rounded
radius.lg(8px), 1px left border (4px thick) using platform color. White background. Title textfont-size: caption, single line, truncated. Status dot (8px circle) top-right of card. - Drag-drop: Cards are draggable. On drag start, card opacity reduces to 60%. On drop over a day cell, cell background changes to
info-soft(#C1E8EF). On drop,content.updatemutation fires with newscheduledFor. - Today indicator: Current day cell has
background-color: info-soft(#C1E8EF) with "Today" label in top-left. - Empty state: For days with no content, cell shows dashed border (
border-dashed) and "+" icon on hover (CLIENT/EDITOR only). Clicking opensContentCreateModalpre-filled with that date. - Overflow: If a day has > 3 items, show "+2 more" link that expands the cell or opens a modal with that day's items.
4. Backend Endpoint
// No dedicated calendar endpoint exists in backend-api.md.
// Frontend uses content.list with client-side filtering and grouping.
content.list
// Input: { status?: "PENDING_REVIEW" | "APPROVED" | "PUBLISHED" }
// Auth: practiceProcedure
// Returns: ContentPiece[]
// Frontend filters: items with scheduledFor != null
// Frontend groups: by date (YYYY-MM-DD)
// Frontend sorts: by scheduledFor ascending
Backend gap: content.getCalendar endpoint does not exist. Frontend implements calendar view by filtering content.list results. Recommended backend addition: content.getCalendar({ month, year }) returning pre-grouped data.
5. Fetch Frequency
| Event | Interval | Strategy |
|---|---|---|
| Initial load | Once | content.list with status: undefined (all statuses) |
| Polling | 5 minutes | refetchInterval: 300_000 |
| After reschedule | Immediate | Invalidate and re-fetch |
| Month navigation | On change | Filter existing client-side data; no new fetch if month already loaded |
6. Data Manipulations
| Manipulation | Logic | Implementation |
|---|---|---|
| Date grouping | Group by YYYY-MM-DD |
useMemo with Map<string, ContentPiece[]> |
| Time slot mapping | Map scheduledFor to 30-min slot |
Math.floor(minutes / 30) * 30 |
| Drag-drop reorder | Update scheduledFor to target date |
onDragEnd → content.update.mutate({ id, scheduledFor: newDate }) |
| Month filtering | Filter content.list by month |
new Date(item.scheduledFor).getMonth() === currentMonth |
| Platform color | Map type to platform color |
Client-side object map |
| Overflow count | If items.length > 3, show +${n} |
items.length - 3 |
7. Why Structured This Way
- 5-minute polling: Calendar data changes less frequently than the content list. Posts are scheduled days/weeks in advance. 5 minutes is sufficient.
- Client-side month filtering: Fetching all content items once per practice (bounded by plan limits: Starter=4 GBP/mo, Standard=8/mo, Premium=12/mo, Enterprise=20/mo) is lightweight. Month navigation is instant without API round-trips.
- Drag-drop rescheduling: Direct manipulation is faster than opening an edit modal and changing a date field. The visual grid provides spatial context for scheduling density.
- Platform color coding: At-a-glance identification of content mix. Medical clients need to see if they have sufficient GBP vs. social content.
- 30-minute slots: Social posts and GBP posts are scheduled at specific times (e.g., 9:00 AM, 10:00 AM). 30-minute granularity provides enough precision without clutter.
8. Interaction Flows
User navigates to /dashboard/content/calendar
→ content.list fires (all content with scheduledFor)
→ Calendar grid renders with current month
→ Cards positioned on their scheduledFor dates
User clicks "Month" → "Week" toggle
→ View switches to 7-day week view with time slots
→ Same cards repositioned vertically by time
User drags a card from June 15 to June 20
→ Card follows cursor with 60% opacity
→ June 20 cell highlights with pale-cyan background
→ On drop: content.update.mutate({ id, scheduledFor: "2026-06-20T09:00:00Z" })
→ Optimistic UI: card moves immediately
→ On success: toast "Rescheduled to June 20"
→ On error: card snaps back to original position; toast "Reschedule failed"
User clicks empty day cell
→ If CLIENT/EDITOR: ContentCreateModal opens with scheduledFor pre-filled
→ If VIEWER: no action
User clicks "+2 more" on a crowded day
→ DayDetailModal opens with all items for that day
→ Each item shows full title, status, platform, time
→ Actions: Edit, Reschedule, Delete (CLIENT only)
User clicks "Today" button
→ Calendar scrolls to current week/month
→ Today cell is highlighted
9. Error States
| State | Trigger | UI Treatment | Recovery |
|---|---|---|---|
| Loading | content.list pending | Skeleton calendar: 7 columns × 5 rows of empty cells with dashed borders | Auto-resolves |
| Empty month | No scheduled content for month | Empty state overlay: "No content scheduled for June 2026" + "Schedule First Post" CTA (ember-orange) | Navigate to content list |
| Drag-drop error | content.update fails | Card snaps back to original position with shake animation (300ms). Toast: "Reschedule failed — please retry." | Retry drag or use edit modal |
| tRPC error | content.list fails | Full-page error: "Failed to load calendar." + Retry button | Retry re-fetches |
10. Role-Based Variations
| Role | Drag-Drop | Create from Cell | Day Detail Actions |
|---|---|---|---|
CLIENT |
Enabled | Enabled | Edit, reschedule, delete |
EDITOR |
Enabled | Enabled | Edit, reschedule (no delete) |
VIEWER |
Disabled | Disabled | View only. No actions. |
9.1.4 Approval Queue Widget (/dashboard/content + /dashboard)#
1. Screen Name & Route + Role Access
| Route | Component | Role Access |
|---|---|---|
Embedded in /dashboard/content and /dashboard |
ApprovalQueueWidget |
CLIENT (full actions), EDITOR (approve/reject), VIEWER (read-only list) |
2. Data to Show
| Field | Type | Format | Source |
|---|---|---|---|
pendingCount |
number |
Badge on widget header | Client-side count from content.list filtered by status: PENDING_REVIEW |
items |
ContentPiece[] |
Max 5 items shown, truncated | content.list with status: PENDING_REVIEW |
item.id |
string |
CUID | content.list |
item.title |
string |
Truncated to 40 chars | content.list |
item.type |
enum |
Icon + label | content.list |
item.autoPublishAt |
Date |
Countdown: HH:MM:SS |
content.list |
item.aiProvider |
string |
Icon | content.list |
3. Display Pattern
- Component type: Card (
component.cardtokens) with header + list body. - Layout: On
/dashboard, appears in right sidebar (300px wide) or as a top-row card in main grid. On/dashboard/content, appears as a collapsible panel above the table. - Header: "Approval Queue" +
pendingCountbadge (ember-orange if > 0, slate if 0). Right side: "View All" link. - List items: Each item is a horizontal row: status dot (amber), title (medium weight, 14px), type icon, countdown timer (
font-family: mono, 12px), action buttons. - Actions per item:
Approve(check icon, ember-orange),Reject(X icon, error-red),Edit(pencil icon, graphite). VIEWER sees no action buttons. - Timer display: Real-time countdown. Color transitions:
graphite(> 8h),action-accent(4h–8h),error(< 4h). - Empty state: "Queue is clear. All content approved." with
CheckCircleicon (series-1color). - Max items: 5 items visible. If > 5, "+N more" link to
/dashboard/content?status=PENDING_REVIEW.
4. Backend Endpoint
// No dedicated queue endpoint. Uses content.list with filter.
content.list
// Input: { status: "PENDING_REVIEW" }
// Auth: practiceProcedure
// Returns: ContentPiece[] (filtered to pending review)
// Mutations for actions:
content.approve
content.reject
content.update
Backend gap: content.getQueue does not exist. Frontend filters content.list client-side. Recommended addition: content.getQueue with pagination and pre-sorted by autoPublishAt.
5. Fetch Frequency
| Event | Interval | Strategy |
|---|---|---|
| Initial load | Once | content.list({ status: "PENDING_REVIEW" }) |
| Polling | 60 seconds | refetchInterval: 60_000 |
| Timer display | 1 second | Client-side setInterval |
| After action | Immediate | Invalidate content.list |
6. Data Manipulations
| Manipulation | Logic | Implementation |
|---|---|---|
| Count badge | items.filter(i => i.status === PENDING_REVIEW).length |
useMemo on content.list data |
| Sort by urgency | autoPublishAt ascending (soonest first) |
useMemo with .sort() |
| Truncate title | Max 40 chars, ellipsis | title.slice(0, 40) + (title.length > 40 ? '...' : '') |
| Timer color | Based on remaining hours | Client-side conditional class |
| Max 5 display | .slice(0, 5) |
useMemo |
7. Why Structured This Way
- Widget on dashboard: The approval queue is the highest-priority client action. Embedding it on the dashboard homepage ensures visibility without requiring navigation. The 5-item limit prevents clutter.
- 60-second polling: Pending review items can transition to auto-publish at any time. The queue count must be accurate for the client to trust the system.
- Real-time countdown: The 24h medical compliance timer creates urgency. Real-time display (1s refresh) makes the time boundary tangible.
- Inline actions: Approve/reject without navigating to the editor reduces friction for simple content (e.g., a standard GBP post). For complex edits, the "Edit" button opens the full editor.
8. Interaction Flows
Widget mounts on /dashboard
→ content.list({ status: "PENDING_REVIEW" }) fires
→ List renders with pending items
→ Timers start counting down
User clicks "Approve" on first item
→ content.approve.mutate({ id })
→ Item slides out with 300ms exit animation
→ Remaining items animate up to fill gap
→ Count badge decrements
→ Toast: "Approved: {title}"
User clicks "Reject" on first item
→ Mini-modal opens: "Reason for rejection?" (optional textarea)
→ User clicks "Reject" in modal
→ content.reject.mutate({ id, reason })
→ Item slides out
→ Toast: "Rejected: {title}. AI will regenerate."
User clicks "Edit"
→ Navigates to /dashboard/content/[id]
→ Editor opens with full content
Timer reaches 0:00:00 on an item
→ Item automatically removed from queue (server updated status)
→ On next poll (max 60s delay), item disappears
→ Toast: "Auto-published: {title}"
9. Error States
| State | Trigger | UI Treatment | Recovery |
|---|---|---|---|
| Loading | Initial fetch | Skeleton: 3 rows of text + button skeletons | Auto-resolves |
| Empty | No pending items | "Queue is clear" with check icon | N/A |
| tRPC error | content.list fails | Inline error: "Queue unavailable." + retry icon | Retry click |
| Action error | approve/reject fails | Item remains in queue. Toast: "Action failed. Please retry." | Retry button on item |
10. Role-Based Variations
| Role | Header Badge | Actions | Edit Link |
|---|---|---|---|
CLIENT |
Full count, clickable | Approve, reject, edit | Enabled |
EDITOR |
Full count, clickable | Approve, reject, edit | Enabled |
VIEWER |
Full count, read-only | None. Read-only list. | Disabled (no click) |
9.2 Leads Dashboard (/dashboard/leads)#
9.2.1 Lead List Screen (/dashboard/leads)#
1. Screen Name & Route + Role Access
| Route | Component | Role Access |
|---|---|---|
/dashboard/leads |
LeadListPage |
CLIENT (full), EDITOR (view + status update), VIEWER (read-only) |
2. Data to Show
| Field | Type | Format | Source |
|---|---|---|---|
id |
string |
CUID | lead.list |
name |
string |
2–100 chars | lead.list |
email |
string |
Email format, masked partially on VIEWER | lead.list |
phone |
string |
+91XXXXXXXXXX format |
lead.list |
source |
string |
landing_page / gbp / social / citation / direct / referral |
lead.list |
status |
enum |
NEW / CONTACTED / QUALIFIED / CONVERTED / LOST |
lead.list |
createdAt |
Date |
DD MMM YYYY |
lead.list |
utmSource |
string |
e.g., google_ads, meta_ads, organic |
lead.list |
utmMedium |
string |
e.g., cpc, social, email |
lead.list |
assignedTo |
string |
User name or "Unassigned" | lead.list |
notes |
string |
Truncated to 50 chars in table | lead.list |
lastContactedAt |
Date |
DD MMM YYYY or Never |
lead.list |
Status Badge Color Mapping
| Status | Badge Color | Token | Text |
|---|---|---|---|
NEW |
Blue | color.semantic.info (#023247) |
New |
CONTACTED |
Amber | color.semantic.action-accent (#EC652B) |
Contacted |
QUALIFIED |
Green | color.semantic.success (#167E6C) |
Qualified |
CONVERTED |
Green | color.semantic.success (#167E6C) |
Converted |
LOST |
Red | color.semantic.error (#C2442A) |
Lost |
3. Display Pattern
- Component type:
DataTablewithFilterBar. - Layout: Full-width table. Top action bar: left side has "Add Lead" button (ember-orange, CLIENT only), filter dropdowns, date picker; right side has search input and "Export CSV" button (ghost, CLIENT only).
- Table columns: 10 columns (name, email, phone, source, status, date, assignedTo, notes preview, lastContactedAt, actions). Actions column: "View", "Edit" (CLIENT only), "Delete" (CLIENT only).
- Bulk actions: Checkbox column. Select multiple → bulk action bar appears with: "Update Status" (dropdown), "Delete" (CLIENT only), "Export Selected" (CLIENT only).
- Source icons:
landing_page→Globe,gbp→MapPin,social→Share2,citation→Link,direct→ArrowRight,referral→Users(all Lucide). - Responsive: Tablet hides
notesandlastContactedAt. Mobile shows card list with name, status, source, date. - Pagination: 25 rows default. Page size selector.
4. Backend Endpoint
// tRPC query
lead.list
// Input schema
const listLeadsSchema = z.object({
status: z.enum(["NEW", "CONTACTED", "QUALIFIED", "CONVERTED", "LOST"]).optional(),
});
// Auth: practiceProcedure
// Returns: Lead[]
// tRPC mutation: update status
lead.updateStatus
// Input schema
const updateLeadStatusSchema = z.object({
id: z.string(),
status: z.enum(["NEW", "CONTACTED", "QUALIFIED", "CONVERTED", "LOST"]),
});
// tRPC mutation: delete
lead.delete
// Input schema
const deleteLeadSchema = z.object({
id: z.string(),
});
Backend gap: lead.create does not exist in backend-api.md. Frontend requires this for manual lead entry. lead.export does not exist for CSV export. lead.update (full update) does not exist — only lead.updateStatus is available.
5. Fetch Frequency
| Event | Interval | Strategy |
|---|---|---|
| Initial load | Once | useQuery with staleTime: 60_000 |
| Polling | 60 seconds | refetchInterval: 60_000 |
| After mutation | Immediate | utils.lead.list.invalidate() |
| Window focus | Event-driven | refetchOnWindowFocus: true |
6. Data Manipulations
| Manipulation | Logic | Implementation |
|---|---|---|
| Client-side search | Case-insensitive match on name + email |
useMemo with debounce(300ms) |
| Status filter | Exact match on status enum |
Dropdown filter |
| Source filter | Exact match on source string |
Dropdown filter |
| Date range filter | createdAt within range |
DateRangePicker + client-side filter |
| Sort by date | Descending default | Table header click |
| Sort by name | Alphanumeric ascending | Table header click |
| Bulk status update | Loop through selectedIds, call lead.updateStatus |
Promise.all with toast summary |
| CSV export | Convert selected/all rows to CSV blob | Client-side convertToCSV utility + URL.createObjectURL download |
| Source icon mapping | Map string to Lucide icon component | Client-side object map |
7. Why Structured This Way
- 60-second polling: Leads are high-value, time-sensitive events. A new lead from a landing page form needs immediate visibility. 60s ensures the client sees leads within a minute of creation.
- CSV export: Medical practices often need to export lead data for their own CRM or compliance records. Client-side export avoids server load for small datasets (< 1000 leads).
- Source tracking: UTM parameters (
utmSource,utmMedium) are critical for ROI analysis. The source column helps clients understand which marketing channel is driving leads. - Status pipeline:
NEW→CONTACTED→QUALIFIED→CONVERTED/LOSTis the standard B2B lead pipeline. The status dropdown in the table enables quick pipeline updates without opening a detail page. - Bulk actions: Clinics receive lead batches (e.g., after a social media campaign). Bulk status update and bulk export handle these efficiently.
8. Interaction Flows
User lands on /dashboard/leads
→ lead.list fires
→ Table renders with leads
→ "Add Lead" button visible (CLIENT only)
User clicks "Add Lead"
→ LeadFormModal opens
→ User fills name, email, phone, source, notes
→ Clicks "Save"
→ lead.create.mutate(data) (requires backend implementation)
→ On success: modal closes, lead.list invalidates, toast "Lead added"
→ On error: inline validation errors
User selects 3 leads via checkboxes
→ Bulk action bar appears
→ User selects "Update Status" → "QUALIFIED"
→ ConfirmDialog: "Mark 3 leads as Qualified?"
→ On confirm: Promise.all(lead.updateStatus.mutate for each)
→ Toast: "3 leads updated to Qualified"
→ Table rows update with green badges
User clicks "Export CSV"
→ Client-side CSV generation from current filtered rows
→ Blob download triggered
→ Filename: `rankflow-leads-2026-06-13.csv`
User clicks "Delete" on a row (CLIENT only)
→ ConfirmDialog: "Delete lead for {name}? This cannot be undone."
→ On confirm: lead.delete.mutate({ id })
→ Row slides out with exit animation
→ Toast: "Lead deleted"
9. Error States
| State | Trigger | UI Treatment | Recovery |
|---|---|---|---|
| Loading | lead.list pending | Skeleton table (8 rows) | Auto-resolves |
| Empty | No leads | EmptyState with Users icon: "No leads yet." + "Add Lead" (CLIENT) |
Add lead or wait |
| Filter empty | Filters match 0 | "No leads match filters" + "Clear Filters" | Clear filters |
| tRPC error | lead.list fails | Error banner + retry button | Retry |
| Create error | lead.create fails | Modal shows error message: "Failed to add lead." | Retry in modal |
| Export error | CSV generation fails | Toast: "Export failed. Please try again." | Retry export |
10. Role-Based Variations
| Role | Add Lead | Edit | Delete | Bulk Actions | Status Update | Export |
|---|---|---|---|---|---|---|
CLIENT |
Enabled | Enabled | Enabled | Enabled | Enabled | Enabled |
EDITOR |
Disabled | Disabled | Disabled | Bulk status update only | Enabled | Disabled |
VIEWER |
Hidden | Hidden | Hidden | Hidden | Hidden | Hidden |
9.2.2 Lead Detail Screen (/dashboard/leads/[id])#
1. Screen Name & Route + Role Access
| Route | Component | Role Access |
|---|---|---|
/dashboard/leads/[id] |
LeadDetailPage |
CLIENT (full), EDITOR (view + status update), VIEWER (read-only) |
2. Data to Show
| Field | Type | Format | Source |
|---|---|---|---|
id |
string |
CUID | lead.get |
name |
string |
2–100 chars | lead.get |
email |
string |
Clickable mailto link | lead.get |
phone |
string |
Clickable tel link (+91 prefix) |
lead.get |
source |
string |
Icon + label + UTM params | lead.get |
status |
enum |
Editable dropdown | lead.get |
createdAt |
Date |
DD MMM YYYY, HH:MM |
lead.get |
utmSource |
string |
e.g., google_ads |
lead.get |
utmMedium |
string |
e.g., cpc |
lead.get |
utmCampaign |
string |
e.g., kerala-dental-june |
lead.get |
landingPageUrl |
string |
Clickable link to landing page | lead.get |
notes |
string |
Rich text, editable | lead.get |
assignedTo |
string |
User name or "Unassigned" | lead.get |
contactHistory |
ContactEvent[] |
Timeline of calls, emails, meetings | lead.get (or computed from related tables) |
conversionValue |
number |
₹XX,XXX if converted |
lead.get |
convertedAt |
Date |
DD MMM YYYY or — |
lead.get |
Contact Event Timeline
| Event Type | Icon | Color | Fields |
|---|---|---|---|
CALL |
Phone |
series-1 |
Duration, notes, outcome |
EMAIL |
Mail |
series-2 |
Subject, body preview, sent/received |
MEETING |
Calendar |
series-3 |
Date, location, notes |
NOTE |
FileText |
slate |
Text, createdBy, timestamp |
STATUS_CHANGE |
ArrowRight |
action-accent |
From → To status |
3. Display Pattern
- Component type: Two-column layout (66/33 split on desktop, stacked on mobile).
- Left column: Lead profile card + contact history timeline.
- Right column: Actions panel + notes + conversion tracking.
- Profile card: White card with
shadow.subtle. Contains name (heading-sm, 24px), status badge, email/phone as click-to-action links, source with UTM params displayed as tag pills (utmSource=google_ads→ gray pill), creation date. - Status dropdown: Inline
Selectcomponent in profile card. Options:NEW,CONTACTED,QUALIFIED,CONVERTED,LOST. On change, immediately callslead.updateStatus. - Contact history timeline: Vertical timeline with left border (
1px solid mist). Each event is a dot (12px, color per type) + card with event details. Events ordered bycreatedAtdescending. Newest at top. - Notes section:
Textareawith auto-save (debounce 2s). Notes stored inlead.notes. - Conversion tracking: If status is
CONVERTED, show conversion card withconvertedAtdate andconversionValueinput (currency formatted). If not converted, show "Mark as Converted" button that sets status and opens value input. - Assign to team member:
Selectdropdown with practice members (practice.getmembers). CLIENT only. EDITOR/VIEWER see read-only assignment. - Action buttons: "Send Email" (opens mailto), "Call" (opens tel), "Add Note" (opens inline textarea), "Delete Lead" (error-red, CLIENT only).
4. Backend Endpoint
// tRPC query
lead.get
// Input schema
const getLeadSchema = z.object({
id: z.string(),
});
// Auth: practiceProcedure
// Returns: Lead
// tRPC mutation: update status
lead.updateStatus
// Input schema (same as list screen)
// Returns: Lead
Backend gap: lead.get returns basic fields. contactHistory is not in the backend schema. Frontend either derives it from related tables or requires backend to add contactHistory to lead.get response. lead.update (full update) does not exist — only lead.updateStatus.
5. Fetch Frequency
| Event | Interval | Strategy |
|---|---|---|
| Initial load | Once | lead.get({ id }) |
| Background refresh | 5 minutes | refetchInterval: 300_000 |
| After status change | Immediate | Invalidate lead.get and lead.list |
| Notes auto-save | 2 seconds debounce | lead.update (requires backend) or lead.updateStatus with notes payload |
6. Data Manipulations
| Manipulation | Logic | Implementation |
|---|---|---|
| Timeline sort | contactHistory descending by createdAt |
useMemo with .sort() |
| UTM param display | utmSource + utmMedium + utmCampaign as pills |
Map to <Badge> components |
| Phone click | tel: link with +91 prefix |
href="tel:+91${phone}" |
| Email click | mailto: link |
href="mailto:${email}" |
| Notes auto-save | Debounce 2s, call update | useEffect with setTimeout cleanup |
| Conversion value | Currency input with ₹ prefix |
Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR' }) |
| Assignment | Select with members from practice.get |
practice.get query in parallel |
7. Why Structured This Way
- Two-column layout: Left side provides the narrative (timeline), right side provides actionability (status, notes, conversion). This matches CRM conventions (HubSpot, Salesforce).
- Click-to-call / click-to-email: Medical practice staff are often on mobile devices. Direct
tel:andmailto:links reduce friction from "see lead" to "contact lead". - UTM param pills: Marketing attribution is critical for practices spending on Google/Meta ads. Displaying UTM params as pills makes the source visible without requiring the user to open a tracking dashboard.
- Timeline over table: A contact history table would be dense. The vertical timeline provides visual rhythm and chronological context. The dot colors make event types scannable.
- Auto-save notes: Notes are frequently added during calls. Auto-save prevents data loss if the user navigates away. The 2s debounce balances responsiveness with server load.
- 5-minute refresh: Lead detail is less frequently updated than the lead list. 5 minutes is sufficient for status updates from other team members.
8. Interaction Flows
User navigates to /dashboard/leads/lead_123
→ lead.get({ id: "lead_123" }) fires
→ Two-column layout renders
→ Profile card shows lead info
→ Timeline shows contact history
User changes status dropdown from "NEW" to "CONTACTED"
→ lead.updateStatus.mutate({ id, status: "CONTACTED" })
→ Status badge transitions to amber
→ Timeline appends new STATUS_CHANGE event
→ Toast: "Status updated to Contacted"
User clicks "Add Note"
→ Inline textarea appears below existing notes
→ User types note
→ Auto-save fires after 2s of inactivity
→ Note saved to backend (requires lead.update endpoint)
→ Timeline appends NOTE event
User clicks "Assign to" dropdown (CLIENT only)
→ Select opens with practice members
→ User selects member "Dr. Sharma"
→ Assignment updates (requires backend endpoint)
→ Toast: "Lead assigned to Dr. Sharma"
User clicks "Delete Lead" (CLIENT only)
→ ConfirmDialog: "Permanently delete lead for {name}?"
→ On confirm: lead.delete.mutate({ id })
→ Redirect to /dashboard/leads
→ Toast: "Lead deleted"
9. Error States
| State | Trigger | UI Treatment | Recovery |
|---|---|---|---|
| Loading | lead.get pending | Skeleton: profile card + 3 timeline skeletons | Auto-resolves |
| Not found | Invalid lead ID | Full-page error: "Lead not found" + back to list | Navigate back |
| tRPC error | lead.get fails | Error banner: "Failed to load lead." + retry | Retry |
| Status update error | lead.updateStatus fails | Status badge reverts to previous value. Toast: "Update failed." | Retry dropdown |
| Notes save error | Auto-save fails | Inline error below textarea: "Save failed. Retry?" + retry button | Retry button |
| Assignment error | Assignment mutation fails | Dropdown reverts. Toast: "Assignment failed." | Retry |
10. Role-Based Variations
| Role | Status Dropdown | Notes Edit | Assignment | Delete | Conversion Value |
|---|---|---|---|---|---|
CLIENT |
Editable | Editable | Editable | Enabled | Editable |
EDITOR |
Editable | Read-only | Read-only | Hidden | Read-only |
VIEWER |
Read-only | Read-only | Read-only | Hidden | Read-only |
9.2.3 Lead Form Modal (Add New Lead)#
1. Screen Name & Route + Role Access
| Route | Component | Role Access |
|---|---|---|
Modal from /dashboard/leads |
LeadFormModal |
CLIENT only |
2. Data to Show
| Field | Type | Required | Validation | Format |
|---|---|---|---|---|
name |
string |
Yes | 2–100 chars | Text input |
email |
string |
Yes | Valid email regex | Email input |
phone |
string |
Yes | ^\+91[0-9]{10}$ |
Tel input with +91 prefix |
source |
enum |
Yes | landing_page / gbp / social / citation / direct / referral |
Select dropdown |
status |
enum |
No | Default: NEW |
Select dropdown |
notes |
string |
No | Max 1000 chars | Textarea |
assignedTo |
string |
No | User ID from practice members | Select dropdown |
3. Display Pattern
- Component type:
Dialog(shadcn/ui) withModalwrapper. - Layout: 480px max-width modal. 2-column grid for name/email and phone/source on wider screens. Stacked on mobile.
- Form fields:
Inputcomponents withlabelabove,helperTextbelow for validation hints.Textareafor notes (4 rows, resizable). - Status: Pre-filled to
NEW.Selectdropdown with color-coded options (same as table badges). - Phone input:
+91prefix is non-editable (left adornment). Input accepts 10 digits only. Auto-formats as user types. - Source dropdown: Each option has a Lucide icon prefix (same as table source icons).
- Assignment: Populated from
practice.getmembers. "Unassigned" is the default option. - Actions: "Cancel" (ghost), "Save Lead" (accent, ember-orange). Disabled until all required fields are valid.
- Validation: Inline validation on blur. Zod schema matches backend
CreateLeadInput(proposed).
4. Backend Endpoint
// Backend endpoint DOES NOT EXIST in backend-api.md.
// Frontend requires a new mutation endpoint.
// Proposed tRPC mutation:
lead.create
// Proposed input schema:
const createLeadSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
phone: z.string().regex(/^\+91[0-9]{10}$/),
source: z.enum(["landing_page", "gbp", "social", "citation", "direct", "referral"]),
status: z.enum(["NEW", "CONTACTED", "QUALIFIED", "CONVERTED", "LOST"]).default("NEW"),
notes: z.string().max(1000).optional(),
assignedTo: z.string().optional(),
});
// Auth: practiceProcedure (CLIENT role only)
// Returns: Lead
Backend requirement: Implement lead.create mutation in src/server/api/routers/lead.ts.
5. Fetch Frequency
| Event | Interval | Strategy |
|---|---|---|
| Form open | Once | Pre-fetch practice.get for member list |
| Save | On submit | lead.create.mutate(data) |
| After save | Immediate | utils.lead.list.invalidate() |
6. Data Manipulations
| Manipulation | Logic | Implementation |
|---|---|---|
| Phone formatting | Strip non-digits, prepend +91 |
value.replace(/\D/g, '').slice(0, 10) |
| Validation | Zod schema validation on submit | createLeadSchema.safeParse(formData) |
| Source icon mapping | Map enum to Lucide icon | Client-side map |
| Status color mapping | Map enum to badge color | Client-side map |
| Member list | Filter practice.get members |
members.filter(m => m.role !== 'VIEWER') |
7. Why Structured This Way
- Modal over page: Adding a lead is a quick, transactional action. A full page would be excessive. The modal keeps the user in context (they just saw the lead list and noticed a missing entry).
- Phone validation: Indian medical practices require valid 10-digit mobile numbers with
+91country code. The regex^\+91[0-9]{10}$enforces this. The+91prefix is non-editable to prevent formatting errors. - Pre-filled NEW status: Most manually added leads are new inquiries. Defaulting to
NEWreduces clicks. - Source enum: Restricting sources to known values ensures consistent attribution reporting. Free-text sources would create data quality issues.
- Assignment on creation: For practices with multiple staff, immediate assignment ensures the lead is routed to the right person without a separate step.
8. Interaction Flows
User clicks "Add Lead" on /dashboard/leads
→ LeadFormModal opens
→ practice.get fires in background for member list
→ Form fields render empty, status = NEW
User types name: "Ravi Kumar"
→ Validation on blur: passes (2–100 chars)
User types email: "ravi@example.com"
→ Validation on blur: passes regex
User types phone: "9876543210"
→ Auto-formats to "+91 98765 43210" in display
→ Stored as "+919876543210"
User selects source: "Google Business Profile"
→ Dropdown closes, icon visible
User clicks "Save Lead"
→ Client-side Zod validation runs
→ If valid: lead.create.mutate(data)
→ Loading state on button (spinner)
→ On success: modal closes, lead.list invalidates, toast "Lead added: Ravi Kumar"
→ On error: modal shows error banner, button returns to "Save Lead"
User clicks "Cancel"
→ Modal closes, form state discarded
9. Error States
| State | Trigger | UI Treatment | Recovery |
|---|---|---|---|
| Validation error | Invalid field | Inline error below field: "Email is required" or "Phone must be 10 digits" | Correct input |
| Server error | lead.create fails | Modal error banner: "Failed to add lead. Please try again." | Retry save |
| Member load error | practice.get fails | Assignment dropdown shows "Loading failed" with retry | Retry dropdown load |
| Duplicate email | Unique constraint violation | Inline error on email field: "A lead with this email already exists." | Change email |
10. Role-Based Variations
| Role | Modal Access | Fields Editable |
|---|---|---|
CLIENT |
Full access | All fields |
EDITOR |
Cannot open | N/A |
VIEWER |
Cannot open | N/A |