diff --git a/backend/src/config.js b/backend/src/config.js index 1ddeeec..edce01c 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -70,6 +70,9 @@ const config = { }; config.pexelsKey = process.env.PEXELS_KEY || ''; +config.rentcastApiKey = process.env.RENTCAST_API_KEY || ''; +config.propertySearchProvider = process.env.PROPERTY_SEARCH_PROVIDER || ''; +config.rentcastBaseUrl = process.env.RENTCAST_BASE_URL || 'https://api.rentcast.io/v1'; config.pexelsQuery = 'Mountain path at sunrise'; config.host = process.env.NODE_ENV === "production" ? config.remote : "http://localhost"; diff --git a/backend/src/index.js b/backend/src/index.js index 8e4837d..d0a510c 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -6,7 +6,7 @@ const passport = require('passport'); const path = require('path'); const fs = require('fs'); const bodyParser = require('body-parser'); -const db = require('./db/models'); +require('./db/models'); const config = require('./config'); const swaggerUI = require('swagger-ui-express'); const swaggerJsDoc = require('swagger-jsdoc'); @@ -111,7 +111,10 @@ app.use('/api-docs', function (req, res, next) { app.use(cors({origin: true})); require('./auth/auth'); -app.use(bodyParser.json()); +const requestBodyLimit = '10mb'; + +app.use(bodyParser.json({ limit: requestBodyLimit })); +app.use(bodyParser.urlencoded({ extended: true, limit: requestBodyLimit })); app.use('/api/auth', authRoutes); app.use('/api/file', fileRoutes); diff --git a/backend/src/routes/projects.js b/backend/src/routes/projects.js index 463aea6..937f604 100644 --- a/backend/src/routes/projects.js +++ b/backend/src/routes/projects.js @@ -2,11 +2,11 @@ const express = require('express'); const ProjectsService = require('../services/projects'); +const LegacyBuilderService = require('../services/legacyBuilder'); +const PropertySearchService = require('../services/propertySearch'); const ProjectsDBApi = require('../db/api/projects'); const wrapAsync = require('../helpers').wrapAsync; -const config = require('../config'); - const router = express.Router(); @@ -19,6 +19,17 @@ const { router.use(checkCrudPermissions('projects')); +router.post('/legacy-builder', wrapAsync(async (req, res) => { + const forwardedProto = req.headers['x-forwarded-proto'] || req.protocol; + const requestOrigin = `${forwardedProto}://${req.get('host')}`; + const payload = await LegacyBuilderService.buildLaunchPlan(req.body.data, req.currentUser, requestOrigin); + res.status(200).send(payload); +})); + +router.post('/property-search', wrapAsync(async (req, res) => { + const payload = await PropertySearchService.searchListings(req.body.data || {}); + res.status(200).send(payload); +})); /** * @swagger diff --git a/backend/src/services/legacyBuilder.js b/backend/src/services/legacyBuilder.js new file mode 100644 index 0000000..1168ede --- /dev/null +++ b/backend/src/services/legacyBuilder.js @@ -0,0 +1,1564 @@ +const db = require('../db/models'); +const BusinessIdeasDBApi = require('../db/api/business_ideas'); +const LocationsDBApi = require('../db/api/locations'); +const ProjectsDBApi = require('../db/api/projects'); +const ProjectPhasesDBApi = require('../db/api/project_phases'); +const TasksDBApi = require('../db/api/tasks'); +const DocumentsDBApi = require('../db/api/documents'); +const LegalRequirementsDBApi = require('../db/api/legal_requirements'); +const FundingRoundsDBApi = require('../db/api/funding_rounds'); +const PropertiesDBApi = require('../db/api/properties'); +const PositionsDBApi = require('../db/api/positions'); +const TrainingProgramsDBApi = require('../db/api/training_programs'); +const MarketingCampaignsDBApi = require('../db/api/marketing_campaigns'); +const DesignAssetsDBApi = require('../db/api/design_assets'); +const AiRunsDBApi = require('../db/api/ai_runs'); +const { LocalAIApi } = require('../ai/LocalAIApi'); + +const DAY_IN_MS = 24 * 60 * 60 * 1000; +const TODAY_ISO = '2026-05-01'; + +const PHASE_TYPES = [ + 'idea_intake', + 'feasibility', + 'funding', + 'land_property', + 'design_engineering', + 'construction', + 'legal_compliance', + 'procurement', + 'staffing', + 'training', + 'marketing', + 'operations', + 'launch', +]; + +const TASK_TYPES = [ + 'research', + 'document', + 'permit', + 'legal', + 'vendor', + 'construction', + 'inspection', + 'hiring', + 'training', + 'marketing', + 'finance', + 'ops', + 'other', +]; + +const PRIORITY_TYPES = ['low', 'medium', 'high', 'critical']; +const DOCUMENT_TYPES = [ + 'business_plan', + 'pitch_deck', + 'budget_model', + 'operating_agreement', + 'bylaws', + 'lease', + 'purchase_agreement', + 'permit_application', + 'policy', + 'training_material', + 'marketing_asset', + 'other', +]; +const LEGAL_JURISDICTIONS = ['city', 'county', 'state', 'federal']; +const LEGAL_REQUIREMENT_TYPES = [ + 'business_registration', + 'zoning', + 'permit', + 'license', + 'tax', + 'health_safety', + 'employment', + 'environmental', + 'fire', + 'ada_accessibility', + 'insurance', + 'other', +]; +const FUNDING_TYPES = [ + 'bootstrapped', + 'loan', + 'grant', + 'angel', + 'venture_capital', + 'crowdfunding', + 'family_office', + 'other', +]; +const PROPERTY_TYPES = [ + 'raw_land', + 'commercial_building', + 'residential_conversion', + 'industrial', + 'mixed_use', + 'other', +]; +const EMPLOYMENT_TYPES = ['full_time', 'part_time', 'contractor', 'temporary', 'intern']; +const TRAINING_AUDIENCES = [ + 'all_staff', + 'managers', + 'operations', + 'sales_marketing', + 'compliance', + 'new_hires', +]; +const MARKETING_CHANNELS = [ + 'google_ads', + 'meta_ads', + 'tiktok', + 'youtube', + 'seo', + 'email', + 'sms', + 'radio', + 'tv', + 'print', + 'events', + 'partnerships', + 'other', +]; +const DESIGN_ASSET_TYPES = [ + 'site_plan', + 'floor_plan', + 'elevation', + 'mep', + 'rendering', + 'three_d_model', + 'walkthrough', + 'diagram', + 'other', +]; +const DESIGN_FORMATS = ['image', 'pdf', 'cad', 'ifc', 'glb', 'gltf', 'obj', 'fbx', 'video', 'other']; +const LOCATION_FLEXIBILITY_TYPES = ['specific_area', 'usa_wide', 'international']; + +const CORE_PHASES = [ + 'idea_intake', + 'funding', + 'land_property', + 'legal_compliance', + 'design_engineering', + 'staffing', + 'marketing', + 'launch', +]; + +const PHASE_TITLES = { + idea_intake: 'Founder brief and scope lock', + feasibility: 'Feasibility and market proof', + funding: 'Capital stack and funding readiness', + land_property: 'Land, site, and property path', + design_engineering: 'Design, systems, and layout package', + construction: 'Construction and build execution', + legal_compliance: 'Legal, permitting, and compliance', + procurement: 'Equipment and procurement readiness', + staffing: 'Leadership hiring and workforce plan', + training: 'Training systems and SOP preparation', + marketing: 'Launch marketing and demand engine', + operations: 'Operating model and handoff', + launch: 'Opening readiness and go-live', +}; + +function createBadRequest(message) { + const error = new Error(message); + error.code = 400; + return error; +} + +function cleanText(value) { + if (typeof value !== 'string') { + return ''; + } + + return value.replace(/\s+/g, ' ').trim(); +} + +function trimForStorage(value, max = 4000) { + const text = cleanText(value); + if (text.length <= max) { + return text; + } + + return `${text.slice(0, Math.max(max - 3, 1))}...`; +} + +function toArray(value) { + if (!value) { + return []; + } + + return Array.isArray(value) ? value : [value]; +} + +function toNumber(value) { + if (value === null || value === undefined || value === '') { + return null; + } + + if (typeof value === 'number') { + return Number.isFinite(value) ? value : null; + } + + const normalized = String(value).replace(/[^0-9.-]/g, ''); + const parsed = Number.parseFloat(normalized); + return Number.isFinite(parsed) ? parsed : null; +} + +function toDate(value) { + if (!value) { + return null; + } + + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return null; + } + + return parsed; +} + +function toIsoDate(value) { + if (!value) { + return null; + } + + const parsed = value instanceof Date ? value : toDate(value); + if (!parsed) { + return null; + } + + return parsed.toISOString(); +} + +function addDays(dateValue, days) { + const baseDate = dateValue instanceof Date ? dateValue : new Date(dateValue); + return new Date(baseDate.getTime() + days * DAY_IN_MS); +} + +function diffDays(start, end) { + return Math.max(Math.round((end.getTime() - start.getTime()) / DAY_IN_MS), 1); +} + +function sanitizeEnum(value, allowedValues, fallbackValue) { + if (!value) { + return fallbackValue; + } + + const normalized = cleanText(String(value)).toLowerCase().replace(/[\s-]+/g, '_'); + return allowedValues.includes(normalized) ? normalized : fallbackValue; +} + +function toPlain(record) { + if (!record) { + return record; + } + + if (typeof record.get === 'function') { + return record.get({ plain: true }); + } + + return record; +} + +function isLikelyPublicUrl(url) { + if (!url || typeof url !== 'string') { + return false; + } + + try { + const parsed = new URL(url); + const host = parsed.hostname || ''; + return parsed.protocol.startsWith('http') && !['localhost', '127.0.0.1'].includes(host); + } catch (error) { + return false; + } +} + +function absolutizePublicUrl(url, requestOrigin) { + if (!url || typeof url !== 'string') { + return null; + } + + if (isLikelyPublicUrl(url)) { + return url; + } + + if (url.startsWith('/') && requestOrigin) { + const absoluteUrl = `${requestOrigin}${url}`; + if (isLikelyPublicUrl(absoluteUrl)) { + return absoluteUrl; + } + } + + return null; +} + +function normalizeCurrentUser(currentUser) { + return { + ...currentUser, + organization: currentUser?.organization || { id: null }, + }; +} + +function validateInput(input) { + if (!input.ideaTitle) { + throw createBadRequest('A business idea title is required.'); + } + + if (!input.businessType) { + throw createBadRequest('A business type is required.'); + } + + if (!input.vision) { + throw createBadRequest('A founder vision is required.'); + } +} + +function normalizeInput(rawData) { + return { + ideaTitle: cleanText(rawData?.ideaTitle), + businessType: cleanText(rawData?.businessType), + city: cleanText(rawData?.city), + county: cleanText(rawData?.county), + state: cleanText(rawData?.state), + locationFlexibility: sanitizeEnum(rawData?.locationFlexibility, LOCATION_FLEXIBILITY_TYPES, 'usa_wide'), + sitePreferences: trimForStorage(rawData?.sitePreferences, 1500), + targetBudget: toNumber(rawData?.targetBudget), + targetOpenDate: toDate(rawData?.targetOpenDate), + vision: trimForStorage(rawData?.vision, 5000), + familyLegacyGoal: trimForStorage(rawData?.familyLegacyGoal, 2500), + referenceImages: toArray(rawData?.reference_images), + referenceFiles: toArray(rawData?.reference_files), + }; +} + +function joinNonEmpty(values, separator = ', ') { + return values + .map((value) => cleanText(value)) + .filter(Boolean) + .join(separator); +} + +function hasSpecificLocation(input) { + return Boolean(input.city || input.county || input.state); +} + +function getSpecificLocationLabel(input) { + return joinNonEmpty([input.city, input.state]) || joinNonEmpty([input.county, input.state]) || cleanText(input.state); +} + +function getLocationScopeLabel(input) { + const specificLocation = getSpecificLocationLabel(input); + if (specificLocation) { + return specificLocation; + } + + switch (input.locationFlexibility) { + case 'usa_wide': + return 'Anywhere in the USA'; + case 'international': + return 'USA or other countries'; + default: + return 'Location to be decided'; + } +} + +function getLocationSearchDescriptor(input) { + const specificLocation = getSpecificLocationLabel(input); + if (specificLocation) { + return specificLocation; + } + + switch (input.locationFlexibility) { + case 'usa_wide': + return 'the best-fit market anywhere in the USA'; + case 'international': + return 'the best-fit market in the USA or another country'; + default: + return 'the best-fit market'; + } +} + +function getLocationScopeSentence(input) { + if (hasSpecificLocation(input)) { + return `Focus the first site search around ${getLocationSearchDescriptor(input)}.`; + } + + switch (input.locationFlexibility) { + case 'usa_wide': + return 'Keep the first site search open to any market in the USA.'; + case 'international': + return 'Keep the first site search open across the USA and other promising countries.'; + default: + return 'Keep the first site search flexible until a stronger market thesis is chosen.'; + } +} + +function getSitePreferencesSentence(input) { + return input.sitePreferences ? ` Site priorities: ${input.sitePreferences}.` : ''; +} + +function getComplianceScopeDescription(input) { + const jurisdictions = [input.city, input.county, input.state].map((value) => cleanText(value)).filter(Boolean); + + if (jurisdictions.length) { + return `${jurisdictions.join(', ')}, and federal requirements`; + } + + switch (input.locationFlexibility) { + case 'usa_wide': + return 'federal plus state and local requirements in the final chosen U.S. market'; + case 'international': + return 'country-level, state/provincial, local, and cross-border requirements in the final chosen market'; + default: + return 'local, state/provincial, federal, and market-specific requirements once a site is chosen'; + } +} + +function getBusinessRegistryAuthority(input) { + return input.state ? `${input.state} Secretary of State` : 'Business registry or corporate filing authority for the chosen market'; +} + +function getPlanningAuthority(input) { + return input.city ? `${input.city} Planning and Zoning` : 'Planning and zoning authority for the chosen market'; +} + +function getPermitAuthority(input) { + return input.county ? `${input.county} Permit Office` : input.city ? `${input.city} Permit Office` : 'Permit and inspections office for the chosen site'; +} + +function getRevenueAuthority(input) { + return input.state ? `${input.state} Revenue and Labor Agencies` : 'Revenue, labor, and employer agencies for the chosen market'; +} + +function getLocationLabel(input) { + const specificLocation = getSpecificLocationLabel(input); + + if (specificLocation) { + return `${input.ideaTitle} target site — ${specificLocation}`; + } + + switch (input.locationFlexibility) { + case 'usa_wide': + return `${input.ideaTitle} site search — Anywhere in the USA`; + case 'international': + return `${input.ideaTitle} site search — USA or other countries`; + default: + return `${input.ideaTitle} site search`; + } +} + +function getLocationNotes(input) { + return trimForStorage( + `Target market and site thesis for ${input.ideaTitle}. Search scope: ${getLocationScopeLabel(input)}. ${getLocationScopeSentence(input)}${getSitePreferencesSentence(input)}${input.familyLegacyGoal ? ` Family legacy goal: ${input.familyLegacyGoal}.` : ''}`, + 2000, + ); +} + +function getLocationCountry(input) { + return input.locationFlexibility === 'international' && !input.state ? null : 'USA'; +} + +function buildFallbackBlueprint(input) { + const targetBudget = input.targetBudget || 500000; + const earlyFunding = Math.round(targetBudget * 0.35); + const mainFunding = Math.round(targetBudget * 0.65); + const legacyClause = input.familyLegacyGoal + ? ` Family legacy goal: ${input.familyLegacyGoal}` + : ''; + const specificLocation = getSpecificLocationLabel(input); + const locationSearchDescriptor = getLocationSearchDescriptor(input); + const complianceScopeDescription = getComplianceScopeDescription(input); + const sitePreferencesSentence = getSitePreferencesSentence(input); + const landPhaseSummary = specificLocation + ? `Identify the best site path in ${locationSearchDescriptor} and stress-test property fit, utilities, and zoning alignment.` + : `Compare candidate markets and identify the best site path in ${locationSearchDescriptor}, then stress-test property fit, utilities, zoning posture, and land economics.`; + const siteChecklistTitle = specificLocation ? 'Create the zoning and site fit checklist' : 'Shortlist candidate markets and site criteria'; + const siteChecklistDescription = specificLocation + ? `Check zoning, parking, ingress/egress, utility access, and surrounding fit for candidate sites in ${locationSearchDescriptor}.${sitePreferencesSentence}` + : `Compare land and site options, utilities, zoning posture, access, and cost structure across ${locationSearchDescriptor}.${sitePreferencesSentence}`; + + return { + idea_profile: { + elevator_pitch: `${input.ideaTitle} is a ${input.businessType} concept for ${locationSearchDescriptor} that turns the founder vision into a financeable, buildable, and family-owned operating business.${legacyClause}`, + problem_statement: `The founder needs a guided path to move ${input.ideaTitle} from concept to opening day without losing momentum across funding, site selection, compliance, staffing, or launch execution.`, + target_customers: `Primary customers in and around ${locationSearchDescriptor} who value a well-run ${input.businessType} with a strong local identity and dependable operations.`, + unique_value_proposition: `A modern, system-led ${input.businessType} that is intentionally designed for multigenerational ownership, operational clarity, and a premium customer experience.`, + success_criteria: `Secure a viable site, lock a realistic capital plan, complete the compliance checklist, hire launch-critical roles, and open with repeatable systems that the family can operate long-term.`, + }, + project_name: `${input.ideaTitle} Launch Program`, + executive_summary: `${input.ideaTitle} becomes a tracked launch program covering capital, land/site decisions, legal and permitting work, design assets, staffing, training, and go-to-market execution for ${locationSearchDescriptor}.`, + budget_strategy: `Use the target budget as a phased capital stack: protect early cash for research, legal setup, and land/site diligence; unlock major design/build spend only after zoning, permitting direction, and a clear funding path are in place.`, + compliance_notice: `This plan creates an AI-generated compliance research checklist for ${complianceScopeDescription}. It is a starting brief only and must be verified with local agencies and licensed legal, tax, and construction professionals before decisions are made.`, + phases: [ + { + phase_name: 'Vision lock, scope, and founder brief', + phase_type: 'idea_intake', + summary: `Clarify the founder vision, core offer, and long-term family operating model for ${input.ideaTitle}.`, + }, + { + phase_name: 'Funding stack and bankability', + phase_type: 'funding', + summary: 'Prepare the business plan, capital story, and lender-ready materials needed to finance the launch.', + }, + { + phase_name: 'Land, site, and build feasibility', + phase_type: 'land_property', + summary: landPhaseSummary, + }, + { + phase_name: 'Compliance and permits', + phase_type: 'legal_compliance', + summary: 'Convert legal, zoning, permitting, tax, safety, insurance, and hiring requirements into a tracked approvals path.', + }, + { + phase_name: 'Design systems and 3D walkthrough package', + phase_type: 'design_engineering', + summary: 'Translate the concept into layout briefs, systems diagrams, and 3D-ready walkthrough deliverables.', + }, + { + phase_name: 'Leadership hiring and launch training', + phase_type: 'staffing', + summary: 'Hire the launch-critical team and install SOPs, training materials, and accountability rhythms.', + }, + { + phase_name: 'Launch demand and opening readiness', + phase_type: 'marketing', + summary: 'Create launch demand, opening-day playbooks, and a measurable first-90-day operating plan.', + }, + { + phase_name: 'Grand opening and family handoff system', + phase_type: 'launch', + summary: 'Run final readiness checks, open the business, and document the legacy ownership operating cadence.', + }, + ], + tasks: [ + { + task_title: 'Write the founder thesis, vision, and non-negotiables', + task_type: 'research', + priority: 'high', + phase_type: 'idea_intake', + description: 'Capture the founder story, customer promise, desired systems, and family legacy requirements in one brief.', + estimated_cost: 0, + }, + { + task_title: 'Draft the lender-ready business plan and financial narrative', + task_type: 'finance', + priority: 'critical', + phase_type: 'funding', + description: 'Package the concept, market assumptions, capital needs, and payback story into a financeable plan.', + estimated_cost: Math.round(targetBudget * 0.01), + }, + { + task_title: siteChecklistTitle, + task_type: 'permit', + priority: 'critical', + phase_type: 'land_property', + description: siteChecklistDescription, + estimated_cost: Math.round(targetBudget * 0.005), + }, + { + task_title: 'Build the permit and compliance tracker', + task_type: 'legal', + priority: 'critical', + phase_type: 'legal_compliance', + description: 'Turn market-specific legal and permit obligations into owners, dates, and approvals.', + estimated_cost: Math.round(targetBudget * 0.0075), + }, + { + task_title: 'Translate the concept into a site plan and walkthrough brief', + task_type: 'construction', + priority: 'high', + phase_type: 'design_engineering', + description: 'Define layouts, customer flow, back-of-house systems, utilities, and 3D deliverables for design teams.', + estimated_cost: Math.round(targetBudget * 0.025), + }, + { + task_title: 'Define launch-critical roles and compensation bands', + task_type: 'hiring', + priority: 'high', + phase_type: 'staffing', + description: 'Lock the first wave of hires, responsibilities, compensation ranges, and start timing.', + estimated_cost: Math.round(targetBudget * 0.002), + }, + { + task_title: 'Build the opening-day training deck and SOP starter pack', + task_type: 'training', + priority: 'high', + phase_type: 'staffing', + description: 'Create repeatable training, quality control, compliance reminders, and leadership routines.', + estimated_cost: Math.round(targetBudget * 0.003), + }, + { + task_title: 'Launch the pre-opening marketing engine', + task_type: 'marketing', + priority: 'high', + phase_type: 'marketing', + description: 'Set the launch calendar, local demand-building campaign, and opening-week offer.', + estimated_cost: Math.round(targetBudget * 0.015), + }, + { + task_title: 'Run the final go-live checklist and soft opening', + task_type: 'ops', + priority: 'critical', + phase_type: 'launch', + description: 'Validate staffing, permits, customer journey, and contingency plans before public opening.', + estimated_cost: Math.round(targetBudget * 0.01), + }, + ], + legal_requirements: [ + { + requirement_title: 'Register the business entity and ownership structure', + jurisdiction_level: 'state', + requirement_type: 'business_registration', + authority_name: getBusinessRegistryAuthority(input), + estimated_fees: 500, + details: 'Confirm the correct entity structure, ownership documentation, registered agent, and annual filing obligations.', + }, + { + requirement_title: 'Verify zoning and land-use compatibility for the target site', + jurisdiction_level: 'city', + requirement_type: 'zoning', + authority_name: getPlanningAuthority(input), + estimated_fees: 750, + details: 'Check approved use, parking, setbacks, occupancy, signage, and any conditional-use or variance path.', + }, + { + requirement_title: 'Prepare local operating permits and inspections path', + jurisdiction_level: 'county', + requirement_type: 'permit', + authority_name: getPermitAuthority(input), + estimated_fees: 1200, + details: 'Map permit applications, inspection sequencing, and the documents needed before construction or occupancy.', + }, + { + requirement_title: 'Set up tax, payroll, and employer compliance', + jurisdiction_level: 'state', + requirement_type: 'employment', + authority_name: getRevenueAuthority(input), + estimated_fees: 600, + details: 'Confirm payroll setup, workers comp, unemployment, labor postings, and employer registrations.', + }, + { + requirement_title: 'Confirm insurance, safety, and accessibility requirements', + jurisdiction_level: 'federal', + requirement_type: 'insurance', + authority_name: 'Licensed insurance broker and compliance counsel', + estimated_fees: 2500, + details: 'Verify general liability, property, builder risk, employment coverage, and ADA-related design obligations.', + }, + ], + documents: [ + { + document_type: 'business_plan', + document_title: `${input.ideaTitle} launch business plan`, + content: 'Executive summary, market opportunity, operating model, milestones, funding use, and risk controls.', + }, + { + document_type: 'pitch_deck', + document_title: `${input.ideaTitle} capital story deck`, + content: 'Problem, solution, market, location thesis, budget, timeline, team, and return/impact case.', + }, + { + document_type: 'budget_model', + document_title: `${input.ideaTitle} buildout and opening budget`, + content: 'Sources and uses, phased spend, contingency reserve, staffing ramp, and launch marketing allocation.', + }, + { + document_type: 'permit_application', + document_title: `${input.ideaTitle} permit packet checklist`, + content: 'Application list, agency touchpoints, submission order, and file requirements for each approval.', + }, + { + document_type: 'training_material', + document_title: `${input.ideaTitle} opening week training pack`, + content: 'Role expectations, service standards, safety rules, opening/closing checklists, and escalation paths.', + }, + ], + funding_rounds: [ + { + round_name: 'Founder proof-of-concept capital', + funding_type: 'bootstrapped', + target_amount: earlyFunding, + notes: 'Use early capital for planning, legal setup, site diligence, design scoping, and lender preparation.', + }, + { + round_name: 'Launch and buildout capital', + funding_type: 'loan', + target_amount: mainFunding, + notes: 'Primary capital for site control, design/build, training, initial inventory/equipment, and launch runway.', + }, + ], + property: { + property_name: specificLocation ? `${input.ideaTitle} flagship site candidate` : `${input.ideaTitle} flagship site search shortlist`, + property_type: 'commercial_building', + acquisition_status: 'candidate', + asking_price: Math.round(targetBudget * 0.55), + lot_size_acres: 1.5, + notes: `Target a site in ${locationSearchDescriptor} with room for the customer experience, operations flow, parking, and future expansion.${sitePreferencesSentence}`, + }, + positions: [ + { + position_title: 'Launch operations lead', + employment_type: 'full_time', + salary_min: 65000, + salary_max: 95000, + job_description: 'Own opening readiness, staffing coordination, SOP rollout, and daily operating discipline.', + }, + { + position_title: 'Project and build coordinator', + employment_type: 'contractor', + salary_min: 40000, + salary_max: 70000, + job_description: 'Coordinate milestones across site diligence, design teams, permits, vendors, and launch readiness.', + }, + { + position_title: 'Growth and local demand manager', + employment_type: 'full_time', + salary_min: 55000, + salary_max: 85000, + job_description: 'Run pre-opening campaigns, launch events, partnerships, and the first 90-day customer funnel.', + }, + ], + training_programs: [ + { + program_title: 'Launch operations bootcamp', + audience: 'all_staff', + overview: 'Role clarity, quality standards, customer journey, opening/closing SOPs, and escalation routines.', + }, + { + program_title: 'Compliance and safety readiness', + audience: 'compliance', + overview: 'Permit conditions, documentation habits, inspection readiness, workplace safety, and incident response.', + }, + ], + marketing_campaigns: [ + { + campaign_name: `${input.ideaTitle} grand opening campaign`, + channel: 'meta_ads', + budget: Math.round(targetBudget * 0.03), + objective: 'Build local awareness, capture early interest, and convert opening-week traffic into repeat customers.', + }, + { + campaign_name: `${input.ideaTitle} local SEO and partnerships`, + channel: 'seo', + budget: Math.round(targetBudget * 0.012), + objective: 'Establish search presence, map visibility, and trusted local referral partnerships before launch.', + }, + ], + design_assets: [ + { + asset_name: `${input.ideaTitle} site layout concept`, + asset_type: 'site_plan', + format: 'pdf', + description: 'A site planning brief covering access, customer flow, back-of-house systems, and expansion logic.', + }, + { + asset_name: `${input.ideaTitle} 3D walkthrough storyboard`, + asset_type: 'walkthrough', + format: 'video', + description: 'A room-by-room walkthrough brief describing the customer journey, operational systems, and build details to visualize in 3D.', + }, + { + asset_name: `${input.ideaTitle} 3D systems model brief`, + asset_type: 'three_d_model', + format: 'glb', + description: 'A 3D model brief for utilities, circulation, equipment placement, and system adjacencies based on the founder vision.', + }, + ], + }; +} + +function sanitizePhaseList(rawPhases) { + const seenPhaseTypes = new Set(); + const phases = []; + + for (const phase of toArray(rawPhases)) { + const phaseType = sanitizeEnum(phase?.phase_type, PHASE_TYPES, null); + if (!phaseType || seenPhaseTypes.has(phaseType)) { + continue; + } + + seenPhaseTypes.add(phaseType); + phases.push({ + phase_name: trimForStorage(phase?.phase_name || PHASE_TITLES[phaseType] || 'Phase', 180), + phase_type: phaseType, + summary: trimForStorage(phase?.summary || '', 2000), + }); + } + + return phases; +} + +function ensureCorePhases(aiPhases, fallbackPhases) { + const output = []; + const aiMap = new Map(); + const fallbackMap = new Map(); + + sanitizePhaseList(aiPhases).forEach((phase) => { + aiMap.set(phase.phase_type, phase); + }); + + sanitizePhaseList(fallbackPhases).forEach((phase) => { + fallbackMap.set(phase.phase_type, phase); + }); + + CORE_PHASES.forEach((phaseType) => { + const selected = aiMap.get(phaseType) || fallbackMap.get(phaseType) || { + phase_name: PHASE_TITLES[phaseType], + phase_type: phaseType, + summary: '', + }; + output.push(selected); + }); + + return output; +} + +function sanitizeTasks(rawTasks, fallbackTasks) { + const selectedTasks = toArray(rawTasks).map((task) => ({ + task_title: trimForStorage(task?.task_title || '', 180), + task_type: sanitizeEnum(task?.task_type, TASK_TYPES, 'other'), + priority: sanitizeEnum(task?.priority, PRIORITY_TYPES, 'medium'), + phase_type: sanitizeEnum(task?.phase_type, PHASE_TYPES, 'idea_intake'), + description: trimForStorage(task?.description || '', 3000), + estimated_cost: toNumber(task?.estimated_cost), + })).filter((task) => task.task_title); + + if (selectedTasks.length >= 8) { + return selectedTasks.slice(0, 12); + } + + return toArray(fallbackTasks).slice(0, 12); +} + +function sanitizeDocuments(rawDocuments, fallbackDocuments) { + const documents = toArray(rawDocuments).map((document) => ({ + document_type: sanitizeEnum(document?.document_type, DOCUMENT_TYPES, 'other'), + document_title: trimForStorage(document?.document_title || '', 180), + content: trimForStorage(document?.content || '', 3500), + })).filter((document) => document.document_title); + + if (documents.length) { + return documents.slice(0, 6); + } + + return toArray(fallbackDocuments).slice(0, 6); +} + +function sanitizeLegalRequirements(rawRequirements, fallbackRequirements) { + const requirements = toArray(rawRequirements).map((requirement) => ({ + requirement_title: trimForStorage(requirement?.requirement_title || '', 180), + jurisdiction_level: sanitizeEnum(requirement?.jurisdiction_level, LEGAL_JURISDICTIONS, 'state'), + requirement_type: sanitizeEnum(requirement?.requirement_type, LEGAL_REQUIREMENT_TYPES, 'other'), + authority_name: trimForStorage(requirement?.authority_name || '', 180), + estimated_fees: toNumber(requirement?.estimated_fees), + details: trimForStorage(requirement?.details || '', 3500), + })).filter((requirement) => requirement.requirement_title); + + if (requirements.length) { + return requirements.slice(0, 6); + } + + return toArray(fallbackRequirements).slice(0, 6); +} + +function sanitizeFundingRounds(rawRounds, fallbackRounds) { + const rounds = toArray(rawRounds).map((round) => ({ + round_name: trimForStorage(round?.round_name || '', 180), + funding_type: sanitizeEnum(round?.funding_type, FUNDING_TYPES, 'other'), + target_amount: toNumber(round?.target_amount), + notes: trimForStorage(round?.notes || '', 2500), + })).filter((round) => round.round_name); + + if (rounds.length) { + return rounds.slice(0, 3); + } + + return toArray(fallbackRounds).slice(0, 3); +} + +function sanitizeProperty(rawProperty, fallbackProperty) { + const property = rawProperty && typeof rawProperty === 'object' ? rawProperty : fallbackProperty; + return { + property_name: trimForStorage(property?.property_name || fallbackProperty?.property_name || 'Property candidate', 180), + property_type: sanitizeEnum(property?.property_type, PROPERTY_TYPES, fallbackProperty?.property_type || 'commercial_building'), + acquisition_status: 'candidate', + asking_price: toNumber(property?.asking_price) || fallbackProperty?.asking_price || null, + lot_size_acres: toNumber(property?.lot_size_acres) || fallbackProperty?.lot_size_acres || null, + notes: trimForStorage(property?.notes || fallbackProperty?.notes || '', 3000), + }; +} + +function sanitizePositions(rawPositions, fallbackPositions) { + const positions = toArray(rawPositions).map((position) => ({ + position_title: trimForStorage(position?.position_title || '', 180), + employment_type: sanitizeEnum(position?.employment_type, EMPLOYMENT_TYPES, 'full_time'), + salary_min: toNumber(position?.salary_min), + salary_max: toNumber(position?.salary_max), + job_description: trimForStorage(position?.job_description || '', 3000), + })).filter((position) => position.position_title); + + if (positions.length) { + return positions.slice(0, 5); + } + + return toArray(fallbackPositions).slice(0, 5); +} + +function sanitizeTrainingPrograms(rawPrograms, fallbackPrograms) { + const programs = toArray(rawPrograms).map((program) => ({ + program_title: trimForStorage(program?.program_title || '', 180), + audience: sanitizeEnum(program?.audience, TRAINING_AUDIENCES, 'all_staff'), + overview: trimForStorage(program?.overview || '', 2500), + })).filter((program) => program.program_title); + + if (programs.length) { + return programs.slice(0, 4); + } + + return toArray(fallbackPrograms).slice(0, 4); +} + +function sanitizeMarketingCampaigns(rawCampaigns, fallbackCampaigns) { + const campaigns = toArray(rawCampaigns).map((campaign) => ({ + campaign_name: trimForStorage(campaign?.campaign_name || '', 180), + channel: sanitizeEnum(campaign?.channel, MARKETING_CHANNELS, 'other'), + budget: toNumber(campaign?.budget), + objective: trimForStorage(campaign?.objective || '', 2500), + })).filter((campaign) => campaign.campaign_name); + + if (campaigns.length) { + return campaigns.slice(0, 4); + } + + return toArray(fallbackCampaigns).slice(0, 4); +} + +function sanitizeDesignAssets(rawAssets, fallbackAssets) { + const assets = toArray(rawAssets).map((asset) => ({ + asset_name: trimForStorage(asset?.asset_name || '', 180), + asset_type: sanitizeEnum(asset?.asset_type, DESIGN_ASSET_TYPES, 'other'), + format: sanitizeEnum(asset?.format, DESIGN_FORMATS, 'other'), + description: trimForStorage(asset?.description || '', 2500), + })).filter((asset) => asset.asset_name); + + if (assets.length) { + return assets.slice(0, 5); + } + + return toArray(fallbackAssets).slice(0, 5); +} + +function buildBlueprint(input, aiBlueprint) { + const fallbackBlueprint = buildFallbackBlueprint(input); + const incomingBlueprint = aiBlueprint && typeof aiBlueprint === 'object' ? aiBlueprint : {}; + const ideaProfile = incomingBlueprint.idea_profile && typeof incomingBlueprint.idea_profile === 'object' + ? incomingBlueprint.idea_profile + : {}; + + return { + project_name: trimForStorage(incomingBlueprint.project_name || fallbackBlueprint.project_name, 180), + executive_summary: trimForStorage(incomingBlueprint.executive_summary || fallbackBlueprint.executive_summary, 3500), + budget_strategy: trimForStorage(incomingBlueprint.budget_strategy || fallbackBlueprint.budget_strategy, 3000), + compliance_notice: trimForStorage(incomingBlueprint.compliance_notice || fallbackBlueprint.compliance_notice, 2500), + idea_profile: { + elevator_pitch: trimForStorage(ideaProfile.elevator_pitch || fallbackBlueprint.idea_profile.elevator_pitch, 1500), + problem_statement: trimForStorage(ideaProfile.problem_statement || fallbackBlueprint.idea_profile.problem_statement, 1500), + target_customers: trimForStorage(ideaProfile.target_customers || fallbackBlueprint.idea_profile.target_customers, 1500), + unique_value_proposition: trimForStorage(ideaProfile.unique_value_proposition || fallbackBlueprint.idea_profile.unique_value_proposition, 1500), + success_criteria: trimForStorage(ideaProfile.success_criteria || fallbackBlueprint.idea_profile.success_criteria, 1500), + }, + phases: ensureCorePhases(incomingBlueprint.phases, fallbackBlueprint.phases), + tasks: sanitizeTasks(incomingBlueprint.tasks, fallbackBlueprint.tasks), + legal_requirements: sanitizeLegalRequirements(incomingBlueprint.legal_requirements, fallbackBlueprint.legal_requirements), + documents: sanitizeDocuments(incomingBlueprint.documents, fallbackBlueprint.documents), + funding_rounds: sanitizeFundingRounds(incomingBlueprint.funding_rounds, fallbackBlueprint.funding_rounds), + property: sanitizeProperty(incomingBlueprint.property, fallbackBlueprint.property), + positions: sanitizePositions(incomingBlueprint.positions, fallbackBlueprint.positions), + training_programs: sanitizeTrainingPrograms(incomingBlueprint.training_programs, fallbackBlueprint.training_programs), + marketing_campaigns: sanitizeMarketingCampaigns(incomingBlueprint.marketing_campaigns, fallbackBlueprint.marketing_campaigns), + design_assets: sanitizeDesignAssets(incomingBlueprint.design_assets, fallbackBlueprint.design_assets), + }; +} + +function buildAiPayload(input, requestOrigin) { + const referenceImageNames = input.referenceImages.map((file) => file?.name).filter(Boolean); + const referenceFileNames = input.referenceFiles.map((file) => file?.name).filter(Boolean); + const maybeImageUrls = input.referenceImages + .map((file) => absolutizePublicUrl(file?.publicUrl, requestOrigin)) + .filter(Boolean) + .slice(0, 1); + const locationScopeLabel = getLocationScopeLabel(input); + + const prompt = [ + `Today's date is ${TODAY_ISO}.`, + 'You are generating the first launch blueprint for a family-owned business builder application.', + 'Return JSON only. Do not include markdown, code fences, commentary, or explanations outside the JSON object.', + 'Do not claim legal certainty. Legal items must be framed as draft compliance research requirements to verify with local agencies and licensed professionals.', + 'Do not invent named real vendors, named people, or fake contact details.', + '', + 'Allowed enum values:', + `phase_type: ${PHASE_TYPES.join(', ')}`, + `task_type: ${TASK_TYPES.join(', ')}`, + `priority: ${PRIORITY_TYPES.join(', ')}`, + `document_type: ${DOCUMENT_TYPES.join(', ')}`, + `jurisdiction_level: ${LEGAL_JURISDICTIONS.join(', ')}`, + `requirement_type: ${LEGAL_REQUIREMENT_TYPES.join(', ')}`, + `funding_type: ${FUNDING_TYPES.join(', ')}`, + `property_type: ${PROPERTY_TYPES.join(', ')}`, + `employment_type: ${EMPLOYMENT_TYPES.join(', ')}`, + `audience: ${TRAINING_AUDIENCES.join(', ')}`, + `channel: ${MARKETING_CHANNELS.join(', ')}`, + `asset_type: ${DESIGN_ASSET_TYPES.join(', ')}`, + `format: ${DESIGN_FORMATS.join(', ')}`, + '', + 'Founder intake:', + `idea_title: ${input.ideaTitle}`, + `business_type: ${input.businessType}`, + `city: ${input.city || 'Not provided'}`, + `county: ${input.county || 'Not provided'}`, + `state: ${input.state || 'Not provided'}`, + `location_search_scope: ${locationScopeLabel}`, + `site_preferences: ${input.sitePreferences || 'Not provided'}`, + `target_budget_usd: ${input.targetBudget || 'Not provided'}`, + `target_open_date: ${toIsoDate(input.targetOpenDate) || 'Not provided'}`, + `vision: ${input.vision}`, + `family_legacy_goal: ${input.familyLegacyGoal || 'Not provided'}`, + `reference_image_filenames: ${referenceImageNames.length ? referenceImageNames.join(', ') : 'None uploaded'}`, + `reference_document_filenames: ${referenceFileNames.length ? referenceFileNames.join(', ') : 'None uploaded'}`, + '', + 'Required JSON shape:', + '{', + ' "idea_profile": {', + ' "elevator_pitch": string,', + ' "problem_statement": string,', + ' "target_customers": string,', + ' "unique_value_proposition": string,', + ' "success_criteria": string', + ' },', + ' "project_name": string,', + ' "executive_summary": string,', + ' "budget_strategy": string,', + ' "compliance_notice": string,', + ' "phases": [{ "phase_name": string, "phase_type": string, "summary": string }],', + ' "tasks": [{ "task_title": string, "task_type": string, "priority": string, "phase_type": string, "description": string, "estimated_cost": number }],', + ' "legal_requirements": [{ "requirement_title": string, "jurisdiction_level": string, "requirement_type": string, "authority_name": string, "estimated_fees": number, "details": string }],', + ' "documents": [{ "document_type": string, "document_title": string, "content": string }],', + ' "funding_rounds": [{ "round_name": string, "funding_type": string, "target_amount": number, "notes": string }],', + ' "property": { "property_name": string, "property_type": string, "asking_price": number, "lot_size_acres": number, "notes": string },', + ' "positions": [{ "position_title": string, "employment_type": string, "salary_min": number, "salary_max": number, "job_description": string }],', + ' "training_programs": [{ "program_title": string, "audience": string, "overview": string }],', + ' "marketing_campaigns": [{ "campaign_name": string, "channel": string, "budget": number, "objective": string }],', + ' "design_assets": [{ "asset_name": string, "asset_type": string, "format": string, "description": string }]', + '}', + '', + 'If city/county/state are missing, keep legal authorities generic and use the land/property work to compare candidate markets before a final site is chosen.', + 'Use concise, decision-ready language. Keep arrays practical: 8 phases max, 12 tasks max, 6 legal requirements max, 6 documents max, 3 funding rounds max, 5 positions max, 4 training programs max, 4 marketing campaigns max, 5 design assets max.', + ].join('\n'); + + const userContent = [{ type: 'input_text', text: prompt }]; + maybeImageUrls.forEach((imageUrl) => { + userContent.push({ type: 'input_image', image_url: imageUrl }); + }); + + return { + input: [ + { + role: 'system', + content: 'You are a pragmatic business-launch architect and workflow planner. Produce structured launch blueprints that are useful, specific, and honest about legal/compliance uncertainty.', + }, + { + role: 'user', + content: userContent, + }, + ], + }; +} + +async function generateBlueprint(input, requestOrigin) { + const warnings = []; + const aiPayload = buildAiPayload(input, requestOrigin); + + try { + const response = await LocalAIApi.createResponse(aiPayload, { + poll_interval: 5, + poll_timeout: 120, + }); + + if (!response.success) { + console.error('Legacy builder AI payload:', JSON.stringify(aiPayload)); + console.error('Legacy builder AI error response:', response); + warnings.push('The AI planner was unavailable, so a smart template launch plan was created instead.'); + return { + mode: 'template', + warnings, + blueprint: buildBlueprint(input, null), + response, + }; + } + + try { + const decoded = LocalAIApi.decodeJsonFromResponse(response); + return { + mode: 'ai', + warnings, + blueprint: buildBlueprint(input, decoded), + response, + }; + } catch (error) { + console.error('Legacy builder AI payload:', JSON.stringify(aiPayload)); + console.error('Legacy builder AI JSON parse failed:', error); + console.error('Legacy builder AI raw response:', response); + warnings.push('The AI response could not be parsed cleanly, so a smart template launch plan was created instead.'); + return { + mode: 'template', + warnings, + blueprint: buildBlueprint(input, null), + response, + }; + } + } catch (error) { + console.error('Legacy builder AI payload:', JSON.stringify(aiPayload)); + console.error('Legacy builder AI request failed:', error); + warnings.push('The AI planner hit an unexpected error, so a smart template launch plan was created instead.'); + return { + mode: 'template', + warnings, + blueprint: buildBlueprint(input, null), + response: null, + }; + } +} + +function buildPhaseSchedule(phases, targetOpenDate) { + const startDate = new Date(); + const safeTargetDate = targetOpenDate && targetOpenDate.getTime() > startDate.getTime() + ? targetOpenDate + : addDays(startDate, 240); + const totalDays = Math.max(diffDays(startDate, safeTargetDate), phases.length * 14); + const phaseDuration = Math.max(Math.floor(totalDays / Math.max(phases.length, 1)), 14); + + return phases.map((phase, index) => { + const phaseStart = addDays(startDate, index * phaseDuration); + const phaseEnd = index === phases.length - 1 + ? safeTargetDate + : addDays(startDate, ((index + 1) * phaseDuration) - 1); + + return { + ...phase, + start_at: phaseStart, + end_at: phaseEnd, + status: index === 0 ? 'in_progress' : 'not_started', + sort_order: index + 1, + }; + }); +} + +function attachTaskDates(tasks, phaseSchedule, projectFallbackDate) { + const phaseMap = new Map(); + phaseSchedule.forEach((phase) => { + phaseMap.set(phase.phase_type, phase); + }); + + return tasks.map((task, index) => { + const phase = phaseMap.get(task.phase_type) || phaseSchedule[Math.min(index, phaseSchedule.length - 1)]; + const phaseStart = phase?.start_at || new Date(); + const phaseEnd = phase?.end_at || projectFallbackDate; + const taskDueDate = addDays(phaseStart, Math.max(Math.floor(diffDays(phaseStart, phaseEnd) * 0.65), 1)); + + return { + ...task, + due_at: taskDueDate, + status: index === 0 ? 'in_progress' : 'todo', + phase_type: phase?.phase_type || task.phase_type, + }; + }); +} + +function buildLegalDueDate(requirement, baseDate) { + const offsets = { + city: 14, + county: 24, + state: 35, + federal: 50, + }; + + return addDays(baseDate, offsets[requirement.jurisdiction_level] || 21); +} + +function buildFundingDates(index, phaseSchedule) { + const fundingPhase = phaseSchedule.find((phase) => phase.phase_type === 'funding') || phaseSchedule[0]; + const openAt = addDays(fundingPhase.start_at, index * 14); + const closeAt = addDays(openAt, 30); + return { openAt, closeAt }; +} + +function buildStaffingDate(index, phaseSchedule) { + const staffingPhase = phaseSchedule.find((phase) => ['staffing', 'training'].includes(phase.phase_type)) || phaseSchedule[phaseSchedule.length - 2] || phaseSchedule[0]; + return addDays(staffingPhase.start_at, index * 10); +} + +function buildMarketingDates(index, phaseSchedule) { + const marketingPhase = phaseSchedule.find((phase) => phase.phase_type === 'marketing') || phaseSchedule[phaseSchedule.length - 1] || phaseSchedule[0]; + return { + startAt: addDays(marketingPhase.start_at, index * 7), + endAt: addDays(marketingPhase.end_at, -Math.max(index * 3, 0)), + }; +} + +module.exports = class LegacyBuilderService { + static async buildLaunchPlan(rawData, currentUser, requestOrigin) { + const input = normalizeInput(rawData); + validateInput(input); + + const normalizedCurrentUser = normalizeCurrentUser(currentUser); + const organizationId = normalizedCurrentUser.organization?.id || null; + const generation = await generateBlueprint(input, requestOrigin); + const blueprint = generation.blueprint; + const phaseSchedule = buildPhaseSchedule(blueprint.phases, input.targetOpenDate); + const scheduledTasks = attachTaskDates( + blueprint.tasks, + phaseSchedule, + input.targetOpenDate || addDays(new Date(), 240), + ); + + const transaction = await db.sequelize.transaction(); + + try { + const businessIdea = await BusinessIdeasDBApi.create( + { + idea_title: input.ideaTitle, + elevator_pitch: blueprint.idea_profile.elevator_pitch, + problem_statement: blueprint.idea_profile.problem_statement, + target_customers: blueprint.idea_profile.target_customers, + unique_value_proposition: blueprint.idea_profile.unique_value_proposition, + success_criteria: blueprint.idea_profile.success_criteria, + status: 'in_review', + creator_user: normalizedCurrentUser.id, + reference_images: input.referenceImages, + reference_files: input.referenceFiles, + }, + { + currentUser: normalizedCurrentUser, + transaction, + }, + ); + + const location = await LocationsDBApi.create( + { + label: getLocationLabel(input), + location_type: 'site_candidate', + city: input.city || null, + county: input.county || null, + state: input.state || null, + country: getLocationCountry(input), + notes: getLocationNotes(input), + }, + { + currentUser: normalizedCurrentUser, + transaction, + }, + ); + + const project = await ProjectsDBApi.create( + { + project_name: blueprint.project_name, + stage: 'planning', + target_budget: input.targetBudget, + target_open_date: input.targetOpenDate, + created_on: new Date(), + vision: trimForStorage(`${blueprint.executive_summary}\n\nFounder vision: ${input.vision}${input.familyLegacyGoal ? `\n\nFamily legacy goal: ${input.familyLegacyGoal}` : ''}`, 5000), + business_idea: businessIdea.id, + primary_location: location.id, + }, + { + currentUser: normalizedCurrentUser, + transaction, + }, + ); + + const createdPhases = []; + for (const phase of phaseSchedule) { + const createdPhase = await ProjectPhasesDBApi.create( + { + phase_name: phase.phase_name, + phase_type: phase.phase_type, + status: phase.status, + start_at: phase.start_at, + end_at: phase.end_at, + sort_order: phase.sort_order, + summary: phase.summary, + project: project.id, + organizations: organizationId, + }, + { + currentUser: normalizedCurrentUser, + transaction, + }, + ); + createdPhases.push(toPlain(createdPhase)); + } + + const phaseIdByType = createdPhases.reduce((accumulator, phase) => { + accumulator[phase.phase_type] = phase.id; + return accumulator; + }, {}); + + const createdTasks = []; + for (const task of scheduledTasks) { + const createdTask = await TasksDBApi.create( + { + task_title: task.task_title, + task_type: task.task_type, + status: task.status, + priority: task.priority, + due_at: task.due_at, + estimated_cost: task.estimated_cost, + description: task.description, + project: project.id, + phase: phaseIdByType[task.phase_type] || null, + organizations: organizationId, + }, + { + currentUser: normalizedCurrentUser, + transaction, + }, + ); + createdTasks.push(toPlain(createdTask)); + } + + const createdLegalRequirements = []; + for (const requirement of blueprint.legal_requirements) { + const createdRequirement = await LegalRequirementsDBApi.create( + { + requirement_title: requirement.requirement_title, + jurisdiction_level: requirement.jurisdiction_level, + requirement_type: requirement.requirement_type, + status: 'not_started', + due_at: buildLegalDueDate(requirement, new Date()), + authority_name: requirement.authority_name, + reference_link: null, + estimated_fees: requirement.estimated_fees, + details: requirement.details, + project: project.id, + location: location.id, + organizations: organizationId, + }, + { + currentUser: normalizedCurrentUser, + transaction, + }, + ); + createdLegalRequirements.push(toPlain(createdRequirement)); + } + + const createdDocuments = []; + for (const document of blueprint.documents) { + const createdDocument = await DocumentsDBApi.create( + { + document_type: document.document_type, + document_title: document.document_title, + status: 'draft', + content: document.content, + project: project.id, + organizations: organizationId, + }, + { + currentUser: normalizedCurrentUser, + transaction, + }, + ); + createdDocuments.push(toPlain(createdDocument)); + } + + const createdFundingRounds = []; + for (const [index, fundingRound] of blueprint.funding_rounds.entries()) { + const fundingDates = buildFundingDates(index, phaseSchedule); + const createdFundingRound = await FundingRoundsDBApi.create( + { + round_name: fundingRound.round_name, + funding_type: fundingRound.funding_type, + status: 'planned', + target_amount: fundingRound.target_amount, + committed_amount: null, + funded_amount: null, + open_at: fundingDates.openAt, + close_at: fundingDates.closeAt, + notes: fundingRound.notes, + project: project.id, + organizations: organizationId, + }, + { + currentUser: normalizedCurrentUser, + transaction, + }, + ); + createdFundingRounds.push(toPlain(createdFundingRound)); + } + + const property = await PropertiesDBApi.create( + { + property_name: blueprint.property.property_name, + property_type: blueprint.property.property_type, + acquisition_status: blueprint.property.acquisition_status, + asking_price: blueprint.property.asking_price, + purchase_price: null, + lot_size_acres: blueprint.property.lot_size_acres, + building_area_sqft: null, + offer_date: null, + closing_date: null, + notes: blueprint.property.notes, + project: project.id, + location: location.id, + organizations: organizationId, + }, + { + currentUser: normalizedCurrentUser, + transaction, + }, + ); + + const createdPositions = []; + for (const [index, position] of blueprint.positions.entries()) { + const createdPosition = await PositionsDBApi.create( + { + position_title: position.position_title, + employment_type: position.employment_type, + status: 'planned', + salary_min: position.salary_min, + salary_max: position.salary_max, + target_start_at: buildStaffingDate(index, phaseSchedule), + job_description: position.job_description, + project: project.id, + organizations: organizationId, + }, + { + currentUser: normalizedCurrentUser, + transaction, + }, + ); + createdPositions.push(toPlain(createdPosition)); + } + + const createdTrainingPrograms = []; + for (const program of blueprint.training_programs) { + const createdProgram = await TrainingProgramsDBApi.create( + { + program_title: program.program_title, + audience: program.audience, + status: 'draft', + overview: program.overview, + project: project.id, + organizations: organizationId, + }, + { + currentUser: normalizedCurrentUser, + transaction, + }, + ); + createdTrainingPrograms.push(toPlain(createdProgram)); + } + + const createdMarketingCampaigns = []; + for (const [index, campaign] of blueprint.marketing_campaigns.entries()) { + const marketingDates = buildMarketingDates(index, phaseSchedule); + const createdCampaign = await MarketingCampaignsDBApi.create( + { + campaign_name: campaign.campaign_name, + channel: campaign.channel, + status: 'planned', + budget: campaign.budget, + start_at: marketingDates.startAt, + end_at: marketingDates.endAt, + objective: campaign.objective, + project: project.id, + organizations: organizationId, + }, + { + currentUser: normalizedCurrentUser, + transaction, + }, + ); + createdMarketingCampaigns.push(toPlain(createdCampaign)); + } + + const createdDesignAssets = []; + for (const asset of blueprint.design_assets) { + const createdAsset = await DesignAssetsDBApi.create( + { + asset_name: asset.asset_name, + asset_type: asset.asset_type, + format: asset.format, + description: asset.description, + project: project.id, + property: property.id, + organizations: organizationId, + }, + { + currentUser: normalizedCurrentUser, + transaction, + }, + ); + createdDesignAssets.push(toPlain(createdAsset)); + } + + const aiRun = await AiRunsDBApi.create( + { + run_type: 'plan_generation', + status: generation.mode === 'ai' ? 'succeeded' : 'failed', + started_at: new Date(), + finished_at: new Date(), + input_summary: trimForStorage(`${input.ideaTitle} / ${input.businessType} / ${getLocationScopeLabel(input)} / budget ${input.targetBudget || 'n/a'}`, 1000), + output_summary: trimForStorage(blueprint.executive_summary, 2000), + project: project.id, + initiator_user: normalizedCurrentUser.id, + organizations: organizationId, + }, + { + currentUser: normalizedCurrentUser, + transaction, + }, + ); + + await transaction.commit(); + + const projectDetail = await ProjectsDBApi.findBy({ id: project.id }); + const businessIdeaDetail = await BusinessIdeasDBApi.findBy({ id: businessIdea.id }); + const locationDetail = await LocationsDBApi.findBy({ id: location.id }); + + return { + mode: generation.mode, + warnings: generation.warnings, + generatedAt: new Date().toISOString(), + summary: { + executive_summary: blueprint.executive_summary, + budget_strategy: blueprint.budget_strategy, + compliance_notice: blueprint.compliance_notice, + }, + businessIdea: businessIdeaDetail, + location: locationDetail, + project: projectDetail, + counts: { + phases: createdPhases.length, + tasks: createdTasks.length, + legal_requirements: createdLegalRequirements.length, + documents: createdDocuments.length, + funding_rounds: createdFundingRounds.length, + positions: createdPositions.length, + training_programs: createdTrainingPrograms.length, + marketing_campaigns: createdMarketingCampaigns.length, + design_assets: createdDesignAssets.length, + ai_runs: aiRun ? 1 : 0, + }, + }; + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/propertySearch.js b/backend/src/services/propertySearch.js new file mode 100644 index 0000000..bf8930c --- /dev/null +++ b/backend/src/services/propertySearch.js @@ -0,0 +1,415 @@ +const axios = require('axios'); +const config = require('../config'); + +const SUPPORTED_PROPERTY_TYPES = [ + 'any', + 'land', + 'single_family', + 'condo', + 'townhouse', + 'multi_family', + 'apartment', + 'manufactured', +]; + +const RENTCAST_PROPERTY_TYPE_MAP = { + any: null, + land: 'Land', + single_family: 'Single Family', + condo: 'Condo', + townhouse: 'Townhouse', + multi_family: 'Multi-Family', + apartment: 'Apartment', + manufactured: 'Manufactured', +}; + +const US_STATE_ALIASES = { + AL: 'AL', + ALABAMA: 'AL', + AK: 'AK', + ALASKA: 'AK', + AZ: 'AZ', + ARIZONA: 'AZ', + AR: 'AR', + ARKANSAS: 'AR', + CA: 'CA', + CALIFORNIA: 'CA', + CO: 'CO', + COLORADO: 'CO', + CT: 'CT', + CONNECTICUT: 'CT', + DE: 'DE', + DELAWARE: 'DE', + DC: 'DC', + 'DISTRICT OF COLUMBIA': 'DC', + FL: 'FL', + FLORIDA: 'FL', + GA: 'GA', + GEORGIA: 'GA', + HI: 'HI', + HAWAII: 'HI', + ID: 'ID', + IDAHO: 'ID', + IL: 'IL', + ILLINOIS: 'IL', + IN: 'IN', + INDIANA: 'IN', + IA: 'IA', + IOWA: 'IA', + KS: 'KS', + KANSAS: 'KS', + KY: 'KY', + KENTUCKY: 'KY', + LA: 'LA', + LOUISIANA: 'LA', + ME: 'ME', + MAINE: 'ME', + MD: 'MD', + MARYLAND: 'MD', + MA: 'MA', + MASSACHUSETTS: 'MA', + MI: 'MI', + MICHIGAN: 'MI', + MN: 'MN', + MINNESOTA: 'MN', + MS: 'MS', + MISSISSIPPI: 'MS', + MO: 'MO', + MISSOURI: 'MO', + MT: 'MT', + MONTANA: 'MT', + NE: 'NE', + NEBRASKA: 'NE', + NV: 'NV', + NEVADA: 'NV', + NH: 'NH', + 'NEW HAMPSHIRE': 'NH', + NJ: 'NJ', + 'NEW JERSEY': 'NJ', + NM: 'NM', + 'NEW MEXICO': 'NM', + NY: 'NY', + 'NEW YORK': 'NY', + NC: 'NC', + 'NORTH CAROLINA': 'NC', + ND: 'ND', + 'NORTH DAKOTA': 'ND', + OH: 'OH', + OHIO: 'OH', + OK: 'OK', + OKLAHOMA: 'OK', + OR: 'OR', + OREGON: 'OR', + PA: 'PA', + PENNSYLVANIA: 'PA', + RI: 'RI', + 'RHODE ISLAND': 'RI', + SC: 'SC', + 'SOUTH CAROLINA': 'SC', + SD: 'SD', + 'SOUTH DAKOTA': 'SD', + TN: 'TN', + TENNESSEE: 'TN', + TX: 'TX', + TEXAS: 'TX', + UT: 'UT', + UTAH: 'UT', + VT: 'VT', + VERMONT: 'VT', + VA: 'VA', + VIRGINIA: 'VA', + WA: 'WA', + WASHINGTON: 'WA', + WV: 'WV', + 'WEST VIRGINIA': 'WV', + WI: 'WI', + WISCONSIN: 'WI', + WY: 'WY', + WYOMING: 'WY', +}; + +function createBadRequest(message) { + const error = new Error(message); + error.code = 400; + return error; +} + +function cleanText(value) { + if (typeof value !== 'string') { + return ''; + } + + return value.replace(/\s+/g, ' ').trim(); +} + +function toNumber(value) { + if (value === null || value === undefined || value === '') { + return null; + } + + if (typeof value === 'number') { + return Number.isFinite(value) ? value : null; + } + + const normalized = String(value).replace(/[^0-9.-]/g, ''); + const parsed = Number.parseFloat(normalized); + return Number.isFinite(parsed) ? parsed : null; +} + +function toInteger(value, fallback = 6, min = 1, max = 12) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) { + return fallback; + } + + return Math.max(min, Math.min(parsed, max)); +} + +function normalizeState(value) { + const cleaned = cleanText(value); + if (!cleaned) { + return ''; + } + + const alias = US_STATE_ALIASES[cleaned.toUpperCase()]; + return alias || cleaned.toUpperCase(); +} + +function normalizeCountry(value) { + const cleaned = cleanText(value); + if (!cleaned) { + return 'USA'; + } + + const normalized = cleaned.toUpperCase(); + if (['USA', 'US', 'UNITED STATES', 'UNITED STATES OF AMERICA'].includes(normalized)) { + return 'USA'; + } + + return cleaned; +} + +function normalizePropertyType(value) { + const normalized = cleanText(value).toLowerCase().replace(/[\s-]+/g, '_'); + if (!normalized) { + return 'any'; + } + + return SUPPORTED_PROPERTY_TYPES.includes(normalized) ? normalized : 'any'; +} + +function firstNonEmptyText(values) { + for (const value of values) { + const cleaned = cleanText(value); + if (cleaned) { + return cleaned; + } + } + + return ''; +} + +function firstNumber(values) { + for (const value of values) { + const parsed = toNumber(value); + if (parsed !== null) { + return parsed; + } + } + + return null; +} + +function toAcresFromSquareFeet(value) { + const numeric = toNumber(value); + if (numeric === null) { + return null; + } + + return Number((numeric / 43560).toFixed(2)); +} + +function normalizeListing(item) { + if (!item || typeof item !== 'object') { + return null; + } + + const photoCandidates = []; + if (Array.isArray(item.photos)) { + item.photos.forEach((photo) => { + if (typeof photo === 'string') { + photoCandidates.push(photo); + } else if (photo && typeof photo === 'object') { + photoCandidates.push(photo.href, photo.url, photo.src, photo.original); + } + }); + } + + const lotSizeAcres = firstNumber([ + item.lotSizeAcres, + item.acres, + item.lotSize, + ]); + + const normalizedLotSizeAcres = item.lotSizeAcres || item.acres + ? lotSizeAcres + : toAcresFromSquareFeet(item.lotSize); + + return { + id: firstNonEmptyText([item.id, item.listingId, item.propertyId]) || `listing-${Math.random().toString(36).slice(2, 10)}`, + address: firstNonEmptyText([ + item.formattedAddress, + item.address, + [item.addressLine1, item.city, item.state, item.zipCode].filter(Boolean).join(', '), + ]), + city: firstNonEmptyText([item.city]), + state: firstNonEmptyText([item.state]), + county: firstNonEmptyText([item.county, item.countyOrParish]), + zipCode: firstNonEmptyText([item.zipCode]), + status: firstNonEmptyText([item.status, item.listingStatus, item.mlsStatus, 'active']), + propertyType: firstNonEmptyText([item.propertyType, item.propertySubType, 'Property']), + price: firstNumber([item.price, item.listPrice, item.askingPrice]), + bedrooms: firstNumber([item.bedrooms, item.beds]), + bathrooms: firstNumber([item.bathrooms, item.baths, item.fullBathrooms]), + squareFootage: firstNumber([item.squareFootage, item.livingArea, item.buildingArea]), + lotSizeAcres: normalizedLotSizeAcres, + daysOnMarket: firstNumber([item.daysOnMarket]), + listedDate: firstNonEmptyText([item.listedDate, item.listDate]), + description: firstNonEmptyText([item.description, item.publicRemarks, item.remarks]), + photoUrl: firstNonEmptyText(photoCandidates), + listingUrl: firstNonEmptyText([item.url, item.listingUrl, item.detailUrl, item.propertyUrl]), + }; +} + +function normalizeInput(rawData) { + return { + city: cleanText(rawData?.city), + state: normalizeState(rawData?.state), + country: normalizeCountry(rawData?.country), + propertyType: normalizePropertyType(rawData?.propertyType), + maxPrice: toNumber(rawData?.maxPrice), + minLotAcres: toNumber(rawData?.minLotAcres), + limit: toInteger(rawData?.limit, 6, 1, 12), + businessType: cleanText(rawData?.businessType), + sitePreferences: cleanText(rawData?.sitePreferences), + }; +} + +function buildRentcastParams(input) { + const params = { + state: input.state, + status: 'Active', + limit: input.limit, + }; + + if (input.city) { + params.city = input.city; + } + + const rentcastPropertyType = RENTCAST_PROPERTY_TYPE_MAP[input.propertyType]; + if (rentcastPropertyType) { + params.propertyType = rentcastPropertyType; + } + + if (input.maxPrice !== null) { + params.price = `0:${Math.round(input.maxPrice)}`; + } + + if (input.minLotAcres !== null) { + const minimumSquareFeet = Math.max(Math.round(input.minLotAcres * 43560), 1); + params.lotSize = `${minimumSquareFeet}:999999999`; + } + + return params; +} + +function getProvider() { + const configuredProvider = cleanText(config.propertySearchProvider).toLowerCase(); + if (configuredProvider) { + return configuredProvider; + } + + return config.rentcastApiKey ? 'rentcast' : ''; +} + +module.exports = class PropertySearchService { + static async searchListings(rawData) { + const provider = getProvider(); + const input = normalizeInput(rawData); + + if (input.country !== 'USA') { + throw createBadRequest('The first live property search version currently supports U.S. listings only. We can add an international provider next.'); + } + + if (!input.state) { + throw createBadRequest('Enter at least a U.S. state to search live listings. City is optional.'); + } + + if (provider !== 'rentcast' || !config.rentcastApiKey) { + throw createBadRequest('Live property search is coded, but the backend still needs a RentCast API key before it can return real listings.'); + } + + const params = buildRentcastParams(input); + + try { + const response = await axios.get(`${config.rentcastBaseUrl}/listings/sale`, { + headers: { + 'X-Api-Key': config.rentcastApiKey, + Accept: 'application/json', + }, + params, + timeout: 20000, + }); + + const rawRows = Array.isArray(response.data) + ? response.data + : Array.isArray(response.data?.results) + ? response.data.results + : Array.isArray(response.data?.data) + ? response.data.data + : []; + + const results = rawRows + .map((item) => normalizeListing(item)) + .filter(Boolean); + + return { + provider: 'rentcast', + fetchedAt: new Date().toISOString(), + search: { + city: input.city, + state: input.state, + country: input.country, + propertyType: input.propertyType, + maxPrice: input.maxPrice, + minLotAcres: input.minLotAcres, + limit: input.limit, + businessType: input.businessType, + sitePreferences: input.sitePreferences, + }, + results, + }; + } catch (error) { + console.error('Live property search request failed:', { + provider: 'rentcast', + params, + status: error.response?.status, + data: error.response?.data, + }); + + if (error.response?.status === 401 || error.response?.status === 403) { + throw createBadRequest('The property-search provider rejected the API key. Once the backend key is corrected, live listings will work.'); + } + + if (error.response?.status === 429) { + throw createBadRequest('The property-search provider rate limit was hit. Please wait a minute and try again.'); + } + + if (error.response?.status === 400) { + throw createBadRequest('The live listing provider rejected the search filters. Try simplifying the city/state or property type.'); + } + + throw new Error('Failed to fetch live property listings.'); + } + } +}; diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx index ef313d7..f840be1 100644 --- a/frontend/src/components/AsideMenuLayer.tsx +++ b/frontend/src/components/AsideMenuLayer.tsx @@ -3,10 +3,8 @@ import { mdiLogout, mdiClose } from '@mdi/js' import BaseIcon from './BaseIcon' import AsideMenuList from './AsideMenuList' import { MenuAsideItem } from '../interfaces' -import { useAppSelector } from '../stores/hooks' +import { useAppDispatch, useAppSelector } from '../stores/hooks' import Link from 'next/link'; - -import { useAppDispatch } from '../stores/hooks'; import { createAsyncThunk } from '@reduxjs/toolkit'; import axios from 'axios'; diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..fcbd9b9 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index c48b0be..f21dac5 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -7,6 +7,12 @@ const menuAside: MenuAsideItem[] = [ icon: icon.mdiViewDashboardOutline, label: 'Dashboard', }, + { + href: '/legacy-launchpad', + icon: icon.mdiRobotOutline, + label: 'Legacy Launchpad', + permissions: 'CREATE_PROJECTS', + }, { href: '/users/users-list', diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx index ebbf253..8376e53 100644 --- a/frontend/src/pages/dashboard.tsx +++ b/frontend/src/pages/dashboard.tsx @@ -7,6 +7,8 @@ import LayoutAuthenticated from '../layouts/Authenticated' import SectionMain from '../components/SectionMain' import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton' import BaseIcon from "../components/BaseIcon"; +import BaseButton from '../components/BaseButton'; +import CardBox from '../components/CardBox'; import { getPageTitle } from '../config' import Link from "next/link"; @@ -53,6 +55,14 @@ const Dashboard = () => { const { isFetchingQuery } = useAppSelector((state) => state.openAi); const { rolesWidgets, loading } = useAppSelector((state) => state.roles); + + const hasLaunchpadStarterDataLoaded = typeof projects === 'number' && typeof business_ideas === 'number'; + const showLaunchpadStarter = + hasLaunchpadStarterDataLoaded && + projects === 0 && + business_ideas === 0 && + hasPermission(currentUser, 'CREATE_PROJECTS'); + const projectsCardHref = showLaunchpadStarter ? '/legacy-launchpad' : '/projects/projects-list'; const organizationId = currentUser?.organizations?.id; @@ -111,6 +121,39 @@ const Dashboard = () => { main> {''} + + {showLaunchpadStarter && ( + +
+
+

+ Start here +

+

+ Your workspace is clean and ready for your first business idea. +

+

+ The mock launch plans are gone. Open Legacy Launchpad to describe your idea and generate your first preview plan for land, buildout, staffing, funding, and launch steps. +

+
+
+ + +
+
+
+ )} {hasPermission(currentUser, 'CREATE_ROLES') && { } - {hasPermission(currentUser, 'READ_PROJECTS') && + {hasPermission(currentUser, 'READ_PROJECTS') &&
diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 873d54d..4efec87 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,166 +1,274 @@ - -import React, { useEffect, useState } from 'react'; -import type { ReactElement } from 'react'; +import { + mdiAccountTieOutline, + mdiArrowRight, + mdiBullhornOutline, + mdiCashMultiple, + mdiChartTimelineVariant, + mdiFileDocumentOutline, + mdiHomeCityOutline, + mdiLightbulbOnOutline, + mdiMapMarkerOutline, + mdiOpenInNew, + mdiRobotOutline, + mdiScaleBalance, +} from '@mdi/js'; import Head from 'next/head'; import Link from 'next/link'; +import React, { ReactElement } from 'react'; import BaseButton from '../components/BaseButton'; +import BaseIcon from '../components/BaseIcon'; import CardBox from '../components/CardBox'; -import SectionFullScreen from '../components/SectionFullScreen'; import LayoutGuest from '../layouts/Guest'; -import BaseDivider from '../components/BaseDivider'; -import BaseButtons from '../components/BaseButtons'; import { getPageTitle } from '../config'; -import { useAppSelector } from '../stores/hooks'; -import CardBoxComponentTitle from "../components/CardBoxComponentTitle"; -import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; +const featureCards = [ + { + icon: mdiRobotOutline, + title: 'AI launch architect', + text: 'Turn founder notes into a saved launch blueprint with project phases, tasks, and budget-aligned next steps.', + }, + { + icon: mdiScaleBalance, + title: 'Compliance research hub', + text: 'Track city, county, state, and federal research items as real checklist records instead of scattered notes.', + }, + { + icon: mdiHomeCityOutline, + title: 'Land, build, and layout planning', + text: 'Seed site strategy, property records, and design-asset briefs for walkthroughs, systems, and 3D-ready layouts.', + }, + { + icon: mdiAccountTieOutline, + title: 'Staffing to opening day', + text: 'Generate launch roles, training tracks, and go-to-market campaigns so the business is prepared to operate — not just exist on paper.', + }, +]; -export default function Starter() { - const [illustrationImage, setIllustrationImage] = useState({ - src: undefined, - photographer: undefined, - photographer_url: undefined, - }) - const [illustrationVideo, setIllustrationVideo] = useState({video_files: []}) - const [contentType, setContentType] = useState('image'); - const [contentPosition, setContentPosition] = useState('left'); - const textColor = useAppSelector((state) => state.style.linkColor); +const workflowSteps = [ + { + eyebrow: 'Step 01', + title: 'Capture the founder vision', + text: 'Describe the business, target location, budget, and legacy goal. Add a reference image or scope file when you have one.', + icon: mdiLightbulbOnOutline, + }, + { + eyebrow: 'Step 02', + title: 'Generate the first launch package', + text: 'The app saves a project workspace with timelines, documents, funding motions, staffing plans, and design backlog items.', + icon: mdiChartTimelineVariant, + }, + { + eyebrow: 'Step 03', + title: 'Run the business buildout', + text: 'Use the existing admin interface to refine records, assign work, track approvals, and move toward opening-day readiness.', + icon: mdiOpenInNew, + }, +]; - const title = 'Legacy Business Builder' - - // Fetch Pexels image/video - useEffect(() => { - async function fetchData() { - const image = await getPexelsImage(); - const video = await getPexelsVideo(); - setIllustrationImage(image); - setIllustrationVideo(video); - } - fetchData(); - }, []); - - const imageBlock = (image) => ( -
-
- - Photo by {image?.photographer} on Pexels - -
-
- ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- -
- - Video by {video.user.name} on Pexels - -
-
) - } - }; +const valueBlocks = [ + { + icon: mdiCashMultiple, + label: 'Funding path', + text: 'Budget strategy, capital rounds, and lender-ready planning.', + }, + { + icon: mdiMapMarkerOutline, + label: 'Site path', + text: 'Location, property, and zoning-aware research checkpoints.', + }, + { + icon: mdiFileDocumentOutline, + label: 'Docs + checklists', + text: 'Operating, permit, training, and launch deliverables in one hub.', + }, + { + icon: mdiBullhornOutline, + label: 'Opening demand', + text: 'Marketing and launch motions seeded from the first intake.', + }, +]; +export default function HomePage() { return ( -
+ <> - {getPageTitle('Starter Page')} + {getPageTitle('Legacy Business Builder')} - -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

This is a React.js/Node.js app generated by the Flatlogic Web App Generator

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

+
+
+
+ + + + +
+
Legacy Business Builder
+
AI-powered launch operating system
+
+ + +
+ + +
- - - +
+
- - -
-
- -
-

© 2026 {title}. All rights reserved

- - Privacy Policy - -
+
+
+
+
+
+
+
+
+ + Idea to opening-day execution +
+

+ Build the business legacy your family can inherit, operate, and grow. +

+

+ This app is designed for founder ideas that need more than a pitch deck. It creates a real launch workflow across funding, + property, compliance research, design assets, staffing, training, marketing, and opening-day readiness. +

-
+
+ + +
+ +
+ {valueBlocks.map((block) => ( +
+
+ +
+
{block.label}
+

{block.text}

+
+ ))} +
+
+ +
+
+
+
+
Now live
+

Legacy Launchpad

+
+ + First MVP slice + +
+

+ The first delivery is a real founder intake that creates saved project records, timelines, tasks, compliance research, funding + rounds, property notes, staffing plans, training, launch campaigns, and 3D/layout asset briefs. +

+
+
Best for the first conversation
+
+ “I have the vision in my head. Help me turn it into the first executable launch plan for my target city and state.” +
+
+
+
+
Important
+

+ The product can organize and draft launch research, but city/county/state legal and compliance items still need confirmation with + agencies and licensed professionals before real-world decisions are made. +

+
+
+
+
+ +
+
+
What the platform handles
+

A premium operations shell for turning a founder dream into a buildable business.

+

+ Instead of leaving the concept trapped in notes, the app converts it into connected records and workflows your family can keep using as the business grows. +

+
+ +
+ {featureCards.map((card) => ( + +
+ +
+

{card.title}

+

{card.text}

+
+ ))} +
+
+ +
+
+
+
How it works
+

Three deliberate moves to get from idea to a saved launch system.

+
+ +
+ {workflowSteps.map((step) => ( +
+
+
{step.eyebrow}
+ + + +
+

{step.title}

+

{step.text}

+
+ ))} +
+
+
+ +
+
+
+
+
Start inside the real app
+

Open the admin interface, then use Legacy Launchpad to create the first working plan.

+

+ The landing page stays public. The planning workflow and entity records stay protected behind login, so your business operating system stays private while you build it. +

+
+
+ + +
+
+
+
+
+ +
+
+
© 2026 Legacy Business Builder. Built to turn founder vision into a lasting family-owned operating system.
+
+ + Privacy Policy + + + Login + +
+
+
+
+ ); } -Starter.getLayout = function getLayout(page: ReactElement) { +HomePage.getLayout = function getLayout(page: ReactElement) { return {page}; }; - diff --git a/frontend/src/pages/legacy-launchpad.tsx b/frontend/src/pages/legacy-launchpad.tsx new file mode 100644 index 0000000..d983c9b --- /dev/null +++ b/frontend/src/pages/legacy-launchpad.tsx @@ -0,0 +1,1407 @@ +/* eslint-disable @next/next/no-img-element */ +import { + mdiAccountTieOutline, + mdiAlertCircle, + mdiArrowRight, + mdiBullhornOutline, + mdiCashMultiple, + mdiChartTimelineVariant, + mdiCheckCircle, + mdiFileDocumentOutline, + mdiHomeCityOutline, + mdiLightbulbOnOutline, + mdiMapMarkerOutline, + mdiOpenInNew, + mdiRobotOutline, + mdiScaleBalance, + mdiUpload, +} from '@mdi/js'; +import axios from 'axios'; +import { Field, Form, Formik } from 'formik'; +import Head from 'next/head'; +import Link from 'next/link'; +import React, { ReactElement, useCallback, useEffect, useMemo, useState } from 'react'; +import BaseButton from '../components/BaseButton'; +import BaseIcon from '../components/BaseIcon'; +import CardBox from '../components/CardBox'; +import FormField from '../components/FormField'; +import FormFilePicker from '../components/FormFilePicker'; +import FormImagePicker from '../components/FormImagePicker'; +import NotificationBar from '../components/NotificationBar'; +import SectionMain from '../components/SectionMain'; +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../config'; +import { humanize } from '../helpers/humanize'; +import { hasPermission } from '../helpers/userPermissions'; +import LayoutAuthenticated from '../layouts/Authenticated'; +import { useAppSelector } from '../stores/hooks'; + +type LaunchpadState = Record | null; + +type LivePropertySearchForm = { + city: string; + state: string; + country: string; + propertyType: string; + maxPrice: string; + minLotAcres: string; + limit: string; +}; + +type MetricCardProps = { + icon: string; + label: string; + value: string | number; + helper: string; +}; + +type SectionCardProps = { + eyebrow: string; + title: string; + children: React.ReactNode; +}; + +const blueprintSingleLineLimits: Record = { + ideaTitle: 180, + businessType: 180, + city: 120, + county: 120, + state: 60, + targetBudget: 40, + targetOpenDate: 40, +}; + +const blueprintLongTextLimits: Record = { + sitePreferences: 1500, + vision: 5000, + familyLegacyGoal: 2500, +}; + +const initialValues = { + ideaTitle: '', + businessType: '', + locationFlexibility: 'usa_wide', + sitePreferences: '', + city: '', + county: '', + state: '', + targetBudget: '', + targetOpenDate: '', + vision: '', + familyLegacyGoal: '', + reference_images: [], + reference_files: [], +}; + +const numberFormatter = new Intl.NumberFormat('en-US'); +const currencyFormatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, +}); +const dateFormatter = new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', +}); +const locationSearchOptions = [ + { value: 'specific_area', label: 'I already have a place in mind' }, + { value: 'usa_wide', label: 'Search anywhere in the USA' }, + { value: 'international', label: 'Search USA or other countries' }, +]; + +const livePropertyTypeOptions = [ + { value: 'any', label: 'Any supported listing type' }, + { value: 'land', label: 'Land' }, + { value: 'single_family', label: 'Single family' }, + { value: 'condo', label: 'Condo' }, + { value: 'townhouse', label: 'Townhouse' }, + { value: 'multi_family', label: 'Multi-family' }, + { value: 'apartment', label: 'Apartment' }, + { value: 'manufactured', label: 'Manufactured' }, +]; + +const initialLivePropertySearch: LivePropertySearchForm = { + city: '', + state: '', + country: 'USA', + propertyType: 'any', + maxPrice: '', + minLotAcres: '', + limit: '6', +}; + +function formatCurrency(value: number | string | null | undefined) { + if (value === null || value === undefined || value === '') { + return '—'; + } + + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return '—'; + } + + return currencyFormatter.format(numeric); +} + +function formatNumber(value: number | string | null | undefined) { + if (value === null || value === undefined || value === '') { + return '—'; + } + + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return '—'; + } + + return numberFormatter.format(numeric); +} + +function formatDate(value: string | number | Date | null | undefined) { + if (!value) { + return 'TBD'; + } + + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return 'TBD'; + } + + return dateFormatter.format(parsed); +} + +function formatLocationSummary(location: Record | null | undefined) { + if (!location) { + return 'Flexible / to be chosen'; + } + + const specificLocation = [location.city, location.state].filter(Boolean).join(', '); + if (specificLocation) { + return specificLocation; + } + + if (location.county) { + return location.county; + } + + if (location.label) { + return location.label; + } + + if (location.country) { + return location.country; + } + + return 'Flexible / to be chosen'; +} + +function statusTone(status?: string) { + switch (status) { + case 'approved': + case 'completed': + case 'done': + case 'succeeded': + case 'funded': + return 'bg-emerald-100 text-emerald-700'; + case 'in_progress': + case 'running': + case 'submitted': + return 'bg-blue-100 text-blue-700'; + case 'failed': + case 'blocked': + case 'rejected': + return 'bg-rose-100 text-rose-700'; + default: + return 'bg-slate-100 text-slate-700'; + } +} + +function deriveLivePropertyType(values: Record, responseData: Record) { + const savedPropertyType = responseData?.project?.properties_project?.[0]?.property_type; + if (savedPropertyType === 'raw_land') { + return 'land'; + } + + if (savedPropertyType === 'residential_conversion') { + return 'single_family'; + } + + const searchText = `${values?.sitePreferences || ''} ${values?.businessType || ''}`.toLowerCase(); + if (/(land|acre|rural|retreat|farm)/.test(searchText)) { + return 'land'; + } + + return 'any'; +} + +function buildInitialLivePropertySearch(values: Record, responseData: Record): LivePropertySearchForm { + return { + city: values?.city || responseData?.location?.city || '', + state: values?.state || responseData?.location?.state || '', + country: 'USA', + propertyType: deriveLivePropertyType(values, responseData), + maxPrice: values?.targetBudget || '', + minLotAcres: '', + limit: '6', + }; +} + +function cleanBlueprintSingleLine(value: any, maxLength: number) { + if (typeof value !== 'string') { + return ''; + } + + const normalized = value.replace(/\s+/g, ' ').trim(); + return normalized.length > maxLength ? normalized.slice(0, maxLength).trim() : normalized; +} + +function cleanBlueprintLongText(value: any, maxLength: number) { + if (typeof value !== 'string') { + return ''; + } + + const compactedLines = value + .replace(/\r\n?/g, '\n') + .split('\n') + .map((line) => line.replace(/[ \t]+/g, ' ').trim()) + .reduce((accumulator: string[], line) => { + const previousLine = accumulator[accumulator.length - 1]; + + if (!line && !previousLine) { + return accumulator; + } + + if (line && previousLine === line) { + return accumulator; + } + + accumulator.push(line); + return accumulator; + }, []); + + const normalized = compactedLines.join('\n').trim(); + return normalized.length > maxLength ? normalized.slice(0, maxLength).trim() : normalized; +} + +function compactUploadedReference(item: any) { + if (!item || typeof item !== 'object') { + return null; + } + + const normalizedItem = { + id: cleanBlueprintSingleLine(item.id, 120), + name: cleanBlueprintSingleLine(item.name, 240), + sizeInBytes: Number.isFinite(Number(item.sizeInBytes)) ? Number(item.sizeInBytes) : undefined, + privateUrl: cleanBlueprintSingleLine(item.privateUrl, 500), + publicUrl: cleanBlueprintSingleLine(item.publicUrl, 500), + new: Boolean(item.new), + }; + + return Object.fromEntries(Object.entries(normalizedItem).filter(([, currentValue]) => currentValue !== undefined && currentValue !== '')); +} + +function buildBlueprintPayload(values: Record) { + return { + ideaTitle: cleanBlueprintSingleLine(values.ideaTitle, blueprintSingleLineLimits.ideaTitle), + businessType: cleanBlueprintSingleLine(values.businessType, blueprintSingleLineLimits.businessType), + locationFlexibility: cleanBlueprintSingleLine(values.locationFlexibility, 40) || 'usa_wide', + sitePreferences: cleanBlueprintLongText(values.sitePreferences, blueprintLongTextLimits.sitePreferences), + city: cleanBlueprintSingleLine(values.city, blueprintSingleLineLimits.city), + county: cleanBlueprintSingleLine(values.county, blueprintSingleLineLimits.county), + state: cleanBlueprintSingleLine(values.state, blueprintSingleLineLimits.state), + targetBudget: cleanBlueprintSingleLine(values.targetBudget, blueprintSingleLineLimits.targetBudget), + targetOpenDate: cleanBlueprintSingleLine(values.targetOpenDate, blueprintSingleLineLimits.targetOpenDate), + vision: cleanBlueprintLongText(values.vision, blueprintLongTextLimits.vision), + familyLegacyGoal: cleanBlueprintLongText(values.familyLegacyGoal, blueprintLongTextLimits.familyLegacyGoal), + reference_images: Array.isArray(values.reference_images) ? values.reference_images.map(compactUploadedReference).filter(Boolean).slice(0, 1) : [], + reference_files: Array.isArray(values.reference_files) ? values.reference_files.map(compactUploadedReference).filter(Boolean).slice(0, 1) : [], + }; +} + +function buildRecoveredLaunchpadResult(project: Record) { + const aiRunStatus = Array.isArray(project?.ai_runs_project) ? project.ai_runs_project[0]?.status : null; + const recoveredMode = aiRunStatus === 'succeeded' ? 'ai' : 'template'; + const recoveredSummary = String(project?.vision || '') + .split(/\n{2,}/) + .map((section) => section.trim()) + .filter(Boolean)[0] || 'Your launch blueprint was saved and is ready for review.'; + + return { + mode: recoveredMode, + warnings: [ + `Your blueprint was saved as “${project?.project_name || 'Saved launch blueprint'}”, but the final response back to Launchpad was interrupted. Use the saved workspace below instead of submitting again.`, + ], + generatedAt: project?.createdAt || project?.created_on || new Date().toISOString(), + summary: { + executive_summary: recoveredSummary, + budget_strategy: 'The workspace is already saved. Review the funding, property, staffing, and task records inside the project to keep refining the plan.', + compliance_notice: 'Please verify permits, zoning, taxes, and licensing with local professionals before acting on the saved checklist.', + }, + businessIdea: project?.business_idea || null, + location: project?.primary_location || null, + project, + counts: { + phases: Array.isArray(project?.project_phases_project) ? project.project_phases_project.length : 0, + tasks: Array.isArray(project?.tasks_project) ? project.tasks_project.length : 0, + legal_requirements: Array.isArray(project?.legal_requirements_project) ? project.legal_requirements_project.length : 0, + documents: Array.isArray(project?.documents_project) ? project.documents_project.length : 0, + funding_rounds: Array.isArray(project?.funding_rounds_project) ? project.funding_rounds_project.length : 0, + positions: Array.isArray(project?.positions_project) ? project.positions_project.length : 0, + training_programs: Array.isArray(project?.training_programs_project) ? project.training_programs_project.length : 0, + marketing_campaigns: Array.isArray(project?.marketing_campaigns_project) ? project.marketing_campaigns_project.length : 0, + design_assets: Array.isArray(project?.design_assets_project) ? project.design_assets_project.length : 0, + ai_runs: Array.isArray(project?.ai_runs_project) ? project.ai_runs_project.length : 0, + }, + }; +} + +function formatApiErrorMessage(error: any, fallbackMessage: string) { + const statusCode = error?.response?.status; + const responseData = error?.response?.data; + const responseMessage = typeof responseData === 'string' + ? responseData.trim() + : typeof responseData?.message === 'string' + ? responseData.message.trim() + : typeof responseData?.error === 'string' + ? responseData.error.trim() + : ''; + + if (statusCode === 413) { + return 'This blueprint request was larger than the planner expected, so the page could not finish the response cleanly. Keep the intake high-level, and if a saved blueprint appears below, use it instead of submitting again.'; + } + + if (responseMessage) { + return responseMessage; + } + + if (typeof error?.message === 'string' && error.message.trim()) { + return error.message.trim(); + } + + return fallbackMessage; +} + +function MetricCard({ icon, label, value, helper }: MetricCardProps) { + return ( +
+
+ +
+
{label}
+
{value}
+

{helper}

+
+ ); +} + +function SectionCard({ eyebrow, title, children }: SectionCardProps) { + return ( + +
+
+
{eyebrow}
+

{title}

+
+
+
{children}
+
+ ); +} + +const LegacyLaunchpad = () => { + const { currentUser } = useAppSelector((state) => state.auth); + const [result, setResult] = useState(null); + const [submissionError, setSubmissionError] = useState(''); + const [recentPlans, setRecentPlans] = useState[]>([]); + const [recentPlansLoading, setRecentPlansLoading] = useState(false); + const [recentPlansError, setRecentPlansError] = useState(''); + const [livePropertySearch, setLivePropertySearch] = useState(initialLivePropertySearch); + const [livePropertyResults, setLivePropertyResults] = useState[]>([]); + const [livePropertySearchLoading, setLivePropertySearchLoading] = useState(false); + const [livePropertySearchError, setLivePropertySearchError] = useState(''); + const [livePropertySearchMeta, setLivePropertySearchMeta] = useState | null>(null); + const [hasAttemptedLivePropertySearch, setHasAttemptedLivePropertySearch] = useState(false); + + const canReadProjects = currentUser ? hasPermission(currentUser, 'READ_PROJECTS') : false; + const canReadBusinessIdeas = currentUser ? hasPermission(currentUser, 'READ_BUSINESS_IDEAS') : false; + + const loadRecentPlans = useCallback(async () => { + if (!canReadProjects) { + return; + } + + setRecentPlansLoading(true); + setRecentPlansError(''); + + try { + const response = await axios.get('/projects', { + params: { + limit: 4, + page: 0, + }, + }); + setRecentPlans(Array.isArray(response.data?.rows) ? response.data.rows : []); + } catch (error) { + console.error('Failed to load recent launch plans', error); + setRecentPlansError('Could not load recent launch plans right now.'); + } finally { + setRecentPlansLoading(false); + } + }, [canReadProjects]); + + const recoverSavedBlueprint = useCallback(async (submittedValues: Record, submissionStartedAt: number) => { + if (!canReadProjects || !submittedValues?.ideaTitle) { + return null; + } + + try { + const response = await axios.get('/projects', { + params: { + project_name: submittedValues.ideaTitle, + limit: 5, + page: 0, + }, + }); + + const matchingPlan = (Array.isArray(response.data?.rows) ? response.data.rows : []).find((plan: Record) => { + const planName = String(plan?.project_name || '').toLowerCase(); + const expectedName = String(submittedValues.ideaTitle || '').toLowerCase(); + const createdAtMs = Date.parse(plan?.createdAt || plan?.created_on || ''); + const createdRecently = Number.isNaN(createdAtMs) || createdAtMs >= submissionStartedAt - (2 * 60 * 1000); + + return planName.includes(expectedName) && createdRecently; + }); + + if (!matchingPlan?.id) { + return null; + } + + try { + const detailResponse = await axios.get(`/projects/${matchingPlan.id}`); + return buildRecoveredLaunchpadResult(detailResponse.data || matchingPlan); + } catch (detailError) { + console.error('Failed to load recovered launch blueprint details', detailError); + return buildRecoveredLaunchpadResult(matchingPlan); + } + } catch (recoveryError) { + console.error('Failed to check for a saved launch blueprint after an interrupted response', recoveryError); + return null; + } + }, [canReadProjects]); + + useEffect(() => { + loadRecentPlans(); + }, [loadRecentPlans]); + + const handleLivePropertySearch = useCallback(async () => { + setLivePropertySearchError(''); + setLivePropertySearchLoading(true); + setHasAttemptedLivePropertySearch(true); + + try { + const response = await axios.post('/projects/property-search', { + data: livePropertySearch, + }); + setLivePropertyResults(Array.isArray(response.data?.results) ? response.data.results : []); + setLivePropertySearchMeta(response.data || null); + } catch (error: any) { + console.error('Live property search failed', error); + setLivePropertyResults([]); + setLivePropertySearchMeta(null); + setLivePropertySearchError(formatApiErrorMessage(error, 'We could not load live property listings right now.')); + } finally { + setLivePropertySearchLoading(false); + } + }, [livePropertySearch]); + + const generatedProject = result?.project; + const generatedIdea = result?.businessIdea; + const phaseItems = useMemo( + () => [...(generatedProject?.project_phases_project || [])].sort((left, right) => (left?.sort_order || 0) - (right?.sort_order || 0)), + [generatedProject], + ); + const taskItems = useMemo( + () => [...(generatedProject?.tasks_project || [])].sort((left, right) => new Date(left?.due_at || 0).getTime() - new Date(right?.due_at || 0).getTime()), + [generatedProject], + ); + const legalItems = generatedProject?.legal_requirements_project || []; + const documentItems = generatedProject?.documents_project || []; + const fundingItems = generatedProject?.funding_rounds_project || []; + const propertyItems = generatedProject?.properties_project || []; + const staffingItems = generatedProject?.positions_project || []; + const trainingItems = generatedProject?.training_programs_project || []; + const marketingItems = generatedProject?.marketing_campaigns_project || []; + const designItems = generatedProject?.design_assets_project || []; + + return ( + <> + + {getPageTitle('Legacy Launchpad')} + + + + {''} + + + + Build a real launch blueprint from one founder intake. The AI-generated compliance checklist is a research starter for a known city, + county, and state — or for broader market-specific follow-up if you are still choosing the best place. It is not a substitute for legal, tax, + construction, or licensing advice. + + +
+
+
+
+
+
+
+ + Founder-first AI workflow +
+

+ Turn your business idea into a saved operating blueprint your family can actually build on. +

+

+ One intake creates the first planning package across funding, land/site, legal research, design assets, staffing, training, and + launch marketing — all saved into your existing workspace so the next step is obvious. +

+
+
+
+
Welcome back
+
{currentUser?.firstName || 'Founder'}
+

Start with the core vision, any location clues you have, and one reference upload.

+
+
+
What ships now
+
    +
  • • Saved business idea + project workspace
  • +
  • • Launch phases, tasks, and compliance checklist
  • +
  • • Funding, property, staffing, marketing, and 3D asset briefs
  • +
+
+
+
+
+ +
+ +
+
+
Intake
+

Create the first launch blueprint

+

+ Keep it high-level. If you do not know the city or state yet, leave them blank and let the planner create a broader land/site + search path with candidate areas to explore. +

+
+
+ + { + setSubmissionError(''); + + const payload = buildBlueprintPayload(values); + const submissionStartedAt = Date.now(); + + try { + const response = await axios.post('/projects/legacy-builder', { + data: payload, + }); + setResult(response.data); + setLivePropertySearch(buildInitialLivePropertySearch(payload, response.data)); + setLivePropertyResults([]); + setLivePropertySearchError(''); + setLivePropertySearchMeta(null); + setHasAttemptedLivePropertySearch(false); + await loadRecentPlans(); + } catch (error: any) { + console.error('Legacy launchpad generation failed', error); + + const recoveredResult = await recoverSavedBlueprint(payload, submissionStartedAt); + + if (recoveredResult) { + setResult(recoveredResult); + setLivePropertySearch(buildInitialLivePropertySearch(payload, recoveredResult)); + setLivePropertyResults([]); + setLivePropertySearchError(''); + setLivePropertySearchMeta(null); + setHasAttemptedLivePropertySearch(false); + await loadRecentPlans(); + } else { + setSubmissionError(formatApiErrorMessage(error, 'We could not generate the launch blueprint. Please try again.')); + } + } finally { + setSubmitting(false); + } + }} + > + {({ values, isSubmitting }) => { + const referenceImage = values.reference_images?.[0]; + const referenceFile = values.reference_files?.[0]; + + return ( +
+
+ + + + + + +
+ +
+ + + {locationSearchOptions.map((option) => ( + + ))} + + + + + +
+ +
+ + + + + + + + + +
+ + {values.locationFlexibility !== 'specific_area' && ( +
+ Leave city, county, and state blank if you are still deciding. The planner will treat this as a broader land/site search and recommend + candidate areas to explore first. +
+ )} + +
+ + + + + + +
+ + + + + + + + + +
+
+ + + + {referenceImage?.publicUrl && ( +
+ {referenceImage.name +
{referenceImage.name}
+
+ )} +
+
+ + + +
+
Current file
+

{referenceFile?.name || 'No supporting file uploaded yet.'}

+
+
+
+ + {submissionError && ( +
+ + {submissionError} + +
+ )} + +
+ + +
+
+ ); + }} +
+
+ +
+ +
Blueprint output
+

What this workflow creates right away

+
+ {[ + { + icon: mdiLightbulbOnOutline, + title: 'Idea and project records', + text: 'A saved business idea, site-search record, and project workspace so the concept becomes a trackable program even before the final location is chosen.', + }, + { + icon: mdiCashMultiple, + title: 'Funding and property path', + text: 'Capital milestones plus a first property/site brief so you can move from concept to a financeable, land-aware plan.', + }, + { + icon: mdiScaleBalance, + title: 'Compliance research hub', + text: 'AI-generated legal and permit checkpoints for the chosen market or shortlist — with statuses and due dates.', + }, + { + icon: mdiAccountTieOutline, + title: 'Staffing, training, and launch', + text: 'Planned roles, onboarding material, launch campaigns, and design-asset briefs for layout and walkthrough work.', + }, + ].map((item) => ( +
+
+ +
+
+
{item.title}
+

{item.text}

+
+
+ ))} +
+
+ + +
+
+
Design note
+

3D-ready planning, not vaporware

+
+
+ +
+
+

+ This first version captures reference images/files, saves them with the business idea, and generates design-asset briefs for site + plans, walkthroughs, and 3D model work. It gives you a real design backlog the next iteration can expand into richer multimodal and + rendering workflows. +

+
+
+
+ + {result && ( +
+
+ + Launch blueprint created. Your project, idea brief, timeline, and checklist records are now + saved in the workspace. + + {Array.isArray(result.warnings) && result.warnings.length > 0 && ( + + {result.warnings[0]} + + )} +
+ +
+
+
+
+
+ + {result.mode === 'ai' ? 'AI-assisted blueprint' : 'Template-assisted blueprint'} +
+

{generatedProject?.project_name}

+

{result.summary?.executive_summary}

+

{result.summary?.budget_strategy}

+ +
+ {canReadProjects && ( + + )} + {canReadBusinessIdeas && ( + + )} + {canReadProjects && } +
+
+
+
Compliance note
+

{result.summary?.compliance_notice}

+
+
+
Search area
+
{formatLocationSummary(result.location)}
+
+
+
Budget
+
{formatCurrency(generatedProject?.target_budget)}
+
+
+
Target open
+
{formatDate(generatedProject?.target_open_date)}
+
+
+
Generated
+
{formatDate(result.generatedAt)}
+
+
+
+
+
+ +
+ + + + +
+ +
+ + {phaseItems.map((phase) => ( +
+
+
+
{phase.phase_name}
+
{formatDate(phase.start_at)} → {formatDate(phase.end_at)}
+
+ + {humanize(phase.status)} + +
+

{phase.summary}

+
+ ))} +
+ + + {taskItems.slice(0, 8).map((task) => ( +
+
+
+
{task.task_title}
+
+ {humanize(task.task_type)} · due {formatDate(task.due_at)} +
+
+ + {humanize(task.priority)} + +
+

{task.description}

+
Estimated cost: {formatCurrency(task.estimated_cost)}
+
+ ))} +
+ + +
+ {fundingItems.map((round) => ( +
+
+
+
{round.round_name}
+
{humanize(round.funding_type)} · {formatDate(round.open_at)} → {formatDate(round.close_at)}
+
+ + {humanize(round.status)} + +
+

{round.notes}

+
Target amount: {formatCurrency(round.target_amount)}
+
+ ))} + {propertyItems.map((property) => ( +
+
+
+
{property.property_name}
+
+ {humanize(property.property_type)} · {humanize(property.acquisition_status)} +
+
+
Lot size: {formatNumber(property.lot_size_acres)} acres
+
+

{property.notes}

+
Asking price target: {formatCurrency(property.asking_price)}
+
+ ))} +
+
+ + +
+ {legalItems.map((requirement) => ( +
+
+
+
{requirement.requirement_title}
+
+ {humanize(requirement.jurisdiction_level)} · {humanize(requirement.requirement_type)} +
+
+ + {humanize(requirement.status)} + +
+

{requirement.details}

+
+ Authority: {requirement.authority_name || 'To verify'} · Est. fees {formatCurrency(requirement.estimated_fees)} +
+
+ ))} +
+ {documentItems.map((document) => ( +
+
{document.document_title}
+
{humanize(document.document_type)}
+

{document.content}

+
+ ))} +
+
+
+ + +
+ {staffingItems.map((position) => ( +
+
+
+
{position.position_title}
+
{humanize(position.employment_type)} · starts {formatDate(position.target_start_at)}
+
+ + {humanize(position.status)} + +
+

{position.job_description}

+
+ Salary range: {formatCurrency(position.salary_min)} to {formatCurrency(position.salary_max)} +
+
+ ))} +
+ {trainingItems.map((program) => ( +
+
{program.program_title}
+
{humanize(program.audience)}
+

{program.overview}

+
+ ))} + {marketingItems.map((campaign) => ( +
+
{campaign.campaign_name}
+
{humanize(campaign.channel)}
+

{campaign.objective}

+
Budget: {formatCurrency(campaign.budget)}
+
+ ))} +
+
+
+ + +
+ {designItems.map((asset) => ( +
+
+
+
{asset.asset_name}
+
{humanize(asset.asset_type)} · {humanize(asset.format)}
+
+
+ +
+
+

{asset.description}

+
+ ))} +
+
+
+
+ )} + + {result && ( +
+ +
+
+
Live market scan
+

Search live U.S. property listings

+

+ This first live search version lets you explore real sale listings from inside Launchpad. It works best for land and other + supported U.S. listing types while you compare possible places to build your idea. +

+
+
+
Starting area
+
{formatLocationSummary(result.location)}
+
U.S. live listings only for now
+
+
+ +
+ + setLivePropertySearch((current) => ({ ...current, city: event.target.value }))} + placeholder="Austin" + /> + + + setLivePropertySearch((current) => ({ ...current, state: event.target.value }))} + placeholder="TX" + /> + + + + + + setLivePropertySearch((current) => ({ ...current, maxPrice: event.target.value }))} + placeholder="750000" + inputMode="numeric" + /> + + + setLivePropertySearch((current) => ({ ...current, minLotAcres: event.target.value }))} + placeholder="3" + inputMode="decimal" + /> + +
+ +
+ +
+ Tip: start with just a state if you want a wider market view, then narrow into cities that look promising. +
+
+ + {livePropertySearchError && ( +
+ + {livePropertySearchError} + +
+ )} + + {livePropertySearchLoading && ( +
+ Searching live property listings... +
+ )} + + {hasAttemptedLivePropertySearch && !livePropertySearchLoading && !livePropertySearchError && livePropertyResults.length === 0 && ( +
+ No live listings matched that search yet. Try widening the state search, removing a filter, or switching the listing type back to + "Any supported listing type." +
+ )} + + {!livePropertySearchLoading && livePropertyResults.length > 0 && ( +
+
+
+
Live results
+
+ Showing {livePropertyResults.length} live listing candidates{livePropertySearchMeta?.fetchedAt ? ` • refreshed ${formatDate(livePropertySearchMeta.fetchedAt)}` : ''} +
+
+
+ Search filters: {livePropertySearch.city ? `${livePropertySearch.city}, ` : ''}{livePropertySearch.state || 'State needed'} +
+
+ +
+ {livePropertyResults.map((listing) => ( +
+ {listing.photoUrl && ( + {listing.address + )} +
+
+
+
{listing.address || 'Property listing'}
+
+ {listing.city && listing.state ? `${listing.city}, ${listing.state}` : listing.state || 'U.S. listing'} +
+
+ + {humanize(listing.status || 'active')} + +
+ +
+
+
Price
+
{formatCurrency(listing.price)}
+
+
+
Type
+
{listing.propertyType || 'Property'}
+
+
+
Lot size
+
{formatNumber(listing.lotSizeAcres)} acres
+
+
+
Interior
+
+ {listing.squareFootage ? `${formatNumber(listing.squareFootage)} sqft` : 'Not listed'} +
+
+
+
Beds / baths
+
+ {listing.bedrooms || '—'} / {listing.bathrooms || '—'} +
+
+
+
Days on market
+
{formatNumber(listing.daysOnMarket)}
+
+
+ + {listing.description && ( +

{listing.description}

+ )} + + {listing.listingUrl && ( + + Open listing + + )} +
+
+ ))} +
+
+ )} +
+
+ )} + +
+ +
+
+
Workspace library
+

Recent launch blueprints

+

+ These are existing project workspaces in your admin app. Use them as saved launch programs you can refine with the generated CRUD + screens already in the platform. +

+
+ {canReadProjects && } +
+ + {!canReadProjects && ( +
+ Your role can create new launch plans here, but it does not currently have permission to browse the full projects library. +
+ )} + + {canReadProjects && recentPlansError && ( +
+ + {recentPlansError} + +
+ )} + + {canReadProjects && !recentPlansError && ( +
+ {!recentPlansLoading && recentPlans.length === 0 && ( +
+
+ +
+

No saved launch plans yet

+

Generate your first blueprint above and it will appear here automatically.

+
+ )} + + {recentPlansLoading && ( +
+ Loading recent plans... +
+ )} + + {!recentPlansLoading && recentPlans.map((plan) => ( + +
+
{humanize(plan.stage)}
+
+ Open +
+
+

{plan.project_name}

+

{plan.vision || 'Launch blueprint workspace ready for refinement.'}

+
+
+ Budget: {formatCurrency(plan.target_budget)} +
+
+ Target open: {formatDate(plan.target_open_date)} +
+ {plan.primary_location && ( +
+ Search area: {formatLocationSummary(plan.primary_location)} +
+ )} + {plan.business_idea?.idea_title && ( +
+ Idea: {plan.business_idea.idea_title} +
+ )} +
+ + ))} +
+ )} +
+
+
+ + ); +}; + +LegacyLaunchpad.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default LegacyLaunchpad; diff --git a/frontend/src/pages/search.tsx b/frontend/src/pages/search.tsx index 00f5168..005eb07 100644 --- a/frontend/src/pages/search.tsx +++ b/frontend/src/pages/search.tsx @@ -1,9 +1,7 @@ import React, { ReactElement, useEffect, useState } from 'react'; import Head from 'next/head'; import 'react-datepicker/dist/react-datepicker.css'; -import { useAppDispatch } from '../stores/hooks'; - -import { useAppSelector } from '../stores/hooks'; +import { useAppDispatch, useAppSelector } from '../stores/hooks'; import { useRouter } from 'next/router'; import LayoutAuthenticated from '../layouts/Authenticated';