Path: blob/main/components/dashboard/src/AppNotifications.tsx
2498 views
/**1* Copyright (c) 2023 Gitpod GmbH. All rights reserved.2* Licensed under the GNU Affero General Public License (AGPL).3* See License-AGPL.txt in the project root for license information.4*/56import dayjs from "dayjs";7import { useCallback, useEffect, useState } from "react";8import Alert, { AlertType } from "./components/Alert";9import { useUserLoader } from "./hooks/use-user-loader";10import { isGitpodIo } from "./utils";11import { trackEvent } from "./Analytics";12import { useUpdateCurrentUserMutation } from "./data/current-user/update-mutation";13import { User as UserProtocol } from "@gitpod/gitpod-protocol";14import { User } from "@gitpod/public-api/lib/gitpod/v1/user_pb";15import { useCurrentOrg } from "./data/organizations/orgs-query";16import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution";17import { getGitpodService } from "./service/service";18import { useOrgBillingMode } from "./data/billing-mode/org-billing-mode-query";19import { Organization } from "@gitpod/public-api/lib/gitpod/v1/organization_pb";20import { MaintenanceModeBanner } from "./org-admin/MaintenanceModeBanner";21import { MaintenanceNotificationBanner } from "./org-admin/MaintenanceNotificationBanner";22import { useToast } from "./components/toasts/Toasts";23import { getPrimaryEmail } from "@gitpod/public-api-common/lib/user-utils";24import onaWordmark from "./images/ona-wordmark.svg";2526const KEY_APP_DISMISSED_NOTIFICATIONS = "gitpod-app-notifications-dismissed";27const PRIVACY_POLICY_LAST_UPDATED = "2025-07-21";2829interface Notification {30id: string;31type: AlertType;32message: JSX.Element;33preventDismiss?: boolean;34onClose?: () => void;35}3637const UPDATED_PRIVACY_POLICY = (updateUser: (user: Partial<UserProtocol>) => Promise<User>) => {38return {39id: "privacy-policy-update",40type: "info",41preventDismiss: true,42onClose: async () => {43let dismissSuccess = false;44try {45const updatedUser = await updateUser({46additionalData: { profile: { acceptedPrivacyPolicyDate: dayjs().toISOString() } },47});48dismissSuccess = !!updatedUser;49} catch (err) {50console.error("Failed to update user's privacy policy acceptance date", err);51dismissSuccess = false;52} finally {53trackEvent("privacy_policy_update_accepted", {54path: window.location.pathname,55success: dismissSuccess,56});57}58},59message: (60<span className="text-md">61We've updated our Privacy Policy. You can review it{" "}62<a className="gp-link" href="https://www.gitpod.io/privacy" target="_blank" rel="noreferrer">63here64</a>65.66</span>67),68} as Notification;69};7071const GITPOD_FLEX_INTRODUCTION_COACHMARK_KEY = "gitpod_flex_introduction";72const GITPOD_FLEX_INTRODUCTION = (updateUser: (user: Partial<UserProtocol>) => Promise<User>) => {73return {74id: GITPOD_FLEX_INTRODUCTION_COACHMARK_KEY,75type: "info",76preventDismiss: true,77onClose: async () => {78let dismissSuccess = false;79try {80const updatedUser = await updateUser({81additionalData: {82profile: {83coachmarksDismissals: {84[GITPOD_FLEX_INTRODUCTION_COACHMARK_KEY]: new Date().toISOString(),85},86},87},88});89dismissSuccess = !!updatedUser;90} catch (err) {91dismissSuccess = false;92} finally {93trackEvent("coachmark_dismissed", {94name: "gitpod-flex-introduction",95success: dismissSuccess,96});97}98},99message: (100<span className="text-md">101<b>Introducing Gitpod Flex:</b> self-host for free in 3 min or run locally using Gitpod Desktop |{" "}102<a className="text-kumquat-ripe" href="https://app.gitpod.io" target="_blank" rel="noreferrer">103Try now104</a>105</span>106),107} as Notification;108};109110const INVALID_BILLING_ADDRESS = (stripePortalUrl: string | undefined) => {111return {112id: "invalid-billing-address",113type: "warning",114preventDismiss: true,115message: (116<span className="text-md">117Invalid billing address: tax calculations may be affected. Ensure your address includes Country, City,118State, and Zip code. Update your details{" "}119<a120href={`${stripePortalUrl}/customer/update`}121target="_blank"122rel="noopener noreferrer"123className="gp-link"124>125here126</a>127.128</span>129),130} as Notification;131};132133const GITPOD_CLASSIC_SUNSET = (134user: User | undefined,135toast: any,136onaClicked: boolean,137handleOnaBannerClick: () => void,138) => {139return {140id: "gitpod-classic-sunset",141type: "info" as AlertType,142message: (143<span className="text-md text-white font-semibold items-center justify-center">144<img src={onaWordmark} alt="Ona" className="inline align-middle w-12 mb-0.5" draggable="false" /> |145parallel SWE agents in the cloud, sandboxed for high-autonomy.{" "}146<a href="https://app.ona.com" target="_blank" rel="noreferrer" className="underline hover:no-underline">147Start for free148</a>{" "}149and get $100 credits. Gitpod Classic sunsets Oct 15 |{" "}150<a151href="https://ona.com/stories/gitpod-classic-payg-sunset"152target="_blank"153rel="noreferrer"154className="underline hover:no-underline"155>156Learn more157</a>158</span>159),160} as Notification;161};162163export function AppNotifications() {164const [topNotification, setTopNotification] = useState<Notification | undefined>(undefined);165const [onaClicked, setOnaClicked] = useState(false);166const { user, loading } = useUserLoader();167const { mutateAsync } = useUpdateCurrentUserMutation();168const { toast } = useToast();169170const currentOrg = useCurrentOrg().data;171const { data: billingMode } = useOrgBillingMode();172173useEffect(() => {174const storedOnaData = localStorage.getItem("ona-banner-data");175if (storedOnaData) {176const { clicked } = JSON.parse(storedOnaData);177setOnaClicked(clicked || false);178}179}, []);180181const handleOnaBannerClick = useCallback(() => {182const userEmail = user ? getPrimaryEmail(user) || "" : "";183trackEvent("waitlist_joined", { email: userEmail, feature: "Ona" });184185setOnaClicked(true);186const existingData = localStorage.getItem("ona-banner-data");187const parsedData = existingData ? JSON.parse(existingData) : {};188localStorage.setItem("ona-banner-data", JSON.stringify({ ...parsedData, clicked: true }));189190toast(191<div>192<div className="font-medium">You're on the waitlist</div>193<div className="text-sm opacity-80">We'll reach out to you soon.</div>194</div>,195);196}, [user, toast]);197198useEffect(() => {199let ignore = false;200201const updateNotifications = async () => {202const notifications = [];203if (!loading) {204if (isGitpodIo()) {205notifications.push(GITPOD_CLASSIC_SUNSET(user, toast, onaClicked, handleOnaBannerClick));206}207208if (209isGitpodIo() &&210(!user?.profile?.acceptedPrivacyPolicyDate ||211new Date(PRIVACY_POLICY_LAST_UPDATED) > new Date(user.profile.acceptedPrivacyPolicyDate))212) {213notifications.push(UPDATED_PRIVACY_POLICY((u: Partial<UserProtocol>) => mutateAsync(u)));214}215216if (isGitpodIo() && currentOrg && billingMode?.mode === "usage-based") {217const notification = await checkForInvalidBillingAddress(currentOrg);218if (notification) {219notifications.push(notification);220}221}222223if (isGitpodIo() && !user?.profile?.coachmarksDismissals[GITPOD_FLEX_INTRODUCTION_COACHMARK_KEY]) {224notifications.push(GITPOD_FLEX_INTRODUCTION((u: Partial<UserProtocol>) => mutateAsync(u)));225}226}227228if (!ignore) {229const dismissedNotifications = getDismissedNotifications();230const topNotification = notifications.find((n) => !dismissedNotifications.includes(n.id));231setTopNotification(topNotification);232}233};234updateNotifications();235236return () => {237ignore = true;238};239}, [loading, mutateAsync, user, currentOrg, billingMode, onaClicked, handleOnaBannerClick, toast]);240241const dismissNotification = useCallback(() => {242if (!topNotification) {243return;244}245246const dismissedNotifications = getDismissedNotifications();247dismissedNotifications.push(topNotification.id);248setDismissedNotifications(dismissedNotifications);249setTopNotification(undefined);250}, [topNotification, setTopNotification]);251252return (253<div className="app-container pt-2">254<MaintenanceModeBanner />255<MaintenanceNotificationBanner />256{topNotification && (257<Alert258type={topNotification.type}259closable={true}260onClose={() => {261if (!topNotification.preventDismiss) {262dismissNotification();263} else {264if (topNotification.onClose) {265topNotification.onClose();266}267}268}}269showIcon={true}270className={`flex rounded mb-2 w-full ${271topNotification.id === "gitpod-classic-sunset"272? "bg-[linear-gradient(to_left,#1F1329_0%,#333A75_20%,#556CA8_40%,#90A898_60%,#E2B15C_80%,#E2B15C_97%,#BEA462_100%)]"273: ""274}`}275>276<span>{topNotification.message}</span>277</Alert>278)}279</div>280);281}282283async function checkForInvalidBillingAddress(org: Organization): Promise<Notification | undefined> {284try {285const attributionId = AttributionId.render(AttributionId.createFromOrganizationId(org.id));286287const subscriptionId = await getGitpodService().server.findStripeSubscriptionId(attributionId);288if (!subscriptionId) {289return undefined;290}291292const invalidBillingAddress = await getGitpodService().server.isCustomerBillingAddressInvalid(attributionId);293if (!invalidBillingAddress) {294return undefined;295}296297const stripePortalUrl = await getGitpodService().server.getStripePortalUrl(attributionId);298return INVALID_BILLING_ADDRESS(stripePortalUrl);299} catch (err) {300// On error we don't want to block but still would like to report against metrics301console.debug("failed to determine 'invalid billing address' state", err);302return undefined;303}304}305306function getDismissedNotifications(): string[] {307try {308const str = window.localStorage.getItem(KEY_APP_DISMISSED_NOTIFICATIONS);309const parsed = JSON.parse(str || "[]");310if (!Array.isArray(parsed)) {311window.localStorage.removeItem(KEY_APP_DISMISSED_NOTIFICATIONS);312return [];313}314return parsed;315} catch (err) {316console.debug("Failed to parse dismissed notifications", err);317window.localStorage.removeItem(KEY_APP_DISMISSED_NOTIFICATIONS);318return [];319}320}321322function setDismissedNotifications(ids: string[]) {323try {324window.localStorage.setItem(KEY_APP_DISMISSED_NOTIFICATIONS, JSON.stringify(ids));325} catch (err) {326console.debug("Failed to set dismissed notifications", err);327window.localStorage.removeItem(KEY_APP_DISMISSED_NOTIFICATIONS);328}329}330331332