eduardo4547 commited on
Commit
efedc31
Β·
verified Β·
1 Parent(s): 3d232a4

Upload 214 files

Browse files
.gitattributes CHANGED
@@ -149,3 +149,7 @@ backend/uploads/demo-room.jpg filter=lfs diff=lfs merge=lfs -text
149
  backend/uploads/proyecto-White-Houses_edit_d4a831c5_edit_c5a95477.jpg filter=lfs diff=lfs merge=lfs -text
150
  backend/uploads/demo-room_edit_3755debd_edit_cdb044f5_edit_cc649bbc.jpg filter=lfs diff=lfs merge=lfs -text
151
  backend/uploads/demo-room_edit_3755debd_edit_cdb044f5.jpg filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
149
  backend/uploads/proyecto-White-Houses_edit_d4a831c5_edit_c5a95477.jpg filter=lfs diff=lfs merge=lfs -text
150
  backend/uploads/demo-room_edit_3755debd_edit_cdb044f5_edit_cc649bbc.jpg filter=lfs diff=lfs merge=lfs -text
151
  backend/uploads/demo-room_edit_3755debd_edit_cdb044f5.jpg filter=lfs diff=lfs merge=lfs -text
152
+ backend/texturas/Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_claro.png filter=lfs diff=lfs merge=lfs -text
153
+ backend/texturas/Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_gris.png filter=lfs diff=lfs merge=lfs -text
154
+ backend/texturas/Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_oscuro.png filter=lfs diff=lfs merge=lfs -text
155
+ backend/texturas/Texture_WPC_EXTERIOR_INTERIOR/WPC_negro.png filter=lfs diff=lfs merge=lfs -text
backend/.env CHANGED
@@ -5,3 +5,4 @@ GRADIO_SPACE_URL=https://eduardo4547-hyper-reality-sam2-gpu.hf.space
5
  GRADIO_CPU_FALLBACK_URL=https://eduardo4547-hyper-reality-sam2-cpu.hf.space
6
  # Para desarrollo local:
7
  # GRADIO_SPACE_URL=http://localhost:7860
 
 
5
  GRADIO_CPU_FALLBACK_URL=https://eduardo4547-hyper-reality-sam2-cpu.hf.space
6
  # Para desarrollo local:
7
  # GRADIO_SPACE_URL=http://localhost:7860
8
+ # https://eduardo4547-hyper-reality-sam2-gpu.hf.space
backend/__pycache__/main.cpython-312.pyc CHANGED
Binary files a/backend/__pycache__/main.cpython-312.pyc and b/backend/__pycache__/main.cpython-312.pyc differ
 
backend/logs/app.log CHANGED
The diff for this file is too large to render. See raw diff
 
backend/main.py CHANGED
@@ -1,4 +1,5 @@
1
  import mimetypes
 
2
  import subprocess
3
  import threading
4
  import time
@@ -12,7 +13,8 @@ from fastapi.middleware.cors import CORSMiddleware
12
  from fastapi.staticfiles import StaticFiles
13
 
14
  from core.config import GRADIO_SPACE_URL, logger
15
- from routers import auth, catalog, media, pages, segmentation, share
 
16
  from services.sam2_service import lifespan
17
 
18
  mimetypes.add_type("application/javascript", ".js", strict=True)
@@ -27,7 +29,7 @@ app.add_middleware(
27
  CORSMiddleware,
28
  allow_origins=["*"],
29
  allow_credentials=True,
30
- allow_methods=["GET", "POST", "OPTIONS"],
31
  allow_headers=["*"],
32
  )
33
 
@@ -47,6 +49,7 @@ app.include_router(auth.router)
47
  app.include_router(share.router)
48
  app.include_router(media.router)
49
  app.include_router(catalog.router)
 
50
  app.include_router(segmentation.router)
51
 
52
  # Static files
@@ -105,6 +108,15 @@ def watch_frontend_changes(interval: float = 2.0) -> None:
105
  last_state = current_state
106
 
107
 
 
 
 
 
 
 
 
 
 
108
  @app.on_event("startup")
109
  async def startup_watch_frontend():
110
  thread = threading.Thread(target=watch_frontend_changes, daemon=True)
 
1
  import mimetypes
2
+ import os
3
  import subprocess
4
  import threading
5
  import time
 
13
  from fastapi.staticfiles import StaticFiles
14
 
15
  from core.config import GRADIO_SPACE_URL, logger
16
+ from routers import auth, catalog, media, pages, segmentation, sessions, share
17
+ from routers.catalog import seed_catalog
18
  from services.sam2_service import lifespan
19
 
20
  mimetypes.add_type("application/javascript", ".js", strict=True)
 
29
  CORSMiddleware,
30
  allow_origins=["*"],
31
  allow_credentials=True,
32
+ allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
33
  allow_headers=["*"],
34
  )
35
 
 
49
  app.include_router(share.router)
50
  app.include_router(media.router)
51
  app.include_router(catalog.router)
52
+ app.include_router(sessions.router)
53
  app.include_router(segmentation.router)
54
 
55
  # Static files
 
108
  last_state = current_state
109
 
110
 
111
+ @app.on_event("startup")
112
+ async def startup_seed_catalog():
113
+ if MONGODB_URI := os.getenv("MONGODB_URI", ""):
114
+ try:
115
+ await seed_catalog()
116
+ except Exception as exc:
117
+ logger.warning("[STARTUP] seed_catalog fallΓ³: %s", exc)
118
+
119
+
120
  @app.on_event("startup")
121
  async def startup_watch_frontend():
122
  thread = threading.Thread(target=watch_frontend_changes, daemon=True)
backend/routers/__pycache__/catalog.cpython-312.pyc CHANGED
Binary files a/backend/routers/__pycache__/catalog.cpython-312.pyc and b/backend/routers/__pycache__/catalog.cpython-312.pyc differ
 
backend/routers/__pycache__/pages.cpython-312.pyc CHANGED
Binary files a/backend/routers/__pycache__/pages.cpython-312.pyc and b/backend/routers/__pycache__/pages.cpython-312.pyc differ
 
backend/routers/__pycache__/sessions.cpython-312.pyc ADDED
Binary file (5.23 kB). View file
 
backend/routers/catalog.py CHANGED
@@ -1,150 +1,221 @@
 
 
 
1
  from fastapi import APIRouter
2
  from fastapi.responses import JSONResponse
 
 
3
 
4
  router = APIRouter(prefix="/api/catalog")
5
 
6
- _CATALOG = [
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  {
8
- "id": "acm",
9
  "nombre": "ACM (Aluminio Compuesto)",
 
10
  "descripcion": "Paneles de aluminio compuesto para fachadas y exteriores",
11
  "especificaciones": [
12
  "Espesor de ACM 4mm.",
13
  "Medida 1.22m x 2.44m",
14
- "FΓ‘cil Mantenimiento.",
15
  "Espesor de Aluminio 0.40mm.",
16
  "Se puede doblar o biselar",
17
  ],
18
  "url_detalle": "https://heyzine.com/flip-book/447fe3eb8e.html#page/16",
19
  "productos": [
20
- {
21
- "id": "acm_white",
22
- "nombre": "Glossy White",
23
- "textura": "Texture_ACM/ACM_White.png",
24
- "url_preview": "/seg/texture-preview/Texture_ACM/ACM_White.png",
25
- "dimensiones": ["1.22x2.44", "1.50x4.98"],
26
- },
27
- {
28
- "id": "acm_amarillo",
29
- "nombre": "Amarillo",
30
- "textura": "Texture_ACM/ACM_Amarillo.png",
31
- "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Amarillo.png",
32
- "dimensiones": ["1.22x2.44", "1.50x4.98"],
33
- },
34
- {
35
- "id": "acm_orange",
36
- "nombre": "Glossy Orange",
37
- "textura": "Texture_ACM/ACM_Orange.png",
38
- "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Orange.png",
39
- "dimensiones": ["1.22x2.44", "1.50x4.98"],
40
- },
41
- {
42
- "id": "acm_red",
43
- "nombre": "Glossy Red",
44
- "textura": "Texture_ACM/ACM_Glossy_Red.png",
45
- "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Glossy_Red.png",
46
- "dimensiones": ["1.22x2.44", "1.50x4.98"],
47
- },
48
- {
49
- "id": "acm_light_blue",
50
- "nombre": "Light Blue",
51
- "textura": "Texture_ACM/ACM_Light_Blue.png",
52
- "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Light_Blue.png",
53
- "dimensiones": ["1.22x2.44"],
54
- },
55
- {
56
- "id": "acm_azul",
57
- "nombre": "Azul",
58
- "textura": "Texture_ACM/ACM_Azul.png",
59
- "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Azul.png",
60
- "dimensiones": ["1.22x2.44", "1.50x4.98"],
61
- },
62
- {
63
- "id": "acm_verde_hn",
64
- "nombre": "Verde HN",
65
- "textura": "Texture_ACM/ACM_Verde_HN.png",
66
- "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Verde_HN.png",
67
- "dimensiones": ["1.22x2.44"],
68
- },
69
- {
70
- "id": "acm_verde_lima",
71
- "nombre": "Verde Lima",
72
- "textura": "Texture_ACM/ACM_Verde_Lima.png",
73
- "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Verde_Lima.png",
74
- "dimensiones": ["1.22x2.44", "1.50x4.98"],
75
- },
76
- {
77
- "id": "acm_verde",
78
- "nombre": "Verde",
79
- "textura": "Texture_ACM/ACM_Verde.png",
80
- "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Verde.png",
81
- "dimensiones": ["1.22x2.44", "1.50x4.98"],
82
- },
83
- {
84
- "id": "acm_madera_clara",
85
- "nombre": "Madera Clara",
86
- "textura": "Texture_ACM/ACM_Madera_Clara.png",
87
- "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Madera_Clara.png",
88
- "dimensiones": ["1.22x2.44"],
89
- },
90
- {
91
- "id": "acm_roble",
92
- "nombre": "Roble (Oak)",
93
- "textura": "Texture_ACM/ACM_ROBLE(OAK).png",
94
- "url_preview": "/seg/texture-preview/Texture_ACM/ACM_ROBLE(OAK).png",
95
- "dimensiones": ["1.22x2.44"],
96
- },
97
- {
98
- "id": "acm_grafito",
99
- "nombre": "Grafito",
100
- "textura": "Texture_ACM/ACM_Grafito.png",
101
- "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Grafito.png",
102
- "dimensiones": ["1.22x2.44", "1.50x4.98"],
103
- },
104
- {
105
- "id": "acm_metalic",
106
- "nombre": "Silver Metallic",
107
- "textura": "Texture_ACM/ACM_Metalic.png",
108
- "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Metalic.png",
109
- "dimensiones": ["1.22x2.44", "1.50x4.98"],
110
- },
111
- {
112
- "id": "acm_mouse_grey",
113
- "nombre": "Mouse Grey",
114
- "textura": "Texture_ACM/ACM_MouseGrey.png",
115
- "url_preview": "/seg/texture-preview/Texture_ACM/ACM_MouseGrey.png",
116
- "dimensiones": ["1.22x2.44", "1.50x4.98"],
117
- },
118
- {
119
- "id": "acm_matte_black",
120
- "nombre": "Matte Black",
121
- "textura": "Texture_ACM/ACM_Matteblack.png",
122
- "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Matteblack.png",
123
- "dimensiones": ["1.22x2.44", "1.50x4.98"],
124
- },
125
- {
126
- "id": "acm_glossy_black",
127
- "nombre": "Glossy Black",
128
- "textura": "Texture_ACM/ACM_Glossy_Black.png",
129
- "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Glossy_Black.png",
130
- "dimensiones": ["1.22x2.44", "1.50x4.98"],
131
- },
132
  ],
 
133
  },
134
  ]
135
 
136
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  @router.get("/textures")
138
  async def get_texture_catalog() -> JSONResponse:
139
- return JSONResponse(content={"categories": _CATALOG})
 
 
 
 
 
 
 
 
140
 
141
 
142
  @router.get("/textures/{category_id}")
143
  async def get_texture_category(category_id: str) -> JSONResponse:
144
- category = next((c for c in _CATALOG if c["id"] == category_id), None)
145
- if category is None:
146
- return JSONResponse(
147
- content={"detail": f"CategorΓ­a '{category_id}' no encontrada"},
148
- status_code=404,
149
- )
150
- return JSONResponse(content=category)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from datetime import datetime
3
+
4
  from fastapi import APIRouter
5
  from fastapi.responses import JSONResponse
6
+ from motor.motor_asyncio import AsyncIOMotorClient
7
+ from pydantic import BaseModel
8
 
9
  router = APIRouter(prefix="/api/catalog")
10
 
11
+ MONGODB_URI = os.getenv("MONGODB_URI", "")
12
+ _client: AsyncIOMotorClient | None = None
13
+ _db = None
14
+ _col = None
15
+
16
+
17
+ def _get_col():
18
+ global _client, _db, _col
19
+ if _col is None:
20
+ if not MONGODB_URI:
21
+ raise RuntimeError("MONGODB_URI no configurado")
22
+ _client = AsyncIOMotorClient(MONGODB_URI)
23
+ _db = _client["hyper_reality"]
24
+ _col = _db["catalog"]
25
+ return _col
26
+
27
+
28
+ # ── Datos iniciales (se insertan solo si la colecciΓ³n estΓ‘ vacΓ­a) ─────────────
29
+ _SEED = [
30
  {
31
+ "_id": "acm",
32
  "nombre": "ACM (Aluminio Compuesto)",
33
+ "tipo": "paredes",
34
  "descripcion": "Paneles de aluminio compuesto para fachadas y exteriores",
35
  "especificaciones": [
36
  "Espesor de ACM 4mm.",
37
  "Medida 1.22m x 2.44m",
38
+ "Facil Mantenimiento.",
39
  "Espesor de Aluminio 0.40mm.",
40
  "Se puede doblar o biselar",
41
  ],
42
  "url_detalle": "https://heyzine.com/flip-book/447fe3eb8e.html#page/16",
43
  "productos": [
44
+ {"id": "acm_white", "nombre": "Glossy White", "textura": "Texture_ACM/ACM_White.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_White.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
45
+ {"id": "acm_amarillo", "nombre": "Amarillo", "textura": "Texture_ACM/ACM_Amarillo.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Amarillo.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
46
+ {"id": "acm_orange", "nombre": "Glossy Orange", "textura": "Texture_ACM/ACM_Orange.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Orange.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
47
+ {"id": "acm_red", "nombre": "Glossy Red", "textura": "Texture_ACM/ACM_Glossy_Red.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Glossy_Red.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
48
+ {"id": "acm_light_blue", "nombre": "Light Blue", "textura": "Texture_ACM/ACM_Light_Blue.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Light_Blue.png", "dimensiones": ["1.22x2.44"]},
49
+ {"id": "acm_azul", "nombre": "Azul", "textura": "Texture_ACM/ACM_Azul.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Azul.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
50
+ {"id": "acm_verde_hn", "nombre": "Verde HN", "textura": "Texture_ACM/ACM_Verde_HN.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Verde_HN.png", "dimensiones": ["1.22x2.44"]},
51
+ {"id": "acm_verde_lima", "nombre": "Verde Lima", "textura": "Texture_ACM/ACM_Verde_Lima.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Verde_Lima.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
52
+ {"id": "acm_verde", "nombre": "Verde", "textura": "Texture_ACM/ACM_Verde.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Verde.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
53
+ {"id": "acm_madera_clara","nombre": "Madera Clara", "textura": "Texture_ACM/ACM_Madera_Clara.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Madera_Clara.png", "dimensiones": ["1.22x2.44"]},
54
+ {"id": "acm_roble", "nombre": "Roble (Oak)", "textura": "Texture_ACM/ACM_ROBLE(OAK).png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_ROBLE(OAK).png", "dimensiones": ["1.22x2.44"]},
55
+ {"id": "acm_grafito", "nombre": "Grafito", "textura": "Texture_ACM/ACM_Grafito.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Grafito.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
56
+ {"id": "acm_metalic", "nombre": "Silver Metallic", "textura": "Texture_ACM/ACM_Metalic.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Metalic.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
57
+ {"id": "acm_mouse_grey", "nombre": "Mouse Grey", "textura": "Texture_ACM/ACM_MouseGrey.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_MouseGrey.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
58
+ {"id": "acm_matte_black", "nombre": "Matte Black", "textura": "Texture_ACM/ACM_Matteblack.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Matteblack.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
59
+ {"id": "acm_glossy_black","nombre": "Glossy Black", "textura": "Texture_ACM/ACM_Glossy_Black.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Glossy_Black.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
60
+ ],
61
+ "created_at": "2026-04-20T00:00:00Z",
62
+ },
63
+ {
64
+ "_id": "wpc",
65
+ "nombre": "WPC (Exterior e Interior)",
66
+ "tipo": "paredes",
67
+ "descripcion": "Los paneles de WPC se utilizan como revestimiento decorativo para paredes. No se deforma, no requiere mantenimiento constante y tiene mayor durabilidad. Crea mayor estetica e instalacion rapida.",
68
+ "especificaciones": [
69
+ "Revestimiento decorativo para paredes.",
70
+ "No se deforma ni requiere mantenimiento constante.",
71
+ "Mayor durabilidad y estetica.",
72
+ "Instalacion rapida.",
73
+ "Ideal para: sala principal (pared protagonista), comedor, interior de oficina, pasillo o entradas.",
74
+ ],
75
+ "url_detalle": "https://heyzine.com/flip-book/447fe3eb8e.html#page/39",
76
+ "productos": [
77
+ {"id": "WPC_madera_oscuro", "nombre": "WPC Madera Oscuro", "textura": "Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_oscuro.png", "url_preview": "/seg/texture-preview/Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_oscuro.png", "dimensiones": ["2.90x0.25"]},
78
+ {"id": "WPC_madera_claro", "nombre": "WPC Madera Claro", "textura": "Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_claro.png", "url_preview": "/seg/texture-preview/Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_claro.png", "dimensiones": ["2.90x0.25"]},
79
+ {"id": "WPC_madera_gris", "nombre": "WPC Madera Gris", "textura": "Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_gris.png", "url_preview": "/seg/texture-preview/Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_gris.png", "dimensiones": ["2.90x0.25"]},
80
+ {"id": "WPC_negro", "nombre": "WPC Negro", "textura": "Texture_WPC_EXTERIOR_INTERIOR/WPC_negro.png", "url_preview": "/seg/texture-preview/Texture_WPC_EXTERIOR_INTERIOR/WPC_negro.png", "dimensiones": ["2.90x0.25"]},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  ],
82
+ "created_at": "2026-05-07T00:00:00Z",
83
  },
84
  ]
85
 
86
 
87
+ async def seed_catalog() -> None:
88
+ col = _get_col()
89
+ count = await col.count_documents({})
90
+ if count == 0:
91
+ await col.insert_many(_SEED)
92
+ return
93
+
94
+ # Migrar documentos existentes: aΓ±adir campos que falten segΓΊn _SEED
95
+ for seed_item in _SEED:
96
+ doc = await col.find_one({"_id": seed_item["_id"]})
97
+ if not doc:
98
+ continue
99
+ patch = {k: v for k, v in seed_item.items() if k not in doc and k != "_id"}
100
+ if patch:
101
+ await col.update_one({"_id": seed_item["_id"]}, {"$set": patch})
102
+
103
+
104
+ def _serialize(doc: dict) -> dict:
105
+ out = dict(doc)
106
+ out["id"] = str(out.pop("_id"))
107
+ return out
108
+
109
+
110
+ def _seed_as_response() -> list[dict]:
111
+ return [{**{k: v for k, v in item.items() if k != "_id"}, "id": item["_id"]} for item in _SEED]
112
+
113
+
114
+ # ── Endpoints de lectura ──────────────────────────────────────────────────────
115
+
116
  @router.get("/textures")
117
  async def get_texture_catalog() -> JSONResponse:
118
+ try:
119
+ col = _get_col()
120
+ docs = await col.find({}).to_list(length=200)
121
+ if docs:
122
+ return JSONResponse(content={"categories": [_serialize(d) for d in docs]})
123
+ except Exception:
124
+ pass
125
+ # Fallback a datos estΓ‘ticos si MongoDB no estΓ‘ disponible o la colecciΓ³n estΓ‘ vacΓ­a
126
+ return JSONResponse(content={"categories": _seed_as_response()})
127
 
128
 
129
  @router.get("/textures/{category_id}")
130
  async def get_texture_category(category_id: str) -> JSONResponse:
131
+ try:
132
+ col = _get_col()
133
+ doc = await col.find_one({"_id": category_id})
134
+ if doc:
135
+ return JSONResponse(content=_serialize(doc))
136
+ except Exception:
137
+ pass
138
+ fallback = next((item for item in _SEED if item["_id"] == category_id), None)
139
+ if fallback:
140
+ return JSONResponse(content=_seed_as_response()[_SEED.index(fallback)])
141
+ return JSONResponse(content={"detail": f"Categoria '{category_id}' no encontrada"}, status_code=404)
142
+
143
+
144
+ # ── Modelos ───────────────────────────────────────────────────────────────────
145
+
146
+ class ProductoItem(BaseModel):
147
+ id: str
148
+ nombre: str
149
+ textura: str
150
+ url_preview: str
151
+ dimensiones: list[str] = []
152
+
153
+
154
+ class CategoriaBody(BaseModel):
155
+ id: str
156
+ nombre: str
157
+ tipo: str = "paredes"
158
+ descripcion: str = ""
159
+ especificaciones: list[str] = []
160
+ url_detalle: str = ""
161
+ productos: list[ProductoItem] = []
162
+
163
+
164
+ # ── Endpoints de escritura ──────────────────────────────────────────────���─────
165
+
166
+ @router.post("/category")
167
+ async def add_category(body: CategoriaBody) -> JSONResponse:
168
+ col = _get_col()
169
+ existing = await col.find_one({"_id": body.id})
170
+ if existing:
171
+ return JSONResponse(content={"error": f"Categoria '{body.id}' ya existe"}, status_code=409)
172
+ doc = body.model_dump()
173
+ doc["_id"] = doc.pop("id")
174
+ doc["created_at"] = datetime.utcnow().isoformat() + "Z"
175
+ await col.insert_one(doc)
176
+ return JSONResponse(content={"ok": True, "id": body.id}, status_code=201)
177
+
178
+
179
+ @router.put("/category/{category_id}")
180
+ async def update_category(category_id: str, body: CategoriaBody) -> JSONResponse:
181
+ col = _get_col()
182
+ doc = body.model_dump()
183
+ doc.pop("id", None)
184
+ doc["updated_at"] = datetime.utcnow().isoformat() + "Z"
185
+ result = await col.update_one({"_id": category_id}, {"$set": doc})
186
+ if result.matched_count == 0:
187
+ return JSONResponse(content={"error": "Categoria no encontrada"}, status_code=404)
188
+ return JSONResponse(content={"ok": True})
189
+
190
+
191
+ @router.delete("/category/{category_id}")
192
+ async def delete_category(category_id: str) -> JSONResponse:
193
+ col = _get_col()
194
+ result = await col.delete_one({"_id": category_id})
195
+ if result.deleted_count == 0:
196
+ return JSONResponse(content={"error": "Categoria no encontrada"}, status_code=404)
197
+ return JSONResponse(content={"ok": True, "deleted": category_id})
198
+
199
+
200
+ @router.post("/category/{category_id}/product")
201
+ async def add_product(category_id: str, product: ProductoItem) -> JSONResponse:
202
+ col = _get_col()
203
+ result = await col.update_one(
204
+ {"_id": category_id, "productos.id": {"$ne": product.id}},
205
+ {"$push": {"productos": product.model_dump()}, "$set": {"updated_at": datetime.utcnow().isoformat() + "Z"}},
206
+ )
207
+ if result.matched_count == 0:
208
+ return JSONResponse(content={"error": "Categoria no encontrada o producto duplicado"}, status_code=409)
209
+ return JSONResponse(content={"ok": True}, status_code=201)
210
+
211
+
212
+ @router.delete("/category/{category_id}/product/{product_id}")
213
+ async def delete_product(category_id: str, product_id: str) -> JSONResponse:
214
+ col = _get_col()
215
+ result = await col.update_one(
216
+ {"_id": category_id},
217
+ {"$pull": {"productos": {"id": product_id}}, "$set": {"updated_at": datetime.utcnow().isoformat() + "Z"}},
218
+ )
219
+ if result.matched_count == 0:
220
+ return JSONResponse(content={"error": "Categoria no encontrada"}, status_code=404)
221
+ return JSONResponse(content={"ok": True})
backend/routers/sessions.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from datetime import datetime, timedelta
3
+
4
+ from fastapi import APIRouter
5
+ from fastapi.responses import JSONResponse
6
+ from motor.motor_asyncio import AsyncIOMotorClient
7
+ from pydantic import BaseModel
8
+
9
+ router = APIRouter(prefix="/api/sessions")
10
+
11
+ MONGODB_URI = os.getenv("MONGODB_URI", "")
12
+ _client: AsyncIOMotorClient | None = None
13
+ _db = None
14
+ _col = None
15
+
16
+ MAX_ITEMS = 10
17
+ ITEM_TTL_DAYS = 30
18
+
19
+
20
+ def _get_col():
21
+ global _client, _db, _col
22
+ if _col is None:
23
+ if not MONGODB_URI:
24
+ raise RuntimeError("MONGODB_URI no configurado")
25
+ _client = AsyncIOMotorClient(MONGODB_URI)
26
+ _db = _client["hyper_reality"]
27
+ _col = _db["sessions"]
28
+ return _col
29
+
30
+
31
+ class SessionItem(BaseModel):
32
+ filename: str
33
+ previewUrl: str
34
+ maskCount: int
35
+ uploadedAt: int # ms timestamp
36
+
37
+
38
+ @router.get("/{user_id}")
39
+ async def get_sessions(user_id: str):
40
+ try:
41
+ col = _get_col()
42
+ doc = await col.find_one({"_id": user_id})
43
+ if not doc:
44
+ return JSONResponse(content={"items": []})
45
+
46
+ cutoff_ms = int((datetime.utcnow() - timedelta(days=ITEM_TTL_DAYS)).timestamp() * 1000)
47
+ items = [i for i in doc.get("items", []) if i.get("uploadedAt", 0) > cutoff_ms]
48
+
49
+ return JSONResponse(content={"items": items})
50
+ except Exception as exc:
51
+ return JSONResponse(content={"items": [], "error": str(exc)})
52
+
53
+
54
+ @router.delete("/{user_id}/{filename:path}")
55
+ async def delete_session_item(user_id: str, filename: str):
56
+ try:
57
+ col = _get_col()
58
+ result = await col.update_one(
59
+ {"_id": user_id},
60
+ {"$pull": {"items": {"filename": filename}}},
61
+ )
62
+ if result.matched_count == 0:
63
+ return JSONResponse(content={"ok": False, "error": "Usuario no encontrado"}, status_code=404)
64
+ return JSONResponse(content={"ok": True})
65
+ except Exception as exc:
66
+ return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)
67
+
68
+
69
+ @router.post("/{user_id}")
70
+ async def save_session(user_id: str, item: SessionItem):
71
+ try:
72
+ col = _get_col()
73
+
74
+ doc = await col.find_one({"_id": user_id}) or {"_id": user_id, "items": []}
75
+
76
+ cutoff_ms = int((datetime.utcnow() - timedelta(days=ITEM_TTL_DAYS)).timestamp() * 1000)
77
+ items = [
78
+ i for i in doc.get("items", [])
79
+ if i.get("filename") != item.filename and i.get("uploadedAt", 0) > cutoff_ms
80
+ ]
81
+
82
+ items = [item.model_dump()] + items
83
+ items = items[:MAX_ITEMS]
84
+
85
+ await col.replace_one(
86
+ {"_id": user_id},
87
+ {"_id": user_id, "items": items, "updated_at": datetime.utcnow().isoformat() + "Z"},
88
+ upsert=True,
89
+ )
90
+
91
+ return JSONResponse(content={"ok": True, "count": len(items)})
92
+ except Exception as exc:
93
+ return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)
backend/services/__pycache__/image_service.cpython-312.pyc CHANGED
Binary files a/backend/services/__pycache__/image_service.cpython-312.pyc and b/backend/services/__pycache__/image_service.cpython-312.pyc differ
 
backend/services/__pycache__/texture_service.cpython-312.pyc CHANGED
Binary files a/backend/services/__pycache__/texture_service.cpython-312.pyc and b/backend/services/__pycache__/texture_service.cpython-312.pyc differ
 
backend/services/texture_service.py CHANGED
@@ -302,7 +302,7 @@ def _tile_texture_perspective(
302
 
303
  def classify_texture_material(texture_name: str) -> str:
304
  texture_key = texture_name.lower()
305
- if "acm" in texture_key:
306
  return "acm"
307
  if any(hint in texture_key for hint in ("deck", "wood", "plank", "laminate", "floor")):
308
  return "wood"
 
302
 
303
  def classify_texture_material(texture_name: str) -> str:
304
  texture_key = texture_name.lower()
305
+ if "acm" in texture_key or "wpc" in texture_key:
306
  return "acm"
307
  if any(hint in texture_key for hint in ("deck", "wood", "plank", "laminate", "floor")):
308
  return "wood"
backend/texturas/Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_claro.png ADDED

Git LFS Details

  • SHA256: 0a17004fb6e46ca5451b328a20ded8d30a10a05ec6c28949095322a6b1fd9f66
  • Pointer size: 132 Bytes
  • Size of remote file: 1.49 MB
backend/texturas/Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_gris.png ADDED

Git LFS Details

  • SHA256: c95a85ef3d2ff1cb5238a93e7bffe44be40593cccb9c8fe9c00be26ca09c0342
  • Pointer size: 132 Bytes
  • Size of remote file: 1.19 MB
backend/texturas/Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_oscuro.png ADDED

Git LFS Details

  • SHA256: 0d789f46ac0942cac88246e5f2cd5071ad37b2ab8c7e36c9ef9554635512901b
  • Pointer size: 131 Bytes
  • Size of remote file: 883 kB
backend/texturas/Texture_WPC_EXTERIOR_INTERIOR/WPC_negro.png ADDED

Git LFS Details

  • SHA256: f24673b15bbded943f5750bc969751edb6f2d03b356202d6fad656643d97d8d3
  • Pointer size: 132 Bytes
  • Size of remote file: 1.03 MB
frontend/src/features/roomSetup/RoomSetup.tsx CHANGED
@@ -6,16 +6,38 @@ import {
6
  Camera as CameraIcon,
7
  UploadCloud,
8
  Users,
 
9
  } from "lucide-react";
 
10
  import { categorias, habitaciones } from "../../data/roomSetupData";
11
  import { FilterButton, RoomCard } from "./RoomSetupComponents";
12
  import { useRoomSetup } from "./roomSetupHooks";
13
  import useAppStore from "../../store/useAppStore";
 
14
  import { useActiveSessions } from "../../hooks/useActiveSessions";
 
 
 
15
 
16
  export default function RoomSetup() {
 
17
  const [categoriaActiva, setCategoriaActiva] = useState("todos");
 
18
  const segmentProgress = useAppStore((s) => s.segmentProgress);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  const { count: activeSessions } = useActiveSessions();
20
  const {
21
  isDragging,
@@ -62,7 +84,8 @@ export default function RoomSetup() {
62
  <span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
63
  <Users className="w-4 h-4 text-[#0047AB]" />
64
  <span className="text-sm font-medium text-[#0047AB]">
65
- {activeSessions} usuario{activeSessions !== 1 ? "s" : ""} activo{activeSessions !== 1 ? "s" : ""}
 
66
  </span>
67
  </div>
68
  </div>
@@ -87,14 +110,38 @@ export default function RoomSetup() {
87
  </li>
88
  </ul>
89
 
90
- <div className="w-full">
91
  <button
92
  onClick={triggerFileInput}
93
  className="w-full bg-[#333333] hover:bg-black text-white font-bold text-base sm:text-lg py-3 sm:py-4 px-6 rounded-xl flex items-center justify-center gap-3 transition-colors shadow-sm"
94
  >
95
- <CameraIcon className="w-5 h-5 sm:w-6 sm:h-6" strokeWidth={2.5} />
 
 
 
96
  Sube tu foto
97
  </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  </div>
99
  {uploadMessage && (
100
  <p className="text-sm text-[#707070] mt-3">{uploadMessage}</p>
@@ -164,6 +211,78 @@ export default function RoomSetup() {
164
  </div>
165
  </section>
166
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  {/* SECCIΓ“N INFERIOR: Habitaciones de demostraciΓ³n */}
168
  <section>
169
  <h2 className="text-xl sm:text-2xl font-bold text-[#0047AB] mb-4 sm:mb-6">
 
6
  Camera as CameraIcon,
7
  UploadCloud,
8
  Users,
9
+ ArrowRight,
10
  } from "lucide-react";
11
+ import { useNavigate } from "react-router-dom";
12
  import { categorias, habitaciones } from "../../data/roomSetupData";
13
  import { FilterButton, RoomCard } from "./RoomSetupComponents";
14
  import { useRoomSetup } from "./roomSetupHooks";
15
  import useAppStore from "../../store/useAppStore";
16
+ import { useHistoryStore, type HistoryItem } from "../../store/useAppStore";
17
  import { useActiveSessions } from "../../hooks/useActiveSessions";
18
+ import { API_BASE } from "../../api/client";
19
+ import { useLoadSessionHistory, deleteSessionFromBackend } from "../../hooks/useSessionSync";
20
+ import { useCallback } from "react";
21
 
22
  export default function RoomSetup() {
23
+ useLoadSessionHistory();
24
  const [categoriaActiva, setCategoriaActiva] = useState("todos");
25
+ const navigate = useNavigate();
26
  const segmentProgress = useAppStore((s) => s.segmentProgress);
27
+ const segmentFilename = useAppStore((s) => s.segmentFilename);
28
+ const storedPreviewImage = useAppStore((s) => s.previewImage);
29
+ const sessionHistory = useHistoryStore((s) => s.sessionHistory);
30
+ const removeFromHistory = useHistoryStore((s) => s.removeFromHistory);
31
+ const userId = useHistoryStore((s) => s.userId);
32
+
33
+ const handleDeleteSession = useCallback(
34
+ (e: React.MouseEvent, filename: string) => {
35
+ e.stopPropagation();
36
+ removeFromHistory(filename);
37
+ deleteSessionFromBackend(userId, filename);
38
+ },
39
+ [removeFromHistory, userId],
40
+ );
41
  const { count: activeSessions } = useActiveSessions();
42
  const {
43
  isDragging,
 
84
  <span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
85
  <Users className="w-4 h-4 text-[#0047AB]" />
86
  <span className="text-sm font-medium text-[#0047AB]">
87
+ {activeSessions} usuario{activeSessions !== 1 ? "s" : ""} activo
88
+ {activeSessions !== 1 ? "s" : ""}
89
  </span>
90
  </div>
91
  </div>
 
110
  </li>
111
  </ul>
112
 
113
+ <div className="w-full flex flex-col gap-3">
114
  <button
115
  onClick={triggerFileInput}
116
  className="w-full bg-[#333333] hover:bg-black text-white font-bold text-base sm:text-lg py-3 sm:py-4 px-6 rounded-xl flex items-center justify-center gap-3 transition-colors shadow-sm"
117
  >
118
+ <CameraIcon
119
+ className="w-5 h-5 sm:w-6 sm:h-6"
120
+ strokeWidth={2.5}
121
+ />
122
  Sube tu foto
123
  </button>
124
+
125
+ {segmentFilename && !isUploading && (
126
+ <button
127
+ onClick={() =>
128
+ navigate("/visualizer", {
129
+ state: {
130
+ previewImage:
131
+ storedPreviewImage ??
132
+ `${API_BASE}/seg/image/${segmentFilename}`,
133
+ },
134
+ })
135
+ }
136
+ className="w-full bg-[#0047AB] hover:bg-[#003a94] text-white font-bold text-base sm:text-lg py-3 sm:py-4 px-6 rounded-xl flex items-center justify-center gap-3 transition-colors shadow-sm"
137
+ >
138
+ Continuar al visualizador
139
+ <ArrowRight
140
+ className="w-5 h-5 sm:w-6 sm:h-6"
141
+ strokeWidth={2.5}
142
+ />
143
+ </button>
144
+ )}
145
  </div>
146
  {uploadMessage && (
147
  <p className="text-sm text-[#707070] mt-3">{uploadMessage}</p>
 
211
  </div>
212
  </section>
213
 
214
+ {/* SECCIΓ“N HISTORIAL DE SESIΓ“N */}
215
+ {sessionHistory.length > 0 && (
216
+ <section className="mb-10 sm:mb-14">
217
+ <h2 className="text-xl sm:text-2xl font-bold text-[#333333] mb-4 sm:mb-6">
218
+ Tus espacios recientes
219
+ </h2>
220
+ <div
221
+ className="flex gap-4 overflow-x-auto pb-2"
222
+ style={{ scrollbarWidth: "thin" }}
223
+ >
224
+ {sessionHistory.map((item: HistoryItem) => (
225
+ <button
226
+ key={item.filename}
227
+ onClick={() =>
228
+ navigate("/visualizer", {
229
+ state: {
230
+ previewImage: item.previewUrl,
231
+ filename: item.filename,
232
+ maskCount: item.maskCount,
233
+ },
234
+ })
235
+ }
236
+ className={`flex-shrink-0 group relative rounded-2xl overflow-hidden border-2 transition-all duration-200 ${
237
+ segmentFilename === item.filename
238
+ ? "border-[#0047AB] shadow-lg shadow-[#0047AB]/20"
239
+ : "border-[#dbe7ff] hover:border-[#0047AB]"
240
+ }`}
241
+ style={{ width: 160, height: 120 }}
242
+ >
243
+ <img
244
+ src={item.previewUrl}
245
+ alt="HabitaciΓ³n previa"
246
+ className="w-full h-full object-cover"
247
+ />
248
+ {/* Overlay con hora */}
249
+ <div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent flex flex-col justify-end p-2">
250
+ <p className="text-white/80 text-[10px]">
251
+ {new Date(item.uploadedAt).toLocaleDateString([], {
252
+ day: "2-digit",
253
+ month: "short",
254
+ })}{" "}
255
+ {new Date(item.uploadedAt).toLocaleTimeString([], {
256
+ hour: "2-digit",
257
+ minute: "2-digit",
258
+ })}
259
+ </p>
260
+ </div>
261
+ {/* Badge "activa" o botΓ³n eliminar */}
262
+ {segmentFilename === item.filename ? (
263
+ <div className="absolute top-2 right-2 bg-[#0047AB] text-white text-[9px] font-bold px-1.5 py-0.5 rounded-full">
264
+ Activa
265
+ </div>
266
+ ) : (
267
+ <button
268
+ onClick={(e) => handleDeleteSession(e, item.filename)}
269
+ className="absolute top-2 right-2 w-5 h-5 rounded-full bg-black/50 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-500"
270
+ >
271
+ <X className="w-3 h-3" />
272
+ </button>
273
+ )}
274
+ {/* Hover: continuar */}
275
+ <div className="absolute inset-0 bg-[#0047AB]/80 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
276
+ <span className="text-white text-xs font-bold flex items-center gap-1">
277
+ Continuar <ArrowRight className="w-3.5 h-3.5" />
278
+ </span>
279
+ </div>
280
+ </button>
281
+ ))}
282
+ </div>
283
+ </section>
284
+ )}
285
+
286
  {/* SECCIΓ“N INFERIOR: Habitaciones de demostraciΓ³n */}
287
  <section>
288
  <h2 className="text-xl sm:text-2xl font-bold text-[#0047AB] mb-4 sm:mb-6">
frontend/src/features/roomSetup/roomSetupHooks.ts CHANGED
@@ -8,8 +8,10 @@ import {
8
  } from "react";
9
  import { useNavigate } from "react-router-dom";
10
  import useAppStore from "../../store/useAppStore";
 
11
  import { useSegmentUpload } from "../../hooks/useSegmentUpload";
12
  import { API_BASE } from "../../api/client";
 
13
 
14
  export function useRoomSetup(): {
15
  isDragging: boolean;
@@ -35,6 +37,8 @@ export function useRoomSetup(): {
35
  const setUploadMessage = useAppStore((state) => state.setUploadMessage);
36
  const setSegmentResult = useAppStore((state) => state.setSegmentResult);
37
  const setSegmentProgress = useAppStore((state) => state.setSegmentProgress);
 
 
38
 
39
  const { uploadAndSegment, isUploading } = useSegmentUpload();
40
 
@@ -67,6 +71,15 @@ export function useRoomSetup(): {
67
  const serverImageUrl = `${API_BASE}/seg/image/${filename}`;
68
  setPreviewImage(serverImageUrl);
69
 
 
 
 
 
 
 
 
 
 
70
  navigate("/visualizer", {
71
  state: { previewImage: serverImageUrl },
72
  });
@@ -83,6 +96,8 @@ export function useRoomSetup(): {
83
  setSegmentResult,
84
  setSegmentProgress,
85
  uploadAndSegment,
 
 
86
  ],
87
  );
88
 
 
8
  } from "react";
9
  import { useNavigate } from "react-router-dom";
10
  import useAppStore from "../../store/useAppStore";
11
+ import { useHistoryStore } from "../../store/useAppStore";
12
  import { useSegmentUpload } from "../../hooks/useSegmentUpload";
13
  import { API_BASE } from "../../api/client";
14
+ import { saveSessionToBackend } from "../../hooks/useSessionSync";
15
 
16
  export function useRoomSetup(): {
17
  isDragging: boolean;
 
37
  const setUploadMessage = useAppStore((state) => state.setUploadMessage);
38
  const setSegmentResult = useAppStore((state) => state.setSegmentResult);
39
  const setSegmentProgress = useAppStore((state) => state.setSegmentProgress);
40
+ const addToHistory = useHistoryStore((state) => state.addToHistory);
41
+ const userId = useHistoryStore((state) => state.userId);
42
 
43
  const { uploadAndSegment, isUploading } = useSegmentUpload();
44
 
 
71
  const serverImageUrl = `${API_BASE}/seg/image/${filename}`;
72
  setPreviewImage(serverImageUrl);
73
 
74
+ const historyItem = {
75
+ filename,
76
+ previewUrl: serverImageUrl,
77
+ maskCount,
78
+ uploadedAt: Date.now(),
79
+ };
80
+ addToHistory(historyItem);
81
+ saveSessionToBackend(userId, historyItem);
82
+
83
  navigate("/visualizer", {
84
  state: { previewImage: serverImageUrl },
85
  });
 
96
  setSegmentResult,
97
  setSegmentProgress,
98
  uploadAndSegment,
99
+ addToHistory,
100
+ userId,
101
  ],
102
  );
103
 
frontend/src/features/roomVisualizer/RoomPreviewPanel.tsx CHANGED
@@ -124,12 +124,31 @@ export function RoomPreviewPanel({
124
  >
125
  <Download className="h-4 w-4 text-[#0047AB]" /> Descargar
126
  </button>
127
- <button className="pointer-events-auto rounded-full bg-transparent px-4 py-2 text-sm font-medium text-[#0047AB] hover:bg-[#eaf1ff] flex items-center gap-2">
 
 
 
 
 
128
  <MapPin className="h-4 w-4 text-[#0047AB]" /> Encuentra tu tienda
129
- </button>
130
- <button className="pointer-events-auto rounded-full bg-transparent px-4 py-2 text-sm font-medium text-[#0047AB] hover:bg-[#eaf1ff] flex items-center gap-2">
131
- <ShoppingCart className="h-4 w-4 text-[#0047AB]" /> Ir a la pΓ‘gina del producto
132
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  </div>
134
 
135
  {/* ── Área de imagen + canvas ──────────────────────────────── */}
 
124
  >
125
  <Download className="h-4 w-4 text-[#0047AB]" /> Descargar
126
  </button>
127
+ <a
128
+ href="https://nauffargermany.com/gt/sucursales-2/"
129
+ target="_blank"
130
+ rel="noopener noreferrer"
131
+ className="pointer-events-auto rounded-full bg-transparent px-4 py-2 text-sm font-medium text-[#0047AB] hover:bg-[#eaf1ff] flex items-center gap-2"
132
+ >
133
  <MapPin className="h-4 w-4 text-[#0047AB]" /> Encuentra tu tienda
134
+ </a>
135
+ {selectedProduct?.detailUrl ? (
136
+ <a
137
+ href={selectedProduct.detailUrl}
138
+ target="_blank"
139
+ rel="noopener noreferrer"
140
+ className="pointer-events-auto rounded-full bg-transparent px-4 py-2 text-sm font-medium text-[#0047AB] hover:bg-[#eaf1ff] flex items-center gap-2"
141
+ >
142
+ <ShoppingCart className="h-4 w-4 text-[#0047AB]" /> Ir a la pΓ‘gina del producto
143
+ </a>
144
+ ) : (
145
+ <button
146
+ disabled
147
+ className="pointer-events-auto rounded-full bg-transparent px-4 py-2 text-sm font-medium text-gray-300 flex items-center gap-2 cursor-default"
148
+ >
149
+ <ShoppingCart className="h-4 w-4 text-gray-300" /> Ir a la pΓ‘gina del producto
150
+ </button>
151
+ )}
152
  </div>
153
 
154
  {/* ── Área de imagen + canvas ──────────────────────────────── */}
frontend/src/features/roomVisualizer/RoomVisualizer.tsx CHANGED
@@ -5,6 +5,7 @@ import {
5
  useState,
6
  type PointerEvent,
7
  } from "react";
 
8
  import { createRoot } from "react-dom/client";
9
  import { useLocation, useNavigate } from "react-router-dom";
10
  import {
@@ -33,6 +34,8 @@ import { API_BASE } from "../../api/client";
33
 
34
  type RoomVisualizerState = {
35
  previewImage?: string;
 
 
36
  };
37
 
38
  // ── Hook para detectar mobile (< 1024px) ─────────────────────────────────────
@@ -56,6 +59,21 @@ export default function RoomVisualizer() {
56
  const segmentFilename = useAppStore((store) => store.segmentFilename);
57
  const accumulatedFilename = useAppStore((store) => store.accumulatedFilename);
58
  const setAccumulatedFilename = useAppStore((store) => store.setAccumulatedFilename);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
 
60
  const [currentPreviewImage, setCurrentPreviewImage] = useState<string | null>(() => {
61
  if (accumulatedFilename) return `${API_BASE}/seg/image/${accumulatedFilename}`;
@@ -68,7 +86,35 @@ export default function RoomVisualizer() {
68
  const [imageSize, setImageSize] = useState({ width: 0, height: 0 });
69
  const wrapperRef = useRef<HTMLDivElement>(null);
70
 
71
- const { products, loading, error } = useCatalogProducts();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
  const {
74
  viewMode, showGrid, showList,
@@ -298,7 +344,7 @@ export default function RoomVisualizer() {
298
  hoveredMask,
299
  segmentMeta,
300
  isApplying,
301
- onBack: () => navigate(-1),
302
  onPointerDown: handlePointerDown,
303
  onPointerMove: handlePointerMove,
304
  onPointerUp: handlePointerUp,
@@ -474,28 +520,73 @@ export default function RoomVisualizer() {
474
  )}
475
  </div>
476
 
477
- {/* Lista de productos */}
478
- <div className={`w-full max-w-2xl flex-1 ${viewMode === "grid" ? "px-4" : "px-2"} py-2 overflow-y-auto`}>
479
  {loading ? (
480
  <div className="flex items-center justify-center h-32 text-sm text-gray-400">Cargando productos...</div>
481
  ) : error ? (
482
  <div className="flex items-center justify-center h-32 text-sm text-red-400">{error}</div>
483
- ) : viewMode === "grid" ? (
484
- <div className="flex flex-col gap-6">
485
- {chunkArray(filteredProducts, 3).map((group, index) => (
486
- <ProductGroupCard key={index} group={group} openProductId={openProductId} onSelectProduct={handleProductSelect} />
487
- ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
488
  </div>
489
  ) : (
490
- <div className="grid grid-cols-1 gap-4">
491
- {filteredProducts.map((product) => (
492
- <IndividualProductCard
493
- key={product.id}
494
- product={product}
495
- isSelected={openProductId === product.id}
496
- onToggle={() => handleProductSelect(product.id)}
497
- />
498
- ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
499
  </div>
500
  )}
501
  </div>
 
5
  useState,
6
  type PointerEvent,
7
  } from "react";
8
+ import { ChevronDown } from "lucide-react";
9
  import { createRoot } from "react-dom/client";
10
  import { useLocation, useNavigate } from "react-router-dom";
11
  import {
 
34
 
35
  type RoomVisualizerState = {
36
  previewImage?: string;
37
+ filename?: string;
38
+ maskCount?: number;
39
  };
40
 
41
  // ── Hook para detectar mobile (< 1024px) ─────────────────────────────────────
 
59
  const segmentFilename = useAppStore((store) => store.segmentFilename);
60
  const accumulatedFilename = useAppStore((store) => store.accumulatedFilename);
61
  const setAccumulatedFilename = useAppStore((store) => store.setAccumulatedFilename);
62
+ const setSegmentResult = useAppStore((store) => store.setSegmentResult);
63
+ const setPreviewImage = useAppStore((store) => store.setPreviewImage);
64
+
65
+ // Restaurar segmentFilename y previewImage cuando se abre una sesiΓ³n del historial
66
+ useEffect(() => {
67
+ if (state?.filename && state.filename !== segmentFilename) {
68
+ setSegmentResult(state.filename, state.maskCount ?? 0);
69
+ setAccumulatedFilename(null); // limpiar ediciones de sesiΓ³n anterior
70
+ }
71
+ if (state?.previewImage) {
72
+ setPreviewImage(state.previewImage);
73
+ }
74
+ // Solo al montar β€” state no cambia tras la navegaciΓ³n
75
+ // eslint-disable-next-line react-hooks/exhaustive-deps
76
+ }, []);
77
 
78
  const [currentPreviewImage, setCurrentPreviewImage] = useState<string | null>(() => {
79
  if (accumulatedFilename) return `${API_BASE}/seg/image/${accumulatedFilename}`;
 
86
  const [imageSize, setImageSize] = useState({ width: 0, height: 0 });
87
  const wrapperRef = useRef<HTMLDivElement>(null);
88
 
89
+ const { products, categories, loading, error } = useCatalogProducts();
90
+
91
+ // null = sin interacciΓ³n (primera categorΓ­a abierta por defecto)
92
+ // Set vacΓ­o = usuario cerrΓ³ todo deliberadamente
93
+ const [openCategoryIds, setOpenCategoryIds] = useState<Set<string> | null>(null);
94
+
95
+ const isCategoryOpen = useCallback(
96
+ (id: string) => {
97
+ if (openCategoryIds === null) {
98
+ return categories.length > 0 && id === categories[0].id;
99
+ }
100
+ return openCategoryIds.has(id);
101
+ },
102
+ [openCategoryIds, categories],
103
+ );
104
+
105
+ const toggleCategory = useCallback((id: string) => {
106
+ setOpenCategoryIds((prev) => {
107
+ const base =
108
+ prev === null
109
+ ? categories.length > 0
110
+ ? new Set([categories[0].id])
111
+ : new Set<string>()
112
+ : new Set(prev);
113
+ if (base.has(id)) base.delete(id);
114
+ else base.add(id);
115
+ return base;
116
+ });
117
+ }, [categories]);
118
 
119
  const {
120
  viewMode, showGrid, showList,
 
344
  hoveredMask,
345
  segmentMeta,
346
  isApplying,
347
+ onBack: () => navigate("/app"),
348
  onPointerDown: handlePointerDown,
349
  onPointerMove: handlePointerMove,
350
  onPointerUp: handlePointerUp,
 
520
  )}
521
  </div>
522
 
523
+ {/* Lista de productos con acordeΓ³n por categorΓ­a */}
524
+ <div className="flex-1 overflow-y-auto py-2">
525
  {loading ? (
526
  <div className="flex items-center justify-center h-32 text-sm text-gray-400">Cargando productos...</div>
527
  ) : error ? (
528
  <div className="flex items-center justify-center h-32 text-sm text-red-400">{error}</div>
529
+ ) : searchQuery ? (
530
+ /* BΓΊsqueda activa β†’ lista plana de resultados */
531
+ <div className={viewMode === "grid" ? "px-4" : "px-2"}>
532
+ {filteredProducts.length === 0 ? (
533
+ <div className="flex items-center justify-center h-24 text-sm text-gray-400">Sin resultados</div>
534
+ ) : viewMode === "grid" ? (
535
+ <div className="flex flex-col gap-4">
536
+ {chunkArray(filteredProducts, 3).map((group, i) => (
537
+ <ProductGroupCard key={i} group={group} openProductId={openProductId} onSelectProduct={handleProductSelect} />
538
+ ))}
539
+ </div>
540
+ ) : (
541
+ <div className="grid grid-cols-1 gap-3">
542
+ {filteredProducts.map((product) => (
543
+ <IndividualProductCard key={product.id} product={product} isSelected={openProductId === product.id} onToggle={() => handleProductSelect(product.id)} />
544
+ ))}
545
+ </div>
546
+ )}
547
  </div>
548
  ) : (
549
+ /* Sin bΓΊsqueda β†’ acordeΓ³n por categorΓ­a */
550
+ <div className="flex flex-col">
551
+ {categories.map((cat) => {
552
+ const isOpen = isCategoryOpen(cat.id);
553
+ return (
554
+ <div key={cat.id} className="border-b border-gray-100 last:border-0">
555
+ <button
556
+ onClick={() => toggleCategory(cat.id)}
557
+ className="w-full flex items-center justify-between px-4 py-3 hover:bg-[#f4f8ff] transition-colors text-left"
558
+ >
559
+ <div className="flex items-center gap-2 min-w-0">
560
+ <span className="font-semibold text-sm text-[#333] truncate">{cat.nombre}</span>
561
+ <span className="text-xs text-[#0047AB] bg-[#eaf1ff] px-1.5 py-0.5 rounded-full flex-shrink-0">
562
+ {cat.products.length}
563
+ </span>
564
+ </div>
565
+ <ChevronDown
566
+ className={`w-4 h-4 text-[#0047AB] flex-shrink-0 transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
567
+ />
568
+ </button>
569
+
570
+ {isOpen && (
571
+ <div className={viewMode === "grid" ? "px-4 pb-4" : "px-2 pb-2"}>
572
+ {viewMode === "grid" ? (
573
+ <div className="flex flex-col gap-4">
574
+ {chunkArray(cat.products, 3).map((group, i) => (
575
+ <ProductGroupCard key={i} group={group} openProductId={openProductId} onSelectProduct={handleProductSelect} />
576
+ ))}
577
+ </div>
578
+ ) : (
579
+ <div className="grid grid-cols-1 gap-3">
580
+ {cat.products.map((product) => (
581
+ <IndividualProductCard key={product.id} product={product} isSelected={openProductId === product.id} onToggle={() => handleProductSelect(product.id)} />
582
+ ))}
583
+ </div>
584
+ )}
585
+ </div>
586
+ )}
587
+ </div>
588
+ );
589
+ })}
590
  </div>
591
  )}
592
  </div>
frontend/src/features/roomVisualizer/useCatalogProducts.ts CHANGED
@@ -3,14 +3,6 @@ import type { Product } from "../../types";
3
 
4
  const API_BASE = import.meta.env.VITE_API_URL ?? "";
5
 
6
- const CATEGORY_TYPE: Record<string, "suelos" | "paredes"> = {
7
- acm: "paredes",
8
- pisos: "suelos",
9
- piedra_concreto: "suelos",
10
- metales: "paredes",
11
- madera: "suelos",
12
- };
13
-
14
  interface CatalogProduct {
15
  id: string;
16
  nombre: string;
@@ -22,6 +14,7 @@ interface CatalogProduct {
22
  interface CatalogCategory {
23
  id: string;
24
  nombre: string;
 
25
  descripcion: string;
26
  especificaciones?: string[];
27
  url_detalle?: string;
@@ -42,13 +35,23 @@ function mapToProduct(item: CatalogProduct, category: CatalogCategory): Product
42
  image: `${API_BASE}${item.url_preview}`,
43
  description: category.especificaciones,
44
  detailUrl: category.url_detalle,
45
- tipo: CATEGORY_TYPE[category.id],
46
  categoria: category.id,
47
  };
48
  }
49
 
 
 
 
 
 
 
 
 
 
50
  export function useCatalogProducts() {
51
  const [products, setProducts] = useState<Product[]>([]);
 
52
  const [loading, setLoading] = useState(true);
53
  const [error, setError] = useState<string | null>(null);
54
 
@@ -61,12 +64,19 @@ export function useCatalogProducts() {
61
  if (!res.ok) throw new Error(`Error ${res.status}`);
62
  const data: CatalogResponse = await res.json();
63
 
64
- const flat = data.categories
65
- .flatMap((category) =>
66
- category.productos.map((item) => mapToProduct(item, category))
67
- );
 
 
 
 
 
 
68
 
69
  if (!cancelled) {
 
70
  setProducts(flat);
71
  setError(null);
72
  }
@@ -83,5 +93,5 @@ export function useCatalogProducts() {
83
  return () => { cancelled = true; };
84
  }, []);
85
 
86
- return { products, loading, error };
87
  }
 
3
 
4
  const API_BASE = import.meta.env.VITE_API_URL ?? "";
5
 
 
 
 
 
 
 
 
 
6
  interface CatalogProduct {
7
  id: string;
8
  nombre: string;
 
14
  interface CatalogCategory {
15
  id: string;
16
  nombre: string;
17
+ tipo?: string;
18
  descripcion: string;
19
  especificaciones?: string[];
20
  url_detalle?: string;
 
35
  image: `${API_BASE}${item.url_preview}`,
36
  description: category.especificaciones,
37
  detailUrl: category.url_detalle,
38
+ tipo: (category.tipo as "suelos" | "paredes") ?? undefined,
39
  categoria: category.id,
40
  };
41
  }
42
 
43
+ export interface ProductCategory {
44
+ id: string;
45
+ nombre: string;
46
+ tipo?: string;
47
+ descripcion: string;
48
+ especificaciones?: string[];
49
+ products: Product[];
50
+ }
51
+
52
  export function useCatalogProducts() {
53
  const [products, setProducts] = useState<Product[]>([]);
54
+ const [categories, setCategories] = useState<ProductCategory[]>([]);
55
  const [loading, setLoading] = useState(true);
56
  const [error, setError] = useState<string | null>(null);
57
 
 
64
  if (!res.ok) throw new Error(`Error ${res.status}`);
65
  const data: CatalogResponse = await res.json();
66
 
67
+ const cats: ProductCategory[] = data.categories.map((category) => ({
68
+ id: category.id,
69
+ nombre: category.nombre,
70
+ tipo: category.tipo,
71
+ descripcion: category.descripcion,
72
+ especificaciones: category.especificaciones,
73
+ products: category.productos.map((item) => mapToProduct(item, category)),
74
+ }));
75
+
76
+ const flat = cats.flatMap((c) => c.products);
77
 
78
  if (!cancelled) {
79
+ setCategories(cats);
80
  setProducts(flat);
81
  setError(null);
82
  }
 
93
  return () => { cancelled = true; };
94
  }, []);
95
 
96
+ return { products, categories, loading, error };
97
  }
frontend/src/hooks/useSessionSync.ts ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect } from "react";
2
+ import { useHistoryStore, type HistoryItem } from "../store/useAppStore";
3
+ import { API_BASE } from "../api/client";
4
+
5
+ export async function saveSessionToBackend(
6
+ userId: string,
7
+ item: HistoryItem,
8
+ ): Promise<void> {
9
+ try {
10
+ await fetch(`${API_BASE}/api/sessions/${userId}`, {
11
+ method: "POST",
12
+ headers: { "Content-Type": "application/json" },
13
+ body: JSON.stringify(item),
14
+ });
15
+ } catch {
16
+ // fallback silencioso β€” localStorage es la fuente de verdad local
17
+ }
18
+ }
19
+
20
+ export async function deleteSessionFromBackend(
21
+ userId: string,
22
+ filename: string,
23
+ ): Promise<void> {
24
+ try {
25
+ await fetch(
26
+ `${API_BASE}/api/sessions/${userId}/${encodeURIComponent(filename)}`,
27
+ { method: "DELETE" },
28
+ );
29
+ } catch {
30
+ // fallback silencioso
31
+ }
32
+ }
33
+
34
+ export function useLoadSessionHistory(): void {
35
+ const userId = useHistoryStore((s) => s.userId);
36
+ const localHistory = useHistoryStore((s) => s.sessionHistory);
37
+ const setSessionHistory = useHistoryStore((s) => s.setSessionHistory);
38
+
39
+ useEffect(() => {
40
+ async function load() {
41
+ try {
42
+ const res = await fetch(`${API_BASE}/api/sessions/${userId}`);
43
+ if (!res.ok) return;
44
+ const data = await res.json();
45
+ const remote: HistoryItem[] = data.items ?? [];
46
+ if (remote.length === 0) return;
47
+
48
+ // Merge remoto + local: dedupe por filename, mΓ‘s reciente gana
49
+ const merged = [...remote, ...localHistory];
50
+ const seen = new Set<string>();
51
+ const deduped = merged.filter((item) => {
52
+ if (seen.has(item.filename)) return false;
53
+ seen.add(item.filename);
54
+ return true;
55
+ });
56
+ deduped.sort((a, b) => b.uploadedAt - a.uploadedAt);
57
+ setSessionHistory(deduped.slice(0, 10));
58
+ } catch {
59
+ // red no disponible β€” se mantiene historial local
60
+ }
61
+ }
62
+ load();
63
+ // eslint-disable-next-line react-hooks/exhaustive-deps
64
+ }, [userId]);
65
+ }
frontend/src/store/useAppStore.ts CHANGED
@@ -3,6 +3,63 @@ import { persist, createJSONStorage } from "zustand/middleware";
3
 
4
  type ViewMode = "grid" | "list";
5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  type AppStore = {
7
  previewImage: string | null;
8
  uploadMessage: string | null;
@@ -56,7 +113,6 @@ const useAppStore = create<AppStore>()(
56
  {
57
  name: "hr-session",
58
  storage: createJSONStorage(() => sessionStorage),
59
- // Only persist the fields needed to restore the editing session
60
  partialize: (state) => ({
61
  previewImage: state.previewImage,
62
  segmentFilename: state.segmentFilename,
 
3
 
4
  type ViewMode = "grid" | "list";
5
 
6
+ export type HistoryItem = {
7
+ filename: string;
8
+ previewUrl: string;
9
+ maskCount: number;
10
+ uploadedAt: number;
11
+ };
12
+
13
+ // ── userId persistente en localStorage ───────────────────────────────────────
14
+ function getOrCreateUserId(): string {
15
+ const key = "hr-user-id";
16
+ let id = localStorage.getItem(key);
17
+ if (!id) {
18
+ id = typeof crypto !== "undefined" && crypto.randomUUID
19
+ ? crypto.randomUUID()
20
+ : `u-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
21
+ localStorage.setItem(key, id);
22
+ }
23
+ return id;
24
+ }
25
+
26
+ // ── Store de historial (localStorage β€” persiste entre sesiones) ───────────────
27
+ type HistoryStore = {
28
+ userId: string;
29
+ sessionHistory: HistoryItem[];
30
+ addToHistory: (item: HistoryItem) => void;
31
+ setSessionHistory: (items: HistoryItem[]) => void;
32
+ removeFromHistory: (filename: string) => void;
33
+ };
34
+
35
+ export const useHistoryStore = create<HistoryStore>()(
36
+ persist(
37
+ (set) => ({
38
+ userId: getOrCreateUserId(),
39
+ sessionHistory: [],
40
+ addToHistory: (item) =>
41
+ set((state) => {
42
+ const withoutDupe = state.sessionHistory.filter(
43
+ (h) => h.filename !== item.filename,
44
+ );
45
+ const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;
46
+ const fresh = withoutDupe.filter((h) => h.uploadedAt > cutoff);
47
+ return { sessionHistory: [item, ...fresh].slice(0, 10) };
48
+ }),
49
+ setSessionHistory: (items) => set({ sessionHistory: items }),
50
+ removeFromHistory: (filename) =>
51
+ set((state) => ({
52
+ sessionHistory: state.sessionHistory.filter((h) => h.filename !== filename),
53
+ })),
54
+ }),
55
+ {
56
+ name: "hr-history",
57
+ storage: createJSONStorage(() => localStorage),
58
+ },
59
+ ),
60
+ );
61
+
62
+ // ── Store de sesiΓ³n activa (sessionStorage β€” se limpia al cerrar el tab) ──────
63
  type AppStore = {
64
  previewImage: string | null;
65
  uploadMessage: string | null;
 
113
  {
114
  name: "hr-session",
115
  storage: createJSONStorage(() => sessionStorage),
 
116
  partialize: (state) => ({
117
  previewImage: state.previewImage,
118
  segmentFilename: state.segmentFilename,
frontend/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const appVersion = "0.1.0-dev.20260507T033905";
 
1
+ export const appVersion = "0.1.0-dev.20260507T183851";