feat(24-02): render-first root layout, guarded write actions, public setup viewing
- Remove authLoading spinner gate — app renders immediately for all visitors - Expand isPublicRoute to include /, /global-items/*, /setups/*, /users/, /login - Replace hard window.location.href redirect with soft navigate() after auth resolves - Remove onboarding loading spinner — pass isAuthenticated as enabled to guard query - Add AuthPromptModal to root JSX for global availability - Guard Add to Collection and Add to Thread buttons with isAuthenticated check - Rework setup detail page to use usePublicSetup for anonymous visitors - Wrap all write action UI (Add Items, Delete, Public toggle, remove/classify) in isAuthenticated guards
This commit is contained in:
@@ -12,6 +12,7 @@ import { Toaster } from "sonner";
|
|||||||
import "../app.css";
|
import "../app.css";
|
||||||
import { AddToCollectionModal } from "../components/AddToCollectionModal";
|
import { AddToCollectionModal } from "../components/AddToCollectionModal";
|
||||||
import { AddToThreadModal } from "../components/AddToThreadModal";
|
import { AddToThreadModal } from "../components/AddToThreadModal";
|
||||||
|
import { AuthPromptModal } from "../components/AuthPromptModal";
|
||||||
import { CatalogSearchOverlay } from "../components/CatalogSearchOverlay";
|
import { CatalogSearchOverlay } from "../components/CatalogSearchOverlay";
|
||||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||||
import { ExternalLinkDialog } from "../components/ExternalLinkDialog";
|
import { ExternalLinkDialog } from "../components/ExternalLinkDialog";
|
||||||
@@ -94,7 +95,7 @@ function RootLayout() {
|
|||||||
|
|
||||||
// Onboarding — only check when authenticated (endpoint requires auth)
|
// Onboarding — only check when authenticated (endpoint requires auth)
|
||||||
const { data: onboardingComplete, isLoading: onboardingLoading } =
|
const { data: onboardingComplete, isLoading: onboardingLoading } =
|
||||||
useOnboardingComplete();
|
useOnboardingComplete(isAuthenticated);
|
||||||
const [wizardDismissed, setWizardDismissed] = useState(false);
|
const [wizardDismissed, setWizardDismissed] = useState(false);
|
||||||
|
|
||||||
// Don't show onboarding wizard until user has created an account
|
// Don't show onboarding wizard until user has created an account
|
||||||
@@ -117,40 +118,21 @@ function RootLayout() {
|
|||||||
|
|
||||||
const totalsBarProps = isDashboard ? {} : { linkTo: "/" };
|
const totalsBarProps = isDashboard ? {} : { linkTo: "/" };
|
||||||
|
|
||||||
// Show loading while checking auth
|
|
||||||
if (authLoading) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
|
||||||
<div className="w-6 h-6 border-2 border-gray-600 border-t-transparent rounded-full animate-spin" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect unauthenticated users to login (server-side OIDC route)
|
|
||||||
// Allow public routes through without auth
|
// Allow public routes through without auth
|
||||||
const isPublicRoute =
|
const isPublicRoute =
|
||||||
location.pathname.startsWith("/users/") || location.pathname === "/login";
|
location.pathname === "/" ||
|
||||||
|
location.pathname.startsWith("/users/") ||
|
||||||
|
location.pathname.startsWith("/global-items") ||
|
||||||
|
location.pathname.startsWith("/setups/") ||
|
||||||
|
location.pathname === "/login";
|
||||||
|
|
||||||
// FAB visibility: show on all authenticated, non-public routes
|
// FAB visibility: show on all authenticated, non-public routes
|
||||||
const isSetupsPage = !!matchRoute({ to: "/setups", fuzzy: true });
|
const isSetupsPage = !!matchRoute({ to: "/setups", fuzzy: true });
|
||||||
const showFab = isAuthenticated && !isPublicRoute;
|
const showFab = isAuthenticated && !isPublicRoute;
|
||||||
|
|
||||||
if (!isAuthenticated && !isPublicRoute) {
|
if (!isAuthenticated && !isPublicRoute && !authLoading) {
|
||||||
window.location.href = "/login";
|
navigate({ to: "/login" });
|
||||||
return (
|
return null;
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
|
||||||
<p className="text-sm text-gray-500">Redirecting to login...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show loading while checking onboarding status
|
|
||||||
if (onboardingLoading) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
|
||||||
<div className="w-6 h-6 border-2 border-gray-600 border-t-transparent rounded-full animate-spin" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -198,6 +180,9 @@ function RootLayout() {
|
|||||||
<AddToThreadModal />
|
<AddToThreadModal />
|
||||||
<Toaster position="bottom-right" richColors />
|
<Toaster position="bottom-right" richColors />
|
||||||
|
|
||||||
|
{/* Auth Prompt Modal */}
|
||||||
|
<AuthPromptModal />
|
||||||
|
|
||||||
{/* Onboarding Wizard */}
|
{/* Onboarding Wizard */}
|
||||||
{showWizard && (
|
{showWizard && (
|
||||||
<OnboardingWizard onComplete={() => setWizardDismissed(true)} />
|
<OnboardingWizard onComplete={() => setWizardDismissed(true)} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { useAuth } from "../../hooks/useAuth";
|
||||||
import { useFormatters } from "../../hooks/useFormatters";
|
import { useFormatters } from "../../hooks/useFormatters";
|
||||||
import { useGlobalItem } from "../../hooks/useGlobalItems";
|
import { useGlobalItem } from "../../hooks/useGlobalItems";
|
||||||
import { useUIStore } from "../../stores/uiStore";
|
import { useUIStore } from "../../stores/uiStore";
|
||||||
@@ -11,8 +12,11 @@ function GlobalItemDetail() {
|
|||||||
const { globalItemId } = Route.useParams();
|
const { globalItemId } = Route.useParams();
|
||||||
const { data: item, isLoading, error } = useGlobalItem(Number(globalItemId));
|
const { data: item, isLoading, error } = useGlobalItem(Number(globalItemId));
|
||||||
const { weight, price } = useFormatters();
|
const { weight, price } = useFormatters();
|
||||||
|
const { data: auth } = useAuth();
|
||||||
|
const isAuthenticated = !!auth?.user;
|
||||||
const openAddToCollection = useUIStore((s) => s.openAddToCollection);
|
const openAddToCollection = useUIStore((s) => s.openAddToCollection);
|
||||||
const openAddToThread = useUIStore((s) => s.openAddToThread);
|
const openAddToThread = useUIStore((s) => s.openAddToThread);
|
||||||
|
const openAuthPrompt = useUIStore((s) => s.openAuthPrompt);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -133,18 +137,26 @@ function GlobalItemDetail() {
|
|||||||
<div className="flex gap-3 mb-6">
|
<div className="flex gap-3 mb-6">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
openAddToCollection(item.id, `${item.brand} ${item.model}`)
|
if (!isAuthenticated) {
|
||||||
|
openAuthPrompt();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
openAddToCollection(item.id, `${item.brand} ${item.model}`);
|
||||||
|
}}
|
||||||
className="bg-gray-700 text-white rounded-lg px-5 py-2.5 text-sm font-medium hover:bg-gray-800 transition-colors"
|
className="bg-gray-700 text-white rounded-lg px-5 py-2.5 text-sm font-medium hover:bg-gray-800 transition-colors"
|
||||||
>
|
>
|
||||||
Add to Collection
|
Add to Collection
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
openAddToThread(item.id, `${item.brand} ${item.model}`)
|
if (!isAuthenticated) {
|
||||||
|
openAuthPrompt();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
openAddToThread(item.id, `${item.brand} ${item.model}`);
|
||||||
|
}}
|
||||||
className="bg-white text-gray-700 border border-gray-200 rounded-lg px-5 py-2.5 text-sm font-medium hover:bg-gray-50 transition-colors"
|
className="bg-white text-gray-700 border border-gray-200 rounded-lg px-5 py-2.5 text-sm font-medium hover:bg-gray-50 transition-colors"
|
||||||
>
|
>
|
||||||
Add to Thread
|
Add to Thread
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import { CategoryHeader } from "../../components/CategoryHeader";
|
|||||||
import { ItemCard } from "../../components/ItemCard";
|
import { ItemCard } from "../../components/ItemCard";
|
||||||
import { ItemPicker } from "../../components/ItemPicker";
|
import { ItemPicker } from "../../components/ItemPicker";
|
||||||
import { WeightSummaryCard } from "../../components/WeightSummaryCard";
|
import { WeightSummaryCard } from "../../components/WeightSummaryCard";
|
||||||
|
import { useAuth } from "../../hooks/useAuth";
|
||||||
import { useFormatters } from "../../hooks/useFormatters";
|
import { useFormatters } from "../../hooks/useFormatters";
|
||||||
import {
|
import {
|
||||||
useDeleteSetup,
|
useDeleteSetup,
|
||||||
|
usePublicSetup,
|
||||||
useRemoveSetupItem,
|
useRemoveSetupItem,
|
||||||
useSetup,
|
useSetup,
|
||||||
useUpdateItemClassification,
|
useUpdateItemClassification,
|
||||||
@@ -23,7 +25,16 @@ function SetupDetailPage() {
|
|||||||
const { weight, price } = useFormatters();
|
const { weight, price } = useFormatters();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const numericId = Number(setupId);
|
const numericId = Number(setupId);
|
||||||
const { data: setup, isLoading } = useSetup(numericId);
|
|
||||||
|
const { data: auth } = useAuth();
|
||||||
|
const isAuthenticated = !!auth?.user;
|
||||||
|
|
||||||
|
const privateSetup = useSetup(isAuthenticated ? numericId : null);
|
||||||
|
const publicSetup = usePublicSetup(!isAuthenticated ? numericId : null);
|
||||||
|
const { data: setup, isLoading } = isAuthenticated
|
||||||
|
? privateSetup
|
||||||
|
: publicSetup;
|
||||||
|
|
||||||
const deleteSetup = useDeleteSetup();
|
const deleteSetup = useDeleteSetup();
|
||||||
const updateSetup = useUpdateSetup(numericId);
|
const updateSetup = useUpdateSetup(numericId);
|
||||||
const removeItem = useRemoveSetupItem(numericId);
|
const removeItem = useRemoveSetupItem(numericId);
|
||||||
@@ -140,7 +151,8 @@ function SetupDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions — only visible to authenticated users */}
|
||||||
|
{isAuthenticated && (
|
||||||
<div className="flex items-center gap-3 py-4">
|
<div className="flex items-center gap-3 py-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -196,6 +208,7 @@ function SetupDetailPage() {
|
|||||||
Delete Setup
|
Delete Setup
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Empty state */}
|
{/* Empty state */}
|
||||||
{itemCount === 0 && (
|
{itemCount === 0 && (
|
||||||
@@ -214,6 +227,7 @@ function SetupDetailPage() {
|
|||||||
<p className="text-sm text-gray-500 mb-6">
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
Add items from your collection to build this loadout.
|
Add items from your collection to build this loadout.
|
||||||
</p>
|
</p>
|
||||||
|
{isAuthenticated && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPickerOpen(true)}
|
onClick={() => setPickerOpen(true)}
|
||||||
@@ -221,6 +235,7 @@ function SetupDetailPage() {
|
|||||||
>
|
>
|
||||||
Add Items
|
Add Items
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -268,15 +283,22 @@ function SetupDetailPage() {
|
|||||||
imageFilename={item.imageFilename}
|
imageFilename={item.imageFilename}
|
||||||
imageUrl={item.imageUrl}
|
imageUrl={item.imageUrl}
|
||||||
productUrl={item.productUrl}
|
productUrl={item.productUrl}
|
||||||
onRemove={() => removeItem.mutate(item.id)}
|
onRemove={
|
||||||
|
isAuthenticated
|
||||||
|
? () => removeItem.mutate(item.id)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
classification={item.classification}
|
classification={item.classification}
|
||||||
onClassificationCycle={() =>
|
onClassificationCycle={
|
||||||
|
isAuthenticated
|
||||||
|
? () =>
|
||||||
updateClassification.mutate({
|
updateClassification.mutate({
|
||||||
itemId: item.id,
|
itemId: item.id,
|
||||||
classification: nextClassification(
|
classification: nextClassification(
|
||||||
item.classification,
|
item.classification,
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -288,16 +310,18 @@ function SetupDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Item Picker */}
|
{/* Item Picker — only for authenticated users */}
|
||||||
|
{isAuthenticated && (
|
||||||
<ItemPicker
|
<ItemPicker
|
||||||
setupId={numericId}
|
setupId={numericId}
|
||||||
currentItemIds={currentItemIds}
|
currentItemIds={currentItemIds}
|
||||||
isOpen={pickerOpen}
|
isOpen={pickerOpen}
|
||||||
onClose={() => setPickerOpen(false)}
|
onClose={() => setPickerOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
{/* Delete Confirmation Dialog — only for authenticated users */}
|
||||||
{confirmDelete && (
|
{isAuthenticated && confirmDelete && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-black/30"
|
className="absolute inset-0 bg-black/30"
|
||||||
|
|||||||
Reference in New Issue
Block a user