Page /download, nom d'app, scan QR conditionnel et suggestions GPS

/download/{instanceId}/{config}/{section} présente l'app mobile de
l'instance : nom, image principale, liens stores, dans la langue du
navigateur. Sans app mobile, redirection vers la visite web.

Le nom de l'application titre l'accueil et l'onglet, le bouton de scan
suit le réglage du manager, et le scanner lit le préfixe download.

Suggestion de contenu quand le visiteur entre dans la zone GPS d'une
section, sur activation explicite. Pas de notification système : un
navigateur ne suit pas la position en arrière-plan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thomas Fransolet 2026-09-10 11:42:24 +02:00
parent e2a1c97c00
commit 7ff72345b3
10 changed files with 344 additions and 9 deletions

View File

@ -2,6 +2,7 @@ import { notFound } from 'next/navigation'
import { getInstanceBySlug, getConfiguration, getSections } from '@/lib/api/client'
import SectionList from '@/components/SectionList'
import QRScannerButton from '@/components/QRScannerButton'
import ProximitySuggestion from '@/components/ProximitySuggestion'
export default async function ConfigPage({
params,
@ -38,7 +39,8 @@ export default async function ConfigPage({
configPrimaryColor={config.primaryColor}
languages={config.languages ?? ['FR']}
/>
<QRScannerButton slug={slug} configurationId={configId} />
{instance.isQRCodeEnabled !== false && <QRScannerButton slug={slug} configurationId={configId} />}
<ProximitySuggestion sections={activeSections} slug={slug} configId={configId} />
</>
)
}

View File

@ -1,9 +1,21 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getInstanceBySlug } from '@/lib/api/client'
import { tPlain } from '@/lib/i18n'
import { resolveColors } from '@/lib/theme'
import { VisitorProvider } from '@/context/VisitorContext'
import TrialWatermark from '@/components/ui/TrialWatermark'
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
const { slug } = await params
try {
const instance = await getInstanceBySlug(slug)
return { title: tPlain(instance.appName, 'FR') || instance.label }
} catch {
return {}
}
}
export default async function SlugLayout({
children,
params,

View File

@ -21,13 +21,15 @@ export default async function HomePage({
const featuredEvent = instance.sectionEventDTO
const languages = [...new Set(items.flatMap((i) => i.configuration.languages ?? []))]
const hasAppName = !!instance.appName?.some((a) => a.value?.trim())
return (
<>
{(featuredEvent || instance.mainImageUrl) && (
{(featuredEvent || instance.mainImageUrl || hasAppName) && (
<HomeHero
featuredEvent={featuredEvent ?? undefined}
mainImageUrl={instance.mainImageUrl}
appName={instance.appName}
slug={slug}
configurationId={featuredEvent?.configurationId ?? undefined}
/>
@ -39,7 +41,7 @@ export default async function HomePage({
instancePrimaryColor={instance.primaryColor}
instanceSecondaryColor={instance.secondaryColor}
/>
<QRScannerButton slug={slug} />
{instance.isQRCodeEnabled !== false && <QRScannerButton slug={slug} />}
</>
)
}

View File

@ -0,0 +1,147 @@
import type { Metadata } from 'next'
import Image from 'next/image'
import { headers } from 'next/headers'
import { notFound, redirect } from 'next/navigation'
import { getApplicationInstances } from '@/lib/api/client'
import { tPlain } from '@/lib/i18n'
import { resolveColors } from '@/lib/theme'
// Cible des QR générés par le manager : app.myinfomate.be/download/{instanceId}/{configId}/{sectionId}.
// Scanné depuis l'app mobile, le QR ouvre la section ; scanné à l'appareil photo, il
// arrive ici. Le visiteur n'a pas encore choisi de langue : on suit celle du navigateur.
type Language = 'FR' | 'EN' | 'NL'
const TEXTS: Record<Language, { fallbackTitle: string; intro: string; download: string; afterInstall: string; comingSoon: string }> = {
FR: {
fallbackTitle: 'Application mobile',
intro: 'Ce contenu se découvre dans notre application mobile.',
download: 'Téléchargez-la sur votre téléphone :',
afterInstall: "Une fois l'application installée, scannez à nouveau le QR code depuis l'application.",
comingSoon: "L'application sera bientôt disponible sur les stores.",
},
EN: {
fallbackTitle: 'Mobile app',
intro: 'This content is available in our mobile app.',
download: 'Download it on your phone:',
afterInstall: 'Once the app is installed, scan the QR code again from inside the app.',
comingSoon: 'The app will soon be available in the stores.',
},
NL: {
fallbackTitle: 'Mobiele app',
intro: 'Deze inhoud ontdek je in onze mobiele app.',
download: 'Download ze op je telefoon:',
afterInstall: 'Scan de QR-code opnieuw vanuit de app zodra ze geïnstalleerd is.',
comingSoon: 'De app is binnenkort beschikbaar in de stores.',
},
}
async function resolveLanguage(): Promise<Language> {
const acceptLanguage = (await headers()).get('accept-language') ?? ''
const preferred = acceptLanguage.slice(0, 2).toUpperCase()
return preferred === 'EN' || preferred === 'NL' ? preferred : 'FR'
}
async function loadApps(instanceId: string) {
try {
return await getApplicationInstances(instanceId)
} catch {
return []
}
}
async function loadMobileApp(instanceId: string) {
return (await loadApps(instanceId)).find((app) => app.appType === 'Mobile')
}
export async function generateMetadata({ params }: { params: Promise<{ instanceId: string }> }): Promise<Metadata> {
const { instanceId } = await params
const [mobileApp, language] = await Promise.all([loadMobileApp(instanceId), resolveLanguage()])
return { title: tPlain(mobileApp?.appName, language) || mobileApp?.instanceName || TEXTS[language].fallbackTitle }
}
export default async function DownloadPage({
params,
}: {
params: Promise<{ instanceId: string; rest?: string[] }>
}) {
const { instanceId, rest } = await params
const [apps, language] = await Promise.all([loadApps(instanceId), resolveLanguage()])
const mobileApp = apps.find((app) => app.appType === 'Mobile')
// Sans app mobile (offre web seule), la page n'a rien à proposer : on emmène le
// visiteur sur la visite web. Cas d'un ancien QR redirigé ici.
if (!mobileApp) {
const webSlug = apps.find((app) => app.webSlug)?.webSlug
const [configId, sectionId] = rest ?? []
if (webSlug && configId && sectionId) redirect(`/${webSlug}/${configId}/sections/${sectionId}`)
if (webSlug) redirect(`/${webSlug}`)
notFound()
}
const texts = TEXTS[language]
const title = tPlain(mobileApp.appName, language) || mobileApp.instanceName || texts.fallbackTitle
const theme = resolveColors(mobileApp)
const hasStoreLink = !!(mobileApp.appStoreUrl || mobileApp.playStoreUrl)
return (
<main
className="min-h-screen flex flex-col items-center bg-neutral-50 text-neutral-900"
style={theme as React.CSSProperties}
>
<div className="relative w-full max-w-md overflow-hidden rounded-b-3xl" style={{ height: '45vh', minHeight: 220 }}>
{mobileApp.mainImageUrl ? (
<Image src={mobileApp.mainImageUrl} alt={title} fill className="object-cover" sizes="(max-width: 448px) 100vw, 448px" priority />
) : (
<div
className="absolute inset-0"
style={{ background: 'linear-gradient(135deg, var(--color-primary), var(--color-secondary))' }}
/>
)}
<div
className="absolute inset-0"
style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.72) 0%, rgba(0,0,0,0.08) 55%)' }}
/>
<h1
className="absolute bottom-5 left-5 right-5 text-white text-3xl font-bold leading-tight"
style={{ textShadow: '0 2px 8px rgba(0,0,0,0.6)' }}
>
{title}
</h1>
</div>
<section className="w-full max-w-md px-6 py-8 flex flex-col gap-5">
<p className="text-lg">{texts.intro}</p>
{hasStoreLink ? (
<>
<p className="text-sm text-neutral-600">{texts.download}</p>
<div className="flex flex-col gap-3">
{mobileApp.appStoreUrl && (
<a
href={mobileApp.appStoreUrl}
className="rounded-2xl px-5 py-4 text-center font-semibold"
style={{ background: 'var(--color-primary)', color: 'var(--color-on-primary)' }}
>
App Store
</a>
)}
{mobileApp.playStoreUrl && (
<a
href={mobileApp.playStoreUrl}
className="rounded-2xl px-5 py-4 text-center font-semibold"
style={{ background: 'var(--color-primary)', color: 'var(--color-on-primary)' }}
>
Google Play
</a>
)}
</div>
<p className="text-sm text-neutral-500">{texts.afterInstall}</p>
</>
) : (
<p className="text-sm text-neutral-600">{texts.comingSoon}</p>
)}
</section>
</main>
)
}

View File

@ -97,3 +97,8 @@ body {
.mim-fab-qr svg { width: 26px; height: 26px; }
[data-assistant='true'] .mim-fab-qr { bottom: calc(20px + var(--mim-assistant-inset-y)); }
/* Suggestions à proximité : même gabarit, coin bas-gauche (le bas-droit est pris). */
.mim-fab-qr.mim-fab-nearby,
[data-assistant='true'] .mim-fab-qr.mim-fab-nearby { left: 20px; right: auto; bottom: 20px; }
.mim-fab-nearby.is-active { background: var(--color-primary); color: var(--color-on-primary); }

View File

@ -5,12 +5,13 @@ import Link from 'next/link'
import Image from 'next/image'
import { useVisitor } from '@/context/VisitorContext'
import { t, tPlain } from '@/lib/i18n'
import type { SectionDTO } from '@/lib/api/types'
import type { SectionDTO, TranslationDTO } from '@/lib/api/types'
import LanguageSelector from '@/components/ui/LanguageSelector'
interface Props {
featuredEvent?: SectionDTO
mainImageUrl?: string
appName?: TranslationDTO[]
slug: string
configurationId?: string
}
@ -26,7 +27,7 @@ function formatDateRange(start?: string, end?: string, locale: string = 'fr'): s
return `${d1.toLocaleDateString(locale, opt)}${d2.toLocaleDateString(locale, opt)}`
}
export default function HomeHero({ featuredEvent, mainImageUrl, slug, configurationId }: Props) {
export default function HomeHero({ featuredEvent, mainImageUrl, appName, slug, configurationId }: Props) {
const { language } = useVisitor()
const heroRef = useRef<HTMLDivElement>(null)
const [opacity, setOpacity] = useState(1)
@ -43,6 +44,7 @@ export default function HomeHero({ featuredEvent, mainImageUrl, slug, configurat
const imageUrl = featuredEvent?.imageSource ?? mainImageUrl
const isClickable = !!featuredEvent && !!configurationId
const appTitle = tPlain(appName, language)
const dateLabel = formatDateRange(
featuredEvent?.event?.startDate,
@ -62,7 +64,7 @@ export default function HomeHero({ featuredEvent, mainImageUrl, slug, configurat
{imageUrl ? (
<Image
src={imageUrl}
alt={featuredEvent ? tPlain(featuredEvent.title, language) : ''}
alt={featuredEvent ? tPlain(featuredEvent.title, language) : appTitle}
fill
className="object-cover"
sizes="100vw"
@ -97,6 +99,15 @@ export default function HomeHero({ featuredEvent, mainImageUrl, slug, configurat
)}
</div>
)}
{!featuredEvent && appTitle && (
<h1
className="absolute bottom-4 left-4 right-12 text-white text-2xl font-bold leading-tight line-clamp-2"
style={{ textShadow: '0 2px 8px rgba(0,0,0,0.6)' }}
>
{appTitle}
</h1>
)}
</div>
)

View File

@ -0,0 +1,137 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { useVisitor } from '@/context/VisitorContext'
import { useGeolocation } from '@/hooks/useGeolocation'
import { tPlain } from '@/lib/i18n'
import type { SectionDTO } from '@/lib/api/types'
// Équivalent web des suggestions de proximité de mymuseum-visitapp (ConfigurationPage),
// limité au GPS : pas de beacons dans un navigateur, et rien en arrière-plan.
// Même règle de zone que GeoBeaconTriggerService.buildGeoPoints : rayon meterZoneGPS,
// 20 m par défaut ; même délai entre deux suggestions que la popup beacon (20 s).
const DEFAULT_RADIUS_METERS = 20
const COOLDOWN_MS = 20000
const TEXTS: Record<string, { enable: string; disable: string; nearby: string; open: string; close: string; denied: string }> = {
FR: { enable: 'Activer les suggestions à proximité', disable: 'Désactiver les suggestions à proximité', nearby: 'À proximité', open: 'Ouvrir', close: 'Fermer', denied: 'Localisation refusée' },
EN: { enable: 'Enable nearby suggestions', disable: 'Disable nearby suggestions', nearby: 'Nearby', open: 'Open', close: 'Close', denied: 'Location denied' },
NL: { enable: 'Suggesties in de buurt inschakelen', disable: 'Suggesties in de buurt uitschakelen', nearby: 'In de buurt', open: 'Openen', close: 'Sluiten', denied: 'Locatie geweigerd' },
}
interface Zone {
section: SectionDTO
latitude: number
longitude: number
radiusMeters: number
}
function distanceMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
const toRad = (deg: number) => (deg * Math.PI) / 180
const dLat = toRad(lat2 - lat1)
const dLng = toRad(lng2 - lng1)
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2
return 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
}
function readSuggestedIds(storageKey: string): string[] {
try {
return JSON.parse(sessionStorage.getItem(storageKey) ?? '[]')
} catch {
return []
}
}
export default function ProximitySuggestion({ sections, slug, configId }: { sections: SectionDTO[]; slug: string; configId: string }) {
const { language } = useVisitor()
const texts = TEXTS[language] ?? TEXTS.FR
const [enabled, setEnabled] = useState(false)
const [suggestion, setSuggestion] = useState<SectionDTO | null>(null)
const [lastClosedAt, setLastClosedAt] = useState(0)
const geo = useGeolocation(enabled)
const storageKey = `mim-nearby-${configId}`
// L'API renvoie latitude/longitude en chaînes : conversion plutôt que cast.
const zones = useMemo<Zone[]>(
() =>
sections.flatMap((section) => {
const latitude = Number(section.latitude)
const longitude = Number(section.longitude)
if (section.latitude == null || section.longitude == null || !Number.isFinite(latitude) || !Number.isFinite(longitude)) return []
return [{ section, latitude, longitude, radiusMeters: section.meterZoneGPS || DEFAULT_RADIUS_METERS }]
}),
[sections]
)
useEffect(() => {
if (geo.lat == null || geo.lng == null || suggestion) return
if (Date.now() - lastClosedAt < COOLDOWN_MS) return
const suggestedIds = readSuggestedIds(storageKey)
const zone = zones.find(
(z) => !suggestedIds.includes(z.section.id) && distanceMeters(geo.lat!, geo.lng!, z.latitude, z.longitude) <= z.radiusMeters
)
if (!zone) return
try {
sessionStorage.setItem(storageKey, JSON.stringify([...suggestedIds, zone.section.id]))
} catch {}
setSuggestion(zone.section)
}, [geo.lat, geo.lng, zones, suggestion, lastClosedAt, storageKey])
if (zones.length === 0) return null
function close() {
setSuggestion(null)
setLastClosedAt(Date.now())
}
return (
<>
<button
onClick={() => setEnabled((e) => !e)}
className={`mim-fab-qr mim-fab-nearby${enabled ? ' is-active' : ''}`}
aria-label={enabled ? texts.disable : texts.enable}
aria-pressed={enabled}
title={geo.status === 'denied' ? texts.denied : undefined}
>
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 8c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm8.94 3A8.994 8.994 0 0 0 13 3.06V1h-2v2.06A8.994 8.994 0 0 0 3.06 11H1v2h2.06A8.994 8.994 0 0 0 11 20.94V23h2v-2.06A8.994 8.994 0 0 0 20.94 13H23v-2h-2.06zM12 19c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7z" />
</svg>
</button>
{suggestion && (
<div
role="dialog"
className="fixed left-4 right-4 bottom-24 z-[1300] mx-auto max-w-md rounded-2xl overflow-hidden"
style={{ background: 'var(--color-surface)', color: 'var(--color-text)', boxShadow: '0 12px 32px -8px rgba(0,0,0,.45)' }}
>
{suggestion.imageSource && (
<img src={suggestion.imageSource} alt={tPlain(suggestion.title, language)} className="w-full h-36 object-cover" />
)}
<div className="p-4 flex flex-col gap-3">
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: 'var(--color-primary)' }}>
{texts.nearby}
</span>
<p className="text-lg font-bold leading-tight">{tPlain(suggestion.title, language)}</p>
<div className="flex justify-end gap-2">
<button onClick={close} className="px-4 py-2 rounded-xl text-sm font-semibold" style={{ color: 'var(--color-text-muted)' }}>
{texts.close}
</button>
<Link
href={`/${slug}/${configId}/sections/${suggestion.id}`}
onClick={close}
className="px-4 py-2 rounded-xl text-sm font-semibold"
style={{ background: 'var(--color-primary)', color: 'var(--color-on-primary)' }}
>
{texts.open}
</Link>
</div>
</div>
</div>
)}
</>
)
}

View File

@ -11,13 +11,15 @@ interface Props {
}
// Parses a QR payload and returns a navigation path within the current app, or null.
// Flutter generates URLs as: https://web.myinfomate.be/{slug}/{configId}/{sectionId}
// Next.js routing expects: /{slug}/{configId}/sections/{sectionId}
// QR formats: https://app.myinfomate.be/download/{instanceId}/{configId}/{sectionId} (manager-app)
// https://web.mymuseum.be/{instanceId}/{configId}/{sectionId} (older printed QR codes)
// Next.js routing expects: /{slug}/{configId}/sections/{sectionId}
function parseQrToPath(payload: string): string | null {
try {
const m = payload.match(/(?:https?:\/\/[^/]+)?(\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*)/)
if (!m?.[1]) return null
const parts = m[1].split('/').filter(Boolean)
let parts = m[1].split('/').filter(Boolean)
if (parts[0] === 'download') parts = parts.slice(1)
// 3 segments without "sections" = Flutter format → rewrite to Next.js route
if (parts.length === 3 && parts[1] !== 'sections') {
return `/${parts[0]}/${parts[1]}/sections/${parts[2]}`

View File

@ -32,6 +32,7 @@ function normalizeSectionDTO(raw: any): SectionDTO {
parentId: raw.parentId,
latitude: raw.latitude,
longitude: raw.longitude,
meterZoneGPS: raw.meterZoneGPS,
isBeacon: raw.isBeacon,
beaconId: raw.beaconId,
}
@ -157,6 +158,12 @@ export async function getInstanceBySlug(slug: string): Promise<ApplicationInstan
}
}
/// Les apps d'une instance, pour la page /download. Endpoint anonyme : le visiteur
/// arrive d'un QR imprimé, sans slug ni clé API.
export async function getApplicationInstances(instanceId: string): Promise<ApplicationInstanceDTO[]> {
return apiFetch<ApplicationInstanceDTO[]>(`/api/ApplicationInstance?instanceId=${encodeURIComponent(instanceId)}`)
}
export interface WeightedConfiguration {
configuration: ConfigurationDTO
order?: number

View File

@ -34,6 +34,14 @@ export interface ApplicationInstanceDTO {
/// Assistant activé sur l'instance. Il doit l'être aussi sur l'app (AppType.Web)
/// pour que la bulle apparaisse.
isAssistant?: boolean
/// Renseigné par la liste des apps d'une instance (GET /api/ApplicationInstance).
appType?: 'Mobile' | 'Tablet' | 'Web' | 'VR' | 'Voice'
appName?: TranslationDTO[]
/// Nom de l'instance, renvoyé par GET /api/ApplicationInstance (lecture seule).
instanceName?: string
isQRCodeEnabled?: boolean
appStoreUrl?: string
playStoreUrl?: string
}
// ── Assistant ──────────────────────────────────────────────────────────────
@ -122,6 +130,8 @@ export interface SectionDTO {
parentId?: string
latitude?: number
longitude?: number
/// Rayon (m) de la zone GPS de la section, réglé dans manager-app.
meterZoneGPS?: number
isBeacon?: boolean
beaconId?: string
// Typed data per section type (fields are flat at root — all sub-DTOs extend SectionDTO)