refactor-upto-phase 6

This commit is contained in:
alorig
2025-11-20 21:29:14 +05:00
parent 8b798ed191
commit b0409d965b
14 changed files with 478 additions and 314 deletions

View File

@@ -4,6 +4,7 @@ Extracts account from JWT token and injects into request context
"""
from django.utils.deprecation import MiddlewareMixin
from django.http import JsonResponse
from django.contrib.auth import logout
from rest_framework import status
try:
@@ -41,14 +42,19 @@ class AccountContextMiddleware(MiddlewareMixin):
request.user = user
# Get account from refreshed user
user_account = getattr(user, 'account', None)
if user_account:
request.account = user_account
return None
validation_error = self._validate_account_and_plan(request, user)
if validation_error:
return validation_error
request.account = getattr(user, 'account', None)
return None
except (AttributeError, UserModel.DoesNotExist, Exception):
# If refresh fails, fallback to cached account
try:
user_account = getattr(request.user, 'account', None)
if user_account:
validation_error = self._validate_account_and_plan(request, request.user)
if validation_error:
return validation_error
request.account = user_account
return None
except (AttributeError, Exception):
@@ -96,6 +102,9 @@ class AccountContextMiddleware(MiddlewareMixin):
# Get user from DB (but don't set request.user - let DRF authentication handle that)
# Only set request.account for account context
user = User.objects.select_related('account', 'account__plan').get(id=user_id)
validation_error = self._validate_account_and_plan(request, user)
if validation_error:
return validation_error
if account_id:
# Verify account still exists
try:
@@ -120,3 +129,46 @@ class AccountContextMiddleware(MiddlewareMixin):
return None
def _validate_account_and_plan(self, request, user):
"""
Ensure the authenticated user has an account and an active plan.
If not, logout the user (for session auth) and block the request.
"""
try:
account = getattr(user, 'account', None)
except Exception:
account = None
if not account:
return self._deny_request(
request,
error='Account not configured for this user. Please contact support.',
status_code=status.HTTP_403_FORBIDDEN,
)
plan = getattr(account, 'plan', None)
if plan is None or getattr(plan, 'is_active', False) is False:
return self._deny_request(
request,
error='Active subscription required. Visit igny8.com/pricing to subscribe.',
status_code=status.HTTP_402_PAYMENT_REQUIRED,
)
return None
def _deny_request(self, request, error, status_code):
"""Logout session users (if any) and return a consistent JSON error."""
try:
if hasattr(request, 'user') and request.user and request.user.is_authenticated:
logout(request)
except Exception:
pass
return JsonResponse(
{
'success': False,
'error': error,
},
status=status_code,
)

View File

@@ -926,13 +926,28 @@ class AuthViewSet(viewsets.GenericViewSet):
)
if user.check_password(password):
# Ensure user has an account
account = getattr(user, 'account', None)
if account is None:
return error_response(
error='Account not configured for this user. Please contact support.',
status_code=status.HTTP_403_FORBIDDEN,
request=request,
)
# Ensure account has an active plan
plan = getattr(account, 'plan', None)
if plan is None or getattr(plan, 'is_active', False) is False:
return error_response(
error='Active subscription required. Visit igny8.com/pricing to subscribe.',
status_code=status.HTTP_402_PAYMENT_REQUIRED,
request=request,
)
# Log the user in (create session for session authentication)
from django.contrib.auth import login
login(request, user)
# Get account from user
account = getattr(user, 'account', None)
# Generate JWT tokens
access_token = generate_access_token(user, account)
refresh_token = generate_refresh_token(user, account)

View File

@@ -1,4 +1,4 @@
import { Suspense, lazy } from "react";
import { Suspense, lazy, useEffect } from "react";
import { BrowserRouter as Router, Routes, Route, Navigate } from "react-router";
import { HelmetProvider } from "react-helmet-async";
import AppLayout from "./layout/AppLayout";
@@ -7,6 +7,7 @@ import ProtectedRoute from "./components/auth/ProtectedRoute";
import ModuleGuard from "./components/common/ModuleGuard";
import GlobalErrorDisplay from "./components/common/GlobalErrorDisplay";
import LoadingStateMonitor from "./components/common/LoadingStateMonitor";
import { useAuthStore } from "./store/authStore";
// Auth pages - loaded immediately (needed for login)
import SignIn from "./pages/AuthPages/SignIn";
@@ -137,6 +138,23 @@ const Tooltips = lazy(() => import("./pages/Settings/UiElements/Tooltips"));
const Videos = lazy(() => import("./pages/Settings/UiElements/Videos"));
export default function App() {
const { isAuthenticated, refreshUser, logout } = useAuthStore((state) => ({
isAuthenticated: state.isAuthenticated,
refreshUser: state.refreshUser,
logout: state.logout,
}));
useEffect(() => {
if (!isAuthenticated) {
return;
}
refreshUser().catch((error) => {
console.warn('Session validation failed:', error);
logout();
});
}, [isAuthenticated, refreshUser, logout]);
return (
<>
<GlobalErrorDisplay />

View File

@@ -4,6 +4,8 @@ import { useAuthStore } from "../../store/authStore";
import { useErrorHandler } from "../../hooks/useErrorHandler";
import { trackLoading } from "../common/LoadingStateMonitor";
const PRICING_URL = "https://igny8.com/pricing";
interface ProtectedRouteProps {
children: ReactNode;
}
@@ -13,7 +15,7 @@ interface ProtectedRouteProps {
* Redirects to /signin if user is not authenticated
*/
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated, loading } = useAuthStore();
const { isAuthenticated, loading, user, logout } = useAuthStore();
const location = useLocation();
const { addError } = useErrorHandler('ProtectedRoute');
const [showError, setShowError] = useState(false);
@@ -24,6 +26,24 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) {
trackLoading('auth-loading', loading);
}, [loading]);
// Validate account + plan whenever auth/user changes
useEffect(() => {
if (!isAuthenticated) {
return;
}
if (!user?.account) {
setErrorMessage('This user is not linked to an account. Please contact support.');
logout();
return;
}
if (!user.account.plan) {
logout();
window.location.href = PRICING_URL;
}
}, [isAuthenticated, user, logout]);
// Immediate check on mount: if loading is true, reset it immediately
useEffect(() => {
if (loading) {

View File

@@ -32,6 +32,10 @@ export default function SignInForm() {
const from = (location.state as any)?.from?.pathname || "/";
navigate(from, { replace: true });
} catch (err: any) {
if (err?.code === 'PLAN_REQUIRED') {
window.location.href = 'https://igny8.com/pricing';
return;
}
setError(err.message || "Login failed. Please check your credentials.");
}
};

View File

@@ -8,13 +8,14 @@ import {
import Button from "../../../components/ui/button/Button";
import PageMeta from "../../../components/common/PageMeta";
import SiteAndSectorSelector from "../../../components/common/SiteAndSectorSelector";
import PageHeader from "../../../components/common/PageHeader";
import Alert from "../../../components/ui/alert/Alert";
import {
Loader2,
PlayCircle,
RefreshCw,
Wand2,
} from "lucide-react";
GridIcon,
ArrowLeftIcon,
ArrowRightIcon,
BoltIcon,
} from "../../../icons";
import { useSiteStore } from "../../../store/siteStore";
import { useSectorStore } from "../../../store/sectorStore";
import { useBuilderStore } from "../../../store/builderStore";
@@ -289,29 +290,41 @@ export default function SiteBuilderWizard() {
}
};
const renderPrimaryIcon = () => {
if (isSubmitting) {
return (
<span className="inline-flex h-4 w-4 items-center justify-center">
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white/70 border-t-transparent" />
</span>
);
}
if (isLastStep) {
return <ArrowRightIcon className="size-4" />;
}
return undefined;
};
return (
<div className="space-y-6 p-6">
<PageMeta title="Create Site - IGNY8" />
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs uppercase tracking-wider text-gray-500 dark:text-white/50">
Sites / Create Site
</p>
<h1 className="text-3xl font-semibold text-gray-900 dark:text-white">
Site Builder
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
Create a new site using IGNY8s AI-powered wizard. Align the estate,
strategy, and tone before publishing.
<PageHeader
title="Site Builder"
badge={{ icon: <GridIcon className="text-white size-5" />, color: "purple" }}
hideSiteSector
/>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<p className="text-sm text-gray-600 dark:text-gray-400 max-w-2xl">
Use the AI-powered wizard to capture business context, brand direction, and tone before generating blueprints.
</p>
<Button
variant="outline"
onClick={() => navigate("/sites")}
startIcon={<ArrowLeftIcon className="size-4" />}
>
Back to Sites
</Button>
</div>
<Button
variant="outline"
onClick={() => navigate("/sites")}
startIcon={<Wand2 size={16} />}
>
Back to sites
</Button>
</div>
<SiteAndSectorSelector hideSectorSelector />
@@ -390,13 +403,7 @@ export default function SiteBuilderWizard() {
tone="brand"
disabled={missingContext || isSubmitting}
onClick={handlePrimary}
startIcon={
isSubmitting ? (
<Loader2 className="animate-spin" size={16} />
) : isLastStep ? (
<PlayCircle size={16} />
) : undefined
}
startIcon={renderPrimaryIcon()}
>
{isLastStep ? "Generate structure" : "Next"}
</Button>
@@ -421,28 +428,29 @@ export default function SiteBuilderWizard() {
<span>{pages.length}</span>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<Button
variant="outline"
tone="brand"
fullWidth
startIcon={<RefreshCw size={16} />}
onClick={() => refreshPages(activeBlueprint.id)}
>
Sync pages
</Button>
<Button
variant="soft"
tone="brand"
fullWidth
disabled={isGenerating}
onClick={() =>
loadBlueprint(activeBlueprint.id).then(() =>
navigate("/sites/builder/preview"),
)
}
>
Open preview
</Button>
<Button
variant="outline"
tone="brand"
fullWidth
startIcon={<BoltIcon className="size-4" />}
onClick={() => refreshPages(activeBlueprint.id)}
>
Sync pages
</Button>
<Button
variant="soft"
tone="brand"
fullWidth
disabled={isGenerating}
endIcon={<ArrowRightIcon className="size-4" />}
onClick={() =>
loadBlueprint(activeBlueprint.id).then(() =>
navigate("/sites/builder/preview"),
)
}
>
Open preview
</Button>
</div>
</div>
) : (

View File

@@ -10,10 +10,10 @@ import { useState, useEffect } from 'react';
import { useBuilderWorkflowStore } from '../../../../store/builderWorkflowStore';
import { fetchSiteBlueprintById, updateSiteBlueprint, SiteBlueprint } from '../../../../services/api';
import { Card, CardDescription, CardTitle } from '../../../../components/ui/card';
import ButtonWithTooltip from '../../../../components/ui/button/ButtonWithTooltip';
import Button from '../../../../components/ui/button/Button';
import Input from '../../../../components/form/input/InputField';
import Alert from '../../../../components/ui/alert/Alert';
import { Loader2 } from 'lucide-react';
import { BoltIcon, GridIcon } from '../../../../icons';
import type { BuilderFormData, SiteBuilderMetadata } from '../../../../types/siteBuilder';
// Stage 1 Wizard props
@@ -115,11 +115,13 @@ function BusinessDetailsStepStage2({ blueprintId }: { blueprintId: number }) {
const canProceed = formData.name.trim().length > 0;
return (
<Card className="p-6">
<CardTitle>Business Details</CardTitle>
<CardDescription>
Tell us about your business and site type to get started.
</CardDescription>
<Card variant="surface" padding="lg" className="space-y-6">
<div>
<CardTitle>Business details</CardTitle>
<CardDescription>
Tell us about your business and hosting preference to keep blueprints organized.
</CardDescription>
</div>
{error && (
<Alert variant="error" className="mt-4">
@@ -128,62 +130,58 @@ function BusinessDetailsStepStage2({ blueprintId }: { blueprintId: number }) {
)}
<div className="mt-6 space-y-4">
<div>
<label className="block text-sm font-medium mb-2">Site Name *</label>
<Input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="My Awesome Site"
required
/>
<div className="grid gap-6 md:grid-cols-2">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 block">
Site name *
</label>
<Input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Acme Robotics"
required
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 block">
Hosting type
</label>
<select
value={formData.hosting_type}
onChange={(e) => setFormData({ ...formData, hosting_type: e.target.value as any })}
className="w-full rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900 shadow-theme-xs dark:border-white/10 dark:bg-white/[0.03] dark:text-white"
>
<option value="igny8_sites">IGNY8 Sites</option>
<option value="wordpress">WordPress</option>
<option value="shopify">Shopify</option>
<option value="multi">Multiple destinations</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2">Description</label>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 block">
Business description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-3 py-2 border rounded-md"
className="w-full rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900 shadow-theme-xs dark:border-white/10 dark:bg-white/[0.03] dark:text-white"
rows={3}
placeholder="Brief description of your site..."
placeholder="Brief description of your business and what the site should cover"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Hosting Type</label>
<select
value={formData.hosting_type}
onChange={(e) => setFormData({ ...formData, hosting_type: e.target.value as any })}
className="w-full px-3 py-2 border rounded-md"
>
<option value="igny8_sites">IGNY8 Sites</option>
<option value="wordpress">WordPress</option>
<option value="shopify">Shopify</option>
<option value="multi">Multiple Destinations</option>
</select>
</div>
</div>
<div className="mt-6 flex justify-end">
<ButtonWithTooltip
<Button
onClick={handleSave}
disabled={!canProceed || saving || loading}
variant="primary"
tooltip={
!canProceed ? 'Please provide a site name to continue' :
saving ? 'Saving...' :
loading ? 'Loading...' : undefined
}
startIcon={<GridIcon className="size-4" />}
>
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
'Save & Continue'
)}
</ButtonWithTooltip>
{saving ? 'Saving…' : 'Save & continue'}
</Button>
</div>
{!canProceed && (
@@ -208,69 +206,88 @@ function BusinessDetailsStepStage1({
selectedSectors?: Array<{ id: number; name: string }>;
}) {
return (
<Card variant="surface" padding="lg">
<div className="space-y-4">
<div>
<p className="text-xs uppercase tracking-wider text-gray-500 dark:text-white/50">
Business context
</p>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Business details
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
These details help the AI understand what kind of site we are building.
</p>
<Card variant="surface" padding="lg" className="space-y-6">
<div>
<div className="inline-flex items-center gap-2 rounded-xl bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-dark)] px-3 py-1 text-xs font-semibold uppercase tracking-wide text-white">
<BoltIcon className="size-3.5" />
Business context
</div>
<h3 className="mt-3 text-xl font-semibold text-gray-900 dark:text-white">
Business details
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-2xl">
These inputs help the AI understand what were building. You can refine them later in the builder or site settings.
</p>
</div>
<div>
<label className="block text-sm font-medium mb-2">Site name</label>
<div className="grid gap-6 lg:grid-cols-2">
<div className="space-y-4 rounded-2xl border border-gray-100 bg-white p-4 shadow-theme-sm dark:border-white/5 dark:bg-white/[0.02]">
<label className="text-sm font-semibold text-gray-900 dark:text-white">
Site name
</label>
<Input
value={data.siteName}
onChange={(e) => onChange('siteName', e.target.value)}
placeholder="Acme Robotics"
/>
<p className="text-xs text-gray-500 dark:text-gray-400">
Appears in dashboards, blueprints, and deployment metadata.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">Business type</label>
<Input
value={data.businessType}
onChange={(e) => onChange('businessType', e.target.value)}
placeholder="B2B SaaS platform"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Industry</label>
<Input
value={data.industry}
onChange={(e) => onChange('industry', e.target.value)}
placeholder="Supply chain automation"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2">Target audience</label>
<div className="space-y-4 rounded-2xl border border-gray-100 bg-white p-4 shadow-theme-sm dark:border-white/5 dark:bg-white/[0.02]">
<label className="text-sm font-semibold text-gray-900 dark:text-white">
Target audience
</label>
<Input
value={data.targetAudience}
onChange={(e) => onChange('targetAudience', e.target.value)}
placeholder="Operations leaders at fast-scaling eCommerce brands"
/>
<p className="text-xs text-gray-500 dark:text-gray-400">
Helps the AI craft messaging, examples, and tone.
</p>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2">Hosting preference</label>
<div className="grid gap-6 lg:grid-cols-3">
<div className="space-y-3 rounded-2xl border border-gray-100 bg-white p-4 shadow-theme-sm dark:border-white/5 dark:bg-white/[0.02]">
<label className="text-sm font-semibold text-gray-900 dark:text-white">
Business type
</label>
<Input
value={data.businessType}
onChange={(e) => onChange('businessType', e.target.value)}
placeholder="B2B SaaS platform"
/>
</div>
<div className="space-y-3 rounded-2xl border border-gray-100 bg-white p-4 shadow-theme-sm dark:border-white/5 dark:bg-white/[0.02]">
<label className="text-sm font-semibold text-gray-900 dark:text-white">
Industry
</label>
<Input
value={data.industry}
onChange={(e) => onChange('industry', e.target.value)}
placeholder="Supply chain automation"
/>
</div>
<div className="space-y-3 rounded-2xl border border-gray-100 bg-white p-4 shadow-theme-sm dark:border-white/5 dark:bg-white/[0.02]">
<label className="text-sm font-semibold text-gray-900 dark:text-white">
Hosting preference
</label>
<select
value={data.hostingType}
onChange={(e) => onChange('hostingType', e.target.value as BuilderFormData['hostingType'])}
className="w-full px-3 py-2 border rounded-md"
className="w-full rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900 shadow-theme-xs dark:border-white/10 dark:bg-white/[0.03] dark:text-white"
>
<option value="igny8_sites">IGNY8 Sites</option>
<option value="wordpress">WordPress</option>
<option value="shopify">Shopify</option>
<option value="multi">Multiple destinations</option>
</select>
<p className="text-xs text-gray-500 dark:text-gray-400">
Determines deployment targets and integration requirements.
</p>
</div>
</div>
</Card>

View File

@@ -5,12 +5,21 @@
*/
import React, { useState, useEffect, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { SearchIcon, FilterIcon, EditIcon, EyeIcon, TrashIcon, PlusIcon } from 'lucide-react';
import PageMeta from '../../components/common/PageMeta';
import PageHeader from '../../components/common/PageHeader';
import { Card } from '../../components/ui/card';
import Button from '../../components/ui/button/Button';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { fetchAPI } from '../../services/api';
import {
SearchIcon,
PencilIcon,
EyeIcon,
TrashBinIcon,
PlusIcon,
FileIcon,
GridIcon
} from '../../icons';
interface ContentItem {
id: number;
@@ -118,15 +127,12 @@ export default function SiteContentManager() {
<div className="p-6">
<PageMeta title="Site Content Manager - IGNY8" />
<div className="mb-6 flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Content Manager
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Manage and organize your site content ({totalCount} items)
</p>
</div>
<PageHeader
title={`Content Manager (${totalCount} items)`}
badge={{ icon: <FileIcon />, color: 'blue' }}
hideSiteSector
/>
<div className="mb-6 flex justify-end">
<Button onClick={() => navigate(`/sites/${siteId}/posts/new`)} variant="primary">
<PlusIcon className="w-4 h-4 mr-2" />
New Post
@@ -146,7 +152,7 @@ export default function SiteContentManager() {
setSearchTerm(e.target.value);
setCurrentPage(1);
}}
className="w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md dark:bg-gray-800 dark:text-white"
className="w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-gray-700 rounded-lg dark:bg-gray-800 dark:text-white"
/>
</div>
@@ -261,7 +267,7 @@ export default function SiteContentManager() {
onClick={() => navigate(`/sites/${siteId}/posts/${item.id}/edit`)}
title="Edit"
>
<EditIcon className="w-4 h-4" />
<PencilIcon className="w-4 h-4" />
</Button>
<Button
variant="ghost"
@@ -269,7 +275,7 @@ export default function SiteContentManager() {
onClick={() => handleDelete(item.id)}
title="Delete"
>
<TrashIcon className="w-4 h-4" />
<TrashBinIcon className="w-4 h-4" />
</Button>
</div>
</div>

View File

@@ -6,24 +6,23 @@
*/
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
CheckCircleIcon,
XCircleIcon,
AlertCircleIcon,
RocketIcon,
RotateCcwIcon,
RefreshCwIcon,
FileTextIcon,
TagIcon,
LinkIcon,
CheckSquareIcon,
XSquareIcon,
} from 'lucide-react';
import PageMeta from '../../components/common/PageMeta';
import PageHeader from '../../components/common/PageHeader';
import { Card } from '../../components/ui/card';
import Button from '../../components/ui/button/Button';
import Badge from '../../components/ui/badge/Badge';
import { useToast } from '../../components/ui/toast/ToastContainer';
import {
CheckCircleIcon,
ErrorIcon,
AlertIcon,
BoltIcon,
ArrowRightIcon,
FileIcon,
BoxIcon,
CheckLineIcon,
GridIcon
} from '../../icons';
import {
fetchDeploymentReadiness,
fetchSiteBlueprints,
@@ -111,7 +110,7 @@ export default function DeploymentPanel() {
return passed ? (
<CheckCircleIcon className="w-5 h-5 text-green-600 dark:text-green-400" />
) : (
<XCircleIcon className="w-5 h-5 text-red-600 dark:text-red-400" />
<ErrorIcon className="w-5 h-5 text-red-600 dark:text-red-400" />
);
};
@@ -140,7 +139,7 @@ export default function DeploymentPanel() {
<PageMeta title="Deployment Panel" />
<Card className="p-6">
<div className="text-center py-8">
<AlertCircleIcon className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<AlertIcon className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<p className="text-gray-600 dark:text-gray-400 mb-2">No blueprints found for this site</p>
<Button
variant="primary"
@@ -160,38 +159,34 @@ export default function DeploymentPanel() {
<div className="p-6">
<PageMeta title="Deployment Panel" />
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Deployment Panel</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Check readiness and deploy your site
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => navigate(`/sites/${siteId}`)}
>
Back to Dashboard
</Button>
<Button
variant="outline"
onClick={handleRollback}
disabled={!selectedBlueprintId}
>
<RotateCcwIcon className="w-4 h-4 mr-2" />
Rollback
</Button>
<Button
variant="primary"
onClick={handleDeploy}
disabled={deploying || !readiness?.ready || !selectedBlueprintId}
>
<RocketIcon className={`w-4 h-4 mr-2 ${deploying ? 'animate-pulse' : ''}`} />
{deploying ? 'Deploying...' : 'Deploy'}
</Button>
</div>
<PageHeader
title="Deployment Panel"
badge={{ icon: <BoltIcon />, color: 'orange' }}
hideSiteSector
/>
<div className="mb-6 flex justify-end gap-2">
<Button
variant="outline"
onClick={() => navigate(`/sites/${siteId}`)}
>
Back to Dashboard
</Button>
<Button
variant="outline"
onClick={handleRollback}
disabled={!selectedBlueprintId}
>
<ArrowRightIcon className="w-4 h-4 mr-2 rotate-180" />
Rollback
</Button>
<Button
variant="primary"
onClick={handleDeploy}
disabled={deploying || !readiness?.ready || !selectedBlueprintId}
>
<BoltIcon className={`w-4 h-4 mr-2 ${deploying ? 'animate-pulse' : ''}`} />
{deploying ? 'Deploying...' : 'Deploy'}
</Button>
</div>
{/* Blueprint Selector */}
@@ -286,7 +281,7 @@ export default function DeploymentPanel() {
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<LinkIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
<GridIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
<h3 className="font-medium text-gray-900 dark:text-white">
Cluster Coverage
</h3>
@@ -313,7 +308,7 @@ export default function DeploymentPanel() {
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<CheckSquareIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
<CheckLineIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
<h3 className="font-medium text-gray-900 dark:text-white">
Content Validation
</h3>
@@ -340,7 +335,7 @@ export default function DeploymentPanel() {
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<TagIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
<BoxIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
<h3 className="font-medium text-gray-900 dark:text-white">
Taxonomy Completeness
</h3>
@@ -366,7 +361,7 @@ export default function DeploymentPanel() {
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<RefreshCwIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
<BoltIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
<h3 className="font-medium text-gray-900 dark:text-white">Sync Status</h3>
</div>
{getCheckBadge(readiness.checks.sync_status)}
@@ -395,7 +390,7 @@ export default function DeploymentPanel() {
variant="outline"
onClick={() => loadReadiness(selectedBlueprintId!)}
>
<RefreshCwIcon className="w-4 h-4 mr-2" />
<BoltIcon className="w-4 h-4 mr-2" />
Refresh Checks
</Button>
<Button
@@ -403,7 +398,7 @@ export default function DeploymentPanel() {
onClick={handleDeploy}
disabled={deploying || !readiness.ready}
>
<RocketIcon className={`w-4 h-4 mr-2 ${deploying ? 'animate-pulse' : ''}`} />
<BoltIcon className={`w-4 h-4 mr-2 ${deploying ? 'animate-pulse' : ''}`} />
{deploying ? 'Deploying...' : 'Deploy Now'}
</Button>
</div>

View File

@@ -7,12 +7,20 @@ import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { DndProvider, useDrag, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { PlusIcon, EditIcon, TrashIcon, GripVerticalIcon, CheckSquareIcon, SquareIcon } from 'lucide-react';
import PageMeta from '../../components/common/PageMeta';
import PageHeader from '../../components/common/PageHeader';
import { Card } from '../../components/ui/card';
import Button from '../../components/ui/button/Button';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { fetchAPI } from '../../services/api';
import {
PlusIcon,
PencilIcon,
TrashBinIcon,
HorizontaLDots,
CheckLineIcon,
PageIcon
} from '../../icons';
interface Page {
id: number;
@@ -71,12 +79,12 @@ const DraggablePageItem: React.FC<{
className="cursor-pointer"
>
{isSelected ? (
<CheckSquareIcon className="w-5 h-5 text-brand-600 dark:text-brand-400" />
<CheckLineIcon className="w-5 h-5 text-brand-600 dark:text-brand-400" />
) : (
<SquareIcon className="w-5 h-5 text-gray-400" />
<div className="w-5 h-5 border-2 border-gray-400 rounded" />
)}
</button>
<GripVerticalIcon className="w-5 h-5 text-gray-400 cursor-move" />
<HorizontaLDots className="w-5 h-5 text-gray-400 cursor-move" />
<div className="flex-1">
<h3 className="font-semibold text-gray-900 dark:text-white">{page.title}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
@@ -86,11 +94,11 @@ const DraggablePageItem: React.FC<{
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => onEdit(page.id)}>
<EditIcon className="w-4 h-4 mr-1" />
<PencilIcon className="w-4 h-4 mr-1" />
Edit
</Button>
<Button variant="ghost" size="sm" onClick={() => onDelete(page.id)}>
<TrashIcon className="w-4 h-4" />
<TrashBinIcon className="w-4 h-4" />
</Button>
</div>
</div>
@@ -272,15 +280,12 @@ export default function PageManager() {
<div className="p-6">
<PageMeta title="Page Manager - IGNY8" />
<div className="mb-6 flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Page Manager
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Manage pages for your site
</p>
</div>
<PageHeader
title="Page Manager"
badge={{ icon: <PageIcon />, color: 'blue' }}
hideSiteSector
/>
<div className="mb-6 flex justify-end">
<Button onClick={handleAddPage} variant="primary">
<PlusIcon className="w-4 h-4 mr-2" />
Add Page
@@ -326,7 +331,7 @@ export default function PageManager() {
onClick={handleBulkDelete}
className="text-red-600 hover:text-red-700"
>
<TrashIcon className="w-4 h-4 mr-1" />
<TrashBinIcon className="w-4 h-4 mr-1" />
Delete Selected
</Button>
<Button variant="ghost" size="sm" onClick={() => setSelectedPages(new Set())}>
@@ -367,9 +372,9 @@ export default function PageManager() {
className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white"
>
{selectedPages.size === pages.length ? (
<CheckSquareIcon className="w-5 h-5 text-brand-600 dark:text-brand-400" />
<CheckLineIcon className="w-5 h-5 text-brand-600 dark:text-brand-400" />
) : (
<SquareIcon className="w-5 h-5 text-gray-400" />
<div className="w-5 h-5 border-2 border-gray-400 rounded" />
)}
<span>Select All</span>
</button>

View File

@@ -5,8 +5,8 @@
*/
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { SettingsIcon, SearchIcon, Share2Icon, CodeIcon, PlugIcon } from 'lucide-react';
import PageMeta from '../../components/common/PageMeta';
import PageHeader from '../../components/common/PageHeader';
import { Card } from '../../components/ui/card';
import Button from '../../components/ui/button/Button';
import Label from '../../components/form/Label';
@@ -18,6 +18,7 @@ import { fetchAPI } from '../../services/api';
import WordPressIntegrationCard from '../../components/sites/WordPressIntegrationCard';
import WordPressIntegrationModal, { WordPressIntegrationFormData } from '../../components/sites/WordPressIntegrationModal';
import { integrationApi, SiteIntegration } from '../../services/integration.api';
import { GridIcon, PlugInIcon, PaperPlaneIcon, DocsIcon, BoltIcon } from '../../icons';
export default function SiteSettings() {
const { id: siteId } = useParams<{ id: string }>();
@@ -211,14 +212,11 @@ export default function SiteSettings() {
<div className="p-6">
<PageMeta title="Site Settings - IGNY8" />
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Site Settings
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Configure site type, hosting, and other settings
</p>
</div>
<PageHeader
title="Site Settings"
badge={{ icon: <GridIcon />, color: 'blue' }}
hideSiteSector
/>
{/* Tabs */}
<div className="mb-6 border-b border-gray-200 dark:border-gray-700">
@@ -235,7 +233,7 @@ export default function SiteSettings() {
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
}`}
>
<SettingsIcon className="w-4 h-4 inline mr-2" />
<GridIcon className="w-4 h-4 inline mr-2" />
General
</button>
<button
@@ -250,7 +248,7 @@ export default function SiteSettings() {
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
}`}
>
<SearchIcon className="w-4 h-4 inline mr-2" />
<DocsIcon className="w-4 h-4 inline mr-2" />
SEO Meta Tags
</button>
<button
@@ -265,7 +263,7 @@ export default function SiteSettings() {
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
}`}
>
<Share2Icon className="w-4 h-4 inline mr-2" />
<PaperPlaneIcon className="w-4 h-4 inline mr-2" />
Open Graph
</button>
<button
@@ -280,7 +278,7 @@ export default function SiteSettings() {
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
}`}
>
<CodeIcon className="w-4 h-4 inline mr-2" />
<BoltIcon className="w-4 h-4 inline mr-2" />
Schema.org
</button>
<button
@@ -295,7 +293,7 @@ export default function SiteSettings() {
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
}`}
>
<PlugIcon className="w-4 h-4 inline mr-2" />
<PlugInIcon className="w-4 h-4 inline mr-2" />
Integrations
</button>
</div>

View File

@@ -6,24 +6,25 @@
*/
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
CheckCircleIcon,
XCircleIcon,
AlertCircleIcon,
RefreshCwIcon,
ClockIcon,
FileTextIcon,
TagIcon,
PackageIcon,
ArrowRightIcon,
ChevronDownIcon,
ChevronUpIcon,
} from 'lucide-react';
import PageMeta from '../../components/common/PageMeta';
import PageHeader from '../../components/common/PageHeader';
import { Card } from '../../components/ui/card';
import Button from '../../components/ui/button/Button';
import Badge from '../../components/ui/badge/Badge';
import { useToast } from '../../components/ui/toast/ToastContainer';
import {
CheckCircleIcon,
ErrorIcon,
AlertIcon,
BoltIcon,
TimeIcon,
FileIcon,
BoxIcon,
ArrowRightIcon,
ChevronDownIcon,
ChevronUpIcon,
PlugInIcon
} from '../../icons';
import {
fetchSyncStatus,
runSync,
@@ -106,12 +107,12 @@ export default function SyncDashboard() {
case 'success':
return <CheckCircleIcon className="w-5 h-5 text-green-600 dark:text-green-400" />;
case 'warning':
return <AlertCircleIcon className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />;
return <AlertIcon className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />;
case 'error':
case 'failed':
return <XCircleIcon className="w-5 h-5 text-red-600 dark:text-red-400" />;
return <ErrorIcon className="w-5 h-5 text-red-600 dark:text-red-400" />;
default:
return <ClockIcon className="w-5 h-5 text-gray-400" />;
return <TimeIcon className="w-5 h-5 text-gray-400" />;
}
};
@@ -132,7 +133,7 @@ export default function SyncDashboard() {
<PageMeta title="Sync Dashboard" />
<Card className="p-6">
<div className="text-center py-8">
<AlertCircleIcon className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<AlertIcon className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<p className="text-gray-600 dark:text-gray-400">No sync data available</p>
</div>
</Card>
@@ -153,30 +154,26 @@ export default function SyncDashboard() {
<div className="p-6">
<PageMeta title="Sync Dashboard" />
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Sync Dashboard</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Monitor and manage WordPress sync status
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => navigate(`/sites/${siteId}`)}
>
Back to Dashboard
</Button>
<Button
variant="primary"
onClick={() => handleSync('both')}
disabled={syncing || !hasIntegrations}
>
<RefreshCwIcon className={`w-4 h-4 mr-2 ${syncing ? 'animate-spin' : ''}`} />
{syncing ? 'Syncing...' : 'Sync All'}
</Button>
</div>
<PageHeader
title="Sync Dashboard"
badge={{ icon: <PlugInIcon />, color: 'blue' }}
hideSiteSector
/>
<div className="mb-6 flex justify-end gap-2">
<Button
variant="outline"
onClick={() => navigate(`/sites/${siteId}`)}
>
Back to Dashboard
</Button>
<Button
variant="primary"
onClick={() => handleSync('both')}
disabled={syncing || !hasIntegrations}
>
<BoltIcon className={`w-4 h-4 mr-2 ${syncing ? 'animate-spin' : ''}`} />
{syncing ? 'Syncing...' : 'Sync All'}
</Button>
</div>
{/* Overall Status */}
@@ -275,7 +272,7 @@ export default function SyncDashboard() {
) : (
<Card className="p-6 mb-6">
<div className="text-center py-8">
<AlertCircleIcon className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<AlertIcon className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<p className="text-gray-600 dark:text-gray-400 mb-2">No active integrations</p>
<Button
variant="primary"
@@ -311,7 +308,7 @@ export default function SyncDashboard() {
mismatches.taxonomies.missing_in_igny8.length > 0) && (
<div>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
<TagIcon className="w-4 h-4" />
<BoxIcon className="w-4 h-4" />
Taxonomy Mismatches
</h3>
<div className="space-y-2">
@@ -348,7 +345,7 @@ export default function SyncDashboard() {
mismatches.products.missing_in_igny8.length > 0) && (
<div>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
<PackageIcon className="w-4 h-4" />
<BoxIcon className="w-4 h-4" />
Product Mismatches
</h3>
<div className="space-y-2">
@@ -375,7 +372,7 @@ export default function SyncDashboard() {
mismatches.posts.missing_in_igny8.length > 0) && (
<div>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
<FileTextIcon className="w-4 h-4" />
<FileIcon className="w-4 h-4" />
Post Mismatches
</h3>
<div className="space-y-2">
@@ -404,7 +401,7 @@ export default function SyncDashboard() {
onClick={() => handleSync('both')}
disabled={syncing}
>
<RefreshCwIcon className="w-4 h-4 mr-2" />
<BoltIcon className="w-4 h-4 mr-2" />
Retry Sync to Resolve
</Button>
</div>

View File

@@ -6,6 +6,14 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { fetchAPI } from '../services/api';
type AuthErrorCode = 'ACCOUNT_REQUIRED' | 'PLAN_REQUIRED' | 'AUTH_FAILED';
function createAuthError(message: string, code: AuthErrorCode): Error & { code: AuthErrorCode } {
const error = new Error(message) as Error & { code: AuthErrorCode };
error.code = code;
return error;
}
interface User {
id: number;
email: string;
@@ -60,15 +68,31 @@ export const useAuthStore = create<AuthState>()(
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || data.message || 'Login failed');
const message = data.error || data.message || 'Login failed';
if (response.status === 402) {
throw createAuthError(message, 'PLAN_REQUIRED');
}
if (response.status === 403) {
throw createAuthError(message, 'ACCOUNT_REQUIRED');
}
throw createAuthError(message, 'AUTH_FAILED');
}
// Store user and JWT tokens (handle both old and new API formats)
const responseData = data.data || data;
// Support both formats: new (access/refresh at top level) and old (tokens.access/refresh)
const tokens = responseData.tokens || {};
const userData = responseData.user || data.user;
if (!userData?.account) {
throw createAuthError('Account not configured for this user. Please contact support.', 'ACCOUNT_REQUIRED');
}
if (!userData.account.plan) {
throw createAuthError('Active subscription required. Visit igny8.com/pricing to subscribe.', 'PLAN_REQUIRED');
}
set({
user: responseData.user || data.user,
user: userData,
token: responseData.access || tokens.access || data.access || null,
refreshToken: responseData.refresh || tokens.refresh || data.refresh || null,
isAuthenticated: true,
@@ -196,23 +220,24 @@ export const useAuthStore = create<AuthState>()(
}
try {
// Use fetchAPI which handles token automatically and extracts data from unified format
// fetchAPI is already imported at the top of the file
const response = await fetchAPI('/v1/auth/me/');
// fetchAPI extracts data field, so response is {user: {...}}
if (!response || !response.user) {
throw new Error('Failed to refresh user data');
}
// Update user data with latest from server
// This ensures account/plan changes are reflected immediately
set({ user: response.user });
const refreshedUser = response.user;
if (!refreshedUser.account) {
throw createAuthError('Account not configured for this user. Please contact support.', 'ACCOUNT_REQUIRED');
}
if (!refreshedUser.account.plan) {
throw createAuthError('Active subscription required. Visit igny8.com/pricing to subscribe.', 'PLAN_REQUIRED');
}
set({ user: refreshedUser, isAuthenticated: true });
} catch (error: any) {
// If refresh fails, don't logout - just log the error
// User might still be authenticated, just couldn't refresh data
console.warn('Failed to refresh user data:', error);
// Don't throw - just log the warning to prevent error accumulation
set({ user: null, token: null, refreshToken: null, isAuthenticated: false });
throw error;
}
},
}),

View File

@@ -148,10 +148,10 @@
| Location | Current State | Required Changes | Priority |
|----------|---------------|------------------|----------|
| `frontend/src/pages/Dashboard/Home.tsx` | Shows generic metrics, module cards, recent activity | Add workflow guide component inline at top, push dashboard content below | **HIGH** |
| `frontend/src/components/onboarding/WorkflowGuide.tsx` | Does not exist | Create component with visual workflow map, inline in page | **HIGH** |
| `frontend/src/components/header/AppHeader.tsx` | No guide button | Add orange-colored "Show Guide" button in top right corner | **HIGH** |
| `frontend/src/store/onboardingStore.ts` | Does not exist | Create store for "Don't show again" preference and visibility toggle | **MEDIUM** |
| `frontend/src/pages/Dashboard/Home.tsx` | ✅ Inline `WorkflowGuide` rendered above dashboard content | Keep guide responsive, continue iterating on progress tracking & backend dismissal | **HIGH** |
| `frontend/src/components/onboarding/WorkflowGuide.tsx` | ✅ Component created with Build vs Integrate flows and CTA grid | Add advanced progress logic + backend persistence once API is ready | **HIGH** |
| `frontend/src/components/header/AppHeader.tsx` | ✅ Orange "Show/Hide Guide" button added next to metrics | Ensure button state syncs with backend dismissal when implemented | **HIGH** |
| `frontend/src/store/onboardingStore.ts` | ✅ Store created with dismiss + toggle actions (persisted) | Wire to backend `guide_dismissed` field once available | **MEDIUM** |
| Backend - User model/settings | No field for guide dismissal | Add `guide_dismissed` or `show_workflow_guide` field | **MEDIUM** |
**Workflow Guide Features:**
@@ -312,8 +312,8 @@
- **Hover States**: Use `hover:border-[var(--color-primary)]` pattern for interactive cards
**Completed:**
1. ✅ Refactor Sites Dashboard - Replaced lucide-react icons, using EnhancedMetricCard, standard colors/gradients, PageHeader component, matching Planner Dashboard patterns
2. ✅ Refactor Sites List - Replaced lucide-react icons, using standard Button/Card/Badge components, matching Dashboard styling, gradient icon backgrounds
1. ✅ Refactor Sites Dashboard - Replaced lucide-react icons, using EnhancedMetricCard, standard colors/gradients, PageHeader component (matches Planner dashboard patterns)
2. ✅ Refactor Sites List - Converted to `TablePageTemplate`, added table/grid toggle, mirrored Planner/Writer table styling, moved actions into standard header buttons, removed legacy site/sector selectors
**Remaining:**
3. Refactor Sites Builder pages - Apply same design system patterns
@@ -330,14 +330,18 @@
2. Audit and fix site/sector null handling
### Phase 7: Welcome/Guide Screen & Onboarding (HIGH Priority)
1. Create WorkflowGuide component (inline, not modal)
2. Create onboarding store for state management
3. Add orange "Show Guide" button in header
4. Implement flow structure (Build New Site vs Integrate Existing Site)
5. Add backend dismissal field
6. Implement progress tracking logic
7. Integrate guide at top of Home page (pushes dashboard below)
8. Test responsive design on mobile/tablet views
**Completed**
1. Create WorkflowGuide component (inline, not modal)
2. ✅ Create onboarding store for state management
3. ✅ Add orange "Show Guide" button in header
4. ✅ Implement flow structure (Build New Site vs Integrate Existing Site)
5. ✅ Integrate guide at top of Home page (pushes dashboard below)
6. ✅ Initial responsive pass on desktop/tablet/mobile
**Next**
7. Add backend dismissal field + persist state
8. Expand progress tracking logic (planner/writer milestones)
9. Cross-device QA once backend wiring is complete
### Phase 8: Sidebar Restructuring & Navigation (HIGH Priority)
1. Restructure sidebar: Dashboard (standalone) → SETUP → WORKFLOW → SETTINGS