Added onboarding + a lightweight install banner to both the mobile login screen and the settings screen, with Android/Chromium
install prompt support and iOS “Share → Add to Home Screen” guidance. Also added a small helper + tests to decide when/which banner variant should show, and shared copy in common.json.
This commit is contained in:
24
resources/js/admin/mobile/lib/installBanner.test.ts
Normal file
24
resources/js/admin/mobile/lib/installBanner.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveInstallBannerState } from './installBanner';
|
||||
|
||||
describe('resolveInstallBannerState', () => {
|
||||
it('returns null when already installed', () => {
|
||||
expect(resolveInstallBannerState({ isInstalled: true, isStandalone: false, canInstall: true, isIos: true })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when running in standalone mode', () => {
|
||||
expect(resolveInstallBannerState({ isInstalled: false, isStandalone: true, canInstall: true, isIos: true })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns prompt when install prompt is available', () => {
|
||||
expect(resolveInstallBannerState({ isInstalled: false, isStandalone: false, canInstall: true, isIos: false })).toEqual({ variant: 'prompt' });
|
||||
});
|
||||
|
||||
it('returns ios when on iOS without prompt', () => {
|
||||
expect(resolveInstallBannerState({ isInstalled: false, isStandalone: false, canInstall: false, isIos: true })).toEqual({ variant: 'ios' });
|
||||
});
|
||||
|
||||
it('returns null when no install option exists', () => {
|
||||
expect(resolveInstallBannerState({ isInstalled: false, isStandalone: false, canInstall: false, isIos: false })).toBeNull();
|
||||
});
|
||||
});
|
||||
28
resources/js/admin/mobile/lib/installBanner.ts
Normal file
28
resources/js/admin/mobile/lib/installBanner.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export type InstallBannerVariant = 'prompt' | 'ios';
|
||||
|
||||
export type InstallBannerState = {
|
||||
variant: InstallBannerVariant;
|
||||
};
|
||||
|
||||
export type InstallBannerInput = {
|
||||
isInstalled: boolean;
|
||||
isStandalone: boolean;
|
||||
canInstall: boolean;
|
||||
isIos: boolean;
|
||||
};
|
||||
|
||||
export function resolveInstallBannerState(input: InstallBannerInput): InstallBannerState | null {
|
||||
if (input.isInstalled || input.isStandalone) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (input.canInstall) {
|
||||
return { variant: 'prompt' };
|
||||
}
|
||||
|
||||
if (input.isIos) {
|
||||
return { variant: 'ios' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
27
resources/js/admin/mobile/lib/installPrompt.test.ts
Normal file
27
resources/js/admin/mobile/lib/installPrompt.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isIosDevice, resolveStandaloneDisplayMode } from './installPrompt';
|
||||
|
||||
describe('isIosDevice', () => {
|
||||
it('detects iOS user agents', () => {
|
||||
expect(isIosDevice('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1')).toBe(true);
|
||||
expect(isIosDevice('Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-iOS user agents', () => {
|
||||
expect(isIosDevice('Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveStandaloneDisplayMode', () => {
|
||||
it('returns true when matchMedia says standalone', () => {
|
||||
expect(resolveStandaloneDisplayMode(true, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when navigator.standalone is true', () => {
|
||||
expect(resolveStandaloneDisplayMode(false, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when both are false', () => {
|
||||
expect(resolveStandaloneDisplayMode(false, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
25
resources/js/admin/mobile/lib/installPrompt.ts
Normal file
25
resources/js/admin/mobile/lib/installPrompt.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export type InstallOutcome = 'accepted' | 'dismissed' | 'unknown';
|
||||
|
||||
export type BeforeInstallPromptEvent = Event & {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: InstallOutcome; platform: string }>;
|
||||
};
|
||||
|
||||
export function isIosDevice(userAgent: string): boolean {
|
||||
return /iphone|ipad|ipod/i.test(userAgent);
|
||||
}
|
||||
|
||||
export function resolveStandaloneDisplayMode(matchMediaStandalone: boolean, navigatorStandalone?: boolean): boolean {
|
||||
return matchMediaStandalone || navigatorStandalone === true;
|
||||
}
|
||||
|
||||
export function getStandaloneStatus(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const matchMediaStandalone = window.matchMedia?.('(display-mode: standalone)')?.matches ?? false;
|
||||
const navigatorStandalone = typeof navigator !== 'undefined' ? (navigator as Navigator & { standalone?: boolean }).standalone : undefined;
|
||||
|
||||
return resolveStandaloneDisplayMode(matchMediaStandalone, navigatorStandalone);
|
||||
}
|
||||
12
resources/js/admin/mobile/lib/mobileTour.test.ts
Normal file
12
resources/js/admin/mobile/lib/mobileTour.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveTourStepKeys } from './mobileTour';
|
||||
|
||||
describe('resolveTourStepKeys', () => {
|
||||
it('includes the event step when there are no events', () => {
|
||||
expect(resolveTourStepKeys(false)).toEqual(['event', 'qr', 'photos', 'push']);
|
||||
});
|
||||
|
||||
it('omits the event step when events exist', () => {
|
||||
expect(resolveTourStepKeys(true)).toEqual(['qr', 'photos', 'push']);
|
||||
});
|
||||
});
|
||||
9
resources/js/admin/mobile/lib/mobileTour.ts
Normal file
9
resources/js/admin/mobile/lib/mobileTour.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type TourStepKey = 'event' | 'qr' | 'photos' | 'push';
|
||||
|
||||
export function resolveTourStepKeys(hasEvents: boolean): TourStepKey[] {
|
||||
if (hasEvents) {
|
||||
return ['qr', 'photos', 'push'];
|
||||
}
|
||||
|
||||
return ['event', 'qr', 'photos', 'push'];
|
||||
}
|
||||
Reference in New Issue
Block a user