Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions packages/app-kit/src/popup/AuthErrorPopup.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<AuthErrorPopup message="Sign-in failed" onClose={vi.fn()} />);

expect(screen.getByText("Sign-in failed")).toBeInTheDocument();
});

it("calls onClose when Close is clicked", () => {
const onClose = vi.fn();
render(<AuthErrorPopup message="Sign-in failed" onClose={onClose} />);

fireEvent.click(screen.getByRole("button", { name: "Close" }));

expect(onClose).toHaveBeenCalledTimes(1);
});
});
29 changes: 29 additions & 0 deletions packages/app-kit/src/popup/AuthErrorPopup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"use client";

import { AlertCircle } from "lucide-react";
import { BasePopup, BasePopupProps } from "./BasePopup";
import { Button } from "@behindthemusictree/ui";

type AuthErrorPopupProps = Omit<BasePopupProps, "title" | "children" | "icon" | "isDismissable"> & {
message: string;
onClose: () => void;
};

export default function AuthErrorPopup({ message, onClose, ...rest }: AuthErrorPopupProps) {
return (
<BasePopup
{...rest}
title="Sign-in error"
isDismissable
icon={AlertCircle}
children={
<div className="flex flex-col items-center space-y-6 py-4">
<p className="text-center text-lg font-medium text-gray-800">{message}</p>
<Button onClick={onClose} variant="secondary" className="w-full max-w-xs">
Close
</Button>
</div>
}
/>
);
}
133 changes: 133 additions & 0 deletions packages/app-kit/src/popup/AuthPopup.test.tsx
Original file line number Diff line number Diff line change
@@ -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 = (
<>
<b>My Library</b> requires Spotify to access your saved tracks and playlists.
</>
);
const defaultDescription = (
<>
<b>My App</b> 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(
<AuthPopup
handleSpotifyOAuth={handleSpotifyOAuth}
redirectAfterAuthPath="/library"
spotifyOnlyDescription={spotifyOnlyDescription}
defaultDescription={defaultDescription}
/>,
);

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(
<AuthPopup
handleSpotifyOAuth={vi.fn()}
handleGoogleOAuth={handleGoogleOAuth}
redirectAfterAuthPath="/library"
spotifyOnlyDescription={spotifyOnlyDescription}
defaultDescription={defaultDescription}
/>,
);

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(
<AuthPopup
handleSpotifyOAuth={vi.fn()}
handleGoogleOAuth={vi.fn()}
spotifyOnly
spotifyOnlyDescription={spotifyOnlyDescription}
defaultDescription={defaultDescription}
/>,
);

expect(screen.queryByRole("button", { name: /Sign in with Google/i })).not.toBeInTheDocument();
});

it("hides the Google button when handleGoogleOAuth is not provided", () => {
render(
<AuthPopup
handleSpotifyOAuth={vi.fn()}
spotifyOnlyDescription={spotifyOnlyDescription}
defaultDescription={defaultDescription}
/>,
);

expect(screen.queryByRole("button", { name: /Sign in with Google/i })).not.toBeInTheDocument();
});

it("shows the spotifyOnly title and description when spotifyOnly is true", () => {
render(
<AuthPopup
handleSpotifyOAuth={vi.fn()}
spotifyOnly
spotifyOnlyDescription={spotifyOnlyDescription}
defaultDescription={defaultDescription}
/>,
);

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(
<AuthPopup
handleSpotifyOAuth={vi.fn()}
spotifyOnlyDescription={spotifyOnlyDescription}
defaultDescription={defaultDescription}
/>,
);

expect(screen.getByText("Sign in")).toBeInTheDocument();
expect(screen.getByText(/My App/)).toBeInTheDocument();
});

it("renders the optional message when provided", () => {
render(
<AuthPopup
handleSpotifyOAuth={vi.fn()}
message="Session expired"
spotifyOnlyDescription={spotifyOnlyDescription}
defaultDescription={defaultDescription}
/>,
);

expect(screen.getByText("Session expired")).toBeInTheDocument();
});

it("is not dismissable", () => {
render(
<AuthPopup
handleSpotifyOAuth={vi.fn()}
spotifyOnlyDescription={spotifyOnlyDescription}
defaultDescription={defaultDescription}
/>,
);

expect(screen.queryByLabelText("Close popup")).not.toBeInTheDocument();
});
});
81 changes: 81 additions & 0 deletions packages/app-kit/src/popup/AuthPopup.tsx
Original file line number Diff line number Diff line change
@@ -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<BasePopupProps, "title" | "children" | "icon" | "isDismissable"> & {
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 (
<BasePopup
{...rest}
title={spotifyOnly ? "Connect with Spotify" : "Sign in"}
isDismissable={false}
icon={User}
type="auth"
children={
<div className="flex flex-col items-center space-y-7">
<div className="px-2 text-center">
{message && <p className="mb-3 text-base font-medium leading-relaxed text-amber-200">{message}</p>}
<p className="text-lg font-medium leading-relaxed text-white">
{spotifyOnly ? spotifyOnlyDescription : defaultDescription}
</p>
</div>
<div className="flex w-full flex-col items-center gap-3">
<Button
onClick={() => handleSpotifyOAuth(redirectAfterAuthPath)}
className="relative mb-2 w-fit transform bg-green-600 px-8 py-3 text-white shadow-lg transition-all duration-200 hover:scale-[1.02] hover:bg-green-700 hover:shadow-xl"
>
<span className="flex items-center justify-center">
<FaSpotify className="absolute left-6 text-3xl" />
<span className="w-full pl-14 pr-8 text-center text-lg font-medium">Sign in with Spotify</span>
</span>
</Button>
{showGoogle && (
<Button
variant="outline"
onClick={() => handleGoogleOAuth!(redirectAfterAuthPath)}
className="relative w-fit transform border-white bg-white px-8 py-3 text-black shadow-lg transition-all duration-200 hover:scale-[1.02] hover:bg-gray-100 hover:shadow-xl"
>
<span className="flex items-center justify-center">
<FcGoogle className="absolute left-6 text-2xl" />
<span className="w-full pl-14 pr-8 text-center text-lg font-medium text-black">
Sign in with Google
</span>
</span>
</Button>
)}
</div>
</div>
}
/>
);
}
31 changes: 31 additions & 0 deletions packages/app-kit/src/popup/InternalErrorPopup.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<InternalErrorPopup errorCode={ErrorCode.CLIENT_INTERNAL_ERROR} />);

expect(screen.getByText(`Error Code: ${ErrorCode.CLIENT_INTERNAL_ERROR}`)).toBeInTheDocument();
});

it("renders the given contact email as a mailto link", () => {
render(<InternalErrorPopup errorCode={ErrorCode.CLIENT_INTERNAL_ERROR} contactEmail="support@example.com" />);

const link = screen.getByRole("link", { name: "support@example.com" });
expect(link.getAttribute("href")).toBe("mailto:support@example.com");
});

it("is not dismissable", () => {
render(<InternalErrorPopup errorCode={ErrorCode.CLIENT_INTERNAL_ERROR} />);

expect(screen.queryByLabelText("Close popup")).not.toBeInTheDocument();
});
});
44 changes: 44 additions & 0 deletions packages/app-kit/src/popup/InternalErrorPopup.tsx
Original file line number Diff line number Diff line change
@@ -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<BasePopupProps, "title" | "children" | "icon" | "isDismissable"> & {
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 (
<BasePopup
{...rest}
title="Internal Error"
isDismissable={false}
icon={AlertTriangle}
children={
<div className="flex flex-col items-center space-y-6 py-4">
<AlertCircle className="h-16 w-16 text-red-500" strokeWidth={1.5} />
<div>
<p className="text-center text-gray-600">
Please try again. If the problem persists, contact us at{" "}
<a href={`mailto:${contactEmail}`} className="text-blue-600 hover:text-blue-800">
{contactEmail}
</a>
</p>
</div>
{errorCode && (
<div className="text-sm text-gray-500 bg-gray-50 p-3 rounded-lg border border-gray-100">
Error Code: {errorCode}
</div>
)}
</div>
}
/>
);
}
23 changes: 23 additions & 0 deletions packages/app-kit/src/popup/NetworkErrorPopup.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<NetworkErrorPopup />);

expect(screen.getByText(/not connected to the internet/i)).toBeInTheDocument();
});

it("is not dismissable", () => {
render(<NetworkErrorPopup />);

expect(screen.queryByLabelText("Close popup")).not.toBeInTheDocument();
});
});
Loading
Loading