Add site builder service to Docker Compose and remove obsolete scripts

- Introduced a new service `igny8_site_builder` in `docker-compose.app.yml` for site building functionality, including environment variables and volume mappings.
- Deleted several outdated scripts: `create_test_users.py`, `test_image_write_access.py`, `update_free_plan.py`, and the database file `db.sqlite3` to clean up the backend.
- Updated Django settings and URL configurations to integrate the new site builder module.
This commit is contained in:
IGNY8 VPS (Salman)
2025-11-17 16:08:51 +00:00
parent e3d4ba2c02
commit 5a36686844
74 changed files with 7217 additions and 374 deletions

Binary file not shown.

View File

@@ -1,187 +0,0 @@
#!/usr/bin/env python
"""
Script to create 3 real users with 3 paid packages (Starter, Growth, Scale)
All accounts will be active and properly configured.
Email format: plan-name@igny8.com
"""
import os
import django
import sys
from decimal import Decimal
# Setup Django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8_core.settings')
django.setup()
from django.db import transaction
from igny8_core.auth.models import Plan, Account, User
from django.utils.text import slugify
# User data - 3 users with 3 different paid plans
# Email format: plan-name@igny8.com
USERS_DATA = [
{
"email": "starter@igny8.com",
"username": "starter",
"first_name": "Starter",
"last_name": "Account",
"password": "SecurePass123!@#",
"plan_slug": "starter", # $89/month
"account_name": "Starter Account",
},
{
"email": "growth@igny8.com",
"username": "growth",
"first_name": "Growth",
"last_name": "Account",
"password": "SecurePass123!@#",
"plan_slug": "growth", # $139/month
"account_name": "Growth Account",
},
{
"email": "scale@igny8.com",
"username": "scale",
"first_name": "Scale",
"last_name": "Account",
"password": "SecurePass123!@#",
"plan_slug": "scale", # $229/month
"account_name": "Scale Account",
},
]
def create_user_with_plan(user_data):
"""Create a user with account and assigned plan."""
try:
with transaction.atomic():
# Get the plan
try:
plan = Plan.objects.get(slug=user_data['plan_slug'], is_active=True)
except Plan.DoesNotExist:
print(f"❌ ERROR: Plan '{user_data['plan_slug']}' not found or inactive!")
return None
# Check if user already exists
if User.objects.filter(email=user_data['email']).exists():
print(f"⚠️ User {user_data['email']} already exists. Updating...")
existing_user = User.objects.get(email=user_data['email'])
if existing_user.account:
existing_user.account.plan = plan
existing_user.account.status = 'active'
existing_user.account.save()
print(f" ✅ Updated account plan to {plan.name} and set status to active")
return existing_user
# Generate unique account slug
base_slug = slugify(user_data['account_name'])
account_slug = base_slug
counter = 1
while Account.objects.filter(slug=account_slug).exists():
account_slug = f"{base_slug}-{counter}"
counter += 1
# Create user first (without account)
user = User.objects.create_user(
username=user_data['username'],
email=user_data['email'],
password=user_data['password'],
first_name=user_data['first_name'],
last_name=user_data['last_name'],
account=None, # Will be set after account creation
role='owner'
)
# Create account with user as owner and assigned plan
account = Account.objects.create(
name=user_data['account_name'],
slug=account_slug,
owner=user,
plan=plan,
status='active', # Set to active
credits=plan.included_credits or 0, # Set initial credits from plan
)
# Update user to reference the new account
user.account = account
user.save()
print(f"✅ Created user: {user.email}")
print(f" - Name: {user.get_full_name()}")
print(f" - Username: {user.username}")
print(f" - Account: {account.name} (slug: {account.slug})")
print(f" - Plan: {plan.name} (${plan.price}/month)")
print(f" - Status: {account.status}")
print(f" - Credits: {account.credits}")
print(f" - Max Sites: {plan.max_sites}")
print(f" - Max Users: {plan.max_users}")
print()
return user
except Exception as e:
print(f"❌ ERROR creating user {user_data['email']}: {e}")
import traceback
traceback.print_exc()
return None
def main():
"""Main function to create all users."""
print("=" * 80)
print("Creating 3 Users with Paid Plans")
print("=" * 80)
print()
# Verify plans exist
print("Checking available plans...")
plans = Plan.objects.filter(is_active=True).order_by('price')
if plans.count() < 3:
print(f"⚠️ WARNING: Only {plans.count()} active plan(s) found. Need at least 3.")
print("Available plans:")
for p in plans:
print(f" - {p.slug} (${p.price})")
print()
print("Please run import_plans.py first to create the plans.")
return
print("✅ Found plans:")
for p in plans:
print(f" - {p.name} ({p.slug}): ${p.price}/month")
print()
# Create users
created_users = []
for user_data in USERS_DATA:
user = create_user_with_plan(user_data)
if user:
created_users.append(user)
# Summary
print("=" * 80)
print("SUMMARY")
print("=" * 80)
print(f"Total users created/updated: {len(created_users)}")
print()
print("User Login Credentials:")
print("-" * 80)
for user_data in USERS_DATA:
print(f"Email: {user_data['email']}")
print(f"Password: {user_data['password']}")
print(f"Plan: {user_data['plan_slug'].title()}")
print()
print("✅ All users created successfully!")
print()
print("You can now log in with any of these accounts at:")
print("https://app.igny8.com/login")
if __name__ == '__main__':
try:
main()
except Exception as e:
print(f"❌ Fatal error: {e}", file=sys.stderr)
import traceback
traceback.print_exc()
sys.exit(1)

Binary file not shown.

View File

@@ -34,6 +34,8 @@ class AIEngine:
return f"{count} task{'s' if count != 1 else ''}"
elif function_name == 'generate_images':
return f"{count} task{'s' if count != 1 else ''}"
elif function_name == 'generate_site_structure':
return "1 site blueprint"
return f"{count} item{'s' if count != 1 else ''}"
def _build_validation_message(self, function_name: str, payload: dict, count: int, input_description: str) -> str:
@@ -80,6 +82,12 @@ class AIEngine:
total_images = 1 + max_images
return f"Mapping Content for {total_images} Image Prompts"
return f"Mapping Content for Image Prompts"
elif function_name == 'generate_site_structure':
blueprint_name = ''
if isinstance(data, dict):
blueprint = data.get('blueprint')
blueprint_name = f"{getattr(blueprint, 'name', '')}" if blueprint and getattr(blueprint, 'name', None) else ''
return f"Preparing site blueprint {blueprint_name}".strip()
return f"Preparing {count} item{'s' if count != 1 else ''}"
def _get_ai_call_message(self, function_name: str, count: int) -> str:
@@ -92,6 +100,8 @@ class AIEngine:
return f"Writing article{'s' if count != 1 else ''} with AI"
elif function_name == 'generate_images':
return f"Creating image{'s' if count != 1 else ''} with AI"
elif function_name == 'generate_site_structure':
return "Designing complete site architecture"
return f"Processing with AI"
def _get_parse_message(self, function_name: str) -> str:
@@ -104,6 +114,8 @@ class AIEngine:
return "Formatting content"
elif function_name == 'generate_images':
return "Processing images"
elif function_name == 'generate_site_structure':
return "Compiling site map"
return "Processing results"
def _get_parse_message_with_count(self, function_name: str, count: int) -> str:
@@ -122,6 +134,8 @@ class AIEngine:
if in_article_count > 0:
return f"Writing {in_article_count} Inarticle Image Prompts"
return "Writing Inarticle Image Prompts"
elif function_name == 'generate_site_structure':
return f"{count} page blueprint{'s' if count != 1 else ''} mapped"
return f"{count} item{'s' if count != 1 else ''} processed"
def _get_save_message(self, function_name: str, count: int) -> str:
@@ -137,6 +151,8 @@ class AIEngine:
elif function_name == 'generate_image_prompts':
# Count is total prompts created
return f"Assigning {count} Prompts to Dedicated Slots"
elif function_name == 'generate_site_structure':
return f"Publishing {count} page blueprint{'s' if count != 1 else ''}"
return f"Saving {count} item{'s' if count != 1 else ''}"
def execute(self, fn: BaseAIFunction, payload: dict) -> dict:
@@ -494,6 +510,7 @@ class AIEngine:
'generate_content': 'content_generation',
'generate_image_prompts': 'image_prompt_extraction',
'generate_images': 'image_generation',
'generate_site_structure': 'site_structure_generation',
}
return mapping.get(function_name, function_name)
@@ -554,6 +571,7 @@ class AIEngine:
'generate_content': 'content',
'generate_image_prompts': 'image',
'generate_images': 'image',
'generate_site_structure': 'site_blueprint',
}
return mapping.get(function_name, 'unknown')

View File

@@ -6,6 +6,7 @@ from igny8_core.ai.functions.generate_ideas import GenerateIdeasFunction
from igny8_core.ai.functions.generate_content import GenerateContentFunction
from igny8_core.ai.functions.generate_images import GenerateImagesFunction, generate_images_core
from igny8_core.ai.functions.generate_image_prompts import GenerateImagePromptsFunction
from igny8_core.ai.functions.generate_site_structure import GenerateSiteStructureFunction
__all__ = [
'AutoClusterFunction',
@@ -14,4 +15,5 @@ __all__ = [
'GenerateImagesFunction',
'generate_images_core',
'GenerateImagePromptsFunction',
'GenerateSiteStructureFunction',
]

View File

@@ -0,0 +1,214 @@
"""
Generate Site Structure AI Function
Phase 3 Site Builder
"""
import json
import logging
from typing import Any, Dict, List, Tuple
from django.utils.text import slugify
from igny8_core.ai.base import BaseAIFunction
from igny8_core.ai.prompts import PromptRegistry
from igny8_core.business.site_building.models import SiteBlueprint, PageBlueprint
logger = logging.getLogger(__name__)
class GenerateSiteStructureFunction(BaseAIFunction):
"""AI function that turns a business brief into a full site blueprint."""
def get_name(self) -> str:
return 'generate_site_structure'
def get_metadata(self) -> Dict:
metadata = super().get_metadata()
metadata.update({
'display_name': 'Generate Site Structure',
'description': 'Create site/page architecture from business brief, objectives, and style guides.',
'phases': {
'INIT': 'Validating blueprint data…',
'PREP': 'Preparing site context…',
'AI_CALL': 'Generating site structure with AI…',
'PARSE': 'Parsing generated blueprint…',
'SAVE': 'Saving pages and blocks…',
'DONE': 'Site structure ready!'
}
})
return metadata
def validate(self, payload: dict, account=None) -> Dict[str, Any]:
if not payload.get('ids'):
return {'valid': False, 'error': 'Site blueprint ID is required'}
return {'valid': True}
def prepare(self, payload: dict, account=None) -> Dict[str, Any]:
blueprint_ids = payload.get('ids', [])
queryset = SiteBlueprint.objects.filter(id__in=blueprint_ids)
if account:
queryset = queryset.filter(account=account)
blueprint = queryset.select_related('account', 'site').prefetch_related('pages').first()
if not blueprint:
raise ValueError("Site blueprint not found")
config = blueprint.config_json or {}
business_brief = payload.get('business_brief') or config.get('business_brief') or ''
objectives = payload.get('objectives') or config.get('objectives') or []
style = payload.get('style') or config.get('style') or {}
return {
'blueprint': blueprint,
'business_brief': business_brief,
'objectives': objectives,
'style': style,
}
def build_prompt(self, data: Dict[str, Any], account=None) -> str:
blueprint: SiteBlueprint = data['blueprint']
objectives = data.get('objectives') or []
objectives_text = '\n'.join(f"- {obj}" for obj in objectives) if isinstance(objectives, list) else objectives
style = data.get('style') or {}
style_text = json.dumps(style, indent=2) if isinstance(style, dict) and style else str(style)
existing_pages = [
{
'title': page.title,
'slug': page.slug,
'type': page.type,
'status': page.status,
}
for page in blueprint.pages.all()
]
context = {
'BUSINESS_BRIEF': data.get('business_brief', ''),
'OBJECTIVES': objectives_text or 'Create a full marketing site with clear navigation.',
'STYLE': style_text or 'Modern, responsive, accessible web design.',
'SITE_INFO': json.dumps({
'site_name': blueprint.name,
'site_description': blueprint.description,
'hosting_type': blueprint.hosting_type,
'existing_pages': existing_pages,
'existing_structure': blueprint.structure_json or {},
}, indent=2)
}
return PromptRegistry.get_prompt(
'generate_site_structure',
account=account or blueprint.account,
context=context
)
def parse_response(self, response: str, step_tracker=None) -> Dict[str, Any]:
if not response:
raise ValueError("AI response is empty")
response = response.strip()
try:
return self._ensure_dict(json.loads(response))
except json.JSONDecodeError:
logger.warning("Response not valid JSON, attempting to extract JSON object")
cleaned = self._extract_json_object(response)
if cleaned:
return self._ensure_dict(json.loads(cleaned))
raise ValueError("Unable to parse AI response into JSON")
def save_output(
self,
parsed: Dict[str, Any],
original_data: Dict[str, Any],
account=None,
progress_tracker=None,
step_tracker=None
) -> Dict[str, Any]:
blueprint: SiteBlueprint = original_data['blueprint']
structure = self._ensure_dict(parsed)
pages = structure.get('pages', [])
blueprint.structure_json = structure
blueprint.status = 'ready'
blueprint.save(update_fields=['structure_json', 'status', 'updated_at'])
created, updated, deleted = self._sync_page_blueprints(blueprint, pages)
message = f"Pages synced (created: {created}, updated: {updated}, deleted: {deleted})"
logger.info("[GenerateSiteStructure] %s for blueprint %s", message, blueprint.id)
return {
'success': True,
'count': created + updated,
'site_blueprint_id': blueprint.id,
'pages_created': created,
'pages_updated': updated,
'pages_deleted': deleted,
}
# Helpers -----------------------------------------------------------------
def _ensure_dict(self, data: Any) -> Dict[str, Any]:
if isinstance(data, dict):
return data
raise ValueError("AI response must be a JSON object with site metadata")
def _extract_json_object(self, text: str) -> str:
start = text.find('{')
end = text.rfind('}')
if start != -1 and end != -1 and end > start:
return text[start:end + 1]
return ''
def _sync_page_blueprints(self, blueprint: SiteBlueprint, pages: List[Dict[str, Any]]) -> Tuple[int, int, int]:
existing = {page.slug: page for page in blueprint.pages.all()}
seen_slugs = set()
created = updated = 0
for order, page_data in enumerate(pages or []):
slug = page_data.get('slug') or page_data.get('id') or page_data.get('title') or f"page-{order + 1}"
slug = slugify(slug) or f"page-{order + 1}"
seen_slugs.add(slug)
defaults = {
'title': page_data.get('title') or page_data.get('name') or slug.replace('-', ' ').title(),
'type': self._map_page_type(page_data.get('type')),
'blocks_json': page_data.get('blocks') or page_data.get('sections') or [],
'status': page_data.get('status') or 'draft',
'order': order,
}
page_obj, created_flag = PageBlueprint.objects.update_or_create(
site_blueprint=blueprint,
slug=slug,
defaults=defaults
)
if created_flag:
created += 1
else:
updated += 1
# Delete pages not present in new structure
deleted = 0
for slug, page in existing.items():
if slug not in seen_slugs:
page.delete()
deleted += 1
return created, updated, deleted
def _map_page_type(self, page_type: Any) -> str:
allowed = {choice[0] for choice in PageBlueprint._meta.get_field('type').choices}
if isinstance(page_type, str):
normalized = page_type.lower()
if normalized in allowed:
return normalized
# Map friendly names
mapping = {
'homepage': 'home',
'landing': 'home',
'service': 'services',
'product': 'products',
}
mapped = mapping.get(normalized)
if mapped in allowed:
return mapped
return 'custom'

View File

@@ -238,6 +238,73 @@ OUTPUT FORMAT
Return ONLY the final JSON object.
Do NOT include any comments, formatting, or explanations.""",
'site_structure_generation': """You are a senior UX architect and information designer. Use the business brief, objectives, style references, and existing site info to propose a complete multi-page marketing website structure.
INPUT CONTEXT
==============
BUSINESS BRIEF:
[IGNY8_BUSINESS_BRIEF]
PRIMARY OBJECTIVES:
[IGNY8_OBJECTIVES]
STYLE & BRAND NOTES:
[IGNY8_STYLE]
SITE INFO / CURRENT STRUCTURE:
[IGNY8_SITE_INFO]
OUTPUT REQUIREMENTS
====================
Return ONE JSON object with the following keys:
{
"site": {
"name": "...",
"primary_navigation": ["home", "services", "about", "contact"],
"secondary_navigation": ["blog", "faq"],
"hero_message": "High level value statement",
"tone": "voice + tone summary"
},
"pages": [
{
"slug": "home",
"title": "Home",
"type": "home | about | services | products | blog | contact | custom",
"status": "draft",
"objective": "Explain the core brand promise and primary CTA",
"primary_cta": "Book a strategy call",
"seo": {
"meta_title": "...",
"meta_description": "..."
},
"blocks": [
{
"type": "hero | features | services | stats | testimonials | faq | contact | custom",
"heading": "Section headline",
"subheading": "Support copy",
"layout": "full-width | two-column | cards | carousel",
"content": [
"Bullet or short paragraph describing what to render in this block"
]
}
]
}
]
}
RULES
=====
- Include 58 pages covering the complete buyer journey (awareness → evaluation → conversion → trust).
- Every page must have at least 3 blocks with concrete guidance (no placeholders like "Lorem ipsum").
- Use consistent slug naming, all lowercase with hyphens.
- Type must match the allowed enum and reflect page intent.
- Ensure the navigation arrays align with the page list.
- Focus on practical descriptions that an engineering team can hand off directly to the Site Builder.
Return ONLY valid JSON. No commentary, explanations, or Markdown.
""",
'image_prompt_extraction': """Extract image prompts from the following article content.
@@ -275,6 +342,7 @@ Make sure each prompt is detailed enough for image generation, describing the vi
'generate_images': 'image_prompt_extraction',
'extract_image_prompts': 'image_prompt_extraction',
'generate_image_prompts': 'image_prompt_extraction',
'generate_site_structure': 'site_structure_generation',
}
@classmethod

View File

@@ -94,9 +94,15 @@ def _load_generate_image_prompts():
from igny8_core.ai.functions.generate_image_prompts import GenerateImagePromptsFunction
return GenerateImagePromptsFunction
def _load_generate_site_structure():
"""Lazy loader for generate_site_structure function"""
from igny8_core.ai.functions.generate_site_structure import GenerateSiteStructureFunction
return GenerateSiteStructureFunction
register_lazy_function('auto_cluster', _load_auto_cluster)
register_lazy_function('generate_ideas', _load_generate_ideas)
register_lazy_function('generate_content', _load_generate_content)
register_lazy_function('generate_images', _load_generate_images)
register_lazy_function('generate_image_prompts', _load_generate_image_prompts)
register_lazy_function('generate_site_structure', _load_generate_site_structure)

View File

@@ -3,4 +3,4 @@ Site Building Business Logic
Phase 3: Site Builder
"""
default_app_config = 'igny8_core.business.site_building.apps.SiteBuildingConfig'

View File

@@ -0,0 +1,9 @@
from django.apps import AppConfig
class SiteBuildingConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'igny8_core.business.site_building'
verbose_name = 'Site Building'

View File

@@ -0,0 +1,94 @@
from django.db import migrations, models
import django.db.models.deletion
import django.core.validators
class Migration(migrations.Migration):
initial = True
dependencies = [
('igny8_core_auth', '0008_passwordresettoken_alter_industry_options_and_more'),
]
operations = [
migrations.CreateModel(
name='SiteBlueprint',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('name', models.CharField(help_text='Site name', max_length=255)),
('description', models.TextField(blank=True, help_text='Site description', null=True)),
('config_json', models.JSONField(default=dict, help_text='Wizard configuration: business_type, style, objectives, etc.')),
('structure_json', models.JSONField(default=dict, help_text='AI-generated structure: pages, layout, theme, etc.')),
('status', models.CharField(choices=[('draft', 'Draft'), ('generating', 'Generating'), ('ready', 'Ready'), ('deployed', 'Deployed')], db_index=True, default='draft', help_text='Blueprint status', max_length=20)),
('hosting_type', models.CharField(choices=[('igny8_sites', 'IGNY8 Sites'), ('wordpress', 'WordPress'), ('shopify', 'Shopify'), ('multi', 'Multiple Destinations')], default='igny8_sites', help_text='Target hosting platform', max_length=50)),
('version', models.IntegerField(default=1, help_text='Blueprint version', validators=[django.core.validators.MinValueValidator(1)])),
('deployed_version', models.IntegerField(blank=True, help_text='Currently deployed version', null=True)),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='siteblueprint_set', to='igny8_core_auth.account')),
('sector', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='siteblueprint_set', to='igny8_core_auth.sector')),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='siteblueprint_set', to='igny8_core_auth.site')),
],
options={
'verbose_name': 'Site Blueprint',
'verbose_name_plural': 'Site Blueprints',
'db_table': 'igny8_site_blueprints',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='PageBlueprint',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('slug', models.SlugField(help_text='Page URL slug', max_length=255)),
('title', models.CharField(help_text='Page title', max_length=255)),
('type', models.CharField(choices=[('home', 'Home'), ('about', 'About'), ('services', 'Services'), ('products', 'Products'), ('blog', 'Blog'), ('contact', 'Contact'), ('custom', 'Custom')], default='custom', help_text='Page type', max_length=50)),
('blocks_json', models.JSONField(default=list, help_text="Page content blocks: [{'type': 'hero', 'data': {...}}, ...]")),
('status', models.CharField(choices=[('draft', 'Draft'), ('generating', 'Generating'), ('ready', 'Ready')], db_index=True, default='draft', help_text='Page status', max_length=20)),
('order', models.IntegerField(default=0, help_text='Page order in navigation')),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pageblueprint_set', to='igny8_core_auth.account')),
('sector', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pageblueprint_set', to='igny8_core_auth.sector')),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pageblueprint_set', to='igny8_core_auth.site')),
('site_blueprint', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pages', to='site_building.siteblueprint')),
],
options={
'verbose_name': 'Page Blueprint',
'verbose_name_plural': 'Page Blueprints',
'db_table': 'igny8_page_blueprints',
'ordering': ['order', 'created_at'],
'unique_together': {('site_blueprint', 'slug')},
},
),
migrations.AddIndex(
model_name='siteblueprint',
index=models.Index(fields=['status'], name='igny8_site__status_247ddc_idx'),
),
migrations.AddIndex(
model_name='siteblueprint',
index=models.Index(fields=['hosting_type'], name='igny8_site__hosting_c4bb41_idx'),
),
migrations.AddIndex(
model_name='siteblueprint',
index=models.Index(fields=['site', 'sector'], name='igny8_site__site_id__5f0a4e_idx'),
),
migrations.AddIndex(
model_name='siteblueprint',
index=models.Index(fields=['account', 'status'], name='igny8_site__account__38f18a_idx'),
),
migrations.AddIndex(
model_name='pageblueprint',
index=models.Index(fields=['site_blueprint', 'status'], name='igny8_page__site_bl_1b5d8b_idx'),
),
migrations.AddIndex(
model_name='pageblueprint',
index=models.Index(fields=['type'], name='igny8_page__type_b11552_idx'),
),
migrations.AddIndex(
model_name='pageblueprint',
index=models.Index(fields=['site_blueprint', 'order'], name='igny8_page__site_bl_7a77d7_idx'),
),
]

View File

@@ -0,0 +1,2 @@

View File

@@ -2,4 +2,12 @@
Site Building Services
"""
from igny8_core.business.site_building.services.file_management_service import SiteBuilderFileService
from igny8_core.business.site_building.services.structure_generation_service import StructureGenerationService
from igny8_core.business.site_building.services.page_generation_service import PageGenerationService
__all__ = [
'SiteBuilderFileService',
'StructureGenerationService',
'PageGenerationService',
]

View File

@@ -0,0 +1,149 @@
"""
Page Generation Service
Leverages the Writer ContentGenerationService to draft page copy for Site Builder blueprints.
"""
import logging
from typing import Optional
from django.db import transaction
from igny8_core.business.content.models import Tasks
from igny8_core.business.content.services.content_generation_service import ContentGenerationService
from igny8_core.business.site_building.models import PageBlueprint
logger = logging.getLogger(__name__)
class PageGenerationService:
"""
Thin wrapper that converts Site Builder pages into writer tasks and reuses the
existing content generation pipeline. This keeps content authoring logic
inside the Writer module while Site Builder focuses on structure.
"""
def __init__(self):
self.content_service = ContentGenerationService()
def generate_page_content(self, page_blueprint: PageBlueprint, force_regenerate: bool = False) -> dict:
"""
Generate (or regenerate) content for a single Site Builder page.
Args:
page_blueprint: Target PageBlueprint instance.
force_regenerate: If True, resets any temporary task data.
"""
if not page_blueprint:
raise ValueError("Page blueprint is required")
task = self._ensure_task(page_blueprint, force_regenerate=force_regenerate)
# Mark page as generating before handing off to Writer pipeline
page_blueprint.status = 'generating'
page_blueprint.save(update_fields=['status', 'updated_at'])
account = page_blueprint.account
logger.info(
"[PageGenerationService] Triggering content generation for page %s (task %s)",
page_blueprint.id,
task.id,
)
return self.content_service.generate_content([task.id], account)
def regenerate_page(self, page_blueprint: PageBlueprint) -> dict:
"""Force regeneration by dropping the cached task metadata."""
return self.generate_page_content(page_blueprint, force_regenerate=True)
# Internal helpers --------------------------------------------------------
def _ensure_task(self, page_blueprint: PageBlueprint, force_regenerate: bool = False) -> Tasks:
"""
Create or reuse a Writer task that mirrors the given page blueprint.
We rely on a deterministic title pattern to keep the mapping lightweight
without introducing new relations/migrations.
"""
title = self._build_task_title(page_blueprint)
task_qs = Tasks.objects.filter(
account=page_blueprint.account,
site=page_blueprint.site,
sector=page_blueprint.sector,
title=title,
)
if force_regenerate:
task_qs.delete()
else:
existing = task_qs.first()
if existing:
return existing
return self._create_task_from_page(page_blueprint, title)
@transaction.atomic
def _create_task_from_page(self, page_blueprint: PageBlueprint, title: str) -> Tasks:
"""Translate blueprint metadata into a Writer task."""
description_parts = [
f"Site Blueprint: {page_blueprint.site_blueprint.name}",
f"Page Type: {page_blueprint.type}",
]
hero_block = self._first_block_heading(page_blueprint)
if hero_block:
description_parts.append(f"Hero/Primary Heading: {hero_block}")
keywords = self._build_keywords_hint(page_blueprint)
task = Tasks.objects.create(
account=page_blueprint.account,
site=page_blueprint.site,
sector=page_blueprint.sector,
title=title,
description="\n".join(filter(None, description_parts)),
keywords=keywords,
content_structure=self._map_content_structure(page_blueprint.type),
content_type='article',
status='queued',
)
logger.info(
"[PageGenerationService] Created writer task %s for page blueprint %s",
task.id,
page_blueprint.id,
)
return task
def _build_task_title(self, page_blueprint: PageBlueprint) -> str:
base = page_blueprint.title or page_blueprint.slug.replace('-', ' ').title()
return f"[Site Builder] {base}"
def _build_keywords_hint(self, page_blueprint: PageBlueprint) -> str:
keywords = []
if page_blueprint.blocks_json:
for block in page_blueprint.blocks_json:
heading = block.get('heading') if isinstance(block, dict) else None
if heading:
keywords.append(heading)
keywords.append(page_blueprint.slug.replace('-', ' '))
return ", ".join(dict.fromkeys(filter(None, keywords)))
def _map_content_structure(self, page_type: Optional[str]) -> str:
if not page_type:
return 'landing_page'
mapping = {
'home': 'landing_page',
'about': 'supporting_page',
'services': 'pillar_page',
'products': 'pillar_page',
'blog': 'cluster_hub',
'contact': 'supporting_page',
}
return mapping.get(page_type.lower(), 'landing_page')
def _first_block_heading(self, page_blueprint: PageBlueprint) -> Optional[str]:
if not page_blueprint.blocks_json:
return None
for block in page_blueprint.blocks_json:
if isinstance(block, dict):
heading = block.get('heading') or block.get('title')
if heading:
return heading
return None

View File

@@ -0,0 +1,122 @@
"""
Structure Generation Service
Triggers the AI workflow that maps business briefs to page blueprints.
"""
import logging
from typing import Any, Dict, List, Optional
from django.utils import timezone
from igny8_core.business.billing.exceptions import InsufficientCreditsError
from igny8_core.business.billing.services.credit_service import CreditService
from igny8_core.business.site_building.models import SiteBlueprint
logger = logging.getLogger(__name__)
class StructureGenerationService:
"""Orchestrates AI-powered site structure generation."""
def __init__(self):
self.credit_service = CreditService()
def generate_structure(
self,
site_blueprint: SiteBlueprint,
business_brief: str,
objectives: Optional[List[str]] = None,
style_preferences: Optional[Dict[str, Any]] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
Kick off AI structure generation for a single blueprint.
Args:
site_blueprint: Target blueprint instance.
business_brief: Business description / positioning statement.
objectives: Optional list of goals for the new site.
style_preferences: Optional design/style hints.
metadata: Additional free-form context.
"""
if not site_blueprint:
raise ValueError("Site blueprint is required")
account = site_blueprint.account
objectives = objectives or []
style_preferences = style_preferences or {}
metadata = metadata or {}
logger.info(
"[StructureGenerationService] Starting generation for blueprint %s (account %s)",
site_blueprint.id,
getattr(account, 'id', None),
)
# Ensure the account can afford the request
try:
self.credit_service.check_credits(account, 'site_structure_generation')
except InsufficientCreditsError:
site_blueprint.status = 'draft'
site_blueprint.save(update_fields=['status', 'updated_at'])
raise
# Persist the latest inputs for future regenerations
config = site_blueprint.config_json or {}
config.update({
'business_brief': business_brief,
'objectives': objectives,
'style': style_preferences,
'last_requested_at': timezone.now().isoformat(),
'metadata': metadata,
})
site_blueprint.config_json = config
site_blueprint.status = 'generating'
site_blueprint.save(update_fields=['config_json', 'status', 'updated_at'])
payload = {
'ids': [site_blueprint.id],
'business_brief': business_brief,
'objectives': objectives,
'style': style_preferences,
'metadata': metadata,
}
return self._dispatch_ai_task(payload, account_id=account.id)
# Internal helpers --------------------------------------------------------
def _dispatch_ai_task(self, payload: Dict[str, Any], account_id: int) -> Dict[str, Any]:
from igny8_core.ai.tasks import run_ai_task
try:
if hasattr(run_ai_task, 'delay'):
async_result = run_ai_task.delay(
function_name='generate_site_structure',
payload=payload,
account_id=account_id
)
logger.info(
"[StructureGenerationService] Queued AI task %s for account %s",
async_result.id,
account_id,
)
return {
'success': True,
'task_id': str(async_result.id),
'message': 'Site structure generation queued',
}
# Celery not available run synchronously
logger.warning("[StructureGenerationService] Celery unavailable, running synchronously")
return run_ai_task(
function_name='generate_site_structure',
payload=payload,
account_id=account_id
)
except Exception as exc:
logger.error("Failed to dispatch structure generation: %s", exc, exc_info=True)
return {
'success': False,
'error': str(exc),
}

View File

@@ -0,0 +1,5 @@
"""
Site Builder module (Phase 3)
"""

View File

@@ -0,0 +1,9 @@
from django.apps import AppConfig
class SiteBuilderConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'igny8_core.modules.site_builder'
verbose_name = 'Site Builder'

View File

@@ -0,0 +1,78 @@
from rest_framework import serializers
from igny8_core.business.site_building.models import SiteBlueprint, PageBlueprint
class PageBlueprintSerializer(serializers.ModelSerializer):
site_blueprint_id = serializers.PrimaryKeyRelatedField(
source='site_blueprint',
queryset=SiteBlueprint.objects.all(),
write_only=True
)
site_blueprint = serializers.PrimaryKeyRelatedField(read_only=True)
class Meta:
model = PageBlueprint
fields = [
'id',
'site_blueprint_id',
'site_blueprint',
'slug',
'title',
'type',
'blocks_json',
'status',
'order',
'created_at',
'updated_at',
]
read_only_fields = [
'site_blueprint',
'created_at',
'updated_at',
]
class SiteBlueprintSerializer(serializers.ModelSerializer):
pages = PageBlueprintSerializer(many=True, read_only=True)
site_id = serializers.IntegerField(write_only=True, required=False)
sector_id = serializers.IntegerField(write_only=True, required=False)
class Meta:
model = SiteBlueprint
fields = [
'id',
'name',
'description',
'config_json',
'structure_json',
'status',
'hosting_type',
'version',
'deployed_version',
'site_id',
'sector_id',
'created_at',
'updated_at',
'pages',
]
read_only_fields = [
'structure_json',
'status',
'created_at',
'updated_at',
'pages',
]
def validate(self, attrs):
site_id = attrs.pop('site_id', None)
sector_id = attrs.pop('sector_id', None)
if self.instance is None:
if not site_id:
raise serializers.ValidationError({'site_id': 'This field is required.'})
if not sector_id:
raise serializers.ValidationError({'sector_id': 'This field is required.'})
attrs['site_id'] = site_id
attrs['sector_id'] = sector_id
return attrs

View File

@@ -0,0 +1,18 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from igny8_core.modules.site_builder.views import (
PageBlueprintViewSet,
SiteAssetView,
SiteBlueprintViewSet,
)
router = DefaultRouter()
router.register(r'blueprints', SiteBlueprintViewSet, basename='site_blueprint')
router.register(r'pages', PageBlueprintViewSet, basename='page_blueprint')
urlpatterns = [
path('', include(router.urls)),
path('assets/', SiteAssetView.as_view(), name='site_builder_assets'),
]

View File

@@ -0,0 +1,150 @@
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.exceptions import ValidationError
from igny8_core.api.base import SiteSectorModelViewSet
from igny8_core.api.permissions import IsAuthenticatedAndActive, IsEditorOrAbove
from igny8_core.api.response import success_response, error_response
from igny8_core.api.throttles import DebugScopedRateThrottle
from igny8_core.business.site_building.models import SiteBlueprint, PageBlueprint
from igny8_core.business.site_building.services import (
PageGenerationService,
SiteBuilderFileService,
StructureGenerationService,
)
from igny8_core.modules.site_builder.serializers import (
PageBlueprintSerializer,
SiteBlueprintSerializer,
)
class SiteBlueprintViewSet(SiteSectorModelViewSet):
"""
CRUD + AI actions for site blueprints.
"""
queryset = SiteBlueprint.objects.all().prefetch_related('pages')
serializer_class = SiteBlueprintSerializer
permission_classes = [IsAuthenticatedAndActive, IsEditorOrAbove]
throttle_scope = 'site_builder'
throttle_classes = [DebugScopedRateThrottle]
def perform_create(self, serializer):
from igny8_core.auth.models import Site, Sector
site_id = serializer.validated_data.pop('site_id', None)
sector_id = serializer.validated_data.pop('sector_id', None)
if not site_id or not sector_id:
raise ValidationError({'detail': 'site_id and sector_id are required.'})
try:
site = Site.objects.get(id=site_id)
except Site.DoesNotExist:
raise ValidationError({'site_id': 'Site not found.'})
try:
sector = Sector.objects.get(id=sector_id, site=site)
except Sector.DoesNotExist:
raise ValidationError({'sector_id': 'Sector does not belong to the selected site.'})
serializer.save(account=site.account, site=site, sector=sector)
@action(detail=True, methods=['post'])
def generate_structure(self, request, pk=None):
blueprint = self.get_object()
business_brief = request.data.get('business_brief') or \
blueprint.config_json.get('business_brief', '')
objectives = request.data.get('objectives') or \
blueprint.config_json.get('objectives', [])
style = request.data.get('style') or \
blueprint.config_json.get('style', {})
service = StructureGenerationService()
result = service.generate_structure(
site_blueprint=blueprint,
business_brief=business_brief,
objectives=objectives,
style_preferences=style,
metadata=request.data.get('metadata', {}),
)
return Response(result, status=status.HTTP_202_ACCEPTED if 'task_id' in result else status.HTTP_200_OK)
class PageBlueprintViewSet(SiteSectorModelViewSet):
"""
CRUD endpoints for page blueprints with content generation hooks.
"""
queryset = PageBlueprint.objects.select_related('site_blueprint')
serializer_class = PageBlueprintSerializer
permission_classes = [IsAuthenticatedAndActive, IsEditorOrAbove]
throttle_scope = 'site_builder'
throttle_classes = [DebugScopedRateThrottle]
def perform_create(self, serializer):
page = serializer.save()
# Align account/site/sector with parent blueprint
page.account = page.site_blueprint.account
page.site = page.site_blueprint.site
page.sector = page.site_blueprint.sector
page.save(update_fields=['account', 'site', 'sector'])
@action(detail=True, methods=['post'])
def generate_content(self, request, pk=None):
page = self.get_object()
service = PageGenerationService()
result = service.generate_page_content(page, force_regenerate=request.data.get('force', False))
return success_response(result, request=request)
@action(detail=True, methods=['post'])
def regenerate(self, request, pk=None):
page = self.get_object()
service = PageGenerationService()
result = service.regenerate_page(page)
return success_response(result, request=request)
class SiteAssetView(APIView):
"""
File management for Site Builder assets.
"""
permission_classes = [IsAuthenticated]
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.file_service = SiteBuilderFileService()
def get(self, request, *args, **kwargs):
site_id = request.query_params.get('site_id')
folder = request.query_params.get('folder')
if not site_id:
return error_response('site_id is required', status.HTTP_400_BAD_REQUEST, request)
files = self.file_service.list_files(request.user, int(site_id), folder=folder)
return success_response({'files': files}, request)
def post(self, request, *args, **kwargs):
site_id = request.data.get('site_id')
version = int(request.data.get('version', 1))
folder = request.data.get('folder', 'images')
upload = request.FILES.get('file')
if not site_id or not upload:
return error_response('site_id and file are required', status.HTTP_400_BAD_REQUEST, request)
info = self.file_service.upload_file(request.user, int(site_id), upload, folder=folder, version=version)
return success_response(info, request, status.HTTP_201_CREATED)
def delete(self, request, *args, **kwargs):
site_id = request.data.get('site_id')
file_path = request.data.get('path')
version = int(request.data.get('version', 1))
if not site_id or not file_path:
return error_response('site_id and path are required', status.HTTP_400_BAD_REQUEST, request)
deleted = self.file_service.delete_file(request.user, int(site_id), file_path, version=version)
if deleted:
return success_response({'deleted': True}, request, status.HTTP_204_NO_CONTENT)
return error_response('File not found', status.HTTP_404_NOT_FOUND, request)

View File

@@ -52,6 +52,8 @@ INSTALLED_APPS = [
'igny8_core.modules.system.apps.SystemConfig',
'igny8_core.modules.billing.apps.BillingConfig',
'igny8_core.modules.automation.apps.AutomationConfig',
'igny8_core.business.site_building.apps.SiteBuildingConfig',
'igny8_core.modules.site_builder.apps.SiteBuilderConfig',
]
# System module needs explicit registration for admin

View File

@@ -27,6 +27,7 @@ urlpatterns = [
path('api/v1/auth/', include('igny8_core.auth.urls')), # Auth endpoints
path('api/v1/planner/', include('igny8_core.modules.planner.urls')),
path('api/v1/writer/', include('igny8_core.modules.writer.urls')),
path('api/v1/site-builder/', include('igny8_core.modules.site_builder.urls')),
path('api/v1/system/', include('igny8_core.modules.system.urls')),
path('api/v1/billing/', include('igny8_core.modules.billing.urls')), # Billing endpoints
path('api/v1/automation/', include('igny8_core.modules.automation.urls')), # Automation endpoints

View File

@@ -1,126 +0,0 @@
"""
Test script to verify write access to image directories
"""
import os
import sys
from pathlib import Path
# Add project to path
sys.path.insert(0, str(Path(__file__).parent))
# Test write access logic
def test_write_access():
print("=" * 60)
print("Testing Image Directory Write Access")
print("=" * 60)
# Test 1: Absolute path /data/app/images
images_dir = '/data/app/images'
write_test_passed = False
print(f"\n[Test 1] Testing absolute path: {images_dir}")
try:
os.makedirs(images_dir, exist_ok=True)
print(f" ✓ Directory created/verified: {images_dir}")
# Test write access
test_file = os.path.join(images_dir, '.write_test')
print(f" → Attempting to write test file: {test_file}")
with open(test_file, 'w') as f:
f.write('test')
print(f" ✓ Write successful")
os.remove(test_file)
print(f" ✓ Test file removed")
write_test_passed = True
print(f" ✅ SUCCESS: {images_dir} is writable")
except PermissionError as e:
print(f" ✗ PERMISSION DENIED: {e}")
print(f" → Trying fallback path...")
# Fallback to project-relative path
try:
from django.conf import settings
base_dir = Path(settings.BASE_DIR) if hasattr(settings, 'BASE_DIR') else Path(__file__).resolve().parent.parent
except:
base_dir = Path(__file__).resolve().parent
images_dir = str(base_dir / 'data' / 'app' / 'images')
print(f"\n[Test 2] Testing fallback path: {images_dir}")
try:
os.makedirs(images_dir, exist_ok=True)
print(f" ✓ Directory created/verified: {images_dir}")
# Test fallback directory write access
test_file = os.path.join(images_dir, '.write_test')
print(f" → Attempting to write test file: {test_file}")
with open(test_file, 'w') as f:
f.write('test')
print(f" ✓ Write successful")
os.remove(test_file)
print(f" ✓ Test file removed")
write_test_passed = True
print(f" ✅ SUCCESS: {images_dir} is writable")
except Exception as fallback_error:
print(f" ✗ FAILED: {fallback_error}")
print(f" ❌ ERROR: Neither /data/app/images nor {images_dir} is writable")
return False
except Exception as e:
print(f" ✗ ERROR: {e}")
print(f" → Trying fallback path...")
# Fallback to project-relative path
try:
from django.conf import settings
base_dir = Path(settings.BASE_DIR) if hasattr(settings, 'BASE_DIR') else Path(__file__).resolve().parent.parent
except:
base_dir = Path(__file__).resolve().parent
images_dir = str(base_dir / 'data' / 'app' / 'images')
print(f"\n[Test 2] Testing fallback path: {images_dir}")
try:
os.makedirs(images_dir, exist_ok=True)
print(f" ✓ Directory created/verified: {images_dir}")
# Test fallback directory write access
test_file = os.path.join(images_dir, '.write_test')
print(f" → Attempting to write test file: {test_file}")
with open(test_file, 'w') as f:
f.write('test')
print(f" ✓ Write successful")
os.remove(test_file)
print(f" ✓ Test file removed")
write_test_passed = True
print(f" ✅ SUCCESS: {images_dir} is writable")
except Exception as fallback_error:
print(f" ✗ FAILED: {fallback_error}")
print(f" ❌ ERROR: Neither /data/app/images nor {images_dir} is writable")
return False
if not write_test_passed:
print(f"\n❌ FAILED: No writable directory found")
return False
print(f"\n" + "=" * 60)
print(f"✅ FINAL RESULT: Images will be saved to: {images_dir}")
print("=" * 60)
return True
if __name__ == '__main__':
success = test_write_access()
sys.exit(0 if success else 1)

View File

@@ -1,60 +0,0 @@
#!/usr/bin/env python
"""
Script to fix and update the Free plan with proper values.
"""
import os
import django
import sys
# Setup Django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8_core.settings')
django.setup()
from igny8_core.auth.models import Plan
from decimal import Decimal
def update_free_plan():
"""Update the Free plan with proper values and fix JSON fields."""
try:
free_plan = Plan.objects.get(slug='free')
print(f"Found Free plan: {free_plan.name}")
print("Updating values...")
# Update free plan values - keeping existing values but fixing JSON fields
# Fix JSON fields - use proper Python lists/dicts
free_plan.image_model_choices = [] # Empty list for free plan (no image generation)
# Ensure features is a proper list (not dict)
if isinstance(free_plan.features, dict):
free_plan.features = []
elif not isinstance(free_plan.features, list):
free_plan.features = []
# Save the plan
free_plan.save()
print("✅ Successfully updated Free plan!")
print(f" - Image Model Choices: {free_plan.image_model_choices}")
print(f" - Features: {free_plan.features}")
print(f" - Max Sites: {free_plan.max_sites}")
print(f" - Max Users: {free_plan.max_users}")
print(f" - Max Keywords: {free_plan.max_keywords}")
print(f" - Max Clusters: {free_plan.max_clusters}")
print(f" - Max Content Ideas: {free_plan.max_content_ideas}")
print(f" - Monthly AI Credits: {free_plan.monthly_ai_credit_limit}")
print(f" - Daily AI Request Limit: {free_plan.daily_ai_request_limit}")
print(f" - Daily Image Generation Limit: {free_plan.daily_image_generation_limit}")
except Plan.DoesNotExist:
print("❌ Free plan not found!")
sys.exit(1)
except Exception as e:
print(f"❌ Error updating free plan: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == '__main__':
update_free_plan()

View File

@@ -100,6 +100,25 @@ services:
- "com.docker.compose.project=igny8-app"
- "com.docker.compose.service=igny8_marketing_dev"
igny8_site_builder:
image: igny8-site-builder-dev:latest
container_name: igny8_site_builder
restart: always
ports:
- "0.0.0.0:8025:5175"
environment:
VITE_API_URL: "https://api.igny8.com/api"
volumes:
- /data/app/igny8/site-builder:/app:rw
- /data/app/igny8/frontend:/frontend:ro
depends_on:
igny8_backend:
condition: service_healthy
networks: [igny8_net]
labels:
- "com.docker.compose.project=igny8-app"
- "com.docker.compose.service=igny8_site_builder"
igny8_celery_worker:
image: igny8-backend:latest
container_name: igny8_celery_worker

View File

@@ -0,0 +1,30 @@
import './blocks.css';
export interface FeatureGridBlockProps {
heading?: string;
features: Array<{
title: string;
description?: string;
icon?: string;
}>;
columns?: 2 | 3 | 4;
}
export function FeatureGridBlock({ heading, features, columns = 3 }: FeatureGridBlockProps) {
return (
<section className="shared-card">
{heading && <h3>{heading}</h3>}
<div className={`shared-grid shared-grid--${columns}`}>
{features.map((feature) => (
<article key={feature.title} className="shared-feature">
{feature.icon && <span className="shared-feature__icon">{feature.icon}</span>}
<h4>{feature.title}</h4>
{feature.description && <p>{feature.description}</p>}
</article>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,29 @@
import type { ReactNode } from 'react';
import './blocks.css';
export interface HeroBlockProps {
eyebrow?: string;
title: string;
subtitle?: string;
ctaLabel?: string;
onCtaClick?: () => void;
supportingContent?: ReactNode;
}
export function HeroBlock({ eyebrow, title, subtitle, ctaLabel, onCtaClick, supportingContent }: HeroBlockProps) {
return (
<section className="shared-hero">
{eyebrow && <p className="shared-hero__eyebrow">{eyebrow}</p>}
<h2 className="shared-hero__title">{title}</h2>
{subtitle && <p className="shared-hero__subtitle">{subtitle}</p>}
{supportingContent && <div className="shared-hero__support">{supportingContent}</div>}
{ctaLabel && (
<button type="button" className="shared-button" onClick={onCtaClick}>
{ctaLabel}
</button>
)}
</section>
);
}

View File

@@ -0,0 +1,31 @@
import './blocks.css';
export interface StatItem {
label: string;
value: string;
description?: string;
}
export interface StatsPanelProps {
heading?: string;
stats: StatItem[];
}
export function StatsPanel({ heading, stats }: StatsPanelProps) {
return (
<section className="shared-card">
{heading && <h3>{heading}</h3>}
<div className="shared-stats">
{stats.map((stat) => (
<div key={stat.label} className="shared-stat">
<strong>{stat.value}</strong>
<span>{stat.label}</span>
{stat.description && <p>{stat.description}</p>}
</div>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,123 @@
.shared-hero {
padding: 2.5rem;
border-radius: 24px;
background: linear-gradient(135deg, #4c1d95, #312e81);
color: #fff;
display: flex;
flex-direction: column;
gap: 1rem;
}
.shared-hero__eyebrow {
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.75rem;
margin: 0;
opacity: 0.8;
}
.shared-hero__title {
margin: 0;
font-size: clamp(2rem, 5vw, 3rem);
font-weight: 700;
}
.shared-hero__subtitle {
margin: 0;
color: rgba(255, 255, 255, 0.85);
font-size: 1.05rem;
}
.shared-button {
border: none;
border-radius: 999px;
padding: 0.9rem 1.8rem;
font-size: 0.95rem;
font-weight: 600;
color: #0f172a;
background: #fff;
align-self: flex-start;
cursor: pointer;
}
.shared-card {
padding: 2rem;
border-radius: 22px;
background: #fff;
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.08);
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.shared-card h3 {
margin: 0;
font-size: 1.35rem;
color: #0f172a;
}
.shared-grid {
display: grid;
gap: 1rem;
}
.shared-grid--2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.shared-grid--3 {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.shared-grid--4 {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.shared-feature {
padding: 1rem;
border-radius: 16px;
border: 1px solid rgba(15, 23, 42, 0.08);
background: #f8fafc;
}
.shared-feature h4 {
margin: 0;
font-size: 1.05rem;
color: #0f172a;
}
.shared-feature p {
margin: 0.5rem 0 0;
color: #475569;
}
.shared-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.75rem;
}
.shared-stat {
padding: 1rem;
border-radius: 16px;
background: rgba(15, 23, 42, 0.03);
}
.shared-stat strong {
display: block;
font-size: 1.6rem;
color: #0f172a;
}
.shared-stat span {
display: block;
color: #475569;
}
.shared-stat p {
margin: 0.35rem 0 0;
color: #64748b;
font-size: 0.9rem;
}

View File

@@ -0,0 +1,8 @@
export { HeroBlock } from './HeroBlock';
export type { HeroBlockProps } from './HeroBlock';
export { FeatureGridBlock } from './FeatureGridBlock';
export type { FeatureGridBlockProps } from './FeatureGridBlock';
export { StatsPanel } from './StatsPanel';
export type { StatsPanelProps, StatItem } from './StatsPanel';

View File

@@ -0,0 +1,9 @@
export * from './blocks';
export * from './layouts';
export * from './templates';
export * from './blocks';
export * from './layouts';
export * from './templates';

View File

@@ -0,0 +1,28 @@
import type { ReactNode } from 'react';
import './layouts.css';
export interface DefaultLayoutProps {
hero?: ReactNode;
sections: ReactNode[];
sidebar?: ReactNode;
}
export function DefaultLayout({ hero, sections, sidebar }: DefaultLayoutProps) {
return (
<div className="shared-layout">
{hero && <div className="shared-layout__hero">{hero}</div>}
<div className="shared-layout__body">
<div className="shared-layout__main">
{sections.map((section, index) => (
<div key={index} className="shared-layout__section">
{section}
</div>
))}
</div>
{sidebar && <aside className="shared-layout__sidebar">{sidebar}</aside>}
</div>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import type { ReactNode } from 'react';
import './layouts.css';
export interface MinimalLayoutProps {
children: ReactNode;
background?: 'light' | 'dark';
}
export function MinimalLayout({ children, background = 'light' }: MinimalLayoutProps) {
return <div className={`shared-layout__minimal shared-layout__minimal--${background}`}>{children}</div>;
}

View File

@@ -0,0 +1,6 @@
export { DefaultLayout } from './DefaultLayout';
export type { DefaultLayoutProps } from './DefaultLayout';
export { MinimalLayout } from './MinimalLayout';
export type { MinimalLayoutProps } from './MinimalLayout';

View File

@@ -0,0 +1,65 @@
.shared-layout {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.shared-layout__hero {
min-height: 320px;
}
.shared-layout__body {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1.25rem;
}
@media (min-width: 1024px) {
.shared-layout__body {
grid-template-columns: minmax(0, 2.3fr) minmax(0, 1fr);
}
}
.shared-layout__section {
margin-bottom: 1.25rem;
}
.shared-layout__sidebar {
position: sticky;
top: 3rem;
align-self: flex-start;
background: #0f172a;
color: #fff;
border-radius: 24px;
padding: 1.75rem;
box-shadow: 0 20px 45px rgba(15, 23, 42, 0.35);
}
.shared-layout__minimal {
border-radius: 30px;
padding: 2.5rem;
}
.shared-layout__minimal--light {
background: #f8fafc;
border: 1px solid rgba(15, 23, 42, 0.06);
}
.shared-layout__minimal--dark {
background: #0f172a;
color: #fff;
}
.shared-landing {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.shared-landing__highlights {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
}

View File

@@ -0,0 +1,26 @@
import type { ReactNode } from 'react';
import { MinimalLayout } from '../layouts/MinimalLayout';
export interface LandingTemplateProps {
hero: ReactNode;
highlights: ReactNode[];
background?: 'light' | 'dark';
}
export function LandingTemplate({ hero, highlights, background }: LandingTemplateProps) {
return (
<MinimalLayout background={background}>
<div className="shared-landing">
<div className="shared-landing__hero">{hero}</div>
<div className="shared-landing__highlights">
{highlights.map((highlight, index) => (
// eslint-disable-next-line react/no-array-index-key
<div key={index}>{highlight}</div>
))}
</div>
</div>
</MinimalLayout>
);
}

View File

@@ -0,0 +1,14 @@
import type { ReactNode } from 'react';
import { DefaultLayout } from '../layouts/DefaultLayout';
export interface MarketingTemplateProps {
hero: ReactNode;
sections: ReactNode[];
sidebar?: ReactNode;
}
export function MarketingTemplate(props: MarketingTemplateProps) {
return <DefaultLayout {...props} />;
}

View File

@@ -0,0 +1,6 @@
export { MarketingTemplate } from './MarketingTemplate';
export type { MarketingTemplateProps } from './MarketingTemplate';
export { LandingTemplate } from './LandingTemplate';
export type { LandingTemplateProps } from './LandingTemplate';

24
site-builder/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,18 @@
# Site Builder Dev Image (Node 22 to satisfy Vite requirements)
FROM node:22-alpine
WORKDIR /app
# Copy package manifests first for better caching
COPY package*.json ./
RUN npm install
# Copy source (still bind-mounted at runtime, but needed for initial run)
COPY . .
EXPOSE 5175
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5175"]

73
site-builder/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
site-builder/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>site-builder</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3894
site-builder/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
site-builder/package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "site-builder",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.13.2",
"lucide-react": "^0.554.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.66.0",
"react-router-dom": "^7.9.6",
"zustand": "^5.0.8"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^5.1.0",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.3",
"vite": "^7.2.2"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

330
site-builder/src/App.css Normal file
View File

@@ -0,0 +1,330 @@
.app-shell {
display: grid;
grid-template-columns: 280px 1fr;
min-height: 100vh;
background: #f5f7fb;
color: #0f172a;
}
.app-sidebar {
border-right: 1px solid rgba(15, 23, 42, 0.08);
padding: 2rem 1.5rem;
display: flex;
flex-direction: column;
gap: 2rem;
background: #ffffff;
}
.app-sidebar .brand span {
font-size: 1.25rem;
font-weight: 700;
display: block;
}
.app-sidebar .brand small {
color: #64748b;
}
.app-sidebar nav {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.app-sidebar a {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0.75rem;
border-radius: 10px;
text-decoration: none;
color: inherit;
font-weight: 500;
}
.app-sidebar a.active {
background: #eef2ff;
color: #4338ca;
}
.app-main {
padding: 2rem 3rem;
overflow-y: auto;
}
.wizard-page {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.wizard-progress {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.wizard-progress__dot {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
border: none;
background: transparent;
color: #94a3b8;
font-weight: 500;
cursor: pointer;
}
.wizard-progress__dot span {
width: 34px;
height: 34px;
border-radius: 50%;
border: 2px solid currentColor;
display: grid;
place-items: center;
}
.wizard-progress__dot.is-active {
color: #4338ca;
}
.wizard-step {
margin-top: 1rem;
}
.wizard-actions {
display: flex;
justify-content: space-between;
gap: 1rem;
}
.wizard-actions button {
padding: 0.75rem 1.5rem;
border-radius: 12px;
border: 1px solid rgba(67, 56, 202, 0.3);
background: #fff;
cursor: pointer;
}
.wizard-actions button.primary {
background: #4338ca;
color: #fff;
border-color: transparent;
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.wizard-actions button.ghost,
.ghost {
background: transparent;
border-color: rgba(67, 56, 202, 0.35);
color: #4338ca;
}
.wizard-actions button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.sb-error {
color: #dc2626;
margin: 0.5rem 0 0;
}
.sb-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.sb-field {
display: flex;
flex-direction: column;
gap: 0.4rem;
font-size: 0.95rem;
color: #0f172a;
}
.sb-field input,
.sb-field select,
.sb-field textarea {
border: 1px solid rgba(15, 23, 42, 0.15);
border-radius: 10px;
padding: 0.65rem 0.85rem;
font-size: 0.95rem;
font-family: inherit;
background: #f8fafc;
}
.sb-pill-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.sb-pill {
padding: 0.35rem 0.75rem;
border-radius: 999px;
background: rgba(67, 56, 202, 0.1);
color: #4338ca;
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.sb-pill button {
border: none;
background: transparent;
color: inherit;
cursor: pointer;
}
.sb-objective-input {
display: flex;
gap: 0.5rem;
}
.sb-objective-input input {
flex: 1;
}
.sb-objective-input button {
border: none;
background: #0f172a;
color: #fff;
border-radius: 10px;
padding: 0.65rem 1rem;
cursor: pointer;
}
.sb-blueprint-meta {
display: flex;
justify-content: space-between;
align-items: center;
}
.status-dot {
display: inline-flex;
align-items: center;
gap: 0.35rem;
text-transform: capitalize;
}
.status-dot::before {
content: '';
width: 10px;
height: 10px;
border-radius: 50%;
background: currentColor;
}
.status-ready {
color: #10b981;
}
.status-generating {
color: #f97316;
}
.status-draft {
color: #94a3b8;
}
.preview-canvas {
background: #fff;
border-radius: 18px;
padding: 1.5rem;
box-shadow: 0 8px 26px rgba(15, 23, 42, 0.08);
}
.preview-nav {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.preview-nav button {
border: 1px solid rgba(15, 23, 42, 0.1);
background: #f8fafc;
border-radius: 999px;
padding: 0.35rem 0.85rem;
cursor: pointer;
}
.preview-nav button.is-active {
background: #4338ca;
color: white;
border-color: transparent;
}
.preview-hero {
margin-bottom: 1.25rem;
}
.preview-hero .preview-label {
text-transform: uppercase;
font-size: 0.7rem;
letter-spacing: 0.08em;
color: #94a3b8;
}
.preview-blocks {
display: grid;
gap: 1rem;
}
.preview-block {
border: 1px dashed rgba(67, 56, 202, 0.2);
border-radius: 14px;
padding: 1rem;
background: rgba(67, 56, 202, 0.04);
}
.sb-blueprint-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.sb-blueprint-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 0.75rem;
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
}
.sb-blueprint-list strong {
display: block;
}
.sb-blueprint-list span {
color: #475569;
font-size: 0.9rem;
}
.sb-loading {
display: flex;
align-items: center;
gap: 0.5rem;
color: #475569;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

43
site-builder/src/App.tsx Normal file
View File

@@ -0,0 +1,43 @@
import { NavLink, Route, Routes } from 'react-router-dom';
import { Wand2, LayoutTemplate, PanelsTopLeft } from 'lucide-react';
import { WizardPage } from './pages/wizard/WizardPage';
import { PreviewCanvas } from './pages/preview/PreviewCanvas';
import { SiteDashboard } from './pages/dashboard/SiteDashboard';
import './App.css';
function App() {
return (
<div className="app-shell">
<aside className="app-sidebar">
<div className="brand">
<span>Site Builder</span>
<small>Phase 3 · wizard + preview</small>
</div>
<nav>
<NavLink to="/" end>
<Wand2 size={18} />
Wizard
</NavLink>
<NavLink to="/preview">
<LayoutTemplate size={18} />
Preview
</NavLink>
<NavLink to="/dashboard">
<PanelsTopLeft size={18} />
Blueprint history
</NavLink>
</nav>
</aside>
<main className="app-main">
<Routes>
<Route path="/" element={<WizardPage />} />
<Route path="/preview" element={<PreviewCanvas />} />
<Route path="/dashboard" element={<SiteDashboard />} />
</Routes>
</main>
</div>
);
}
export default App;

View File

@@ -0,0 +1,61 @@
import axios from 'axios';
import type {
BuilderFormData,
PageBlueprint,
SiteBlueprint,
SiteStructure,
} from '../types/siteBuilder';
const API_ROOT = import.meta.env.VITE_API_URL ?? 'http://localhost:8010/api';
const BASE_PATH = `${API_ROOT}/v1/site-builder`;
const client = axios.create({
baseURL: BASE_PATH,
withCredentials: true,
});
export interface CreateBlueprintPayload {
name: string;
description?: string;
site_id: number;
sector_id: number;
hosting_type: BuilderFormData['hostingType'];
config_json: Record<string, unknown>;
}
export interface GenerateStructurePayload {
business_brief: string;
objectives: string[];
style: BuilderFormData['style'];
metadata?: Record<string, unknown>;
}
export const builderApi = {
async listBlueprints(): Promise<SiteBlueprint[]> {
const res = await client.get('/blueprints/');
if (Array.isArray(res.data?.results)) {
return res.data.results as SiteBlueprint[];
}
return Array.isArray(res.data) ? res.data : [];
},
async createBlueprint(payload: CreateBlueprintPayload): Promise<SiteBlueprint> {
const res = await client.post('/blueprints/', payload);
return res.data;
},
async generateStructure(
blueprintId: number,
payload: GenerateStructurePayload,
): Promise<{ task_id?: string; success?: boolean; structure?: SiteStructure }> {
const res = await client.post(`/blueprints/${blueprintId}/generate_structure/`, payload);
return res.data;
},
async listPages(blueprintId: number): Promise<PageBlueprint[]> {
const res = await client.get(`/pages/?site_blueprint=${blueprintId}`);
return Array.isArray(res.data?.results) ? res.data.results : res.data;
},
};

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,45 @@
.sb-card {
background: #ffffff;
border-radius: 16px;
border: 1px solid rgba(15, 23, 42, 0.08);
padding: 1.5rem;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05);
display: flex;
flex-direction: column;
gap: 1rem;
}
.sb-card__header {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.sb-card__title {
font-size: 1.1rem;
font-weight: 600;
color: #0f172a;
margin: 0;
}
.sb-card__description {
color: #475569;
margin: 0;
font-size: 0.95rem;
}
.sb-card__body {
display: flex;
flex-direction: column;
gap: 1rem;
}
.sb-card__footer {
border-top: 1px solid rgba(15, 23, 42, 0.06);
padding-top: 1rem;
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}

View File

@@ -0,0 +1,25 @@
import type { PropsWithChildren, ReactNode } from 'react';
import './Card.css';
interface CardProps extends PropsWithChildren {
title?: ReactNode;
description?: ReactNode;
footer?: ReactNode;
}
export function Card({ title, description, footer, children }: CardProps) {
return (
<section className="sb-card">
{(title || description) && (
<header className="sb-card__header">
{title && <h2 className="sb-card__title">{title}</h2>}
{description && <p className="sb-card__description">{description}</p>}
</header>
)}
<div className="sb-card__body">{children}</div>
{footer && <footer className="sb-card__footer">{footer}</footer>}
</section>
);
}

View File

@@ -0,0 +1,28 @@
import './HeroBlock.css';
interface HeroBlockProps {
heading: string;
subheading?: string;
ctaLabel?: string;
secondaryCta?: string;
badge?: string;
}
export function HeroBlock({ heading, subheading, ctaLabel, secondaryCta, badge }: HeroBlockProps) {
return (
<div className="sb-hero-block">
{badge && <span className="sb-hero-block__badge">{badge}</span>}
<h1>{heading}</h1>
{subheading && <p>{subheading}</p>}
<div className="sb-hero-block__ctas">
{ctaLabel && <button>{ctaLabel}</button>}
{secondaryCta && (
<button className="ghost" type="button">
{secondaryCta}
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,13 @@
.sb-page-canvas {
border-radius: 24px;
padding: 2.5rem;
box-shadow: 0 30px 80px rgba(15, 23, 42, 0.12);
border: 1px solid rgba(15, 23, 42, 0.08);
}
.sb-page-canvas__body {
display: flex;
flex-direction: column;
gap: 2.25rem;
}

View File

@@ -0,0 +1,12 @@
import type { PropsWithChildren } from 'react';
import { palette } from '../theme';
import './PageCanvas.css';
export function PageCanvas({ children }: PropsWithChildren) {
return (
<article className="sb-page-canvas" style={{ background: palette.background }}>
<div className="sb-page-canvas__body">{children}</div>
</article>
);
}

View File

@@ -0,0 +1,39 @@
.sb-section {
border-radius: 20px;
padding: 1.75rem;
border: 1px solid rgba(15, 23, 42, 0.07);
background: #ffffff;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.sb-section--soft {
background: #f4f6ff;
}
.sb-section__header h3 {
margin: 0.15rem 0;
font-size: 1.8rem;
font-weight: 700;
}
.sb-section__subtitle {
margin: 0;
color: #64748b;
}
.sb-section__overline {
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #6366f1;
margin: 0;
}
.sb-section__content {
display: flex;
flex-direction: column;
gap: 1rem;
}

View File

@@ -0,0 +1,25 @@
import type { PropsWithChildren, ReactNode } from 'react';
import './Section.css';
interface SectionProps extends PropsWithChildren {
overline?: string;
title?: ReactNode;
subtitle?: ReactNode;
background?: 'surface' | 'soft';
}
export function Section({ overline, title, subtitle, background = 'surface', children }: SectionProps) {
return (
<section className={`sb-section sb-section--${background}`}>
{(overline || title || subtitle) && (
<header className="sb-section__header">
{overline && <p className="sb-section__overline">{overline}</p>}
{title && <h3>{title}</h3>}
{subtitle && <p className="sb-section__subtitle">{subtitle}</p>}
</header>
)}
<div className="sb-section__content">{children}</div>
</section>
);
}

View File

@@ -0,0 +1,29 @@
export const palette = {
background: '#f8fbff',
surface: '#ffffff',
accent: '#6366f1',
accentSoft: '#eef2ff',
text: '#0f172a',
textMuted: '#64748b',
border: 'rgba(15, 23, 42, 0.08)',
};
export const typography = {
title: {
fontSize: '2.5rem',
fontWeight: 700,
lineHeight: 1.1,
},
subtitle: {
fontSize: '1.15rem',
color: palette.textMuted,
lineHeight: 1.5,
},
label: {
fontSize: '0.75rem',
textTransform: 'uppercase',
letterSpacing: '0.08em',
color: palette.accent,
},
};

View File

@@ -0,0 +1,28 @@
:root {
font-family: 'Inter', 'Inter var', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
color: #0f172a;
background-color: #f5f7fb;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
background: #f5f7fb;
}
button {
font-family: inherit;
}
a {
color: inherit;
}

13
site-builder/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import './index.css';
import App from './App.tsx';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);

View File

@@ -0,0 +1,56 @@
import { useEffect, useState } from 'react';
import { Loader2 } from 'lucide-react';
import { builderApi } from '../../api/builder.api';
import type { SiteBlueprint } from '../../types/siteBuilder';
import { Card } from '../../components/common/Card';
export function SiteDashboard() {
const [blueprints, setBlueprints] = useState<SiteBlueprint[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | undefined>();
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const data = await builderApi.listBlueprints();
setBlueprints(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to load blueprints');
} finally {
setLoading(false);
}
};
fetchData();
}, []);
return (
<Card title="Blueprint history" description="Every generated structure is stored and can be reopened.">
{loading && (
<div className="sb-loading">
<Loader2 className="spin" size={18} /> Loading blueprints
</div>
)}
{error && <p className="sb-error">{error}</p>}
{!loading && !blueprints.length && (
<p className="sb-muted">You havent generated any sites yet. Launch the wizard to create your first one.</p>
)}
<ul className="sb-blueprint-list">
{blueprints.map((bp) => (
<li key={bp.id}>
<div>
<strong>{bp.name}</strong>
<span>{bp.description}</span>
</div>
<span className={`status-dot status-${bp.status}`}>{bp.status}</span>
</li>
))}
</ul>
</Card>
);
}

View File

@@ -0,0 +1,291 @@
import { useMemo } from 'react';
import {
FeatureGridBlock,
HeroBlock,
MarketingTemplate,
StatsPanel,
type FeatureGridBlockProps,
type StatItem,
} from '@shared';
import { useSiteDefinitionStore } from '../../state/siteDefinitionStore';
import type { PageBlock, PageBlueprint, SiteStructure } from '../../types/siteBuilder';
type StructuredContent = Record<string, unknown> & {
items?: unknown[];
eyebrow?: string;
ctaLabel?: string;
supportingCopy?: string;
columns?: number;
};
export function PreviewCanvas() {
const { structure, pages, selectedSlug, selectPage } = useSiteDefinitionStore();
const page = useMemo(() => {
if (structure?.pages?.length) {
return structure.pages.find((p) => p.slug === selectedSlug) ?? structure.pages[0];
}
return pages.find((p) => p.slug === selectedSlug) ?? pages[0];
}, [structure, pages, selectedSlug]);
if (!structure && !pages.length) {
return (
<div className="preview-placeholder">
<p>Generate a blueprint to see live previews of every page.</p>
</div>
);
}
const navItems = structure?.site?.primary_navigation ?? pages.map((p) => p.slug);
const blocks = getBlocks(page);
const heroBlock = blocks.find((block) => normalizeType(block.type) === 'hero');
const contentBlocks = heroBlock ? blocks.filter((block) => block !== heroBlock) : blocks;
const heroSection =
heroBlock || page
? renderBlock(heroBlock ?? buildFallbackHero(page, structure))
: null;
const sectionNodes =
contentBlocks.length > 0
? contentBlocks.map((block, index) => <div key={`${block.type}-${index}`}>{renderBlock(block)}</div>)
: buildFallbackSections(page);
const sidebar = (
<div className="preview-sidebar">
<p className="preview-label">Page objective</p>
<h4>{page?.objective ?? 'Launch a high-converting page'}</h4>
<ul className="preview-sidebar__list">
{buildSidebarInsights(page, structure).map((insight) => (
<li key={insight.label}>
<span>{insight.label}</span>
<strong>{insight.value}</strong>
</li>
))}
</ul>
</div>
);
return (
<div className="preview-canvas">
<div className="preview-nav">
{navItems?.map((slug) => (
<button
key={slug}
type="button"
onClick={() => selectPage(slug)}
className={slug === (page?.slug ?? '') ? 'is-active' : ''}
>
{slug.replace('-', ' ')}
</button>
))}
</div>
<MarketingTemplate hero={heroSection} sections={sectionNodes} sidebar={sidebar} />
</div>
);
}
function getBlocks(
page: (SiteStructure['pages'][number] & { blocks_json?: PageBlock[] }) | PageBlueprint | undefined,
) {
if (!page) return [];
const fromStructure = (page as { blocks?: PageBlock[] }).blocks;
if (Array.isArray(fromStructure)) return fromStructure;
const fromBlueprint = (page as PageBlueprint).blocks_json;
return Array.isArray(fromBlueprint) ? fromBlueprint : [];
}
function renderBlock(block?: PageBlock) {
if (!block) return null;
const type = normalizeType(block.type);
const structuredContent = extractStructuredContent(block);
const listContent = extractListContent(block, structuredContent);
if (type === 'hero') {
return (
<HeroBlock
eyebrow={structuredContent.eyebrow as string | undefined}
title={block.heading ?? 'Untitled hero'}
subtitle={block.subheading ?? (structuredContent.supportingCopy as string | undefined)}
ctaLabel={(structuredContent.ctaLabel as string | undefined) ?? undefined}
supportingContent={
listContent.length > 0 ? (
<ul>
{listContent.map((item) => (
<li key={String(item)}>{String(item)}</li>
))}
</ul>
) : undefined
}
/>
);
}
if (type === 'feature-grid' || type === 'features' || type === 'value-props') {
const features = toFeatureList(listContent, structuredContent.items);
const columns = normalizeColumns(structuredContent.columns, features.length);
return <FeatureGridBlock heading={block.heading} features={features} columns={columns} />;
}
if (type === 'stats' || type === 'metrics') {
const stats = toStatItems(listContent, structuredContent.items, block);
if (!stats.length) return defaultBlock(block);
return <StatsPanel heading={block.heading} stats={stats} />;
}
return defaultBlock(block);
}
function defaultBlock(block: PageBlock) {
return (
<div className="preview-block preview-block--legacy">
{block.heading && <h4>{block.heading}</h4>}
{block.subheading && <p>{block.subheading}</p>}
{Array.isArray(block.content) && (
<ul>
{block.content.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
)}
</div>
);
}
function normalizeType(type?: string) {
return (type ?? '').toLowerCase();
}
function extractStructuredContent(block: PageBlock): StructuredContent {
if (Array.isArray(block.content)) {
return {};
}
return (block.content ?? {}) as StructuredContent;
}
function extractListContent(block: PageBlock, structuredContent: StructuredContent): unknown[] {
if (Array.isArray(block.content)) {
return block.content;
}
if (Array.isArray(structuredContent.items)) {
return structuredContent.items;
}
return [];
}
function toFeatureList(listItems: unknown[], structuredItems?: unknown[]): FeatureGridBlockProps['features'] {
const source = structuredItems && Array.isArray(structuredItems) && structuredItems.length > 0 ? structuredItems : listItems;
return source.map((item) => {
if (typeof item === 'string') {
return { title: item };
}
if (typeof item === 'object' && item) {
const record = item as Record<string, unknown>;
return {
title: String(record.title ?? record.heading ?? 'Feature'),
description: record.description ? String(record.description) : undefined,
icon: record.icon ? String(record.icon) : undefined,
};
}
return { title: String(item) };
});
}
function toStatItems(
listItems: unknown[],
structuredItems: unknown[] | undefined,
block: PageBlock,
): StatItem[] {
const source = structuredItems && Array.isArray(structuredItems) && structuredItems.length > 0 ? structuredItems : listItems;
return source
.map((item, index) => {
if (typeof item === 'string') {
return {
label: block.heading ?? `Metric ${index + 1}`,
value: item,
};
}
if (typeof item === 'object' && item) {
const record = item as Record<string, unknown>;
const label = record.label ?? record.title ?? `Metric ${index + 1}`;
const value = record.value ?? record.metric ?? record.score;
if (!value) return null;
return {
label: String(label),
value: String(value),
description: record.description ? String(record.description) : undefined,
};
}
return null;
})
.filter((stat): stat is StatItem => Boolean(stat));
}
function normalizeColumns(
candidate: StructuredContent['columns'],
featureCount: number,
): FeatureGridBlockProps['columns'] {
const inferred = typeof candidate === 'number' ? candidate : featureCount >= 4 ? 4 : featureCount === 2 ? 2 : 3;
if (inferred <= 2) return 2;
if (inferred >= 4) return 4;
return 3;
}
function buildFallbackHero(
page: SiteStructure['pages'][number] | PageBlueprint | undefined,
structure: SiteStructure | undefined,
): PageBlock {
return {
type: 'hero',
heading: page?.title ?? 'Site Builder preview',
subheading: structure?.site?.hero_message ?? 'Preview updates as the AI hydrates your blueprint.',
content: Array.isArray(structure?.site?.secondary_navigation) ? structure?.site?.secondary_navigation : [],
};
}
function buildFallbackSections(page: SiteStructure['pages'][number] | PageBlueprint | undefined) {
return [
<FeatureGridBlock
key="fallback-features"
heading="Generated sections"
features={[
{ title: 'AI messaging kit', description: 'Structured copy generated for each funnel stage.' },
{ title: 'Audience resonance', description: 'Language tuned to your target segment.' },
{ title: 'Conversion spine', description: 'CTA hierarchy anchored to your objectives.' },
]}
/>,
<StatsPanel
key="fallback-stats"
heading="Blueprint signals"
stats={[
{ label: 'Page type', value: page?.type ?? 'Landing' },
{ label: 'Status', value: page?.status ?? 'Draft' },
{ label: 'Blocks', value: '0' },
]}
/>,
];
}
function buildSidebarInsights(
page: SiteStructure['pages'][number] | PageBlueprint | undefined,
structure: SiteStructure | undefined,
) {
return [
{
label: 'Primary CTA',
value: page?.primary_cta ?? 'Book a demo',
},
{
label: 'Tone',
value: structure?.site?.tone ?? 'Confident & clear',
},
{
label: 'Status',
value: page?.status ?? 'Draft',
},
];
}

View File

@@ -0,0 +1,119 @@
import { useMemo } from 'react';
import { Loader2, PlayCircle, RefreshCw } from 'lucide-react';
import { useBuilderStore } from '../../state/builderStore';
import { useSiteDefinitionStore } from '../../state/siteDefinitionStore';
import { BusinessDetailsStep } from './steps/BusinessDetailsStep';
import { BriefStep } from './steps/BriefStep';
import { ObjectivesStep } from './steps/ObjectivesStep';
import { StyleStep } from './steps/StyleStep';
import { Card } from '../../components/common/Card';
const stepTitles = ['Business', 'Brief', 'Objectives', 'Style'];
export function WizardPage() {
const {
form,
currentStep,
setField,
updateStyle,
addObjective,
removeObjective,
nextStep,
previousStep,
setStep,
submitWizard,
isSubmitting,
error,
activeBlueprint,
refreshPages,
} = useBuilderStore();
const { structure } = useSiteDefinitionStore();
const stepComponents = useMemo(
() => [
<BusinessDetailsStep key="business" data={form} onChange={setField} />,
<BriefStep key="brief" data={form} onChange={setField} />,
<ObjectivesStep key="objectives" data={form} addObjective={addObjective} removeObjective={removeObjective} />,
<StyleStep key="style" style={form.style} onChange={updateStyle} />,
],
[form, setField, addObjective, removeObjective, updateStyle],
);
return (
<div className="wizard-page">
<Card
title="Site builder wizard"
description="Capture your strategy in four lightweight steps. When you hit “Generate structure” well call the Site Builder AI and hydrate the preview canvas."
>
<div className="wizard-progress">
{stepTitles.map((title, idx) => (
<button
key={title}
type="button"
className={`wizard-progress__dot ${idx === currentStep ? 'is-active' : ''}`}
onClick={() => setStep(idx)}
>
<span>{idx + 1}</span>
<small>{title}</small>
</button>
))}
</div>
<div className="wizard-step">{stepComponents[currentStep]}</div>
<div className="wizard-actions">
<button type="button" onClick={previousStep} disabled={currentStep === 0 || isSubmitting}>
Back
</button>
{currentStep < stepComponents.length - 1 ? (
<button type="button" className="primary" onClick={nextStep}>
Next
</button>
) : (
<button type="button" className="primary" onClick={submitWizard} disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="spin" size={18} />
Generating
</>
) : (
<>
<PlayCircle size={18} />
Generate structure
</>
)}
</button>
)}
</div>
{error && <p className="sb-error">{error}</p>}
</Card>
{activeBlueprint && (
<Card
title="Latest blueprint"
description="Refresh the preview to fetch the latest AI output."
footer={
<button type="button" className="ghost" onClick={() => refreshPages(activeBlueprint.id)} disabled={isSubmitting}>
<RefreshCw size={16} />
Sync pages
</button>
}
>
<div className="sb-blueprint-meta">
<div>
<strong>Status</strong>
<span className={`status-dot status-${activeBlueprint.status}`}>{activeBlueprint.status}</span>
</div>
<div>
<strong>Structure</strong>
<span>{structure?.pages?.length ?? 0} pages</span>
</div>
</div>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,28 @@
import type { BuilderFormData } from '../../../types/siteBuilder';
import { Card } from '../../../components/common/Card';
interface Props {
data: BuilderFormData;
onChange: <K extends keyof BuilderFormData>(key: K, value: BuilderFormData[K]) => void;
}
export function BriefStep({ data, onChange }: Props) {
return (
<Card
title="Business brief"
description="Describe the brand, what it sells, and what makes it unique. The more context we have, the more accurate the structure."
>
<label className="sb-field">
<span>Business brief</span>
<textarea
rows={8}
value={data.businessBrief}
placeholder="Acme Robotics builds autonomous fulfillment robots that reduce warehouse picking time by 60%..."
onChange={(event) => onChange('businessBrief', event.target.value)}
/>
</label>
</Card>
);
}

View File

@@ -0,0 +1,94 @@
import type { BuilderFormData } from '../../../types/siteBuilder';
import { Card } from '../../../components/common/Card';
interface Props {
data: BuilderFormData;
onChange: <K extends keyof BuilderFormData>(key: K, value: BuilderFormData[K]) => void;
}
export function BusinessDetailsStep({ data, onChange }: Props) {
return (
<Card
title="Business context"
description="These details help the AI understand what kind of site we are building."
>
<div className="sb-grid">
<label className="sb-field">
<span>Site ID</span>
<input
type="number"
value={data.siteId ?? ''}
placeholder="123"
onChange={(event) => onChange('siteId', Number(event.target.value) || null)}
/>
</label>
<label className="sb-field">
<span>Sector ID</span>
<input
type="number"
value={data.sectorId ?? ''}
placeholder="456"
onChange={(event) => onChange('sectorId', Number(event.target.value) || null)}
/>
</label>
</div>
<label className="sb-field">
<span>Site name</span>
<input
type="text"
value={data.siteName}
placeholder="Acme Robotics"
onChange={(event) => onChange('siteName', event.target.value)}
/>
</label>
<div className="sb-grid">
<label className="sb-field">
<span>Business type</span>
<input
type="text"
value={data.businessType}
placeholder="B2B SaaS platform"
onChange={(event) => onChange('businessType', event.target.value)}
/>
</label>
<label className="sb-field">
<span>Industry</span>
<input
type="text"
value={data.industry}
placeholder="Supply chain automation"
onChange={(event) => onChange('industry', event.target.value)}
/>
</label>
</div>
<label className="sb-field">
<span>Target audience</span>
<input
type="text"
value={data.targetAudience}
placeholder="Operations leaders at fast-scaling eCommerce brands"
onChange={(event) => onChange('targetAudience', event.target.value)}
/>
</label>
<label className="sb-field">
<span>Hosting preference</span>
<select
value={data.hostingType}
onChange={(event) => onChange('hostingType', event.target.value as BuilderFormData['hostingType'])}
>
<option value="igny8_sites">IGNY8 Sites</option>
<option value="wordpress">WordPress</option>
<option value="shopify">Shopify</option>
<option value="multi">Multiple destinations</option>
</select>
</label>
</Card>
);
}

View File

@@ -0,0 +1,52 @@
import { useState } from 'react';
import type { BuilderFormData } from '../../../types/siteBuilder';
import { Card } from '../../../components/common/Card';
interface Props {
data: BuilderFormData;
addObjective: (value: string) => void;
removeObjective: (index: number) => void;
}
export function ObjectivesStep({ data, addObjective, removeObjective }: Props) {
const [value, setValue] = useState('');
const handleAdd = () => {
const trimmed = value.trim();
if (!trimmed) return;
addObjective(trimmed);
setValue('');
};
return (
<Card
title="Success metrics & flows"
description="List the outcomes the site must accomplish. These become top-level navigation items and hero CTAs."
>
<div className="sb-pill-list">
{data.objectives.map((objective, idx) => (
<span className="sb-pill" key={`${objective}-${idx}`}>
{objective}
<button type="button" onClick={() => removeObjective(idx)} aria-label="Remove objective">
×
</button>
</span>
))}
</div>
<div className="sb-objective-input">
<input
type="text"
value={value}
placeholder="Offer product tour, capture demo requests, educate on ROI…"
onChange={(event) => setValue(event.target.value)}
/>
<button type="button" onClick={handleAdd}>
Add objective
</button>
</div>
</Card>
);
}

View File

@@ -0,0 +1,74 @@
import type { StylePreferences } from '../../../types/siteBuilder';
import { Card } from '../../../components/common/Card';
interface Props {
style: StylePreferences;
onChange: (partial: Partial<StylePreferences>) => void;
}
const palettes = [
'Minimal monochrome with bright accent',
'Rich jewel tones with high contrast',
'Soft gradients and glassmorphism',
'Playful pastel palette',
];
const typographyOptions = [
'Modern sans-serif for headings, serif body text',
'Editorial serif across the site',
'Geometric sans with tight tracking',
'Rounded fonts with friendly tone',
];
export function StyleStep({ style, onChange }: Props) {
return (
<Card
title="Look & Feel"
description="Capture the brand personality so the preview canvas can mirror the right tone."
>
<div className="sb-grid">
<label className="sb-field">
<span>Palette direction</span>
<select value={style.palette} onChange={(event) => onChange({ palette: event.target.value })}>
{palettes.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<label className="sb-field">
<span>Typography</span>
<select value={style.typography} onChange={(event) => onChange({ typography: event.target.value })}>
{typographyOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
</div>
<label className="sb-field">
<span>Brand personality</span>
<textarea
rows={3}
value={style.personality}
onChange={(event) => onChange({ personality: event.target.value })}
/>
</label>
<label className="sb-field">
<span>Hero imagery direction</span>
<textarea
rows={3}
value={style.heroImagery}
onChange={(event) => onChange({ heroImagery: event.target.value })}
/>
</label>
</Card>
);
}

View File

@@ -0,0 +1,156 @@
import { create } from 'zustand';
import { builderApi } from '../api/builder.api';
import type {
BuilderFormData,
PageBlueprint,
SiteBlueprint,
StylePreferences,
} from '../types/siteBuilder';
import { useSiteDefinitionStore } from './siteDefinitionStore';
const defaultStyle: StylePreferences = {
palette: 'Vibrant modern palette with rich accent color',
typography: 'Sans-serif display for headings, humanist body font',
personality: 'Confident, energetic, optimistic',
heroImagery: 'Real people interacting with the product/service',
};
const defaultForm: BuilderFormData = {
siteId: null,
sectorId: null,
siteName: '',
businessType: '',
industry: '',
targetAudience: '',
hostingType: 'igny8_sites',
businessBrief: '',
objectives: ['Launch a conversion-focused marketing site'],
style: defaultStyle,
};
interface BuilderState {
form: BuilderFormData;
currentStep: number;
isSubmitting: boolean;
error?: string;
activeBlueprint?: SiteBlueprint;
pages: PageBlueprint[];
setField: <K extends keyof BuilderFormData>(key: K, value: BuilderFormData[K]) => void;
updateStyle: (partial: Partial<StylePreferences>) => void;
addObjective: (value: string) => void;
removeObjective: (index: number) => void;
setStep: (step: number) => void;
nextStep: () => void;
previousStep: () => void;
reset: () => void;
submitWizard: () => Promise<void>;
refreshPages: (blueprintId: number) => Promise<void>;
}
export const useBuilderStore = create<BuilderState>((set, get) => ({
form: defaultForm,
currentStep: 0,
isSubmitting: false,
pages: [],
setField: (key, value) =>
set((state) => ({
form: { ...state.form, [key]: value },
})),
updateStyle: (partial) =>
set((state) => ({
form: { ...state.form, style: { ...state.form.style, ...partial } },
})),
addObjective: (value) =>
set((state) => ({
form: { ...state.form, objectives: [...state.form.objectives, value] },
})),
removeObjective: (index) =>
set((state) => ({
form: {
...state.form,
objectives: state.form.objectives.filter((_, idx) => idx !== index),
},
})),
setStep: (step) => set({ currentStep: step }),
nextStep: () =>
set((state) => ({
currentStep: Math.min(state.currentStep + 1, 3),
})),
previousStep: () =>
set((state) => ({
currentStep: Math.max(state.currentStep - 1, 0),
})),
reset: () =>
set({
form: defaultForm,
currentStep: 0,
isSubmitting: false,
error: undefined,
activeBlueprint: undefined,
pages: [],
}),
submitWizard: async () => {
const { form } = get();
if (!form.siteId || !form.sectorId) {
set({ error: 'Site and sector are required to generate a blueprint.' });
return;
}
set({ isSubmitting: true, error: undefined });
try {
const payload = {
name: form.siteName || `Site Blueprint (${form.industry || 'New'})`,
description: `${form.businessType} for ${form.targetAudience}`,
site_id: form.siteId,
sector_id: form.sectorId,
hosting_type: form.hostingType,
config_json: {
business_type: form.businessType,
industry: form.industry,
target_audience: form.targetAudience,
},
};
const blueprint = await builderApi.createBlueprint(payload);
set({ activeBlueprint: blueprint });
const generation = await builderApi.generateStructure(blueprint.id, {
business_brief: form.businessBrief,
objectives: form.objectives,
style: form.style,
metadata: { targetAudience: form.targetAudience },
});
if (generation?.structure) {
useSiteDefinitionStore.getState().setStructure(generation.structure);
}
await get().refreshPages(blueprint.id);
} catch (error) {
set({ error: error instanceof Error ? error.message : 'Unexpected error' });
} finally {
set({ isSubmitting: false });
}
},
refreshPages: async (blueprintId: number) => {
try {
const pages = await builderApi.listPages(blueprintId);
set({ pages });
useSiteDefinitionStore.getState().setPages(pages);
} catch (error) {
set({ error: error instanceof Error ? error.message : 'Unable to load pages' });
}
},
}));

View File

@@ -0,0 +1,28 @@
import { create } from 'zustand';
import type { PageBlueprint, SiteStructure } from '../types/siteBuilder';
interface SiteDefinitionState {
structure?: SiteStructure;
pages: PageBlueprint[];
selectedSlug?: string;
setStructure: (structure: SiteStructure) => void;
setPages: (pages: PageBlueprint[]) => void;
selectPage: (slug: string) => void;
}
export const useSiteDefinitionStore = create<SiteDefinitionState>((set) => ({
pages: [],
setStructure: (structure) =>
set({
structure,
selectedSlug: structure.pages?.[0]?.slug,
}),
setPages: (pages) =>
set((state) => ({
pages,
selectedSlug: state.selectedSlug ?? pages[0]?.slug,
})),
selectPage: (slug) => set({ selectedSlug: slug }),
}));

View File

@@ -0,0 +1,87 @@
export type HostingType = 'igny8_sites' | 'wordpress' | 'shopify' | 'multi';
export interface StylePreferences {
palette: string;
typography: string;
personality: string;
heroImagery: string;
}
export interface BuilderFormData {
siteId: number | null;
sectorId: number | null;
siteName: string;
businessType: string;
industry: string;
targetAudience: string;
hostingType: HostingType;
businessBrief: string;
objectives: string[];
style: StylePreferences;
}
export interface SiteBlueprint {
id: number;
name: string;
description?: string;
status: 'draft' | 'generating' | 'ready' | 'deployed';
hosting_type: HostingType;
config_json: Record<string, unknown>;
structure_json: SiteStructure | null;
created_at: string;
updated_at: string;
}
export interface PageBlueprint {
id: number;
site_blueprint: number;
slug: string;
title: string;
type: string;
status: string;
order: number;
blocks_json: PageBlock[];
}
export interface PageBlock {
type: string;
heading?: string;
subheading?: string;
layout?: string;
content?: string[] | Record<string, unknown>;
}
export interface SiteStructure {
site?: {
name?: string;
primary_navigation?: string[];
secondary_navigation?: string[];
hero_message?: string;
tone?: string;
};
pages: Array<{
slug: string;
title: string;
type: string;
status?: string;
objective?: string;
primary_cta?: string;
blocks?: PageBlock[];
}>;
}
export interface ApiListResponse<T> {
count?: number;
next?: string | null;
previous?: string | null;
results?: T[];
data?: T[] | T;
}
export interface ApiError {
message?: string;
error?: string;
detail?: string;
}

View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@shared/*": ["../frontend/src/components/shared/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,31 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const sharedPathCandidates = [
path.resolve(__dirname, '../frontend/src/components/shared'),
path.resolve(__dirname, '../../frontend/src/components/shared'),
'/frontend/src/components/shared',
];
const sharedComponentsPath = sharedPathCandidates.find((candidate) => fs.existsSync(candidate)) ?? sharedPathCandidates[0];
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@shared': sharedComponentsPath,
},
},
server: {
host: '0.0.0.0',
port: 5175,
allowedHosts: ['builder.igny8.com'],
fs: {
allow: [path.resolve(__dirname, '..'), sharedComponentsPath],
},
},
});