From cdc415079cf5771d0e47deaab9d16d7c208dac42 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 20 Mar 2025 22:16:02 -0400 Subject: [PATCH] add supporer key program --- server/db/schema.ts | 10 + server/lib/config.ts | 101 ++++- server/routers/external.ts | 5 +- server/routers/internal.ts | 11 +- .../routers/supporterKey/hideSupporterKey.ts | 35 ++ server/routers/supporterKey/index.ts | 3 + .../supporterKey/isSupporterKeyVisible.ts | 54 +++ .../supporterKey/validateSupporterKey.ts | 115 ++++++ server/setup/migrations.ts | 4 +- server/setup/scripts/1.1.0.ts | 28 ++ .../[resourceId]/ResourceAuthPortal.tsx | 36 +- src/app/layout.tsx | 133 ++++--- src/components/Header.tsx | 9 +- src/components/SupporterStatus.tsx | 364 ++++++++++++++++++ src/contexts/supporterStatusContext.ts | 16 + src/hooks/useSupporterStatusContext.ts | 12 + src/providers/SupporterStatusProvider.tsx | 46 +++ 17 files changed, 908 insertions(+), 74 deletions(-) create mode 100644 server/routers/supporterKey/hideSupporterKey.ts create mode 100644 server/routers/supporterKey/index.ts create mode 100644 server/routers/supporterKey/isSupporterKeyVisible.ts create mode 100644 server/routers/supporterKey/validateSupporterKey.ts create mode 100644 server/setup/scripts/1.1.0.ts create mode 100644 src/components/SupporterStatus.tsx create mode 100644 src/contexts/supporterStatusContext.ts create mode 100644 src/hooks/useSupporterStatusContext.ts create mode 100644 src/providers/SupporterStatusProvider.tsx diff --git a/server/db/schema.ts b/server/db/schema.ts index 3d5f234f..02fe02eb 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -405,6 +405,15 @@ export const resourceRules = sqliteTable("resourceRules", { value: text("value").notNull() }); +export const supporterKey = sqliteTable("supporterKey", { + keyId: integer("keyId").primaryKey({ autoIncrement: true }), + key: text("key").notNull(), + githubUsername: text("githubUsername").notNull(), + phrase: text("phrase"), + tier: text("tier"), + valid: integer("valid", { mode: "boolean" }).notNull().default(false) +}); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -439,3 +448,4 @@ export type ResourceWhitelist = InferSelectModel; export type VersionMigration = InferSelectModel; export type ResourceRule = InferSelectModel; export type Domain = InferSelectModel; +export type SupporterKey = InferSelectModel; diff --git a/server/lib/config.ts b/server/lib/config.ts index a04285d3..3f2902cc 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -10,6 +10,8 @@ import { } from "@server/lib/consts"; import { passwordSchema } from "@server/auth/passwordSchema"; import stoi from "./stoi"; +import db from "@server/db"; +import { SupporterKey, supporterKey } from "@server/db/schema"; const portSchema = z.number().positive().gt(0).lte(65535); @@ -155,6 +157,10 @@ const configSchema = z.object({ export class Config { private rawConfig!: z.infer; + supporterData: SupporterKey | null = null; + + supporterHiddenUntil: number | null = null; + constructor() { this.loadConfig(); } @@ -183,7 +189,9 @@ export class Config { } if (process.env.APP_BASE_DOMAIN) { - console.log("You're using deprecated environment variables. Transition to the configuration file. https://docs.fossorial.io/"); + console.log( + "You're using deprecated environment variables. Transition to the configuration file. https://docs.fossorial.io/" + ); } if (!environment) { @@ -235,6 +243,17 @@ export class Config { : "false"; process.env.DASHBOARD_URL = parsedConfig.data.app.dashboard_url; + try { + this.checkSupporterKey(); + } catch (error) { + console.error("Error checking supporter key:", error); + } + + if (this.supporterData) { + process.env.SUPPORTER_DATA = JSON.stringify(this.supporterData); + console.log("Thank you for being a supporter of Pangolin!"); + } + this.rawConfig = parsedConfig.data; } @@ -251,6 +270,86 @@ export class Config { public getDomain(domainId: string) { return this.rawConfig.domains[domainId]; } + + public hideSupporterKey(days: number = 7) { + const now = new Date().getTime(); + + if (this.supporterHiddenUntil && now < this.supporterHiddenUntil) { + return; + } + + this.supporterHiddenUntil = now + 1000 * 60 * 60 * 24 * days; + } + + public isSupporterKeyHidden() { + const now = new Date().getTime(); + + if (this.supporterHiddenUntil && now < this.supporterHiddenUntil) { + return true; + } + + return false; + } + + public async checkSupporterKey() { + const [key] = await db.select().from(supporterKey).limit(1); + + if (!key) { + return; + } + + const { key: licenseKey, githubUsername } = key; + + const response = await fetch( + "https://api.dev.fossorial.io/api/v1/license/validate", + { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + licenseKey, + githubUsername + }) + } + ); + + if (!response.ok) { + this.supporterData = key; + return; + } + + const data = await response.json(); + + if (!data.data.valid) { + this.supporterData = { + ...key, + valid: false + }; + return; + } + + this.supporterData = { + ...key, + valid: true + }; + + // update the supporter key in the database + await db.transaction(async (trx) => { + await trx.delete(supporterKey); + await trx.insert(supporterKey).values({ + githubUsername, + key: licenseKey, + tier: data.data.tier || null, + phrase: data.data.cutePhrase || null, + valid: true + }); + }); + } + + public getSupporterData() { + return this.supporterData; + } } export const config = new Config(); diff --git a/server/routers/external.ts b/server/routers/external.ts index f22fb281..c3f584c4 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -8,6 +8,7 @@ import * as target from "./target"; import * as user from "./user"; import * as auth from "./auth"; import * as role from "./role"; +import * as supporterKey from "./supporterKey"; import * as accessToken from "./accessToken"; import HttpCode from "@server/types/HttpCode"; import { @@ -239,7 +240,6 @@ authenticated.delete( target.deleteTarget ); - authenticated.put( "/org/:orgId/role", verifyOrgAccess, @@ -382,6 +382,9 @@ authenticated.get( authenticated.get(`/org/:orgId/overview`, verifyOrgAccess, org.getOrgOverview); +authenticated.post(`/supporter-key/validate`, supporterKey.validateSupporterKey); +authenticated.post(`/supporter-key/hide`, supporterKey.hideSupporterKey); + unauthenticated.get("/resource/:resourceId/auth", resource.getResourceAuthInfo); // authenticated.get( diff --git a/server/routers/internal.ts b/server/routers/internal.ts index ead70d13..aaa955e6 100644 --- a/server/routers/internal.ts +++ b/server/routers/internal.ts @@ -4,8 +4,12 @@ import * as traefik from "@server/routers/traefik"; import * as resource from "./resource"; import * as badger from "./badger"; import * as auth from "@server/routers/auth"; +import * as supporterKey from "@server/routers/supporterKey"; import HttpCode from "@server/types/HttpCode"; -import { verifyResourceAccess, verifySessionUserMiddleware } from "@server/middlewares"; +import { + verifyResourceAccess, + verifySessionUserMiddleware +} from "@server/middlewares"; // Root routes const internalRouter = Router(); @@ -28,6 +32,11 @@ internalRouter.post( resource.getExchangeToken ); +internalRouter.get( + `/supporter-key/visible`, + supporterKey.isSupporterKeyVisible +); + // Gerbil routes const gerbilRouter = Router(); internalRouter.use("/gerbil", gerbilRouter); diff --git a/server/routers/supporterKey/hideSupporterKey.ts b/server/routers/supporterKey/hideSupporterKey.ts new file mode 100644 index 00000000..f9d4e89b --- /dev/null +++ b/server/routers/supporterKey/hideSupporterKey.ts @@ -0,0 +1,35 @@ +import { Request, Response, NextFunction } from "express"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { response as sendResponse } from "@server/lib"; +import config from "@server/lib/config"; + +export type HideSupporterKeyResponse = { + hidden: boolean; +}; + +export async function hideSupporterKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + config.hideSupporterKey(); + + return sendResponse(res, { + data: { + hidden: true + }, + success: true, + error: false, + message: "Hidden", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/supporterKey/index.ts b/server/routers/supporterKey/index.ts new file mode 100644 index 00000000..4e339a69 --- /dev/null +++ b/server/routers/supporterKey/index.ts @@ -0,0 +1,3 @@ +export * from "./validateSupporterKey"; +export * from "./isSupporterKeyVisible"; +export * from "./hideSupporterKey"; diff --git a/server/routers/supporterKey/isSupporterKeyVisible.ts b/server/routers/supporterKey/isSupporterKeyVisible.ts new file mode 100644 index 00000000..0247aca6 --- /dev/null +++ b/server/routers/supporterKey/isSupporterKeyVisible.ts @@ -0,0 +1,54 @@ +import { Request, Response, NextFunction } from "express"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { response as sendResponse } from "@server/lib"; +import config from "@server/lib/config"; +import db from "@server/db"; +import { count } from "drizzle-orm"; +import { users } from "@server/db/schema"; + +export type IsSupporterKeyVisibleResponse = { + visible: boolean; +}; + +const USER_LIMIT = 5; + +export async function isSupporterKeyVisible( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const hidden = config.isSupporterKeyHidden(); + const key = config.getSupporterData(); + + let visible = !hidden && key?.valid !== true; + + if (key?.tier === "Limited Supporter") { + const [numUsers] = await db.select({ count: count() }).from(users); + + if (numUsers.count > USER_LIMIT) { + visible = true; + } + } + + logger.debug(`Supporter key visible: ${visible}`); + logger.debug(JSON.stringify(key)); + + return sendResponse(res, { + data: { + visible + }, + success: true, + error: false, + message: "Status", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/supporterKey/validateSupporterKey.ts b/server/routers/supporterKey/validateSupporterKey.ts new file mode 100644 index 00000000..bbe7ed48 --- /dev/null +++ b/server/routers/supporterKey/validateSupporterKey.ts @@ -0,0 +1,115 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { response as sendResponse } from "@server/lib"; +import { suppressDeprecationWarnings } from "moment"; +import { supporterKey } from "@server/db/schema"; +import db from "@server/db"; +import { eq } from "drizzle-orm"; +import config from "@server/lib/config"; + +const validateSupporterKeySchema = z + .object({ + githubUsername: z.string().nonempty(), + key: z.string().nonempty() + }) + .strict(); + +export type ValidateSupporterKeyResponse = { + valid: boolean; + githubUsername?: string; + tier?: string; + phrase?: string; +}; + +export async function validateSupporterKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = validateSupporterKeySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { githubUsername, key } = parsedBody.data; + + const response = await fetch( + "https://api.dev.fossorial.io/api/v1/license/validate", + { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + licenseKey: key, + githubUsername: githubUsername + }) + } + ); + + if (!response.ok) { + logger.error(response); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred" + ) + ); + } + + const data = await response.json(); + + if (!data || !data.data.valid) { + return sendResponse(res, { + data: { + valid: false + }, + success: true, + error: false, + message: "Invalid supporter key", + status: HttpCode.OK + }); + } + + await db.transaction(async (trx) => { + await trx.delete(supporterKey); + await trx.insert(supporterKey).values({ + githubUsername: githubUsername, + key: key, + tier: data.data.tier || null, + phrase: data.data.cutePhrase || null, + valid: true + }); + }); + + await config.checkSupporterKey(); + + return sendResponse(res, { + data: { + valid: true, + githubUsername: data.data.githubUsername, + tier: data.data.tier, + phrase: data.data.cutePhrase + }, + success: true, + error: false, + message: "Valid supporter key", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/setup/migrations.ts b/server/setup/migrations.ts index 87eec21f..ad081b6e 100644 --- a/server/setup/migrations.ts +++ b/server/setup/migrations.ts @@ -17,6 +17,7 @@ import m8 from "./scripts/1.0.0-beta12"; import m13 from "./scripts/1.0.0-beta13"; import m15 from "./scripts/1.0.0-beta15"; import m16 from "./scripts/1.0.0"; +import m17 from "./scripts/1.1.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -33,7 +34,8 @@ const migrations = [ { version: "1.0.0-beta.12", run: m8 }, { version: "1.0.0-beta.13", run: m13 }, { version: "1.0.0-beta.15", run: m15 }, - { version: "1.0.0", run: m16 } + { version: "1.0.0", run: m16 }, + { version: "1.1.0", run: m17 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scripts/1.1.0.ts b/server/setup/scripts/1.1.0.ts new file mode 100644 index 00000000..8bd2cd19 --- /dev/null +++ b/server/setup/scripts/1.1.0.ts @@ -0,0 +1,28 @@ +import db from "@server/db"; +import { sql } from "drizzle-orm"; + +const version = "1.1.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + try { + db.transaction((trx) => { + trx.run(sql`CREATE TABLE 'supporterKey' ( + 'keyId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'key' text NOT NULL, + 'githubUsername' text NOT NULL, + 'phrase' text, + 'tier' text, + 'valid' integer DEFAULT false NOT NULL +);`); + }); + + console.log(`Migrated database schema`); + } catch (e) { + console.log("Unable to migrate database schema"); + throw e; + } + + console.log(`${version} migration complete`); +} diff --git a/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx b/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx index 2c0c54e2..2480cd67 100644 --- a/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx +++ b/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx @@ -23,14 +23,7 @@ import { FormLabel, FormMessage } from "@/components/ui/form"; -import { - LockIcon, - Binary, - Key, - User, - Send, - AtSign -} from "lucide-react"; +import { LockIcon, Binary, Key, User, Send, AtSign } from "lucide-react"; import { InputOTP, InputOTPGroup, @@ -50,6 +43,7 @@ import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import Link from "next/link"; +import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext"; const pinSchema = z.object({ pin: z @@ -115,6 +109,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { const api = createApiClient({ env }); + const { supporterStatus } = useSupporterStatusContext(); + function getDefaultSelectedMethod() { if (props.methods.sso) { return "sso"; @@ -194,7 +190,10 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { const session = res.data.data.session; if (session) { - window.location.href = appendRequestToken(props.redirect, session); + window.location.href = appendRequestToken( + props.redirect, + session + ); } }) .catch((e) => { @@ -216,7 +215,10 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { setPincodeError(null); const session = res.data.data.session; if (session) { - window.location.href = appendRequestToken(props.redirect, session); + window.location.href = appendRequestToken( + props.redirect, + session + ); } }) .catch((e) => { @@ -241,7 +243,10 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { setPasswordError(null); const session = res.data.data.session; if (session) { - window.location.href = appendRequestToken(props.redirect, session); + window.location.href = appendRequestToken( + props.redirect, + session + ); } }) .catch((e) => { @@ -621,6 +626,15 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { + {supporterStatus?.visible && ( +
+ + Server is running without a supporter key. +
+ Consider supporting the project! +
+
+ )} ) : ( diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 8a60bc42..78217a50 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -8,6 +8,10 @@ import { Separator } from "@app/components/ui/separator"; import { pullEnv } from "@app/lib/pullEnv"; import { BookOpenText, ExternalLink } from "lucide-react"; import Image from "next/image"; +import SupportStatusProvider from "@app/providers/SupporterStatusProvider"; +import { createApiClient, internal, priv } from "@app/lib/api"; +import { AxiosResponse } from "axios"; +import { IsSupporterKeyVisibleResponse } from "@server/routers/supporterKey"; export const metadata: Metadata = { title: `Dashboard - Pangolin`, @@ -24,6 +28,15 @@ export default async function RootLayout({ }>) { const env = pullEnv(); + let supporterData = { + visible: true + }; + + const res = await priv.get< + AxiosResponse + >("supporter-key/visible"); + supporterData.visible = res.data.data.visible; + const version = env.app.version; return ( @@ -36,65 +49,69 @@ export default async function RootLayout({ disableTransitionOnChange > - {/* Main content */} -
{children}
- - {/* Footer */} - + + {/* Footer */} + +
diff --git a/src/components/Header.tsx b/src/components/Header.tsx index b3153220..f1823cc3 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -24,6 +24,7 @@ import { useRouter } from "next/navigation"; import { useState } from "react"; import { useUserContext } from "@app/hooks/useUserContext"; import ProfileIcon from "./ProfileIcon"; +import SupporterStatus from "./SupporterStatus"; type HeaderProps = { orgId?: string; @@ -42,7 +43,13 @@ export function Header({ orgId, orgs }: HeaderProps) { return ( <>
- +
+ + +
+ +
+
diff --git a/src/components/SupporterStatus.tsx b/src/components/SupporterStatus.tsx new file mode 100644 index 00000000..125c6522 --- /dev/null +++ b/src/components/SupporterStatus.tsx @@ -0,0 +1,364 @@ +"use client"; + +import Image from "next/image"; +import { Separator } from "@app/components/ui/separator"; +import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext"; +import { useState } from "react"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { Button } from "./ui/button"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "./Credenza"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "./ui/form"; +import { Input } from "./ui/input"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { AxiosResponse } from "axios"; +import { ValidateSupporterKeyResponse } from "@server/routers/supporterKey"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle +} from "./ui/card"; +import { Check, ExternalLink } from "lucide-react"; + +const formSchema = z.object({ + githubUsername: z + .string() + .nonempty({ message: "GitHub username is required" }), + key: z.string().nonempty({ message: "Supporter key is required" }) +}); + +export default function SupporterStatus() { + const { supporterStatus, updateSupporterStatus } = + useSupporterStatusContext(); + const [supportOpen, setSupportOpen] = useState(false); + const [keyOpen, setKeyOpen] = useState(false); + const [purchaseOptionsOpen, setPurchaseOptionsOpen] = useState(false); + + const api = createApiClient(useEnvContext()); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + githubUsername: "", + key: "" + } + }); + + async function hide() { + await api.post("/supporter-key/hide"); + + updateSupporterStatus({ + visible: false + }); + } + + async function onSubmit(values: z.infer) { + try { + const res = await api.post< + AxiosResponse + >("/supporter-key/validate", { + githubUsername: values.githubUsername, + key: values.key + }); + + const data = res.data.data; + + if (!data || !data.valid) { + toast({ + variant: "destructive", + title: "Invalid Key", + description: "Your supporter key is invalid." + }); + return; + } + + toast({ + variant: "default", + title: "Valid Key", + description: + "Your supporter key has been validated. Thank you for your support!" + }); + + setPurchaseOptionsOpen(false); + setKeyOpen(false); + + updateSupporterStatus({ + visible: false + }); + } catch (error) { + toast({ + variant: "destructive", + title: "Error", + description: formatAxiosError( + error, + "Failed to validate supporter key." + ) + }); + return; + } + } + + return ( + <> + { + setPurchaseOptionsOpen(val); + }} + > + + + + Support Development and Adopt a Pangolin! + + + +

+ Purchase a supporter key to help us continue + developing Pangolin. Your contribution allows us + commit more time to maintain and add new features to + the application for everyone. We will never use this + to paywall features. +

+ +

+ You will also get to adopt and meet your very own + pet Pangolin! +

+ +

+ Payments are processed via GitHub. Afterward, you + can retrieve your key on{" "} + + our website + {" "} + and redeem it here.{" "} + + Learn more. + +

+ +

Please select the option that best suits you.

+ +
+ + + Full Supporter + + +

$95

+
    +
  • + + + For the whole server + +
  • +
  • + + + Lifetime purchase + +
  • +
  • + + + Supporter status + +
  • +
+
+ + + + + +
+ + + + Limited Supporter + + +

$25

+
    +
  • + + + For 5 or less users + +
  • +
  • + + + Lifetime purchase + +
  • +
  • + + + Supporter status + +
  • +
+
+ + + + + +
+
+ +
+ + +
+
+ + + + + +
+
+ + { + setKeyOpen(val); + }} + > + + + Enter Supporter Key + + Meet your very own pet Pangolin! + + + +
+ + ( + + + GitHub Username + + + + + + + )} + /> + ( + + Supporter Key + + + + + + )} + /> + + +
+ + + + + + +
+
+ + {supporterStatus?.visible ? ( + + ) : null} + + ); +} diff --git a/src/contexts/supporterStatusContext.ts b/src/contexts/supporterStatusContext.ts new file mode 100644 index 00000000..9ce88d6b --- /dev/null +++ b/src/contexts/supporterStatusContext.ts @@ -0,0 +1,16 @@ +import { createContext } from "react"; + +export type SupporterStatus = { + visible: boolean; +}; + +type SupporterStatusContextType = { + supporterStatus: SupporterStatus | null; + updateSupporterStatus: (updatedSite: Partial) => void; +}; + +const SupporterStatusContext = createContext< + SupporterStatusContextType | undefined +>(undefined); + +export default SupporterStatusContext; diff --git a/src/hooks/useSupporterStatusContext.ts b/src/hooks/useSupporterStatusContext.ts new file mode 100644 index 00000000..359b4010 --- /dev/null +++ b/src/hooks/useSupporterStatusContext.ts @@ -0,0 +1,12 @@ +import SupporterStatusContext from "@app/contexts/supporterStatusContext"; +import { useContext } from "react"; + +export function useSupporterStatusContext() { + const context = useContext(SupporterStatusContext); + if (context === undefined) { + throw new Error( + "useSupporterStatusContext must be used within an SupporterStatusProvider" + ); + } + return context; +} diff --git a/src/providers/SupporterStatusProvider.tsx b/src/providers/SupporterStatusProvider.tsx new file mode 100644 index 00000000..bcb8be2b --- /dev/null +++ b/src/providers/SupporterStatusProvider.tsx @@ -0,0 +1,46 @@ +"use client"; + +import SupportStatusContext, { + SupporterStatus +} from "@app/contexts/supporterStatusContext"; +import { useState } from "react"; + +interface ProviderProps { + children: React.ReactNode; + supporterStatus: SupporterStatus | null; +} + +export function SupporterStatusProvider({ + children, + supporterStatus +}: ProviderProps) { + const [supporterStatusState, setSupporterStatusState] = + useState(supporterStatus); + + const updateSupporterStatus = ( + updatedSupporterStatus: Partial + ) => { + setSupporterStatusState((prev) => { + if (!prev) { + return updatedSupporterStatus as SupporterStatus; + } + return { + ...prev, + ...updatedSupporterStatus + }; + }); + }; + + return ( + + {children} + + ); +} + +export default SupporterStatusProvider;