-
-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add trial accounts for Advocate invited users #421
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
af44380
085881b
2cc6255
2f81cde
2d7c9f4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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`), | ||
| ) | ||
| .execute(); | ||
| } | ||
|
|
||
| export async function down(db: Kysely<any>): Promise<void> { | ||
| await db.schema.alterTable("invitation").dropColumn("isTrial").execute(); | ||
| } | ||
| 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(); | ||
| } |
| 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"; | ||||
|
||||
| import "./global.css"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"; | ||
|
||
|
|
||
| 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> | ||
| ); | ||
| } | ||
| 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 = "/"; | ||
| } |
| 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'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> | ||
| ); | ||
| } |
There was a problem hiding this comment.
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 toisTrialhere and usedefaultTo(false)(nosqltag needed) to match existing migrations like1761056533021_invitation_used.ts.