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:
parent
e2a1c97c00
commit
7ff72345b3
@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
147
src/app/download/[instanceId]/[[...rest]]/page.tsx
Normal file
147
src/app/download/[instanceId]/[[...rest]]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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); }
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
|
||||
137
src/components/ProximitySuggestion.tsx
Normal file
137
src/components/ProximitySuggestion.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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}
|
||||
// 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]}`
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user