feat(pwa): add InstallBanner component with tests
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e23013b0af
commit
8e41b031aa
2 changed files with 177 additions and 0 deletions
128
apps/main/src/components/InstallBanner.test.tsx
Normal file
128
apps/main/src/components/InstallBanner.test.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock the hook so we can control its return values
|
||||
const mockPromptInstall = vi.fn();
|
||||
const mockDismiss = vi.fn();
|
||||
|
||||
vi.mock("../hooks/useInstallPrompt", () => ({
|
||||
useInstallPrompt: vi.fn(() => ({
|
||||
canInstall: false,
|
||||
isStandalone: false,
|
||||
isIOS: false,
|
||||
isDismissed: false,
|
||||
promptInstall: mockPromptInstall,
|
||||
dismiss: mockDismiss,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { useInstallPrompt } from "../hooks/useInstallPrompt";
|
||||
import { InstallBanner } from "./InstallBanner";
|
||||
|
||||
const mockUseInstallPrompt = vi.mocked(useInstallPrompt);
|
||||
|
||||
describe("InstallBanner", () => {
|
||||
beforeEach(() => {
|
||||
mockPromptInstall.mockClear();
|
||||
mockDismiss.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders nothing when canInstall is false and not iOS", () => {
|
||||
mockUseInstallPrompt.mockReturnValue({
|
||||
canInstall: false,
|
||||
isStandalone: false,
|
||||
isIOS: false,
|
||||
isDismissed: false,
|
||||
promptInstall: mockPromptInstall,
|
||||
dismiss: mockDismiss,
|
||||
});
|
||||
const { container } = render(<InstallBanner />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing when already in standalone mode", () => {
|
||||
mockUseInstallPrompt.mockReturnValue({
|
||||
canInstall: true,
|
||||
isStandalone: true,
|
||||
isIOS: false,
|
||||
isDismissed: false,
|
||||
promptInstall: mockPromptInstall,
|
||||
dismiss: mockDismiss,
|
||||
});
|
||||
const { container } = render(<InstallBanner />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing when dismissed", () => {
|
||||
mockUseInstallPrompt.mockReturnValue({
|
||||
canInstall: true,
|
||||
isStandalone: false,
|
||||
isIOS: false,
|
||||
isDismissed: true,
|
||||
promptInstall: mockPromptInstall,
|
||||
dismiss: mockDismiss,
|
||||
});
|
||||
const { container } = render(<InstallBanner />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders install banner when canInstall is true", () => {
|
||||
mockUseInstallPrompt.mockReturnValue({
|
||||
canInstall: true,
|
||||
isStandalone: false,
|
||||
isIOS: false,
|
||||
isDismissed: false,
|
||||
promptInstall: mockPromptInstall,
|
||||
dismiss: mockDismiss,
|
||||
});
|
||||
render(<InstallBanner />);
|
||||
expect(screen.getByText(/install/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls promptInstall when install button is clicked", async () => {
|
||||
mockUseInstallPrompt.mockReturnValue({
|
||||
canInstall: true,
|
||||
isStandalone: false,
|
||||
isIOS: false,
|
||||
isDismissed: false,
|
||||
promptInstall: mockPromptInstall,
|
||||
dismiss: mockDismiss,
|
||||
});
|
||||
render(<InstallBanner />);
|
||||
await userEvent.click(screen.getByRole("button", { name: /install/i }));
|
||||
expect(mockPromptInstall).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("calls dismiss when close button is clicked", async () => {
|
||||
mockUseInstallPrompt.mockReturnValue({
|
||||
canInstall: true,
|
||||
isStandalone: false,
|
||||
isIOS: false,
|
||||
isDismissed: false,
|
||||
promptInstall: mockPromptInstall,
|
||||
dismiss: mockDismiss,
|
||||
});
|
||||
render(<InstallBanner />);
|
||||
await userEvent.click(screen.getByRole("button", { name: /dismiss|close/i }));
|
||||
expect(mockDismiss).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders iOS instructions when isIOS is true and not dismissed", () => {
|
||||
mockUseInstallPrompt.mockReturnValue({
|
||||
canInstall: false,
|
||||
isStandalone: false,
|
||||
isIOS: true,
|
||||
isDismissed: false,
|
||||
promptInstall: mockPromptInstall,
|
||||
dismiss: mockDismiss,
|
||||
});
|
||||
render(<InstallBanner />);
|
||||
expect(screen.getByText(/share/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/add to home screen/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
49
apps/main/src/components/InstallBanner.tsx
Normal file
49
apps/main/src/components/InstallBanner.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { Download, Share, X } from "lucide-react";
|
||||
import { useInstallPrompt } from "../hooks/useInstallPrompt";
|
||||
|
||||
export function InstallBanner() {
|
||||
const { canInstall, isStandalone, isIOS, isDismissed, promptInstall, dismiss } =
|
||||
useInstallPrompt();
|
||||
|
||||
// Don't show if already installed, dismissed, or no install option available
|
||||
if (isStandalone || isDismissed) return null;
|
||||
if (!canInstall && !isIOS) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 border-b border-border bg-card px-4 py-2.5 text-sm">
|
||||
{isIOS ? (
|
||||
<>
|
||||
<Share className="size-4 shrink-0 text-muted-foreground" />
|
||||
<p className="flex-1 text-foreground">
|
||||
Install XTablo: tap{" "}
|
||||
<span className="font-medium">Share</span> then{" "}
|
||||
<span className="font-medium">Add to Home Screen</span>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="size-4 shrink-0 text-muted-foreground" />
|
||||
<p className="flex-1 text-foreground">
|
||||
Get the app for a faster, native experience
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={promptInstall}
|
||||
className="shrink-0 rounded-md bg-primary px-3 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
aria-label="Install app"
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue