diff --git a/docs/onboarding-guide-admin-flows.csv b/docs/onboarding-guide-admin-flows.csv new file mode 100644 index 0000000000..8fce477957 --- /dev/null +++ b/docs/onboarding-guide-admin-flows.csv @@ -0,0 +1,30 @@ +Use Case Group,Video Title,Flow,Priority,Description +Foundation,How to set up your institute,Settings > Institute Settings > Branding > Roles > Invite Team,Must-have,One-time foundational setup — institute name & logo / theme color / time zone / invite your first team members / assign roles. Covers everything you do once before anyone else logs in. +Foundation,How to connect your payment gateway,Settings > Payment Gateways > Razorpay/Stripe > Tax/GST > Invoice Template,Must-have,Connect Razorpay or Stripe / set up GST or tax / pick your default gateway / design your invoice header & footer. Get ready to collect money. +Foundation,How to connect your communication channels,Settings > WhatsApp > Wati/Meta > Email SMTP > Exotel Telephony,Must-have,Connect WhatsApp via Wati or Meta / connect SMTP for email / connect Exotel for click-to-call. Get all your outbound channels live. +LMS Course Creation,How to create your first course end-to-end,Courses > Create Course > Add Level > Add Session > Add Subject > Add Module > Add Chapter > Publish,Must-have,Walk through the full course structure: course → level → session → subject → module → chapter → publish. The single most important admin flow. +LMS Course Creation,How to add content to a course (PDF / video / quiz / coding),Courses > [Chapter] > Add Slide > PDF + Video + Quiz + Coding,Must-have,Show how to add each major slide type (PDF / video lecture / quiz / coding problem) and how to reorder them. One video covers all slide types. +LMS Course Creation,How to set drip rules and pricing for a course,Courses > [Course] > Drip Conditions + Pricing + Bundles,Should-have,Control when chapters unlock (time / completion) and set the price or installment plan for the course. +LMS Live + AI,How to run your first live class,Live Sessions > Create > Connect Zoom > Schedule > Recording > Attendance > Auto-Notes,Must-have,Schedule a live session / authorize Zoom / record automatically / track attendance / auto-generate notes & quiz from the recording with Instructor Copilot. +LMS Live + AI,How to create and run an assessment,Assessments > Create > Add Questions > Set Duration > Enable Proctoring > Assign > Schedule > Publish Results,Must-have,Complete assessment lifecycle: create / add questions from bank or AI / set timer / turn on proctoring / assign to a batch / schedule window / grade subjective answers / publish results. +LMS Live + AI,How to use AI to create a question paper,AI Center > VSmart > Question Paper from Document / Topic Questions / Paper Digitiser,Must-have,Three AI ways to make a paper: upload a document → AI extracts questions / type a topic → AI generates questions / scan a handwritten paper → AI digitises it. +LMS Live + AI,How to use AI to evaluate handwritten papers,AI Center > VSmart Feedback > Upload Sheets > Review > Publish,Should-have,Scan handwritten answer sheets / AI grades them / you review and adjust / publish results back to learners. +LMS Live + AI,How to create an AI explainer video with Vimotion,Vimotion > Create > Prompt or Deck > Edit > Embed in Course,Should-have,Generate an AI-narrated explainer video from a prompt or slide deck / edit captions and voiceover / embed it into a course chapter. +CRM,How to capture leads from your website and ads,Audience Manager > Create Form > Embed/Share > Connect Meta Lead Ads > Bulk Import,Must-have,Three ways to capture leads: build a custom form and embed on your website / share via link or WhatsApp / connect Facebook Lead Ads for auto-import / bulk import a CSV of leads. +CRM,How to set up your lead pipeline,Settings > Leads > Statuses + Tiers + Scoring + SLA,Must-have,Configure custom lead statuses (your pipeline) / set up HOT/WARM/COLD tiers / configure lead scoring rules / set SLA so leads can't be ignored. +CRM,How to distribute leads across your counsellor team,Settings > Leads > Pools > Create Pool > Add Members > Round Robin > Region/Schedule,Must-have,Create a counsellor pool / add members / configure round-robin or region-based routing / set working-hour shifts. +CRM,How to work a lead day-in-the-life,Audience Manager > [Lead] > View Profile > Add Note > Schedule Follow-up > WhatsApp/Email > Call > Update Status > Convert,Must-have,The counsellor's daily workflow: open a lead / read profile + timeline / log a note / schedule follow-up / send WhatsApp or email / make a call (Exotel) / update status / mark as Converted. +CRM,How to use the full-screen lead profile overlay,Audience Manager > [Lead] > Expand Icon > Section Nav,Should-have,Open the 90vw full-screen profile / use the left nav to jump between sections / faster for deep review than the drawer. +CRM,How to create email and WhatsApp templates,Settings > Templates > Email + WhatsApp > Submit Meta-approved,Should-have,Create a reusable email template with merge fields / submit a Meta-approved WhatsApp template / use them in workflows. +CRM,How to build a lead nurture workflow,Workflow > Create > Trigger > Send Email > Wait > Condition > Send WhatsApp > Schedule > Monitor,Must-have,Build a multi-step automation in the visual editor: pick a trigger / send an email / wait 2 days / branch on lead score / send WhatsApp / schedule the workflow / monitor executions. +CRM,How to read your CRM reports,Audience Manager > Reports > Conversion Funnel + Source ROI + Counsellor Performance,Should-have,Where leads are dropping off in the funnel / which sources are giving the best ROI / how each counsellor is performing. +Admissions,How to process applications and enquiries,Admissions > Enquiries > Convert > Application > Assign Counsellor > Documents > Status > Communication,Must-have,End-to-end admissions flow: enquiry comes in / convert to application / assign to counsellor / track document submission / track status / communicate with the applicant. +Learners + Payments,How to enroll learners and onboard them,Manage Learners > Bulk Enroll/Invite > Portal Access > Welcome Email > Track,Must-have,Bulk enroll from CSV or send invite links / grant portal access credentials / trigger the welcome flow / verify they logged in. +Learners + Payments,How to manage a learner's full profile,Manage Learners > [Learner] > Overview + Progress + Tests + Payment + Communication + Files + Edit,Must-have,Tour every tab of the learner side drawer: overview / progress / test history / payment history / communications / files / how to edit details. +Learners + Payments,How to handle fees from setup to collection,Settings > Fee Management > Plan + Installments > Create Invoice > Payment Link > Track > Refund,Must-have,Full payment lifecycle: set up a fee plan with installments / create an invoice for a learner / send a payment link via WhatsApp / track payment in logs / handle a refund. +Learners + Payments,How to create and apply coupon codes,Settings > Coupons > Create > Expiry/Limit > Apply at Checkout,Should-have,Issue a discount code / set expiry and usage limit / apply it on a learner's invoice. Useful for scholarship / promo / season-specific discounts. +Operations,How to manage student doubts,Doubts > View > Assign to Instructor > Reply > Mark Resolved + SLA Config,Must-have,How instructors handle the doubts queue: view incoming doubts / assign to the right instructor / reply with text or attachments / mark resolved / how to configure SLA so doubts don't get stuck. +Operations,How to read your admin dashboard and learner insights,Dashboard > KPIs + Finance Summary > Learner Insights > Reports,Must-have,Tour the admin dashboard / understand the KPI widgets / drill into Finance Summary / use Learner Insights to spot at-risk learners / generate a deep-dive report on one learner. +Operations,How to view assessment analytics,Assessments > [Assessment] > Analytics + Insights > Export,Should-have,Question-wise difficulty / score distribution / attempt rate / identifying which questions need rework / export to CSV. +Operations,How to set up sub-organizations and switch between them,Settings > Sub-Org > Create > Add Admins > Org Switcher,Nice-to-have,Set up a child branch under your main institute / give it its own admins / switch between sub-orgs from the top bar. Only needed for multi-branch institutes. +Operations,How to use admin activity logs,Admin Activity Logs > Filter > Export,Nice-to-have,Full audit trail of admin actions / filter by user or action / export for compliance reviews. diff --git a/docs/onboarding-guide-admin.csv b/docs/onboarding-guide-admin.csv new file mode 100644 index 0000000000..05d31856b4 --- /dev/null +++ b/docs/onboarding-guide-admin.csv @@ -0,0 +1,317 @@ +Module,Video Title,Navigation Flow,Priority,Description +Getting Started,How to navigate the admin dashboard,Dashboard,Must-have,Tour of the main navigation: sidebar / top bar / search / quick actions. +Getting Started,How to invite team members,Settings > Teams > Invite Member,Must-have,Send invites to admins / teachers / counsellors so they can join the institute. +Getting Started,How to set up your institute name and logo,Settings > Institute Settings,Must-have,First-time setup of institute identity. +Getting Started,How to set your time zone and currency,Settings > Institute Settings > Locale,Should-have,Configure default time zone and currency for the institute. +Getting Started,How to enable dark mode,Profile > Preferences > Appearance,Nice-to-have,Switch the admin UI to dark mode. +Settings - Branding,How to set up white-label branding,Settings > White Label,Must-have,Apply your brand identity across the platform. +Settings - Branding,How to upload your institute logo,Settings > White Label > Logo,Must-have,Replace the default logo with your own. +Settings - Branding,How to set theme primary color,Settings > White Label > Theme,Should-have,Switch the primary accent color used across the dashboard. +Settings - Branding,How to set up a custom domain,Settings > White Label > Domain Routing,Should-have,Map a custom domain (yourinstitute.com) to your Vacademy tenant. +Settings - Branding,How to upload favicon and email logo,Settings > White Label > Assets,Nice-to-have,Brand the tabs / system emails with your own assets. +Settings - Roles & Permissions,How to create a custom role,Settings > Roles > Create Role,Must-have,Define a new role (e.g. Branch Admin / Counsellor Lead). +Settings - Roles & Permissions,How to assign permissions to a role,Settings > Roles > [Role] > Permissions,Must-have,Configure what each role can see and do. +Settings - Roles & Permissions,How to assign roles to team members,Settings > Roles > [Role] > Members,Must-have,Place team members into the appropriate role. +Settings - Roles & Permissions,How to set up team hierarchy,Settings > Teams > Hierarchy,Should-have,Build the org tree (HQ → branches → teams). +Settings - Roles & Permissions,How to configure role display settings,Settings > Display Settings > Roles,Should-have,Pick which sidebar items / KPIs each role sees. +Settings - Roles & Permissions,How to control side-view tab visibility per role,Settings > Display Settings > Side View,Should-have,Hide tabs from learner-profile drawer for specific roles. +Settings - Custom Fields,How to add a custom field,Settings > Custom Fields > Add Field,Must-have,Add a configurable field shown on forms / profiles / lists. +Settings - Custom Fields,How to group custom fields into a section,Settings > Custom Fields > Field Groups,Should-have,Group related fields so they render together. +Settings - Custom Fields,How to control field visibility per location,Settings > Custom Fields > [Field] > Visibility,Should-have,Show / hide a field per surface (learner list / admission form / lead profile). +Settings - Custom Fields,How to make a field required or optional,Settings > Custom Fields > [Field] > Required,Should-have,Toggle the required flag on a custom field. +Settings - Naming,How to rename course / level / session terms,Settings > Naming > Content Terms,Must-have,Use your institute's own vocabulary (e.g. Programme / Batch). +Settings - Naming,How to rename learner / teacher / counsellor terms,Settings > Naming > Role Terms,Should-have,Switch learner-facing labels (e.g. Student / Trainee). +Settings - Naming,How to rename system terminology,Settings > Naming > System Terms,Should-have,Override generic system labels institute-wide. +Settings - Payments,How to connect Razorpay,Settings > Payment Gateways > Razorpay,Must-have,Connect Razorpay as your payment gateway. +Settings - Payments,How to connect Stripe,Settings > Payment Gateways > Stripe,Must-have,Connect Stripe as your payment gateway. +Settings - Payments,How to connect Cashfree,Settings > Payment Gateways > Cashfree,Should-have,Connect Cashfree as an additional gateway. +Settings - Payments,How to connect eWay,Settings > Payment Gateways > eWay,Nice-to-have,Connect eWay (Australia / New Zealand) as a gateway. +Settings - Payments,How to set the default payment gateway,Settings > Payment Gateways > Default,Must-have,Pick which gateway processes new payments by default. +Settings - Payments,How to set up tax / GST,Settings > Invoice > Tax Settings,Must-have,Configure tax rates applied to invoices. +Settings - Payments,How to design an invoice template,Settings > Invoice > Templates,Should-have,Customize how invoices look (header / footer / line items). +Settings - Payments,How to set up payment reminders,Settings > Notifications > Payment Reminders,Should-have,Auto-remind learners about pending dues. +Settings - Payments,How to enable test mode on a gateway,Settings > Payment Gateways > Test Mode,Nice-to-have,Run transactions in sandbox mode for verification. +Settings - Communication,How to connect Wati WhatsApp,Settings > WhatsApp Settings > Wati,Must-have,Connect Wati as your WhatsApp provider. +Settings - Communication,How to connect Meta WhatsApp Business API,Settings > WhatsApp Settings > Meta,Must-have,Connect Meta directly as your WhatsApp provider. +Settings - Communication,How to manage Meta-approved WhatsApp templates,Settings > WhatsApp > Templates,Must-have,Author / submit / sync Meta-approved templates. +Settings - Communication,How to set up custom SMTP for email,Settings > Email Settings > SMTP,Must-have,Send email from your own domain. +Settings - Communication,How to connect SendGrid,Settings > Email Settings > SendGrid,Should-have,Use SendGrid as your email provider. +Settings - Communication,How to set up an SMS provider,Settings > SMS Settings,Should-have,Connect an SMS gateway. +Settings - Communication,How to test your email setup,Settings > Email Settings > Test Send,Should-have,Send a test email to verify your configuration. +Settings - Telephony,How to connect Exotel,Settings > Telephony > Exotel,Must-have,Connect Exotel for click-to-call and recording. +Settings - Telephony,How to configure inbound call routing,Settings > Telephony > Inbound Routing,Should-have,Decide what happens when leads call your business number. +Settings - Telephony,How to set up IVR,Settings > Telephony > IVR,Should-have,Build a press-1-press-2 menu for incoming calls. +Settings - Telephony,How to enable call recording,Settings > Telephony > Recording,Should-have,Auto-record calls for quality and training. +Settings - Telephony,How to set up voicemail fallback,Settings > Telephony > Voicemail,Nice-to-have,Route unanswered calls to a voicemail. +Settings - Live Sessions,How to connect Zoom OAuth,Settings > Integrations > Zoom,Must-have,Authorize Zoom so live sessions can be created with your account. +Settings - Live Sessions,How to set up BigBlueButton,Settings > Integrations > BigBlueButton,Should-have,Use the built-in BBB stack for live classes. +Settings - Live Sessions,How to auto-sync Zoom recordings,Settings > Integrations > Zoom > Recording Sync,Should-have,Pull Zoom recordings into the platform automatically. +Settings - Live Sessions,How to auto-sync Zoom attendance,Settings > Integrations > Zoom > Attendance Sync,Should-have,Capture Zoom attendance automatically. +Settings - Ad Platforms,How to connect Facebook Lead Ads,Settings > Integrations > Meta Lead Ads,Must-have,Auto-import leads from Facebook / Instagram lead forms. +Settings - Ad Platforms,How to set up a form webhook,Settings > Integrations > Webhooks > Form,Should-have,Push leads from any web form into the platform. +Settings - Ad Platforms,How to set up an ad-platform webhook,Settings > Integrations > Webhooks > Ad Platform,Should-have,Connect a third-party ad source via webhook. +Settings - AI,How to configure AI settings,Settings > AI Settings,Must-have,Pick default AI provider / model / behaviour. +Settings - AI,How to bring your own model keys (BYOK),Settings > AI Settings > BYOK,Should-have,Use your own OpenAI / Anthropic / Gemini key to skip Vacademy credits. +Settings - AI,How to monitor AI credit usage,Settings > AI Credits,Must-have,Track how many credits each feature is consuming. +Settings - AI,How to top up AI credits,Settings > AI Credits > Buy Credits,Should-have,Purchase additional Vacademy AI credits. +Settings - Lead Configuration,How to set up custom lead statuses,Settings > Leads > Statuses,Must-have,Define your own lead pipeline stages. +Settings - Lead Configuration,How to define lead status transitions,Settings > Leads > Statuses > Transitions,Should-have,Control which stages can move to which. +Settings - Lead Configuration,How to set up lead tiers (HOT / WARM / COLD),Settings > Leads > Tiers,Must-have,Tag leads by intent strength. +Settings - Lead Configuration,How to configure lead scoring rules,Settings > Leads > Scoring,Must-have,Auto-score leads based on signals. +Settings - Lead Configuration,How to set up Lead SLA,Settings > Leads > SLA,Must-have,Define how quickly leads must be contacted. +Settings - Lead Configuration,How to configure SLA reminder windows,Settings > Leads > SLA > Reminder Windows,Should-have,Set how many escalations and at what intervals. +Settings - Counsellor Pools,How to create a counsellor pool,Settings > Leads > Pools > Create Pool,Must-have,Group counsellors so leads route across the team. +Settings - Counsellor Pools,How to add counsellors to a pool,Settings > Leads > Pools > [Pool] > Members,Must-have,Pick which team members belong to a pool. +Settings - Counsellor Pools,How to set up round-robin distribution,Settings > Leads > Pools > [Pool] > Round Robin,Must-have,Auto-rotate new leads across the pool fairly. +Settings - Counsellor Pools,How to set up sticky-per-lead assignment,Settings > Leads > Pools > [Pool] > Sticky,Should-have,Keep the same counsellor for the same lead. +Settings - Counsellor Pools,How to set up region-based routing,Settings > Leads > Pools > [Pool] > Region,Should-have,Route leads based on lead's region. +Settings - Counsellor Pools,How to configure pool working hours / shifts,Settings > Leads > Pools > [Pool] > Schedule,Should-have,Restrict assignment to active counsellors only. +Settings - Counsellor Pools,How to bulk re-assign leads when a counsellor goes inactive,Settings > Leads > Pools > Bulk Reassign,Should-have,Redistribute leads when someone leaves the team. +Settings - Memberships,How to create a membership plan,Settings > Memberships > Create Plan,Should-have,Define a subscription with billing cycle. +Settings - Memberships,How to set a membership price,Settings > Memberships > [Plan] > Pricing,Should-have,Configure recurring price and currency. +Settings - Coupons,How to create a coupon code,Settings > Coupons > Create Coupon,Must-have,Issue a discount code. +Settings - Coupons,How to set coupon expiry and usage limits,Settings > Coupons > [Coupon] > Rules,Should-have,Cap how many times the code can be used. +Settings - Certificates,How to design a certificate template,Settings > Certificates > Create Template,Should-have,Use the visual editor to design completion certificates. +Settings - Certificates,How to auto-issue certificates on course completion,Settings > Certificates > Auto-Issue,Should-have,Mint certificates automatically when learners complete a course. +Settings - Referral,How to set up a referral program,Settings > Referral,Should-have,Reward learners for bringing in friends. +Settings - Referral,How to set referral rewards,Settings > Referral > Rewards,Should-have,Define cash / credit / discount on a successful referral. +Settings - Sub-Org,How to create a sub-organization,Settings > Sub-Org > Create,Should-have,Add a child branch under the main institute. +Settings - Sub-Org,How to add admins to a sub-org,Settings > Sub-Org > [Sub-Org] > Members,Should-have,Give sub-org admins their own scope. +Settings - Sub-Org,How to switch between sub-organizations,Dashboard > Org Switcher,Should-have,Move between sub-orgs from the top bar. +Settings - Activity Logs,How to view admin activity logs,Admin Activity Logs,Must-have,Full audit trail of admin actions. +Settings - Activity Logs,How to filter activity logs by user,Admin Activity Logs > Filters > User,Should-have,Drill into one team member's actions. +Settings - Activity Logs,How to set log retention policy,Settings > Activity Logs > Retention,Nice-to-have,Configure how long audit trails are kept. +LMS - Course Setup,How to create a course,Courses > Create Course,Must-have,Set up a new course with name / description / category. +LMS - Course Setup,How to set a course thumbnail,Courses > [Course] > Settings > Thumbnail,Should-have,Upload a cover image for the course card. +LMS - Course Setup,How to set course pricing,Courses > [Course] > Pricing,Must-have,Configure the price and payment plan. +LMS - Course Setup,How to add course prerequisites,Courses > [Course] > Prerequisites,Should-have,Require learners to complete other courses first. +LMS - Course Setup,How to add course faculty,Courses > [Course] > Faculty,Should-have,Tag instructors that teach this course. +LMS - Course Setup,How to publish or unpublish a course,Courses > [Course] > Publish,Must-have,Toggle whether learners can see this course. +LMS - Course Setup,How to clone a course,Courses > [Course] > Clone,Should-have,Duplicate a course for a new batch. +LMS - Course Setup,How to archive a course,Courses > [Course] > Archive,Nice-to-have,Hide an old course without deleting it. +LMS - Course Setup,How to create a course bundle,Courses > Bundles > Create Bundle,Should-have,Sell multiple courses together as one offering. +LMS - Course Setup,How to create a level,Courses > [Course] > Create Level,Must-have,Add a level (e.g. Beginner / Advanced). +LMS - Course Setup,How to create a session,Courses > [Course] > [Level] > Create Session,Must-have,Create a session (May 2026 batch) inside a level. +LMS - Course Setup,How to add a subject,Courses > [Session] > Add Subject,Must-have,Add a subject inside a session. +LMS - Course Setup,How to add a module,Courses > [Subject] > Add Module,Must-have,Break a subject into modules. +LMS - Course Setup,How to add a chapter,Courses > [Module] > Add Chapter,Must-have,Add chapters inside a module. +LMS - Course Setup,How to reorder modules and chapters,Courses > [Subject] > Reorder,Should-have,Drag-and-drop to change the structure. +LMS - Course Setup,How to set drip conditions on chapters,Courses > [Course] > Drip Conditions,Should-have,Unlock chapters based on time or completion. +LMS - Course Setup,How to set time-based chapter unlock,Courses > [Course] > Drip > Time,Should-have,Release chapters on a fixed schedule. +LMS - Content,How to add a PDF slide,Courses > [Chapter] > Add Slide > PDF,Must-have,Upload a PDF as a slide. +LMS - Content,How to add a video slide,Courses > [Chapter] > Add Slide > Video,Must-have,Upload or link a video lecture as a slide. +LMS - Content,How to add a document slide,Courses > [Chapter] > Add Slide > Document,Should-have,Add a doc / spreadsheet / presentation as a slide. +LMS - Content,How to add a quiz slide,Courses > [Chapter] > Add Slide > Quiz,Must-have,Insert a quick quiz inline within course flow. +LMS - Content,How to add a coding question slide,Courses > [Chapter] > Add Slide > Coding,Should-have,Add a coding problem that learners can run and submit. +LMS - Content,How to add a presentation slide,Courses > [Chapter] > Add Slide > Presentation,Should-have,Use slides as in-class presentations. +LMS - Content,How to upload supplementary materials,Courses > [Chapter] > Materials,Should-have,Attach extra resources to a chapter. +LMS - Content,How to reorder slides within a chapter,Courses > [Chapter] > Reorder Slides,Should-have,Change the order learners see slides. +LMS - Content,How to preview a slide as a learner,Courses > [Slide] > Preview,Nice-to-have,See what the learner sees. +LMS - Live Sessions,How to schedule a live session,Live Sessions > Create Session,Must-have,Pick date / time / provider for a live class. +LMS - Live Sessions,How to create a recurring live session,Live Sessions > Create > Recurring,Should-have,Set a weekly / daily repeat. +LMS - Live Sessions,How to enable session recording,Live Sessions > [Session] > Recording,Should-have,Auto-record sessions to the learner library. +LMS - Live Sessions,How to track session attendance,Live Sessions > [Session] > Attendance,Must-have,View who joined / left and total watch time. +LMS - Live Sessions,How to send pre-session reminders,Live Sessions > [Session] > Reminders,Should-have,Trigger WhatsApp / email reminders before the session starts. +LMS - Live Sessions,How to let learners book a session slot,Live Sessions > Booking,Should-have,Open self-service slot booking to learners. +LMS - Question Bank,How to add a question to the question bank,Question Bank > Add Question,Must-have,Save a question for reuse across assessments. +LMS - Question Bank,How to import questions from a file,Question Bank > Import,Must-have,Bulk add questions from CSV / Word / PDF. +LMS - Question Bank,How to tag a question by topic,Question Bank > [Question] > Tags,Should-have,Categorise questions for easy lookup. +LMS - Question Bank,How to set question difficulty,Question Bank > [Question] > Difficulty,Should-have,Mark questions easy / medium / hard. +LMS - Question Bank,How to organise questions into sections,Question Bank > Sections,Should-have,Group questions by section for sectioned assessments. +LMS - Question Bank,How to search the question bank,Question Bank > Search,Must-have,Find a question quickly by content / tag / type. +LMS - Question Bank,How to archive a question,Question Bank > [Question] > Archive,Nice-to-have,Retire old questions without deleting them. +LMS - Assessments,How to create an assessment,Assessments > Create Assessment,Must-have,Set up a new assessment with name / type / instructions. +LMS - Assessments,How to add questions manually,Assessments > [Assessment] > Add Question,Must-have,Add MCQ / subjective / numerical / coding questions. +LMS - Assessments,How to import questions from question bank,Assessments > [Assessment] > Import from Bank,Must-have,Reuse questions from your saved bank. +LMS - Assessments,How to generate questions from a topic with AI,AI Center > VSmart > Topic Questions,Should-have,Auto-generate a question set from a topic prompt. +LMS - Assessments,How to upload questions from a document,AI Center > VSmart > Extract,Should-have,Let AI extract questions from a PDF. +LMS - Assessments,How to set assessment duration,Assessments > [Assessment] > Duration,Must-have,Configure the time limit for an attempt. +LMS - Assessments,How to set assessment instructions,Assessments > [Assessment] > Instructions,Should-have,Tell learners how to attempt the assessment. +LMS - Assessments,How to enable negative marking,Assessments > [Assessment] > Negative Marking,Should-have,Deduct marks for wrong answers. +LMS - Assessments,How to assign assessment to a batch,Assessments > [Assessment] > Assign,Must-have,Choose which course / batch sees the assessment. +LMS - Assessments,How to enable proctoring,Assessments > [Assessment] > Proctoring,Must-have,Turn on camera / fullscreen / anti-cheating signals. +LMS - Assessments,How to schedule an assessment window,Assessments > [Assessment] > Schedule,Must-have,Set the live window during which learners can attempt. +LMS - Assessments,How to create a sectioned assessment,Assessments > Create > Sections,Should-have,Build an assessment with multiple sections. +LMS - Assessments,How to create a practice test,Assessments > Create > Practice,Should-have,Offer a no-stakes practice attempt. +LMS - Assessments,How to create a mock test,Assessments > Create > Mock,Should-have,Simulate the real assessment under timed conditions. +LMS - Assessments,How to create a homework assignment,Assessments > Create > Homework,Should-have,Set out-of-class assignments. +LMS - Assessments,How to bulk import assessments,Assessments > Bulk Import,Nice-to-have,Bulk create many assessments at once. +LMS - Assessments,How to clone an assessment,Assessments > [Assessment] > Clone,Should-have,Duplicate an assessment to reuse the structure. +LMS - Grading,How to grade subjective answers,Assessments > [Assessment] > Grade,Must-have,Score open-ended answers by hand. +LMS - Grading,How to use AI to evaluate handwritten papers,AI Center > VSmart > Feedback,Should-have,Scan handwritten answer sheets and let AI grade them. +LMS - Grading,How to view assessment results,Assessments > [Assessment] > Results,Must-have,See the score table for all attempts. +LMS - Grading,How to publish results to learners,Assessments > [Assessment] > Publish Results,Must-have,Control when scores become visible. +LMS - Grading,How to handle re-evaluation requests,Assessments > [Assessment] > Re-eval Requests,Should-have,Process learner requests for re-grading. +LMS - Grading,How to view individual response analysis,Assessments > [Assessment] > [Learner] > Responses,Must-have,Drill into one learner's answers. +LMS - Grading,How to export all responses to CSV,Assessments > [Assessment] > Export Responses,Should-have,Download response data for offline analysis. +LMS - Analytics,How to view assessment analytics,Assessments > [Assessment] > Analytics,Must-have,Question-wise difficulty / score distribution / attempt rate. +LMS - Analytics,How to view question-wise insights,Assessments > [Assessment] > Insights,Should-have,Identify which questions need rework. +LMS - Analytics,How to view learner insights,Learner Insights,Must-have,See progress / engagement / risk across all learners. +LMS - Analytics,How to generate a learner analysis report,Learner Insights > Generate Report,Should-have,Deep-dive AI analysis on one learner. +LMS - Analytics,How to view course completion analytics,Learner Insights > Course Reports,Should-have,Track how many learners finish each course. +LMS - Analytics,How to view batch performance,Learner Insights > Batch Analytics,Should-have,Compare performance across batches. +LMS - Analytics,How to view cohort comparison,Learner Insights > Cohort Compare,Nice-to-have,Compare two cohorts side by side. +LMS - Materials,How to upload learning materials,Manage Content > Upload,Must-have,Add a learning resource to a folder. +LMS - Materials,How to organise materials in folders,Manage Content > Folders,Should-have,Create / move / nest folders for materials. +LMS - Materials,How to share materials with a course,Manage Content > [File] > Share,Should-have,Make a file available inside a course. +LMS - Materials,How to bulk upload files,Manage Content > Bulk Upload,Should-have,Drag-and-drop multiple files at once. +LMS - Doubts,How to view all student doubts,Doubts,Must-have,See open questions across courses. +LMS - Doubts,How to assign a doubt to an instructor,Doubts > [Doubt] > Assign,Must-have,Route a doubt to the right teacher. +LMS - Doubts,How to reply to a doubt,Doubts > [Doubt] > Reply,Must-have,Send a written / video / audio response. +LMS - Doubts,How to attach files to a doubt reply,Doubts > [Doubt] > Reply > Attach,Should-have,Include a PDF / image / diagram in your reply. +LMS - Doubts,How to set SLA for doubt resolution,Settings > Doubts > SLA,Should-have,Define max response time. +LMS - Doubts,How to mark doubts as resolved,Doubts > [Doubt] > Resolve,Must-have,Close out a doubt thread. +LMS - Doubts,How to view doubt analytics,Doubts > Analytics,Nice-to-have,See average response time / counts per instructor. +LMS - Coding,How to set up Judge0 for code execution,Settings > Integrations > Judge0,Should-have,Connect Judge0 so coding submissions can run. +LMS - Coding,How to create a coding question,Question Bank > Coding > Add,Should-have,Author a coding problem. +LMS - Coding,How to add test cases to a coding question,Question Bank > Coding > [Question] > Test Cases,Should-have,Define what passing looks like. +LMS - Coding,How to add starter code,Question Bank > Coding > [Question] > Starter Code,Should-have,Give learners a scaffold to start from. +LMS - Coding,How to set time and memory limits,Question Bank > Coding > [Question] > Limits,Should-have,Constrain how much compute a submission can use. +LMS - Coding,How to view coding submissions,Assessments > [Assessment] > Submissions > Coding,Should-have,Inspect a learner's code and run output. +LMS - Certificates,How to issue a certificate to a learner,Manage Learners > [Learner] > Certificates > Issue,Should-have,Hand out a one-off certificate. +LMS - Certificates,How to bulk issue certificates,Manage Learners > Certificates > Bulk Issue,Nice-to-have,Issue certificates to a whole batch at once. +AI Tools - VSmart,How to create a question paper from a document,AI Center > VSmart > Question Paper,Must-have,Upload a chapter and get a question paper. +AI Tools - VSmart,How to digitise a scanned question paper,AI Center > VSmart > Paper Digitiser,Must-have,Convert a scanned paper into editable digital questions. +AI Tools - VSmart,How to organise your question bank with AI,AI Center > VSmart > Bank Organiser,Should-have,Auto-tag and categorise questions by topic and difficulty. +AI Tools - VSmart,How to generate questions from a topic,AI Center > VSmart > Topic Questions,Must-have,Type a topic and get questions. +AI Tools - VSmart,How to use the AI Lesson Planner,AI Center > VSmart > Lesson Planner,Should-have,Generate a structured lesson plan from a topic. +AI Tools - VSmart,How to use the AI Lecture Coach,AI Center > VSmart > Lecture Coach,Nice-to-have,Get AI feedback on a recorded lecture. +AI Tools - VSmart,How to use the AI Course Creator,AI Center > VSmart > Course Creator,Should-have,Describe a course and AI builds the full outline. +AI Tools - VSmart,How to use VSmart Chat,AI Center > VSmart > Chat,Should-have,Have a conversation with AI about your content. +AI Tools - VSmart,How to use VSmart Image,AI Center > VSmart > Image,Nice-to-have,AI image processing tool. +AI Tools - VSmart,How to use VSmart Audio,AI Center > VSmart > Audio,Nice-to-have,AI audio processing / speech-to-text. +AI Tools - VSmart,How to use VSmart Prompt for custom AI tasks,AI Center > VSmart > Prompt,Nice-to-have,Run a custom prompt against your data. +AI Tools - VSmart,How to use VSmart Sorter,AI Center > VSmart > Sorter,Nice-to-have,Auto-sort questions by topic and taxonomy. +AI Tools - VSmart,How to use VSmart Extract,AI Center > VSmart > Extract,Should-have,Extract questions / content from any document. +AI Tools - VSmart,How to use VSmart Feedback for evaluation,AI Center > VSmart > Feedback,Must-have,Evaluate answer sheets with AI. +AI Tools - Vimotion,How to create a video from a prompt,Vimotion > Create > Prompt,Should-have,Type a topic and get an AI-narrated video. +AI Tools - Vimotion,How to create a video from a slide deck,Vimotion > Create > Deck,Should-have,Upload slides and get a narrated explainer video. +AI Tools - Vimotion,How to edit a Vimotion video,Vimotion > [Video] > Editor,Should-have,Adjust captions / voiceover / scenes. +AI Tools - Vimotion,How to set up a brand kit for videos,Vimotion > Brand Kits,Should-have,Apply your brand fonts / colors to all videos. +AI Tools - Vimotion,How to generate API keys for Vimotion,Vimotion > API Keys,Nice-to-have,Generate videos programmatically. +AI Tools - Vimotion,How to embed a Vimotion video in a course,Courses > [Chapter] > Add Slide > Vimotion,Should-have,Insert an AI video into your course content. +AI Tools - Instructor Copilot,How to start an Instructor Copilot session,Instructor Copilot > Start Session,Should-have,Record a live lecture for AI processing. +AI Tools - Instructor Copilot,How to generate notes from a lecture,Instructor Copilot > [Session] > Notes,Should-have,Auto-produce lecture notes. +AI Tools - Instructor Copilot,How to generate a quiz from a lecture,Instructor Copilot > [Session] > Quiz,Should-have,Auto-produce a quick quiz from what was taught. +AI Tools - Instructor Copilot,How to generate homework from a lecture,Instructor Copilot > [Session] > Homework,Should-have,Auto-produce an out-of-class assignment. +CRM - Lead Capture,How to create an audience form,Audience Manager > Create Form,Must-have,Build a custom lead-capture form. +CRM - Lead Capture,How to add fields to a form,Audience Manager > [Form] > Fields,Must-have,Configure what info you collect. +CRM - Lead Capture,How to add custom fields to a form,Audience Manager > [Form] > Custom Fields,Should-have,Use institute-specific fields on a form. +CRM - Lead Capture,How to embed a form on your website,Audience Manager > [Form] > Embed,Must-have,Get a snippet to paste on any web page. +CRM - Lead Capture,How to share a form via link,Audience Manager > [Form] > Share Link,Must-have,Send a one-tap link by WhatsApp / email. +CRM - Lead Capture,How to set form thank-you page,Audience Manager > [Form] > Thank You,Should-have,Customize what learners see after submitting. +CRM - Lead Capture,How to clone an audience form,Audience Manager > [Form] > Clone,Should-have,Duplicate a form to reuse it. +CRM - Lead Capture,How to set up form webhooks,Audience Manager > [Form] > Webhooks,Should-have,Push form submissions to an external system. +CRM - Lead Capture,How to create a lead-capture campaign,Audience Manager > Campaigns > Create,Must-have,Group leads under a tracked campaign. +CRM - Lead Capture,How to import leads from CSV,Audience Manager > Bulk Import,Must-have,Upload a spreadsheet of leads. +CRM - Lead Management,How to view recent leads,Audience Manager > Recent Leads,Must-have,See the newest leads across all campaigns. +CRM - Lead Management,How to filter and search leads,Audience Manager > Filters,Must-have,Slice leads by source / tier / score / counsellor. +CRM - Lead Management,How to open a lead's full profile,Audience Manager > [Lead] > Open Profile,Must-have,See the 17-tab side drawer. +CRM - Lead Management,How to expand to the full-screen profile overlay,Audience Manager > [Lead] > Expand,Should-have,Launch the 90vw lead overlay. +CRM - Lead Management,How to edit lead details,Audience Manager > [Lead] > Edit,Must-have,Update name / contact / address / custom fields. +CRM - Lead Management,How to merge duplicate leads,Audience Manager > [Lead] > Merge Duplicate,Should-have,Combine duplicate records. +CRM - Lead Management,How to mark a lead as Lost,Audience Manager > [Lead] > Status > Lost,Must-have,Close out a lead that won't convert. +CRM - Lead Management,How to mark a lead as Converted,Audience Manager > [Lead] > Status > Convert,Must-have,Move a converted lead into a learner record. +CRM - Lead Management,How to opt out a lead from communications,Audience Manager > [Lead] > Opt-out,Should-have,Honor unsubscribe preferences. +CRM - Lead Management,How to view a lead's activity timeline,Audience Manager > [Lead] > Activity Timeline,Must-have,See every touchpoint in chronological order. +CRM - Lead Management,How to add a note to a lead,Audience Manager > [Lead] > Activity > Add Note,Must-have,Log call summary / meeting outcome. +CRM - Lead Management,How to bulk update lead status,Audience Manager > Bulk > Update Status,Should-have,Mark many leads at once. +CRM - Lead Management,How to export leads to CSV,Audience Manager > Export,Should-have,Download leads for offline analysis. +CRM - Lead Management,How to view a lead's score history,Audience Manager > [Lead] > Score History,Should-have,Track how a lead's score has evolved. +CRM - Follow-ups,How to schedule a follow-up,Audience Manager > [Lead] > Follow-ups > Add,Must-have,Set a reminder to call / message a lead later. +CRM - Follow-ups,How to view all pending follow-ups,Audience Manager > Follow-ups,Must-have,Manage your daily follow-up workload. +CRM - Follow-ups,How to close a follow-up,Audience Manager > [Lead] > Follow-ups > Close,Must-have,Mark a follow-up done. +CRM - Follow-ups,How to view SLA breach reports,Audience Manager > Reports > SLA,Should-have,Identify leads with overdue follow-ups. +CRM - Counsellor Assignment,How to assign a lead to yourself or a teammate,Audience Manager > [Lead] > Assign Counsellor,Must-have,Take ownership or hand off a lead. +CRM - Counsellor Assignment,How to re-assign a lead's counsellor,Audience Manager > [Lead] > Assign Counsellor > Change,Should-have,Move a lead to a different counsellor. +CRM - Communication,How to send an email to a lead,Audience Manager > [Lead] > Send Email,Must-have,Compose and send a one-off email. +CRM - Communication,How to send a WhatsApp to a lead,Audience Manager > [Lead] > Send WhatsApp,Must-have,Send via a Meta-approved WhatsApp template. +CRM - Communication,How to send an SMS to a lead,Audience Manager > [Lead] > Send SMS,Should-have,Send a single-channel SMS. +CRM - Communication,How to create an email template,Settings > Templates > Email > Create,Must-have,Save reusable email designs with merge fields. +CRM - Communication,How to create a WhatsApp template,Settings > WhatsApp > Templates > Create,Must-have,Author and submit a Meta-approved WhatsApp template. +CRM - Communication,How to view email open and click rates,Audience Manager > Reports > Email,Should-have,Track inbox performance. +CRM - Communication,How to view bounced emails,Reports > Email > Bounces,Should-have,Manage your sender reputation. +CRM - Communication,How to access the WhatsApp inbox,Communication > WhatsApp Inbox,Must-have,Reply to live WhatsApp conversations. +CRM - Communication,How to handle a live WhatsApp conversation,Communication > WhatsApp Inbox > [Conversation],Must-have,Two-way chat with a lead in WhatsApp. +CRM - Communication,How to broadcast a message to a segment,Communication > Broadcast > Create,Should-have,Send the same message to many leads. +CRM - Telephony,How to make a click-to-call,Audience Manager > [Lead] > Call,Must-have,Trigger an Exotel call to a lead. +CRM - Telephony,How to view call logs,Communication > Call Logs,Must-have,See every call attempted / completed. +CRM - Telephony,How to listen to a recorded call,Communication > Call Logs > [Call] > Play,Must-have,Play back a recorded conversation. +CRM - Telephony,How to handle inbound calls,Communication > Inbound Calls,Should-have,Manage incoming calls routed to your team. +CRM - Telephony,How to view talk-time analytics,Communication > Calls > Analytics,Should-have,Monitor productive call time across the team. +CRM - Workflows,How to create a workflow,Workflow > Create Workflow,Must-have,Build a multi-step automation in the visual editor. +CRM - Workflows,How to choose a trigger,Workflow > [Workflow] > Trigger,Must-have,Pick what event starts the workflow. +CRM - Workflows,How to add a Send Email node,Workflow > [Workflow] > Add Node > Send Email,Must-have,Send an automated email. +CRM - Workflows,How to add a Send WhatsApp node,Workflow > [Workflow] > Add Node > Send WhatsApp,Must-have,Send an automated WhatsApp. +CRM - Workflows,How to add a Wait / Delay node,Workflow > [Workflow] > Add Node > Wait,Must-have,Pause the workflow for hours / days. +CRM - Workflows,How to add a Condition node,Workflow > [Workflow] > Add Node > Condition,Must-have,Branch a workflow on lead attributes. +CRM - Workflows,How to add an Action node,Workflow > [Workflow] > Add Node > Action,Should-have,Run a custom business action. +CRM - Workflows,How to schedule a workflow,Workflow > [Workflow] > Schedule,Should-have,Run a workflow on a cron-style schedule. +CRM - Workflows,How to monitor workflow executions,Workflow > [Workflow] > Executions,Should-have,See real-time runs / failures / retries. +CRM - Workflows,How to use a workflow template,Workflow > Templates,Must-have,Start from a pre-built nurture / reminder flow. +CRM - Workflows,How to test a workflow before publishing,Workflow > [Workflow] > Test Run,Should-have,Dry-run a workflow on a sample lead. +CRM - Workflows,How to pause or stop a workflow,Workflow > [Workflow] > Pause,Should-have,Halt a workflow without deleting it. +CRM - Campaigns & Reports,How to view campaign performance,Audience Manager > Campaigns > [Campaign],Must-have,See conversion / counts / source ROI per campaign. +CRM - Campaigns & Reports,How to view source-wise lead reports,Audience Manager > Reports > Sources,Should-have,Compare lead quality across sources. +CRM - Campaigns & Reports,How to view counsellor performance,Settings > Leads > Pools > Performance,Must-have,Calls made / leads converted per counsellor. +CRM - Campaigns & Reports,How to view conversion funnel,Audience Manager > Reports > Funnel,Should-have,Where leads drop off in your pipeline. +CRM - Campaigns & Reports,How to export campaign reports,Audience Manager > Reports > Export,Nice-to-have,Download for offline analysis. +Admissions,How to view all enquiries,Admissions > Enquiries,Must-have,See top-of-funnel enquiry submissions. +Admissions,How to convert an enquiry to an application,Admissions > [Enquiry] > Convert,Must-have,Move an enquiry into the application pipeline. +Admissions,How to create an admission form,Admissions > Forms > Create,Must-have,Build the application form for a program. +Admissions,How to view application status,Admissions > [Application] > Status,Must-have,Drill into each applicant's stage. +Admissions,How to manage applicant communication,Admissions > [Application] > Communication,Must-have,Email / WhatsApp / SMS an applicant. +Admissions,How to bulk import applicants,Admissions > Bulk Import,Should-have,Upload many applicants from CSV. +Admissions,How to assign an applicant to a counsellor,Admissions > [Application] > Assign,Must-have,Take ownership of an applicant. +Admissions,How to track applicant documents,Admissions > [Application] > Documents,Should-have,See which documents are submitted / pending. +Admissions,How to set application stages,Settings > Admissions > Stages,Should-have,Configure the stages an applicant moves through. +Manage Learners,How to enroll a learner manually,Manage Learners > Enroll Manually,Must-have,Add a single learner and assign them to a course. +Manage Learners,How to bulk enroll learners from CSV,Manage Learners > Bulk Enroll,Must-have,Upload a spreadsheet to enroll many learners at once. +Manage Learners,How to send a learner invite link,Manage Learners > Invite > Create,Must-have,Generate a shareable invite with payment / form options. +Manage Learners,How to view a learner's full profile,Manage Learners > [Learner],Must-have,Open the 17-tab learner side view. +Manage Learners,How to expand to the full-screen profile overlay,Manage Learners > [Learner] > Expand,Should-have,Open the 90vw full-screen profile. +Manage Learners,How to edit a learner's details,Manage Learners > [Learner] > Edit Details,Must-have,Update name / contact / address / parent / custom fields. +Manage Learners,How to upload a learner's profile photo,Manage Learners > [Learner] > Edit > Photo,Should-have,Add a photo to the learner profile. +Manage Learners,How to view a learner's course progress,Manage Learners > [Learner] > Progress,Must-have,Track chapter / slide / assessment completion. +Manage Learners,How to view a learner's test history,Manage Learners > [Learner] > Tests,Must-have,See past attempts and scores. +Manage Learners,How to view a learner's payment history,Manage Learners > [Learner] > Payment History,Must-have,See invoices / installments / pending dues. +Manage Learners,How to view a learner's communication history,Manage Learners > [Learner] > Notifications,Should-have,Inspect every message / call to this learner. +Manage Learners,How to view a learner's files,Manage Learners > [Learner] > Files,Should-have,Upload / download / organise the learner's documents. +Manage Learners,How to add tags to a learner,Manage Learners > [Learner] > Tagging,Should-have,Label learners for filtering and segmentation. +Manage Learners,How to grant portal access credentials,Manage Learners > [Learner] > Portal Access,Must-have,Share login credentials so the learner can sign in. +Manage Learners,How to send a password reset to a learner,Manage Learners > [Learner] > Portal Access > Reset,Should-have,Help a learner recover their login. +Manage Learners,How to deroll a learner from a course,Manage Learners > [Learner] > Enroll-Deroll > Cancel,Must-have,End a learner's enrollment. +Manage Learners,How to view a learner's lead profile (CRM view),Manage Learners > [Learner] > Lead Profile,Should-have,See CRM data on a learner who started as a lead. +Manage Learners,How to view the full learner history,Manage Learners > [Learner] > Full History,Should-have,Cross-functional timeline of every event. +Manage Learners,How to assign a counsellor to a learner,Manage Learners > [Learner] > Assign Counsellor,Should-have,Pick who owns this relationship. +Manage Learners,How to initiate a deep-dive learner report,Manage Learners > [Learner] > Reports > Initiate,Should-have,Trigger an AI analysis on one learner. +Manage Learners,How to view a learner's enrollment & deroll history,Manage Learners > [Learner] > Enroll-Deroll,Should-have,Audit course memberships over time. +Manage Learners,How to view a learner's membership status,Manage Learners > [Learner] > Membership,Should-have,Track active subscriptions per learner. +Manage Learners,How to view sub-org affiliation of a learner,Manage Learners > [Learner] > Sub-Org,Nice-to-have,See which sub-org a learner belongs to. +Manage Contacts,How to view all contacts,Manage Contacts > All Contacts,Must-have,See every contact (lead + learner) in one place. +Manage Contacts,How to filter contacts by tier,Manage Contacts > Filters > Tier,Should-have,Show only HOT / WARM / COLD leads. +Manage Contacts,How to filter contacts by source,Manage Contacts > Filters > Source,Should-have,Slice contacts by where they came from. +Manage Contacts,How to bulk import contacts,Manage Contacts > Bulk Import,Must-have,Upload contacts from CSV with mapping. +Manage Contacts,How to add tags to contacts,Manage Contacts > [Contact] > Tags,Should-have,Label contacts for segmentation. +Manage Contacts,How to open the full profile overlay,Manage Contacts > [Row] > Expand,Should-have,Launch the 90vw full-screen profile. +Manage Contacts,How to link a contact to a learner,Manage Contacts > [Contact] > Link Learner,Nice-to-have,Connect a contact record to an enrolled learner. +Payments & Fees,How to create a fee plan,Settings > Fee Management > Create Plan,Must-have,Define course fees with installment structure. +Payments & Fees,How to add installments to a fee plan,Settings > Fee Management > [Plan] > Installments,Must-have,Break fees into payment tranches. +Payments & Fees,How to set late fee rules,Settings > Fee Management > Late Fees,Should-have,Auto-apply late fees on overdue installments. +Payments & Fees,How to create an invoice for a learner,Manage Learners > [Learner] > Payment History > Create Invoice,Must-have,Generate a one-off invoice on demand. +Payments & Fees,How to edit CPO installments per learner,Manage Learners > [Learner] > Payment History > Installments,Should-have,Adjust amounts / due dates for one learner. +Payments & Fees,How to send a payment link to a learner,Manage Learners > [Learner] > Payment History > Send Link,Must-have,One-tap payment link via WhatsApp / email. +Payments & Fees,How to record a manual / cash payment,Manage Payments > Record Payment,Should-have,Log a payment received outside the gateway. +Payments & Fees,How to view payment logs,Manage Payments > Payment Logs,Must-have,Search and filter every transaction. +Payments & Fees,How to handle a refund,Manage Payments > [Payment] > Refund,Should-have,Initiate a refund through the gateway. +Payments & Fees,How to download a payment report,Manage Payments > Reports > Export,Should-have,Get CSV of payments for accounting. +Payments & Fees,How to reconcile payments,Manage Payments > Reconciliation,Nice-to-have,Match platform records to bank statements. +Reports & Dashboards,How to use the main admin dashboard,Dashboard,Must-have,KPIs / quick actions / recent activity at a glance. +Reports & Dashboards,How to view real-time activity,Dashboard > Real-time,Should-have,See who is active right now. +Reports & Dashboards,How to view today's activity,Dashboard > Activity,Should-have,Daily / hourly engagement charts. +Reports & Dashboards,How to view finance summary,Dashboard > Finance Summary,Must-have,Revenue / collections / pending dues. +Reports & Dashboards,How to view assessment dashboards,Assessments > Dashboard,Should-have,Roll-up of attempts / averages / completion. +Reports & Dashboards,How to schedule a recurring report,Reports > Scheduled Reports > Create,Nice-to-have,Auto-email reports on a schedule. +Drip & Notifications,How to create notification templates,Settings > Notification Templates > Create,Should-have,Customize system notification copy. +Drip & Notifications,How to enable or disable notification channels,Settings > Notifications > Channels,Should-have,Pick which channels (email / WhatsApp / SMS / push) deliver alerts. +Drip & Notifications,How to set up announcement broadcasts,Communication > Announcements > Create,Should-have,Broadcast a message to all learners. diff --git a/docs/onboarding-guide-learner-flows.csv b/docs/onboarding-guide-learner-flows.csv new file mode 100644 index 0000000000..11034f6b92 --- /dev/null +++ b/docs/onboarding-guide-learner-flows.csv @@ -0,0 +1,11 @@ +Use Case Group,Video Title,Flow,Priority,Description +Getting Started,How to sign up and complete your profile,Sign Up > Verify OTP > Profile > Edit > Upload Photo > Personal Details,Must-have,Sign up with email or phone / verify OTP / fill in name & photo & details. The very first thing every new learner does. +Getting Started,How to navigate your dashboard,Dashboard > My Courses + Live Classes + Streak + Notifications + Achievements,Must-have,Tour the home screen so learners know where everything lives: courses / live class calendar / streak counter / notifications / achievements. +Courses,How to take your first course,My Courses > [Course] > [Chapter] > Slide > Mark Complete > Download PDF > Offline Access,Must-have,The core learner flow: open a course / start a chapter / view slides / mark complete / download materials / enable offline access for travel or low-signal. +Live Classes,How to join and follow up on a live class,Live Sessions > Upcoming > Join > Past > Watch Recording > Attendance,Must-have,View your schedule of live classes / join one at the right time / re-watch the recording afterwards / check your attendance percentage. +Assessments,How to take an assessment,Assessments > [Assessment] > Start > Attempt > Submit > Result > Marks Breakdown > Response Analysis,Must-have,The complete assessment flow: start a timed test / attempt questions / submit / see your score / view marks per section / review your answers vs correct ones. +Doubts,How to ask a doubt and use the AI tutor,Courses > [Chapter] > Ask Doubt > Attach Photo > AI Tutor > Replies,Must-have,Two ways to get help: ask your instructor a doubt with a photo of your work / OR use the AI Tutor for instant 24/7 answers. +Payments,How to make a payment,My Payments > Invoices > [Invoice] > Pay > UPI/Card/Netbanking > Receipt > History,Must-have,Pay an invoice using UPI / card / netbanking / download the receipt / view your payment history. Covers everything about money in the app. +Profile,How to update your profile and notification settings,Profile > Edit > Photo + Password + 2FA + Notifications + Privacy Screen,Must-have,Manage your account: update details / change password / enable two-factor auth / pick which notifications you want / turn on privacy screen on mobile. +Mobile App,How to use the mobile app like a pro,Install > Offline Download > Dark Mode > OTA Updates,Should-have,Install on iOS or Android / pre-download chapters for offline study / switch to dark mode / pull the latest app version without going to the store. +Reports & Certificates,How to view your reports and certificates,My Reports > Performance + Subjects > My Certificates > Download > Share to LinkedIn,Should-have,Track your performance over time / drill into subject-wise insights / download your completion certificates / share achievements on LinkedIn. diff --git a/docs/onboarding-guide-learner.csv b/docs/onboarding-guide-learner.csv new file mode 100644 index 0000000000..76186b71a7 --- /dev/null +++ b/docs/onboarding-guide-learner.csv @@ -0,0 +1,78 @@ +Module,Video Title,Navigation Flow,Priority,Description +Getting Started,How to sign up,Learner App > Sign Up,Must-have,Create your account using email or phone. +Getting Started,How to verify your email and phone,Learner App > Sign Up > Verify,Must-have,Complete OTP verification to activate your account. +Getting Started,How to complete your profile,Learner App > Profile > Edit,Must-have,Fill in name / photo / date of birth / address. +Getting Started,How to navigate your dashboard,Learner App > Dashboard,Must-have,Tour the home screen: courses / live classes / streak / XP. +Getting Started,How to install the learner mobile app,App Store / Play Store > Search Vacademy > Install,Must-have,Get the iOS / Android app on your phone. +Getting Started,How to set notification preferences for the first time,Learner App > Profile > Notifications,Should-have,Pick which alerts you want from the platform. +Getting Started,How to switch the app language,Learner App > Profile > Language,Nice-to-have,Use the app in your preferred language. +Courses,How to access your courses,Learner App > Dashboard > My Courses,Must-have,Open the list of courses you are enrolled in. +Courses,How to start a chapter,Learner App > Courses > [Course] > [Chapter] > Start,Must-have,Begin a chapter and view its slides. +Courses,How to mark a slide complete,Learner App > Courses > [Chapter] > [Slide] > Mark Complete,Must-have,Track progress slide by slide. +Courses,How to download course PDFs and notes,Learner App > Courses > [Chapter] > Materials > Download,Should-have,Save reading material for offline access. +Courses,How to enable offline access on mobile,Learner App > Settings > Offline > Download,Should-have,Pre-download chapters for offline study. +Courses,How to track your overall course progress,Learner App > Dashboard > Continue Learning,Must-have,See completion percentage and what to do next. +Courses,How to bookmark a slide,Learner App > Courses > [Slide] > Bookmark,Should-have,Save important slides for quick access later. +Courses,How to use the streak counter and XP,Learner App > Dashboard > Streak,Should-have,Maintain a daily streak and earn XP for completing chapters. +Courses,How to view your achievements / badges,Learner App > Dashboard > Achievements,Should-have,Track milestones you have unlocked. +Courses,How to view course faculty / instructors,Learner App > Courses > [Course] > Faculty,Nice-to-have,See who teaches your course. +Courses,How to view course announcements,Learner App > Courses > [Course] > Announcements,Should-have,Read latest updates from your instructor. +Live Sessions,How to view scheduled live sessions,Learner App > Live Sessions > Upcoming,Must-have,See your calendar of live classes. +Live Sessions,How to join a live session,Learner App > Live Sessions > [Session] > Join,Must-have,Enter a live class at the scheduled time. +Live Sessions,How to access session recordings,Learner App > Live Sessions > Past > [Session] > Watch,Must-have,Re-watch a recorded class. +Live Sessions,How to view your attendance,Learner App > Live Sessions > Attendance,Should-have,Check your attendance percentage per session. +Live Sessions,How to book a session slot,Learner App > Live Sessions > Book Slot,Should-have,Reserve a slot for office hours / 1:1 sessions. +Assessments,How to take an assessment,Learner App > Assessments > [Assessment] > Start,Must-have,Begin a timed assessment. +Assessments,How to view your assessment scores,Learner App > Assessments > [Assessment] > Result,Must-have,See your total score and pass / fail status. +Assessments,How to view the marks breakdown,Learner App > Assessments > [Assessment] > Marks Breakdown,Must-have,See marks per question and per section. +Assessments,How to view your response analysis,Learner App > Assessments > [Assessment] > Response Breakdown,Should-have,Compare your answers vs the correct ones. +Assessments,How to request re-evaluation,Learner App > Assessments > [Assessment] > Request Re-eval,Should-have,Ask an instructor to re-check your grading. +Assessments,How to attempt a practice test,Learner App > Assessments > Practice,Should-have,Take a no-stakes practice attempt. +Assessments,How to attempt a mock test,Learner App > Assessments > Mock,Should-have,Simulate the real assessment under timed conditions. +Assessments,How to view your overall test history,Learner App > Assessments > History,Should-have,See all past attempts in one place. +Coding,How to attempt a coding question,Learner App > Courses > [Coding Slide] > Run Code,Should-have,Write code in the in-app editor and run it against test cases. +Coding,How to view your coding submission history,Learner App > Coding > Submissions,Nice-to-have,See past attempts and pass / fail results. +Doubts,How to ask a doubt on a chapter,Learner App > Courses > [Chapter] > Ask a Doubt,Must-have,Submit a question to your instructor. +Doubts,How to view replies to your doubts,Learner App > Doubts > [Doubt] > Replies,Must-have,Check instructor responses. +Doubts,How to attach an image or PDF to a doubt,Learner App > Doubts > New > Attach,Should-have,Include a photo of your work with your question. +Doubts,How to chat with the AI tutor,Learner App > AI Tutor > Ask,Should-have,Get instant AI-powered answers to your questions. +Doubts,How to view your doubt history,Learner App > Doubts > History,Should-have,See all your past doubts and resolutions. +Payments,How to view your invoices,Learner App > My Payments > Invoices,Must-have,See all your invoices in one place. +Payments,How to make a fee payment,Learner App > My Payments > [Invoice] > Pay,Must-have,Pay an invoice using UPI / card / netbanking. +Payments,How to use a coupon code at checkout,Learner App > My Payments > Checkout > Apply Coupon,Should-have,Apply a discount before paying. +Payments,How to view your payment history,Learner App > My Payments > History,Must-have,See all completed and pending payments. +Payments,How to download a payment receipt,Learner App > My Payments > [Invoice] > Download Receipt,Must-have,Get a printable receipt. +Payments,How to view upcoming installments,Learner App > My Payments > Upcoming,Should-have,See what's due next and when. +Payments,How to set up auto-pay for installments,Learner App > My Payments > Auto-Pay,Nice-to-have,Schedule automatic payment of installments. +Profile,How to update your profile,Learner App > Profile > Edit,Must-have,Change your name / photo / details. +Profile,How to upload a profile picture,Learner App > Profile > Upload Photo,Must-have,Add a profile photo. +Profile,How to change your password,Learner App > Profile > Security > Change Password,Must-have,Update your account password. +Profile,How to enable two-factor authentication,Learner App > Profile > Security > 2FA,Should-have,Add an extra security step at login. +Profile,How to manage notification preferences,Learner App > Profile > Notifications,Should-have,Pick which alerts you want to receive. +Profile,How to enable privacy screen on mobile,Learner App > Profile > Privacy > Privacy Screen,Should-have,Block screenshots and screen recording. +Profile,How to view your parent / guardian details,Learner App > Profile > Family,Nice-to-have,Manage parent / guardian contact info. +Profile,How to add additional contact info,Learner App > Profile > Contact,Should-have,Add backup phone / email. +Notifications,How to view your notifications,Learner App > Notifications,Must-have,See system / course / payment / message alerts. +Notifications,How to mark notifications as read,Learner App > Notifications > Mark Read,Should-have,Clear out the unread queue. +Notifications,How to filter notifications by type,Learner App > Notifications > Filter,Should-have,Show only payment / message / class alerts. +Files,How to access your files,Learner App > My Files,Should-have,Open files shared by your institute or saved by you. +Files,How to organize your files into folders,Learner App > My Files > Folders,Should-have,Create folders to keep materials tidy. +Files,How to download a shared file,Learner App > My Files > [File] > Download,Should-have,Save a shared file to your device. +Reports,How to view your performance report,Learner App > My Reports,Should-have,See progress / scores / attendance over time. +Reports,How to share a report with your parent,Learner App > My Reports > Share,Should-have,Send a snapshot to a parent or guardian. +Reports,How to view subject-wise insights,Learner App > My Reports > Subjects,Should-have,Drill into how you are doing in each subject. +Reports,How to view your test rank in the cohort,Learner App > My Reports > Rank,Nice-to-have,Compare your performance with peers anonymously. +Membership,How to view your active membership,Learner App > Profile > Membership,Should-have,See your subscription plan and renewal date. +Membership,How to renew or upgrade your plan,Learner App > Profile > Membership > Upgrade,Should-have,Move to a higher tier or extend your current plan. +Membership,How to cancel your subscription,Learner App > Profile > Membership > Cancel,Should-have,End your active subscription. +Certificates,How to download your certificate,Learner App > My Certificates > [Certificate] > Download,Should-have,Save and share your completion certificates. +Certificates,How to share a certificate to LinkedIn,Learner App > My Certificates > [Certificate] > Share,Nice-to-have,Post your achievement on LinkedIn. +Community,How to join a discussion thread,Learner App > Community > [Thread] > Join,Nice-to-have,Participate in cohort discussions. +Community,How to follow other learners,Learner App > Community > Profile > Follow,Nice-to-have,Build a learning network inside your cohort. +Community,How to ask a question in the community,Learner App > Community > New Question,Nice-to-have,Post a question to peers. +Mobile App,How to update the app over the air,Learner App > Settings > About > Check for Updates,Should-have,Pull the latest version without going to the store. +Mobile App,How to enable dark mode,Learner App > Profile > Appearance > Dark Mode,Nice-to-have,Switch to dark theme. +Mobile App,How to clear app cache,Learner App > Settings > Storage > Clear Cache,Nice-to-have,Free up space on your device. +Mobile App,How to submit feedback / report a bug,Learner App > Profile > Help > Report,Nice-to-have,Send feedback or report issues. +Help & Support,How to contact your institute,Learner App > Help > Contact Institute,Must-have,Reach your institute admin team for support. +Help & Support,How to view FAQs,Learner App > Help > FAQ,Should-have,Browse the help center for common questions. diff --git a/tools/walkthrough-generator/.gitignore b/tools/walkthrough-generator/.gitignore new file mode 100644 index 0000000000..ba9615bb8c --- /dev/null +++ b/tools/walkthrough-generator/.gitignore @@ -0,0 +1,12 @@ +# Generated output (prompts, flows, screenshots, html) — regenerable +out/ +screenshots/ +capture/_inspect/ + +# Secrets / local config for the capture stage — NEVER commit +.env +.env.* +auth-state.json + +# Node +node_modules/ diff --git a/tools/walkthrough-generator/CONTEXT.md b/tools/walkthrough-generator/CONTEXT.md new file mode 100644 index 0000000000..3b9cd82ea0 --- /dev/null +++ b/tools/walkthrough-generator/CONTEXT.md @@ -0,0 +1,346 @@ +# CONTEXT — walkthrough-generator (read me first) + +This file is the **handoff brief**: hand someone (or a fresh AI chat) *this folder* + this +file and they have everything needed to understand the task and continue it. For the +mechanics of the engine, see `README.md`; this file is the *state, conventions, and +gotchas*. + +--- + +## 1. The goal + +Turn the institute's onboarding task lists (`/docs/onboarding-guide-*.csv`, ~432 tasks) +into **self-contained animated HTML "videos"** — one `.html` per task. A "video" is a +faux-browser frame playing **real product screenshots** with an animated **ghost cursor** +that moves to each step, types into fields, opens dropdowns, with captions + a player bar. +No real video file, no screen recording, no AI re-drawing the UI — the UI shown *is* the +captured product. + +Each video is built deterministically by driving the **live demo institute** with +Playwright (one real screenshot per step) and then compositing the cursor over those +frames. + +--- + +## 2. Where things stand (state) + +| Stage | Status | +|---|---| +| **Prompts/flows generated** for all 432 tasks (`out/`) | ✅ done (the manual-LLM path) | +| **Engine** (authored flows → real video) | ✅ working, hardened | +| **First 10 admin onboarding videos** → `walkthroughs/` | ✅ first pass (some are thin — see below) | +| **v2 end-to-end videos** → `walkthroughs-v2/` | ✅ 4 done: portal-tab-title, rename-terminology, currency, lead-settings | +| **+20 more end-to-end videos** (batch 2, distinct from the 5 above) | 🔄 IN PROGRESS — see §9 batch log | +| **Remaining ~400 task videos** | ⏳ TODO — use the v2 convention + the §0 quality bar | + +**Reworked to the §0 quality bar (the good versions live in `walkthroughs-v2/`):** +- **rename terminology** — clears the Course field and types "Programme", shows the + "unsaved changes" banner, points at Save. +- **set currency** — opens the Currency dropdown and shows the full currency list (read-only). +- **lead settings** — a 3-stop tour, cursor moving down the page. +- **create a course** — the full thing: fill name/description → Next → pick a flat structure + (No sessions, No levels via the `#sessions-no`/`#levels-no` radios) → **click Create** → + the final frame is the REAL result: a "Course created successfully" toast on the new + "Foundation Science" Course Details page. This is the model for "action → true success". + +The old thin/early versions of these still sit in `walkthroughs/` — **superseded; delete or +ignore them.** Each capture of `create a course` makes one real demo course (that's fine). + +> `walkthroughs/` also contains a few earlier one-off videos from before the first-10 batch. +> Treat `walkthroughs/` as "the original 10 + legacy" and **leave it alone**; all NEW work +> goes to `walkthroughs-v2/`. + +### The first 10 (in `walkthroughs/`, deep-linked, DONE — don't redo) +dashboard tour · invite team members · white-label branding · upload logo · theme color · +custom domain · favicon · set currency · rename terminology · lead settings. +(`capture/authored.mjs` holds their specs.) + +**Honesty note baked into the first 10:** the idealized CSV listed flows that don't exist in +the real app — *dark mode* (not implemented), *institute name / time-zone* (no dedicated +screen). Those were **substituted** with real essential flows, not faked. When you continue: +verify a task maps to real UI before authoring; if it doesn't, substitute and say so. Never +fabricate a screen. + +--- + +## 3. Conventions & the quality bar (apply to ALL future videos) + +### ⭐ (0) THE QUALITY BAR — every video must be genuinely end-to-end + +This is the most important rule, learned the hard way from review feedback. A walkthrough +is a *video*, not a slideshow. It must read as a complete how-to where **every frame is the +next step of the previous one**. + +- **MOVE every frame.** Each frame must visibly advance: navigate to a new screen, move the + cursor to a *different* control, click into a field, type text, open a dropdown, select an + option. **Never two near-identical stills** with the cursor parked in the same place. + (Bad example we fixed: a "rename terminology" video that was just two identical Naming + screenshots — Step 1 and Step 2 looked the same, nothing happened.) +- **The final frame must show the REAL result.** If the caption says "…is created", "…is + saved", "…done", the frame must actually show that outcome — a success screen, a toast, + the new item in a list, the changed value, an "unsaved changes" banner, etc. **Never put a + "done/created" caption over a stale, unchanged form.** (Bad example we caught: a + "create a course" video whose last 3 frames were the *same open Add-Course modal*, with + "Your course is created and ready" over a form that was never submitted.) +- **If you can't capture a true result, don't fake it.** End the flow honestly + ("Review the details, then click Create to finish.") rather than claiming success over an + unchanged screen. (Submitting may be blocked by the safety guard, or may clutter the demo — + that's fine; just don't lie in the caption.) +- **Show the interaction, not just the destination.** Use the engine features built for this: + `then:{type:{…,clear:true}}` to *type out* a value (and replace an existing one), + `then:{select:{…,commit:false}}` to *open a dropdown and show the options*, + `point:{…,scroll:true}` to bring an off-screen control into view first. A "set currency" + video should OPEN the currency dropdown and show the list; a "rename" video should CLEAR + the field and TYPE the new word. +- **No silent dead frames.** If a `point` resolves to `NULL` (control not found) or a frame + comes back blank, that's a defect — fix the selector/settle and re-capture; never ship it. + +> Verify with `overlay-cursor.mjs` (§4): scan the frames in order — if two consecutive +> overlays look the same, the flow isn't end-to-end yet. + +### (a) Full navigation — start on the Dashboard, walk the whole path +The first 10 *deep-linked* straight to a settings tab (`/settings?selectedTab=…`). Going +forward, **every flow starts on the Dashboard and navigates step-by-step** so the viewer +learns how to *get* there: + +``` +Dashboard → click Settings (left rail) → Settings sidebar → click the tab → the action +``` + +Use the **`dashToSettings(tabValue, opts)`** helper in `capture/authored-v2.mjs`. It emits +the two shared opener frames (rail → sidebar). `tabValue` is the EXACT sidebar/card label +from `frontend-admin-dashboard/src/routes/settings/-utils/utils.ts → getAvailableSettingsTabs()` +(e.g. `'White-Label Setup'`, `'Lead Settings'`, `'Invoice Settings'`, `'Naming Settings'`, +`'Custom Fields'`, `'Coupon Settings'`). The settings sidebar is long/alphabetical, so the +tab click uses `point:{…, scroll:true}` to bring the item into view first. + +### (b) Separate output folder +New videos build into **`walkthroughs-v2/`**, leaving the original `walkthroughs/` untouched: + +``` +node capture/build-video.mjs --out=walkthroughs-v2 +``` + +Frames + manifest still go to `screenshots/flows//` (shared intermediate). Give every +new flow its **own unique slug** so it never collides with the first 10. + +> **The template is already built**: `admin-how-to-set-your-portal-tab-title` +> (in `authored-v2.mjs` → `walkthroughs-v2/`) demonstrates the complete +> Dashboard → Settings → White-Label → expand → type pattern. Copy it. + +--- + +## 4. The authoring loop (how to make one video) + +```bash +# 1. Author the flow spec in capture/authored-v2.mjs (push onto FLOWS; start with dashToSettings(...)) +# 2. Capture it against the live demo (writes real frames + manifest): +node capture/authored-v2.mjs --slugs= +# 3. QA the cursor placement WITHOUT opening the html: +node capture/overlay-cursor.mjs # → render-check//cursor-NN.png +# -> view those PNGs; each shows the ghost cursor stamped at its recorded x,y. +# -> if a cursor is NULL or off-target, fix the point in the spec and re-capture. +# 4. Build the video into the v2 folder: +node capture/build-video.mjs --out=walkthroughs-v2 +``` + +The `manifest.json` holds cursor `{x,y}`, captions, and per-frame durations, so step 4 can +be re-run to **rebuild** without re-driving the browser. Caption-only fixes can edit the +manifest directly + rebuild. + +### Verifying a batch +A good final pass is an adversarial audit: for each video, look at every +`render-check//cursor-NN.png` and confirm the cursor lands on the control the caption +names, no frame is blank, and the caption matches the screen. (The first-10 batch was +audited this way; 7/10 passed first time, 3 were fixed.) + +--- + +## 5. Step DSL (what you write in a flow spec) + +Full reference is in the header of `capture/engine.mjs`. Quick map: + +- **Navigate:** `goto:'/path'` · `navRail:'CRM'` · `navClick:{text,region}` · `settle:ms` +- **Address bar:** `path:'/x'` · **Shared screen cache:** `screen:'dashboard'` +- **Caption:** `caption:'… bold nouns …'` +- **Point the cursor (`point:{…}`):** + `{text,region}` · `{coords:[x,y]}` · `{firstField:true}` · `{field:'regex'}` (a control by + placeholder/aria/label/id) · `{sel:'css'}` (a precise element, e.g. `'#sessions-no'`) · + `{submit:true}` · add `scroll:true` to reveal a below-the-fold target first. +- **Advance after the shot (`then:{…}`):** `{clickPoint}` · `{click:{text,region}}` · + `{clickSel:'css'}` (click a precise element, e.g. a radio by id) · + `{fill:true}` · `{type:{field:'regex',value,clear?}}` (clear:true replaces the existing + value — for renames) · `{select:{trigger,option?,caption?,commit?}}` (commit:false = open & + show the list but DON'T change the setting) · `{submit:{text?}}` · `wait:ms` + +These are the levers that make a flow end-to-end (§0): `type` writes a value out progressively, +`type.clear` does a clean rename, `select` opens a real dropdown, `select.commit:false` keeps +it read-only, `point.scroll` reveals an off-screen target. The `dashToSettings()` helper in +`authored-v2.mjs` emits the shared Dashboard→Settings→sidebar opener. +- **End:** `final:true` + +`region` = where to look: `rail` (far-left icon rail, x<74) · `sidebar` (x<300) · +`content` (x>300) · `any`. Text matches are case-insensitive regex on the element's first +line, so `'^Settings$'` is exact, `'theme|#4F46E5'` is fuzzy. + +### Reference facts you'll reuse +- **Settings tab deep-link values** (only if you ever bypass navigation): + `frontend-admin-dashboard/src/routes/settings/-constants/terms.ts → SettingsTabs` + (`whiteLabel`, `invoice`, `naming`, `leadSettings`, `customFields`, `coupons`, …). +- **Settings sidebar/card labels** (what `dashToSettings` clicks): + `…/settings/-utils/utils.ts → getAvailableSettingsTabs()` (the `value` field). +- **Invoice Settings renders slowly** — give it `settle: ~5200` (or `tabWait`) or its first + frame captures blank. +- White-Label branding fields (Tab Title/Icon, Theme/Color) are behind a per-domain + **"Settings"** expand button — click `point:{text:'^Settings$',region:'content'}` first. + +--- + +## 6. Safety (the engine is institute-locked) + +- Runs only against the demo institute; asserts the logged-in `institute_id` up front and + **hard-aborts** on drift. +- **In-demo writes are allowed and encouraged** (owner-confirmed): create courses, chapters, + users, admins, and click **Save / Submit** so a flow reaches a REAL result screen (that's + how `create a course` now ends on a true success page). The only hard limits are + **third-party / outbound** calls — the network guard **aborts** payment gateways, + custom-domain/DNS, and real comms-send (WhatsApp/SMS/email), and never issues `DELETE`. So: + complete the action and show the true result; just don't trigger payment/DNS/comms (e.g. + don't Save a custom domain, don't Send a WhatsApp). +- Credentials live only in the gitignored `.env` + (`VACADEMY_BASE_URL`, `VACADEMY_INSTITUTE_ID`, `VACADEMY_USERNAME`, `VACADEMY_PASSWORD`). + Auth session is cached in `auth-state.json` (gitignored). Nothing in this folder is + committed unless explicitly asked. + +--- + +## 7. File map + +``` +capture/ + engine.mjs # the driver: runs a flow spec, writes real frames + manifest. Step DSL in its header. + authored.mjs # the original 10 flows (deep-linked). DONE — leave as-is. + authored-v2.mjs # NEW flows (dashboard-start). dashToSettings() helper + the demo. ADD HERE. + build-video.mjs # frames + manifest -> one self-contained .html. --out= to target a folder. + overlay-cursor.mjs # QA: stamps the cursor onto each real frame -> render-check//cursor-NN.png + env.mjs # loads .env, exposes TOOL_ROOT +screenshots/flows// # real frames (NN.png) + manifest.json (shared intermediate) +render-check// # cursor-overlay QA images +walkthroughs/ # the original 10 + legacy videos (LEAVE ALONE) +walkthroughs-v2/ # NEW videos go here +out/ # generated prompts/flows for all 432 tasks (manual-LLM path) +README.md # engine mechanics + the manual-LLM pipeline +``` + +--- + +## 8. Next steps for whoever continues + +0. **Internalize the §0 quality bar.** Every video is end-to-end, every frame moves, the last + frame shows the real result. This is the bar reviewers hold the work to. +1. Pick the next tasks from `out/INDEX.md` (or the `docs/onboarding-guide-*.csv`). +2. For each, **confirm it maps to real UI** (grep `frontend-admin-dashboard/src/routes/…`); + substitute truthfully if it doesn't. +3. Author it in `authored-v2.mjs` starting with `dashToSettings(, …)`, then action + steps that actually *do* the task (click in, `type`/`type.clear`, `select`, reach a result + frame); run the loop in §4; build into `walkthroughs-v2/`. +4. Audit the batch (§4 "Verifying") **against §0** — scan the overlays in order; if two + consecutive frames look the same, or the final frame doesn't show the result, it's not done. + +Learner-side videos need a learner base URL (`VACADEMY_LEARNER_BASE_URL`) and a learner +session — not set up yet. + +--- + +## 9. Batch log (real-time status) + +**Batch 1 — DONE (5, in `walkthroughs-v2/`):** create-a-course · rename-system-terminology · +set-your-time-zone-and-currency (currency) · configure-lead-scoring-rules (lead) · +set-your-portal-tab-title. + +**Batch 2 — IN PROGRESS (+20 end-to-end, distinct from batch 1):** mapped real routes / +triggers / fields / submit-enablers / success states for ~22 candidate tasks via a research +workflow (full data in the workflow transcript), then authoring → capturing → verifying each +to the §0 bar. Status: + +| Slug | Status | +|---|---| +| create-a-coupon | ✅ built — toast "Coupon WELCOME20 created" + ACTIVE row | +| create-a-payment-plan | ✅ built — toast "Payment plan created successfully" + plan in list | +| add-a-tax-rate | ⚠️ reaches "Invoice settings saved" toast, but field-targeting types into a persisted row (CGSTCGST) — fix: target the NEW/last tax row, and the demo has accumulated test rows | +| create-a-custom-field | 🔁 flaky — succeeded once, then a re-capture left the dialog open. Build the run that closes the dialog + shows the field in the list | +| set-up-custom-lead-statuses | 🔁 "Add status" is at the bottom of the Configuration tab; loosened the text match + scroll, re-capturing | +| (audience-list, session, batch, note-to-lead, enroll-learner, live-session, question-bank) | ⏳ authored data ready (research) — not yet captured | +| (custom-role, automation, email-template, certificate, workflow, subject/module/chapter/slide) | ⏳ hardest — canvas/drag-drop/upload/deep-context; may not auto-drive to a clean success | +| add-a-contact | ❌ dropped — feature doesn't exist (Manage Contacts is view-only) | + +### Batch 2/3 progress — 14 end-to-end videos built (was 8) +The breakthrough was making capture resilient to the flaky app, then a diagnose→fix→re-grind loop: + +- **`capture/grind.mjs`** — the reliable runner. Gives EACH flow its OWN fresh browser + + health-gate + up to N retries, and **auto-builds** on success. A mid-run app hang now costs + one retry, not the whole queue (that single change took the count from stuck to climbing). + `node capture/grind.mjs --slugs= --attempts=8` +- **`flow.expect`** (a success-toast regex, top-level on a flow) — a flow only counts as done + when that toast actually appears. **Make it SPECIFIC** (the real toast text), not a generic + word — `'Audience'` matched the page title and gave a false success. +- **Systemic fixes found by the diagnosis workflow (apply to new flows):** + - **Unique names** — the demo accumulates real data across runs; a hardcoded "Guardian Phone" + / "Content Reviewer" / "North Zone Pool" hits the backend "already exists" guard on re-run. + Use the `uniq('Base')` helper in authored-v2.mjs for any created entity's name. + - **Radix controls** — checkboxes are `button[role="checkbox"]` (not ``); Radix Select / + custom dropdowns close themselves on click — the engine no longer presses Escape after + selecting (Escape was closing the parent dialog). + - **Full-width buttons** (>560px) are rejected by `findByText` — target them with + `point:{sel:'button:has-text("…")'}` + `then:{clickSel}` instead. + - **Save POSTs need time** — give a Save/submit `wait: ~4000` so the toast/clean-state render + before the final frame; and when a page has TWO "Save" buttons, point at the right one + (`text:'^Save$'` is top-most via `clickPoint`, not the `submit`/preferBottom one). + +**18 end-to-end videos built (walkthroughs-v2/)** — more than doubled from 8: +the 5 batch-1 + create-course + coupon + payment + lead-statuses + assessment-settings + +custom-field + session + custom-role + doubt-category + lead-pool + content-protection + +custom-team + live-session-settings + course-settings. + +The reliable recipe: author from research → `grind.mjs` → if a flow fails, run the diagnosis +workflow (reads its overlay frames + the real component code → exact root cause + fix) → apply +→ re-grind. Every failure this far has had a precise, real cause (duplicate name, Radix +selector, an Escape that closed a dialog, a "Clear All Fields" menu item, a wrong/`Save now` +button, a faded toast). Systemic fixes that benefit all flows now live in the engine. + +**The two reliable high-yield categories (largely mined out):** +1. **Settings "create X" with a clear toast** (coupon, payment-plan, custom-field, custom-role, + doubt-category, lead-pool, lead-statuses) — done. +2. **Settings "toggle a switch → Save → toast"** — works ONLY for tabs with a single global + save banner (live-session-settings, course-settings, content-protection, assessment-settings + landed). Tabs that are multi-section pages with per-card save buttons (student-display, + notification, lms) do NOT fit and need custom per-tab authoring. + +**What's left needs custom per-flow work (not a reusable pattern):** +- multi-step wizards still stuck after 2 fix rounds: **audience-list** (campaign-type commit), + **batch** (3-step dropdown wizard), **referral-reward** (tier config). +- the 3 multi-section settings tabs above. +- course-content (subject/module/chapter/level — need a course open + sometimes uploads). +- **genuinely infeasible** (won't auto-reach a real success, don't fake): the **workflow + ReactFlow canvas**, **certificate + email drag-drop designers**, **PDF/file-upload slides**. + +**Realistic ceiling ≈ 20–25** clean, non-faked videos. Each beyond ~18 is a custom dig. + +### Resource note (real blocker on this machine) +Capturing launches a headless chromium per flow; with the demo's own large Chrome session the +box ran to ~1.5 GB free RAM and `fork`/CreateFileMapping failures killed runs. Mitigations: +grind one flow at a time (it's already sequential), don't leave detached grinds (`&`) running, +and kill orphaned capture-`node` procs (match cmdline `walkthrough-generator\\capture|grind.mjs`) +— never the harness/MCP. Freeing the demo's Chrome tabs lets several flows run in parallel again. + +### ⚠️ Two realities learned in batch 2 (important for whoever continues) +1. **The demo app intermittently HANGS on load** → a flow then resolves almost no cursors + (all-NULL frames = a blank/spinner page). The engine now **self-heals**: `runFlows` retries + a flow up to 3× when it resolves `<2` cursors, re-warming `/dashboard` between tries, plus a + 3.5s startup warmup and an 0.8s breather between flows. Capture is still a roll of the dice — + **build a flow the moment it reaches success; don't blindly re-capture a good one** (a bad + re-capture overwrites the good frames). +2. **Repeated captures POLLUTE the demo** with real test data (coupons, plans, tax rows, …). + That's allowed, but it accumulates and can confuse field-targeting (e.g. typing into an + existing row). Prefer targeting the NEW/last element, and don't re-run a create more than + needed. diff --git a/tools/walkthrough-generator/README.md b/tools/walkthrough-generator/README.md new file mode 100644 index 0000000000..c031e5b32c --- /dev/null +++ b/tools/walkthrough-generator/README.md @@ -0,0 +1,196 @@ +# Walkthrough generator + +Turns the `docs/onboarding-guide-*.csv` task lists into **animated HTML walkthrough +videos** — one self-contained `.html` per task — with as little manual work as +possible. + +A "video" here is the self-playing animated HTML file produced by an LLM from +**(a)** screenshots of the flow + **(b)** the master walkthrough prompt. This tool's +job is to make (a) and (b) effortless for all ~430 tasks; you (or, optionally, the +Claude API) run the LLM step that emits the HTML. + +## Pipeline + +``` +docs/*.csv ──▶ [1] generate.mjs ──▶ out/prompts/.md (filled master prompt) + out/flows/.json (step list) + out/INDEX.md / index.json + +(later) ──▶ [2] capture (Playwright) ──▶ out/screenshots//step-NN.png + +then YOU ──▶ [3] paste prompt + screenshots into Claude ──▶ .html ← the walkthrough +``` + +## What you get (output, in `out/`) + +| Path | What it is | Stage | +|---|---|---| +| `out/prompts/.md` | The full master prompt, pre-filled for this task (its flow description + route hint). **Paste this into Claude.** | 1 (done) | +| `out/flows/.json` | The parsed step list for the task (used by capture). | 1 (done) | +| `out/index.json`, `out/INDEX.md` | Catalog of every task with its slug + steps. | 1 (done) | +| `out/screenshots//step-NN.png` | One clean screenshot per step. **Attach these to Claude.** | 2 (capture) | + +## How to get the HTML for one flow + +You run the LLM step yourself (no API key needed): + +1. Open `out/prompts/.md` and copy all of it. +2. Attach the images in `out/screenshots//`. +3. Paste into Claude (claude.ai). It returns **one self-contained `.html`**. +4. Save it as `.html` and open in a browser — that's the animated walkthrough. + +### Doing ~430 of them with less effort + +- **claude.ai Project (recommended manual path):** create a Project, paste + `master-prompt.md` once as the Project's custom instructions. Then per task you + only drop the screenshots + one line: `This covers: . Route hint: <slug>.` + No need to paste the whole prompt every time. +- **Full automation (optional, needs an API key):** with an `ANTHROPIC_API_KEY`, + the generate step can submit all tasks as **one Claude batch** and write every + `<slug>.html` automatically. Off by default. + +## Stage 1 — generate prompts (offline, safe) + +``` +node tools/walkthrough-generator/generate.mjs +``` + +Reads only the CSVs and writes only local files. **No network, no app, no +backend, no institute/user is touched.** + +## Stage 2 — capture screenshots (Playwright) — SAFETY GUARANTEES + +The capture stage drives the real admin app to screenshot each step. It is built so +it **cannot affect any other institute, user, or the platform**: + +- **Single-institute lock.** Runs only with the demo-institute login; asserts the + logged-in `institute_id` equals the configured demo ID and **hard-aborts** + otherwise. (Multi-tenant isolation already prevents cross-tenant access.) +- **Read-only capture.** Navigates and opens dialogs/fills fields for the + screenshot, but **never clicks terminal actions** (Save / Create / Invite / + Send / Publish / Delete / Connect / Pay). No data is created even in the demo + institute. +- **No real payment / no outbound.** Network requests to payment gateways + (Razorpay/Stripe/Cashfree), invoice-pay, and comms send endpoints + (email/WhatsApp/SMS/Exotel telephony) are **blocked at the network layer**. +- Credentials live only in a **gitignored `.env`** (never committed). + +Config (`.env`, gitignored): + +``` +VACADEMY_BASE_URL= # the admin app URL to drive (provided by you) +VACADEMY_INSTITUTE_ID=3be88465-0100-4a34-807b-c22c80c86b87 +VACADEMY_USERNAME=admin_distancelearning +VACADEMY_PASSWORD=... +``` + +> Nothing in this folder is committed or pushed unless you explicitly ask. + +--- + +## Engine — authored flows → real walkthrough videos (deterministic, no LLM) + +The pipeline above (CSV → prompt → paste into Claude) is the **manual** path. There is +also an **engine** path that produces a finished, self-playing video **without any LLM +in the loop** — the UI shown in the video *is* the real captured product. + +``` +capture/authored.mjs (flow specs) + │ drives the live demo, one real screenshot per step + ▼ +capture/engine.mjs ──▶ screenshots/flows/<slug>/NN.png (real frames) + screenshots/flows/<slug>/manifest.json (cursor + caption + path per frame) + │ + ▼ +capture/build-video.mjs ──▶ walkthroughs/<slug>.html ← the finished video (self-contained) +``` + +### What the engine produces + +A `<slug>.html` "video" is a **faux-browser frame showing the real screenshots**, with a +**ghost cursor** that glides to each recorded click point, a tap ripple, crossfades, +captions, an address bar, and a seekable player bar. It is 100% real UI — `build-video` +only animates a cursor over the captured frames. No UI is recreated or hallucinated. +Frames are inlined as base64, so each `.html` is fully portable (one file, no assets). + +### Authored vs auto + +| Path | Script | Output | Use | +|---|---|---|---| +| **Authored** (end-to-end) | `capture/authored.mjs` → `engine.mjs` | full task: navigate → click → type → every step → submit → result, with ghost cursor | the real "how-to" videos | +| **Auto sweep** (landing only) | `capture/bulk-capture-auto.mjs` | one read-only landing screenshot per flow | bulk coverage / smoke | + +### Realism the engine bakes in + +- **Typing reads as writing.** `fillForm` types each field in a few chunks and snapshots + after each chunk, so a name/email/description is *written out* across frames instead of + popping in fully formed. Sub-frames play fast (`dur: 540ms`) via the per-frame `dur`, + which `build-video` honors. +- **Dropdowns show the list.** `selectDropdown` opens the picker, **captures the open + list** with the cursor tapping the chosen option (`dur: 1700ms`), *then* selects — so the + viewer sees the choice being made, not just the final chip. Options are matched only in + the popup **below** the trigger, never a same-named element in the background page. +- **Cursor lands on the target.** Each frame records a real cursor `{x,y}` from the live + element's bounding box. `point:{firstField:true}` resolves to the **same field the fill + will type into** (modal-scoped; it does *not* assume fields start past x=300 — modal + fields often begin near x≈128), so the "name your …" cursor sits on the field, not on + empty space. `build-video` maps capture-space (1440×900) → the 1080-wide stage by a + single uniform scale (`k = 1080/1440`), so what's recorded is what's shown. + +### Safety (engine = authored path) + +- **Institute-locked.** Asserts the logged-in institute equals the demo ID up front and + before steps; **hard-aborts** on drift. +- **In-demo actions allowed, 3rd-party blocked.** Per owner direction the authored flows + may click/fill/submit *within the demo institute*, but the network guard **aborts** + payment gateways, custom-domain/DNS, and real comms-send (WhatsApp/SMS/email) calls, and + **never** issues `DELETE`. +- Credentials stay in the gitignored `.env`. + +### Step DSL (one entry per `steps[]` in an authored flow) + +Every field optional unless noted. Full reference in the header of `capture/engine.mjs`. + +| Field | Meaning | +|---|---| +| `goto:'/path'` | navigate first (full navigation) | +| `navRail` / `navClick:{text,region}` | click a left-rail section / a control to advance | +| `path:'/x'` | address-bar path shown on this frame | +| `screen:'id'` | shared-screen cache: capture once, reuse the real image across flows | +| `caption:'…'` | one short line (`<b>bold</b>` the key nouns) | +| `point:{…}` | what the cursor points at: `{text,region}` · `{coords:[x,y]}` · `{firstField:true}` · `{field:'regex'}` (a control by placeholder/aria/label/id) · `{submit:true}` · add `scroll:true` to reveal an off-screen target first | +| `then:{…}` | advance after the shot: `{clickPoint}` · `{click:{text,region}}` · `{fill:true}` · `{type:{field:'regex',value,clear?}}` (clear:true replaces the value) · `{select:{trigger,option?,caption?,commit?}}` (commit:false = show the list, don't change it) · `{submit:{text?}}` · `wait:ms` | + +> **Quality bar:** every video must be genuinely end-to-end — each frame moves, and the final +> frame shows the real result (never a "done" caption over a stale form). See `CONTEXT.md` §0. +| `final:true` | last frame (no advance) | + +Settings screens are deep-linked by tab — `goto:'/settings?selectedTab=<value>'` (values in +`src/routes/settings/-constants/terms.ts` → `SettingsTabs`, e.g. `whiteLabel`, `invoice`, +`naming`, `leadSettings`) — rather than clicking through the Settings home grid. + +### Add / capture / build a flow + +```bash +# 1. author the flow in capture/authored.mjs (push a spec onto FLOWS) +# 2. capture it against the live demo (writes real frames + manifest) +node capture/authored.mjs --slugs=admin-how-to-create-a-course +# 3. build the self-contained video +node capture/build-video.mjs admin-how-to-create-a-course +# → walkthroughs/admin-how-to-create-a-course.html +``` + +Omit `--slugs=` to capture every authored flow. The cursor `{x,y}`, captions, and +per-frame durations all live in `screenshots/flows/<slug>/manifest.json`, so a flow can be +**rebuilt** (re-run step 3) without re-driving the browser. + +### QA: see exactly where the cursor lands + +```bash +node capture/overlay-cursor.mjs <flow-slug-dir> +# → render-check/<slug>/cursor-NN.png (real frame + the ghost cursor stamped at its x,y) +``` + +`overlay-cursor` composites the recorded cursor onto each real frame (same placement the +player uses), so you can verify a `point:{…}` lands on the right control before — or instead +of — opening the `.html`. Pure rendering: no app, no network. diff --git a/tools/walkthrough-generator/capture/_inspect-course.mjs b/tools/walkthrough-generator/capture/_inspect-course.mjs new file mode 100644 index 0000000000..2598c03f88 --- /dev/null +++ b/tools/walkthrough-generator/capture/_inspect-course.mjs @@ -0,0 +1,38 @@ +import { chromium } from 'playwright'; +import { join } from 'node:path'; +import { loadEnv, TOOL_ROOT } from './env.mjs'; +const env = loadEnv(); +const BASE = env.VACADEMY_BASE_URL.replace(/\/+$/,''); +const browser = await chromium.launch({ headless:true }); +const ctx = await browser.newContext({ storageState: join(TOOL_ROOT,'auth-state.json'), viewport:{width:1440,height:900} }); +const page = await ctx.newPage(); +const log=(...a)=>console.log(...a); +await page.goto(BASE+'/study-library/courses',{waitUntil:'domcontentloaded',timeout:35000}); +await page.waitForLoadState('networkidle').catch(()=>{}); +await page.waitForTimeout(2500); +await page.getByText('Create Course',{exact:false}).first().click().catch(()=>{}); +await page.waitForTimeout(1500); +// step 1: fill course name +for (const h of await page.locator('input').elementHandles()){ const ph=(await h.getAttribute('placeholder'))||''; if(/course name/i.test(ph)){ await h.fill('Foundation Science'); log('filled course name'); } } +await page.waitForTimeout(400); +// click Next +const next = page.getByRole('button',{name:/^Next$/i}).last(); +log('Next disabled:', await next.isDisabled().catch(()=>'n/a')); +await next.click().catch(e=>log('next click err',e.message.split(String.fromCharCode(10))[0])); +await page.waitForTimeout(1500); +log('on step 2 now. heading present:', await page.getByText(/Step 2|Course Structure/i).first().count().catch(()=>0)); +// click 3-Level card +const card = page.getByText('3-Level Course Structure',{exact:false}).first(); +await card.click().catch(e=>log('card click err',e.message.split(String.fromCharCode(10))[0])); +await page.waitForTimeout(800); +// Create button +const createBtn = page.getByRole('button',{name:/Create/i}).last(); +log('Create disabled:', await createBtn.isDisabled().catch(()=>'n/a')); +const cb = await createBtn.boundingBox().catch(()=>null); log('Create box:', JSON.stringify(cb)); +await createBtn.click().catch(e=>log('create click err',e.message.split(String.fromCharCode(10))[0])); +await page.waitForTimeout(4000); +log('after Create: url=', page.url()); +log('dialog still open:', await page.getByText('Add Course',{exact:false}).first().count().catch(()=>0)); +log('any toast/success text:', JSON.stringify((await page.locator('body').innerText().catch(()=>'')).match(/success|created|congrat|added/gi)||[])); +await page.screenshot({path: join(TOOL_ROOT,'capture','_inspect','course-after-create.png')}); +await browser.close(); diff --git a/tools/walkthrough-generator/capture/_inspect-course2.mjs b/tools/walkthrough-generator/capture/_inspect-course2.mjs new file mode 100644 index 0000000000..4e04005d34 --- /dev/null +++ b/tools/walkthrough-generator/capture/_inspect-course2.mjs @@ -0,0 +1,39 @@ +import { chromium } from 'playwright'; +import { join } from 'node:path'; +import { loadEnv, TOOL_ROOT } from './env.mjs'; +const env = loadEnv(); +const BASE = env.VACADEMY_BASE_URL.replace(/\/+$/,''); +const browser = await chromium.launch({ headless:true }); +const ctx = await browser.newContext({ storageState: join(TOOL_ROOT,'auth-state.json'), viewport:{width:1440,height:900} }); +const page = await ctx.newPage(); +const log=(...a)=>console.log(...a); +await page.goto(BASE+'/study-library/courses',{waitUntil:'domcontentloaded',timeout:35000}); +await page.waitForLoadState('networkidle').catch(()=>{}); +await page.waitForTimeout(2500); +await page.getByText('Create Course',{exact:false}).first().click().catch(()=>{}); +await page.waitForTimeout(1500); +for (const h of await page.locator('input').elementHandles()){ const ph=(await h.getAttribute('placeholder'))||''; if(/course name/i.test(ph)) await h.fill('Foundation Science'); } +await page.waitForTimeout(300); +await page.getByRole('button',{name:/^Next$/i}).last().click().catch(()=>{}); +await page.waitForTimeout(1500); +const createDisabled = async () => await page.getByRole('button',{name:/Create/i}).last().isDisabled().catch(()=>'n/a'); +log('Create disabled at step2 start:', await createDisabled()); +// click each structure card by its CONTAINER center and re-check +for (const name of ['2-Level Course Structure','3-Level Course Structure']){ + const heading = page.getByText(name,{exact:false}).first(); + const box = await heading.boundingBox().catch(()=>null); + if(!box){ log('no box for',name); continue; } + // click ~120px above-left center to hit the card body, then re-check + await page.mouse.click(Math.round(box.x+200), Math.round(box.y+40)).catch(()=>{}); + await page.waitForTimeout(700); + log('after click '+name+': Create disabled=', await createDisabled()); +} +// if enabled, click it +const cb = page.getByRole('button',{name:/Create/i}).last(); +if(!(await cb.isDisabled().catch(()=>true))){ + await cb.click().catch(e=>log('create err',e.message.split(String.fromCharCode(10))[0])); + await page.waitForTimeout(4500); + log('after Create click: url=',page.url(),' addCourseStillOpen=', await page.getByText('Add Course',{exact:false}).first().count().catch(()=>0)); +} +await page.screenshot({path: join(TOOL_ROOT,'capture','_inspect','course2.png')}); +await browser.close(); diff --git a/tools/walkthrough-generator/capture/_inspect-role.mjs b/tools/walkthrough-generator/capture/_inspect-role.mjs new file mode 100644 index 0000000000..169e7855fd --- /dev/null +++ b/tools/walkthrough-generator/capture/_inspect-role.mjs @@ -0,0 +1,33 @@ +import { chromium } from 'playwright'; +import { join } from 'node:path'; +import { loadEnv, TOOL_ROOT } from './env.mjs'; +const env = loadEnv(); +const BASE = env.VACADEMY_BASE_URL.replace(/\/+$/,''); +const browser = await chromium.launch({ headless:true }); +const ctx = await browser.newContext({ storageState: join(TOOL_ROOT,'auth-state.json'), viewport:{width:1440,height:900} }); +const page = await ctx.newPage(); +await page.goto(BASE+'/manage-institute/teams',{waitUntil:'domcontentloaded',timeout:35000}); +await page.waitForLoadState('networkidle').catch(()=>{}); +await page.waitForTimeout(2500); +// open invite dialog +const inv = page.getByText('Invite User',{exact:false}).first(); +await inv.click().catch(()=>{}); +await page.waitForTimeout(1500); +// find the Role Type trigger +const trig = page.getByText('Select option',{exact:false}).first(); +const tb = await trig.boundingBox().catch(()=>null); +console.log('role trigger box:', JSON.stringify(tb)); +await trig.click().catch(()=>{}); +await page.waitForTimeout(900); +// dump what appeared +const dump = await page.evaluate(()=>{ + const out={roleOptions:[],portals:[]}; + for (const el of document.querySelectorAll('[role="option"],[role="menuitem"],li,[class*="option"],[class*="Option"]')){ + const t=(el.innerText||'').trim(); const r=el.getBoundingClientRect(); + if(t && t.length<40 && r.width>0 && r.top>150) out.roleOptions.push({t, tag:el.tagName.toLowerCase(), cls:(el.className||'').toString().slice(0,50), x:Math.round(r.x),y:Math.round(r.y)}); + } + return out; +}); +console.log('options after click:', JSON.stringify(dump.roleOptions.slice(0,15),null,1)); +await page.screenshot({path: join(TOOL_ROOT,'capture','_inspect','role-open.png')}); +await browser.close(); diff --git a/tools/walkthrough-generator/capture/_inspect-role2.mjs b/tools/walkthrough-generator/capture/_inspect-role2.mjs new file mode 100644 index 0000000000..8994ec8959 --- /dev/null +++ b/tools/walkthrough-generator/capture/_inspect-role2.mjs @@ -0,0 +1,48 @@ +import { chromium } from 'playwright'; +import { join } from 'node:path'; +import { loadEnv, TOOL_ROOT } from './env.mjs'; +const env = loadEnv(); +const BASE = env.VACADEMY_BASE_URL.replace(/\/+$/,''); +const browser = await chromium.launch({ headless:true }); +const ctx = await browser.newContext({ storageState: join(TOOL_ROOT,'auth-state.json'), viewport:{width:1440,height:900} }); +const page = await ctx.newPage(); +await page.goto(BASE+'/manage-institute/teams',{waitUntil:'domcontentloaded',timeout:35000}); +await page.waitForLoadState('networkidle').catch(()=>{}); +await page.waitForTimeout(2500); +await page.getByText('Invite User',{exact:false}).first().click().catch(()=>{}); +await page.waitForTimeout(1200); +// fill name + email +const inputs = await page.locator('input').elementHandles(); +for (const h of inputs){ const ph=(await h.getAttribute('placeholder'))||''; if(/full name|first and last/i.test(ph)) await h.fill('Rahul Sharma'); else if(/email/i.test(ph)) await h.fill('rahul.sharma@example.com'); } +await page.waitForTimeout(300); +const btn = () => page.getByRole('button',{name:/^Invite User$/i}).last(); +console.log('button disabled BEFORE role:', await btn().isDisabled().catch(()=>'(n/a)')); +// open role dropdown +await page.getByText('Select option',{exact:false}).first().click().catch(()=>{}); +await page.waitForTimeout(800); +// inspect the Admin option element structure +const info = await page.evaluate(()=>{ + const cands=[...document.querySelectorAll('*')].filter(el=>{ + const t=(el.childElementCount===0?el.textContent:'')?.trim(); + const r=el.getBoundingClientRect(); + return t==='Admin' && r.width>0 && r.top>540; + }); + return cands.slice(0,3).map(el=>{ + const chain=[]; let n=el; for(let i=0;i<4&&n;i++){chain.push(n.tagName.toLowerCase()+(n.getAttribute('role')?'[role='+n.getAttribute('role')+']':'')+(n.className?'.'+String(n.className).split(' ').slice(0,2).join('.'):''));n=n.parentElement;} + const r=el.getBoundingClientRect(); + return {chain:chain.join(' < '), box:[Math.round(r.x),Math.round(r.y),Math.round(r.width),Math.round(r.height)]}; + }); +}); +console.log('Admin option structure:', JSON.stringify(info,null,1)); +// try clicking the first Admin option (force) +const adminOpt = page.getByText('Admin',{exact:true}).filter({hasNot:page.locator('x')}).last(); +const opts = await page.getByText('Admin',{exact:true}).all(); +console.log('num exact "Admin" matches:', opts.length); +// click the one in the popup (top>540) +for (const o of opts){ const b=await o.boundingBox().catch(()=>null); if(b&&b.y>540){ await o.click().catch(e=>console.log('click err',e.message.split(String.fromCharCode(10))[0])); console.log('clicked Admin at y=',Math.round(b.y)); break; } } +await page.waitForTimeout(900); +console.log('button disabled AFTER click Admin:', await btn().isDisabled().catch(()=>'(n/a)')); +const trigTxt = await page.getByText('Role Type',{exact:false}).first().evaluate(el=>el.closest('div')?.parentElement?.innerText?.slice(0,60)).catch(()=>''); +console.log('role area text after:', JSON.stringify(trigTxt)); +await page.screenshot({path: join(TOOL_ROOT,'capture','_inspect','role-after-click.png')}); +await browser.close(); diff --git a/tools/walkthrough-generator/capture/api-seed.mjs b/tools/walkthrough-generator/capture/api-seed.mjs new file mode 100644 index 0000000000..908d78760d --- /dev/null +++ b/tools/walkthrough-generator/capture/api-seed.mjs @@ -0,0 +1,148 @@ +/** + * api-seed — seeds the demo institute FAST by calling the same admin APIs the + * dashboard uses (learned from backend source). Creates a batch (package_session) + * for the existing course+session, then enrolls demo learners with notify=false + * (NO emails). Guarded: demo-institute-locked; only admin-core-service calls. + * + * It sniffs the backend base URL + Authorization header from a real request the + * app makes on load, then replays REST calls via the authenticated context. + */ +import { chromium } from 'playwright'; +import { join } from 'node:path'; +import { loadEnv, requireEnv, TOOL_ROOT } from './env.mjs'; + +const env = loadEnv(); +requireEnv(env, ['VACADEMY_BASE_URL', 'VACADEMY_INSTITUTE_ID']); +const BASE = env.VACADEMY_BASE_URL.replace(/\/+$/, ''); +const INSTITUTE_ID = env.VACADEMY_INSTITUTE_ID; +const COURSE_NAME = 'Foundation Mathematics'; +const SESSION_NAME = '2025-26'; + +const LEARNERS = [ + { full_name: 'Aarav Sharma', email: 'demo.aarav@example.com', gender: 'MALE' }, + { full_name: 'Diya Patel', email: 'demo.diya@example.com', gender: 'FEMALE' }, + { full_name: 'Vivaan Reddy', email: 'demo.vivaan@example.com', gender: 'MALE' }, + { full_name: 'Ananya Iyer', email: 'demo.ananya@example.com', gender: 'FEMALE' }, + { full_name: 'Kabir Singh', email: 'demo.kabir@example.com', gender: 'MALE' }, + { full_name: 'Meera Nair', email: 'demo.meera@example.com', gender: 'FEMALE' }, +]; + +const browser = await chromium.launch({ headless: true }); +const context = await browser.newContext({ storageState: join(TOOL_ROOT, 'auth-state.json'), viewport: { width: 1440, height: 900 } }); +const page = await context.newPage(); + +let backend = null; +let authHeader = null; +let clientId = null; +page.on('request', (req) => { + const u = req.url(); + if (/\/(admin-core-service|auth-service)\//.test(u)) { + try { if (!backend) backend = new URL(u).origin; } catch {} + const h = req.headers(); + authHeader = h['authorization'] || authHeader; + clientId = h['clientid'] || h['client-id'] || clientId; + } +}); + +await page.goto(BASE + '/dashboard', { waitUntil: 'domcontentloaded', timeout: 30000 }); +await page.waitForLoadState('networkidle').catch(() => {}); +await page.waitForTimeout(3000); + +const active = await page.evaluate(() => localStorage.getItem('selectedInstituteId')); +if (active !== INSTITUTE_ID) { console.error('ABORT institute', active); await browser.close(); process.exit(2); } +if (!backend) backend = BASE; +if (!authHeader) { + const c = (await context.cookies()).find((x) => x.name === 'accessToken'); + if (c) authHeader = 'Bearer ' + c.value; +} +console.log('backend:', backend, '| auth:', authHeader ? 'yes' : 'NO', '| clientId:', clientId || '(none)'); + +const headers = { 'Content-Type': 'application/json' }; +if (authHeader) headers['Authorization'] = authHeader; +if (clientId) headers['clientId'] = clientId; +const api = context.request; + +const jget = async (path) => { + const r = await api.get(backend + path, { headers }); + const t = await r.text(); + try { return { status: r.status(), json: JSON.parse(t) }; } catch { return { status: r.status(), text: t.slice(0, 200) }; } +}; +const jpost = async (path, body) => { + const r = await api.post(backend + path, { headers, data: body }); + const t = await r.text(); + try { return { status: r.status(), json: JSON.parse(t) }; } catch { return { status: r.status(), text: t.slice(0, 200) }; } +}; + +// 1) discover course + session + level ids +const summary = await jget(`/admin-core-service/institute/v1/batches-summary/${INSTITUTE_ID}`); +console.log('batches-summary status', summary.status); +const pick = (arr, name) => (arr || []).find((x) => (x.name || x.package_name || x.session_name || x.level_name || '').toLowerCase().includes(name.toLowerCase())); +let courseId, sessionId, levelId; +if (summary.json) { + courseId = pick(summary.json.packages, COURSE_NAME)?.id; + sessionId = pick(summary.json.sessions, SESSION_NAME)?.id; + levelId = (summary.json.levels || [])[0]?.id; +} +console.log('from summary -> course:', courseId, '| session:', sessionId, '| level:', levelId); + +// fallback for course id via study-library init +if (!courseId) { + const sl = await jget(`/admin-core-service/v1/study-library/init?instituteId=${INSTITUTE_ID}`); + if (Array.isArray(sl.json)) { + const c = sl.json.find((x) => (x.course?.package_name || '').toLowerCase().includes(COURSE_NAME.toLowerCase())); + courseId = c?.course?.id; + } + console.log('from study-library/init -> course:', courseId); +} + +// 2) does a package_session already exist for this course+session? +const details = await jget(`/admin-core-service/institute/v1/details/${INSTITUTE_ID}`); +const findPS = (d) => (d?.batches_for_sessions || []).find((b) => b.package_dto?.id === courseId && b.session?.id === sessionId); +let ps = details.json ? findPS(details.json) : null; +if (!sessionId && details.json) sessionId = (details.json.sessions || []).find((s) => (s.session_name || '').includes(SESSION_NAME))?.id; +if (!levelId && details.json) levelId = (details.json.levels || [])[0]?.id; +console.log('existing package_session:', ps?.id || '(none)'); + +// 3) create batch if needed +if (!ps && courseId && sessionId) { + const r = await jpost( + `/admin-core-service/level/v1/add-level?packageId=${courseId}&sessionId=${sessionId}&instituteId=${INSTITUTE_ID}`, + { new_level: true, level_name: 'DEFAULT', duration_in_days: 365, thumbnail_file_id: null } + ); + console.log('create-batch (add-level) status', r.status, r.text || JSON.stringify(r.json).slice(0, 120)); + await page.waitForTimeout(2000); + const d2 = await jget(`/admin-core-service/institute/v1/details/${INSTITUTE_ID}`); + ps = d2.json ? findPS(d2.json) : null; + console.log('package_session after create:', ps?.id || '(still none)'); +} + +const packageSessionId = ps?.id; +if (!packageSessionId) { + console.error('No package_session_id resolved — cannot enroll. course:', courseId, 'session:', sessionId); + await browser.close(); + process.exit(3); +} + +// 4) enroll learners (notify=false -> no emails) +let ok = 0; +for (const l of LEARNERS) { + const body = { + user_details: { full_name: l.full_name, email: l.email, gender: l.gender }, + student_extra_details: {}, + institute_student_details: { + institute_id: INSTITUTE_ID, + package_session_id: packageSessionId, + enrollment_status: 'ACTIVE', + access_days: '365', + }, + }; + const r = await jpost('/admin-core-service/institute/institute_learner/v1/add-institute_learner?notify=false', body); + const okThis = r.status >= 200 && r.status < 300; + if (okThis) ok++; + console.log(` enroll ${l.full_name}: ${r.status} ${okThis ? 'OK' : (r.text || JSON.stringify(r.json)).slice(0, 120)}`); + await page.waitForTimeout(400); +} +console.log(`enrolled ${ok}/${LEARNERS.length} learners into package_session ${packageSessionId}`); + +await browser.close(); +console.log('api-seed done'); diff --git a/tools/walkthrough-generator/capture/authored-v2.mjs b/tools/walkthrough-generator/capture/authored-v2.mjs new file mode 100644 index 0000000000..b79956d792 --- /dev/null +++ b/tools/walkthrough-generator/capture/authored-v2.mjs @@ -0,0 +1,429 @@ +/** + * authored-v2 — NEW walkthrough flows (the v2 convention). + * + * Two differences from authored.mjs (the original 10, kept in `walkthroughs/`): + * + * 1. FULL NAVIGATION. Every flow STARTS on the Dashboard and walks the COMPLETE + * path step-by-step (Dashboard → Settings → the tab → the action) instead of + * deep-linking straight to a tab. The viewer learns how to GET there, not just + * what the destination looks like. Use the `dashToSettings()` helper below. + * + * 2. SEPARATE OUTPUT. The finished .html is built into a different folder so the + * original 10 are untouched: + * node capture/build-video.mjs <slug> --out=walkthroughs-v2 + * + * Frames + manifest still land in screenshots/flows/<slug>/ (shared intermediate); + * only the .html goes to the v2 folder. Give each new flow its OWN slug so it never + * collides with the original 10. + * + * Run: + * node capture/authored-v2.mjs # capture every v2 flow + * node capture/authored-v2.mjs --slugs=a,b # specific flows + * node capture/build-video.mjs <slug> --out=walkthroughs-v2 + */ +import { runFlows } from './engine.mjs'; +import { fileURLToPath } from 'node:url'; + +// Make a created entity's name unique per run, so re-captures don't hit the backend's +// "name already exists" guard (the demo accumulates real data across runs). +const uniq = (base) => `${base} ${Date.now().toString().slice(-5)}`; + +/** + * Dashboard → Settings → <tab card>. The shared "how you get there" opener every + * v2 settings flow begins with. Returns the two nav frames; spread them in front of + * the flow's own action steps. + * + * `tabValue` MUST be the EXACT card label from + * frontend-admin-dashboard/src/routes/settings/-utils/utils.ts → getAvailableSettingsTabs() + * e.g. 'White-Label Setup', 'Lead Settings', 'Invoice Settings', 'Naming Settings', + * 'Custom Fields', 'Coupon Settings', 'Notification Settings', … + * + * Opening Settings from the rail lands on the tabbed layout with a left SIDEBAR + * listing every tab (alphabetical). We click the tab in that sidebar; it's a long + * list, so the click uses `scroll:true` to bring the item into view before the shot. + */ +export const dashToSettings = (tabLabel, opts = {}) => ([ + { goto: '/dashboard', screen: 'dashboard', path: '/dashboard', settle: 2200, + caption: opts.dashCaption || 'Start on your <b>Dashboard</b> — open <b>Settings</b> from the left rail.', + point: { text: '^Settings$', region: 'rail' }, then: { clickPoint: true, wait: 1800 } }, + // Pass opts.tab (the SettingsTabs value, e.g. 'invoice') to deep-link the tab via goto — + // reliable even on a cold start. Without it, fall back to clicking the sidebar item. + opts.tab + ? { goto: `/settings?selectedTab=${opts.tab}`, path: '/settings', settle: opts.tabWait || 3200, + caption: opts.gridCaption || `Open <b>${tabLabel}</b> from the settings menu.`, + point: { text: tabLabel, region: 'sidebar', scroll: true } } + : { path: '/settings', caption: opts.gridCaption || `In the <b>Settings</b> sidebar, open <b>${tabLabel}</b>.`, + point: { text: tabLabel, region: 'sidebar', scroll: true }, then: { clickPoint: true, wait: opts.tabWait || 2400 } }, +]); + +/** + * Dashboard → a workspace (rail icon) → goto the screen → click the create trigger. + * For NON-settings flows (CRM/LMS/Manage). `railText` is the rail label (CRM/LMS/AI/ + * Settings); `route` is the real path; `trigger` is the visible text of the button that + * opens the create form. Shows the Dashboard start + lands reliably via goto, then opens. + */ +export const dashToRoute = (railText, route, trigger, opts = {}) => ([ + { goto: '/dashboard', screen: 'dashboard', path: '/dashboard', settle: 2200, + caption: opts.dashCaption || `From the <b>Dashboard</b>, open the <b>${railText}</b> workspace.`, + point: { text: `^${railText}$`, region: 'rail' }, then: { clickPoint: true, wait: 1500 } }, + { goto: route, path: opts.path || route.split('?')[0], settle: opts.settle || 2800, + caption: opts.landCaption || `Click <b>${trigger}</b> to start.`, + point: { text: trigger, region: 'content', scroll: true }, then: { clickPoint: true, wait: opts.openWait || 1400 } }, +]); + +// END-TO-END RULE: every flow must MOVE each frame — navigate, click in, type, open, +// then show the result. Never two near-identical stills. Each frame is the next step +// of the previous one, and the whole thing reads as a complete how-to. + +export const FLOWS = [ + // Template: shows the COMPLETE path from the Dashboard + a real edit. + { + slug: 'admin-how-to-set-your-portal-tab-title', + title: 'set your portal tab title', + steps: [ + ...dashToSettings('White-Label Setup', { tabWait: 2800 }), + { path: '/settings', caption: 'Open a domain’s <b>Settings</b> to reveal its branding controls.', + point: { text: '^Settings$', region: 'content' }, then: { clickPoint: true, wait: 1300 } }, + { path: '/settings', caption: 'Type a new <b>Tab Title</b> — the name shown on the browser tab.', + point: { field: 'My School|tab title' }, then: { type: { field: 'My School|tab title', value: 'Acme Academy', clear: true } } }, + { path: '/settings', caption: 'That title now appears on your portal’s browser tab.', + point: { text: '^Settings$', region: 'content' }, final: true }, + ], + }, + + // Rename a system term end-to-end: navigate → click the field → clear & retype → Save. + { + slug: 'admin-how-to-rename-system-terminology', + title: 'rename system terminology', + steps: [ + ...dashToSettings('Naming Settings', { tabWait: 2600 }), + { path: '/settings', caption: 'Every system term has an editable <b>Singular</b> & <b>Plural</b> label.', + point: { field: '^Course$' } }, + { path: '/settings', caption: 'Rename “Course” to your institute’s word — here, <b>Programme</b>.', + point: { field: '^Course$' }, then: { type: { field: '^Course$', value: 'Programme', clear: true } } }, + { path: '/settings', caption: 'Then click <b>Save Changes</b> to apply it across your institute.', + point: { text: 'Save Changes', region: 'content' }, final: true }, + ], + }, + + // Set the invoice currency end-to-end: navigate → open the dropdown → show options → result. + { + slug: 'admin-how-to-set-your-time-zone-and-currency', + title: 'set your invoice currency', + steps: [ + ...dashToSettings('Invoice Settings', { tabWait: 5200 }), + { path: '/settings', caption: 'In the <b>General</b> section, open the <b>Currency</b> dropdown.', + point: { field: 'currency' }, + then: { select: { trigger: 'Indian Rupee|INR|₹', caption: 'Pick the currency every invoice will use.', commit: false }, wait: 600 } }, + { path: '/settings', caption: 'Your invoices now use the <b>currency</b> you chose.', + point: { field: 'currency' }, final: true }, + ], + }, + + // Tour Lead Settings end-to-end: navigate → master toggle → badge visibility → scoring weights. + { + slug: 'admin-how-to-configure-lead-scoring-rules', + title: 'configure lead settings', + steps: [ + ...dashToSettings('Lead Settings', { tabWait: 3200 }), + { path: '/settings', caption: 'Lead Settings opens on <b>Configuration</b> — the master switch is up top.', + point: { text: '^Configuration$', region: 'content' } }, + { path: '/settings', caption: '<b>Score Badge Visibility</b> chooses where HOT/WARM/COLD badges appear.', + point: { coords: [440, 436] } }, + { path: '/settings', caption: 'Under <b>Scoring Weights</b>, set how much each factor counts (they sum to 100).', + point: { firstField: true }, final: true }, + ], + }, + + // ===== Batch 2: more end-to-end creates (Dashboard → action → real success) ===== + + { + slug: 'admin-how-to-add-a-tax-rate', + title: 'add a tax rate', + steps: [ + ...dashToSettings('Invoice Settings', { tab: 'invoice', tabWait: 5500 }), + { path: '/settings', caption: 'Open the <b>Country & Tax</b> tab.', + point: { text: 'Country & Tax', region: 'content' }, then: { clickPoint: true, wait: 1500 } }, + { path: '/settings', caption: 'Click <b>Add tax component</b>.', + point: { text: 'Add tax component', region: 'content', scroll: true }, then: { clickPoint: true, wait: 1000 } }, + { path: '/settings', caption: 'Name the tax — e.g. <b>CGST</b>.', + point: { field: 'CGST|^Label', scroll: true }, then: { type: { field: 'CGST|^Label', value: 'CGST' } } }, + { path: '/settings', caption: 'Set its <b>rate</b> (%).', + point: { field: '^Rate$|Rate' }, then: { type: { field: '^Rate$|Rate', value: '9' } } }, + { path: '/settings', caption: 'Then <b>Save Invoice Settings</b>.', + point: { submit: true, submitText: 'Save Invoice Settings|^Save', scroll: true }, then: { submit: { text: 'Save Invoice Settings|^Save' }, wait: 2500 } }, + { path: '/settings', caption: 'Done — your <b>tax rate</b> is saved.', final: true }, + ], + }, + { + slug: 'admin-how-to-set-up-custom-lead-statuses', + title: 'set up a custom lead status', + expect: 'statuses saved|status saved|saved', + steps: [ + ...dashToSettings('Lead Settings', { tab: 'leadSettings', tabWait: 3500 }), + { path: '/settings', caption: 'Scroll to <b>Lead Statuses</b> and click <b>Add status</b>.', + point: { text: 'Add status', region: 'content', scroll: true }, then: { clickPoint: true, wait: 1100 } }, + { path: '/settings', caption: 'Name the new status — e.g. <b>Interested</b>.', + point: { field: 'Status name|Interested', scroll: true }, then: { type: { field: 'Status name|Interested', value: 'Interested' } } }, + { path: '/settings', caption: 'Then click <b>Save statuses</b>.', + point: { text: 'Save statuses', region: 'content' }, then: { submit: { text: 'Save statuses' }, wait: 2000 } }, + { path: '/settings', caption: 'Done — your custom <b>lead status</b> is saved.', final: true }, + ], + }, + { + slug: 'admin-how-to-create-a-payment-plan', + title: 'create a payment plan', + steps: [ + ...dashToSettings('Payment Settings', { tab: 'payment', tabWait: 3200 }), + { path: '/settings', caption: 'Click <b>Add Payment Plan</b>.', + point: { text: 'Add Payment Plan', region: 'content' }, then: { clickPoint: true, wait: 1300 } }, + { path: '/settings', caption: 'Name your plan.', + point: { field: 'plan name|Enter plan' }, then: { type: { field: 'plan name|Enter plan', value: 'Free Access Plan' } } }, + { path: '/settings', caption: 'Pick a plan type — a <b>Free Plan</b>.', + point: { sel: '#free', scroll: true }, then: { clickSel: '#free', wait: 900 } }, + { path: '/settings', caption: 'Continue to the next step.', + point: { submit: true, submitText: '^Next$|Continue' }, then: { submit: { text: '^Next$|Continue' }, wait: 1600 } }, + { path: '/settings', caption: 'Then <b>Create</b> the plan.', + point: { submit: true, submitText: 'Create Plan|Create Payment Plan|^Create$' }, then: { submit: { text: 'Create Plan|Create Payment Plan|^Create$' }, wait: 2800 } }, + { path: '/settings', caption: 'Done — your <b>payment plan</b> is created.', final: true }, + ], + }, + { + slug: 'admin-how-to-create-a-custom-field', + title: 'create a custom field', + expect: 'saved|added|created', + steps: [ + ...dashToSettings('Custom Fields', { tab: 'customFields', tabWait: 3200 }), + { path: '/settings', caption: 'Click <b>Add Custom Field</b>.', + point: { text: 'Add Custom Field', region: 'content' }, then: { clickPoint: true, wait: 1300 } }, + { path: '/settings', caption: 'Name the field.', + point: { sel: '#fieldName' }, then: { type: { field: 'Enter field name|fieldName', value: uniq('Guardian Phone') } } }, + { path: '/settings', caption: 'Click <b>Add Field</b> to add it.', + point: { text: '^Add Field$', region: 'content' }, then: { clickPoint: true, wait: 1300 } }, + { path: '/settings', caption: 'Then <b>Save</b> to persist your changes.', + point: { text: '^Save$', region: 'content', scroll: true }, then: { submit: { text: '^Save$' }, wait: 2200 } }, + { path: '/settings', caption: 'Done — your custom <b>field</b> is added.', final: true }, + ], + }, + { + slug: 'admin-how-to-create-a-coupon', + title: 'create a coupon', + steps: [ + ...dashToSettings('Coupon Settings', { tab: 'coupons', tabWait: 3200 }), + { path: '/settings', caption: 'Click <b>Create coupon</b> to open the form.', + point: { text: 'Create coupon', region: 'content' }, then: { clickPoint: true, wait: 1500 } }, + { path: '/settings', caption: 'Enter a <b>coupon code</b>.', + point: { field: 'SAVE20' }, then: { type: { field: 'SAVE20', value: 'WELCOME20' } } }, + { path: '/settings', caption: 'Set the discount <b>value</b> (%).', + point: { field: '^20$|percent|value', scroll: true }, then: { type: { field: '^20$|percent|value', value: '20' } } }, + { path: '/settings', caption: 'For a percentage, set a <b>max cap</b>.', + point: { field: '1000|cap', scroll: true }, then: { type: { field: '1000|cap', value: '500' } } }, + { path: '/settings', caption: 'Give it an <b>expiry date</b>.', + point: { sel: 'input[type="date"]', nth: 1, scroll: true }, then: { set: { sel: 'input[type="date"]', nth: 1, value: '2026-12-31' } } }, + { path: '/settings', caption: 'Then <b>Create coupon</b>.', + point: { submit: true, submitText: 'Create coupon', scroll: true }, then: { submit: { text: 'Create coupon' }, wait: 2800 } }, + { path: '/settings', caption: 'Done — your <b>coupon</b> is created.', final: true }, + ], + }, + + // ----- non-settings creates (dashToRoute: Dashboard → rail → route → form) ----- + { + slug: 'admin-how-to-create-a-session', + title: 'create a session', + expect: 'added successfully|session added|added', + steps: [ + ...dashToRoute('CRM', '/manage-institute/sessions', 'Add New Session', { settle: 3000, openWait: 1600 }), + { path: '/manage-institute/sessions', caption: 'Name the <b>session</b> — e.g. 2025-2026.', + point: { field: '2024-2025|Eg\\.|session name' }, then: { type: { field: '2024-2025|Eg\\.|session name', value: '2025-2026' } } }, + { path: '/manage-institute/sessions', caption: 'Set a <b>start date</b>.', + point: { sel: 'input[type="date"]', scroll: true }, then: { set: { sel: 'input[type="date"]', value: '2025-06-01' } } }, + { path: '/manage-institute/sessions', caption: 'Pick at least one <b>level</b> from your courses.', + point: { sel: 'button[role="checkbox"]', scroll: true }, then: { clickSel: 'button[role="checkbox"]', wait: 800 } }, + { path: '/manage-institute/sessions', caption: 'Then click <b>Add</b>.', + point: { submit: true, submitText: '^Add$', scroll: true }, then: { submit: { text: '^Add$' }, wait: 2800 } }, + { path: '/manage-institute/sessions', caption: 'Done — your <b>session</b> is created.', final: true }, + ], + }, + { + slug: 'admin-how-to-create-an-audience-list', + title: 'create an audience list', + expect: 'created successfully|Campaign created', + steps: [ + ...dashToRoute('CRM', '/audience-manager/list', 'Add Audience List', { settle: 3200, openWait: 2600 }), + { path: '/audience-manager/list', caption: 'Name your <b>campaign</b>.', + point: { field: 'Enter campaign name' }, then: { type: { field: 'Enter campaign name', value: uniq('Summer Drive') } } }, + { path: '/audience-manager/list', caption: 'Pick a <b>campaign type</b> — e.g. Website. (Dates are pre-filled.)', + point: { text: 'Select campaign type', region: 'content', scroll: true }, then: { select: { trigger: 'Select campaign type', option: 'Website', caption: 'Choose <b>Website</b>.' }, wait: 900 } }, + { path: '/audience-manager/list', caption: 'Then <b>Create Audience List</b>.', + point: { submit: true, submitText: 'Create Audience List|^Create', scroll: true }, then: { submit: { text: 'Create Audience List|^Create' }, wait: 5000 } }, + { path: '/audience-manager/list', caption: 'Done — your <b>audience list</b> is created.', final: true }, + ], + }, + { + slug: 'admin-how-to-create-a-custom-role', + title: 'create a custom role', + expect: 'Role created successfully|created successfully', + steps: [ + ...dashToSettings('Display Settings', { tab: 'roleDisplay', tabWait: 3200 }), + { path: '/settings', caption: 'Switch to the <b>Custom Role</b> tab.', + point: { text: '^Custom Role$', region: 'content' }, then: { clickPoint: true, wait: 1100 } }, + { path: '/settings', caption: 'Click <b>+</b> to add a new role.', + point: { sel: '[aria-label="Add new role"]', scroll: true }, then: { clickSel: '[aria-label="Add new role"]', wait: 900 } }, + { path: '/settings', caption: 'Name the role — e.g. <b>Content Reviewer</b>.', + point: { field: 'Enter role name' }, then: { type: { field: 'Enter role name', value: uniq('Content Reviewer') } } }, + { path: '/settings', caption: 'Then click <b>Create</b>.', + point: { submit: true, submitText: '^Create$' }, then: { submit: { text: '^Create$' }, wait: 2200 } }, + { path: '/settings', caption: 'Done — your custom <b>role</b> is created.', final: true }, + ], + }, + + // ===== Batch 3: more settings creates/saves (clear success toasts) ===== + { + slug: 'admin-how-to-create-a-doubt-category', + title: 'create a doubt category', + expect: 'Doubt management settings saved|settings saved|saved', + steps: [ + ...dashToSettings('Doubt Management', { tab: 'doubtManagement', tabWait: 3200 }), + { path: '/settings', caption: 'Click <b>Add query type</b>.', + point: { sel: 'button:has-text("Add query type")', scroll: true }, then: { clickSel: 'button:has-text("Add query type")', wait: 1000 } }, + { path: '/settings', caption: 'Name the category.', + point: { field: 'Type name', scroll: true }, then: { type: { field: 'Type name', value: uniq('Refund Request') } } }, + { path: '/settings', caption: 'Then <b>Save settings</b>.', + point: { text: 'Save settings', region: 'content', scroll: true }, then: { submit: { text: 'Save settings' }, wait: 2200 } }, + { path: '/settings', caption: 'Done — your <b>doubt category</b> is saved.', final: true }, + ], + }, + { + slug: 'admin-how-to-set-up-content-protection', + title: 'set up content protection', + expect: 'permissions saved|protection saved|saved', + steps: [ + ...dashToSettings('Content Protection', { tab: 'contentProtection', tabWait: 3200 }), + { path: '/settings', caption: 'Toggle a <b>download permission</b> — e.g. for PDFs.', + point: { sel: '#slide-dl-ADMIN-DOCUMENT_PDF', scroll: true }, then: { clickSel: '#slide-dl-ADMIN-DOCUMENT_PDF', wait: 800 } }, + { path: '/settings', caption: 'Then <b>Save</b> the download rules.', + point: { text: '^Save$', region: 'content', scroll: true }, then: { clickPoint: true, wait: 4000 } }, + { path: '/settings', caption: 'Done — your <b>content protection</b> rules are saved.', final: true }, + ], + }, + { + slug: 'admin-how-to-configure-assessment-settings', + title: 'configure assessment settings', + expect: 'Assessment settings saved|settings saved|saved successfully', + steps: [ + ...dashToSettings('Assessment Settings', { tab: 'assessment', tabWait: 3200 }), + { path: '/settings', caption: 'Toggle a setting to change it.', + point: { sel: 'button[role="switch"]', scroll: true }, then: { clickSel: 'button[role="switch"]', wait: 800 } }, + { path: '/settings', caption: 'Then <b>Save Changes</b>.', + point: { text: 'Save Changes', region: 'content', scroll: true }, then: { submit: { text: 'Save Changes' }, wait: 2200 } }, + { path: '/settings', caption: 'Done — your <b>assessment settings</b> are saved.', final: true }, + ], + }, + { + slug: 'admin-how-to-create-a-lead-distribution-pool', + title: 'create a lead distribution pool', + expect: 'Pool .*created|created successfully|pool created', + steps: [ + ...dashToSettings('Lead Settings', { tab: 'leadSettings', tabWait: 3500 }), + { path: '/settings', caption: 'Open the <b>Pools</b> tab.', + point: { text: '^Pools$', region: 'content' }, then: { clickPoint: true, wait: 1300 } }, + { path: '/settings', caption: 'Click <b>+ Create Pool</b>.', + point: { text: 'Create Pool', region: 'content', scroll: true }, then: { clickPoint: true, wait: 1600 } }, + { path: '/settings/leads/pools/new', caption: 'Name your <b>pool</b>.', + point: { field: 'pool-name|Pool name|Enter pool name|Class 11 Counselors' }, then: { type: { field: 'pool-name|Pool name|Enter pool name|Class 11 Counselors', value: uniq('North Zone Pool') } } }, + { path: '/settings/leads/pools/new', caption: 'Then <b>Create Pool</b>.', + point: { submit: true, submitText: 'Create Pool' }, then: { submit: { text: 'Create Pool' }, wait: 2600 } }, + { path: '/settings/leads/pools', caption: 'Done — your <b>distribution pool</b> is created.', final: true }, + ], + }, + + // ===== multi-step wizards (lower first-pass yield; diagnose-fix loop converges them) ===== + { + slug: 'admin-how-to-create-a-custom-team', + title: 'create a custom team', + expect: 'Sub-organization created|created with subscription|created successfully', + steps: [ + ...dashToRoute('CRM', '/manage-custom-teams', 'Create Sub-Organization', { settle: 3000, openWait: 1600 }), + { path: '/manage-custom-teams', caption: 'Name your <b>sub-organization</b>.', + point: { field: 'Sub-Org Name|sub.?org|organization name|Enter.*name' }, then: { type: { field: 'Sub-Org Name|sub.?org|organization name|Enter.*name', value: uniq('North Campus') } } }, + { path: '/manage-custom-teams', caption: 'Continue.', + point: { submit: true, submitText: '^Next|Continue' }, then: { submit: { text: '^Next|Continue' }, wait: 1500 } }, + { path: '/manage-custom-teams', caption: 'Click <b>Skip (No Subscription)</b> — that creates the sub-org.', + point: { text: '^Skip', region: 'content', scroll: true }, then: { clickPoint: true, wait: 3200 } }, + { path: '/manage-custom-teams', caption: 'Done — your <b>sub-organization</b> is created.', final: true }, + ], + }, + { + slug: 'admin-how-to-create-a-batch', + title: 'create a batch', + expect: 'Batch created|created successfully', + steps: [ + ...dashToRoute('CRM', '/manage-institute/batches', 'Create Batch', { settle: 3000, openWait: 1600 }), + { path: '/manage-institute/batches', caption: 'Use an <b>existing course</b>.', + point: { sel: '#existing-course', scroll: true }, then: { clickSel: '#existing-course', wait: 700 } }, + { path: '/manage-institute/batches', caption: 'Pick the course.', + point: { text: 'Select a Course', region: 'content', scroll: true }, then: { select: { trigger: 'Select a Course', option: 'Foundation Science' }, wait: 700 } }, + { path: '/manage-institute/batches', caption: 'Continue.', + point: { submit: true, submitText: '^Next' }, then: { submit: { text: '^Next' }, wait: 1300 } }, + { path: '/manage-institute/batches', caption: 'Use an <b>existing session</b>.', + point: { sel: '#existing-session', scroll: true }, then: { clickSel: '#existing-session', wait: 700 } }, + { path: '/manage-institute/batches', caption: 'Pick the session.', + point: { text: 'Select a Session', region: 'content', scroll: true }, then: { select: { trigger: 'Select a Session', option: '2025-26' }, wait: 700 } }, + { path: '/manage-institute/batches', caption: 'Continue.', + point: { submit: true, submitText: '^Next' }, then: { submit: { text: '^Next' }, wait: 1300 } }, + { path: '/manage-institute/batches', caption: 'Use an <b>existing level</b>.', + point: { sel: '#existing-level', scroll: true }, then: { clickSel: '#existing-level', wait: 700 } }, + { path: '/manage-institute/batches', caption: 'Pick the level.', + point: { text: 'Select a Level', region: 'content', scroll: true }, then: { select: { trigger: 'Select a Level', option: 'Beginner' }, wait: 700 } }, + { path: '/manage-institute/batches', caption: 'Then <b>Create Batch</b>.', + point: { submit: true, submitText: 'Create Batch' }, then: { submit: { text: 'Create Batch' }, wait: 3200 } }, + { path: '/manage-institute/batches', caption: 'Done — your <b>batch</b> is created.', final: true }, + ], + }, + + // ===== settings configure-and-save flows (the high-yield pattern: toggle → Save → toast) ===== + ...[ + ['admin-how-to-configure-student-display-settings', 'configure student display settings', 'Student Display', 'studentDisplay'], + ['admin-how-to-configure-notification-settings', 'configure notification settings', 'Notification Settings', 'notification'], + ['admin-how-to-configure-lms-settings', 'configure LMS settings', 'LMS Settings', 'lms'], + ['admin-how-to-configure-live-session-settings', 'configure live session settings', 'Live Session Settings', 'liveSession'], + ['admin-how-to-configure-course-settings', 'configure course settings', 'Course Settings', 'course'], + ].map(([slug, title, label, tab]) => ({ + slug, title, + expect: 'saved|updated|success', + steps: [ + ...dashToSettings(label, { tab, tabWait: 3200 }), + { path: '/settings', caption: 'Change a setting to enable saving.', + point: { sel: 'button[role="switch"]', scroll: true }, then: { clickSel: 'button[role="switch"]', wait: 900 } }, + { path: '/settings', caption: 'Then <b>Save</b> your changes.', + point: { text: 'Save now|Save Changes|^Save$', region: 'content', scroll: true }, then: { clickPoint: true, wait: 1900 } }, + { path: '/settings', caption: 'Done — your settings are saved.', final: true }, + ], + })), + { + slug: 'admin-how-to-create-a-referral-reward', + title: 'create a referral reward', + expect: 'Referral program created|program created|created successfully', + steps: [ + ...dashToSettings('Referral Settings', { tab: 'referral', tabWait: 3200 }), + { path: '/settings', caption: 'Click <b>Create New Program</b>.', + point: { text: 'Create New Program|Create Program', region: 'content', scroll: true }, then: { clickPoint: true, wait: 1300 } }, + { path: '/settings', caption: 'Name the <b>referral program</b>.', + point: { field: 'name for your referral|referral program|program name|Enter a name' }, then: { type: { field: 'name for your referral|referral program|program name|Enter a name', value: uniq('Refer & Earn') } } }, + { path: '/settings', caption: 'Add your first <b>reward tier</b>.', + point: { text: 'Add Your First Tier|Add Tier|Add Reward', region: 'content', scroll: true }, then: { clickPoint: true, wait: 1000 } }, + { path: '/settings', caption: 'Label the tier.', + point: { field: 'First Referral|tier name|Referrals' }, then: { type: { field: 'First Referral|tier name|Referrals', value: 'First Referral' } } }, + { path: '/settings', caption: 'Then <b>Create Program</b>.', + point: { submit: true, submitText: 'Create Program|Create New Program', scroll: true }, then: { submit: { text: 'Create Program|Create New Program' }, wait: 3200 } }, + { path: '/settings', caption: 'Done — your <b>referral program</b> is created.', final: true }, + ], + }, +]; + +// CLI — only when run directly (so grind.mjs can `import { FLOWS }` without capturing) +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const arg = process.argv.find((a) => a.startsWith('--slugs=')); + const onlySlugs = arg ? arg.split('=')[1].split(',').map((s) => s.trim()).filter(Boolean) : null; + await runFlows(FLOWS, { onlySlugs }); +} diff --git a/tools/walkthrough-generator/capture/authored.mjs b/tools/walkthrough-generator/capture/authored.mjs new file mode 100644 index 0000000000..61b108deb4 --- /dev/null +++ b/tools/walkthrough-generator/capture/authored.mjs @@ -0,0 +1,188 @@ +/** + * authored — hand-authored, end-to-end flow specs run by engine.mjs. + * Each flow performs the COMPLETE real task (navigate → click → fill → every step + * → submit → result). Shared screens (e.g. 'dashboard') are captured once via the + * engine's screen cache and reused across flows. + * + * Run: node capture/authored.mjs # all authored flows + * node capture/authored.mjs --slugs=a,b # specific flows + */ +import { runFlows } from './engine.mjs'; + +// reusable dashboard opener: frame 1 of every flow (cached image, per-flow cursor) +const fromDashboard = (railText, navCaption) => ({ + goto: '/dashboard', screen: 'dashboard', path: '/dashboard', + caption: navCaption, point: { text: `^${railText}$|^${railText}`, region: 'rail' }, then: { clickPoint: true }, +}); + +export const FLOWS = [ + { + slug: 'admin-how-to-invite-team-members', + title: 'invite a team member', + steps: [ + fromDashboard('CRM', 'From the <b>Dashboard</b>, open the <b>CRM</b> workspace to reach <b>Manage Institute → Teams</b>.'), + { goto: '/manage-institute/teams', path: '/manage-institute/teams', + caption: 'On the <b>Teams</b> page, click <b>Invite Users</b>.', + point: { text: 'Invite User', region: 'content' }, then: { clickPoint: true } }, + { path: '/manage-institute/teams', caption: "Enter the new member's <b>name and email</b>.", + point: { firstField: true }, then: { fill: true } }, + { path: '/manage-institute/teams', caption: 'Open the <b>role</b> picker.', + point: { text: 'Select option', region: 'content' }, then: { select: { trigger: 'Select option', option: 'Admin', caption: 'Choose <b>Admin</b> from the list.' } } }, + { path: '/manage-institute/teams', caption: 'Then click <b>Invite User</b> to send the invite.', + point: { submit: true, submitText: 'Invite User' }, then: { submit: { text: 'Invite User' } } }, + { path: '/manage-institute/teams', caption: 'Done — the <b>invite</b> is sent.', final: true }, + ], + }, + { + slug: 'admin-how-to-create-a-course', + title: 'create a course', + steps: [ + fromDashboard('LMS', 'From the <b>Dashboard</b>, open <b>Courses</b>.'), + { goto: '/study-library/courses', path: '/study-library/courses', + caption: 'On <b>Courses</b>, click <b>Create Course</b>.', + point: { text: 'Create Course', region: 'content' }, then: { clickPoint: true } }, + { path: '/study-library/courses', caption: 'Step 1 — name your <b>course</b> and add a description.', + point: { firstField: true }, then: { fill: true } }, + { path: '/study-library/courses', caption: 'Fill the overview, then click <b>Next</b>.', + point: { submit: true, submitText: '^Next' }, then: { submit: { text: '^Next' } } }, + { path: '/study-library/courses', caption: 'Step 2 — pick a <b>structure</b>. Keep it simple: <b>no sessions</b>…', + point: { sel: '#sessions-no', scroll: true }, then: { clickSel: '#sessions-no', wait: 900 } }, + { path: '/study-library/courses', caption: '…and <b>no levels</b> — a flat course you can fill with content later.', + point: { sel: '#levels-no', scroll: true }, then: { clickSel: '#levels-no', wait: 900 } }, + { path: '/study-library/courses', caption: 'Now click <b>Create</b> — the button enables once the structure is set.', + point: { submit: true, submitText: 'Create' }, then: { submit: { text: '^\\+?\\s*Create$|Create' }, wait: 5000 } }, + { path: '/study-library/courses', caption: 'Done — your new <b>course</b> opens, ready to add content.', final: true }, + ], + }, + + // ---- first-10 admin onboarding flows ------------------------------------ + // Settings screens are deep-linked via ?selectedTab=<value> (see + // src/routes/settings/-constants/terms.ts → SettingsTabs). No clicking through + // the Settings home grid — the deep-link renders the exact tab. + { + slug: 'admin-how-to-navigate-the-admin-dashboard', + title: 'navigate the admin dashboard', + steps: [ + { goto: '/dashboard', screen: 'dashboard', path: '/dashboard', settle: 2200, + caption: 'This is your <b>admin dashboard</b>. The left rail switches workspaces — <b>CRM</b> for contacts & leads.', + point: { text: '^CRM$', region: 'rail' } }, + { screen: 'dashboard', path: '/dashboard', + caption: '<b>LMS</b> holds your courses, study library, and content.', + point: { text: '^LMS$', region: 'rail' } }, + { screen: 'dashboard', path: '/dashboard', + caption: 'The <b>AI</b> workspace gives you AI-powered tools.', + point: { text: '^AI$', region: 'rail' } }, + { screen: 'dashboard', path: '/dashboard', + caption: '<b>Recent</b> jumps you back to pages you visited.', + point: { text: '^Recent$', region: 'rail' } }, + { screen: 'dashboard', path: '/dashboard', + caption: 'And <b>Settings</b> is where you configure your whole institute.', + point: { text: '^Settings$', region: 'rail' }, final: true }, + ], + }, + { + slug: 'admin-how-to-set-up-white-label-branding', + title: 'set up white-label branding', + steps: [ + { goto: '/settings?selectedTab=whiteLabel', path: '/settings', settle: 2800, + caption: 'Open <b>Settings → White-Label Setup</b> to brand your institute portal.', + point: { field: 'myschool|learn\\.' } }, + { path: '/settings', caption: 'Each audience can have its own <b>domain</b>. Open a domain’s <b>Settings</b> to reveal branding.', + point: { text: '^Settings$', region: 'content' }, then: { clickPoint: true, wait: 1300 } }, + { path: '/settings', caption: 'Set the <b>tab title, icon, theme, and font</b> for that branded portal.', + point: { field: 'theme|#4F46E5' }, final: true }, + ], + }, + { + slug: 'admin-how-to-upload-your-institute-logo', + title: 'upload your institute logo', + steps: [ + { goto: '/settings?selectedTab=whiteLabel', path: '/settings', settle: 2800, + caption: 'Open <b>Settings → White-Label Setup</b>.', + point: { field: 'myschool|learn\\.' } }, + { path: '/settings', caption: 'Open a domain’s <b>Settings</b> to reveal its branding controls.', + point: { text: '^Settings$', region: 'content' }, then: { clickPoint: true, wait: 1300 } }, + { path: '/settings', caption: 'Use <b>Tab Icon</b> to upload the logo shown on the browser tab.', + point: { field: 'file UUID|UUID' }, final: true }, + ], + }, + { + slug: 'admin-how-to-set-theme-primary-color', + title: 'set theme primary color', + steps: [ + { goto: '/settings?selectedTab=whiteLabel', path: '/settings', settle: 2800, + caption: 'Open <b>Settings → White-Label Setup</b>.', + point: { field: 'myschool|learn\\.' } }, + { path: '/settings', caption: 'Open a domain’s <b>Settings</b> panel.', + point: { text: '^Settings$', region: 'content' }, then: { clickPoint: true, wait: 1300 } }, + { path: '/settings', caption: 'Enter your brand’s <b>primary color</b> in the <b>Theme / Color</b> field.', + point: { field: 'theme|#4F46E5' }, then: { type: { field: 'theme|#4F46E5', value: '#F5A700' } } }, + { path: '/settings', caption: 'That color now themes your branded portal.', + point: { field: 'theme|#4F46E5' }, final: true }, + ], + }, + { + slug: 'admin-how-to-set-up-a-custom-domain', + title: 'set up a custom domain', + steps: [ + { goto: '/settings?selectedTab=whiteLabel', path: '/settings', settle: 2800, + caption: 'Open <b>Settings → White-Label Setup</b> and find a <b>Domain</b> field.', + point: { field: 'myschool|learn\\.' } }, + { path: '/settings', caption: 'Enter the <b>custom domain</b> learners will use to reach your portal.', + point: { field: 'myschool|learn\\.' }, then: { type: { field: 'myschool|learn\\.', value: 'learn.myinstitute.com' } } }, + { path: '/settings', caption: 'Save to start <b>DNS</b> verification and routing. (Demo stops here — DNS is not submitted.)', + point: { field: 'myschool|learn\\.' }, final: true }, + ], + }, + { + slug: 'admin-how-to-upload-favicon-and-email-logo', + title: 'upload favicon and email logo', + steps: [ + { goto: '/settings?selectedTab=whiteLabel', path: '/settings', settle: 2800, + caption: 'In <b>White-Label Setup</b>, pick the <b>domain</b> whose favicon you’re setting.', + point: { field: 'myschool|learn\\.' } }, + { path: '/settings', caption: 'Open a domain’s <b>Settings</b> panel.', + point: { text: '^Settings$', region: 'content' }, then: { clickPoint: true, wait: 1300 } }, + { path: '/settings', caption: 'The <b>Tab Icon</b> sets the browser favicon and email logo for that domain.', + point: { field: 'file UUID|UUID' }, final: true }, + ], + }, + { + slug: 'admin-how-to-set-your-time-zone-and-currency', + title: 'set your currency', + steps: [ + { goto: '/settings?selectedTab=invoice', path: '/settings', settle: 5200, + caption: 'Open <b>Settings → Invoice Settings</b>.', + point: { field: 'currency' } }, + { path: '/settings', caption: 'Pick your <b>currency</b> in the General section — it’s used on every invoice.', + point: { field: 'currency' }, final: true }, + ], + }, + { + slug: 'admin-how-to-rename-system-terminology', + title: 'rename system terminology', + steps: [ + { goto: '/settings?selectedTab=naming', path: '/settings', settle: 2800, + caption: 'Open <b>Settings → Naming</b> to relabel system terms.', + point: { firstField: true } }, + { path: '/settings', caption: 'Type your institute’s wording in the <b>Custom</b> column — e.g. call a “Course” a “Programme”.', + point: { firstField: true }, final: true }, + ], + }, + { + slug: 'admin-how-to-configure-lead-scoring-rules', + title: 'configure lead settings', + steps: [ + { goto: '/settings?selectedTab=leadSettings', path: '/settings', settle: 3200, + caption: 'Open <b>Settings → Lead Settings</b> to control your CRM’s lead engine.', + point: { text: 'Lead Settings', region: 'sidebar' } }, + { path: '/settings', caption: 'Tune <b>scoring weights</b> — plus statuses, SLAs, and distribution pools.', + point: { firstField: true }, final: true }, + ], + }, +]; + +// CLI +const arg = process.argv.find((a) => a.startsWith('--slugs=')); +const onlySlugs = arg ? arg.split('=')[1].split(',').map((s) => s.trim()).filter(Boolean) : null; +await runFlows(FLOWS, { onlySlugs }); diff --git a/tools/walkthrough-generator/capture/auto-spec.mjs b/tools/walkthrough-generator/capture/auto-spec.mjs new file mode 100644 index 0000000000..98d1f6d748 --- /dev/null +++ b/tools/walkthrough-generator/capture/auto-spec.mjs @@ -0,0 +1,64 @@ +/** + * auto-spec — reads the onboarding CSVs, resolves each flow to a real route, and + * writes capture/flows-auto.json (one entry per task). Offline; no app contact. + * Run: node capture/auto-spec.mjs + */ +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseCsv } from '../csv.mjs'; +import { resolveRoute } from './route-map.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..', '..'); +const docsDir = join(repoRoot, 'docs'); +const outFile = join(here, 'flows-auto.json'); + +const SOURCES = [ + { file: 'onboarding-guide-admin.csv', side: 'admin' }, + { file: 'onboarding-guide-admin-flows.csv', side: 'admin' }, + { file: 'onboarding-guide-learner.csv', side: 'learner' }, + { file: 'onboarding-guide-learner-flows.csv', side: 'learner' }, +]; + +const kebab = (s) => String(s || '').toLowerCase().replace(/&/g, ' and ').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64); +const used = new Set(); +const uniq = (b) => { let s = b || 'task', i = 2; while (used.has(s)) s = `${b}-${i++}`; used.add(s); return s; }; + +const flows = []; +let fallback = 0; +for (const src of SOURCES) { + const p = join(docsDir, src.file); + if (!existsSync(p)) continue; + const rows = parseCsv(readFileSync(p, 'utf8')).slice(1); + for (const row of rows) { + const title = (row[1] || '').trim(); + if (!title) continue; + const flow = (row[2] || '').trim(); + const description = (row[4] || '').trim(); + // learner-side flows live in a different app; for now route admin-side only, + // learner flows are tagged so capture can skip/route them separately. + const { route, tab } = resolveRoute(`${flow} ${title}`); + if (route === '/dashboard' && !/dashboard|navigate|home/i.test(`${flow} ${title}`)) fallback++; + flows.push({ + slug: uniq(`${src.side}-${kebab(title)}`), + side: src.side, + title, + flow, + description, + route, + tab: tab || null, + }); + } +} + +writeFileSync(outFile, JSON.stringify(flows, null, 2)); + +// report +const byRoute = {}; +for (const f of flows) byRoute[f.route] = (byRoute[f.route] || 0) + 1; +console.log(`Resolved ${flows.length} flows → ${outFile}`); +console.log(`Admin: ${flows.filter((f) => f.side === 'admin').length} | Learner: ${flows.filter((f) => f.side === 'learner').length}`); +console.log(`Unmapped (fell back to /dashboard): ${fallback}`); +console.log('Top routes:'); +Object.entries(byRoute).sort((a, b) => b[1] - a[1]).slice(0, 20).forEach(([r, c]) => console.log(` ${c.toString().padStart(3)} ${r}`)); diff --git a/tools/walkthrough-generator/capture/build-all.mjs b/tools/walkthrough-generator/capture/build-all.mjs new file mode 100644 index 0000000000..78f9a0cb42 --- /dev/null +++ b/tools/walkthrough-generator/capture/build-all.mjs @@ -0,0 +1,31 @@ +/** + * build-all — runs build-video over every captured flow (any dir under + * screenshots/flows/ that has a manifest.json). The "all in one go" HTML pass. + * + * Usage: node capture/build-all.mjs + */ +import { readdirSync, existsSync, statSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; +import { TOOL_ROOT } from './env.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const flowsRoot = join(TOOL_ROOT, 'screenshots', 'flows'); +const builder = join(here, 'build-video.mjs'); + +const dirs = readdirSync(flowsRoot) + .filter((d) => { const p = join(flowsRoot, d); return statSync(p).isDirectory() && existsSync(join(p, 'manifest.json')); }); + +console.log(`building ${dirs.length} walkthrough(s)…\n`); +let ok = 0, fail = 0; +for (const d of dirs) { + try { + execFileSync(process.execPath, [builder, d], { stdio: 'pipe' }); + ok += 1; + } catch (e) { + fail += 1; + console.log(` ! build failed: ${d} :: ${(e.stderr || e.message || '').toString().split('\n')[0]}`); + } +} +console.log(`\nbuilt ${ok}/${dirs.length} (${fail} failed) -> walkthroughs/`); diff --git a/tools/walkthrough-generator/capture/build-video.mjs b/tools/walkthrough-generator/capture/build-video.mjs new file mode 100644 index 0000000000..6007c493bf --- /dev/null +++ b/tools/walkthrough-generator/capture/build-video.mjs @@ -0,0 +1,259 @@ +/** + * build-video — turns a captured flow (real frames + cursor coords + captions) + * into ONE self-contained HTML "video": a faux-browser frame showing the REAL + * screenshots, with a ghost cursor that moves to each recorded click point, a tap + * ripple, crossfades between frames, captions, and a seekable player bar. + * + * Deterministic — no AI, no UI recreation. The UI shown IS the captured product. + * Frames are inlined as base64 so the file is fully portable. + * + * Usage: node capture/build-video.mjs <flow-slug-dir> (→ walkthroughs/) + * node capture/build-video.mjs <slug> --out=walkthroughs-v2 (→ another folder) + * node capture/build-video.mjs learner-profile-view + */ +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { TOOL_ROOT } from './env.mjs'; + +const argv = process.argv.slice(2); +const dirName = argv.find((a) => !a.startsWith('--')) || 'learner-profile-view'; +const outArg = argv.find((a) => a.startsWith('--out=')); +const OUT_FOLDER = outArg ? outArg.split('=')[1] : 'walkthroughs'; +const flowDir = join(TOOL_ROOT, 'screenshots', 'flows', dirName); +if (!existsSync(join(flowDir, 'manifest.json'))) { console.error('no manifest in', flowDir); process.exit(1); } +const man = JSON.parse(readFileSync(join(flowDir, 'manifest.json'), 'utf8')); + +const VW = man.viewport.width, VH = man.viewport.height; +const DEFAULT_DUR = 3400, FINAL_DUR = 3800; + +const frames = man.steps.map((s, i) => { + const b64 = readFileSync(join(flowDir, s.img)).toString('base64'); + return { + src: `data:image/png;base64,${b64}`, + caption: s.caption || '', + address: s.address || '/', + cursor: s.cursor || null, + click: !!s.click, + dur: s.dur != null ? s.dur : (s.final ? FINAL_DUR : DEFAULT_DUR), + }; +}); + +const outDir = join(TOOL_ROOT, OUT_FOLDER); +mkdirSync(outDir, { recursive: true }); +const outFile = join(outDir, `${man.slug}.html`); + +const DATA = JSON.stringify({ vw: VW, vh: VH, urlBase: man.urlBase, title: man.title, frames }); + +const html = `<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="UTF-8"> +<meta name="viewport" content="width=device-width, initial-scale=1.0"> +<title>Walkthrough — ${man.title.replace(/</g, '<')} + + + + + + +
+
+
Guided walkthrough
+
+
+
+
+
+
dash.vacademy.io
+
+
+
+
+
+
+
+
+ + +
+
Step 1
+
0:00 / 0:00
+
+ + +`; + +writeFileSync(outFile, html); +console.log(`built ${frames.length}-frame video → ${outFile}`); +console.log(`(self-contained, ${(html.length / 1024 / 1024).toFixed(2)} MB)`); diff --git a/tools/walkthrough-generator/capture/bulk-capture-auto.mjs b/tools/walkthrough-generator/capture/bulk-capture-auto.mjs new file mode 100644 index 0000000000..d7061cb985 --- /dev/null +++ b/tools/walkthrough-generator/capture/bulk-capture-auto.mjs @@ -0,0 +1,170 @@ +/** + * bulk-capture-auto — unattended, guarded capture across the full flow set. + * + * Reads capture/flows-auto.json (produced by auto-spec.mjs), dedupes flows by + * their resolved URL (many flows share a landing screen — e.g. all /settings + * sub-tabs, or the /dashboard fallbacks), navigates to each UNIQUE url exactly + * once, screenshots it, then fans the shot out to every flow's own folder so the + * generation stage finds screenshots//01-landing.png as it expects. + * + * Same safety model as run-batch.mjs / capture.mjs: + * - reuse demo-admin session; assert demo institute up front AND before every + * shot; abort on mismatch + * - network guard blocks ALL real integrations (payment/domain/comms/etc.) and + * every mutating verb (DELETE/PUT/PATCH/POST) — this is a READ-ONLY sweep + * - never submits anything (pure navigation + screenshot) + * + * Usage: + * node capture/bulk-capture-auto.mjs # admin side (default) + * node capture/bulk-capture-auto.mjs --limit=20 # first 20 unique URLs (smoke) + * node capture/bulk-capture-auto.mjs --side=all # include learner (needs learner base) + */ +import { chromium } from 'playwright'; +import { mkdirSync, existsSync, writeFileSync, readFileSync, copyFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { loadEnv, requireEnv, TOOL_ROOT } from './env.mjs'; +import { routeToUrl } from './route-map.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const env = loadEnv(); +requireEnv(env, ['VACADEMY_BASE_URL', 'VACADEMY_INSTITUTE_ID']); +const BASE = env.VACADEMY_BASE_URL.replace(/\/+$/, ''); +const LEARNER_BASE = (env.VACADEMY_LEARNER_BASE_URL || '').replace(/\/+$/, ''); +const INSTITUTE_ID = env.VACADEMY_INSTITUTE_ID; +const authPath = join(TOOL_ROOT, 'auth-state.json'); +if (!existsSync(authPath)) { + console.error('No auth-state.json — run: node capture/smoke-login.mjs first.'); + process.exit(1); +} + +const args = process.argv.slice(2); +const limitArg = args.find((a) => a.startsWith('--limit=')); +const LIMIT = limitArg ? parseInt(limitArg.split('=')[1], 10) : Infinity; +const sideArg = (args.find((a) => a.startsWith('--side=')) || '--side=admin').split('=')[1]; + +const flows = JSON.parse(readFileSync(join(here, 'flows-auto.json'), 'utf8')); + +// Which flows can we capture? Admin always; learner only if a learner base is set. +const captureable = flows.filter((f) => { + if (f.side === 'admin') return sideArg === 'admin' || sideArg === 'all'; + if (f.side === 'learner') return sideArg === 'all' && !!LEARNER_BASE; + return false; +}); +const skippedLearner = flows.filter((f) => f.side === 'learner' && !(sideArg === 'all' && LEARNER_BASE)).length; + +// Group flows by their resolved URL so each unique screen is captured once. +const byUrl = new Map(); +for (const f of captureable) { + const base = f.side === 'learner' ? LEARNER_BASE : BASE; + const url = routeToUrl(base, f.route, f.tab); + if (!byUrl.has(url)) byUrl.set(url, []); + byUrl.get(url).push(f); +} +let uniqueUrls = [...byUrl.keys()]; +if (Number.isFinite(LIMIT)) uniqueUrls = uniqueUrls.slice(0, LIMIT); + +const BLOCK = [ + /razorpay/i, /stripe/i, /cashfree/i, /payment-?gateway/i, /\/pay(ment)?s?\b/i, /checkout/i, /\/order/i, + /whatsapp/i, /\bsms\b/i, /exotel/i, /telephony/i, /\bwati\b/i, + /\bdomain\b/i, /subdomain/i, /\bdns\b/i, /custom-?domain/i, + /oauth/i, /\bsmtp\b/i, /\bgtm\b/i, /youtube/i, /facebook|meta-?ads|lead-?ads/i, + /\/send\b/i, /dispatch/i, /notification-service\/.*(send|whatsapp|email|sms)/i, +]; +// POST to one of these paths = a write. Read POSTs (list/search/filter) are allowed +// so list pages actually populate for the screenshot. +const MUTATING_PATH = /\/(add|create|update|edit|save|delete|remove|destroy|send|dispatch|invite|enroll|import|upload|merge|assign|approve|reject|publish|deactivate|activate|generate)\b/i; +function installGuard(context) { + // Read-only navigation sweep. Gate ONLY data calls (xhr/fetch) — never the + // page's own document/scripts/styles/assets (blocking those breaks the SPA). + return context.route('**', (route) => { + const req = route.request(); + const type = req.resourceType(); + const m = req.method(); + const url = req.url(); + if (type === 'document' || type === 'stylesheet' || type === 'script' || + type === 'image' || type === 'font' || type === 'media' || type === 'manifest') { + return route.continue(); + } + if (m === 'DELETE' || m === 'PUT' || m === 'PATCH') { + console.log(' [guard] blocked', m, url.slice(0, 70)); + return route.abort(); + } + if (m === 'POST' && MUTATING_PATH.test(url)) { + console.log(' [guard] blocked', m, url.slice(0, 70)); + return route.abort(); + } + if (BLOCK.some((re) => re.test(url))) { + console.log(' [guard] blocked', m, url.slice(0, 70)); + return route.abort(); + } + return route.continue(); + }); +} +const wait = (page, ms) => page.waitForTimeout(ms); +const urlKey = (url) => url.replace(/^https?:\/\//, '').replace(/[^a-z0-9]+/gi, '_').replace(/^_+|_+$/g, '').slice(0, 80); + +(async () => { + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ storageState: authPath, viewport: { width: 1440, height: 900 } }); + await installGuard(context); + const page = await context.newPage(); + + await page.goto(BASE + '/dashboard', { waitUntil: 'domcontentloaded', timeout: 30000 }); + await page.waitForLoadState('networkidle').catch(() => {}); + await wait(page, 2000); + const active = await page.evaluate(() => localStorage.getItem('selectedInstituteId')); + if (active !== INSTITUTE_ID) { + console.error(`ABORT: active institute "${active}" !== demo "${INSTITUTE_ID}"`); + await browser.close(); + process.exit(2); + } + console.log(`institute lock OK (${active})`); + console.log(`flows: ${flows.length} | captureable: ${captureable.length} | unique URLs: ${byUrl.size} (capturing ${uniqueUrls.length}) | learner skipped: ${skippedLearner}\n`); + + const sharedDir = join(TOOL_ROOT, 'screenshots', '_by-url'); + mkdirSync(sharedDir, { recursive: true }); + const report = []; + let done = 0; + + for (const url of uniqueUrls) { + const group = byUrl.get(url); + const key = urlKey(url); + const sharedShot = join(sharedDir, `${key}.png`); + let ok = false; + try { + await page.goto(url, { waitUntil: 'commit', timeout: 35000 }); + await page.waitForLoadState('domcontentloaded').catch(() => {}); + await page.waitForLoadState('networkidle').catch(() => {}); + await wait(page, 2600); + // re-assert institute lock before every shot (cheap safety net) + const cur = await page.evaluate(() => localStorage.getItem('selectedInstituteId')); + if (cur !== INSTITUTE_ID) throw new Error(`institute drifted to ${cur}`); + await page.screenshot({ path: sharedShot }); + ok = true; + } catch (e) { + console.log(` ! ${url.slice(0, 70)} -> ${e.message.split('\n')[0]}`); + } + + // fan the shared shot out to each flow that uses this URL + for (const f of group) { + const flowDir = join(TOOL_ROOT, 'screenshots', f.slug); + mkdirSync(flowDir, { recursive: true }); + if (ok) { + try { copyFileSync(sharedShot, join(flowDir, '01-landing.png')); } catch {} + } + report.push({ slug: f.slug, side: f.side, title: f.title, route: f.route, tab: f.tab, url, captured: ok }); + } + done += 1; + console.log(` [${done}/${uniqueUrls.length}] ${ok ? 'shot' : 'FAIL'} ${group.length}x ${url.replace(BASE, '')}`); + } + + writeFileSync(join(TOOL_ROOT, 'screenshots', 'bulk-capture-report.json'), JSON.stringify({ + base: BASE, institute: INSTITUTE_ID, total_flows: flows.length, captureable: captureable.length, + unique_urls: byUrl.size, captured_urls: done, learner_skipped: skippedLearner, flows: report, + }, null, 2)); + const okFlows = report.filter((r) => r.captured).length; + await browser.close(); + console.log(`\nbulk capture done. ${okFlows}/${report.length} flow-shots written.`); + console.log('report -> screenshots/bulk-capture-report.json'); +})(); diff --git a/tools/walkthrough-generator/capture/capture-auto-flow.mjs b/tools/walkthrough-generator/capture/capture-auto-flow.mjs new file mode 100644 index 0000000000..87a52ce7fe --- /dev/null +++ b/tools/walkthrough-generator/capture/capture-auto-flow.mjs @@ -0,0 +1,370 @@ +/** + * capture-auto-flow — generalized REAL-screenshot capture across many flows. + * + * For each flow (out/flows/.json), drives the live demo institute and records + * real frames in build-video's manifest format: + * 1. Dashboard (cursor → the nav item that leads to the feature) + * 2. Feature landing (the real destination screen; cursor → primary action) + * 3. Primary action open (the real Create/Add/Invite dialog or sub-view, if any) + * (+ optional extra sub-view / tab frame where cleanly detectable) + * + * Every frame is a REAL screenshot of the product — nothing is recreated or guessed. + * This is the automatic generalization of the hand-authored learner-profile spec. + * + * Safety (read-only-ish): + * - institute-locked to the demo; asserted up front AND before every shot + * - network guard blocks all 3rd-party / cross-institute calls AND every mutating + * submit (DELETE/PUT/PATCH + add/create/update/... POSTs). We OPEN dialogs and + * switch tabs (client-side) but never SUBMIT — so no demo data is created. + * - one shared browser session, sequential (the demo is a single shared workspace) + * + * Output per flow: screenshots/flows//NN.png + manifest.json + * + * Usage: + * node capture/capture-auto-flow.mjs --limit=5 # first 5 admin flows (smoke) + * node capture/capture-auto-flow.mjs --slugs=a,b,c # specific slugs + * node capture/capture-auto-flow.mjs # all admin flows + * node capture/capture-auto-flow.mjs --skip-existing # skip flows already captured + */ +import { chromium } from 'playwright'; +import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { loadEnv, requireEnv, TOOL_ROOT } from './env.mjs'; +import { routeToUrl } from './route-map.mjs'; + +const env = loadEnv(); +requireEnv(env, ['VACADEMY_BASE_URL', 'VACADEMY_INSTITUTE_ID']); +const BASE = env.VACADEMY_BASE_URL.replace(/\/+$/, ''); +const INSTITUTE_ID = env.VACADEMY_INSTITUTE_ID; +const URLBASE = BASE.replace(/^https?:\/\//, '').replace(/\/+$/, ''); +const authPath = join(TOOL_ROOT, 'auth-state.json'); +if (!existsSync(authPath)) { console.error('No auth-state.json — run smoke-login first.'); process.exit(1); } + +const args = process.argv.slice(2); +const getArg = (k) => { const a = args.find((x) => x.startsWith(`--${k}=`)); return a ? a.split('=')[1] : null; }; +const LIMIT = getArg('limit') ? parseInt(getArg('limit'), 10) : Infinity; +const ONLY = getArg('slugs') ? getArg('slugs').split(',').map((s) => s.trim()).filter(Boolean) : null; +const SKIP_EXISTING = args.includes('--skip-existing'); + +const VIEW = { width: 1440, height: 900 }; +const outRoot = join(TOOL_ROOT, 'screenshots', 'flows'); + +// ---- which flows ---- +// flows-auto.json carries the RESOLVED route/tab (out/flows/*.json do not). +let flows = JSON.parse(readFileSync(join(TOOL_ROOT, 'capture', 'flows-auto.json'), 'utf8')) + .filter((f) => f.side === 'admin'); // learner side needs a learner base URL +if (ONLY) flows = ONLY.map((s) => flows.find((f) => f.slug === s)).filter(Boolean); +if (SKIP_EXISTING) flows = flows.filter((f) => !existsSync(join(outRoot, f.slug, 'manifest.json'))); +if (Number.isFinite(LIMIT)) flows = flows.slice(0, LIMIT); + +// ---- nav label per route (what to point at on the Dashboard) ---- +const NAV_LABEL = [ + [/^\/study-library\/courses/, 'Courses'], [/^\/study-library\/live/, 'Live'], + [/^\/study-library\/doubt/, 'Doubt'], [/^\/study-library\/attendance/, 'Attendance'], + [/^\/study-library\/reports/, 'Reports'], [/^\/study-library\/ai-copilot/, 'Copilot'], + [/^\/study-library/, 'Courses'], + [/^\/assessment\/question/, 'Question'], [/^\/assessment/, 'Assessment'], + [/^\/homework/, 'Homework'], [/^\/evaluator/, 'Evaluator'], [/^\/instructor-copilot/, 'Instructor'], + [/^\/ai-center/, 'AI Center'], [/^\/vim/, 'ViMotion'], + [/^\/manage-students/, 'Learners'], [/^\/manage-contacts/, 'Contacts'], + [/^\/audience-manager/, 'Leads'], + [/^\/manage-institute\/teams/, 'Teams'], [/^\/manage-institute\/batches/, 'Batches'], + [/^\/manage-institute\/sessions/, 'Sessions'], [/^\/manage-institute/, 'Teams'], + [/^\/manage-custom-teams/, 'Teams'], [/^\/manage-inventory/, 'Inventory'], + [/^\/admin-package-management/, 'Package'], + [/^\/financial-management/, 'Fee'], [/^\/manage-payments/, 'Payment'], + [/^\/communication/, 'Inbox'], [/^\/announcement/, 'Announcement'], + [/^\/workflow/, 'Workflow'], [/^\/automation/, 'Automation'], + [/^\/admissions\/enquir/, 'Enquir'], [/^\/admissions\/application/, 'Application'], [/^\/admissions/, 'Admission'], + [/^\/membership/, 'Membership'], [/^\/user-tags/, 'Tag'], + [/^\/settings/, 'Settings'], [/^\/dashboard/, 'Dashboard'], +]; +const navLabelFor = (route) => { for (const [re, l] of NAV_LABEL) if (re.test(route)) return l; return null; }; + +// which left-rail section icon leads to this route (the real first click from the dashboard) +const railFor = (route) => { + if (/^\/settings/.test(route)) return 'Settings'; + if (/^\/(study-library|assessment|homework|evaluator|instructor-copilot)/.test(route)) return 'LMS'; + if (/^\/(ai-center|vim)/.test(route)) return 'AI'; + return 'CRM'; +}; + +// short human feature name from the flow title ("How to invite team members" -> "invite team members") +const featureName = (f) => (f.title || '').replace(/^how to\s+/i, '').trim(); +const routePath = (f) => f.route + (f.tab ? `?selectedTab=${f.tab}` : ''); + +// intent: does this flow CREATE something (so a primary-action dialog is meaningful)? +const CREATE_INTENT = /\b(add|create|invite|set ?up|configure|build|generate|upload|import|issue|enrol|enroll|send|write|make|design|connect|define|schedule|publish|register|draft|compose|launch|new)\b/i; +// deeply-nested actions that live inside an editor the generic driver can't reach +// (a slide is inside chapter→module→subject→course; a node is inside a workflow). +// For these we stop at the section landing instead of opening a misleading top-level dialog. +const DEEP_NESTED = /\b(slide|chapter|module|subject|lesson|coding question|condition node|email node|whatsapp node|delay node|\bnode\b)\b/i; +const wantsDialog = (f) => CREATE_INTENT.test(featureName(f)) && !DEEP_NESTED.test(featureName(f)); + +// ---- network guard ---- +// Owner direction: allow in-demo interaction (clicks + submits with demo data) so +// walkthroughs show the real, completed flow. Still HARD-block anything that reaches +// a third party / another institute (payment charge, domain-DNS, real comms send), +// and never DELETE demo data. +const BLOCK = [ + /razorpay|stripe|cashfree|phonepe|payment-?gateway|\/charge\b|\/capture\b|checkout/i, + /\bdns\b|custom-?domain|\/domain\/(add|update|verify)/i, + /\/whatsapp\/send|\/sms\/send|\/email\/send|notification-service\/.*(send|dispatch)/i, +]; +function installGuard(context) { + return context.route('**', (route) => { + const req = route.request(); const type = req.resourceType(); const m = req.method(); const url = req.url(); + if (['document', 'stylesheet', 'script', 'image', 'font', 'media', 'manifest'].includes(type)) return route.continue(); + if (BLOCK.some((re) => re.test(url))) return route.abort(); // 3rd-party / cross-institute + if (m === 'DELETE') return route.abort(); // never delete demo data + return route.continue(); // allow in-demo GET/POST/PUT/PATCH (clicks + submits) + }); +} + +const wait = (page, ms) => page.waitForTimeout(ms); +// SPA self-redirects (e.g. /dashboard -> /dashboard) interrupt goto; tolerate them. +async function safeGoto(page, url) { + try { + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 35000 }); + } catch (e) { + if (!/interrupted by another navigation/i.test(e.message)) { + await page.goto(url, { waitUntil: 'commit', timeout: 35000 }).catch(() => {}); + } + } + await page.waitForLoadState('domcontentloaded').catch(() => {}); + await page.waitForLoadState('networkidle').catch(() => {}); +} +const assertInstitute = async (page) => { + const cur = await page.evaluate(() => localStorage.getItem('selectedInstituteId')); + if (cur !== INSTITUTE_ID) throw new Error(`institute drifted to ${cur}`); +}; + +// find a visible clickable in a region matching label text; returns {handle,box,text} or null +async function pointAt(page, { text, minX, maxX, minY, maxY, action, preferBottom } = {}) { + const handles = await page.locator('button, a, [role="button"], [role="tab"], li').elementHandles().catch(() => []); + let best = null; + for (const h of handles) { + const box = await h.boundingBox().catch(() => null); + if (!box || box.width < 6 || box.height < 6 || box.width > 520) continue; + if (minX != null && box.x < minX) continue; + if (maxX != null && box.x > maxX) continue; + if (minY != null && box.y < minY) continue; + if (maxY != null && box.y > maxY) continue; + const t = ((await h.innerText().catch(() => '')) || '').trim(); + if (text && !new RegExp(text, 'i').test(t)) continue; + if (action && !/^(create|add|new|invite|generate|upload|import|enroll|build|\+)/i.test(t)) continue; + if (action && t.length > 28) continue; // CTAs are short + // prefer top-most (page CTAs sit high) or bottom-most (dialog submit sits low) + if (!best || (preferBottom ? box.y > best.box.y : box.y < best.box.y)) best = { handle: h, box, text: t }; + } + return best; +} + +const centerOf = (box) => ({ x: Math.round(box.x + box.width / 2), y: Math.round(box.y + box.height / 2) }); + +// a realistic demo value for a field, from its type + placeholder/label text. +// Name-specific patterns are checked BEFORE the generic email/number rules so a +// "Full Name" field never gets an email value. +function demoValueFor(type, hint) { + const h = (hint || '').toLowerCase(); + if (/full name|first and last|first name|last name|person name|contact name|your name|student name|learner name|member name/.test(h)) return 'Rahul Sharma'; + if (/course name|course title|name of (the )?course/.test(h) || /^course$/.test(h.trim())) return 'Foundation Science'; + if (/batch name|batch/.test(h)) return 'Morning Batch 2025'; + if (/session/.test(h)) return '2025-26'; + if (/subject/.test(h)) return 'Mathematics'; + if (/coupon|promo code|discount code/.test(h)) return 'WELCOME10'; + if (/title|subject line|heading|label|display name/.test(h)) return 'Welcome to the program'; + if (type === 'email' || /e-?mail/.test(h)) return 'rahul.sharma@example.com'; + if (type === 'tel' || /phone|mobile|whatsapp number|contact number/.test(h)) return '9876543210'; + if (type === 'number' || /amount|price|fee|cost|qty|quantity|count|marks|score|percent|discount|\bdays\b|duration|limit/.test(h)) return '10'; + if (type === 'url' || /url|link|website|domain/.test(h)) return 'https://example.com'; + if (/description|message|note|detail|content|body|remark|about/.test(h)) return 'A short demo description for this walkthrough.'; + if (/tag/.test(h)) return 'Popular'; + if (/name/.test(h)) return 'Demo Sample'; + return 'Demo Sample'; +} + +// the open modal/dialog panel box (so we fill ITS fields, not the background page) +async function modalBox(page) { + const el = page.locator('[role="dialog"], [aria-modal="true"], .modal, [class*="Dialog"], [class*="dialog"], [class*="Modal"]').last(); + if (await el.count().catch(() => 0)) { + const b = await el.boundingBox().catch(() => null); + if (b && b.width > 220 && b.height > 120) return b; + } + return null; +} +const inside = (box, m) => box.x + box.width / 2 >= m.x - 4 && box.x + box.width / 2 <= m.x + m.width + 4 && + box.y + box.height / 2 >= m.y - 4 && box.y + box.height / 2 <= m.y + m.height + 4; + +// Fill the visible form fields of the open dialog with demo data so the frame +// shows a populated form (and usually an enabled submit). Never submits. +async function fillForm(page) { + let filled = 0; + const m = await modalBox(page); + const inputs = await page.locator('input, textarea, [contenteditable="true"]').elementHandles().catch(() => []); + for (const h of inputs) { + const box = await h.boundingBox().catch(() => null); + if (!box || box.width < 40 || box.height < 10 || box.y < 95) continue; + if (m ? !inside(box, m) : box.x < 64) continue; // scope to the modal (or off the rail) + const tag = await h.evaluate((e) => e.tagName.toLowerCase()).catch(() => 'input'); + const type = ((await h.getAttribute('type').catch(() => '')) || 'text').toLowerCase(); + if (['checkbox', 'radio', 'file', 'hidden', 'range', 'submit', 'button', 'color', 'image'].includes(type)) continue; + const hint = ((await h.getAttribute('placeholder').catch(() => '')) || '') + ' ' + + ((await h.getAttribute('name').catch(() => '')) || '') + ' ' + + ((await h.getAttribute('aria-label').catch(() => '')) || ''); + if (/search/i.test(hint)) continue; // never the search box + if (tag === 'div') { // contenteditable rich text + const txt = (await h.innerText().catch(() => '')) || ''; + if (txt.trim()) continue; + try { await h.click({ timeout: 1500 }); await page.keyboard.type('A short demo description for this walkthrough.', { delay: 2 }); filled += 1; } catch {} + continue; + } + const cur = (await h.inputValue().catch(() => '')) || ''; + if (cur.trim()) continue; // leave pre-filled fields alone + try { await h.fill(demoValueFor(type, hint)); filled += 1; } catch {} + } + // native selects → first real option + for (const s of await page.locator('select').elementHandles().catch(() => [])) { + const box = await s.boundingBox().catch(() => null); + if (!box || (m && !inside(box, m))) continue; + try { await s.selectOption({ index: 1 }); filled += 1; } catch {} + } + // best-effort custom dropdown(s) (e.g. Role Type "Select options") to enable the submit + for (let i = 0; i < 2; i++) { + const trigger = await pointAt(page, { text: 'select option|select role|select type|choose|^select\\b', minX: 320, minY: 120, maxY: 800 }); + if (!trigger) break; + await trigger.handle.click({ timeout: 2500 }).catch(() => {}); + await page.waitForTimeout(500); + const opt = page.locator('[role="option"], [role="menuitem"], [class*="option"], li[data-value]').first(); + if (await opt.count().catch(() => 0)) { await opt.click({ timeout: 2000 }).catch(() => {}); filled += 1; } + await page.keyboard.press('Escape').catch(() => {}); + await page.waitForTimeout(300); + } + return filled; +} + +async function captureFlow(page, flow) { + const dir = join(outRoot, flow.slug); + mkdirSync(dir, { recursive: true }); + const steps = []; + let n = 0; + const shoot = async ({ caption, address, cursor, click, final }) => { + n += 1; + const img = `${String(n).padStart(2, '0')}.png`; + await page.screenshot({ path: join(dir, img) }); + steps.push({ img, caption, address, cursor: cursor || null, click: !!click, final: !!final }); + }; + + const navLabel = navLabelFor(flow.route) || featureName(flow); + const railLabel = railFor(flow.route); + const doDialog = wantsDialog(flow); + + // 1 — Dashboard, cursor on the left-rail section icon that leads to the feature + await safeGoto(page, BASE + '/dashboard'); + await wait(page, 2200); + await assertInstitute(page); + const navHit = await pointAt(page, { text: `^${railLabel}$|^${railLabel}`, maxX: 72 }); + await shoot({ + caption: `From the Dashboard, open ${navLabel}.`, + address: '/dashboard', + cursor: navHit ? centerOf(navHit.box) : null, + click: !!navHit, + }); + + // 2 — Feature landing (the real destination screen) + const url = routeToUrl(BASE, flow.route, flow.tab); + await safeGoto(page, url); + await wait(page, 2600); + await assertInstitute(page); + // only hunt for a primary action when the flow is actually a (reachable) create + const action = doDialog ? await pointAt(page, { action: true, minX: 320, maxY: 340 }) : null; + await shoot({ + caption: action + ? `Open ${navLabel}, then click ${action.text.replace(/\s+/g, ' ')}.` + : `Here's ${navLabel}.`, + address: routePath(flow), + cursor: action ? centerOf(action.box) : null, + click: !!action, + final: !action, + }); + + // 3 + 4 — Open the real primary-action dialog, show it empty, then filled. + if (action) { + await action.handle.click({ timeout: 6000 }).catch(() => {}); + await page.waitForLoadState('networkidle').catch(() => {}); + await wait(page, 1900); + await assertInstitute(page).catch(() => {}); + + // 3 — empty dialog: cursor on the first field the user would type into + const firstField = await pointAt(page, { minX: 320, minY: 150, maxY: 720 }); + const firstInput = await page.locator('input, textarea').elementHandles().catch(() => []); + let fieldCursor = firstField ? centerOf(firstField.box) : null; + for (const h of firstInput) { + const b = await h.boundingBox().catch(() => null); + if (b && b.x > 300 && b.y > 110 && b.y < 820 && b.width > 40) { fieldCursor = centerOf(b); break; } + } + await shoot({ + caption: `Enter the details.`, + address: routePath(flow), + cursor: fieldCursor, + click: !!fieldCursor, + }); + + // 4 — filled dialog: real demo data typed in, cursor on the (now active) submit + await fillForm(page); + await wait(page, 700); + const submit = await pointAt(page, { + text: 'save|create|add|submit|next|continue|generate|send|invite|confirm|finish|done|publish', + minX: 320, minY: 150, preferBottom: true, + }); + await shoot({ + caption: `Then click ${(submit?.text || action.text || 'save').replace(/\s+/g, ' ').toLowerCase()}.`, + address: routePath(flow), + cursor: submit ? centerOf(submit.box) : null, + click: false, + final: true, + }); + } + + const manifest = { slug: flow.slug, title: featureName(flow) || flow.title, urlBase: URLBASE, viewport: VIEW, steps }; + writeFileSync(join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2)); + return { slug: flow.slug, frames: steps.length, action: !!action, nav: !!navHit }; +} + +(async () => { + console.log(`capture-auto-flow: ${flows.length} admin flow(s)\n`); + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ storageState: authPath, viewport: VIEW, deviceScaleFactor: 1 }); + await installGuard(context); + const page = await context.newPage(); + + await page.goto(BASE + '/dashboard', { waitUntil: 'domcontentloaded', timeout: 35000 }); + await page.waitForLoadState('networkidle').catch(() => {}); + await wait(page, 1500); + try { await assertInstitute(page); } catch (e) { console.error('ABORT —', e.message); await browser.close(); process.exit(2); } + console.log('institute lock OK\n'); + + const report = []; + let i = 0; + for (const flow of flows) { + i += 1; + try { + const r = await captureFlow(page, flow); + report.push({ ...r, ok: true }); + console.log(` [${i}/${flows.length}] ${r.frames}f ${r.action ? '+dialog' : ''} ${r.nav ? '' : '(no-nav)'} ${flow.slug}`); + } catch (e) { + report.push({ slug: flow.slug, ok: false, error: e.message.split('\n')[0] }); + console.log(` [${i}/${flows.length}] FAIL ${flow.slug} :: ${e.message.split('\n')[0]}`); + } + } + + writeFileSync(join(outRoot, '_auto-capture-report.json'), JSON.stringify({ + base: BASE, institute: INSTITUTE_ID, total: flows.length, + ok: report.filter((r) => r.ok).length, report, + }, null, 2)); + await browser.close(); + const ok = report.filter((r) => r.ok).length; + const withDialog = report.filter((r) => r.action).length; + console.log(`\ndone. ${ok}/${flows.length} captured (${withDialog} reached a primary dialog).`); + console.log('report -> screenshots/flows/_auto-capture-report.json'); +})(); diff --git a/tools/walkthrough-generator/capture/capture-flow.mjs b/tools/walkthrough-generator/capture/capture-flow.mjs new file mode 100644 index 0000000000..8243002502 --- /dev/null +++ b/tools/walkthrough-generator/capture/capture-flow.mjs @@ -0,0 +1,154 @@ +/** + * capture-flow — drives a real flow on the DEMO institute and records, per step: + * - a real screenshot frame (the actual UI), + * - the on-screen pixel coordinates of the element the cursor should click next, + * - a caption + the faux address-bar path. + * Output: screenshots/flows//NN.png + screenshots/flows//manifest.json + * + * The video is then 100% real UI (build-video.mjs animates a cursor over these + * frames). No UI is recreated or invented. + * + * Safety: institute-locked to the demo; allows in-demo interaction (clicks/tabs/ + * submits with demo data) per owner direction, but still blocks anything that + * reaches a third party or another institute (live payment charge / domain-DNS / + * real comms send). Screenshots are viewport-sized at scale 1 so element + * bounding boxes map 1:1 to screenshot pixels. + */ +import { chromium } from 'playwright'; +import { mkdirSync, writeFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { loadEnv, requireEnv, TOOL_ROOT } from './env.mjs'; +import { LEARNER_PROFILE_FLOW } from './flow-specs.mjs'; + +const SPECS = { 'learner-profile': LEARNER_PROFILE_FLOW }; + +const env = loadEnv(); +requireEnv(env, ['VACADEMY_BASE_URL', 'VACADEMY_INSTITUTE_ID']); +const BASE = env.VACADEMY_BASE_URL.replace(/\/+$/, ''); +const INSTITUTE_ID = env.VACADEMY_INSTITUTE_ID; + +const which = process.argv[2] || 'learner-profile'; +const spec = SPECS[which]; +if (!spec) { console.error('unknown spec', which, '— have:', Object.keys(SPECS).join(', ')); process.exit(1); } + +const VIEW = { width: 1440, height: 900 }; +const outDir = join(TOOL_ROOT, 'screenshots', 'flows', spec.slug); +mkdirSync(outDir, { recursive: true }); + +// Block only third-party / cross-institute side effects; allow in-demo writes. +const HARD_BLOCK = [ + /razorpay|stripe|cashfree|phonepe|payment-?gateway|\/charge\b|\/capture\b/i, + /\bdns\b|custom-?domain|\/domain\/(add|update|verify)/i, + /notification-service\/.*(send|dispatch)\b/i, + /\/sms\/send|\/whatsapp\/send|\/email\/send/i, +]; +function installGuard(context) { + return context.route('**', (route) => { + const url = route.request().url(); + if (HARD_BLOCK.some((re) => re.test(url))) { console.log(' [guard] blocked', url.slice(0, 70)); return route.abort(); } + return route.continue(); + }); +} + +const wait = (page, ms) => page.waitForTimeout(ms); + +// locate a clickable by text, optionally restricted to the right-hand slide-over +async function findByText(page, text, opts = {}) { + const handles = await page.locator(`text="${text}"`).elementHandles().catch(() => []); + let best = null, bestX = -1; + for (const h of handles) { + const box = await h.boundingBox().catch(() => null); + if (!box || box.width === 0) continue; + if (opts.minX != null && box.x < opts.minX) continue; + if (opts.rightmost) { if (box.x > bestX) { bestX = box.x; best = h; } } + else { best = h; break; } + } + return best; +} + +// the row "open profile" icon in the Details column (validated heuristic) +async function findOpenIcon(page) { + const cand = await page.locator('button:has(svg), a:has(svg), [role="button"]:has(svg)').elementHandles(); + for (const h of cand) { + const box = await h.boundingBox().catch(() => null); + if (!box) continue; + if (box.width > 0 && box.width < 50 && box.x > 380 && box.x < 760 && box.y > 380 && box.y < 900) return h; + } + return null; +} + +async function resolveTarget(page, step) { + if (step.openIcon) return await findOpenIcon(page); + if (step.clickText) return await findByText(page, step.clickText, { minX: step.minX, rightmost: step.rightmost }); + return null; +} + +(async () => { + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ storageState: join(TOOL_ROOT, 'auth-state.json'), viewport: VIEW, deviceScaleFactor: 1 }); + await installGuard(context); + const page = await context.newPage(); + + await page.goto(BASE + '/dashboard', { waitUntil: 'domcontentloaded', timeout: 35000 }); + await page.waitForLoadState('networkidle').catch(() => {}); + await wait(page, 2000); + const active = await page.evaluate(() => localStorage.getItem('selectedInstituteId')); + if (active !== INSTITUTE_ID) { console.error('ABORT institute', active); await browser.close(); process.exit(2); } + console.log('institute lock OK', active, '\nflow:', spec.slug, '-', spec.title, '\n'); + + const manifestSteps = []; + let curPath = '/dashboard'; + let n = 0; + + for (const step of spec.steps) { + if (step.goto) { + await page.goto(BASE + step.goto, { waitUntil: 'commit', timeout: 35000 }); + await page.waitForLoadState('domcontentloaded').catch(() => {}); + await page.waitForLoadState('networkidle').catch(() => {}); + await wait(page, step.settle || 2400); + curPath = step.goto; + } else if (step.wait) { + await wait(page, step.wait); + } + if (step.path) curPath = step.path; + + // resolve the element the cursor will point at on THIS frame (if any) + let cursor = null; + if (!step.final) { + const target = await resolveTarget(page, step); + if (target) { + await target.scrollIntoViewIfNeeded().catch(() => {}); + await wait(page, 350); + const box = await target.boundingBox().catch(() => null); + if (box) cursor = { x: Math.round(box.x + box.width / 2), y: Math.round(box.y + box.height / 2) }; + // keep a handle to click after the screenshot + step.__target = target; + } else { + console.log(` ! could not resolve target for: ${JSON.stringify(step).slice(0, 80)}`); + } + } + + // screenshot the frame (viewport, scale 1 → coords map 1:1) + n += 1; + const img = `${String(n).padStart(2, '0')}.png`; + await page.screenshot({ path: join(outDir, img) }); + manifestSteps.push({ img, caption: step.caption || '', address: curPath, cursor, click: !step.final && !!cursor }); + console.log(` [${n}] ${img} ${cursor ? `cursor(${cursor.x},${cursor.y})` : 'final'} "${(step.caption || '').replace(/<[^>]+>/g, '')}"`); + + // perform the click to advance to the next frame + if (step.__target) { + await step.__target.click({ timeout: 6000 }).catch((e) => console.log(' ! click failed:', e.message.split('\n')[0])); + await page.waitForLoadState('networkidle').catch(() => {}); + await wait(page, step.after || 1600); + } + } + + const manifest = { + slug: spec.slug, title: spec.title, + urlBase: (env.VACADEMY_BASE_URL || 'dash.vacademy.io').replace(/^https?:\/\//, '').replace(/\/+$/, ''), + viewport: VIEW, steps: manifestSteps, + }; + writeFileSync(join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2)); + await browser.close(); + console.log(`\ncaptured ${manifestSteps.length} real frames → ${outDir}`); +})(); diff --git a/tools/walkthrough-generator/capture/capture.mjs b/tools/walkthrough-generator/capture/capture.mjs new file mode 100644 index 0000000000..65d81cf8da --- /dev/null +++ b/tools/walkthrough-generator/capture/capture.mjs @@ -0,0 +1,173 @@ +/** + * capture — guarded screenshot capture of a flow on the demo institute. + * + * SAFETY (all enforced here): + * - Reuses the saved demo-admin session (auth-state.json). + * - Asserts the active institute === the configured demo institute; aborts otherwise. + * - NETWORK GUARD blocks every REAL integration / side-effect: + * payment gateways (Razorpay/Stripe/Cashfree), custom domain/DNS, + * WhatsApp/Meta/Wati, SMS, Exotel telephony, OAuth connects, SMTP, GTM, + * YouTube connect, FB/Meta lead ads — plus ALL DELETE/PUT/PATCH writes. + * - The ONLY write allowed is the team-invite email (explicitly approved), + * and only when run with `--submit`. It is sent to a reserved example.com + * address that cannot reach a real person. + * + * Usage: node capture/capture.mjs teams-invite [--submit] + */ +import { chromium } from 'playwright'; +import { mkdirSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { loadEnv, requireEnv, TOOL_ROOT } from './env.mjs'; + +const env = loadEnv(); +requireEnv(env, ['VACADEMY_BASE_URL', 'VACADEMY_INSTITUTE_ID']); +const BASE = env.VACADEMY_BASE_URL.replace(/\/+$/, ''); +const INSTITUTE_ID = env.VACADEMY_INSTITUTE_ID; +const authPath = join(TOOL_ROOT, 'auth-state.json'); +if (!existsSync(authPath)) { + console.error('No auth-state.json — run: node capture/smoke-login.mjs first.'); + process.exit(1); +} + +const args = process.argv.slice(2); +const flow = args.find((a) => !a.startsWith('--')) || 'teams-invite'; +const allowSubmit = args.includes('--submit'); + +// ---- Network safety guard: block ALL real integrations / side-effects ------- +// (invite-email is intentionally NOT in this list — it's the one approved write.) +const BLOCK = [ + /razorpay/i, /stripe/i, /cashfree/i, /payment-?gateway/i, /\/pay(ment)?s?\b/i, /checkout/i, /\/order/i, + /whatsapp/i, /\bsms\b/i, /exotel/i, /telephony/i, /\bwati\b/i, + /\bdomain\b/i, /subdomain/i, /\bdns\b/i, /custom-?domain/i, + /oauth/i, /\bsmtp\b/i, /\bgtm\b/i, /youtube/i, /facebook|meta-?ads|lead-?ads/i, + /\/send\b/i, /dispatch/i, /notification-service\/.*(send|whatsapp|email|sms)/i, +]; +function installGuard(context) { + return context.route('**', (route) => { + const req = route.request(); + const m = req.method(); + const url = req.url(); + if (m === 'DELETE' || m === 'PUT' || m === 'PATCH') { + console.log(' [guard] blocked write', m, url.slice(0, 80)); + return route.abort(); + } + if (m !== 'GET' && m !== 'HEAD' && m !== 'OPTIONS' && BLOCK.some((re) => re.test(url))) { + console.log(' [guard] blocked integration', m, url.slice(0, 80)); + return route.abort(); + } + return route.continue(); + }); +} + +const wait = (page, ms) => page.waitForTimeout(ms); + +(async () => { + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ + storageState: authPath, + viewport: { width: 1440, height: 900 }, + }); + await installGuard(context); + const page = await context.newPage(); + + const outDir = join(TOOL_ROOT, 'screenshots', flow); + mkdirSync(outDir, { recursive: true }); + let n = 0; + const shot = async (label) => { + n += 1; + const file = join(outDir, `${String(n).padStart(2, '0')}-${label}.png`); + await page.screenshot({ path: file }); + console.log(` shot ${file}`); + }; + const clickText = async (text, opts = {}) => { + const loc = page.getByText(text, { exact: opts.exact ?? false }).first(); + await loc.waitFor({ state: 'visible', timeout: opts.timeout ?? 8000 }); + await loc.scrollIntoViewIfNeeded().catch(() => {}); + await loc.click(); + }; + + await page.goto(BASE + '/dashboard', { waitUntil: 'domcontentloaded', timeout: 30000 }); + await page.waitForLoadState('networkidle').catch(() => {}); + await wait(page, 2500); + + // ---- INSTITUTE LOCK ----------------------------------------------------- + const active = await page.evaluate(() => localStorage.getItem('selectedInstituteId')); + if (active !== INSTITUTE_ID) { + console.error(`ABORT: active institute "${active}" !== demo "${INSTITUTE_ID}"`); + await browser.close(); + process.exit(2); + } + console.log(`institute lock OK (${active}) | submit=${allowSubmit}`); + + if (flow === 'teams-invite') { + await shot('dashboard'); + + await clickText('Manage Institute').catch((e) => console.log(' ! Manage Institute:', e.message)); + await wait(page, 1200); + await shot('manage-institute-open'); + + await clickText('Teams', { exact: true }).catch((e) => console.log(' ! Teams:', e.message)); + await wait(page, 2500); + await page.waitForLoadState('networkidle').catch(() => {}); + await shot('teams'); + + await clickText('Invite Users').catch((e) => console.log(' ! Invite Users:', e.message)); + await wait(page, 1800); + await shot('invite-modal'); + + // Fill name + email (modal-scoped placeholders → unambiguous) + await page.getByPlaceholder('Full name (First and Last)').fill('Priya Sharma').catch((e) => console.log(' ! name:', e.message)); + await page.getByPlaceholder('Enter Email').fill('walkthrough.demo@example.com').catch((e) => console.log(' ! email:', e.message)); + await wait(page, 500); + await shot('filled'); + + // Pick a role (needed to enable submit) + await clickText('Select options').catch((e) => console.log(' ! role open:', e.message)); + await wait(page, 900); + await shot('role-open'); + // The options are a plain floating list below the trigger; "Admin" also + // appears in the table behind, so pick the one positioned below the trigger. + const rolePicked = await page.evaluate(() => { + const els = [...document.querySelectorAll('div,li,span,button,a,[role]')]; + const cand = els.find( + (el) => + el.textContent && + el.textContent.trim() === 'Admin' && + el.getBoundingClientRect().top > 545 && + el.getBoundingClientRect().width > 0 && + el.offsetParent !== null + ); + if (cand) { + cand.click(); + return true; + } + return false; + }); + console.log(' role picked:', rolePicked); + await wait(page, 700); + await page.keyboard.press('Escape').catch(() => {}); + await wait(page, 600); + await shot('role-picked'); + + // Submit — only if explicitly allowed AND the button is enabled + const submitBtn = page.getByRole('button', { name: 'Invite User', exact: true }).last(); + const enabled = await submitBtn.isEnabled().catch(() => false); + console.log(' submit enabled:', enabled, '| allowSubmit:', allowSubmit); + if (allowSubmit && enabled) { + await submitBtn.click().catch((e) => console.log(' ! submit:', e.message)); + await page.waitForLoadState('networkidle').catch(() => {}); + await wait(page, 3000); + await shot('success'); + console.log(' invite submitted (example.com — no real recipient).'); + } else { + console.log(' NOT submitting (either --submit not passed or button disabled).'); + } + + console.log('teams-invite capture done.'); + } else { + console.error('Unknown flow:', flow); + } + + await browser.close(); + console.log('capture done.'); +})(); diff --git a/tools/walkthrough-generator/capture/engine.mjs b/tools/walkthrough-generator/capture/engine.mjs new file mode 100644 index 0000000000..09d73ee56e --- /dev/null +++ b/tools/walkthrough-generator/capture/engine.mjs @@ -0,0 +1,431 @@ +/** + * engine — runs an AUTHORED flow spec against the live demo institute and records + * a real screenshot per step, plus the cursor target + caption + address-bar path. + * The video is 100% real UI (build-video animates a ghost cursor over these frames). + * + * Unlike the generic auto-driver, each flow here is hand-authored, so it performs + * the COMPLETE end-to-end task: navigate the real path, click the right control, + * fill the form with demo data, advance through every step, submit, show the result. + * + * Shared-screen cache: a step tagged `screen:''` is captured ONCE; later flows + * reuse the cached image (same real screenshot) instead of re-shooting it — fast, + * lossless. The live browser is still positioned so the flow can continue. + * + * Safety: institute-locked to the demo; allows in-demo clicks/fills/submits per + * owner direction, but HARD-blocks 3rd-party / cross-institute calls (payment, + * domain-DNS, real comms send) and never DELETEs. + * + * Step DSL (every field optional unless noted): + * goto:'/path' navigate first (full navigation) + * navRail:'CRM' click a left-rail section to advance (CRM|LMS|AI|Settings) + * navClick:{text,region}click something to advance to this frame's screen + * path:'/x' address-bar path to show on this frame + * screen:'id' shared-screen cache id (reuse/store the image) + * caption:'...' one short line (bold key nouns) + * point:{...} what the cursor points at on THIS frame: + * {text,region} | {coords:[x,y]} | {firstField:true} | {submit:true} + * {field:'regex'} a specific control by placeholder/aria/label/id + * {sel:'css'} a specific element by CSS selector (e.g. '#sessions-no') + * any point may add scroll:true to reveal an off-screen target first + * then:{...} advance AFTER the shot: + * {clickPoint:true} click the pointed element + * {click:{text,region}} click something else + * {clickSel:'css'} click an element by CSS selector + * {fill:true} fill the open form (typed as progressive snapshots) + * {type:{field:'regex',value,clear?}} type into ONE matched field (clear:true replaces its value) + * {set:{field?|sel?,value}} fill a field directly (dates / native inputs) + * {select:{trigger,option,caption,commit?}} open a dropdown, show it, pick option (commit:false = show only, don't change) + * {submit:{text?}} click the submit/primary button + * wait:ms extra settle after the action + * settle:ms extra settle after goto/navRail + * final:true last frame (no advance) + */ +import { chromium } from 'playwright'; +import { mkdirSync, writeFileSync, existsSync, copyFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { loadEnv, requireEnv, TOOL_ROOT } from './env.mjs'; + +const env = loadEnv(); +requireEnv(env, ['VACADEMY_BASE_URL', 'VACADEMY_INSTITUTE_ID']); +export const BASE = env.VACADEMY_BASE_URL.replace(/\/+$/, ''); +export const URLBASE = BASE.replace(/^https?:\/\//, '').replace(/\/+$/, ''); +const INSTITUTE_ID = env.VACADEMY_INSTITUTE_ID; +const authPath = join(TOOL_ROOT, 'auth-state.json'); +const VIEW = { width: 1440, height: 900 }; +const cacheDir = join(TOOL_ROOT, 'screens', '_cache'); + +const REGIONS = { rail: [0, 74], sidebar: [0, 300], content: [300, 1440], any: [0, 1440] }; +const SUBMIT_RE = 'invite user|create|save|submit|^next|continue|finish|done|publish|^add|generate|send|confirm|apply'; + +const wait = (page, ms) => page.waitForTimeout(ms); +const pad = (n) => String(n).padStart(2, '0'); +const centerOf = (b) => ({ x: Math.round(b.x + b.width / 2), y: Math.round(b.y + b.height / 2) }); + +const BLOCK = [ + /razorpay|stripe|cashfree|phonepe|payment-?gateway|\/charge\b|\/capture\b|checkout/i, + /\bdns\b|custom-?domain|\/domain\/(add|update|verify)/i, + /\/whatsapp\/send|\/sms\/send|\/email\/send|notification-service\/.*(send|dispatch)/i, +]; +function installGuard(context) { + return context.route('**', (route) => { + const req = route.request(); const type = req.resourceType(); const m = req.method(); const url = req.url(); + if (['document', 'stylesheet', 'script', 'image', 'font', 'media', 'manifest'].includes(type)) return route.continue(); + if (BLOCK.some((re) => re.test(url))) return route.abort(); + if (m === 'DELETE') return route.abort(); + return route.continue(); + }); +} + +async function safeGoto(page, url) { + try { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 35000 }); } + catch (e) { if (!/interrupted by another navigation/i.test(e.message)) await page.goto(url, { waitUntil: 'commit', timeout: 35000 }).catch(() => {}); } + await page.waitForLoadState('domcontentloaded').catch(() => {}); + await page.waitForLoadState('networkidle').catch(() => {}); +} +const assertInstitute = async (page) => { + const cur = await page.evaluate(() => localStorage.getItem('selectedInstituteId')); + if (cur !== INSTITUTE_ID) throw new Error(`institute drifted to ${cur}`); +}; + +// find a visible clickable matching `text` (string regex) within a region +async function findByText(page, text, region = 'any', preferBottom = false) { + const [minX, maxX] = REGIONS[region] || REGIONS.any; + const re = text ? new RegExp(text, 'i') : null; + const handles = await page.locator('button, a, [role="button"], [role="tab"], [role="option"], li, .cursor-pointer').elementHandles().catch(() => []); + let best = null; + for (const h of handles) { + const box = await h.boundingBox().catch(() => null); + if (!box || box.width < 6 || box.height < 6 || box.width > 560) continue; + const cx = box.x + box.width / 2; + if (cx < minX || cx > maxX) continue; + if (box.y < 40) continue; + const t = ((await h.innerText().catch(() => '')) || '').trim(); + const head = (t.split('\n')[0] || '').trim(); // first line = the title/label (cards carry a description below it) + if (re && !re.test(t)) continue; + if (re && head.length > 40) continue; // judge length by the first line, so a titled card with a description still matches + if (!best || (preferBottom ? box.y > best.box.y : box.y < best.box.y)) best = { handle: h, box, text: head }; + } + return best; +} + +// Cursor target for the "name your X" frame: the FIRST field the form-fill will +// actually type into — so the ghost cursor lands on that field, not empty space. +// Mirrors fillForm's selection (modal-scoped, skips search/hidden/non-text), and +// crucially does NOT assume fields start past x=300 — modal fields commonly begin +// near x≈128, which the old `b.x > 300` filter skipped (cursor floated off-target). +async function firstFieldCursor(page) { + const m = await modalBox(page); + for (const h of await page.locator('input, textarea, [contenteditable="true"]').elementHandles().catch(() => [])) { + const b = await h.boundingBox().catch(() => null); + if (!b || b.width < 60 || b.height < 10 || b.y < 110 || b.y > 820) continue; + if (m ? !inside(b, m) : b.x < 64) continue; + const type = ((await h.getAttribute('type').catch(() => '')) || 'text').toLowerCase(); + if (['checkbox', 'radio', 'file', 'hidden', 'range', 'submit', 'button', 'color', 'image'].includes(type)) continue; + const hint = ((await h.getAttribute('placeholder').catch(() => '')) || '') + ' ' + + ((await h.getAttribute('name').catch(() => '')) || '') + ' ' + + ((await h.getAttribute('aria-label').catch(() => '')) || ''); + if (/search/i.test(hint)) continue; + return centerOf(b); + } + return null; +} + +// Locate a SPECIFIC control by its placeholder / aria-label / name / id / associated +//