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

@@ -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;
}
},
}),