Openstatus www.openstatus.dev

add white label status page (#1695)

* add white label status page

* small changes

* fixing build

* ci: apply automated fixes

* chore: white-label

* fix build

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Maximilian Kaske <maximilian@kaske.org>

authored by

Thibault Le Ouay
autofix-ci[bot]
Maximilian Kaske
and committed by
GitHub
02b26ba0 08045c48

+70 -30
+14 -3
apps/dashboard/src/app/(dashboard)/settings/billing/client.tsx
··· 127 127 128 128 if (!workspace) return null; 129 129 130 + const addons = allPlans[workspace.plan].addons; 131 + 130 132 return ( 131 133 <SectionGroup> 132 134 <Section> ··· 178 180 </FormCardHeader> 179 181 <div className="flex flex-col gap-2 pt-4"> 180 182 {/* TODO: redirect to stripe product */} 181 - {allPlans[workspace.plan].addons["email-domain-protection"] ? ( 183 + {addons["email-domain-protection"] ? ( 182 184 <BillingAddons 183 185 label="Magic Link (Auth)" 184 186 description="Only allow user with a given email domain to access the status page." 185 187 addon="email-domain-protection" 186 188 workspace={workspace} 187 189 /> 188 - ) : ( 190 + ) : null} 191 + {addons["white-label"] ? ( 192 + <BillingAddons 193 + label="White Label" 194 + description="Remove the 'powered by openstatus.dev' footer from your status pages." 195 + addon="white-label" 196 + workspace={workspace} 197 + /> 198 + ) : null} 199 + {Object.keys(addons).length === 0 ? ( 189 200 <EmptyStateContainer> 190 201 <EmptyStateTitle>No add-ons available</EmptyStateTitle> 191 202 </EmptyStateContainer> 192 - )} 203 + ) : null} 193 204 </div> 194 205 </FormCardContent> 195 206 <FormCardFooter>
-7
apps/dashboard/src/components/content/billing-addons.tsx
··· 23 23 import { useState, useTransition } from "react"; 24 24 import { toast } from "sonner"; 25 25 26 - const BASE_URL = 27 - process.env.NODE_ENV === "production" 28 - ? "https://app.openstatus.dev" 29 - : "http://localhost:3000"; 30 - 31 26 type Workspace = RouterOutputs["workspace"]["get"]; 32 27 33 28 interface BillingAddonsProps { ··· 67 62 try { 68 63 const promise = checkoutSessionMutation.mutateAsync({ 69 64 workspaceSlug: workspace.slug, 70 - successUrl: `${BASE_URL}/settings/billing?success=true`, 71 - cancelUrl: `${BASE_URL}/settings/billing`, 72 65 feature: addon, 73 66 remove: value, 74 67 });
+4
apps/dashboard/src/data/plans.ts
··· 67 67 value: "custom-domain", 68 68 label: "Custom domain", 69 69 }, 70 + { 71 + value: "white-label", 72 + label: "White Label", 73 + }, 70 74 ], 71 75 }, 72 76 "status-page-audience": {
+12 -10
apps/status-page/src/components/nav/footer.tsx
··· 29 29 <footer {...props}> 30 30 <div className="mx-auto flex max-w-2xl items-center justify-between gap-4 px-3 py-2"> 31 31 <div> 32 - <p className="font-mono text-muted-foreground text-xs leading-none sm:text-sm"> 33 - powered by{" "} 34 - <Link 35 - href="https://openstatus.dev" 36 - target="_blank" 37 - rel="noreferrer" 38 - > 39 - openstatus.dev 40 - </Link> 41 - </p> 32 + {!page.whiteLabel ? ( 33 + <p className="font-mono text-muted-foreground text-xs leading-none sm:text-sm"> 34 + powered by{" "} 35 + <Link 36 + href="https://openstatus.dev" 37 + target="_blank" 38 + rel="noreferrer" 39 + > 40 + openstatus.dev 41 + </Link> 42 + </p> 43 + ) : null} 42 44 </div> 43 45 <div className="flex items-center gap-4"> 44 46 <TimestampHoverCard
+1 -1
apps/web/next-env.d.ts
··· 1 1 /// <reference types="next" /> 2 2 /// <reference types="next/image-types/global" /> 3 - import "./.next/dev/types/routes.d.ts"; 3 + import "./.next/types/routes.d.ts"; 4 4 5 5 // NOTE: This file should not be edited 6 6 // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+1
apps/web/src/content/pages/unrelated/pricing.mdx
··· 32 32 ["Toggle numbers visibility", "+", "+", "+"], 33 33 ["Subscribers", "", "+", "+"], 34 34 ["Custom domain", "", "+", "+"], 35 + ["White Label", "", "add-on $300/mo.", "add-on $300/mo."], 35 36 [<strong>Audience</strong>, "", "", ""], 36 37 ["Password Protection", "", "+", "+"], 37 38 ["Email Authentification", "", "add-on $100/mo.", "add-on $100/mo."],
+5
packages/api/src/router/statusPage.ts
··· 102 102 103 103 if (!_page) return null; 104 104 105 + const ws = selectWorkspaceSchema.safeParse(_page.workspace); 106 + 105 107 const configuration = pageConfigurationSchema.safeParse( 106 108 _page.configuration ?? {}, 107 109 ); ··· 294 296 }) 295 297 .sort((a, b) => a.order - b.order); 296 298 299 + const whiteLabel = ws.data?.limits["white-label"] ?? false; 300 + 297 301 return selectPublicPageSchemaWithRelation.parse({ 298 302 ..._page, 299 303 monitors, ··· 319 323 status, 320 324 lastEvents, 321 325 openEvents, 326 + whiteLabel, 322 327 }); 323 328 }), 324 329
+2 -3
packages/api/src/router/stripe/index.ts
··· 9 9 workspacePlans, 10 10 } from "@openstatus/db/src/schema"; 11 11 12 + import { addons } from "@openstatus/db/src/schema/plan/schema"; 12 13 import { updateAddonInLimits } from "@openstatus/db/src/schema/plan/utils"; 13 14 import { TRPCError } from "@trpc/server"; 14 15 import type { Stripe } from "stripe"; ··· 174 175 .input( 175 176 z.object({ 176 177 workspaceSlug: z.string(), 177 - feature: z.enum(["email-domain-protection"]), 178 - successUrl: z.string().optional(), 179 - cancelUrl: z.string().optional(), 178 + feature: z.enum(addons), 180 179 remove: z.boolean().optional(), 181 180 }), 182 181 )
+12 -3
packages/api/src/router/stripe/utils.ts
··· 23 23 return PLANS.find((p) => p.plan === plan)?.price.monthly.priceIds[env]; 24 24 }; 25 25 26 - export const getPriceIdForFeature = ( 27 - feature: "email-domain-protection" | "status-pages-whitelabel", 28 - ) => { 26 + export const getPriceIdForFeature = (feature: keyof Addons) => { 29 27 const env = 30 28 process.env.NEXT_PUBLIC_VERCEL_ENV === "production" ? "production" : "test"; 31 29 return FEATURES.find((f) => f.feature === feature)?.price.monthly.priceIds[ ··· 71 69 priceIds: { 72 70 test: "price_1Sl4xqBXJcTfzsyJlzpD1DDm", 73 71 production: "price_1Sl6oqBXJcTfzsyJCxtzDIx5", 72 + }, 73 + }, 74 + }, 75 + }, 76 + { 77 + feature: "white-label", 78 + price: { 79 + monthly: { 80 + priceIds: { 81 + test: "price_1SlbQsBXJcTfzsyJ1awtpOno", 82 + production: "price_1SlbSdBXJcTfzsyJahJiFE8D", 74 83 }, 75 84 }, 76 85 },
+14
packages/db/src/schema/plan/config.ts
··· 72 72 INR: 10_000, 73 73 }, 74 74 }, 75 + "white-label": { 76 + price: { 77 + USD: 300, 78 + EUR: 300, 79 + INR: 30_000, 80 + }, 81 + }, 75 82 }, 76 83 limits: { 77 84 version: undefined, ··· 120 127 USD: 100, 121 128 EUR: 100, 122 129 INR: 10_000, 130 + }, 131 + }, 132 + "white-label": { 133 + price: { 134 + USD: 300, 135 + EUR: 300, 136 + INR: 30_000, 123 137 }, 124 138 }, 125 139 },
+4 -3
packages/db/src/schema/plan/schema.ts
··· 65 65 66 66 export type Price = z.infer<typeof priceSchema>; 67 67 68 - export const addons = ["email-domain-protection"] as const satisfies Partial< 69 - keyof Limits 70 - >[]; 68 + export const addons = [ 69 + "email-domain-protection", 70 + "white-label", 71 + ] as const satisfies Partial<keyof Limits>[]; 71 72 72 73 export const addonsSchema = z.partialRecord( 73 74 z.enum(addons),
+1
packages/db/src/schema/shared.ts
··· 152 152 .nullable() 153 153 .prefault("free") 154 154 .transform((val) => val ?? "free"), 155 + whiteLabel: z.boolean().prefault(false), 155 156 }); 156 157 157 158 export const selectPublicStatusReportSchemaWithRelation =