InstallAppButton.tsx 2.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
  1. import { useState, useEffect } from 'react';
  2. import { Download } from 'lucide-react';
  3. import { useTranslation } from 'react-i18next';
  4. import { useToast } from '../contexts/ToastContext';
  5. // The beforeinstallprompt event is not in the standard TS DOM lib.
  6. interface BeforeInstallPromptEvent extends Event {
  7. readonly platforms: string[];
  8. prompt: () => Promise<void>;
  9. readonly userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>;
  10. }
  11. /**
  12. * Sidebar-footer button that installs Bambuddy as a PWA.
  13. *
  14. * Chrome for Android removed the automatic install banner in Chrome 108, so
  15. * without an in-app trigger the only install path on Android is a buried
  16. * browser-menu item (#1460). This button captures the `beforeinstallprompt`
  17. * event and re-fires it on click. It renders nothing when the browser has no
  18. * pending prompt - already installed, unsupported browser, or iOS Safari
  19. * (which has no programmatic install at all).
  20. */
  21. export function InstallAppButton() {
  22. const { t } = useTranslation();
  23. const { showToast } = useToast();
  24. const [promptEvent, setPromptEvent] = useState<BeforeInstallPromptEvent | null>(null);
  25. useEffect(() => {
  26. const onBeforeInstallPrompt = (e: Event) => {
  27. // Suppress Chrome's own mini-infobar (desktop) so this button is the
  28. // single, predictable install entry point.
  29. e.preventDefault();
  30. setPromptEvent(e as BeforeInstallPromptEvent);
  31. };
  32. const onInstalled = () => setPromptEvent(null);
  33. window.addEventListener('beforeinstallprompt', onBeforeInstallPrompt);
  34. window.addEventListener('appinstalled', onInstalled);
  35. return () => {
  36. window.removeEventListener('beforeinstallprompt', onBeforeInstallPrompt);
  37. window.removeEventListener('appinstalled', onInstalled);
  38. };
  39. }, []);
  40. if (!promptEvent) {
  41. return null;
  42. }
  43. const handleInstall = async () => {
  44. await promptEvent.prompt();
  45. const { outcome } = await promptEvent.userChoice;
  46. // A captured prompt can only be used once; drop it either way so the
  47. // button hides until the browser fires a fresh beforeinstallprompt.
  48. setPromptEvent(null);
  49. if (outcome === 'accepted') {
  50. showToast(t('nav.installAppSuccess'), 'success');
  51. }
  52. };
  53. return (
  54. <button
  55. onClick={handleInstall}
  56. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  57. title={t('nav.installApp')}
  58. aria-label={t('nav.installApp')}
  59. >
  60. <Download className="w-5 h-5" />
  61. </button>
  62. );
  63. }