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
73 changes: 45 additions & 28 deletions e2e/onboarding/interactive-tour.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,49 +21,66 @@ test("Start Now runs the interactive tour happy path", async ({ page }) => {
const card = page.locator("[data-onboarding-tour]");
await expect(card).toContainText("Create with the keyboard");

// Act 1: create, save, moveFocus, editSequence.
await page.keyboard.press("c");
await expect(card).toContainText("Name it and save");

const title = createEventTitle("Tour Event");
await fillTitleAndSaveEventForm(page, title);
await expect(card).toContainText("Move between events");

await page.keyboard.press("ArrowRight");
// moveFocus auto-focuses today's seeded Morning standup event; today has
// several other seeded events, so an arrow key has somewhere real to go.
await page.keyboard.press("ArrowDown");
await expect(card).toContainText("Jump straight to a field");

// E then T is the edit sequence; it acts on whichever event has DOM
// focus. There is only one event on the calendar, so the ArrowRight
// above (there being no adjacent event to move to) may not have kept
// focus on it -- refocus it explicitly, mirroring
// e2e/timed/edit-sequence-title.spec.ts.
const eventButton = page
.locator("#mainGrid")
.getByRole("button", { name: title });
await eventButton.focus();

await page.keyboard.press("e");
await page.keyboard.press("t");
await expect(card).toContainText("Open the command palette");

// Close the form the edit sequence opened before testing the palette
// shortcut, so Escape here closes the form rather than the palette.
await page.keyboard.press("Escape");

// Linux CI uses Ctrl; macOS local runs use Meta. Press both modifiers' chord
// via ControlOrMeta through Playwright's platform-aware ControlOrMeta token.
await page.keyboard.press("ControlOrMeta+k");
// Palette stays open with search focused; the tour must not advance to "?" yet.
await expect(card).toContainText("Open the command palette");
await page.keyboard.press("Escape");
await expect(card).toContainText("Browse every shortcut");

// Shift+/ opens the legend (same as ? on US keyboards) once the calendar has focus.
await page.keyboard.press("Shift+/");
await expect(card).toContainText("That's the basics");

await card.getByRole("button", { name: "I'm done" }).click();
await card.getByRole("button", { name: "Keep going" }).click();
await expect(card).toContainText("Jump to Dentist");

// Act 2: targetEvent, move, resizeEdge, placeDraft, undo. The exact
// Shift-hold jump key is covered by e2e/timed/shift-hold-event-hints.spec.ts;
// here we drive the mission's actual completion signal (Dentist focused),
// same as a jump would leave it.
const dentistButton = page
.locator("#mainGrid")
.getByRole("button", { name: /Dentist/ });
await dentistButton.focus();
await expect(card).toContainText("Move Dentist out of the overlap");

await page.keyboard.press("Shift+ArrowRight");
await expect(card).toContainText("Give Dentist more time");

// A single Tab reaches the end edge (the step seeds edge focus to start).
await page.keyboard.press("Tab");
await page.keyboard.press("Shift+ArrowDown");
await expect(card).toContainText("Place a new event on the grid");

// The step blurs focus on entry, so nothing is focused for the place-draft
// Shift+Arrow to move.
await page.keyboard.press("Shift+ArrowRight");
await expect(card).toContainText("Never stress about a mistake");

await page.keyboard.press("ControlOrMeta+z");
await page.keyboard.press("ControlOrMeta+Shift+z");
await expect(card).toContainText("Graduate to Hardcore Mode");

// Act 3: hardcore graduation, the tour's finale.
await page.keyboard.down("Shift");
await page.keyboard.up("Shift");
await page.keyboard.down("Shift");
await page.keyboard.up("Shift");
await expect(card).toHaveCount(0);

// Leave Hardcore Mode so it doesn't affect other assertions/reload below.
await page.keyboard.down("Shift");
await page.keyboard.up("Shift");
await page.keyboard.down("Shift");
await page.keyboard.up("Shift");

await page.reload({ waitUntil: "domcontentloaded" });
await expect(page.locator("[data-onboarding-tour]")).toHaveCount(0);
});
Expand Down
3 changes: 2 additions & 1 deletion packages/backend/src/billing/billing.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@
* small diff instead of a re-implementation.
*/
export const BILLING_DEFAULTS = {
TRIAL_LENGTH_DAYS: 14,
// Aligned with the client-side anonymous trial clock (packages/web/src/billing/trial.storage.ts).
TRIAL_LENGTH_DAYS: 7,
} as const;
18 changes: 0 additions & 18 deletions packages/web/src/api/billing.api.ts

This file was deleted.

4 changes: 3 additions & 1 deletion packages/web/src/auth/posthog/track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ export type ProductEvent =
| "onboarding_game_skipped"
| "onboarding_game_finished"
| "onboarding_game_replayed"
| "onboarding_step_assist_used"
| "connect_cta_shown"
| "connect_cta_accepted"
| "connect_cta_skipped"
| "trial_cta_shown"
| "trial_started"
| "trial_converted"
| "trial_expired"
| "trial_gate_shown"
| "trial_gate_cta_clicked"
| "shortcut_tip_shown"
| "shortcut_tip_acted_on";

Expand Down
30 changes: 30 additions & 0 deletions packages/web/src/billing/TrialCountdownChip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { type FC } from "react";
import { track } from "@web/auth/posthog/track";
import { useTrialStatus } from "@web/billing/useTrialStatus";
import { useAuthModal } from "@web/components/AuthModal/hooks/useAuthModal";

/**
* Sidebar status bar slot: quiet countdown for the anonymous browser trial.
* Never shown for authenticated users (server billing status governs them).
*/
export const TrialCountdownChip: FC = () => {
const { isExpired, daysLeft, isAnonymousTrial } = useTrialStatus();
const { openModal } = useAuthModal();

if (!isAnonymousTrial || isExpired) return null;

const isUrgent = daysLeft <= 2;

return (
<button
className={`c-focus-ring truncate text-xs ${isUrgent ? "font-semibold text-warning" : "text-text-muted"}`}
onClick={() => {
track("signup_started", { source: "trial_chip" });
openModal("signUp");
}}
type="button"
>
Trial: {daysLeft} {daysLeft === 1 ? "day" : "days"} left
</button>
);
};
93 changes: 93 additions & 0 deletions packages/web/src/billing/TrialGateModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { type FC, useEffect, useRef, useState } from "react";
import { track } from "@web/auth/posthog/track";
import { Z_INDEX_MODAL } from "@web/common/constants/web.constants";
import { runExportMyData } from "@web/common/storage/offline-data/export-user-data.util";
import { useAuthModal } from "@web/components/AuthModal/hooks/useAuthModal";
import { PixelPirateScouting } from "@web/components/WelcomeModal/PixelPirateScouting";
import { useAppLockReason } from "@web/shortcuts/app-lock";

/**
* Full app-lock overlay shown once the anonymous browser trial has expired.
* Unlike every other overlay in onboarding, this one is intentionally NOT
* dismissible on Escape/backdrop — see 06-trial-spec.md. It still must be
* fully keyboard-operable: focus lands here on mount, all actions are real
* buttons, nothing depends on a mouse.
*/
export const TrialGateModal: FC = () => {
useAppLockReason("trialGate", true);
const { openModal } = useAuthModal();
const panelRef = useRef<HTMLDivElement>(null);
const [isExporting, setIsExporting] = useState(false);
const shownRef = useRef(false);

useEffect(() => {
panelRef.current?.focus();
if (!shownRef.current) {
shownRef.current = true;
track("trial_expired");
track("trial_gate_shown");
}
}, []);

const handleExport = async () => {
setIsExporting(true);
track("trial_gate_cta_clicked", { cta: "export" });
try {
await runExportMyData();
} finally {
setIsExporting(false);
}
};

return (
<div
className="fixed inset-0 flex items-center justify-center bg-background/80 p-4 backdrop-blur-sm"
style={{ zIndex: Z_INDEX_MODAL }}
>
<div
ref={panelRef}
aria-label="Your free trial has ended"
aria-modal="true"
className="flex w-120 max-w-full flex-col items-center gap-4 rounded-xl border border-border bg-surface p-8 text-center text-text shadow-xl"
role="dialog"
tabIndex={-1}
>
<PixelPirateScouting className="h-14 w-14" />
<h1 className="font-medium text-xl">Your free trial has ended</h1>
<p className="text-sm text-text-muted">
Sign up to keep using Compass and pick up right where you left off.
</p>
<div className="mt-2 flex w-full flex-col gap-2">
<button
className="c-button c-button-primary c-button-elevated rounded-full px-6 py-2"
onClick={() => {
track("trial_gate_cta_clicked", { cta: "signup" });
openModal("signUp");
}}
type="button"
>
Sign up to continue
</button>
<button
className="c-button c-button-secondary rounded-full px-6 py-2"
onClick={() => {
track("trial_gate_cta_clicked", { cta: "login" });
openModal("login");
}}
type="button"
>
Log in
</button>
</div>
<button
className="c-focus-ring text-text-muted text-xs underline-offset-4 hover:text-text hover:underline"
disabled={isExporting}
onClick={() => void handleExport()}
type="button"
>
{isExporting ? "Exporting…" : "Export my data"}
</button>
</div>
</div>
);
};
36 changes: 36 additions & 0 deletions packages/web/src/billing/trial.storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { STORAGE_KEYS } from "@web/common/constants/storage.constants";
import { persistentBrowserStore } from "@web/common/storage/browser-key-value.store";

/** Matches the server trial length (billing.constants.ts TRIAL_LENGTH_DAYS). */
export const TRIAL_LENGTH_DAYS = 7;

const MS_PER_DAY = 24 * 60 * 60 * 1000;

/**
* Anonymous-only, client-side trial clock: no server identity exists yet to
* track it against. Deliberately unsophisticated — clearing storage renews
* the trial, and that's an accepted tradeoff, not a bug to fix.
*/
/** Returns true the one time it actually stamps the start (a fresh trial). */
export function ensureTrialStarted(): boolean {
if (!persistentBrowserStore.isAvailable()) return false;
if (persistentBrowserStore.get(STORAGE_KEYS.TRIAL_STARTED_AT)) return false;
persistentBrowserStore.set(
STORAGE_KEYS.TRIAL_STARTED_AT,
new Date().toISOString(),
);
return true;
}

/** Days remaining, floored at 0. Treats missing/unreadable state as a full trial. */
export function getTrialDaysLeft(): number {
if (!persistentBrowserStore.isAvailable()) return TRIAL_LENGTH_DAYS;
const startedAt = persistentBrowserStore.get(STORAGE_KEYS.TRIAL_STARTED_AT);
if (!startedAt) return TRIAL_LENGTH_DAYS;

const startedMs = Date.parse(startedAt);
if (Number.isNaN(startedMs)) return TRIAL_LENGTH_DAYS;

const elapsedDays = Math.floor((Date.now() - startedMs) / MS_PER_DAY);
return Math.max(0, TRIAL_LENGTH_DAYS - elapsedDays);
}
71 changes: 71 additions & 0 deletions packages/web/src/billing/useTrialStatus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { renderHook } from "@testing-library/react";
import * as authStateUtil from "@web/auth/compass/state/auth.state.util";
import { useTrialStatus } from "@web/billing/useTrialStatus";
import { STORAGE_KEYS } from "@web/common/constants/storage.constants";
import { persistentBrowserStore } from "@web/common/storage/browser-key-value.store";
import { beforeEach, describe, expect, it, spyOn } from "bun:test";

const daysAgo = (days: number) =>
new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();

describe("useTrialStatus", () => {
beforeEach(() => {
localStorage.clear();
});

it("starts the clock on first use and reports a full trial", () => {
const { result } = renderHook(() => useTrialStatus());

expect(result.current.isExpired).toBe(false);
expect(result.current.daysLeft).toBe(7);
expect(result.current.isAnonymousTrial).toBe(true);
expect(
persistentBrowserStore.get(STORAGE_KEYS.TRIAL_STARTED_AT),
).toBeTruthy();
});

it("counts down without expiring inside the window", () => {
persistentBrowserStore.set(STORAGE_KEYS.TRIAL_STARTED_AT, daysAgo(5));

const { result } = renderHook(() => useTrialStatus());

expect(result.current.daysLeft).toBe(2);
expect(result.current.isExpired).toBe(false);
});

it("expires once the window has passed", () => {
persistentBrowserStore.set(STORAGE_KEYS.TRIAL_STARTED_AT, daysAgo(8));

const { result } = renderHook(() => useTrialStatus());

expect(result.current.daysLeft).toBe(0);
expect(result.current.isExpired).toBe(true);
});

// Regression: `authenticated` from SessionContext is false until the async
// SuperTokens check resolves, so it cannot be the only guard. Someone who
// tried Compass anonymously, signed up, and kept the same browser still has
// a stale trial.started-at; gating on the context alone flashed "your trial
// has ended" at them on every load.
//
// hasUserEverAuthenticated is spied directly rather than driven through
// real localStorage state: several other test files in this suite
// mock.module the whole auth.state.util module with a bare `hasAuthenticated`
// stub and no restoration, which leaks process-wide across bun test files -
// spying on this file's own resolved binding sidesteps that ordering-
// dependent pollution instead of adding to it.
it("never gates a user who has authenticated before, despite a stale expired clock", () => {
persistentBrowserStore.set(STORAGE_KEYS.TRIAL_STARTED_AT, daysAgo(30));
const hasUserEverAuthenticatedSpy = spyOn(
authStateUtil,
"hasUserEverAuthenticated",
).mockReturnValue(true);

const { result } = renderHook(() => useTrialStatus());

expect(result.current.isExpired).toBe(false);
expect(result.current.isAnonymousTrial).toBe(false);

hasUserEverAuthenticatedSpy.mockRestore();
});
});
Loading