diff --git a/CHANGELOG.md b/CHANGELOG.md index cb2c593..eeecab9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- **Popup**: Added `AuthErrorPopup`, `InternalErrorPopup`, `NetworkErrorPopup`, `AuthPopup`, and + `SpotifyAuthErrorPopup`, extracted from `grow`/`hear`'s local copies (which were near-identical + apart from `topOffset` and env-var reads). `InternalErrorPopup` and `SpotifyAuthErrorPopup` now + take an explicit `contactEmail` prop instead of reading `process.env.NEXT_PUBLIC_CONTACT_EMAIL` + directly, and `AuthPopup` takes `spotifyOnlyDescription`/`defaultDescription` props instead of + hardcoded copy, since both env var name and body copy differ per consuming app. + ## [1.0.2] - 2026-08-15 ### Fixed diff --git a/packages/app-kit/src/popup/AuthErrorPopup.test.tsx b/packages/app-kit/src/popup/AuthErrorPopup.test.tsx new file mode 100644 index 0000000..f11e260 --- /dev/null +++ b/packages/app-kit/src/popup/AuthErrorPopup.test.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import AuthErrorPopup from "./AuthErrorPopup"; + +describe("AuthErrorPopup", () => { + afterEach(() => { + cleanup(); + }); + + it("renders the message", () => { + render(); + + expect(screen.getByText("Sign-in failed")).toBeInTheDocument(); + }); + + it("calls onClose when Close is clicked", () => { + const onClose = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Close" })); + + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/app-kit/src/popup/AuthErrorPopup.tsx b/packages/app-kit/src/popup/AuthErrorPopup.tsx new file mode 100644 index 0000000..577ca51 --- /dev/null +++ b/packages/app-kit/src/popup/AuthErrorPopup.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { AlertCircle } from "lucide-react"; +import { BasePopup, BasePopupProps } from "./BasePopup"; +import { Button } from "@behindthemusictree/ui"; + +type AuthErrorPopupProps = Omit & { + message: string; + onClose: () => void; +}; + +export default function AuthErrorPopup({ message, onClose, ...rest }: AuthErrorPopupProps) { + return ( + +

{message}

+ + + } + /> + ); +} diff --git a/packages/app-kit/src/popup/AuthPopup.test.tsx b/packages/app-kit/src/popup/AuthPopup.test.tsx new file mode 100644 index 0000000..1c419cf --- /dev/null +++ b/packages/app-kit/src/popup/AuthPopup.test.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import AuthPopup from "./AuthPopup"; + +const spotifyOnlyDescription = ( + <> + My Library requires Spotify to access your saved tracks and playlists. + +); +const defaultDescription = ( + <> + My App requires sign-in to access your library + +); + +describe("AuthPopup", () => { + afterEach(() => { + cleanup(); + }); + + it("calls handleSpotifyOAuth with redirectAfterAuthPath when the Spotify button is clicked", () => { + const handleSpotifyOAuth = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /Sign in with Spotify/i })); + + expect(handleSpotifyOAuth).toHaveBeenCalledWith("/library"); + }); + + it("shows the Google button and wires it to handleGoogleOAuth when provided and not spotifyOnly", () => { + const handleGoogleOAuth = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /Sign in with Google/i })); + + expect(handleGoogleOAuth).toHaveBeenCalledWith("/library"); + }); + + it("hides the Google button when spotifyOnly is true", () => { + render( + , + ); + + expect(screen.queryByRole("button", { name: /Sign in with Google/i })).not.toBeInTheDocument(); + }); + + it("hides the Google button when handleGoogleOAuth is not provided", () => { + render( + , + ); + + expect(screen.queryByRole("button", { name: /Sign in with Google/i })).not.toBeInTheDocument(); + }); + + it("shows the spotifyOnly title and description when spotifyOnly is true", () => { + render( + , + ); + + expect(screen.getByText("Connect with Spotify")).toBeInTheDocument(); + expect(screen.getByText(/My Library/)).toBeInTheDocument(); + }); + + it("shows the default title and description when spotifyOnly is not set", () => { + render( + , + ); + + expect(screen.getByText("Sign in")).toBeInTheDocument(); + expect(screen.getByText(/My App/)).toBeInTheDocument(); + }); + + it("renders the optional message when provided", () => { + render( + , + ); + + expect(screen.getByText("Session expired")).toBeInTheDocument(); + }); + + it("is not dismissable", () => { + render( + , + ); + + expect(screen.queryByLabelText("Close popup")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/app-kit/src/popup/AuthPopup.tsx b/packages/app-kit/src/popup/AuthPopup.tsx new file mode 100644 index 0000000..d746f66 --- /dev/null +++ b/packages/app-kit/src/popup/AuthPopup.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { ReactNode } from "react"; +import { User } from "lucide-react"; +import { BasePopup, BasePopupProps } from "./BasePopup"; +import { Button } from "@behindthemusictree/ui"; +import { FaSpotify } from "react-icons/fa"; +import { FcGoogle } from "react-icons/fc"; + +type AuthPopupProps = Omit & { + handleSpotifyOAuth: (redirectAfterAuthPath?: string) => void; + handleGoogleOAuth?: (redirectAfterAuthPath?: string) => void; + redirectAfterAuthPath?: string; + spotifyOnly?: boolean; + message?: string; + /** + * Body copy shown when `spotifyOnly` is true. Wording (app name, feature description) is + * app-specific, so each consuming app supplies its own instead of this package assuming one. + */ + spotifyOnlyDescription: ReactNode; + /** Body copy shown when `spotifyOnly` is false, for the same reason as `spotifyOnlyDescription`. */ + defaultDescription: ReactNode; +}; + +export default function AuthPopup({ + handleSpotifyOAuth, + handleGoogleOAuth, + redirectAfterAuthPath, + spotifyOnly, + message, + spotifyOnlyDescription, + defaultDescription, + ...rest +}: AuthPopupProps) { + const showGoogle = !spotifyOnly && handleGoogleOAuth; + + return ( + +
+ {message &&

{message}

} +

+ {spotifyOnly ? spotifyOnlyDescription : defaultDescription} +

+
+
+ + {showGoogle && ( + + )} +
+ + } + /> + ); +} diff --git a/packages/app-kit/src/popup/InternalErrorPopup.test.tsx b/packages/app-kit/src/popup/InternalErrorPopup.test.tsx new file mode 100644 index 0000000..342a049 --- /dev/null +++ b/packages/app-kit/src/popup/InternalErrorPopup.test.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { describe, it, expect, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { ErrorCode } from "../transport/app-errors/app-error-codes"; +import InternalErrorPopup from "./InternalErrorPopup"; + +describe("InternalErrorPopup", () => { + afterEach(() => { + cleanup(); + }); + + it("renders the error code", () => { + render(); + + expect(screen.getByText(`Error Code: ${ErrorCode.CLIENT_INTERNAL_ERROR}`)).toBeInTheDocument(); + }); + + it("renders the given contact email as a mailto link", () => { + render(); + + const link = screen.getByRole("link", { name: "support@example.com" }); + expect(link.getAttribute("href")).toBe("mailto:support@example.com"); + }); + + it("is not dismissable", () => { + render(); + + expect(screen.queryByLabelText("Close popup")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/app-kit/src/popup/InternalErrorPopup.tsx b/packages/app-kit/src/popup/InternalErrorPopup.tsx new file mode 100644 index 0000000..045fd83 --- /dev/null +++ b/packages/app-kit/src/popup/InternalErrorPopup.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { AlertTriangle, AlertCircle } from "lucide-react"; +import { BasePopup, BasePopupProps } from "./BasePopup"; +import { ErrorCode } from "../transport/app-errors/app-error-codes"; + +type InternalErrorPopupProps = Omit & { + errorCode: ErrorCode; + /** + * Support contact email shown in the "if the problem persists" message. Grow's original + * hardcoded `process.env.NEXT_PUBLIC_CONTACT_EMAIL` directly; the consuming app now passes + * its own value (from whatever env var / config it uses) so this package doesn't assume that name. + */ + contactEmail?: string | null; +}; + +export default function InternalErrorPopup({ errorCode, contactEmail, ...rest }: InternalErrorPopupProps) { + return ( + + +
+

+ Please try again. If the problem persists, contact us at{" "} + + {contactEmail} + +

+
+ {errorCode && ( +
+ Error Code: {errorCode} +
+ )} + + } + /> + ); +} diff --git a/packages/app-kit/src/popup/NetworkErrorPopup.test.tsx b/packages/app-kit/src/popup/NetworkErrorPopup.test.tsx new file mode 100644 index 0000000..f743d30 --- /dev/null +++ b/packages/app-kit/src/popup/NetworkErrorPopup.test.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { describe, it, expect, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import NetworkErrorPopup from "./NetworkErrorPopup"; + +describe("NetworkErrorPopup", () => { + afterEach(() => { + cleanup(); + }); + + it("renders the offline message", () => { + render(); + + expect(screen.getByText(/not connected to the internet/i)).toBeInTheDocument(); + }); + + it("is not dismissable", () => { + render(); + + expect(screen.queryByLabelText("Close popup")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/app-kit/src/popup/NetworkErrorPopup.tsx b/packages/app-kit/src/popup/NetworkErrorPopup.tsx new file mode 100644 index 0000000..aa91792 --- /dev/null +++ b/packages/app-kit/src/popup/NetworkErrorPopup.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { AlertTriangle } from "lucide-react"; +import { BasePopup, BasePopupProps } from "./BasePopup"; + +type NetworkErrorPopupProps = Omit; + +export default function NetworkErrorPopup(props: NetworkErrorPopupProps) { + return ( + + +

+ It seems that you are not connected to the internet. Please check your connection and try again. +

+ + } + /> + ); +} diff --git a/packages/app-kit/src/popup/SpotifyAuthErrorPopup.test.tsx b/packages/app-kit/src/popup/SpotifyAuthErrorPopup.test.tsx new file mode 100644 index 0000000..ff58035 --- /dev/null +++ b/packages/app-kit/src/popup/SpotifyAuthErrorPopup.test.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import SpotifyAuthErrorPopup from "./SpotifyAuthErrorPopup"; + +describe("SpotifyAuthErrorPopup", () => { + afterEach(() => { + cleanup(); + }); + + it("renders the message", () => { + render(); + + expect(screen.getByText("Invalid credentials")).toBeInTheDocument(); + }); + + it("renders details when provided", () => { + render(); + + expect(screen.getByText("invalid_grant")).toBeInTheDocument(); + }); + + it("renders no details when omitted", () => { + render(); + + expect(screen.queryByText("invalid_grant")).not.toBeInTheDocument(); + }); + + it("renders a mailto link with the contact email when contactEmail is provided", () => { + render(); + + const links = screen.getAllByRole("link", { name: /support@example.com|Spotify full name/i }); + expect(links.length).toBeGreaterThan(0); + links.forEach((link) => expect(link.getAttribute("href")).toMatch(/^mailto:support@example\.com\?/)); + }); + + it("falls back to a generic message when no contact email is provided", () => { + render(); + + expect(screen.getByText(/To request access, contact the app owner\./)).toBeInTheDocument(); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + }); + + it("calls onClose when Try Again is clicked", () => { + const onClose = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: /Try Again/i })); + + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/app-kit/src/popup/SpotifyAuthErrorPopup.tsx b/packages/app-kit/src/popup/SpotifyAuthErrorPopup.tsx new file mode 100644 index 0000000..854e548 --- /dev/null +++ b/packages/app-kit/src/popup/SpotifyAuthErrorPopup.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { FaSpotify } from "react-icons/fa"; +import { BasePopup, BasePopupProps } from "./BasePopup"; +import { Button } from "@behindthemusictree/ui"; +import { User } from "lucide-react"; +import { getSpotifyAllowlistMailtoHref } from "../transport/app-errors/app-error-messages"; + +type SpotifyAuthErrorPopupProps = Omit & { + message: string; + details?: string; + onClose: () => void; + /** + * Support contact email used to build the allowlist-request mailto link. Grow's original + * hardcoded `process.env.NEXT_PUBLIC_CONTACT_EMAIL` directly; the consuming app now passes + * its own value (from whatever env var / config it uses) so this package doesn't assume that name. + */ + contactEmail?: string | null; +}; + +export default function SpotifyAuthErrorPopup({ + message, + details, + onClose, + contactEmail = null, + ...rest +}: SpotifyAuthErrorPopupProps) { + const requestAccessHref = getSpotifyAllowlistMailtoHref(contactEmail); + + return ( + + +
+

{message}

+ {details && ( +

{details}

+ )} +
+

+ The Spotify app is in development mode, so only allowlisted accounts can connect. +

+ {requestAccessHref && contactEmail ? ( +

+ Request access by emailing your{" "} + + Spotify full name and Spotify email address + {" "} + to{" "} + + {contactEmail} + + . +

+ ) : ( +

To request access, contact the app owner.

+ )} +
+
+ + + } + /> + ); +} diff --git a/packages/app-kit/src/popup/index.ts b/packages/app-kit/src/popup/index.ts index 5f06f46..5e15f03 100644 --- a/packages/app-kit/src/popup/index.ts +++ b/packages/app-kit/src/popup/index.ts @@ -4,3 +4,8 @@ export * from "./PopupButtons"; export * from "./BasePopup"; export * from "./useConnectivityErrorPopup"; export { default as TrackUploadPopup } from "./TrackUploadPopup"; +export { default as AuthErrorPopup } from "./AuthErrorPopup"; +export { default as InternalErrorPopup } from "./InternalErrorPopup"; +export { default as NetworkErrorPopup } from "./NetworkErrorPopup"; +export { default as AuthPopup } from "./AuthPopup"; +export { default as SpotifyAuthErrorPopup } from "./SpotifyAuthErrorPopup"; diff --git a/packages/app-kit/src/vitest-matchers.d.ts b/packages/app-kit/src/vitest-matchers.d.ts new file mode 100644 index 0000000..f149f27 --- /dev/null +++ b/packages/app-kit/src/vitest-matchers.d.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest";