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
15 changes: 15 additions & 0 deletions migrations/1776346034220_invitation_is_trial.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { type Kysely, sql } from "kysely";

export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable("invitation")
.addColumn("isTrial", "boolean", (col) =>
col.notNull().defaultTo(sql`false`),
)
Comment on lines +2 to +9
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This migration uses a snake_case column name (is_trial) with the schema builder. Repo convention is to use camelCase identifiers with Kysely’s schema/query builder (CamelCasePlugin maps to snake_case in Postgres). Rename the column to isTrial here and use defaultTo(false) (no sql tag needed) to match existing migrations like 1761056533021_invitation_used.ts.

Suggested change
import { type Kysely, sql } from "kysely";
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable("invitation")
.addColumn("isTrial", "boolean", (col) =>
col.notNull().defaultTo(sql`false`),
)
import { type Kysely } from "kysely";
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable("invitation")
.addColumn("isTrial", "boolean", (col) => col.notNull().defaultTo(false))

Copilot uses AI. Check for mistakes.
.execute();
}

export async function down(db: Kysely<any>): Promise<void> {
await db.schema.alterTable("invitation").dropColumn("isTrial").execute();
}
10 changes: 10 additions & 0 deletions migrations/1776346034221_user_trial_ends_at.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { Kysely } from "kysely";

export async function up(db: Kysely<any>): Promise<void> {
await db.schema.alterTable("user").addColumn("trialEndsAt", "text").execute();
}

export async function down(db: Kysely<any>): Promise<void> {
await db.schema.alterTable("user").dropColumn("trialEndsAt").execute();
}
4 changes: 4 additions & 0 deletions src/app/(private)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getServerSession } from "@/auth";
import { redirectToLogin } from "@/auth/redirectToLogin";
import SentryFeedbackWidget from "@/components/SentryFeedbackWidget";
import TrialBanner from "@/components/TrialBanner";
import type { Metadata } from "next";

export const metadata: Metadata = {
Expand All @@ -16,8 +17,11 @@ export default async function PrivateLayout({
if (!serverSession.currentUser) {
await redirectToLogin();
}

const { trialEndsAt } = serverSession.currentUser ?? {};
return (
<>
{trialEndsAt && <TrialBanner trialEndsAt={trialEndsAt} />}
{children}
<SentryFeedbackWidget />
</>
Expand Down
29 changes: 29 additions & 0 deletions src/app/HTMLBody.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { IBM_Plex_Mono, IBM_Plex_Sans } from "next/font/google";
import "./global.css";
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HTMLBody imports ./global.css. In Next.js (App Router), global CSS can only be imported from the root app/layout.tsx (or an equivalent top-level entry). Importing it from a shared component will cause a build-time error. Move the global.css import back to src/app/layout.tsx (or convert it to a CSS module if it must be imported here).

Suggested change
import "./global.css";

Copilot uses AI. Check for mistakes.

const ibmPlexSans = IBM_Plex_Sans({
subsets: ["latin"],
weight: ["300", "400", "500", "600", "700"],
variable: "--font-ibm-plex-sans",
display: "swap",
preload: true,
});

const ibmPlexMono = IBM_Plex_Mono({
subsets: ["latin"],
weight: ["400", "500"],
variable: "--font-ibm-plex-mono",
display: "swap",
preload: true,
});

export default function HTMLBody({ children }: { children: React.ReactNode }) {
return (
<html
lang="en"
className={`${ibmPlexSans.variable} ${ibmPlexMono.variable} `}
>
<body className={ibmPlexSans.className + " antialiased"}>{children}</body>
</html>
);
}
72 changes: 62 additions & 10 deletions src/app/global-error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,78 @@

import * as Sentry from "@sentry/nextjs";
import NextError from "next/error";
import Link from "next/link";
import { useEffect } from "react";
import { logout } from "@/auth/logout";
import { TRIAL_EXPIRED_MESSAGE } from "@/constants";
import { Button } from "@/shadcn/ui/button";
import { Card, CardContent, CardTitle } from "@/shadcn/ui/card";
import HTMLBody from "./HTMLBody";
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GlobalError is a client component, but it imports HTMLBody (a server component by default). Client components cannot import server components in the App Router, so this will fail compilation. Inline the <html>/<body> wrapper in global-error.tsx, or refactor HTMLBody so it is client-compatible and does not import server-only modules.

Copilot uses AI. Check for mistakes.

function isTrialExpiredError(error: Error) {
return error.name === "TRPCError" && error.message === TRIAL_EXPIRED_MESSAGE;
}

function TrialExpired() {
return (
<div className="bg-brand-background">
<header className="absolute top-0 left-0 w-full flex items-center h-16 md:h-20">
<div className="w-full max-w-[1440px] px-4 md:px-10 mx-auto">
<Link href="/">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/logo.svg" alt="Mapped" width={28} height={28} />
</Link>
</div>
</header>
<main className="min-h-[100vh] flex justify-center items-center py-[120px] px-6">
<Card className="w-[350px] border-none">
<CardContent className="flex flex-col gap-4">
<CardTitle className="text-2xl">Trial Expired</CardTitle>
<p className="text-sm text-muted-foreground">
Your trial period has ended. Please contact us to continue using
Mapped.
</p>
<div className="flex flex-col gap-2">
<Button asChild size="sm">
<a href="mailto:hello@commonknowledge.coop">Get in touch</a>
</Button>
<Button variant="outline" size="sm" onClick={logout}>
Log out
</Button>
</div>
</CardContent>
</Card>
</main>
</div>
);
}

export default function GlobalError({
error,
}: {
error: Error & { digest?: string };
}) {
useEffect(() => {
Sentry.captureException(error);
if (!isTrialExpiredError(error)) {
Sentry.captureException(error);
}
}, [error]);

if (isTrialExpiredError(error)) {
return (
<HTMLBody>
<TrialExpired />
</HTMLBody>
);
}

return (
<html>
<body>
{/* `NextError` is the default Next.js error page component. Its type
definition requires a `statusCode` prop. However, since the App Router
does not expose status codes for errors, we simply pass 0 to render a
generic error message. */}
<NextError statusCode={0} />
</body>
</html>
<HTMLBody>
{/* `NextError` is the default Next.js error page component. Its type
definition requires a `statusCode` prop. However, since the App Router
does not expose status codes for errors, we simply pass 0 to render a
generic error message. */}
<NextError statusCode={0} />
</HTMLBody>
);
}
64 changes: 20 additions & 44 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { HydrationBoundary, dehydrate } from "@tanstack/react-query";
import { IBM_Plex_Mono, IBM_Plex_Sans } from "next/font/google";
import { cookies } from "next/headers";
import { getServerSession } from "@/auth";
import { ORGANISATION_COOKIE_NAME } from "@/constants";
Expand All @@ -11,26 +10,10 @@ import { TRPCReactProvider } from "@/services/trpc/react";
import { createCaller, getQueryClient, trpc } from "@/services/trpc/server";
import { Toaster } from "@/shadcn/ui/sonner";
import { getAbsoluteUrl } from "@/utils/appUrl";
import HTMLBody from "./HTMLBody";
import type { Organisation } from "@/models/Organisation";
import type { Metadata, Viewport } from "next";
import "nprogress/nprogress.css";
import "./global.css";

const ibmPlexSans = IBM_Plex_Sans({
subsets: ["latin"],
weight: ["300", "400", "500", "600", "700"],
variable: "--font-ibm-plex-sans",
display: "swap",
preload: true,
});

const ibmPlexMono = IBM_Plex_Mono({
subsets: ["latin"],
weight: ["400", "500"],
variable: "--font-ibm-plex-mono",
display: "swap",
preload: true,
});

export const metadata: Metadata = {
metadataBase: new URL(getAbsoluteUrl()),
Expand Down Expand Up @@ -65,31 +48,24 @@ export default async function RootLayout({
const storedOrgId = cookieStore.get(ORGANISATION_COOKIE_NAME)?.value ?? null;

return (
<html
lang="en"
className={`${ibmPlexSans.variable} ${ibmPlexMono.variable} `}
>
<body className={ibmPlexSans.className + " antialiased"}>
<TRPCReactProvider>
<HydrationBoundary state={dehydrate(queryClient)}>
<SessionProvider serverSession={serverSession}>
<PostHogProvider>
<OrganisationProvider
organisations={organisations}
storedOrgId={storedOrgId}
>
<NProgressProvider>
<main className="min-h-screen relative z-10">
{children}
</main>
<Toaster position="top-center" />
</NProgressProvider>
</OrganisationProvider>
</PostHogProvider>
</SessionProvider>
</HydrationBoundary>
</TRPCReactProvider>
</body>
</html>
<HTMLBody>
<TRPCReactProvider>
<HydrationBoundary state={dehydrate(queryClient)}>
<SessionProvider serverSession={serverSession}>
<PostHogProvider>
<OrganisationProvider
organisations={organisations}
storedOrgId={storedOrgId}
>
<NProgressProvider>
<main className="min-h-screen relative z-10">{children}</main>
<Toaster position="top-center" />
</NProgressProvider>
</OrganisationProvider>
</PostHogProvider>
</SessionProvider>
</HydrationBoundary>
</TRPCReactProvider>
</HTMLBody>
);
}
1 change: 1 addition & 0 deletions src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const getServerSession = cache(async (): Promise<ServerSession> => {
name: user.name,
avatarUrl: user.avatarUrl,
role: user.role,
trialEndsAt: user.trialEndsAt,
},
};
}
Expand Down
11 changes: 11 additions & 0 deletions src/auth/logout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { JWT_LIFETIME_SECONDS } from "@/constants";

export async function logout() {
try {
await fetch("/api/logout", { method: "POST" });
} catch {
// Server unavailable so JWT cookie may not be removed - set client side LoggedOut cookie
document.cookie = `LoggedOut=1; path=/; SameSite=lax; max-age=${JWT_LIFETIME_SECONDS}`;
}
window.location.href = "/";
}
1 change: 1 addition & 0 deletions src/authTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface CurrentUser {
name: string;
avatarUrl?: string | null;
role?: UserRole | null;
trialEndsAt?: Date | null;
}

export interface ServerSession {
Expand Down
10 changes: 2 additions & 8 deletions src/components/SidebarUserMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
LogOutIcon,
SettingsIcon,
} from "lucide-react";
import { JWT_LIFETIME_SECONDS } from "@/constants";
import { logout } from "@/auth/logout";
import { useCurrentUser } from "@/hooks";
import { useOrganisations } from "@/hooks/useOrganisations";
import { Avatar, AvatarFallback, AvatarImage } from "@/shadcn/ui/avatar";
Expand Down Expand Up @@ -40,13 +40,7 @@ export default function SidebarUserMenu() {

const onSubmitLogout = async (e: SyntheticEvent<HTMLFormElement>) => {
e.preventDefault();
try {
await fetch("/api/logout", { method: "POST" });
} catch {
// Server unavailable so JWT cookie may not be removed - set client side LoggedOut cookie
document.cookie = `LoggedOut=1; path=/; SameSite=lax; max-age=${JWT_LIFETIME_SECONDS}`;
}
location.href = "/";
await logout();
};

return (
Expand Down
49 changes: 49 additions & 0 deletions src/components/TrialBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"use client";

import { useState } from "react";
import { Alert, AlertDescription } from "@/shadcn/ui/alert";

const DISMISSED_KEY = "mapped-trial-banner-dismissed";

function getDaysRemaining(trialEndsAt: Date) {
const ms = new Date(trialEndsAt).getTime() - Date.now();
if (ms <= 0) return null;
return Math.ceil(ms / (1000 * 60 * 60 * 24));
}

export default function TrialBanner({ trialEndsAt }: { trialEndsAt: Date }) {
const [dismissed, setDismissed] = useState(() => {
if (typeof window === "undefined") return false;
return localStorage.getItem(DISMISSED_KEY) === "true";
});
// useState (not useMemo) because Date.now() triggers the react-hooks/purity lint rule
const [daysRemaining] = useState(() => getDaysRemaining(trialEndsAt));

if (daysRemaining === null || dismissed) {
return null;
}

function handleDismiss() {
localStorage.setItem(DISMISSED_KEY, "true");
setDismissed(true);
}

return (
<Alert className="rounded-none border-x-0 border-t-0 border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950">
<AlertDescription className="flex items-center justify-between">
<span>
You&apos;re on a trial period.{" "}
{daysRemaining === 1
? "1 day remaining."
: `${daysRemaining} days remaining.`}
</span>
<button
onClick={handleDismiss}
className="ml-4 text-sm text-muted-foreground underline hover:text-foreground"
>
Dismiss
</button>
</AlertDescription>
</Alert>
);
}
4 changes: 4 additions & 0 deletions src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export const DATA_RECORDS_JOB_BATCH_SIZE = 100;

export const DEFAULT_AUTH_REDIRECT = "/maps";

export const DEFAULT_TRIAL_PERIOD_DAYS = 30;

export const DEFAULT_ZOOM = 5;

export const DEFAULT_CUSTOM_COLOR = "#3b82f6";
Expand Down Expand Up @@ -42,3 +44,5 @@ export const ORGANISATION_COOKIE_NAME = "MappedOrgId";
export const SORT_BY_LOCATION = "__location";
// Special sort column to sort by `dataSource.columnRoles.nameColumns`
export const SORT_BY_NAME_COLUMNS = "__name";

export const TRIAL_EXPIRED_MESSAGE = "Your trial has expired.";
1 change: 1 addition & 0 deletions src/models/Invitation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const invitationSchema = z.object({
createdAt: z.date(),
updatedAt: z.date(),
used: z.boolean(),
isTrial: z.boolean(),
});

export type Invitation = z.infer<typeof invitationSchema>;
1 change: 1 addition & 0 deletions src/models/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const userSchema = z.object({
avatarUrl: z.string().url().trim().nullish(),
passwordHash: z.string(),
role: z.nativeEnum(UserRole).nullish(),
trialEndsAt: z.date().nullish(),
});

export type User = z.infer<typeof userSchema>;
1 change: 1 addition & 0 deletions src/server/models/Invitation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
export type InvitationTable = Invitation & {
id: GeneratedAlways<string>;
used: Generated<boolean>;
isTrial: Generated<boolean>;
createdAt: ColumnType<Date, string | undefined, never>;
updatedAt: ColumnType<Date, string | undefined, string>;
};
Expand Down
1 change: 1 addition & 0 deletions src/server/models/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
export type UserTable = User & {
id: GeneratedAlways<string>;
createdAt: ColumnType<Date, string | undefined, never>;
trialEndsAt: ColumnType<Date | null, string | undefined, string | undefined>;
};
export type NewUser = Insertable<UserTable>;
export type UserUpdate = Updateable<UserTable>;
Loading
Loading