diff --git a/backend/celerybeat-schedule b/backend/celerybeat-schedule
index 20c33110..784ff7cd 100644
Binary files a/backend/celerybeat-schedule and b/backend/celerybeat-schedule differ
diff --git a/backend/igny8_core/api/throttles.py b/backend/igny8_core/api/throttles.py
index 0b7aaf1d..617403d2 100644
--- a/backend/igny8_core/api/throttles.py
+++ b/backend/igny8_core/api/throttles.py
@@ -28,11 +28,19 @@ class DebugScopedRateThrottle(ScopedRateThrottle):
- IGNY8_DEBUG_THROTTLE environment variable is True
- User belongs to aws-admin or other system accounts
- User is admin/developer role
+ - Public blueprint list request with site filter (for Sites Renderer)
"""
# Check if throttling should be bypassed
debug_bypass = getattr(settings, 'DEBUG', False)
env_bypass = getattr(settings, 'IGNY8_DEBUG_THROTTLE', False)
+ # Bypass for public blueprint list requests (Sites Renderer fallback)
+ public_blueprint_bypass = False
+ if hasattr(view, 'action') and view.action == 'list':
+ if hasattr(request, 'query_params') and request.query_params.get('site'):
+ if not request.user or not hasattr(request.user, 'is_authenticated') or not request.user.is_authenticated:
+ public_blueprint_bypass = True
+
# Bypass for system account users (aws-admin, default-account, etc.)
system_account_bypass = False
if hasattr(request, 'user') and request.user and hasattr(request.user, 'is_authenticated') and request.user.is_authenticated:
@@ -47,7 +55,7 @@ class DebugScopedRateThrottle(ScopedRateThrottle):
# If checking fails, continue with normal throttling
pass
- if debug_bypass or env_bypass or system_account_bypass:
+ if debug_bypass or env_bypass or system_account_bypass or public_blueprint_bypass:
# In debug mode or for system accounts, still set throttle headers but don't actually throttle
# This allows testing throttle headers without blocking requests
if hasattr(self, 'get_rate'):
diff --git a/backend/igny8_core/auth/views.py b/backend/igny8_core/auth/views.py
index 089972ba..685c6930 100644
--- a/backend/igny8_core/auth/views.py
+++ b/backend/igny8_core/auth/views.py
@@ -478,16 +478,26 @@ class SiteViewSet(AccountModelViewSet):
def get_permissions(self):
"""Allow normal users (viewer) to create sites, but require editor+ for other operations."""
+ # Allow public read access for list requests with slug filter (used by Sites Renderer)
+ if self.action == 'list' and self.request.query_params.get('slug'):
+ from rest_framework.permissions import AllowAny
+ return [AllowAny()]
if self.action == 'create':
return [permissions.IsAuthenticated()]
return [IsEditorOrAbove()]
def get_queryset(self):
"""Return sites accessible to the current user."""
- user = self.request.user
- if not user or not user.is_authenticated:
+ # If this is a public request (no auth) with slug filter, return site by slug
+ if not self.request.user or not self.request.user.is_authenticated:
+ slug = self.request.query_params.get('slug')
+ if slug:
+ # Return queryset directly from model (bypassing base class account filtering)
+ return Site.objects.filter(slug=slug, is_active=True)
return Site.objects.none()
+ user = self.request.user
+
# ADMIN/DEV OVERRIDE: Both admins and developers can see all sites
if user.is_admin_or_developer():
return Site.objects.all().distinct()
diff --git a/backend/igny8_core/business/site_building/models.py b/backend/igny8_core/business/site_building/models.py
index b3a921b9..5fb9dd93 100644
--- a/backend/igny8_core/business/site_building/models.py
+++ b/backend/igny8_core/business/site_building/models.py
@@ -101,6 +101,7 @@ class PageBlueprint(SiteSectorBaseModel):
('draft', 'Draft'),
('generating', 'Generating'),
('ready', 'Ready'),
+ ('published', 'Published'),
]
site_blueprint = models.ForeignKey(
diff --git a/backend/igny8_core/modules/billing/views.py b/backend/igny8_core/modules/billing/views.py
index 68cc7692..376e9009 100644
--- a/backend/igny8_core/modules/billing/views.py
+++ b/backend/igny8_core/modules/billing/views.py
@@ -81,8 +81,10 @@ class CreditBalanceViewSet(viewsets.ViewSet):
'credits_remaining': credits_remaining,
}
- serializer = CreditBalanceSerializer(data)
- return success_response(data=serializer.data, request=request)
+ # Validate and serialize data
+ serializer = CreditBalanceSerializer(data=data)
+ serializer.is_valid(raise_exception=True)
+ return success_response(data=serializer.validated_data, request=request)
@extend_schema_view(
diff --git a/backend/igny8_core/modules/publisher/views.py b/backend/igny8_core/modules/publisher/views.py
index 83d86dc2..fd30fed5 100644
--- a/backend/igny8_core/modules/publisher/views.py
+++ b/backend/igny8_core/modules/publisher/views.py
@@ -219,61 +219,104 @@ class SiteDefinitionView(APIView):
GET /api/v1/publisher/sites/{site_id}/definition/
"""
- sites_data_path = os.getenv('SITES_DATA_PATH', '/data/app/sites-data')
-
- # Try to find latest deployed version
- site_dir = Path(sites_data_path) / 'clients' / str(site_id)
-
- if not site_dir.exists():
- return error_response(
- f'Site {site_id} not found',
- status.HTTP_404_NOT_FOUND,
- request
- )
-
- # Look for latest version (check for 'latest' symlink or highest version number)
- latest_path = site_dir / 'latest' / 'site.json'
- if latest_path.exists():
+ try:
+ sites_data_path = os.getenv('SITES_DATA_PATH', '/data/app/sites-data')
+
+ # Try to find latest deployed version
+ site_dir = Path(sites_data_path) / 'clients' / str(site_id)
+
+ if not site_dir.exists():
+ return error_response(
+ error=f'Site {site_id} not found at {site_dir}. Site may not be deployed yet.',
+ status_code=status.HTTP_404_NOT_FOUND,
+ request=request
+ )
+
+ # Look for latest version (check for 'latest' symlink or highest version number)
+ latest_path = site_dir / 'latest' / 'site.json'
+ if latest_path.exists():
+ try:
+ with open(latest_path, 'r', encoding='utf-8') as f:
+ definition = json.load(f)
+ return Response(definition, status=status.HTTP_200_OK)
+ except json.JSONDecodeError as e:
+ return error_response(
+ error=f'Invalid JSON in site definition file: {str(e)}',
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ request=request
+ )
+ except Exception as e:
+ return error_response(
+ error=f'Failed to load site definition from {latest_path}: {str(e)}',
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ request=request
+ )
+
+ # Fallback: find highest version number
try:
- with open(latest_path, 'r', encoding='utf-8') as f:
- definition = json.load(f)
- return Response(definition, status=status.HTTP_200_OK)
+ version_dirs = [d for d in site_dir.iterdir() if d.is_dir() and d.name.startswith('v')]
+ except PermissionError as e:
+ return error_response(
+ error=f'Permission denied accessing site directory: {str(e)}',
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ request=request
+ )
except Exception as e:
return error_response(
- f'Failed to load site definition: {str(e)}',
- status.HTTP_500_INTERNAL_SERVER_ERROR,
- request
+ error=f'Error reading site directory: {str(e)}',
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ request=request
+ )
+
+ if not version_dirs:
+ return error_response(
+ error=f'No deployed versions found for site {site_id}. Site may not be deployed yet.',
+ status_code=status.HTTP_404_NOT_FOUND,
+ request=request
+ )
+
+ # Sort by version number (extract number from 'v1', 'v2', etc.)
+ try:
+ version_dirs.sort(key=lambda d: int(d.name[1:]) if d.name[1:].isdigit() else 0, reverse=True)
+ latest_version_dir = version_dirs[0]
+ site_json_path = latest_version_dir / 'site.json'
+
+ if site_json_path.exists():
+ with open(site_json_path, 'r', encoding='utf-8') as f:
+ definition = json.load(f)
+ return Response(definition, status=status.HTTP_200_OK)
+ else:
+ return error_response(
+ error=f'Site definition file not found at {site_json_path}',
+ status_code=status.HTTP_404_NOT_FOUND,
+ request=request
+ )
+ except json.JSONDecodeError as e:
+ return error_response(
+ error=f'Invalid JSON in site definition file: {str(e)}',
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ request=request
+ )
+ except Exception as e:
+ return error_response(
+ error=f'Failed to load site definition: {str(e)}',
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ request=request
)
-
- # Fallback: find highest version number
- version_dirs = [d for d in site_dir.iterdir() if d.is_dir() and d.name.startswith('v')]
- if not version_dirs:
- return error_response(
- f'No deployed versions found for site {site_id}',
- status.HTTP_404_NOT_FOUND,
- request
- )
-
- # Sort by version number (extract number from 'v1', 'v2', etc.)
- try:
- version_dirs.sort(key=lambda d: int(d.name[1:]) if d.name[1:].isdigit() else 0, reverse=True)
- latest_version_dir = version_dirs[0]
- site_json_path = latest_version_dir / 'site.json'
- if site_json_path.exists():
- with open(site_json_path, 'r', encoding='utf-8') as f:
- definition = json.load(f)
- return Response(definition, status=status.HTTP_200_OK)
- except Exception as e:
return error_response(
- f'Failed to load site definition: {str(e)}',
- status.HTTP_500_INTERNAL_SERVER_ERROR,
- request
+ error=f'Site definition not found for site {site_id}',
+ status_code=status.HTTP_404_NOT_FOUND,
+ request=request
+ )
+ except Exception as e:
+ # Catch any unhandled exceptions
+ import logging
+ logger = logging.getLogger(__name__)
+ logger.error(f'Unhandled exception in SiteDefinitionView: {str(e)}', exc_info=True)
+ return error_response(
+ error=f'Internal server error: {str(e)}',
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ request=request
)
-
- return error_response(
- f'Site definition not found for site {site_id}',
- status.HTTP_404_NOT_FOUND,
- request
- )
diff --git a/backend/igny8_core/modules/site_builder/views.py b/backend/igny8_core/modules/site_builder/views.py
index 29fafda2..6d897222 100644
--- a/backend/igny8_core/modules/site_builder/views.py
+++ b/backend/igny8_core/modules/site_builder/views.py
@@ -39,6 +39,47 @@ class SiteBlueprintViewSet(SiteSectorModelViewSet):
permission_classes = [IsAuthenticatedAndActive, IsEditorOrAbove]
throttle_scope = 'site_builder'
throttle_classes = [DebugScopedRateThrottle]
+
+ def get_permissions(self):
+ """
+ Allow public read access for list requests with site filter (used by Sites Renderer fallback).
+ This allows the Sites Renderer to load blueprint data for deployed sites without authentication.
+ """
+ # Allow public access for list requests with site filter (used by Sites Renderer)
+ if self.action == 'list' and self.request.query_params.get('site'):
+ from rest_framework.permissions import AllowAny
+ return [AllowAny()]
+ # Otherwise use default permissions
+ return super().get_permissions()
+
+ def get_throttles(self):
+ """
+ Bypass throttling for public list requests with site filter (used by Sites Renderer).
+ """
+ # Bypass throttling for public requests (no auth) with site filter
+ if self.action == 'list' and self.request.query_params.get('site'):
+ if not self.request.user or not self.request.user.is_authenticated:
+ return [] # No throttling for public blueprint access
+ return super().get_throttles()
+
+ def get_queryset(self):
+ """
+ Override to allow public access when filtering by site_id.
+ """
+ # If this is a public request (no auth) with site filter, bypass base class filtering
+ # and return deployed blueprints for that site
+ if not self.request.user or not self.request.user.is_authenticated:
+ site_id = self.request.query_params.get('site')
+ if site_id:
+ # Return queryset directly from model (bypassing base class account/site filtering)
+ from igny8_core.business.site_building.models import SiteBlueprint
+ return SiteBlueprint.objects.filter(
+ site_id=site_id,
+ status='deployed'
+ ).prefetch_related('pages').order_by('-version')
+
+ # For authenticated users, use base class filtering
+ return super().get_queryset()
def perform_create(self, serializer):
from igny8_core.auth.models import Site, Sector
diff --git a/frontend/src/components/shared/blocks/blocks.css b/frontend/src/components/shared/blocks/blocks.css
index 661ad9a8..8d3e94d0 100644
--- a/frontend/src/components/shared/blocks/blocks.css
+++ b/frontend/src/components/shared/blocks/blocks.css
@@ -6,6 +6,8 @@
display: flex;
flex-direction: column;
gap: 1rem;
+ align-items: center;
+ text-align: center;
}
.shared-hero__eyebrow {
@@ -36,7 +38,7 @@
font-weight: 600;
color: #0f172a;
background: #fff;
- align-self: flex-start;
+ align-self: center;
cursor: pointer;
}
diff --git a/frontend/src/components/shared/layouts/layouts.css b/frontend/src/components/shared/layouts/layouts.css
index 2ee5b190..d74a0805 100644
--- a/frontend/src/components/shared/layouts/layouts.css
+++ b/frontend/src/components/shared/layouts/layouts.css
@@ -2,16 +2,19 @@
display: flex;
flex-direction: column;
gap: 1.5rem;
+ width: 100%;
}
.shared-layout__hero {
min-height: 320px;
+ width: 100%;
}
.shared-layout__body {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1.25rem;
+ width: 100%;
}
@media (min-width: 1024px) {
@@ -22,6 +25,18 @@
.shared-layout__section {
margin-bottom: 1.25rem;
+ text-align: center;
+}
+
+.shared-layout__section > * {
+ text-align: left;
+ max-width: 100%;
+}
+
+.shared-layout__section h2,
+.shared-layout__section h3,
+.shared-layout__section h4 {
+ text-align: center;
}
.shared-layout__sidebar {
diff --git a/sites/src/App.tsx b/sites/src/App.tsx
index 5c948155..96120126 100644
--- a/sites/src/App.tsx
+++ b/sites/src/App.tsx
@@ -24,7 +24,8 @@ function App() {
}>
{/* Public Site Renderer Routes (No Auth) */}
- } />
+ } />
+ } />
IGNY8 Sites Renderer} />
{/* Builder Routes (Auth Required) */}
diff --git a/sites/src/loaders/loadSiteDefinition.ts b/sites/src/loaders/loadSiteDefinition.ts
index b5b51f1f..c4940ad5 100644
--- a/sites/src/loaders/loadSiteDefinition.ts
+++ b/sites/src/loaders/loadSiteDefinition.ts
@@ -41,11 +41,53 @@ function getApiBaseUrl(): string {
const API_URL = getApiBaseUrl();
/**
- * Load site definition by site ID.
- * First tries to load from filesystem (deployed sites),
+ * Resolve site slug to site ID.
+ * Queries the Site API to get the site ID from the slug.
+ */
+async function resolveSiteIdFromSlug(siteSlug: string): Promise {
+ try {
+ // Query sites by slug - slug is unique per account, but we need to search across all accounts for public sites
+ const response = await axios.get(`${API_URL}/v1/auth/sites/`, {
+ params: { slug: siteSlug },
+ timeout: 10000,
+ headers: {
+ 'Accept': 'application/json',
+ },
+ });
+
+ const sites = Array.isArray(response.data?.results) ? response.data.results :
+ Array.isArray(response.data) ? response.data : [];
+
+ if (sites.length > 0) {
+ return sites[0].id;
+ }
+
+ throw new Error(`Site with slug "${siteSlug}" not found`);
+ } catch (error) {
+ if (axios.isAxiosError(error)) {
+ if (error.response?.status === 404) {
+ throw new Error(`Site with slug "${siteSlug}" not found`);
+ }
+ throw new Error(`Failed to resolve site slug: ${error.message}`);
+ }
+ throw error;
+ }
+}
+
+/**
+ * Load site definition by site slug.
+ * First resolves slug to ID, then tries to load from filesystem (deployed sites),
* then falls back to API.
*/
-export async function loadSiteDefinition(siteId: string): Promise {
+export async function loadSiteDefinition(siteSlug: string): Promise {
+ // First, resolve slug to site ID
+ let siteId: number;
+ try {
+ siteId = await resolveSiteIdFromSlug(siteSlug);
+ } catch (error) {
+ throw error; // Re-throw slug resolution errors
+ }
+
// Try API endpoint for deployed site definition first
try {
const response = await axios.get(`${API_URL}/v1/publisher/sites/${siteId}/definition/`, {
@@ -82,7 +124,7 @@ export async function loadSiteDefinition(siteId: string): Promise();
+ const { siteSlug, pageSlug } = useParams<{ siteSlug: string; pageSlug?: string }>();
const [siteDefinition, setSiteDefinition] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
- if (!siteId) {
- setError('Site ID is required');
+ if (!siteSlug) {
+ setError('Site slug is required');
setLoading(false);
return;
}
- loadSiteDefinition(siteId)
+ loadSiteDefinition(siteSlug)
.then((definition) => {
setSiteDefinition(definition);
setLoading(false);
@@ -26,7 +26,7 @@ function SiteRenderer() {
setError(err.message || 'Failed to load site');
setLoading(false);
});
- }, [siteId]);
+ }, [siteSlug, pageSlug]);
if (loading) {
return
Loading site...
;
@@ -40,9 +40,122 @@ function SiteRenderer() {
return
Site not found
;
}
+ // Build navigation from site definition
+ // Show pages that are published, ready, or in navigation (excluding home and draft/generating)
+ const navigation = siteDefinition.navigation || siteDefinition.pages
+ .filter(p =>
+ p.slug !== 'home' &&
+ (p.status === 'published' || p.status === 'ready' || siteDefinition.navigation?.some(n => n.slug === p.slug))
+ )
+ .sort((a, b) => {
+ // Try to get order from navigation or use page order
+ const navA = siteDefinition.navigation?.find(n => n.slug === a.slug);
+ const navB = siteDefinition.navigation?.find(n => n.slug === b.slug);
+ return (navA?.order ?? a.order ?? 0) - (navB?.order ?? b.order ?? 0);
+ })
+ .map(page => ({
+ label: page.title,
+ slug: page.slug,
+ order: page.order || 0
+ }));
+
+ // Filter pages based on current route
+ const currentPageSlug = pageSlug || 'home';
+ const currentPage = siteDefinition.pages.find(p => p.slug === currentPageSlug);
+
+ // If specific page requested, show only that page; otherwise show all published/ready pages
+ const pagesToRender = currentPageSlug && currentPageSlug !== 'home' && currentPage
+ ? [currentPage]
+ : siteDefinition.pages.filter(p =>
+ p.status === 'published' ||
+ p.status === 'ready' ||
+ (p.slug === 'home' && p.status !== 'draft' && p.status !== 'generating')
+ );
+
return (
-