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.utils.deprecation import MiddlewareMixin
from django.http import JsonResponse from django.http import JsonResponse
from django.contrib.auth import logout
from rest_framework import status from rest_framework import status
try: try:
@@ -41,14 +42,19 @@ class AccountContextMiddleware(MiddlewareMixin):
request.user = user request.user = user
# Get account from refreshed user # Get account from refreshed user
user_account = getattr(user, 'account', None) user_account = getattr(user, 'account', None)
if user_account: validation_error = self._validate_account_and_plan(request, user)
request.account = user_account if validation_error:
return None return validation_error
request.account = getattr(user, 'account', None)
return None
except (AttributeError, UserModel.DoesNotExist, Exception): except (AttributeError, UserModel.DoesNotExist, Exception):
# If refresh fails, fallback to cached account # If refresh fails, fallback to cached account
try: try:
user_account = getattr(request.user, 'account', None) user_account = getattr(request.user, 'account', None)
if user_account: if user_account:
validation_error = self._validate_account_and_plan(request, request.user)
if validation_error:
return validation_error
request.account = user_account request.account = user_account
return None return None
except (AttributeError, Exception): 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) # Get user from DB (but don't set request.user - let DRF authentication handle that)
# Only set request.account for account context # Only set request.account for account context
user = User.objects.select_related('account', 'account__plan').get(id=user_id) 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: if account_id:
# Verify account still exists # Verify account still exists
try: try:
@@ -119,4 +128,47 @@ class AccountContextMiddleware(MiddlewareMixin):
request.account = None request.account = None
return None 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): 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) # Log the user in (create session for session authentication)
from django.contrib.auth import login from django.contrib.auth import login
login(request, user) login(request, user)
# Get account from user
account = getattr(user, 'account', None)
# Generate JWT tokens # Generate JWT tokens
access_token = generate_access_token(user, account) access_token = generate_access_token(user, account)
refresh_token = generate_refresh_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 { BrowserRouter as Router, Routes, Route, Navigate } from "react-router";
import { HelmetProvider } from "react-helmet-async"; import { HelmetProvider } from "react-helmet-async";
import AppLayout from "./layout/AppLayout"; import AppLayout from "./layout/AppLayout";
@@ -7,6 +7,7 @@ import ProtectedRoute from "./components/auth/ProtectedRoute";
import ModuleGuard from "./components/common/ModuleGuard"; import ModuleGuard from "./components/common/ModuleGuard";
import GlobalErrorDisplay from "./components/common/GlobalErrorDisplay"; import GlobalErrorDisplay from "./components/common/GlobalErrorDisplay";
import LoadingStateMonitor from "./components/common/LoadingStateMonitor"; import LoadingStateMonitor from "./components/common/LoadingStateMonitor";
import { useAuthStore } from "./store/authStore";
// Auth pages - loaded immediately (needed for login) // Auth pages - loaded immediately (needed for login)
import SignIn from "./pages/AuthPages/SignIn"; 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")); const Videos = lazy(() => import("./pages/Settings/UiElements/Videos"));
export default function App() { 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 ( return (
<> <>
<GlobalErrorDisplay /> <GlobalErrorDisplay />

View File

@@ -4,6 +4,8 @@ import { useAuthStore } from "../../store/authStore";
import { useErrorHandler } from "../../hooks/useErrorHandler"; import { useErrorHandler } from "../../hooks/useErrorHandler";
import { trackLoading } from "../common/LoadingStateMonitor"; import { trackLoading } from "../common/LoadingStateMonitor";
const PRICING_URL = "https://igny8.com/pricing";
interface ProtectedRouteProps { interface ProtectedRouteProps {
children: ReactNode; children: ReactNode;
} }
@@ -13,7 +15,7 @@ interface ProtectedRouteProps {
* Redirects to /signin if user is not authenticated * Redirects to /signin if user is not authenticated
*/ */
export default function ProtectedRoute({ children }: ProtectedRouteProps) { export default function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated, loading } = useAuthStore(); const { isAuthenticated, loading, user, logout } = useAuthStore();
const location = useLocation(); const location = useLocation();
const { addError } = useErrorHandler('ProtectedRoute'); const { addError } = useErrorHandler('ProtectedRoute');
const [showError, setShowError] = useState(false); const [showError, setShowError] = useState(false);
@@ -24,6 +26,24 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) {
trackLoading('auth-loading', loading); trackLoading('auth-loading', 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 // Immediate check on mount: if loading is true, reset it immediately
useEffect(() => { useEffect(() => {
if (loading) { if (loading) {

View File

@@ -32,6 +32,10 @@ export default function SignInForm() {
const from = (location.state as any)?.from?.pathname || "/"; const from = (location.state as any)?.from?.pathname || "/";
navigate(from, { replace: true }); navigate(from, { replace: true });
} catch (err: any) { } 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."); setError(err.message || "Login failed. Please check your credentials.");
} }
}; };

View File

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

View File

@@ -10,10 +10,10 @@ import { useState, useEffect } from 'react';
import { useBuilderWorkflowStore } from '../../../../store/builderWorkflowStore'; import { useBuilderWorkflowStore } from '../../../../store/builderWorkflowStore';
import { fetchSiteBlueprintById, updateSiteBlueprint, SiteBlueprint } from '../../../../services/api'; import { fetchSiteBlueprintById, updateSiteBlueprint, SiteBlueprint } from '../../../../services/api';
import { Card, CardDescription, CardTitle } from '../../../../components/ui/card'; 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 Input from '../../../../components/form/input/InputField';
import Alert from '../../../../components/ui/alert/Alert'; import Alert from '../../../../components/ui/alert/Alert';
import { Loader2 } from 'lucide-react'; import { BoltIcon, GridIcon } from '../../../../icons';
import type { BuilderFormData, SiteBuilderMetadata } from '../../../../types/siteBuilder'; import type { BuilderFormData, SiteBuilderMetadata } from '../../../../types/siteBuilder';
// Stage 1 Wizard props // Stage 1 Wizard props
@@ -115,11 +115,13 @@ function BusinessDetailsStepStage2({ blueprintId }: { blueprintId: number }) {
const canProceed = formData.name.trim().length > 0; const canProceed = formData.name.trim().length > 0;
return ( return (
<Card className="p-6"> <Card variant="surface" padding="lg" className="space-y-6">
<CardTitle>Business Details</CardTitle> <div>
<CardDescription> <CardTitle>Business details</CardTitle>
Tell us about your business and site type to get started. <CardDescription>
</CardDescription> Tell us about your business and hosting preference to keep blueprints organized.
</CardDescription>
</div>
{error && ( {error && (
<Alert variant="error" className="mt-4"> <Alert variant="error" className="mt-4">
@@ -128,62 +130,58 @@ function BusinessDetailsStepStage2({ blueprintId }: { blueprintId: number }) {
)} )}
<div className="mt-6 space-y-4"> <div className="mt-6 space-y-4">
<div> <div className="grid gap-6 md:grid-cols-2">
<label className="block text-sm font-medium mb-2">Site Name *</label> <div>
<Input <label className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 block">
value={formData.name} Site name *
onChange={(e) => setFormData({ ...formData, name: e.target.value })} </label>
placeholder="My Awesome Site" <Input
required 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>
<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 <textarea
value={formData.description} value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })} 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} rows={3}
placeholder="Brief description of your site..." placeholder="Brief description of your business and what the site should cover"
/> />
</div> </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>
<div className="mt-6 flex justify-end"> <div className="mt-6 flex justify-end">
<ButtonWithTooltip <Button
onClick={handleSave} onClick={handleSave}
disabled={!canProceed || saving || loading} disabled={!canProceed || saving || loading}
variant="primary" variant="primary"
tooltip={ startIcon={<GridIcon className="size-4" />}
!canProceed ? 'Please provide a site name to continue' :
saving ? 'Saving...' :
loading ? 'Loading...' : undefined
}
> >
{saving ? ( {saving ? 'Saving…' : 'Save & continue'}
<> </Button>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
'Save & Continue'
)}
</ButtonWithTooltip>
</div> </div>
{!canProceed && ( {!canProceed && (
@@ -208,69 +206,88 @@ function BusinessDetailsStepStage1({
selectedSectors?: Array<{ id: number; name: string }>; selectedSectors?: Array<{ id: number; name: string }>;
}) { }) {
return ( return (
<Card variant="surface" padding="lg"> <Card variant="surface" padding="lg" className="space-y-6">
<div className="space-y-4"> <div>
<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">
<p className="text-xs uppercase tracking-wider text-gray-500 dark:text-white/50"> <BoltIcon className="size-3.5" />
Business context 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>
</div> </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> <div className="grid gap-6 lg:grid-cols-2">
<label className="block text-sm font-medium mb-2">Site name</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">
Site name
</label>
<Input <Input
value={data.siteName} value={data.siteName}
onChange={(e) => onChange('siteName', e.target.value)} onChange={(e) => onChange('siteName', e.target.value)}
placeholder="Acme Robotics" placeholder="Acme Robotics"
/> />
<p className="text-xs text-gray-500 dark:text-gray-400">
Appears in dashboards, blueprints, and deployment metadata.
</p>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <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]">
<div> <label className="text-sm font-semibold text-gray-900 dark:text-white">
<label className="block text-sm font-medium mb-2">Business type</label> Target audience
<Input </label>
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>
<Input <Input
value={data.targetAudience} value={data.targetAudience}
onChange={(e) => onChange('targetAudience', e.target.value)} onChange={(e) => onChange('targetAudience', e.target.value)}
placeholder="Operations leaders at fast-scaling eCommerce brands" 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>
<div> <div className="grid gap-6 lg:grid-cols-3">
<label className="block text-sm font-medium mb-2">Hosting preference</label> <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 <select
value={data.hostingType} value={data.hostingType}
onChange={(e) => onChange('hostingType', e.target.value as BuilderFormData['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="igny8_sites">IGNY8 Sites</option>
<option value="wordpress">WordPress</option> <option value="wordpress">WordPress</option>
<option value="shopify">Shopify</option> <option value="shopify">Shopify</option>
<option value="multi">Multiple destinations</option> <option value="multi">Multiple destinations</option>
</select> </select>
<p className="text-xs text-gray-500 dark:text-gray-400">
Determines deployment targets and integration requirements.
</p>
</div> </div>
</div> </div>
</Card> </Card>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,14 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import { fetchAPI } from '../services/api'; 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 { interface User {
id: number; id: number;
email: string; email: string;
@@ -60,15 +68,31 @@ export const useAuthStore = create<AuthState>()(
const data = await response.json(); const data = await response.json();
if (!response.ok || !data.success) { 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) // Store user and JWT tokens (handle both old and new API formats)
const responseData = data.data || data; const responseData = data.data || data;
// Support both formats: new (access/refresh at top level) and old (tokens.access/refresh) // Support both formats: new (access/refresh at top level) and old (tokens.access/refresh)
const tokens = responseData.tokens || {}; 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({ set({
user: responseData.user || data.user, user: userData,
token: responseData.access || tokens.access || data.access || null, token: responseData.access || tokens.access || data.access || null,
refreshToken: responseData.refresh || tokens.refresh || data.refresh || null, refreshToken: responseData.refresh || tokens.refresh || data.refresh || null,
isAuthenticated: true, isAuthenticated: true,
@@ -196,23 +220,24 @@ export const useAuthStore = create<AuthState>()(
} }
try { 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/'); const response = await fetchAPI('/v1/auth/me/');
// fetchAPI extracts data field, so response is {user: {...}}
if (!response || !response.user) { if (!response || !response.user) {
throw new Error('Failed to refresh user data'); throw new Error('Failed to refresh user data');
} }
// Update user data with latest from server const refreshedUser = response.user;
// This ensures account/plan changes are reflected immediately if (!refreshedUser.account) {
set({ user: response.user }); 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) { } 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); 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 | | 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/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` | Does not exist | Create component with visual workflow map, inline in page | **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` | No guide button | Add orange-colored "Show Guide" button in top right corner | **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` | Does not exist | Create store for "Don't show again" preference and visibility toggle | **MEDIUM** | | `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** | | Backend - User model/settings | No field for guide dismissal | Add `guide_dismissed` or `show_workflow_guide` field | **MEDIUM** |
**Workflow Guide Features:** **Workflow Guide Features:**
@@ -312,8 +312,8 @@
- **Hover States**: Use `hover:border-[var(--color-primary)]` pattern for interactive cards - **Hover States**: Use `hover:border-[var(--color-primary)]` pattern for interactive cards
**Completed:** **Completed:**
1. ✅ Refactor Sites Dashboard - Replaced lucide-react icons, using EnhancedMetricCard, standard colors/gradients, PageHeader component, matching Planner Dashboard patterns 1. ✅ Refactor Sites Dashboard - Replaced lucide-react icons, using EnhancedMetricCard, standard colors/gradients, PageHeader component (matches Planner dashboard patterns)
2. ✅ Refactor Sites List - Replaced lucide-react icons, using standard Button/Card/Badge components, matching Dashboard styling, gradient icon backgrounds 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:** **Remaining:**
3. Refactor Sites Builder pages - Apply same design system patterns 3. Refactor Sites Builder pages - Apply same design system patterns
@@ -330,14 +330,18 @@
2. Audit and fix site/sector null handling 2. Audit and fix site/sector null handling
### Phase 7: Welcome/Guide Screen & Onboarding (HIGH Priority) ### Phase 7: Welcome/Guide Screen & Onboarding (HIGH Priority)
1. Create WorkflowGuide component (inline, not modal) **Completed**
2. Create onboarding store for state management 1. Create WorkflowGuide component (inline, not modal)
3. Add orange "Show Guide" button in header 2. ✅ Create onboarding store for state management
4. Implement flow structure (Build New Site vs Integrate Existing Site) 3. ✅ Add orange "Show Guide" button in header
5. Add backend dismissal field 4. ✅ Implement flow structure (Build New Site vs Integrate Existing Site)
6. Implement progress tracking logic 5. ✅ Integrate guide at top of Home page (pushes dashboard below)
7. Integrate guide at top of Home page (pushes dashboard below) 6. ✅ Initial responsive pass on desktop/tablet/mobile
8. Test responsive design on mobile/tablet views
**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) ### Phase 8: Sidebar Restructuring & Navigation (HIGH Priority)
1. Restructure sidebar: Dashboard (standalone) → SETUP → WORKFLOW → SETTINGS 1. Restructure sidebar: Dashboard (standalone) → SETUP → WORKFLOW → SETTINGS