<?php
namespace App\Controller;
use App\Entity\Instance;
use App\Entity\InstanceView;
use App\Repository\InstanceRepository;
use App\Repository\InstanceViewRepository;
use App\Repository\MediaItemRepository;
use App\Service\PropertyListService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class EmbedController extends AbstractController
{
/** @var HttpClientInterface */
private $httpClient;
public function __construct(HttpClientInterface $httpClient)
{
$this->httpClient = $httpClient;
}
/**
* @Route("/embed/{slug}", name="instance_embed")
*/
public function embed(string $slug, InstanceRepository $instanceRepository, Request $request, InstanceViewRepository $viewRepository, MediaItemRepository $mediaItemRepository, PropertyListService $propertyListService): Response
{
$instance = $instanceRepository->findOneBy(['slug' => $slug]);
if (!$instance) {
throw $this->createNotFoundException('Instance not found.');
}
$defaultView = $this->getDefaultViewForInstance($instance, $viewRepository);
// Handle missing data gracefully - return empty arrays instead of blocking
$propertyData = [];
try {
$propertyData = $this->fetch3DPropertyDetails($instance, $defaultView);
} catch (\Exception $e) {
// If fetch fails, continue with empty data
$propertyData = [];
}
// Basic environment / device information
$userAgent = $request->headers->get('User-Agent', '');
$isMobile = (bool) preg_match('/Mobile|Android|iP(hone|od|ad)|IEMobile|BlackBerry|Opera Mini/i', $userAgent);
// Data derived from the 3D property payload
$properties = $propertyData['properties'] ?? [];
$rooms = array_values($propertyData['chList'] ?? []);
sort($rooms);
$biensList = $propertyListService->build($properties, $request->query->all());
$surfaceMin = $propertyData['surfaceMin'] ?? null;
$surfaceMax = $propertyData['surfaceMax'] ?? null;
$priceMin = $propertyData['priceMin'] ?? null;
$priceMax = $propertyData['priceMax'] ?? null;
// Details are JSON-encoded in the Twig templates for use in JS
$details = [
'properties' => $properties,
'mappedProperties' => $propertyData['mappedProperties'] ?? null,
];
$viewMode = $instance->getViewMode();
// Determine the default view for this instance and build the 3D image dataset
$data3d = $this->buildData3DFromMedia($instance, $defaultView, $mediaItemRepository, $request);
$presentationItems = $mediaItemRepository->findBy(
['instance' => $instance, 'type' => 'presentation'],
['row' => 'ASC', 'id' => 'ASC']
);
// If the default view has an uploaded GLB model, expose its relative path for the front-end
// Normalize any Windows-style backslashes to forward slashes so it can be safely used as a web URL.
$modelPath = null;
if ($defaultView && $defaultView->getGlbPath()) {
$storedGlbPath = $defaultView->getGlbPath();
$modelPath = str_replace('\\', '/', $storedGlbPath);
}
// Generic configuration placeholders for the front-end scripts.
$baseUrl = rtrim((string) $instance->getBaseUrl(), '/');
$view = $defaultView ? (string) $defaultView->getId() : null;
$viewDataUrl = $this->generateUrl('instance_embed_view_data', ['slug' => $slug]);
$baseWeb = '';
$baseWebLanguage = '';
$ajaxDetailUrl = $this->generateUrl('instance_embed_property_detail', ['slug' => $slug]);
$socketUrl = '';
$version = '';
$favorisUrl = '';
$comparateurUrl = '';
$groupeName = $instance->getParentName();
if ($defaultView && $defaultView->getMeshGroupName()) {
$groupeName = $defaultView->getMeshGroupName();
}
if ($viewMode === 'floor') {
// Floor-specific helpers/placeholders used by the Twig/JS layer
$etageMin = null;
$etageMax = null;
$floorsRooms = [];
$categories = [];
$showPoints = '';
$model = $modelPath ?? null;
$icon360 = '';
$views = $viewRepository->findBy(['instance' => $instance], ['sortOrder' => 'ASC', 'id' => 'ASC']);
return $this->render('embed/floor.html.twig', [
'instance' => $instance,
'views' => $views,
'propertyData' => $propertyData,
'properties' => $properties,
'biensList' => $biensList,
'rooms' => $rooms,
'presentationItems' => $presentationItems,
'surfaceMin' => $surfaceMin,
'surfaceMax' => $surfaceMax,
'priceMin' => $priceMin,
'priceMax' => $priceMax,
'etageMin' => $etageMin,
'etageMax' => $etageMax,
'categories' => $categories,
'floorsRooms' => $floorsRooms,
'is_mobile' => $isMobile,
'data3d' => $data3d,
'details' => $details,
'view' => $view,
'viewDataUrl' => $viewDataUrl,
'baseWeb' => $baseWeb,
'baseUrl' => $baseUrl,
'showPoints' => $showPoints,
'baseWebLanguage' => $baseWebLanguage,
'ajaxDetailUrl' => $ajaxDetailUrl,
'socketUrl' => $socketUrl,
'model' => $model,
'icon360' => $icon360,
'groupeName' => $groupeName,
]);
}
return $this->render('embed/normal.html.twig', [
'instance' => $instance,
'propertyData' => $propertyData,
'properties' => $properties,
'biensList' => $biensList,
'rooms' => $rooms,
'presentationItems' => $presentationItems,
'is_mobile' => $isMobile,
'data3d' => $data3d,
'details' => $details,
'view' => $view,
'viewDataUrl' => $viewDataUrl,
'baseWeb' => $baseWeb,
'baseUrl' => $baseUrl,
'baseWebLanguage' => $baseWebLanguage,
'ajaxDetailUrl' => $ajaxDetailUrl,
'socketUrl' => $socketUrl,
'version' => $version,
'favorisUrl' => $favorisUrl,
'comparateurUrl' => $comparateurUrl,
'modelPath' => $modelPath,
'groupeName' => $groupeName,
]);
}
/**
* Returns detailed information for a single property and renders the modal HTML (AJAX).
*
* This proxies the remote /api2/property-details endpoint and wraps the result
* into a small Twig-rendered HTML fragment, to be injected into #property-detail-modal.
*
* @Route("/embed/{slug}/property-detail", name="instance_embed_property_detail", methods={"GET"})
*/
public function propertyDetail(
string $slug,
Request $request,
InstanceRepository $instanceRepository
): Response {
$instance = $instanceRepository->findOneBy(['slug' => $slug]);
if (!$instance) {
throw $this->createNotFoundException('Instance not found.');
}
$id = (int) $request->query->get('id', 0);
if ($id <= 0) {
return $this->json([
'status' => false,
'error' => 'Id not found',
]);
}
$property = $this->fetch3DPropertyDetail($instance, $id);
if (!$property) {
return $this->json([
'status' => false,
'error' => 'Detail not found',
]);
}
// Build a small, safe view model for Twig (avoid repeating array checks there)
$propertyTypeLabel = null;
$propertyType = $property['property_type']['name'] ?? null;
$propertyTypeId = $property['property_type']['id'] ?? null;
if ($propertyTypeId === 12) {
$propertyTypeLabel = 'Bureau';
} else {
if (!empty($property['property_sub_type'][0]['name'])) {
$propertyTypeLabel = $property['property_sub_type'][0]['name'];
} else {
$propertyTypeLabel = $propertyType;
}
}
$view = [
'name' => $property['name'] ?? '',
'propertyTypeLabel' => $propertyTypeLabel,
'statusLabel' => $property['statut']['name'] ?? null,
'surface' => $property['surface_terrain'] ?? null,
'bedrooms' => $property['chambres'] ?? null,
'terraceSurface' => $property['surface_terrasse'] ?? null,
];
$html = $this->renderView('embed/_property_detail_modal.html.twig', [
'instance' => $instance,
'property' => $property,
'view' => $view,
]);
return $this->json([
'status' => true,
'html' => $html,
'plan3d' => null,
'floorsData' => null,
]);
}
/**
* @Route("/embed/{slug}/biens", name="instance_embed_biens_partial", methods={"GET"})
*/
public function biensPartial(string $slug, Request $request, InstanceRepository $instanceRepository, InstanceViewRepository $viewRepository, PropertyListService $propertyListService): Response
{
$instance = $instanceRepository->findOneBy(['slug' => $slug]);
if (!$instance) {
throw $this->createNotFoundException('Instance not found.');
}
$defaultView = $this->getDefaultViewForInstance($instance, $viewRepository);
$propertyData = [];
try {
$propertyData = $this->fetch3DPropertyDetails($instance, $defaultView);
} catch (\Exception $e) {
$propertyData = [];
}
$properties = $propertyData['properties'] ?? [];
$biensList = $propertyListService->build($properties, $request->query->all());
return $this->render('shared/_biens_list.html.twig', [
'filters' => $biensList['filters'] ?? [],
'statsByPieces' => $biensList['statsByPieces'] ?? [],
'groups' => $biensList['groups'] ?? [],
'pagination' => $biensList['pagination'] ?? [],
'basePath' => $this->generateUrl('instance_embed', ['slug' => $instance->getSlug()]),
'ajaxUrl' => $this->generateUrl('instance_embed_biens_partial', ['slug' => $instance->getSlug()]),
'containerId' => 'biens-list-container-embed-' . $instance->getSlug(),
'wrapperClass' => 'p-0',
'theme' => 'light',
'detailUrl' => $this->generateUrl('instance_embed_property_detail', ['slug' => $instance->getSlug()]),
]);
}
/**
* Returns 3D data for a specific view (used by AJAX in the embeds).
*
* @Route("/embed/{slug}/view-data", name="instance_embed_view_data", methods={"GET"})
*/
public function viewData(
string $slug,
Request $request,
InstanceRepository $instanceRepository,
InstanceViewRepository $viewRepository,
MediaItemRepository $mediaItemRepository
): Response {
$instance = $instanceRepository->findOneBy(['slug' => $slug]);
if (!$instance) {
throw $this->createNotFoundException('Instance not found.');
}
$viewId = $request->query->get('view');
$view = null;
if ($viewId !== null && $viewId !== '') {
$view = $viewRepository->findOneBy([
'id' => (int) $viewId,
'instance' => $instance,
]);
}
if (!$view) {
$view = $this->getDefaultViewForInstance($instance, $viewRepository);
}
if (!$view) {
return $this->json([
'model' => null,
'data3d' => [],
'firstImageOriginalWidth' => null,
'firstImageOriginalHeight' => null,
'firstRowImagesCount' => 0,
]);
}
$data3d = $this->buildData3DFromMedia($instance, $view, $mediaItemRepository, $request);
// Build model URL for this view
$modelRel = null;
if ($view->getGlbPath()) {
$storedGlbPath = $view->getGlbPath();
$modelRel = str_replace('\\', '/', $storedGlbPath);
}
$basePath = $request->getBasePath();
$publicPrefix = $basePath === '' ? '/' : rtrim($basePath, '/') . '/';
$model = null;
if ($modelRel) {
$model = $publicPrefix . ltrim($modelRel, '/\\');
}
// Derive first image metadata from the returned data3d
$firstImageOriginalWidth = null;
$firstImageOriginalHeight = null;
$firstRowImagesCount = 0;
if (\is_array($data3d) && isset($data3d[0]) && \is_array($data3d[0]) && isset($data3d[0][0]) && \is_array($data3d[0][0])) {
$firstRow = $data3d[0];
$firstRowImagesCount = \count($firstRow);
$first = $firstRow[0];
if (\array_key_exists('width', $first)) {
$firstImageOriginalWidth = $first['width'];
}
if (\array_key_exists('height', $first)) {
$firstImageOriginalHeight = $first['height'];
}
}
$propertyDetails = [];
try {
$details = $this->fetch3DPropertyDetails($instance, $view);
$propertyDetails = $details['mappedProperties'] ?? [];
} catch (\Exception $e) {
$propertyDetails = [];
}
$debugPayload = null;
if ($request->query->getBoolean('debug')) {
$meshMap = $view->getMeshPropertyMap();
$debugPayload = [
'viewId' => $view->getId(),
'hasMeshMap' => \is_array($meshMap) && $meshMap !== [],
'meshMapCount' => \is_array($meshMap) ? \count($meshMap) : 0,
'meshMapSample' => \is_array($meshMap) ? array_slice($meshMap, 0, 10, true) : null,
'resolvedPropertyDetailsCount' => \is_array($propertyDetails) ? \count($propertyDetails) : 0,
'resolvedPropertyDetailsSampleKeys' => \is_array($propertyDetails) ? array_slice(array_keys($propertyDetails), 0, 10) : null,
];
}
return $this->json([
'model' => $model,
'data3d' => $data3d,
'firstImageOriginalWidth' => $firstImageOriginalWidth,
'firstImageOriginalHeight' => $firstImageOriginalHeight,
'firstRowImagesCount' => $firstRowImagesCount,
'groupeName' => $view->getMeshGroupName() ?: $instance->getParentName(),
'propertyDetails' => $propertyDetails,
'debug' => $debugPayload,
]);
}
private function getDefaultViewForInstance(Instance $instance, InstanceViewRepository $viewRepository): ?InstanceView
{
$views = $viewRepository->findBy(['instance' => $instance], ['id' => 'ASC']);
if (!$views) {
return null;
}
foreach ($views as $view) {
if ($view->isDefault()) {
return $view;
}
}
return $views[0];
}
private function buildData3DFromMedia(Instance $instance, ?InstanceView $view, MediaItemRepository $mediaItemRepository, Request $request): array
{
if (!$view) {
return [];
}
$items = $mediaItemRepository->findBy(
['instance' => $instance, 'view' => $view, 'type' => 'image'],
['row' => 'ASC', 'id' => 'ASC']
);
if (!$items) {
return [];
}
$grouped = [];
$basePath = $request->getBasePath();
$publicPrefix = $basePath === '' ? '/' : rtrim($basePath, '/') . '/';
$toUrl = static function (?string $relative) use ($publicPrefix): ?string {
if (!$relative) {
return null;
}
$relative = ltrim($relative, '/\\');
return $publicPrefix . str_replace('\\', '/', $relative);
};
foreach ($items as $item) {
$variants = $item->getVariants();
if (!\is_array($variants) || $variants === []) {
continue;
}
$low915 = $variants[915] ?? null;
$low1366 = $variants[1366] ?? null;
$good4000 = $variants[4000] ?? null;
$webp915 = \is_array($low915) ? ($low915['webp'] ?? null) : null;
$jpg915 = \is_array($low915) ? ($low915['jpg'] ?? null) : null;
$webp1366 = \is_array($low1366) ? ($low1366['webp'] ?? null) : null;
$jpg1366 = \is_array($low1366) ? ($low1366['jpg'] ?? null) : null;
$webp4000 = \is_array($good4000) ? ($good4000['webp'] ?? null) : null;
$jpg4000 = \is_array($good4000) ? ($good4000['jpg'] ?? null) : null;
$imageWebp = $webp915 ?? $webp1366 ?? $webp4000;
$imageJpg = $jpg915 ?? $jpg1366 ?? $jpg4000;
if (!$imageWebp && !$imageJpg) {
continue;
}
$row = $item->getRow();
if ($row <= 0) {
$row = 1;
}
$rowIndex = $row - 1;
$width = null;
$height = null;
$sizeSourceRel = $webp4000 ?? $jpg4000 ?? $webp1366 ?? $jpg1366 ?? $imageWebp ?? $imageJpg;
if ($sizeSourceRel) {
$absolute = $this->getParameter('kernel.project_dir')
. DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR
. ltrim($sizeSourceRel, '/\\');
if (is_file($absolute)) {
$sizeInfo = @getimagesize($absolute);
if (\is_array($sizeInfo)) {
$width = $sizeInfo[0] ?? null;
$height = $sizeInfo[1] ?? null;
}
}
}
$entry = [
'image' => $toUrl($imageWebp),
'image1366' => $toUrl($webp1366),
'image915' => $toUrl($webp915),
'bigImage' => $toUrl($webp4000),
'imageJpeg' => $toUrl($imageJpg),
'imageJpeg1366' => $toUrl($jpg1366),
'imageJpeg915' => $toUrl($jpg915),
'bigImageJpeg' => $toUrl($jpg4000),
'floor' => (string) ($view->getName() ?? ''),
'width' => $width,
'height' => $height,
'type' => 18,
'attr' => ($width && $height) ? sprintf('width="%d" height="%d"', $width, $height) : '',
'default' => false,
];
$grouped[$rowIndex][] = $entry;
}
if ($grouped === []) {
return [];
}
ksort($grouped);
$data3d = [];
foreach ($grouped as $rowEntries) {
$data3d[] = array_values($rowEntries);
}
if (isset($data3d[0][0])) {
$data3d[0][0]['default'] = true;
}
return $data3d;
}
private function fetch3DPropertyDetail(Instance $instance, int $id): ?array
{
$baseUrl = rtrim((string) $instance->getBaseUrl(), '/');
$token = $instance->getToken();
// Return null if essential data is missing
if ($baseUrl === '' || !$token) {
return null;
}
$url = sprintf('%s/api2/property-details', $baseUrl);
$response = $this->httpClient->request('GET', $url, [
'query' => ['id' => $id],
'headers' => [
'cache-control' => 'no-cache',
'Content-Type' => 'application/x-www-form-urlencoded; charset=utf-8',
'x-auth-token' => $token,
],
'max_redirects' => 10,
'timeout' => 30,
]);
if ($response->getStatusCode() !== 200) {
return null;
}
$raw = $response->getContent(false);
$decoded = json_decode(utf8_decode($raw), true);
if (!\is_array($decoded) || $decoded === [] || isset($decoded['msg'])) {
return null;
}
return $decoded;
}
private function fetch3DPropertyDetails(Instance $instance, ?InstanceView $view = null): array
{
$baseUrl = rtrim((string) $instance->getBaseUrl(), '/');
$projectId = $instance->getProjectId();
$token = $instance->getToken();
// Return empty array if essential data is missing
if ($baseUrl === '' || !$projectId || !$token) {
return [
'properties' => [],
'surfaceMin' => null,
'surfaceMax' => null,
'priceMin' => null,
'priceMax' => null,
'chList' => [],
'statusList' => [],
'orientations' => [],
];
}
$url = sprintf('%s/api2/%s/draw-property-list-by-project', $baseUrl, $projectId);
$response = $this->httpClient->request('GET', $url, [
'query' => ['hasCoords' => false],
'headers' => [
'cache-control' => 'no-cache',
'Content-Type' => 'application/x-www-form-urlencoded; charset=utf-8',
'x-auth-token' => $token,
],
'max_redirects' => 10,
'timeout' => 30,
]);
if ($response->getStatusCode() !== 200) {
return [
'properties' => [],
'surfaceMin' => null,
'surfaceMax' => null,
'priceMin' => null,
'priceMax' => null,
'chList' => [],
'statusList' => [],
'orientations' => [],
];
}
$raw = $response->getContent(false);
$decoded = json_decode(utf8_decode($raw), true);
if (!\is_array($decoded)) {
return [
'properties' => [],
'surfaceMin' => null,
'surfaceMax' => null,
'priceMin' => null,
'priceMax' => null,
'chList' => [],
'statusList' => [],
'orientations' => [],
];
}
$surfaceMin = null;
$surfaceMax = null;
$priceMin = null;
$priceMax = null;
$statusList = [];
$chList = [];
$newProperties = [];
$orientations = [];
foreach ($decoded as $property) {
if (!isset($property['info']['title'])) {
continue;
}
$info = $property['info'];
$name = $info['title'];
$status = $info['statut'] ?? null;
$price = isset($info['price']) ? (float) $info['price'] : 0.0;
$surface = isset($info['surface_terrain']) ? (float) $info['surface_terrain'] : 0.0;
$pieces = $info['pieces'] ?? null;
$orientation = $info['orientation'] ?? null;
if ($surfaceMin === null || $surface < $surfaceMin) {
$surfaceMin = $surface;
}
if ($surfaceMax === null || $surface > $surfaceMax) {
$surfaceMax = $surface;
}
if ($priceMin === null || $price < $priceMin) {
$priceMin = $price;
}
if ($priceMax === null || $price > $priceMax) {
$priceMax = $price;
}
if ($pieces !== null) {
$chList[$pieces] = $pieces;
}
$statusTag = $status !== null ? (string) $status : '';
$property['info']['statutTag'] = $statusTag;
if ($status !== null) {
$statusList[$status] = $statusTag;
}
$orientationLabel = null;
if ($orientation === 1) {
$orientationLabel = 'Nord';
} elseif ($orientation === 2) {
$orientationLabel = 'Ouest';
} elseif ($orientation === 3) {
$orientationLabel = 'Est';
} elseif ($orientation === 4) {
$orientationLabel = 'Sud';
}
if ($orientationLabel !== null && $orientation !== null) {
$orientations[$orientation] = [
'id' => $orientation,
'label' => $orientationLabel,
];
}
$slugTitle = $name;
$slugTitle = str_replace('°', '', $slugTitle);
$slugTitle = strtolower($slugTitle);
$slugTitle = str_replace('boutique -', 'boutique-', $slugTitle);
if (preg_match('/^p\.b /', $slugTitle)) {
$slugTitle = str_replace('p.b ', 'pb-', $slugTitle);
}
if ($status == 2) {
$color = '#99BEB2';
if($pieces == 2){
$color = '#397B65';
} elseif($pieces == 3){
$color = '#395D7B';
} elseif($pieces == 4){
$color = '#1b7a0c';
} elseif($pieces == 5){
$color = '#4C2D77';
}
if (isset($property['info']['property_type_id']) && intval($property['info']['property_type_id']) == 12) {
if(in_array('25', $property['info']['property_sub_type_idx'])){
$color = '#107252';
} else {
$color = '#99BEB2';
}
}
} else {
$color = '#B71818';
}
$newProperties[$slugTitle] = [
'color'=>$color,
'infos'=>$property
];
}
$allProperties = $newProperties;
$normalizeSlugTitle = static function (?string $value): string {
$value = trim((string) ($value ?? ''));
if ($value === '') {
return '';
}
$value = str_replace('°', '', $value);
$value = strtolower($value);
$value = str_replace('boutique -', 'boutique-', $value);
if (preg_match('/^p\.b /', $value)) {
$value = str_replace('p.b ', 'pb-', $value);
}
return $value;
};
$mappedProperties = null;
if ($view && $view->getMeshPropertyMap()) {
$map = $view->getMeshPropertyMap();
if (\is_array($map) && $map !== []) {
$mapped = [];
foreach ($map as $meshName => $slugTitle) {
$meshName = trim((string) $meshName);
$rawKey = trim((string) ($slugTitle ?? ''));
$normalizedKey = $normalizeSlugTitle($rawKey);
if ($meshName === '' || ($rawKey === '' && $normalizedKey === '')) {
continue;
}
$entry = null;
if ($rawKey !== '' && isset($allProperties[$rawKey])) {
$entry = $allProperties[$rawKey];
} elseif ($normalizedKey !== '' && isset($allProperties[$normalizedKey])) {
$entry = $allProperties[$normalizedKey];
}
if ($entry === null) {
continue;
}
$mapped[$meshName] = $entry;
}
$mappedProperties = $mapped;
}
}
ksort($chList);
ksort($statusList);
return [
'properties' => $allProperties,
'mappedProperties' => $mappedProperties,
'surfaceMin' => $surfaceMin !== null ? (int) floor($surfaceMin) : null,
'surfaceMax' => $surfaceMax !== null ? (int) floor($surfaceMax) : null,
'priceMin' => $priceMin !== null ? (int) floor($priceMin) : null,
'priceMax' => $priceMax !== null ? (int) floor($priceMax) : null,
'chList' => $chList,
'statusList' => $statusList,
'orientations' => $orientations,
];
}
}