diff --git a/src/components/immersive-landing/ImmersiveLanding.tsx b/src/components/immersive-landing/ImmersiveLanding.tsx index 870e7dc..110e697 100644 --- a/src/components/immersive-landing/ImmersiveLanding.tsx +++ b/src/components/immersive-landing/ImmersiveLanding.tsx @@ -1,11 +1,17 @@ import { Show, splitProps, type Component } from "solid-js"; import { twMerge } from "tailwind-merge"; -import type { ImmersiveLandingProps, ImmersiveLandingContextValue } from "./types"; +import type { + ImmersiveLandingProps, + ImmersiveLandingContextValue, +} from "./types"; import { useImmersiveLanding } from "./useImmersiveLanding"; import { ImmersiveLandingContext } from "./ImmersiveLandingContext"; import ImmersiveLandingPage from "./ImmersiveLandingPage"; import ImmersiveLandingArrows from "./ImmersiveLandingArrows"; import ImmersiveLandingNavigation from "./ImmersiveLandingNavigation"; +import { PWAInstallPrompt } from "./components/PWAInstallPrompt"; +import { FirefoxPWABanner } from "./components/FirefoxPWABanner"; +import { CookieConsent } from "./components/CookieConsent"; const ImmersiveLanding: Component = (props) => { // Don't split children - access directly from props to preserve reactivity @@ -25,6 +31,12 @@ const ImmersiveLanding: Component = (props) => { "class", "className", "style", + "pwaConfig", + "cookieConfig", + "firefoxPWAConfig", + "showPWAPrompt", + "showCookieConsent", + "showFirefoxBanner", ]); const navigation = useImmersiveLanding({ @@ -121,6 +133,35 @@ const ImmersiveLanding: Component = (props) => { isLastPage={navigation.isLastPage()} /> )} + + + + + + + + + + ); }; diff --git a/src/components/immersive-landing/components/CookieConsent.tsx b/src/components/immersive-landing/components/CookieConsent.tsx new file mode 100644 index 0000000..8f7f6c8 --- /dev/null +++ b/src/components/immersive-landing/components/CookieConsent.tsx @@ -0,0 +1,371 @@ +import { + type Component, + createSignal, + createEffect, + onMount, + onCleanup, + Show, +} from "solid-js"; + +import { ConsentType, CookieConsentProps } from "../types"; +import Button from "../../button"; +import Flex from "../../flex"; + +/** + * CookieConsent Component + * + * Displays a fixed bottom banner on first visit to collect user cookie preferences. + * Provides three options: + * - Accept All: Enables all cookies (essential, analytics, marketing) + * - Decline: Only essential cookies (analytics and marketing disabled) + * - Manage: Opens modal to customize cookie preferences + * + * This component is brand-agnostic and fully configurable via props. + * + * @component + * + * @example + * ```tsx + * import { CookieConsent } from "@your-shared-library"; + * import { t } from "~/stores/i18nStore"; + * + * export const App = () => { + * return ( + *
+ * + * + * { + * console.log("Consent changed:", data); + * + * if (data.analytics) { + * enableAnalytics(); + * } + * }} + * /> + *
+ * ); + * }; + * ``` + * + * Storage Keys: + * - consentKey: stores consent type ("all" | "essential" | "custom") + * - analyticsKey: stores analytics preference ("true" | "false") + * - marketingKey: stores marketing preference ("true" | "false") + * + * Accessibility: + * - Banner: role="dialog", aria-modal="false" + * - Modal: role="dialog", aria-modal="true" + * - All interactive elements are keyboard accessible + * - Uses semantic HTML and proper ARIA labels + * + * Performance: + * - Only renders on first visit (checks localStorage) + * - Smooth animations with CSS transitions + * - Respects prefers-reduced-motion + * + * Events: + * - onConsentChange(payload) + * payload = { + * type: "all" | "essential" | "custom", + * analytics: boolean, + * marketing: boolean + * } + */ + +const defaultTexts = { + message: + "We use cookies to improve your experience. You can accept all cookies or manage your preferences.", + acceptAll: "Accept all", + decline: "Decline", + manage: "Manage", + manageTitle: "Manage cookie preferences", + essential: "Essential (required)", + analytics: "Analytics", + marketing: "Marketing", + cancel: "Cancel", + save: "Save", + closeLabel: "Close", + }; + + +export const CookieConsent: Component = (props) => { + const [showBanner, setShowBanner] = createSignal(false); + const [showManage, setShowManage] = createSignal(false); + const [isClosing, setIsClosing] = createSignal(false); + + // Preference states for manage modal + const [analyticsEnabled, setAnalyticsEnabled] = createSignal(false); + const [marketingEnabled, setMarketingEnabled] = createSignal(false); + + const CONSENT_KEY = () => + props.storageKeys?.consentKey ?? "app_cookie_consent"; + + const ANALYTICS_KEY = () => + props.storageKeys?.analyticsKey ?? "app_cookie_analytics"; + + const MARKETING_KEY = () => + props.storageKeys?.marketingKey ?? "app_cookie_marketing"; + + const texts = () => ({ + message: props.texts?.message ?? defaultTexts.message, + acceptAll: props.texts?.acceptAll ?? defaultTexts.acceptAll, + decline: props.texts?.decline ?? defaultTexts.decline, + manage: props.texts?.manage ?? defaultTexts.manage, + manageTitle: props.texts?.manageTitle ?? defaultTexts.manageTitle, + essential: props.texts?.essential ?? defaultTexts.essential, + analytics: props.texts?.analytics ?? defaultTexts.analytics, + marketing: props.texts?.marketing ?? defaultTexts.marketing, + cancel: props.texts?.cancel ?? defaultTexts.cancel, + save: props.texts?.save ?? defaultTexts.save, + closeLabel: props.texts?.closeLabel ?? defaultTexts.closeLabel, + }); + + + + const checkConsent = (): boolean => { + const consent = localStorage.getItem(CONSENT_KEY()); + return !consent; // Show banner if no consent is stored + }; + + const saveConsent = (type: ConsentType) => { + localStorage.setItem(CONSENT_KEY(), type); + + if (type === "all") { + localStorage.setItem(ANALYTICS_KEY(), "true"); + localStorage.setItem(MARKETING_KEY(), "true"); + } else if (type === "essential") { + localStorage.setItem(ANALYTICS_KEY(), "false"); + localStorage.setItem(MARKETING_KEY(), "false"); + } + // "custom" is handled by the manage modal's save function + emitChange(type); + }; + + const emitChange = (type: ConsentType) => { + props.onConsentChange?.({ + type, + analytics: localStorage.getItem(ANALYTICS_KEY()) === "true", + marketing: localStorage.getItem(MARKETING_KEY()) === "true", + }); + }; + + const handleAcceptAll = () => { + saveConsent("all"); + closeBanner(); + }; + + const handleDecline = () => { + saveConsent("essential"); + closeBanner(); + }; + + const handleManageOpen = () => { + // Load current preferences + const analytics = localStorage.getItem(ANALYTICS_KEY()) === "true"; + const marketing = localStorage.getItem(MARKETING_KEY()) === "true"; + setAnalyticsEnabled(analytics); + setMarketingEnabled(marketing); + setShowManage(true); + }; + + const handleManageClose = () => { + setShowManage(false); + }; + + const handleManageSave = () => { + localStorage.setItem(CONSENT_KEY(), "custom"); + localStorage.setItem(ANALYTICS_KEY(), analyticsEnabled().toString()); + localStorage.setItem(MARKETING_KEY(), marketingEnabled().toString()); + setShowManage(false); + closeBanner(); + emitChange("custom"); + }; + + const closeBanner = () => { + setIsClosing(true); + // Wait for fade-out animation + setTimeout(() => { + setShowBanner(false); + setIsClosing(false); + }, 300); + }; + + // Handle escape key for manage modal + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && showManage()) { + handleManageClose(); + } + }; + + onMount(() => { + if (checkConsent()) { + setShowBanner(true); + } + document.addEventListener("keydown", handleKeyDown); + }); + + onCleanup(() => { + document.removeEventListener("keydown", handleKeyDown); + }); + + return ( + <> + {/* Cookie Consent Banner */} + + + + + + + + + ); +}; diff --git a/src/components/immersive-landing/components/FirefoxPWABanner.tsx b/src/components/immersive-landing/components/FirefoxPWABanner.tsx new file mode 100644 index 0000000..a90d82f --- /dev/null +++ b/src/components/immersive-landing/components/FirefoxPWABanner.tsx @@ -0,0 +1,180 @@ +import { type Component, createSignal, onMount, Show } from "solid-js"; +import { BrowserType, FirefoxPWABannerProps } from "../types"; +import Button from "../../button"; +import Card from "../../card"; +import Flex from "../../flex"; +import Icon from "../../icon"; + +const defaultTexts = { + title: "Install App on Firefox", + description: + "Firefox does not support direct app installation. Install our helper extension to enable PWA support.", + installButton: "Install Extension", + dismissButton: "Maybe later", + closeLabel: "Close", +}; +/** + * Detect browser type and PWA support + */ +const detectBrowser = (): BrowserType => { + const ua = navigator.userAgent.toLowerCase(); + + // Check if beforeinstallprompt is supported (Chromium browsers) + if ("BeforeInstallPromptEvent" in globalThis) { + return "supported"; + } + + // Detect specific browsers + if (ua.includes("firefox")) { + return "firefox"; + } + + if (ua.includes("safari") && !ua.includes("chrome")) { + return "safari"; + } + + return "other"; +}; + +/** + * Check if running on mobile + */ +const isMobile = (): boolean => { + return /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test( + navigator.userAgent.toLowerCase() + ); +}; + +/** + * Check if PWA is already installed (running in standalone mode) + */ +const isPWAInstalled = (): boolean => { + return globalThis.matchMedia("(display-mode: standalone)").matches; +}; + +/** + * PWAUnsupportedBanner Component + * + * Shows a dismissible banner for browsers that don't support native PWA installation + * (beforeinstallprompt). Currently shows for Firefox desktop with extension recommendation. + */ +export const FirefoxPWABanner: Component = (props) => { + const [showBanner, setShowBanner] = createSignal(false); + const [browser, setBrowser] = createSignal("supported"); + + const STORAGE_KEY = () => props.storageKey ?? "app_firefox_pwa_dismissed"; + const extensionUrl = () => + props.extensionUrl ?? "https://addons.mozilla.org/"; + const texts = () => ({ + title: props.texts?.title ?? defaultTexts.title, + description: props.texts?.description ?? defaultTexts.description, + installButton: props.texts?.installButton ?? defaultTexts.installButton, + dismissButton: props.texts?.dismissButton ?? defaultTexts.dismissButton, + closeLabel: props.texts?.closeLabel ?? defaultTexts.closeLabel, + }); + + const checkShouldShow = (): boolean => { + const detectedBrowser = detectBrowser(); + setBrowser(detectedBrowser); + + // Only show for unsupported browsers on desktop + if (detectedBrowser === "supported") return false; + if (isMobile()) return false; + + // For now, only show for Firefox (has extension solution) + // Safari users can use "Add to Dock" manually + if (detectedBrowser !== "firefox") return false; + + // Don't show if already running as PWA + if (isPWAInstalled()) return false; + + // Don't show if user has dismissed + const dismissed = localStorage.getItem(STORAGE_KEY()); + if (dismissed === "true") return false; + + return true; + }; + + const handleDismiss = () => { + setShowBanner(false); + localStorage.setItem(STORAGE_KEY(), "true"); + props.onDismiss?.(); + }; + + const handleAction = () => { + if (browser() === "firefox") { + globalThis.open(extensionUrl(), "_blank", "noopener,noreferrer"); + props.onInstall?.(); + } + }; + + onMount(() => { + if (checkShouldShow()) { + // Small delay to not overwhelm user immediately + setTimeout(() => setShowBanner(true), 2000); + } + }); + + return ( + +
+ + + + + +
+ + + +
+ + + + {texts().title} + +

+ {texts().description} +

+
+
+ + + + + +
+
+
+
+ ); +}; diff --git a/src/components/immersive-landing/components/PWAInstallPrompt.tsx b/src/components/immersive-landing/components/PWAInstallPrompt.tsx new file mode 100644 index 0000000..5c22aa9 --- /dev/null +++ b/src/components/immersive-landing/components/PWAInstallPrompt.tsx @@ -0,0 +1,161 @@ +import { + type Component, + createSignal, + onMount, + onCleanup, + Show, +} from "solid-js"; +import { PWAInstallPromptProps } from "../types"; +import Button from "../../button"; +import Card from "../../card"; +import Flex from "../../flex"; +const DISMISS_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds + +const defaultTexts = { + title: "Install App", + description: "Add this app to your home screen for a better experience.", + installButton: "Install", + notNowButton: "Not now", + closeLabel: "Close", +}; + +export const PWAInstallPrompt: Component = (props) => { + const STORAGE_KEY = () => props.storageKey ?? "app_pwa_dismissed"; + const appName = () => props.appName ?? "My App"; + const appIcon = () => props.appIcon ?? "/icon-192.png"; + + const texts = () => ({ + title: props.texts?.title ?? defaultTexts.title, + description: props.texts?.description ?? defaultTexts.description, + installButton: props.texts?.installButton ?? defaultTexts.installButton, + notNowButton: props.texts?.notNowButton ?? defaultTexts.notNowButton, + closeLabel: props.texts?.closeLabel ?? defaultTexts.closeLabel, + }); + + const [deferredPrompt, setDeferredPrompt] = + createSignal(null); + const [showPrompt, setShowPrompt] = createSignal(false); + + const checkDismissalStatus = (): boolean => { + const dismissed = localStorage.getItem(STORAGE_KEY()); + if (!dismissed) return true; + + const dismissedTime = Number.parseInt(dismissed, 10); + const now = Date.now(); + + if (now - dismissedTime > DISMISS_DURATION) { + localStorage.removeItem(STORAGE_KEY()); + return true; + } + + return false; + }; + + const handleBeforeInstallPrompt = (e: BeforeInstallPromptEvent) => { + e.preventDefault(); + setDeferredPrompt(e); + + if (checkDismissalStatus()) { + setShowPrompt(true); + } + }; + + const handleInstall = async () => { + const prompt = deferredPrompt(); + if (!prompt) return; + + try { + prompt.prompt(); + const result = await prompt.userChoice; + + if (result.outcome === "accepted") { + setShowPrompt(false); + setDeferredPrompt(null); + props.onInstall?.(); + } + } catch (error) { + console.debug("PWA install prompt failed:", error); + } + }; + + const handleDismiss = () => { + setShowPrompt(false); + localStorage.setItem(STORAGE_KEY(), Date.now().toString()); + props.onDismiss?.(); + }; + + const handleAppInstalled = () => { + setShowPrompt(false); + setDeferredPrompt(null); + }; + + onMount(() => { + window.addEventListener( + "beforeinstallprompt", + handleBeforeInstallPrompt as EventListener + ); + window.addEventListener("appinstalled", handleAppInstalled); + }); + + onCleanup(() => { + window.removeEventListener( + "beforeinstallprompt", + handleBeforeInstallPrompt as EventListener + ); + window.removeEventListener("appinstalled", handleAppInstalled); + }); + + return ( + + + + ); +}; diff --git a/src/components/immersive-landing/types.ts b/src/components/immersive-landing/types.ts index aef8c32..5860e5a 100644 --- a/src/components/immersive-landing/types.ts +++ b/src/components/immersive-landing/types.ts @@ -52,6 +52,12 @@ export interface ImmersiveLandingProps extends IComponentBaseProps { appVersion?: string; overlay?: JSX.Element | ((context: ImmersiveLandingContextValue) => JSX.Element); children: JSX.Element | ((context: ImmersiveLandingContextValue) => JSX.Element); + pwaConfig?: PWAInstallPromptProps; + cookieConfig?: CookieConsentProps; + firefoxPWAConfig?: FirefoxPWABannerProps; + showPWAPrompt?: boolean; + showCookieConsent?: boolean; + showFirefoxBanner?: boolean; } export interface ImmersiveLandingPageProps extends IComponentBaseProps { @@ -75,3 +81,77 @@ export interface ImmersiveLandingNavigationProps extends IComponentBaseProps { isFirstPage: boolean; isLastPage: boolean; } + +export type ConsentType = "all" | "essential" | "custom"; + +export interface CookieConsentTexts { + message?: string; + acceptAll?: string; + decline?: string; + manage?: string; + + manageTitle?: string; + + essential?: string; + analytics?: string; + marketing: string; + + cancel?: string; + save?: string; + + closeLabel?: string; +} + +export interface CookieConsentStorageKeys { + consentKey?: string; + analyticsKey?: string; + marketingKey?: string; +} + +export interface CookieConsentProps { + texts?: CookieConsentTexts; + storageKeys?: CookieConsentStorageKeys; + + onConsentChange?: (payload: { + type: ConsentType; + analytics: boolean; + marketing: boolean; + }) => void; +} + + +export type BrowserType = "firefox" | "safari" | "other" | "supported"; + +export interface FirefoxPWABannerTexts { + title?: string; + description?: string; + installButton?: string; + dismissButton?: string; + closeLabel?: string; +} + +export interface FirefoxPWABannerProps { + extensionUrl?: string; + storageKey?: string; + texts?: FirefoxPWABannerTexts; + onInstall?: () => void; + onDismiss?: () => void; +} + + +export interface PWAInstallPromptTexts { + title?: string; + description?: string; + installButton?: string; + notNowButton?: string; + closeLabel?: string; +} + +export interface PWAInstallPromptProps { + appName?: string; + appIcon?: string; + storageKey?: string; + texts?: PWAInstallPromptTexts; + onInstall?: () => void; + onDismiss?: () => void; +} diff --git a/src/components/immersive-landing/types/pwa.d.ts b/src/components/immersive-landing/types/pwa.d.ts new file mode 100644 index 0000000..16897e1 --- /dev/null +++ b/src/components/immersive-landing/types/pwa.d.ts @@ -0,0 +1,7 @@ +// TypeScript interface for PWA BeforeInstallPromptEvent +// This event is fired when the browser determines the app can be installed +interface BeforeInstallPromptEvent extends Event { + prompt(): Promise; + userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>; + } + \ No newline at end of file