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)