diff --git a/divemap-android/twa-manifest.json b/divemap-android/twa-manifest.json index 249cde1e..c26b6feb 100644 --- a/divemap-android/twa-manifest.json +++ b/divemap-android/twa-manifest.json @@ -12,7 +12,7 @@ "navigationDividerColorDark": "#000000", "backgroundColor": "#FFFFFF", "enableNotifications": true, - "startUrl": "/", + "startUrl": "/?utm_source=android-twa", "iconUrl": "https://divemap.gr/favicons/android-chrome-512x512.png", "maskableIconUrl": "https://divemap.gr/favicons/android-chrome-512x512.png", "splashScreenFadeOutDuration": 300, @@ -20,8 +20,8 @@ "path": "/home/kargig/src/divemap/divemap-android/android.keystore", "alias": "android" }, - "appVersionName": "1.5", - "appVersionCode": 9, + "appVersionName": "1.9", + "appVersionCode": 25, "shortcuts": [ { "name": "Explore Map", @@ -82,5 +82,5 @@ "standalone", "minimal-ui" ], - "appVersion": "1.5" + "appVersion": "1.9" } \ No newline at end of file diff --git a/docs/superpowers/plans/2026-07-13-anonymous-user-promos.md b/docs/superpowers/plans/2026-07-13-anonymous-user-promos.md new file mode 100644 index 00000000..7b7d9c3b --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-anonymous-user-promos.md @@ -0,0 +1,570 @@ +# Anonymous User Promos & PWA/TWA Targeting System Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement a non-intrusive, platform-aware banner and in-feed promotional card system that nudges anonymous visitors to register or install the PWA after visiting at least 3 pages, following a progressive dismissal fallback strategy. + +**Architecture:** +A client-side layout manager (`PromoBannerManager.jsx`) listens to React Router location changes, tracks current-session views via `sessionStorage`, lifetime views via `localStorage`, and intercepts browser installation requests (`beforeinstallprompt`). Segment-tailored components render sticky bottom banners (Desktop/Android) or floating share tooltips (iOS). High-traffic feed layouts dynamically inject in-feed promo cards (`InFeedPromoCard.jsx`) inside lists for anonymous users. + +**Tech Stack:** React (TypeScript/JSX), Tailwind CSS, Lucide React (Icons), Vitest (Testing), React Router DOM (v7). + +## Global Constraints +- **Suppression:** Suppress all promos/cards if `user` from `useAuth()` is non-null or if `isStandalone` is true. +- **Mobile Width Gutters:** Banners/cards must stretch full-width on mobile viewports (`px-0` wrapper layout, `rounded-none sm:rounded-2xl` borders) to maximize lateral reading space without causing horizontal overflows. +- **Git Commit Rules:** NEVER execute `git add` or `git commit`. All commit steps require writing the draft message to `commit-message.txt` for manual execution by the user. + +--- + +### Task 1: Setup Storage Tracking & Utility State Helpers + +**Files:** +- Create: `frontend/src/utils/promoStorage.js` +- Test: `frontend/src/utils/promoStorage.test.js` + +**Interfaces:** +- Produces: + - `incrementSessionPageViews()`: returns `number` (current session count) + - `incrementCumulativePageViews()`: returns `number` (current lifetime count) + - `getPromoEligibility()`: returns `{ isEligible: boolean, activePlatform: string }` + - `dismissPromo()`: returns `void` (calculates progressive page offset gaps) + +- [ ] **Step 1: Write the failing tests** + Create `frontend/src/utils/promoStorage.test.js`: + ```javascript + import { describe, it, expect, beforeEach, vi } from 'vitest'; + import { + incrementSessionPageViews, + incrementCumulativePageViews, + getPromoEligibility, + dismissPromo + } from './promoStorage'; + + describe('promoStorage', () => { + beforeEach(() => { + window.sessionStorage.clear(); + window.localStorage.clear(); + }); + + it('should increment session views', () => { + expect(incrementSessionPageViews()).toBe(1); + expect(incrementSessionPageViews()).toBe(2); + }); + + it('should not be eligible initially', () => { + const eligibility = getPromoEligibility(); + expect(eligibility.isEligible).toBe(false); + }); + }); + ``` + +- [ ] **Step 2: Run tests and verify they fail** + Run: `npm run test -- src/utils/promoStorage.test.js` + Expected: FAIL with "functions not defined" or similar import errors. + +- [ ] **Step 3: Write the minimal implementation** + Create `frontend/src/utils/promoStorage.js`: + ```javascript + const SESSION_KEY = 'divemap_session_page_views'; + const CUMULATIVE_KEY = 'divemap_lifetime_page_views'; + const DISMISSAL_COUNT_KEY = 'divemap_promo_dismissals'; + const NEXT_ELIGIBLE_VIEW_KEY = 'divemap_next_eligible_view'; + + export const incrementSessionPageViews = () => { + const current = parseInt(sessionStorage.getItem(SESSION_KEY) || '0', 10); + const updated = current + 1; + sessionStorage.setItem(SESSION_KEY, updated.toString()); + return updated; + }; + + export const incrementCumulativePageViews = () => { + const current = parseInt(localStorage.getItem(CUMULATIVE_KEY) || '0', 10); + const updated = current + 1; + localStorage.setItem(CUMULATIVE_KEY, updated.toString()); + return updated; + }; + + export const dismissPromo = () => { + const dismissals = parseInt(localStorage.getItem(DISMISSAL_COUNT_KEY) || '0', 10) + 1; + localStorage.setItem(DISMISSAL_COUNT_KEY, dismissals.toString()); + + const cumulative = parseInt(localStorage.getItem(CUMULATIVE_KEY) || '0', 10); + let offset = 0; + + if (dismissals === 1) offset = 10; + else if (dismissals === 2) offset = 20; + else if (dismissals === 3) offset = 30; + else offset = 9999999; // Permanent suppression + + localStorage.setItem(NEXT_ELIGIBLE_VIEW_KEY, (cumulative + offset).toString()); + }; + + export const getPromoEligibility = () => { + const sessionCount = parseInt(sessionStorage.getItem(SESSION_KEY) || '0', 10); + const cumulativeCount = parseInt(localStorage.getItem(CUMULATIVE_KEY) || '0', 10); + const dismissals = parseInt(localStorage.getItem(DISMISSAL_COUNT_KEY) || '0', 10); + const nextEligible = parseInt(localStorage.getItem(NEXT_ELIGIBLE_VIEW_KEY) || '0', 10); + + const isStandalone = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true; + if (isStandalone || dismissals >= 4) { + return { isEligible: false, platform: 'standalone' }; + } + + const isAndroid = /Android/i.test(navigator.userAgent); + const isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent); + const platform = isAndroid ? 'android' : isIOS ? 'ios' : 'desktop'; + + const isEligible = sessionCount >= 3 && cumulativeCount >= nextEligible; + + return { isEligible, platform }; + }; + ``` + +- [ ] **Step 4: Run tests to verify they pass** + Run: `npm run test -- src/utils/promoStorage.test.js` + Expected: PASS + +- [ ] **Step 5: Prepare Commit** + Write the following to `/home/kargig/src/divemap/commit-message.txt`: + ``` + feat: Add state tracking utility for anonymous user promos + + Implement client-side session and cumulative view tracking with progressive + dismissal offsets (10, 20, 30, and permanent suppression on 4th dismissal). + ``` + +--- + +### Task 2: Create `` Component & Layouts + +**Files:** +- Create: `frontend/src/components/PromoBannerManager.jsx` +- Test: `frontend/src/components/PromoBannerManager.test.jsx` + +**Interfaces:** +- Consumes: + - `useAuth()` from `contexts/AuthContext` + - `incrementSessionPageViews`, `incrementCumulativePageViews`, `getPromoEligibility`, `dismissPromo` from `utils/promoStorage` +- Produces: + - `` (silent wrapper mounting visual banners or iOS tooltip) + +- [ ] **Step 1: Write mock tests** + Create `frontend/src/components/PromoBannerManager.test.jsx` focusing on verifying state integration and suppressing renders for logged-in users. + ```javascript + import { render, screen } from '@testing-library/react'; + import { describe, it, expect, vi } from 'vitest'; + import PromoBannerManager from './PromoBannerManager'; + import { AuthProvider } from '../contexts/AuthContext'; + import { BrowserRouter as Router } from 'react-router-dom'; + + vi.mock('../contexts/AuthContext', () => ({ + useAuth: () => ({ user: { id: 1 }, loading: false }), + })); + + describe('PromoBannerManager', () => { + it('suppresses render if logged in', () => { + const { container } = render( + + + + ); + expect(container.firstChild).toBeNull(); + }); + }); + ``` + +- [ ] **Step 2: Run tests to ensure failure** + Run: `npm run test -- src/components/PromoBannerManager.test.jsx` + Expected: FAIL with component missing. + +- [ ] **Step 3: Write Component Implementation** + Create `frontend/src/components/PromoBannerManager.jsx`. Implement standard styling matching the Okabe-Ito brand colors (`bg-divemap-surface`, `text-divemap-trench`, `border-divemap-blue`). Include step-by-step guides inside modals for iOS and custom event handling for `beforeinstallprompt`. + ```jsx + import React, { useState, useEffect } from 'react'; + import { useLocation, useNavigate } from 'react-router-dom'; + import { X, Share, Plus, HelpCircle } from 'lucide-react'; + import { useAuth } from '../contexts/AuthContext'; + import { + incrementSessionPageViews, + incrementCumulativePageViews, + getPromoEligibility, + dismissPromo + } from '../utils/promoStorage'; + + const PromoBannerManager = () => { + const { user, loading } = useAuth(); + const location = useLocation(); + const navigate = useNavigate(); + const [promoState, setPromoState] = useState({ isEligible: false, platform: 'desktop' }); + const [deferredPrompt, setDeferredPrompt] = useState(null); + const [showIOSModal, setShowIOSModal] = useState(false); + + // Capture install prompt for Android + useEffect(() => { + const handleInstallPrompt = (e) => { + e.preventDefault(); + setDeferredPrompt(e); + }; + window.addEventListener('beforeinstallprompt', handleInstallPrompt); + return () => window.removeEventListener('beforeinstallprompt', handleInstallPrompt); + }, []); + + // Monitor navigation to increment and update eligibility + useEffect(() => { + if (user || loading) return; + + incrementSessionPageViews(); + incrementCumulativePageViews(); + + const eligibility = getPromoEligibility(); + setPromoState(eligibility); + }, [location.pathname, user, loading]); + + if (user || loading || !promoState.isEligible) return null; + + const handleDismiss = (e) => { + e.stopPropagation(); + dismissPromo(); + setPromoState({ isEligible: false, platform: 'desktop' }); + }; + + const handleAndroidInstall = async () => { + if (!deferredPrompt) return; + deferredPrompt.prompt(); + const { outcome } = await deferredPrompt.userChoice; + if (outcome === 'accepted') { + dismissPromo(); + setPromoState({ isEligible: false, platform: 'android' }); + } + setDeferredPrompt(null); + }; + + // Rendering segmented layouts + if (promoState.platform === 'android') { + return ( +
+
+

Install Divemap App

+

Get the native experience with offline support and fast load times!

+
+
+ + +
+
+ ); + } + + if (promoState.platform === 'ios') { + return ( + <> +
+
+ Add Divemap to iPhone: Tap Safari's Share button and select 'Add to Home Screen' . +
+
+ + +
+
+ + {showIOSModal && ( +
+
+ +

How to install on iOS

+
    +
  1. + 1 +
    Tap the Share button at the bottom of Safari.
    +
  2. +
  3. + 2 +
    Scroll down and select "Add to Home Screen" .
    +
  4. +
+ +
+
+ )} + + ); + } + + return ( +
+
+

Join the Divemap Community!

+

Register a free account to log your own dives, save favorite sites, and find buddies.

+
+
+ + +
+
+ ); + }; + + export default PromoBannerManager; + ``` + +- [ ] **Step 4: Verify test suite passes** + Change the `useAuth` mock inside `PromoBannerManager.test.jsx` to return `user: null`, verifying the element renders correctly when conditions match. + Run: `npm run test -- src/components/PromoBannerManager.test.jsx` + Expected: PASS + +- [ ] **Step 5: Prepare Commit** + Write to `/home/kargig/src/divemap/commit-message.txt`: + ``` + feat: Implement responsive global PromoBannerManager component + + Build platform-aware bottom sticky promo bars for Desktop and Android (utilizing PWA install prompt hooks) alongside floating tooltip guidance overlays for iOS devices. + ``` + +--- + +### Task 3: Mount `PromoBannerManager` into Router Layout + +**Files:** +- Modify: `frontend/src/App.jsx:500-519` + +**Interfaces:** +- Consumes: `` + +- [ ] **Step 1: Check imports** + Import `PromoBannerManager` directly at the top of `frontend/src/App.jsx`. + +- [ ] **Step 2: Mount the component** + Insert the `` component right inside the `
` wrapper, alongside ``. + + ```jsx + // Add import: + import PromoBannerManager from './components/PromoBannerManager'; + + // Mount in AppContent(): + return ( +
+ + + + +
+ + {/* Injected Manager */} + }> + ``` + +- [ ] **Step 3: Verify the application builds and bundles successfully** + Run: `npm run build` + Expected: Build finishes successfully without diagnostic or Rollup errors. + +- [ ] **Step 4: Prepare Commit** + Write to `/home/kargig/src/divemap/commit-message.txt`: + ``` + feat: Mount PromoBannerManager into core application layout + + Integrate the promotion and PWA installation manager directly into the global App layout to track page navigation triggers and render banners globally. + ``` + +--- + +### Task 4: Add PWA & TWA Manifest Launches Disambiguation + +**Files:** +- Modify: `frontend/vite.config.mjs` +- Modify: `divemap-android/twa-manifest.json` + +- [ ] **Step 1: Update Vite PWA configurations** + Open `frontend/vite.config.mjs`. Locate the `VitePWA` plugin definition. Replace `start_url: '/'` with `start_url: '/?utm_source=pwa'`. + +- [ ] **Step 2: Update Android TWA configurations** + Open `divemap-android/twa-manifest.json`. Replace `"startUrl": "/"` with `"startUrl": "/?utm_source=android-twa"`. + +- [ ] **Step 3: Prepare Commit** + Write to `/home/kargig/src/divemap/commit-message.txt`: + ``` + config: Configure discrete launcher UTM tracking parameter in manifests + + Set start_url to include utm_source=pwa for the Web PWA and utm_source=android-twa for the Android TWA, supporting precise client-side platform tracking and banner suppression. + ``` + +--- + +### Task 5: Create Native `` Component + +**Files:** +- Create: `frontend/src/components/ui/InFeedPromoCard.jsx` +- Test: `frontend/src/components/ui/InFeedPromoCard.test.jsx` + +**Interfaces:** +- Consumes: OS/platform detections. +- Produces: `` + +- [ ] **Step 1: Implement Card Component** + Create `frontend/src/components/ui/InFeedPromoCard.jsx` styling it with full-width responsive mobile metrics (`rounded-none sm:rounded-2xl border-y sm:border` and `p-4 sm:p-6` card padding) to blend flawlessly with nearby site detail cards. + ```jsx + import React, { useState } from 'react'; + import { useNavigate } from 'react-router-dom'; + import { Shield, Smartphone, Plus, Share, X } from 'lucide-react'; + + const InFeedPromoCard = ({ platform }) => { + const navigate = useNavigate(); + const [showIOSModal, setShowIOSModal] = useState(false); + + if (platform === 'standalone') return null; + + const renderCardContent = () => { + if (platform === 'android') { + return ( +
+
+ +
+

Install Divemap App

+

Install our app on your device for fast access, offline logs, and push updates!

+ +
+ ); + } + + if (platform === 'ios') { + return ( +
+
+ +
+

Add to Home Screen

+

Add Divemap directly to your iPhone for the ultimate mobile-first experience.

+ + + {showIOSModal && ( +
+
+ +

Install on iPhone

+
    +
  1. + 1 +
    Tap Safari's Share button .
    +
  2. +
  3. + 2 +
    Scroll down and select "Add to Home Screen" .
    +
  4. +
+
+
+ )} +
+ ); + } + + // Default Desktop + return ( +
+
+ +
+

Track Your Adventures

+

Log your scuba dives, map your certs, and connect with other buddies worldwide!

+ +
+ ); + }; + + return ( +
+ {renderCardContent()} +
+ ); + }; + + export default InFeedPromoCard; + ``` + +- [ ] **Step 2: Write tests for InFeedPromoCard** + Create `frontend/src/components/ui/InFeedPromoCard.test.jsx`. Ensure proper routing contexts. + Run: `npm run test -- src/components/ui/InFeedPromoCard.test.jsx` + Expected: PASS + +- [ ] **Step 3: Prepare Commit** + Write to `/home/kargig/src/divemap/commit-message.txt`: + ``` + feat: Create native InFeedPromoCard promotional layout + + Implement customized inline card promos following responsive edge-to-edge layout constraints for seamless feed embedding. + ``` + +--- + +### Task 6: Embed `InFeedPromoCard` into Dive Sites List Feed + +**Files:** +- Modify: `frontend/src/pages/DiveSites.jsx` + +**Interfaces:** +- Consumes: `` + +- [ ] **Step 1: Check existing page structure** + Open `frontend/src/pages/DiveSites.jsx`. Locate the list render loops containing `sites.map((site) => ...)` and ``. + +- [ ] **Step 2: Add dynamic injection** + Import `InFeedPromoCard` and inspect user state. If user is null, pageViewCount triggers are eligible, and `index === 2` (after the third card), insert the `` dynamically. + + ```jsx + // Add import: + import InFeedPromoCard from '../components/ui/InFeedPromoCard'; + import { getPromoEligibility } from '../utils/promoStorage'; + + // Inside list render loops, inject: + const eligibility = getPromoEligibility(); + const shouldShowFeedPromo = !user && eligibility.isEligible; + + // Render fragment mapping: + {sites.map((site, index) => ( + + + {shouldShowFeedPromo && index === 2 && ( + + )} + + ))} + ``` + +- [ ] **Step 3: Run comprehensive frontend linter & compilation** + Run: `make lint-frontend` + Expected: Success without any linter violations or typescript diagnostics errors. + +- [ ] **Step 4: Prepare Commit** + Write to `/home/kargig/src/divemap/commit-message.txt`: + ``` + feat: Inject InFeedPromoCard into DiveSites explorer feed + + Insert in-feed cards dynamically after the third card for eligible anonymous users to promote account registration or application installation. + ``` diff --git a/docs/superpowers/specs/2026-07-13-anonymous-user-promos-design.md b/docs/superpowers/specs/2026-07-13-anonymous-user-promos-design.md new file mode 100644 index 00000000..9d3e3c95 --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-anonymous-user-promos-design.md @@ -0,0 +1,168 @@ +# Specification: Anonymous User Promos & PWA/TWA Targeting System + +## Status: Approved +## Date: 2026-07-13 + +--- + +## πŸ—ΊοΈ Overview & Goals +The objective of this feature is to improve user adoption and registration rates on the Divemap platform by strategically prompting anonymous visitors. Rather than displaying intrusive, blocking overlays immediately upon landing, the system employs a gentle, conversion-oriented targeting framework based on user behavior and platform characteristics. + +### Key Goals: +1. **Engagement-first (The Three-Page Rule):** Only show promos after a visitor has visited at least 3 pages in their current session, ensuring they are engaged and interested. +2. **Platform-Tailored CTAs:** + - **Android Mobile Browser:** Prioritize PWA app installation by intercepting and presenting the native installation prompt. + - **iOS Safari Browser:** Show a clear, floating bubble tooltip pointing to the Safari share menu guiding them on how to "Add to Home Screen". + - **Desktop/Tablet:** Display a slim, elegant banner nudging them to create a free account to track dives, certifications, and find buddies. +3. **Suppression for Logged-In Users:** Completely suppress all promos and in-feed cards if a user is logged in. +4. **Suppression inside Standalone Mode:** Suppress installation banners if the application is already running in `standalone` (installed PWA/TWA) mode. +5. **Native In-Feed Promo Cards:** Conditionally inject stylized card prompts directly inside high-traffic feed layouts (e.g., Dive Sites Explorer list), blending seamlessly with standard content. +6. **Progressive Fallback Dismissal (User Control):** Respect the user's decision with a progressive display gap. When dismissed, the banner is hidden and only displayed again at specific cumulative page views: + - Initial View: Renders on page view 3. + - Dismissal 1: Re-appears at cumulative page view 7. + - Dismissal 2: Re-appears at cumulative page view 12. + - Dismissal 3: Re-appears at cumulative page view 18. + - Dismissal 4: Re-appears at cumulative page view 25. + - Dismissal 5: Stopped displaying completely (permanent suppression). + - *Note:* Session page views (resetting per tab) must be `>= 3` for any reappearances to prevent immediate spamming on new session loads. + +--- + +## πŸ“± Platform Detection & Targeting Matrix + +All platform and eligibility checks are performed client-side. We utilize a combination of user-agent parsing, media queries, document referrers, and URL parameters to segment visitors: + +### 1. Platform Detection Rules + +```javascript +// Check if the application is running in installed / standalone mode +const isStandalone = + window.matchMedia('(display-mode: standalone)').matches || + window.navigator.standalone === true; + +// Basic OS checks +const isAndroid = /Android/i.test(navigator.userAgent); +const isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent); +const isMobile = isAndroid || isIOS || /Mobi|Android/i.test(navigator.userAgent); +``` + +### 2. Manifest Tracking Adjustments (First-Load Referrer) +To accurately track and separate Web PWA launches from Play Store Android TWA launches, we configure the start URLs in the respective manifests to contain query parameters: +- **Web PWA Manifest (`frontend/vite.config.mjs`):** `start_url: "/?utm_source=pwa"` +- **Android TWA Manifest (`divemap-android/twa-manifest.json`):** `"startUrl": "/?utm_source=android-twa"` + +When the application mounts, if the query contains `utm_source`, we store the platform string in `sessionStorage` and sanitise the browser address bar immediately: +```javascript +const urlParams = new URLSearchParams(window.location.search); +const utmSource = urlParams.get('utm_source'); +if (utmSource === 'android-twa' || utmSource === 'pwa') { + sessionStorage.setItem('divemap_platform', utmSource); + window.history.replaceState({}, document.title, window.location.pathname); +} +``` + +### 3. Segment Matrix + +| Segment | `isStandalone` | `isAndroid` | `isIOS` | Eligibility Trigger | visual Presentation | CTA Action | +|:---|:---|:---|:---|:---|:---|:---| +| **Desktop / General** | `false` | `false` | `false` | `pageViewCount >= 3` | Bottom Slide-in Slim Banner | Redirect to `/register` | +| **Android Mobile Browser** | `false` | `true` | `false` | `pageViewCount >= 3` | Bottom Slide-in Banner with Install CTA | Triggers PWA install prompt | +| **iOS Safari Browser** | `false` | `false` | `true` | `pageViewCount >= 3` | Anchored Bubble Tooltip pointing to share menu | Shows overlay instruction modal on click | +| **Installed Web PWA** | `true` | `any` | `any` | *Suppressed* | None | None | +| **Installed Android TWA**| `true` | `true` | `false` | *Suppressed* | None | None | +| **Logged-In User** | `any` | `any` | `any` | *Suppressed* | None | None | + +--- + +## 🎨 Component Architecture + +We will implement two modular, self-contained components within the frontend: + +``` +frontend/src/ +β”œβ”€β”€ components/ +β”‚ β”œβ”€β”€ PromoBannerManager.jsx # Manages tracking state, event listeners, and renders bottom banners/tooltips. +β”‚ └── ui/ +β”‚ └── InFeedPromoCard.jsx # Custom native-styled inline promotional card injected in list views. +``` + +### 1. `PromoBannerManager.jsx` +This component will wrap the global banner rendering. It executes silently at the bottom of the layout inside `AppContent()` in `App.jsx`. + +- **State Managed:** + - `pageViewCount` (read/write from `sessionStorage` to track current session views, trigger requires `>= 3`). + - `cumulativePageViewCount` (read/write from `localStorage` to track lifetime pages browsed across sessions). + - `dismissalCount` (read/write from `localStorage` to track total times the user dismissed the banner, max 5). + - `nextEligibleCumulativePageView` (read/write from `localStorage` storing the minimum cumulative page view count required to display again). + - `deferredPrompt` (stores the browser's native `beforeinstallprompt` event). + - `activePlatform` (detected OS/browser platform). +- **Behavior:** + - Listens to React Router's `useLocation()` to increment `pageViewCount` on route changes. + - Listens to `window.addEventListener('beforeinstallprompt', (e) => { ... })` to capture the native installation hook. + - Listens to `window.addEventListener('appinstalled', () => { ... })` to dynamically clean up state and banners upon successful install. + +### 2. `InFeedPromoCard.jsx` +A stylized, responsive card component that matches the padding, border, shadow, and aspect-ratio parameters of a normal `DiveSiteCard` (`DiveSiteCard.jsx`). +- **Insertion Strategy:** Rendered conditionally inside high-traffic feed files: + - `DiveSites.jsx` (Dive Sites Explorer list and grid feeds) + - `Dives.jsx` (Public Dive Logs list feed) + - `DivingCenters.jsx` (Diving Centers list feed) + - `DiveRoutes.jsx` (Dive Routes list feed) + - *Example in list rendering:* + ```jsx + {items.map((item, index) => ( + + + {shouldShowFeedPromo && (index - 2) % 15 === 0 && ( + + )} + + ))} + ``` + +--- + +## πŸ“ Layout & Visual Design + +To guarantee cohesive, pixel-perfect layouts, we strictly adhere to the project's styling and mobile-first guidelines: + +### A. Bottom-Sticky Slim Banner (Desktop & Android Browser) +- **Container Styling:** Sticky footer wrapper, high-contrast borderless circle for the close button, and optimized padding. + - Desktop: `max-w-[450px] fixed bottom-6 right-6 z-[999]` (A floating side-card format). + - Mobile: `fixed bottom-0 left-0 right-0 z-[999] border-t border-gray-100 rounded-none` (Maximized lateral space). +- **Visuals:** Background uses `bg-gradient-to-r from-divemap-surface to-white` with a brand-standard deep blue accent `border-l-4 border-divemap-blue` to feel premium. +- **Typography:** DM Sans, text sizes clamped properly (`text-sm` for mobile, `text-base` for desktop). + +### B. iOS Safari Pulsing Tooltip +- **Positioning:** Fixed at the bottom-center of the screen, floating exactly `20px` above the bottom edge. + - `fixed bottom-5 left-1/2 -translate-x-1/2 max-w-[90vw] w-[350px] z-[999]` +- **Design:** Styled as a speech bubble using a CSS triangular arrow (`after:content-[''] after:absolute after:top-full after:left-1/2 after:-translate-x-1/2 after:border-8 after:border-transparent after:border-t-divemap-blue`). +- **Interaction:** Includes a subtle vertical bouncing/pulsing animation (`animate-bounce-gentle`). + +### C. iOS Step-by-Step Guidance Modal +When an iOS user clicks `[ How to Install ]` on either a banner or an in-feed card, a beautiful modal overlay appears displaying: +1. **Step 1:** Tap Safari's share button `πŸ“€` (usually located at the bottom-center of Safari). +2. **Step 2:** Scroll down and select **"Add to Home Screen"** `βž•`. + +--- + +## πŸ§ͺ Testing & Validation Plan + +We will verify both behavioral routing and platform mock scenarios: + +1. **State & Dismissal Logic Tests:** + - Assert `pageViewCount` is correctly stored in `sessionStorage` and increments on route transition. + - Assert progressive dismissal offsets calculate correctly on dismissal mapping to cumulative targets (7, 12, 18, 25). + - Assert `dismissalCount` increments on dismissal, and that once it reaches 5, the banner is permanently suppressed. + - Assert that if `cumulativePageViewCount < nextEligibleCumulativePageView`, the banner remains suppressed even if the session page count is `>= 3`. + - Assert that logging in immediately unmounts/suppresses all banners and promotional cards. +2. **Platform Verification:** + - Override/Mock `navigator.userAgent` to verify layout differences across: + - Android Mobile + - iOS Safari + - Desktop Chrome/Safari +3. **PWA Install Flow Verification:** + - Dispatch a mock `beforeinstallprompt` event programmatically to ensure the `[ Install App ]` button is activated and successfully calls `.prompt()` upon click. +4. **Visual Quality Check:** + - Run our standard `make lint-frontend` linter rules. + - Load the modified routes in Chrome DevTools to inspect responsive scaling and confirm that **zero horizontal scrolling** occurs on narrow mobile screens (320px - 412px viewport width). diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ea547a53..3ebcefe6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -9,6 +9,7 @@ import CapacitorBackButtonHandler from './components/CapacitorBackButtonHandler' import ChatWidget from './components/Chat/ChatWidget'; import EmailVerificationBanner from './components/EmailVerificationBanner'; import Navbar from './components/Navbar'; +import PromoBannerManager from './components/PromoBannerManager'; import PWAUpdater from './components/PWAUpdater'; import ReportIssueButton from './components/ReportIssueButton'; import ScrollToTop from './components/ScrollToTop'; @@ -205,6 +206,7 @@ function AppContent() { className={`${isAdminPath ? 'w-full max-w-none px-0' : 'container mx-auto px-4 sm:px-6 lg:px-8'} py-4 sm:py-8 pt-16`} > + }> } /> diff --git a/frontend/src/components/Chat/ChatWidget.jsx b/frontend/src/components/Chat/ChatWidget.jsx index 2f769ff7..d3ee24bb 100644 --- a/frontend/src/components/Chat/ChatWidget.jsx +++ b/frontend/src/components/Chat/ChatWidget.jsx @@ -22,11 +22,13 @@ const ChatWidget = () => { const [position, setPosition] = useState(() => { const saved = localStorage.getItem('divemap_chat_widget_pos'); - const defaultPos = { x: 20, y: window.innerHeight - 80, side: 'right' }; + const defaultBottomOffset = window.innerWidth < 640 ? 180 : 80; + const defaultPos = { x: 20, y: window.innerHeight - defaultBottomOffset, side: 'right' }; if (!saved) return defaultPos; try { const parsed = JSON.parse(saved); - if (parsed.y > window.innerHeight) parsed.y = window.innerHeight - 80; + if (parsed.y > window.innerHeight - defaultBottomOffset) + parsed.y = window.innerHeight - defaultBottomOffset; if (parsed.y < 0) parsed.y = 80; return parsed; } catch (e) { @@ -34,6 +36,12 @@ const ChatWidget = () => { } }); + // Always elevate bottom offset to 180px on mobile to prevent overlapping and layout jumping + const bottomOffset = window.innerWidth < 640 ? 180 : 80; + const displayY = useMemo(() => { + return Math.min(position.y, window.innerHeight - bottomOffset); + }, [position.y, bottomOffset]); + // Handle inactivity timeout to trigger "edge peek" useEffect(() => { if (isOpen || isDragging) { @@ -177,9 +185,13 @@ const ChatWidget = () => { const margin = 20; const isRightSide = upEvent.clientX > screenWidth / 2; + const finalBottomOffset = window.innerWidth < 640 ? 180 : 80; const finalPos = { x: margin, - y: Math.max(margin, Math.min(upEvent.clientY - 28, window.innerHeight - 80)), + y: Math.max( + margin, + Math.min(upEvent.clientY - 28, window.innerHeight - finalBottomOffset) + ), side: isRightSide ? 'right' : 'left', }; @@ -219,7 +231,7 @@ const ChatWidget = () => { ${isExpanded ? 'md:w-[800px] md:h-[80dvh]' : 'md:w-[400px] md:h-[600px]'} `} style={{ - bottom: '100px', // Offset from the FAB + bottom: `${window.innerHeight - displayY + 20}px`, // Always floats perfectly 20px above the FAB }} > {isOpen && ( @@ -246,7 +258,7 @@ const ChatWidget = () => { style={{ left: position.side === 'right' ? 'auto' : `${position.x}px`, right: position.side === 'right' ? `${position.x}px` : 'auto', - top: `${position.y}px`, + top: `${displayY}px`, touchAction: 'none', }} > diff --git a/frontend/src/components/PromoBannerManager.jsx b/frontend/src/components/PromoBannerManager.jsx new file mode 100644 index 00000000..4dcddeb8 --- /dev/null +++ b/frontend/src/components/PromoBannerManager.jsx @@ -0,0 +1,191 @@ +import { X, Share, Plus, HelpCircle } from 'lucide-react'; +import React, { useState, useEffect } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; + +import { useAuth } from '../contexts/AuthContext'; +import { + incrementSessionPageViews, + incrementCumulativePageViews, + getPromoEligibility, + dismissPromo, +} from '../utils/promoStorage'; + +const PromoBannerManager = () => { + const { user, loading } = useAuth(); + const location = useLocation(); + const navigate = useNavigate(); + const [promoState, setPromoState] = useState({ isEligible: false, platform: 'desktop' }); + const [deferredPrompt, setDeferredPrompt] = useState(null); + const [showIOSModal, setShowIOSModal] = useState(false); + + // Capture install prompt for Android + useEffect(() => { + const handleInstallPrompt = e => { + e.preventDefault(); + setDeferredPrompt(e); + }; + window.addEventListener('beforeinstallprompt', handleInstallPrompt); + return () => window.removeEventListener('beforeinstallprompt', handleInstallPrompt); + }, []); + + // Monitor navigation to increment and update eligibility + useEffect(() => { + if (user || loading) return; + + incrementSessionPageViews(); + incrementCumulativePageViews(); + + const eligibility = getPromoEligibility(); + setPromoState(eligibility); + }, [location.pathname, user, loading]); + + if (user || loading || !promoState.isEligible) return null; + + const handleDismiss = e => { + e.stopPropagation(); + dismissPromo(); + setPromoState({ isEligible: false, platform: 'desktop' }); + }; + + const handleAndroidInstall = () => { + dismissPromo(); + setPromoState({ isEligible: false, platform: 'android' }); + window.open( + 'https://play.google.com/store/apps/details?id=gr.divemap.twa', + '_blank', + 'noopener,noreferrer' + ); + }; + + // Rendering segmented layouts + if (promoState.platform === 'android') { + return ( +
+
+

Install Divemap App

+

+ Get the native experience with offline support and fast load times! +

+
+
+ + +
+
+ ); + } + + if (promoState.platform === 'ios') { + return ( + <> +
+
+ Add Divemap to iPhone: Tap Safari's Share button{' '} + and select{' '} + 'Add to Home Screen' . +
+
+ + +
+
+ + {showIOSModal && ( +
+
+ +

+ How to install on iOS +

+
    +
  1. + + 1 + +
    + Tap the Share button{' '} + at the bottom of Safari. +
    +
  2. +
  3. + + 2 + +
    + Scroll down and select "Add to Home Screen"{' '} + . +
    +
  4. +
+ +
+
+ )} + + ); + } + + return ( +
+
+

+ Join the Divemap Community! +

+

+ Register a free account to log your own dives, save favorite sites, and find buddies. +

+
+
+ + +
+
+ ); +}; + +export default PromoBannerManager; diff --git a/frontend/src/components/PromoBannerManager.test.jsx b/frontend/src/components/PromoBannerManager.test.jsx new file mode 100644 index 00000000..bf79d64d --- /dev/null +++ b/frontend/src/components/PromoBannerManager.test.jsx @@ -0,0 +1,28 @@ +import { render, screen } from '@testing-library/react'; +import { BrowserRouter as Router } from 'react-router-dom'; +import { describe, it, expect, vi } from 'vitest'; + +import PromoBannerManager from './PromoBannerManager'; + +vi.mock('../contexts/AuthContext', () => ({ + useAuth: () => ({ user: { id: 1 }, loading: false }), +})); + +// Also mock promoStorage to prevent real storage access / missing import errors +vi.mock('../utils/promoStorage', () => ({ + incrementSessionPageViews: vi.fn(), + incrementCumulativePageViews: vi.fn(), + getPromoEligibility: () => ({ isEligible: false, platform: 'desktop' }), + dismissPromo: vi.fn(), +})); + +describe('PromoBannerManager', () => { + it('suppresses render if logged in', () => { + const { container } = render( + + + + ); + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/frontend/src/components/ui/InFeedPromoCard.jsx b/frontend/src/components/ui/InFeedPromoCard.jsx new file mode 100644 index 00000000..be069fab --- /dev/null +++ b/frontend/src/components/ui/InFeedPromoCard.jsx @@ -0,0 +1,239 @@ +import { + Shield, + Smartphone, + Plus, + Share, + X, + MapPin, + Anchor, + BookOpen, + Compass, + Award, + CloudSun, + Eye, + Key, +} from 'lucide-react'; +import React, { useState, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; + +const InFeedPromoCard = ({ platform, index }) => { + const navigate = useNavigate(); + const [showIOSModal, setShowIOSModal] = useState(false); + + // Pool of 10 engaging, relevant feature-centric messages about Divemap + const promoPool = useMemo( + () => [ + { + title: 'Log Your Dive Computer Profiles', + message: + 'Visualize your dive data! Upload Subsurface XML, Garmin FIT, or Suunto files to generate interactive depth & temperature charts.', + icon: , + }, + { + title: 'Explore the Interactive Map', + message: + 'Discover thousands of dive sites worldwide. Filter by labels, difficulty, and community ratings.', + icon: , + }, + { + title: 'Secure Buddy Messaging System', + message: + 'Connect with local divers! Coordinate your next underwater dive trip securely using our encrypted end-to-end chat.', + icon: , + }, + { + title: 'Wind & Wave Suitability', + message: + 'Dive safely! Check real-time wind impact and marine conditions suggestions before packing your regulator.', + icon: , + }, + { + title: 'Verified Diving Centers', + message: + 'Find premier dive centers near you. Check their rental gear prices, and view direct contact profiles.', + icon: , + }, + { + title: 'Diving Organization Certifications', + message: + 'Keep your credentials handy! Link your certifications (PADI, SSI, GUE, CMAS, etc.) directly on your public profile.', + icon: , + }, + { + title: 'Interactive GPS Route Tracking', + message: + 'Trace your exact path! Draw, save, and visualize 2D dive routes and coordinates directly on our maps. Never miss a point of interest again!', + icon: , + }, + { + title: 'Join the Diver Leaderboard', + message: + 'Connect with the community, earn badges, share photos, and see where your monthly dive counts rank on the leaderboard.', + icon: , + }, + { + title: 'Personal Access Tokens (API)', + message: + 'Power your own scripts! Generate secure personal tokens to integrate your dive logs with external platforms programmatically.', + icon: , + }, + { + title: 'Scuba Physics Calculators', + message: + 'Plan your breathing gases! Use our built-in tools to calculate MOD, Best Mix, SAC Rate, Gas Planning, and Minimum Gas limits.', + icon: , + }, + ], + [] + ); + + // Deterministically pick one message per card based on its index in the list. + // This guarantees that scrolled cards show a diverse and progressive sequence of feature highlights, + // falling back to standard random selection if index is omitted. + const selectedPromo = useMemo(() => { + const selectedIndex = + typeof index === 'number' + ? index % promoPool.length + : Math.floor(Math.random() * promoPool.length); + return promoPool[selectedIndex]; + }, [promoPool, index]); + + if (platform === 'standalone') return null; + + const renderCardContent = () => { + if (platform === 'android') { + return ( +
+
+
+ +
+
+

+ Install Divemap App +

+

+ {selectedPromo.message} Install the app for fast access and offline support! +

+
+
+
+ +
+
+ ); + } + + if (platform === 'ios') { + return ( +
+
+
+ +
+
+

+ Add to Home Screen +

+

+ {selectedPromo.message} Install on your iPhone to access your offline dive logs + anytime. +

+
+
+
+ + + {showIOSModal && ( +
+
+ +

+ Install on iPhone +

+
    +
  1. + + 1 + +
    + Tap Safari's Share button{' '} + . +
    +
  2. +
  3. + + 2 + +
    + Scroll down and select "Add to Home Screen"{' '} + . +
    +
  4. +
+
+
+ )} +
+
+ ); + } + + // Default Desktop + return ( +
+
+
+ {selectedPromo.icon} +
+
+

+ {selectedPromo.title} +

+

+ {selectedPromo.message} +

+
+
+
+ +
+
+ ); + }; + + return ( +
+ {renderCardContent()} +
+ ); +}; + +export default InFeedPromoCard; diff --git a/frontend/src/components/ui/InFeedPromoCard.test.jsx b/frontend/src/components/ui/InFeedPromoCard.test.jsx new file mode 100644 index 00000000..e7557365 --- /dev/null +++ b/frontend/src/components/ui/InFeedPromoCard.test.jsx @@ -0,0 +1,110 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { BrowserRouter as Router } from 'react-router-dom'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import InFeedPromoCard from './InFeedPromoCard'; + +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +describe('InFeedPromoCard', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + it('renders default desktop layout when platform is desktop or not specified', () => { + // Mock Math.random to return 0.0, selecting the first item (Log Your Dive Computer Profiles) + vi.spyOn(Math, 'random').mockReturnValue(0.0); + + render( + + + + ); + + expect(screen.getByText('Log Your Dive Computer Profiles')).toBeInTheDocument(); + expect(screen.getByText(/Visualize your dive data! Upload Subsurface XML/)).toBeInTheDocument(); + + const signUpButton = screen.getByRole('button', { name: 'Sign Up Free' }); + expect(signUpButton).toBeInTheDocument(); + + fireEvent.click(signUpButton); + expect(mockNavigate).toHaveBeenCalledWith('/register'); + }); + + it('renders android layout when platform is android', () => { + vi.spyOn(Math, 'random').mockReturnValue(0.0); + const mockOpen = vi.spyOn(window, 'open').mockImplementation(() => {}); + + render( + + + + ); + + expect(screen.getByText('Install Divemap App')).toBeInTheDocument(); + expect(screen.getByText(/Visualize your dive data!/)).toBeInTheDocument(); + expect( + screen.getByText(/Install the app for fast access and offline support!/) + ).toBeInTheDocument(); + + const installButton = screen.getByRole('button', { name: 'Install App' }); + expect(installButton).toBeInTheDocument(); + + fireEvent.click(installButton); + expect(mockOpen).toHaveBeenCalledWith( + 'https://play.google.com/store/apps/details?id=gr.divemap.twa', + '_blank', + 'noopener,noreferrer' + ); + }); + + it('renders ios layout and toggles instruction modal when platform is ios', () => { + vi.spyOn(Math, 'random').mockReturnValue(0.0); + + render( + + + + ); + + expect(screen.getByText('Add to Home Screen')).toBeInTheDocument(); + expect(screen.getByText(/Visualize your dive data!/)).toBeInTheDocument(); + expect( + screen.getByText(/Install on your iPhone to access your offline dive logs anytime./) + ).toBeInTheDocument(); + + const showButton = screen.getByRole('button', { name: 'How to Install' }); + expect(showButton).toBeInTheDocument(); + + expect(screen.queryByText('Install on iPhone')).not.toBeInTheDocument(); + + fireEvent.click(showButton); + expect(screen.getByText('Install on iPhone')).toBeInTheDocument(); + expect(screen.getByText(/Tap Safari's/)).toBeInTheDocument(); + expect(screen.getByText(/Scroll down and select/)).toBeInTheDocument(); + + const svg = document.querySelector('svg.lucide-x'); + const closeBtn = svg.closest('button'); + fireEvent.click(closeBtn); + + expect(screen.queryByText('Install on iPhone')).not.toBeInTheDocument(); + }); + + it('returns null when platform is standalone', () => { + const { container } = render( + + + + ); + + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/frontend/src/pages/DiveRoutes.jsx b/frontend/src/pages/DiveRoutes.jsx index c371920a..cf14e13a 100644 --- a/frontend/src/pages/DiveRoutes.jsx +++ b/frontend/src/pages/DiveRoutes.jsx @@ -13,7 +13,7 @@ import { Grid, List, } from 'lucide-react'; -import { useState, useEffect, useCallback, useMemo } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { useInfiniteQuery } from 'react-query'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; @@ -24,7 +24,9 @@ import LoadingSkeleton from '../components/LoadingSkeleton'; import PageHeader from '../components/PageHeader'; import ResponsiveFilterBar from '../components/ResponsiveFilterBar'; import SEO from '../components/SEO'; +import InFeedPromoCard from '../components/ui/InFeedPromoCard'; import InfiniteScrollTrigger from '../components/ui/InfiniteScrollTrigger'; +import { useAuth } from '../contexts/AuthContext'; import { useCompactLayout } from '../hooks/useCompactLayout'; import { useResponsive } from '../hooks/useResponsive'; import useSorting from '../hooks/useSorting'; @@ -32,15 +34,20 @@ import { getDiveRoutes } from '../services/diveSites'; import { formatDate } from '../utils/dateHelpers'; import { decodeHtmlEntities } from '../utils/htmlDecode'; import { MARKER_TYPES } from '../utils/markerTypes'; +import { getPromoEligibility } from '../utils/promoStorage'; import { getRouteTypeLabel } from '../utils/routeUtils'; import { slugify } from '../utils/slugify'; import { getSortOptions } from '../utils/sortOptions'; import { renderTextWithLinks } from '../utils/textHelpers'; const DiveRoutes = () => { + const { user } = useAuth(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); + const eligibility = getPromoEligibility(); + const shouldShowFeedPromo = !user && eligibility.isEligible; + // State for filters const [search, setSearch] = useState(searchParams.get('search') || ''); const [debouncedSearch, setDebouncedSearch] = useState(searchParams.get('search') || ''); @@ -304,107 +311,111 @@ const DiveRoutes = () => { : 'flex flex-col gap-4' } > - {routes.map(route => { + {routes.map((route, index) => { const detectedType = getRouteTypeLabel(route.route_type, null, route.route_data); return ( -
- {/* Header: Title & Type */} -
-
-

- +
+ {/* Header: Title & Type */} +
+
+

- {decodeHtmlEntities(route.name)} - -

- {/* Route Type Badge */} - - {detectedType.replace('_', ' ')} - -
-
- - {/* Meta Info: Site & Creator */} -
- {route.dive_site && ( -
- - at - + {decodeHtmlEntities(route.name)} + +

+ {/* Route Type Badge */} + - - {route.dive_site.name} - - + {detectedType.replace('_', ' ')} +
- )} +
-
- - {route.creator?.username || route.owner?.username || 'Unknown'} + {/* Meta Info: Site & Creator */} +
+ {route.dive_site && ( +
+ + at + + + {route.dive_site.name} + + +
+ )} + +
+ + {route.creator?.username || route.owner?.username || 'Unknown'} +
-
- {/* Body: Description (Hidden in List Compact Mode if desired, but good to keep) */} - {route.description && ( + {/* Body: Description (Hidden in List Compact Mode if desired, but good to keep) */} + {route.description && ( +
+ {renderTextWithLinks(decodeHtmlEntities(route.description))} +
+ )} + + {/* Stats Strip & Actions */}
- {renderTextWithLinks(decodeHtmlEntities(route.description))} -
- )} - - {/* Stats Strip & Actions */} -
-
- - Created - -
- - - {formatDate(route.created_at, { - day: 'numeric', - month: 'short', - year: 'numeric', - })} +
+ + Created +
+ + + {formatDate(route.created_at, { + day: 'numeric', + month: 'short', + year: 'numeric', + })} + +
-
- - - + + + +
-
+ {shouldShowFeedPromo && (index - 2) % 15 === 0 && ( + + )} + ); })}
diff --git a/frontend/src/pages/DiveSites.jsx b/frontend/src/pages/DiveSites.jsx index 5af607da..31ac8e0d 100644 --- a/frontend/src/pages/DiveSites.jsx +++ b/frontend/src/pages/DiveSites.jsx @@ -21,7 +21,7 @@ import { Route, Search, } from 'lucide-react'; -import { useState, useEffect, useCallback, useMemo, lazy, Suspense } from 'react'; +import React, { useState, useEffect, useCallback, useMemo, lazy, Suspense } from 'react'; import { toast } from 'react-hot-toast'; import { useInfiniteQuery, useMutation, useQueryClient, useQuery } from 'react-query'; import { Link, useNavigate, useSearchParams, useLocation } from 'react-router-dom'; @@ -39,6 +39,7 @@ import PageHeader from '../components/PageHeader'; import RateLimitError from '../components/RateLimitError'; import ResponsiveFilterBar from '../components/ResponsiveFilterBar'; import SEO from '../components/SEO'; +import InFeedPromoCard from '../components/ui/InFeedPromoCard'; import InfiniteScrollTrigger from '../components/ui/InfiniteScrollTrigger'; import { useAuth } from '../contexts/AuthContext'; import { useCompactLayout } from '../hooks/useCompactLayout'; @@ -46,6 +47,7 @@ import useFlickrImages from '../hooks/useFlickrImages'; import { useResponsive } from '../hooks/useResponsive'; import useSorting from '../hooks/useSorting'; import { decodeHtmlEntities } from '../utils/htmlDecode'; +import { getPromoEligibility } from '../utils/promoStorage'; import { handleRateLimitError } from '../utils/rateLimitHandler'; import { slugify } from '../utils/slugify'; import { getSortOptions } from '../utils/sortOptions'; @@ -59,6 +61,9 @@ const DiveSites = () => { const [searchParams, setSearchParams] = useSearchParams(); const queryClient = useQueryClient(); + const eligibility = getPromoEligibility(); + const shouldShowFeedPromo = !user && eligibility.isEligible; + // Enhanced state for mobile UX const [viewMode, setViewMode] = useState(() => { // Use React Router's searchParams consistently @@ -719,15 +724,19 @@ const DiveSites = () => { data-testid='dive-sites-list' className={`space-y-3 sm:space-y-4 ${compactLayout ? 'view-mode-compact' : ''}`} > - {diveSites.results.map(site => ( - + {diveSites.results.map((site, index) => ( + + + {shouldShowFeedPromo && (index - 2) % 15 === 0 && ( + + )} + ))} )} @@ -737,15 +746,19 @@ const DiveSites = () => {
- {diveSites.results.map(site => ( - + {diveSites.results.map((site, index) => ( + + + {shouldShowFeedPromo && (index - 2) % 15 === 0 && ( + + )} + ))}
)} diff --git a/frontend/src/pages/Dives.jsx b/frontend/src/pages/Dives.jsx index 6f592b1f..22ab18f2 100644 --- a/frontend/src/pages/Dives.jsx +++ b/frontend/src/pages/Dives.jsx @@ -25,7 +25,7 @@ import { Anchor, Notebook, } from 'lucide-react'; -import { useState, useEffect, useCallback, useMemo, lazy, Suspense } from 'react'; +import React, { useState, useEffect, useCallback, useMemo, lazy, Suspense } from 'react'; import { toast } from 'react-hot-toast'; import { useInfiniteQuery, useMutation, useQueryClient, useQuery } from 'react-query'; import { Link, useNavigate, useSearchParams, useLocation } from 'react-router-dom'; @@ -45,6 +45,7 @@ import ResponsiveFilterBar from '../components/ResponsiveFilterBar'; import SEO from '../components/SEO'; import DepthIcon from '../components/ui/DepthIcon'; import DifficultyBadge from '../components/ui/DifficultyBadge'; +import InFeedPromoCard from '../components/ui/InFeedPromoCard'; import InfiniteScrollTrigger from '../components/ui/InfiniteScrollTrigger'; import { useAuth } from '../contexts/AuthContext'; import { useCompactLayout } from '../hooks/useCompactLayout'; @@ -53,6 +54,7 @@ import useSorting from '../hooks/useSorting'; import { deleteDive } from '../services/dives'; import { getDiveSite, getDiveSites } from '../services/diveSites'; import { formatDate, formatTime } from '../utils/dateHelpers'; +import { getPromoEligibility } from '../utils/promoStorage'; import { handleRateLimitError } from '../utils/rateLimitHandler'; import { slugify } from '../utils/slugify'; import { getSortOptions } from '../utils/sortOptions'; @@ -71,6 +73,9 @@ const Dives = () => { const [searchParams, setSearchParams] = useSearchParams(); const queryClient = useQueryClient(); + const eligibility = getPromoEligibility(); + const shouldShowFeedPromo = !user && eligibility.isEligible; + // Get initial values from URL parameters const getInitialViewMode = () => { const mode = searchParams.get('view') || 'list'; @@ -865,143 +870,147 @@ const Dives = () => { {/* Dives List */} {viewMode === 'list' && (
- {dives?.map(dive => ( -
-
- {/* HEADER ROW */} -
-
- {/* Compact Title & Site combo */} -

- - {dive.name || `Dive #${dive.id}`} - - {dive.dive_site?.name && ( - - @ {dive.dive_site.name} - - )} -

- - {/* Meta Byline - Single line on mobile */} -
- - {formatDate(dive.dive_date, { - day: 'numeric', - month: 'short', - year: 'numeric', - })} - {dive.dive_time && ( - - β€’ - {formatTime(dive.dive_time)} - - )} -
-
- - {/* Right Side: Rating */} - {dive.user_rating !== undefined && dive.user_rating !== null && ( -
- Rating - - {dive.user_rating} - -
- )} -
- - {/* STATS STRIP - Compact 1-liner */} -
- {dive.max_depth && ( -
- - - {dive.max_depth}m - -
- )} - {dive.duration && ( -
- - - {dive.duration}m - -
- )} - -
- - {/* FOOTER: Tags & Buddies - Only show icons/counts on mobile */} -
-
- {dive.tags?.length > 0 && ( -
- {dive.tags.slice(0, isMobile ? 3 : 5).map(tag => ( - - {tag.name} + {dives?.map((dive, index) => ( + +
+
+ {/* HEADER ROW */} +
+
+ {/* Compact Title & Site combo */} +

+ + {dive.name || `Dive #${dive.id}`} + + {dive.dive_site?.name && ( + + @ {dive.dive_site.name} - ))} - {dive.tags.length > (isMobile ? 3 : 5) && ( - - +{dive.tags.length - (isMobile ? 3 : 5)} + )} +

+ + {/* Meta Byline - Single line on mobile */} +
+ + {formatDate(dive.dive_date, { + day: 'numeric', + month: 'short', + year: 'numeric', + })} + {dive.dive_time && ( + + β€’ + {formatTime(dive.dive_time)} )}
+
+ + {/* Right Side: Rating */} + {dive.user_rating !== undefined && dive.user_rating !== null && ( +
+ Rating + + {dive.user_rating} + +
)} - {dive.buddies?.length > 0 && ( -
- {dive.buddies.slice(0, isMobile ? 2 : 5).map(buddy => ( -
- {buddy.avatar_url ? ( - - ) : ( - - {buddy.username[0]} - - )} -
- ))} +
+ + {/* STATS STRIP - Compact 1-liner */} +
+ {dive.max_depth && ( +
+ + + {dive.max_depth}m + +
+ )} + {dive.duration && ( +
+ + + {dive.duration}m +
)} + +
+ + {/* FOOTER: Tags & Buddies - Only show icons/counts on mobile */} +
+
+ {dive.tags?.length > 0 && ( +
+ {dive.tags.slice(0, isMobile ? 3 : 5).map(tag => ( + + {tag.name} + + ))} + {dive.tags.length > (isMobile ? 3 : 5) && ( + + +{dive.tags.length - (isMobile ? 3 : 5)} + + )} +
+ )} + {dive.buddies?.length > 0 && ( +
+ {dive.buddies.slice(0, isMobile ? 2 : 5).map(buddy => ( +
+ {buddy.avatar_url ? ( + + ) : ( + + {buddy.username[0]} + + )} +
+ ))} +
+ )} +
+ + +
- - -
-
+ {shouldShowFeedPromo && (index - 2) % 15 === 0 && ( + + )} +
))}
)} diff --git a/frontend/src/pages/DivingCenters.jsx b/frontend/src/pages/DivingCenters.jsx index 6ef0bece..c4675368 100644 --- a/frontend/src/pages/DivingCenters.jsx +++ b/frontend/src/pages/DivingCenters.jsx @@ -15,7 +15,7 @@ import { ChevronRight, Wrench, } from 'lucide-react'; -import { useState, useEffect, useCallback, lazy, Suspense, useMemo } from 'react'; +import React, { useState, useEffect, useCallback, lazy, Suspense, useMemo } from 'react'; import { toast } from 'react-hot-toast'; import { useInfiniteQuery, useMutation, useQueryClient } from 'react-query'; import { Link, useNavigate, useSearchParams, useLocation } from 'react-router-dom'; @@ -31,12 +31,14 @@ import MatchTypeBadge from '../components/MatchTypeBadge'; import PageHeader from '../components/PageHeader'; import RateLimitError from '../components/RateLimitError'; import SEO from '../components/SEO'; +import InFeedPromoCard from '../components/ui/InFeedPromoCard'; import InfiniteScrollTrigger from '../components/ui/InfiniteScrollTrigger'; import { useAuth } from '../contexts/AuthContext'; import { useCompactLayout } from '../hooks/useCompactLayout'; import { useResponsive, useResponsiveScroll } from '../hooks/useResponsive'; import { useSetting } from '../hooks/useSettings'; import { decodeHtmlEntities } from '../utils/htmlDecode'; +import { getPromoEligibility } from '../utils/promoStorage'; import { slugify, getDivingCenterSlug } from '../utils/slugify'; const DivingCenters = () => { @@ -45,6 +47,9 @@ const DivingCenters = () => { const navigate = useNavigate(); const location = useLocation(); const queryClient = useQueryClient(); + + const eligibility = getPromoEligibility(); + const shouldShowFeedPromo = !user && eligibility.isEligible; const { isMobile } = useResponsive(); const { searchBarVisible, quickFiltersVisible } = useResponsiveScroll(); const { compactLayout, handleDisplayOptionChange: setCompactLayout } = useCompactLayout({ @@ -417,138 +422,142 @@ const DivingCenters = () => {
- {divingCenters?.map(center => ( -
- {/* Main content column */} -
- {/* Main info */} -
- {/* Title row */} -
-
- -

- - {center.name} - -

-
- {center.average_rating && ( -
- Rating ( + +
+ {/* Main content column */} +
+ {/* Main info */} +
+ {/* Title row */} +
+
+ - {center.average_rating.toFixed(1)} +

+ + {center.name} + +

- )} -
+ {center.average_rating && ( +
+ Rating + {center.average_rating.toFixed(1)} +
+ )} +
- {/* Location & Metadata Row - Very compact */} -
- {(center.country || center.region || center.city) && ( -
- - - {Array.from( - new Set( - [center.country, center.region, center.city] - .filter(Boolean) - .flatMap(s => s.split(',').map(p => p.trim())) - ) - ).join(', ')} - -
- )} - {matchTypes[center.id] && ( - + {/* Location & Metadata Row - Very compact */} +
+ {(center.country || center.region || center.city) && ( +
+ + + {Array.from( + new Set( + [center.country, center.region, center.city] + .filter(Boolean) + .flatMap(s => s.split(',').map(p => p.trim())) + ) + ).join(', ')} + +
+ )} + {matchTypes[center.id] && ( + + )} +
+ + {/* Description - completely removed on mobile view */} + {!isMobile && center.description && ( +

+ {decodeHtmlEntities(center.description)} +

)}
- {/* Description - completely removed on mobile view */} - {!isMobile && center.description && ( -

- {decodeHtmlEntities(center.description)} -

- )} -
- - {/* Bottom row - Quick Action Row (High consistency) */} -
- {center.website && ( - - - - )} - {center.email && - (isMobile ? ( + {/* Bottom row - Quick Action Row (High consistency) */} +
+ {center.website && ( - + - ) : ( - + + + ) : ( + + + + ))} + {center.phone && ( + - - - ))} - {center.phone && ( - + + )} + - - - )} - - - + + +
-
+ {shouldShowFeedPromo && (index - 2) % 15 === 0 && ( + + )} + ))}
)} diff --git a/frontend/src/utils/promoStorage.js b/frontend/src/utils/promoStorage.js new file mode 100644 index 00000000..98aede97 --- /dev/null +++ b/frontend/src/utils/promoStorage.js @@ -0,0 +1,69 @@ +const SESSION_KEY = 'divemap_session_page_views'; +const CUMULATIVE_KEY = 'divemap_lifetime_page_views'; +const DISMISSAL_COUNT_KEY = 'divemap_promo_dismissals'; +const NEXT_ELIGIBLE_VIEW_KEY = 'divemap_next_eligible_view'; + +export const incrementSessionPageViews = () => { + const current = parseInt(window.sessionStorage.getItem(SESSION_KEY), 10) || 0; + const updated = current + 1; + window.sessionStorage.setItem(SESSION_KEY, updated.toString()); + return updated; +}; + +export const incrementCumulativePageViews = () => { + const current = parseInt(window.localStorage.getItem(CUMULATIVE_KEY), 10) || 0; + const updated = current + 1; + window.localStorage.setItem(CUMULATIVE_KEY, updated.toString()); + return updated; +}; + +export const dismissPromo = () => { + const dismissals = (parseInt(window.localStorage.getItem(DISMISSAL_COUNT_KEY), 10) || 0) + 1; + window.localStorage.setItem(DISMISSAL_COUNT_KEY, dismissals.toString()); + + const cumulative = parseInt(window.localStorage.getItem(CUMULATIVE_KEY), 10) || 0; + + // Absolute target thresholds sequence: 3 -> 7 -> 12 -> 18 -> 25 + const TARGETS = [3, 7, 12, 18, 25]; + + let target = 9999999; + + if (dismissals < 5) { + // Find the very next absolute threshold in our sequence that is strictly in the future. + // This handles both perfect path dismissals and late dismissals gracefully. + const nextAbsoluteTarget = TARGETS.find(t => t > cumulative); + + // Fall back to current cumulative + 5 if they have scrolled past all spec targets + target = nextAbsoluteTarget || cumulative + 5; + } + + window.localStorage.setItem(NEXT_ELIGIBLE_VIEW_KEY, target.toString()); +}; + +export const getPromoEligibility = () => { + const sessionCount = parseInt(window.sessionStorage.getItem(SESSION_KEY), 10) || 0; + const cumulativeCount = parseInt(window.localStorage.getItem(CUMULATIVE_KEY), 10) || 0; + const dismissals = parseInt(window.localStorage.getItem(DISMISSAL_COUNT_KEY), 10) || 0; + + // Default to cumulative 3 for first appearance if no target is stored + const storedNextEligible = parseInt(window.localStorage.getItem(NEXT_ELIGIBLE_VIEW_KEY), 10) || 0; + const nextEligible = storedNextEligible || 3; + + const isStandalone = + (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) || + window.navigator.standalone === true; + + if (isStandalone || dismissals >= 5) { + return { isEligible: false, platform: 'standalone', activePlatform: 'standalone' }; + } + + const userAgentString = window.navigator.userAgent || ''; + const isAndroid = /Android/i.test(userAgentString); + const isIOS = /iPhone|iPad|iPod/i.test(userAgentString); + const platform = isAndroid ? 'android' : isIOS ? 'ios' : 'desktop'; + + // Requirements: Current session views must be >= 3 AND total views must meet or exceed target + const isEligible = sessionCount >= 3 && cumulativeCount >= nextEligible; + + return { isEligible, platform, activePlatform: platform }; +}; diff --git a/frontend/src/utils/promoStorage.test.js b/frontend/src/utils/promoStorage.test.js new file mode 100644 index 00000000..20441771 --- /dev/null +++ b/frontend/src/utils/promoStorage.test.js @@ -0,0 +1,235 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import { + incrementSessionPageViews, + incrementCumulativePageViews, + getPromoEligibility, + dismissPromo, +} from './promoStorage'; + +describe('promoStorage', () => { + beforeEach(() => { + window.sessionStorage.clear(); + window.localStorage.clear(); + vi.restoreAllMocks(); + + // Default mock for matchMedia (not standalone) + window.matchMedia = vi.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + })); + + // Reset navigator.standalone in case it was modified + if (window.navigator) { + Object.defineProperty(window.navigator, 'standalone', { + value: undefined, + configurable: true, + }); + } + }); + + describe('incrementSessionPageViews', () => { + it('should increment session views correctly', () => { + expect(incrementSessionPageViews()).toBe(1); + expect(incrementSessionPageViews()).toBe(2); + expect(incrementSessionPageViews()).toBe(3); + }); + }); + + describe('incrementCumulativePageViews', () => { + it('should increment cumulative views correctly', () => { + expect(incrementCumulativePageViews()).toBe(1); + expect(incrementCumulativePageViews()).toBe(2); + expect(incrementCumulativePageViews()).toBe(3); + }); + }); + + describe('getPromoEligibility', () => { + it('should not be eligible initially when session views is 0', () => { + const eligibility = getPromoEligibility(); + expect(eligibility.isEligible).toBe(false); + }); + + it('should not be eligible if session views is less than 3', () => { + incrementSessionPageViews(); + incrementSessionPageViews(); + expect(getPromoEligibility().isEligible).toBe(false); + }); + + it('should be eligible if session views >= 3 and cumulative >= nextEligible (default 3)', () => { + incrementSessionPageViews(); + incrementSessionPageViews(); + incrementSessionPageViews(); + // Increments cumulative to 3 as well + incrementCumulativePageViews(); + incrementCumulativePageViews(); + incrementCumulativePageViews(); + expect(getPromoEligibility().isEligible).toBe(true); + }); + + it('should identify Android platform correctly', () => { + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue( + 'Mozilla/5.0 (Linux; Android 10; SM-G975F)' + ); + incrementSessionPageViews(); + incrementSessionPageViews(); + incrementSessionPageViews(); + incrementCumulativePageViews(); + incrementCumulativePageViews(); + incrementCumulativePageViews(); + + const eligibility = getPromoEligibility(); + expect(eligibility.platform).toBe('android'); + expect(eligibility.activePlatform).toBe('android'); + }); + + it('should identify iOS platform correctly', () => { + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue( + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X)' + ); + incrementSessionPageViews(); + incrementSessionPageViews(); + incrementSessionPageViews(); + incrementCumulativePageViews(); + incrementCumulativePageViews(); + incrementCumulativePageViews(); + + const eligibility = getPromoEligibility(); + expect(eligibility.platform).toBe('ios'); + expect(eligibility.activePlatform).toBe('ios'); + }); + + it('should identify desktop platform as fallback', () => { + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + ); + incrementSessionPageViews(); + incrementSessionPageViews(); + incrementSessionPageViews(); + incrementCumulativePageViews(); + incrementCumulativePageViews(); + incrementCumulativePageViews(); + + const eligibility = getPromoEligibility(); + expect(eligibility.platform).toBe('desktop'); + expect(eligibility.activePlatform).toBe('desktop'); + }); + + it('should not be eligible if running in standalone display mode (matchMedia)', () => { + window.matchMedia = vi.fn().mockImplementation(query => ({ + matches: query.includes('standalone'), + })); + incrementSessionPageViews(); + incrementSessionPageViews(); + incrementSessionPageViews(); + incrementCumulativePageViews(); + incrementCumulativePageViews(); + incrementCumulativePageViews(); + + const eligibility = getPromoEligibility(); + expect(eligibility.isEligible).toBe(false); + expect(eligibility.platform).toBe('standalone'); + expect(eligibility.activePlatform).toBe('standalone'); + }); + + it('should not be eligible if running in standalone mode (navigator.standalone)', () => { + Object.defineProperty(window.navigator, 'standalone', { + value: true, + configurable: true, + }); + incrementSessionPageViews(); + incrementSessionPageViews(); + incrementSessionPageViews(); + incrementCumulativePageViews(); + incrementCumulativePageViews(); + incrementCumulativePageViews(); + + const eligibility = getPromoEligibility(); + expect(eligibility.isEligible).toBe(false); + expect(eligibility.platform).toBe('standalone'); + expect(eligibility.activePlatform).toBe('standalone'); + }); + }); + + describe('dismissPromo', () => { + it('should delay eligibility progressively on dismissal following sequence 3 -> 7 -> 12 -> 18 -> 25', () => { + // 1. Get eligible initially (views >= 3) + for (let i = 0; i < 3; i++) { + incrementSessionPageViews(); + incrementCumulativePageViews(); + } + expect(getPromoEligibility().isEligible).toBe(true); + + // 2. Dismiss 1st time on cumulative 3 -> next eligible target is 7. + dismissPromo(); + expect(getPromoEligibility().isEligible).toBe(false); + + // Increment cumulative up to 6 -> still ineligible + for (let i = 0; i < 3; i++) { + incrementCumulativePageViews(); + } + expect(getPromoEligibility().isEligible).toBe(false); + + // Increment cumulative to 7 -> now eligible again + incrementCumulativePageViews(); + expect(getPromoEligibility().isEligible).toBe(true); + + // 3. Dismiss 2nd time on cumulative 7 -> next eligible target is 12. + dismissPromo(); + expect(getPromoEligibility().isEligible).toBe(false); + + // Increment cumulative to 11 -> still ineligible + for (let i = 0; i < 4; i++) { + incrementCumulativePageViews(); + } + expect(getPromoEligibility().isEligible).toBe(false); + + // Increment to 12 -> now eligible again + incrementCumulativePageViews(); + expect(getPromoEligibility().isEligible).toBe(true); + + // 4. Dismiss 3rd time on cumulative 12 -> next eligible target is 18. + dismissPromo(); + expect(getPromoEligibility().isEligible).toBe(false); + + // Increment to 17 -> still ineligible + for (let i = 0; i < 5; i++) { + incrementCumulativePageViews(); + } + expect(getPromoEligibility().isEligible).toBe(false); + + // Increment to 18 -> now eligible again + incrementCumulativePageViews(); + expect(getPromoEligibility().isEligible).toBe(true); + + // 5. Dismiss 4th time on cumulative 18 -> next eligible target is 25. + dismissPromo(); + expect(getPromoEligibility().isEligible).toBe(false); + + // Increment to 24 -> still ineligible + for (let i = 0; i < 6; i++) { + incrementCumulativePageViews(); + } + expect(getPromoEligibility().isEligible).toBe(false); + + // Increment to 25 -> now eligible again + incrementCumulativePageViews(); + expect(getPromoEligibility().isEligible).toBe(true); + + // 6. Dismiss 5th time -> permanent suppression + dismissPromo(); + expect(getPromoEligibility().isEligible).toBe(false); + + // Even with massive page views, should remain permanently ineligible + for (let i = 0; i < 100; i++) { + incrementCumulativePageViews(); + } + expect(getPromoEligibility().isEligible).toBe(false); + expect(getPromoEligibility().platform).toBe('standalone'); + expect(getPromoEligibility().activePlatform).toBe('standalone'); + }); + }); +}); diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 8c3fe01b..6f8c210f 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -31,6 +31,15 @@ module.exports = { boxShadow: { 'dive-card': '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)', 'dive-card-hover': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)', + }, + keyframes: { + 'bounce-gentle': { + '0%, 100%': { transform: 'translateY(0) translateX(-50%)' }, + '50%': { transform: 'translateY(-6px) translateX(-50%)' }, + } + }, + animation: { + 'bounce-gentle': 'bounce-gentle 2.5s ease-in-out infinite', } }, }, diff --git a/frontend/vite.config.mjs b/frontend/vite.config.mjs index 323452a7..879b6c95 100644 --- a/frontend/vite.config.mjs +++ b/frontend/vite.config.mjs @@ -45,7 +45,7 @@ export default defineConfig({ prefer_related_applications: false, related_applications: [], scope: '/', - start_url: '/', + start_url: '/?utm_source=pwa', id: '/', categories: ['travel', 'sports', 'social'], launch_handler: {