Openstatus www.openstatus.dev

feat: the new subscribe button (#1151)

* feat: create modal to email subscribe

* feat: include feed links in the subscribe button

* fix: closes modal on submition error

* fix: set status page url as feed home

* fix: fix links to feed items

* fix: fix lint issue on the subscribe button

* chore: apply format updates

* fix: apply more lint suggestions

* feat: RSS/Atom links available to everyone

* chore: open rss/atom links in a new tab

* ci: apply automated fixes

* refactor: replace popover components with dropdown menu

* fix: add missing props on the features page

---------

Co-authored-by: Washington <22279738+washingtonbr@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

authored by

Washington Pires
Washington
autofix-ci[bot]
and committed by
GitHub
4ea50653 3957ae1f

+179 -81
+1 -1
apps/web/src/app/(content)/features/status-page/page.tsx
··· 72 72 subTitle="Let your users subscribe to your status page, to automatically receive updates about the status of your services." 73 73 component={ 74 74 <div className="m-auto"> 75 - <SubscribeButton slug={""} isDemo /> 75 + <SubscribeButton plan="pro" slug={""} isDemo /> 76 76 </div> 77 77 } 78 78 col={1}
+6 -4
apps/web/src/app/status-page/[domain]/_components/header.tsx
··· 4 4 import { useSelectedLayoutSegment } from "next/navigation"; 5 5 6 6 import type { PublicPage } from "@openstatus/db/src/schema"; 7 - import { allPlans } from "@openstatus/db/src/schema/plan/config"; 8 7 import type { WorkspacePlan } from "@openstatus/db/src/schema/workspaces/validation"; 9 8 10 9 import { cn } from "@/lib/utils"; ··· 25 24 26 25 export function Header({ navigation, plan, page }: Props) { 27 26 const selectedSegment = useSelectedLayoutSegment(); 28 - const isSubscribers = allPlans[plan].limits["status-subscribers"]; // FIXME: use the workspace.limits 29 27 30 28 return ( 31 29 <header className="sticky top-3 z-10 w-full"> ··· 65 63 <div className="block sm:hidden"> 66 64 <Menu navigation={navigation} /> 67 65 </div> 68 - <div className="text-end sm:w-[120px]"> 69 - {isSubscribers ? <SubscribeButton slug={page.slug} /> : null} 66 + <div className="text-end sm:w-32"> 67 + <SubscribeButton 68 + plan={plan} 69 + slug={page.slug} 70 + customDomain={page.customDomain} 71 + /> 70 72 </div> 71 73 </div> 72 74 </div>
+72 -73
apps/web/src/app/status-page/[domain]/_components/subscribe-button.tsx
··· 1 1 "use client"; 2 2 3 - import { Mail } from "lucide-react"; 4 - import { useFormStatus } from "react-dom"; 3 + import { Bell, Mail, Rss } from "lucide-react"; 4 + import { useState } from "react"; 5 5 6 + import { allPlans } from "@openstatus/db/src/schema/plan/config"; 7 + import type { WorkspacePlan } from "@openstatus/db/src/schema/workspaces/validation"; 6 8 import { 7 - Popover, 8 - PopoverContent, 9 - PopoverTrigger, 10 - } from "@openstatus/ui/src/components/popover"; 9 + DropdownMenu, 10 + DropdownMenuContent, 11 + DropdownMenuItem, 12 + DropdownMenuTrigger, 13 + } from "@openstatus/ui/src/components/dropdown-menu"; 11 14 12 - import { LoadingAnimation } from "@/components/loading-animation"; 13 - import { toast } from "@/lib/toast"; 14 - import { wait } from "@/lib/utils"; 15 15 import { Button } from "@openstatus/ui/src/components/button"; 16 - import { Input } from "@openstatus/ui/src/components/input"; 17 - import { Label } from "@openstatus/ui/src/components/label"; 18 - import { handleSubscribe } from "./actions"; 16 + import { getBaseUrl } from "../utils"; 17 + import { SubscribeModal } from "./subscribe-modal"; 19 18 20 19 interface Props { 20 + plan: WorkspacePlan; 21 21 slug: string; 22 + customDomain?: string; 22 23 isDemo?: boolean; 23 24 } 24 25 25 - export function SubscribeButton({ slug, isDemo = false }: Props) { 26 + export function SubscribeButton({ 27 + plan, 28 + slug, 29 + customDomain, 30 + isDemo = false, 31 + }: Props) { 32 + const [showModal, setShowModal] = useState(false); 33 + const isSubscribers = allPlans[plan].limits["status-subscribers"]; // FIXME: use the workspace.limits 34 + const baseUrl = getBaseUrl({ 35 + slug: slug, 36 + customDomain: customDomain, 37 + }); 38 + 26 39 return ( 27 - <Popover> 28 - <PopoverTrigger asChild> 29 - <Button variant="outline" className="rounded-full"> 30 - Get updates 31 - </Button> 32 - </PopoverTrigger> 33 - <PopoverContent align="end"> 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 - if (!isDemo) { 48 - const res = await handleSubscribe(formData); 49 - if (res?.error) { 50 - toast.error("Something went wrong", { 51 - description: res.error, 52 - }); 53 - return; 54 - } 55 - toast.message("Success", { 56 - description: "Please confirm your email.", 57 - }); 58 - } else { 59 - await wait(1000); 60 - toast.message("Success (Demo)", { 61 - description: "Please confirm your email (not).", 62 - }); 63 - } 64 - }} 65 - > 66 - <Label htmlFor="email">Email</Label> 67 - <Input 68 - id="email" 69 - name="email" 70 - type="email" 71 - placeholder="notify@me.com" 72 - /> 73 - <input type="hidden" name="slug" value={slug} /> 74 - <SubmitButton /> 75 - </form> 76 - </div> 77 - </PopoverContent> 78 - </Popover> 79 - ); 80 - } 40 + <> 41 + <DropdownMenu> 42 + <DropdownMenuTrigger asChild> 43 + <Button variant="outline" className="gap-2 rounded-full"> 44 + <Bell className="h-4 w-4" /> 45 + Subscribe 46 + </Button> 47 + </DropdownMenuTrigger> 48 + <DropdownMenuContent align="end"> 49 + <DropdownMenuItem asChild> 50 + <a 51 + href={`${baseUrl}/feed/rss`} 52 + target="_blank" 53 + rel="noreferrer" 54 + className="flex items-center gap-2" 55 + > 56 + <Rss className="h-4 w-4" /> 57 + RSS 58 + </a> 59 + </DropdownMenuItem> 60 + <DropdownMenuItem asChild> 61 + <a 62 + href={`${baseUrl}/feed/atom`} 63 + target="_blank" 64 + rel="noreferrer" 65 + className="flex items-center gap-2" 66 + > 67 + <Rss className="h-4 w-4" /> 68 + Atom 69 + </a> 70 + </DropdownMenuItem> 71 + {isSubscribers ? ( 72 + <DropdownMenuItem onClick={() => setShowModal(true)}> 73 + <Mail className="h-4 w-4 mr-2" /> 74 + Email 75 + </DropdownMenuItem> 76 + ) : null} 77 + </DropdownMenuContent> 78 + </DropdownMenu> 81 79 82 - function SubmitButton() { 83 - const { pending } = useFormStatus(); 84 - return ( 85 - <Button type="submit" disabled={pending}> 86 - {pending ? <LoadingAnimation /> : "Subscribe"} 87 - </Button> 80 + <SubscribeModal 81 + open={showModal} 82 + onOpenChange={setShowModal} 83 + slug={slug} 84 + isDemo={isDemo} 85 + /> 86 + </> 88 87 ); 89 88 }
+97
apps/web/src/app/status-page/[domain]/_components/subscribe-modal.tsx
··· 1 + "use client"; 2 + 3 + import { Mail } from "lucide-react"; 4 + import { useFormStatus } from "react-dom"; 5 + 6 + import { 7 + Dialog, 8 + DialogContent, 9 + DialogHeader, 10 + DialogTitle, 11 + } from "@openstatus/ui/src/components/dialog"; 12 + 13 + import { LoadingAnimation } from "@/components/loading-animation"; 14 + import { toast } from "@/lib/toast"; 15 + import { wait } from "@/lib/utils"; 16 + import { Button } from "@openstatus/ui/src/components/button"; 17 + import { Input } from "@openstatus/ui/src/components/input"; 18 + import { Label } from "@openstatus/ui/src/components/label"; 19 + import { handleSubscribe } from "./actions"; 20 + 21 + interface Props { 22 + open: boolean; 23 + onOpenChange: (open: boolean) => void; 24 + slug: string; 25 + isDemo?: boolean; 26 + } 27 + 28 + export function SubscribeModal({ 29 + open, 30 + onOpenChange, 31 + slug, 32 + isDemo = false, 33 + }: Props) { 34 + return ( 35 + <Dialog open={open} onOpenChange={onOpenChange}> 36 + <DialogContent className="sm:max-w-md"> 37 + <DialogHeader> 38 + <DialogTitle className="flex items-center gap-2"> 39 + <Mail className="h-5 w-5" /> 40 + Subscribe to updates 41 + </DialogTitle> 42 + </DialogHeader> 43 + 44 + <div className="grid gap-4"> 45 + <p className="text-muted-foreground text-sm"> 46 + Get email notifications whenever a report has been created or 47 + resolved. 48 + </p> 49 + 50 + <form 51 + className="grid gap-2" 52 + action={async (formData) => { 53 + if (!isDemo) { 54 + const res = await handleSubscribe(formData); 55 + if (res?.error) { 56 + toast.error("Something went wrong", { 57 + description: res.error, 58 + }); 59 + onOpenChange(false); 60 + return; 61 + } 62 + toast.message("Success", { 63 + description: "Please confirm your email.", 64 + }); 65 + } else { 66 + await wait(1000); 67 + toast.message("Success (Demo)", { 68 + description: "Please confirm your email (not).", 69 + }); 70 + } 71 + onOpenChange(false); 72 + }} 73 + > 74 + <Label htmlFor="email">Email</Label> 75 + <Input 76 + id="email" 77 + name="email" 78 + type="email" 79 + placeholder="notify@me.com" 80 + /> 81 + <input type="hidden" name="slug" value={slug} /> 82 + <SubmitButton /> 83 + </form> 84 + </div> 85 + </DialogContent> 86 + </Dialog> 87 + ); 88 + } 89 + 90 + function SubmitButton() { 91 + const { pending } = useFormStatus(); 92 + return ( 93 + <Button type="submit" disabled={pending}> 94 + {pending ? <LoadingAnimation /> : "Subscribe"} 95 + </Button> 96 + ); 97 + }
+3 -3
apps/web/src/app/status-page/[domain]/feed/[type]/route.ts
··· 30 30 rss: `${baseUrl}/feed/rss`, 31 31 atom: `${baseUrl}/feed/atom`, 32 32 }, 33 - link: "https://www.openstatus.dev", 33 + link: baseUrl, 34 34 author: { 35 35 name: "OpenStatus Team", 36 36 email: "ping@openstatus.dev", ··· 43 43 }); 44 44 45 45 for (const maintenance of page.maintenances) { 46 - const maintenanceUrl = `${baseUrl}/maintenances/${maintenance.id}`; 46 + const maintenanceUrl = `${baseUrl}/events?filter=maintenances`; 47 47 feed.addItem({ 48 48 id: maintenanceUrl, 49 49 title: `Maintenance - ${maintenance.title}`, ··· 54 54 } 55 55 56 56 for (const statusReport of page.statusReports) { 57 - const statusReportUrl = `${baseUrl}/reports/${statusReport.id}`; 57 + const statusReportUrl = `${baseUrl}/events/report/${statusReport.id}`; 58 58 const status = statusDict[statusReport.status].label; 59 59 const statusReportUpdates = statusReport.statusReportUpdates 60 60 .map((update) => {