diff --git a/apps/main/src/components/InstallBanner.test.tsx b/apps/main/src/components/InstallBanner.test.tsx
new file mode 100644
index 0000000..8aaed34
--- /dev/null
+++ b/apps/main/src/components/InstallBanner.test.tsx
@@ -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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ expect(screen.getByText(/share/i)).toBeInTheDocument();
+ expect(screen.getByText(/add to home screen/i)).toBeInTheDocument();
+ });
+});
diff --git a/apps/main/src/components/InstallBanner.tsx b/apps/main/src/components/InstallBanner.tsx
new file mode 100644
index 0000000..e9ff517
--- /dev/null
+++ b/apps/main/src/components/InstallBanner.tsx
@@ -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 (
+
+ {isIOS ? (
+ <>
+
+
+ Install XTablo: tap{" "}
+ Share then{" "}
+ Add to Home Screen
+
+ >
+ ) : (
+ <>
+
+
+ Get the app for a faster, native experience
+
+
+ >
+ )}
+
+
+ );
+}