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
7 changes: 5 additions & 2 deletions apps/app/src/app/[lang]/profile/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { redirect } from "next/navigation";

import { ProfileShell } from "@/features/organizations/client";
import { getFullDictionary } from "@/i18n/dictionaries";
import { AnalyticsArea } from "@/shared/analytics/analytics-area";
import { ProductAnalytics } from "@/shared/analytics/product-analytics";
import { api, HydrateClient } from "@/shared/api/trpc/server";

Expand Down Expand Up @@ -36,9 +37,11 @@ export default async function RootLayout({
const dict = await getFullDictionary(lang);

return (
<SciblyPostHogProvider surface="app">
<ProductAnalytics />
<SciblyPostHogProvider surface="app" identity={{ userId: session.user.id }}>
<HydrateClient>
{/* Inside the boundary so AnalyticsArea's organization query reads the cache prefetched above. */}
<ProductAnalytics />
<AnalyticsArea />
<ErrorBoundaryWrapper>
<ProfileShell
user={{
Expand Down
7 changes: 7 additions & 0 deletions apps/app/src/features/organizations/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ at once. Says nothing on its own about what they may do — that is the
membership's to say.
_Avoid_: account, profile

**Area**:
Where a user currently is: their own space, or one organization. Never a synonym
for Organization — an area may be personal, an organization never is, and only
an organization area has a role attached to it.
_Avoid_: workspace (an integration's word for the provider's own container),
tenant, scope, section

### Who belongs to it

**Member**:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { ProfileShellController } from "./profile-shell";

import { signOut } from "@scibly/auth/client";
import { getInitials } from "@scibly/lib";
import { usePostHog } from "@scibly/observability/client";
import { routes } from "@scibly/routes";
import {
Avatar,
Expand Down Expand Up @@ -102,6 +103,8 @@ export function ProfileTopBar({
displayAvatar: string | null;
openMobileNavigation: () => void;
}) {
const posthog = usePostHog();

return (
<div className="border-hairline flex h-14 shrink-0 items-center justify-between border-b bg-white px-4 md:px-8">
<div className="flex items-center gap-2">
Expand Down Expand Up @@ -133,6 +136,8 @@ export function ProfileTopBar({
<button
onClick={async () => {
await signOut();
// PostHog does not infer identity from the session ending, and reset() also wipes its own consent record — restored below from our consent cookie.
posthog.reset();
window.location.href = routes.app.auth.default;
}}
className="text-ink-faint hover:bg-ink/[0.05] hover:text-ink rounded-xl p-1.5 transition-colors"
Expand Down
74 changes: 74 additions & 0 deletions apps/app/src/shared/analytics/analytics-area.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { render } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { AnalyticsArea } from "./analytics-area";

const useParams = vi.hoisted(() => vi.fn());
const useQuery = vi.hoisted(() => vi.fn());
const setAnalyticsContext = vi.hoisted(() => vi.fn());

vi.mock("next/navigation", () => ({ useParams }));
vi.mock("@/shared/api/trpc/client", () => ({
api: { organization: { listMyOrgs: { useQuery } } },
}));
vi.mock("@scibly/observability/event-context", () => ({ setAnalyticsContext }));

const acme = { id: "org_1", slug: "acme", name: "Acme", role: "owner" };
const globex = { id: "org_2", slug: "globex", name: "Globex", role: "member" };

function at(orgSlug: string | undefined) {
useParams.mockReturnValue(orgSlug ? { orgSlug } : {});
}

beforeEach(() => {
vi.clearAllMocks();
useQuery.mockReturnValue({ data: [acme, globex] });
at(undefined);
});

describe("AnalyticsArea", () => {
it("reports the organization the route is in", () => {
at("acme");
render(<AnalyticsArea />);

expect(setAnalyticsContext).toHaveBeenCalledWith({
area: "organization",
org_id: "org_1",
org_name: "Acme",
role: "owner",
});
});

it("reports the user's own space off the organization routes", () => {
render(<AnalyticsArea />);

expect(setAnalyticsContext).toHaveBeenCalledWith({ area: "personal" });
});

it("replaces the whole context on the way out of an organization", () => {
at("acme");
const view = render(<AnalyticsArea />);

at(undefined);
view.rerender(<AnalyticsArea />);

// Replaced, not merged — otherwise `role` would outlive the organization it came from.
expect(setAnalyticsContext).toHaveBeenLastCalledWith({ area: "personal" });
});

// Kept apart from "personal": calling an organization route personal while data is still missing is a wrong answer, not a missing one.
it("stays quiet while the organization list is still loading", () => {
at("acme");
useQuery.mockReturnValue({ data: undefined });
render(<AnalyticsArea />);

expect(setAnalyticsContext).not.toHaveBeenCalled();
});

it("stays quiet on an organization the user is not a member of", () => {
at("initech");
render(<AnalyticsArea />);

expect(setAnalyticsContext).not.toHaveBeenCalled();
});
});
32 changes: 32 additions & 0 deletions apps/app/src/shared/analytics/analytics-area.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"use client";

import { setAnalyticsContext } from "@scibly/observability/event-context";
import { useParams } from "next/navigation";
import { useEffect } from "react";

import { api } from "@/shared/api/trpc/client";

/** Held by the observability package rather than PostHog's own store, which it clears on every `reset()` — including the ones cookieless mode triggers on its own. */
export function AnalyticsArea() {
const orgSlug = useParams<{ orgSlug?: string }>().orgSlug ?? null;
const orgsQuery = api.organization.listMyOrgs.useQuery();
const org = orgsQuery.data?.find((o) => o.slug === orgSlug) ?? null;

useEffect(() => {
// Don't call an organization route personal just because the list is still in flight.
if (orgSlug !== null && !org) return;

setAnalyticsContext(
org
? {
area: "organization",
org_id: org.id,
org_name: org.name,
role: org.role,
}
: { area: "personal" },
);
}, [org, orgSlug]);

return null;
}
22 changes: 2 additions & 20 deletions apps/app/src/shared/analytics/product-analytics.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,22 +69,6 @@ describe("ProductAnalytics", () => {
);
});

it("carries the organization the action happened in", async () => {
renderWithClient(
<RunMutation
path="organization.inviteMembers"
variables={{ orgSlug: "acme" }}
/>,
);

await waitFor(() =>
expect(capture).toHaveBeenCalledWith("members_invited", {
kind: undefined,
orgSlug: "acme",
}),
);
});

it("resolves procedures nested under a sub-router", async () => {
renderWithClient(<RunMutation path="notebook.source.addText" />);

Expand All @@ -106,12 +90,12 @@ describe("ProductAnalytics", () => {
await waitFor(() =>
expect(capture).toHaveBeenCalledWith("checkout_started", {
kind: "topup",
orgSlug: "acme",
pack: "large",
}),
);
});

// orgSlug is deliberately not expected back out: AnalyticsArea already reports the organization.
it("leaves out mutation input that is not worth reporting", async () => {
renderWithClient(
<RunMutation
Expand All @@ -123,7 +107,6 @@ describe("ProductAnalytics", () => {
await waitFor(() =>
expect(capture).toHaveBeenCalledWith("course_created", {
kind: undefined,
orgSlug: "acme",
}),
);
});
Expand Down Expand Up @@ -153,8 +136,7 @@ describe("ProductAnalytics", () => {
expect(capture).toHaveBeenCalledTimes(1);
});

// Both negatives run a tracked mutation alongside the one under test and wait for *its*
// event, so the cache has drained before we conclude nothing was reported.
// Runs a tracked mutation alongside the untracked one and waits for *its* event, so the cache has drained before concluding nothing else was reported.
it("stays silent for mutations that are not on the allowlist", async () => {
renderWithClient(
<>
Expand Down
3 changes: 2 additions & 1 deletion apps/app/src/shared/analytics/product-analytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ const TRACKED_BY_PATH = new Map<string, TrackedMutation>(
Object.entries(TRACKED_MUTATIONS),
);

const TRACKED_INPUTS = ["orgSlug", "pack", "quantity"];
// orgSlug is not here: AnalyticsArea puts the organization on every event already.
const TRACKED_INPUTS = ["pack", "quantity"];

function trackedInputsOf(variables: unknown) {
const properties: Record<string, string | number> = {};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
toPillarId,
} from "@/app/[lang]/components/marketing-tokens";
import { SciblyMark } from "@/components/brand-logo";
import { useInViewOnce } from "@/components/in-view-reveal";

import { type ComparisonDictionary } from "./i18n/comparison.types";

Expand All @@ -38,15 +37,10 @@ const boardKeyClass =
"mt-[1px] inline-flex shrink-0 items-center gap-[7px] rounded-[11px] bg-[#0066FF] px-[11px] py-[7px] text-[11.5px] leading-none font-bold text-white shadow-[0_2px_0_0_#0046ad] transition-transform duration-150 ease-press group-hover:-translate-y-[2px] sm:mt-0 sm:size-[26px] sm:justify-center sm:gap-0 sm:rounded-[8px] sm:p-0";

export function ComparisonSection({ t }: ComparisonSectionProps) {
const { ref, inView } = useInViewOnce<HTMLElement>(0.16);
const lastIndex = t.table.rows.length - 1;

return (
<MarketingSection
ref={ref}
id="comparison"
aria-labelledby="comparison-heading"
>
<MarketingSection id="comparison" aria-labelledby="comparison-heading">
<h2 id="comparison-heading" className={cn(titleClass, "max-w-[680px]")}>
{t.title1} {t.title2}
</h2>
Expand Down Expand Up @@ -98,15 +92,13 @@ export function ComparisonSection({ t }: ComparisonSectionProps) {
<li
key={row.label}
className={cn(
"group grid transition-opacity duration-500 ease-out motion-reduce:transition-none",
"sc-reveal group grid",
"max-sm:border-hairline max-sm:overflow-hidden max-sm:rounded-[20px] max-sm:border-2 max-sm:bg-white max-sm:shadow-[0_4px_0_0_var(--color-lip)]",
columnsClass,
inView ? "opacity-100" : "sc-reveal-hidden",
)}
style={{
"--row-wash": tint(pillar.softColor, 26),
transitionDelay: inView ? `${70 + index * 55}ms` : "0ms",
}}
// Each row runs its own scroll timeline, so they stagger by how
// far apart they sit rather than by a delay we have to pick.
style={{ "--row-wash": tint(pillar.softColor, 26) }}
>
<div className="border-ground flex items-center gap-3 border-b-2 px-4 py-3.5 sm:border-0 sm:px-0 sm:pt-[clamp(14px,1.8vw,19px)] sm:pr-[clamp(12px,2vw,24px)] sm:pb-[clamp(14px,1.8vw,19px)]">
<ChapterKey id={kind} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,15 +93,7 @@ export function ProductPreviewSection({
className="scroll-mt-16"
atmosphere={<ProductPreviewAtmosphere />}
>
<div
className={cn(
"transition-[opacity,translate] duration-700",
REVEAL_EASING,
hasSeen
? "translate-y-0 opacity-100"
: "sc-reveal-hidden translate-y-4",
)}
>
<div className="sc-reveal">
<MarketingSectionHeader
titleId="product-preview-heading"
eyebrow={t.previewSection.eyebrow}
Expand Down Expand Up @@ -163,17 +155,13 @@ export function ProductPreviewSection({
chapters use */}
<div
className={cn(
"min-w-0 rounded-[28px] border p-3 duration-700 md:p-[18px]",
"transition-[opacity,translate,background-color,border-color,box-shadow]",
"sc-reveal min-w-0 rounded-[28px] border p-3 duration-700 md:p-[18px]",
// The wash still transitions, because switching tabs recolours the
// stage in place. Only the entrance moved to the scroll timeline.
"transition-[background-color,border-color,box-shadow]",
REVEAL_EASING,
hasSeen
? "translate-y-0 opacity-100"
: "sc-reveal-hidden translate-y-6",
)}
style={{
...washStyle(pillar),
transitionDelay: hasSeen ? "140ms" : "0ms",
}}
style={washStyle(pillar)}
>
<div id="product-preview-stage" role="tabpanel">
<div key={tab} className="product-preview-swap">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ import { type ComponentPropsWithoutRef, type ReactNode, type Ref } from "react";
import { MarketingGridField } from "./marketing-grid-field";
import { MarketingSectionFrame } from "./marketing-section-frame";

// `clip` and not `hidden`: `overflow-hidden` makes the section a scroll container, which breaks `position: sticky` descendants and rebinds `animation-timeline: view()` to a scroller that never moves.
export const marketingPageSectionClass =
"font-display relative z-10 overflow-hidden bg-white";
"font-display relative z-10 overflow-clip bg-white";

// `overflow-hidden` would make the section a scroll container, which silently stops `position: sticky` descendants from sticking, so this only clips the horizontal axis.
// Same reasoning, clipped on the inline axis only so a sticky child can still travel past the section's own block-axis box.
export const marketingPageSectionStickyClass =
"font-display relative z-10 overflow-x-clip bg-white";

Expand Down
2 changes: 0 additions & 2 deletions apps/web/src/app/[lang]/components/navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,6 @@ export function Navbar({ t }: NavbarProps) {
</div>
</div>

{/* Mobile: sign in + primary action + menu toggle */}
<div className="flex items-center gap-2.5 md:hidden">
<a
href={routes.app.auth.signIn}
Expand Down Expand Up @@ -318,7 +317,6 @@ export function Navbar({ t }: NavbarProps) {
</div>
</div>

{/* Mobile Menu Overlay */}
{isMobileMenuOpen && (
<div
id="navbar-mobile-menu"
Expand Down
9 changes: 0 additions & 9 deletions apps/web/src/app/[lang]/loading.tsx

This file was deleted.

9 changes: 0 additions & 9 deletions apps/web/src/app/loading.tsx

This file was deleted.

28 changes: 0 additions & 28 deletions apps/web/src/components/localized-loading-overlay.tsx

This file was deleted.

Loading
Loading