@affectively/aeon-pages 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +625 -0
  3. package/examples/basic/aeon.config.ts +39 -0
  4. package/examples/basic/components/Cursor.tsx +86 -0
  5. package/examples/basic/components/OfflineIndicator.tsx +103 -0
  6. package/examples/basic/components/PresenceBar.tsx +77 -0
  7. package/examples/basic/package.json +20 -0
  8. package/examples/basic/pages/index.tsx +80 -0
  9. package/package.json +101 -0
  10. package/packages/analytics/README.md +309 -0
  11. package/packages/analytics/build.ts +35 -0
  12. package/packages/analytics/package.json +50 -0
  13. package/packages/analytics/src/click-tracker.ts +368 -0
  14. package/packages/analytics/src/context-bridge.ts +319 -0
  15. package/packages/analytics/src/data-layer.ts +302 -0
  16. package/packages/analytics/src/gtm-loader.ts +239 -0
  17. package/packages/analytics/src/index.ts +230 -0
  18. package/packages/analytics/src/merkle-tree.ts +489 -0
  19. package/packages/analytics/src/provider.tsx +300 -0
  20. package/packages/analytics/src/types.ts +320 -0
  21. package/packages/analytics/src/use-analytics.ts +296 -0
  22. package/packages/analytics/tsconfig.json +19 -0
  23. package/packages/benchmarks/src/benchmark.test.ts +691 -0
  24. package/packages/cli/dist/index.js +61899 -0
  25. package/packages/cli/package.json +43 -0
  26. package/packages/cli/src/commands/build.test.ts +682 -0
  27. package/packages/cli/src/commands/build.ts +890 -0
  28. package/packages/cli/src/commands/dev.ts +473 -0
  29. package/packages/cli/src/commands/init.ts +409 -0
  30. package/packages/cli/src/commands/start.ts +297 -0
  31. package/packages/cli/src/index.ts +105 -0
  32. package/packages/directives/src/use-aeon.ts +272 -0
  33. package/packages/mcp-server/package.json +51 -0
  34. package/packages/mcp-server/src/index.ts +178 -0
  35. package/packages/mcp-server/src/resources.ts +346 -0
  36. package/packages/mcp-server/src/tools/index.ts +36 -0
  37. package/packages/mcp-server/src/tools/navigation.ts +545 -0
  38. package/packages/mcp-server/tsconfig.json +21 -0
  39. package/packages/react/package.json +40 -0
  40. package/packages/react/src/Link.tsx +388 -0
  41. package/packages/react/src/components/InstallPrompt.tsx +286 -0
  42. package/packages/react/src/components/OfflineDiagnostics.tsx +677 -0
  43. package/packages/react/src/components/PushNotifications.tsx +453 -0
  44. package/packages/react/src/hooks/useAeonNavigation.ts +219 -0
  45. package/packages/react/src/hooks/useConflicts.ts +277 -0
  46. package/packages/react/src/hooks/useNetworkState.ts +209 -0
  47. package/packages/react/src/hooks/usePilotNavigation.ts +254 -0
  48. package/packages/react/src/hooks/useServiceWorker.ts +278 -0
  49. package/packages/react/src/hooks.ts +195 -0
  50. package/packages/react/src/index.ts +151 -0
  51. package/packages/react/src/provider.tsx +467 -0
  52. package/packages/react/tsconfig.json +19 -0
  53. package/packages/runtime/README.md +399 -0
  54. package/packages/runtime/build.ts +48 -0
  55. package/packages/runtime/package.json +71 -0
  56. package/packages/runtime/schema.sql +40 -0
  57. package/packages/runtime/src/api-routes.ts +465 -0
  58. package/packages/runtime/src/benchmark.ts +171 -0
  59. package/packages/runtime/src/cache.ts +479 -0
  60. package/packages/runtime/src/durable-object.ts +1341 -0
  61. package/packages/runtime/src/index.ts +360 -0
  62. package/packages/runtime/src/navigation.test.ts +421 -0
  63. package/packages/runtime/src/navigation.ts +422 -0
  64. package/packages/runtime/src/nextjs-adapter.ts +272 -0
  65. package/packages/runtime/src/offline/encrypted-queue.test.ts +607 -0
  66. package/packages/runtime/src/offline/encrypted-queue.ts +478 -0
  67. package/packages/runtime/src/offline/encryption.test.ts +412 -0
  68. package/packages/runtime/src/offline/encryption.ts +397 -0
  69. package/packages/runtime/src/offline/types.ts +465 -0
  70. package/packages/runtime/src/predictor.ts +371 -0
  71. package/packages/runtime/src/registry.ts +351 -0
  72. package/packages/runtime/src/router/context-extractor.ts +661 -0
  73. package/packages/runtime/src/router/esi-control-react.tsx +2053 -0
  74. package/packages/runtime/src/router/esi-control.ts +541 -0
  75. package/packages/runtime/src/router/esi-cyrano.ts +779 -0
  76. package/packages/runtime/src/router/esi-format-react.tsx +1744 -0
  77. package/packages/runtime/src/router/esi-react.tsx +1065 -0
  78. package/packages/runtime/src/router/esi-translate-observer.ts +476 -0
  79. package/packages/runtime/src/router/esi-translate-react.tsx +556 -0
  80. package/packages/runtime/src/router/esi-translate.ts +503 -0
  81. package/packages/runtime/src/router/esi.ts +666 -0
  82. package/packages/runtime/src/router/heuristic-adapter.test.ts +295 -0
  83. package/packages/runtime/src/router/heuristic-adapter.ts +557 -0
  84. package/packages/runtime/src/router/index.ts +298 -0
  85. package/packages/runtime/src/router/merkle-capability.ts +473 -0
  86. package/packages/runtime/src/router/speculation.ts +451 -0
  87. package/packages/runtime/src/router/types.ts +630 -0
  88. package/packages/runtime/src/router.test.ts +470 -0
  89. package/packages/runtime/src/router.ts +302 -0
  90. package/packages/runtime/src/server.ts +481 -0
  91. package/packages/runtime/src/service-worker-push.ts +319 -0
  92. package/packages/runtime/src/service-worker.ts +553 -0
  93. package/packages/runtime/src/skeleton-hydrate.ts +237 -0
  94. package/packages/runtime/src/speculation.test.ts +389 -0
  95. package/packages/runtime/src/speculation.ts +486 -0
  96. package/packages/runtime/src/storage.test.ts +1297 -0
  97. package/packages/runtime/src/storage.ts +1048 -0
  98. package/packages/runtime/src/sync/conflict-resolver.test.ts +528 -0
  99. package/packages/runtime/src/sync/conflict-resolver.ts +565 -0
  100. package/packages/runtime/src/sync/coordinator.test.ts +608 -0
  101. package/packages/runtime/src/sync/coordinator.ts +596 -0
  102. package/packages/runtime/src/tree-compiler.ts +295 -0
  103. package/packages/runtime/src/types.ts +728 -0
  104. package/packages/runtime/src/worker.ts +327 -0
  105. package/packages/runtime/tsconfig.json +20 -0
  106. package/packages/runtime/wasm/aeon_pages_runtime.d.ts +504 -0
  107. package/packages/runtime/wasm/aeon_pages_runtime.js +1657 -0
  108. package/packages/runtime/wasm/aeon_pages_runtime_bg.wasm +0 -0
  109. package/packages/runtime/wasm/aeon_pages_runtime_bg.wasm.d.ts +196 -0
  110. package/packages/runtime/wasm/package.json +21 -0
  111. package/packages/runtime/wrangler.toml +41 -0
  112. package/packages/runtime-wasm/Cargo.lock +436 -0
  113. package/packages/runtime-wasm/Cargo.toml +29 -0
  114. package/packages/runtime-wasm/pkg/aeon_pages_runtime.d.ts +480 -0
  115. package/packages/runtime-wasm/pkg/aeon_pages_runtime.js +1568 -0
  116. package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm +0 -0
  117. package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm.d.ts +192 -0
  118. package/packages/runtime-wasm/pkg/package.json +21 -0
  119. package/packages/runtime-wasm/src/hydrate.rs +352 -0
  120. package/packages/runtime-wasm/src/lib.rs +191 -0
  121. package/packages/runtime-wasm/src/render.rs +629 -0
  122. package/packages/runtime-wasm/src/router.rs +298 -0
  123. package/packages/runtime-wasm/src/skeleton.rs +430 -0
  124. package/rfcs/RFC-001-ZERO-DEPENDENCY-RENDERING.md +1446 -0
@@ -0,0 +1,388 @@
1
+ /**
2
+ * Aeon Link Component
3
+ *
4
+ * Drop-in replacement for <a> with superpowers:
5
+ * - Visibility-based prefetch
6
+ * - Hover prefetch
7
+ * - Intent detection (cursor trajectory)
8
+ * - View transitions
9
+ * - Presence awareness
10
+ * - Total preload support
11
+ */
12
+
13
+ import React, {
14
+ forwardRef,
15
+ useEffect,
16
+ useRef,
17
+ useCallback,
18
+ useState,
19
+ type ReactNode,
20
+ type MouseEvent,
21
+ type AnchorHTMLAttributes,
22
+ } from 'react';
23
+ import { useAeonNavigation, useRoutePresence } from './hooks/useAeonNavigation';
24
+
25
+ export type TransitionType = 'slide' | 'fade' | 'morph' | 'none';
26
+ export type PrefetchStrategy = 'hover' | 'visible' | 'intent' | 'none';
27
+
28
+ export interface PresenceRenderProps {
29
+ count: number;
30
+ editing: number;
31
+ hot: boolean;
32
+ users?: { userId: string; name?: string }[];
33
+ }
34
+
35
+ export interface LinkProps
36
+ extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'children'> {
37
+ href: string;
38
+ prefetch?: PrefetchStrategy;
39
+ transition?: TransitionType;
40
+ showPresence?: boolean;
41
+ preloadData?: boolean;
42
+ replace?: boolean;
43
+ children?:
44
+ | ReactNode
45
+ | ((props: { presence: PresenceRenderProps | null }) => ReactNode);
46
+ onNavigateStart?: () => void;
47
+ onNavigateEnd?: () => void;
48
+ }
49
+
50
+ export const Link = forwardRef<HTMLAnchorElement, LinkProps>(
51
+ (
52
+ {
53
+ href,
54
+ prefetch = 'visible',
55
+ transition = 'fade',
56
+ showPresence = false,
57
+ preloadData = true,
58
+ replace = false,
59
+ children,
60
+ onNavigateStart,
61
+ onNavigateEnd,
62
+ onClick,
63
+ onMouseEnter,
64
+ onMouseMove,
65
+ className,
66
+ ...props
67
+ },
68
+ ref,
69
+ ) => {
70
+ const internalRef = useRef<HTMLAnchorElement>(null);
71
+ const linkRef = (ref as React.RefObject<HTMLAnchorElement>) ?? internalRef;
72
+ const trajectoryRef = useRef<{ x: number; y: number; time: number }[]>([]);
73
+ const intentTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
74
+
75
+ const {
76
+ navigate,
77
+ prefetch: doPrefetch,
78
+ isPreloaded,
79
+ isNavigating,
80
+ } = useAeonNavigation();
81
+ const { getPresence, subscribePresence } = useRoutePresence();
82
+
83
+ const [presence, setPresence] = useState<PresenceRenderProps | null>(null);
84
+ const [isPrefetched, setIsPrefetched] = useState(false);
85
+
86
+ // Check initial prefetch state
87
+ useEffect(() => {
88
+ setIsPrefetched(isPreloaded(href));
89
+ }, [href, isPreloaded]);
90
+
91
+ // Visibility-based prefetch
92
+ useEffect(() => {
93
+ if (
94
+ prefetch !== 'visible' ||
95
+ typeof IntersectionObserver === 'undefined'
96
+ ) {
97
+ return;
98
+ }
99
+
100
+ const observer = new IntersectionObserver(
101
+ ([entry]) => {
102
+ if (entry.isIntersecting) {
103
+ doPrefetch(href, { data: preloadData, presence: showPresence });
104
+ setIsPrefetched(true);
105
+ }
106
+ },
107
+ { rootMargin: '100px' },
108
+ );
109
+
110
+ const element = linkRef.current;
111
+ if (element) observer.observe(element);
112
+
113
+ return () => observer.disconnect();
114
+ }, [href, prefetch, preloadData, showPresence, doPrefetch, linkRef]);
115
+
116
+ // Presence subscription
117
+ useEffect(() => {
118
+ if (!showPresence) return;
119
+
120
+ // Get initial presence
121
+ const initialPresence = getPresence(href);
122
+ if (initialPresence) {
123
+ const { count, editing, hot, users } = initialPresence;
124
+ setPresence({ count, editing, hot, users });
125
+ }
126
+
127
+ // Subscribe to updates
128
+ const unsubscribe = subscribePresence((route, info) => {
129
+ if (route === href) {
130
+ const { count, editing, hot, users } = info;
131
+ setPresence({ count, editing, hot, users });
132
+ }
133
+ });
134
+
135
+ return unsubscribe;
136
+ }, [href, showPresence, getPresence, subscribePresence]);
137
+
138
+ // Hover prefetch handler
139
+ const handleMouseEnter = useCallback(
140
+ (e: MouseEvent<HTMLAnchorElement>) => {
141
+ onMouseEnter?.(e);
142
+
143
+ if (prefetch === 'hover' || prefetch === 'intent') {
144
+ doPrefetch(href, { data: preloadData, presence: showPresence });
145
+ setIsPrefetched(true);
146
+ }
147
+ },
148
+ [href, prefetch, preloadData, showPresence, doPrefetch, onMouseEnter],
149
+ );
150
+
151
+ // Intent detection (cursor trajectory prediction)
152
+ const handleMouseMove = useCallback(
153
+ (e: MouseEvent<HTMLAnchorElement>) => {
154
+ onMouseMove?.(e);
155
+
156
+ if (prefetch !== 'intent') return;
157
+
158
+ // Track cursor trajectory
159
+ const now = Date.now();
160
+ trajectoryRef.current.push({ x: e.clientX, y: e.clientY, time: now });
161
+
162
+ // Keep only last 5 points
163
+ if (trajectoryRef.current.length > 5) {
164
+ trajectoryRef.current.shift();
165
+ }
166
+
167
+ // Clear previous timeout
168
+ if (intentTimeoutRef.current) {
169
+ clearTimeout(intentTimeoutRef.current);
170
+ }
171
+
172
+ // Predict intent after short delay
173
+ intentTimeoutRef.current = setTimeout(() => {
174
+ const points = trajectoryRef.current;
175
+ if (points.length < 2) return;
176
+
177
+ const element = linkRef.current;
178
+ if (!element) return;
179
+
180
+ // Calculate if cursor is approaching the link
181
+ const rect = element.getBoundingClientRect();
182
+ const centerX = rect.left + rect.width / 2;
183
+ const centerY = rect.top + rect.height / 2;
184
+
185
+ const lastPoint = points[points.length - 1];
186
+ const prevPoint = points[points.length - 2];
187
+
188
+ const velocityX = lastPoint.x - prevPoint.x;
189
+ const velocityY = lastPoint.y - prevPoint.y;
190
+
191
+ // Project cursor position
192
+ const projectedX = lastPoint.x + velocityX * 10;
193
+ const projectedY = lastPoint.y + velocityY * 10;
194
+
195
+ // Check if projected position is closer to link
196
+ const currentDist = Math.hypot(
197
+ lastPoint.x - centerX,
198
+ lastPoint.y - centerY,
199
+ );
200
+ const projectedDist = Math.hypot(
201
+ projectedX - centerX,
202
+ projectedY - centerY,
203
+ );
204
+
205
+ if (projectedDist < currentDist) {
206
+ // Cursor is approaching - prefetch with high priority
207
+ doPrefetch(href, {
208
+ data: preloadData,
209
+ presence: showPresence,
210
+ priority: 'high',
211
+ });
212
+ setIsPrefetched(true);
213
+ }
214
+ }, 50);
215
+ },
216
+ [
217
+ href,
218
+ prefetch,
219
+ preloadData,
220
+ showPresence,
221
+ doPrefetch,
222
+ onMouseMove,
223
+ linkRef,
224
+ ],
225
+ );
226
+
227
+ // Click navigation with view transition
228
+ const handleClick = useCallback(
229
+ async (e: MouseEvent<HTMLAnchorElement>) => {
230
+ // Call original onClick if provided
231
+ onClick?.(e);
232
+
233
+ // Don't handle if default prevented or modified
234
+ if (
235
+ e.defaultPrevented ||
236
+ e.metaKey ||
237
+ e.ctrlKey ||
238
+ e.shiftKey ||
239
+ e.altKey ||
240
+ e.button !== 0
241
+ ) {
242
+ return;
243
+ }
244
+
245
+ e.preventDefault();
246
+
247
+ onNavigateStart?.();
248
+
249
+ try {
250
+ await navigate(href, { transition, replace });
251
+ } finally {
252
+ onNavigateEnd?.();
253
+ }
254
+ },
255
+ [
256
+ href,
257
+ transition,
258
+ replace,
259
+ navigate,
260
+ onClick,
261
+ onNavigateStart,
262
+ onNavigateEnd,
263
+ ],
264
+ );
265
+
266
+ // Cleanup
267
+ useEffect(() => {
268
+ return () => {
269
+ if (intentTimeoutRef.current) {
270
+ clearTimeout(intentTimeoutRef.current);
271
+ }
272
+ };
273
+ }, []);
274
+
275
+ // Render children
276
+ const renderChildren = () => {
277
+ if (typeof children === 'function') {
278
+ return children({ presence });
279
+ }
280
+ return (
281
+ <>
282
+ {children}
283
+ {showPresence && presence && presence.count > 0 && (
284
+ <span
285
+ className="aeon-presence-badge"
286
+ aria-label={`${presence.count} active`}
287
+ >
288
+ {presence.hot ? '\uD83D\uDD25' : '\uD83D\uDC65'} {presence.count}
289
+ {presence.editing > 0 && ` (${presence.editing} editing)`}
290
+ </span>
291
+ )}
292
+ </>
293
+ );
294
+ };
295
+
296
+ return (
297
+ <a
298
+ ref={linkRef}
299
+ href={href}
300
+ onClick={handleClick}
301
+ onMouseEnter={handleMouseEnter}
302
+ onMouseMove={handleMouseMove}
303
+ className={className}
304
+ data-preloaded={isPrefetched ? '' : undefined}
305
+ data-navigating={isNavigating ? '' : undefined}
306
+ data-transition={transition}
307
+ aria-busy={isNavigating}
308
+ {...props}
309
+ >
310
+ {renderChildren()}
311
+ </a>
312
+ );
313
+ },
314
+ );
315
+
316
+ Link.displayName = 'Link';
317
+
318
+ // CSS for presence badge (can be overridden)
319
+ if (typeof document !== 'undefined') {
320
+ const style = document.createElement('style');
321
+ style.textContent = `
322
+ .aeon-presence-badge {
323
+ display: inline-flex;
324
+ align-items: center;
325
+ gap: 0.25rem;
326
+ font-size: 0.75rem;
327
+ padding: 0.125rem 0.375rem;
328
+ margin-left: 0.5rem;
329
+ background: rgba(0, 0, 0, 0.05);
330
+ border-radius: 9999px;
331
+ }
332
+
333
+ [data-preloaded]::after {
334
+ content: '';
335
+ display: inline-block;
336
+ width: 4px;
337
+ height: 4px;
338
+ margin-left: 0.25rem;
339
+ background: #10b981;
340
+ border-radius: 50%;
341
+ opacity: 0.5;
342
+ }
343
+
344
+ /* View transition styles */
345
+ ::view-transition-old(aeon-page) {
346
+ animation: aeon-fade-out 200ms ease-out;
347
+ }
348
+
349
+ ::view-transition-new(aeon-page) {
350
+ animation: aeon-fade-in 300ms ease-out;
351
+ }
352
+
353
+ @keyframes aeon-fade-out {
354
+ from { opacity: 1; }
355
+ to { opacity: 0; }
356
+ }
357
+
358
+ @keyframes aeon-fade-in {
359
+ from { opacity: 0; transform: translateY(10px); }
360
+ to { opacity: 1; transform: translateY(0); }
361
+ }
362
+
363
+ /* Slide transition */
364
+ [data-transition="slide"]::view-transition-old(aeon-page) {
365
+ animation: aeon-slide-out 200ms ease-out;
366
+ }
367
+
368
+ [data-transition="slide"]::view-transition-new(aeon-page) {
369
+ animation: aeon-slide-in 300ms ease-out;
370
+ }
371
+
372
+ @keyframes aeon-slide-out {
373
+ from { transform: translateX(0); opacity: 1; }
374
+ to { transform: translateX(-20px); opacity: 0; }
375
+ }
376
+
377
+ @keyframes aeon-slide-in {
378
+ from { transform: translateX(20px); opacity: 0; }
379
+ to { transform: translateX(0); opacity: 1; }
380
+ }
381
+ `;
382
+
383
+ // Only inject once
384
+ if (!document.getElementById('aeon-link-styles')) {
385
+ style.id = 'aeon-link-styles';
386
+ document.head.appendChild(style);
387
+ }
388
+ }
@@ -0,0 +1,286 @@
1
+ /**
2
+ * InstallPrompt Component
3
+ *
4
+ * PWA install prompt component for web applications.
5
+ * Handles beforeinstallprompt event and iOS-specific instructions.
6
+ *
7
+ * Features:
8
+ * - Cross-browser install prompt handling
9
+ * - iOS-specific installation instructions
10
+ * - Standalone mode detection
11
+ * - Customizable UI via render props
12
+ * - Headless hook export
13
+ */
14
+
15
+ 'use client';
16
+
17
+ import { useState, useEffect, useCallback, type ReactNode } from 'react';
18
+
19
+ // ============================================================================
20
+ // Types
21
+ // ============================================================================
22
+
23
+ interface BeforeInstallPromptEvent extends Event {
24
+ prompt: () => Promise<void>;
25
+ userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
26
+ }
27
+
28
+ export interface InstallPromptState {
29
+ /** Whether the app can be installed */
30
+ canInstall: boolean;
31
+ /** Whether the app is already installed (standalone mode) */
32
+ isInstalled: boolean;
33
+ /** Whether on iOS */
34
+ isIOS: boolean;
35
+ /** Trigger the install prompt (Chrome/Edge) */
36
+ install: () => Promise<'accepted' | 'dismissed' | 'unavailable'>;
37
+ /** Dismiss the install prompt */
38
+ dismiss: () => void;
39
+ }
40
+
41
+ export interface InstallPromptProps {
42
+ /** Render when app is already installed */
43
+ renderInstalled?: () => ReactNode;
44
+ /** Render install prompt (receives install function) */
45
+ renderPrompt?: (state: InstallPromptState) => ReactNode;
46
+ /** Render iOS-specific instructions */
47
+ renderIOSInstructions?: () => ReactNode;
48
+ /** Only show when install is available */
49
+ showOnlyWhenInstallable?: boolean;
50
+ /** CSS class for container */
51
+ className?: string;
52
+ }
53
+
54
+ // ============================================================================
55
+ // useInstallPrompt Hook
56
+ // ============================================================================
57
+
58
+ /**
59
+ * Hook for managing PWA install prompt
60
+ */
61
+ export function useInstallPrompt(): InstallPromptState {
62
+ const [isIOS, setIsIOS] = useState(false);
63
+ const [isInstalled, setIsInstalled] = useState(false);
64
+ const [deferredPrompt, setDeferredPrompt] =
65
+ useState<BeforeInstallPromptEvent | null>(null);
66
+ const [canInstall, setCanInstall] = useState(false);
67
+
68
+ useEffect(() => {
69
+ if (typeof window === 'undefined' || typeof navigator === 'undefined') {
70
+ return;
71
+ }
72
+
73
+ // Detect iOS
74
+ const iOS =
75
+ /iPad|iPhone|iPod/.test(navigator.userAgent) &&
76
+ !(window as unknown as { MSStream?: unknown }).MSStream;
77
+ setIsIOS(iOS);
78
+
79
+ // Detect standalone mode (already installed)
80
+ const standalone =
81
+ window.matchMedia('(display-mode: standalone)').matches ||
82
+ (window.navigator as unknown as { standalone?: boolean }).standalone ===
83
+ true;
84
+ setIsInstalled(standalone);
85
+
86
+ // For iOS, show instructions even if can't trigger prompt
87
+ if (iOS && !standalone) {
88
+ setCanInstall(true);
89
+ }
90
+
91
+ // Listen for beforeinstallprompt event
92
+ const handleBeforeInstallPrompt = (e: Event) => {
93
+ e.preventDefault();
94
+ setDeferredPrompt(e as BeforeInstallPromptEvent);
95
+ setCanInstall(true);
96
+ };
97
+
98
+ window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
99
+
100
+ // Listen for app installed event
101
+ const handleAppInstalled = () => {
102
+ setIsInstalled(true);
103
+ setCanInstall(false);
104
+ setDeferredPrompt(null);
105
+ };
106
+
107
+ window.addEventListener('appinstalled', handleAppInstalled);
108
+
109
+ return () => {
110
+ window.removeEventListener(
111
+ 'beforeinstallprompt',
112
+ handleBeforeInstallPrompt,
113
+ );
114
+ window.removeEventListener('appinstalled', handleAppInstalled);
115
+ };
116
+ }, []);
117
+
118
+ const install = useCallback(async (): Promise<
119
+ 'accepted' | 'dismissed' | 'unavailable'
120
+ > => {
121
+ if (!deferredPrompt) {
122
+ return 'unavailable';
123
+ }
124
+
125
+ await deferredPrompt.prompt();
126
+ const { outcome } = await deferredPrompt.userChoice;
127
+
128
+ if (outcome === 'accepted') {
129
+ setDeferredPrompt(null);
130
+ setCanInstall(false);
131
+ }
132
+
133
+ return outcome;
134
+ }, [deferredPrompt]);
135
+
136
+ const dismiss = useCallback(() => {
137
+ setCanInstall(false);
138
+ setDeferredPrompt(null);
139
+ }, []);
140
+
141
+ return {
142
+ canInstall,
143
+ isInstalled,
144
+ isIOS,
145
+ install,
146
+ dismiss,
147
+ };
148
+ }
149
+
150
+ // ============================================================================
151
+ // InstallPrompt Component
152
+ // ============================================================================
153
+
154
+ /**
155
+ * PWA install prompt component
156
+ */
157
+ export function InstallPrompt({
158
+ renderInstalled,
159
+ renderPrompt,
160
+ renderIOSInstructions,
161
+ showOnlyWhenInstallable = true,
162
+ className,
163
+ }: InstallPromptProps): ReactNode {
164
+ const state = useInstallPrompt();
165
+
166
+ // Don't render if already installed
167
+ if (state.isInstalled) {
168
+ return renderInstalled?.() || null;
169
+ }
170
+
171
+ // Don't render if not installable and only showing when installable
172
+ if (showOnlyWhenInstallable && !state.canInstall) {
173
+ return null;
174
+ }
175
+
176
+ // Custom render for iOS
177
+ if (state.isIOS) {
178
+ if (renderIOSInstructions) {
179
+ return renderIOSInstructions();
180
+ }
181
+
182
+ return (
183
+ <div
184
+ className={className}
185
+ role="region"
186
+ aria-label="Install app instructions"
187
+ >
188
+ <h3
189
+ style={{
190
+ fontSize: '1.125rem',
191
+ fontWeight: 600,
192
+ marginBottom: '0.5rem',
193
+ }}
194
+ >
195
+ Install App
196
+ </h3>
197
+ <p style={{ fontSize: '0.875rem', marginBottom: '0.5rem' }}>
198
+ To install this app on your iOS device:
199
+ </p>
200
+ <ol
201
+ style={{
202
+ fontSize: '0.875rem',
203
+ paddingLeft: '1.5rem',
204
+ listStyleType: 'decimal',
205
+ }}
206
+ >
207
+ <li>Tap the share button in Safari</li>
208
+ <li>Scroll down and tap &quot;Add to Home Screen&quot;</li>
209
+ <li>Tap &quot;Add&quot; to confirm</li>
210
+ </ol>
211
+ </div>
212
+ );
213
+ }
214
+
215
+ // Custom render for install prompt
216
+ if (renderPrompt) {
217
+ return renderPrompt(state);
218
+ }
219
+
220
+ // Default install prompt
221
+ if (!state.canInstall) {
222
+ return null;
223
+ }
224
+
225
+ return (
226
+ <div className={className} role="region" aria-label="Install app prompt">
227
+ <h3
228
+ style={{
229
+ fontSize: '1.125rem',
230
+ fontWeight: 600,
231
+ marginBottom: '0.5rem',
232
+ }}
233
+ >
234
+ Install App
235
+ </h3>
236
+ <p style={{ fontSize: '0.875rem', marginBottom: '1rem' }}>
237
+ Install this app on your device for a better experience.
238
+ </p>
239
+ <div style={{ display: 'flex', gap: '0.5rem' }}>
240
+ <button
241
+ onClick={() => state.install()}
242
+ style={{
243
+ padding: '0.5rem 1rem',
244
+ backgroundColor: '#0d9488',
245
+ color: 'white',
246
+ border: 'none',
247
+ borderRadius: '0.375rem',
248
+ cursor: 'pointer',
249
+ fontSize: '0.875rem',
250
+ }}
251
+ aria-label="Install application"
252
+ >
253
+ Add to Home Screen
254
+ </button>
255
+ <button
256
+ onClick={state.dismiss}
257
+ style={{
258
+ padding: '0.5rem 1rem',
259
+ backgroundColor: 'transparent',
260
+ color: '#6b7280',
261
+ border: '1px solid #d1d5db',
262
+ borderRadius: '0.375rem',
263
+ cursor: 'pointer',
264
+ fontSize: '0.875rem',
265
+ }}
266
+ aria-label="Dismiss install prompt"
267
+ >
268
+ Not now
269
+ </button>
270
+ </div>
271
+ </div>
272
+ );
273
+ }
274
+
275
+ // ============================================================================
276
+ // Type declarations for global events
277
+ // ============================================================================
278
+
279
+ declare global {
280
+ interface WindowEventMap {
281
+ beforeinstallprompt: BeforeInstallPromptEvent;
282
+ appinstalled: Event;
283
+ }
284
+ }
285
+
286
+ export default InstallPrompt;