Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions cypress/e2e/survey-and-landing-fixes.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* Minimal smoke tests for the frontend fixes:
* 1. VariantSwitcher is gone — no "V2 Dark / V3 Editorial / …" bar on the landing page
* 2. Landing page has a direct "Launch" CTA visible in the mobile header
* (users must not need the hamburger menu to reach the game)
* 3. Unauthenticated users are NOT redirected away from "/" (landing stays up)
* 4. "/game" redirects unauthenticated users away (protected route still gated)
*/

describe("Landing page & survey UX fixes", () => {
beforeEach(() => {
cy.waitForSupabase();
});

// ── 1. No VariantSwitcher ────────────────────────────────────────────────
it("landing page does not render the variant-switcher nav bar", () => {
cy.visit("/");
// The VariantSwitcher used an aria-label of "Landing page variant switcher"
cy.get('[aria-label="Landing page variant switcher"]').should("not.exist");
// Also confirm the variant labels are absent
cy.contains("V2 Dark").should("not.exist");
cy.contains("V3 Editorial").should("not.exist");
});

// ── 2. Mobile header has a direct "Launch" CTA ───────────────────────────
it("mobile viewport shows a Launch button in the header without opening the hamburger", () => {
cy.viewport("iphone-x");
cy.visit("/");

// The button text is "Launch →" and should be visible without any interaction
cy.get("header").within(() => {
cy.contains("Launch").should("be.visible");
});

// Confirm it links to /auth
cy.get("header a").contains("Launch").should("have.attr", "href", "/auth");
});

// ── 3. Unauthenticated users can see the landing page ────────────────────
it("unauthenticated visit to / renders the landing page (not a redirect)", () => {
cy.visit("/");
cy.url().should("include", "/").and("not.include", "/auth").and("not.include", "/game");
// Key landing page content should be present
cy.contains("Star Sailors").should("be.visible");
});

// ── 4. /game still protected for unauthenticated users ───────────────────
it("visiting /game while unauthenticated redirects away from the game hub", () => {
cy.visit("/game", { failOnStatusCode: false });
cy.url().should("satisfy", (url: string) =>
url.includes("/auth") || url.includes("/") && !url.includes("/game")
);
});

// ── 5. Desktop landing has the full nav (Launch Web Client link) ─────────
it("desktop viewport shows Launch Web Client in the header nav", () => {
cy.viewport(1280, 800);
cy.visit("/");
cy.get("header").within(() => {
cy.contains("Launch Web Client").should("be.visible");
});
});
});
58 changes: 14 additions & 44 deletions src/app/game/GameClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@ export default function GameClient({ initialData, user }: GameClientProps) {
<div className="grid h-full grid-cols-1 gap-0 lg:grid-cols-[1fr_300px] lg:overflow-hidden">

{/* ── Left / Main column ── */}
<div className="flex h-full flex-col gap-3 p-3 sm:gap-4 sm:p-4 lg:overflow-y-auto">
<div className="flex h-full flex-col gap-2 overflow-y-auto p-2 sm:gap-4 sm:p-4 lg:gap-4 lg:p-4">

{/* Mission Brief — always above fold */}
<MissionBriefCard
Expand Down Expand Up @@ -492,8 +492,8 @@ export default function GameClient({ initialData, user }: GameClientProps) {
}
/>

{/* Sector Radar — hidden until first deployment */}
{Object.values(radarStations).some(r => r.deployed) ? (
{/* Sector Radar — only shown once structures are deployed */}
{Object.values(radarStations).some(r => r.deployed) && (
<>
<SectionLabel text="Sector Radar" />
<SectorRadar
Expand All @@ -503,54 +503,24 @@ export default function GameClient({ initialData, user }: GameClientProps) {
className="origin-top justify-center scale-[0.94] sm:scale-100 lg:justify-start"
/>
</>
) : (
<div className="rounded-xl border border-border/20 p-4 text-center sm:p-6">
<p className="text-sm font-bold text-foreground/60">What do you want to do today?</p>
<p className="mt-1 text-xs text-muted-foreground/50">Deploy a structure to start scanning your sector.</p>
<div className="mt-4 grid grid-cols-2 gap-2 text-left">
{[
{ id: "telescope" as const, label: "Hunt planets", accent: "border-teal-500/30 text-teal-300 hover:bg-teal-500/10" },
{ id: "satellite" as const, label: "Track weather", accent: "border-sky-500/30 text-sky-300 hover:bg-sky-500/10" },
{ id: "rover" as const, label: "Train rovers", accent: "border-amber-500/30 text-amber-300 hover:bg-amber-500/10" },
{ id: "solar" as const, label: "Join solar", accent: "border-orange-500/30 text-orange-300 hover:bg-orange-500/10" },
].map((option) => (
<button
key={option.id}
type="button"
onClick={() => handleStructureClick(option.id)}
className={cn(
"rounded-lg border px-3 py-2 text-xs font-semibold transition-colors",
option.accent,
)}
>
{option.label}
</button>
))}
</div>
<button
onClick={() => setShowPreferencesModal(true)}
className="mt-4 rounded-full border border-teal-500/40 px-5 py-2 text-xs font-bold text-teal-400 hover:bg-teal-500/10 transition-colors"
>
+ Add project
</button>
</div>
)}

{/* Structure cards — primary navigation */}
{/* Structure cards — horizontal scroll on mobile, 4-col grid on sm+ */}
<SectionLabel text="Station Systems" />
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4 sm:gap-3">
<div className="flex gap-2 overflow-x-auto pb-1 sm:grid sm:grid-cols-4 sm:overflow-x-visible sm:pb-0 sm:gap-3">
{(["telescope","satellite","rover","solar"] as const).map((id) => {
const station = radarStations[id];
const state = structureStates[id];
return (
<StructureCard
key={id}
id={id}
state={state}
signals={station.signals}
isSolar={id === "solar"}
onClick={() => handleStructureClick(id)}
/>
<div key={id} className="min-w-[calc(50%-4px)] sm:min-w-0">
<StructureCard
id={id}
state={state}
signals={station.signals}
isSolar={id === "solar"}
onClick={() => handleStructureClick(id)}
/>
</div>
);
})}
</div>
Expand Down
12 changes: 9 additions & 3 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ import { LandingAnalytics } from "./apt/_components/LandingAnalytics";
import { LandingMobileMenu } from "./apt/_components/LandingMobileMenu";
import { LandingStats, LandingStatsFallback } from "./apt/_components/LandingStats";

export const experimental_ppr = true;

export const metadata: Metadata = {
title: "Star Sailors — Real astronomy. Your kind of game.",
description:
Expand Down Expand Up @@ -117,7 +115,15 @@ export default async function HomePage() {
</Link>
</div>

<div className="relative md:hidden">
<div className="flex items-center gap-2 md:hidden">
<Link
href="/auth"
data-track="hero_cta_clicked"
data-track-props='{"source":"mobile_header"}'
className="rounded-full bg-primary px-4 py-2 text-xs font-black uppercase tracking-wider text-primary-foreground"
>
Launch →
</Link>
<LandingMobileMenu />
</div>
</header>
Expand Down
44 changes: 0 additions & 44 deletions src/components/landing/VariantSwitcher.tsx

This file was deleted.

3 changes: 2 additions & 1 deletion src/features/surveys/components/GameSurveys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { usePostHog } from "posthog-js/react";
import { useGameSurveys } from "../hooks/useGameSurveys";
import { useProjectEngagementSurveys } from "../hooks/useProjectEngagementSurveys";
import type { ClassificationForSurvey } from "../hooks/useProjectEngagementSurveys";
import type { ClassificationForMechanicSurvey } from "../hooks/useGameSurveys";
import MechanicPulseSurvey from "./MechanicPulseSurvey";

interface GameSurveysProps {
Expand All @@ -18,7 +19,7 @@ export function GameSurveys({ userId, classifications = [] }: GameSurveysProps)
activeSurvey: activeMechanicSurvey,
dismissSurvey: dismissMechanic,
completeSurvey: completeMechanic,
} = useGameSurveys(userId);
} = useGameSurveys(userId, classifications as ClassificationForMechanicSurvey[]);

const {
activeSurvey: activeEngagementSurvey,
Expand Down
39 changes: 30 additions & 9 deletions src/features/surveys/hooks/useGameSurveys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,17 @@ import { useSearchParams } from "next/navigation";
import { MECHANIC_SURVEYS, SURVEY_DISPLAY_DELAY_MS, surveyStorageKey } from "../mechanic-surveys";
import type { MechanicMicroSurvey } from "../types";

export function useGameSurveys(userId?: string) {
export type ClassificationForMechanicSurvey = {
classificationtype?: string | null;
};

export function useGameSurveys(
userId?: string,
classifications: ClassificationForMechanicSurvey[] = []
) {
const searchParams = useSearchParams();
const view = searchParams.get("view") || "base";

const [activeSurvey, setActiveSurvey] = useState<MechanicMicroSurvey | null>(null);

useEffect(() => {
Expand All @@ -27,15 +34,29 @@ export function useGameSurveys(userId?: string) {
"satellite": "mechanic_satellite_loop_v1",
"solar": "mechanic_solar_loop_v1",
"inventory": "feature_inventory_v1",
"base": "feature_profile_v1" // Just an example mapping
"base": "feature_profile_v1",
};

if (s.id === viewMap[view]) {
// Check if already completed
const key = surveyStorageKey(s.id, userId);
return !localStorage.getItem(key);
if (s.id !== viewMap[view]) return false;

// Check if already completed or dismissed
const key = surveyStorageKey(s.id, userId);
if (localStorage.getItem(key)) return false;

// For mechanic surveys, enforce a minimum classification threshold
// so new users are not immediately shown surveys about tools they've never used
if (s.minClassificationsRequired && s.minClassificationsRequired > 0) {
const relevantTypes = s.relevantClassificationTypes;
const count = classifications.filter((c) => {
if (!c.classificationtype) return false;
// If no specific types are defined, count all classifications
if (!relevantTypes || relevantTypes.length === 0) return true;
return relevantTypes.includes(c.classificationtype);
}).length;
if (count < s.minClassificationsRequired) return false;
}
return false;

return true;
});

if (candidate) {
Expand All @@ -44,7 +65,7 @@ export function useGameSurveys(userId?: string) {
}, SURVEY_DISPLAY_DELAY_MS);

return () => clearTimeout(timer);
}, [userId, view]);
}, [userId, view, classifications]);

const dismissSurvey = () => {
if (activeSurvey && userId) {
Expand Down
8 changes: 8 additions & 0 deletions src/features/surveys/mechanic-surveys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export const MECHANIC_SURVEYS: readonly MechanicMicroSurvey[] = [
title: "Telescope Debrief",
subtitle: "How did the detection loop feel this run?",
triggerSurface: "game",
minClassificationsRequired: 3,
relevantClassificationTypes: ["planet", "telescope-tess", "telescope-minorPlanet"],
questions: [
{
id: "pace",
Expand All @@ -84,6 +86,8 @@ export const MECHANIC_SURVEYS: readonly MechanicMicroSurvey[] = [
title: "Rover Debrief",
subtitle: "Quick systems check from your terrain run.",
triggerSurface: "game",
minClassificationsRequired: 3,
relevantClassificationTypes: ["rover"],
questions: [
{
id: "control",
Expand All @@ -104,6 +108,8 @@ export const MECHANIC_SURVEYS: readonly MechanicMicroSurvey[] = [
title: "Satellite Ops",
subtitle: "Feedback on cloud tracking.",
triggerSurface: "game",
minClassificationsRequired: 3,
relevantClassificationTypes: ["cloud", "lidar-jovianVortexHunter"],
questions: [
{
id: "ui",
Expand All @@ -118,6 +124,8 @@ export const MECHANIC_SURVEYS: readonly MechanicMicroSurvey[] = [
title: "Solar Array",
subtitle: "Energy collection efficiency.",
triggerSurface: "game",
minClassificationsRequired: 3,
relevantClassificationTypes: ["telescope-sunspot"],
questions: [
{
id: "complexity",
Expand Down
4 changes: 4 additions & 0 deletions src/features/surveys/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ export type MechanicMicroSurvey = {
subtitle: string;
triggerSurface: "game" | "ecosystem";
questions: readonly [MicroSurveyQuestion, MicroSurveyQuestion?];
/** Minimum number of relevant classifications before this survey is shown. */
minClassificationsRequired?: number;
/** Which classification types count toward minClassificationsRequired. */
relevantClassificationTypes?: readonly string[];
};

export type ProjectType =
Expand Down
20 changes: 16 additions & 4 deletions src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,28 @@ function hasSupabaseAuthCookie(req: NextRequest) {
}

export function middleware(req: NextRequest) {
if (!hasSupabaseAuthCookie(req) && req.nextUrl.pathname.startsWith("/game")) {
const isAuthenticated = hasSupabaseAuthCookie(req);
const { pathname } = req.nextUrl;

// Authenticated users visiting the landing page → send straight to the game.
// Cookie presence is sufficient here: if the access token is expired but the
// refresh token is still valid, /game will resolve correctly. If both are
// expired the game's own auth guard will redirect to /auth.
if (isAuthenticated && pathname === "/") {
return NextResponse.redirect(new URL("/game?from=landing", req.url));
}

// Unauthenticated users trying to access the game → send to login.
if (!isAuthenticated && pathname.startsWith("/game")) {
const loginUrl = new URL("/auth", req.url);
const nextPath = `${req.nextUrl.pathname}${req.nextUrl.search}`;
loginUrl.searchParams.set("next", nextPath);
loginUrl.searchParams.set("next", `${pathname}${req.nextUrl.search}`);
return NextResponse.redirect(loginUrl);
}

return NextResponse.next({ request: { headers: req.headers } });
}

export const config = {
matcher: ["/game/:path*"],
// Run on the landing page and all game routes.
matcher: ["/", "/game/:path*"],
};
Loading
Loading