Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { Sidebar } from '@/components/sidebar'
import { Analytics } from "@vercel/analytics/next"
import { SpeedInsights } from "@vercel/speed-insights/next"
import { Toaster } from '@/components/ui/sonner'
import { IosInstallPrompt } from '@/components/pwa/ios-install-prompt'
import { SwRegistration } from '@/components/pwa/sw-registration'
import { MapToggleProvider } from '@/components/map-toggle-context'
import { ProfileToggleProvider } from '@/components/profile-toggle-context'
import { CalendarToggleProvider } from '@/components/calendar-toggle-context'
Expand All @@ -36,6 +38,15 @@ export const metadata: Metadata = {
metadataBase: new URL('https://www.qcx.world'),
title,
description,
appleWebApp: {
capable: true,
statusBarStyle: 'default',
title: 'QCX'
},
icons: {
icon: '/icon-192.png',
apple: '/icon-192.png'
},
Comment on lines +41 to +49

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

metadata.icons.apple is set to /icon-192.png, but iOS home screen icons are typically expected at specific sizes (e.g., 180x180). Using only a 192px icon may lead to suboptimal rendering on iOS. If you want good iOS support, consider adding an apple-touch-icon at 180x180 (and optionally additional sizes) and reference it via metadata.

Suggestion

Add a dedicated public/apple-touch-icon.png (180x180) and set:

icons: {
  icon: [
    { url: '/icon-192.png', sizes: '192x192', type: 'image/png' },
    { url: '/icon-512.png', sizes: '512x512', type: 'image/png' },
  ],
  apple: [{ url: '/apple-touch-icon.png', sizes: '180x180' }],
}

Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.

openGraph: {
title,
description
Expand Down Expand Up @@ -87,6 +98,8 @@ export default function RootLayout({
<Sidebar />
<Footer />
<Toaster />
<IosInstallPrompt />
<SwRegistration />
</MapLoadingProvider>
</MapContextProvider>
</ThemeProvider>
Expand Down
34 changes: 34 additions & 0 deletions app/manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { MetadataRoute } from 'next'

export default function manifest(): MetadataRoute.Manifest {
return {
name: 'QCX - AI-powered language to Maps',
short_name: 'QCX',
description: 'A minimalistic AI-powered tool that transforms language into interactive maps.',
start_url: '/?utm_source=pwa',
display: 'standalone',
background_color: '#171717',
theme_color: '#171717',
icons: [
{
src: '/icon-192.png',
sizes: '192x192',
type: 'image/png',
purpose: 'any'
},
{
src: '/icon-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any'
},
{
src: '/icon-maskable.png',
sizes: '1024x1024',
type: 'image/png',
purpose: 'maskable'
}
],
categories: ['search', 'ai', 'productivity', 'maps']
}
}
73 changes: 73 additions & 0 deletions components/pwa/ios-install-prompt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
'use client'

import React, { useEffect, useState } from 'react'
import { X, Share } from 'lucide-react'

export function IosInstallPrompt() {
const [showPrompt, setShowPrompt] = useState(false)

useEffect(() => {
if (typeof window === 'undefined') return

// Detect iOS
const userAgent = window.navigator.userAgent
const isIosDevice = /iPad|iPhone|iPod/.test(userAgent)

// Detect if already in standalone mode (installed)
const isInStandaloneMode =
(window.navigator as any).standalone ||
window.matchMedia('(display-mode: standalone)').matches

// Check if prompt was dismissed recently
const isPromptDismissed = localStorage.getItem('iosPwaPromptDismissed')

if (isIosDevice && !isInStandaloneMode && !isPromptDismissed) {
setShowPrompt(true)
}
}, [])

const handleDismiss = () => {
setShowPrompt(false)
localStorage.setItem('iosPwaPromptDismissed', 'true')
}
Comment on lines +21 to +32

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The iOS prompt uses a permanent dismissal flag (iosPwaPromptDismissed = true). That means users who dismiss once will never see it again, even months later or after major updates. Typically this should be a TTL (e.g., 7–30 days) or a versioned dismissal key so you can re-prompt after meaningful product changes.

Suggestion

Store a timestamp and only suppress for a period (or version the key):

const KEY = 'iosPwaPromptDismissedAt'
const dismissedAt = Number(localStorage.getItem(KEY) ?? '0')
const THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000
const dismissedRecently = dismissedAt && Date.now() - dismissedAt < THIRTY_DAYS

// ...
localStorage.setItem(KEY, String(Date.now()))

Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.

Comment on lines +12 to +32

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The iOS install prompt is dismissed permanently (localStorage.setItem('iosPwaPromptDismissed', 'true')). That means users who dismiss once will never see it again, even after weeks/months, which is usually not desired and can reduce install conversions. Also, the iOS detection via /iPad|iPhone|iPod/ misses modern iPadOS Safari in some cases (reports itself closer to macOS); the prompt may not appear on iPads.

Suggestion

Use a TTL-based dismissal and broaden iPadOS detection. Example:

const DISMISS_KEY = 'iosPwaPromptDismissedAt'
const DISMISS_TTL_MS = 1000 * 60 * 60 * 24 * 14 // 14 days

const dismissedAt = Number(localStorage.getItem(DISMISS_KEY) ?? '0')
const isPromptDismissed = Date.now() - dismissedAt < DISMISS_TTL_MS

const ua = navigator.userAgent
const isIphoneIpod = /iPhone|iPod/.test(ua)
const isIpad = /iPad/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
const isIosDevice = isIphoneIpod || isIpad

// on dismiss:
localStorage.setItem(DISMISS_KEY, String(Date.now()))

Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.


if (!showPrompt) return null

return (
<div className="fixed bottom-4 left-4 right-4 z-[100] md:left-auto md:right-4 md:max-w-sm animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="relative overflow-hidden rounded-2xl bg-background border shadow-2xl p-4 pr-10">
<button
onClick={handleDismiss}
className="absolute right-2 top-2 p-1 text-muted-foreground hover:text-foreground rounded-full hover:bg-muted transition-colors"
aria-label="Dismiss"
>
<X className="h-4 w-4" />
</button>

<div className="flex items-start gap-4">
<div className="flex-shrink-0 bg-primary/10 p-2 rounded-xl">
<div className="w-10 h-10 bg-primary rounded-lg flex items-center justify-center text-primary-foreground font-bold">
Q
</div>
</div>

<div className="flex-grow pt-1">
<h3 className="text-sm font-semibold leading-none mb-1">Install QCX</h3>
<p className="text-xs text-muted-foreground leading-snug">
Add this app to your home screen for a better experience.
</p>
</div>
</div>

<div className="mt-4 pt-3 border-t flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
<span>Tap the Share icon</span>
<div className="bg-muted p-1 rounded-md">
<Share className="h-3 w-3" />
</div>
<span>then scroll down and select</span>
<span className="font-semibold text-foreground">&quot;Add to Home Screen&quot;</span>
</div>
</div>
</div>
)
}
22 changes: 22 additions & 0 deletions components/pwa/sw-registration.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
'use client'

import { useEffect } from 'react'

export function SwRegistration() {
useEffect(() => {
if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/sw.js')
.then((registration) => {
console.log('SW registered: ', registration)
})
.catch((registrationError) => {
console.log('SW registration failed: ', registrationError)
})
})
}
}, [])
Comment on lines +5 to +19

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SwRegistration adds a load event listener but never removes it. In React Strict Mode/dev, effects may run more than once, and you can end up with duplicate listeners and duplicate registrations. Also, logging SW registration to the console in production is noisy.

Suggestion

Consider registering immediately (or via a stable handler) and adding cleanup to avoid leaking listeners, and gate logs behind NODE_ENV !== 'production'.

Example:

useEffect(() => {
  if (!('serviceWorker' in navigator)) return

  const register = () => {
    navigator.serviceWorker.register('/sw.js').catch(() => {
      // optionally report
    })
  }

  if (document.readyState === 'complete') {
    register()
    return
  }

  window.addEventListener('load', register, { once: true })
  return () => window.removeEventListener('load', register)
}, [])

Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.


return null
}
Binary file added public/icon-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icon-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icon-maskable.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions public/sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// public/sw.js
// Basic service worker to satisfy PWA install criteria
self.addEventListener('install', (event) => {
self.skipWaiting();
});

self.addEventListener('activate', (event) => {
event.waitUntil(clients.claim());
});

self.addEventListener('fetch', (event) => {
// A fetch handler is required for the PWA to be installable on Chrome.
// This is a "no-op" fetch handler.
});
Comment on lines +1 to +14

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The service worker has a fetch handler that does nothing. A no-op fetch handler can have unintended effects (it still intercepts all requests and can complicate debugging), and it doesn't provide offline capability. If you only need installability, consider omitting the fetch handler and instead implement minimal caching (or explicitly event.respondWith(fetch(event.request))) so the handler is semantically correct.

Suggestion

Either remove the fetch listener entirely (if install criteria are met without it in your target browsers), or make it explicitly pass-through to avoid surprises.

Example pass-through:

self.addEventListener('fetch', (event) => {
  event.respondWith(fetch(event.request))
})

If you want minimal offline support, add a cache in install and serve cached assets first for navigation requests.

Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.