Openstatus www.openstatus.dev
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: subscriber button (#518)

authored by

Maximilian Kaske and committed by
GitHub
6f4b11f6 17857191

+201 -76
+79
apps/web/src/app/status-page/[domain]/_components/actions.ts
··· 1 + "use server"; 2 + 3 + import { z } from "zod"; 4 + 5 + import { and, eq } from "@openstatus/db"; 6 + import { db } from "@openstatus/db/src/db"; 7 + import { page, pageSubscriber } from "@openstatus/db/src/schema"; 8 + import { sendEmail, SubscribeEmail } from "@openstatus/emails"; 9 + 10 + import { wait } from "@/lib/utils"; 11 + 12 + const schema = z.object({ 13 + email: z 14 + .string({ 15 + invalid_type_error: "Invalid Email", 16 + }) 17 + .email(), 18 + slug: z.string(), 19 + }); 20 + 21 + export async function handleSubscribe(formData: FormData) { 22 + const validatedFields = schema.safeParse({ 23 + email: formData.get("email"), 24 + slug: formData.get("slug"), 25 + }); 26 + 27 + if (!validatedFields.success) { 28 + const fieldErrors = validatedFields.error.flatten().fieldErrors; 29 + throw new Error(fieldErrors?.email?.[0] || "Invalid form data"); 30 + } 31 + 32 + const pageData = await db 33 + .select() 34 + .from(page) 35 + .where(eq(page.slug, validatedFields.data.slug)) 36 + .get(); 37 + 38 + if (!pageData) { 39 + throw new Error("Page not found"); 40 + } 41 + 42 + const alreadySubscribed = await db 43 + .select() 44 + .from(pageSubscriber) 45 + .where( 46 + and( 47 + eq(pageSubscriber.email, validatedFields.data.email), 48 + eq(pageSubscriber.pageId, pageData.id), 49 + ), 50 + ) 51 + .get(); 52 + 53 + if (alreadySubscribed) { 54 + throw new Error("Already subscribed"); 55 + } 56 + 57 + const token = crypto.randomUUID(); 58 + 59 + await sendEmail({ 60 + react: SubscribeEmail({ 61 + domain: validatedFields.data.slug, 62 + token: token, 63 + page: pageData.title, 64 + }), 65 + from: "OpenStatus <notification@openstatus.dev>", 66 + to: [validatedFields.data.email], 67 + subject: "Verify your subscription", 68 + }); 69 + 70 + await db 71 + .insert(pageSubscriber) 72 + .values({ 73 + email: validatedFields.data.email, 74 + token, 75 + pageId: pageData.id, 76 + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), 77 + }) 78 + .execute(); 79 + }
+1
apps/web/src/app/status-page/[domain]/_components/navigation-link.tsx
··· 34 34 asChild 35 35 variant={isActive ? "secondary" : "ghost"} 36 36 className={isActive ? "font-bold" : ""} 37 + size="sm" 37 38 > 38 39 <Link href={href}>{children}</Link> 39 40 </Button>
+87
apps/web/src/app/status-page/[domain]/_components/subscribe-button.tsx
··· 1 + "use client"; 2 + 3 + import { Mail } from "lucide-react"; 4 + import { experimental_useFormStatus } from "react-dom"; 5 + 6 + import { 7 + Button, 8 + Input, 9 + Label, 10 + Popover, 11 + PopoverContent, 12 + PopoverTrigger, 13 + useToast, 14 + } from "@openstatus/ui"; 15 + 16 + import { LoadingAnimation } from "@/components/loading-animation"; 17 + import { handleSubscribe } from "./actions"; 18 + 19 + interface Props { 20 + slug: string; 21 + } 22 + 23 + export function SubscribeButton({ slug }: Props) { 24 + const { toast } = useToast(); 25 + 26 + return ( 27 + <Popover> 28 + <PopoverTrigger asChild> 29 + <Button size="sm" variant="outline"> 30 + Get updates 31 + </Button> 32 + </PopoverTrigger> 33 + <PopoverContent> 34 + <div className="grid gap-4"> 35 + <div className="space-y-2"> 36 + <h4 className="flex items-center font-medium leading-none"> 37 + <Mail className="mr-2 h-4 w-4" /> Subscribe to updates 38 + </h4> 39 + <p className="text-muted-foreground text-sm"> 40 + Get email notifications whenever a report has been created or 41 + resolved. 42 + </p> 43 + </div> 44 + <form 45 + className="grid gap-2" 46 + action={async (formData) => { 47 + try { 48 + await handleSubscribe(formData); 49 + toast({ 50 + title: "Success", 51 + description: "Please confirm your email.", 52 + }); 53 + } catch (e) { 54 + if (e instanceof Error) { 55 + toast({ 56 + title: "Something went wrong", 57 + description: e.message, 58 + variant: "destructive", 59 + }); 60 + } 61 + } 62 + }} 63 + > 64 + <Label htmlFor="email">Email</Label> 65 + <Input 66 + id="email" 67 + name="email" 68 + type="email" 69 + placeholder="notify@me.com" 70 + /> 71 + <input type="hidden" name="slug" value={slug} /> 72 + <SubmitButton /> 73 + </form> 74 + </div> 75 + </PopoverContent> 76 + </Popover> 77 + ); 78 + } 79 + 80 + function SubmitButton() { 81 + const { pending } = experimental_useFormStatus(); 82 + return ( 83 + <Button type="submit" disabled={pending}> 84 + {pending ? <LoadingAnimation /> : "Subscribe"} 85 + </Button> 86 + ); 87 + }
+23 -7
apps/web/src/app/status-page/[domain]/layout.tsx
··· 1 + import { notFound } from "next/navigation"; 2 + 1 3 import { Shell } from "@/components/dashboard/shell"; 2 4 import { ThemeToggle } from "@/components/theme-toggle"; 5 + import { api } from "@/trpc/server"; 3 6 import NavigationLink from "./_components/navigation-link"; 7 + import { SubscribeButton } from "./_components/subscribe-button"; 4 8 5 - export default function StatusPageLayout({ 6 - children, 7 - }: { 9 + type Props = { 10 + params: { domain: string }; 8 11 children: React.ReactNode; 9 - }) { 12 + }; 13 + 14 + export default async function StatusPageLayout({ children, params }: Props) { 15 + const page = await api.page.getPageBySlug.query({ slug: params.domain }); 16 + if (!page) return notFound(); 17 + 10 18 return ( 11 19 <div className="flex min-h-screen w-full flex-col space-y-6 p-4 md:p-8"> 12 20 <header className="mx-auto w-full max-w-xl"> 13 - <Shell className="mx-auto flex items-center justify-center gap-2 p-2 px-2 md:p-3"> 14 - <NavigationLink slug={null}>Status</NavigationLink> 15 - <NavigationLink slug="incidents">Incidents</NavigationLink> 21 + <Shell className="mx-auto flex items-center justify-between gap-4 p-2 px-2 md:p-3"> 22 + <div className="hidden w-[100px] md:block" /> 23 + <div className="flex items-center gap-2"> 24 + <NavigationLink slug={null}>Status</NavigationLink> 25 + <NavigationLink slug="incidents">Incidents</NavigationLink> 26 + </div> 27 + <div className="w-[100px] text-end"> 28 + {page.workspacePlan !== "free" ? ( 29 + <SubscribeButton slug={params.domain} /> 30 + ) : null} 31 + </div> 16 32 </Shell> 17 33 </header> 18 34 <main className="flex h-full w-full flex-1 flex-col">
+1 -7
apps/web/src/app/status-page/[domain]/page.tsx
··· 27 27 }; 28 28 29 29 export default async function Page({ params }: Props) { 30 - // We should fetch the monitors and incident here 31 - // also the page information 32 - if (!params.domain) return notFound(); 33 30 const page = await api.page.getPageBySlug.query({ slug: params.domain }); 34 - if (!page) { 35 - return notFound(); 36 - } 37 - 31 + if (!page) return notFound(); 38 32 const isEmptyState = !( 39 33 Boolean(page.monitors.length) || Boolean(page.statusReports.length) 40 34 );
-62
apps/web/src/app/status-page/[domain]/subscribe/route.ts
··· 1 - import { z } from "zod"; 2 - 3 - import { and, eq } from "@openstatus/db"; 4 - import { db } from "@openstatus/db/src/db"; 5 - import { page, pageSubscriber } from "@openstatus/db/src/schema"; 6 - import { sendEmail, SubscribeEmail } from "@openstatus/emails"; 7 - 8 - export async function POST( 9 - req: Request, 10 - { params }: { params: { domain: string } }, 11 - ) { 12 - // 13 - const data = await req.json(); 14 - const result = z.object({ email: z.string().email() }).parse(data); 15 - 16 - const pageData = await db 17 - .select() 18 - .from(page) 19 - .where(eq(page.slug, params.domain)) 20 - .get(); 21 - if (!pageData) { 22 - return new Response("Not found", { status: 401 }); 23 - } 24 - 25 - const alreadySubscribed = await db 26 - .select() 27 - .from(pageSubscriber) 28 - .where( 29 - and( 30 - eq(pageSubscriber.email, data.email), 31 - eq(pageSubscriber.pageId, pageData?.id), 32 - ), 33 - ) 34 - .get(); 35 - 36 - if (alreadySubscribed) { 37 - return new Response("Not found", { status: 401 }); 38 - } 39 - 40 - const token = (Math.random() + 1).toString(36).substring(10); 41 - 42 - await sendEmail({ 43 - react: SubscribeEmail({ 44 - domain: params.domain, 45 - token: token, 46 - page: pageData.title, 47 - }), 48 - from: "OpenStatus <notification@openstatus.dev>", 49 - to: [result.email], 50 - subject: "Verify your subscription", 51 - }); 52 - await db 53 - .insert(pageSubscriber) 54 - .values({ 55 - pageId: pageData.id, 56 - email: result.email, 57 - token, 58 - expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), 59 - }) 60 - .execute(); 61 - return Response.json({ message: "Hello world" }); 62 - }
+8
packages/api/src/router/page.ts
··· 11 11 pagesToStatusReports, 12 12 selectPublicPageSchemaWithRelation, 13 13 statusReport, 14 + workspace, 14 15 } from "@openstatus/db/src/schema"; 15 16 import { allPlans } from "@openstatus/plans"; 16 17 ··· 164 165 return; 165 166 } 166 167 168 + const workspaceResult = await opts.ctx.db 169 + .select() 170 + .from(workspace) 171 + .where(eq(workspace.id, result.workspaceId)) 172 + .get(); 173 + 167 174 // FIXME: There is probably a better way to do this 168 175 const monitorsToPagesResult = await opts.ctx.db 169 176 .select() ··· 234 241 ...result, 235 242 monitors, 236 243 statusReports, 244 + workspacePlan: workspaceResult?.plan, 237 245 }); 238 246 }), 239 247
+2
packages/db/src/schema/shared.ts
··· 6 6 selectStatusReportSchema, 7 7 selectStatusReportUpdateSchema, 8 8 } from "./status_reports"; 9 + import { workspacePlanSchema } from "./workspaces"; 9 10 10 11 // FIXME: delete this file! 11 12 ··· 33 34 .extend({ 34 35 monitors: z.array(selectPublicMonitorSchema), 35 36 statusReports: selectStatusReportPageSchema, 37 + workspacePlan: workspacePlanSchema.default("free"), 36 38 }) 37 39 .omit({ 38 40 workspaceId: true,