Skip to Content
📚 MyStoryFlow Docs — Your guide to preserving family stories

F024: Coach Marketplace & Discovery (MyStoryFlow Integration)

Overview

Create a literary coach marketplace within MyStoryFlow where authors can discover certified writing mentors, featuring elegant profiles with vintage book aesthetics and sophisticated search capabilities.

Dependencies

  • F000: MyStoryFlow Monorepo Structure
  • F000B: Shared Packages (@mystoryflow/*)
  • F023: Consultation System
  • F021: Subscription Tiers

Quick Implementation

Using MyStoryFlow Components

  • Bookshelf-style grid for coach cards
  • Literary-themed search interface
  • Achievement badges with manuscript icons
  • Star ratings with amber styling
  • Modal dialogs with paper texture

New Requirements

  • Literary coach certification system
  • Genre-based matching algorithms
  • Writing style compatibility
  • Review system with quotes

MVP Implementation

1. Enhanced Database Schema

-- Coach certifications (MyStoryFlow integrated) CREATE TABLE coach_certifications ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), name VARCHAR(255) NOT NULL, code VARCHAR(50) UNIQUE NOT NULL, description TEXT, requirements TEXT[], badge_color VARCHAR(20), badge_icon VARCHAR(50), created_at TIMESTAMP DEFAULT NOW() ); -- Coach certification status CREATE TABLE coach_cert_status ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), coach_id UUID REFERENCES coach_profiles(id), certification_id UUID REFERENCES coach_certifications(id), status VARCHAR(50) DEFAULT 'pending', -- 'pending', 'approved', 'expired' issued_at TIMESTAMP, expires_at TIMESTAMP, verification_data JSONB, created_at TIMESTAMP DEFAULT NOW(), UNIQUE(coach_id, certification_id) ); -- Coach portfolios CREATE TABLE analyzer.coach_portfolios ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), coach_id UUID REFERENCES analyzer.coach_profiles(id), title VARCHAR(255) NOT NULL, description TEXT, category VARCHAR(50), -- 'success_story', 'testimonial', 'sample_work' content TEXT, author_name VARCHAR(255), author_consent BOOLEAN DEFAULT true, results_data JSONB, -- before/after scores, achievements created_at TIMESTAMP DEFAULT NOW() ); -- Coach reviews CREATE TABLE analyzer.coach_reviews ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), coach_id UUID REFERENCES analyzer.coach_profiles(id), author_id UUID REFERENCES auth.users(id), session_id UUID REFERENCES analyzer.consultation_sessions(id), rating INTEGER CHECK (rating >= 1 AND rating <= 5), review_text TEXT, is_verified BOOLEAN DEFAULT false, is_visible BOOLEAN DEFAULT true, helpful_count INTEGER DEFAULT 0, response_text TEXT, responded_at TIMESTAMP, created_at TIMESTAMP DEFAULT NOW() ); -- Featured coaches CREATE TABLE analyzer.featured_coaches ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), coach_id UUID REFERENCES analyzer.coach_profiles(id) UNIQUE, featured_until DATE NOT NULL, featured_reason TEXT, priority INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT NOW() ); -- Seed certifications INSERT INTO analyzer.coach_certifications (code, name, description, requirements, badge_color, badge_icon) VALUES ('genre_specialist', 'Genre Specialist', 'Expert in specific genre coaching', ARRAY['50+ hours genre-specific coaching', 'Published in genre', '90%+ satisfaction rating'], 'purple', 'bookmark'), ('bestseller_coach', 'Bestseller Coach', 'Has coached bestselling authors', ARRAY['Coached 3+ bestselling authors', 'Verified sales data', '95%+ satisfaction'], 'gold', 'trophy'), ('industry_pro', 'Industry Professional', 'Publishing industry experience', ARRAY['5+ years industry experience', 'Verified credentials', 'Active connections'], 'blue', 'briefcase'), ('master_editor', 'Master Editor', 'Professional editing certification', ARRAY['Certified editor credentials', '100+ manuscripts edited', 'Portfolio review'], 'green', 'edit'); -- Indexes CREATE INDEX idx_coach_reviews_coach_id ON analyzer.coach_reviews(coach_id); CREATE INDEX idx_coach_reviews_rating ON analyzer.coach_reviews(rating); CREATE INDEX idx_featured_coaches_until ON analyzer.featured_coaches(featured_until); CREATE INDEX idx_coach_profiles_rating ON analyzer.coach_profiles(rating); CREATE INDEX idx_coach_profiles_specialties ON coach_profiles USING GIN(specialties);

2. Coach Marketplace Service

// packages/marketplace/src/services/marketplace-service.ts import { createClient } from '@mystoryflow/database/server' interface SearchFilters { genres?: string[] priceRange?: { min: number; max: number } rating?: number certifications?: string[] availability?: boolean sortBy?: 'rating' | 'price' | 'experience' | 'sessions' } interface CoachScore { coach: any score: number matchReasons: string[] } export class MarketplaceService { private supabase = getSupabaseBrowserClient() async searchCoaches( query: string, filters: SearchFilters, limit: number = 20, offset: number = 0 ): Promise<{ coaches: any[], total: number }> { let queryBuilder = this.supabase .from('analyzer.coach_profiles') .select(` *, coach_cert_status ( certification_id, status, coach_certifications ( name, code, badge_color, badge_icon ) ), coach_reviews ( rating, review_text, author_id ), featured_coaches ( featured_until, priority ) `, { count: 'exact' }) .eq('is_active', true) // Apply text search if (query) { queryBuilder = queryBuilder.or( `full_name.ilike.%${query}%,bio.ilike.%${query}%` ) } // Apply filters if (filters.genres?.length) { queryBuilder = queryBuilder.contains('specialties', filters.genres) } if (filters.priceRange) { queryBuilder = queryBuilder .gte('hourly_rate', filters.priceRange.min * 100) .lte('hourly_rate', filters.priceRange.max * 100) } if (filters.rating) { queryBuilder = queryBuilder.gte('rating', filters.rating) } if (filters.certifications?.length) { // Filter by certification codes queryBuilder = queryBuilder.in( 'coach_cert_status.coach_certifications.code', filters.certifications ) } // Apply sorting switch (filters.sortBy) { case 'rating': queryBuilder = queryBuilder.order('rating', { ascending: false }) break case 'price': queryBuilder = queryBuilder.order('hourly_rate', { ascending: true }) break case 'experience': queryBuilder = queryBuilder.order('years_experience', { ascending: false }) break case 'sessions': queryBuilder = queryBuilder.order('total_sessions', { ascending: false }) break default: // Default: Featured coaches first, then by rating queryBuilder = queryBuilder .order('featured_coaches.priority', { ascending: false, nullsFirst: false }) .order('rating', { ascending: false }) } // Apply pagination queryBuilder = queryBuilder.range(offset, offset + limit - 1) const { data, count, error } = await queryBuilder if (error) throw error return { coaches: data || [], total: count || 0 } } async getCoachDetails(coachId: string): Promise<any> { const { data: coach } = await this.supabase .from('analyzer.coach_profiles') .select(` *, coach_cert_status ( *, coach_certifications (*) ), coach_portfolios (*), coach_reviews ( *, authors:users!coach_reviews_author_id_fkey ( id, email ) ), coach_availability (*) `) .eq('id', coachId) .single() if (!coach) throw new Error('Coach not found') // Calculate additional metrics const metrics = await this.calculateCoachMetrics(coachId) return { ...coach, metrics } } async getRecommendedCoaches( userId: string, manuscriptId?: string ): Promise<CoachScore[]> { // Get user preferences and manuscript details const userProfile = await this.getUserProfile(userId) const manuscript = manuscriptId ? await this.getManuscriptDetails(manuscriptId) : null // Get all active coaches const { data: coaches } = await this.supabase .from('analyzer.coach_profiles') .select(` *, coach_cert_status ( certification_id, status, coach_certifications (*) ) `) .eq('is_active', true) // Score and rank coaches const scoredCoaches = coaches?.map(coach => { const score = this.calculateMatchScore(coach, userProfile, manuscript) return score }) || [] // Sort by score and return top matches return scoredCoaches .sort((a, b) => b.score - a.score) .slice(0, 10) } private calculateMatchScore( coach: any, userProfile: any, manuscript: any ): CoachScore { let score = 0 const matchReasons: string[] = [] // Genre match (40 points) if (manuscript?.genre && coach.specialties.includes(manuscript.genre)) { score += 40 matchReasons.push(`Specializes in ${manuscript.genre}`) } // Rating score (30 points max) score += Math.min(30, coach.rating * 6) if (coach.rating >= 4.5) { matchReasons.push('Highly rated coach') } // Experience score (20 points max) score += Math.min(20, coach.years_experience * 2) if (coach.years_experience >= 10) { matchReasons.push('10+ years experience') } // Certification bonus (10 points per relevant cert) coach.coach_cert_status?.forEach((cert: any) => { if (cert.status === 'approved') { score += 10 if (cert.coach_certifications.code === 'genre_specialist' && manuscript?.genre) { score += 10 matchReasons.push(cert.coach_certifications.name) } } }) // Price match (within user budget) if (userProfile.budget && coach.hourly_rate <= userProfile.budget) { score += 10 matchReasons.push('Within your budget') } return { coach, score, matchReasons } } async submitReview( coachId: string, authorId: string, sessionId: string, rating: number, reviewText: string ): Promise<void> { // Verify session completion const { data: session } = await this.supabase .from('analyzer.consultation_sessions') .select('status') .eq('id', sessionId) .eq('author_id', authorId) .eq('coach_id', coachId) .single() if (!session || session.status !== 'completed') { throw new Error('Can only review completed sessions') } // Check for existing review const { data: existing } = await this.supabase .from('analyzer.coach_reviews') .select('id') .eq('session_id', sessionId) .single() if (existing) { throw new Error('Session already reviewed') } // Create review await this.supabase .from('analyzer.coach_reviews') .insert({ coach_id: coachId, author_id: authorId, session_id: sessionId, rating, review_text: reviewText, is_verified: true // Verified because session completed }) // Update coach rating await this.updateCoachRating(coachId) } private async updateCoachRating(coachId: string): Promise<void> { const { data: reviews } = await this.supabase .from('analyzer.coach_reviews') .select('rating') .eq('coach_id', coachId) .eq('is_visible', true) if (!reviews || reviews.length === 0) return const avgRating = reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length await this.supabase .from('analyzer.coach_profiles') .update({ rating: Math.round(avgRating * 100) / 100, total_sessions: reviews.length }) .eq('id', coachId) } private async calculateCoachMetrics(coachId: string): Promise<any> { // Response time const { data: sessions } = await this.supabase .from('analyzer.consultation_sessions') .select('created_at, scheduled_at') .eq('coach_id', coachId) .gte('created_at', new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()) const avgResponseTime = sessions?.length ? sessions.reduce((sum, s) => { const created = new Date(s.created_at).getTime() const scheduled = new Date(s.scheduled_at).getTime() return sum + (scheduled - created) }, 0) / sessions.length / (1000 * 60 * 60) : 0 // Convert to hours // Repeat client rate const { data: repeatClients } = await this.supabase .from('analyzer.consultation_sessions') .select('author_id') .eq('coach_id', coachId) .eq('status', 'completed') const uniqueClients = new Set(repeatClients?.map(r => r.author_id)) const repeatRate = repeatClients && uniqueClients.size > 0 ? ((repeatClients.length - uniqueClients.size) / repeatClients.length) * 100 : 0 return { avgResponseTimeHours: Math.round(avgResponseTime), repeatClientRate: Math.round(repeatRate), totalReviews: await this.getReviewCount(coachId), successStories: await this.getSuccessStoryCount(coachId) } } private async getReviewCount(coachId: string): Promise<number> { const { count } = await this.supabase .from('analyzer.coach_reviews') .select('*', { count: 'exact' }) .eq('coach_id', coachId) .eq('is_visible', true) return count || 0 } private async getSuccessStoryCount(coachId: string): Promise<number> { const { count } = await this.supabase .from('analyzer.coach_portfolios') .select('*', { count: 'exact' }) .eq('coach_id', coachId) .eq('category', 'success_story') return count || 0 } }

3. Coach Marketplace Page

// apps/analyzer-app/src/app/coaches/page.tsx 'use client' import { useState } from 'react' import { Input, Select, Slider, Button, Badge } from '@mystoryflow/ui' import { Search, Filter, Star, Award, TrendingUp } from 'lucide-react' import { CoachGrid } from '@/components/marketplace/CoachGrid' import { CoachFilters } from '@/components/marketplace/CoachFilters' import { useCoachSearch } from '@/hooks/useCoachSearch' export default function CoachMarketplace() { const [searchQuery, setSearchQuery] = useState('') const [filters, setFilters] = useState({ genres: [], priceRange: { min: 0, max: 500 }, rating: 0, certifications: [], sortBy: 'featured' }) const { coaches, loading, total } = useCoachSearch(searchQuery, filters) return ( <div className="container py-8"> {/* Header */} <div className="text-center mb-12"> <h1 className="text-4xl font-bold mb-4"> Find Your Perfect Writing Coach </h1> <p className="text-xl text-muted-foreground max-w-2xl mx-auto"> Connect with certified coaches who specialize in your genre and can help take your manuscript to the next level. </p> </div> {/* Search Bar */} <div className="max-w-2xl mx-auto mb-8"> <div className="relative"> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground" /> <Input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="Search by name, genre, or expertise..." className="pl-10 pr-4 py-3 text-lg" /> </div> </div> {/* Quick Stats */} <div className="grid grid-cols-4 gap-4 mb-8"> <Card className="p-4 text-center"> <div className="text-2xl font-bold">{total}</div> <div className="text-sm text-muted-foreground">Active Coaches</div> </Card> <Card className="p-4 text-center"> <div className="text-2xl font-bold">4.8</div> <div className="text-sm text-muted-foreground">Average Rating</div> </Card> <Card className="p-4 text-center"> <div className="text-2xl font-bold">2,847</div> <div className="text-sm text-muted-foreground">Success Stories</div> </Card> <Card className="p-4 text-center"> <div className="text-2xl font-bold">12</div> <div className="text-sm text-muted-foreground">Specializations</div> </Card> </div> {/* Main Content */} <div className="grid lg:grid-cols-4 gap-8"> {/* Filters Sidebar */} <div className="lg:col-span-1"> <CoachFilters filters={filters} onFiltersChange={setFilters} /> </div> {/* Coach Grid */} <div className="lg:col-span-3"> {/* Sort Options */} <div className="flex items-center justify-between mb-6"> <p className="text-sm text-muted-foreground"> Showing {coaches.length} of {total} coaches </p> <Select value={filters.sortBy} onValueChange={(value) => setFilters({ ...filters, sortBy: value }) } > <option value="featured">Featured</option> <option value="rating">Highest Rated</option> <option value="price">Lowest Price</option> <option value="experience">Most Experienced</option> <option value="sessions">Most Sessions</option> </Select> </div> {/* Coach Cards */} <CoachGrid coaches={coaches} loading={loading} /> </div> </div> </div> ) }

4. Coach Profile Card

// apps/analyzer-app/src/components/marketplace/CoachCard.tsx 'use client' import { Card, Badge, Button } from '@mystoryflow/ui' import { Star, Award, Clock, Users, ChevronRight } from 'lucide-react' import Link from 'next/link' import { CoachProfileModal } from './CoachProfileModal' import { useState } from 'react' interface CoachCardProps { coach: { id: string full_name: string bio: string avatar_url?: string specialties: string[] hourly_rate: number rating: number total_sessions: number years_experience: number coach_cert_status: any[] featured_coaches?: any } } export function CoachCard({ coach }: CoachCardProps) { const [showProfile, setShowProfile] = useState(false) const isFeatured = coach.featured_coaches?.featured_until && new Date(coach.featured_coaches.featured_until) > new Date() return ( <> <Card className={`p-6 hover:shadow-lg transition-shadow ${ isFeatured ? 'ring-2 ring-primary' : '' }`}> {isFeatured && ( <Badge className="absolute -top-2 -right-2" variant="default"> Featured </Badge> )} <div className="flex items-start gap-4"> {/* Avatar */} {coach.avatar_url ? ( <img src={coach.avatar_url} alt={coach.full_name} className="w-16 h-16 rounded-full object-cover" /> ) : ( <div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center"> <span className="text-xl font-semibold"> {coach.full_name.charAt(0)} </span> </div> )} {/* Info */} <div className="flex-1"> <h3 className="font-semibold text-lg">{coach.full_name}</h3> <p className="text-sm text-muted-foreground line-clamp-2"> {coach.bio} </p> {/* Stats */} <div className="flex items-center gap-4 mt-3 text-sm"> <div className="flex items-center gap-1"> <Star className="h-4 w-4 text-yellow-500 fill-current" /> <span className="font-medium">{coach.rating}</span> </div> <div className="flex items-center gap-1"> <Users className="h-4 w-4" /> <span>{coach.total_sessions} sessions</span> </div> <div className="flex items-center gap-1"> <Clock className="h-4 w-4" /> <span>{coach.years_experience}+ years</span> </div> </div> {/* Specialties */} <div className="flex flex-wrap gap-2 mt-3"> {coach.specialties.slice(0, 3).map(specialty => ( <Badge key={specialty} variant="secondary" size="sm"> {specialty} </Badge> ))} {coach.specialties.length > 3 && ( <Badge variant="outline" size="sm"> +{coach.specialties.length - 3} more </Badge> )} </div> {/* Certifications */} {coach.coach_cert_status.length > 0 && ( <div className="flex items-center gap-2 mt-3"> {coach.coach_cert_status .filter(cert => cert.status === 'approved') .slice(0, 2) .map(cert => ( <div key={cert.certification_id} className="flex items-center gap-1" title={cert.coach_certifications.name} > <Award className="h-4 w-4" style={{ color: cert.coach_certifications.badge_color }} /> </div> )) } </div> )} </div> {/* Price */} <div className="text-right"> <div className="text-2xl font-bold"> ${coach.hourly_rate / 100} </div> <div className="text-sm text-muted-foreground">per hour</div> </div> </div> {/* Actions */} <div className="flex gap-3 mt-4 pt-4 border-t"> <Button variant="outline" size="sm" className="flex-1" onClick={() => setShowProfile(true)} > View Profile </Button> <Button size="sm" className="flex-1" asChild> <Link href={`/coaches/${coach.id}/book`}> Book Session <ChevronRight className="h-4 w-4 ml-1" /> </Link> </Button> </div> </Card> {/* Profile Modal */} {showProfile && ( <CoachProfileModal coachId={coach.id} onClose={() => setShowProfile(false)} /> )} </> ) }

5. Coach Certification Display

// apps/analyzer-app/src/components/marketplace/CertificationBadges.tsx 'use client' import { Badge, Tooltip } from '@mystoryflow/ui' import { Award, CheckCircle, Info } from 'lucide-react' interface Certification { id: string name: string code: string description: string badge_color: string badge_icon: string status: string issued_at?: string } export function CertificationBadges({ certifications }: { certifications: Certification[] }) { const activeCerts = certifications.filter(c => c.status === 'approved') const certIcons = { 'genre_specialist': '📚', 'bestseller_coach': '🏆', 'industry_pro': '💼', 'master_editor': '✏️' } const certColors = { 'purple': 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200', 'gold': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', 'blue': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', 'green': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' } return ( <div className="space-y-4"> <h3 className="font-semibold flex items-center gap-2"> <Award className="h-5 w-5" /> Certifications & Credentials </h3> <div className="space-y-3"> {activeCerts.map(cert => ( <div key={cert.id} className="flex items-start gap-3"> <div className={` p-2 rounded-lg ${certColors[cert.badge_color] || certColors.blue} `}> <span className="text-xl"> {certIcons[cert.code] || '🎓'} </span> </div> <div className="flex-1"> <div className="flex items-center gap-2"> <h4 className="font-medium">{cert.name}</h4> <CheckCircle className="h-4 w-4 text-green-500" /> <Tooltip content={cert.description}> <Info className="h-4 w-4 text-muted-foreground cursor-help" /> </Tooltip> </div> <p className="text-sm text-muted-foreground"> {cert.description} </p> {cert.issued_at && ( <p className="text-xs text-muted-foreground mt-1"> Certified since {new Date(cert.issued_at).getFullYear()} </p> )} </div> </div> ))} </div> {activeCerts.length === 0 && ( <p className="text-sm text-muted-foreground italic"> No verified certifications yet </p> )} </div> ) }

MVP Acceptance Criteria

  • Coach profile creation and management
  • Certification system with verification
  • Advanced search and filtering
  • Coach matching algorithm
  • Review and rating system
  • Portfolio showcase
  • Featured coach highlighting
  • Detailed coach profiles
  • Mobile-responsive marketplace
  • Quick view modals

Post-MVP Enhancements

  • Video introductions for coaches
  • AI-powered coach matching
  • Coach performance analytics
  • Certification exam system
  • Coach onboarding workflow
  • Commission management
  • Coach community features
  • Specialization tests
  • Client success tracking
  • Marketing tools for coaches

Implementation Time

  • Development: 2.5 days
  • Testing: 0.5 days
  • Total: 3 days

Dependencies

  • F023-CONSULTATION-SYSTEM (booking integration)
  • Coach verification process
  • Review moderation system

Next Feature

After completion, proceed to F025-LANDING-PAGES for marketing.