From 7ff72345b365141fef662705b1fd5415bba52445 Mon Sep 17 00:00:00 2001 From: Thomas Fransolet Date: Thu, 10 Sep 2026 11:42:24 +0200 Subject: [PATCH] Page /download, nom d'app, scan QR conditionnel et suggestions GPS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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) --- src/app/[slug]/[configId]/page.tsx | 4 +- src/app/[slug]/layout.tsx | 12 ++ src/app/[slug]/page.tsx | 6 +- .../[instanceId]/[[...rest]]/page.tsx | 147 ++++++++++++++++++ src/app/globals.css | 5 + src/components/HomeHero.tsx | 17 +- src/components/ProximitySuggestion.tsx | 137 ++++++++++++++++ src/components/QRScannerButton.tsx | 8 +- src/lib/api/client.ts | 7 + src/lib/api/types.ts | 10 ++ 10 files changed, 344 insertions(+), 9 deletions(-) create mode 100644 src/app/download/[instanceId]/[[...rest]]/page.tsx create mode 100644 src/components/ProximitySuggestion.tsx diff --git a/src/app/[slug]/[configId]/page.tsx b/src/app/[slug]/[configId]/page.tsx index 0991000..9cdcae0 100644 --- a/src/app/[slug]/[configId]/page.tsx +++ b/src/app/[slug]/[configId]/page.tsx @@ -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']} /> - + {instance.isQRCodeEnabled !== false && } + ) } diff --git a/src/app/[slug]/layout.tsx b/src/app/[slug]/layout.tsx index b989196..a938691 100644 --- a/src/app/[slug]/layout.tsx +++ b/src/app/[slug]/layout.tsx @@ -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 { + 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, diff --git a/src/app/[slug]/page.tsx b/src/app/[slug]/page.tsx index c4d26a5..b9209f6 100644 --- a/src/app/[slug]/page.tsx +++ b/src/app/[slug]/page.tsx @@ -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) && ( @@ -39,7 +41,7 @@ export default async function HomePage({ instancePrimaryColor={instance.primaryColor} instanceSecondaryColor={instance.secondaryColor} /> - + {instance.isQRCodeEnabled !== false && } ) } diff --git a/src/app/download/[instanceId]/[[...rest]]/page.tsx b/src/app/download/[instanceId]/[[...rest]]/page.tsx new file mode 100644 index 0000000..3f56afe --- /dev/null +++ b/src/app/download/[instanceId]/[[...rest]]/page.tsx @@ -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 = { + 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 { + 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 { + 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 ( +
+
+ {mobileApp.mainImageUrl ? ( + {title} + ) : ( +
+ )} +
+

+ {title} +

+
+ +
+

{texts.intro}

+ + {hasStoreLink ? ( + <> +

{texts.download}

+
+ {mobileApp.appStoreUrl && ( + + App Store + + )} + {mobileApp.playStoreUrl && ( + + Google Play + + )} +
+

{texts.afterInstall}

+ + ) : ( +

{texts.comingSoon}

+ )} +
+
+ ) +} diff --git a/src/app/globals.css b/src/app/globals.css index 1450d79..e20cb7d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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); } diff --git a/src/components/HomeHero.tsx b/src/components/HomeHero.tsx index 1938218..1a02d4d 100644 --- a/src/components/HomeHero.tsx +++ b/src/components/HomeHero.tsx @@ -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(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 ? ( {featuredEvent )} + + {!featuredEvent && appTitle && ( +

+ {appTitle} +

+ )} ) diff --git a/src/components/ProximitySuggestion.tsx b/src/components/ProximitySuggestion.tsx new file mode 100644 index 0000000..5dd3ca5 --- /dev/null +++ b/src/components/ProximitySuggestion.tsx @@ -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 = { + 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(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( + () => + 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 ( + <> + + + {suggestion && ( +
+ {suggestion.imageSource && ( + {tPlain(suggestion.title, + )} +
+ + {texts.nearby} + +

{tPlain(suggestion.title, language)}

+
+ + + {texts.open} + +
+
+
+ )} + + ) +} diff --git a/src/components/QRScannerButton.tsx b/src/components/QRScannerButton.tsx index d7cd0e2..eaed3c0 100644 --- a/src/components/QRScannerButton.tsx +++ b/src/components/QRScannerButton.tsx @@ -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]}` diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 4d7d5c4..79793aa 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -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 { + return apiFetch(`/api/ApplicationInstance?instanceId=${encodeURIComponent(instanceId)}`) +} + export interface WeightedConfiguration { configuration: ConfigurationDTO order?: number diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index df5eedd..9ac05a0 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -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)