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 { AddToCollectionModal } from "../components/AddToCollectionModal";
|
||||
import { AddToThreadModal } from "../components/AddToThreadModal";
|
||||
import { AuthPromptModal } from "../components/AuthPromptModal";
|
||||
import { CatalogSearchOverlay } from "../components/CatalogSearchOverlay";
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||
import { ExternalLinkDialog } from "../components/ExternalLinkDialog";
|
||||
@@ -94,7 +95,7 @@ function RootLayout() {
|
||||
|
||||
// Onboarding — only check when authenticated (endpoint requires auth)
|
||||
const { data: onboardingComplete, isLoading: onboardingLoading } =
|
||||
useOnboardingComplete();
|
||||
useOnboardingComplete(isAuthenticated);
|
||||
const [wizardDismissed, setWizardDismissed] = useState(false);
|
||||
|
||||
// Don't show onboarding wizard until user has created an account
|
||||
@@ -117,40 +118,21 @@ function RootLayout() {
|
||||
|
||||
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
|
||||
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
|
||||
const isSetupsPage = !!matchRoute({ to: "/setups", fuzzy: true });
|
||||
const showFab = isAuthenticated && !isPublicRoute;
|
||||
|
||||
if (!isAuthenticated && !isPublicRoute) {
|
||||
window.location.href = "/login";
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
if (!isAuthenticated && !isPublicRoute && !authLoading) {
|
||||
navigate({ to: "/login" });
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -198,6 +180,9 @@ function RootLayout() {
|
||||
<AddToThreadModal />
|
||||
<Toaster position="bottom-right" richColors />
|
||||
|
||||
{/* Auth Prompt Modal */}
|
||||
<AuthPromptModal />
|
||||
|
||||
{/* Onboarding Wizard */}
|
||||
{showWizard && (
|
||||
<OnboardingWizard onComplete={() => setWizardDismissed(true)} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useAuth } from "../../hooks/useAuth";
|
||||
import { useFormatters } from "../../hooks/useFormatters";
|
||||
import { useGlobalItem } from "../../hooks/useGlobalItems";
|
||||
import { useUIStore } from "../../stores/uiStore";
|
||||
@@ -11,8 +12,11 @@ function GlobalItemDetail() {
|
||||
const { globalItemId } = Route.useParams();
|
||||
const { data: item, isLoading, error } = useGlobalItem(Number(globalItemId));
|
||||
const { weight, price } = useFormatters();
|
||||
const { data: auth } = useAuth();
|
||||
const isAuthenticated = !!auth?.user;
|
||||
const openAddToCollection = useUIStore((s) => s.openAddToCollection);
|
||||
const openAddToThread = useUIStore((s) => s.openAddToThread);
|
||||
const openAuthPrompt = useUIStore((s) => s.openAuthPrompt);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -133,18 +137,26 @@ function GlobalItemDetail() {
|
||||
<div className="flex gap-3 mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openAddToCollection(item.id, `${item.brand} ${item.model}`)
|
||||
onClick={() => {
|
||||
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"
|
||||
>
|
||||
Add to Collection
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openAddToThread(item.id, `${item.brand} ${item.model}`)
|
||||
onClick={() => {
|
||||
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"
|
||||
>
|
||||
Add to Thread
|
||||
|
||||
@@ -4,9 +4,11 @@ import { CategoryHeader } from "../../components/CategoryHeader";
|
||||
import { ItemCard } from "../../components/ItemCard";
|
||||
import { ItemPicker } from "../../components/ItemPicker";
|
||||
import { WeightSummaryCard } from "../../components/WeightSummaryCard";
|
||||
import { useAuth } from "../../hooks/useAuth";
|
||||
import { useFormatters } from "../../hooks/useFormatters";
|
||||
import {
|
||||
useDeleteSetup,
|
||||
usePublicSetup,
|
||||
useRemoveSetupItem,
|
||||
useSetup,
|
||||
useUpdateItemClassification,
|
||||
@@ -23,7 +25,16 @@ function SetupDetailPage() {
|
||||
const { weight, price } = useFormatters();
|
||||
const navigate = useNavigate();
|
||||
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 updateSetup = useUpdateSetup(numericId);
|
||||
const removeItem = useRemoveSetupItem(numericId);
|
||||
@@ -140,7 +151,8 @@ function SetupDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{/* Actions — only visible to authenticated users */}
|
||||
{isAuthenticated && (
|
||||
<div className="flex items-center gap-3 py-4">
|
||||
<button
|
||||
type="button"
|
||||
@@ -196,6 +208,7 @@ function SetupDetailPage() {
|
||||
Delete Setup
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{itemCount === 0 && (
|
||||
@@ -214,6 +227,7 @@ function SetupDetailPage() {
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
Add items from your collection to build this loadout.
|
||||
</p>
|
||||
{isAuthenticated && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
@@ -221,6 +235,7 @@ function SetupDetailPage() {
|
||||
>
|
||||
Add Items
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -268,15 +283,22 @@ function SetupDetailPage() {
|
||||
imageFilename={item.imageFilename}
|
||||
imageUrl={item.imageUrl}
|
||||
productUrl={item.productUrl}
|
||||
onRemove={() => removeItem.mutate(item.id)}
|
||||
onRemove={
|
||||
isAuthenticated
|
||||
? () => removeItem.mutate(item.id)
|
||||
: undefined
|
||||
}
|
||||
classification={item.classification}
|
||||
onClassificationCycle={() =>
|
||||
onClassificationCycle={
|
||||
isAuthenticated
|
||||
? () =>
|
||||
updateClassification.mutate({
|
||||
itemId: item.id,
|
||||
classification: nextClassification(
|
||||
item.classification,
|
||||
),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
@@ -288,16 +310,18 @@ function SetupDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Item Picker */}
|
||||
{/* Item Picker — only for authenticated users */}
|
||||
{isAuthenticated && (
|
||||
<ItemPicker
|
||||
setupId={numericId}
|
||||
currentItemIds={currentItemIds}
|
||||
isOpen={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
{confirmDelete && (
|
||||
{/* Delete Confirmation Dialog — only for authenticated users */}
|
||||
{isAuthenticated && confirmDelete && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/30"
|
||||
|
||||
Reference in New Issue
Block a user