refactor-upto-phase 6
This commit is contained in:
@@ -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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 IGNY8’s 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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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 we’re 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user